core(GET /v1/tasks): 分页 + 多维筛选 + ordering 排序

- 响应壳固定 {page, page_size, count, results}
- 6 个 query 参数:page(1-based) / page_size(1-100 clamp) /
  status / skill / working_dir(末段名,后端拼前缀比对) /
  q(name + description ILIKE)
- ordering DRF 风格逗号分隔,-field 倒序;allowlist
  created_at/updated_at/name/status;非法字段静默丢弃;**默认 -created_at**
- 单次 COUNT + 单次 SELECT LIMIT/OFFSET,无 N+1
- dev SPA:task pane 三段头(status + 刷新 / q + working_dir / ordering),
  prev/next 翻页 + "from–to / count (第 P/L 页)" + 输入 debounce 300ms +
  默认 -created_at 不发到 URL(参数干净)
- DESIGN §7.2 / RUN 路由表 / PROGRESS 同步

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-17 19:26:29 +08:00
parent 4a6aaaf34d
commit 0c577ba0a5
5 changed files with 186 additions and 21 deletions

View File

@ -237,7 +237,12 @@ Tasks
留空 working_dir → 用 name 作目录名; 留空 working_dir → 用 name 作目录名;
working_dir 派生 workspace/users/<user_id>/<working_dir>/; working_dir 派生 workspace/users/<user_id>/<working_dir>/;
name/working_dir 不合法 → 400 name/working_dir 不合法 → 400
GET /v1/tasks?status=&limit= 列表(updated_at 降序,?status=active|completed|abandoned) GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=
列表,返 `{page, page_size, count, results}`
分页 1-based;page_size 1100 clamp;status active/completed/abandoned;
skill 精确;working_dir 末段名(后端拼前缀比对);q 在 name+description ILIKE;
ordering DRF 风格逗号分隔,`-field` 倒序;allowlist
created_at/updated_at/name/status;**默认 `-created_at`**
GET /v1/tasks/{id} 单 task meta + 完整 messages GET /v1/tasks/{id} 单 task meta + 完整 messages
PATCH /v1/tasks/{id} {status?,description?,name?,skill?};status 从 web 不让切回 active(走 CLI) PATCH /v1/tasks/{id} {status?,description?,name?,skill?};status 从 web 不让切回 active(走 CLI)
DELETE /v1/tasks/{id} 硬删:DB 行 + messages(CASCADE);**FS working_dir 保留** DELETE /v1/tasks/{id} 硬删:DB 行 + messages(CASCADE);**FS working_dir 保留**

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-17(schema 重构:`name`(必填,显示名)+ `working_dir`(可选,留空 fallback name)解耦;`task_dir → working_dir` + `mode → skill` 列重命名;新增 `GET /v1/folders`(已有目录 autocomplete);dev SPA 任务名 / 工作目录字段拆开 + datalist 选已有目录;files 面板 UX:pane-head 显示项目名 + crumbs root 用项目名 + 修 root 处多渲 "." crumb 的 bug;`DELETE /v1/tasks/{id}` 硬删 + dev SPA delete 按钮 + 文件 per-row 删按钮;task 创建立即建项目目录(从懒创建改 eager)) 最后更新:2026-05-17(`GET /v1/tasks` 分页 + 多维筛选 + ordering(DRF 风格,默认 `-created_at`):`{page, page_size, count, results}` 标准壳 + status/skill/working_dir/q 过滤 + 排序;dev SPA prev/next 翻页 + 搜索框 + 工作目录筛选 + 排序 dropdown;schema 重构:`name`(必填,显示名)+ `working_dir`(可选,留空 fallback name)解耦;`task_dir → working_dir` + `mode → skill` 列重命名)
--- ---
@ -44,6 +44,8 @@
- **05-15 / §7 Phase G G5 文件浏览 + 上传 / 下载 / 删**:`web/app.py` 加四件套路由 — `GET /tasks/{id}/files?path=<rel>`(列目录树,面包屑 + 目录在前文件在后 + size humanize + mtime 格式化)/ `GET /tasks/{id}/files/download?path=<rel>`(FileResponse + Content-Disposition)/ `POST /tasks/{id}/files/upload`(multipart `list[UploadFile]`,`?path=` 指目标子目录,自动 `mkdir(parents=True)`,303 回浏览页)/ `POST /tasks/{id}/files/delete`(form `path=...`,文件 / 空目录可删,非空目录 → 400,root → 400)。**核心:`_safe_join(root, rel)` 路径安全归一**——空 / "."→ root;`/` `\\` 起头 → 400(absolute-style 拒);Path.is_absolute → 400;`(root / rel).resolve().relative_to(root.resolve())` 校验仍在 root 内(防 `../` / symlink 逃逸)。上传文件名 strict 拒带 path 痕迹(`/` `\\` `..` parts)—— 现代浏览器只给 basename,异常 client 直接 400 不悄悄 sanitize。task_dir 不存在(skill 还没产物)→ 200 + 空文案,不报错。task_dir 空(legacy / 未绑)→ 400。`_load_task_dir(task_id)` 共用入口:404 if 非 UUID / task 不存在,400 if task_dir 空,否则返 `(tid, abs_path)`。**模板**:新增 `files.html`(面包屑 nav + upload-form `multipart/form-data` + `<table class="file-list">` 行渲染目录用蓝色 + `/` 后缀,文件用 `download` 链 + size + mtime + 删除按钮);`chat.html` 在 page-head 加 `files` 按钮(task_dir 非空时显示)。**CSS**:`.crumbs` / `.upload-form`(虚线红框 accent-soft 区)/ `.file-list` 表 / `.btn-mini` mini 按钮 + `.btn-mini-danger` 红 hover / `.ico-dir` `.ico-file` 文件类型标识。**Smoke 50+ case 全绿**:task_dir 不存在 200(2) / 列文件 + 子目录(12) / download 文件 + 子目录 + 404 + 目录-是非文件 400(7) / path 安全 6 case(`../` 越界 + POSIX 绝对 + Win 绝对 + `\\` 越界 + `/tmp`) / upload 单文件 + multi-file + nested mkdir + 攻击名 `../escape.txt` / `../../boom` / empty 全拒 + 目标 path 是文件 400 + 文件落 FS 内容一致(13) / delete 文件 + 空目录 + 非空 400 + ghost 404 + root 拒 + 越界拒(9) / chat.html files 链接 + ghost task_id 全 404(5) / task_dir 空 400(2)。版本 0.6 → 0.7。`_smoke_g5.py` ad-hoc 跑完即删。 - **05-15 / §7 Phase G G5 文件浏览 + 上传 / 下载 / 删**:`web/app.py` 加四件套路由 — `GET /tasks/{id}/files?path=<rel>`(列目录树,面包屑 + 目录在前文件在后 + size humanize + mtime 格式化)/ `GET /tasks/{id}/files/download?path=<rel>`(FileResponse + Content-Disposition)/ `POST /tasks/{id}/files/upload`(multipart `list[UploadFile]`,`?path=` 指目标子目录,自动 `mkdir(parents=True)`,303 回浏览页)/ `POST /tasks/{id}/files/delete`(form `path=...`,文件 / 空目录可删,非空目录 → 400,root → 400)。**核心:`_safe_join(root, rel)` 路径安全归一**——空 / "."→ root;`/` `\\` 起头 → 400(absolute-style 拒);Path.is_absolute → 400;`(root / rel).resolve().relative_to(root.resolve())` 校验仍在 root 内(防 `../` / symlink 逃逸)。上传文件名 strict 拒带 path 痕迹(`/` `\\` `..` parts)—— 现代浏览器只给 basename,异常 client 直接 400 不悄悄 sanitize。task_dir 不存在(skill 还没产物)→ 200 + 空文案,不报错。task_dir 空(legacy / 未绑)→ 400。`_load_task_dir(task_id)` 共用入口:404 if 非 UUID / task 不存在,400 if task_dir 空,否则返 `(tid, abs_path)`。**模板**:新增 `files.html`(面包屑 nav + upload-form `multipart/form-data` + `<table class="file-list">` 行渲染目录用蓝色 + `/` 后缀,文件用 `download` 链 + size + mtime + 删除按钮);`chat.html` 在 page-head 加 `files` 按钮(task_dir 非空时显示)。**CSS**:`.crumbs` / `.upload-form`(虚线红框 accent-soft 区)/ `.file-list` 表 / `.btn-mini` mini 按钮 + `.btn-mini-danger` 红 hover / `.ico-dir` `.ico-file` 文件类型标识。**Smoke 50+ case 全绿**:task_dir 不存在 200(2) / 列文件 + 子目录(12) / download 文件 + 子目录 + 404 + 目录-是非文件 400(7) / path 安全 6 case(`../` 越界 + POSIX 绝对 + Win 绝对 + `\\` 越界 + `/tmp`) / upload 单文件 + multi-file + nested mkdir + 攻击名 `../escape.txt` / `../../boom` / empty 全拒 + 目标 path 是文件 400 + 文件落 FS 内容一致(13) / delete 文件 + 空目录 + 非空 400 + ghost 404 + root 拒 + 越界拒(9) / chat.html files 链接 + ghost task_id 全 404(5) / task_dir 空 400(2)。版本 0.6 → 0.7。`_smoke_g5.py` ad-hoc 跑完即删。
- **05-15 / §7 Phase G G6 三件套:/done /abandon 按钮 + /export 下载 + 全局 toast**:① `POST /tasks/{id}/status`(`status=completed|abandoned`,active 不让从 web 切回 → 400)走 `UPDATE tasks SET status`,303 redirect 回 `/tasks/{id}` —— 浏览器全页刷新,聊天流不重发。chat.html active task 渲两个 `<form method="post">` 按钮(原生 `confirm()` 防误操,无 HTMX 依赖),completed/abandoned 自动隐藏按钮只显 status badge。② `GET /tasks/{id}/export``tempfile.mkstemp(suffix=.docx)``export_chat_to_docx(tid, out_path=tmp)``FileResponse(..., background=BackgroundTask(tmp.unlink, missing_ok=True))` 响应完成自动删 tmpfile;无 messages → 400 / ghost UUID → 404 / 失败 → 500 带错文。chat.html 在 `n_messages > 0` 时渲 `<a class="btn">export .docx</a>`(浏览器原生下载,无 HTMX 干预 Content-Disposition)。**`export_chat_to_docx` 顺手修了一个 bug**:`task_dir is None` 且 PG 也空时旧逻辑硬抛 `ValueError`,即便 `out_path` 已经显式传入 —— 现在 `task_dir` 改为可选(None 时 meta 段显示 `(未绑)`),只在 `out_path` 也 None 时才报错。③ `base.html` 末尾加 `<div id="toast-region">` + inline JS 监听 `htmx:responseError`(4xx/5xx 抓 responseText 截 200 字)和 `htmx:sendError`(网络层挂),自动 5-6s dismiss + 手动 × 关。CSS `.toast` / `.toast-error` 右上角 fixed 区 + `@keyframes toast-in/out` 滑入滑出;`#toast-region` z-index 9999 + `pointer-events: none`(容器穿透,toast 自身可点)。Smoke 32 case 全绿:status 6 case(completed/abandoned 303 + DB UPDATE + GET 不再渲按钮、invalid status 400、active 400 拒切回、非 UUID 404、ghost 404)+ export 7 case(200 + Content-Disposition attachment + filename `chat_<8>.docx` + media-type docx + size > 8KB + magic `PK\x03\x04` + no messages 400 + 404 双路径)+ toast 6 case(div / 两 listener / CSS)+ chat.html 7 case(active 渲 done/abandon/export + confirm 文案 + completed 不渲)。版本 0.5 → 0.6。`_smoke_g6.py` ad-hoc 跑完即删(不入 git)。**TODO**:并发同 task 多 run lock 还没做(留到 D 阶段或下次)。 - **05-15 / §7 Phase G G6 三件套:/done /abandon 按钮 + /export 下载 + 全局 toast**:① `POST /tasks/{id}/status`(`status=completed|abandoned`,active 不让从 web 切回 → 400)走 `UPDATE tasks SET status`,303 redirect 回 `/tasks/{id}` —— 浏览器全页刷新,聊天流不重发。chat.html active task 渲两个 `<form method="post">` 按钮(原生 `confirm()` 防误操,无 HTMX 依赖),completed/abandoned 自动隐藏按钮只显 status badge。② `GET /tasks/{id}/export``tempfile.mkstemp(suffix=.docx)``export_chat_to_docx(tid, out_path=tmp)``FileResponse(..., background=BackgroundTask(tmp.unlink, missing_ok=True))` 响应完成自动删 tmpfile;无 messages → 400 / ghost UUID → 404 / 失败 → 500 带错文。chat.html 在 `n_messages > 0` 时渲 `<a class="btn">export .docx</a>`(浏览器原生下载,无 HTMX 干预 Content-Disposition)。**`export_chat_to_docx` 顺手修了一个 bug**:`task_dir is None` 且 PG 也空时旧逻辑硬抛 `ValueError`,即便 `out_path` 已经显式传入 —— 现在 `task_dir` 改为可选(None 时 meta 段显示 `(未绑)`),只在 `out_path` 也 None 时才报错。③ `base.html` 末尾加 `<div id="toast-region">` + inline JS 监听 `htmx:responseError`(4xx/5xx 抓 responseText 截 200 字)和 `htmx:sendError`(网络层挂),自动 5-6s dismiss + 手动 × 关。CSS `.toast` / `.toast-error` 右上角 fixed 区 + `@keyframes toast-in/out` 滑入滑出;`#toast-region` z-index 9999 + `pointer-events: none`(容器穿透,toast 自身可点)。Smoke 32 case 全绿:status 6 case(completed/abandoned 303 + DB UPDATE + GET 不再渲按钮、invalid status 400、active 400 拒切回、非 UUID 404、ghost 404)+ export 7 case(200 + Content-Disposition attachment + filename `chat_<8>.docx` + media-type docx + size > 8KB + magic `PK\x03\x04` + no messages 400 + 404 双路径)+ toast 6 case(div / 两 listener / CSS)+ chat.html 7 case(active 渲 done/abandon/export + confirm 文案 + completed 不渲)。版本 0.5 → 0.6。`_smoke_g6.py` ad-hoc 跑完即删(不入 git)。**TODO**:并发同 task 多 run lock 还没做(留到 D 阶段或下次)。
- **05-15 / §7 D' 过渡 auth + dev SPA**:platform 联调前需要 auth,但完整 OIDC 还要等;落地 **PLATFORM_KEY → JWT 兑换** 过渡形态(`web/auth.py`),前后端走完全同一条流(platform 服务端 / dev 浏览器都持有 PLATFORM_KEY、调 `/v1/auth/login` 换 token、后续 `Authorization: Bearer <jwt>`)。**实现**:`pyjwt` HS256,`AuthConfig.from_env()` 启动校验 `PLATFORM_KEY` / `JWT_SECRET` 必填(任一缺失 fail-fast)、`ZCBOT_JWT_TTL_SECONDS` 默认 7d、`mint_token` / `verify_token` / `ensure_user_row`(任意 user_id 幂等 INSERT users 行避免 FK 失败)。`HTTPBearer(auto_error=False)` Depends 拿凭证 → `verify_token` → UUID;`make_require_user(cfg)` 工厂闭包持 cfg,FastAPI Depends 抽签到每个 /v1/tasks* 路由。**数据隔离**:所有 `SELECT Task` / `UPDATE Task``Task.user_id == user_id` 条件;`_load_task_dir(task_id, user_id)` 跨 user 视为 404(不暴露存在性);`check_no_subtask(... user_id=user_id)`、`ensure_local_task_row(... user_id=user_id)` 同 user 隔离 no-subtask 校验。新增 `_assert_owns_task(s, tid, user_id)` helper 复用 messages / SSE / export 三处所有权校验。**豁免**:`/`、`/healthz`、`/docs`、`/openapi.json`、`/v1/auth/login`、`/static/*` 不验 token。**dev SPA**(`web/static/dev.html` ~600 行单文件 vanilla JS):login overlay(user_id 默 SENTINEL 全 0 + platform_key) → localStorage 存 token → 3 栏布局(左 task 列表 + 状态 filter + 新建按钮;中 chat meta + 流式消息卡 + send 表单;右 file 浏览 + 面包屑 + 下载)+ 顶 bar(user 显示 + logout)+ new task modal。**SSE 走 fetch + ReadableStream**(不用 EventSource,因为 EventSource API 不支持自定义 header,token 没法塞;改用 fetch + 手解 SSE frame `\n\n` 切帧、`event:` `data:` 行解析、JSON.parse data 字段)。/ 302 → /static/dev.html(Swagger 仍在 /docs)。**Smoke 32 case 全绿**(TestClient + 真实 HTTP via uvicorn @8767):基本路由(/healthz / / 302 / dev.html 28KB / /docs 仍 200)+ 未带 token 8 路径全 401 + login 路径(bad key 403 / bad user_id 400 / happy 200 + token/expires_at/user_id 回显)+ 带 token CRUD 200/201 + 跨 user 隔离 4 case(other 看 sentinel 404 / 列表不串 / 各自创建独立 / sentinel 看 other 404)+ token 异常(garbled / Basic scheme / wrong-secret / expired 全 401)+ 真实 HTTP login + bearer call + dev.html 静态服务 29KB + root 302 Location 正确 + /docs 仍开放。版本 0.7 → 0.8。requirements 加 `pyjwt>=2.8.0`。**没动**:`core/*`、`build_agent`、`Session.append`、CLI 全链(本地 SENTINEL 单 user 默认走通,不进 web auth)。**TODO**:真 OIDC 接入(替换 /v1/auth/login 内部为 ID token 校验,路由层不动)。 - **05-15 / §7 D' 过渡 auth + dev SPA**:platform 联调前需要 auth,但完整 OIDC 还要等;落地 **PLATFORM_KEY → JWT 兑换** 过渡形态(`web/auth.py`),前后端走完全同一条流(platform 服务端 / dev 浏览器都持有 PLATFORM_KEY、调 `/v1/auth/login` 换 token、后续 `Authorization: Bearer <jwt>`)。**实现**:`pyjwt` HS256,`AuthConfig.from_env()` 启动校验 `PLATFORM_KEY` / `JWT_SECRET` 必填(任一缺失 fail-fast)、`ZCBOT_JWT_TTL_SECONDS` 默认 7d、`mint_token` / `verify_token` / `ensure_user_row`(任意 user_id 幂等 INSERT users 行避免 FK 失败)。`HTTPBearer(auto_error=False)` Depends 拿凭证 → `verify_token` → UUID;`make_require_user(cfg)` 工厂闭包持 cfg,FastAPI Depends 抽签到每个 /v1/tasks* 路由。**数据隔离**:所有 `SELECT Task` / `UPDATE Task``Task.user_id == user_id` 条件;`_load_task_dir(task_id, user_id)` 跨 user 视为 404(不暴露存在性);`check_no_subtask(... user_id=user_id)`、`ensure_local_task_row(... user_id=user_id)` 同 user 隔离 no-subtask 校验。新增 `_assert_owns_task(s, tid, user_id)` helper 复用 messages / SSE / export 三处所有权校验。**豁免**:`/`、`/healthz`、`/docs`、`/openapi.json`、`/v1/auth/login`、`/static/*` 不验 token。**dev SPA**(`web/static/dev.html` ~600 行单文件 vanilla JS):login overlay(user_id 默 SENTINEL 全 0 + platform_key) → localStorage 存 token → 3 栏布局(左 task 列表 + 状态 filter + 新建按钮;中 chat meta + 流式消息卡 + send 表单;右 file 浏览 + 面包屑 + 下载)+ 顶 bar(user 显示 + logout)+ new task modal。**SSE 走 fetch + ReadableStream**(不用 EventSource,因为 EventSource API 不支持自定义 header,token 没法塞;改用 fetch + 手解 SSE frame `\n\n` 切帧、`event:` `data:` 行解析、JSON.parse data 字段)。/ 302 → /static/dev.html(Swagger 仍在 /docs)。**Smoke 32 case 全绿**(TestClient + 真实 HTTP via uvicorn @8767):基本路由(/healthz / / 302 / dev.html 28KB / /docs 仍 200)+ 未带 token 8 路径全 401 + login 路径(bad key 403 / bad user_id 400 / happy 200 + token/expires_at/user_id 回显)+ 带 token CRUD 200/201 + 跨 user 隔离 4 case(other 看 sentinel 404 / 列表不串 / 各自创建独立 / sentinel 看 other 404)+ token 异常(garbled / Basic scheme / wrong-secret / expired 全 401)+ 真实 HTTP login + bearer call + dev.html 静态服务 29KB + root 302 Location 正确 + /docs 仍开放。版本 0.7 → 0.8。requirements 加 `pyjwt>=2.8.0`。**没动**:`core/*`、`build_agent`、`Session.append`、CLI 全链(本地 SENTINEL 单 user 默认走通,不进 web auth)。**TODO**:真 OIDC 接入(替换 /v1/auth/login 内部为 ID token 校验,路由层不动)。
- **05-17 / `GET /v1/tasks` + ordering 排序(DRF 风格)**:加 `ordering` query 参数,逗号分隔多字段,`-field` 倒序;allowlist `created_at/updated_at/name/status`;非法字段静默丢弃,全非法 fallback 默认。**默认从 `-updated_at``-created_at`**(用户要求,创建时间倒序更稳定)。`_parse_ordering(s)` helper 返 sqlalchemy `order_by` 列表直接 `*expand`。dev SPA 加 ordering dropdown(7 个常用选项:创建/更新时间双向 + 名称双向 + 状态分组),默认值 `-created_at` 不发送 URL(参数干净);onchange 同 filter 一样 reset page=1。Smoke 7 case:default = `-created_at`(mu/alpha/zeta) / `created_at` asc 反向 / `name` asc(alpha/mu/zeta) / `-name` desc 反向 / 多字段 `status,-created_at`(状态 alpha 排序 abandoned→active→completed) / 非法字段 `garbage` → fallback default / 混合 `garbage,-name` → 仅 `-name` 生效。文档 DESIGN §7.2 / RUN 路由表同步。
- **05-17 / `GET /v1/tasks` 分页 + 多维筛选 + dev SPA 翻页/搜索**:用户反馈 list 接口缺分页和 status 等筛选。改 `list_tasks_route`:① **标准分页壳** `{page, page_size, count, results}`(响应键固定顺序,前端契约稳定);② **6 个 query 参数** —— `page`(default 1, ≥1 clamp)/ `page_size`(default 20,1100 clamp)/ `status` 单值 active|completed|abandoned(非法值静默忽略)/ `skill` 精确匹配 / `working_dir` **末段目录名**(后端自动拼 `workspace/users/<uid>/<name>` 比对,客户端不用知道完整 db form)/ `q` 走 PG `ILIKE '%q%'` 同时打 `name` + `description` 两列;③ 实现 select 出 conditions list 一把过 + 单 `COUNT(*)` + 单 `SELECT LIMIT/OFFSET`,无 N+1。**dev SPA** 改 `loadTaskList`:从老的"无分页 ?status=" 改成构造 URLSearchParams 传 page+filters;state 新增 `taskPage / taskPageSize / taskTotal`;新增 `renderPager()` 显示 `fromto / count (第 P/L 页)` + prev/next 按钮(`disabled` 边界态);筛选输入框(`#filter-q` `#filter-wd`)debounce 300ms 后 reset page=1 重拉;`#filter-wd` autocomplete 复用 `<datalist id="folders-datalist">`(focus 时 lazy 拉 `/v1/folders`)。task list pane 改成三段:① label + status select + 刷新 ② q 搜索 + 工作目录筛选 ③ pager(条件态显示)。**Smoke 12 case 全绿**:无 filter (count=25, page1=20条) / page=2 (count=25, 5条) / page_size clamp 500 → 100 / page=0 → 1 / status 单维 (10) / skill 单维 (10) / working_dir CJK '水泥申报' (10) / q 'AI' (10) / q CJK '废弃' (5) / status+skill 组合 (10) / status+skill 不命中 (0+[]) / 非法 status 静默 → 全集 25。新 envelope 验证:`list(data.keys()) == ['page','page_size','count','results']` 顺序固定。文档 DESIGN §7.2 路由签名 + RUN 路由表同步。
- **05-17 / 0003 schema:name + working_dir + skill 三件套(去掉 task_dir / mode 旧名)**:用户反馈"name 应该自动 / 可改;现在的 name 其实是工作目录(可建可选)"——也就是要把任务标识和工作目录解耦。同时观察到 `mode` 命名抽象,跟项目 `skills/` 注册表对不上,顺手改 `skill`。**alembic 0003**:用户授权清表(`TRUNCATE tasks CASCADE`)+ `task_dir → working_dir` + `mode → skill` + 加 `name TEXT NOT NULL`(空表上 NOT NULL 不需要 backfill)。**ORM** `Task` 三列同步;**TaskState** 加 `name` 字段、`task_dir → working_dir`、`mode → skill`;**ensure_local_task_row / upsert_task** 签名重排(name 必传 INSERT 路径);**check_no_subtask** ORM 引用 + 形参 `task_dir → working_dir`;**core/paths.py / export_docx.py / session.py** 同步刷;**main.py::build_agent** 重构:new task 必传 `name`(任务名)+ 可选 `working_dir`(留空 → fallback name 作目录),两者都过 `validate_task_name`;`working_dir_from_name` 取代 `task_dir_from_name`(纯路径派生);`resolve_task_id` 形参 `working_dir_name`;mkdir 在新建分支后 + check_no_subtask 后立即落盘;**meta dict** 多 `name` 字段、原 `task_dir`/`mode` 改 `working_dir`/`skill`。**CLI**:`chat --name <必填>` + `--working-dir <可选>` + `--skill <coding/ppt/...>`;`/new <name>` 自动复用当前 working_dir(取上层 task_dir 末段);`/new` 无参 → 自动 gen `新任务_HH-MM-SS`;`/status` 显示 name + skill + working_dir 全套;`/resume` 列表加 name + skill 两列。**web /v1**:`TaskCreateRequest` 字段 `name`(req)+ `working_dir`(opt) + `description` + `skill`;`TaskPatchRequest` 加 `name` + `skill`(去 `mode`);`create_task` working_dir 留空 fallback 用 name + mkdir + check_no_subtask;**新增 `GET /v1/folders`**(列 user 下 FS 非 dotfile 子目录 + 关联 task 计数 + 最后使用时间,sort `last_used desc, name asc`);`_load_task_dir → _load_working_dir`;`_task_dict` 返回 dict 加 `name` 字段、`task_dir → working_dir`、`mode → skill`;路径越界错文案 task_dir → working_dir。**dev SPA** modal:任务名 + 工作目录(配 `<datalist>` autocomplete 走 `/v1/folders`)+ skill + description 四字段;`hd-new` 打开 modal 时拉 folders;`nt-wd` 输入时实时提示"→ 复用已有目录 (N 个 task)" / "→ 新建目录 X" / "留空 → 用任务名 fallback";`renderTaskList` 主行从"working_dir 末段"改为 `t.name`(任务名优先),`📁 工作目录名`+ skill + description 走副行;`renderChatMeta` 同步把 name 顶头 + 📁 + skill + tid + desc + 计数。**Smoke**:9 case `/v1/tasks` POST 全绿(name+working_dir 双填 / 同 working_dir 二次共享 / 留空 fallback / name 缺 → 422 / name 非法 → 400 / working_dir 非法 → 400 / GET 列表含三新字段 / PATCH name+skill / `/v1/folders` 含 水泥申报 n_tasks=2 last_used 非空);CLI build_agent 4 case(new 双填 / append 后 reloaded 字段对 / resume 还原 / fallback)。文档:DESIGN §3.1 目录树注释 / §3.6 三件套字段语义 + 创建语义 / §7.2 POST + PATCH + DELETE + 新 GET /v1/folders / §7.4 schema 块 + index 同步;PROGRESS 单条记录;RUN 待刷。 - **05-17 / 0003 schema:name + working_dir + skill 三件套(去掉 task_dir / mode 旧名)**:用户反馈"name 应该自动 / 可改;现在的 name 其实是工作目录(可建可选)"——也就是要把任务标识和工作目录解耦。同时观察到 `mode` 命名抽象,跟项目 `skills/` 注册表对不上,顺手改 `skill`。**alembic 0003**:用户授权清表(`TRUNCATE tasks CASCADE`)+ `task_dir → working_dir` + `mode → skill` + 加 `name TEXT NOT NULL`(空表上 NOT NULL 不需要 backfill)。**ORM** `Task` 三列同步;**TaskState** 加 `name` 字段、`task_dir → working_dir`、`mode → skill`;**ensure_local_task_row / upsert_task** 签名重排(name 必传 INSERT 路径);**check_no_subtask** ORM 引用 + 形参 `task_dir → working_dir`;**core/paths.py / export_docx.py / session.py** 同步刷;**main.py::build_agent** 重构:new task 必传 `name`(任务名)+ 可选 `working_dir`(留空 → fallback name 作目录),两者都过 `validate_task_name`;`working_dir_from_name` 取代 `task_dir_from_name`(纯路径派生);`resolve_task_id` 形参 `working_dir_name`;mkdir 在新建分支后 + check_no_subtask 后立即落盘;**meta dict** 多 `name` 字段、原 `task_dir`/`mode` 改 `working_dir`/`skill`。**CLI**:`chat --name <必填>` + `--working-dir <可选>` + `--skill <coding/ppt/...>`;`/new <name>` 自动复用当前 working_dir(取上层 task_dir 末段);`/new` 无参 → 自动 gen `新任务_HH-MM-SS`;`/status` 显示 name + skill + working_dir 全套;`/resume` 列表加 name + skill 两列。**web /v1**:`TaskCreateRequest` 字段 `name`(req)+ `working_dir`(opt) + `description` + `skill`;`TaskPatchRequest` 加 `name` + `skill`(去 `mode`);`create_task` working_dir 留空 fallback 用 name + mkdir + check_no_subtask;**新增 `GET /v1/folders`**(列 user 下 FS 非 dotfile 子目录 + 关联 task 计数 + 最后使用时间,sort `last_used desc, name asc`);`_load_task_dir → _load_working_dir`;`_task_dict` 返回 dict 加 `name` 字段、`task_dir → working_dir`、`mode → skill`;路径越界错文案 task_dir → working_dir。**dev SPA** modal:任务名 + 工作目录(配 `<datalist>` autocomplete 走 `/v1/folders`)+ skill + description 四字段;`hd-new` 打开 modal 时拉 folders;`nt-wd` 输入时实时提示"→ 复用已有目录 (N 个 task)" / "→ 新建目录 X" / "留空 → 用任务名 fallback";`renderTaskList` 主行从"working_dir 末段"改为 `t.name`(任务名优先),`📁 工作目录名`+ skill + description 走副行;`renderChatMeta` 同步把 name 顶头 + 📁 + skill + tid + desc + 计数。**Smoke**:9 case `/v1/tasks` POST 全绿(name+working_dir 双填 / 同 working_dir 二次共享 / 留空 fallback / name 缺 → 422 / name 非法 → 400 / working_dir 非法 → 400 / GET 列表含三新字段 / PATCH name+skill / `/v1/folders` 含 水泥申报 n_tasks=2 last_used 非空);CLI build_agent 4 case(new 双填 / append 后 reloaded 字段对 / resume 还原 / fallback)。文档:DESIGN §3.1 目录树注释 / §3.6 三件套字段语义 + 创建语义 / §7.2 POST + PATCH + DELETE + 新 GET /v1/folders / §7.4 schema 块 + index 同步;PROGRESS 单条记录;RUN 待刷。
- **05-17 / files 面板 UX 让用户清楚"我在哪个项目里" + 修 root crumb bug**:用户反馈"web 右侧 files 看不到文件夹"——实际场景是用户建了 task name=水泥申报,FS `workspace/users/.../水泥申报/` 已建,但里面是空的,files 面板只显示"(空目录)"——用户混淆为"看不到 水泥申报 这个文件夹本身"。真因是 UI 没把"现在面板内部就是 水泥申报 的内容"说清楚。修两处:① 后端 `_enumerate_files`:`cur_rel == "."`(target == root)时不再追加一个无意义 "." crumb(原来 `if cur_rel:` 把 "." 当真值,会塞 `{label: ".", rel: "."}` 进 crumbs[1]);改为 `if cur_rel and cur_rel != "."`。② dev SPA `renderFiles`:`pane-head` 旁加 `<span id="files-proj">`(`muted small` 样式 + ellipsis),`textContent = "· " + projName`(取 `task_dir.split('/').filter(Boolean).pop()`);crumbs 第一格 label 从 "/" 替换为项目名(`projName`),整条路径直观为 `水泥申报 / 草稿 / draft.md`。`deleteCurrentTask` 清面板时也 reset `files-proj`。Smoke:root 路径 crumbs 长度 == 1(原 == 2);进 `水泥申报/草稿` 子目录 crumbs == 2 且第二格 label == "草稿"(CJK 透传 OK);GBK 控制台显示乱码确认是 stdout encoding 而非 PG 存储问题(`task_dir.encode('utf-8')` 字节正确 + codepoints 是 [0x6c34, 0x6ce5, 0x7533, 0x62a5])。 - **05-17 / files 面板 UX 让用户清楚"我在哪个项目里" + 修 root crumb bug**:用户反馈"web 右侧 files 看不到文件夹"——实际场景是用户建了 task name=水泥申报,FS `workspace/users/.../水泥申报/` 已建,但里面是空的,files 面板只显示"(空目录)"——用户混淆为"看不到 水泥申报 这个文件夹本身"。真因是 UI 没把"现在面板内部就是 水泥申报 的内容"说清楚。修两处:① 后端 `_enumerate_files`:`cur_rel == "."`(target == root)时不再追加一个无意义 "." crumb(原来 `if cur_rel:` 把 "." 当真值,会塞 `{label: ".", rel: "."}` 进 crumbs[1]);改为 `if cur_rel and cur_rel != "."`。② dev SPA `renderFiles`:`pane-head` 旁加 `<span id="files-proj">`(`muted small` 样式 + ellipsis),`textContent = "· " + projName`(取 `task_dir.split('/').filter(Boolean).pop()`);crumbs 第一格 label 从 "/" 替换为项目名(`projName`),整条路径直观为 `水泥申报 / 草稿 / draft.md`。`deleteCurrentTask` 清面板时也 reset `files-proj`。Smoke:root 路径 crumbs 长度 == 1(原 == 2);进 `水泥申报/草稿` 子目录 crumbs == 2 且第二格 label == "草稿"(CJK 透传 OK);GBK 控制台显示乱码确认是 stdout encoding 而非 PG 存储问题(`task_dir.encode('utf-8')` 字节正确 + codepoints 是 [0x6c34, 0x6ce5, 0x7533, 0x62a5])。
- **05-17 / task 硬删 API + dev SPA delete 按钮 + 文件 per-row 删**:用户反馈缺 task 删除入口(原本只有 PATCH status=abandoned 软态)。新增 `DELETE /v1/tasks/{id}`:user_id ownership 校验(跨 user → 404 不暴露存在性)+ DB 行 DELETE(messages / runs CASCADE)+ **FS task_dir 不动**(同 name 多 task 共享语义下"最后一个 task 删了顺便 rmtree"的判断有边界 case,易擦用户素材;让用户经 /files/delete 或文件管理器显式清更安全)。返 204 No Content。dev SPA:chat 面板 head 加 `btn-delete-task`(`small danger` 样式,title 说明"清 DB 行 + messages,FS 文件不动"),`disabled` 仅在没选 task 时 true —— 任何 status 都可删(active / completed / abandoned 不限,confirm 弹窗带项目名 + 消息条数二次确认)。点击后清空 chat 面板 + files 面板 + state reset + reload task list。file 面板 per-row 加红 `×` 按钮(`del-file` class),click stopPropagation 不触发行的下载/进目录;调原有 `POST /v1/tasks/{id}/files/delete`(API 没改,非空目录仍 400 拒,弹错文)。Smoke 6 case 全绿:happy 路径 204 + DB 行 gone + FS `should_survive.txt` 保留 / messages CASCADE 真生效(idx=1 user msg INSERT 后 DELETE task → messages count = 0)/ ghost UUID 404 / 非 UUID 字符串 404 / 跨 user delete 404 + 原 user 仍可删 + 原 task 行未被擦 / 无 token 401。文档:DESIGN §7.2 资源模型加 `DELETE /v1/tasks/{id}` 行 + 注释 "FS task_dir 保留"。 - **05-17 / task 硬删 API + dev SPA delete 按钮 + 文件 per-row 删**:用户反馈缺 task 删除入口(原本只有 PATCH status=abandoned 软态)。新增 `DELETE /v1/tasks/{id}`:user_id ownership 校验(跨 user → 404 不暴露存在性)+ DB 行 DELETE(messages / runs CASCADE)+ **FS task_dir 不动**(同 name 多 task 共享语义下"最后一个 task 删了顺便 rmtree"的判断有边界 case,易擦用户素材;让用户经 /files/delete 或文件管理器显式清更安全)。返 204 No Content。dev SPA:chat 面板 head 加 `btn-delete-task`(`small danger` 样式,title 说明"清 DB 行 + messages,FS 文件不动"),`disabled` 仅在没选 task 时 true —— 任何 status 都可删(active / completed / abandoned 不限,confirm 弹窗带项目名 + 消息条数二次确认)。点击后清空 chat 面板 + files 面板 + state reset + reload task list。file 面板 per-row 加红 `×` 按钮(`del-file` class),click stopPropagation 不触发行的下载/进目录;调原有 `POST /v1/tasks/{id}/files/delete`(API 没改,非空目录仍 400 拒,弹错文)。Smoke 6 case 全绿:happy 路径 204 + DB 行 gone + FS `should_survive.txt` 保留 / messages CASCADE 真生效(idx=1 user msg INSERT 后 DELETE task → messages count = 0)/ ghost UUID 404 / 非 UUID 字符串 404 / 跨 user delete 404 + 原 user 仍可删 + 原 task 行未被擦 / 无 token 401。文档:DESIGN §7.2 资源模型加 `DELETE /v1/tasks/{id}` 行 + 注释 "FS task_dir 保留"。

2
RUN.md
View File

@ -138,7 +138,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` | body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 | | `POST /v1/auth/login` | body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 |
| `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?status=&limit=` | 列当前 user 的任务,`updated_at` 降序 | 必填 | | `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`page` 1-based,`page_size` 1100;`working_dir` 末段名;`q` ILIKE name+desc;`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 | 必填 |
| `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}` 部分更新;active 走 CLI 切回 | 必填 | | `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}` 部分更新;active 走 CLI 切回 | 必填 |
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE),FS working_dir 保留 | 必填 | | `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE),FS working_dir 保留 | 必填 |

View File

@ -45,6 +45,8 @@ from .sinks import WebEventSink
STATUS_FILTERS = ("active", "completed", "abandoned") STATUS_FILTERS = ("active", "completed", "abandoned")
STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI) STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI)
ORDER_FIELDS = ("created_at", "updated_at", "name", "status")
ORDER_DEFAULT = "-created_at"
# ─────────────────────────── helpers ─────────────────────────── # ─────────────────────────── helpers ───────────────────────────
@ -58,6 +60,31 @@ def _iso(dt: Optional[Any]) -> Optional[str]:
return dt.isoformat() if dt else None return dt.isoformat() if dt else None
def _parse_ordering(s: Optional[str]) -> list:
"""DRF 风格 `ordering` 解析:逗号分隔多字段,`-` 前缀代表 desc。
allowlist `ORDER_FIELDS`;非法字段静默丢弃全部非法或空串 `ORDER_DEFAULT`(`-created_at`)
返回 sqlalchemy `order_by` 列表(可直接 `*expand`)
"""
spec = (s or "").strip() or ORDER_DEFAULT
cols = []
for part in spec.split(","):
p = part.strip()
if not p:
continue
asc = True
if p.startswith("-"):
asc = False
p = p[1:]
if p in ORDER_FIELDS:
col = getattr(Task, p)
cols.append(col.asc() if asc else col.desc())
if not cols:
# 用户传了全无效字段 → fallback 默认
cols = [Task.created_at.desc()]
return cols
def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict: def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict:
"""Task ORM row → API JSON dict。""" """Task ORM row → API JSON dict。"""
d = { d = {
@ -359,20 +386,63 @@ def create_app() -> FastAPI:
@app.get("/v1/tasks", tags=["tasks"]) @app.get("/v1/tasks", tags=["tasks"])
def list_tasks_route( def list_tasks_route(
page: int = 1,
page_size: int = 20,
status: Optional[str] = None, status: Optional[str] = None,
limit: int = 50, skill: Optional[str] = None,
working_dir: Optional[str] = None,
q: Optional[str] = None,
ordering: Optional[str] = None,
user_id: UUID = Depends(require_user), user_id: UUID = Depends(require_user),
): ):
"""列出当前 user 的 task,`updated_at` 降序。""" """列出当前 user 的 task,分页 + 多维筛选 + 排序。
if status and status not in STATUS_FILTERS:
status = None - `page` 1(1-based);`page_size` 1100(超界 clamp)
with session_scope() as s: - `status` active/completed/abandoned;非法值静默忽略
q = select(Task).where(Task.user_id == user_id).order_by(Task.updated_at.desc()) - `skill` 精确匹配(空忽略)
- `working_dir` 末段目录名( `水泥申报`);后端自动拼 `workspace/users/<uid>/<name>` 比对
- `q` 模糊搜索 name + description(ILIKE,大小写不敏感)
- `ordering` DRF 风格,逗号分隔,`-field` 倒序;allowlist `created_at/updated_at/name/status`;
非法字段静默忽略;**默认 `-created_at`**(创建时间倒序)
返回标准分页壳 `{page, page_size, count, results}` count 供前端算总页数
"""
# clamp + sanitize
page = max(1, page)
page_size = max(1, min(page_size, 100))
status = status if status in STATUS_FILTERS else None
skill = (skill or "").strip() or None
wd_name = (working_dir or "").strip() or None
q_text = (q or "").strip() or None
# 组装 WHERE
conditions = [Task.user_id == user_id]
if status: if status:
q = q.where(Task.status == status) conditions.append(Task.status == status)
rows = s.execute(q.limit(limit)).scalars().all() if skill:
conditions.append(Task.skill == skill)
if wd_name:
# 末段 → 完整 db form。同 working_dir 多 task 共享时,这是命中入口。
wd_db = f"workspace/users/{user_id}/{wd_name}"
conditions.append(Task.working_dir == wd_db)
if q_text:
pat = f"%{q_text}%"
conditions.append(Task.name.ilike(pat) | Task.description.ilike(pat))
offset = (page - 1) * page_size
with session_scope() as s:
cnt = s.execute(
select(func.count()).select_from(Task).where(*conditions)
).scalar_one() or 0
rows = s.execute(
select(Task).where(*conditions)
.order_by(*_parse_ordering(ordering))
.limit(page_size).offset(offset)
).scalars().all()
tids = [r.task_id for r in rows] tids = [r.task_id for r in rows]
counts = ( msg_counts = (
dict(s.execute( dict(s.execute(
select(Message.task_id, func.count()) select(Message.task_id, func.count())
.where(Message.task_id.in_(tids)) .where(Message.task_id.in_(tids))
@ -380,11 +450,15 @@ def create_app() -> FastAPI:
).all()) ).all())
if tids else {} if tids else {}
) )
return { return {
"tasks": [ "page": page,
_task_dict(r, n_messages=counts.get(r.task_id, 0)) "page_size": page_size,
"count": int(cnt),
"results": [
_task_dict(r, n_messages=msg_counts.get(r.task_id, 0))
for r in rows for r in rows
] ],
} }
@app.get("/v1/tasks/{task_id}", tags=["tasks"]) @app.get("/v1/tasks/{task_id}", tags=["tasks"])

View File

@ -266,9 +266,32 @@
<option value="completed">completed</option> <option value="completed">completed</option>
<option value="abandoned">abandoned</option> <option value="abandoned">abandoned</option>
</select> </select>
<button id="btn-refresh-tasks" class="small"></button> <button id="btn-refresh-tasks" class="small" title="刷新"></button>
</div>
<div class="pane-head" style="border-top: 1px solid var(--border); gap: 6px;">
<input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:1; padding: 3px 6px;" />
<input id="filter-wd" list="folders-datalist" class="small" placeholder="工作目录" style="flex:1; padding: 3px 6px;" />
</div>
<div class="pane-head" style="border-top: 1px solid var(--border); gap: 6px;">
<span class="small muted" style="white-space:nowrap;">排序</span>
<select id="filter-order" class="small" style="flex:1; width:auto;">
<option value="-created_at">创建时间 ↓(新→旧)</option>
<option value="created_at">创建时间 ↑(旧→新)</option>
<option value="-updated_at">更新时间 ↓</option>
<option value="updated_at">更新时间 ↑</option>
<option value="name">名称 A→Z</option>
<option value="-name">名称 Z→A</option>
<option value="status,-created_at">状态分组(同状态按时间倒序)</option>
</select>
</div> </div>
<div id="task-list"><div class="empty">loading…</div></div> <div id="task-list"><div class="empty">loading…</div></div>
<div id="task-pager" class="pane-head" style="border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); justify-content: space-between; display: none;">
<span id="pager-info"></span>
<span style="display:flex; gap: 4px;">
<button id="btn-prev-page" class="small"></button>
<button id="btn-next-page" class="small"></button>
</span>
</div>
</div> </div>
<!-- middle --> <!-- middle -->
@ -340,6 +363,10 @@ const state = {
taskMeta: null, taskMeta: null,
filesPath: "", filesPath: "",
evtSrc: null, evtSrc: null,
// task list 分页 + 筛选
taskPage: 1,
taskPageSize: 20,
taskTotal: 0,
}; };
// ───── helpers ───── // ───── helpers ─────
@ -465,17 +492,53 @@ function enterApp() {
} }
async function loadTaskList() { async function loadTaskList() {
const filter = $("filter-status").value; const params = new URLSearchParams();
const qs = filter ? "?status=" + filter : ""; params.set("page", state.taskPage);
params.set("page_size", state.taskPageSize);
const st = $("filter-status").value;
if (st) params.set("status", st);
const q = $("filter-q").value.trim();
if (q) params.set("q", q);
const wd = $("filter-wd").value.trim();
if (wd) params.set("working_dir", wd);
const ord = $("filter-order").value;
if (ord && ord !== "-created_at") params.set("ordering", ord); // 默认值不发送,URL 更干净
try { try {
const data = await api("GET", "/v1/tasks" + qs); const data = await api("GET", "/v1/tasks?" + params.toString());
renderTaskList(data.tasks); state.taskTotal = data.count || 0;
state.taskPage = data.page || 1;
state.taskPageSize = data.page_size || state.taskPageSize;
renderTaskList(data.results || []);
renderPager();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
$("task-list").innerHTML = `<div class="empty">load failed: ${escapeHtml(e.message)}</div>`; $("task-list").innerHTML = `<div class="empty">load failed: ${escapeHtml(e.message)}</div>`;
$("task-pager").style.display = "none";
} }
} }
function renderPager() {
const total = state.taskTotal;
const ps = state.taskPageSize;
const page = state.taskPage;
const lastPage = Math.max(1, Math.ceil(total / ps));
if (total === 0) {
$("task-pager").style.display = "none";
return;
}
$("task-pager").style.display = "flex";
const from = (page - 1) * ps + 1;
const to = Math.min(page * ps, total);
$("pager-info").textContent = `${from}${to} / ${total} (第 ${page}/${lastPage} 页)`;
$("btn-prev-page").disabled = page <= 1;
$("btn-next-page").disabled = page >= lastPage;
}
function resetPageAndReload() {
state.taskPage = 1;
loadTaskList();
}
function renderTaskList(tasks) { function renderTaskList(tasks) {
if (!tasks.length) { if (!tasks.length) {
$("task-list").innerHTML = `<div class="empty">(no tasks)</div>`; $("task-list").innerHTML = `<div class="empty">(no tasks)</div>`;
@ -508,8 +571,29 @@ function renderTaskList(tasks) {
}); });
} }
$("filter-status").onchange = loadTaskList; // 任何筛选 / 排序变化都 reset page=1 重拉;刷新按钮保持当前页;翻页只动 page
$("filter-status").onchange = resetPageAndReload;
$("filter-order").onchange = resetPageAndReload;
$("btn-refresh-tasks").onclick = loadTaskList; $("btn-refresh-tasks").onclick = loadTaskList;
$("btn-prev-page").onclick = () => { if (state.taskPage > 1) { state.taskPage--; loadTaskList(); } };
$("btn-next-page").onclick = () => {
const lastPage = Math.max(1, Math.ceil(state.taskTotal / state.taskPageSize));
if (state.taskPage < lastPage) { state.taskPage++; loadTaskList(); }
};
// 搜索 / 工作目录筛选:debounce 300ms,避免每个字符都打 API
let _filterDebounce = null;
function scheduleFilter() {
clearTimeout(_filterDebounce);
_filterDebounce = setTimeout(resetPageAndReload, 300);
}
$("filter-q").addEventListener("input", scheduleFilter);
$("filter-wd").addEventListener("input", scheduleFilter);
// 工作目录输入框打开 enterApp 时拉一次 datalist(modal 也复用同一 list)
async function ensureFoldersLoaded() {
if ($("folders-datalist").children.length === 0) await loadFolderSuggestions();
}
$("filter-wd").addEventListener("focus", ensureFoldersLoaded);
// ───── select task ───── // ───── select task ─────
async function selectTask(tid) { async function selectTask(tid) {