feat(dev): 顶栏自助改密码 + 选入按钮文字改图标(防换行)
改密码:web/auth.py::change_password(验旧密码 + bcrypt 重哈希,错误归一到 UserCreateError code);POST /v1/auth/change_password 挂 require_user, user_id 取自 JWT 不信前端(旧密码错/无密码 403、弱密码 400)。前端顶栏 「退出登录」左侧加「改密码」按钮(并入 embed 隐藏规则)+ 复用 .modal 弹框 (旧/新/确认,前端先验长度与一致性,成功不登出,401 走 logout)。 选入:#btn-src-pick 文字「选入…」→ 单字符 ⊕(同 ⬆ ↻ › 风格,title 保留 语义),修窄面板偶发换行。 文档:PROGRESS / RUN(API 表 + 用户管理 + 两条兜底)/ DESIGN(auth API 清单)同步。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f81dd2def6
commit
f2b3675337
|
|
@ -252,6 +252,9 @@ Auth
|
||||||
POST /v1/auth/login_password {email, password} → JWT(dev SPA / 同事试用)
|
POST /v1/auth/login_password {email, password} → JWT(dev SPA / 同事试用)
|
||||||
bcrypt 校验 users.password_hash(0005 加 UNIQUE(email));
|
bcrypt 校验 users.password_hash(0005 加 UNIQUE(email));
|
||||||
错邮箱 / 错密码 / 未设密码统一 403 防探测
|
错邮箱 / 错密码 / 未设密码统一 403 防探测
|
||||||
|
POST /v1/auth/change_password {old_password, new_password} → {ok}(dev SPA 顶栏自助改密)
|
||||||
|
需 Bearer(user_id 取自 JWT);验旧密码 + 新密码 ≥6 bcrypt 重哈希;
|
||||||
|
旧密码错 / platform_key 建的无密码行 → 403,弱密码 → 400
|
||||||
|
|
||||||
Files(user-rooted,workspace/users/<uid>/ 为根)
|
Files(user-rooted,workspace/users/<uid>/ 为根)
|
||||||
GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};
|
GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};
|
||||||
|
|
@ -298,6 +301,7 @@ done {}
|
||||||
**当前形态(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}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)
|
||||||
- `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户
|
- `POST /v1/auth/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)
|
||||||
|
|
||||||
后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`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` 条件做隔离。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-06-05
|
### 2026-06-05
|
||||||
|
|
||||||
|
- **dev 页加"改密码"功能 + 文件面板"选入"按钮文字改图标(防换行)**:① 自助改密码——`web/auth.py::change_password(user_id, old, new)`(验旧密码 → 新密码 ≥6 → bcrypt 重哈希写回,错误归一到现成 `UserCreateError` code 体系 `wrong_password/no_password/weak_password/user_not_found`,不为此新开异常类),`POST /v1/auth/change_password` 挂 `Depends(require_user)`(user_id 取自 JWT 不信前端,旧密码错/无密码→403、弱→400、行没了→401)。前端顶栏「退出登录」左侧加「改密码」按钮(`#hd-chpw`,并入 embed 隐藏规则——embed 模式不显示)+ 一个复用 `.modal` 骨架的弹框(旧/新/确认三项,前端先验长度+两次一致再提交,成功 `alert` 提示不登出,401 走 `logout()`)。否决"点用户名展开菜单"(多写菜单逻辑不划算)。② `#btn-src-pick` 的文字 `选入…` 改单字符图标 `⊕`(和旁边 `⬆ ↻ ›` 同款单色字形,`title` 保留"选入"语义)——原中文文字在窄面板偶发换行。
|
||||||
- **记账给 DeepSeek 前缀缓存命中折价(修虚高 ~2-3x)+ 前端体现缓存命中/真实成本**:排查"rust 优势→PPT"那 task(flash,34 轮)发现 `tokens_in` 累计 69.9 万里 **88.6% 是缓存命中**,但 `usage.py::_fallback_chat_cost_cny` 把命中段也按 `input` 全价(1.0)算 → 记 ¥0.84,真实(命中按 0.1x)只 ~¥0.28,**越大的 task 虚高越多**(文献采集 53% 命中:¥33→~¥16)。修:① `ModelCapabilities` 加 `cache_hit_cny_per_mtoken`(deepseek flash 0.1 / pro 0.2;0=不区分按全价兜底,绝不少记);② 成本公式拆三段「命中×缓存价 + (input−命中)×input价 + output×output价」,`loop.py` 把 `cache_hit_tokens` + 缓存单价透传进 `record_chat_usage`;③ 前端不加 DB 列——`web/app.py` 加 `_usage_aggregates`(单查询 GROUP BY `usage_events`,复用列表 `msg_counts` 同款批量范式,无 N+1)on-the-fly 算每 task 真实成本 + chat token + 缓存命中,`_task_dict` 带出;列表行**不内联花费**、只显 tok 数,花费/缓存命中率藏 hover tooltip(`taskUsageTooltip`,多行:输入/输出拆分 · 命中 + 命中率 · ¥真实花费),顶栏额外内联简版。**折价只对新 chat 事件生效**,历史走 backfill 脚本(`scripts/backfill_chat_cost_cache_discount.py`,默认 dry-run,`--apply` 落库;`--assume-cache-hit-rate RATE` 给无 `cache_hit_tokens` 字段的老事件按估算命中率折价——DeepSeek 当时缓存了只是没记,全价偏高;实测过的事件用真实值不受影响)。**坑修**:命中率分母原误用 `tasks.tokens_prompt`,但该列会被「清空对话」重置而 `usage_events` 不重置 → 跨源相除算出 822% 怪值;改为 `_task_dict` 的 token 总量也优先取 usage_events 聚合(与 cache_hit 同源,命中率恒 ≤100%)。**注**:真正压低 token 体量的杠杆是减少轮数(高成本 task 全是 100+ 轮的逐步 write/run_python 循环),非本次范围。
|
- **记账给 DeepSeek 前缀缓存命中折价(修虚高 ~2-3x)+ 前端体现缓存命中/真实成本**:排查"rust 优势→PPT"那 task(flash,34 轮)发现 `tokens_in` 累计 69.9 万里 **88.6% 是缓存命中**,但 `usage.py::_fallback_chat_cost_cny` 把命中段也按 `input` 全价(1.0)算 → 记 ¥0.84,真实(命中按 0.1x)只 ~¥0.28,**越大的 task 虚高越多**(文献采集 53% 命中:¥33→~¥16)。修:① `ModelCapabilities` 加 `cache_hit_cny_per_mtoken`(deepseek flash 0.1 / pro 0.2;0=不区分按全价兜底,绝不少记);② 成本公式拆三段「命中×缓存价 + (input−命中)×input价 + output×output价」,`loop.py` 把 `cache_hit_tokens` + 缓存单价透传进 `record_chat_usage`;③ 前端不加 DB 列——`web/app.py` 加 `_usage_aggregates`(单查询 GROUP BY `usage_events`,复用列表 `msg_counts` 同款批量范式,无 N+1)on-the-fly 算每 task 真实成本 + chat token + 缓存命中,`_task_dict` 带出;列表行**不内联花费**、只显 tok 数,花费/缓存命中率藏 hover tooltip(`taskUsageTooltip`,多行:输入/输出拆分 · 命中 + 命中率 · ¥真实花费),顶栏额外内联简版。**折价只对新 chat 事件生效**,历史走 backfill 脚本(`scripts/backfill_chat_cost_cache_discount.py`,默认 dry-run,`--apply` 落库;`--assume-cache-hit-rate RATE` 给无 `cache_hit_tokens` 字段的老事件按估算命中率折价——DeepSeek 当时缓存了只是没记,全价偏高;实测过的事件用真实值不受影响)。**坑修**:命中率分母原误用 `tasks.tokens_prompt`,但该列会被「清空对话」重置而 `usage_events` 不重置 → 跨源相除算出 822% 怪值;改为 `_task_dict` 的 token 总量也优先取 usage_events 聚合(与 cache_hit 同源,命中率恒 ≤100%)。**注**:真正压低 token 体量的杠杆是减少轮数(高成本 task 全是 100+ 轮的逐步 write/run_python 循环),非本次范围。
|
||||||
|
|
||||||
### 2026-06-04
|
### 2026-06-04
|
||||||
|
|
|
||||||
6
RUN.md
6
RUN.md
|
|
@ -45,7 +45,7 @@
|
||||||
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。
|
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。
|
||||||
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
|
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
|
||||||
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
|
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
|
||||||
- **用户管理**(`users.email/password_hash`,0005 UNIQUE(email)):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。改密 / 改邮箱手动 SQL 或先 DELETE 再 add。
|
- **用户管理**(`users.email/password_hash`,0005 UNIQUE(email)):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -144,6 +144,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
|
||||||
| `GET /static/*` | dev.html 等静态文件 | 豁免 |
|
| `GET /static/*` | dev.html 等静态文件 | 豁免 |
|
||||||
| `POST /v1/auth/login` | platform 机器对机器;body `{user_id, platform_key}` → `{token,expires_at,user_id,ttl_seconds}` | 豁免 |
|
| `POST /v1/auth/login` | platform 机器对机器;body `{user_id, platform_key}` → `{token,expires_at,user_id,ttl_seconds}` | 豁免 |
|
||||||
| `POST /v1/auth/login_password` | dev SPA 邮箱密码;body `{email, password}` → `{token,...,email,...}`;邮箱不存在 / 密码错 / 未设密码统一 403 | 豁免 |
|
| `POST /v1/auth/login_password` | dev SPA 邮箱密码;body `{email, password}` → `{token,...,email,...}`;邮箱不存在 / 密码错 / 未设密码统一 403 | 豁免 |
|
||||||
|
| `POST /v1/auth/change_password` | dev SPA 顶栏「改密码」;body `{old_password, new_password}`(user_id 取自 JWT)→ `{ok:true}`;新密码 <6→400、旧密码错 / 无密码(platform_key 建的行)→403 | 必填 |
|
||||||
| `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 |
|
| `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 |
|
||||||
| `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 |
|
| `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 |
|
||||||
| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
|
| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
|
||||||
|
|
@ -696,7 +697,8 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
|
||||||
| `main.py user add` 报 `IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传让随机生成 |
|
| `main.py user add` 报 `IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传让随机生成 |
|
||||||
| 登录页"+ 管理员添加用户"提交后 503 `admin create_user disabled` | `ZCBOT_ADMIN_TOKEN` env 未设,功能默关。设了 env 重启 web 即可;或临时回退 `main.py user add` |
|
| 登录页"+ 管理员添加用户"提交后 503 `admin create_user disabled` | `ZCBOT_ADMIN_TOKEN` env 未设,功能默关。设了 env 重启 web 即可;或临时回退 `main.py user add` |
|
||||||
| 登录页"+ 管理员添加用户"返 403 `invalid admin_token` | 弹窗里管理员口令栏填错或没复制完整。跟 `.env` 里 `ZCBOT_ADMIN_TOKEN` 比对(注意末尾空格 / 引号) |
|
| 登录页"+ 管理员添加用户"返 403 `invalid admin_token` | 弹窗里管理员口令栏填错或没复制完整。跟 `.env` 里 `ZCBOT_ADMIN_TOKEN` 比对(注意末尾空格 / 引号) |
|
||||||
| 改了用户邮箱 / 密码后他登不上 | `UPDATE users SET email=...` 不影响 user_id(行同一行,task 仍归属),用新邮箱登即可;DB 里应存小写(后端 lower() 后查)。改密 `UPDATE users SET password_hash=<bcrypt>` 同理 |
|
| 改了用户邮箱 / 密码后他登不上 | `UPDATE users SET email=...` 不影响 user_id(行同一行,task 仍归属),用新邮箱登即可;DB 里应存小写(后端 lower() 后查)。改密 `UPDATE users SET password_hash=<bcrypt>` 同理。**用户知道旧密码时优先让他用顶栏「改密码」自助**,只有忘了旧密码 / 改邮箱才手动 SQL |
|
||||||
|
| 顶栏「改密码」返 403 `该账号未设置密码` | 该 user 是 platform_key(UUID+PLATFORM_KEY)入口建的占位行,`password_hash` 为空,无旧密码可验。先手动 `UPDATE users SET password_hash=<bcrypt>` 设一个,再让他用密码登 + 自助改 |
|
||||||
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 login 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
|
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 login 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
|
||||||
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |
|
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |
|
||||||
| dev.html SSE 收不到流(消息发出去 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`。devtools Network 看 POST /messages 是否 202 + events_url GET 是否 200 + Content-Type 是 text/event-stream;401 → token 过期,logout 重 login |
|
| dev.html SSE 收不到流(消息发出去 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`。devtools Network 看 POST /messages 是否 202 + events_url GET 是否 200 + Content-Type 是 text/event-stream;401 → token 过期,logout 重 login |
|
||||||
|
|
|
||||||
29
web/app.py
29
web/app.py
|
|
@ -41,6 +41,7 @@ from core.storage.utils import ensure_local_task_row
|
||||||
from .auth import (
|
from .auth import (
|
||||||
AuthConfig,
|
AuthConfig,
|
||||||
UserCreateError,
|
UserCreateError,
|
||||||
|
change_password,
|
||||||
create_user,
|
create_user,
|
||||||
ensure_user_row,
|
ensure_user_row,
|
||||||
make_require_user,
|
make_require_user,
|
||||||
|
|
@ -525,6 +526,11 @@ class AdminCreateUserRequest(BaseModel):
|
||||||
admin_token: str
|
admin_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────── App 工厂 ──────────────────────
|
# ────────────────────── App 工厂 ──────────────────────
|
||||||
|
|
||||||
# web/static 目录路径 — /static 静态挂载用,dev.html 也放这
|
# web/static 目录路径 — /static 静态挂载用,dev.html 也放这
|
||||||
|
|
@ -849,6 +855,29 @@ def create_app() -> FastAPI:
|
||||||
"ttl_seconds": auth_cfg.ttl_seconds,
|
"ttl_seconds": auth_cfg.ttl_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.post("/v1/auth/change_password", tags=["auth"])
|
||||||
|
def change_password_route(
|
||||||
|
body: ChangePasswordRequest, user_id: UUID = Depends(require_user)
|
||||||
|
):
|
||||||
|
"""改密码(dev SPA 顶栏入口)。user_id 取自 JWT,不信任前端传值。
|
||||||
|
|
||||||
|
- 新密码 < 6 → 400
|
||||||
|
- 旧密码错 / 该账号无密码(platform_key 建的)→ 403(不细分,防探测)
|
||||||
|
- 用户不存在(JWT 有效但行没了)→ 401
|
||||||
|
成功 → `{"ok": true}`,前端提示并清空表单。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
change_password(user_id, body.old_password, body.new_password)
|
||||||
|
except UserCreateError as ex:
|
||||||
|
if ex.code == "weak_password":
|
||||||
|
raise HTTPException(400, ex.message)
|
||||||
|
if ex.code in ("wrong_password", "no_password"):
|
||||||
|
raise HTTPException(403, ex.message)
|
||||||
|
if ex.code == "user_not_found":
|
||||||
|
raise HTTPException(401, ex.message)
|
||||||
|
raise HTTPException(500, f"change_password failed: {ex.message}")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
# ───────────── Tasks CRUD ─────────────
|
# ───────────── Tasks CRUD ─────────────
|
||||||
|
|
||||||
@app.post("/v1/tasks", status_code=201, tags=["tasks"])
|
@app.post("/v1/tasks", status_code=201, tags=["tasks"])
|
||||||
|
|
|
||||||
24
web/auth.py
24
web/auth.py
|
|
@ -191,6 +191,29 @@ def create_user(email: str, password: str, user_id: Optional[UUID] = None) -> tu
|
||||||
return uid, e
|
return uid, e
|
||||||
|
|
||||||
|
|
||||||
|
def change_password(user_id: UUID, old_password: str, new_password: str) -> None:
|
||||||
|
"""改密码:验旧密码 → 校验新密码 → bcrypt 重哈希写回 users.password_hash。
|
||||||
|
|
||||||
|
错误归一到 `UserCreateError.code`(复用同一份 code 载体,web 路由映射成 HTTP):
|
||||||
|
- 'user_not_found' — user_id 无对应行(JWT 有效却查不到,极少见)
|
||||||
|
- 'no_password' — 该用户从没设过密码(platform_key 入口建的占位行),无从校验旧密码
|
||||||
|
- 'wrong_password' — 旧密码不匹配
|
||||||
|
- 'weak_password' — 新密码 < 6 字符
|
||||||
|
成功返 None;写在 session_scope 内,退出时一次 commit,下次 login 立即生效。
|
||||||
|
"""
|
||||||
|
if not new_password or len(new_password) < 6:
|
||||||
|
raise UserCreateError("weak_password", "新密码至少 6 字符")
|
||||||
|
with session_scope() as s:
|
||||||
|
user = s.execute(select(User).where(User.user_id == user_id)).scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise UserCreateError("user_not_found", "用户不存在")
|
||||||
|
if not user.password_hash:
|
||||||
|
raise UserCreateError("no_password", "该账号未设置密码,无法修改")
|
||||||
|
if not verify_password(old_password, user.password_hash):
|
||||||
|
raise UserCreateError("wrong_password", "旧密码不正确")
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
|
||||||
|
|
||||||
def ensure_user_row(user_id: UUID) -> None:
|
def ensure_user_row(user_id: UUID) -> None:
|
||||||
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
"""幂等 INSERT 一行 users 占位(`ON CONFLICT DO NOTHING`)。
|
||||||
|
|
||||||
|
|
@ -235,6 +258,7 @@ def make_require_user(cfg: AuthConfig):
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthConfig",
|
"AuthConfig",
|
||||||
"UserCreateError",
|
"UserCreateError",
|
||||||
|
"change_password",
|
||||||
"create_user",
|
"create_user",
|
||||||
"ensure_user_row",
|
"ensure_user_row",
|
||||||
"hash_password",
|
"hash_password",
|
||||||
|
|
|
||||||
|
|
@ -731,7 +731,7 @@
|
||||||
/* tab 按钮:整行铺底,order:99 让它换行到 header 第二行 */
|
/* tab 按钮:整行铺底,order:99 让它换行到 header 第二行 */
|
||||||
.mobile-tabs { display: flex; order: 99; flex-basis: 100%; }
|
.mobile-tabs { display: flex; order: 99; flex-basis: 100%; }
|
||||||
.mobile-tabs button { flex: 1; }
|
.mobile-tabs button { flex: 1; }
|
||||||
#hd-logout { padding: 4px 8px; font-size: 12px; }
|
#hd-chpw, #hd-logout { padding: 4px 8px; font-size: 12px; }
|
||||||
|
|
||||||
/* iOS 防 focus 自动缩放:input/textarea 字号 ≥ 16 */
|
/* iOS 防 focus 自动缩放:input/textarea 字号 ≥ 16 */
|
||||||
textarea,
|
textarea,
|
||||||
|
|
@ -777,6 +777,7 @@
|
||||||
body.embed-mode #login { display: none !important; }
|
body.embed-mode #login { display: none !important; }
|
||||||
body.embed-mode header .brand,
|
body.embed-mode header .brand,
|
||||||
body.embed-mode header #hd-who,
|
body.embed-mode header #hd-who,
|
||||||
|
body.embed-mode header #hd-chpw,
|
||||||
body.embed-mode header #hd-logout { display: none; }
|
body.embed-mode header #hd-logout { display: none; }
|
||||||
@media (min-width: 641px) {
|
@media (min-width: 641px) {
|
||||||
body.embed-mode header { display: none; }
|
body.embed-mode header { display: none; }
|
||||||
|
|
@ -876,6 +877,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ───── change-password modal(顶栏「改密码」入口,需已登录)───── -->
|
||||||
|
<div id="chpw-modal" class="modal">
|
||||||
|
<div class="card">
|
||||||
|
<h3>修改密码</h3>
|
||||||
|
<label for="cp-old">旧密码</label>
|
||||||
|
<input id="cp-old" type="password" autocomplete="current-password" placeholder="当前密码" />
|
||||||
|
<label for="cp-new">新密码</label>
|
||||||
|
<input id="cp-new" type="password" autocomplete="new-password" placeholder="≥ 6 字符" />
|
||||||
|
<label for="cp-new2">确认新密码</label>
|
||||||
|
<input id="cp-new2" type="password" autocomplete="new-password" placeholder="再输一次新密码" />
|
||||||
|
<div class="err" id="cp-err"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="cp-cancel">取消</button>
|
||||||
|
<button class="primary" id="cp-go">确认修改</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ───── embed-mode waiting overlay (token 握手中) ───── -->
|
<!-- ───── embed-mode waiting overlay (token 握手中) ───── -->
|
||||||
<div id="embed-waiting">
|
<div id="embed-waiting">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
|
|
@ -892,6 +911,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="who" id="hd-who"></div>
|
<div class="who" id="hd-who"></div>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
<button id="hd-chpw" title="修改登录密码">改密码</button>
|
||||||
<button id="hd-logout">退出登录</button>
|
<button id="hd-logout">退出登录</button>
|
||||||
<!-- 手机 tab(桌面 display:none):任务 / 对话 / 文件 -->
|
<!-- 手机 tab(桌面 display:none):任务 / 对话 / 文件 -->
|
||||||
<div class="mobile-tabs" role="tablist">
|
<div class="mobile-tabs" role="tablist">
|
||||||
|
|
@ -976,7 +996,7 @@
|
||||||
<span class="label">文件</span>
|
<span class="label">文件</span>
|
||||||
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span>
|
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button id="btn-src-pick" class="small" title="从其他目录勾选文件 / 目录,复制或移动到当前主目录">选入…</button>
|
<button id="btn-src-pick" class="small" title="选入:从其他目录勾选文件 / 目录,复制或移动到当前主目录">⊕</button>
|
||||||
<button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)">⬆</button>
|
<button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)">⬆</button>
|
||||||
<button id="btn-refresh-files" class="small">↻</button>
|
<button id="btn-refresh-files" class="small">↻</button>
|
||||||
<button id="pane-toggle-right" class="small" title="折叠文件列表">›</button>
|
<button id="pane-toggle-right" class="small" title="折叠文件列表">›</button>
|
||||||
|
|
@ -1496,6 +1516,57 @@ async function doAdminAdd() {
|
||||||
}
|
}
|
||||||
$("ad-go").onclick = doAdminAdd;
|
$("ad-go").onclick = doAdminAdd;
|
||||||
|
|
||||||
|
// ───── 改密码(顶栏入口,需已登录)─────
|
||||||
|
// 旧/新/确认三项;user_id 不传,后端从 JWT 取。成功后关弹窗,提示一下(不登出)。
|
||||||
|
function openChpwModal() {
|
||||||
|
$("cp-old").value = "";
|
||||||
|
$("cp-new").value = "";
|
||||||
|
$("cp-new2").value = "";
|
||||||
|
$("cp-err").textContent = "";
|
||||||
|
$("chpw-modal").classList.add("show");
|
||||||
|
$("cp-old").focus();
|
||||||
|
}
|
||||||
|
function closeChpwModal() {
|
||||||
|
$("chpw-modal").classList.remove("show");
|
||||||
|
}
|
||||||
|
$("hd-chpw").onclick = openChpwModal;
|
||||||
|
$("cp-cancel").onclick = closeChpwModal;
|
||||||
|
$("chpw-modal").addEventListener("click", (e) => {
|
||||||
|
if (e.target.id === "chpw-modal") closeChpwModal(); // 点遮罩关闭
|
||||||
|
});
|
||||||
|
document.querySelectorAll("#chpw-modal input").forEach(i => {
|
||||||
|
i.addEventListener("keydown", (e) => { if (e.key === "Enter") doChangePassword(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function doChangePassword() {
|
||||||
|
$("cp-err").textContent = "";
|
||||||
|
const oldPw = $("cp-old").value;
|
||||||
|
const newPw = $("cp-new").value;
|
||||||
|
const newPw2 = $("cp-new2").value;
|
||||||
|
if (!oldPw || !newPw || !newPw2) {
|
||||||
|
$("cp-err").textContent = "请填旧密码和新密码";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPw.length < 6) {
|
||||||
|
$("cp-err").textContent = "新密码至少 6 字符";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPw !== newPw2) {
|
||||||
|
$("cp-err").textContent = "两次输入的新密码不一致";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api("POST", "/v1/auth/change_password", { old_password: oldPw, new_password: newPw });
|
||||||
|
closeChpwModal();
|
||||||
|
// 不登出:同一会话 JWT 仍有效,下次登录用新密码即可
|
||||||
|
alert("密码已修改,下次登录请用新密码");
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) { closeChpwModal(); logout(); return; }
|
||||||
|
$("cp-err").textContent = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("cp-go").onclick = doChangePassword;
|
||||||
|
|
||||||
// ───── pane 折叠 + splitters(rail 模式 + localStorage 持久化) ─────
|
// ───── pane 折叠 + splitters(rail 模式 + localStorage 持久化) ─────
|
||||||
const PANE_W = { left: { min: 220, max: 560, def: 320 }, right: { min: 220, max: 560, def: 320 } };
|
const PANE_W = { left: { min: 220, max: 560, def: 320 }, right: { min: 220, max: 560, def: 320 } };
|
||||||
function clampPaneWidth(side, value) {
|
function clampPaneWidth(side, value) {
|
||||||
|
|
@ -3641,6 +3712,7 @@ $("mini-preview-modal").addEventListener("click", (e) => {
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
|
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
|
||||||
|
if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; }
|
||||||
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
|
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
|
||||||
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
|
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
|
||||||
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
|
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue