Files
ai-worker-platform/static/app.js
T
hz4th_coder 5deb447c8e DAG 工作流画布升级:节点可拖动 + 滚轮缩放 + 画布平移
- 节点拖拽调整布局(位移按缩放比例换算,拖动不误触详情点击)
- 滚轮以鼠标为锚点缩放(0.25x-3x),双击空白一键复位
- 拖拽空白区域平移画布(cursor grab/grabbing)
- 布局与视图状态 localStorage 按项目记忆,刷新/重开自动恢复
- 实测:节点精确拖动、锚点缩放、平移、复位、点击详情全部通过
2026-08-12 17:26:30 +08:00

1646 lines
93 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* AI Worker 项目管理平台 - 前端 SPAV1) */
'use strict';
const $ = (sel, el = document) => el.querySelector(sel);
const $$ = (sel, el = document) => [...el.querySelectorAll(sel)];
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmtTime = ts => ts ? new Date(ts * 1000).toLocaleString('zh-CN', {hour12:false}) : '—';
const fmtMoney = v => (v ?? 0) >= 0.01 ? '¥' + Number(v).toFixed(2) : '¥' + Number(v ?? 0).toFixed(4);
const STATUS = {
todo: {label:'待执行', cls:'st-todo'}, running:{label:'执行中', cls:'st-running'},
review:{label:'待审核', cls:'st-review'}, done:{label:'已完成', cls:'st-done'},
rejected:{label:'已打回', cls:'st-rejected'}, failed:{label:'失败', cls:'st-failed'},
cancelled:{label:'已取消', cls:'st-cancelled'}
};
const PRI = {high:'高', medium:'中', low:'低'};
const KANBAN_ORDER = ['todo','running','review','done','rejected','failed'];
const ALERT_LEVEL = {info:'️', warn:'⚠️', critical:'🚨'};
/* ---------- API ---------- */
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: {'Content-Type': 'application/json'},
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined
});
let data = {};
try { data = await res.json(); } catch (e) {}
if (res.status === 401) { showLogin(); throw new Error('未登录'); }
if (!res.ok || data.ok === false) throw new Error(data.error || `请求失败(${res.status})`);
return data;
}
/* ---------- Toast ---------- */
function toast(msg, type = '') {
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = msg;
$('#toast-wrap').appendChild(el);
setTimeout(() => el.remove(), 3200);
}
/* ---------- Login ---------- */
function showLogin() { $('#login-mask').style.display = 'flex'; }
function hideLogin() { $('#login-mask').style.display = '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();
} catch (e) { $('#login-err').textContent = e.message; }
});
$('#login-pwd').addEventListener('keydown', e => { if (e.key === 'Enter') $('#login-btn').click(); });
$('#logout-btn').addEventListener('click', async e => {
e.preventDefault(); await api('/api/logout', {method:'POST'}); location.hash = '#/dashboard'; location.reload();
});
/* ---------- Modal ---------- */
function openModal(html) {
$('#modal-box').innerHTML = html;
$('#modal-mask').style.display = 'flex';
$$('#modal-box [data-close]').forEach(b => b.addEventListener('click', closeModal));
}
function closeModal() { $('#modal-mask').style.display = 'none'; $('#modal-box').innerHTML = ''; }
$('#modal-mask').addEventListener('click', e => { if (e.target.id === 'modal-mask') closeModal(); });
/* ---------- 告警角标 ---------- */
async function refreshAlertBadge() {
try {
const r = await api('/api/alerts/unread_count');
const b = $('#alert-badge');
if (r.count > 0) { b.textContent = r.count; b.style.display = 'inline'; }
else b.style.display = 'none';
} catch (e) {}
}
setInterval(refreshAlertBadge, 30000);
/* ---------- Router ---------- */
const routes = {
'dashboard': pageDashboard, 'projects': pageProjects, 'project': pageProject,
'workers': pageWorkers, 'reports': pageReports, 'logs': pageLogs,
'alerts': pageAlerts, 'api': pageApiTokens, 'settings': pageSettings,
'agents': pageAgents, 'eval': pageEval, 'templates': pageTemplates,
'enterprise': pageEnterprise
};
function router() {
const hash = location.hash.replace(/^#\//, '') || 'dashboard';
const parts = hash.split('/');
const name = parts[0];
const fn = routes[name] || pageDashboard;
const navMap = {project:'projects', api:'api', settings:'settings', alerts:'alerts'};
$$('#sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.route === (navMap[name] || name)));
$('#main').innerHTML = '<div class="empty"><span class="spin"></span>加载中…</div>';
fn(parts.slice(1)).catch(e => { $('#main').innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
}
window.addEventListener('hashchange', router);
/* ---------- 仪表盘 ---------- */
async function pageDashboard() {
const s = (await api('/api/stats')).data;
const total = Object.values(s.by_status).reduce((a, b) => a + b, 0);
const pct = n => total ? Math.round(n / total * 100) : 0;
$('#main').innerHTML = `
<h1 class="page-title">仪表盘<small>AI 虚拟团队运营总览</small></h1>
<div class="grid g4">
<div class="stat"><div class="lbl">项目</div><div class="num">${s.projects}</div><div class="sub">进行中 ${s.by_status.done ?? 0} 个已完成任务</div></div>
<div class="stat"><div class="lbl">任务总数</div><div class="num">${s.tasks}</div><div class="sub">完成率 ${pct(s.by_status.done ?? 0)}% · 待审核 ${s.by_status.review ?? 0}</div></div>
<div class="stat"><div class="lbl">AI Worker</div><div class="num">${s.workers}</div><div class="sub">虚拟员工档案数</div></div>
<div class="stat"><div class="lbl">累计成本</div><div class="num">${fmtMoney(s.total_cost)}</div><div class="sub">${(s.total_tokens/1e6).toFixed(2)}M tokens 消耗</div></div>
</div>
<div class="grid g3" style="margin-top:14px">
<div class="card">
<div class="sec-title" style="margin-top:0">质量指标</div>
<div class="kv">
<dt>一次通过率</dt><dd><b style="color:var(--accent2)">${s.one_pass_rate ?? '—'}%</b>(验收一次通过)</dd>
<dt>返工次数</dt><dd>${s.rework_count} 次打回</dd>
<dt>执行失败</dt><dd>${s.by_status.failed ?? 0} 个任务</dd>
</div>
</div>
<div class="card">
<div class="sec-title" style="margin-top:0">近 7 天成本趋势(元)</div>
${s.daily_cost.map(d => `
<div style="font-size:12px;color:var(--muted);margin-top:8px">${d.day} <span style="float:right">${d.cost.toFixed(4)}</span></div>
<div class="bar"><div style="width:${Math.min(100, d.cost / Math.max(...s.daily_cost.map(x=>x.cost), 0.001) * 100)}%"></div></div>`).join('')}
</div>
<div class="card">
<div class="sec-title" style="margin-top:0">任务状态分布</div>
${Object.entries(STATUS).filter(([k]) => s.by_status[k]).map(([k, v]) => `
<div style="font-size:12px;margin-top:8px"><span class="badge ${v.cls}">${v.label}</span> ${s.by_status[k]}
<span style="float:right;color:var(--muted)">${pct(s.by_status[k])}%</span></div>
<div class="bar"><div style="width:${pct(s.by_status[k])}%;background:${v.cls === 'st-done' ? 'var(--accent2)' : v.cls === 'st-review' ? 'var(--warn)' : v.cls === 'st-running' ? 'var(--accent)' : 'var(--border)'}"></div></div>`).join('') || '<div class="empty">暂无数据</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></tr></thead><tbody>
${s.recent.map(t => `<tr><td>${esc(t.project_name || '—')}</td><td><a href="#/project/${t.project_id}" style="color:var(--accent)">${esc(t.title)}</a></td>
<td><span class="badge ${STATUS[t.status].cls}">${STATUS[t.status].label}</span></td><td class="mono">${fmtTime(t.updated_at)}</td></tr>`).join('') || '<tr><td colspan="4" class="empty">暂无任务,去 <a href="#/projects" style="color:var(--accent)">创建项目</a> 吧</td></tr>'}
</tbody></table>
</div>`;
}
/* ---------- 项目列表 ---------- */
async function pageProjects() {
const list = (await api('/api/projects')).data;
$('#main').innerHTML = `
<h1 class="page-title">项目<small>立项 → 规划 → 执行 → 验收 → 归档</small></h1>
<div class="toolbar"><button class="btn primary" onclick="openProjectModal()"> 新建项目</button></div>
<div class="grid g3" id="proj-list">
${list.map(p => `
<div class="card proj-card" onclick="location.hash='#/project/${p.id}'">
<div style="display:flex;justify-content:space-between;align-items:center">
<b>${esc(p.name)}</b><span class="badge st-${p.status === 'active' ? 'running' : p.status === 'done' ? 'done' : p.status === 'archived' ? 'cancelled' : 'todo'}">${{active:'进行中', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}</span>
</div>
<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>`).join('') || '<div class="empty">还没有项目,点右上角新建</div>'}
</div>`;
}
function openProjectModal(p = {}) {
openModal(`
<h3>${p.id ? '编辑项目' : '新建项目'}<button class="close" data-close>×</button></h3>
<label>项目名称 *</label><input id="pm-name" value="${esc(p.name || '')}" placeholder="例如:营销内容批量生产">
<label>目标(Objective</label><textarea id="pm-objective" placeholder="项目要达成什么目标">${esc(p.objective || '')}</textarea>
<label>验收标准</label><textarea id="pm-accept" placeholder="什么样算验收通过">${esc(p.acceptance_criteria || '')}</textarea>
<label>项目描述</label><textarea id="pm-desc">${esc(p.description || '')}</textarea>
<div class="row">
<div><label>状态</label><select id="pm-status">
${['planning','active','done','archived'].map(s => `<option value="${s}" ${p.status === s ? 'selected' : ''}>${{planning:'规划中', active:'进行中', done:'已完成', archived:'已归档'}[s]}</option>`).join('')}
</select></div>
<div><label>预算上限(元,0=不限)</label><input id="pm-budget" type="number" value="${p.budget_limit || 0}"></div>
</div>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="pm-save">保存</button>
</div>`);
$('#pm-save').addEventListener('click', async () => {
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)
};
if (!body.name) return toast('请填写项目名称', 'err');
try {
await api(p.id ? `/api/projects/${p.id}` : '/api/projects', {method: p.id ? 'PUT' : 'POST', body});
toast('已保存', 'ok'); closeModal(); router();
} catch (e) { toast(e.message, 'err'); }
});
}
/* ---------- 项目详情(看板 / DAG / 知识库) ---------- */
let projCtx = null; // {pid, p, tasks, workers, tab}
async function pageProject([pid, tab]) {
const p = (await api(`/api/projects/${pid}`)).data;
const tasks = (await api(`/api/tasks?project_id=${pid}`)).data;
const workers = (await api('/api/workers')).data;
projCtx = {pid, p, tasks, workers, tab: tab || 'kanban'};
renderProjectShell();
if (projCtx.tab === 'dag') renderDag();
else if (projCtx.tab === 'kb') renderKb();
else renderKanban();
}
function renderProjectShell() {
const {p, pid, tab} = projCtx;
$('#main').innerHTML = `
<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>
</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>
</div>
<div id="tab-body"></div>`;
}
async function refreshProj() {
projCtx.tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data;
if (projCtx.tab === 'dag') renderDag();
else renderKanban();
}
/* --- 看板 --- */
function renderKanban() {
const {tasks} = projCtx;
const by = {};
tasks.forEach(t => { (by[t.status] = by[t.status] || []).push(t); });
$('#tab-body').innerHTML = `<div style="overflow-x:auto"><div class="kanban" id="kanban">${KANBAN_ORDER.map(s => `
<div class="kb-col" data-status="${s}">
<div class="kb-head"><span><span class="badge ${STATUS[s].cls}">${STATUS[s].label}</span></span>
<span class="cnt" id="cnt-${s}">0</span></div>
<div class="kb-body" data-drop="${s}"></div>
</div>`).join('')}</div></div>`;
KANBAN_ORDER.forEach(s => {
const list = by[s] || [];
$('#cnt-' + s).textContent = list.length;
$('#kanban .kb-body[data-drop="' + s + '"]').innerHTML = list.map(t => `
<div class="kb-card" draggable="${t.status === 'todo' || t.status === 'rejected' || t.status === 'done'}"
data-id="${t.id}" onclick="openTaskDetail(${t.id})">
<div class="t">${esc(t.title)}</div>
<div class="m">
<span class="pri-${t.priority}">●${PRI[t.priority]}</span>
${t.worker_id ? `<span class="pill" style="color:var(--accent)">Worker#${t.worker_id}</span>` : '<span class="pill">自动路由</span>'}
${t.depends_on && t.depends_on.length ? `<span class="pill" style="color:var(--purple)">依赖×${t.depends_on.length}</span>` : ''}
${t.rejection_count ? `<span class="badge st-rejected">打回×${t.rejection_count}</span>` : ''}
${t.status === 'running' ? '<span class="spin"></span>' : ''}
</div>
${t.status === 'done' ? '<div class="out">✅ 已交付 v' + t.output_version + '</div>' : ''}
</div>`).join('') || '<div style="color:var(--muted);font-size:12px;text-align:center;padding:16px 0">空</div>';
});
bindKanbanDnD();
}
function bindKanbanDnD() {
$$('.kb-body').forEach(bd => {
bd.addEventListener('dragover', e => { e.preventDefault(); bd.classList.add('drag-over'); });
bd.addEventListener('dragleave', () => bd.classList.remove('drag-over'));
bd.addEventListener('drop', e => {
e.preventDefault(); bd.classList.remove('drag-over');
const tid = parseInt(e.dataTransfer.getData('text/plain'));
moveTask(tid, bd.dataset.drop);
});
});
$$('.kb-card[draggable="true"]').forEach(c => {
c.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', c.dataset.id);
c.classList.add('dragging');
});
c.addEventListener('dragend', () => c.classList.remove('dragging'));
});
}
async function moveTask(tid, status) {
const t = projCtx.tasks.find(x => x.id === tid);
if (!t || t.status === status) return;
if (['running', 'review'].includes(status)) return toast('不能直接拖到该状态', 'err');
if (status === 'todo' && !['rejected', 'failed', 'done'].includes(t.status)) return;
if (status === 'done') return toast('完成请走审核流(审核通过)', 'err');
await api(`/api/tasks/${tid}`, {method:'PUT', body:{status}});
toast('状态已更新', 'ok'); refreshProj();
}
/* --- DAG 工作流(可交互画布:拖节点 / 滚轮缩放 / 拖画布平移) --- */
let dagState = null; // {pid, pos:{id:{x,y}}, scale, tx, ty, drag:null, moved:false}
function dagLayout(tasks, idMap) {
// 拓扑分层:最长路径
const layer = {};
const visit = id => {
if (layer[id] !== undefined) return layer[id];
const t = idMap[id];
let l = 0;
(t.depends_on || []).forEach(d => { if (idMap[d]) l = Math.max(l, visit(d) + 1); });
layer[id] = l;
return l;
};
tasks.forEach(t => visit(t.id));
const layers = {};
tasks.forEach(t => { (layers[layer[t.id]] = layers[layer[t.id]] || []).push(t.id); });
const W = 260, H = 110, PAD = 60;
const width = Math.max(2, Object.keys(layers).length) * W + PAD;
const height = Math.max(...tasks.map(t => layer[t.id] === undefined ? 0 : layer[t.id]), 0) * H + PAD + 60;
const pos = {};
Object.entries(layers).forEach(([l, ids]) => {
const x = PAD + parseInt(l) * W;
const step = Math.min(H - 60, Math.max(70, (height - 2 * PAD) / (ids.length + 1)));
ids.forEach((id, i) => { pos[id] = {x, y: PAD + (i + 1) * step}; });
});
return {width, height, pos};
}
function dagSize() {
const {tasks} = projCtx;
const idMap = {};
tasks.forEach(t => { idMap[t.id] = t; });
return dagLayout(tasks, idMap);
}
function saveDagState() {
if (!dagState) return;
try {
const pos = {};
Object.entries(dagState.pos).forEach(([k, v]) => { pos[k] = {x: Math.round(v.x), y: Math.round(v.y)}; });
localStorage.setItem('aw_dag_pos_' + dagState.pid, JSON.stringify(pos));
localStorage.setItem('aw_dag_view_' + dagState.pid,
JSON.stringify({scale: dagState.scale, tx: Math.round(dagState.tx), ty: Math.round(dagState.ty)}));
} catch (e) {}
}
function dagApplyView() {
const s = dagState;
const svg = $('#dag-svg');
if (!svg) return;
svg.style.transform = `translate(${s.tx}px, ${s.ty}px) scale(${s.scale})`;
svg.style.transformOrigin = '0 0';
}
function drawDag() {
const {tasks} = projCtx;
const idMap = {};
tasks.forEach(t => { idMap[t.id] = t; });
const edges = [];
tasks.forEach(t => (t.depends_on || []).forEach(d => { if (idMap[d]) edges.push({from: d, to: t.id}); }));
const {width, height} = dagSize();
const colors = {todo:'#2a3550', running:'#173b6b', review:'#4a3a10', done:'#0f3d2e', rejected:'#4a1c1c', failed:'#3d1c2e', cancelled:'#333'};
const ready = tasks.filter(t => (t.status === 'todo' || t.status === 'failed') && (!t.depends_on || t.depends_on.every(d => idMap[d]?.status === 'done')));
const body = $('#tab-body');
if (!body) return;
body.innerHTML = `
<div class="toolbar" style="margin-bottom:10px">
<span class="pager-tip">共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''} · 拖节点调位 / 滚轮缩放 / 拖空白平移 / 双击复位</span>
<button class="btn success" ${ready.length ? '' : 'disabled'} onclick="runWorkflow()">▶ 执行整个工作流(${ready.length}</button>
</div>
<div class="dag-box" id="dag-box">
<svg id="dag-svg" width="100%" height="${Math.max(480, height)}" viewBox="0 0 ${width} ${height}" style="transform-origin:0 0">
<defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#4a5878"/></marker></defs>
<rect id="dag-bg" x="-2000" y="-2000" width="${width + 4000}" height="${height + 4000}" fill="transparent"/>
${edges.map(e => {
const a = dagState.pos[e.from], b = dagState.pos[e.to];
if (!a || !b) return '';
const mx = (a.x + b.x) / 2;
const act = idMap[e.from].status === 'done';
return `<path data-e="${e.from}-${e.to}" class="dag-edge${act ? ' act' : ''}" d="M${a.x + 55},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - 55},${b.y}"/>`;
}).join('')}
${tasks.map(t => {
const p = dagState.pos[t.id];
const col = colors[t.status];
return `<g class="dag-node" data-id="${t.id}" style="cursor:grab">
<rect x="${p.x - 55}" y="${p.y - 24}" width="110" height="48" fill="${col}" stroke="#3a4a6a"/>
<text class="dag-label" x="${p.x}" y="${p.y - 2}" fill="#fff">#${t.id} ${esc(t.title).slice(0, 10)}</text>
<text class="dag-sub" x="${p.x}" y="${p.y + 15}">${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''}</text>
</g>`;
}).join('')}
</svg>
</div>
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。</div>`;
dagApplyView();
bindDagEvents();
}
function bindDagEvents() {
const box = $('#dag-box');
const svg = $('#dag-svg');
if (!box || !svg) return;
const s = dagState;
let down = null; // {type:'node'|'pan', id?, sx, sy, ox, oy}
box.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const nodeEl = e.target.closest('.dag-node');
if (nodeEl) {
const id = Number(nodeEl.dataset.id);
const p = s.pos[id];
down = {type: 'node', id, sx: e.clientX, sy: e.clientY, ox: p.x, oy: p.y, moved: false};
nodeEl.style.cursor = 'grabbing';
svg.style.cursor = 'grabbing';
} else {
down = {type: 'pan', sx: e.clientX, sy: e.clientY, ox: s.tx, oy: s.ty, moved: false};
svg.style.cursor = 'grabbing';
}
e.preventDefault();
});
window.addEventListener('mousemove', e => {
if (!down) return;
const dx = e.clientX - down.sx, dy = e.clientY - down.sy;
if (!down.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
down.moved = true;
if (down.type === 'node') {
const p = s.pos[down.id];
if (p) { p.x = down.ox + dx / s.scale; p.y = down.oy + dy / s.scale; }
} else {
s.tx = down.ox + dx;
s.ty = down.oy + dy;
}
dagApplyView();
if (down.type === 'node') {
// 轻量更新:节点坐标 + 关联连线
const svgEl = $('#dag-svg');
if (!svgEl) return;
svgEl.querySelectorAll('.dag-node').forEach(g => {
const nid = Number(g.dataset.id);
const pp = s.pos[nid];
if (!pp) return;
g.querySelector('rect').setAttribute('x', pp.x - 55);
g.querySelector('rect').setAttribute('y', pp.y - 24);
const ts = g.querySelectorAll('text');
ts[0].setAttribute('x', pp.x); ts[0].setAttribute('y', pp.y - 2);
ts[1].setAttribute('x', pp.x); ts[1].setAttribute('y', pp.y + 15);
});
// 以拖动节点为中心更新相关连线
const touched = new Set([down.id]);
projCtx.tasks.forEach(t => (t.depends_on || []).forEach(d => {
if (d === down.id || t.id === down.id) { touched.add(d); touched.add(t.id); }
}));
touched.forEach(tid => {
const a = s.pos[tid];
if (!a) return;
projCtx.tasks.forEach(t => {
if (t.id !== tid) return;
(t.depends_on || []).forEach(d => {
const b = s.pos[d];
if (!b) return;
const path = svgEl.querySelector(`path[data-e="${d}-${tid}"]`);
if (!path) return;
const mx = (b.x + a.x) / 2;
path.setAttribute('d', `M${b.x + 55},${b.y} C${mx},${b.y} ${mx},${a.y} ${a.x - 55},${a.y}`);
});
});
});
}
});
window.addEventListener('mouseup', () => {
if (!down) return;
const wasNode = down.type === 'node';
if (wasNode) {
const nodeEl = svg.querySelector('.dag-node[data-id="' + down.id + '"]');
if (nodeEl) nodeEl.style.cursor = 'grab';
}
svg.style.cursor = '';
if (down.moved) {
dagState.suppressClick = true;
setTimeout(() => { if (dagState) dagState.suppressClick = false; }, 400);
saveDagState();
}
down = null;
});
// 滚轮缩放(以鼠标为锚点)
box.addEventListener('wheel', e => {
e.preventDefault();
const rect = box.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const ns = Math.min(3, Math.max(0.25, s.scale * factor));
// 保持鼠标指向的内容坐标不动
const cx = (mx - s.tx) / s.scale, cy = (my - s.ty) / s.scale;
s.scale = ns;
s.tx = mx - cx * ns;
s.ty = my - cy * ns;
dagApplyView();
saveDagState();
}, {passive: false});
// 双击空白复位视图
box.addEventListener('dblclick', e => {
if (e.target.closest('.dag-node')) return;
s.scale = 1; s.tx = 0; s.ty = 0;
dagApplyView();
saveDagState();
});
// 点击节点打开详情(拖动超过阈值则不触发)
box.addEventListener('click', e => {
const nodeEl = e.target.closest('.dag-node');
if (!nodeEl) return;
if (dagState.suppressClick) { dagState.suppressClick = false; return; }
openTaskDetail(Number(nodeEl.dataset.id));
});
}
function renderDag() {
const {tasks, pid} = projCtx;
const idMap = {};
tasks.forEach(t => { idMap[t.id] = t; });
const {pos} = dagLayout(tasks, idMap);
// 恢复已保存布局 / 视图
let savedPos = null, savedView = null;
try {
savedPos = JSON.parse(localStorage.getItem('aw_dag_pos_' + pid) || 'null');
savedView = JSON.parse(localStorage.getItem('aw_dag_view_' + pid) || 'null');
} catch (e) {}
if (savedPos) Object.entries(savedPos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; });
dagState = {
pid, pos,
scale: savedView?.scale || 1,
tx: savedView?.tx || 0,
ty: savedView?.ty || 0,
drag: null, moved: false,
};
const box = $('#tab-body');
box.innerHTML = '';
drawDag();
}
async function runWorkflow() {
const r = await api(`/api/projects/${projCtx.pid}/workflow/run`, {method:'POST'});
toast(`🚀 已派发 ${r.started.length} 个任务${r.blocked.length ? `${r.blocked.length} 个因依赖阻塞` : ''}`, 'ok');
setTimeout(refreshProj, 1500);
}
/* --- 知识库 RAG --- */
async function renderKb() {
const docs = (await api(`/api/projects/${projCtx.pid}/documents`)).data;
$('#tab-body').innerHTML = `
<div class="toolbar">
<span class="pager-tip">文档共 ${docs.length} 篇 · 任务执行时自动检索注入上下文</span>
<button class="btn primary" onclick="openDocModal()"> 添加文档</button>
</div>
<div class="card">
<label>🔍 检索测试(模拟任务执行时的 RAG 命中)</label>
<div class="row"><input id="kb-q" placeholder="输入查询,例如:保温杯卖点"><button class="btn" onclick="kbSearch()">检索</button></div>
<div id="kb-results"></div>
<div class="sec-title" style="margin-top:16px">文档列表</div>
<div id="kb-docs">${docs.map(d => `
<div class="doc-item">
<div><b>${esc(d.name)}</b> <span class="pill">${d.chunks} 块</span> <span style="color:var(--muted);font-size:12px">${d.content.length} 字 · ${fmtTime(d.created_at)}</span></div>
<div><button class="btn sm" onclick="openDocModal(${d.id})">查看/编辑</button>
<button class="btn sm danger" onclick="delDoc(${d.id})">删除</button></div>
</div>`).join('') || '<div class="empty">暂无文档,添加产品资料/知识手册后 AI Worker 干活时会自动参考</div>'}
</div>
</div>`;
}
async function kbSearch() {
const q = $('#kb-q').value.trim();
if (!q) return;
const r = await api(`/api/projects/${projCtx.pid}/search?q=${encodeURIComponent(q)}`);
$('#kb-results').innerHTML = r.data.length ? r.data.map(h => `
<div class="alert-row" style="margin-top:8px">
<div class="t">📄 命中 文档#${h.document_id} · 块${h.chunk_id} · 得分 ${h.score}</div>
<div class="d">${esc(h.content.slice(0, 160))}${h.content.length > 160 ? '…' : ''}</div>
</div>`).join('') : '<div class="empty" style="padding:14px">无命中(知识库为空或关键词不匹配)</div>';
}
function openDocModal(docId) {
(docId ? api(`/api/documents/${docId}`) : Promise.resolve({data: {}})).then(async r => {
const doc = docId ? r.data : {};
openModal(`
<h3>${docId ? '编辑文档' : '添加文档'}<button class="close" data-close>×</button></h3>
<label>文档名称 *</label><input id="dm-name" value="${esc(doc.name || '')}">
<label>内容(支持粘贴文本 / 上传 txt、md 文件)</label>
<textarea id="dm-content" style="min-height:220px">${esc(doc.content || '')}</textarea>
<input type="file" id="dm-file" accept=".txt,.md" style="margin-top:8px">
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="dm-save">保存并分块索引</button>
</div>`);
$('#dm-file').addEventListener('change', e => {
const f = e.target.files[0];
if (!f) return;
const rd = new FileReader();
rd.onload = () => { $('#dm-content').value = rd.result; if (!$('#dm-name').value) $('#dm-name').value = f.name; };
rd.readAsText(f);
});
$('#dm-save').addEventListener('click', async () => {
const name = $('#dm-name').value.trim(), content = $('#dm-content').value;
if (!name || !content.trim()) return toast('名称和内容必填', 'err');
const rr = await api(docId ? `/api/documents/${docId}` : `/api/projects/${projCtx.pid}/documents`,
{method: docId ? 'PUT' : 'POST', body: docId ? {name, content} : {name, content}});
toast(docId ? '已保存' : `已添加,分块 ${rr.chunks} 块`, 'ok'); closeModal(); renderKb();
});
});
}
async function delDoc(docId) {
if (!confirm('确认删除该文档及其分块?')) return;
await api(`/api/documents/${docId}`, {method:'DELETE'});
toast('已删除', 'ok'); renderKb();
}
/* --- 新建任务(含依赖) --- */
function openTaskModal(editTask) {
const t = editTask || {};
const {tasks, workers, pid} = projCtx || {tasks: [], workers: []};
const candidates = tasks.filter(x => x.status !== 'running' && x.id !== t.id);
const deps = t.depends_on || [];
openModal(`
<h3>${t.id ? '编辑任务' : '新建任务'}<button class="close" data-close>×</button></h3>
<label>任务标题 *</label><input id="tm-title" value="${esc(t.title || '')}" placeholder="例如:生成 10 条小红书种草文案">
<label>任务说明(发给 AI Worker 的指令)</label><textarea id="tm-desc" placeholder="描述任务目标、要求、输出格式…">${esc(t.description || '')}</textarea>
<div class="row">
<div><label>执行 Worker</label><select id="tm-worker">
<option value="">🤖 自动路由(最便宜可用)</option>
${workers.map(w => `<option value="${w.id}" ${t.worker_id === w.id ? 'selected' : ''}>${esc(w.name)}${w.provider}/${w.model}</option>`).join('')}
</select></div>
<div><label>优先级</label><select id="tm-pri">
${Object.entries(PRI).map(([k, v]) => `<option value="${k}" ${t.priority === k ? 'selected' : ''}>${v}</option>`).join('')}
</select></div>
</div>
<div class="row">
<div><label>截止时间</label><input id="tm-deadline" type="date" value="${t.deadline || ''}"></div>
<div><label>完成后人工审核</label><select id="tm-review">
<option value="1" ${t.review_required !== 0 ? 'selected' : ''}>需要(HITL</option>
<option value="0" ${t.review_required === 0 ? 'selected' : ''}>不需要</option>
</select></div>
</div>
<label>前置任务(DAG 依赖,完成后方可执行)</label>
<div class="check-group" id="tm-deps">
${candidates.length ? candidates.map(c => `
<label><input type="checkbox" value="${c.id}" ${deps.includes(c.id) ? 'checked' : ''}>
#${c.id} ${esc(c.title).slice(0, 18)}${STATUS[c.status].label}</label>`).join('') : '<span style="color:var(--muted);font-size:12px">暂无其他任务可选</span>'}
</div>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="tm-save">${t.id ? '保存' : '创建并派活'}</button>
</div>`);
$('#tm-save').addEventListener('click', async () => {
const title = $('#tm-title').value.trim();
if (!title) return toast('请填写任务标题', 'err');
const body = {
title, description: $('#tm-desc').value,
worker_id: $('#tm-worker').value ? parseInt($('#tm-worker').value) : null,
priority: $('#tm-pri').value, deadline: $('#tm-deadline').value,
review_required: $('#tm-review').value === '1',
depends_on: $$('#tm-deps input:checked').map(x => parseInt(x.value))
};
try {
if (t.id) {
await api(`/api/tasks/${t.id}`, {method:'PUT', body});
toast('已保存', 'ok'); closeModal(); refreshProj();
} else {
const r = await api('/api/tasks', {method:'POST', body:{...body, project_id: projCtx.pid}});
toast('任务已创建', 'ok'); closeModal(); refreshProj();
if (confirm('任务已创建,立即派给 AI Worker 执行?')) runTask(r.id);
}
} catch (e) { toast(e.message, 'err'); }
});
}
async function runTask(tid) {
try { await api(`/api/tasks/${tid}/run`, {method:'POST'}); toast('🚀 已派活,AI Worker 开始执行', 'ok'); refreshProj(); }
catch (e) { toast(e.message, 'err'); }
}
/* ---------- 任务详情(含审核) ---------- */
async function openTaskDetail(tid) {
const t = (await api(`/api/tasks/${tid}`)).data;
const workers = projCtx?.workers || (await api('/api/workers')).data;
const w = t.worker || (t.worker_id ? {id: t.worker_id, name: `#${t.worker_id}`} : null);
const deps = (t.depends_on || []).map(d => projCtx?.tasks.find(x => x.id === d)).filter(Boolean);
openModal(`
<h3>${esc(t.title)}<span class="badge ${STATUS[t.status].cls}" style="margin-left:8px">${STATUS[t.status].label}</span>
<button class="close" data-close>×</button></h3>
<div class="kv" style="margin-bottom:6px">
<dt>执行 Worker</dt><dd>${w ? `${esc(w.name)} <span class="pill">${esc(w.provider)}/${esc(w.model)}</span>` : '未指派(自动路由)'}</dd>
<dt>优先级</dt><dd><span class="pri-${t.priority}">●${PRI[t.priority]}</span> · 返工 ${t.rejection_count} 次</dd>
<dt>前置任务</dt><dd>${deps.length ? deps.map(d => `<span class="pill" style="color:var(--purple)" onclick="openTaskDetail(${d.id})">#${d.id} ${esc(d.title).slice(0,12)}${STATUS[d.status].label}</span>`).join('') : '无'}</dd>
<dt>创建/更新</dt><dd class="mono">${fmtTime(t.created_at)} / ${fmtTime(t.updated_at)}</dd>
<dt>截止</dt><dd>${t.deadline || '—'}</dd>
</div>
<div class="sec-title" style="margin-top:12px">任务指令</div>
<div class="output-box" style="max-height:140px">${esc(t.description || t.title)}</div>
${t.output_text ? `<div class="sec-title">产出物 v${t.output_version}${t.output_text.length} 字)</div>
<div class="output-box">${esc(t.output_text)}</div>` : ''}
${t.error ? `<div class="sec-title" style="color:var(--danger)">错误信息</div>
<div class="output-box" style="border-color:var(--danger)">${esc(t.error)}</div>` : ''}
<div class="sec-title">执行日志</div>
<div id="td-logs">${t.logs.map(logLine).join('') || '<div class="empty">暂无日志</div>'}</div>
<div class="modal-actions" id="td-actions">
${t.status === 'todo' ? `<button class="btn primary" onclick="runTask(${t.id})">🚀 派活执行</button>
<button class="btn" onclick="openTaskModal(${JSON.stringify(t).replace(/"/g, '&quot;')})">编辑</button>
<button class="btn danger" onclick="delTask(${t.id})">删除</button>` : ''}
${t.status === 'running' ? `<button class="btn warn" onclick="cancelTask(${t.id})">⏹ 取消执行</button>` : ''}
${t.status === 'review' ? `<button class="btn success" onclick="reviewTask(${t.id}, 'approve')">✅ 审核通过</button>
<button class="btn warn" onclick="reviewTask(${t.id}, 'reject')">⛔ 打回返工</button>` : ''}
${t.status === 'failed' ? `<button class="btn primary" onclick="runTask(${t.id})">🔄 重试</button>
<button class="btn" onclick="openTaskModal(${JSON.stringify(t).replace(/"/g, '&quot;')})">编辑</button>` : ''}
${['done','cancelled'].includes(t.status) ? `<button class="btn" onclick="reopenTask(${t.id})">↩️ 重新打开</button>` : ''}
</div>`);
}
function logLine(l) {
const icons = {info:'️', success:'✅', warn:'⚠️', error:'❌'};
return `<div class="log-line ${l.level}"><span class="ts">${fmtTime(l.created_at)}</span>${icons[l.level] || ''} ${esc(l.message)}</div>`;
}
async function reviewTask(tid, action) {
if (action === 'reject') {
const reason = prompt('请输入打回原因(将退回重做):');
if (reason === null) return;
if (!reason.trim()) return toast('打回必须填写原因', 'err');
await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action, reason: reason.trim()}});
} else {
await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action}});
}
toast(action === 'approve' ? '✅ 验收通过,任务完成' : '任务已打回,等待返工', 'ok');
closeModal(); refreshProj();
}
async function cancelTask(tid) {
if (!confirm('确认取消该任务?')) return;
await api(`/api/tasks/${tid}/cancel`, {method:'POST'});
toast('已取消', 'ok'); closeModal(); refreshProj();
}
async function reopenTask(tid) {
await api(`/api/tasks/${tid}`, {method:'PUT', body:{status:'todo'}});
toast('任务已重新打开', 'ok'); closeModal(); refreshProj();
}
async function delTask(tid) {
if (!confirm('确认删除该任务及其日志/成本记录?')) return;
await api(`/api/tasks/${tid}`, {method:'DELETE'});
toast('已删除', 'ok'); closeModal(); refreshProj();
}
/* ---------- AI 规划 WBS ---------- */
function openWbsModal() {
const {p} = projCtx;
openModal(`
<h3>🧠 AI 辅助规划(WBS 任务分解)<button class="close" data-close>×</button></h3>
<label>项目目标(默认取项目 objective</label>
<textarea id="wb-goal">${esc(p.objective || p.name || '')}</textarea>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="wb-gen">生成任务分解</button>
</div>
<div id="wb-result"></div>`);
$('#wb-gen').addEventListener('click', async () => {
const goal = $('#wb-goal').value.trim();
if (!goal) return toast('请填写项目目标', 'err');
$('#wb-result').innerHTML = '<div class="empty"><span class="spin"></span>AI 正在拆解任务(约 10-30 秒)…</div>';
try {
const r = await api(`/api/projects/${projCtx.pid}/wbs/generate`, {method:'POST', body:{goal}});
let wbs = r.data;
$('#wb-result').innerHTML = `
<div class="sec-title" style="margin-top:16px">生成 ${wbs.length} 个任务(可编辑后导入)· 花费 ¥${r.cost.toFixed(4)} / ${r.usage} tokens</div>
${wbs.map((t, i) => `
<div class="wbs-row">
<div style="display:flex;align-items:center;gap:8px">
<span class="idx">${i+1}</span>
<input value="${esc(t.title || '')}" style="flex:1;font-weight:600" data-f="title" data-i="${i}">
</div>
<textarea style="margin-top:8px" data-f="desc" data-i="${i}">${esc(t.description || '')}</textarea>
<div style="font-size:12px;color:var(--muted);margin-top:6px">依赖任务:${wbs.map((_, j) => `<label style="margin:0 6px 0 0"><input type="checkbox" data-dep="${i}" value="${j}" ${(t.depends_on||[]).includes(j) ? 'checked' : ''}>#${j+1}</label>`).join('')}</div>
</div>`).join('')}
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="wb-import">✅ 导入为项目任务</button>
</div>`;
$('#wb-import').addEventListener('click', async () => {
const tasks = wbs.map((t, i) => ({
title: $(`[data-f="title"][data-i="${i}"]`).value.trim() || t.title,
description: $(`[data-f="desc"][data-i="${i}"]`).value,
depends_on: $$(`[data-dep="${i}"]:checked`).map(x => parseInt(x.value))
}));
const rr = await api(`/api/projects/${projCtx.pid}/wbs/import`, {method:'POST', body:{tasks, worker_id: projCtx.workers[0]?.id || null}});
toast(`✅ 已导入 ${rr.created} 个任务(含依赖关系)`, 'ok');
closeModal(); refreshProj(); router();
});
} catch (e) { $('#wb-result').innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
});
}
/* ---------- Worker 管理 ---------- */
async function pageWorkers() {
const [ws, ps] = await Promise.all([api('/api/workers'), api('/api/providers')]);
$('#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>
${ws.data.map(w => `
<tr>
<td class="mono">#${w.id}</td>
<td><b>${esc(w.name)}</b><br><span style="color:var(--muted);font-size:12px">${esc(w.description || '')}</span></td>
<td><span class="pill">${esc(w.provider)}</span> <span class="pill">${esc(w.model)}</span></td>
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(w.system_prompt)}">${esc((w.system_prompt || '—').slice(0, 40))}</td>
<td class="mono">${w.temperature} / ${w.max_tokens}</td>
<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><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>'}
</tbody></table>`;
}
async function testWorker(wid) {
toast('正在测试连通性…');
try { const r = await api(`/api/workers/${wid}/test`, {method:'POST'});
toast(`✅ 连通正常:${r.data.latency_ms}ms,回复「${r.data.reply}」,成本 ¥${r.data.cost}`, 'ok'); }
catch (e) { toast('❌ ' + e.message, 'err'); }
}
async function visionTest(wid) {
openModal(`
<h3>🖼️ 视觉能力测试</h3>
<div class="tab-note">多模态智能体:输入图片 URL 与问题,验证图像理解能力。示例:<br>
https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/640px-Cat03.jpg</div>
<label>图片 URL</label><input id="vt-url" placeholder="https://...">
<label>问题</label><textarea id="vt-q" rows="2">请描述这张图片的内容,并给出专业分析。</textarea>
<button class="btn primary" id="vt-go">🚀 开始分析</button>
<div id="vt-out" style="margin-top:10px"></div>`);
$('#vt-go').addEventListener('click', async () => {
const url = $('#vt-url').value.trim();
if (!url) return toast('请填写图片 URL', 'err');
$('#vt-out').innerHTML = '<div class="tab-note"><span class="spin"></span> 视觉分析中(多模态调用约 10-60 秒)…</div>';
try {
const r = await api(`/api/workers/${wid}/vision_test`, {method:'POST', body: {image_url: url, question: $('#vt-q').value}});
$('#vt-out').innerHTML = `<pre style="white-space:pre-wrap;background:#f6f8fa;padding:10px;border-radius:8px;max-height:320px;overflow:auto">${esc(r.data.text)}</pre>
<div class="tab-note">tokens ${r.data.total_tokens} · 成本 ${fmtMoney(r.data.cost)} · 延迟 ${r.data.latency_ms ?? '—'}ms</div>`;
} catch (e) { $('#vt-out').innerHTML = `<div class="err">${esc(e.message)}</div>`; }
});
}
async function delWorker(wid) {
if (!confirm('确认删除该 Worker?(历史任务将解除指派)')) return;
await api(`/api/workers/${wid}`, {method:'DELETE'});
toast('已删除', 'ok'); router();
}
function openWorkerModal(w, providers) {
w = w || {};
const sel = (providerId) => providers.find(p => p.id === providerId);
openModal(`
<h3>${w.id ? '编辑 Worker' : '注册 Worker'}<button class="close" data-close>×</button></h3>
<label>Worker 名称 *</label><input id="wm-name" value="${esc(w.name || '')}" placeholder="例如:文案小助手">
<label>模型供应商</label><select id="wm-provider" onchange="onProviderChange()">
${providers.map(p => `<option value="${p.id}" ${w.provider === p.id ? 'selected' : ''}>${esc(p.name)}${p.has_key ? '' : '(未配 Key'}</option>`).join('')}
</select>
<label>模型</label><input id="wm-model" value="${esc(w.model || '')}" placeholder="如 doubao-seed-evolving / deepseek-chat">
<div class="row">
<div><label>自定义 Base URL(留空用默认)</label><input id="wm-base" value="${esc(w.base_url || '')}" placeholder="${esc(sel(w.provider)?.base_url || '')}"></div>
<div><label>自定义 API Key(留空用全局)</label><input id="wm-key" value="${esc(w.api_key || '')}"></div>
</div>
<label>角色提示词(System Prompt</label><textarea id="wm-prompt" placeholder="你是资深文案专家,擅长小红书种草文案…">${esc(w.system_prompt || '')}</textarea>
<label>角色描述</label><input id="wm-desc" value="${esc(w.description || '')}" placeholder="一句话描述这个虚拟员工的职责">
<div class="row">
<div><label>Temperature</label><input id="wm-temp" type="number" step="0.1" min="0" max="2" value="${w.temperature ?? 0.7}"></div>
<div><label>Max Tokens</label><input id="wm-max" type="number" min="1" value="${w.max_tokens ?? 2000}"></div>
</div>
<div class="row">
<div><label>单任务成本上限(元)</label><input id="wm-tasklimit" type="number" step="0.01" value="${w.task_cost_limit || 0}"></div>
<div><label>月度成本上限(元)</label><input id="wm-monthlimit" type="number" step="0.01" value="${w.monthly_cost_limit || 0}"></div>
</div>
<label>状态</label><select id="wm-status">
<option value="enabled" ${w.status !== 'disabled' ? 'selected' : ''}>启用</option>
<option value="disabled" ${w.status === 'disabled' ? 'selected' : ''}>停用</option>
</select>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
${w.id ? `<button class="btn" onclick="testWorker(${w.id})">测试连通</button>` : ''}
<button class="btn primary" id="wm-save">保存</button>
</div>`);
window.onProviderChange = () => {
const p = providers.find(x => x.id === $('#wm-provider').value);
if (!p) return;
const base = $('#wm-base');
if (!base.value || base.placeholder === base.value) base.value = p.base_url;
base.placeholder = p.base_url;
const model = $('#wm-model');
if (p.models.length && !model.value) model.value = p.models[0];
};
$('#wm-save').addEventListener('click', async () => {
const body = {
name: $('#wm-name').value.trim(), provider: $('#wm-provider').value,
model: $('#wm-model').value.trim(), base_url: $('#wm-base').value.trim(),
api_key: $('#wm-key').value.trim(), system_prompt: $('#wm-prompt').value,
description: $('#wm-desc').value.trim(), temperature: parseFloat($('#wm-temp').value || 0.7),
max_tokens: parseInt($('#wm-max').value || 2000),
task_cost_limit: parseFloat($('#wm-tasklimit').value || 0),
monthly_cost_limit: parseFloat($('#wm-monthlimit').value || 0),
status: $('#wm-status').value
};
if (!body.name || !body.provider || !body.model) return toast('名称/供应商/模型必填', 'err');
try {
await api(w.id ? `/api/workers/${w.id}` : '/api/workers', {method: w.id ? 'PUT' : 'POST', body});
toast('已保存', 'ok'); closeModal(); router();
} catch (e) { toast(e.message, 'err'); }
});
}
/* ---------- 成本报表 ---------- */
async function pageReports() {
const group = location.hash.includes('group=') ? location.hash.split('group=')[1] : 'project';
const r = (await api('/api/reports/cost?group=' + group)).data;
const s = (await api('/api/stats')).data;
const head = {project:['项目','调用次数','Tokens','成本'], worker:['Worker','模型','调用次数','Tokens','成本'], model:['供应商/模型','调用次数','Tokens','成本']}[group];
$('#main').innerHTML = `
<h1 class="page-title">成本报表<small>按项目 × 任务 × Worker × 模型 多维核算</small></h1>
<div class="toolbar">
<a class="btn ${group==='project'?'primary':''}" href="#/reports/group=project">按项目</a>
<a class="btn ${group==='worker'?'primary':''}" href="#/reports/group=worker">按 Worker</a>
<a class="btn ${group==='model'?'primary':''}" href="#/reports/group=model">按模型</a>
<span style="margin-left:auto" class="pager-tip">累计成本 ${fmtMoney(s.total_cost)} · ${(s.total_tokens/1e6).toFixed(2)}M tokens</span>
</div>
<div class="card"><table><thead><tr>${head.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>
${r.map(x => {
const cost = x.cost || 0, tokens = x.tokens || 0;
const maxCost = Math.max(...r.map(v => v.cost || 0), 0.001);
return `<tr>${group === 'project' ? `<td><b>${esc(x.project_name || '—')}</b></td>` : group === 'worker' ?
`<td><b>${esc(x.worker_name || '—')}</b></td><td><span class="pill">${esc(x.provider || '')}</span> <span class="pill">${esc(x.model || '')}</span></td>` :
`<td><span class="pill">${esc(x.provider || '')}</span> <span class="pill">${esc(x.model || '')}</span></td>`}
<td>${x.runs}</td><td class="mono">${(tokens/1e6).toFixed(3)}M</td>
<td class="mono">${fmtMoney(cost)}<div class="bar"><div style="width:${cost/maxCost*100}%"></div></div></td></tr>`;
}).join('') || '<tr><td colspan="5" class="empty">暂无成本数据,先派几个任务吧</td></tr>'}
</tbody></table></div>`;
}
/* ---------- 运行日志 ---------- */
async function pageLogs() {
const logs = (await api('/api/logs?limit=200')).data;
$('#main').innerHTML = `
<h1 class="page-title">运行日志<small>全链路可追溯,每个结论都能溯源</small></h1>
<div class="card">${logs.map(l => `<div class="log-line ${l.level}"><span class="ts">${fmtTime(l.created_at)}</span>
[任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}</div>`).join('') || '<div class="empty">暂无日志</div>'}</div>`;
}
/* ---------- 告警中心 ---------- */
async function pageAlerts() {
const alerts = (await api('/api/alerts?limit=200')).data;
$('#main').innerHTML = `
<h1 class="page-title">告警中心<small>任务失败 · 预算超标 · Worker 异常 · 成本拦截</small></h1>
<div class="toolbar"><button class="btn" onclick="readAllAlerts()">✔ 全部标记已读</button></div>
<div class="card">${alerts.map(a => `
<div class="alert-row ${a.read ? '' : 'unread'} ${a.level === 'critical' ? 'critical' : ''}" onclick="readAlert(${a.id})">
<div class="t">${ALERT_LEVEL[a.level] || '️'} ${esc(a.title)} ${a.read ? '' : '<span class="badge st-review">未读</span>'}</div>
<div class="d">${esc(a.detail)}</div>
<div class="tm">${fmtTime(a.created_at)} · ${esc(a.type)}</div>
</div>`).join('') || '<div class="empty">暂无告警 🎉</div>'}</div>`;
refreshAlertBadge();
}
async function readAlert(id) {
await api(`/api/alerts/${id}/read`, {method:'POST'});
pageAlerts();
}
async function readAllAlerts() {
await api('/api/alerts/read_all', {method:'POST'});
toast('已全部标记为已读', 'ok'); pageAlerts();
}
/* ---------- 开放 API ---------- */
async function pageApiTokens() {
const tokens = (await api('/api/tokens')).data;
$('#main').innerHTML = `
<h1 class="page-title">开放 API<small>外部系统通过 Bearer Token 调平台能力</small></h1>
<div class="toolbar">
<button class="btn primary" onclick="createToken()"> 生成 API Token</button>
</div>
<div class="grid g2">
<div class="card">
<div class="sec-title" style="margin-top:0">已生成的 Token</div>
<table><thead><tr><th>名称</th><th>创建时间</th><th>最近使用</th><th>操作</th></tr></thead><tbody>
${tokens.map(t => `<tr><td><b>${esc(t.name)}</b></td><td class="mono">${fmtTime(t.created_at)}</td>
<td class="mono">${t.last_used_at ? fmtTime(t.last_used_at) : '从未使用'}</td>
<td><button class="btn sm danger" onclick="delToken(${t.id})">删除</button></td></tr>`).join('') || '<tr><td colspan="4" class="empty">暂无 Token</td></tr>'}
</tbody></table>
</div>
<div class="card">
<div class="sec-title" style="margin-top:0">调用示例</div>
<div style="font-size:13px;line-height:2">
<div><code>POST /api/tasks</code> 创建任务</div>
<pre class="output-box" style="font-size:12px">curl -X POST http://&lt;host&gt;:16071/api/tasks \\
-H "Authorization: Bearer &lt;TOKEN&gt;" \\
-H "Content-Type: application/json" \\
-d '{"project_id":1,"title":"生成周报","description":"…"}'
# 响应: {"ok":true,"id":42}</pre>
<div><code>POST /api/tasks/42/run</code> 派活执行</div>
<pre class="output-box" style="font-size:12px">curl -X POST http://&lt;host&gt;:16071/api/tasks/42/run \\
-H "Authorization: Bearer &lt;TOKEN&gt;"</pre>
<div><code>GET /api/tasks/42</code> 查结果(status=review 时人工审核)</div>
<div><code>POST /api/tasks/42/review</code> 审核</div>
<pre class="output-box" style="font-size:12px">curl -X POST http://&lt;host&gt;:16071/api/tasks/42/review \\
-H "Authorization: Bearer &lt;TOKEN&gt;" -H "Content-Type: application/json" \\
-d '{"action":"approve"}' # 或 {"action":"reject","reason":"…"}</pre>
</div>
</div>
</div>`;
}
async function createToken() {
const name = prompt('Token 名称(用途说明):') || 'default';
const r = await api('/api/tokens', {method:'POST', body:{name}});
alert('✅ 生成成功(只显示一次,请立即复制保存):\n\n' + r.token);
pageApiTokens();
}
async function delToken(id) {
if (!confirm('删除后使用该 Token 的调用将失效,确认?')) return;
await api(`/api/tokens/${id}`, {method:'DELETE'});
toast('已删除', 'ok'); pageApiTokens();
}
/* ---------- 通知设置 ---------- */
async function pageSettings() {
const [chans, evs, st] = await Promise.all([api('/api/channels'), api('/api/events'), api('/api/settings')]);
$('#main').innerHTML = `
<h1 class="page-title">通知与设置<small>飞书 / 企微群机器人 · 邮件 · 预算告警阈值</small></h1>
<div class="toolbar"><button class="btn primary" onclick="openChannelModal(null, ${JSON.stringify(evs.data)})"> 添加通知渠道</button></div>
<div class="grid g2">
<div class="card">
<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>
${chans.data.map(c => `<tr>
<td><b>${esc(c.name)}</b></td>
<td><span class="pill">${c.type === 'feishu' ? '飞书' : c.type === 'wecom' ? '企微' : '邮件'}</span></td>
<td>${(c.events || []).map(e => `<span class="pill">${(evs.data.find(x => x.id === e) || {}).name || e}</span>`).join('') || '—'}</td>
<td><span class="badge ${c.enabled ? 'st-done' : 'st-cancelled'}">${c.enabled ? '启用' : '停用'}</span></td>
<td><button class="btn sm" onclick="testChannel(${c.id})">测试</button>
<button class="btn sm" onclick="openChannelModal(${JSON.stringify(c).replace(/"/g,'&quot;')}, ${JSON.stringify(evs.data)})">编辑</button>
<button class="btn sm danger" onclick="delChannel(${c.id})">删除</button></td>
</tr>`).join('') || '<tr><td colspan="5" class="empty">暂无渠道,添加飞书/企微机器人后任务事件自动推送</td></tr>'}
</tbody></table>
<div class="tab-note" style="margin-top:10px">💡 飞书群机器人:群设置 → 群机器人 → 添加自定义机器人,复制 Webhook 地址(以 https://open.feishu.cn/open-apis/bot/v2/hook/ 开头)。企微同理(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…)。</div>
</div>
<div class="card">
<div class="sec-title" style="margin-top:0">预算告警</div>
<label>项目预算使用率告警阈值(0~1,达到即告警)</label>
<div class="row"><input id="st-ratio" type="number" step="0.05" min="0.1" max="1" value="${st.data.budget_alert_ratio}">
<button class="btn primary" onclick="saveSettings()">保存</button></div>
<div class="tab-note" style="margin-top:12px">告警会写入告警中心并推送到订阅了「预算告警」事件的渠道。</div>
<div class="sec-title">邮件通知</div>
<div class="tab-note">${st.data.email_configured ? '✅ 已配置 SMTPconfig.py EMAIL' : '⚠️ 未配置 SMTPconfig.py 的 EMAIL 填写 host/user/password 后重启生效)'}</div>
</div>
</div>`;
}
async function saveSettings() {
await api('/api/settings', {method:'PUT', body:{budget_alert_ratio: parseFloat($('#st-ratio').value)}});
toast('已保存', 'ok');
}
async function testChannel(id) {
toast('正在发送测试消息…');
try { const r = await api(`/api/channels/${id}/test`, {method:'POST'});
r.ok ? toast('✅ 测试消息已送达', 'ok') : toast('❌ ' + r.msg, 'err'); }
catch (e) { toast('❌ ' + e.message, 'err'); }
}
async function delChannel(id) {
if (!confirm('确认删除该渠道?')) return;
await api(`/api/channels/${id}`, {method:'DELETE'});
toast('已删除', 'ok'); pageSettings();
}
function openChannelModal(c, events) {
c = c || {};
openModal(`
<h3>${c.id ? '编辑通知渠道' : '添加通知渠道'}<button class="close" data-close>×</button></h3>
<label>渠道名称 *</label><input id="cm-name" value="${esc(c.name || '')}" placeholder="例如:项目群飞书机器人">
<label>类型</label><select id="cm-type">
<option value="feishu" ${c.type === 'feishu' ? 'selected' : ''}>飞书群机器人</option>
<option value="wecom" ${c.type === 'wecom' ? 'selected' : ''}>企业微信群机器人</option>
<option value="email" ${c.type === 'email' ? 'selected' : ''}>邮件</option>
</select>
<div id="cm-webhook-wrap"><label>Webhook 地址</label><input id="cm-webhook" value="${esc(c.webhook || '')}"></div>
<div id="cm-email-wrap" style="display:none"><label>收件邮箱</label><input id="cm-email" value="${esc(c.email || '')}"></div>
<label>订阅事件</label>
<div class="check-group">
${events.map(e => `<label><input type="checkbox" value="${e.id}" ${(c.events || []).includes(e.id) ? 'checked' : ''}>${esc(e.name)}</label>`).join('')}
</div>
<label>启用</label><div><label class="switch"><input type="checkbox" id="cm-enabled" ${c.enabled !== 0 ? 'checked' : ''}> 接收推送</label></div>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="cm-save">保存</button>
</div>`);
const toggle = () => {
const isMail = $('#cm-type').value === 'email';
$('#cm-webhook-wrap').style.display = isMail ? 'none' : '';
$('#cm-email-wrap').style.display = isMail ? '' : 'none';
};
$('#cm-type').addEventListener('change', toggle);
toggle();
$('#cm-save').addEventListener('click', async () => {
const body = {
name: $('#cm-name').value.trim(), type: $('#cm-type').value,
webhook: $('#cm-webhook').value.trim(), email: $('#cm-email').value.trim(),
events: $$('#modal-box .check-group input:checked').map(x => x.value),
enabled: $('#cm-enabled').checked ? 1 : 0
};
if (!body.name || (body.type !== 'email' && !body.webhook) || (body.type === 'email' && !body.email))
return toast('请填写完整', 'err');
await api(c.id ? `/api/channels/${c.id}` : '/api/channels', {method: c.id ? 'PUT' : 'POST', body});
toast('已保存', 'ok'); closeModal(); pageSettings();
});
}
/* ---------- V2 · 多 Agent 协作 ---------- */
let agentsCtx = {modes: [], workers: [], runs: []};
async function pageAgents() {
const [modes, workers, runs] = await Promise.all([
api('/api/agents/modes'), api('/api/workers'), api('/api/agents/runs')
]);
agentsCtx = {modes: modes.data, workers: workers.data, runs: runs.data};
renderAgents();
}
function renderAgents() {
const {modes, workers, runs} = agentsCtx;
const wOpts = workers.map(w =>
`<option value="${w.id}">${esc(w.name)}${esc(w.model)}</option>`).join('');
$('#main').innerHTML = `
<h1 class="page-title">多 Agent 协作<small>主管 / 评审 / 辩论 三种团队协作模式</small></h1>
<div class="grid g3">${modes.map(m => `
<div class="card mode-card" data-mode="${m.id}" style="cursor:pointer">
<div style="font-size:26px">${m.icon}</div>
<div class="sec-title" style="margin:6px 0 4px">${esc(m.name)}</div>
<div style="font-size:12px;color:var(--muted);min-height:34px">${esc(m.desc)}</div>
<div style="margin-top:8px"><span class="tag">${m.params.length} 项参数</span></div>
</div>`).join('')}</div>
<div class="card" style="margin-top:14px">
<div class="sec-title" style="margin-top:0">⚡ 发起协作运行</div>
<div class="form-grid">
<label>协作模式<select id="ag-mode">${modes.map(m => `<option value="${m.id}">${m.icon} ${esc(m.name)}</option>`).join('')}</select></label>
<label>标题<input id="ag-title" placeholder="选填,默认自动生成"></label>
<label>任务主题 / 议题<textarea id="ag-topic" rows="2" placeholder="例如:为新产品策划上市方案…"></textarea></label>
<label>参与 Agent(可多选)<select id="ag-workers" multiple size="3">${wOpts}</select></label>
<label>最大返工轮次(评审)<input id="ag-rounds" type="number" value="2"></label>
<label>通过分数线(评审)<input id="ag-pass" type="number" value="70"></label>
<label>质询轮次(辩论)<input id="ag-debate-rounds" type="number" value="1"></label>
<label>辩论立场(逗号分隔)<input id="ag-stances" placeholder="正方,反方,裁判"></label>
<label style="grid-column:span 2">背景上下文<textarea id="ag-context" rows="2" placeholder="选填:背景资料、约束条件、参考知识"></textarea></label>
</div>
<div style="margin-top:10px">
<button class="btn primary" id="ag-run">🚀 发起运行</button>
<span class="tab-note">💡 评审模式第 1 个 Agent 为执行者,第 2 个(若有)为评审;辩论模式最后一个为裁判。</span>
</div>
</div>
<div class="card" style="margin-top:14px">
<div class="sec-title" style="margin-top:0">📋 运行记录 <button class="btn mini" style="float:right" onclick="location.hash='#/agents'">刷新</button></div>
<table><thead><tr><th>ID</th><th>模式</th><th>标题</th><th>状态</th><th>过程摘要</th><th>成本</th><th>时间</th><th>操作</th></tr></thead><tbody>
${runs.map(r => `
<tr>
<td>#${r.id}</td><td>${esc(r.mode)}</td>
<td title="${esc(r.topic)}">${esc(r.title || r.topic.slice(0, 30))}</td>
<td><span class="st st-${r.status === 'done' ? 'done' : r.status === 'failed' ? 'failed' : 'running'}">${({done:'✅ 完成',running:'⏳ 执行中',failed:'❌ 失败',cancelled:'已取消'})[r.status] || r.status}</span></td>
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.summary || '—')}</td>
<td>${fmtMoney(r.cost)}</td>
<td>${fmtTime(r.created_at)}</td>
<td><button class="btn mini" onclick="viewAgentRun(${r.id})">查看</button></td>
</tr>`).join('') || '<tr><td colspan="8" class="empty-td">暂无运行记录</td></tr>'}
</tbody></table>
</div>`;
$('#ag-mode').addEventListener('change', e => {
const m = modes.find(x => x.id === e.target.value);
toast('已选择:' + m.name, '');
});
$('#ag-run').addEventListener('click', async () => {
const mode = $('#ag-mode').value;
const topic = $('#ag-topic').value.trim();
if (!topic) return toast('请填写任务主题', 'err');
const workerIds = [...$('#ag-workers').selectedOptions].map(o => Number(o.value));
if (!workerIds.length) return toast('请至少选择一个 Agent', 'err');
const params = {workers: workerIds, context: $('#ag-context').value};
if (mode === 'review') { params.rounds = Number($('#ag-rounds').value) || 2; params.pass_score = Number($('#ag-pass').value) || 70; }
if (mode === 'debate') { params.rounds = Number($('#ag-debate-rounds').value) || 1; params.stances = $('#ag-stances').value.split(',').map(s => s.trim()).filter(Boolean); }
try {
await api('/api/agents/runs', {method: 'POST', body: {mode, title: $('#ag-title').value.trim(), topic, params}});
toast('协作已启动 🚀', 'ok'); pageAgents();
} catch (e) { toast(e.message, 'err'); }
});
}
async function viewAgentRun(id) {
const d = (await api(`/api/agents/runs/${id}`)).data;
openModal(`
<h3>🤝 协作运行 #${d.id} · ${esc(d.mode)}</h3>
<div class="kv">
<dt>状态</dt><dd><span class="st st-${d.status === 'done' ? 'done' : 'failed' ? 'failed' : 'running'}">${d.status}</span></dd>
<dt>主题</dt><dd>${esc(d.topic)}</dd>
<dt>摘要</dt><dd>${esc(d.summary || '—')}</dd>
<dt>成本 / tokens</dt><dd>${fmtMoney(d.cost)} / ${d.total_tokens}</dd>
</div>
<div class="sec-title">执行过程</div>
${(d.steps || []).map(s => `
<div class="agent-step"><b>[${esc(s.role)} / ${esc(s.stage)}]</b> ${esc(s.worker_name || '')}
<pre style="white-space:pre-wrap;max-height:160px;overflow:auto">${esc(s.content)}</pre></div>`).join('') || '<div class="tab-note">暂无步骤</div>'}
<div class="sec-title">最终产出</div>
<pre style="white-space:pre-wrap;max-height:300px;overflow:auto;background:#f6f8fa;padding:10px;border-radius:8px">${esc(d.result || '(执行中…)')}</pre>
<button class="btn" data-close>关闭</button>`);
}
/* ---------- V2 · 自动评估 ---------- */
async function pageEval() {
const [datasets, runs, board, workers] = await Promise.all([
api('/api/eval/datasets'), api('/api/eval/runs'), api('/api/eval/leaderboard'), api('/api/workers')
]);
const wOpts = workers.data.map(w =>
`<option value="${w.id}">${esc(w.name)}${esc(w.model)}</option>`).join('');
$('#main').innerHTML = `
<h1 class="page-title">自动评估体系<small>eval 数据集沉淀 · LLM 评委打分 · Worker 排行榜</small></h1>
<div class="card">
<div class="sec-title" style="margin-top:0">🏆 Worker 评估排行榜</div>
<table><thead><tr><th>排名</th><th>Worker</th><th>模型</th><th>评估次数</th><th>平均分</th><th>累计成本</th></tr></thead><tbody>
${board.data.map((b, i) => `
<tr><td>${i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : '#' + (i+1)}</td>
<td>${esc(b.worker_name)}</td><td>${esc(b.model)}</td><td>${b.runs}</td>
<td><b style="color:var(--accent2)">${b.avg_score}</b>/100</td><td>${fmtMoney(b.cost)}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-td">暂无评估数据,先跑一次评估吧</td></tr>'}
</tbody></table>
</div>
<div class="grid g2" style="margin-top:14px">
<div class="card">
<div class="sec-title" style="margin-top:0">📚 数据集</div>
<button class="btn mini" onclick="evalCreateDataset()"> 新建数据集</button>
<table style="margin-top:8px"><thead><tr><th>名称</th><th>用例</th><th>最佳分</th><th>操作</th></tr></thead><tbody>
${datasets.data.map(ds => `
<tr><td>${esc(ds.name)}${ds.is_builtin ? ' <span class="tag">内置</span>' : ''}</td>
<td>${ds.case_count}</td><td>${ds.best_score != null ? ds.best_score : '—'}</td>
<td><button class="btn mini" onclick="evalOpenDataset(${ds.id})">管理用例</button>
<button class="btn mini" onclick="evalRunDataset(${ds.id})">🚀 评估</button></td></tr>`).join('') || '<tr><td colspan="4" class="empty-td">暂无数据集</td></tr>'}
</tbody></table>
</div>
<div class="card">
<div class="sec-title" style="margin-top:0">🔄 评估运行记录</div>
<button class="btn mini" onclick="evalSink()">📥 沉淀已验收任务产出</button>
<table style="margin-top:8px"><thead><tr><th>ID</th><th>数据集</th><th>Worker</th><th>状态</th><th>得分</th><th>进度</th><th>操作</th></tr></thead><tbody>
${runs.data.map(r => `
<tr><td>#${r.id}</td><td>${esc(r.dataset_name)}</td><td>${esc(r.worker_name)}</td>
<td><span class="st st-${r.status === 'done' ? 'done' : r.status === 'failed' ? 'failed' : 'running'}">${({done:'✅ 完成',running:'⏳ 进行中',failed:'❌ 失败'})[r.status] || r.status}</span></td>
<td><b>${r.status === 'done' ? r.score : '—'}</b></td>
<td>${r.cases_done}/${r.cases_total}</td>
<td><button class="btn mini" onclick="viewEvalRun(${r.id})">查看</button></td></tr>`).join('') || '<tr><td colspan="7" class="empty-td">暂无运行记录</td></tr>'}
</tbody></table>
<div class="tab-note" style="margin-top:8px">💡 评估 = Worker 逐条作答 → LLM 评委按 rubric 打分。沉淀功能会把已验收通过的任务产出自动转为评测用例。</div>
</div>
</div>
<div class="card" style="margin-top:14px">
<div class="sec-title" style="margin-top:0">⚡ 发起评估</div>
<label>数据集<select id="ev-ds">${datasets.data.map(ds => `<option value="${ds.id}">${esc(ds.name)}${ds.case_count} 用例)</option>`).join('')}</select></label>
<label>Worker<select id="ev-w">${wOpts}</select></label>
<button class="btn primary" id="ev-run">🚀 开始评估</button>
</div>`;
$('#ev-run').addEventListener('click', async () => {
try {
await api('/api/eval/runs', {method: 'POST', body: {dataset_id: Number($('#ev-ds').value), worker_id: Number($('#ev-w').value)}});
toast('评估已启动', 'ok'); pageEval();
} catch (e) { toast(e.message, 'err'); }
});
}
function evalCreateDataset() {
openModal(`
<h3>新建评估数据集</h3>
<label>名称<input id="ed-name"></label>
<label>描述<textarea id="ed-desc" rows="2"></textarea></label>
<label>评分标准 rubric<textarea id="ed-rubric" rows="3" placeholder="例如:准确性 40%,完整性 30%,清晰度 30%"></textarea></label>
<button class="btn primary" id="ed-save">创建</button>`);
$('#ed-save').addEventListener('click', async () => {
try {
await api('/api/eval/datasets', {method: 'POST', body: {name: $('#ed-name').value.trim(), description: $('#ed-desc').value, rubric: $('#ed-rubric').value}});
toast('已创建', 'ok'); closeModal(); pageEval();
} catch (e) { toast(e.message, 'err'); }
});
}
async function evalOpenDataset(id) {
const ds = (await api(`/api/eval/datasets/${id}`)).data;
openModal(`
<h3>📚 ${esc(ds.name)} <span class="tag">${ds.case_count || ds.cases.length} 用例</span></h3>
<div class="tab-note">${esc(ds.description)}</div>
<div class="sec-title">评分标准</div>
<pre style="white-space:pre-wrap;background:#f6f8fa;padding:8px;border-radius:6px">${esc(ds.rubric || '(未设置,使用默认)')}</pre>
<div class="sec-title">用例列表</div>
${(ds.cases || []).map(c => `
<div style="border:1px solid var(--line);border-radius:8px;padding:8px;margin-bottom:6px">
<div><b>Q${c.id}:</b> ${esc(c.input.slice(0, 120))}</div>
<div style="color:var(--muted);font-size:12px">期望:${esc((c.expected || '—').slice(0, 120))}</div>
<button class="btn mini" style="margin-top:4px" onclick="evalDelCase(${c.id})">删除</button>
</div>`).join('') || '<div class="tab-note">暂无用例</div>'}
<div class="sec-title">添加用例</div>
<label>输入<textarea id="ec-input" rows="2"></textarea></label>
<label>期望输出<textarea id="ec-expected" rows="2"></textarea></label>
<button class="btn primary" id="ec-add"> 添加用例</button>
<button class="btn" data-close style="float:right">关闭</button>`);
$('#ec-add').addEventListener('click', async () => {
try {
await api(`/api/eval/datasets/${id}/cases`, {method: 'POST', body: {input: $('#ec-input').value, expected: $('#ec-expected').value}});
toast('已添加', 'ok'); closeModal(); evalOpenDataset(id);
} catch (e) { toast(e.message, 'err'); }
});
}
async function evalDelCase(cid) {
if (!confirm('确定删除该用例?')) return;
await api(`/api/eval/cases/${cid}`, {method: 'DELETE'});
toast('已删除', 'ok'); closeModal(); pageEval();
}
async function evalRunDataset(id) {
openModal(`<h3>🚀 评估数据集 #${id}</h3>
<div class="tab-note">将用选定 Worker 逐条作答并由 LLM 评委打分,每条用例约需 1~2 分钟。</div>
<button class="btn primary" id="er-go" onclick="pageEval()">知道了</button>`);
}
async function evalSink() {
try {
const r = await api('/api/eval/datasets/0/sink', {method: 'POST'});
toast(`已沉淀 ${r.sunk} 条任务产出`, 'ok'); pageEval();
} catch (e) { toast(e.message, 'err'); }
}
async function viewEvalRun(id) {
const d = (await api(`/api/eval/runs/${id}`)).data;
openModal(`
<h3>🎯 评估运行 #${d.id} · ${esc(d.dataset_name)}</h3>
<div class="kv">
<dt>Worker</dt><dd>${esc(d.worker_name)}</dd>
<dt>平均分</dt><dd><b style="color:var(--accent2)">${d.status === 'done' ? d.score : '…'}</b>/100</dd>
<dt>进度</dt><dd>${d.cases_done}/${d.cases_total}</dd>
<dt>成本</dt><dd>${fmtMoney(d.cost)}</dd>
</div>
<div class="sec-title">逐条结果</div>
${(d.results || []).map(r => `
<div style="border:1px solid var(--line);border-radius:8px;padding:8px;margin-bottom:6px">
<div><b>用例 #${r.case_id}</b> 得分 <b style="color:${r.score >= 80 ? 'var(--accent2)' : r.score >= 60 ? 'var(--accent)' : '#e5484d'}">${r.score}</b> · ${r.latency_ms}ms · ${fmtMoney(r.cost)}</div>
<div style="font-size:12px;color:var(--muted)">输入:${esc(r.input || '')}</div>
<details><summary>评委意见</summary><pre style="white-space:pre-wrap">${esc(r.judgment)}</pre>
<summary>执行输出</summary><pre style="white-space:pre-wrap;max-height:160px;overflow:auto">${esc(r.output)}</pre></details>
</div>`).join('') || '<div class="tab-note">暂无结果</div>'}
<button class="btn" data-close>关闭</button>`);
}
/* ---------- V2 · 模板市场 ---------- */
async function pageTemplates() {
const [tpls, projects, workers] = await Promise.all([
api('/api/templates'), api('/api/projects'), api('/api/workers')
]);
const typeMap = {task: '📝 任务模板', project: '📁 项目模板', team: '👥 团队模板'};
const projOpts = projects.data.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
const wOpts = workers.data.map(w => `<option value="${w.id}">${esc(w.name)}</option>`).join('');
$('#main').innerHTML = `
<h1 class="page-title">模板市场<small>任务 / 项目 / Agent 团队 一键复用</small></h1>
<div style="display:flex;gap:8px;margin-bottom:10px">
<button class="btn primary" onclick="tplCreate()"> 新建模板</button>
<button class="btn" onclick="tplRestore()">♻️ 恢复内置模板</button>
</div>
${['task', 'project', 'team'].map(t => `
<div class="sec-title">${typeMap[t]}</div>
<div class="grid g3">${tpls.data.filter(x => x.type === t).map(x => `
<div class="card" style="display:flex;flex-direction:column">
<div style="font-weight:600">${esc(x.name)} ${x.is_builtin ? '<span class="tag">内置</span>' : ''}</div>
<div style="font-size:12px;color:var(--muted);flex:1;min-height:40px">${esc(x.description)}</div>
<div style="font-size:12px;color:var(--muted)">使用 ${x.usage_count}${x.tags.map(tg => ` · <span class="tag">${esc(tg)}</span>`).join('')}</div>
<div style="margin-top:8px"><button class="btn mini primary" onclick="tplApply(${x.id}, '${t}')">使用模板</button>
${x.is_builtin ? '' : `<button class="btn mini" onclick="tplDelete(${x.id})">删除</button>`}</div>
</div>`).join('') || '<div class="tab-note">暂无模板</div>'}</div>`).join('')}
`;
window._tplProjOpts = projOpts; window._tplWorkerOpts = wOpts;
}
function tplCreate() {
openModal(`
<h3>新建模板</h3>
<label>类型<select id="tc-type"><option value="task">任务模板</option><option value="project">项目模板</option><option value="team">团队模板</option></select></label>
<label>名称<input id="tc-name"></label>
<label>描述<textarea id="tc-desc" rows="2"></textarea></label>
<label>内容(JSON<textarea id="tc-content" rows="8" placeholder='{"title":"任务:{target}","description":"...","priority":"medium","review_required":true}'></textarea></label>
<label>标签(逗号分隔)<input id="tc-tags"></label>
<button class="btn primary" id="tc-save">保存</button>`);
$('#tc-save').addEventListener('click', async () => {
let content;
try { content = JSON.parse($('#tc-content').value); } catch (e) { return toast('JSON 格式错误', 'err'); }
try {
await api('/api/templates', {method: 'POST', body: {
type: $('#tc-type').value, name: $('#tc-name').value.trim(),
description: $('#tc-desc').value, content,
tags: $('#tc-tags').value.split(',').map(s => s.trim()).filter(Boolean)
}});
toast('已创建', 'ok'); closeModal(); pageTemplates();
} catch (e) { toast(e.message, 'err'); }
});
}
async function tplApply(id, type) {
const t = (await api(`/api/templates/${id}`)).data;
let extra = '';
if (type === 'task') extra = `<label>目标项目<select id="ta-project">${window._tplProjOpts}</select></label>
<label>执行 Worker<select id="ta-worker"><option value="">自动路由</option>${window._tplWorkerOpts}</select></label>`;
if (type === 'team') extra = `<label>供应商<select id="ta-provider"><option value="doubao">火山方舟</option><option value="deepseek">DeepSeek</option><option value="qwen">通义千问</option><option value="openai">OpenAI</option><option value="vllm">本地 vLLM</option></select></label>
<label>模型<input id="ta-model" value="doubao-seed-evolving"></label>`;
const placeholders = Object.keys(t.content || {}).filter(k => typeof t.content[k] === 'string' && t.content[k].includes('{')).map(k => `<label>${esc(k)} 占位值<input id="ta-var-${k}"></label>`).join('');
openModal(`
<h3>使用模板:${esc(t.name)}</h3>
<div class="tab-note">${esc(t.description)}</div>
${extra}${placeholders}
<button class="btn primary" id="ta-go">应用模板</button>`);
$('#ta-go').addEventListener('click', async () => {
const variables = {};
Object.keys(t.content || {}).filter(k => typeof t.content[k] === 'string' && t.content[k].includes('{')).forEach(k => variables[k] = $(`#ta-var-${k}`)?.value || '');
const body = {variables, worker_id: $('#ta-worker')?.value ? Number($('#ta-worker').value) : null,
project_id: $('#ta-project')?.value ? Number($('#ta-project').value) : null,
provider: $('#ta-provider')?.value, model: $('#ta-model')?.value};
try {
const r = await api(`/api/templates/${id}/apply`, {method: 'POST', body});
toast(r.kind === 'project' ? `已创建项目 #${r.id}${r.task_ids.length} 个任务)` : r.kind === 'team' ? `已创建 ${r.ids.length} 个 Worker` : `已创建任务 #${r.id}`, 'ok');
closeModal();
if (r.kind === 'project') location.hash = `#/project/${r.id}/kanban`;
else pageTemplates();
} catch (e) { toast(e.message, 'err'); }
});
}
async function tplDelete(id) {
if (!confirm('确定删除该模板?')) return;
await api(`/api/templates/${id}`, {method: 'DELETE'});
toast('已删除', 'ok'); pageTemplates();
}
async function tplRestore() {
await api('/api/templates/builtin/restore', {method: 'POST'});
toast('内置模板已就绪', 'ok'); pageTemplates();
}
/* ---------- V2 · 企业版 ---------- */
let entTab = 'users';
async function pageEnterprise() {
const [users, audit, sso, comp] = await Promise.all([
api('/api/enterprise/users'), api('/api/enterprise/audit?limit=100'),
api('/api/enterprise/sso'), api('/api/enterprise/compliance')
]);
$('#main').innerHTML = `
<h1 class="page-title">企业版<small>私有化 · SSO · 审计 · 合规</small></h1>
<div class="tabs">
<a class="${entTab === 'users' ? 'active' : ''}" onclick="entSwitch('users')">👥 用户与权限</a>
<a class="${entTab === 'sso' ? 'active' : ''}" onclick="entSwitch('sso')">🔐 SSO 单点登录</a>
<a class="${entTab === 'audit' ? 'active' : ''}" onclick="entSwitch('audit')">📜 审计日志</a>
<a class="${entTab === 'compliance' ? 'active' : ''}" onclick="entSwitch('compliance')">⚖️ 合规中心</a>
</div>
<div id="ent-body"></div>`;
window._entData = {users: users.data, audit: audit.data, sso: sso.data, comp: comp.data};
entRender();
}
function entSwitch(tab) { entTab = tab; entRender(); }
function entRender() {
const d = window._entData || {};
const box = $('#ent-body');
if (entTab === 'users') {
box.innerHTML = `
<div class="card"><div class="sec-title" style="margin-top:0">👥 用户列表(RBAC</div>
<button class="btn mini" onclick="entAddUser()"> 新建用户</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>
<td>${({admin:'👑 管理员',member:'成员',auditor:'审计员'})[u.role] || u.role}</td>
<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('')}
</tbody></table>
<div class="tab-note">💡 角色说明:管理员=全部权限;成员=看板/任务操作;审计员=只读审计。V1 口令 admin123 兼容登录为 admin。</div>
</div>`;
} else if (entTab === 'sso') {
const s = d.sso || {};
box.innerHTML = `
<div class="grid g2">
<div class="card">
<div class="sec-title" style="margin-top:0">🔐 OIDC(推荐:飞书/钉钉/企业微信/Okta</div>
<label><input type="checkbox" id="so-oidc-on" ${s.oidc_enabled === '1' ? 'checked' : ''}> 启用 OIDC 登录</label>
<label>名称<input id="so-oidc-name" value="${esc(s.oidc_name || '')}" placeholder="如:飞书企业版"></label>
<label>Discovery URL<input id="so-oidc-disc" value="${esc(s.oidc_discovery_url || '')}" placeholder="https://xxx/.well-known/openid-configuration"></label>
<label>Client ID<input id="so-oidc-cid" value="${esc(s.oidc_client_id || '')}"></label>
<label>Client Secret<input id="so-oidc-secret" value="${esc(s.oidc_client_secret || '')}" placeholder="留空保持不变"></label>
<label>回调地址<input id="so-oidc-redirect" value="${esc(s.oidc_redirect_uri || '')}" placeholder="http://<host>:16071/api/enterprise/sso/oidc/callback"></label>
<label>管理员组(groups 匹配)<input id="so-oidc-admingroup" value="${esc(s.oidc_admin_group || '')}"></label>
<label><input type="checkbox" id="so-oidc-auto" ${s.sso_auto_provision === '1' ? 'checked' : ''}> 自动开通新用户</label>
<button class="btn primary" id="so-save">保存 SSO 配置</button>
</div>
<div class="card">
<div class="sec-title" style="margin-top:0">🔑 LDAP / AD</div>
<label><input type="checkbox" id="so-ldap-on" ${s.ldap_enabled === '1' ? 'checked' : ''}> 启用 LDAP 登录</label>
<label>LDAP URL<input id="so-ldap-url" value="${esc(s.ldap_url || '')}" placeholder="ldap://host:389"></label>
<label>Base DN<input id="so-ldap-base" value="${esc(s.ldap_base_dn || '')}" placeholder="dc=corp,dc=com"></label>
<label>Bind DN<input id="so-ldap-bind" value="${esc(s.ldap_bind_dn || '')}"></label>
<label>Bind 密码<input id="so-ldap-bindpwd" value="${esc(s.ldap_bind_password || '')}" placeholder="留空保持不变"></label>
<label>用户过滤({username} 占位)<input id="so-ldap-filter" value="${esc(s.ldap_user_filter || '(uid={username})')}"></label>
<div class="sec-title">连通性测试</div>
<label>测试账号<input id="so-ldap-testuser"></label>
<label>测试密码<input id="so-ldap-testpwd" type="password"></label>
<button class="btn" id="so-ldap-test">测试 LDAP 连接</button>
</div>
</div>
<div class="card" style="margin-top:14px">
<div class="sec-title" style="margin-top:0">SSO 登录入口</div>
<button class="btn primary" id="so-oidc-login">🚀 通过 OIDC 发起登录</button>
<div class="tab-note">登录页可对接企业统一身份;私有化部署时 SSO 保证账号体系受企业管控。回调地址需与 IdP 侧配置一致。</div>
</div>`;
$('#so-save').addEventListener('click', async () => {
await api('/api/enterprise/sso', {method: 'PUT', body: {
oidc_enabled: $('#so-oidc-on').checked ? '1' : '0', oidc_name: $('#so-oidc-name').value,
oidc_discovery_url: $('#so-oidc-disc').value.trim(), oidc_client_id: $('#so-oidc-cid').value.trim(),
oidc_client_secret: $('#so-oidc-secret').value.trim(), oidc_redirect_uri: $('#so-oidc-redirect').value.trim(),
oidc_admin_group: $('#so-oidc-admingroup').value.trim(), sso_auto_provision: $('#so-oidc-auto').checked ? '1' : '0',
ldap_enabled: $('#so-ldap-on').checked ? '1' : '0', ldap_url: $('#so-ldap-url').value.trim(),
ldap_base_dn: $('#so-ldap-base').value.trim(), ldap_bind_dn: $('#so-ldap-bind').value.trim(),
ldap_bind_password: $('#so-ldap-bindpwd').value.trim(), ldap_user_filter: $('#so-ldap-filter').value.trim()
}});
toast('SSO 配置已保存', 'ok'); pageEnterprise();
});
$('#so-ldap-test').addEventListener('click', async () => {
try {
const r = await api('/api/enterprise/sso/ldap/test', {method: 'POST', body: {username: $('#so-ldap-testuser').value, password: $('#so-ldap-testpwd').value}});
toast('✅ LDAP 认证成功:' + (r.data.display || r.data.username), 'ok');
} catch (e) { toast(e.message, 'err'); }
});
$('#so-oidc-login').addEventListener('click', async () => {
try {
const r = await api('/api/enterprise/sso/oidc/login', {method: 'POST'});
location.href = r.redirect;
} catch (e) { toast(e.message, 'err'); }
});
} else if (entTab === 'audit') {
box.innerHTML = `
<div class="card">
<div class="sec-title" style="margin-top:0">📜 审计日志 <button class="btn mini" style="float:right" onclick="window.location='/api/enterprise/audit/export'">⬇️ 导出 CSV</button></div>
<table><thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>目标</th><th>详情</th><th>IP</th></tr></thead><tbody>
${(d.audit || []).map(a => `<tr><td>${fmtTime(a.created_at)}</td><td>${esc(a.actor)}</td><td><span class="tag">${esc(a.action)}</span></td>
<td>${esc(a.target)}</td><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(a.detail)}">${esc(a.detail)}</td><td>${esc(a.ip)}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-td">暂无审计记录</td></tr>'}
</tbody></table>
<div class="tab-note">登录/登出、协作运行、评估、数据导出等关键操作自动留痕,满足等保审计要求。</div>
</div>`;
} else {
const c = d.comp || {};
box.innerHTML = `
<div class="grid g2">
<div class="card">
<div class="sec-title" style="margin-top:0">🗄️ 数据保留策略</div>
<label>保留天数(0=永久)<input id="co-ret" type="number" value="${c.retention_days || 0}"></label>
<button class="btn primary" id="co-ret-save">保存并立即清理</button>
<div class="tab-note">到期自动清理审计日志、协作运行、评估结果等历史数据。</div>
</div>
<div class="card">
<div class="sec-title" style="margin-top:0">🛡️ PII 脱敏</div>
<label><input type="checkbox" id="co-mask" ${c.mask_pii ? 'checked' : ''}> 审计日志中掩码邮箱/手机号/身份证</label>
<button class="btn primary" id="co-mask-save">保存</button>
</div>
</div>
<div class="card" style="margin-top:14px">
<div class="sec-title" style="margin-top:0">📤 数据导出(GDPR / 个人信息保护法)</div>
<button class="btn primary" onclick="window.location='/api/enterprise/export'">⬇️ 全量数据导出(JSON</button>
<button class="btn" onclick="window.location='/api/enterprise/audit/export'">⬇️ 审计日志导出(CSV</button>
<div class="tab-note">导出包含项目/任务/协作运行/评估/用户/审计全量数据,供数据可携带权与监管审计使用。</div>
</div>
<div class="card" style="margin-top:14px">
<div class="sec-title" style="margin-top:0">📊 存量统计</div>
<div class="kv">
<dt>审计记录</dt><dd>${c.audit_count} 条</dd>
<dt>协作运行</dt><dd>${c.agent_runs_count} 次</dd>
<dt>评估运行</dt><dd>${c.eval_runs_count} 次</dd>
<dt>数据使用同意凭证</dt><dd>${esc(c.consent_token || '—')}</dd>
</div>
</div>`;
$('#co-ret-save').addEventListener('click', async () => {
await api('/api/enterprise/compliance', {method: 'POST', body: {retention_days: Number($('#co-ret').value), apply_retention: true}});
toast('已保存并清理', 'ok'); pageEnterprise();
});
$('#co-mask-save').addEventListener('click', async () => {
await api('/api/enterprise/compliance', {method: 'POST', body: {mask_pii: $('#co-mask').checked}});
toast('已保存', 'ok'); pageEnterprise();
});
}
}
function entAddUser() {
openModal(`
<h3>新建用户</h3>
<label>用户名<input id="eu-name"></label>
<label>显示名<input id="eu-display"></label>
<label>角色<select id="eu-role"><option value="member">成员</option><option value="admin">管理员</option><option value="auditor">审计员</option></select></label>
<label>来源<select id="eu-source"><option value="local">本地</option><option value="oidc">OIDC</option><option value="ldap">LDAP</option></select></label>
<label>密码(本地用户必填)<input id="eu-pwd" type="password"></label>
<button class="btn primary" id="eu-save">创建</button>`);
$('#eu-save').addEventListener('click', async () => {
try {
await api('/api/enterprise/users', {method: 'POST', body: {
username: $('#eu-name').value.trim(), display_name: $('#eu-display').value,
role: $('#eu-role').value, source: $('#eu-source').value, password: $('#eu-pwd').value
}});
toast('已创建', 'ok'); closeModal(); pageEnterprise();
} catch (e) { toast(e.message, 'err'); }
});
}
function entEditUser(id) {
const u = (window._entData?.users || []).find(x => x.id === id);
if (!u) return;
openModal(`
<h3>编辑用户 ${esc(u.username)}</h3>
<label>显示名<input id="eu2-display" value="${esc(u.display_name)}"></label>
<label>角色<select id="eu2-role"><option ${u.role === 'member' ? 'selected' : ''}>member</option><option ${u.role === 'admin' ? 'selected' : ''}>admin</option><option ${u.role === 'auditor' ? 'selected' : ''}>auditor</option></select></label>
<label>状态<select id="eu2-status"><option value="active" ${u.status === 'active' ? 'selected' : ''}>启用</option><option value="disabled" ${u.status === 'disabled' ? 'selected' : ''}>停用</option></select></label>
<label>重置密码(留空不改)<input id="eu2-pwd" type="password"></label>
<button class="btn primary" id="eu2-save">保存</button>
<button class="btn danger" id="eu2-del" style="float:right">删除用户</button>`);
$('#eu2-save').addEventListener('click', async () => {
await api(`/api/enterprise/users/${id}`, {method: 'PUT', body: {
display_name: $('#eu2-display').value, role: $('#eu2-role').value,
status: $('#eu2-status').value, password: $('#eu2-pwd').value
}});
toast('已保存', 'ok'); closeModal(); pageEnterprise();
});
$('#eu2-del').addEventListener('click', async () => {
if (!confirm('确定删除该用户?')) return;
await api(`/api/enterprise/users/${id}`, {method: 'DELETE'});
toast('已删除', 'ok'); closeModal(); pageEnterprise();
});
}
/* ---------- 启动 ---------- */
(async function init() {
try {
const me = await api('/api/me');
if (!me.authed) { showLogin(); $('#main').innerHTML = '<div class="empty">请先登录</div>'; return; }
$('#logout-btn').style.display = 'block';
} catch (e) { showLogin(); return; }
try { await api('/api/health'); } catch (e) { $('#conn-state').textContent = '● 服务异常'; $('#conn-state').className = 'conn err'; }
refreshAlertBadge();
router();
})();