feat(admin): 角色化管理后台 + 分页各用户用量 + bump 0.9.0

- users 加 role 列(user/admin,migration 0009);make_require_admin 按 DB role gate(不进 JWT,改完即时生效)
- /v1/admin/overview 监控总览:runtime(并发/线程池/SSE/RSS)+ tasks + users + usage 总用量 + storage
- /v1/admin/usage/users 分页各用户 token 用量(全表 LEFT JOIN 含零用量,cost desc,稳定排序)
- /v1/me 返 role;登录/建用户响应带 role;main.py user role / user add --role;建用户弹框加角色下拉
- 独立页 web/static/admin.html + js/admin.js(阈值/热力色差、响应式、10s 轮询、独立翻页);dev SPA admin 才显"管理"入口
- 文档同步:DESIGN §7.3/§7.4、PROGRESS、RUN

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-12 10:02:20 +08:00
parent 44be5753f7
commit ef611b0666
15 changed files with 876 additions and 23 deletions

View File

@ -305,8 +305,10 @@ done {}
- `POST /v1/auth/login {user_id, platform_key}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入) - `POST /v1/auth/login {user_id, platform_key}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)
- `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户 - `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) - `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`。后续续挂建用户/改角色/配置等管理动作
后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。 后续 `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。
**信任模型**:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。 **信任模型**:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。
@ -315,11 +317,16 @@ done {}
### 7.4 存储:Postgres + 本地文件系统 ### 7.4 存储:Postgres + 本地文件系统
```sql ```sql
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, created_at) users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null,
role text not null default 'user', -- 0009:user/admin;admin 才能访问 /v1/admin/* 管理后台
created_at)
-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存 -- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存
-- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用) -- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用)
-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id) -- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id)
-- ③ 未来 OIDC(替换 login 内部;email/oidc_subject 由 ID token 注入) -- ③ 未来 OIDC(替换 login 内部;email/oidc_subject 由 ID token 注入)
-- role:make_require_admin 每请求查(不进 JWT,改完即时生效、老 token 不重签);
-- 提管理员 main.py user role --email X --role admin。与 ZCBOT_ADMIN_TOKEN
-- (发用户共享口令)正交,互不相干
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description, tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description,
status, model_profile, tokens_prompt, tokens_completion, cost_usd, status, model_profile, tokens_prompt, tokens_completion, cost_usd,

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-11(用户私有 skill:多来源 registry + save_skill/fork_skill + skill-creator) 最后更新:2026-06-12(admin 管理后台:users.role + require_admin + /v1/admin/overview + 独立 admin.html 监控页)
--- ---
@ -21,6 +21,10 @@
## 已完成关键能力 ## 已完成关键能力
### 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;五段聚合对真实数据跑通。
### 2026-06-11 ### 2026-06-11
- **版本号机制(单一事实源 + 前端展示)**:此前只有 `web/app.py` 写死 `version="0.8"`(仅进 OpenAPI 文档,前端拿不到)。改为 `core/__init__.py``__version__`(当前 `0.8.0`)作唯一来源 → FastAPI `version`、`/healthz` 返回 `{"status":"ok","version":..}`、前端左栏底部展示全引它,**改版本只动这一行**。前端 `main.js` boot 时无条件 fetch `/healthz`(auth 豁免,embed/未登录都拿得到)填进 `#app-version`,**钉在右侧文件面板底部存储条(`.storage-foot`)最左、带细分隔线、垂直居中**(纯展示不可点;随存储条一起显隐)。**不放顶栏**:embed 模式桌面端整层 header 被 CSS 隐藏,顶栏点不到;**也不放左栏**:左栏底部留给后续按钮。CLAUDE.md「文档维护」段已加规矩:每次 commit/push bump `__version__`(patch=修复/重构/调参/skill、minor=成批新功能/对外行为变化、major=1.0 发版)。 - **版本号机制(单一事实源 + 前端展示)**:此前只有 `web/app.py` 写死 `version="0.8"`(仅进 OpenAPI 文档,前端拿不到)。改为 `core/__init__.py``__version__`(当前 `0.8.0`)作唯一来源 → FastAPI `version`、`/healthz` 返回 `{"status":"ok","version":..}`、前端左栏底部展示全引它,**改版本只动这一行**。前端 `main.js` boot 时无条件 fetch `/healthz`(auth 豁免,embed/未登录都拿得到)填进 `#app-version`,**钉在右侧文件面板底部存储条(`.storage-foot`)最左、带细分隔线、垂直居中**(纯展示不可点;随存储条一起显隐)。**不放顶栏**:embed 模式桌面端整层 header 被 CSS 隐藏,顶栏点不到;**也不放左栏**:左栏底部留给后续按钮。CLAUDE.md「文档维护」段已加规矩:每次 commit/push bump `__version__`(patch=修复/重构/调参/skill、minor=成批新功能/对外行为变化、major=1.0 发版)。

15
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-06-03(默认镜像源改清华 pip+apt / 腾讯 npm —— 腾讯 PyPI 给过损坏 litellm wheel,npmmirror 访问不稳;workspace 落数据盘改 bind mount,撤 ZCBOT_WORKSPACE_DIR env) 最后更新:2026-06-12(admin 角色 + /static/admin.html 管理后台:user role CLI / 建用户带 --role / 顶栏"管理"入口)
--- ---
@ -49,7 +49,8 @@
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。 - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **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))"`。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash`,0005 UNIQUE(email)):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.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 互不相干。
--- ---
@ -99,14 +100,20 @@ python -m venv .venv
# 发用户(两条路径,任选其一) # 发用户(两条路径,任选其一)
# a) CLI: # a) CLI:
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6" .venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
# → [ok] user added email=alice@example.com user_id=<uuid> # → [ok] user added email=alice@example.com role=user user_id=<uuid>
# b) 登录页右下角"+ 管理员添加用户":需先在 .env 里设 `ZCBOT_ADMIN_TOKEN`, # b) 登录页右下角"+ 管理员添加用户":需先在 .env 里设 `ZCBOT_ADMIN_TOKEN`,
# 弹窗输入 email/密码/管理员口令,POST /v1/auth/admin/create_user 落库。 # 弹窗输入 email/密码/管理员口令/角色,POST /v1/auth/admin/create_user 落库。
# 没设 env → 接口直接返 503,UI 入口会报"admin create_user disabled"。 # 没设 env → 接口直接返 503,UI 入口会报"admin create_user disabled"。
# 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径 # 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID> .venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>
# 角色:user(默认)/ admin。admin 才能开顶栏"管理"入口 → /static/admin.html 管理后台
# (监控总览:运行态/用量/任务/用户/存储)。建用户时带 --role,或事后改:
.venv/Scripts/python.exe main.py user add --email ops@x.com --password "s3cret" --role admin
.venv/Scripts/python.exe main.py user role --email alice@example.com --role admin
# → [ok] role set email=alice@example.com role=admin user_id=<uuid>
# 撤用户:先清 tasks(messages CASCADE)再 DELETE user # 撤用户:先清 tasks(messages CASCADE)再 DELETE user
# psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com'); # psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com');
# psql> DELETE FROM users WHERE email='alice@example.com'; # psql> DELETE FROM users WHERE email='alice@example.com';

View File

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

View File

@ -45,6 +45,9 @@ class User(Base):
oidc_subject: Mapped[Optional[str]] = mapped_column(Text, nullable=True) oidc_subject: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True) password_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
plan: Mapped[Optional[str]] = mapped_column(Text, nullable=True) plan: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 0009:访问角色。'user'(默认)/ 'admin';仅 admin 可访问 /v1/admin/* 管理端点。
# 提管理员:main.py user role --email X --role admin。
role: Mapped[str] = mapped_column(Text, nullable=False, server_default="user")
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )

View File

@ -0,0 +1,33 @@
"""users.role 列(admin 管理后台访问控制).
Revision ID: 0009
Revises: 0008
Create Date: 2026-06-12
users role (user / admin),给现有所有行默认 'user';/v1/admin/* 监控端点
make_require_admin gate,只放 role='admin' 的用户提管理员:
`.venv/Scripts/python.exe main.py user role --email X --role admin`
只加列不动现有数据(开发期测试数据保留);server_default='user' 让历史行自动回填
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0009"
down_revision: Union[str, None] = "0008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("role", sa.Text(), nullable=False, server_default="user"),
)
def downgrade() -> None:
op.drop_column("users", "role")

32
main.py
View File

@ -150,8 +150,11 @@ def user() -> None:
@click.option("--password", required=True, help="明文密码,后台 bcrypt 哈希落盘") @click.option("--password", required=True, help="明文密码,后台 bcrypt 哈希落盘")
@click.option("--user-id", default=None, @click.option("--user-id", default=None,
help="可选指定 UUID(默认随机);用于把已有 user_id 接到邮箱密码登录路径") help="可选指定 UUID(默认随机);用于把已有 user_id 接到邮箱密码登录路径")
def user_add(email: str, password: str, user_id: str) -> None: @click.option("--role", "role", default="user",
"""新建用户:bcrypt(password) → INSERT users(email,password_hash[,user_id])。 type=click.Choice(["user", "admin"]), show_default=True,
help="admin 可访问 /static/admin.html 管理后台;之后也可 user role 改")
def user_add(email: str, password: str, user_id: str, role: str) -> None:
"""新建用户:bcrypt(password) → INSERT users(email,password_hash,role[,user_id])。
email UNIQUE 报错退出 2;user_id PK 也是撤销直接 email UNIQUE 报错退出 2;user_id PK 也是撤销直接
`DELETE FROM users WHERE email='...'`(先清该 user tasks,否则 FK ) `DELETE FROM users WHERE email='...'`(先清该 user tasks,否则 FK )
@ -169,11 +172,32 @@ def user_add(email: str, password: str, user_id: str) -> None:
sys.exit(2) sys.exit(2)
try: try:
uid, e = create_user(email=email, password=password, user_id=uid_arg) uid, e = create_user(email=email, password=password, user_id=uid_arg, role=role)
except UserCreateError as ex: except UserCreateError as ex:
click.echo(f"[err] {ex.message}", err=True) click.echo(f"[err] {ex.message}", err=True)
sys.exit(2) sys.exit(2)
click.echo(f"[ok] user added email={e} user_id={uid}") click.echo(f"[ok] user added email={e} role={role} user_id={uid}")
@user.command("role")
@click.option("--email", required=True, help="目标用户登录邮箱")
@click.option("--role", "role", required=True,
type=click.Choice(["user", "admin"]),
help="user(普通)/ admin(可访问 /static/admin.html 管理后台)")
def user_role(email: str, role: str) -> None:
"""改用户角色:UPDATE users SET role=... WHERE email=...。
admin 才能访问 /v1/admin/* /static/admin.html改完下次请求立即生效
(role DB ,不进 JWT, token 无需重签)email 查无此人 退出 2
"""
from web.auth import UserCreateError, set_user_role
try:
uid, e = set_user_role(email=email, role=role)
except UserCreateError as ex:
click.echo(f"[err] {ex.message}", err=True)
sys.exit(2)
click.echo(f"[ok] role set email={e} role={role} user_id={uid}")
# ─────────────── Web 服务 ─────────────── # ─────────────── Web 服务 ───────────────

271
web/admin.py Normal file
View File

@ -0,0 +1,271 @@
"""管理后台端点(admin-only):/v1/admin/*。
`register_admin_routes(app, require_admin)` create_app 内调用,把管理路由挂上去,
整组走 `Depends(require_admin)`(JWT 有效 + users.role=='admin',否则 403)
第一版只有总览(监控指标):单个 `GET /v1/admin/overview` 一次返回全部 section
(runtime / tasks / users / usage / storage),前端定时轮询这一个端点即可runtime
app.state 内存();其余走 DB 聚合(GROUP BY, N+1)指标只读不落库
后续管理动作(建用户 / 改角色 / 配置磁盘配额等)在此模块续挂 /v1/admin/users
/v1/admin/config ,前端 admin.html tab
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from uuid import UUID
from fastapi import Depends, FastAPI
from sqlalchemy import BigInteger, Numeric, cast, func, select
from core.storage import session_scope
from core.storage.models import Task, UsageEvent, User, UserDiskUsage
from .broker import broker
try:
import resource # Unix only;Windows dev 无此模块,RSS 监控降级跳过
except ImportError: # pragma: no cover - Windows
resource = None
def _rss_peak_mb():
"""进程峰值 RSS(MB)。Linux 走 ru_maxrss(KB);Windows dev 返 None(降级)。"""
if resource is None:
return None
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
def _runtime_section(app: FastAPI) -> dict:
"""实时运行态:从 app.state 读内存,无 DB。
active_runs 逼近 max_workers 即线程池排队( run SSE 会卡) 前端据此变色
"""
inflight = getattr(app.state, "inflight", None)
active = len(inflight) if inflight is not None else 0
max_workers = getattr(app.state, "run_max_workers", None)
return {
"active_runs": active,
"max_workers": max_workers,
"sse_subs": broker.total_subscribers(),
"rss_peak_mb": _rss_peak_mb(),
}
def _tasks_section(s: Any) -> dict:
"""task 计数:总数 + 按 status + 按 run_status 分布。"""
total = s.execute(select(func.count()).select_from(Task)).scalar_one()
by_status = {
st: n
for st, n in s.execute(
select(Task.status, func.count()).group_by(Task.status)
).all()
}
by_run_status = {
st: n
for st, n in s.execute(
select(Task.run_status, func.count()).group_by(Task.run_status)
).all()
}
return {"total": total, "by_status": by_status, "by_run_status": by_run_status}
def _users_section(s: Any, cutoff_7d: datetime) -> dict:
"""用户:总数 + 近 7d 有用量事件的活跃用户数。"""
total = s.execute(select(func.count()).select_from(User)).scalar_one()
active_7d = s.execute(
select(func.count(func.distinct(UsageEvent.user_id))).where(
UsageEvent.created_at >= cutoff_7d
)
).scalar_one()
return {"total": total, "active_7d": active_7d}
def _usage_section(s: Any, cutoff_7d: datetime) -> dict:
"""token / 成本聚合:全局合计 + 近 7d 按天 + 按模型 + top 用户。
chat token 取自 usage_events.units JSONB(tokens_in/out/cache_hit_tokens);cost_cny
kind 合计 _usage_aggregates 同源,缓存命中率分母一致( 100%)
"""
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)
# 全局合计(all-time)
g = s.execute(
select(
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.coalesce(func.sum(hit).filter(chat), 0),
func.count(),
)
).one()
total = {
"cost_cny": float(g[0] or 0),
"tokens_in": int(g[1] or 0),
"tokens_out": int(g[2] or 0),
"tokens_cache_hit": int(g[3] or 0),
"n_events": int(g[4] or 0),
}
# 近 7d 按天(date 截断;前端画成条/数字均可)
day = func.date(UsageEvent.created_at)
by_day = [
{
"date": str(d),
"cost_cny": float(c or 0),
"tokens_in": int(ti or 0),
"tokens_out": int(to or 0),
}
for d, c, ti, to in s.execute(
select(
day,
func.coalesce(func.sum(UsageEvent.cost_cny), 0),
func.coalesce(func.sum(tin).filter(chat), 0),
func.coalesce(func.sum(tout).filter(chat), 0),
)
.where(UsageEvent.created_at >= cutoff_7d)
.group_by(day)
.order_by(day)
).all()
]
# 按模型(all-time;cost desc)
by_model = [
{
"model_profile": mp,
"cost_cny": float(c or 0),
"tokens_in": int(ti or 0),
"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()
]
return {
"total": total,
"by_day_7d": by_day,
"by_model": by_model,
}
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:[...]}
"""
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)
total_users = s.execute(select(func.count()).select_from(User)).scalar_one()
rows = [
{
"user_id": str(uid),
"email": email or "",
"role": role or "user",
"cost_cny": float(c or 0),
"tokens_in": int(ti or 0),
"tokens_out": int(to or 0),
"tokens_cache_hit": int(h or 0),
"n_events": int(n or 0),
}
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),
func.coalesce(func.sum(hit).filter(chat), 0),
func.count(UsageEvent.event_id),
)
.join(UsageEvent, UsageEvent.user_id == User.user_id, isouter=True)
.group_by(User.user_id, User.email, User.role)
.order_by(cost_sum.desc(), User.user_id)
.limit(page_size)
.offset(page * page_size)
).all()
]
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 配额。"""
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"))
rows = [
{
"user_id": str(uid),
"email": email or "",
"bytes_used": int(b or 0),
"file_count": int(fc or 0),
"scanned_at": scanned.isoformat() if scanned else None,
}
for uid, email, b, fc, scanned in s.execute(
select(
UserDiskUsage.user_id,
User.email,
UserDiskUsage.bytes_used,
UserDiskUsage.file_count,
UserDiskUsage.scanned_at,
)
.join(User, User.user_id == UserDiskUsage.user_id, isouter=True)
.order_by(UserDiskUsage.bytes_used.desc())
).all()
]
return {"quota_bytes": quota, "users": rows}
def register_admin_routes(app: FastAPI, require_admin) -> None:
"""把 /v1/admin/* 管理路由挂到 app 上,整组走 require_admin gate。"""
@app.get("/v1/admin/overview", tags=["admin"])
def admin_overview(user_id: UUID = Depends(require_admin)):
"""管理总览:一次返回全部 section,供 /static/admin.html 轮询。admin-only。"""
now = datetime.now(timezone.utc)
cutoff_7d = now - timedelta(days=7)
with session_scope() as s:
return {
"generated_at": now.isoformat(),
"runtime": _runtime_section(app),
"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/users", tags=["admin"])
def admin_usage_users(
page: int = 0, page_size: int = 20, user_id: UUID = Depends(require_admin)
):
"""各用户 token 用量(分页,cost desc)。admin-only。
page 0-based;page_size 夹到 [1,100]含零用量用户(全表 LEFT JOIN)
前端独立于 overview 轮询管理本表分页;总用量在 overview.usage.total
"""
page = max(0, page)
page_size = min(100, max(1, page_size))
with session_scope() as s:
return _user_usage_page(s, page, page_size)

View File

@ -51,10 +51,13 @@ from .auth import (
change_password, change_password,
create_user, create_user,
ensure_user_row, ensure_user_row,
get_user_role,
make_require_admin,
make_require_user, make_require_user,
mint_token, mint_token,
resolve_user_by_email, resolve_user_by_email,
) )
from .admin import register_admin_routes
from .broker import broker from .broker import broker
from .sinks import WebEventSink from .sinks import WebEventSink
from .static_files import NoCacheStaticFiles from .static_files import NoCacheStaticFiles
@ -542,6 +545,7 @@ class AdminCreateUserRequest(BaseModel):
email: str email: str
password: str password: str
admin_token: str admin_token: str
role: str = "user" # 'user' / 'admin';admin 可访问 /static/admin.html 管理后台
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
@ -559,6 +563,7 @@ def create_app() -> FastAPI:
# fail-fast:env 缺失直接抛,不裸跑无密 # fail-fast:env 缺失直接抛,不裸跑无密
auth_cfg = AuthConfig.from_env() auth_cfg = AuthConfig.from_env()
require_user = make_require_user(auth_cfg) require_user = make_require_user(auth_cfg)
require_admin = make_require_admin(auth_cfg)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@ -817,6 +822,15 @@ def create_app() -> FastAPI:
def healthz(): def healthz():
return {"status": "ok", "version": __version__} return {"status": "ok", "version": __version__}
@app.get("/v1/me", tags=["misc"])
def me(user_id: UUID = Depends(require_user)):
"""当前登录用户身份(JWT → user_id → DB role)。
前端 dev SPA localStorage 里的 token 恢复会话时调一次, role=='admin'
决定显不显"管理"入口(/static/admin.html)role DB ,改完即时生效
"""
return {"user_id": str(user_id), "role": get_user_role(user_id) or "user"}
@app.get("/v1/models", tags=["misc"]) @app.get("/v1/models", tags=["misc"])
def list_models(user_id: UUID = Depends(require_user)): def list_models(user_id: UUID = Depends(require_user)):
"""列出所有可用 LLM 模型(扫 config/models/*.yaml)。 """列出所有可用 LLM 模型(扫 config/models/*.yaml)。
@ -941,14 +955,16 @@ def create_app() -> FastAPI:
if body.admin_token != auth_cfg.admin_token: if body.admin_token != auth_cfg.admin_token:
raise HTTPException(403, "invalid admin_token") raise HTTPException(403, "invalid admin_token")
try: try:
uid, email = create_user(email=body.email, password=body.password) uid, email = create_user(
email=body.email, password=body.password, role=body.role
)
except UserCreateError as ex: except UserCreateError as ex:
if ex.code in ("invalid_email", "weak_password"): if ex.code in ("invalid_email", "weak_password", "invalid_role"):
raise HTTPException(400, ex.message) raise HTTPException(400, ex.message)
if ex.code == "email_taken": if ex.code == "email_taken":
raise HTTPException(409, "email already exists") raise HTTPException(409, "email already exists")
raise HTTPException(500, f"create_user failed: {ex.message}") raise HTTPException(500, f"create_user failed: {ex.message}")
return {"user_id": str(uid), "email": email} return {"user_id": str(uid), "email": email, "role": body.role}
@app.post("/v1/auth/login_password", tags=["auth"]) @app.post("/v1/auth/login_password", tags=["auth"])
def login_password(body: PasswordLoginRequest): def login_password(body: PasswordLoginRequest):
@ -970,6 +986,7 @@ def create_app() -> FastAPI:
"expires_at": _dt.fromtimestamp(exp).isoformat(), "expires_at": _dt.fromtimestamp(exp).isoformat(),
"user_id": str(uid), "user_id": str(uid),
"email": email, "email": email,
"role": get_user_role(uid) or "user",
"ttl_seconds": auth_cfg.ttl_seconds, "ttl_seconds": auth_cfg.ttl_seconds,
} }
@ -2189,4 +2206,7 @@ def create_app() -> FastAPI:
background=BackgroundTask(tmp_path.unlink, missing_ok=True), background=BackgroundTask(tmp_path.unlink, missing_ok=True),
) )
# ───────────── 管理后台(admin-only)─────────────
register_admin_routes(app, require_admin)
return app return app

View File

@ -160,12 +160,14 @@ class UserCreateError(Exception):
self.message = message self.message = message
def create_user(email: str, password: str, user_id: Optional[UUID] = None) -> tuple[UUID, str]: def create_user(
email: str, password: str, user_id: Optional[UUID] = None, role: str = "user"
) -> tuple[UUID, str]:
"""新建用户:bcrypt(password) + INSERT users。 """新建用户:bcrypt(password) + INSERT users。
校验:email @ + 非空;password 6 字符`user_id` 不传 随机 UUID4 校验:email @ + 非空;password 6 字符;role {'user','admin'}`user_id` 不传
冲突:email UNIQUE / user_id PK `UserCreateError('email_taken' | 'db_error')` 随机 UUID4冲突:email UNIQUE / user_id PK `UserCreateError('email_taken'
返回 `(user_id, normalized_email)`,供调用方记 log / 提示 | 'db_error')`返回 `(user_id, normalized_email)`,供调用方记 log / 提示
""" """
from uuid import uuid4 as _uuid4 from uuid import uuid4 as _uuid4
@ -176,10 +178,13 @@ def create_user(email: str, password: str, user_id: Optional[UUID] = None) -> tu
raise UserCreateError("invalid_email", f"email 不合法: {email!r}") raise UserCreateError("invalid_email", f"email 不合法: {email!r}")
if not password or len(password) < 6: if not password or len(password) < 6:
raise UserCreateError("weak_password", "password 至少 6 字符") raise UserCreateError("weak_password", "password 至少 6 字符")
r = (role or "user").strip().lower()
if r not in ("user", "admin"):
raise UserCreateError("invalid_role", f"role 必须是 user / admin,收到 {role!r}")
uid = user_id or _uuid4() uid = user_id or _uuid4()
try: try:
with session_scope() as s: with session_scope() as s:
s.add(User(user_id=uid, email=e, password_hash=hash_password(password))) s.add(User(user_id=uid, email=e, password_hash=hash_password(password), role=r))
except IntegrityError as ex: except IntegrityError as ex:
# email UNIQUE 撞最常见;user_id PK 撞理论上几乎不可能(uuid4)但也归一到 email_taken # email UNIQUE 撞最常见;user_id PK 撞理论上几乎不可能(uuid4)但也归一到 email_taken
# 之外 — 这里只对 email 报 409 语义,其他 DB 异常归 db_error # 之外 — 这里只对 email 报 409 语义,其他 DB 异常归 db_error
@ -214,6 +219,39 @@ def change_password(user_id: UUID, old_password: str, new_password: str) -> None
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
def get_user_role(user_id: UUID) -> Optional[str]:
"""查 users.role。user_id 无对应行返 None;有行返 'user' / 'admin' 等。
单次 SELECT,不缓存 role 下次请求立即生效(make_require_admin 每请求查一次,
管理员端点流量低,无需放进 JWT,也避免老 token 带过期 role)
"""
with session_scope() as s:
row = s.execute(
select(User.role).where(User.user_id == user_id)
).first()
return row[0] if row is not None else None
def set_user_role(email: str, role: str) -> tuple[UUID, str]:
"""按 email 改 users.role。返 `(user_id, normalized_email)`。
错误归一到 `UserCreateError.code`:
- 'invalid_role' role 不在 {'user', 'admin'}
- 'user_not_found' email 查无此人
成功在 session_scope 内一次 commit,下次请求立即生效
"""
r = (role or "").strip().lower()
if r not in ("user", "admin"):
raise UserCreateError("invalid_role", f"role 必须是 user / admin,收到 {role!r}")
e = (email or "").strip().lower()
with session_scope() as s:
user = s.execute(select(User).where(User.email == e)).scalar_one_or_none()
if user is None:
raise UserCreateError("user_not_found", f"email 查无此人: {email!r}")
user.role = r
return user.user_id, e
def ensure_user_row(user_id: UUID) -> None: def ensure_user_row(user_id: UUID) -> None:
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。 """幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
@ -255,16 +293,41 @@ def make_require_user(cfg: AuthConfig):
return require_user return require_user
def make_require_admin(cfg: AuthConfig):
"""工厂:返回一个 Depends 函数,先验 JWT(同 require_user)再查 users.role=='admin'
用于 /v1/admin/* 管理端点:
require_admin = make_require_admin(cfg)
@app.get("/v1/admin/...", )
def route(user_id: UUID = Depends(require_admin)): ...
admin 403(token 有效但无权限);DB role(不放进 JWT), role 立即生效
"""
_require_user = make_require_user(cfg)
async def require_admin(
creds: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
) -> UUID:
user_id = await _require_user(creds)
if get_user_role(user_id) != "admin":
raise HTTPException(403, "admin role required")
return user_id
return require_admin
__all__ = [ __all__ = [
"AuthConfig", "AuthConfig",
"UserCreateError", "UserCreateError",
"change_password", "change_password",
"create_user", "create_user",
"ensure_user_row", "ensure_user_row",
"get_user_role",
"hash_password", "hash_password",
"make_require_admin",
"make_require_user", "make_require_user",
"mint_token", "mint_token",
"resolve_user_by_email", "resolve_user_by_email",
"set_user_role",
"verify_password", "verify_password",
"verify_token", "verify_token",
] ]

115
web/static/admin.html Normal file
View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>zcbot 管理后台</title>
<style>
:root {
--bg: #f7f7f7; --panel: #ffffff; --border: #e3e3e3; --border-soft: #ececec;
--text: #222; --muted: #888; --accent: #c0392b; --accent-soft: #fde9e7;
--ok: #2e7d32; --warn: #c87f0a; --danger: #c0392b;
--r-md: 4px; --r-lg: 8px;
--mono: ui-monospace, "Cascadia Code", "SF Mono", Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text); background: var(--bg); font-size: 13px; line-height: 1.5;
}
header {
position: sticky; top: 0; z-index: 10; background: #fff;
border-bottom: 1px solid var(--border); box-shadow: 0 1px 2px rgba(0,0,0,.03);
display: flex; align-items: center; gap: 12px; padding: 10px 16px; flex-wrap: wrap;
}
header .logo {
width: 24px; height: 24px; border-radius: var(--r-md);
background: linear-gradient(135deg, var(--accent), #8e2a20);
color: #fff; font-weight: 700; font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
header .title { font-weight: 600; font-size: 15px; }
header .spacer { flex: 1; }
header .meta { color: var(--muted); font-size: 12px; font-family: var(--mono); }
header a, header button {
font-size: 12px; color: var(--accent); text-decoration: none;
padding: 4px 10px; border: 1px solid var(--accent-soft); border-radius: var(--r-md);
background: #fff; cursor: pointer;
}
header a:hover, header button:hover { background: var(--accent-soft); }
header label.auto { color: var(--muted); display: flex; align-items: center; gap: 4px; cursor: pointer; }
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
.msg { padding: 40px 16px; text-align: center; color: var(--muted); }
.msg a { color: var(--accent); }
.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);
padding: 14px 16px; margin-bottom: 14px;
}
.card h2 { margin: 0 0 10px; font-size: 13px; color: var(--muted); font-weight: 600; letter-spacing: .3px; }
/* 大数字 stat 块 */
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 12px 14px; }
.stat .k { color: var(--muted); font-size: 12px; }
.stat .v { font-size: 22px; font-weight: 600; font-family: var(--mono); margin-top: 4px; }
.stat .sub { color: var(--muted); font-size: 11px; margin-top: 2px; }
.stat.warn { border-color: var(--warn); background: #fff8ec; }
.stat.warn .v { color: var(--warn); }
.stat.danger { border-color: var(--danger); background: var(--accent-soft); }
.stat.danger .v { color: var(--danger); }
/* chips(状态分布) */
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
font-size: 12px; padding: 3px 9px; border-radius: 999px;
background: #f1f1f1; color: var(--text); font-family: var(--mono);
}
.chip b { font-weight: 700; }
.chip.err { background: var(--accent-soft); color: var(--danger); }
.chip.run { background: #e7f3ff; color: #1565c0; }
.chip.ok { background: #e8f5e9; color: var(--ok); }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 6px 8px; text-align: right; border-bottom: 1px solid var(--border-soft); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; }
th { color: var(--muted); font-weight: 600; }
td.num { font-family: var(--mono); }
td.email { font-family: var(--mono); max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
.bar-cell { position: relative; }
.scroll-x { overflow-x: auto; }
.empty { color: var(--muted); padding: 8px; text-align: center; }
.pager { display: flex; align-items: center; gap: 12px; justify-content: flex-end; margin-top: 10px; }
.pager button {
font-size: 12px; padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--r-md);
background: #fff; cursor: pointer; color: var(--text);
}
.pager button:hover:not(:disabled) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.pager button:disabled { opacity: .45; cursor: default; }
.pager .pginfo { color: var(--muted); font-size: 12px; font-family: var(--mono); }
@media (max-width: 640px) {
main { padding: 10px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.stat .v { font-size: 18px; }
}
</style>
</head>
<body>
<header>
<div class="logo">Z</div>
<div class="title">zcbot 管理后台</div>
<span class="meta" id="gen-at"></span>
<div class="spacer"></div>
<label class="auto"><input type="checkbox" id="auto-refresh" checked /> 自动刷新</label>
<button id="refresh">刷新</button>
<a href="/static/dev.html">← 返回控制台</a>
</header>
<main id="main">
<div class="msg" id="boot">加载中…</div>
</main>
<script type="module" src="/static/js/admin.js"></script>
</body>
</html>

View File

@ -170,7 +170,7 @@
display: block; margin-top: 10px; margin-bottom: 4px; display: block; margin-top: 10px; margin-bottom: 4px;
font-size: 12px; color: var(--muted); font-size: 12px; color: var(--muted);
} }
#admin-modal input { #admin-modal input, #admin-modal select {
width: 100%; padding: 8px 10px; border-radius: var(--r-md); width: 100%; padding: 8px 10px; border-radius: var(--r-md);
border: 1px solid var(--border); background: #fafafa; border: 1px solid var(--border); background: #fafafa;
} }
@ -341,6 +341,11 @@
header .title { font-weight: 600; font-size: 15px; letter-spacing: .2px; } header .title { font-weight: 600; font-size: 15px; letter-spacing: .2px; }
header .who { color: var(--muted); font-size: 12px; font-family: var(--mono); } header .who { color: var(--muted); font-size: 12px; font-family: var(--mono); }
header .spacer { flex: 1; } header .spacer { flex: 1; }
#hd-admin {
text-decoration: none; color: var(--accent); font-size: 12px;
padding: 4px 10px; border: 1px solid var(--accent-soft); border-radius: var(--r-md);
}
#hd-admin:hover { background: var(--accent-soft); }
.pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; min-height: 0; } .pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; min-height: 0; }
/* 左 pane:flex column,顶部多行 pane-head 固定,只让 #task-scroll 滚 — 滚动条不再覆盖顶栏 */ /* 左 pane:flex column,顶部多行 pane-head 固定,只让 #task-scroll 滚 — 滚动条不再覆盖顶栏 */
@ -1054,6 +1059,11 @@
<input id="ad-password" type="password" autocomplete="new-password" placeholder="≥ 6 字符" /> <input id="ad-password" type="password" autocomplete="new-password" placeholder="≥ 6 字符" />
<label for="ad-token">管理员口令</label> <label for="ad-token">管理员口令</label>
<input id="ad-token" type="password" autocomplete="off" placeholder="$ZCBOT_ADMIN_TOKEN env 值" /> <input id="ad-token" type="password" autocomplete="off" placeholder="$ZCBOT_ADMIN_TOKEN env 值" />
<label for="ad-role">角色</label>
<select id="ad-role">
<option value="user" selected>user普通用户</option>
<option value="admin">admin可访问监控页</option>
</select>
<div class="err" id="ad-err"></div> <div class="err" id="ad-err"></div>
<div class="actions"> <div class="actions">
<button id="ad-cancel">取消</button> <button id="ad-cancel">取消</button>
@ -1115,6 +1125,8 @@
</div> </div>
<div class="who" id="hd-who"></div> <div class="who" id="hd-who"></div>
<div class="spacer"></div> <div class="spacer"></div>
<a id="hd-admin" href="/static/admin.html" target="_blank" rel="noopener"
title="管理后台(仅管理员)" style="display:none;">管理</a>
<button id="hd-chpw" title="修改登录密码">改密码</button> <button id="hd-chpw" title="修改登录密码">改密码</button>
<button id="hd-logout">退出登录</button> <button id="hd-logout">退出登录</button>
<!-- 手机 tab(桌面 display:none):任务 / 对话 / 文件 --> <!-- 手机 tab(桌面 display:none):任务 / 对话 / 文件 -->

280
web/static/js/admin.js Normal file
View File

@ -0,0 +1,280 @@
// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,但不挂主应用模块图,
// 自成一页:拉 GET /v1/admin/overview 一次渲染全部 section,默 10s 自动轮询。
// 鉴权失败:401(token 失效)/ 403(非 admin)给出明确提示 + 回控制台链接。
// 后续管理动作(建用户 / 改角色 / 配置)在此页加 tab,各自打对应 /v1/admin/* 端点。
import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js";
const LS_TOKEN = "zcbot.token";
const REFRESH_MS = 10000;
const PAGE_SIZE = 20;
const $ = (id) => document.getElementById(id);
const token = () => localStorage.getItem(LS_TOKEN) || "";
let timer = null;
let userPage = 0; // 各用户用量表当前页(0-based);独立于 overview 轮询
// ───── 格式化 ─────
function fmtCNY(n) {
n = Number(n) || 0;
if (n < 0.01 && n > 0) return "¥" + n.toFixed(4);
return "¥" + n.toFixed(2);
}
// 相对热力底色:value 占 max 越高,accent 底色越深(占用多 → 有色差)。
function tint(value, max) {
if (!max || max <= 0 || !value || value <= 0) return "";
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 "";
}
// ───── 渲染各 section ─────
function statCard(k, v, sub, cls) {
return `<div class="stat ${cls || ""}"><div class="k">${escapeHtml(k)}</div>`
+ `<div class="v">${v}</div>`
+ (sub ? `<div class="sub">${sub}</div>` : "") + `</div>`;
}
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("SSE 订阅", r.sse_subs || 0, "当前流式连接")
+ statCard("内存峰值", rss, "进程 RSS high-water")
+ `</div></div>`;
}
function renderTasks(t) {
const order = ["active", "completed", "abandoned"];
const statusChips = Object.entries(t.by_status || {})
.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
.map(([k, n]) => `<span class="chip ${k === "completed" ? "ok" : ""}">${escapeHtml(k)} <b>${n}</b></span>`)
.join("") || `<span class="empty">无</span>`;
const runChips = Object.entries(t.by_run_status || {})
.map(([k, n]) => {
const c = k === "error" ? "err" : (k === "running" || k === "cancelling") ? "run" : "";
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>`;
}
function renderUsersAndUsage(users, usage) {
const u = usage.total || {};
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
return `<div class="card"><h2>用户与用量总览all-time</h2><div class="grid">`
+ statCard("用户数", users.total || 0, `近 7 天活跃 ${users.active_7d || 0}`)
+ statCard("总成本", fmtCNY(u.cost_cny), `${u.n_events || 0} 次事件`)
+ statCard("输入 token", fmtTokens(u.tokens_in), `缓存命中 ${hitRate}%`)
+ statCard("输出 token", fmtTokens(u.tokens_out), "")
+ `</div></div>`;
}
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));
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("");
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>`
+ `<thead><tr><th>模型</th><th>成本</th><th>输入</th><th>输出</th><th>事件</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`;
}
// 各用户 token 用量(分页)。独立于 overview 轮询:用户翻页时按需拉,overview tick
// 时也顺手刷新当前页保持数字新鲜(userPage 不丢)。
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;
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 maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const maxTin = Math.max(0, ...rows.map(r => r.tokens_in || 0));
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">${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>`
+ `<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);
}
function renderStorage(st) {
const quota = st.quota_bytes;
const rows = st.users || [];
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 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);" : "")
: tint(r.bytes_used, maxUsed);
return `<tr>`
+ `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}</td>`
+ `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
+ `<td class="num">${pctTxt}</td>`
+ `<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>`
+ `<thead><tr><th>用户</th><th>已用</th><th>占配额</th><th>文件数</th><th>扫描于</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`;
}
// #main 拆两块:#metrics(每次 overview tick 整体重渲)与 #user-usage(分页表,
// 独立 fetch、自管页码)。骨架只建一次,避免翻页态被 overview 重渲冲掉。
function ensureSkeleton() {
if ($("metrics")) return;
$("main").innerHTML = `<div id="metrics"></div><div id="user-usage"></div>`;
}
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 || {});
}
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() {
const t = token();
if (!t) {
showMsg(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
stopAuto();
return;
}
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;
}
ensureSkeleton();
renderMetrics(await r.json());
loadUserUsage(userPage); // 顺手刷当前页,保持数字新鲜(不丢页码)
} catch (e) {
showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
}
}
// ───── 自动刷新 ─────
function startAuto() {
stopAuto();
if ($("auto-refresh").checked) timer = setInterval(refresh, REFRESH_MS);
}
function stopAuto() {
if (timer) { clearInterval(timer); timer = null; }
}
$("refresh").onclick = refresh;
$("auto-refresh").onchange = startAuto;
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopAuto();
else { refresh(); startAuto(); }
});
refresh();
startAuto();

View File

@ -111,6 +111,7 @@ function openAdminModal() {
$("ad-email").value = ""; $("ad-email").value = "";
$("ad-password").value = ""; $("ad-password").value = "";
$("ad-token").value = ""; $("ad-token").value = "";
$("ad-role").value = "user";
$("ad-err").textContent = ""; $("ad-err").textContent = "";
$("admin-modal").classList.add("show"); $("admin-modal").classList.add("show");
$("ad-email").focus(); $("ad-email").focus();
@ -133,6 +134,7 @@ async function doAdminAdd() {
const email = $("ad-email").value.trim(); const email = $("ad-email").value.trim();
const password = $("ad-password").value; const password = $("ad-password").value;
const admin_token = $("ad-token").value; const admin_token = $("ad-token").value;
const role = $("ad-role").value;
if (!email || !password || !admin_token) { if (!email || !password || !admin_token) {
$("ad-err").textContent = "请填邮箱、密码、管理员口令"; $("ad-err").textContent = "请填邮箱、密码、管理员口令";
return; return;
@ -144,7 +146,7 @@ async function doAdminAdd() {
try { try {
const r = await fetch("/v1/auth/admin/create_user", { const r = await fetch("/v1/auth/admin/create_user", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, admin_token }), body: JSON.stringify({ email, password, admin_token, role }),
}); });
if (!r.ok) { if (!r.ok) {
const d = await r.json().catch(() => ({})); const d = await r.json().catch(() => ({}));
@ -158,7 +160,7 @@ async function doAdminAdd() {
$("li-password").value = ""; $("li-password").value = "";
$("li-password").focus(); $("li-password").focus();
$("li-err").style.color = "var(--muted)"; // 临时降级为提示色 $("li-err").style.color = "var(--muted)"; // 临时降级为提示色
$("li-err").textContent = `已创建 ${data.email},请登录`; $("li-err").textContent = `已创建 ${data.email}${data.role || role}),请登录`;
setTimeout(() => { $("li-err").style.color = ""; }, 4000); setTimeout(() => { $("li-err").style.color = ""; }, 4000);
} catch (e) { } catch (e) {
$("ad-err").textContent = e.message; $("ad-err").textContent = e.message;

View File

@ -26,6 +26,18 @@ export function enterApp() {
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标 loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项) loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项)
loadStorage(); // 顶栏存储用量(后台扫描快照,非实时) loadStorage(); // 顶栏存储用量(后台扫描快照,非实时)
loadRole(); // 拉 /v1/me,admin 才显「管理」入口(/static/admin.html)
}
// 当前用户角色:/v1/me 返 {user_id, role}。admin → 显顶栏「管理」链接。
// 失败静默(入口是增量功能,拉不到就当普通用户,不挡主流程)。
async function loadRole() {
const link = $("hd-admin");
if (!link) return;
try {
const me = await api("GET", "/v1/me");
link.style.display = (me && me.role === "admin") ? "" : "none";
} catch (e) { link.style.display = "none"; }
} }
// 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描, // 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描,