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:
2026-08-13 12:38:38 +08:00
parent 78ccca61c3
commit eb92a4e591
8 changed files with 1451 additions and 66 deletions
+248 -13
View File
@@ -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, '&quot;')})">编辑项目</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, '&quot;')})">编辑项目</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,'&quot;')})"> 注册 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,'&quot;')})"> 注册 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,'&quot;')}, ${JSON.stringify(ps.data).replace(/"/g,'&quot;')})">编辑</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,'&quot;')}, ${JSON.stringify(ps.data).replace(/"/g,'&quot;')})">编辑</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'; }