Compare commits

..

2 Commits

Author SHA1 Message Date
caoqianming f17da6a6e1 feat(auth): 平台登录注入 name/user_name + 监控页/dev 顶栏用户名展示 + bump 0.26.1
平台登录档案注入(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) <noreply@anthropic.com>
2026-06-25 09:31:32 +08:00
caoqianming 2b2b4531b3 fix(web): 登录失败提示统一为「账号或密码错误」,不再回显原始状态码 + bump 0.25.2
输错密码时前端弹「404」:后端 login_password 实际返 403,前置网关/旧构建
把状态改写成 404 后,doLogin 直接回显 r.status 导致语义错误。

- auth.js doLogin 失败分支:表单已校验非空,非 2xx 绝大多数是凭据不对,
  统一给「账号或密码错误」(pw)/「user_id 或 PLATFORM_KEY 错误」(key);
  仅 5xx 暴露状态码提示服务端问题。
- app.py:1399 detail 同步改中文,保持契约自洽。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:08:00 +08:00
13 changed files with 244 additions and 56 deletions

View File

@ -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 <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。
@ -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 共存

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-24(微信 task 在 web 端只读镜像:web→微信单向不同步,锚定微信为单一交互权威 + bump 0.25.1)
最后更新:2026-06-25(用户名展示:监控页用户列 + dev 顶栏走 name→user_name→email→uid8 兜底链,hover 显完整身份 + bump 0.26.1)
---
@ -21,6 +21,21 @@
## 已完成关键能力
### 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 同步改中文「账号或密码错误」保持契约自洽。
### 2026-06-24 / 微信 task 在 web 端只读镜像(bump 0.25.1)
- 问题:web 端打开 channel=wechat 的常驻 task 能正常发消息,但 web→微信**单向不同步**(web 发消息走 `/v1/tasks/{id}/messages`→`_run_agent_bg`,不经过 inbound loop 里 `send_text` 回微信那段,微信侧零感知);微信→web 则同步(同一条 task)。
- 取舍:不做"双向打通"(受微信 24h `context_token` 窗口约束 → 只能"有时同步",不可预测 + 两入口并发写歧义),改为 web 端**只读镜像**(单一交互权威锚定微信;想主动推走 `wechat_push`/定时简报)。

View File

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

View File

@ -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")

View File

@ -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")

View File

@ -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,

View File

@ -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,
}
@ -1396,15 +1412,18 @@ def create_app() -> FastAPI:
"""
hit = resolve_user_by_email(body.email, body.password)
if hit is None:
raise HTTPException(403, "invalid email or password")
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,
}

View File

@ -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 响应用,单次 SELECTname / 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",

View File

@ -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)
? ` <span style="color:var(--muted);font-size:.85em;">${escapeHtml(r.user_name)}</span>`
: "";
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 `<tr>`
+ `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}`
+ `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}`
+ (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>`
+ `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
+ `<td class="num bar-cell" style="${byTok ? tint(r.tokens_in, maxTin) : ""}">${fmtTokens(r.tokens_in)}</td>`
@ -198,7 +221,7 @@ function renderStorage(d) {
: 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="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}</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>`
@ -371,13 +394,13 @@ function buildReport(ov, models, users, storage, health) {
const modelBody = (models.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.model_profile || "—")}</td>`
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
const userBody = (users.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.email || r.user_id.slice(0, 8))}</td>`
const userBody = (users.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(userLabelText(r))}</td>`
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
const quota = storage.quota_bytes;
const stBody = (storage.rows || []).slice(0, 10).map(r => {
const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
return `<tr><td>${escapeHtml(r.email || r.user_id.slice(0, 8))}</td><td>${humanSize(r.bytes_used)}</td>`
return `<tr><td>${escapeHtml(userLabelText(r))}</td><td>${humanSize(r.bytes_used)}</td>`
+ `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`;
}).join("") || `<tr><td colspan="4">无数据</td></tr>`;

View File

@ -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, {
@ -69,19 +67,27 @@ async function doLogin() {
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " login failed"));
// 登录失败:表单已校验非空,非 2xx 绝大多数是凭据不对。
// 凭据类状态(400/401/403/404 —— 404 多半是前置网关把 403 改写了)统一给友好提示;
// 5xx 才暴露状态码,提示是服务端问题而非用户输错。
if (r.status >= 500) {
throw new Error(`服务器错误,请稍后重试(${r.status}`);
}
throw new Error(loginTab === "pw"
? "账号或密码错误"
: "user_id 或 PLATFORM_KEY 错误");
}
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;
@ -89,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" });

View File

@ -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(尊重用户中间切过的选择)

View File

@ -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 扫描,

View File

@ -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");
}