Files
ai-worker-platform/static/app.js
T

915 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* AI Worker 项目管理平台 - 前端 SPAV1) */
'use strict';
const $ = (sel, el = document) => el.querySelector(sel);
const $$ = (sel, el = document) => [...el.querySelectorAll(sel)];
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmtTime = ts => ts ? new Date(ts * 1000).toLocaleString('zh-CN', {hour12:false}) : '—';
const fmtMoney = v => (v ?? 0) >= 0.01 ? '¥' + Number(v).toFixed(2) : '¥' + Number(v ?? 0).toFixed(4);
const STATUS = {
todo: {label:'待执行', cls:'st-todo'}, running:{label:'执行中', cls:'st-running'},
review:{label:'待审核', cls:'st-review'}, done:{label:'已完成', cls:'st-done'},
rejected:{label:'已打回', cls:'st-rejected'}, failed:{label:'失败', cls:'st-failed'},
cancelled:{label:'已取消', cls:'st-cancelled'}
};
const PRI = {high:'高', medium:'中', low:'低'};
const KANBAN_ORDER = ['todo','running','review','done','rejected','failed'];
const ALERT_LEVEL = {info:'️', warn:'⚠️', critical:'🚨'};
/* ---------- API ---------- */
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: {'Content-Type': 'application/json'},
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined
});
let data = {};
try { data = await res.json(); } catch (e) {}
if (res.status === 401) { showLogin(); throw new Error('未登录'); }
if (!res.ok || data.ok === false) throw new Error(data.error || `请求失败(${res.status})`);
return data;
}
/* ---------- Toast ---------- */
function toast(msg, type = '') {
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = msg;
$('#toast-wrap').appendChild(el);
setTimeout(() => el.remove(), 3200);
}
/* ---------- Login ---------- */
function showLogin() { $('#login-mask').style.display = 'flex'; }
function hideLogin() { $('#login-mask').style.display = 'none'; }
$('#login-btn').addEventListener('click', async () => {
try {
await api('/api/login', {method:'POST', body:{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, '&quot;')})">编辑项目</button>
<button class="btn" onclick="openWbsModal()">🧠 AI 规划(WBS)</button>
<button class="btn primary" onclick="openTaskModal()"> 新建任务</button>
</div>
<div class="tabs">
<a href="#/project/${pid}/kanban" class="${tab === 'kanban' ? 'active' : ''}">看板</a>
<a href="#/project/${pid}/dag" class="${tab === 'dag' ? 'active' : ''}">DAG 工作流</a>
<a href="#/project/${pid}/kb" class="${tab === 'kb' ? 'active' : ''}">知识库 RAG</a>
</div>
<div id="tab-body"></div>`;
}
async function refreshProj() {
projCtx.tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data;
if (projCtx.tab === 'dag') renderDag();
else renderKanban();
}
/* --- 看板 --- */
function renderKanban() {
const {tasks} = projCtx;
const by = {};
tasks.forEach(t => { (by[t.status] = by[t.status] || []).push(t); });
$('#tab-body').innerHTML = `<div style="overflow-x:auto"><div class="kanban" id="kanban">${KANBAN_ORDER.map(s => `
<div class="kb-col" data-status="${s}">
<div class="kb-head"><span><span class="badge ${STATUS[s].cls}">${STATUS[s].label}</span></span>
<span class="cnt" id="cnt-${s}">0</span></div>
<div class="kb-body" data-drop="${s}"></div>
</div>`).join('')}</div></div>`;
KANBAN_ORDER.forEach(s => {
const list = by[s] || [];
$('#cnt-' + s).textContent = list.length;
$('#kanban .kb-body[data-drop="' + s + '"]').innerHTML = list.map(t => `
<div class="kb-card" draggable="${t.status === 'todo' || t.status === 'rejected' || t.status === 'done'}"
data-id="${t.id}" onclick="openTaskDetail(${t.id})">
<div class="t">${esc(t.title)}</div>
<div class="m">
<span class="pri-${t.priority}">●${PRI[t.priority]}</span>
${t.worker_id ? `<span class="pill" style="color:var(--accent)">Worker#${t.worker_id}</span>` : '<span class="pill">自动路由</span>'}
${t.depends_on && t.depends_on.length ? `<span class="pill" style="color:var(--purple)">依赖×${t.depends_on.length}</span>` : ''}
${t.rejection_count ? `<span class="badge st-rejected">打回×${t.rejection_count}</span>` : ''}
${t.status === 'running' ? '<span class="spin"></span>' : ''}
</div>
${t.status === 'done' ? '<div class="out">✅ 已交付 v' + t.output_version + '</div>' : ''}
</div>`).join('') || '<div style="color:var(--muted);font-size:12px;text-align:center;padding:16px 0">空</div>';
});
bindKanbanDnD();
}
function bindKanbanDnD() {
$$('.kb-body').forEach(bd => {
bd.addEventListener('dragover', e => { e.preventDefault(); bd.classList.add('drag-over'); });
bd.addEventListener('dragleave', () => bd.classList.remove('drag-over'));
bd.addEventListener('drop', e => {
e.preventDefault(); bd.classList.remove('drag-over');
const tid = parseInt(e.dataTransfer.getData('text/plain'));
moveTask(tid, bd.dataset.drop);
});
});
$$('.kb-card[draggable="true"]').forEach(c => {
c.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', c.dataset.id);
c.classList.add('dragging');
});
c.addEventListener('dragend', () => c.classList.remove('dragging'));
});
}
async function moveTask(tid, status) {
const t = projCtx.tasks.find(x => x.id === tid);
if (!t || t.status === status) return;
if (['running', 'review'].includes(status)) return toast('不能直接拖到该状态', 'err');
if (status === 'todo' && !['rejected', 'failed', 'done'].includes(t.status)) return;
if (status === 'done') return toast('完成请走审核流(审核通过)', 'err');
await api(`/api/tasks/${tid}`, {method:'PUT', body:{status}});
toast('状态已更新', 'ok'); refreshProj();
}
/* --- DAG 工作流 --- */
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, '&quot;')})">编辑</button>
<button class="btn danger" onclick="delTask(${t.id})">删除</button>` : ''}
${t.status === 'running' ? `<button class="btn warn" onclick="cancelTask(${t.id})">⏹ 取消执行</button>` : ''}
${t.status === 'review' ? `<button class="btn success" onclick="reviewTask(${t.id}, 'approve')">✅ 审核通过</button>
<button class="btn warn" onclick="reviewTask(${t.id}, 'reject')">⛔ 打回返工</button>` : ''}
${t.status === 'failed' ? `<button class="btn primary" onclick="runTask(${t.id})">🔄 重试</button>
<button class="btn" onclick="openTaskModal(${JSON.stringify(t).replace(/"/g, '&quot;')})">编辑</button>` : ''}
${['done','cancelled'].includes(t.status) ? `<button class="btn" onclick="reopenTask(${t.id})">↩️ 重新打开</button>` : ''}
</div>`);
}
function logLine(l) {
const icons = {info:'️', success:'✅', warn:'⚠️', error:'❌'};
return `<div class="log-line ${l.level}"><span class="ts">${fmtTime(l.created_at)}</span>${icons[l.level] || ''} ${esc(l.message)}</div>`;
}
async function reviewTask(tid, action) {
if (action === 'reject') {
const reason = prompt('请输入打回原因(将退回重做):');
if (reason === null) return;
if (!reason.trim()) return toast('打回必须填写原因', 'err');
await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action, reason: reason.trim()}});
} else {
await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action}});
}
toast(action === 'approve' ? '✅ 验收通过,任务完成' : '任务已打回,等待返工', 'ok');
closeModal(); refreshProj();
}
async function cancelTask(tid) {
if (!confirm('确认取消该任务?')) return;
await api(`/api/tasks/${tid}/cancel`, {method:'POST'});
toast('已取消', 'ok'); closeModal(); refreshProj();
}
async function reopenTask(tid) {
await api(`/api/tasks/${tid}`, {method:'PUT', body:{status:'todo'}});
toast('任务已重新打开', 'ok'); closeModal(); refreshProj();
}
async function delTask(tid) {
if (!confirm('确认删除该任务及其日志/成本记录?')) return;
await api(`/api/tasks/${tid}`, {method:'DELETE'});
toast('已删除', 'ok'); closeModal(); refreshProj();
}
/* ---------- AI 规划 WBS ---------- */
function openWbsModal() {
const {p} = projCtx;
openModal(`
<h3>🧠 AI 辅助规划(WBS 任务分解)<button class="close" data-close>×</button></h3>
<label>项目目标(默认取项目 objective</label>
<textarea id="wb-goal">${esc(p.objective || p.name || '')}</textarea>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="wb-gen">生成任务分解</button>
</div>
<div id="wb-result"></div>`);
$('#wb-gen').addEventListener('click', async () => {
const goal = $('#wb-goal').value.trim();
if (!goal) return toast('请填写项目目标', 'err');
$('#wb-result').innerHTML = '<div class="empty"><span class="spin"></span>AI 正在拆解任务(约 10-30 秒)…</div>';
try {
const r = await api(`/api/projects/${projCtx.pid}/wbs/generate`, {method:'POST', body:{goal}});
let wbs = r.data;
$('#wb-result').innerHTML = `
<div class="sec-title" style="margin-top:16px">生成 ${wbs.length} 个任务(可编辑后导入)· 花费 ¥${r.cost.toFixed(4)} / ${r.usage} tokens</div>
${wbs.map((t, i) => `
<div class="wbs-row">
<div style="display:flex;align-items:center;gap:8px">
<span class="idx">${i+1}</span>
<input value="${esc(t.title || '')}" style="flex:1;font-weight:600" data-f="title" data-i="${i}">
</div>
<textarea style="margin-top:8px" data-f="desc" data-i="${i}">${esc(t.description || '')}</textarea>
<div style="font-size:12px;color:var(--muted);margin-top:6px">依赖任务:${wbs.map((_, j) => `<label style="margin:0 6px 0 0"><input type="checkbox" data-dep="${i}" value="${j}" ${(t.depends_on||[]).includes(j) ? 'checked' : ''}>#${j+1}</label>`).join('')}</div>
</div>`).join('')}
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="wb-import">✅ 导入为项目任务</button>
</div>`;
$('#wb-import').addEventListener('click', async () => {
const tasks = wbs.map((t, i) => ({
title: $(`[data-f="title"][data-i="${i}"]`).value.trim() || t.title,
description: $(`[data-f="desc"][data-i="${i}"]`).value,
depends_on: $$(`[data-dep="${i}"]:checked`).map(x => parseInt(x.value))
}));
const rr = await api(`/api/projects/${projCtx.pid}/wbs/import`, {method:'POST', body:{tasks, worker_id: projCtx.workers[0]?.id || null}});
toast(`✅ 已导入 ${rr.created} 个任务(含依赖关系)`, 'ok');
closeModal(); refreshProj(); router();
});
} catch (e) { $('#wb-result').innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
});
}
/* ---------- Worker 管理 ---------- */
async function pageWorkers() {
const [ws, ps] = await Promise.all([api('/api/workers'), api('/api/providers')]);
$('#main').innerHTML = `
<h1 class="page-title">AI Worker 管理<small>模型 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案</small></h1>
<div class="toolbar"><button class="btn primary" onclick="openWorkerModal(null, ${JSON.stringify(ps.data).replace(/"/g,'&quot;')})"> 注册 Worker</button></div>
<table><thead><tr><th>ID</th><th>名称</th><th>模型</th><th>角色提示词</th><th>温度/上限</th><th>本月成本</th><th>成本上限</th><th>状态</th><th>操作</th></tr></thead><tbody>
${ws.data.map(w => `
<tr>
<td class="mono">#${w.id}</td>
<td><b>${esc(w.name)}</b><br><span style="color:var(--muted);font-size:12px">${esc(w.description || '')}</span></td>
<td><span class="pill">${esc(w.provider)}</span> <span class="pill">${esc(w.model)}</span></td>
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(w.system_prompt)}">${esc((w.system_prompt || '—').slice(0, 40))}</td>
<td class="mono">${w.temperature} / ${w.max_tokens}</td>
<td class="mono">${fmtMoney(w.month_cost)}</td>
<td class="mono">${w.task_cost_limit ? '单任务 ¥' + w.task_cost_limit : '—'}<br>${w.monthly_cost_limit ? '月 ¥' + w.monthly_cost_limit : ''}</td>
<td><span class="badge ${w.status === 'enabled' ? 'st-done' : 'st-cancelled'}">${w.status === 'enabled' ? '启用' : '停用'}</span></td>
<td><button class="btn sm" onclick="testWorker(${w.id})">测试</button>
<button class="btn sm" onclick="openWorkerModal(${JSON.stringify(w).replace(/"/g,'&quot;')}, ${JSON.stringify(ps.data).replace(/"/g,'&quot;')})">编辑</button>
<button class="btn sm danger" onclick="delWorker(${w.id})">删除</button></td>
</tr>`).join('') || '<tr><td colspan="9" class="empty">还没有 Worker,注册第一个虚拟员工吧</td></tr>'}
</tbody></table>`;
}
async function testWorker(wid) {
toast('正在测试连通性…');
try { const r = await api(`/api/workers/${wid}/test`, {method:'POST'});
toast(`✅ 连通正常:${r.data.latency_ms}ms,回复「${r.data.reply}」,成本 ¥${r.data.cost}`, 'ok'); }
catch (e) { toast('❌ ' + e.message, 'err'); }
}
async function delWorker(wid) {
if (!confirm('确认删除该 Worker?(历史任务将解除指派)')) return;
await api(`/api/workers/${wid}`, {method:'DELETE'});
toast('已删除', 'ok'); router();
}
function openWorkerModal(w, providers) {
w = w || {};
const sel = (providerId) => providers.find(p => p.id === providerId);
openModal(`
<h3>${w.id ? '编辑 Worker' : '注册 Worker'}<button class="close" data-close>×</button></h3>
<label>Worker 名称 *</label><input id="wm-name" value="${esc(w.name || '')}" placeholder="例如:文案小助手">
<label>模型供应商</label><select id="wm-provider" onchange="onProviderChange()">
${providers.map(p => `<option value="${p.id}" ${w.provider === p.id ? 'selected' : ''}>${esc(p.name)}${p.has_key ? '' : '(未配 Key'}</option>`).join('')}
</select>
<label>模型</label><input id="wm-model" value="${esc(w.model || '')}" placeholder="如 doubao-seed-evolving / deepseek-chat">
<div class="row">
<div><label>自定义 Base URL(留空用默认)</label><input id="wm-base" value="${esc(w.base_url || '')}" placeholder="${esc(sel(w.provider)?.base_url || '')}"></div>
<div><label>自定义 API Key(留空用全局)</label><input id="wm-key" value="${esc(w.api_key || '')}"></div>
</div>
<label>角色提示词(System Prompt</label><textarea id="wm-prompt" placeholder="你是资深文案专家,擅长小红书种草文案…">${esc(w.system_prompt || '')}</textarea>
<label>角色描述</label><input id="wm-desc" value="${esc(w.description || '')}" placeholder="一句话描述这个虚拟员工的职责">
<div class="row">
<div><label>Temperature</label><input id="wm-temp" type="number" step="0.1" min="0" max="2" value="${w.temperature ?? 0.7}"></div>
<div><label>Max Tokens</label><input id="wm-max" type="number" min="1" value="${w.max_tokens ?? 2000}"></div>
</div>
<div class="row">
<div><label>单任务成本上限(元)</label><input id="wm-tasklimit" type="number" step="0.01" value="${w.task_cost_limit || 0}"></div>
<div><label>月度成本上限(元)</label><input id="wm-monthlimit" type="number" step="0.01" value="${w.monthly_cost_limit || 0}"></div>
</div>
<label>状态</label><select id="wm-status">
<option value="enabled" ${w.status !== 'disabled' ? 'selected' : ''}>启用</option>
<option value="disabled" ${w.status === 'disabled' ? 'selected' : ''}>停用</option>
</select>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
${w.id ? `<button class="btn" onclick="testWorker(${w.id})">测试连通</button>` : ''}
<button class="btn primary" id="wm-save">保存</button>
</div>`);
window.onProviderChange = () => {
const p = providers.find(x => x.id === $('#wm-provider').value);
if (!p) return;
const base = $('#wm-base');
if (!base.value || base.placeholder === base.value) base.value = p.base_url;
base.placeholder = p.base_url;
const model = $('#wm-model');
if (p.models.length && !model.value) model.value = p.models[0];
};
$('#wm-save').addEventListener('click', async () => {
const body = {
name: $('#wm-name').value.trim(), provider: $('#wm-provider').value,
model: $('#wm-model').value.trim(), base_url: $('#wm-base').value.trim(),
api_key: $('#wm-key').value.trim(), system_prompt: $('#wm-prompt').value,
description: $('#wm-desc').value.trim(), temperature: parseFloat($('#wm-temp').value || 0.7),
max_tokens: parseInt($('#wm-max').value || 2000),
task_cost_limit: parseFloat($('#wm-tasklimit').value || 0),
monthly_cost_limit: parseFloat($('#wm-monthlimit').value || 0),
status: $('#wm-status').value
};
if (!body.name || !body.provider || !body.model) return toast('名称/供应商/模型必填', 'err');
try {
await api(w.id ? `/api/workers/${w.id}` : '/api/workers', {method: w.id ? 'PUT' : 'POST', body});
toast('已保存', 'ok'); closeModal(); router();
} catch (e) { toast(e.message, 'err'); }
});
}
/* ---------- 成本报表 ---------- */
async function pageReports() {
const group = location.hash.includes('group=') ? location.hash.split('group=')[1] : 'project';
const r = (await api('/api/reports/cost?group=' + group)).data;
const s = (await api('/api/stats')).data;
const head = {project:['项目','调用次数','Tokens','成本'], worker:['Worker','模型','调用次数','Tokens','成本'], model:['供应商/模型','调用次数','Tokens','成本']}[group];
$('#main').innerHTML = `
<h1 class="page-title">成本报表<small>按项目 × 任务 × Worker × 模型 多维核算</small></h1>
<div class="toolbar">
<a class="btn ${group==='project'?'primary':''}" href="#/reports/group=project">按项目</a>
<a class="btn ${group==='worker'?'primary':''}" href="#/reports/group=worker">按 Worker</a>
<a class="btn ${group==='model'?'primary':''}" href="#/reports/group=model">按模型</a>
<span style="margin-left:auto" class="pager-tip">累计成本 ${fmtMoney(s.total_cost)} · ${(s.total_tokens/1e6).toFixed(2)}M tokens</span>
</div>
<div class="card"><table><thead><tr>${head.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>
${r.map(x => {
const cost = x.cost || 0, tokens = x.tokens || 0;
const maxCost = Math.max(...r.map(v => v.cost || 0), 0.001);
return `<tr>${group === 'project' ? `<td><b>${esc(x.project_name || '—')}</b></td>` : group === 'worker' ?
`<td><b>${esc(x.worker_name || '—')}</b></td><td><span class="pill">${esc(x.provider || '')}</span> <span class="pill">${esc(x.model || '')}</span></td>` :
`<td><span class="pill">${esc(x.provider || '')}</span> <span class="pill">${esc(x.model || '')}</span></td>`}
<td>${x.runs}</td><td class="mono">${(tokens/1e6).toFixed(3)}M</td>
<td class="mono">${fmtMoney(cost)}<div class="bar"><div style="width:${cost/maxCost*100}%"></div></div></td></tr>`;
}).join('') || '<tr><td colspan="5" class="empty">暂无成本数据,先派几个任务吧</td></tr>'}
</tbody></table></div>`;
}
/* ---------- 运行日志 ---------- */
async function pageLogs() {
const logs = (await api('/api/logs?limit=200')).data;
$('#main').innerHTML = `
<h1 class="page-title">运行日志<small>全链路可追溯,每个结论都能溯源</small></h1>
<div class="card">${logs.map(l => `<div class="log-line ${l.level}"><span class="ts">${fmtTime(l.created_at)}</span>
[任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}</div>`).join('') || '<div class="empty">暂无日志</div>'}</div>`;
}
/* ---------- 告警中心 ---------- */
async function pageAlerts() {
const alerts = (await api('/api/alerts?limit=200')).data;
$('#main').innerHTML = `
<h1 class="page-title">告警中心<small>任务失败 · 预算超标 · Worker 异常 · 成本拦截</small></h1>
<div class="toolbar"><button class="btn" onclick="readAllAlerts()">✔ 全部标记已读</button></div>
<div class="card">${alerts.map(a => `
<div class="alert-row ${a.read ? '' : 'unread'} ${a.level === 'critical' ? 'critical' : ''}" onclick="readAlert(${a.id})">
<div class="t">${ALERT_LEVEL[a.level] || '️'} ${esc(a.title)} ${a.read ? '' : '<span class="badge st-review">未读</span>'}</div>
<div class="d">${esc(a.detail)}</div>
<div class="tm">${fmtTime(a.created_at)} · ${esc(a.type)}</div>
</div>`).join('') || '<div class="empty">暂无告警 🎉</div>'}</div>`;
refreshAlertBadge();
}
async function readAlert(id) {
await api(`/api/alerts/${id}/read`, {method:'POST'});
pageAlerts();
}
async function readAllAlerts() {
await api('/api/alerts/read_all', {method:'POST'});
toast('已全部标记为已读', 'ok'); pageAlerts();
}
/* ---------- 开放 API ---------- */
async function pageApiTokens() {
const tokens = (await api('/api/tokens')).data;
$('#main').innerHTML = `
<h1 class="page-title">开放 API<small>外部系统通过 Bearer Token 调平台能力</small></h1>
<div class="toolbar">
<button class="btn primary" onclick="createToken()"> 生成 API Token</button>
</div>
<div class="grid g2">
<div class="card">
<div class="sec-title" style="margin-top:0">已生成的 Token</div>
<table><thead><tr><th>名称</th><th>创建时间</th><th>最近使用</th><th>操作</th></tr></thead><tbody>
${tokens.map(t => `<tr><td><b>${esc(t.name)}</b></td><td class="mono">${fmtTime(t.created_at)}</td>
<td class="mono">${t.last_used_at ? fmtTime(t.last_used_at) : '从未使用'}</td>
<td><button class="btn sm danger" onclick="delToken(${t.id})">删除</button></td></tr>`).join('') || '<tr><td colspan="4" class="empty">暂无 Token</td></tr>'}
</tbody></table>
</div>
<div class="card">
<div class="sec-title" style="margin-top:0">调用示例</div>
<div style="font-size:13px;line-height:2">
<div><code>POST /api/tasks</code> 创建任务</div>
<pre class="output-box" style="font-size:12px">curl -X POST http://&lt;host&gt;:16071/api/tasks \\
-H "Authorization: Bearer &lt;TOKEN&gt;" \\
-H "Content-Type: application/json" \\
-d '{"project_id":1,"title":"生成周报","description":"…"}'
# 响应: {"ok":true,"id":42}</pre>
<div><code>POST /api/tasks/42/run</code> 派活执行</div>
<pre class="output-box" style="font-size:12px">curl -X POST http://&lt;host&gt;:16071/api/tasks/42/run \\
-H "Authorization: Bearer &lt;TOKEN&gt;"</pre>
<div><code>GET /api/tasks/42</code> 查结果(status=review 时人工审核)</div>
<div><code>POST /api/tasks/42/review</code> 审核</div>
<pre class="output-box" style="font-size:12px">curl -X POST http://&lt;host&gt;:16071/api/tasks/42/review \\
-H "Authorization: Bearer &lt;TOKEN&gt;" -H "Content-Type: application/json" \\
-d '{"action":"approve"}' # 或 {"action":"reject","reason":"…"}</pre>
</div>
</div>
</div>`;
}
async function createToken() {
const name = prompt('Token 名称(用途说明):') || 'default';
const r = await api('/api/tokens', {method:'POST', body:{name}});
alert('✅ 生成成功(只显示一次,请立即复制保存):\n\n' + r.token);
pageApiTokens();
}
async function delToken(id) {
if (!confirm('删除后使用该 Token 的调用将失效,确认?')) return;
await api(`/api/tokens/${id}`, {method:'DELETE'});
toast('已删除', 'ok'); pageApiTokens();
}
/* ---------- 通知设置 ---------- */
async function pageSettings() {
const [chans, evs, st] = await Promise.all([api('/api/channels'), api('/api/events'), api('/api/settings')]);
$('#main').innerHTML = `
<h1 class="page-title">通知与设置<small>飞书 / 企微群机器人 · 邮件 · 预算告警阈值</small></h1>
<div class="toolbar"><button class="btn primary" onclick="openChannelModal(null, ${JSON.stringify(evs.data)})"> 添加通知渠道</button></div>
<div class="grid g2">
<div class="card">
<div class="sec-title" style="margin-top:0">通知渠道</div>
<table><thead><tr><th>名称</th><th>类型</th><th>订阅事件</th><th>状态</th><th>操作</th></tr></thead><tbody>
${chans.data.map(c => `<tr>
<td><b>${esc(c.name)}</b></td>
<td><span class="pill">${c.type === 'feishu' ? '飞书' : c.type === 'wecom' ? '企微' : '邮件'}</span></td>
<td>${(c.events || []).map(e => `<span class="pill">${(evs.data.find(x => x.id === e) || {}).name || e}</span>`).join('') || '—'}</td>
<td><span class="badge ${c.enabled ? 'st-done' : 'st-cancelled'}">${c.enabled ? '启用' : '停用'}</span></td>
<td><button class="btn sm" onclick="testChannel(${c.id})">测试</button>
<button class="btn sm" onclick="openChannelModal(${JSON.stringify(c).replace(/"/g,'&quot;')}, ${JSON.stringify(evs.data)})">编辑</button>
<button class="btn sm danger" onclick="delChannel(${c.id})">删除</button></td>
</tr>`).join('') || '<tr><td colspan="5" class="empty">暂无渠道,添加飞书/企微机器人后任务事件自动推送</td></tr>'}
</tbody></table>
<div class="tab-note" style="margin-top:10px">💡 飞书群机器人:群设置 → 群机器人 → 添加自定义机器人,复制 Webhook 地址(以 https://open.feishu.cn/open-apis/bot/v2/hook/ 开头)。企微同理(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…)。</div>
</div>
<div class="card">
<div class="sec-title" style="margin-top:0">预算告警</div>
<label>项目预算使用率告警阈值(0~1,达到即告警)</label>
<div class="row"><input id="st-ratio" type="number" step="0.05" min="0.1" max="1" value="${st.data.budget_alert_ratio}">
<button class="btn primary" onclick="saveSettings()">保存</button></div>
<div class="tab-note" style="margin-top:12px">告警会写入告警中心并推送到订阅了「预算告警」事件的渠道。</div>
<div class="sec-title">邮件通知</div>
<div class="tab-note">${st.data.email_configured ? '✅ 已配置 SMTPconfig.py EMAIL' : '⚠️ 未配置 SMTPconfig.py 的 EMAIL 填写 host/user/password 后重启生效)'}</div>
</div>
</div>`;
}
async function saveSettings() {
await api('/api/settings', {method:'PUT', body:{budget_alert_ratio: parseFloat($('#st-ratio').value)}});
toast('已保存', 'ok');
}
async function testChannel(id) {
toast('正在发送测试消息…');
try { const r = await api(`/api/channels/${id}/test`, {method:'POST'});
r.ok ? toast('✅ 测试消息已送达', 'ok') : toast('❌ ' + r.msg, 'err'); }
catch (e) { toast('❌ ' + e.message, 'err'); }
}
async function delChannel(id) {
if (!confirm('确认删除该渠道?')) return;
await api(`/api/channels/${id}`, {method:'DELETE'});
toast('已删除', 'ok'); pageSettings();
}
function openChannelModal(c, events) {
c = c || {};
openModal(`
<h3>${c.id ? '编辑通知渠道' : '添加通知渠道'}<button class="close" data-close>×</button></h3>
<label>渠道名称 *</label><input id="cm-name" value="${esc(c.name || '')}" placeholder="例如:项目群飞书机器人">
<label>类型</label><select id="cm-type">
<option value="feishu" ${c.type === 'feishu' ? 'selected' : ''}>飞书群机器人</option>
<option value="wecom" ${c.type === 'wecom' ? 'selected' : ''}>企业微信群机器人</option>
<option value="email" ${c.type === 'email' ? 'selected' : ''}>邮件</option>
</select>
<div id="cm-webhook-wrap"><label>Webhook 地址</label><input id="cm-webhook" value="${esc(c.webhook || '')}"></div>
<div id="cm-email-wrap" style="display:none"><label>收件邮箱</label><input id="cm-email" value="${esc(c.email || '')}"></div>
<label>订阅事件</label>
<div class="check-group">
${events.map(e => `<label><input type="checkbox" value="${e.id}" ${(c.events || []).includes(e.id) ? 'checked' : ''}>${esc(e.name)}</label>`).join('')}
</div>
<label>启用</label><div><label class="switch"><input type="checkbox" id="cm-enabled" ${c.enabled !== 0 ? 'checked' : ''}> 接收推送</label></div>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="cm-save">保存</button>
</div>`);
const toggle = () => {
const isMail = $('#cm-type').value === 'email';
$('#cm-webhook-wrap').style.display = isMail ? 'none' : '';
$('#cm-email-wrap').style.display = isMail ? '' : 'none';
};
$('#cm-type').addEventListener('change', toggle);
toggle();
$('#cm-save').addEventListener('click', async () => {
const body = {
name: $('#cm-name').value.trim(), type: $('#cm-type').value,
webhook: $('#cm-webhook').value.trim(), email: $('#cm-email').value.trim(),
events: $$('#modal-box .check-group input:checked').map(x => x.value),
enabled: $('#cm-enabled').checked ? 1 : 0
};
if (!body.name || (body.type !== 'email' && !body.webhook) || (body.type === 'email' && !body.email))
return toast('请填写完整', 'err');
await api(c.id ? `/api/channels/${c.id}` : '/api/channels', {method: c.id ? 'PUT' : 'POST', body});
toast('已保存', 'ok'); closeModal(); pageSettings();
});
}
/* ---------- 启动 ---------- */
(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();
})();