diff --git a/DESIGN.md b/DESIGN.md index fc3f384..3db95ee 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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_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`。后续续挂建用户/改角色/配置等管理动作 -后续 `Authorization: Bearer ` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。 +后续 `Authorization: Bearer ` 走所有 /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 服务端泄漏 = 用户身份泄漏"同级,可接受。 @@ -315,11 +317,16 @@ done {} ### 7.4 存储:Postgres + 本地文件系统 ```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 共存 -- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用) -- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id) -- ③ 未来 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, status, model_profile, tokens_prompt, tokens_completion, cost_usd, diff --git a/PROGRESS.md b/PROGRESS.md index 31c0a8d..0951d94 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `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 - **版本号机制(单一事实源 + 前端展示)**:此前只有 `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 发版)。 diff --git a/RUN.md b/RUN.md index 2293704..cfa93f8 100644 --- a/RUN.md +++ b/RUN.md @@ -2,7 +2,7 @@ > 怎么把 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`)。 - **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`,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: .venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6" -# → [ok] user added email=alice@example.com user_id= +# → [ok] user added email=alice@example.com role=user user_id= # 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"。 # 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径 .venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id +# 角色: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= + # 撤用户:先清 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 users WHERE email='alice@example.com'; diff --git a/core/__init__.py b/core/__init__.py index c665ffe..ce9a39a 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.8.1" +__version__ = "0.9.0" diff --git a/core/storage/models.py b/core/storage/models.py index 77c31f1..199cba0 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -45,6 +45,9 @@ class User(Base): oidc_subject: 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) + # 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( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/db/migrations/versions/20260612_1000_0009_users_role.py b/db/migrations/versions/20260612_1000_0009_users_role.py new file mode 100644 index 0000000..482acc2 --- /dev/null +++ b/db/migrations/versions/20260612_1000_0009_users_role.py @@ -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") diff --git a/main.py b/main.py index 414d368..b3ba657 100644 --- a/main.py +++ b/main.py @@ -150,8 +150,11 @@ def user() -> None: @click.option("--password", required=True, help="明文密码,后台 bcrypt 哈希落盘") @click.option("--user-id", default=None, help="可选指定 UUID(默认随机);用于把已有 user_id 接到邮箱密码登录路径") -def user_add(email: str, password: str, user_id: str) -> None: - """新建用户:bcrypt(password) → INSERT users(email,password_hash[,user_id])。 +@click.option("--role", "role", default="user", + 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 也是。撤销直接 `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) 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: click.echo(f"[err] {ex.message}", err=True) 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 服务 ─────────────── diff --git a/web/admin.py b/web/admin.py new file mode 100644 index 0000000..64cebc4 --- /dev/null +++ b/web/admin.py @@ -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) diff --git a/web/app.py b/web/app.py index 3f27fa7..698e186 100644 --- a/web/app.py +++ b/web/app.py @@ -51,10 +51,13 @@ from .auth import ( change_password, create_user, ensure_user_row, + get_user_role, + make_require_admin, make_require_user, mint_token, resolve_user_by_email, ) +from .admin import register_admin_routes from .broker import broker from .sinks import WebEventSink from .static_files import NoCacheStaticFiles @@ -542,6 +545,7 @@ class AdminCreateUserRequest(BaseModel): email: str password: str admin_token: str + role: str = "user" # 'user' / 'admin';admin 可访问 /static/admin.html 管理后台 class ChangePasswordRequest(BaseModel): @@ -559,6 +563,7 @@ def create_app() -> FastAPI: # fail-fast:env 缺失直接抛,不裸跑无密 auth_cfg = AuthConfig.from_env() require_user = make_require_user(auth_cfg) + require_admin = make_require_admin(auth_cfg) @asynccontextmanager async def lifespan(app: FastAPI): @@ -817,6 +822,15 @@ def create_app() -> FastAPI: def healthz(): 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"]) def list_models(user_id: UUID = Depends(require_user)): """列出所有可用 LLM 模型(扫 config/models/*.yaml)。 @@ -941,14 +955,16 @@ def create_app() -> FastAPI: if body.admin_token != auth_cfg.admin_token: raise HTTPException(403, "invalid admin_token") 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: - if ex.code in ("invalid_email", "weak_password"): + if ex.code in ("invalid_email", "weak_password", "invalid_role"): raise HTTPException(400, ex.message) if ex.code == "email_taken": raise HTTPException(409, "email already exists") 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"]) def login_password(body: PasswordLoginRequest): @@ -970,6 +986,7 @@ def create_app() -> FastAPI: "expires_at": _dt.fromtimestamp(exp).isoformat(), "user_id": str(uid), "email": email, + "role": get_user_role(uid) or "user", "ttl_seconds": auth_cfg.ttl_seconds, } @@ -2189,4 +2206,7 @@ def create_app() -> FastAPI: background=BackgroundTask(tmp_path.unlink, missing_ok=True), ) + # ───────────── 管理后台(admin-only)───────────── + register_admin_routes(app, require_admin) + return app diff --git a/web/auth.py b/web/auth.py index 3a1e74f..a883e40 100644 --- a/web/auth.py +++ b/web/auth.py @@ -160,12 +160,14 @@ class UserCreateError(Exception): 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。 - 校验:email 含 @ + 非空;password ≥ 6 字符。`user_id` 不传 → 随机 UUID4。 - 冲突:email UNIQUE / user_id PK 撞 → `UserCreateError('email_taken' | 'db_error')`。 - 返回 `(user_id, normalized_email)`,供调用方记 log / 提示。 + 校验:email 含 @ + 非空;password ≥ 6 字符;role ∈ {'user','admin'}。`user_id` 不传 + → 随机 UUID4。冲突:email UNIQUE / user_id PK 撞 → `UserCreateError('email_taken' + | 'db_error')`。返回 `(user_id, normalized_email)`,供调用方记 log / 提示。 """ 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}") if not password or len(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() try: 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: # email UNIQUE 撞最常见;user_id PK 撞理论上几乎不可能(uuid4)但也归一到 email_taken # 之外 — 这里只对 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) +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: """幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。 @@ -255,16 +293,41 @@ def make_require_user(cfg: AuthConfig): 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__ = [ "AuthConfig", "UserCreateError", "change_password", "create_user", "ensure_user_row", + "get_user_role", "hash_password", + "make_require_admin", "make_require_user", "mint_token", "resolve_user_by_email", + "set_user_role", "verify_password", "verify_token", ] diff --git a/web/static/admin.html b/web/static/admin.html new file mode 100644 index 0000000..d517ef6 --- /dev/null +++ b/web/static/admin.html @@ -0,0 +1,115 @@ + + + + + +zcbot 管理后台 + + + +
+ +
zcbot 管理后台
+ +
+ + + ← 返回控制台 +
+
+
加载中…
+
+ + + diff --git a/web/static/dev.html b/web/static/dev.html index 5ad102e..05f5911 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -170,7 +170,7 @@ display: block; margin-top: 10px; margin-bottom: 4px; 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); border: 1px solid var(--border); background: #fafafa; } @@ -341,6 +341,11 @@ header .title { font-weight: 600; font-size: 15px; letter-spacing: .2px; } header .who { color: var(--muted); font-size: 12px; font-family: var(--mono); } 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:flex column,顶部多行 pane-head 固定,只让 #task-scroll 滚 — 滚动条不再覆盖顶栏 */ @@ -1054,6 +1059,11 @@ + +
@@ -1115,6 +1125,8 @@
+ diff --git a/web/static/js/admin.js b/web/static/js/admin.js new file mode 100644 index 0000000..53f2d48 --- /dev/null +++ b/web/static/js/admin.js @@ -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 `
${escapeHtml(k)}
` + + `
${v}
` + + (sub ? `
${sub}
` : "") + `
`; +} + +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 `

实时运行态

` + + statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, cls) + + statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接") + + statCard("内存峰值", rss, "进程 RSS high-water") + + `
`; +} + +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]) => `${escapeHtml(k)} ${n}`) + .join("") || ``; + const runChips = Object.entries(t.by_run_status || {}) + .map(([k, n]) => { + const c = k === "error" ? "err" : (k === "running" || k === "cancelling") ? "run" : ""; + return `${escapeHtml(k)} ${n}`; + }).join("") || ``; + return `

任务(共 ${t.total || 0})

` + + `
status
${statusChips}
` + + `
run_status
${runChips}
` + + `
`; +} + +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 `

用户与用量总览(all-time)

` + + 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), "") + + `
`; +} + +function renderByDay(rows) { + if (!rows || !rows.length) return `

近 7 天用量

无数据
`; + const maxCost = Math.max(...rows.map(r => r.cost_cny || 0)); + const body = rows.map(r => `` + + `${escapeHtml(r.date)}` + + `${fmtCNY(r.cost_cny)}` + + `${fmtTokens(r.tokens_in)}` + + `${fmtTokens(r.tokens_out)}` + + ``).join(""); + return `

近 7 天用量(按天)

` + + `` + + `${body}
日期成本输入输出
`; +} + +function renderByModel(rows) { + if (!rows || !rows.length) return `

按模型

无数据
`; + const maxCost = Math.max(...rows.map(r => r.cost_cny || 0)); + const body = rows.map(r => `` + + `${escapeHtml(r.model_profile || "—")}` + + `${fmtCNY(r.cost_cny)}` + + `${fmtTokens(r.tokens_in)}` + + `${fmtTokens(r.tokens_out)}` + + `${r.n_events || 0}` + + ``).join(""); + return `

按模型(all-time)

` + + `` + + `${body}
模型成本输入输出事件
`; +} + +// 各用户 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 `` + + `${escapeHtml(r.email || r.user_id.slice(0, 8))}` + + (r.role === "admin" ? ` admin` : "") + `` + + `${fmtCNY(r.cost_cny)}` + + `${fmtTokens(r.tokens_in)}` + + `${fmtTokens(r.tokens_out)}` + + `${hitRate}%` + + `${r.n_events || 0}` + + ``; + }).join("") || `无数据`; + c.innerHTML = `

各用户用量(按成本,all-time)

` + + `
` + + `` + + `${body}
用户成本输入输出缓存命中事件
` + + `
` + + `` + + `${from}–${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)` + + `` + + `
`; + 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 `

存储用量(${quotaLabel})

无数据
`; + 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 `` + + `${escapeHtml(r.email || r.user_id.slice(0, 8))}` + + `${humanSize(r.bytes_used)}` + + `${pctTxt}` + + `${r.file_count || 0}` + + `${r.scanned_at ? fmtTime(r.scanned_at) : "—"}` + + ``; + }).join(""); + return `

存储用量(${quotaLabel})

` + + `` + + `${body}
用户已用占配额文件数扫描于
`; +} + +// #main 拆两块:#metrics(每次 overview tick 整体重渲)与 #user-usage(分页表, +// 独立 fetch、自管页码)。骨架只建一次,避免翻页态被 overview 重渲冲掉。 +function ensureSkeleton() { + if ($("metrics")) return; + $("main").innerHTML = `
`; +} + +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 = `
${html}
`; // 清骨架,错误态独占 +} + +// 处理鉴权/网络错误:命中返 true(调用方据此中止)。 +function handleAuthError(r) { + if (r.status === 401) { + showMsg(`登录已失效。请回 控制台 重新登录。`); + stopAuto(); return true; + } + if (r.status === 403) { + showMsg(`无权限:管理后台仅限管理员(admin)访问。
` + + `返回控制台`); + 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(`未登录。请先在 控制台 登录后再访问管理后台。`); + 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(); diff --git a/web/static/js/auth.js b/web/static/js/auth.js index d3a9f22..fd4f93d 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -111,6 +111,7 @@ function openAdminModal() { $("ad-email").value = ""; $("ad-password").value = ""; $("ad-token").value = ""; + $("ad-role").value = "user"; $("ad-err").textContent = ""; $("admin-modal").classList.add("show"); $("ad-email").focus(); @@ -133,6 +134,7 @@ async function doAdminAdd() { const email = $("ad-email").value.trim(); const password = $("ad-password").value; const admin_token = $("ad-token").value; + const role = $("ad-role").value; if (!email || !password || !admin_token) { $("ad-err").textContent = "请填邮箱、密码、管理员口令"; return; @@ -144,7 +146,7 @@ async function doAdminAdd() { try { const r = await fetch("/v1/auth/admin/create_user", { 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) { const d = await r.json().catch(() => ({})); @@ -158,7 +160,7 @@ async function doAdminAdd() { $("li-password").value = ""; $("li-password").focus(); $("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); } catch (e) { $("ad-err").textContent = e.message; diff --git a/web/static/js/main.js b/web/static/js/main.js index baf8640..d3409b9 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -26,6 +26,18 @@ export function enterApp() { loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标 loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项) 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 扫描,