V3.0 交付体系 + 精准权限系统
交付环节优化: - 新建项目必填送达者(人)邮箱,项目完成/遇到无法绕开的难关时自动邮件及时通知 - 每个项目独立工作目录 data/workspace/project_<id>/,交付物分类存放互不污染(上传/下载/删除) - 网页交付物一键部署到 /demo/<id>/ 免登录公开 Demo,送达者直接打开链接查看 - 非网页交付物 zip 打包 data/packages/,随邮件附件发送送达者(含手动交付/完成交付/自动交付) - 任务全部完成自动收尾交付;失败任务触发难关通知(30分钟限频去重) 用户(人)管理 + 精准权限: - 管理员增删改用户,管理用户项目所属与 Worker 权限(授权弹窗 + 授权总览矩阵) - 角色体系:admin 全部 / auditor 全量只读 / member 按授权 - 项目授权 view/manage/admin;Worker 授权 view/use/manage;创建者自动成为项目管理员 - 仪表盘/成本报表/日志/协作/评估全部按权限过滤,越权访问 403 新表:project_deliverables / user_projects / user_workers;新模块 delivery.py
This commit is contained in:
+248
-13
@@ -43,10 +43,17 @@ function toast(msg, type = '') {
|
||||
/* ---------- Login ---------- */
|
||||
function showLogin() { $('#login-mask').style.display = 'flex'; }
|
||||
function hideLogin() { $('#login-mask').style.display = 'none'; }
|
||||
function applyRoleUI(role) {
|
||||
CURRENT_ROLE = role || 'admin';
|
||||
$$('#sidebar nav a').forEach(a => { a.style.display = (a.dataset.route === 'enterprise' && CURRENT_ROLE !== 'admin') ? 'none' : ''; });
|
||||
}
|
||||
$('#login-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/login', {method:'POST', body:{username: $('#login-user').value, password: $('#login-pwd').value}});
|
||||
hideLogin(); $('#logout-btn').style.display = 'block'; router();
|
||||
hideLogin(); $('#logout-btn').style.display = 'block';
|
||||
const me = await api('/api/me');
|
||||
applyRoleUI(me.user?.role || 'admin');
|
||||
router();
|
||||
} catch (e) { $('#login-err').textContent = e.message; }
|
||||
});
|
||||
$('#login-pwd').addEventListener('keydown', e => { if (e.key === 'Enter') $('#login-btn').click(); });
|
||||
@@ -154,6 +161,10 @@ async function pageProjects() {
|
||||
<div style="color:var(--muted);font-size:12px;margin-top:8px;min-height:32px">${esc(p.objective || p.description || '暂无描述')}</div>
|
||||
<div style="margin-top:10px;font-size:12px;color:var(--muted)">任务 ${p.task_count} · 完成 ${p.done_count}
|
||||
<span style="float:right">${p.budget_limit ? '预算 ¥' + p.budget_limit : ''}</span></div>
|
||||
<div style="margin-top:6px;font-size:12px;color:var(--muted)">📧 ${esc(p.deliver_email || '未配置送达者')}
|
||||
${p.perm ? `<span class="tag" style="float:right">${{view:'查看',manage:'管理',admin:'管理员'}[p.perm] || p.perm}</span>` : ''}
|
||||
${p.demo_url ? `<a href="${esc(p.demo_url)}" target="_blank" style="color:var(--accent);float:right;margin-right:6px" onclick="event.stopPropagation()">🌐 Demo</a>` : ''}
|
||||
</div>
|
||||
</div>`).join('') || '<div class="empty">还没有项目,点右上角新建</div>'}
|
||||
</div>`;
|
||||
}
|
||||
@@ -171,6 +182,15 @@ function openProjectModal(p = {}) {
|
||||
</select></div>
|
||||
<div><label>预算上限(元,0=不限)</label><input id="pm-budget" type="number" value="${p.budget_limit || 0}"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div style="flex:2"><label>📧 送达者(人)邮箱 * ${p.id ? '' : '<small style="color:var(--warn)">项目完成/难关时通知此人</small>'}</label>
|
||||
<input id="pm-email" value="${esc(p.deliver_email || '')}" placeholder="如 client@example.com"></div>
|
||||
<div><label>交付物类型</label><select id="pm-dtype">
|
||||
<option value="web" ${p.deliver_type === 'web' || !p.deliver_type ? 'selected' : ''}>🌐 网页(部署 Demo 供在线查看)</option>
|
||||
<option value="file" ${p.deliver_type === 'file' ? 'selected' : ''}>📦 文件包(打包发邮件)</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<label>交付说明</label><input id="pm-dnote" value="${esc(p.deliver_note || '')}" placeholder="给送达者的交付说明(可选)">
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="pm-save">保存</button>
|
||||
@@ -179,9 +199,12 @@ function openProjectModal(p = {}) {
|
||||
const body = {
|
||||
name: $('#pm-name').value.trim(), objective: $('#pm-objective').value,
|
||||
acceptance_criteria: $('#pm-accept').value, description: $('#pm-desc').value,
|
||||
status: $('#pm-status').value, budget_limit: parseFloat($('#pm-budget').value || 0)
|
||||
status: $('#pm-status').value, budget_limit: parseFloat($('#pm-budget').value || 0),
|
||||
deliver_email: $('#pm-email').value.trim(), deliver_type: $('#pm-dtype').value,
|
||||
deliver_note: $('#pm-dnote').value.trim()
|
||||
};
|
||||
if (!body.name) return toast('请填写项目名称', 'err');
|
||||
if (!body.deliver_email || !body.deliver_email.includes('@')) return toast('必填:送达者(人)邮箱', 'err');
|
||||
try {
|
||||
await api(p.id ? `/api/projects/${p.id}` : '/api/projects', {method: p.id ? 'PUT' : 'POST', body});
|
||||
toast('已保存', 'ok'); closeModal(); router();
|
||||
@@ -200,6 +223,7 @@ async function pageProject([pid, tab]) {
|
||||
renderProjectShell();
|
||||
if (projCtx.tab === 'dag') renderDag();
|
||||
else if (projCtx.tab === 'kb') renderKb();
|
||||
else if (projCtx.tab === 'deliver') renderDeliver();
|
||||
else renderKanban();
|
||||
}
|
||||
|
||||
@@ -209,14 +233,15 @@ function renderProjectShell() {
|
||||
<div class="toolbar">
|
||||
<a class="btn" href="#/projects">← 返回</a>
|
||||
<h1 class="page-title" style="margin:0;flex:1">${esc(p.name)}<small>${esc(p.objective || '')}</small></h1>
|
||||
<button class="btn" onclick="openProjectModal(${JSON.stringify(p).replace(/"/g, '"')})">编辑项目</button>
|
||||
<button class="btn" onclick="openWbsModal()">🧠 AI 规划(WBS)</button>
|
||||
<button class="btn primary" onclick="openTaskModal()">+ 新建任务</button>
|
||||
${p.perm === 'view' ? '' : `<button class="btn" onclick="openProjectModal(${JSON.stringify(p).replace(/"/g, '"')})">编辑项目</button>`}
|
||||
${p.perm === 'view' ? '' : `<button class="btn" onclick="openWbsModal()">🧠 AI 规划(WBS)</button>`}
|
||||
${p.perm === 'view' ? '' : `<button class="btn primary" onclick="openTaskModal()">+ 新建任务</button>`}
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<a href="#/project/${pid}/kanban" class="${tab === 'kanban' ? 'active' : ''}">看板</a>
|
||||
<a href="#/project/${pid}/dag" class="${tab === 'dag' ? 'active' : ''}">DAG 工作流</a>
|
||||
<a href="#/project/${pid}/kb" class="${tab === 'kb' ? 'active' : ''}">知识库 RAG</a>
|
||||
<a href="#/project/${pid}/deliver" class="${tab === 'deliver' ? 'active' : ''}">📦 交付</a>
|
||||
</div>
|
||||
<div id="tab-body"></div>`;
|
||||
}
|
||||
@@ -1181,13 +1206,161 @@ function openWbsModal() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 📦 交付中心(V3) ---------- */
|
||||
let deliverCtx = null;
|
||||
|
||||
async function renderDeliver() {
|
||||
const {pid} = projCtx;
|
||||
const p = (await api(`/api/projects/${pid}`)).data;
|
||||
const [ws, dels] = await Promise.all([
|
||||
api(`/api/projects/${pid}/workspace`),
|
||||
api(`/api/projects/${pid}/deliverables`)
|
||||
]);
|
||||
projCtx.p = p;
|
||||
deliverCtx = {ws: ws.data, dels: dels.data};
|
||||
const isView = p.perm === 'view';
|
||||
const fmtSize = n => n >= 1048576 ? (n/1048576).toFixed(2) + ' MB' : n >= 1024 ? (n/1024).toFixed(1) + ' KB' : n + ' B';
|
||||
$('#tab-body').innerHTML = `
|
||||
<div class="grid g2" style="align-items:start">
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">📬 交付对象与方式</div>
|
||||
<div class="kv">
|
||||
<dt>送达者邮箱</dt><dd>${esc(p.deliver_email || '—')} <small style="color:var(--muted)">(项目完成 / 难关自动通知)</small></dd>
|
||||
<dt>交付物类型</dt><dd>${p.deliver_type === 'web' ? '🌐 网页交付物(在线 Demo)' : '📦 文件包交付物(邮件附件)'}</dd>
|
||||
<dt>交付说明</dt><dd>${esc(p.deliver_note || '—')}</dd>
|
||||
<dt>Demo 地址</dt><dd>${p.demo_url ? `<a href="${esc(p.demo_url)}" target="_blank" style="color:var(--accent)">${esc(p.demo_url)}</a>` : '<span style="color:var(--muted)">未部署</span>'}</dd>
|
||||
<dt>最近送达</dt><dd>${fmtTime(p.delivered_at)}</dd>
|
||||
<dt>项目状态</dt><dd>${p.status === 'done' ? '✅ 已完成' : '🔄 进行中'}</dd>
|
||||
</div>
|
||||
${isView ? '' : `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:12px">
|
||||
<button class="btn" onclick="deliverAction('deploy')" ${p.deliver_type !== 'web' ? 'disabled' : ''}>🚀 部署 Demo</button>
|
||||
<button class="btn" onclick="deliverAction('package')">🗜️ 打包</button>
|
||||
<button class="btn primary" onclick="deliverAction('deliver')">📧 发送给送达者</button>
|
||||
${p.status === 'done' ? '' : `<button class="btn" style="background:var(--accent2);border-color:var(--accent2)" onclick="deliverAction('complete')">✅ 完成项目并交付</button>`}
|
||||
<button class="btn" onclick="deliverAction('notify')">✉️ 手动通知</button>
|
||||
</div>`}
|
||||
<div class="tab-note" style="margin-top:10px">💡 交付流程:工作目录放交付物 → 网页类点「部署 Demo」生成在线地址 → 「发送给送达者」把 Demo 链接 + 打包附件发邮件;文件类直接「打包+发送」。项目所有任务验收后也会自动完成并通知送达者。</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">🗂️ 项目工作目录
|
||||
<small style="color:var(--muted)">data/workspace/${esc(p.workspace_dir || ('project_' + pid))}/(与其他项目隔离)</small>
|
||||
${isView ? '' : '<button class="btn mini" style="float:right" onclick="$(\'#ws-upload\').click()">⬆️ 上传</button>'}
|
||||
</div>
|
||||
${isView ? '' : `<input type="file" id="ws-upload" multiple style="display:none" onchange="uploadWorkspaceFiles(this)">`}
|
||||
<div id="ws-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:14px">
|
||||
<div class="sec-title" style="margin-top:0">📋 交付记录</div>
|
||||
<table><thead><tr><th>时间</th><th>类型</th><th>名称</th><th>大小</th><th>备注</th></tr></thead><tbody>
|
||||
${(dels.data || []).map(d => `<tr><td>${fmtTime(d.created_at)}</td>
|
||||
<td>${{file:'📄 文件', package:'🗜️ 打包', email:'📧 邮件', webpage:'🌐 Demo'}[d.kind] || d.kind}</td>
|
||||
<td>${esc(d.name)}${d.demo_url ? ` <a href="${esc(d.demo_url)}" target="_blank" style="color:var(--accent)">查看</a>` : ''}</td>
|
||||
<td class="mono">${d.size ? fmtSize(d.size) : '—'}</td>
|
||||
<td style="color:var(--muted)">${esc(d.note || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty">暂无交付记录</td></tr>'}
|
||||
</tbody></table>
|
||||
</div>`;
|
||||
renderWorkspaceList();
|
||||
}
|
||||
|
||||
function renderWorkspaceList() {
|
||||
const box = $('#ws-list');
|
||||
if (!box) return;
|
||||
const {ws} = deliverCtx;
|
||||
const fmtSize = n => n >= 1048576 ? (n/1048576).toFixed(2) + ' MB' : n >= 1024 ? (n/1024).toFixed(1) + ' KB' : n + ' B';
|
||||
if (!ws.length) { box.innerHTML = '<div class="empty">工作目录为空,上传交付物文件后即可打包/部署</div>'; return; }
|
||||
box.innerHTML = `<table><thead><tr><th>文件</th><th>大小</th><th>修改时间</th><th>操作</th></tr></thead><tbody>
|
||||
${ws.map(f => `<tr>
|
||||
<td>${esc(f.path)}</td>
|
||||
<td class="mono">${fmtSize(f.size)}</td>
|
||||
<td class="mono">${fmtTime(f.mtime)}</td>
|
||||
<td>
|
||||
<a class="btn mini" href="/api/projects/${projCtx.pid}/workspace/download?path=${encodeURIComponent(f.path)}">⬇️</a>
|
||||
<button class="btn mini danger" onclick="deleteWorkspaceFile('${esc(f.path)}')">🗑️</button>
|
||||
</td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
async function uploadWorkspaceFiles(input) {
|
||||
const files = [...(input.files || [])];
|
||||
if (!files.length) return;
|
||||
const fd = new FormData();
|
||||
files.forEach(f => fd.append('files', f));
|
||||
toast(`正在上传 ${files.length} 个文件…`);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projCtx.pid}/workspace/upload`, {method: 'POST', body: fd});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.ok === false) throw new Error(data.error || '上传失败');
|
||||
toast(`✅ 已上传 ${data.saved.length} 个文件`, 'ok');
|
||||
input.value = '';
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function deleteWorkspaceFile(path) {
|
||||
if (!confirm(`删除文件 ${path} ?`)) return;
|
||||
try {
|
||||
await api(`/api/projects/${projCtx.pid}/workspace?path=${encodeURIComponent(path)}`, {method: 'DELETE'});
|
||||
toast('已删除', 'ok'); await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function deliverAction(action) {
|
||||
const {pid, p} = projCtx;
|
||||
if (action === 'deploy') {
|
||||
try {
|
||||
toast('正在部署 Demo…');
|
||||
const r = await api(`/api/projects/${pid}/deploy`, {method: 'POST'});
|
||||
toast(`✅ Demo 已部署:${r.demo_url}(${r.copied} 个文件)`, 'ok');
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'package') {
|
||||
try {
|
||||
const r = await api(`/api/projects/${pid}/package`, {method: 'POST'});
|
||||
toast(`✅ 已打包:${r.name}(${(r.size/1024).toFixed(1)} KB)`, 'ok');
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'deliver') {
|
||||
if (!confirm(`将把项目「${p.name}」的交付物(Demo 链接 + 打包附件)发送到 ${p.deliver_email},确定?`)) return;
|
||||
try {
|
||||
toast('正在打包并发送邮件…');
|
||||
const r = await api(`/api/projects/${pid}/deliver`, {method: 'POST'});
|
||||
toast(`✅ 已发送:${r.msg}${r.demo_url ? ' · Demo ' + r.demo_url : ''}`, 'ok');
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'complete') {
|
||||
if (!confirm(`确认完成项目「${p.name}」?将部署 Demo、打包交付物并通过邮件通知送达者 ${p.deliver_email}。`)) return;
|
||||
try {
|
||||
const r = await api(`/api/projects/${pid}/complete`, {method: 'POST'});
|
||||
toast(`✅ 项目已完成并交付:${r.msg}`, 'ok');
|
||||
await renderDeliver();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} else if (action === 'notify') {
|
||||
openModal(`
|
||||
<h3>✉️ 手动通知送达者<button class="close" data-close>×</button></h3>
|
||||
<label>主题</label><input id="nt-subject" value="项目动态:${esc(p.name)}">
|
||||
<label>消息内容(将随项目信息一并发送)</label><textarea id="nt-msg" rows="5" placeholder="例如:项目遇到阶段性难关,正在协调处理,预计本周内完成…"></textarea>
|
||||
<div class="modal-actions"><button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="nt-send">发送</button></div>`);
|
||||
$('#nt-send').addEventListener('click', async () => {
|
||||
try {
|
||||
await api(`/api/projects/${pid}/notify_deliverer`, {method:'POST', body: {
|
||||
subject: $('#nt-subject').value.trim(), message: $('#nt-msg').value.trim()}});
|
||||
toast('✅ 已通知送达者', 'ok'); closeModal();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Worker 管理 ---------- */
|
||||
async function pageWorkers() {
|
||||
const [ws, ps] = await Promise.all([api('/api/workers'), api('/api/providers')]);
|
||||
const canManage = CURRENT_ROLE === 'admin';
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">AI Worker 管理<small>模型 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案</small></h1>
|
||||
<div class="toolbar"><button class="btn primary" onclick="openWorkerModal(null, ${JSON.stringify(ps.data).replace(/"/g,'"')})">+ 注册 Worker</button></div>
|
||||
<table><thead><tr><th>ID</th><th>名称</th><th>模型</th><th>角色提示词</th><th>温度/上限</th><th>本月成本</th><th>成本上限</th><th>状态</th><th>操作</th></tr></thead><tbody>
|
||||
${canManage ? `<div class="toolbar"><button class="btn primary" onclick="openWorkerModal(null, ${JSON.stringify(ps.data).replace(/"/g,'"')})">+ 注册 Worker</button></div>` : '<div class="toolbar"><span class="tab-note">👁️ 仅显示你有权限查看的 Worker(由管理员授权)</span></div>'}
|
||||
<table><thead><tr><th>ID</th><th>名称</th><th>模型</th><th>角色提示词</th><th>温度/上限</th><th>本月成本</th><th>成本上限</th><th>状态</th><th>权限</th><th>操作</th></tr></thead><tbody>
|
||||
${ws.data.map(w => `
|
||||
<tr>
|
||||
<td class="mono">#${w.id}</td>
|
||||
@@ -1198,11 +1371,12 @@ async function pageWorkers() {
|
||||
<td class="mono">${fmtMoney(w.month_cost)}</td>
|
||||
<td class="mono">${w.task_cost_limit ? '单任务 ¥' + w.task_cost_limit : '—'}<br>${w.monthly_cost_limit ? '月 ¥' + w.monthly_cost_limit : ''}</td>
|
||||
<td><span class="badge ${w.status === 'enabled' ? 'st-done' : 'st-cancelled'}">${w.status === 'enabled' ? '启用' : '停用'}</span></td>
|
||||
<td><span class="tag">${{view:'查看',use:'使用',manage:'管理',admin:'管理员'}[w.perm] || w.perm}</span></td>
|
||||
<td><button class="btn sm" onclick="testWorker(${w.id})">测试</button>
|
||||
<button class="btn sm" onclick="visionTest(${w.id})">🖼️ 视觉测试</button>
|
||||
<button class="btn sm" onclick="openWorkerModal(${JSON.stringify(w).replace(/"/g,'"')}, ${JSON.stringify(ps.data).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn sm danger" onclick="delWorker(${w.id})">删除</button></td>
|
||||
</tr>`).join('') || '<tr><td colspan="9" class="empty">还没有 Worker,注册第一个虚拟员工吧</td></tr>'}
|
||||
${canManage ? `<button class="btn sm" onclick="openWorkerModal(${JSON.stringify(w).replace(/"/g,'"')}, ${JSON.stringify(ps.data).replace(/"/g,'"')})">编辑</button>
|
||||
<button class="btn sm danger" onclick="delWorker(${w.id})">删除</button>` : ''}</td>
|
||||
</tr>`).join('') || '<tr><td colspan="10" class="empty">还没有 Worker,注册第一个虚拟员工吧</td></tr>'}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
@@ -1873,8 +2047,9 @@ function entRender() {
|
||||
const box = $('#ent-body');
|
||||
if (entTab === 'users') {
|
||||
box.innerHTML = `
|
||||
<div class="card"><div class="sec-title" style="margin-top:0">👥 用户列表(RBAC)</div>
|
||||
<div class="card"><div class="sec-title" style="margin-top:0">👥 用户列表(RBAC + 精准授权)</div>
|
||||
<button class="btn mini" onclick="entAddUser()">+ 新建用户</button>
|
||||
<button class="btn mini" style="float:right" onclick="entGrantOverview()">🗺️ 授权总览</button>
|
||||
<table style="margin-top:8px"><thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>来源</th><th>状态</th><th>最近登录</th><th>操作</th></tr></thead><tbody>
|
||||
${(d.users || []).map(u => `
|
||||
<tr><td>${esc(u.username)}</td><td>${esc(u.display_name)}</td>
|
||||
@@ -1882,9 +2057,10 @@ function entRender() {
|
||||
<td>${({local:'本地',oidc:'OIDC',ldap:'LDAP'})[u.source] || u.source}</td>
|
||||
<td><span class="tag">${u.status === 'active' ? '✅ 启用' : '🚫 停用'}</span></td>
|
||||
<td>${fmtTime(u.last_login_at)}</td>
|
||||
<td><button class="btn mini" onclick="entEditUser(${u.id})">编辑</button></td></tr>`).join('')}
|
||||
<td><button class="btn mini" onclick="entGrantUser(${u.id}, '${esc(u.username)}')">🔑 授权</button>
|
||||
<button class="btn mini" onclick="entEditUser(${u.id})">编辑</button></td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
<div class="tab-note">💡 角色说明:管理员=全部权限;成员=看板/任务操作;审计员=只读审计。V1 口令 admin123 兼容登录为 admin。</div>
|
||||
<div class="tab-note">💡 权限体系:<b>角色</b>(管理员=全部;审计员=全量只读;成员=按授权)+ <b>项目授权</b>(查看/管理/管理员)+ <b>Worker 授权</b>(查看/使用/管理)。成员仅能看到被授权的项目与 Worker,创建项目者自动成为该项目管理员。V1 口令 admin123 兼容登录为 admin。</div>
|
||||
</div>`;
|
||||
} else if (entTab === 'sso') {
|
||||
const s = d.sso || {};
|
||||
@@ -2042,11 +2218,70 @@ function entEditUser(id) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 精准授权:项目 / Worker 权限 ---------- */
|
||||
const PROJ_PERMS = [['', '🚫 无'], ['view', '👁️ 查看'], ['manage', '🛠️ 管理'], ['admin', '👑 管理员']];
|
||||
const WORKER_PERMS = [['', '🚫 无'], ['view', '👁️ 查看'], ['use', '⚡ 使用'], ['manage', '🛠️ 管理']];
|
||||
|
||||
async function entGrantUser(uid, uname) {
|
||||
const r = await api(`/api/enterprise/users/${uid}/grants`);
|
||||
const {projects, workers} = r.data;
|
||||
openModal(`
|
||||
<h3>🔑 精准授权:${esc(uname)}<button class="close" data-close>×</button></h3>
|
||||
<div class="tab-note">项目权限:查看=看板/DAG/知识库只读;管理=可建任务/执行/上传交付物/发送;管理员=项目全部操作。<br>
|
||||
Worker 权限:查看=可见档案;使用=可指派给任务;管理=可改配置。授权后用户登录即可见对应内容。</div>
|
||||
<div class="sec-title" style="margin-top:14px">📁 项目授权(${projects.length} 个)</div>
|
||||
<table style="max-height:240px;overflow:auto;display:block"><tbody>
|
||||
${projects.map(p => `
|
||||
<tr><td>#${p.id} ${esc(p.name)} <span class="tag">${{active:'进行中',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}</span></td>
|
||||
<td style="width:140px"><select data-gp="${p.id}">
|
||||
${PROJ_PERMS.map(([v, l]) => `<option value="${v}" ${p.perm === v ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select></td></tr>`).join('') || '<tr><td class="empty">暂无项目</td></tr>'}
|
||||
</tbody></table>
|
||||
<div class="sec-title" style="margin-top:14px">🤖 Worker 授权(${workers.length} 个)</div>
|
||||
<table style="max-height:240px;overflow:auto;display:block"><tbody>
|
||||
${workers.map(w => `
|
||||
<tr><td>#${w.id} ${esc(w.name)} <span class="pill">${esc(w.provider)}/${esc(w.model)}</span> <span class="tag">${w.status === 'enabled' ? '启用' : '停用'}</span></td>
|
||||
<td style="width:140px"><select data-gw="${w.id}">
|
||||
${WORKER_PERMS.map(([v, l]) => `<option value="${v}" ${w.perm === v ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select></td></tr>`).join('') || '<tr><td class="empty">暂无 Worker</td></tr>'}
|
||||
</tbody></table>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="eg-save">保存授权</button>
|
||||
</div>`);
|
||||
$('#eg-save').addEventListener('click', async () => {
|
||||
try {
|
||||
await api(`/api/enterprise/users/${uid}/grants`, {method: 'PUT', body: {
|
||||
projects: [...$$('[data-gp]')].map(s => ({project_id: Number(s.dataset.gp), perm: s.value})),
|
||||
workers: [...$$('[data-gw]')].map(s => ({worker_id: Number(s.dataset.gw), perm: s.value}))
|
||||
}});
|
||||
toast('✅ 授权已保存', 'ok'); closeModal(); pageEnterprise();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
}
|
||||
|
||||
async function entGrantOverview() {
|
||||
const r = await api('/api/enterprise/grants/overview');
|
||||
openModal(`
|
||||
<h3>🗺️ 授权总览<button class="close" data-close>×</button></h3>
|
||||
<div style="max-height:70vh;overflow:auto">
|
||||
<table><thead><tr><th>用户</th><th>角色</th><th>项目授权</th><th>Worker 授权</th></tr></thead><tbody>
|
||||
${r.data.map(u => `<tr><td><b>${esc(u.username)}</b><br><small style="color:var(--muted)">${esc(u.display_name || '')}</small></td>
|
||||
<td>${{admin:'👑 管理员',member:'成员',auditor:'审计员'}[u.role] || u.role}</td>
|
||||
<td>${(u.projects || []).map(p => `#${p.project_id} ${esc(p.project_name)} <span class="tag">${{view:'查看',manage:'管理',admin:'管理员'}[p.perm]}</span>`).join('<br>') || '<span style="color:var(--muted)">—</span>'}</td>
|
||||
<td>${(u.workers || []).map(w => `#${w.worker_id} ${esc(w.worker_name)} <span class="tag">${{view:'查看',use:'使用',manage:'管理'}[w.perm]}</span>`).join('<br>') || '<span style="color:var(--muted)">—</span>'}</td></tr>`).join('')}
|
||||
</tbody></table></div>
|
||||
<div class="modal-actions"><button class="btn primary" data-close>关闭</button></div>`);
|
||||
}
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
let CURRENT_ROLE = 'admin';
|
||||
(async function init() {
|
||||
try {
|
||||
const me = await api('/api/me');
|
||||
if (!me.authed) { showLogin(); $('#main').innerHTML = '<div class="empty">请先登录</div>'; return; }
|
||||
CURRENT_ROLE = me.user?.role || 'admin';
|
||||
applyRoleUI(CURRENT_ROLE);
|
||||
$('#logout-btn').style.display = 'block';
|
||||
} catch (e) { showLogin(); return; }
|
||||
try { await api('/api/health'); } catch (e) { $('#conn-state').textContent = '● 服务异常'; $('#conn-state').className = 'conn err'; }
|
||||
|
||||
Reference in New Issue
Block a user