feat(admin): 后台目录导航 + 按模型/各用户用量时间筛选排序 + 存储分页 + 导出 PDF + bump 0.11.0

- 左侧目录(sticky,点击平滑滚动 + scrollspy 高亮,窄屏转横向 chip);各区 scroll-margin-top 避开顶栏
- 按模型 / 各用户用量拆为独立端点,带 range(all/7d/30d)+ sort(cost/tokens);
  各用户用量含零用量用户(时间条件放 JOIN ON,避免被 cutoff 挤掉)
- 存储分页(/v1/admin/storage/users);各用户用量分页;overview 瘦身为固定指标(runtime/tasks/users/总用量+近7d),独立表自管 range/sort/page
- 导出 PDF:客户端 window.print()(零依赖),填充隐藏报告 DOM + @media print 版式;列表取前 10
- 文档同步 DESIGN §7.3 / PROGRESS / RUN

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-12 10:44:47 +08:00
parent 81da2f6f55
commit f12df1bd82
7 changed files with 416 additions and 158 deletions

View File

@ -306,7 +306,7 @@ done {}
- `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户
- `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403)
- `GET /v1/me` — 返 `{user_id, role}`(role 走 DB 查),dev SPA 据此决定显不显"管理"入口
- `GET /v1/admin/*` — 管理后台,`Depends(require_admin)`(验 JWT + `users.role=='admin'`,否则 403)。`/v1/admin/overview` 返监控总览(runtime/tasks/users/usage 总用量/storage);`/v1/admin/usage/users?page=&page_size=` 分页返各用户 token 用量。独立页 `/static/admin.html`。后续续挂建用户/改角色/配置等管理动作
- `GET /v1/admin/*` — 管理后台,`Depends(require_admin)`(验 JWT + `users.role=='admin'`,否则 403)。`/v1/admin/overview` 返固定指标(runtime/tasks/users/usage 总用量+近7d趋势,供轮询);`/v1/admin/usage/models?range=&sort=`、`/v1/admin/usage/users?range=&sort=&page=&page_size=`、`/v1/admin/storage/users?page=&page_size=` 是带时间筛选(all/7d/30d)/ 排序(cost/tokens)/ 分页的独立表端点。独立页 `/static/admin.html`(目录导航 + 客户端打印导出 PDF)。后续续挂建用户/改角色/配置等管理动作
后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`/v1/admin/*` 在 `require_user` 基础上再叠一层 `users.role=='admin'` 检查(`make_require_admin`)。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-12(admin 管理后台:users.role + require_admin + /v1/admin/overview + 独立 admin.html 监控页)
最后更新:2026-06-12(admin 管理后台 + 目录/筛选排序/分页/导出 PDF:users.role + require_admin + /v1/admin/* + 独立 admin.html)
---
@ -28,7 +28,15 @@
- 诊断脚本落盘可复用:`scripts/diag_run_python_empty.py`(扫最近 task 的报错形态分桶)、`scripts/diag_run_python_trace.py`(回溯每条报错配对的 assistant 参数)。
- 验证:`tests/test_context_compaction.py` 改 2 条旧"压参数"断言为"原样保留"+ 去除已删统计键;全量 120 tests OK。bump 0.10.0 → 0.10.1。
### 2026-06-12
### 2026-06-12(下午)admin 后台增强:目录 + 筛选排序 + 分页 + 导出 PDF
- **目录(TOC)+ 平滑滚动**:admin.html 左侧加 sticky 目录(运行态/任务/用户与用量/按模型/各用户用量/存储),点击 `scrollIntoView` 平滑滚到对应区(`.anchor { scroll-margin-top }` 避开 sticky 顶栏);IntersectionObserver 高亮当前区;窄屏目录变顶部横向 chip 条。
- **按模型 / 各用户用量:时间筛选 + 排序**:两表从 overview bundle 拆成独立端点 `GET /v1/admin/usage/models?range=&sort=`、`GET /v1/admin/usage/users?range=&sort=&page=&page_size=`。range = all/7d/30d(`_range_cutoff`);sort = cost(按成本)/ tokens(按用量=输入+输出)。**各用户用量含零用量用户**故时间条件放 JOIN ON(非 WHERE),否则带 cutoff 会把零用量用户挤掉。前端每表一组 range/sort 下拉,改筛选即重拉(用户表回第 0 页);热力色按当前排序维度上色。
- **存储分页**:`GET /v1/admin/storage/users?page=&page_size=`(bytes desc + user_id 兜底),前端独立翻页;overview 不再含 storage/by_model(只留 runtime/tasks/users/usage 总用量+近7d趋势,固定形态供轮询)。三个独立表各自 fetch、自管 range/sort/page,overview tick 顺手刷新但不丢状态。
- **导出 PDF(客户端打印)**:顶栏「导出 PDF」→ 现取 overview + models(all/cost)+ users(all/cost top10)+ storage(top10)+ /healthz 版本,填充隐藏的 `#print-report``window.print()`;`@media print` 只显报告、`@page` 边距、表格描边版式。**零依赖**(不引 jsPDF / 不走服务端 soffice)、中文走浏览器字体、版式完全可控;**列表只取前 10**(符合需求)。报告版式:抬头(标题/生成时间/版本)→ 运行态 → 任务 → 用户 → 用量总览 → 近7天 → 按模型 Top10 → 各用户用量 Top10 → 存储 Top10。
- 验证:TestClient 跑通 models(range all=6/7d=4/30d=6、sort cost/tokens)、users(range+sort+分页)、storage(分页 42 行);overview 已不含 by_model/storage;admin.js `node --check` 通过。bump 0.10.1 → 0.11.0。
### 2026-06-12(上午)
- **admin 管理后台(角色鉴权 + 独立监控页,可扩展为管理动作总入口)**:此前只有共享口令 `ZCBOT_ADMIN_TOKEN`(仅用于发用户),无"管理员角色"概念,运维指标只打 stdout(`[stats]`)无界面。本次落地按角色的 admin 区:① **schema**:`users` 加 `role` 列(`user`/`admin`,`server_default='user'`,migration 0009 只加列不动现有数据);② **鉴权**:`make_require_admin(cfg)` 先验 JWT(同 `require_user`)再查 `users.role=='admin'`,否则 403——**role 走 DB 查不进 JWT**,改完下次请求即时生效、老 token 不重签;③ **端点**:`web/admin.py` 的 `register_admin_routes``GET /v1/admin/overview`(整组 `Depends(require_admin)`),一次返回 runtime(active_runs/max_workers/sse_subs/rss_peak,读 app.state,与 `_stats_logger` 同源)/ tasks(按 status+run_status 计数)/ users(总数+近7d活跃)/ usage(全局总用量+近7d按天+按模型)/ storage(各用户 bytes/file_count+配额)五段,全 GROUP BY 无 N+1;另挂 `GET /v1/admin/usage/users?page=&page_size=` 分页返**各用户 token 用量**(全表 LEFT JOIN usage_events 含零用量用户,cost desc,稳定排序兜底 user_id;cost 全 kind、token/缓存命中仅 chat,与总用量同源)——前端独立翻页、不随 overview 轮询丢页码;④ **前端**:独立单页 `web/static/admin.html`+`js/admin.js`(复用 localStorage `zcbot.token` 与 format 工具,不挂主应用模块图),纯数字卡片+表格不画图、**阈值/热力色差**(active_runs 逼近 max_workers 变橙/红、磁盘按配额占比变色、cost 列相对热力底色)、**响应式**(窄屏竖排)、默 10s 轮询(切后台暂停);401/403 给明确提示+回控制台链接;⑤ **入口**:`/v1/me` 返 `{user_id, role}`,dev SPA `enterApp` 拉一次,admin 才显顶栏"管理"链接(`/static/admin.html`);⑥ **建用户带 role**:`POST /v1/auth/admin/create_user` + 登录页弹框加角色下拉,`main.py user add --role` / 新增 `main.py user role --email X --role admin` 改角色。**命名取舍**:先按 inspect/dashboard 摇摆,最终定 **admin**——这页会长出建用户/改角色/配置(磁盘配额等)管理动作,admin 既盖"看"又盖"管"、且与 `require_admin`/`role='admin'`/`/v1/auth/admin/*` 一脉相承;监控总览只是其第一个 tab,后续在 `web/admin.py` 续挂 `/v1/admin/users`、`/v1/admin/config`。已用 TestClient 验:admin→200、非 admin→403、无 token→401;五段聚合对真实数据跑通。

2
RUN.md
View File

@ -50,7 +50,7 @@
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。
- **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(监控总览,走 `GET /v1/admin/overview`,非 admin 403)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。
- **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(非 admin 403)。页面:左侧目录(点击滚到对应区)+ 运行态/任务/用户用量/按模型/各用户用量/存储;「按模型」「各用户用量」支持时间筛选(全部/近7天/近30天)+ 排序(按成本/按用量),「各用户用量」「存储」分页;顶栏「导出 PDF」走浏览器打印(在打印对话框选"另存为 PDF",列表取前 10)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。
---

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.10.1"
__version__ = "0.11.0"

View File

@ -17,7 +17,7 @@ from typing import Any
from uuid import UUID
from fastapi import Depends, FastAPI
from sqlalchemy import BigInteger, Numeric, cast, func, select
from sqlalchemy import BigInteger, and_, cast, func, select
from core.storage import session_scope
from core.storage.models import Task, UsageEvent, User, UserDiskUsage
@ -37,6 +37,15 @@ def _rss_peak_mb():
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
def _range_cutoff(now: datetime, range_key: str):
"""时间筛选 → cutoff datetime(或 None=全部)。range_key: all / 7d / 30d。"""
if range_key == "7d":
return now - timedelta(days=7)
if range_key == "30d":
return now - timedelta(days=30)
return None # all / 未知 → 不筛
def _runtime_section(app: FastAPI) -> dict:
"""实时运行态:从 app.state 读内存,无 DB。
@ -83,10 +92,10 @@ def _users_section(s: Any, cutoff_7d: datetime) -> dict:
def _usage_section(s: Any, cutoff_7d: datetime) -> dict:
"""token / 成本聚合:全局合计 + 近 7d 按天 + 按模型 + top 用户
"""token / 成本聚合(放进 overview,固定形态):全局合计(all-time)+ 近 7d 按天趋势
chat token 取自 usage_events.units JSONB(tokens_in/out/cache_hit_tokens);cost_cny
kind 合计 _usage_aggregates 同源,缓存命中率分母一致( 100%)
按模型 / 各用户用量已拆成独立带筛选排序的端点(_models_usage / _user_usage_page),
不在此 bundlechat token 取自 usage_events.units JSONB;cost_cny kind 合计
"""
chat = UsageEvent.kind == "chat"
tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger)
@ -133,8 +142,30 @@ def _usage_section(s: Any, cutoff_7d: datetime) -> dict:
).all()
]
# 按模型(all-time;cost desc)
by_model = [
return {"total": total, "by_day_7d": by_day}
def _models_usage(s: Any, cutoff, sort: str) -> list:
"""按模型用量(支持时间筛选 + 排序)。sort: cost(按成本)/ tokens(按用量=输入+输出)。
cutoff=None 即全部;cost kind 合计,token chat模型集合从 usage_events 现取
("全模型"基线),故时间条件直接进 WHERE
"""
chat = UsageEvent.kind == "chat"
tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger)
tout = cast(UsageEvent.units["tokens_out"].astext, BigInteger)
cost_sum = func.coalesce(func.sum(UsageEvent.cost_cny), 0)
tin_sum = func.coalesce(func.sum(tin).filter(chat), 0)
tout_sum = func.coalesce(func.sum(tout).filter(chat), 0)
order = (tin_sum + tout_sum).desc() if sort == "tokens" else cost_sum.desc()
q = select(
UsageEvent.model_profile, cost_sum, tin_sum, tout_sum, func.count(),
)
if cutoff is not None:
q = q.where(UsageEvent.created_at >= cutoff)
q = q.group_by(UsageEvent.model_profile).order_by(order, UsageEvent.model_profile)
return [
{
"model_profile": mp,
"cost_cny": float(c or 0),
@ -142,39 +173,30 @@ def _usage_section(s: Any, cutoff_7d: datetime) -> dict:
"tokens_out": int(to or 0),
"n_events": int(n or 0),
}
for mp, c, ti, to, n in s.execute(
select(
UsageEvent.model_profile,
func.coalesce(func.sum(UsageEvent.cost_cny), 0),
func.coalesce(func.sum(tin).filter(chat), 0),
func.coalesce(func.sum(tout).filter(chat), 0),
func.count(),
)
.group_by(UsageEvent.model_profile)
.order_by(func.coalesce(func.sum(UsageEvent.cost_cny), 0).desc())
).all()
for mp, c, ti, to, n in s.execute(q).all()
]
return {
"total": total,
"by_day_7d": by_day,
"by_model": by_model,
}
def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> dict:
"""分页的各用户 token 用量(时间筛选 + 排序),含零用量用户(LEFT JOIN users)。
def _user_usage_page(s: Any, page: int, page_size: int) -> dict:
"""分页的各用户 token 用量(cost desc),含零用量用户(LEFT JOIN users)。
`各用户` 取自 users 全表 LEFT JOIN usage_events,故没产生过用量的用户也出现(0)
cost kind 合计;token/cache_hit chat( _usage_aggregates / total 同源)
排序 cost desc + user_id 兜底(稳定分页,避免大量 0 值并列时跨页错位)
返回 {page, page_size, total_users, rows:[...]}
`各用户` 取自 users 全表 LEFT JOIN usage_events,故没产生过用量的用户也出现(0);
时间筛选放 JOIN ON( WHERE),否则带 cutoff 时会把零用量用户挤掉
sort: cost(按成本)/ tokens(按用量=输入+输出);+ user_id 兜底稳定分页
cost kind 合计;token/cache_hit chat返回 {page, page_size, total_users, rows}
"""
chat = UsageEvent.kind == "chat"
tin = cast(UsageEvent.units["tokens_in"].astext, BigInteger)
tout = cast(UsageEvent.units["tokens_out"].astext, BigInteger)
hit = cast(UsageEvent.units["cache_hit_tokens"].astext, BigInteger)
cost_sum = func.coalesce(func.sum(UsageEvent.cost_cny), 0)
tin_sum = func.coalesce(func.sum(tin).filter(chat), 0)
tout_sum = func.coalesce(func.sum(tout).filter(chat), 0)
order = (tin_sum + tout_sum).desc() if sort == "tokens" else cost_sum.desc()
join_cond = UsageEvent.user_id == User.user_id
if cutoff is not None:
join_cond = and_(join_cond, UsageEvent.created_at >= cutoff)
total_users = s.execute(select(func.count()).select_from(User)).scalar_one()
rows = [
@ -190,18 +212,14 @@ def _user_usage_page(s: Any, page: int, page_size: int) -> dict:
}
for uid, email, role, c, ti, to, h, n in s.execute(
select(
User.user_id,
User.email,
User.role,
cost_sum,
func.coalesce(func.sum(tin).filter(chat), 0),
func.coalesce(func.sum(tout).filter(chat), 0),
User.user_id, User.email, User.role,
cost_sum, tin_sum, tout_sum,
func.coalesce(func.sum(hit).filter(chat), 0),
func.count(UsageEvent.event_id),
)
.join(UsageEvent, UsageEvent.user_id == User.user_id, isouter=True)
.join(UsageEvent, join_cond, isouter=True)
.group_by(User.user_id, User.email, User.role)
.order_by(cost_sum.desc(), User.user_id)
.order_by(order, User.user_id)
.limit(page_size)
.offset(page * page_size)
).all()
@ -209,12 +227,16 @@ def _user_usage_page(s: Any, page: int, page_size: int) -> dict:
return {"page": page, "page_size": page_size, "total_users": total_users, "rows": rows}
def _storage_section(s: Any) -> dict:
"""各用户磁盘用量(user_disk_usage join email),bytes desc;附 per-user 配额。"""
def _storage_page(s: Any, page: int, page_size: int) -> dict:
"""分页的各用户磁盘用量(bytes desc + user_id 兜底);附 per-user 配额。
数据源 user_disk_usage(后台扫描快照,只含扫过的用户);total 为该表行数
"""
from core.agent_builder import load_config
from core.storage.disk_quota import parse_bytes
quota = parse_bytes((load_config().get("quotas") or {}).get("disk_bytes_per_user"))
total = s.execute(select(func.count()).select_from(UserDiskUsage)).scalar_one()
rows = [
{
"user_id": str(uid),
@ -232,10 +254,15 @@ def _storage_section(s: Any) -> dict:
UserDiskUsage.scanned_at,
)
.join(User, User.user_id == UserDiskUsage.user_id, isouter=True)
.order_by(UserDiskUsage.bytes_used.desc())
.order_by(UserDiskUsage.bytes_used.desc(), UserDiskUsage.user_id)
.limit(page_size)
.offset(page * page_size)
).all()
]
return {"quota_bytes": quota, "users": rows}
return {
"page": page, "page_size": page_size, "total": total,
"quota_bytes": quota, "rows": rows,
}
def register_admin_routes(app: FastAPI, require_admin) -> None:
@ -243,7 +270,10 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
@app.get("/v1/admin/overview", tags=["admin"])
def admin_overview(user_id: UUID = Depends(require_admin)):
"""管理总览:一次返回全部 section,供 /static/admin.html 轮询。admin-only。"""
"""管理总览(固定形态,供轮询):runtime/tasks/users/usage 总用量+近7d趋势。admin-only。
按模型 / 各用户用量 / 存储 是带筛选/分页的独立端点,不在此 bundle
"""
now = datetime.now(timezone.utc)
cutoff_7d = now - timedelta(days=7)
with session_scope() as s:
@ -253,19 +283,45 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
"tasks": _tasks_section(s),
"users": _users_section(s, cutoff_7d),
"usage": _usage_section(s, cutoff_7d),
"storage": _storage_section(s),
}
@app.get("/v1/admin/usage/models", tags=["admin"])
def admin_usage_models(
range: str = "all", sort: str = "cost", user_id: UUID = Depends(require_admin)
):
"""按模型用量。range: all/7d/30d;sort: cost/tokens。admin-only。"""
now = datetime.now(timezone.utc)
with session_scope() as s:
return {
"range": range, "sort": sort,
"rows": _models_usage(s, _range_cutoff(now, range), sort),
}
@app.get("/v1/admin/usage/users", tags=["admin"])
def admin_usage_users(
page: int = 0, page_size: int = 20, user_id: UUID = Depends(require_admin)
page: int = 0, page_size: int = 20, range: str = "all", sort: str = "cost",
user_id: UUID = Depends(require_admin),
):
"""各用户 token 用量(分页,cost desc)。admin-only。
"""各用户 token 用量(分页 + 时间筛选 + 排序)。admin-only。
page 0-based;page_size 夹到 [1,100]含零用量用户(全表 LEFT JOIN)
前端独立于 overview 轮询管理本表分页;总用量在 overview.usage.total
page 0-based;page_size 夹到 [1,100];range all/7d/30d;sort cost/tokens
含零用量用户(全表 LEFT JOIN);总用量在 overview.usage.total
"""
page = max(0, page)
page_size = min(100, max(1, page_size))
now = datetime.now(timezone.utc)
with session_scope() as s:
return _user_usage_page(s, page, page_size)
d = _user_usage_page(s, page, page_size, _range_cutoff(now, range), sort)
d["range"] = range
d["sort"] = sort
return d
@app.get("/v1/admin/storage/users", tags=["admin"])
def admin_storage_users(
page: int = 0, page_size: int = 20, user_id: UUID = Depends(require_admin)
):
"""各用户磁盘用量(分页,bytes desc)。admin-only。page 0-based;page_size [1,100]。"""
page = max(0, page)
page_size = min(100, max(1, page_size))
with session_scope() as s:
return _storage_page(s, page, page_size)

View File

@ -43,6 +43,26 @@
.msg { padding: 40px 16px; text-align: center; color: var(--muted); }
.msg a { color: var(--accent); }
/* 目录 + 内容两栏;目录 sticky 跟随滚动 */
#layout { display: grid; grid-template-columns: 132px 1fr; gap: 18px; align-items: start; }
#toc { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 2px; }
#toc a {
color: var(--muted); text-decoration: none; font-size: 13px; padding: 6px 10px;
border-radius: var(--r-md); border-left: 2px solid transparent;
}
#toc a:hover { background: var(--accent-soft); color: var(--accent); }
#toc a.active { color: var(--accent); border-left-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.anchor { scroll-margin-top: 64px; } /* 滚动定位避开 sticky 顶栏 */
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.card-head h2 { margin: 0; }
.ctrl { display: flex; gap: 6px; }
.ctrl select {
font-size: 12px; padding: 3px 6px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; color: var(--text);
}
.sublabel { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg);
@ -94,6 +114,32 @@
main { padding: 10px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.stat .v { font-size: 18px; }
/* 窄屏:目录变顶部横向 chip 条 */
#layout { grid-template-columns: 1fr; gap: 10px; }
#toc {
flex-direction: row; overflow-x: auto; gap: 6px; top: 52px;
background: var(--bg); padding: 6px 0; z-index: 5;
}
#toc a { white-space: nowrap; border-left: none; border: 1px solid var(--border); }
#toc a.active { border-color: var(--accent); }
.anchor { scroll-margin-top: 96px; }
}
/* 屏幕上隐藏打印报告;打印时只显它 */
#print-report { display: none; }
@media print {
header, #layout, .msg { display: none !important; }
body { background: #fff; }
#print-report { display: block; color: #000; }
#print-report h1 { font-size: 18px; margin: 0 0 4px; }
#print-report h2 { font-size: 13px; margin: 14px 0 4px; border-bottom: 1px solid #999; padding-bottom: 2px; }
#print-report .rpt-meta { font-size: 11px; color: #555; margin-bottom: 8px; }
#print-report .rpt-kv { font-size: 12px; margin: 2px 0 4px; }
table.rpt { width: 100%; border-collapse: collapse; font-size: 11px; margin: 4px 0 6px; }
table.rpt th, table.rpt td { border: 1px solid #bbb; padding: 3px 6px; text-align: right; }
table.rpt th:first-child, table.rpt td:first-child { text-align: left; }
table.rpt th { background: #f0f0f0; }
@page { margin: 14mm; }
}
</style>
</head>
@ -105,11 +151,14 @@
<div class="spacer"></div>
<label class="auto"><input type="checkbox" id="auto-refresh" checked /> 自动刷新</label>
<button id="refresh">刷新</button>
<button id="export">导出 PDF</button>
<a href="/static/dev.html">← 返回控制台</a>
</header>
<main id="main">
<div class="msg" id="boot">加载中…</div>
</main>
<!-- 导出 PDF:屏幕隐藏,仅 @media print 显示;exportPdf() 现填充后 window.print() -->
<div id="print-report"></div>
<script type="module" src="/static/js/admin.js"></script>
</body>
</html>

View File

@ -1,19 +1,29 @@
// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,但不挂主应用模块图,
// 自成一页:拉 GET /v1/admin/overview 一次渲染全部 section,默 10s 自动轮询。
// 鉴权失败:401(token 失效)/ 403(非 admin)给出明确提示 + 回控制台链接。
// 后续管理动作(建用户 / 改角色 / 配置)在此页加 tab,各自打对应 /v1/admin/* 端点
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。
// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询;
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印
import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js";
const LS_TOKEN = "zcbot.token";
const REFRESH_MS = 10000;
const PAGE_SIZE = 20;
const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]];
const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
const SECTIONS = [
["s-runtime", "运行态"], ["s-tasks", "任务"], ["s-usage", "用户与用量"],
["s-models", "按模型"], ["s-users", "各用户用量"], ["s-storage", "存储"],
];
const $ = (id) => document.getElementById(id);
const token = () => localStorage.getItem(LS_TOKEN) || "";
let timer = null;
let userPage = 0; // 各用户用量表当前页(0-based);独立于 overview 轮询
// 各表独立状态(不随 overview 轮询重置)
let modelRange = "all", modelSort = "cost";
let userRange = "all", userSort = "cost", userPage = 0;
let storagePage = 0;
// ───── 格式化 ─────
function fmtCNY(n) {
@ -27,12 +37,22 @@ function tint(value, max) {
const a = Math.min(1, value / max) * 0.30;
return `background: rgba(192,57,43,${a.toFixed(3)});`;
}
// 阈值类:ratio>=1 危险,>=0.8 警告。
function levelClass(ratio) {
if (ratio >= 1) return "danger";
if (ratio >= 0.8) return "warn";
return "";
}
// range/sort 下拉一组(prefix 区分 m=模型 / u=用户);值取当前 state。
function ctrlHTML(prefix, range, sort) {
const opt = (cur, list) => list.map(
([v, l]) => `<option value="${v}" ${v === cur ? "selected" : ""}>${l}</option>`
).join("");
return `<div class="ctrl">`
+ `<select id="${prefix}-range">${opt(range, RANGE_OPTS)}</select>`
+ `<select id="${prefix}-sort">${opt(sort, SORT_OPTS)}</select>`
+ `</div>`;
}
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }
// ───── 渲染各 section ─────
function statCard(k, v, sub, cls) {
@ -45,11 +65,10 @@ function renderRuntime(r) {
const active = r.active_runs || 0;
const max = r.max_workers || 0;
const ratio = max ? active / max : 0;
const cls = levelClass(ratio);
const sub = max ? `线程池 ${max}` + (active >= max ? " · 已满,新 run 排队" : "") : "";
const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—";
return `<div class="card"><h2>实时运行态</h2><div class="grid">`
+ statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, cls)
+ statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, levelClass(ratio))
+ statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接")
+ statCard("内存峰值", rss, "进程 RSS high-water")
+ `</div></div>`;
@ -67,8 +86,8 @@ function renderTasks(t) {
return `<span class="chip ${c}">${escapeHtml(k)} <b>${n}</b></span>`;
}).join("") || `<span class="empty">无</span>`;
return `<div class="card"><h2>任务(共 ${t.total || 0}</h2>`
+ `<div style="margin-bottom:10px;"><div class="k" style="color:var(--muted);font-size:11px;margin-bottom:4px;">status</div><div class="chips">${statusChips}</div></div>`
+ `<div><div class="k" style="color:var(--muted);font-size:11px;margin-bottom:4px;">run_status</div><div class="chips">${runChips}</div></div>`
+ `<div style="margin-bottom:10px;"><div class="sublabel">status</div><div class="chips">${statusChips}</div></div>`
+ `<div><div class="sublabel">run_status</div><div class="chips">${runChips}</div></div>`
+ `</div>`;
}
@ -84,39 +103,46 @@ function renderUsersAndUsage(users, usage) {
}
function renderByDay(rows) {
if (!rows || !rows.length) return `<div class="card"><h2>近 7 天用量</h2><div class="empty">无数据</div></div>`;
const maxCost = Math.max(...rows.map(r => r.cost_cny || 0));
rows = rows || [];
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const body = rows.map(r => `<tr>`
+ `<td>${escapeHtml(r.date)}</td>`
+ `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_in)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `</tr>`).join("");
+ `</tr>`).join("") || `<tr><td colspan="4" class="empty">无数据</td></tr>`;
return `<div class="card"><h2>近 7 天用量(按天)</h2><div class="scroll-x"><table>`
+ `<thead><tr><th>日期</th><th>成本</th><th>输入</th><th>输出</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`;
}
function renderByModel(rows) {
if (!rows || !rows.length) return `<div class="card"><h2>按模型</h2><div class="empty">无数据</div></div>`;
const maxCost = Math.max(...rows.map(r => r.cost_cny || 0));
const body = rows.map(r => `<tr>`
+ `<td class="email">${escapeHtml(r.model_profile || "—")}</td>`
+ `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_in)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `<td class="num">${r.n_events || 0}</td>`
+ `</tr>`).join("");
return `<div class="card"><h2>按模型all-time</h2><div class="scroll-x"><table>`
// 按模型(时间筛选 + 排序)。d = {range, sort, rows}
function renderModels(d) {
const rows = d.rows || [];
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const maxTok = Math.max(0, ...rows.map(r => (r.tokens_in || 0) + (r.tokens_out || 0)));
const byTok = d.sort === "tokens";
const body = rows.map(r => {
const tok = (r.tokens_in || 0) + (r.tokens_out || 0);
return `<tr>`
+ `<td class="email">${escapeHtml(r.model_profile || "—")}</td>`
+ `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num bar-cell" style="${byTok ? tint(tok, maxTok) : ""}">${fmtTokens(r.tokens_in)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `<td class="num">${r.n_events || 0}</td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
$("s-models").innerHTML = `<div class="card">`
+ `<div class="card-head"><h2>按模型(${rangeLabel(d.range)}</h2>${ctrlHTML("m", d.range, d.sort)}</div>`
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>模型</th><th>成本</th><th>输入</th><th>输出</th><th>事件</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`;
$("m-range").onchange = (e) => { modelRange = e.target.value; loadModels(); };
$("m-sort").onchange = (e) => { modelSort = e.target.value; loadModels(); };
}
// 各用户 token 用量(分页)。独立于 overview 轮询:用户翻页时按需拉,overview tick
// 时也顺手刷新当前页保持数字新鲜(userPage 不丢)。
// 各用户用量(时间筛选 + 排序 + 分页)。d 含 range/sort/page/page_size/total_users/rows
function renderUserUsage(d) {
const c = $("user-usage");
if (!c) return;
const rows = d.rows || [];
const total = d.total_users || 0;
const size = d.page_size || PAGE_SIZE;
@ -126,43 +152,47 @@ function renderUserUsage(d) {
const to = Math.min(total, (page + 1) * size);
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const maxTin = Math.max(0, ...rows.map(r => r.tokens_in || 0));
const byTok = d.sort === "tokens";
const body = rows.map(r => {
const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
return `<tr>`
+ `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}`
+ (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>`
+ `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num bar-cell" style="${tint(r.tokens_in, maxTin)}">${fmtTokens(r.tokens_in)}</td>`
+ `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num bar-cell" style="${byTok ? tint(r.tokens_in, maxTin) : ""}">${fmtTokens(r.tokens_in)}</td>`
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `<td class="num">${hitRate}%</td>`
+ `<td class="num">${r.n_events || 0}</td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="6" class="empty">无数据</td></tr>`;
c.innerHTML = `<div class="card"><h2>各用户用量按成本all-time</h2>`
$("s-users").innerHTML = `<div class="card">`
+ `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div>`
+ `<div class="pager">`
+ `<button id="uu-prev" ${page <= 0 ? "disabled" : ""}>上一页</button>`
+ `<span class="pginfo">${from}${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)</span>`
+ `<button id="uu-next" ${page >= maxPage ? "disabled" : ""}>下一页</button>`
+ `</div></div>`;
const prev = $("uu-prev"), next = $("uu-next");
if (prev) prev.onclick = () => loadUserUsage(userPage - 1);
if (next) next.onclick = () => loadUserUsage(userPage + 1);
+ pagerHTML("uu", page, maxPage, from, to, total)
+ `</div>`;
$("u-range").onchange = (e) => { userRange = e.target.value; userPage = 0; loadUserUsage(0); };
$("u-sort").onchange = (e) => { userSort = e.target.value; userPage = 0; loadUserUsage(0); };
wirePager("uu", page, maxPage, (p) => loadUserUsage(p));
}
function renderStorage(st) {
const quota = st.quota_bytes;
const rows = st.users || [];
// 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows
function renderStorage(d) {
const quota = d.quota_bytes;
const rows = d.rows || [];
const total = d.total || 0;
const size = d.page_size || PAGE_SIZE;
const page = d.page || 0;
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
const from = total ? page * size + 1 : 0;
const to = Math.min(total, (page + 1) * size);
const quotaLabel = quota && quota > 0 ? `配额 ${humanSize(quota)}/人` : "无配额上限";
if (!rows.length) return `<div class="card"><h2>存储用量(${quotaLabel}</h2><div class="empty">无数据</div></div>`;
const maxUsed = Math.max(...rows.map(r => r.bytes_used || 0));
const maxUsed = Math.max(0, ...rows.map(r => r.bytes_used || 0));
const body = rows.map(r => {
const ratio = quota && quota > 0 ? r.bytes_used / quota : 0;
const cls = levelClass(ratio);
const pctTxt = quota && quota > 0 ? Math.round(ratio * 100) + "%" : "—";
// 有配额时按配额占比上色(逼近上限变橙/红);无配额时按相对最大值热力上色
const cellStyle = quota && quota > 0
? (cls === "danger" ? "background:var(--accent-soft);color:var(--danger);"
: cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "")
@ -174,91 +204,205 @@ function renderStorage(st) {
+ `<td class="num">${r.file_count || 0}</td>`
+ `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>`
+ `</tr>`;
}).join("");
return `<div class="card"><h2>存储用量(${quotaLabel}</h2><div class="scroll-x"><table>`
}).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
$("s-storage").innerHTML = `<div class="card"><h2>存储用量(${quotaLabel}</h2>`
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>已用</th><th>占配额</th><th>文件数</th><th>扫描于</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`;
+ `<tbody>${body}</tbody></table></div>`
+ pagerHTML("st", page, maxPage, from, to, total)
+ `</div>`;
wirePager("st", page, maxPage, (p) => loadStorage(p));
}
// #main 拆两块:#metrics(每次 overview tick 整体重渲)与 #user-usage(分页表,
// 独立 fetch、自管页码)。骨架只建一次,避免翻页态被 overview 重渲冲掉。
function pagerHTML(prefix, page, maxPage, from, to, total) {
return `<div class="pager">`
+ `<button id="${prefix}-prev" ${page <= 0 ? "disabled" : ""}>上一页</button>`
+ `<span class="pginfo">${from}${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)</span>`
+ `<button id="${prefix}-next" ${page >= maxPage ? "disabled" : ""}>下一页</button>`
+ `</div>`;
}
function wirePager(prefix, page, maxPage, go) {
const prev = $(`${prefix}-prev`), next = $(`${prefix}-next`);
if (prev) prev.onclick = () => go(page - 1);
if (next) next.onclick = () => go(page + 1);
}
// ───── 骨架 + 目录 ─────
function ensureSkeleton() {
if ($("metrics")) return;
$("main").innerHTML = `<div id="metrics"></div><div id="user-usage"></div>`;
if ($("layout")) return;
$("main").innerHTML = `<div id="layout">`
+ `<nav id="toc">` + SECTIONS.map(([id, label]) =>
`<a href="#${id}" data-target="${id}">${label}</a>`).join("") + `</nav>`
+ `<div id="content">` + SECTIONS.map(([id]) =>
`<div id="${id}" class="anchor"></div>`).join("") + `</div>`
+ `</div>`;
document.querySelectorAll("#toc a").forEach(a => {
a.onclick = (e) => {
e.preventDefault();
const el = $(a.dataset.target);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
};
});
setupScrollSpy();
}
// 滚动高亮当前目录项(IntersectionObserver,粗粒度即可)
function setupScrollSpy() {
const links = {};
document.querySelectorAll("#toc a").forEach(a => { links[a.dataset.target] = a; });
const obs = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (!en.isIntersecting) return;
Object.values(links).forEach(l => l.classList.remove("active"));
if (links[en.target.id]) links[en.target.id].classList.add("active");
});
}, { rootMargin: "-70px 0px -65% 0px", threshold: 0 });
SECTIONS.forEach(([id]) => { const el = $(id); if (el) obs.observe(el); });
}
function renderMetrics(d) {
$("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
$("metrics").innerHTML =
renderRuntime(d.runtime || {})
+ renderTasks(d.tasks || {})
+ renderUsersAndUsage(d.users || {}, d.usage || {})
+ renderByDay((d.usage || {}).by_day_7d)
+ renderByModel((d.usage || {}).by_model)
+ renderStorage(d.storage || {});
$("s-runtime").innerHTML = renderRuntime(d.runtime || {});
$("s-tasks").innerHTML = renderTasks(d.tasks || {});
$("s-usage").innerHTML =
renderUsersAndUsage(d.users || {}, d.usage || {})
+ renderByDay((d.usage || {}).by_day_7d);
}
function showMsg(html) {
$("main").innerHTML = `<div class="msg">${html}</div>`; // 清骨架,错误态独占
}
// 处理鉴权/网络错误:命中返 true(调用方据此中止)。
function handleAuthError(r) {
if (r.status === 401) {
showMsg(`登录已失效。请回 <a href="/static/dev.html">控制台</a> 重新登录。`);
stopAuto(); return true;
}
if (r.status === 403) {
showMsg(`无权限管理后台仅限管理员admin访问。<br/>` +
`<a href="/static/dev.html">返回控制台</a>`);
stopAuto(); return true;
}
return false;
}
// ───── 各用户用量分页 ─────
async function loadUserUsage(page) {
const t = token();
if (!t) return;
page = Math.max(0, page);
try {
const r = await fetch(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}`, {
headers: { Authorization: "Bearer " + t },
});
if (handleAuthError(r)) return;
if (!r.ok) return;
const d = await r.json();
userPage = d.page || 0; // 以服务端回的页码为准(夹紧后)
renderUserUsage(d);
} catch (e) { /* 静默:overview 那边会报总错 */ }
}
// ───── 拉 overview ─────
async function refresh() {
// ───── 拉数据 ─────
// 统一 GET:无 token / 401 / 403 → showMsg + stopAuto + 抛(调用方据 e.code 静默)。
async function apiGet(path) {
const t = token();
if (!t) {
showMsg(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
stopAuto();
return;
throw Object.assign(new Error("no token"), { code: "auth" });
}
const r = await fetch(path, { headers: { Authorization: "Bearer " + t } });
if (r.status === 401) {
showMsg(`登录已失效。请回 <a href="/static/dev.html">控制台</a> 重新登录。`);
stopAuto();
throw Object.assign(new Error("401"), { code: "auth" });
}
if (r.status === 403) {
showMsg(`无权限管理后台仅限管理员admin访问。<br/><a href="/static/dev.html">返回控制台</a>`);
stopAuto();
throw Object.assign(new Error("403"), { code: "auth" });
}
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || String(r.status));
}
return r.json();
}
async function loadModels() {
try {
const r = await fetch("/v1/admin/overview", {
headers: { Authorization: "Bearer " + t },
});
if (handleAuthError(r)) return;
if (!r.ok) {
const d = await r.json().catch(() => ({}));
showMsg(`加载失败:${escapeHtml(d.detail || (r.status + ""))}`);
return;
}
renderModels(await apiGet(`/v1/admin/usage/models?range=${modelRange}&sort=${modelSort}`));
} catch (e) { /* auth 已提示;其它静默,overview 那边兜底 */ }
}
async function loadUserUsage(page) {
page = Math.max(0, page);
try {
const d = await apiGet(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}&range=${userRange}&sort=${userSort}`);
userPage = d.page || 0;
renderUserUsage(d);
} catch (e) { /* 同上 */ }
}
async function loadStorage(page) {
page = Math.max(0, page);
try {
const d = await apiGet(`/v1/admin/storage/users?page=${page}&page_size=${PAGE_SIZE}`);
storagePage = d.page || 0;
renderStorage(d);
} catch (e) { /* 同上 */ }
}
// overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新三个独立表(保持各自状态)
async function refresh() {
try {
const d = await apiGet("/v1/admin/overview");
ensureSkeleton();
renderMetrics(await r.json());
loadUserUsage(userPage); // 顺手刷当前页,保持数字新鲜(不丢页码)
renderMetrics(d);
loadModels();
loadUserUsage(userPage);
loadStorage(storagePage);
} catch (e) {
showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
if (e.code !== "auth") showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
}
}
// ───── 导出 PDF(客户端打印;列表取前 10)─────
async function exportPdf() {
const btn = $("export");
btn.disabled = true; const old = btn.textContent; btn.textContent = "生成中…";
try {
const [ov, models, users, storage, health] = await Promise.all([
apiGet("/v1/admin/overview"),
apiGet("/v1/admin/usage/models?range=all&sort=cost"),
apiGet("/v1/admin/usage/users?range=all&sort=cost&page=0&page_size=10"),
apiGet("/v1/admin/storage/users?page=0&page_size=10"),
fetch("/healthz").then(r => r.json()).catch(() => ({})),
]);
buildReport(ov, models, users, storage, health);
window.print();
} catch (e) {
if (e.code !== "auth") alert("导出失败:" + (e.message || String(e)));
} finally {
btn.disabled = false; btn.textContent = old;
}
}
function buildReport(ov, models, users, storage, health) {
const rt = ov.runtime || {}, tk = ov.tasks || {}, us = ov.users || {}, u = (ov.usage || {}).total || {};
const byDay = (ov.usage || {}).by_day_7d || [];
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
const tbl = (headers, body) =>
`<table class="rpt"><thead><tr>${headers.map(h => `<th>${h}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table>`;
const dist = (o) => Object.entries(o || {}).map(([k, v]) => `${escapeHtml(k)} ${v}`).join(" · ") || "无";
const dayBody = byDay.map(r => `<tr><td>${escapeHtml(r.date)}</td><td>${fmtCNY(r.cost_cny)}</td>`
+ `<td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td></tr>`).join("")
|| `<tr><td colspan="4">无数据</td></tr>`;
const modelBody = (models.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.model_profile || "—")}</td>`
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
const userBody = (users.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.email || r.user_id.slice(0, 8))}</td>`
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
const quota = storage.quota_bytes;
const stBody = (storage.rows || []).slice(0, 10).map(r => {
const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
return `<tr><td>${escapeHtml(r.email || r.user_id.slice(0, 8))}</td><td>${humanSize(r.bytes_used)}</td>`
+ `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`;
}).join("") || `<tr><td colspan="4">无数据</td></tr>`;
$("print-report").innerHTML =
`<h1>zcbot 管理后台报告</h1>`
+ `<div class="rpt-meta">生成时间 ${ov.generated_at ? fmtTime(ov.generated_at) : "—"}`
+ `${health.version ? " · 版本 v" + escapeHtml(health.version) : ""} · 列表取前 10</div>`
+ `<h2>实时运行态</h2>`
+ `<div class="rpt-kv">活跃 run ${rt.active_runs || 0}${rt.max_workers ? " / " + rt.max_workers : ""}`
+ ` &nbsp;|&nbsp; SSE 订阅 ${rt.sse_subs || 0}`
+ ` &nbsp;|&nbsp; 内存峰值 ${rt.rss_peak_mb != null ? Math.round(rt.rss_peak_mb) + " MB" : "—"}</div>`
+ `<h2>任务(共 ${tk.total || 0}</h2>`
+ `<div class="rpt-kv">status${dist(tk.by_status)}<br/>run_status${dist(tk.by_run_status)}</div>`
+ `<h2>用户</h2><div class="rpt-kv">总数 ${us.total || 0} · 近 7 天活跃 ${us.active_7d || 0}</div>`
+ `<h2>用量总览all-time</h2>`
+ `<div class="rpt-kv">总成本 ${fmtCNY(u.cost_cny)} &nbsp;|&nbsp; 输入 ${fmtTokens(u.tokens_in)}`
+ ` &nbsp;|&nbsp; 输出 ${fmtTokens(u.tokens_out)} &nbsp;|&nbsp; 缓存命中 ${hitRate}%`
+ ` &nbsp;|&nbsp; 事件 ${u.n_events || 0}</div>`
+ `<h2>近 7 天用量(按天)</h2>` + tbl(["日期", "成本", "输入", "输出"], dayBody)
+ `<h2>按模型all-timeTop 10</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
+ `<h2>各用户用量all-timeTop 10</h2>` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
+ `<h2>存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10</h2>`
+ tbl(["用户", "已用", "占配额", "文件数"], stBody);
}
// ───── 自动刷新 ─────
function startAuto() {
stopAuto();
@ -269,6 +413,7 @@ function stopAuto() {
}
$("refresh").onclick = refresh;
$("export").onclick = exportPdf;
$("auto-refresh").onchange = startAuto;
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => {