Compare commits
2 Commits
b5cfce72b5
...
f17da6a6e1
| Author | SHA1 | Date |
|---|---|---|
|
|
f17da6a6e1 | |
|
|
2b2b4531b3 |
|
|
@ -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 共存
|
||||
|
|
|
|||
17
PROGRESS.md
17
PROGRESS.md
|
|
@ -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`/定时简报)。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.25.1"
|
||||
__version__ = "0.26.1"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
"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,
|
||||
|
|
|
|||
31
web/app.py
31
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,
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
50
web/auth.py
50
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",
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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(尊重用户中间切过的选择)
|
||||
|
|
|
|||
|
|
@ -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 扫描,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue