Compare commits

..
1 Commits
Author SHA1 Message Date
hz4th_coder 5428a33a0c V3.5.1 优化:项目存为参考/修复标签切换/对话独立首页/报表按日期
1. 项目一键保存为参考:项目卡片「存为参考」按钮,复制项目+任务到从参考项目中新建;
   参考列表显示来源标记,非内置可删除;内置参考受保护(ref_builtin),种子自愈补标记
2. 修复AI Worker页 大模型接口库/团队 标签点击无反应(workersTab被pageWorkers重置的bug)
3. 对话独立首页:导航改为 对话/仪表盘 并列;首页=上部仪表盘摘要(可点击跳仪表盘)+
   中下对话主体(更高面板、流式光标、每条消息带用量)
4. 成本报表新增「按日期」维度(近7/30天/全部),按天统计调用/输入/输出/缓存/成本
2026-09-05 19:49:47 +08:00
6 changed files with 203 additions and 40 deletions
+12 -1
View File
@@ -3,7 +3,18 @@
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
**当前版本:V3.5**(精细化用量统计 / 大模型接口库 / AI Worker 团队 / 对话融合仪表盘 / 系统工作目录 / 流式单token超时)
**当前版本:V3.5**(精细化用量统计 / 大模型接口库 / AI Worker 团队 / 对话独立首页 / 系统工作目录 / 流式单token超时 / 按日期报表
---
## 🚀 V3.5.1 优化(本轮)
| 能力 | 说明 |
|---|---|
| ⭐ 项目一键存为参考 | 项目卡片新增「⭐ 存为参考」按钮,把任意已创建项目一键保存到「从参考项目中新建」列表(复制项目+任务,原项目不受影响);参考列表里显示「来自「原项目」」来源标记,非内置参考可删除,内置 3 个受保护 |
| 🐛 修复标签切换 | AI Worker 页「大模型接口库 / 团队」标签点击无反应的 bug(onclick 里页面状态被重置)已修复,三标签正常切换 |
| 💬 对话独立首页 | 左侧导航改为「💬 对话 / 📊 仪表盘」并列;首页为全新对话页:**上部=仪表盘关键信息摘要(项目/任务/Worker/成本/调用次数,点击即跳转仪表盘)**,中下部=主体对话界面(更高更宽,流式输出带闪烁光标,每条记录 tokens/缓存命中/成本/首字延迟) |
| 📅 成本报表按日期 | 报表新增「按日期」维度:按天统计调用次数/输入/输出/缓存命中/总Tokens/成本,支持 近7天/30天/全部 筛选 |
---
+85 -8
View File
@@ -239,9 +239,11 @@ def health():
# V3.5 内置参考测试项目(从参考项目新建用,不进普通项目列表)
# ---------------------------------------------------------------------------
def seed_reference_projects():
"""幂等:内置 3 个不同维度的简单参考测试项目,供「从参考项目中新建」快速复制。"""
if db.q('SELECT COUNT(*) c FROM projects WHERE is_reference=1')[0]['c'] > 0:
return
"""幂等且自愈:内置 3 个不同维度的简单参考测试项目ref_builtin=1,受保护),
供「从参考项目中新建」快速复制;历史数据自动补标记,缺失自动重建。"""
# 兼容历史:已有同名内置参考项目补 ref_builtin 标记
for name in ('产品文案速写(参考)', 'Python 小工具(参考)', '市场调研简报(参考)'):
db.w('UPDATE projects SET ref_builtin=1 WHERE name=? AND is_reference=1 AND ref_builtin=0', (name,))
ts = db.now()
refs = [
{
@@ -287,10 +289,14 @@ def seed_reference_projects():
},
]
for ref in refs:
exist = db.q('SELECT id FROM projects WHERE name=? AND is_reference=1 AND ref_builtin=1',
(ref['name'],), one=True)
if exist:
continue
pid = db.w(
'INSERT INTO projects (name, description, objective, acceptance_criteria, status, '
'budget_limit, deliver_type, workspace_dir, is_reference, auto_status, created_at, updated_at) '
'VALUES (?,?,?,?,?,?,?,?,1,?,?,?)',
'budget_limit, deliver_type, workspace_dir, is_reference, ref_builtin, auto_status, created_at, updated_at) '
'VALUES (?,?,?,?,?,?,?,?,1,1,?,?,?)',
(ref['name'], '内置参考测试项目,可「从参考项目新建」快速复制', ref['objective'],
ref['acceptance_criteria'], 'active', 0, 'web', '', 'none', ts, ts))
db.w('UPDATE projects SET workspace_dir=? WHERE id=?', (f'ref_{pid}', pid))
@@ -1005,20 +1011,72 @@ def team_detail(tid):
@app.route('/api/reference_projects')
@require_auth
def reference_projects():
"""内置参考测试项目(3 个,含任务列表),供「从参考项目中新建」快速复制"""
"""参考测试项目(内置 + 用户保存的),供「从参考项目中新建」快速复制"""
err = _check_perm_point('project.view')
if err:
return err
rows = db.q('SELECT * FROM projects WHERE is_reference=1 ORDER BY id')
rows = db.q('SELECT * FROM projects WHERE is_reference=1 ORDER BY ref_builtin DESC, id')
out = []
for r in rows:
r['tasks'] = db.q('SELECT * FROM tasks WHERE project_id=? AND deleted=0 ORDER BY id', (r['id'],))
for t in r['tasks']:
t['depends_on'] = _json.loads(t.get('depends_on') or '[]')
src = db.q('SELECT id, name FROM projects WHERE id=?', (r.get('ref_source_id') or 0,), one=True)
r['source_project'] = dict(src) if src else None
out.append(r)
return jsonify({'ok': True, 'data': out})
@app.route('/api/projects/<int:pid>/save_reference', methods=['POST'])
@require_auth
def project_save_reference(pid):
"""把已创建的项目一键保存为参考项目(复制项目+任务,原项目不受影响),供「从参考项目中新建」使用"""
err = _check_project_perm(pid, 'view')
if err:
return err
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
if not proj:
return jsonify({'ok': False, 'error': '项目不存在'}), 404
if proj.get('is_reference'):
return jsonify({'ok': False, 'error': '该项目本身已是参考项目'}), 400
ts = db.now()
new_name = (proj['name'].strip() or '参考项目') + '(参考)'
rpid = db.w(
'INSERT INTO projects (name, description, objective, acceptance_criteria, status, '
'budget_limit, deliver_type, deliver_note, workspace_dir, is_reference, ref_builtin, '
'ref_source_id, auto_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,1,0,?,?,?,?)',
(new_name, (proj.get('description') or '') + '\n(由项目「' + proj['name'] + '」一键保存为参考)',
proj.get('objective') or '', proj.get('acceptance_criteria') or '', 'active',
proj.get('budget_limit') or 0, proj.get('deliver_type') or 'web', proj.get('deliver_note') or '',
'', pid, 'none', ts, ts))
db.w('UPDATE projects SET workspace_dir=? WHERE id=?', (f'ref_{rpid}', rpid))
# 复制任务(depends_on 重新映射)
n = _copy_tasks_from_reference(proj, rpid)
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
(rpid, 'info', f'由项目「{proj["name"]}」一键保存为参考项目,复制 {n} 个任务', ts))
enterprise.audit(enterprise.current_actor(), 'project.save_reference', f'project#{pid}',
f'{proj["name"]}」保存为参考项目 #{rpid}{n} 任务)', request.remote_addr or '')
return jsonify({'ok': True, 'id': rpid, 'copied_tasks': n})
@app.route('/api/reference_projects/<int:rid>', methods=['DELETE'])
@require_auth
def reference_project_delete(rid):
"""删除参考项目(内置参考项目受保护)"""
err = _check_perm_point('project.manage')
if err:
return err
r = db.q('SELECT * FROM projects WHERE id=? AND is_reference=1', (rid,), one=True)
if not r:
return jsonify({'ok': False, 'error': '参考项目不存在'}), 404
if r.get('ref_builtin'):
return jsonify({'ok': False, 'error': '内置参考项目受保护,不可删除'}), 400
db.w('DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE project_id=?)', (rid,))
db.w('DELETE FROM tasks WHERE project_id=?', (rid,))
db.w('DELETE FROM projects WHERE id=?', (rid,))
return jsonify({'ok': True})
@app.route('/api/workspace/probe')
@require_auth
def workspace_probe():
@@ -1573,7 +1631,26 @@ def report_cost():
scope_sql = 'WHERE project_id IN (%s)' % ','.join('?' * len(visible))
scope_args = list(visible)
group = request.args.get('group', 'project')
if group == 'worker':
if group == 'date':
# 按日期统计(V3.5):date(created_at) 以本地时区分天;days 限制最近 N 天
days = 0
try:
days = int(request.args.get('days') or 0)
except Exception:
days = 0
extra_where = ''
extra_args = []
if days > 0:
extra_where = (scope_sql + ' AND' if scope_sql else 'WHERE') + ' created_at>=?'
extra_args = [db.now() - days * 86400]
rows = db.q(
f'SELECT date(created_at, "unixepoch", "localtime") day, COUNT(*) runs, '
f'COUNT(DISTINCT task_id) task_calls, '
f'SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens, '
f'SUM(cached_tokens) cached_tokens, SUM(total_tokens) tokens, SUM(cost) cost '
f'FROM cost_records {scope_sql}{extra_where} GROUP BY day ORDER BY day DESC',
scope_args + extra_args)
elif group == 'worker':
rows = db.q(
'SELECT worker_id, provider, model, COUNT(*) runs, COUNT(DISTINCT task_id) task_calls, '
'SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens, '
+4
View File
@@ -549,6 +549,10 @@ def _migrate():
pcols3 = {r['name'] for r in conn.execute('PRAGMA table_info(projects)')}
if 'is_reference' not in pcols3:
conn.execute('ALTER TABLE projects ADD COLUMN is_reference INTEGER DEFAULT 0')
if 'ref_builtin' not in pcols3:
conn.execute('ALTER TABLE projects ADD COLUMN ref_builtin INTEGER DEFAULT 0')
if 'ref_source_id' not in pcols3:
conn.execute('ALTER TABLE projects ADD COLUMN ref_source_id INTEGER DEFAULT 0')
acols = {r['name'] for r in conn.execute('PRAGMA table_info(agent_runs)')}
if 'workspace_dir' not in acols:
conn.execute("ALTER TABLE agent_runs ADD COLUMN workspace_dir TEXT DEFAULT ''")
+92 -30
View File
@@ -192,7 +192,7 @@ setInterval(refreshAlertBadge, 30000);
/* ---------- Router ---------- */
const routes = {
'dashboard': pageDashboard, 'projects': pageProjects, 'project': pageProject,
'dashboard': pageDashboard, 'chat': pageChat, 'projects': pageProjects, 'project': pageProject,
'workers': pageWorkers, 'reports': pageReports, 'logs': pageLogs,
'alerts': pageAlerts, 'api': pageApiTokens, 'settings': pageSettings,
'agents': pageAgents, 'eval': pageEval, 'templates': pageTemplates,
@@ -201,13 +201,13 @@ const routes = {
function router() {
clearInterval(projAutoTimer); // 离开项目页时停止 AI 主管轮询
projAutoTimer = null;
const hash = location.hash.replace(/^#\//, '') || 'dashboard';
const hash = location.hash.replace(/^#\//, '') || 'chat';
const parts = hash.split('/');
const name = parts[0];
// 离开项目页时丢弃项目缓存,避免下次进入用旧数据
if (name !== 'project') projCtx = null;
const fn = routes[name] || pageDashboard;
const navMap = {project:'projects', api:'api', settings:'settings', alerts:'alerts'};
const fn = routes[name] || pageChat;
const navMap = {project:'projects', api:'api', settings:'settings', alerts:'alerts', chat:'chat'};
$$('#sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.route === (navMap[name] || name)));
$('#main').innerHTML = '<div class="empty"><span class="spin"></span>加载中…</div>';
fn(parts.slice(1)).catch(e => { $('#main').innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
@@ -231,14 +231,14 @@ function chatTargetLabel() {
return (w?.name || 'AI Worker') + (c.tid === c.options?.main_worker_id ? ' ⭐主力' : '');
}
function chatPanelHtml() {
function chatPanelHtml(tall = false) {
const c = chatCtx;
const eps = (c.options?.endpoints || []).map(e => `<option value="${e.id}" data-models="${esc(JSON.stringify(e.models || []))}">${esc(e.name)}</option>`).join('') || '<option value="">(无可用接口)</option>';
const ws = (c.options?.workers || []).map(w => `<option value="${w.id}" ${String(w.id) === String(c.tid) ? 'selected' : ''}>${esc(w.name)}${String(w.id) === c.options?.main_worker_id ? ' ⭐主力' : ''}</option>`).join('') || '<option value="">(无 Worker</option>';
const ts = (c.options?.teams || []).map(t => `<option value="${t.id}">${esc(t.name)}${t.worker_count} 人)</option>`).join('') || '<option value="">(无团队)</option>';
const sessOpts = (c.sessions || []).map(s => `<option value="${s.id}" ${String(s.id) === String(c.sid) ? 'selected' : ''}>${esc(s.title || ('会话#' + s.id))}</option>`).join('');
return `
<div class="card chat-card">
<div class="card chat-card${tall ? ' chat-tall' : ''}">
<div class="chat-top">
<div class="chat-title">💬 对话 <small style="color:var(--muted)">可选 大模型 / AI Worker / 团队 · 输出按 token 流式 · 默认主力 AI Worker</small></div>
<div class="chat-sess">
@@ -409,7 +409,11 @@ async function chatSend() {
const msgWrap = document.createElement('div');
msgWrap.className = 'chat-msg ai';
bubble.className = 'chat-bubble ai';
bubble.textContent = '';
const txtSpan = document.createElement('span');
const cursorSpan = document.createElement('span');
cursorSpan.className = 'chat-cursor';
bubble.appendChild(txtSpan);
bubble.appendChild(cursorSpan);
msgWrap.appendChild(bubble);
const usage = document.createElement('div');
usage.className = 'chat-usage';
@@ -451,7 +455,7 @@ async function chatSend() {
try { evt = JSON.parse(line.slice(6)); } catch (e) { continue; }
if (evt.type === 'delta') {
acc += evt.content;
bubble.textContent = acc;
txtSpan.textContent = acc;
body.scrollTop = body.scrollHeight;
} else if (evt.type === 'done') {
done_usage = evt.usage;
@@ -460,6 +464,7 @@ async function chatSend() {
}
}
}
cursorSpan.remove();
if (done_usage) {
usage.textContent = `${done_usage.total_tokens} tokens · 输入 ${done_usage.prompt_tokens} / 输出 ${done_usage.completion_tokens}${done_usage.cached_tokens ? ' / 缓存命中 ' + done_usage.cached_tokens : ''} · ${fmtMoney(done_usage.cost)} · 首字 ${done_usage.first_token_ms || '—'}ms · 总 ${done_usage.elapsed_ms}ms · ${esc(done_usage.model)}`;
}
@@ -467,22 +472,22 @@ async function chatSend() {
const d = (await api(`/api/chat/sessions/${chatCtx.sid}`)).data;
chatCtx.msgs = d.messages;
} catch (e) {
usage.textContent = '❌ ' + e.message;
cursorSpan.remove();
if (txtSpan.textContent) txtSpan.textContent += '\n\n❌ 输出中断:' + e.message;
else usage.textContent = '❌ ' + e.message;
} finally {
chatCtx.busy = false;
if (sendBtn) sendBtn.disabled = false;
}
}
/* ---------- 仪表盘(含对话) ---------- */
/* ---------- 仪表盘 ---------- */
async function pageDashboard() {
await chatInit();
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 = `
${chatPanelHtml()}
<h1 class="page-title" style="margin-top:20px">运营总览<small>项目 · 任务 · AI Worker · 用量</small></h1>
<h1 class="page-title">仪表盘<small>项目 · 任务 · AI Worker · 用量</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>
@@ -522,6 +527,28 @@ async function pageDashboard() {
</div>`;
}
/* ---------- 💬 首页(对话):上=仪表盘摘要(可点击跳转) · 中下=对话主体 ---------- */
async function pageChat() {
await chatInit();
let s = {projects:0, tasks:0, workers:0, total_cost:0, total_tokens:0, total_calls:0, total_cached:0,
by_status:{}, recent:[]};
try { s = (await api('/api/stats')).data; } catch (e) {}
const total = Object.values(s.by_status || {}).reduce((a, b) => a + b, 0);
const pct = n => total ? Math.round(n / total * 100) : 0;
const stat = (lbl, num, sub) => `<div class="stat chat-stat-link" onclick="location.hash='#/dashboard'" title="点击进入仪表盘查看详情">
<div class="lbl">${lbl}</div><div class="num">${num}</div><div class="sub">${sub}</div></div>`;
$('#main').innerHTML = `
<h1 class="page-title">首页 · 对话<small>上=运营摘要(点击跳转仪表盘) · 中下=对话界面</small></h1>
<div class="chat-stats">
${stat('项目', s.projects, `完成 ${s.by_status.done ?? 0} 个任务`)}
${stat('任务总数', s.tasks, `完成率 ${pct(s.by_status.done ?? 0)}% · 待审核 ${s.by_status.review ?? 0}`)}
${stat('AI Worker', s.workers, '虚拟员工档案数')}
${stat('累计成本', fmtMoney(s.total_cost), `${(s.total_tokens/1e6).toFixed(2)}M tokens`)}
${stat('调用次数', s.total_calls ?? 0, `缓存命中 ${(s.total_cached ?? 0)/1e6 >= 0.001 ? (s.total_cached/1e6).toFixed(2)+'M' : (s.total_cached ?? 0)} tokens`)}
</div>
${chatPanelHtml(true)}`;
}
/* ---------- 项目列表 ---------- */
let projFilter = 'all';
const PROJ_STATUS = {planning:'规划中', active:'进行中', review:'待验收', done:'已完成', archived:'已归档'};
@@ -555,6 +582,7 @@ async function pageProjects() {
${Object.entries(PROJ_STATUS).map(([k, v]) => `<option value="${k}" ${p.status === k ? 'selected' : ''}>${v}</option>`).join('')}
</select>`}
${p.perm !== 'view' ? `<button class="btn sm danger" onclick="delProject(${p.id})" title="删除项目">🗑</button>` : ''}
<button class="btn sm" onclick="saveAsRef(${p.id})" title="一键保存到「从参考项目中新建」">⭐ 存为参考</button>
</span>
</div>
<div style="color:var(--muted);font-size:12px;margin-top:8px;min-height:32px">${esc(p.objective || p.description || '暂无描述')}</div>
@@ -586,6 +614,16 @@ async function delProject(pid) {
} catch (e) { toast(e.message, 'err'); }
}
/* 一键保存为参考项目(V3.5) */
async function saveAsRef(pid) {
const name = projNameMap[pid] || ('#' + pid);
if (!confirm(`把项目「${name}」一键保存为参考项目?\n(将复制项目与任务到「从参考项目中新建」列表,原项目不受影响)`)) return;
try {
const r = await api(`/api/projects/${pid}/save_reference`, {method: 'POST'});
toast(`✅ 已保存为参考项目(复制 ${r.copied_tasks} 个任务)`, 'ok');
} catch (e) { toast(e.message, 'err'); }
}
function openProjectModal(p = {}) {
const usersP = api('/api/users');
const workersP = api('/api/workers');
@@ -741,17 +779,31 @@ async function openRefProjectModal() {
if (!refs.length) { toast('暂无参考项目', 'err'); return; }
openModal(`
<h3>📋 从参考项目中新建<button class="close" data-close>×</button></h3>
<div class="tab-note">选择下面的内置简单测试项目作为参考,将复制其目标与任务列表,快速生成一个新项目(仍可继续编辑)。</div>
<div class="tab-note">内置参考项目 + 从已创建项目一键保存的参考项目。选择后复制其目标与任务列表生成新项目(仍可继续编辑)。</div>
${refs.map(x => `
<div class="card ref-card" style="margin-bottom:10px;cursor:pointer" onclick="openProjectModalFromRef(${x.id})">
<div style="font-weight:600">${esc(x.name)} <span class="tag">${x.tasks.length} 个任务</span></div>
<div style="color:var(--muted);font-size:12px;margin-top:4px">🎯 ${esc(x.objective)}</div>
<div style="color:var(--muted);font-size:12px;margin-top:4px">✅ 验收标准:${esc(x.acceptance_criteria)}</div>
<div style="color:var(--accent);font-size:12px;margin-top:6px">点击 → 以此参考新建项目</div>
</div>`).join('')}
<div class="card ref-card" style="margin-bottom:10px">
<div style="font-weight:600;cursor:pointer" onclick="openProjectModalFromRef(${x.id})">${esc(x.name)} <span class="tag">${x.tasks.length} 个任务</span>
${x.ref_builtin ? '<span class="badge st-done">内置</span>' : ''}
${x.source_project ? `<span class="tag">来自「${esc(x.source_project.name)}</span>` : ''}
${!x.ref_builtin ? `<button class="btn sm danger" style="float:right" onclick="delRefProject(${x.id})">🗑 删除</button>` : ''}
</div>
<div style="color:var(--muted);font-size:12px;margin-top:4px;cursor:pointer" onclick="openProjectModalFromRef(${x.id})">🎯 ${esc(x.objective)}</div>
<div style="color:var(--muted);font-size:12px;margin-top:4px;cursor:pointer" onclick="openProjectModalFromRef(${x.id})">✅ 验收标准:${esc(x.acceptance_criteria)}</div>
<div style="color:var(--accent);font-size:12px;margin-top:6px;cursor:pointer" onclick="openProjectModalFromRef(${x.id})">点击 → 以此参考新建项目</div>
</div>`).join('') || '<div class="empty">暂无参考项目</div>'}
<button class="btn" data-close>取消</button>`);
}
async function delRefProject(rid) {
if (!confirm('删除该参考项目?(不影响原项目)')) return;
try {
await api(`/api/reference_projects/${rid}`, {method: 'DELETE'});
toast('已删除', 'ok');
closeModal();
openRefProjectModal();
} catch (e) { toast(e.message, 'err'); }
}
async function openProjectModalFromRef(refId) {
const d = (await api('/api/reference_projects')).data;
const ref = d.find(x => x.id === refId);
@@ -2053,9 +2105,9 @@ async function pageWorkers(args = []) {
const canManage = CURRENT_ROLE === 'admin';
const tabBar = `
<div class="tabs">
<a class="${workersTab === 'list' ? 'active' : ''}" onclick="workersTab='list';pageWorkers()">🧑‍💻 AI Worker</a>
<a class="${workersTab === 'endpoints' ? 'active' : ''}" onclick="workersTab='endpoints';pageWorkers()">🔌 大模型接口库</a>
<a class="${workersTab === 'teams' ? 'active' : ''}" onclick="workersTab='teams';pageWorkers()">👥 团队</a>
<a class="${workersTab === 'list' ? 'active' : ''}" onclick="pageWorkers(['list'])">🧑‍💻 AI Worker</a>
<a class="${workersTab === 'endpoints' ? 'active' : ''}" onclick="pageWorkers(['endpoints'])">🔌 大模型接口库</a>
<a class="${workersTab === 'teams' ? 'active' : ''}" onclick="pageWorkers(['teams'])">👥 团队</a>
</div>`;
$('#main').innerHTML = `<h1 class="page-title">AI Worker<small>模型接口 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案</small></h1>${tabBar}<div id="workers-body"></div>`;
if (workersTab === 'endpoints') renderEndpoints(eps.data, canManage);
@@ -2387,28 +2439,38 @@ async function delTeam(tid) {
/* ---------- 成本报表 / 用量明细 ---------- */
async function pageReports() {
const group = location.hash.includes('group=') ? location.hash.split('group=')[1] : 'project';
const hashPart = location.hash.includes('group=') ? location.hash.split('group=')[1] : 'project';
const group = hashPart.split('&')[0];
const days = (hashPart.match(/days=(\d+)/) || [])[1] || 0;
if (group === 'usage') return pageUsageReport();
const r = (await api('/api/reports/cost?group=' + group)).data;
const r = (await api(`/api/reports/cost?group=${group}${days ? '&days=' + days : ''}`)).data;
const s = (await api('/api/stats')).data;
const head = {project:['项目','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本'], worker:['Worker','模型','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本'], model:['供应商/模型','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本']}[group];
const head = {project:['项目','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本'], worker:['Worker','模型','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本'], model:['供应商/模型','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本'], date:['日期','调用次数','输入tokens','输出tokens','缓存命中','总Tokens','成本']}[group];
const fmtT = n => (n / 1e6).toFixed(3) + 'M';
const daySel = group === 'date' ? `<span class="fgroup"><span class="flbl">近</span>
<a class="fbtn ${days === '7' ? 'on' : ''}" href="#/reports/group=date&days=7">7天</a>
<a class="fbtn ${days === '30' ? 'on' : ''}" href="#/reports/group=date&days=30">30天</a>
<a class="fbtn ${!days ? 'on' : ''}" href="#/reports/group=date">全部</a></span>` : '';
$('#main').innerHTML = `
<h1 class="page-title">成本报表<small>按项目 × 任务 × Worker × 模型 多维核算(含输入/输出/缓存命中 token 与调用次数)</small></h1>
<h1 class="page-title">成本报表<small>按项目 × 任务 × Worker × 模型 × 日期 多维核算(含输入/输出/缓存命中 token 与调用次数)</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>
<a class="btn ${group==='date'?'primary':''}" href="#/reports/group=date">📅 按日期</a>
<a class="btn ${group==='usage'?'primary':''}" href="#/reports/group=usage">📊 用量明细(项目×智能体)</a>
${daySel}
<span style="margin-left:auto" class="pager-tip">累计成本 ${fmtMoney(s.total_cost)} · ${(s.total_tokens/1e6).toFixed(2)}M tokens · ${s.total_calls ?? 0} 次调用 · 缓存命中 ${(s.total_cached ?? 0)/1e6 >= 0.001 ? (s.total_cached/1e6).toFixed(2)+'M' : (s.total_cached ?? 0)} 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>`}
const nameCell = 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>` : group === 'date' ?
`<td class="mono"><b>${esc(x.day || '')}</b></td>` :
`<td><span class="pill">${esc(x.provider || '')}</span> <span class="pill">${esc(x.model || '')}</span></td>`;
return `<tr>${nameCell}
<td>${x.runs}</td>
<td class="mono">${fmtT(x.prompt_tokens || 0)}</td>
<td class="mono">${fmtT(x.completion_tokens || 0)}</td>
+2 -1
View File
@@ -11,7 +11,8 @@
<aside id="sidebar">
<div class="logo">🤖 <span>AI Worker</span><small>项目管理平台</small></div>
<nav>
<a href="#/dashboard" data-route="dashboard" data-perm="dashboard.view" class="nav-chat">💬 对话 · 仪表盘</a>
<a href="#/chat" data-route="chat" data-perm="dashboard.view" class="nav-chat">💬 对话</a>
<a href="#/dashboard" data-route="dashboard" data-perm="dashboard.view">📊 仪表盘</a>
<a href="#/projects" data-route="projects" data-perm="project.view">📁 项目</a>
<a href="#/workers" data-route="workers" data-perm="worker.view">🧑‍💻 AI Worker</a>
<a href="#/agents" data-route="agents" data-perm="agent.view">🤝 多 Agent 协作</a>
+8
View File
@@ -277,6 +277,7 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
/* ---------- V3.5 对话 ---------- */
.chat-card{display:flex;flex-direction:column;max-height:560px;padding:14px}
.chat-card.chat-tall{max-height:72vh;margin-top:14px}
.chat-top{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px}
.chat-title{font-size:16px;font-weight:700}
.chat-sess{display:flex;gap:6px;align-items:center}
@@ -285,6 +286,7 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
.chat-target select{width:auto;max-width:240px}
.chat-target-label{font-size:12px;color:var(--accent);font-weight:600}
.chat-body{flex:1;min-height:180px;max-height:330px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px}
.chat-card.chat-tall .chat-body{max-height:none}
.chat-msg{display:flex;margin-bottom:10px}
.chat-msg.user{justify-content:flex-end}
.chat-msg.ai{justify-content:flex-start;flex-direction:column;align-items:flex-start}
@@ -292,8 +294,14 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
.chat-bubble.user{background:var(--accent);color:#fff;border-bottom-right-radius:3px}
.chat-bubble.ai{background:var(--panel2);border:1px solid var(--border);border-bottom-left-radius:3px}
.chat-bubble.ai.err{border-color:var(--danger)}
.chat-cursor{display:inline-block;width:8px;height:15px;background:var(--accent);margin-left:2px;vertical-align:-2px;animation:chatblink .8s steps(1) infinite}
@keyframes chatblink{50%{opacity:0}}
.chat-usage{font-size:11px;color:var(--muted);margin-top:4px;font-family:ui-monospace,Consolas,monospace}
.chat-input{display:flex;gap:10px;margin-top:10px;align-items:flex-end}
.chat-input textarea{flex:1;resize:vertical}
.ref-card{border-left:3px solid var(--accent)}
.ref-card:hover{border-color:var(--accent)}
.chat-stats{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
.chat-stat-link{cursor:pointer;transition:border .15s, transform .1s}
.chat-stat-link:hover{border-color:var(--accent);transform:translateY(-2px)}
@media(max-width:1100px){.chat-stats{grid-template-columns:repeat(3,1fr)}}