/* 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 = '
加载中…
'; fn(rest).catch(e => { $('#main').innerHTML = `
${esc(e.message)}
`; }); } window.addEventListener('hashchange', router); /* ---------- 仪表盘 ---------- */ async function pageDashboard() { const s = (await api('/api/stats')).data; const total = Object.values(s.by_status).reduce((a, b) => a + b, 0); const pct = n => total ? Math.round(n / total * 100) : 0; $('#main').innerHTML = `

仪表盘AI 虚拟团队运营总览

项目
${s.projects}
进行中 ${s.by_status.done ?? 0} 个已完成任务
任务总数
${s.tasks}
完成率 ${pct(s.by_status.done ?? 0)}% · 待审核 ${s.by_status.review ?? 0}
AI Worker
${s.workers}
虚拟员工档案数
累计成本
${fmtMoney(s.total_cost)}
${(s.total_tokens/1e6).toFixed(2)}M tokens 消耗
质量指标
一次通过率
${s.one_pass_rate ?? '—'}%(验收一次通过)
返工次数
${s.rework_count} 次打回
执行失败
${s.by_status.failed ?? 0} 个任务
近 7 天成本趋势(元)
${s.daily_cost.map(d => `
${d.day} ${d.cost.toFixed(4)}
`).join('')}
任务状态分布
${Object.entries(STATUS).filter(([k]) => s.by_status[k]).map(([k, v]) => `
${v.label} ${s.by_status[k]} 个 ${pct(s.by_status[k])}%
`).join('') || '
暂无数据
'}
最近任务动态
${s.recent.map(t => ``).join('') || ''}
项目任务状态更新时间
${esc(t.project_name || '—')}${esc(t.title)} ${STATUS[t.status].label}${fmtTime(t.updated_at)}
暂无任务,去 创建项目
`; } /* ---------- 项目列表 ---------- */ async function pageProjects() { const list = (await api('/api/projects')).data; $('#main').innerHTML = `

项目立项 → 规划 → 执行 → 验收 → 归档

${list.map(p => `
${esc(p.name)}${{active:'进行中', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}
${esc(p.objective || p.description || '暂无描述')}
任务 ${p.task_count} · 完成 ${p.done_count} ${p.budget_limit ? '预算 ¥' + p.budget_limit : ''}
`).join('') || '
还没有项目,点右上角新建
'}
`; } function openProjectModal(p = {}) { openModal(`

${p.id ? '编辑项目' : '新建项目'}

`); $('#pm-save').addEventListener('click', async () => { const body = { name: $('#pm-name').value.trim(), objective: $('#pm-objective').value, acceptance_criteria: $('#pm-accept').value, description: $('#pm-desc').value, status: $('#pm-status').value, budget_limit: parseFloat($('#pm-budget').value || 0) }; if (!body.name) return toast('请填写项目名称', 'err'); try { await api(p.id ? `/api/projects/${p.id}` : '/api/projects', {method: p.id ? 'PUT' : 'POST', body}); toast('已保存', 'ok'); closeModal(); router(); } catch (e) { toast(e.message, 'err'); } }); } /* ---------- 项目详情(看板) ---------- */ 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 = `
← 返回

${esc(p.name)}${esc(p.objective || '')}

${KANBAN_ORDER.map(s => `
${STATUS[s].label} 0
`).join('')}
`; 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 => `
${esc(t.title)}
●${PRI[t.priority]} ${t.worker_id ? `Worker#${t.worker_id}` : '自动路由'} ${t.rejection_count ? `打回×${t.rejection_count}` : ''} ${t.status === 'running' ? '' : ''}
${t.status === 'done' ? '
✅ 已交付 v' + t.output_version + '
' : ''}
`).join('') || '
'; }); $$('.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(`

新建任务

`); $('#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(`

${esc(t.title)}${STATUS[t.status].label}

执行 Worker
${w ? `${esc(w.name)} ${esc(w.provider)}/${esc(w.model)}` : '未指派(自动路由)'}
优先级
●${PRI[t.priority]} · 返工 ${t.rejection_count} 次
创建/更新
${fmtTime(t.created_at)} / ${fmtTime(t.updated_at)}
截止
${t.deadline || '—'}
任务指令
${esc(t.description || t.title)}
${t.output_text ? `
产出物 v${t.output_version}(${t.output_text.length} 字)
${esc(t.output_text)}
` : ''} ${t.error ? `
错误信息
${esc(t.error)}
` : ''}
执行日志
${t.logs.map(l => logLine(l)).join('') || '
暂无日志
'}
`); } function logLine(l) { const icons = {info:'ℹ️', success:'✅', warn:'⚠️', error:'❌'}; return `
${fmtTime(l.created_at)}${icons[l.level] || ''} ${esc(l.message)}
`; } async function reviewTask(tid, action) { if (action === 'reject') { const reason = prompt('请输入打回原因(将退回重做):'); if (reason === null) return; if (!reason.trim()) return toast('打回必须填写原因', 'err'); await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action, reason: reason.trim()}}); } else { await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action}}); } toast(action === 'approve' ? '✅ 验收通过,任务完成' : '任务已打回,等待返工', 'ok'); closeModal(); 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 = `

AI Worker 管理模型 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案

${ws.data.map(w => ` `).join('') || ''}
ID名称模型角色提示词温度/上限本月成本成本上限状态操作
#${w.id} ${esc(w.name)}
${esc(w.description || '')}
${esc(w.provider)} ${esc(w.model)} ${esc((w.system_prompt || '—').slice(0, 40))} ${w.temperature} / ${w.max_tokens} ${fmtMoney(w.month_cost)} ${w.task_cost_limit ? '单任务 ¥' + w.task_cost_limit : '—'}
${w.monthly_cost_limit ? '月 ¥' + w.monthly_cost_limit : ''}
${w.status === 'enabled' ? '启用' : '停用'}
还没有 Worker,注册第一个虚拟员工吧
`; } async function testWorker(wid) { toast('正在测试连通性…'); try { const r = await api(`/api/workers/${wid}/test`, {method:'POST'}); toast(`✅ 连通正常:${r.data.latency_ms}ms,回复「${r.data.reply}」,成本 ¥${r.data.cost}`, 'ok'); } catch (e) { toast('❌ ' + e.message, 'err'); } } async function delWorker(wid) { if (!confirm('确认删除该 Worker?(历史任务将解除指派)')) return; await api(`/api/workers/${wid}`, {method:'DELETE'}); toast('已删除', 'ok'); router(); } function openWorkerModal(w, providers) { w = w || {}; const sel = (providerId) => providers.find(p => p.id === providerId); openModal(`

${w.id ? '编辑 Worker' : '注册 Worker'}

`); window.onProviderChange = () => { const p = providers.find(x => x.id === $('#wm-provider').value); if (!p) return; const base = $('#wm-base'); if (!base.value || base.placeholder === base.value) base.value = p.base_url; base.placeholder = p.base_url; const model = $('#wm-model'); if (p.models.length && !model.value) model.value = p.models[0]; }; $('#wm-save').addEventListener('click', async () => { const body = { name: $('#wm-name').value.trim(), provider: $('#wm-provider').value, model: $('#wm-model').value.trim(), base_url: $('#wm-base').value.trim(), api_key: $('#wm-key').value.trim(), system_prompt: $('#wm-prompt').value, description: $('#wm-desc').value.trim(), temperature: parseFloat($('#wm-temp').value || 0.7), max_tokens: parseInt($('#wm-max').value || 2000), task_cost_limit: parseFloat($('#wm-tasklimit').value || 0), monthly_cost_limit: parseFloat($('#wm-monthlimit').value || 0), status: $('#wm-status').value }; if (!body.name || !body.provider || !body.model) return toast('名称/供应商/模型必填', 'err'); try { await api(w.id ? `/api/workers/${w.id}` : '/api/workers', {method: w.id ? 'PUT' : 'POST', body}); toast('已保存', 'ok'); closeModal(); router(); } catch (e) { toast(e.message, 'err'); } }); } /* ---------- 成本报表 ---------- */ async function pageReports() { const group = location.hash.includes('group=') ? location.hash.split('group=')[1] : 'project'; const r = (await api('/api/reports/cost?group=' + group)).data; const s = (await api('/api/stats')).data; const head = {project:['项目','调用次数','Tokens','成本'], worker:['Worker','模型','调用次数','Tokens','成本'], model:['供应商/模型','调用次数','Tokens','成本']}[group]; $('#main').innerHTML = `

成本报表按项目 × 任务 × Worker × 模型 多维核算

按项目 按 Worker 按模型 累计成本 ${fmtMoney(s.total_cost)} · ${(s.total_tokens/1e6).toFixed(2)}M tokens
${head.map(h => ``).join('')} ${r.map(x => { const cost = x.cost || 0, tokens = x.tokens || 0; const maxCost = Math.max(...r.map(v => v.cost || 0), 0.001); return `${group === 'project' ? `` : group === 'worker' ? `` : ``} `; }).join('') || ''}
${h}
${esc(x.project_name || '—')}${esc(x.worker_name || '—')}${esc(x.provider || '')} ${esc(x.model || '')}${esc(x.provider || '')} ${esc(x.model || '')}${x.runs}${(tokens/1e6).toFixed(3)}M ${fmtMoney(cost)}
暂无成本数据,先派几个任务吧
`; } /* ---------- 运行日志 ---------- */ async function pageLogs() { const logs = (await api('/api/logs?limit=200')).data; $('#main').innerHTML = `

运行日志全链路可追溯,每个结论都能溯源

${logs.map(l => `
${fmtTime(l.created_at)} [任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}
`).join('') || '
暂无日志
'}
`; } /* ---------- 启动 ---------- */ (async function init() { try { const me = await api('/api/me'); if (!me.authed) { showLogin(); $('#main').innerHTML = '
请先登录
'; return; } $('#logout-btn').style.display = 'block'; } catch (e) { showLogin(); return; } try { await api('/api/health'); } catch (e) { $('#conn-state').textContent = '● 服务异常'; $('#conn-state').className = 'conn err'; } router(); })();