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

506 lines
29 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 项目管理平台 - 前端 SPA */
'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'];
/* ---------- 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(); });
/* ---------- Router ---------- */
const routes = {
'dashboard': pageDashboard, 'projects': pageProjects, 'project': pageProject,
'workers': pageWorkers, 'reports': pageReports, 'logs': pageLogs
};
function router() {
const hash = location.hash.replace(/^#\//, '') || 'dashboard';
const [name, ...rest] = hash.split('/');
const fn = routes[name] || pageDashboard;
$$('#sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.route === (name === 'project' ? 'projects' : name)));
$('#main').innerHTML = '<div class="empty"><span class="spin"></span>加载中…</div>';
fn(rest).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'); }
});
}
/* ---------- 项目详情(看板) ---------- */
let kanbanData = null;
async function pageProject([pid]) {
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;
kanbanData = {pid, tasks, workers};
$('#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 primary" onclick="openTaskModal(${pid})"> 新建任务</button>
</div>
<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>`;
renderKanban();
$$('.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 = e.dataTransfer.getData('text/plain');
moveTask(parseInt(tid), bd.dataset.drop);
});
});
}
function renderKanban() {
const {tasks} = kanbanData;
const by = {};
tasks.forEach(t => { (by[t.status] = by[t.status] || []).push(t); });
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 ${t.status === 'running' ? '' : ''}" 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.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>';
});
$$('.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 = kanbanData.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'); refreshKanban();
}
async function refreshKanban() {
const {pid} = kanbanData;
kanbanData.tasks = (await api(`/api/tasks?project_id=${pid}`)).data;
renderKanban();
}
function openTaskModal(pid) {
const workers = kanbanData ? kanbanData.workers : [];
openModal(`
<h3>新建任务<button class="close" data-close>×</button></h3>
<label>任务标题 *</label><input id="tm-title" placeholder="例如:生成 10 条小红书种草文案">
<label>任务说明(发给 AI Worker 的指令)</label><textarea id="tm-desc" placeholder="描述任务目标、要求、输出格式…"></textarea>
<div class="row">
<div><label>执行 Worker</label><select id="tm-worker">
<option value="">🤖 自动路由(最便宜可用)</option>
${workers.map(w => `<option value="${w.id}">${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}">${v}</option>`).join('')}
</select></div>
</div>
<div class="row">
<div><label>截止时间</label><input id="tm-deadline" type="date"></div>
<div><label>完成后人工审核</label><select id="tm-review"><option value="1">需要(HITL</option><option value="0">不需要</option></select></div>
</div>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="tm-save">创建并派活</button>
</div>`);
$('#tm-save').addEventListener('click', async () => {
const title = $('#tm-title').value.trim();
if (!title) return toast('请填写任务标题', 'err');
try {
const r = await api('/api/tasks', {method:'POST', body:{
project_id: pid, 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'
}});
toast('任务已创建', 'ok'); closeModal(); refreshKanban();
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'); refreshKanban(); }
catch (e) { toast(e.message, 'err'); }
}
/* ---------- 任务详情(含审核) ---------- */
async function openTaskDetail(tid) {
const t = (await api(`/api/tasks/${tid}`)).data;
const workers = kanbanData ? kanbanData.workers : (await api('/api/workers')).data;
const w = t.worker || (t.worker_id ? {id: t.worker_id, name: `#${t.worker_id}`} : null);
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 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(l => logLine(l)).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 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>` : ''}
${['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(); refreshKanban();
}
async function cancelTask(tid) {
if (!confirm('确认取消该任务?')) return;
await api(`/api/tasks/${tid}/cancel`, {method:'POST'});
toast('已取消', 'ok'); closeModal(); refreshKanban();
}
async function reopenTask(tid) {
await api(`/api/tasks/${tid}`, {method:'PUT', body:{status:'todo'}});
toast('任务已重新打开', 'ok'); closeModal(); refreshKanban();
}
async function delTask(tid) {
if (!confirm('确认删除该任务及其日志/成本记录?')) return;
await api(`/api/tasks/${tid}`, {method:'DELETE'});
toast('已删除', 'ok'); closeModal(); refreshKanban();
}
/* ---------- 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 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'; }
router();
})();