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:
caoqianming 2026-06-05 15:07:21 +08:00
parent f81dd2def6
commit f2b3675337
6 changed files with 136 additions and 4 deletions

View File

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

View File

@ -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
View File

@ -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}`;新密码 <6400旧密码错 / 无密码(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 |

View File

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

View File

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

View File

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