506 lines
29 KiB
JavaScript
506 lines
29 KiB
JavaScript
/* 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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, '"')})">编辑项目</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,'"')})">+ 注册 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 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();
|
||
})();
|