diff --git a/PROGRESS.md b/PROGRESS.md index 321876d..aa72c4d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 -最后更新:2026-05-20(dev SPA 任务列表行 meta 加最近操作时间相对显示 + 新建弹框工作目录改下拉) +最后更新:2026-05-20(dev SPA 任务列表换滚动加载 — 删 pager bar / 加 IntersectionObserver sentinel / 共 N 个 count badge) --- @@ -23,6 +23,7 @@ ### 2026-05-20 +- **dev SPA 左侧任务列表 pager bar → 滚动加载(ChatGPT/DeepSeek 范式)**:用户嫌底部分页 chrome 别扭。删 `#task-pager`(prev/next/info bar)+ `renderPager` + `resetPageAndReload`,改 `IntersectionObserver` on `#task-sentinel`(`#task-list` 后兄弟,`min-height:1px`),root = `#pane-left`(整 pane 是 scroll 容器,`.pane{overflow:auto}`)+ `rootMargin: 200px 0px` 提前 200px 触发体感更顺。`loadTaskList({append=false})` 双语义:reset 抢占式(filters / refresh / 写操作后,page=1 替换);`append=true` 仅 sentinel 触发,page+1 拼到底,受 `taskLoading || !taskHasMore` 互斥。**并发模型**:用 `_taskLoadSeq` token 让 reset 永远抢占 — 收到响应时若 `mySeq !== _taskLoadSeq` 整段 short-circuit return(也含 finally 的 `taskLoading=false`,避免 reset 在途时被 stale append 错误解锁),解决"append 在途时改筛选被丢"的旧 bug。**新增**:① 首 pane-head 加 `共 N 个` muted 小字补偿总数显示;② sentinel 文案三态(加载中… / — 已加载全部 — / 空字符串);③ `renderTaskList(tasks, append)` append 走 `
.innerHTML` 临时容器 + `appendChild` 不 clobber 已渲染行,事件 handler 只挂新行。**没动**:`/v1/tasks` 后端(本来就是标准分页 `{page,page_size,count,results}`)、page_size=20 默认、所有 7 处 `loadTaskList()` 旧调用点(默认 reset 语义与原行为等价)。**Tradeoff**:失"跳到第 N 页"但筛选 / 搜索 / 排序 + 滚动覆盖所有导航场景;失"当前页位置"但写操作后跳回顶端在 zcbot 任务规模(几十~几百)体感自然。 - **dev SPA 左侧任务列表行加「最近操作时间」**:用户要"显示最新操作时间"。`renderTaskList` 行 meta 区(badge / skill / N 条 / N tok / id-slice)在 id-slice 之前插一个 ``,文案用新加的 `fmtTimeAgo(iso)` 相对时间 helper:`<60s`→刚刚 / `<1h`→N 分钟前 / 同日→N 小时前 / 昨日→昨天 HH:MM / 同年→MM-DD HH:MM / 跨年→YYYY-MM-DD,`title=` hover 出完整 `fmtTime` locale 串。`margin-left:auto` 从 id-slice 挪到时间 span(让两者一起靠右,中间 8px `.meta gap` 自然分隔)。字段用 `updated_at`(任务任何写操作 — 改名 / 新消息 / 状态切 — 都会更新,贴合"最新操作"语义),`/v1/tasks` payload 早已包含,后端零改。**没动**:左 pane 列表默认排序仍 `-created_at`(用户改排序顺序时另说);id-slice 保留(调试参考)。 - **dev SPA 新建任务弹框「工作目录」从 input + datalist 改 `` autocomplete 改 `` 输入新目录名 + autofocus,提交时 `working_dir = sel === "__new__" ? nt-wd-new.value : sel`。hint 区改 `updateWdHint()` 三分支(新建 / 留空 / 复用),change + new-input + name-input 三事件触发。`` 留在 modal 内但不再被它消费,**只供左 pane 顶部 `#filter-wd` 筛选 autocomplete**(datalist 按 id 引用,DOM 位置无关);`loadFolderSuggestions()` 同次拉取灌两边。**没动**:`/v1/folders` API、提交 body 形态(仍 `working_dir: string`,空串语义不变 → 后端 fallback 用任务名)、左 pane filter-wd 仍用 input + datalist(用户只点名"任务弹框")、DESIGN / RUN。**Tradeoff**:纯 select 实现最直接但会失"新名则新建",改两段式(select 含 `+ 新建…`,触发后展开 text input)保留所有原能力。 - **dev SPA 主页轻量美化(纯 CSS / HTML,不动 JS / 路由)**:用户要"简洁美化主页"。改四处:① header 从裸 "zcbot" 文字 → brand wrapper(24px 红渐变 "Z" logo + 标题字号 14→15 + letter-spacing + 顶栏 1px 极淡阴影),沿用登录页 brand 视觉但缩小;② 左 pane 三行 pane-head(任务标签/搜索/排序)用 `#pane-left .pane-head + .pane-head` 选择器把 filter / sort 子行换白底 + `--border-soft #ececec` 分隔,弱化为子层级,把两条 inline `border-top` 顺手去掉(与新 `border-bottom` 重叠会出双线);③ 顶栏 4 个语义按钮(完成/导出/废弃/删除)+ 选入弹框的复制/移动按钮从"常态彩边 + hover 加底色"改"常态中性 + hover 一次性上语义色(color + border + bg)",给 button 基础类加 transition 让色变平滑(沿用现有 `button.danger` 的同款 hover-only 范式);④ 圆角统一:button / input / textarea / select / floating-menu / .msg 4→6,三个 modal 卡片 6→8 + 阴影 `0 8px 24px → 0 12px 32px` 略深显悬浮感。**没动**:布局 / 交互逻辑 / 任何 JS / 后端 / DESIGN(纯视觉)/ RUN(无对外接口变化);dd-item 菜单的语义色保留(菜单内本来就靠色区分动作类型,不属于"顶栏中性"范畴)。 diff --git a/web/static/dev.html b/web/static/dev.html index a615ad4..da39285 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -550,6 +550,7 @@
任务 +
加载中…
- +
@@ -705,10 +700,13 @@ const state = { filesPath: "", evtSrc: null, streaming: false, // 当前是否在流式中;true 时显示 stop 按钮 - // task list 分页 + 筛选 - taskPage: 1, + // task list 滚动加载 + 筛选 + taskPage: 0, // 已加载到的最后一页(0 = 未加载) taskPageSize: 20, taskTotal: 0, + taskLoaded: 0, // 已渲染条数(用于 has-more 判断) + taskLoading: false, // 在途请求标记,防 observer 重复触发 + taskHasMore: true, // 模型清单(GET /v1/models 一次缓存):新建对话框 + 顶栏切换下拉 + 历史小标显示名都用 models: [], }; @@ -963,9 +961,15 @@ async function loadModels() { } } -async function loadTaskList() { +// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发 +// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应 +let _taskLoadSeq = 0; +async function loadTaskList({ append = false } = {}) { + if (append && (state.taskLoading || !state.taskHasMore)) return; + const mySeq = ++_taskLoadSeq; + const nextPage = append ? state.taskPage + 1 : 1; const params = new URLSearchParams(); - params.set("page", state.taskPage); + params.set("page", nextPage); params.set("page_size", state.taskPageSize); const st = $("filter-status").value; if (st) params.set("status", st); @@ -975,49 +979,52 @@ async function loadTaskList() { if (wd) params.set("working_dir", wd); const ord = $("filter-order").value; if (ord && ord !== "-created_at") params.set("ordering", ord); // 默认值不发送,URL 更干净 + state.taskLoading = true; + setSentinel(append ? "加载中…" : ""); try { const data = await api("GET", "/v1/tasks?" + params.toString()); + if (mySeq !== _taskLoadSeq) return; // 已被更新的请求 supersede,丢弃 state.taskTotal = data.count || 0; - state.taskPage = data.page || 1; + state.taskPage = data.page || nextPage; state.taskPageSize = data.page_size || state.taskPageSize; - renderTaskList(data.results || []); - renderPager(); + const results = data.results || []; + if (!append) state.taskLoaded = 0; + state.taskLoaded += results.length; + state.taskHasMore = state.taskLoaded < state.taskTotal; + renderTaskList(results, append); + renderTaskCount(); } catch (e) { + if (mySeq !== _taskLoadSeq) return; if (e.status === 401) { logout(); return; } - $("task-list").innerHTML = `
加载失败:${escapeHtml(e.message)}
`; - $("task-pager").style.display = "none"; + if (!append) { + $("task-list").innerHTML = `
加载失败:${escapeHtml(e.message)}
`; + state.taskHasMore = false; + } + setSentinel(`加载失败:${e.message}`); + } finally { + if (mySeq === _taskLoadSeq) state.taskLoading = false; } } -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 renderTaskCount() { + $("task-count").textContent = state.taskTotal > 0 ? `共 ${state.taskTotal} 个` : ""; + if (state.taskTotal === 0) setSentinel(""); + else if (!state.taskHasMore) setSentinel(state.taskPage > 1 ? "— 已加载全部 —" : ""); + else setSentinel(""); // 还有更多 → 留空,observer 触发时再填"加载中" } -function resetPageAndReload() { - state.taskPage = 1; - loadTaskList(); +function setSentinel(text) { + $("task-sentinel").textContent = text || ""; } -function renderTaskList(tasks) { - state.tasksById = {}; +function renderTaskList(tasks, append = false) { + if (!append) state.tasksById = {}; for (const t of tasks) state.tasksById[t.task_id] = t; - if (!tasks.length) { + if (!append && !tasks.length) { $("task-list").innerHTML = `
(暂无任务)
`; return; } + if (append && !tasks.length) return; // 末页空 batch,不动 DOM const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" }; const html = tasks.map((t) => { const active = state.taskId === t.task_id ? " active" : ""; @@ -1045,20 +1052,32 @@ function renderTaskList(tasks) {
`; }).join(""); - $("task-list").innerHTML = html; - $("task-list").querySelectorAll(".task-row").forEach((el) => { + const listEl = $("task-list"); + let newRows; + if (append) { + const tmp = document.createElement("div"); + tmp.innerHTML = html; + newRows = Array.from(tmp.children); + newRows.forEach((el) => listEl.appendChild(el)); + } else { + listEl.innerHTML = html; + newRows = Array.from(listEl.querySelectorAll(".task-row")); + } + newRows.forEach((el) => { + if (!el.classList || !el.classList.contains("task-row")) return; el.onclick = (e) => { if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中 selectTask(el.dataset.tid); }; - }); - $("task-list").querySelectorAll(".task-menu").forEach((btn) => { - btn.onclick = (e) => { - e.stopPropagation(); - const t = state.tasksById[btn.dataset.tid]; - if (!t) return; - showMenu(btn, taskMenuItems(t)); - }; + const btn = el.querySelector(".task-menu"); + if (btn) { + btn.onclick = (e) => { + e.stopPropagation(); + const t = state.tasksById[btn.dataset.tid]; + if (!t) return; + showMenu(btn, taskMenuItems(t)); + }; + } }); } @@ -1077,24 +1096,28 @@ function taskMenuItems(t) { ]; } -// 任何筛选 / 排序变化都 reset page=1 重拉;刷新按钮保持当前页;翻页只动 page -$("filter-status").onchange = resetPageAndReload; -$("filter-order").onchange = resetPageAndReload; -$("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(); } -}; +// 筛选 / 排序 / 刷新 一律 reset(loadTaskList 默认 append=false);追加由 sentinel observer 触发 +$("filter-status").onchange = () => loadTaskList(); +$("filter-order").onchange = () => loadTaskList(); +$("btn-refresh-tasks").onclick = () => loadTaskList(); // 搜索 / 工作目录筛选:debounce 300ms,避免每个字符都打 API let _filterDebounce = null; function scheduleFilter() { clearTimeout(_filterDebounce); - _filterDebounce = setTimeout(resetPageAndReload, 300); + _filterDebounce = setTimeout(() => loadTaskList(), 300); } $("filter-q").addEventListener("input", scheduleFilter); $("filter-wd").addEventListener("input", scheduleFilter); + +// 滚动加载:左 pane 整体是 scroll 容器(.pane{overflow:auto}),用 #pane-left 作 root +// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖) +const _taskScrollObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) { + loadTaskList({ append: true }); + } +}, { root: $("pane-left"), rootMargin: "200px 0px" }); +_taskScrollObserver.observe($("task-sentinel")); // 工作目录输入框打开 enterApp 时拉一次 datalist(modal 也复用同一 list) async function ensureFoldersLoaded() { if ($("folders-datalist").children.length === 0) await loadFolderSuggestions();