/* 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:{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
};
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 = '
加载中…
';
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('') || '| 暂无任务,去 创建项目 吧 |
'}
`;
}
/* ---------- 项目列表 ---------- */
async function pageProjects() {
const list = (await api('/api/projects')).data;
$('#main').innerHTML = `
项目立项 → 规划 → 执行 → 验收 → 归档
${list.map(p => `
${esc(p.name)}${{active:'进行中', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}
${esc(p.objective || p.description || '暂无描述')}
任务 ${p.task_count} · 完成 ${p.done_count}
${p.budget_limit ? '预算 ¥' + p.budget_limit : ''}
`).join('') || '
还没有项目,点右上角新建
'}
`;
}
function openProjectModal(p = {}) {
openModal(`
${p.id ? '编辑项目' : '新建项目'}
`);
$('#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 = `
`;
}
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 工作流 --- */
function renderDag() {
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 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}; });
});
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')));
$('#tab-body').innerHTML = `
💡 节点点击查看详情;前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。
`;
}
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 = `
文档列表
${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 ? '编辑文档' : '添加文档'}
`);
$('#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 ? '编辑任务' : '新建任务'}
${candidates.length ? candidates.map(c => `
`).join('') : '暂无其他任务可选'}
`);
$('#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 || '—'}
任务指令
${esc(t.description || t.title)}
${t.output_text ? `产出物 v${t.output_version}(${t.output_text.length} 字)
${esc(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('确认删除该任务及其日志/成本记录?')) return;
await api(`/api/tasks/${tid}`, {method:'DELETE'});
toast('已删除', 'ok'); closeModal(); refreshProj();
}
/* ---------- AI 规划 WBS ---------- */
function openWbsModal() {
const {p} = projCtx;
openModal(`
🧠 AI 辅助规划(WBS 任务分解)
`);
$('#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)}
`; }
});
}
/* ---------- Worker 管理 ---------- */
async function pageWorkers() {
const [ws, ps] = await Promise.all([api('/api/workers'), api('/api/providers')]);
$('#main').innerHTML = `
AI 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' ? '启用' : '停用'} |
|
`).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 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'}
${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('') || '| 暂无成本数据,先派几个任务吧 |
'}
`;
}
/* ---------- 运行日志 ---------- */
async function pageLogs() {
const logs = (await api('/api/logs?limit=200')).data;
$('#main').innerHTML = `
运行日志全链路可追溯,每个结论都能溯源
${logs.map(l => `
${fmtTime(l.created_at)}
[任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}
`).join('') || '
暂无日志
'}
`;
}
/* ---------- 告警中心 ---------- */
async function pageAlerts() {
const alerts = (await api('/api/alerts?limit=200')).data;
$('#main').innerHTML = `
告警中心任务失败 · 预算超标 · Worker 异常 · 成本拦截
${alerts.map(a => `
${ALERT_LEVEL[a.level] || 'ℹ️'} ${esc(a.title)} ${a.read ? '' : '未读'}
${esc(a.detail)}
${fmtTime(a.created_at)} · ${esc(a.type)}
`).join('') || '
暂无告警 🎉
'}
`;
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 = `
开放 API外部系统通过 Bearer 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=…)。
预算告警
告警会写入告警中心并推送到订阅了「预算告警」事件的渠道。
邮件通知
${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 ? '编辑通知渠道' : '添加通知渠道'}
${events.map(e => ``).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();
});
}
/* ---------- 启动 ---------- */
(async function init() {
try {
const me = await api('/api/me');
if (!me.authed) { showLogin(); $('#main').innerHTML = '请先登录
'; 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();
})();