ui(dev SPA): 任务列表 pager bar → 滚动加载(IntersectionObserver sentinel)

- 删 #task-pager / renderPager / resetPageAndReload / btn-prev|next 三件套
- 加 #task-sentinel + IO root=#pane-left + rootMargin 200px 提前触发
- loadTaskList({append}) 双语义: reset 抢占(_taskLoadSeq 丢弃过期响应) / append 互斥
- renderTaskList(append=true) 不 clobber 已渲染行, 事件 handler 只挂新行
- 首 pane-head 加 "共 N 个" 总数小字补偿丢失的 pager-info

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-20 11:09:46 +08:00
parent 97d838a9ec
commit afebf25d79
2 changed files with 82 additions and 58 deletions

View File

@ -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 加 `<span id="task-count">共 N 个</span>` muted 小字补偿总数显示;② sentinel 文案三态(加载中… / — 已加载全部 — / 空字符串);③ `renderTaskList(tasks, append)` append 走 `<div>.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 之前插一个 `<span class="muted" style="margin-left:auto;">`,文案用新加的 `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 改 `<select>` 下拉**:用户要"做成下拉选择"。原 `<input list="folders-datalist">` autocomplete 改 `<select id="nt-wd-sel">`,选项 = `(留空 · 用任务名作目录)` + 既有目录(`name — N 个任务` / `空目录`) + `+ 新建目录…` sentinel(`__new__`)。选 `__new__` → 显示备用 `<input id="nt-wd-new">` 输入新目录名 + autofocus,提交时 `working_dir = sel === "__new__" ? nt-wd-new.value : sel`。hint 区改 `updateWdHint()` 三分支(新建 / 留空 / 复用),change + new-input + name-input 三事件触发。`<datalist id="folders-datalist">` 留在 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 菜单的语义色保留(菜单内本来就靠色区分动作类型,不属于"顶栏中性"范畴)。

View File

@ -550,6 +550,7 @@
<div class="pane" id="pane-left">
<div class="pane-head">
<span class="label">任务</span>
<span class="small muted" id="task-count" style="font-size:11px;"></span>
<span class="spacer"></span>
<select id="filter-status" class="small" style="width: auto;">
<option value="">(全部)</option>
@ -576,13 +577,7 @@
</select>
</div>
<div id="task-list"><div class="empty">加载中…</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 id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div>
</div>
<!-- middle -->
@ -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 = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
$("task-pager").style.display = "none";
if (!append) {
$("task-list").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
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 = `<div class="empty">(暂无任务)</div>`;
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) {
</div>
`;
}).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();