915 lines
52 KiB
JavaScript
915 lines
52 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:{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 = '<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 工作流 --- */
|
||
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 = `
|
||
<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"><svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||
<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>
|
||
${edges.map(e => {
|
||
const a = pos[e.from], b = pos[e.to];
|
||
if (!a || !b) return '';
|
||
const mx = (a.x + b.x) / 2;
|
||
const act = idMap[e.from].status === 'done';
|
||
return `<path 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 = pos[t.id];
|
||
const col = colors[t.status];
|
||
return `<g>
|
||
<rect class="dag-node" x="${p.x-55}" y="${p.y-24}" width="110" height="48" fill="${col}" stroke="#3a4a6a" onclick="openTaskDetail(${t.id})"/>
|
||
<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>`;
|
||
}
|
||
|
||
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="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 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();
|
||
});
|
||
}
|
||
|
||
/* ---------- 启动 ---------- */
|
||
(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();
|
||
})();
|