- 节点拖拽调整布局(位移按缩放比例换算,拖动不误触详情点击) - 滚轮以鼠标为锚点缩放(0.25x-3x),双击空白一键复位 - 拖拽空白区域平移画布(cursor grab/grabbing) - 布局与视图状态 localStorage 按项目记忆,刷新/重开自动恢复 - 实测:节点精确拖动、锚点缩放、平移、复位、点击详情全部通过
1646 lines
93 KiB
JavaScript
1646 lines
93 KiB
JavaScript
/* 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:'🚨'};
|
||
|
||
/* ---------- 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, '"')})">编辑项目</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, '"')})">编辑</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, '"')})">编辑</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,'"')})">+ 注册 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,'"')}, ${JSON.stringify(ps.data).replace(/"/g,'"')})">编辑</button>
|
||
<button class="btn sm danger" onclick="delWorker(${w.id})">删除</button></td>
|
||
</tr>`).join('') || '<tr><td colspan="9" class="empty">还没有 Worker,注册第一个虚拟员工吧</td></tr>'}
|
||
</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://<host>:16071/api/tasks \\
|
||
-H "Authorization: Bearer <TOKEN>" \\
|
||
-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://<host>:16071/api/tasks/42/run \\
|
||
-H "Authorization: Bearer <TOKEN>"</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://<host>:16071/api/tasks/42/review \\
|
||
-H "Authorization: Bearer <TOKEN>" -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,'"')}, ${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 ? '✅ 已配置 SMTP(config.py EMAIL)' : '⚠️ 未配置 SMTP(config.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();
|
||
})();
|