AI Worker 项目管理平台 MVP:项目/任务看板 + Worker注册 + 单任务执行 + HITL审核 + 成本记账
This commit is contained in:
+505
@@ -0,0 +1,505 @@
|
||||
/* 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();
|
||||
})();
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Worker 项目管理平台</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside id="sidebar">
|
||||
<div class="logo">🤖 <span>AI Worker</span><small>项目管理平台</small></div>
|
||||
<nav>
|
||||
<a href="#/dashboard" data-route="dashboard">📊 仪表盘</a>
|
||||
<a href="#/projects" data-route="projects">📁 项目</a>
|
||||
<a href="#/workers" data-route="workers">🧑💻 AI Worker</a>
|
||||
<a href="#/reports" data-route="reports">💰 成本报表</a>
|
||||
<a href="#/logs" data-route="logs">📜 运行日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<div id="conn-state" class="conn ok">● 服务正常</div>
|
||||
<a href="#" id="logout-btn" class="logout" style="display:none">退出登录</a>
|
||||
</div>
|
||||
</aside>
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
|
||||
<!-- 登录遮罩 -->
|
||||
<div id="login-mask" style="display:none">
|
||||
<div class="login-box">
|
||||
<h2>🤖 AI Worker 项目管理平台</h2>
|
||||
<p>请输入管理口令</p>
|
||||
<input type="password" id="login-pwd" placeholder="口令">
|
||||
<button id="login-btn">登 录</button>
|
||||
<div id="login-err" class="err"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通用弹窗 -->
|
||||
<div id="modal-mask" style="display:none">
|
||||
<div class="modal" id="modal-box"></div>
|
||||
</div>
|
||||
|
||||
<div id="toast-wrap"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,128 @@
|
||||
:root{
|
||||
--bg:#0f1420; --panel:#171e2e; --panel2:#1d2638; --border:#2a3550;
|
||||
--text:#e6ebf5; --muted:#8b97ad; --accent:#4f8cff; --accent2:#38d39f;
|
||||
--warn:#ffb454; --danger:#ff6b6b; --purple:#a78bfa;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;font-size:14px}
|
||||
#app{display:flex;min-height:100vh}
|
||||
#sidebar{width:220px;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;top:0;bottom:0}
|
||||
.logo{padding:20px 18px;font-size:18px;font-weight:700;border-bottom:1px solid var(--border)}
|
||||
.logo span{color:var(--accent)}
|
||||
.logo small{display:block;font-size:11px;color:var(--muted);font-weight:400;margin-top:2px}
|
||||
nav{padding:12px 0;flex:1}
|
||||
nav a{display:block;padding:11px 20px;color:var(--muted);text-decoration:none;border-left:3px solid transparent}
|
||||
nav a:hover{color:var(--text);background:var(--panel2)}
|
||||
nav a.active{color:var(--accent);border-left-color:var(--accent);background:var(--panel2)}
|
||||
.sidebar-foot{padding:14px 18px;border-top:1px solid var(--border);font-size:12px}
|
||||
.conn.ok{color:var(--accent2)} .conn.err{color:var(--danger)}
|
||||
.logout{color:var(--muted);text-decoration:none;display:block;margin-top:6px}
|
||||
.logout:hover{color:var(--danger)}
|
||||
|
||||
#main{flex:1;margin-left:220px;padding:24px 28px;max-width:1400px}
|
||||
h1.page-title{font-size:20px;margin-bottom:18px}
|
||||
h1.page-title small{color:var(--muted);font-weight:400;font-size:13px;margin-left:8px}
|
||||
.toolbar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.btn{background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:8px 14px;border-radius:8px;cursor:pointer;font-size:13px}
|
||||
.btn:hover{border-color:var(--accent)}
|
||||
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
.btn.primary:hover{filter:brightness(1.15)}
|
||||
.btn.success{background:var(--accent2);border-color:var(--accent2);color:#06281c}
|
||||
.btn.danger{background:transparent;border-color:var(--danger);color:var(--danger)}
|
||||
.btn.warn{background:transparent;border-color:var(--warn);color:var(--warn)}
|
||||
.btn:disabled{opacity:.45;cursor:not-allowed}
|
||||
.btn.sm{padding:4px 10px;font-size:12px;border-radius:6px}
|
||||
input,select,textarea{background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:13px;outline:none;width:100%}
|
||||
input:focus,select:focus,textarea:focus{border-color:var(--accent)}
|
||||
textarea{min-height:90px;resize:vertical;font-family:inherit}
|
||||
label{display:block;font-size:12px;color:var(--muted);margin:12px 0 5px}
|
||||
.row{display:flex;gap:10px}.row>*{flex:1}
|
||||
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px}
|
||||
.grid{display:grid;gap:14px}
|
||||
.g4{grid-template-columns:repeat(4,1fr)}
|
||||
.g3{grid-template-columns:repeat(3,1fr)}
|
||||
.g2{grid-template-columns:repeat(2,1fr)}
|
||||
@media(max-width:1100px){.g4{grid-template-columns:repeat(2,1fr)}.g3,.g2{grid-template-columns:1fr}}
|
||||
|
||||
.stat{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 18px}
|
||||
.stat .num{font-size:26px;font-weight:700;margin-top:6px}
|
||||
.stat .lbl{color:var(--muted);font-size:12px}
|
||||
.stat .sub{color:var(--muted);font-size:11px;margin-top:4px}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{color:var(--muted);text-align:left;font-weight:500;padding:10px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||
td{padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
|
||||
tr:hover td{background:var(--panel2)}
|
||||
.mono{font-family:ui-monospace,Consolas,monospace}
|
||||
|
||||
.badge{display:inline-block;padding:2px 9px;border-radius:20px;font-size:12px;white-space:nowrap}
|
||||
.st-todo{background:#2a3550;color:#aeb9d4}
|
||||
.st-running{background:#173b6b;color:#6db3ff}
|
||||
.st-review{background:#4a3a10;color:#ffd166}
|
||||
.st-done{background:#0f3d2e;color:#38d39f}
|
||||
.st-rejected{background:#4a1c1c;color:#ff8f8f}
|
||||
.st-failed{background:#3d1c2e;color:#ff7ac2}
|
||||
.st-cancelled{background:#333;color:#999}
|
||||
.pri-high{color:#ff6b6b}.pri-medium{color:#ffb454}.pri-low{color:#8b97ad}
|
||||
|
||||
.kanban{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;align-items:start;min-width:1200px}
|
||||
.kb-col{background:var(--panel);border:1px solid var(--border);border-radius:12px;min-height:200px}
|
||||
.kb-head{padding:10px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
|
||||
.kb-head .cnt{color:var(--muted);font-size:12px}
|
||||
.kb-body{padding:10px;display:flex;flex-direction:column;gap:8px;min-height:120px}
|
||||
.kb-card{background:var(--panel2);border:1px solid var(--border);border-radius:10px;padding:10px 12px;cursor:grab;transition:border .15s}
|
||||
.kb-card:hover{border-color:var(--accent)}
|
||||
.kb-card.dragging{opacity:.4}
|
||||
.kb-col.drag-over{border-color:var(--accent);background:var(--panel2)}
|
||||
.kb-card .t{font-size:13px;font-weight:500;margin-bottom:6px}
|
||||
.kb-card .m{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
||||
.kb-card .cost{color:var(--muted);font-size:11px;margin-top:6px}
|
||||
.kb-card .out{font-size:11px;color:var(--accent2);margin-top:4px;display:none}
|
||||
|
||||
.list-item{display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--border)}
|
||||
.list-item:last-child{border-bottom:none}
|
||||
.list-item .title{font-weight:600}
|
||||
.list-item .meta{color:var(--muted);font-size:12px;margin-top:3px}
|
||||
|
||||
.modal{width:640px;max-width:94vw;max-height:86vh;overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:22px;position:relative}
|
||||
.modal h3{font-size:16px;margin-bottom:14px}
|
||||
.modal .close{position:absolute;top:14px;right:16px;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer}
|
||||
.modal .close:hover{color:var(--text)}
|
||||
#modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:100}
|
||||
.modal-actions{display:flex;gap:10px;margin-top:18px;justify-content:flex-end;flex-wrap:wrap}
|
||||
|
||||
#login-mask{position:fixed;inset:0;background:var(--bg);z-index:200;display:flex;align-items:center;justify-content:center}
|
||||
.login-box{width:340px;background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:30px;text-align:center}
|
||||
.login-box h2{font-size:17px;margin-bottom:6px}
|
||||
.login-box p{color:var(--muted);font-size:13px;margin-bottom:16px}
|
||||
.login-box .btn{width:100%;margin-top:12px;padding:10px}
|
||||
.err{color:var(--danger);font-size:12px;margin-top:10px;min-height:16px}
|
||||
|
||||
#toast-wrap{position:fixed;top:20px;right:20px;z-index:300;display:flex;flex-direction:column;gap:8px}
|
||||
.toast{background:var(--panel2);border:1px solid var(--border);border-left:3px solid var(--accent);padding:10px 16px;border-radius:8px;font-size:13px;animation:slidein .25s}
|
||||
.toast.ok{border-left-color:var(--accent2)}
|
||||
.toast.err{border-left-color:var(--danger)}
|
||||
@keyframes slidein{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}}
|
||||
|
||||
.log-line{font-size:12px;padding:7px 10px;border-left:2px solid var(--border);margin-bottom:6px;background:var(--panel2);border-radius:0 8px 8px 0;font-family:ui-monospace,Consolas,monospace;word-break:break-all}
|
||||
.log-line.info{border-left-color:var(--accent)}
|
||||
.log-line.success{border-left-color:var(--accent2)}
|
||||
.log-line.warn{border-left-color:var(--warn)}
|
||||
.log-line.error{border-left-color:var(--danger)}
|
||||
.log-line .ts{color:var(--muted);margin-right:8px}
|
||||
|
||||
.output-box{background:#0b0f18;border:1px solid var(--border);border-radius:10px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Consolas,monospace;font-size:13px;max-height:300px;overflow-y:auto}
|
||||
.kv{display:grid;grid-template-columns:90px 1fr;gap:6px 12px;font-size:13px}
|
||||
.kv dt{color:var(--muted)}
|
||||
.kv dd{margin:0}
|
||||
.sec-title{font-size:14px;font-weight:600;margin:20px 0 10px;color:var(--accent)}
|
||||
.bar{height:8px;background:var(--panel2);border-radius:6px;overflow:hidden;margin-top:6px}
|
||||
.bar>div{height:100%;background:var(--accent);border-radius:6px}
|
||||
.empty{color:var(--muted);text-align:center;padding:40px 0}
|
||||
.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:rot .8s linear infinite;vertical-align:-2px;margin-right:6px}
|
||||
@keyframes rot{to{transform:rotate(360deg)}}
|
||||
.proj-card{cursor:pointer;transition:border .15s}
|
||||
.proj-card:hover{border-color:var(--accent)}
|
||||
.pager-tip{color:var(--muted);font-size:12px}
|
||||
.pill{display:inline-block;background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:12px;margin:2px}
|
||||
Reference in New Issue
Block a user