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:
parent
97d838a9ec
commit
afebf25d79
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。
|
> 配合 `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
|
### 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 左侧任务列表行加「最近操作时间」**:用户要"显示最新操作时间"。`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 新建任务弹框「工作目录」从 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 菜单的语义色保留(菜单内本来就靠色区分动作类型,不属于"顶栏中性"范畴)。
|
- **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 菜单的语义色保留(菜单内本来就靠色区分动作类型,不属于"顶栏中性"范畴)。
|
||||||
|
|
|
||||||
|
|
@ -550,6 +550,7 @@
|
||||||
<div class="pane" id="pane-left">
|
<div class="pane" id="pane-left">
|
||||||
<div class="pane-head">
|
<div class="pane-head">
|
||||||
<span class="label">任务</span>
|
<span class="label">任务</span>
|
||||||
|
<span class="small muted" id="task-count" style="font-size:11px;"></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<select id="filter-status" class="small" style="width: auto;">
|
<select id="filter-status" class="small" style="width: auto;">
|
||||||
<option value="">(全部)</option>
|
<option value="">(全部)</option>
|
||||||
|
|
@ -576,13 +577,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="task-list"><div class="empty">加载中…</div></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;">
|
<div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div>
|
||||||
<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 -->
|
||||||
|
|
@ -705,10 +700,13 @@ const state = {
|
||||||
filesPath: "",
|
filesPath: "",
|
||||||
evtSrc: null,
|
evtSrc: null,
|
||||||
streaming: false, // 当前是否在流式中;true 时显示 stop 按钮
|
streaming: false, // 当前是否在流式中;true 时显示 stop 按钮
|
||||||
// task list 分页 + 筛选
|
// task list 滚动加载 + 筛选
|
||||||
taskPage: 1,
|
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
|
||||||
taskPageSize: 20,
|
taskPageSize: 20,
|
||||||
taskTotal: 0,
|
taskTotal: 0,
|
||||||
|
taskLoaded: 0, // 已渲染条数(用于 has-more 判断)
|
||||||
|
taskLoading: false, // 在途请求标记,防 observer 重复触发
|
||||||
|
taskHasMore: true,
|
||||||
// 模型清单(GET /v1/models 一次缓存):新建对话框 + 顶栏切换下拉 + 历史小标显示名都用
|
// 模型清单(GET /v1/models 一次缓存):新建对话框 + 顶栏切换下拉 + 历史小标显示名都用
|
||||||
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();
|
const params = new URLSearchParams();
|
||||||
params.set("page", state.taskPage);
|
params.set("page", nextPage);
|
||||||
params.set("page_size", state.taskPageSize);
|
params.set("page_size", state.taskPageSize);
|
||||||
const st = $("filter-status").value;
|
const st = $("filter-status").value;
|
||||||
if (st) params.set("status", st);
|
if (st) params.set("status", st);
|
||||||
|
|
@ -975,49 +979,52 @@ async function loadTaskList() {
|
||||||
if (wd) params.set("working_dir", wd);
|
if (wd) params.set("working_dir", wd);
|
||||||
const ord = $("filter-order").value;
|
const ord = $("filter-order").value;
|
||||||
if (ord && ord !== "-created_at") params.set("ordering", ord); // 默认值不发送,URL 更干净
|
if (ord && ord !== "-created_at") params.set("ordering", ord); // 默认值不发送,URL 更干净
|
||||||
|
state.taskLoading = true;
|
||||||
|
setSentinel(append ? "加载中…" : "");
|
||||||
try {
|
try {
|
||||||
const data = await api("GET", "/v1/tasks?" + params.toString());
|
const data = await api("GET", "/v1/tasks?" + params.toString());
|
||||||
|
if (mySeq !== _taskLoadSeq) return; // 已被更新的请求 supersede,丢弃
|
||||||
state.taskTotal = data.count || 0;
|
state.taskTotal = data.count || 0;
|
||||||
state.taskPage = data.page || 1;
|
state.taskPage = data.page || nextPage;
|
||||||
state.taskPageSize = data.page_size || state.taskPageSize;
|
state.taskPageSize = data.page_size || state.taskPageSize;
|
||||||
renderTaskList(data.results || []);
|
const results = data.results || [];
|
||||||
renderPager();
|
if (!append) state.taskLoaded = 0;
|
||||||
|
state.taskLoaded += results.length;
|
||||||
|
state.taskHasMore = state.taskLoaded < state.taskTotal;
|
||||||
|
renderTaskList(results, append);
|
||||||
|
renderTaskCount();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (mySeq !== _taskLoadSeq) return;
|
||||||
if (e.status === 401) { logout(); return; }
|
if (e.status === 401) { logout(); return; }
|
||||||
$("task-list").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
if (!append) {
|
||||||
$("task-pager").style.display = "none";
|
$("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() {
|
function renderTaskCount() {
|
||||||
const total = state.taskTotal;
|
$("task-count").textContent = state.taskTotal > 0 ? `共 ${state.taskTotal} 个` : "";
|
||||||
const ps = state.taskPageSize;
|
if (state.taskTotal === 0) setSentinel("");
|
||||||
const page = state.taskPage;
|
else if (!state.taskHasMore) setSentinel(state.taskPage > 1 ? "— 已加载全部 —" : "");
|
||||||
const lastPage = Math.max(1, Math.ceil(total / ps));
|
else setSentinel(""); // 还有更多 → 留空,observer 触发时再填"加载中"
|
||||||
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() {
|
function setSentinel(text) {
|
||||||
state.taskPage = 1;
|
$("task-sentinel").textContent = text || "";
|
||||||
loadTaskList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTaskList(tasks) {
|
function renderTaskList(tasks, append = false) {
|
||||||
state.tasksById = {};
|
if (!append) state.tasksById = {};
|
||||||
for (const t of tasks) state.tasksById[t.task_id] = t;
|
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>`;
|
$("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (append && !tasks.length) return; // 末页空 batch,不动 DOM
|
||||||
const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" };
|
const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" };
|
||||||
const html = tasks.map((t) => {
|
const html = tasks.map((t) => {
|
||||||
const active = state.taskId === t.task_id ? " active" : "";
|
const active = state.taskId === t.task_id ? " active" : "";
|
||||||
|
|
@ -1045,20 +1052,32 @@ function renderTaskList(tasks) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join("");
|
}).join("");
|
||||||
$("task-list").innerHTML = html;
|
const listEl = $("task-list");
|
||||||
$("task-list").querySelectorAll(".task-row").forEach((el) => {
|
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) => {
|
el.onclick = (e) => {
|
||||||
if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中
|
if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中
|
||||||
selectTask(el.dataset.tid);
|
selectTask(el.dataset.tid);
|
||||||
};
|
};
|
||||||
});
|
const btn = el.querySelector(".task-menu");
|
||||||
$("task-list").querySelectorAll(".task-menu").forEach((btn) => {
|
if (btn) {
|
||||||
btn.onclick = (e) => {
|
btn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const t = state.tasksById[btn.dataset.tid];
|
const t = state.tasksById[btn.dataset.tid];
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
showMenu(btn, taskMenuItems(t));
|
showMenu(btn, taskMenuItems(t));
|
||||||
};
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1077,24 +1096,28 @@ function taskMenuItems(t) {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 任何筛选 / 排序变化都 reset page=1 重拉;刷新按钮保持当前页;翻页只动 page
|
// 筛选 / 排序 / 刷新 一律 reset(loadTaskList 默认 append=false);追加由 sentinel observer 触发
|
||||||
$("filter-status").onchange = resetPageAndReload;
|
$("filter-status").onchange = () => loadTaskList();
|
||||||
$("filter-order").onchange = resetPageAndReload;
|
$("filter-order").onchange = () => loadTaskList();
|
||||||
$("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
|
// 搜索 / 工作目录筛选:debounce 300ms,避免每个字符都打 API
|
||||||
let _filterDebounce = null;
|
let _filterDebounce = null;
|
||||||
function scheduleFilter() {
|
function scheduleFilter() {
|
||||||
clearTimeout(_filterDebounce);
|
clearTimeout(_filterDebounce);
|
||||||
_filterDebounce = setTimeout(resetPageAndReload, 300);
|
_filterDebounce = setTimeout(() => loadTaskList(), 300);
|
||||||
}
|
}
|
||||||
$("filter-q").addEventListener("input", scheduleFilter);
|
$("filter-q").addEventListener("input", scheduleFilter);
|
||||||
$("filter-wd").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)
|
// 工作目录输入框打开 enterApp 时拉一次 datalist(modal 也复用同一 list)
|
||||||
async function ensureFoldersLoaded() {
|
async function ensureFoldersLoaded() {
|
||||||
if ($("folders-datalist").children.length === 0) await loadFolderSuggestions();
|
if ($("folders-datalist").children.length === 0) await loadFolderSuggestions();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue