From f17da6a6e1c198e7f4a171d00749f22eb117dcc4 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 25 Jun 2026 09:31:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=B9=B3=E5=8F=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=B3=A8=E5=85=A5=20name/user=5Fname=20+=20=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E9=A1=B5/dev=20=E9=A1=B6=E6=A0=8F=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=90=8D=E5=B1=95=E7=A4=BA=20+=20bump=200.26.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 平台登录档案注入(0.26.0): - users 加 name/user_name 两列(migration 0016,纯加 nullable 列,平滑兼容存量行) - /v1/auth/login body 可选收 name/user_name,ensure_user_row 升级为 upsert (COALESCE(EXCLUDED, 旧值):平台传非空就刷新、传 null 不覆盖清空) - login / login_password / /v1/me 响应回带 name/user_name/role 用户名展示(0.26.1): - 统一兜底链 name → user_name → email → uid8,监控页与 dev 页共用 - 监控页 admin.js:各用户用量 / 存储 / overview 迷你表用户列走 userCellHTML, name+user_name 都有时主显 name + 浅灰 user_name;title 悬浮完整身份。 admin.py 两表 SELECT 补 User.name/user_name - dev 顶栏 main.js renderWho:默认显 name,hover 显账号/邮箱/ID; state.js 加 userUserName/userEmail + setIdentity/userDisplayName/userDisplayTitle helper, 登录 / embed / /v1/me 校准共用 注:migration 0016 需在目标环境 `main.py db upgrade head` 应用后生效。 Co-Authored-By: Claude Opus 4.8 (1M context) --- DESIGN.md | 7 ++- PROGRESS.md | 13 ++++- core/__init__.py | 2 +- core/storage/models.py | 5 ++ .../20260625_1000_0016_users_name_username.py | 33 ++++++++++++ web/admin.py | 14 ++++-- web/app.py | 29 +++++++++-- web/auth.py | 50 +++++++++++++++++-- web/static/js/admin.js | 31 ++++++++++-- web/static/js/auth.js | 28 +++++------ web/static/js/embed.js | 9 ++-- web/static/js/main.js | 29 +++++++---- web/static/js/state.js | 34 ++++++++++++- 13 files changed, 230 insertions(+), 54 deletions(-) create mode 100644 db/migrations/versions/20260625_1000_0016_users_name_username.py diff --git a/DESIGN.md b/DESIGN.md index 6fa13e8..fb8e3f2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -306,10 +306,10 @@ done {} ### 7.3 认证 **当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL): -- `POST /v1/auth/login {user_id, platform_key}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入) +- `POST /v1/auth/login {user_id, platform_key, name?, user_name?}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)。body 可选带 `name`(显示名)/ `user_name`(平台账号名),`ensure_user_row` upsert 落 `users.name/user_name`(`COALESCE(EXCLUDED, 旧值)`:平台传非空就刷新、同步平台侧改名,传 null 不覆盖);响应回带 `{name, user_name, role}`。缺省即旧行为(只填 user_id),向后兼容老调用方。与未来 OIDC 的 `name/preferred_username` claim 注入同构 - `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/me` — 返 `{user_id, role, name, user_name, email}`(走 DB 查),dev SPA 据 role 决定显不显"管理"入口,据 name/user_name/email 渲顶栏用户名(默认 name,hover 显账号 / 邮箱)。两条 login 响应同样回带 name/user_name(平滑展示,登录即有名,/v1/me 再校准) - `GET /v1/admin/*` — 管理后台,`Depends(require_admin)`(验 JWT + `users.role=='admin'`,否则 403)。`/v1/admin/overview` 返固定指标(runtime/tasks/users/usage 总用量+近7d趋势,供轮询);`/v1/admin/usage/models?range=&sort=`、`/v1/admin/usage/users?range=&sort=&page=&page_size=`、`/v1/admin/storage/users?page=&page_size=` 是带时间筛选(all/7d/30d)/ 排序(cost/tokens)/ 分页的独立表端点。独立页 `/static/admin.html`(目录导航 + 客户端打印导出 PDF)。后续续挂建用户/改角色/配置等管理动作 后续 `Authorization: Bearer ` 走所有 /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。 @@ -322,6 +322,9 @@ done {} ```sql users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, + name text null, user_name text null, -- 0016:平台登录注入的档案(显示名 / 平台账号名); + -- platform_key 入口 ensure_user_row upsert 写, + -- 邮箱密码 / 历史行留空。未来 OIDC claim 注入同构 role text not null default 'user', -- 0009:user/admin;admin 才能访问 /v1/admin/* 管理后台 created_at) -- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存 diff --git a/PROGRESS.md b/PROGRESS.md index 6fb2da1..45fb01a 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-25(登录失败提示修正:前端把凭据类失败统一为「账号或密码错误」,后端 detail 改中文 + bump 0.25.2) +最后更新:2026-06-25(用户名展示:监控页用户列 + dev 顶栏走 name→user_name→email→uid8 兜底链,hover 显完整身份 + bump 0.26.1) --- @@ -21,6 +21,17 @@ ## 已完成关键能力 +### 2026-06-25 / 用户名展示:监控页 + dev 顶栏(bump 0.26.1) +- 统一一条兜底链 `name → user_name → email → uid8`,监控页与 dev 页共用。 +- 监控页(`admin.js`):各用户用量 / 存储两表 + overview 迷你表的用户列改走 `userCellHTML`/`userLabelText`,name 与 user_name 都有时主显 name + 浅灰 user_name;`title` 悬浮给完整姓名/账号/邮箱/ID。后端 `admin.py` 两张表 SELECT 补 `User.name/user_name` 回带。 +- dev 顶栏(`main.js` `renderWho`):默认显 name,hover(title)显账号/邮箱/ID。`state.js` 加 `userUserName/userEmail` + LS 持久化,抽 `setIdentity`/`userDisplayName`/`userDisplayTitle` 三个 helper,登录(`auth.js`)、embed 签发(`embed.js`)、`/v1/me` 校准(`loadRole`)共用;`login_password` 响应也回带 name/user_name 避免展示闪烁。 + +### 2026-06-25 / 平台登录注入用户档案 name/user_name(bump 0.26.0) +- 需求:平台作为可信中间层登录时,把用户 `name`(显示名)/ `user_name`(平台账号名)一并注入 zcbot 持久化,供前端展示。 +- 实现:`users` 加两列(migration `0016`,纯加 nullable 列,平滑兼容存量行);`LoginRequest` 加可选 `name/user_name`,缺省即旧行为(向后兼容老调用方);`ensure_user_row` 升级为 upsert,`ON CONFLICT DO UPDATE SET x = COALESCE(EXCLUDED.x, users.x)` —— 平台传非空就刷新(同步平台侧改名),传 null/空不覆盖清空,空串归一到 None。 +- 暴露:`/v1/auth/login` 响应 + `/v1/me` 回带 `{name, user_name, role}`(新增 `get_user_profile` 单次 SELECT)。机制选 platform 在 login body 推送(零额外往返,与未来 OIDC 的 name/preferred_username claim 注入同构),未选 zcbot 反向拉平台 API。 +- 待办:migration `0016` 需在配好 `ZCBOT_DB_URL` 的环境跑 `.venv/Scripts/python.exe main.py db upgrade head` 应用;前端可消费 `/v1/me` 的 name 显示用户名。 + ### 2026-06-25 / 登录失败提示修正(bump 0.25.2) - 问题:邮箱密码输错时前端弹「404」(后端 `login_password` 实际返 403「invalid email or password」,前置网关/旧构建把状态改写成 404 后,前端 `doLogin` 直接回显 `r.status + " login failed"` → 用户看到「404 login failed」,语义错误)。 - 修:`web/static/js/auth.js` `doLogin` 失败分支不再回显原始状态码 —— 表单已校验非空,非 2xx 绝大多数是凭据不对,统一给「账号或密码错误」(pw tab)/「user_id 或 PLATFORM_KEY 错误」(key tab);仅 5xx 暴露状态码提示服务端问题。后端 `web/app.py:1399` detail 同步改中文「账号或密码错误」保持契约自洽。 diff --git a/core/__init__.py b/core/__init__.py index a8a87b5..972efff 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.25.2" +__version__ = "0.26.1" diff --git a/core/storage/models.py b/core/storage/models.py index 0d9731b..0b42095 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -46,6 +46,11 @@ 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) + # 0016:平台登录注入的用户档案。name=显示名/姓名,user_name=平台账号名;均 nullable + # (platform_key 入口 ensure_user_row upsert 写;邮箱密码 / 历史行留空)。未来 OIDC + # 接管时由 ID token 的 name / preferred_username claim 注入,数据流不变。 + name: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + user_name: 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") diff --git a/db/migrations/versions/20260625_1000_0016_users_name_username.py b/db/migrations/versions/20260625_1000_0016_users_name_username.py new file mode 100644 index 0000000..66e4082 --- /dev/null +++ b/db/migrations/versions/20260625_1000_0016_users_name_username.py @@ -0,0 +1,33 @@ +"""users.name / users.user_name 列(平台登录注入的用户档案). + +Revision ID: 0016 +Revises: 0015 +Create Date: 2026-06-25 + +给 users 加两列:name(显示名/姓名)+ user_name(平台账号名),均 nullable。 +平台经 /v1/auth/login(platform_key 形态)在 body 里注入,ensure_user_row upsert +落库;邮箱密码 / 历史行留空。将来 OIDC 接管时由 ID token 的 name / preferred_username +claim 注入,数据流不变。详 DESIGN §7.3 / §7.4。 + +纯加列、不动现有数据(平滑兼容线上存量行,留 NULL)。 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0016" +down_revision: Union[str, None] = "0015" +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("name", sa.Text(), nullable=True)) + op.add_column("users", sa.Column("user_name", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "user_name") + op.drop_column("users", "name") diff --git a/web/admin.py b/web/admin.py index e4e8271..65bc2fa 100644 --- a/web/admin.py +++ b/web/admin.py @@ -203,6 +203,8 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di { "user_id": str(uid), "email": email or "", + "name": name or "", + "user_name": uname or "", "role": role or "user", "cost_cny": float(c or 0), "tokens_in": int(ti or 0), @@ -210,15 +212,15 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di "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( + for uid, email, name, uname, role, c, ti, to, h, n in s.execute( select( - User.user_id, User.email, User.role, + User.user_id, User.email, User.name, User.user_name, User.role, cost_sum, tin_sum, tout_sum, func.coalesce(func.sum(hit).filter(chat), 0), func.count(UsageEvent.event_id), ) .join(UsageEvent, join_cond, isouter=True) - .group_by(User.user_id, User.email, User.role) + .group_by(User.user_id, User.email, User.name, User.user_name, User.role) .order_by(order, User.user_id) .limit(page_size) .offset(page * page_size) @@ -241,14 +243,18 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict: { "user_id": str(uid), "email": email or "", + "name": name or "", + "user_name": uname 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( + for uid, email, name, uname, b, fc, scanned in s.execute( select( UserDiskUsage.user_id, User.email, + User.name, + User.user_name, UserDiskUsage.bytes_used, UserDiskUsage.file_count, UserDiskUsage.scanned_at, diff --git a/web/app.py b/web/app.py index 91bf0c3..3bbb9db 100644 --- a/web/app.py +++ b/web/app.py @@ -51,7 +51,7 @@ from .auth import ( change_password, create_user, ensure_user_row, - get_user_role, + get_user_profile, make_require_admin, make_require_user, mint_token, @@ -554,6 +554,9 @@ class FileTransferRequest(BaseModel): class LoginRequest(BaseModel): user_id: str platform_key: str + # 0016:平台可选注入的用户档案,缺省即旧行为(只填 user_id),向后兼容老调用方。 + name: Optional[str] = None # 显示名 / 姓名 + user_name: Optional[str] = None # 平台账号名 class PasswordLoginRequest(BaseModel): @@ -1118,8 +1121,16 @@ def create_app() -> FastAPI: 前端 dev SPA 用 localStorage 里的 token 恢复会话时调一次,据 role=='admin' 决定显不显"管理"入口(/static/admin.html)。role 走 DB 查,改完即时生效。 + name / user_name 是平台登录注入的档案(0016),可能为 null(邮箱密码 / 历史行)。 """ - return {"user_id": str(user_id), "role": get_user_role(user_id) or "user"} + prof = get_user_profile(user_id) or {} + return { + "user_id": str(user_id), + "role": prof.get("role", "user"), + "name": prof.get("name"), + "user_name": prof.get("user_name"), + "email": prof.get("email"), + } # ───────────── 微信接入(ClawBot,§8.7)───────────── @@ -1338,7 +1349,8 @@ def create_app() -> FastAPI: """platform_key 校验通过 → 签 JWT(user_id 作为 sub)。 platform_key 错 → 403;user_id 非 UUID → 400。 - user_id 未存在则幂等创建 users 行(避免下游 FK 失败)。 + user_id 未存在则幂等创建 users 行(避免下游 FK 失败);body 带 name / user_name + 时一并 upsert 落库(平台侧改名每次登录自动同步,见 ensure_user_row)。 platform 服务端用此入口注入指定 user_id;dev SPA 走 /login_password。 """ if body.platform_key != auth_cfg.platform_key: @@ -1347,12 +1359,16 @@ def create_app() -> FastAPI: uid = UUID(body.user_id) except (ValueError, TypeError): raise HTTPException(400, f"invalid user_id (must be UUID): {body.user_id!r}") - ensure_user_row(uid) + ensure_user_row(uid, name=body.name, user_name=body.user_name) token, exp = mint_token(auth_cfg, uid) + prof = get_user_profile(uid) or {} return { "token": token, "expires_at": _dt.fromtimestamp(exp).isoformat(), "user_id": str(uid), + "name": prof.get("name"), + "user_name": prof.get("user_name"), + "role": prof.get("role", "user"), "ttl_seconds": auth_cfg.ttl_seconds, } @@ -1399,12 +1415,15 @@ def create_app() -> FastAPI: raise HTTPException(403, "账号或密码错误") uid, email = hit token, exp = mint_token(auth_cfg, uid) + prof = get_user_profile(uid) or {} return { "token": token, "expires_at": _dt.fromtimestamp(exp).isoformat(), "user_id": str(uid), "email": email, - "role": get_user_role(uid) or "user", + "name": prof.get("name"), + "user_name": prof.get("user_name"), + "role": prof.get("role", "user"), "ttl_seconds": auth_cfg.ttl_seconds, } diff --git a/web/auth.py b/web/auth.py index a883e40..7428252 100644 --- a/web/auth.py +++ b/web/auth.py @@ -27,7 +27,7 @@ import bcrypt import jwt from fastapi import Depends, HTTPException from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from sqlalchemy import select +from sqlalchemy import func, select from core.storage import session_scope from core.storage.models import User @@ -232,6 +232,28 @@ def get_user_role(user_id: UUID) -> Optional[str]: return row[0] if row is not None else None +def get_user_profile(user_id: UUID) -> Optional[dict]: + """查当前用户档案 → `{role, name, user_name, email}`;无对应行返 None。 + + /v1/me + login 响应用,单次 SELECT。name / user_name 是平台登录注入的(0016), + 可能为 None(邮箱密码 / 历史行)。 + """ + with session_scope() as s: + row = s.execute( + select(User.role, User.name, User.user_name, User.email).where( + User.user_id == user_id + ) + ).first() + if row is None: + return None + return { + "role": row.role or "user", + "name": row.name, + "user_name": row.user_name, + "email": row.email, + } + + def set_user_role(email: str, role: str) -> tuple[UUID, str]: """按 email 改 users.role。返 `(user_id, normalized_email)`。 @@ -252,15 +274,32 @@ def set_user_role(email: str, role: str) -> tuple[UUID, str]: return user.user_id, e -def ensure_user_row(user_id: UUID) -> None: - """幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。 +def ensure_user_row( + user_id: UUID, + name: Optional[str] = None, + user_name: Optional[str] = None, +) -> None: + """幂等 upsert 一行 users 占位,并同步平台注入的用户档案(name / user_name)。 platform_key 登录入口用 — 平台直传的 user_id 可能是 zcbot 没见过的,首次登录建行 避免下游 FK 失败。邮箱密码登录走 `main.py user add` 已经写好 users 行,不走这条。 + + name / user_name 由平台在 login body 注入(0016): + - 首次建行:有值就一并写入 + - 已存在行:`COALESCE(EXCLUDED.x, users.x)` —— 平台传了非空值就刷新(同步平台侧改名), + 传 None / 空则保留旧值,不会被覆盖清空。空串归一到 None,不当有效值落库。 """ from sqlalchemy.dialects.postgresql import insert - stmt = insert(User).values(user_id=user_id).on_conflict_do_nothing( - index_elements=["user_id"] + + nm = (name or "").strip() or None + un = (user_name or "").strip() or None + stmt = insert(User).values(user_id=user_id, name=nm, user_name=un) + stmt = stmt.on_conflict_do_update( + index_elements=["user_id"], + set_={ + "name": func.coalesce(stmt.excluded.name, User.name), + "user_name": func.coalesce(stmt.excluded.user_name, User.user_name), + }, ) with session_scope() as s: s.execute(stmt) @@ -321,6 +360,7 @@ __all__ = [ "change_password", "create_user", "ensure_user_row", + "get_user_profile", "get_user_role", "hash_password", "make_require_admin", diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 4e42f27..92f9b4f 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -19,6 +19,29 @@ const SECTIONS = [ const $ = (id) => document.getElementById(id); const token = () => localStorage.getItem(LS_TOKEN) || ""; +// 用户显示名兜底链:name → user_name → email → uid8。监控页各处共用同一规则。 +// userCellHTML 给表格单元格:主文本走兜底链;name 与 user_name 都有时,name 后跟一个 +// 浅灰 user_name;title 悬浮给完整 name/账号/邮箱/user_id。userLabelText 给概览迷你表(纯文本)。 +function userLabelText(r) { + return r.name || r.user_name || r.email || (r.user_id || "").slice(0, 8); +} +function userTitle(r) { + const parts = []; + if (r.name) parts.push(`姓名 ${r.name}`); + if (r.user_name) parts.push(`账号 ${r.user_name}`); + if (r.email) parts.push(`邮箱 ${r.email}`); + parts.push(`ID ${r.user_id}`); + return parts.join("\n"); +} +function userCellHTML(r) { + const primary = escapeHtml(userLabelText(r)); + // name 与 user_name 同时存在 → 主显 name,后缀浅灰 user_name(满足"name 和 user_name 都显") + const sub = (r.name && r.user_name) + ? ` ${escapeHtml(r.user_name)}` + : ""; + return `${primary}${sub}`; +} + let timer = null; // 各表独立状态(不随 overview 轮询重置) let modelRange = "all", modelSort = "cost"; @@ -156,7 +179,7 @@ function renderUserUsage(d) { 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))}` + + `${userCellHTML(r)}` + (r.role === "admin" ? ` admin` : "") + `` + `${fmtCNY(r.cost_cny)}` + `${fmtTokens(r.tokens_in)}` @@ -198,7 +221,7 @@ function renderStorage(d) { : cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "") : tint(r.bytes_used, maxUsed); return `` - + `${escapeHtml(r.email || r.user_id.slice(0, 8))}` + + `${userCellHTML(r)}` + `${humanSize(r.bytes_used)}` + `${pctTxt}` + `${r.file_count || 0}` @@ -371,13 +394,13 @@ function buildReport(ov, models, users, storage, health) { const modelBody = (models.rows || []).slice(0, 10).map(r => `${escapeHtml(r.model_profile || "—")}` + `${fmtCNY(r.cost_cny)}${fmtTokens(r.tokens_in)}${fmtTokens(r.tokens_out)}` + `${r.n_events || 0}`).join("") || `无数据`; - const userBody = (users.rows || []).slice(0, 10).map(r => `${escapeHtml(r.email || r.user_id.slice(0, 8))}` + const userBody = (users.rows || []).slice(0, 10).map(r => `${escapeHtml(userLabelText(r))}` + `${fmtCNY(r.cost_cny)}${fmtTokens(r.tokens_in)}${fmtTokens(r.tokens_out)}` + `${r.n_events || 0}`).join("") || `无数据`; const quota = storage.quota_bytes; const stBody = (storage.rows || []).slice(0, 10).map(r => { const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—"; - return `${escapeHtml(r.email || r.user_id.slice(0, 8))}${humanSize(r.bytes_used)}` + return `${escapeHtml(userLabelText(r))}${humanSize(r.bytes_used)}` + `${pct}${r.file_count || 0}`; }).join("") || `无数据`; diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 8119c57..091fbd0 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -3,7 +3,7 @@ // (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。 // 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting // (logout 在 embed 模式通知父页面)——均运行时(点击/401)才调,ES 环 live binding 安全。 -import { state, LS_TOKEN, LS_UID, LS_NAME, EMBED } from "./state.js"; +import { state, LS_TOKEN, LS_UID, LS_NAME, LS_USERNAME, LS_EMAIL, EMBED, setIdentity } from "./state.js"; import { $ } from "./dom.js"; import { api } from "./api.js"; import { enterApp } from "./main.js"; @@ -40,7 +40,7 @@ document.querySelectorAll("#login input").forEach(i => { async function doLogin() { $("li-err").textContent = ""; - let url, body, displayLabel; + let url, body; if (loginTab === "pw") { const email = $("li-email").value.trim(); const password = $("li-password").value; @@ -50,7 +50,6 @@ async function doLogin() { } url = "/v1/auth/login_password"; body = { email, password }; - displayLabel = "email"; } else { const uid = $("li-uid").value.trim(); const pkey = $("li-pkey").value; @@ -60,7 +59,6 @@ async function doLogin() { } url = "/v1/auth/login"; body = { user_id: uid, platform_key: pkey }; - displayLabel = null; // 这条路径不返显示名,顶栏只显 uid 前 8 位 } try { const r = await fetch(url, { @@ -81,15 +79,15 @@ async function doLogin() { } const data = await r.json(); state.token = data.token; - state.userId = data.user_id; - state.userName = displayLabel ? (data[displayLabel] || "") : ""; localStorage.setItem(LS_TOKEN, state.token); - localStorage.setItem(LS_UID, state.userId); - if (state.userName) { - localStorage.setItem(LS_NAME, state.userName); - } else { - localStorage.removeItem(LS_NAME); - } + // 身份字段写 state + LS:platform_key 路径返 name/user_name,邮箱密码路径返 email; + // 缺的走 setIdentity 的兜底(顶栏 userDisplayName)。/v1/me(loadRole)随后再校准一次。 + setIdentity({ + user_id: data.user_id, + name: data.name, + user_name: data.user_name, + email: data.email, + }); enterApp(); } catch (e) { $("li-err").textContent = e.message; @@ -97,10 +95,10 @@ async function doLogin() { } export function logout() { - state.token = ""; state.userId = ""; state.userName = ""; + state.token = ""; localStorage.removeItem(LS_TOKEN); - localStorage.removeItem(LS_UID); - localStorage.removeItem(LS_NAME); + // 清身份(state + LS_UID/NAME/USERNAME/EMAIL),不残留上一个用户 + setIdentity({ user_id: "" }); if (state.evtSrc) state.evtSrc.close(); if (EMBED) { embedPostToParent({ type: "zcbot-401" }); diff --git a/web/static/js/embed.js b/web/static/js/embed.js index 46433f6..68a69e3 100644 --- a/web/static/js/embed.js +++ b/web/static/js/embed.js @@ -1,7 +1,7 @@ // embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。 // 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+ // embedPostToParent / embedShowWaiting(auth 的 logout 在 embed 下通知父页面/显示等待态)。 -import { state, LS_TOKEN, LS_UID, LS_NAME, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID } from "./state.js"; +import { state, LS_TOKEN, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, setIdentity } from "./state.js"; import { $ } from "./dom.js"; import { enterApp } from "./main.js"; import { loadTaskList, selectTask } from "./chat.js"; @@ -32,12 +32,9 @@ function embedHandleMessage(e) { const d = e.data || {}; if (d.type === "zcbot-token" && d.token && d.user_id) { state.token = d.token; - state.userId = d.user_id; - state.userName = d.user_name || ""; localStorage.setItem(LS_TOKEN, state.token); - localStorage.setItem(LS_UID, state.userId); - if (state.userName) localStorage.setItem(LS_NAME, state.userName); - else localStorage.removeItem(LS_NAME); + // 父页面可带 name/user_name/email;缺的由 enterApp → loadRole 拉 /v1/me 补齐 + setIdentity({ user_id: d.user_id, name: d.name, user_name: d.user_name, email: d.email }); document.body.classList.remove("embed-waiting"); if ($("app").classList.contains("ready")) { // 401 后重签:重载列表,不重复 enterApp / 不重复定位 task(尊重用户中间切过的选择) diff --git a/web/static/js/main.js b/web/static/js/main.js index e134293..7866e42 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -1,7 +1,7 @@ // zcbot dev 控制台入口 + 编排:enterApp(应用初始化)、loadStorage(存储用量)、 // Esc 关弹窗栈、boot。功能逻辑全在各模块(chat/files/preview/media/auth/newtask/embed/layout/…), // 经各模块顶层 import 拉入依赖图(layout/markdown/media 由 chat 等引入,副作用照常)。 -import { state, EMBED } from "./state.js"; +import { state, EMBED, setIdentity, userDisplayName, userDisplayTitle } from "./state.js"; import { humanSize, fmtTime } from "./format.js"; import { $ } from "./dom.js"; import { api } from "./api.js"; @@ -20,10 +20,7 @@ import { loadTaskList, loadModels } from "./chat.js"; export function enterApp() { $("login").style.display = "none"; $("app").classList.add("ready"); - // 显示「name · uuid 前 8 位」;name 缺失(老 token 升级前)只显 uuid - const uid8 = (state.userId || "").slice(0, 8); - $("hd-who").textContent = state.userName ? `${state.userName} · ${uid8}` : state.userId; - $("hd-who").title = state.userId; + renderWho(); // 顶栏用户:默认显 name(兜底 user_name/email/uid8),hover 显完整身份 loadTaskList(); loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标 @@ -32,15 +29,27 @@ export function enterApp() { loadRole(); // 拉 /v1/me,admin 才显「管理」入口(/static/admin.html) } -// 当前用户角色:/v1/me 返 {user_id, role}。admin → 显顶栏「管理」链接。 -// 失败静默(入口是增量功能,拉不到就当普通用户,不挡主流程)。 +// 顶栏用户名:默认显 name(兜底 user_name → email → uid8),title 悬浮给完整身份。 +function renderWho() { + const el = $("hd-who"); + if (!el) return; + el.textContent = userDisplayName(); + el.title = userDisplayTitle(); +} + +// 当前用户身份 + 角色:/v1/me 返 {user_id, role, name, user_name, email}。 +// admin → 显顶栏「管理」链接;并用服务端权威值校准顶栏用户名(platform_key 登录 / 老 token +// 升级后,name/user_name 这一刻才到齐)。失败静默(增量功能,拉不到就维持登录时的兜底值)。 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"; } + if (me) { + setIdentity({ user_id: me.user_id, name: me.name, user_name: me.user_name, email: me.email }); + renderWho(); + } + if (link) link.style.display = (me && me.role === "admin") ? "" : "none"; + } catch (e) { if (link) link.style.display = "none"; } } // 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描, diff --git a/web/static/js/state.js b/web/static/js/state.js index b68349a..6bb766f 100644 --- a/web/static/js/state.js +++ b/web/static/js/state.js @@ -4,6 +4,8 @@ export const LS_TOKEN = "zcbot.token"; export const LS_UID = "zcbot.user_id"; export const LS_NAME = "zcbot.name"; +export const LS_USERNAME = "zcbot.user_name"; // 平台账号名(hover 展示) +export const LS_EMAIL = "zcbot.email"; // 邮箱(hover 展示) export const LS_LEFT_COLLAPSED = "zcbot.left-collapsed"; export const LS_RIGHT_COLLAPSED = "zcbot.right-collapsed"; export const LS_LEFT_WIDTH = "zcbot.left-width"; @@ -20,7 +22,9 @@ export const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim(); export const state = { token: localStorage.getItem(LS_TOKEN) || "", userId: localStorage.getItem(LS_UID) || "", - userName: localStorage.getItem(LS_NAME) || "", + userName: localStorage.getItem(LS_NAME) || "", // name(显示名/姓名) + userUserName: localStorage.getItem(LS_USERNAME) || "", // user_name(平台账号名) + userEmail: localStorage.getItem(LS_EMAIL) || "", // email taskId: null, taskMeta: null, filesPath: "", @@ -62,3 +66,31 @@ export const state = { // disabled 状态(否则用户键入 input 会把按钮从"润色中"误启回 enabled) optimizing: false, }; + +// 写入当前用户身份到 state + localStorage(登录 / embed 签发 / /v1/me 刷新共用)。 +// 缺省字段写空串并清掉对应 LS,避免上一个用户的残留串到下一个。token 单独存,不在这里。 +export function setIdentity({ user_id, name, user_name, email } = {}) { + if (user_id !== undefined) { + state.userId = user_id || ""; + if (state.userId) localStorage.setItem(LS_UID, state.userId); else localStorage.removeItem(LS_UID); + } + state.userName = name || ""; + state.userUserName = user_name || ""; + state.userEmail = email || ""; + for (const [k, v] of [[LS_NAME, state.userName], [LS_USERNAME, state.userUserName], [LS_EMAIL, state.userEmail]]) { + if (v) localStorage.setItem(k, v); else localStorage.removeItem(k); + } +} + +// 顶栏显示名兜底链:name → user_name → email → uid8。hover(title)给完整身份。 +export function userDisplayName() { + return state.userName || state.userUserName || state.userEmail || (state.userId || "").slice(0, 8); +} +export function userDisplayTitle() { + const parts = []; + if (state.userName) parts.push(`姓名 ${state.userName}`); + if (state.userUserName) parts.push(`账号 ${state.userUserName}`); + if (state.userEmail) parts.push(`邮箱 ${state.userEmail}`); + if (state.userId) parts.push(`ID ${state.userId}`); + return parts.join("\n"); +}