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>
This commit is contained in:
parent
2b2b4531b3
commit
f17da6a6e1
|
|
@ -306,10 +306,10 @@ done {}
|
||||||
### 7.3 认证
|
### 7.3 认证
|
||||||
|
|
||||||
**当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL):
|
**当前形态(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/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户
|
||||||
- `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403)
|
- `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403)
|
||||||
- `GET /v1/me` — 返 `{user_id, role}`(role 走 DB 查),dev SPA 据此决定显不显"管理"入口
|
- `GET /v1/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)。后续续挂建用户/改角色/配置等管理动作
|
- `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。
|
后续 `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
|
```sql
|
||||||
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null,
|
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/* 管理后台
|
role text not null default 'user', -- 0009:user/admin;admin 才能访问 /v1/admin/* 管理后台
|
||||||
created_at)
|
created_at)
|
||||||
-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存
|
-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存
|
||||||
|
|
|
||||||
13
PROGRESS.md
13
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-06-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)
|
### 2026-06-25 / 登录失败提示修正(bump 0.25.2)
|
||||||
- 问题:邮箱密码输错时前端弹「404」(后端 `login_password` 实际返 403「invalid email or password」,前置网关/旧构建把状态改写成 404 后,前端 `doLogin` 直接回显 `r.status + " login failed"` → 用户看到「404 login failed」,语义错误)。
|
- 问题:邮箱密码输错时前端弹「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 同步改中文「账号或密码错误」保持契约自洽。
|
- 修:`web/static/js/auth.js` `doLogin` 失败分支不再回显原始状态码 —— 表单已校验非空,非 2xx 绝大多数是凭据不对,统一给「账号或密码错误」(pw tab)/「user_id 或 PLATFORM_KEY 错误」(key tab);仅 5xx 暴露状态码提示服务端问题。后端 `web/app.py:1399` detail 同步改中文「账号或密码错误」保持契约自洽。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.25.2"
|
__version__ = "0.26.1"
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ class User(Base):
|
||||||
oidc_subject: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
oidc_subject: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
password_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
password_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
plan: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
plan: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
# 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/* 管理端点。
|
# 0009:访问角色。'user'(默认)/ 'admin';仅 admin 可访问 /v1/admin/* 管理端点。
|
||||||
# 提管理员:main.py user role --email X --role admin。
|
# 提管理员:main.py user role --email X --role admin。
|
||||||
role: Mapped[str] = mapped_column(Text, nullable=False, server_default="user")
|
role: Mapped[str] = mapped_column(Text, nullable=False, server_default="user")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
14
web/admin.py
14
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),
|
"user_id": str(uid),
|
||||||
"email": email or "",
|
"email": email or "",
|
||||||
|
"name": name or "",
|
||||||
|
"user_name": uname or "",
|
||||||
"role": role or "user",
|
"role": role or "user",
|
||||||
"cost_cny": float(c or 0),
|
"cost_cny": float(c or 0),
|
||||||
"tokens_in": int(ti 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),
|
"tokens_cache_hit": int(h or 0),
|
||||||
"n_events": int(n 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(
|
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,
|
cost_sum, tin_sum, tout_sum,
|
||||||
func.coalesce(func.sum(hit).filter(chat), 0),
|
func.coalesce(func.sum(hit).filter(chat), 0),
|
||||||
func.count(UsageEvent.event_id),
|
func.count(UsageEvent.event_id),
|
||||||
)
|
)
|
||||||
.join(UsageEvent, join_cond, isouter=True)
|
.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)
|
.order_by(order, User.user_id)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
.offset(page * 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),
|
"user_id": str(uid),
|
||||||
"email": email or "",
|
"email": email or "",
|
||||||
|
"name": name or "",
|
||||||
|
"user_name": uname or "",
|
||||||
"bytes_used": int(b or 0),
|
"bytes_used": int(b or 0),
|
||||||
"file_count": int(fc or 0),
|
"file_count": int(fc or 0),
|
||||||
"scanned_at": scanned.isoformat() if scanned else None,
|
"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(
|
select(
|
||||||
UserDiskUsage.user_id,
|
UserDiskUsage.user_id,
|
||||||
User.email,
|
User.email,
|
||||||
|
User.name,
|
||||||
|
User.user_name,
|
||||||
UserDiskUsage.bytes_used,
|
UserDiskUsage.bytes_used,
|
||||||
UserDiskUsage.file_count,
|
UserDiskUsage.file_count,
|
||||||
UserDiskUsage.scanned_at,
|
UserDiskUsage.scanned_at,
|
||||||
|
|
|
||||||
29
web/app.py
29
web/app.py
|
|
@ -51,7 +51,7 @@ from .auth import (
|
||||||
change_password,
|
change_password,
|
||||||
create_user,
|
create_user,
|
||||||
ensure_user_row,
|
ensure_user_row,
|
||||||
get_user_role,
|
get_user_profile,
|
||||||
make_require_admin,
|
make_require_admin,
|
||||||
make_require_user,
|
make_require_user,
|
||||||
mint_token,
|
mint_token,
|
||||||
|
|
@ -554,6 +554,9 @@ class FileTransferRequest(BaseModel):
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
platform_key: str
|
platform_key: str
|
||||||
|
# 0016:平台可选注入的用户档案,缺省即旧行为(只填 user_id),向后兼容老调用方。
|
||||||
|
name: Optional[str] = None # 显示名 / 姓名
|
||||||
|
user_name: Optional[str] = None # 平台账号名
|
||||||
|
|
||||||
|
|
||||||
class PasswordLoginRequest(BaseModel):
|
class PasswordLoginRequest(BaseModel):
|
||||||
|
|
@ -1118,8 +1121,16 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
前端 dev SPA 用 localStorage 里的 token 恢复会话时调一次,据 role=='admin'
|
前端 dev SPA 用 localStorage 里的 token 恢复会话时调一次,据 role=='admin'
|
||||||
决定显不显"管理"入口(/static/admin.html)。role 走 DB 查,改完即时生效。
|
决定显不显"管理"入口(/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)─────────────
|
# ───────────── 微信接入(ClawBot,§8.7)─────────────
|
||||||
|
|
||||||
|
|
@ -1338,7 +1349,8 @@ def create_app() -> FastAPI:
|
||||||
"""platform_key 校验通过 → 签 JWT(user_id 作为 sub)。
|
"""platform_key 校验通过 → 签 JWT(user_id 作为 sub)。
|
||||||
|
|
||||||
platform_key 错 → 403;user_id 非 UUID → 400。
|
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。
|
platform 服务端用此入口注入指定 user_id;dev SPA 走 /login_password。
|
||||||
"""
|
"""
|
||||||
if body.platform_key != auth_cfg.platform_key:
|
if body.platform_key != auth_cfg.platform_key:
|
||||||
|
|
@ -1347,12 +1359,16 @@ def create_app() -> FastAPI:
|
||||||
uid = UUID(body.user_id)
|
uid = UUID(body.user_id)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
raise HTTPException(400, f"invalid user_id (must be UUID): {body.user_id!r}")
|
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)
|
token, exp = mint_token(auth_cfg, uid)
|
||||||
|
prof = get_user_profile(uid) or {}
|
||||||
return {
|
return {
|
||||||
"token": token,
|
"token": token,
|
||||||
"expires_at": _dt.fromtimestamp(exp).isoformat(),
|
"expires_at": _dt.fromtimestamp(exp).isoformat(),
|
||||||
"user_id": str(uid),
|
"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,
|
"ttl_seconds": auth_cfg.ttl_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1399,12 +1415,15 @@ def create_app() -> FastAPI:
|
||||||
raise HTTPException(403, "账号或密码错误")
|
raise HTTPException(403, "账号或密码错误")
|
||||||
uid, email = hit
|
uid, email = hit
|
||||||
token, exp = mint_token(auth_cfg, uid)
|
token, exp = mint_token(auth_cfg, uid)
|
||||||
|
prof = get_user_profile(uid) or {}
|
||||||
return {
|
return {
|
||||||
"token": token,
|
"token": token,
|
||||||
"expires_at": _dt.fromtimestamp(exp).isoformat(),
|
"expires_at": _dt.fromtimestamp(exp).isoformat(),
|
||||||
"user_id": str(uid),
|
"user_id": str(uid),
|
||||||
"email": email,
|
"email": email,
|
||||||
"role": get_user_role(uid) or "user",
|
"name": prof.get("name"),
|
||||||
|
"user_name": prof.get("user_name"),
|
||||||
|
"role": prof.get("role", "user"),
|
||||||
"ttl_seconds": auth_cfg.ttl_seconds,
|
"ttl_seconds": auth_cfg.ttl_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
50
web/auth.py
50
web/auth.py
|
|
@ -27,7 +27,7 @@ import bcrypt
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from core.storage import session_scope
|
from core.storage import session_scope
|
||||||
from core.storage.models import User
|
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
|
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]:
|
def set_user_role(email: str, role: str) -> tuple[UUID, str]:
|
||||||
"""按 email 改 users.role。返 `(user_id, normalized_email)`。
|
"""按 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
|
return user.user_id, e
|
||||||
|
|
||||||
|
|
||||||
def ensure_user_row(user_id: UUID) -> None:
|
def ensure_user_row(
|
||||||
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
user_id: UUID,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
user_name: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""幂等 upsert 一行 users 占位,并同步平台注入的用户档案(name / user_name)。
|
||||||
|
|
||||||
platform_key 登录入口用 — 平台直传的 user_id 可能是 zcbot 没见过的,首次登录建行
|
platform_key 登录入口用 — 平台直传的 user_id 可能是 zcbot 没见过的,首次登录建行
|
||||||
避免下游 FK 失败。邮箱密码登录走 `main.py user add` 已经写好 users 行,不走这条。
|
避免下游 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
|
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:
|
with session_scope() as s:
|
||||||
s.execute(stmt)
|
s.execute(stmt)
|
||||||
|
|
@ -321,6 +360,7 @@ __all__ = [
|
||||||
"change_password",
|
"change_password",
|
||||||
"create_user",
|
"create_user",
|
||||||
"ensure_user_row",
|
"ensure_user_row",
|
||||||
|
"get_user_profile",
|
||||||
"get_user_role",
|
"get_user_role",
|
||||||
"hash_password",
|
"hash_password",
|
||||||
"make_require_admin",
|
"make_require_admin",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,29 @@ const SECTIONS = [
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const token = () => localStorage.getItem(LS_TOKEN) || "";
|
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;
|
let timer = null;
|
||||||
// 各表独立状态(不随 overview 轮询重置)
|
// 各表独立状态(不随 overview 轮询重置)
|
||||||
let modelRange = "all", modelSort = "cost";
|
let modelRange = "all", modelSort = "cost";
|
||||||
|
|
@ -156,7 +179,7 @@ function renderUserUsage(d) {
|
||||||
const body = rows.map(r => {
|
const body = rows.map(r => {
|
||||||
const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
|
const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
|
||||||
return `<tr>`
|
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>`
|
+ (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.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
|
||||||
+ `<td class="num bar-cell" style="${byTok ? tint(r.tokens_in, maxTin) : ""}">${fmtTokens(r.tokens_in)}</td>`
|
+ `<td class="num 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);" : "")
|
: cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "")
|
||||||
: tint(r.bytes_used, maxUsed);
|
: tint(r.bytes_used, maxUsed);
|
||||||
return `<tr>`
|
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 bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
|
||||||
+ `<td class="num">${pctTxt}</td>`
|
+ `<td class="num">${pctTxt}</td>`
|
||||||
+ `<td class="num">${r.file_count || 0}</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>`
|
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>${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>`;
|
+ `<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>${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>`;
|
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
|
||||||
const quota = storage.quota_bytes;
|
const quota = storage.quota_bytes;
|
||||||
const stBody = (storage.rows || []).slice(0, 10).map(r => {
|
const stBody = (storage.rows || []).slice(0, 10).map(r => {
|
||||||
const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
|
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>`;
|
+ `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`;
|
||||||
}).join("") || `<tr><td colspan="4">无数据</td></tr>`;
|
}).join("") || `<tr><td colspan="4">无数据</td></tr>`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。
|
// (logout 供全局 401 处理,closeChpwModal 供 main 的 Esc 统一关弹窗栈)。
|
||||||
// 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting
|
// 反向依赖 main 的 glue:enterApp(登录成功进入)、embedPostToParent/embedShowWaiting
|
||||||
// (logout 在 embed 模式通知父页面)——均运行时(点击/401)才调,ES 环 live binding 安全。
|
// (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 { $ } from "./dom.js";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { enterApp } from "./main.js";
|
import { enterApp } from "./main.js";
|
||||||
|
|
@ -40,7 +40,7 @@ document.querySelectorAll("#login input").forEach(i => {
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
$("li-err").textContent = "";
|
$("li-err").textContent = "";
|
||||||
let url, body, displayLabel;
|
let url, body;
|
||||||
if (loginTab === "pw") {
|
if (loginTab === "pw") {
|
||||||
const email = $("li-email").value.trim();
|
const email = $("li-email").value.trim();
|
||||||
const password = $("li-password").value;
|
const password = $("li-password").value;
|
||||||
|
|
@ -50,7 +50,6 @@ async function doLogin() {
|
||||||
}
|
}
|
||||||
url = "/v1/auth/login_password";
|
url = "/v1/auth/login_password";
|
||||||
body = { email, password };
|
body = { email, password };
|
||||||
displayLabel = "email";
|
|
||||||
} else {
|
} else {
|
||||||
const uid = $("li-uid").value.trim();
|
const uid = $("li-uid").value.trim();
|
||||||
const pkey = $("li-pkey").value;
|
const pkey = $("li-pkey").value;
|
||||||
|
|
@ -60,7 +59,6 @@ async function doLogin() {
|
||||||
}
|
}
|
||||||
url = "/v1/auth/login";
|
url = "/v1/auth/login";
|
||||||
body = { user_id: uid, platform_key: pkey };
|
body = { user_id: uid, platform_key: pkey };
|
||||||
displayLabel = null; // 这条路径不返显示名,顶栏只显 uid 前 8 位
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url, {
|
const r = await fetch(url, {
|
||||||
|
|
@ -81,15 +79,15 @@ async function doLogin() {
|
||||||
}
|
}
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
state.token = data.token;
|
state.token = data.token;
|
||||||
state.userId = data.user_id;
|
|
||||||
state.userName = displayLabel ? (data[displayLabel] || "") : "";
|
|
||||||
localStorage.setItem(LS_TOKEN, state.token);
|
localStorage.setItem(LS_TOKEN, state.token);
|
||||||
localStorage.setItem(LS_UID, state.userId);
|
// 身份字段写 state + LS:platform_key 路径返 name/user_name,邮箱密码路径返 email;
|
||||||
if (state.userName) {
|
// 缺的走 setIdentity 的兜底(顶栏 userDisplayName)。/v1/me(loadRole)随后再校准一次。
|
||||||
localStorage.setItem(LS_NAME, state.userName);
|
setIdentity({
|
||||||
} else {
|
user_id: data.user_id,
|
||||||
localStorage.removeItem(LS_NAME);
|
name: data.name,
|
||||||
}
|
user_name: data.user_name,
|
||||||
|
email: data.email,
|
||||||
|
});
|
||||||
enterApp();
|
enterApp();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("li-err").textContent = e.message;
|
$("li-err").textContent = e.message;
|
||||||
|
|
@ -97,10 +95,10 @@ async function doLogin() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
state.token = ""; state.userId = ""; state.userName = "";
|
state.token = "";
|
||||||
localStorage.removeItem(LS_TOKEN);
|
localStorage.removeItem(LS_TOKEN);
|
||||||
localStorage.removeItem(LS_UID);
|
// 清身份(state + LS_UID/NAME/USERNAME/EMAIL),不残留上一个用户
|
||||||
localStorage.removeItem(LS_NAME);
|
setIdentity({ user_id: "" });
|
||||||
if (state.evtSrc) state.evtSrc.close();
|
if (state.evtSrc) state.evtSrc.close();
|
||||||
if (EMBED) {
|
if (EMBED) {
|
||||||
embedPostToParent({ type: "zcbot-401" });
|
embedPostToParent({ type: "zcbot-401" });
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。
|
// embed(iframe)模式:父页面经 postMessage 推送 token → 进入应用;401 后重签。
|
||||||
// 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+
|
// 顶层无副作用,boot 决定是否调 embedInit。导出 embedInit(boot 调)+
|
||||||
// embedPostToParent / embedShowWaiting(auth 的 logout 在 embed 下通知父页面/显示等待态)。
|
// 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 { $ } from "./dom.js";
|
||||||
import { enterApp } from "./main.js";
|
import { enterApp } from "./main.js";
|
||||||
import { loadTaskList, selectTask } from "./chat.js";
|
import { loadTaskList, selectTask } from "./chat.js";
|
||||||
|
|
@ -32,12 +32,9 @@ function embedHandleMessage(e) {
|
||||||
const d = e.data || {};
|
const d = e.data || {};
|
||||||
if (d.type === "zcbot-token" && d.token && d.user_id) {
|
if (d.type === "zcbot-token" && d.token && d.user_id) {
|
||||||
state.token = d.token;
|
state.token = d.token;
|
||||||
state.userId = d.user_id;
|
|
||||||
state.userName = d.user_name || "";
|
|
||||||
localStorage.setItem(LS_TOKEN, state.token);
|
localStorage.setItem(LS_TOKEN, state.token);
|
||||||
localStorage.setItem(LS_UID, state.userId);
|
// 父页面可带 name/user_name/email;缺的由 enterApp → loadRole 拉 /v1/me 补齐
|
||||||
if (state.userName) localStorage.setItem(LS_NAME, state.userName);
|
setIdentity({ user_id: d.user_id, name: d.name, user_name: d.user_name, email: d.email });
|
||||||
else localStorage.removeItem(LS_NAME);
|
|
||||||
document.body.classList.remove("embed-waiting");
|
document.body.classList.remove("embed-waiting");
|
||||||
if ($("app").classList.contains("ready")) {
|
if ($("app").classList.contains("ready")) {
|
||||||
// 401 后重签:重载列表,不重复 enterApp / 不重复定位 task(尊重用户中间切过的选择)
|
// 401 后重签:重载列表,不重复 enterApp / 不重复定位 task(尊重用户中间切过的选择)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// zcbot dev 控制台入口 + 编排:enterApp(应用初始化)、loadStorage(存储用量)、
|
// zcbot dev 控制台入口 + 编排:enterApp(应用初始化)、loadStorage(存储用量)、
|
||||||
// Esc 关弹窗栈、boot。功能逻辑全在各模块(chat/files/preview/media/auth/newtask/embed/layout/…),
|
// Esc 关弹窗栈、boot。功能逻辑全在各模块(chat/files/preview/media/auth/newtask/embed/layout/…),
|
||||||
// 经各模块顶层 import 拉入依赖图(layout/markdown/media 由 chat 等引入,副作用照常)。
|
// 经各模块顶层 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 { humanSize, fmtTime } from "./format.js";
|
||||||
import { $ } from "./dom.js";
|
import { $ } from "./dom.js";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
|
|
@ -20,10 +20,7 @@ import { loadTaskList, loadModels } from "./chat.js";
|
||||||
export function enterApp() {
|
export function enterApp() {
|
||||||
$("login").style.display = "none";
|
$("login").style.display = "none";
|
||||||
$("app").classList.add("ready");
|
$("app").classList.add("ready");
|
||||||
// 显示「name · uuid 前 8 位」;name 缺失(老 token 升级前)只显 uuid
|
renderWho(); // 顶栏用户:默认显 name(兜底 user_name/email/uid8),hover 显完整身份
|
||||||
const uid8 = (state.userId || "").slice(0, 8);
|
|
||||||
$("hd-who").textContent = state.userName ? `${state.userName} · ${uid8}` : state.userId;
|
|
||||||
$("hd-who").title = state.userId;
|
|
||||||
loadTaskList();
|
loadTaskList();
|
||||||
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
|
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
|
||||||
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
|
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
|
||||||
|
|
@ -32,15 +29,27 @@ export function enterApp() {
|
||||||
loadRole(); // 拉 /v1/me,admin 才显「管理」入口(/static/admin.html)
|
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() {
|
async function loadRole() {
|
||||||
const link = $("hd-admin");
|
const link = $("hd-admin");
|
||||||
if (!link) return;
|
|
||||||
try {
|
try {
|
||||||
const me = await api("GET", "/v1/me");
|
const me = await api("GET", "/v1/me");
|
||||||
link.style.display = (me && me.role === "admin") ? "" : "none";
|
if (me) {
|
||||||
} catch (e) { link.style.display = "none"; }
|
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 扫描,
|
// 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
export const LS_TOKEN = "zcbot.token";
|
export const LS_TOKEN = "zcbot.token";
|
||||||
export const LS_UID = "zcbot.user_id";
|
export const LS_UID = "zcbot.user_id";
|
||||||
export const LS_NAME = "zcbot.name";
|
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_LEFT_COLLAPSED = "zcbot.left-collapsed";
|
||||||
export const LS_RIGHT_COLLAPSED = "zcbot.right-collapsed";
|
export const LS_RIGHT_COLLAPSED = "zcbot.right-collapsed";
|
||||||
export const LS_LEFT_WIDTH = "zcbot.left-width";
|
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 = {
|
export const state = {
|
||||||
token: localStorage.getItem(LS_TOKEN) || "",
|
token: localStorage.getItem(LS_TOKEN) || "",
|
||||||
userId: localStorage.getItem(LS_UID) || "",
|
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,
|
taskId: null,
|
||||||
taskMeta: null,
|
taskMeta: null,
|
||||||
filesPath: "",
|
filesPath: "",
|
||||||
|
|
@ -62,3 +66,31 @@ export const state = {
|
||||||
// disabled 状态(否则用户键入 input 会把按钮从"润色中"误启回 enabled)
|
// disabled 状态(否则用户键入 input 会把按钮从"润色中"误启回 enabled)
|
||||||
optimizing: false,
|
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");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue