/* AI Worker 项目管理平台 - 前端 SPA(V1) */
'use strict';
const $ = (sel, el = document) => el.querySelector(sel);
const $$ = (sel, el = document) => [...el.querySelectorAll(sel)];
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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:'🚨'};
/* ---------- 轻量 Markdown 渲染(无外部依赖) ---------- */
function mdInline(s) {
// 先转义,再应用行内格式
s = esc(s);
s = s.replace(/`([^`]+)`/g, '$1');
s = s.replace(/\*\*([^*]+)\*\*/g, '$1 ');
s = s.replace(/__([^_]+)__/g, '$1 ');
s = s.replace(/\*([^*\n]+)\*/g, '$1 ');
s = s.replace(/(^|[^\w])\[([^\]]+)\]\(([^)\s]+)\)/g, '$1$2 ');
return s;
}
function mdToHtml(md) {
if (!md) return '';
const lines = String(md).replace(/\r\n?/g, '\n').split('\n');
const out = [];
let i = 0;
const flushCode = (buf, lang) => {
if (buf.length) out.push(`
${esc(buf.join('\n'))} `);
};
let codeBuf = null, codeLang = '';
while (i < lines.length) {
let line = lines[i];
const fence = line.match(/^```(\w*)\s*$/);
if (fence) {
if (codeBuf !== null) { flushCode(codeBuf, codeLang); codeBuf = null; }
else { codeBuf = []; codeLang = fence[1]; }
i++; continue;
}
if (codeBuf !== null) { codeBuf.push(line); i++; continue; }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { const lv = h[1].length; out.push(`${mdInline(h[2])} `); i++; continue; }
if (/^\s*([-*+])\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*([-*+])\s+/.test(lines[i])) {
items.push(`${mdInline(lines[i].replace(/^\s*([-*+])\s+/, ''))} `); i++;
}
out.push(``); continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(`${mdInline(lines[i].replace(/^\s*\d+\.\s+/, ''))} `); i++;
}
out.push(`${items.join('')} `); continue;
}
if (/^\s*>\s?/.test(line)) {
const items = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
items.push(mdInline(lines[i].replace(/^\s*>\s?/, ''))); i++;
}
out.push(`${items.join(' ')} `); continue;
}
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { out.push(' '); i++; continue; }
if (/^\s*$/.test(line)) { i++; continue; }
// 表格:| a | b | 且下一行是 |---| 分隔
if (line.trim().startsWith('|') && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
const head = line.trim().replace(/^\||\|$/g, '').split('|').map(x => x.trim());
i += 2;
const body = [];
while (i < lines.length && lines[i].trim().startsWith('|')) {
body.push('' + lines[i].trim().replace(/^\||\|$/g, '').split('|').map(x => `${mdInline(x.trim())} `).join('') + ' ');
i++;
}
out.push(`${head.map(x => `${mdInline(x)} `).join('')} ${body.join('')}
`);
continue;
}
// 普通段落:连续行合并
const para = [line];
i++;
while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6})\s/.test(lines[i])
&& !/^```/.test(lines[i]) && !/^\s*([-*+]|\d+\.)\s+/.test(lines[i])
&& !/^\s*>\s?/.test(lines[i]) && !/^\s*([-*_])\1{2,}\s*$/.test(lines[i])
&& !lines[i].trim().startsWith('|')) {
para.push(lines[i]); i++;
}
out.push(`${para.map(l => mdInline(l)).join(' ')}
`);
}
if (codeBuf !== null) flushCode(codeBuf, codeLang);
return out.join('\n');
}
/* ---------- Markdown 区块(带 原文/Markdown 切换,默认 Markdown) ---------- */
const mdRaw = {}; // id -> {md: bool, text: string}
function mdToggleBtn(id) {
// 切换开关:放入区块标题行右侧(由调用方放到 .sec-title 内)
return `📝 Markdown `;
}
function mdBody(id, text, cls = '') {
mdRaw[id] = {md: true, text: text || ''};
return `${mdToHtml(text)}
`;
}
function mdToggle(id) {
const rec = mdRaw[id];
if (!rec) return;
rec.md = !rec.md;
const body = $('#md-body-' + id), btn = $('#md-btn-' + id);
if (!body || !btn) return;
body.innerHTML = rec.md ? mdToHtml(rec.text) : `${esc(rec.text)} `;
btn.textContent = rec.md ? '📝 Markdown' : '📄 原文';
btn.classList.toggle('on', rec.md);
}
/* ---------- 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'; }
let MY_PERMS = null; // null = 全部权限(admin/token)
function applyRoleUI(role, perms) {
CURRENT_ROLE = role || 'admin';
MY_PERMS = (perms && perms.length) ? new Set(perms) : null;
$$('#sidebar nav a').forEach(a => {
const need = a.dataset.perm;
a.style.display = (!need || !MY_PERMS || MY_PERMS.has(need)) ? '' : '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';
const me = await api('/api/me');
applyRoleUI(me.user?.role || 'admin', me.user?.perms);
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() {
clearInterval(projAutoTimer); // 离开项目页时停止 AI 主管轮询
projAutoTimer = null;
const hash = location.hash.replace(/^#\//, '') || 'dashboard';
const parts = hash.split('/');
const name = parts[0];
// 离开项目页时丢弃项目缓存,避免下次进入用旧数据
if (name !== 'project') projCtx = null;
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 = ' 加载中…
';
fn(parts.slice(1)).catch(e => { $('#main').innerHTML = `${esc(e.message)}
`; });
}
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 = `
仪表盘AI 虚拟团队运营总览
项目
${s.projects}
进行中 ${s.by_status.done ?? 0} 个已完成任务
任务总数
${s.tasks}
完成率 ${pct(s.by_status.done ?? 0)}% · 待审核 ${s.by_status.review ?? 0}
AI Worker
${s.workers}
虚拟员工档案数
累计成本
${fmtMoney(s.total_cost)}
${(s.total_tokens/1e6).toFixed(2)}M tokens 消耗
质量指标
一次通过率 ${s.one_pass_rate ?? '—'}% (验收一次通过)
返工次数 ${s.rework_count} 次打回
执行失败 ${s.by_status.failed ?? 0} 个任务
近 7 天成本趋势(元)
${s.daily_cost.map(d => `
${d.day} ${d.cost.toFixed(4)}
`).join('')}
任务状态分布
${Object.entries(STATUS).filter(([k]) => s.by_status[k]).map(([k, v]) => `
${v.label} ${s.by_status[k]} 个
${pct(s.by_status[k])}%
`).join('') || '
暂无数据
'}
最近任务动态
项目 任务 状态 更新时间
${s.recent.map(t => `${esc(t.project_name || '—')} ${esc(t.title)}
${STATUS[t.status].label} ${fmtTime(t.updated_at)} `).join('') || '暂无任务,去 创建项目 吧 '}
`;
}
/* ---------- 项目列表 ---------- */
let projFilter = 'all';
const PROJ_STATUS = {planning:'规划中', active:'进行中', review:'待验收', done:'已完成', archived:'已归档'};
let projNameMap = {}; // id -> name(删除确认用)
async function pageProjects() {
const list = (await api('/api/projects')).data;
projNameMap = {}; list.forEach(p => { projNameMap[p.id] = p.name; });
const shown = projFilter === 'all' ? list : list.filter(p => p.status === projFilter);
const statusCls = s => s === 'active' ? 'running' : s === 'review' ? 'review' : s === 'done' ? 'done' : s === 'archived' ? 'cancelled' : 'todo';
$('#main').innerHTML = `
项目立项 → 规划 → 执行 → 验收 → 归档
+ 新建项目
状态
全部 ${list.length}
${Object.entries(PROJ_STATUS).map(([k, v]) => `${v} ${list.filter(p => p.status === k).length} `).join('')}
${shown.map(p => `
${esc(p.name)}
${p.perm === 'view' ? `${PROJ_STATUS[p.status] || p.status} ` : `
${Object.entries(PROJ_STATUS).map(([k, v]) => `${v} `).join('')}
`}
${p.perm !== 'view' ? `🗑 ` : ''}
${esc(p.objective || p.description || '暂无描述')}
任务 ${p.task_count} · 完成 ${p.done_count}
${p.budget_limit ? '预算 ¥' + p.budget_limit : ''}
📬 送达:${p.deliver_user ? esc(p.deliver_user.display_name || p.deliver_user.username) + '(' + esc(p.deliver_user.email || '') + ')' : esc(p.deliver_email || '未绑定用户')}
${p.perm ? `
${{view:'查看',manage:'管理',admin:'管理员'}[p.perm] || p.perm} ` : ''}
${p.demo_url ? `
🌐 Demo ` : ''}
`).join('') || '
' + (projFilter === 'all' ? '还没有项目,点右上角新建' : '该状态下暂无项目') + '
'}
`;
}
async function changeProjStatus(pid, status) {
try {
await api(`/api/projects/${pid}`, {method: 'PUT', body: {status}});
toast(`✅ 项目状态已改为「${PROJ_STATUS[status]}」`, 'ok');
pageProjects();
} catch (e) { toast(e.message, 'err'); pageProjects(); }
}
async function delProject(pid) {
const name = projNameMap[pid] || ('#' + pid);
if (!confirm(`确认删除项目「${name}」?\n将同时删除其全部任务、日志、交付记录与工作目录文件,且不可恢复!`)) return;
try {
await api(`/api/projects/${pid}`, {method: 'DELETE'});
toast('🗑 项目已删除', 'ok');
pageProjects();
} catch (e) { toast(e.message, 'err'); }
}
function openProjectModal(p = {}) {
const usersP = api('/api/users');
const workersP = api('/api/workers');
openModal(`
${p.id ? '编辑项目' : '新建项目'}×
项目名称 *
目标(Objective)
验收标准
项目描述
📬 送达者(人)* ${p.id ? '' : '项目完成/难关时通知此人 '}
加载用户中…
送达者 = 平台用户,其邮箱变更后项目通知自动跟随;可在项目进行中随时更换
交付物类型
🌐 网页(部署 Demo 供在线查看)
📦 文件包(打包发邮件)
交付说明
🤖 AI 虚拟团队(创建后自动开工)
👔 AI 主管(管理者)* 负责拆解任务、派活、监控,默认「AI主管」
加载 Worker 中…
👷 干活团队(AI 主管支配的 Worker,可多选)* 默认 AI牛 / AI马
任务完成后需人工审核(默认不勾选 = AI 主管全自动跑到完工)
取消
${p.id ? '保存' : '创建并开工 🚀'}
`);
usersP.then(r => {
const sel = $('#pm-email');
sel.disabled = false;
const cur = p.deliver_user ? p.deliver_user.id : (p.deliver_user_id || '');
sel.innerHTML = '— 请选择送达者用户 — ' +
r.data.filter(u => u.status === 'active').map(u =>
`${esc(u.display_name || u.username)}(${esc(u.username)} · ${esc(u.email || '无邮箱')}) `).join('');
if (!cur) sel.value = '';
}).catch(() => {});
workersP.then(r => {
const ws = r.data.filter(w => w.status === 'enabled');
if (!ws.length) { $('#pm-mgr').innerHTML = '请先在 Worker 管理中注册 AI Worker '; return; }
// 默认:AI 主管 = 名字含「主管/经理」的 Worker;干活团队 = 含「牛/马」的(如 AI牛/AI马),否则除主管外全部
let curMgr = p.manager_worker ? p.manager_worker.id : (p.manager_worker_id || '');
if (!curMgr) {
const m = ws.find(w => /主管|经理|manager/i.test(w.name));
curMgr = (m || ws[0]).id;
}
$('#pm-mgr').innerHTML = '— 请选择 AI 主管 — ' +
ws.map(w => `${esc(w.name)}(${esc(w.provider)}/${esc(w.model)}) `).join('');
const curTeam = new Set((p.team_workers || []).map(w => w.id));
let defaults = ws.filter(w => /牛|马/.test(w.name) && String(w.id) !== String(curMgr));
if (!defaults.length) defaults = ws.filter(w => String(w.id) !== String(curMgr));
$('#pm-team').innerHTML = ws.map(w => `
${esc(w.name)} ${esc(w.provider)}/${esc(w.model)} `).join('');
}).catch(() => {});
$('#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),
deliver_user_id: Number($('#pm-email').value || 0),
deliver_type: $('#pm-dtype').value,
deliver_note: $('#pm-dnote').value.trim(),
manager_worker_id: Number($('#pm-mgr').value || 0),
team_worker_ids: [...$$('#pm-team input:checked')].map(i => Number(i.value)),
auto_review: !!$('#pm-auto-review').checked
};
if (!body.name) return toast('请填写项目名称', 'err');
if (!body.deliver_user_id) return toast('必填:选择送达者用户', 'err');
if (!body.manager_worker_id) return toast('必填:选择 AI 主管 Worker', 'err');
if (!body.team_worker_ids.length) return toast('必填:至少勾选 1 个干活 AI Worker', 'err');
try {
if (p.id) {
await api(`/api/projects/${p.id}`, {method: 'PUT', body});
toast('已保存(新主管/团队将在下次开工时生效)', 'ok'); closeModal(); router();
} else {
const r = await api('/api/projects', {method: 'POST', body});
toast('项目已创建,AI 主管已开工 🚀', 'ok'); closeModal();
location.hash = `#/project/${r.id}`;
}
} catch (e) { toast(e.message, 'err'); }
});
}
/* ---------- 项目详情(看板 / DAG / 知识库 / 交付) ---------- */
let projCtx = null; // {pid, p, tasks, workers, tab, autoLogs}
let projAutoTimer = null;
async function pageProject([pid, tab]) {
tab = tab || 'kanban';
// 同一项目内切 tab:复用已加载的数据,不再重新拉项目/任务/Worker/事件
if (projCtx && String(projCtx.pid) === String(pid)) {
projCtx.tab = tab;
renderProjectShell();
renderActiveTab();
startProjAutoPoll(); // 重启轮询(router 已清掉定时器)
return;
}
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;
const events = (await api(`/api/projects/${pid}/events`)).data;
projCtx = {pid, p, tasks, workers, tab, events};
renderProjectShell();
renderActiveTab();
startProjAutoPoll();
}
function renderActiveTab() {
if (projCtx.tab === 'dag') renderDag();
else if (projCtx.tab === 'kb') renderKb();
else if (projCtx.tab === 'deliver') renderDeliver();
else renderKanban();
}
/* V3.3:AI 主管自动开工 —— 轮询刷新状态/事件/看板 */
async function refreshProjEvents() {
try { projCtx.events = (await api(`/api/projects/${projCtx.pid}/events`)).data; } catch (e) {}
}
function startProjAutoPoll() {
clearInterval(projAutoTimer);
renderAutoPanel();
if (projCtx.p.auto_status !== 'running') return;
projAutoTimer = setInterval(async () => {
try {
const wasRunning = projCtx.p.auto_status === 'running';
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
projCtx.p = p;
await refreshProjEvents();
const running = p.auto_status === 'running';
// 状态变化(开工结束/中断)或运行中:都刷新一次任务视图
if (running || wasRunning) {
projCtx.tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data;
if (projCtx.tab === 'dag') renderDag(); else renderKanban();
}
renderAutoPanel();
if (!running) { clearInterval(projAutoTimer); projAutoTimer = null; }
} catch (e) {}
}, 3000);
}
async function autoStartProj() {
try {
await api(`/api/projects/${projCtx.pid}/autostart`, {method: 'POST'});
toast('🚀 AI 主管已开工,正在拆解派活…', 'ok');
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
projCtx.p = p;
await refreshProjEvents();
renderAutoPanel();
startProjAutoPoll();
} catch (e) { toast(e.message, 'err'); }
}
/* V3.4:负责人验收(平台内) */
function openReviewModal() {
const {p} = projCtx;
openModal(`
🔔 项目验收:${esc(p.name)}×
AI 团队已全部完工,由您(负责人)最终验收。 验收通过后项目才算正式完成,将自动打包交付。
${p.demo_url ? `👀 查看交付 Demo
` : ''}
验收意见(打回必填)
取消
⛔ 打回返工
✅ 验收通过
`);
}
async function submitProjectReview(action) {
const reason = $('#rv-reason').value.trim();
if (action === 'reject' && !reason) return toast('打回必须填写原因', 'err');
try {
await api(`/api/projects/${projCtx.pid}/review`, {method: 'POST', body: {action, reason}});
closeModal();
toast(action === 'approve' ? '✅ 验收通过,项目完成并交付' : '⛔ 已打回,可重新开工返工', 'ok');
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
projCtx.p = p;
await refreshProjEvents();
renderProjectShell();
renderActiveTab();
renderAutoPanel();
} catch (e) { toast(e.message, 'err'); }
}
function renderAutoPanel() {
const el = $('#auto-panel');
if (!el || !projCtx) return;
const p = projCtx.p;
const running = p.auto_status === 'running';
const st = {none:['未开工','todo'], running:['🚀 AI 主管工作中','running'], done:['✅ 自动开工完成','done'], failed:['⚠️ 开工中断','failed']}[p.auto_status] || ['—','todo'];
const mgr = p.manager_worker ? esc(p.manager_worker.name) : '未设置 ';
const team = (p.team_workers || []).map(w => `${esc(w.name)} `).join(' ') || '未设置 ';
const evs = projCtx.events || [];
const collapsed = localStorage.getItem('aw_ev_collapsed_' + projCtx.pid) === '1';
const icon = l => ({error:'❌', warn:'⚠️', success:'✅', info:'🤖'}[l] || '•');
const items = evs.map(e => `
${icon(e.level)}
${esc(e.message)}${e.task_id ? `任务 #${e.task_id} › ` : ''}
${fmtTime(e.created_at)}
`).join('') || '暂无事件
';
el.innerHTML = `
${st[0]}
${esc(p.auto_message || '尚未开工:创建项目后自动开工,或点右上角「🚀 AI 主管开工」')}
${running ? ' ' : ''}
👔 AI 主管:${mgr} | 👷 干活团队:${team} | ⏱ ${p.auto_started_at ? '开工于 ' + fmtTime(p.auto_started_at) : '—'}
${collapsed ? '▸' : '▾'}
📋 事件记录
${evs.length} 条
${collapsed ? '点击展开' : '点击收起'}
${items}
`;
}
function toggleEvents() {
const panel = $('#auto-panel .ev-panel');
if (!panel) return;
const collapsed = panel.classList.contains('collapsed');
localStorage.setItem('aw_ev_collapsed_' + projCtx.pid, collapsed ? '0' : '1');
renderAutoPanel();
}
function renderProjectShell() {
const {p, pid, tab} = projCtx;
const running = p.auto_status === 'running';
const inReview = p.status === 'review';
$('#main').innerHTML = `
`;
renderAutoPanel();
}
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 = `${KANBAN_ORDER.map(s => `
`).join('')}
`;
KANBAN_ORDER.forEach(s => {
const list = by[s] || [];
$('#cnt-' + s).textContent = list.length;
$('#kanban .kb-body[data-drop="' + s + '"]').innerHTML = list.map(t => `
${esc(t.title)}
●${PRI[t.priority]}
${t.worker_id ? `Worker#${t.worker_id} ` : '自动路由 '}
${t.depends_on && t.depends_on.length ? `依赖×${t.depends_on.length} ` : ''}
${t.rejection_count ? `打回×${t.rejection_count} ` : ''}
${t.status === 'running' ? ' ' : ''}
${t.status === 'done' ? '
✅ 已交付 v' + t.output_version + '
' : ''}
`).join('') || '空
';
});
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 dagFitView() {
// 画布适配视口:基于节点实际包围盒缩放并居中,让全部节点可见
const s = dagState;
const box = $('#dag-box');
if (!box || !s) return;
const poss = Object.values(s.pos || {});
if (!poss.length) return;
const minX = Math.min(...poss.map(p => p.x)) - 120;
const maxX = Math.max(...poss.map(p => p.x)) + 120;
const minY = Math.min(...poss.map(p => p.y)) - 120;
const maxY = Math.max(...poss.map(p => p.y)) + 120;
const cw = Math.max(200, maxX - minX), ch = Math.max(200, maxY - minY);
const bw = box.clientWidth, bh = box.clientHeight;
const scale = Math.max(0.25, Math.min(1, (bw - 60) / cw, (bh - 60) / ch));
s.scale = scale;
// 节点包围盒中心(viewBox 坐标)对准视口中心
const cx = DAG_M + (minX + maxX) / 2;
const cy = DAG_M + (minY + maxY) / 2;
s.tx = bw / 2 - cx * scale;
s.ty = bh / 2 - cy * scale;
dagApplyView();
saveDagState();
}
function dagZoomTo(scale) {
// 一键回到指定比例(100% = 原始视图)
const s = dagState;
if (!s) return;
s.scale = scale;
s.tx = 0;
s.ty = 0;
dagApplyView();
saveDagState();
}
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';
const pct = $('#dag-zoom-pct');
if (pct) pct.textContent = Math.round(s.scale * 100) + '%';
}
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;
const M = DAG_M; // svg 盒边距:节点平移/缩放后仍在盒内可命中
const dagText = t => {
const raw = `#${t.id} ${t.title}`;
return raw.length > 13 ? raw.slice(0, 13) + '…' : raw;
};
body.innerHTML = `
▶ 执行整个工作流(${ready.length})
🔍 100%
⤢ 适配
100%
🗑️ 删除区0
🔗 重连
🗑️ 删除
✖ 取消
${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, my = (a.y + b.y) / 2;
const act = idMap[e.from].status === 'done';
const sel = dagState.selectedEdge && dagState.selectedEdge.from === e.from && dagState.selectedEdge.to === e.to;
return `
✕
`;
}).join('')}
${tasks.map(t => {
const p = dagState.pos[t.id];
const col = colors[t.status];
return `
${esc(dagText(t))}
${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''}
`;
}).join('')}
💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。连线操作:从节点右侧⚪ 拖到目标节点=新建依赖;单击连线 选中后可重连/删除;双击连线 直接删除;连线中点 ✕ 一键删除。
`;
dagApplyView();
bindDagEvents();
dagTrashCount();
}
let dagBound = false; // window 级监听只绑定一次
let dagDrag = null; // 模块级拖拽状态(box 重绑后 window handler 仍可访问)
const DAG_M = 3000; // svg 盒边距:节点平移/缩放后仍在盒内可命中
const DAG_NW = 140, DAG_NHW = 70; // 节点宽 / 半宽
/* --- DAG 连线(依赖边)编辑:新建 / 重连 / 删除 --- */
function dagCanConnect(from, to, mode, oldTo) {
if (from === to) return {ok:false, why:'不能连接到自己'};
const t = projCtx.tasks.find(x => x.id === to);
if (!t) return {ok:false, why:'目标任务不存在'};
if (mode !== 'repoint' && (t.depends_on || []).includes(from)) return {ok:false, why:'该连线已存在'};
if (dagWouldCycle(from, to, mode === 'repoint' ? {from, to: oldTo} : null)) return {ok:false, why:'会形成依赖环'};
return {ok:true};
}
function dagWouldCycle(from, to, remove) {
// 临时加入 from→to(重连时先移除旧边 remove),判断是否成环
const adj = {};
projCtx.tasks.forEach(t => {
adj[t.id] = new Set((t.depends_on || []).filter(d => !(remove && t.id === remove.to && d === remove.from)));
});
(adj[to] = adj[to] || new Set()).add(from);
const seen = new Set(), stack = [...(adj[from] || [])];
while (stack.length) {
const n = stack.pop();
if (n === from) return true;
if (seen.has(n)) continue;
seen.add(n);
(adj[n] || []).forEach(x => { if (!seen.has(x)) stack.push(x); });
}
return false;
}
async function dagApplyEdge(from, to, mode, oldTo) {
const chk = dagCanConnect(from, to, mode, oldTo);
if (!chk.ok) { toast(chk.why, 'err'); return; }
try {
if (mode === 'repoint') {
const old = projCtx.tasks.find(x => x.id === oldTo);
await api(`/api/tasks/${oldTo}`, {method:'PUT', body:{depends_on: (old?.depends_on || []).filter(x => x !== from)}});
const nt = projCtx.tasks.find(x => x.id === to);
await api(`/api/tasks/${to}`, {method:'PUT', body:{depends_on: [...new Set([...(nt?.depends_on || []), from])]}});
toast(`🔗 连线已重连:#${from} → #${to}`, 'ok');
} else {
const nt = projCtx.tasks.find(x => x.id === to);
await api(`/api/tasks/${to}`, {method:'PUT', body:{depends_on: [...new Set([...(nt?.depends_on || []), from])]}});
toast(`🔗 已新建连线:#${from} → #${to}`, 'ok');
}
dagClearSel();
refreshProj();
} catch (e) { toast('连线操作失败:' + e.message, 'err'); }
}
async function dagDeleteEdge(from, to) {
const t = projCtx.tasks.find(x => x.id === to);
if (!t) return;
if (!confirm(`删除依赖连线 #${from} → #${to} ?\n(#${to} 将不再等待 #${from} 完成)`)) return;
try {
await api(`/api/tasks/${to}`, {method:'PUT', body:{depends_on: (t.depends_on || []).filter(x => x !== from)}});
toast('连线已删除', 'ok');
dagCancelConnect();
dagClearSel();
refreshProj();
} catch (e) { toast('删除失败:' + e.message, 'err'); }
}
function dagSelectEdge(from, to) {
dagState.selectedEdge = {from, to};
const svg = $('#dag-svg');
if (svg) svg.querySelectorAll('.dag-edge-g').forEach(g => g.classList.toggle('sel', g.dataset.e === `${from}-${to}`));
const bar = $('#dag-edge-actions');
if (bar) { $('#dag-edge-info').innerHTML = `#${from} → #${to} `; bar.style.display = 'flex'; }
}
function dagClearSel() {
if (dagState?.connect) dagCancelConnect();
if (dagState) dagState.selectedEdge = null;
const svg = $('#dag-svg');
if (svg) svg.querySelectorAll('.dag-edge-g.sel').forEach(g => g.classList.remove('sel'));
const bar = $('#dag-edge-actions');
if (bar) bar.style.display = 'none';
}
function dagRepointSel() {
const sel = dagState?.selectedEdge;
if (!sel) return;
dagState.connect = {from: sel.from, mode: 'repoint', oldTo: sel.to};
const box = $('#dag-box'); if (box) box.classList.add('dag-connecting');
const bar = $('#dag-edge-actions');
if (bar) { $('#dag-edge-info').innerHTML = `#${sel.from} → ?(点击目标节点) `; bar.style.display = 'flex'; }
const a = dagState.pos[sel.from];
if (a) dagSetGhost(a.x + DAG_NHW, a.y, a.x + DAG_NHW, a.y, true);
toast('点击目标节点完成重连,点空白 / Esc 取消', '');
}
function dagDelSel() {
const sel = dagState?.selectedEdge;
if (sel) dagDeleteEdge(sel.from, sel.to);
}
function dagCancelConnect() {
if (!dagState) return;
dagState.connect = null;
const box = $('#dag-box'); if (box) box.classList.remove('dag-connecting');
const g = $('#dag-ghost'); if (g) g.style.display = 'none';
const svg = $('#dag-svg');
if (svg) svg.querySelectorAll('.dag-node.drop-ok,.dag-node.drop-bad').forEach(n => n.classList.remove('drop-ok', 'drop-bad'));
const bar = $('#dag-edge-actions');
if (bar) {
bar.style.display = dagState.selectedEdge ? 'flex' : 'none';
if (dagState.selectedEdge) {
const {from, to} = dagState.selectedEdge;
$('#dag-edge-info').innerHTML = `#${from} → #${to} `;
}
}
}
function dagSetGhost(x1, y1, x2, y2, valid) {
const g = $('#dag-ghost'); if (!g) return;
const mx = (x1 + x2) / 2;
g.setAttribute('d', `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`);
g.style.display = '';
g.classList.toggle('bad', !valid);
}
function dagUpdateEdgeEl(svg, from, to) {
const g = svg.querySelector(`.dag-edge-g[data-e="${from}-${to}"]`);
if (!g) return;
const a = dagState.pos[from], b = dagState.pos[to];
if (!a || !b) return;
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
const d = `M${a.x + DAG_NHW},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - DAG_NHW},${b.y}`;
g.querySelectorAll('path').forEach(p => p.setAttribute('d', d));
const xc = g.querySelector('.dag-edge-x'); if (xc) { xc.setAttribute('cx', mx); xc.setAttribute('cy', my); }
const xt = g.querySelector('.dag-edge-xt'); if (xt) { xt.setAttribute('x', mx); xt.setAttribute('y', my + 3.5); }
}
function bindDagEvents() {
const box = $('#dag-box');
if (!box) return;
const s = dagState;
const svgEl = () => $('#dag-svg');
// box 级监听:每次 DOM 重建后重新绑定(box 是新元素)
box.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const portOut = e.target.closest('.dag-port.out');
const nodeEl = e.target.closest('.dag-node');
if (portOut) {
// 从节点右侧输出口拖出 → 新建连线
const id = Number(portOut.dataset.id);
const p = s.pos[id];
if (!p) return;
s.connect = {from: id, mode: 'create'};
box.classList.add('dag-connecting');
dagSetGhost(p.x + DAG_NHW, p.y, p.x + DAG_NHW, p.y, true);
e.preventDefault();
return;
}
if (nodeEl) {
if (e.target.closest('.dag-port.in')) { e.preventDefault(); return; } // 输入口只作落点,不拖节点
const id = Number(nodeEl.dataset.id);
const p = s.pos[id];
dagDrag = {type: 'node', id, sx: e.clientX, sy: e.clientY, ox: p.x, oy: p.y, moved: false};
nodeEl.style.cursor = 'grabbing';
const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing';
} else if (e.target.closest('.dag-edge-g') || e.target.closest('.dag-edge-actions')) {
e.preventDefault(); // 点连线 / 操作条不触发平移
return;
} else {
dagDrag = {type: 'pan', sx: e.clientX, sy: e.clientY, ox: s.tx, oy: s.ty, moved: false};
const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing';
}
e.preventDefault();
});
// 滚轮缩放(以鼠标为锚点)
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-edge-actions, .dag-zoom, .dag-trash')) return;
const edgeG = e.target.closest('.dag-edge-g');
if (edgeG) {
const [f, t] = edgeG.dataset.e.split('-').map(Number);
dagDeleteEdge(f, t);
return;
}
if (e.target.closest('.dag-node')) return;
s.scale = 1; s.tx = 0; s.ty = 0;
dagApplyView();
saveDagState();
});
// 点击:连线=选中/删除,节点=详情,空白=取消选中(控件条除外)
box.addEventListener('click', e => {
if (e.target.closest('.dag-edge-actions, .dag-zoom, .dag-trash')) return;
const edgeG = e.target.closest('.dag-edge-g');
if (edgeG) {
const [f, t] = edgeG.dataset.e.split('-').map(Number);
if (e.target.closest('.dag-edge-x')) { dagDeleteEdge(f, t); return; }
dagSelectEdge(f, t);
return;
}
const nodeEl = e.target.closest('.dag-node');
if (nodeEl) {
if (dagState.suppressClick) { dagState.suppressClick = false; return; }
openTaskDetail(Number(nodeEl.dataset.id));
return;
}
dagClearSel();
});
// window 级监听:只绑定一次(handler 内现查 DOM,兼容画布重建)
if (dagBound) return;
dagBound = true;
window.addEventListener('keydown', e => {
if (document.activeElement && /input|textarea|select/i.test(document.activeElement.tagName)) return;
if (e.key === 'Escape') { if (dagState?.connect) dagCancelConnect(); }
if ((e.key === 'Delete' || e.key === 'Backspace') && dagState?.selectedEdge && !dagState?.connect) {
dagDelSel();
}
});
window.addEventListener('mousemove', e => {
const st = dagState;
if (!st) return;
// 连线模式:幽灵线 + 目标节点合法性高亮
if (st.connect) {
const c = st.connect;
const boxEl = $('#dag-box');
if (!boxEl) return;
const rect = boxEl.getBoundingClientRect();
const vx = (e.clientX - rect.left - st.tx) / st.scale;
const vy = (e.clientY - rect.top - st.ty) / st.scale;
const a = st.pos[c.from];
const el = document.elementFromPoint(e.clientX, e.clientY);
const nd = el && el.closest ? el.closest('.dag-node') : null;
const target = nd ? Number(nd.dataset.id) : null;
const svg = svgEl();
if (svg) svg.querySelectorAll('.dag-node.drop-ok,.dag-node.drop-bad').forEach(n => n.classList.remove('drop-ok', 'drop-bad'));
let valid = false;
if (target && target !== c.from) {
const chk = dagCanConnect(c.from, target, c.mode, c.oldTo);
valid = chk.ok;
if (svg) {
const tn = svg.querySelector(`.dag-node[data-id="${target}"]`);
if (tn) tn.classList.add(valid ? 'drop-ok' : 'drop-bad');
}
}
if (a) dagSetGhost(a.x + DAG_NHW, a.y, vx, vy, valid);
return;
}
if (!dagDrag) return;
const dx = e.clientX - dagDrag.sx, dy = e.clientY - dagDrag.sy;
if (!dagDrag.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
dagDrag.moved = true;
const trashEl = $('#dag-trash');
if (trashEl) {
const tb = trashEl.getBoundingClientRect();
trashEl.classList.toggle('hover',
e.clientX >= tb.left && e.clientX <= tb.right && e.clientY >= tb.top && e.clientY <= tb.bottom);
}
if (dagDrag.type === 'node') {
const p = st.pos[dagDrag.id];
if (p) { p.x = dagDrag.ox + dx / st.scale; p.y = dagDrag.oy + dy / st.scale; }
} else {
st.tx = dagDrag.ox + dx;
st.ty = dagDrag.oy + dy;
}
dagApplyView();
if (dagDrag.type === 'node') {
const svgEl2 = $('#dag-svg');
if (!svgEl2) return;
svgEl2.querySelectorAll('.dag-node').forEach(g => {
const nid = Number(g.dataset.id);
const pp = st.pos[nid];
if (!pp) return;
g.querySelector('rect').setAttribute('x', pp.x - DAG_NHW);
g.querySelector('rect').setAttribute('y', pp.y - 24);
g.querySelectorAll('.dag-port').forEach(port => {
port.setAttribute('cx', pp.x + (port.classList.contains('out') ? DAG_NHW : -DAG_NHW));
port.setAttribute('cy', pp.y);
});
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([dagDrag.id]);
projCtx.tasks.forEach(t => (t.depends_on || []).forEach(d => {
if (d === dagDrag.id || t.id === dagDrag.id) { touched.add(d); touched.add(t.id); }
}));
touched.forEach(tid => {
projCtx.tasks.forEach(t => {
if (t.id !== tid) return;
(t.depends_on || []).forEach(d => {
if (st.pos[d]) dagUpdateEdgeEl(svgEl2, d, tid);
});
});
});
}
});
window.addEventListener('mouseup', e => {
const st = dagState;
if (!st) return;
// 连线模式结束:落到合法节点则应用,否则取消
if (st.connect) {
const c = st.connect;
const nd = e.target && e.target.closest ? e.target.closest('.dag-node') : null;
if (nd) {
const target = Number(nd.dataset.id);
if (c.mode === 'create') dagApplyEdge(c.from, target, 'create');
else if (target !== c.from && target !== c.oldTo) dagApplyEdge(c.from, target, 'repoint', c.oldTo);
else toast('重连取消(目标未变化)', '');
} else {
toast('已取消连线', '');
}
dagCancelConnect();
dagState.suppressClick = true;
setTimeout(() => { if (dagState) dagState.suppressClick = false; }, 400);
return;
}
if (!dagDrag) return;
const wasNode = dagDrag.type === 'node';
const trashEl = $('#dag-trash');
let dropped = false;
if (wasNode && dagDrag.moved && trashEl) {
const tb = trashEl.getBoundingClientRect();
if (e.clientX >= tb.left && e.clientX <= tb.right && e.clientY >= tb.top && e.clientY <= tb.bottom) {
dropped = true;
dagTrashNode(dagDrag.id);
}
}
const sv = $('#dag-svg');
if (wasNode) {
const nodeEl = sv ? sv.querySelector('.dag-node[data-id="' + dagDrag.id + '"]') : null;
if (nodeEl) nodeEl.style.cursor = 'grab';
}
if (sv) sv.style.cursor = '';
if (trashEl) trashEl.classList.remove('hover');
if (dagDrag.moved && !dropped) {
dagState.suppressClick = true;
setTimeout(() => { if (dagState) dagState.suppressClick = false; }, 400);
saveDagState();
}
dagDrag = null;
});
}
/* --- DAG 回收站:软删除 / 恢复 / 彻底删除 --- */
async function dagTrashCount() {
try {
const r = await api(`/api/tasks/trash?project_id=${projCtx.pid}`);
const el = $('#dag-trash-cnt');
if (el) el.textContent = r.data.length;
} catch (e) {}
}
async function dagTrashNode(taskId) {
try {
const t = projCtx.tasks.find(x => x.id === taskId);
await api(`/api/tasks/${taskId}/trash`, {method: 'POST'});
toast(`🗑️ 任务「${t?.title || taskId}」已移入回收站`, 'ok');
// 重新拉取项目数据并重绘画布
const tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data;
projCtx.tasks = tasks;
const idMap = {};
tasks.forEach(t => { idMap[t.id] = t; });
const {pos} = dagLayout(tasks, idMap);
Object.entries(dagState.pos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; });
dagState.pos = pos;
drawDag();
} catch (e) { toast('删除失败:' + e.message, 'err'); }
}
async function openDagTrash() {
const r = await api(`/api/tasks/trash?project_id=${projCtx.pid}`);
const rows = r.data;
openModal(`
🗑️ 回收站 ${rows.length} 个已删除任务
被删除的任务都在这里,可恢复或彻底删除(不可恢复)。
ID 标题 状态 删除时间 操作
${rows.map(t => `
#${t.id}
${esc(t.title)}
${STATUS[t.status]?.label || t.status}
${fmtTime(t.deleted_at)}
♻️ 恢复
彻底删除
`).join('') || '回收站是空的 '}
关闭 `);
}
async function dagRestoreTask(taskId) {
try {
await api(`/api/tasks/${taskId}/restore`, {method: 'POST'});
toast('♻️ 已恢复', 'ok');
const tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data;
projCtx.tasks = tasks;
const idMap = {};
tasks.forEach(t => { idMap[t.id] = t; });
const {pos} = dagLayout(tasks, idMap);
Object.entries(dagState.pos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; });
dagState.pos = pos;
drawDag();
closeModal();
} catch (e) { toast('恢复失败:' + e.message, 'err'); }
}
async function dagHardDelete(taskId) {
if (!confirm('彻底删除后不可恢复,确定?')) return;
try {
await api(`/api/tasks/${taskId}?hard=1`, {method: 'DELETE'});
toast('已彻底删除', 'ok');
closeModal();
openDagTrash();
dagTrashCount();
} catch (e) { toast('删除失败:' + e.message, 'err'); }
}
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,
selectedEdge: null, connect: null,
};
const body2 = $('#tab-body');
body2.innerHTML = '';
drawDag();
// 节点是否全部在视口内?任一越界则自动适配(防止节点被平移到视口外无法操作)
const box = $('#dag-box');
const bb = box ? box.getBoundingClientRect() : null;
let allVisible = !bb;
if (bb) {
allVisible = tasks.every(t => {
const p = dagState.pos[t.id];
if (!p) return true;
const vx = bb.left + dagState.tx + (DAG_M + p.x) * dagState.scale;
const vy = bb.top + dagState.ty + (DAG_M + p.y) * dagState.scale;
return vx < bb.right && vx > bb.left && vy < bb.bottom && vy > bb.top;
});
}
if (!allVisible || !savedView) dagFitView();
}
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 = `
+ 添加文档
🔍 检索测试(模拟任务执行时的 RAG 命中)
检索
文档列表
${docs.map(d => `
${esc(d.name)} ${d.chunks} 块 ${d.content.length} 字 · ${fmtTime(d.created_at)}
查看/编辑
删除
`).join('') || '
暂无文档,添加产品资料/知识手册后 AI Worker 干活时会自动参考
'}
`;
}
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 => `
📄 命中 文档#${h.document_id} · 块${h.chunk_id} · 得分 ${h.score}
${esc(h.content.slice(0, 160))}${h.content.length > 160 ? '…' : ''}
`).join('') : '无命中(知识库为空或关键词不匹配)
';
}
function openDocModal(docId) {
(docId ? api(`/api/documents/${docId}`) : Promise.resolve({data: {}})).then(async r => {
const doc = docId ? r.data : {};
openModal(`
${docId ? '编辑文档' : '添加文档'}×
文档名称 *
内容(支持粘贴文本 / 上传 txt、md 文件)
取消
保存并分块索引
`);
$('#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(`
${t.id ? '编辑任务' : '新建任务'}×
任务标题 *
任务说明(发给 AI Worker 的指令)
执行 Worker
🤖 自动路由(最便宜可用)
${workers.map(w => `${esc(w.name)}(${w.provider}/${w.model}) `).join('')}
优先级
${Object.entries(PRI).map(([k, v]) => `${v} `).join('')}
前置任务(DAG 依赖,完成后方可执行)
${candidates.length ? candidates.map(c => `
#${c.id} ${esc(c.title).slice(0, 18)}(${STATUS[c.status].label}) `).join('') : '暂无其他任务可选 '}
取消
${t.id ? '保存' : '创建并派活'}
`);
$('#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(`
${esc(t.title)}${STATUS[t.status].label}
×
执行 Worker ${w ? `${esc(w.name)} ${esc(w.provider)}/${esc(w.model)} ` : '未指派(自动路由)'}
优先级 ●${PRI[t.priority]} · 返工 ${t.rejection_count} 次
前置任务 ${deps.length ? deps.map(d => `#${d.id} ${esc(d.title).slice(0,12)}(${STATUS[d.status].label}) `).join('') : '无'}
创建/更新 ${fmtTime(t.created_at)} / ${fmtTime(t.updated_at)}
截止 ${t.deadline || '—'}
任务指令 ${mdToggleBtn('td-desc-' + t.id)}
${mdBody('td-desc-' + t.id, t.description || t.title, 'sm')}
${t.output_text ? `产出物 v${t.output_version}(${t.output_text.length} 字) ${mdToggleBtn('td-out-' + t.id)}
${mdBody('td-out-' + t.id, t.output_text)}` : ''}
${t.error ? `错误信息
${esc(t.error)}
` : ''}
执行日志
${t.logs.map(logLine).join('') || '
暂无日志
'}
${t.status === 'todo' ? `🚀 派活执行
编辑
删除 ` : ''}
${t.status === 'running' ? `⏹ 取消执行 ` : ''}
${t.status === 'review' ? `✅ 审核通过
⛔ 打回返工 ` : ''}
${t.status === 'failed' ? `🔄 重试
编辑 ` : ''}
${['done','cancelled'].includes(t.status) ? `↩️ 重新打开 ` : ''}
`);
}
function logLine(l) {
const icons = {info:'ℹ️', success:'✅', warn:'⚠️', error:'❌'};
return `${fmtTime(l.created_at)} ${icons[l.level] || ''} ${esc(l.message)}
`;
}
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('删除后任务将移入回收站(DAG 画布左下角🗑️可恢复),确定?')) return;
await api(`/api/tasks/${tid}`, {method:'DELETE'});
toast('已移入回收站', 'ok'); closeModal(); refreshProj();
}
/* ---------- AI 规划 WBS ---------- */
function openWbsModal() {
const {p} = projCtx;
openModal(`
🧠 AI 辅助规划(WBS 任务分解)×
项目目标(默认取项目 objective)
取消
生成任务分解
`);
$('#wb-gen').addEventListener('click', async () => {
const goal = $('#wb-goal').value.trim();
if (!goal) return toast('请填写项目目标', 'err');
$('#wb-result').innerHTML = ' AI 正在拆解任务(约 10-30 秒)…
';
try {
const r = await api(`/api/projects/${projCtx.pid}/wbs/generate`, {method:'POST', body:{goal}});
let wbs = r.data;
$('#wb-result').innerHTML = `
生成 ${wbs.length} 个任务(可编辑后导入)· 花费 ¥${r.cost.toFixed(4)} / ${r.usage} tokens
${wbs.map((t, i) => `
`).join('')}
取消
✅ 导入为项目任务
`;
$('#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 = `❌ ${esc(e.message)}
`; }
});
}
/* ---------- 📦 交付中心(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 = `
📬 交付对象与方式
送达者 ${p.deliver_user ? esc(p.deliver_user.display_name || p.deliver_user.username) + '(' + esc(p.deliver_user.username) + ')' : '⚠️ 未绑定用户'}
邮箱 ${esc(p.deliver_user?.email || p.deliver_email || '—')}(用户改邮箱后自动跟随)
交付物类型 ${p.deliver_type === 'web' ? '🌐 网页交付物(在线 Demo)' : '📦 文件包交付物(邮件附件)'}
交付说明 ${esc(p.deliver_note || '—')}
Demo 地址 ${p.demo_url ? `${esc(p.demo_url)} ` : '未部署 '}
最近送达 ${fmtTime(p.delivered_at)}
项目状态 ${p.status === 'done' ? '✅ 已完成' : '🔄 进行中'}
${isView ? '' : `
🚀 部署 Demo
🗜️ 打包
📧 发送给送达者
${p.status === 'done' ? '' : `✅ 完成项目并交付 `}
✉️ 手动通知
`}
💡 交付流程:工作目录放交付物 → 网页类点「部署 Demo」生成在线地址 → 「发送给送达者」把 Demo 链接 + 打包附件发邮件;文件类直接「打包+发送」。项目所有任务验收后也会自动完成并通知送达者。
🗂️ 项目工作目录
data/workspace/${esc(p.workspace_dir || ('project_' + pid))}/(与其他项目隔离)
${isView ? '' : '⬆️ 上传 '}
${isView ? '' : `
`}
📋 交付记录
时间 类型 名称 大小 备注
${(dels.data || []).map(d => `${fmtTime(d.created_at)}
${{file:'📄 文件', package:'🗜️ 打包', email:'📧 邮件', webpage:'🌐 Demo'}[d.kind] || d.kind}
${esc(d.name)}${d.demo_url ? ` 查看 ` : ''}
${d.size ? fmtSize(d.size) : '—'}
${esc(d.note || '')} `).join('') || '暂无交付记录 '}
`;
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 = '工作目录为空,上传交付物文件后即可打包/部署
'; return; }
box.innerHTML = `文件 大小 修改时间 操作
${ws.map(f => `
${esc(f.path)}
${fmtSize(f.size)}
${fmtTime(f.mtime)}
⬇️
🗑️
`).join('')}
`;
}
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') {
const du = p.deliver_user ? (p.deliver_user.display_name || p.deliver_user.username) + '(' + p.deliver_user.email + ')' : (p.deliver_email || '');
if (!confirm(`将把项目「${p.name}」的交付物(Demo 链接 + 打包附件)发送给送达者 ${du},确定?`)) 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') {
const du = p.deliver_user ? (p.deliver_user.display_name || p.deliver_user.username) + '(' + p.deliver_user.email + ')' : (p.deliver_email || '');
if (!confirm(`确认完成项目「${p.name}」?将部署 Demo、打包交付物并通过邮件通知送达者 ${du}。`)) 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(`
✉️ 手动通知送达者×
主题
消息内容(将随项目信息一并发送)
取消
发送
`);
$('#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 = `
AI Worker 管理模型 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案
${canManage ? `+ 注册 Worker
` : '👁️ 仅显示你有权限查看的 Worker(由管理员授权)
'}
ID 名称 模型 角色提示词 温度/上限 本月成本 成本上限 状态 权限 操作
${ws.data.map(w => `
#${w.id}
${esc(w.name)} ${esc(w.description || '')}
${esc(w.provider)} ${esc(w.model)}
${esc((w.system_prompt || '—').slice(0, 40))}
${w.temperature} / ${w.max_tokens}
${fmtMoney(w.month_cost)}
${w.task_cost_limit ? '单任务 ¥' + w.task_cost_limit : '—'} ${w.monthly_cost_limit ? '月 ¥' + w.monthly_cost_limit : ''}
${w.status === 'enabled' ? '启用' : '停用'}
${{view:'查看',use:'使用',manage:'管理',admin:'管理员'}[w.perm] || w.perm}
测试
🖼️ 视觉测试
${canManage ? `编辑
删除 ` : ''}
`).join('') || '还没有 Worker,注册第一个虚拟员工吧 '}
`;
}
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(`
🖼️ 视觉能力测试
多模态智能体:输入图片 URL 与问题,验证图像理解能力。示例:
https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/640px-Cat03.jpg
图片 URL
问题
🚀 开始分析
`);
$('#vt-go').addEventListener('click', async () => {
const url = $('#vt-url').value.trim();
if (!url) return toast('请填写图片 URL', 'err');
$('#vt-out').innerHTML = ' 视觉分析中(多模态调用约 10-60 秒)…
';
try {
const r = await api(`/api/workers/${wid}/vision_test`, {method:'POST', body: {image_url: url, question: $('#vt-q').value}});
$('#vt-out').innerHTML = `${esc(r.data.text)}
tokens ${r.data.total_tokens} · 成本 ${fmtMoney(r.data.cost)} · 延迟 ${r.data.latency_ms ?? '—'}ms
`;
} catch (e) { $('#vt-out').innerHTML = `${esc(e.message)}
`; }
});
}
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(`
${w.id ? '编辑 Worker' : '注册 Worker'}×
Worker 名称 *
模型供应商
${providers.map(p => `${esc(p.name)}${p.has_key ? '' : '(未配 Key)'} `).join('')}
模型
角色提示词(System Prompt)
角色描述
状态
启用
停用
取消
${w.id ? `测试连通 ` : ''}
保存
`);
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 = `
成本报表按项目 × 任务 × Worker × 模型 多维核算
${head.map(h => `${h} `).join('')}
${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 `${group === 'project' ? `${esc(x.project_name || '—')} ` : group === 'worker' ?
`${esc(x.worker_name || '—')} ${esc(x.provider || '')} ${esc(x.model || '')} ` :
`${esc(x.provider || '')} ${esc(x.model || '')} `}
${x.runs} ${(tokens/1e6).toFixed(3)}M
${fmtMoney(cost)} `;
}).join('') || '暂无成本数据,先派几个任务吧 '}
`;
}
/* ---------- 运行日志 ---------- */
let logsState = {page: 1, pageSize: 50, level: '', q: ''};
window.logsState = logsState; // 供分页组件通过 window[stateVar] 读写
async function pageLogs() {
const st = logsState;
const qs = new URLSearchParams({page: st.page, page_size: st.pageSize, level: st.level});
if (st.q) qs.set('q', st.q);
const r = await api(`/api/logs?${qs}`);
const totalPages = Math.max(1, Math.ceil(r.total / st.pageSize));
const levels = ['info', 'success', 'warn', 'error'];
const lvLabel = {info:'ℹ️ 信息', success:'✅ 成功', warn:'⚠️ 警告', error:'❌ 错误'};
$('#main').innerHTML = `
运行日志全链路可追溯,每个结论都能溯源
${pagerHtml(r.page, totalPages, r.total, 'logsState', 'pageLogs', 'top')}
${r.data.map(l => `
${fmtTime(l.created_at)}
[任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}
`).join('') || '
暂无日志
'}
${pagerHtml(r.page, totalPages, r.total, 'logsState', 'pageLogs', 'bottom')}`;
bindPagerGo('logsState', totalPages, 'pageLogs');
const qel = $('#logs-q');
if (qel) qel.addEventListener('keydown', e => { if (e.key === 'Enter') { logsState.q = qel.value.trim(); logsState.page = 1; pageLogs(); } });
}
/* ---------- 告警中心 ---------- */
let alertsState = {page: 1, pageSize: 20, level: '', type: '', read: ''};
window.alertsState = alertsState; // 供分页组件通过 window[stateVar] 读写
async function pageAlerts() {
const st = alertsState;
const qs = new URLSearchParams({page: st.page, page_size: st.pageSize});
if (st.level) qs.set('level', st.level);
if (st.type) qs.set('type', st.type);
if (st.read !== '') qs.set('read', st.read);
const r = await api(`/api/alerts?${qs}`);
const totalPages = Math.max(1, Math.ceil(r.total / st.pageSize));
const levels = ['info', 'warn', 'critical'];
const lvLabel = {info:'ℹ️', warn:'⚠️', critical:'🚨'};
$('#main').innerHTML = `
告警中心任务失败 · 预算超标 · Worker 异常 · 成本拦截
级别
全部
${levels.map(l => `${lvLabel[l]} ${l} `).join('')}
类型
全部
${['task_failed','task_review','reject','budget','blocker','eval_done'].map(t => `${{task_failed:'任务失败', task_review:'待审核', reject:'打回', budget:'预算', blocker:'难关', eval_done:'评估完成'}[t] || t} `).join('')}
状态
全部
未读
已读
✔ 全部标记已读
${pagerHtml(r.page, totalPages, r.total, 'alertsState', 'pageAlerts', 'top')}
${r.data.map(a => `
${ALERT_LEVEL[a.level] || 'ℹ️'} ${esc(a.title)} ${a.read ? '' : '未读 '}
${esc(a.detail)}
${fmtTime(a.created_at)} · ${esc(a.type)}
`).join('') || '
暂无告警 🎉
'}
${pagerHtml(r.page, totalPages, r.total, 'alertsState', 'pageAlerts', 'bottom')}`;
bindPagerGo('alertsState', totalPages, 'pageAlerts');
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();
}
/* ---------- 分页组件(快速定位页数,顶部/底部各一份 + 每页条数) ---------- */
function pagerHtml(page, totalPages, total, stateVar, refreshFn, pos = 'bottom') {
const st = window[stateVar] || {};
const sizes = [10, 20, 50, 100];
const sizeSel = `
${sizes.map(s => `${s} 条/页 `).join('')}
`;
if (totalPages <= 1) return total ? `` : '';
const pages = [];
const add = p => { if (pages[pages.length - 1] !== p && p >= 1 && p <= totalPages) pages.push(p); };
add(1);
const lo = Math.max(2, page - 2), hi = Math.min(totalPages - 1, page + 2);
if (lo > 2) pages.push('…');
for (let p = lo; p <= hi; p++) add(p);
if (hi < totalPages - 1) pages.push('…');
add(totalPages);
return ``;
}
function setPageSize(stateVar, size, refreshFn) {
window[stateVar].pageSize = parseInt(size) || 50;
window[stateVar].page = 1;
window[refreshFn]();
}
function goPageInput(stateVar, totalPages, refreshFn, pos) {
const el = document.getElementById('pager-go-' + pos);
const v = parseInt(el?.value || '');
if (!v || v < 1 || v > totalPages) return toast(`页码需在 1-${totalPages} 之间`, 'err');
window[stateVar].page = v;
window[refreshFn]();
}
function bindPagerGo(stateVar, totalPages, refreshFn) {
['top', 'bottom'].forEach(pos => {
const el = document.getElementById('pager-go-' + pos);
if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') goPageInput(stateVar, totalPages, refreshFn, pos); });
});
}
/* ---------- 开放 API ---------- */
async function pageApiTokens() {
const tokens = (await api('/api/tokens')).data;
$('#main').innerHTML = `
开放 API外部系统通过 Bearer Token 调平台能力
+ 生成 API Token
已生成的 Token
名称 创建时间 最近使用 操作
${tokens.map(t => `${esc(t.name)} ${fmtTime(t.created_at)}
${t.last_used_at ? fmtTime(t.last_used_at) : '从未使用'}
删除 `).join('') || '暂无 Token '}
调用示例
POST /api/tasks 创建任务
curl -X POST http://<host>:16071/api/tasks \\
-H "Authorization: Bearer <TOKEN>" \\
-H "Content-Type: application/json" \\
-d '{"project_id":1,"title":"生成周报","description":"…"}'
# 响应: {"ok":true,"id":42}
POST /api/tasks/42/run 派活执行
curl -X POST http://<host>:16071/api/tasks/42/run \\
-H "Authorization: Bearer <TOKEN>"
GET /api/tasks/42 查结果(status=review 时人工审核)
POST /api/tasks/42/review 审核
curl -X POST http://<host>:16071/api/tasks/42/review \\
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
-d '{"action":"approve"}' # 或 {"action":"reject","reason":"…"}
`;
}
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 = `
通知与设置飞书 / 企微群机器人 · 邮件 · 预算告警阈值
+ 添加通知渠道
通知渠道
名称 类型 订阅事件 状态 操作
${chans.data.map(c => `
${esc(c.name)}
${c.type === 'feishu' ? '飞书' : c.type === 'wecom' ? '企微' : '邮件'}
${(c.events || []).map(e => `${(evs.data.find(x => x.id === e) || {}).name || e} `).join('') || '—'}
${c.enabled ? '启用' : '停用'}
测试
编辑
删除
`).join('') || '暂无渠道,添加飞书/企微机器人后任务事件自动推送 '}
💡 飞书群机器人:群设置 → 群机器人 → 添加自定义机器人,复制 Webhook 地址(以 https://open.feishu.cn/open-apis/bot/v2/hook/ 开头)。企微同理(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…)。
预算告警
项目预算使用率告警阈值(0~1,达到即告警)
保存
告警会写入告警中心并推送到订阅了「预算告警」事件的渠道。
邮件通知
${st.data.email_configured ? '✅ 已配置 SMTP(config.py EMAIL)' : '⚠️ 未配置 SMTP(config.py 的 EMAIL 填写 host/user/password 后重启生效)'}
`;
}
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(`
${c.id ? '编辑通知渠道' : '添加通知渠道'}×
渠道名称 *
类型
飞书群机器人
企业微信群机器人
邮件
Webhook 地址
收件邮箱
订阅事件
${events.map(e => ` ${esc(e.name)} `).join('')}
启用 接收推送
取消
保存
`);
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 =>
`${esc(w.name)}(${esc(w.model)}) `).join('');
// 各模式专属参数定义(m: 适用模式列表)
const paramRows = [
{id: 'ag-workers', m: 'all', html: `参与 Agent(可多选)${wOpts} `},
{id: 'ag-rounds', m: 'review', html: `最大返工轮次1 轮(快) 2 轮(推荐) 3 轮(严苛) `},
{id: 'ag-pass', m: 'review', html: `通过分数线60 分(宽松) 70 分(标准) 80 分(严格) 90 分(严苛) `},
{id: 'ag-debate-rounds', m: 'debate', html: `质询轮次1 轮 2 轮 3 轮 `},
{id: 'ag-stances', m: 'debate', html: `辩论立场(逗号分隔,按辩手顺序分配) `},
{id: 'ag-context', m: 'all', html: `背景上下文 `},
];
const modeHints = {
supervisor: '💡 第 1 个 Agent 担任主管(负责拆解与最终合成),其余 Agent 并行执行子任务。',
review: '💡 第 1 个 Agent 为执行者,第 2 个为评审(只选 1 个时由同一 Agent 自评)。低于分数线自动返工,最多重试所选轮次。',
debate: '💡 除最后 1 个 Agent 为裁判外,其余均为辩手;立场按顺序分配,裁判综合裁决输出共识。',
};
$('#main').innerHTML = `
多 Agent 协作主管 / 评审 / 辩论 三种团队协作模式
${modes.map(m => `
${m.icon}
${esc(m.name)}
${esc(m.desc)}
${m.id === 'supervisor' ? '拆解+委派+合成' : m.id === 'review' ? '产出+评审+返工' : '立论+质询+裁决'}
`).join('')}
⚡ 发起协作运行
协作模式${modes.map(m => `${m.icon} ${esc(m.name)} `).join('')}
标题
任务主题 / 议题
${paramRows.map(r => r.html).join('')}
${modeHints.supervisor}
🚀 发起运行
📋 运行记录 刷新
ID 模式 标题 状态 过程摘要 成本 时间 操作
${runs.map(r => `
#${r.id} ${esc(r.mode)}
${esc(r.title || r.topic.slice(0, 30))}
${({done:'✅ 完成',running:'⏳ 执行中',failed:'❌ 失败',cancelled:'已取消'})[r.status] || r.status}
${esc(r.summary || '—')}
${fmtMoney(r.cost)}
${fmtTime(r.created_at)}
查看
`).join('') || '暂无运行记录 '}
`;
// 模式切换:动态显示/隐藏专属参数
const applyMode = mode => {
$$('.form-grid [data-modes]').forEach(el => {
const ms = el.dataset.modes.split(',');
el.style.display = (ms.includes('all') || ms.includes(mode)) ? '' : 'none';
});
$('#ag-hint').textContent = modeHints[mode] || '';
$$('.mode-card').forEach(c => c.style.outline = c.dataset.mode === mode ? '2px solid var(--accent)' : '');
};
$('#ag-mode').addEventListener('change', e => applyMode(e.target.value));
$$('.mode-card').forEach(c => c.addEventListener('click', () => {
$('#ag-mode').value = c.dataset.mode;
applyMode(c.dataset.mode);
}));
applyMode($('#ag-mode').value);
$('#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(`
🤝 协作运行 #${d.id} · ${esc(d.mode)}
状态 ${d.status}
主题 ${esc(d.topic)}
摘要 ${esc(d.summary || '—')}
成本 / tokens ${fmtMoney(d.cost)} / ${d.total_tokens}
执行过程
${(d.steps || []).map(s => `
[${esc(s.role)} / ${esc(s.stage)}] ${esc(s.worker_name || '')}
${esc(s.content)} `).join('') || '暂无步骤
'}
最终产出
${esc(d.result || '(执行中…)')}
关闭 `);
}
/* ---------- 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 =>
`${esc(w.name)}(${esc(w.model)}) `).join('');
$('#main').innerHTML = `
自动评估体系eval 数据集沉淀 · LLM 评委打分 · Worker 排行榜
🏆 Worker 评估排行榜
排名 Worker 模型 评估次数 平均分 累计成本
${board.data.map((b, i) => `
${i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : '#' + (i+1)}
${esc(b.worker_name)} ${esc(b.model)} ${b.runs}
${b.avg_score} /100${fmtMoney(b.cost)} `).join('') || '暂无评估数据,先跑一次评估吧 '}
📚 数据集
+ 新建数据集
名称 用例 最佳分 操作
${datasets.data.map(ds => `
${esc(ds.name)}${ds.is_builtin ? ' 内置 ' : ''}
${ds.case_count} ${ds.best_score != null ? ds.best_score : '—'}
管理用例
🚀 评估 `).join('') || '暂无数据集 '}
🔄 评估运行记录
📥 沉淀已验收任务产出
ID 数据集 Worker 状态 得分 进度 操作
${runs.data.map(r => `
#${r.id} ${esc(r.dataset_name)} ${esc(r.worker_name)}
${({done:'✅ 完成',running:'⏳ 进行中',failed:'❌ 失败'})[r.status] || r.status}
${r.status === 'done' ? r.score : '—'}
${r.cases_done}/${r.cases_total}
查看 `).join('') || '暂无运行记录 '}
💡 评估 = Worker 逐条作答 → LLM 评委按 rubric 打分。沉淀功能会把已验收通过的任务产出自动转为评测用例。
⚡ 发起评估
数据集${datasets.data.map(ds => `${esc(ds.name)}(${ds.case_count} 用例) `).join('')}
Worker${wOpts}
🚀 开始评估
`;
$('#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(`
新建评估数据集
名称
描述
评分标准 rubric
创建 `);
$('#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(`
📚 ${esc(ds.name)} ${ds.case_count || ds.cases.length} 用例
${esc(ds.description)}
评分标准
${esc(ds.rubric || '(未设置,使用默认)')}
用例列表
${(ds.cases || []).map(c => `
Q${c.id}: ${esc(c.input.slice(0, 120))}
期望:${esc((c.expected || '—').slice(0, 120))}
删除
`).join('') || '暂无用例
'}
添加用例
输入
期望输出
+ 添加用例
关闭 `);
$('#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(`🚀 评估数据集 #${id}
将用选定 Worker 逐条作答并由 LLM 评委打分,每条用例约需 1~2 分钟。
知道了 `);
}
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(`
🎯 评估运行 #${d.id} · ${esc(d.dataset_name)}
Worker ${esc(d.worker_name)}
平均分 ${d.status === 'done' ? d.score : '…'} /100
进度 ${d.cases_done}/${d.cases_total}
成本 ${fmtMoney(d.cost)}
逐条结果
${(d.results || []).map(r => `
用例 #${r.case_id} 得分 ${r.score} · ${r.latency_ms}ms · ${fmtMoney(r.cost)}
输入:${esc(r.input || '')}
评委意见 ${esc(r.judgment)}
执行输出 ${esc(r.output)}
`).join('') || '暂无结果
'}
关闭 `);
}
/* ---------- 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 => `${esc(p.name)} `).join('');
const wOpts = workers.data.map(w => `${esc(w.name)} `).join('');
$('#main').innerHTML = `
模板市场任务 / 项目 / Agent 团队 一键复用
+ 新建模板
♻️ 恢复内置模板
${['task', 'project', 'team'].map(t => `
${typeMap[t]}
${tpls.data.filter(x => x.type === t).map(x => `
${esc(x.name)} ${x.is_builtin ? '内置 ' : ''}
${esc(x.description)}
使用 ${x.usage_count} 次${x.tags.map(tg => ` · ${esc(tg)} `).join('')}
使用模板
${x.is_builtin ? '' : `删除 `}
`).join('') || '
暂无模板
'}
`).join('')}
`;
window._tplProjOpts = projOpts; window._tplWorkerOpts = wOpts;
}
function tplCreate() {
openModal(`
新建模板
类型任务模板 项目模板 团队模板
名称
描述
内容(JSON)
标签(逗号分隔)
保存 `);
$('#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 = `目标项目${window._tplProjOpts}
执行 Worker自动路由 ${window._tplWorkerOpts} `;
if (type === 'team') extra = `供应商火山方舟 DeepSeek 通义千问 OpenAI 本地 vLLM
模型 `;
const placeholders = Object.keys(t.content || {}).filter(k => typeof t.content[k] === 'string' && t.content[k].includes('{')).map(k => `${esc(k)} 占位值 `).join('');
openModal(`
使用模板:${esc(t.name)}
${esc(t.description)}
${extra}${placeholders}
应用模板 `);
$('#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/V3 · 企业版 ---------- */
let entTab = 'users';
let entSub = 'list'; // users 子页:list 用户列表 / roles 角色管理 / groups Worker 权限组
async function pageEnterprise() {
const [users, audit, sso, comp, roles, groups] = await Promise.all([
api('/api/enterprise/users'), api('/api/enterprise/audit?limit=100'),
api('/api/enterprise/sso'), api('/api/enterprise/compliance'),
api('/api/enterprise/roles'), api('/api/enterprise/worker_groups')
]);
$('#main').innerHTML = `
企业版私有化 · 角色权限 · Worker 权限组 · SSO · 审计 · 合规
`;
window._entData = {users: users.data, audit: audit.data, sso: sso.data, comp: comp.data,
roles: roles.data, groups: groups.data};
entRender();
}
function entSwitch(tab) { entTab = tab; entRender(); }
function entSubSwitch(sub) { entSub = sub; entRender(); }
function entRender() {
const d = window._entData || {};
const box = $('#ent-body');
if (entTab === 'users') {
box.innerHTML = `
`;
if (entSub === 'roles') entRenderRoles();
else if (entSub === 'groups') entRenderGroups();
else entRenderUsers();
} else if (entTab === 'sso') {
const s = d.sso || {};
box.innerHTML = `
SSO 登录入口
🚀 通过 OIDC 发起登录
登录页可对接企业统一身份;私有化部署时 SSO 保证账号体系受企业管控。回调地址需与 IdP 侧配置一致。
`;
$('#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 = `
📜 审计日志 ⬇️ 导出 CSV
时间 操作者 动作 目标 详情 IP
${(d.audit || []).map(a => `${fmtTime(a.created_at)} ${esc(a.actor)} ${esc(a.action)}
${esc(a.target)} ${esc(a.detail)} ${esc(a.ip)} `).join('') || '暂无审计记录 '}
登录/登出、协作运行、评估、数据导出等关键操作自动留痕,满足等保审计要求。
`;
} else {
const c = d.comp || {};
box.innerHTML = `
📤 数据导出(GDPR / 个人信息保护法)
⬇️ 全量数据导出(JSON)
⬇️ 审计日志导出(CSV)
导出包含项目/任务/协作运行/评估/用户/审计全量数据,供数据可携带权与监管审计使用。
📊 存量统计
审计记录 ${c.audit_count} 条
协作运行 ${c.agent_runs_count} 次
评估运行 ${c.eval_runs_count} 次
数据使用同意凭证 ${esc(c.consent_token || '—')}
`;
$('#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 entRenderUsers() {
const d = window._entData || {};
const box = $('#ent-sub-body');
api('/api/enterprise/grants/overview').then(r => {
const gmap = {};
(r.data || []).forEach(u => gmap[u.id] = u);
box.innerHTML = `
👥 用户列表(RBAC + 精准授权)
+ 新建用户
🗺️ 授权总览
用户名 显示名 邮箱 角色 自定义角色/权限组 来源 状态 操作
${(d.users || []).map(u => {
const g = gmap[u.id] || {};
const extra = [...(g.roles || []).map(rr => `🎭 ${esc(rr.role_name)}`), ...(g.worker_groups || []).map(wg => `🤖 ${esc(wg.group_name)}`)].join(' ');
return `${esc(u.username)} ${esc(u.display_name)}
${esc(u.email || '缺邮箱 ')}
${({admin:'👑 管理员',member:'成员',auditor:'审计员'})[u.role] || u.role}
${extra || '— '}
${({local:'本地',oidc:'OIDC',ldap:'LDAP'})[u.source] || u.source}
${u.status === 'active' ? '✅ 启用' : '🚫 停用'}
🔑 授权
编辑 `;
}).join('')}
💡 权限体系:角色 (内置角色可编辑权限点;自定义角色任意创建)+ 项目授权 (查看/管理/管理员)+ Worker 授权 (查看/使用/管理,可直接授权或通过权限组批量授权)。成员仅能看到被授权的项目与 Worker,创建项目者自动成为该项目管理员。
`;
}).catch(() => {});
}
/* ---------- 🎭 角色管理 ---------- */
const PERM_NAMES = {};
let PERM_GROUPS = [];
async function entRenderRoles() {
const [roles, pts] = await Promise.all([api('/api/enterprise/roles'), api('/api/enterprise/perm_points')]);
window._entData.roles = roles.data;
PERM_GROUPS = pts.data;
pts.data.forEach(g => g.points.forEach(p => PERM_NAMES[p.id] = p.name));
$('#ent-sub-body').innerHTML = `
🎭 角色管理 角色 = 一组权限功能点;管理员可自定义角色、可编辑内置角色权限
+ 新建角色
角色 说明 权限功能点 绑定用户 类型 操作
${roles.data.map(r => `
${r.is_builtin ? {admin:'👑 ', auditor:'🔍 ', member:'👤 '}[r.name] || '' : '🎭 '}${esc(r.name)}
${esc(r.description || '—')}
${(r.perms || []).map(p => `${esc(PERM_NAMES[p] || p)} `).join(' ') || '无 '}
${r.user_count} 人
${r.is_builtin ? '内置' : '自定义'}
编辑
${r.is_builtin ? '' : `删除 `} `).join('')}
💡 自定义角色可被多个用户绑定(在用户授权弹窗中勾选),权限自动合并:内置角色基础权限 ∪ 自定义角色权限。内置角色不可删除但可编辑权限点。
`;
}
async function entRoleModal(rid) {
let r = {name: '', description: '', perms: []};
if (rid) r = (await api(`/api/enterprise/roles/${rid}`)).data;
const has = new Set(r.perms || []);
openModal(`
${rid ? '编辑角色' : '新建角色'}×
角色名 *
说明
权限功能点
${PERM_GROUPS.map(g => `
`).join('')}
取消
保存
`);
// 组内全选/取消
$$('#modal-box [data-pgroup]').forEach(group => {
const cbs = group.querySelectorAll('[data-pp]');
group.querySelector('.pg-all').addEventListener('change', e => cbs.forEach(cb => cb.checked = e.target.checked));
cbs.forEach(cb => cb.addEventListener('change', () => {
group.querySelector('.pg-all').checked = [...cbs].every(c => c.checked);
}));
});
$('#rm-save').addEventListener('click', async () => {
const perms = [...$$('#modal-box input[data-pp]:checked')].map(cb => cb.dataset.pp);
if (!$('#rm-name').value.trim()) return toast('请填写角色名', 'err');
try {
if (rid) await api(`/api/enterprise/roles/${rid}`, {method:'PUT', body:{name: $('#rm-name').value.trim(), description: $('#rm-desc').value, perms}});
else await api('/api/enterprise/roles', {method:'POST', body:{name: $('#rm-name').value.trim(), description: $('#rm-desc').value, perms}});
toast('✅ 角色已保存', 'ok'); closeModal(); entRenderRoles();
} catch (e) { toast(e.message, 'err'); }
});
}
async function entRoleDel(rid) {
if (!confirm('删除该角色?绑定该角色的用户将失去其附加权限。')) return;
await api(`/api/enterprise/roles/${rid}`, {method:'DELETE'});
toast('已删除', 'ok'); entRenderRoles();
}
/* ---------- 🤖 Worker 权限组 ---------- */
async function entRenderGroups() {
const groups = (await api('/api/enterprise/worker_groups')).data;
window._entData.groups = groups;
$('#ent-sub-body').innerHTML = `
🤖 Worker 权限组 把一批 Worker+权限打包成组,整组授权给用户,批量管理
+ 新建权限组
组名 说明 成员 Worker(权限) 授权用户 操作
${groups.map(g => `
${esc(g.name)}
${esc(g.description || '—')}
${(g.members || []).map(m => `${esc(m.worker_name)} · ${{view:'查看',use:'使用',manage:'管理'}[m.perm] || m.perm} `).join(' ') || '空组 '}
${(g.users || []).map(u => `${esc(u.username)} `).join(' ') || '未授权 '}
编辑
删除 `).join('') || '还没有权限组,新建一个把常用 Worker 打包吧 '}
💡 权限组与直接授权并行生效(取两者更高权限);给用户绑定权限组后,组内 Worker 自动对用户可见。典型用法:把「文案小助手+设计助手」设为「文案组」=use,一次性授权给所有内容运营。
`;
}
async function entGroupModal(gid) {
let g = {name: '', description: '', members: [], users: []};
if (gid) g = (await api(`/api/enterprise/worker_groups/${gid}`)).data;
const [ws, us] = await Promise.all([api('/api/workers'), api('/api/enterprise/users')]);
const memberMap = {};
(g.members || []).forEach(m => memberMap[m.worker_id] = m.perm);
const userSet = new Set((g.users || []).map(u => u.user_id));
openModal(`
${gid ? '编辑权限组' : '新建权限组'}×
组名 *
说明
组内 Worker(勾选 + 权限)
授权给用户
${us.data.filter(u => u.role !== 'admin').map(u => ` ${esc(u.username)}(${esc(u.display_name || u.role)}) `).join('')}
取消
保存
`);
$$('#modal-box [data-gw]').forEach(cb => cb.addEventListener('change', () => {
const sel = $(`[data-gwperm="${cb.dataset.gw}"]`);
if (sel) sel.disabled = !cb.checked;
}));
$('#gm-save').addEventListener('click', async () => {
const members = [...$$('#modal-box [data-gw]:checked')].map(cb => ({
worker_id: Number(cb.dataset.gw),
perm: $(`[data-gwperm="${cb.dataset.gw}"]`).value
}));
const users = [...$$('#modal-box [data-gu]:checked')].map(cb => Number(cb.dataset.gu));
if (!$('#gm-name').value.trim()) return toast('请填写组名', 'err');
try {
const body = {name: $('#gm-name').value.trim(), description: $('#gm-desc').value, members, users};
if (gid) await api(`/api/enterprise/worker_groups/${gid}`, {method:'PUT', body});
else await api('/api/enterprise/worker_groups', {method:'POST', body});
toast('✅ 权限组已保存', 'ok'); closeModal(); entRenderGroups();
} catch (e) { toast(e.message, 'err'); }
});
}
async function entGroupDel(gid) {
if (!confirm('删除该权限组?组内 Worker 授权将从用户身上移除。')) return;
await api(`/api/enterprise/worker_groups/${gid}`, {method:'DELETE'});
toast('已删除', 'ok'); entRenderGroups();
}
function entAddUser() {
openModal(`
新建用户
用户名
显示名
邮箱 *
角色成员 管理员 审计员
来源本地 OIDC LDAP
密码(本地用户必填)
创建 `);
$('#eu-save').addEventListener('click', async () => {
const email = $('#eu-email').value.trim();
if (!email || !email.includes('@')) return toast('必填:用户邮箱', 'err');
try {
await api('/api/enterprise/users', {method: 'POST', body: {
username: $('#eu-name').value.trim(), display_name: $('#eu-display').value,
email, 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(`
编辑用户 ${esc(u.username)}
显示名
邮箱 *
角色member admin auditor
状态启用 停用
重置密码(留空不改)
保存
删除用户 `);
$('#eu2-save').addEventListener('click', async () => {
const email = $('#eu2-email').value.trim();
if (!email || !email.includes('@')) return toast('必填:用户邮箱', 'err');
await api(`/api/enterprise/users/${id}`, {method: 'PUT', body: {
display_name: $('#eu2-display').value, email,
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();
});
}
/* ---------- 精准授权:项目 / 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, roles, worker_groups} = r.data;
openModal(`
🔑 精准授权:${esc(uname)}×
项目权限:查看=只读;管理=建任务/执行/交付;管理员=全部。Worker 权限:查看/使用/管理(可直接授权,也可通过权限组批量)。自定义角色决定功能模块可见性。
🎭 绑定自定义角色(可多选,权限与内置角色合并)
${(roles || []).filter(rr => !rr.is_builtin || rr.name !== 'admin').map(rr => ` ${esc(rr.name)} ${esc((rr.perms || []).length)} 个权限点 `).join('') || '暂无自定义角色 '}
🤖 绑定 Worker 权限组(可多选)
${(worker_groups || []).map(g => ` ${esc(g.name)} ${g.member_count} 个 Worker `).join('') || '暂无权限组,去「Worker 权限组」创建 '}
📁 项目授权(${projects.length} 个)
${projects.map(p => `
#${p.id} ${esc(p.name)} ${{active:'进行中',review:'待验收',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}
${PROJ_PERMS.map(([v, l]) => `${l} `).join('')}
`).join('') || '暂无项目 '}
🤖 Worker 直接授权(${workers.length} 个)
${workers.map(w => `
#${w.id} ${esc(w.name)} ${esc(w.provider)}/${esc(w.model)} ${w.status === 'enabled' ? '启用' : '停用'}
${WORKER_PERMS.map(([v, l]) => `${l} `).join('')}
`).join('') || '暂无 Worker '}
取消
保存授权
`);
$('#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})),
roles: [...$$('[data-role]:checked')].map(cb => Number(cb.dataset.role)),
worker_groups: [...$$('[data-wg]:checked')].map(cb => Number(cb.dataset.wg))
}});
toast('✅ 授权已保存', 'ok'); closeModal(); pageEnterprise();
} catch (e) { toast(e.message, 'err'); }
});
}
async function entGrantOverview() {
const r = await api('/api/enterprise/grants/overview');
openModal(`
🗺️ 授权总览×
用户 角色 自定义角色/权限组 项目授权 Worker 授权
${r.data.map(u => `${esc(u.username)} ${esc(u.display_name || '')}
${{admin:'👑 管理员',member:'成员',auditor:'审计员'}[u.role] || u.role}
${[...(u.roles || []).map(rr => `🎭 ${esc(rr.role_name)}`), ...(u.worker_groups || []).map(g => `🤖 ${esc(g.group_name)}`)].join(' ') || '— '}
${(u.projects || []).map(p => `#${p.project_id} ${esc(p.project_name)} ${{view:'查看',manage:'管理',admin:'管理员'}[p.perm]} `).join(' ') || '— '}
${(u.workers || []).map(w => `#${w.worker_id} ${esc(w.worker_name)} ${{view:'查看',use:'使用',manage:'管理'}[w.perm]} `).join(' ') || '— '} `).join('')}
关闭
`);
}
/* ---------- 启动 ---------- */
let CURRENT_ROLE = 'admin';
(async function init() {
try {
const me = await api('/api/me');
if (!me.authed) { showLogin(); $('#main').innerHTML = '请先登录
'; return; }
CURRENT_ROLE = me.user?.role || 'admin';
applyRoleUI(CURRENT_ROLE, me.user?.perms);
$('#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();
})();