diff --git a/PROGRESS.md b/PROGRESS.md index 61b6027..a093b75 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-05-25(dev SPA 左侧任务列表滚动区收窄到 task 列表) +最后更新:2026-05-25(dev SPA 三栏支持右栏折叠 + 拖拽调宽) --- @@ -23,6 +23,7 @@ ### 2026-05-25 +- **dev SPA 三栏支持右文件栏折叠 + 左右分隔线拖拽调宽**:`web/static/dev.html` 主布局从 3 列 grid 改为 5 列 grid(任务栏 / 左 splitter / 对话栏 / 右 splitter / 文件栏),新增 `#split-left` / `#split-right` 两条 6px 拖拽分隔线,拖动时分别调整 `--left-pane-width` / `--right-pane-width` 并持久化到 localStorage(`zcbot.left-width` / `zcbot.right-width`)。右侧文件栏新增 `#pane-toggle-right`,折叠态复用左栏 rail 范式:列宽 40px,只保留展开按钮,状态持久化到 `zcbot.right-collapsed`;手机端继续走三 tab 单列,隐藏折叠按钮和 splitter,避免与移动端导航冲突。`DESIGN.md` 不动(纯 dev SPA 布局交互);`RUN.md` 不动(运行方式无变化)。 - **dev SPA 右侧文件列表长名称 hover 显示全路径**:`web/static/dev.html` 在右 pane 文件行 `.file-row .name` 和"选入…"源文件列表 `.sp-row .sp-name` 上补 `title`,内容取 `e.rel || e.name`,保留现有 ellipsis 截断视觉,鼠标悬停可看完整相对路径/名称。`DESIGN.md` 不动(无架构/心智模型变化);`RUN.md` 不动(运行方式无变化)。 - **dev SPA 左侧滚动条只覆盖 task 列表**:`web/static/dev.html` 左 pane 改成 flex column,顶部 4 行 pane-head(任务标题/新建/搜索筛选/排序)固定不参与滚动;`#task-list` 与 `#task-sentinel` 包进 `#task-scroll`,并把 IntersectionObserver root 从 `#pane-left` 改到 `#task-scroll`,保证无限滚动仍按列表区域触发。`DESIGN.md` 不动(无架构/心智模型变化);`RUN.md` 不动(运行方式无变化)。 diff --git a/web/static/dev.html b/web/static/dev.html index 4832428..3166953 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -176,9 +176,18 @@ /* ───── 3-pane layout ───── */ #app { display: none; height: 100vh; } - #app.ready { display: grid; grid-template-columns: 320px 1fr 320px; grid-template-rows: auto 1fr; grid-template-areas: "head head head" "left mid right"; } + #app.ready { + --left-grid-width: var(--left-pane-width, 320px); + --right-grid-width: var(--right-pane-width, 320px); + display: grid; + grid-template-columns: var(--left-grid-width) 6px minmax(0, 1fr) 6px var(--right-grid-width); + grid-template-rows: auto 1fr; + grid-template-areas: + "head head head head head" + "left split-left mid split-right right"; + } /* 折叠左 pane:rail 模式,列收成 40px,pane 内只留一个展开按钮(类 VS Code 范式) */ - body.left-collapsed #app.ready { grid-template-columns: 40px 1fr 320px; } + body.left-collapsed #app.ready { --left-grid-width: 40px; } body.left-collapsed #pane-left > * { display: none; } body.left-collapsed #pane-left > .pane-head:first-child { display: flex; justify-content: center; align-items: center; @@ -187,6 +196,16 @@ } body.left-collapsed #pane-left > .pane-head:first-child > * { display: none; } body.left-collapsed #pane-left > .pane-head:first-child > #pane-toggle-left { display: inline-block; } + /* 折叠右 pane:同左侧 rail,只留展开按钮 */ + body.right-collapsed #app.ready { --right-grid-width: 40px; } + body.right-collapsed #pane-right > * { display: none; } + body.right-collapsed #pane-right > .pane-head:first-child { + display: flex; justify-content: center; align-items: center; + padding: 6px 4px; border-bottom: none; background: transparent; + position: static; + } + body.right-collapsed #pane-right > .pane-head:first-child > * { display: none; } + body.right-collapsed #pane-right > .pane-head:first-child > #pane-toggle-right { display: inline-block; } header { grid-area: head; background: #fff; border-bottom: 1px solid var(--border); @@ -211,9 +230,23 @@ #pane-left > .pane-head { flex-shrink: 0; } #task-scroll { flex: 1; min-height: 0; overflow: auto; } /* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */ - #pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; overflow: hidden; } + #pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; min-width: 0; overflow: hidden; } #pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); min-height: 0; } + .splitter { + min-width: 6px; background: var(--bg); cursor: col-resize; + position: relative; z-index: 5; + } + .splitter::before { + content: ""; position: absolute; top: 0; bottom: 0; left: 2px; width: 1px; + background: var(--border); + } + .splitter:hover, body.resizing-panes .splitter.active { background: var(--accent-soft); } + .splitter:hover::before, body.resizing-panes .splitter.active::before { background: var(--accent); } + #split-left { grid-area: split-left; } + #split-right { grid-area: split-right; } + body.resizing-panes { cursor: col-resize; user-select: none; } + .pane-head { padding: 8px 12px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: center; background: #fafafa; @@ -558,7 +591,12 @@ /* ───── responsive: tablet (641-1024px) ───── 断点内强制 rail(纯 CSS,不写 localStorage;回桌面用户偏好仍生效) */ @media (min-width: 641px) and (max-width: 1024px) { - #app.ready { grid-template-columns: 40px 1fr 260px; } + #app.ready { + --left-grid-width: 40px; + --right-grid-width: min(var(--right-pane-width, 260px), 260px); + grid-template-columns: 40px 0 minmax(0, 1fr) 6px var(--right-grid-width); + } + #split-left { display: none; } #pane-left > * { display: none; } #pane-left > .pane-head:first-child { display: flex; justify-content: center; align-items: center; @@ -588,7 +626,7 @@ body.mv-mid #pane-mid { display: flex; } body.mv-right #pane-right { display: block; } /* 折叠按钮在手机不可见 */ - #pane-toggle-left { display: none !important; } + #pane-toggle-left, #pane-toggle-right, .splitter { display: none !important; } /* header 紧凑化 */ header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; } @@ -796,6 +834,7 @@
+
@@ -821,6 +860,7 @@
+
@@ -831,6 +871,7 @@ +
加载中…
@@ -907,6 +948,9 @@ const LS_TOKEN = "zcbot.token"; const LS_UID = "zcbot.user_id"; const LS_NAME = "zcbot.name"; const LS_LEFT_COLLAPSED = "zcbot.left-collapsed"; +const LS_RIGHT_COLLAPSED = "zcbot.right-collapsed"; +const LS_LEFT_WIDTH = "zcbot.left-width"; +const LS_RIGHT_WIDTH = "zcbot.right-width"; // ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token // 可选 task_id=:首次签发 token 后自动定位到该 task 并加载消息 @@ -1255,24 +1299,92 @@ async function doAdminAdd() { } $("ad-go").onclick = doAdminAdd; -// ───── 左 pane 折叠 toggle(rail 模式 + localStorage 持久化) ───── -// 折叠 = pane 收成 40px rail,只留 #pane-toggle-left 一直可点;按钮符号根据状态翻向 +// ───── pane 折叠 + splitters(rail 模式 + localStorage 持久化) ───── +const PANE_W = { left: { min: 220, max: 560, def: 320 }, right: { min: 220, max: 560, def: 320 } }; +function clampPaneWidth(side, value) { + const cfg = PANE_W[side]; + const n = Number(value); + if (!Number.isFinite(n)) return cfg.def; + return Math.max(cfg.min, Math.min(cfg.max, Math.round(n))); +} +function applyPaneWidth(side, value) { + const width = clampPaneWidth(side, value); + $("app").style.setProperty(side === "left" ? "--left-pane-width" : "--right-pane-width", width + "px"); + localStorage.setItem(side === "left" ? LS_LEFT_WIDTH : LS_RIGHT_WIDTH, String(width)); +} +function restorePaneWidths() { + applyPaneWidth("left", localStorage.getItem(LS_LEFT_WIDTH) || PANE_W.left.def); + applyPaneWidth("right", localStorage.getItem(LS_RIGHT_WIDTH) || PANE_W.right.def); +} + +// 折叠 = pane 收成 40px rail,只留 toggle 一直可点;按钮符号根据状态翻向 function applyLeftCollapsed(collapsed) { document.body.classList.toggle("left-collapsed", collapsed); const btn = $("pane-toggle-left"); btn.textContent = collapsed ? "›" : "‹"; btn.title = collapsed ? "展开任务列表" : "折叠任务列表"; } +function applyRightCollapsed(collapsed) { + document.body.classList.toggle("right-collapsed", collapsed); + const btn = $("pane-toggle-right"); + btn.textContent = collapsed ? "‹" : "›"; + btn.title = collapsed ? "展开文件列表" : "折叠文件列表"; +} $("pane-toggle-left").onclick = () => { const next = !document.body.classList.contains("left-collapsed"); localStorage.setItem(LS_LEFT_COLLAPSED, next ? "1" : ""); applyLeftCollapsed(next); }; +$("pane-toggle-right").onclick = () => { + const next = !document.body.classList.contains("right-collapsed"); + localStorage.setItem(LS_RIGHT_COLLAPSED, next ? "1" : ""); + applyRightCollapsed(next); +}; +restorePaneWidths(); applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); +applyRightCollapsed(localStorage.getItem(LS_RIGHT_COLLAPSED) === "1"); + +function attachPaneSplitter(id, side) { + const el = $(id); + let dragging = false; + el.addEventListener("pointerdown", (ev) => { + if (mqPhone.matches) return; + ev.preventDefault(); + dragging = true; + el.classList.add("active"); + document.body.classList.add("resizing-panes"); + el.setPointerCapture(ev.pointerId); + if (side === "left" && document.body.classList.contains("left-collapsed")) { + localStorage.setItem(LS_LEFT_COLLAPSED, ""); + applyLeftCollapsed(false); + } + if (side === "right" && document.body.classList.contains("right-collapsed")) { + localStorage.setItem(LS_RIGHT_COLLAPSED, ""); + applyRightCollapsed(false); + } + }); + el.addEventListener("pointermove", (ev) => { + if (!dragging) return; + const rect = $("app").getBoundingClientRect(); + const width = side === "left" ? ev.clientX - rect.left : rect.right - ev.clientX; + applyPaneWidth(side, width); + }); + const stop = (ev) => { + if (!dragging) return; + dragging = false; + el.classList.remove("active"); + document.body.classList.remove("resizing-panes"); + try { el.releasePointerCapture(ev.pointerId); } catch (_) {} + }; + el.addEventListener("pointerup", stop); + el.addEventListener("pointercancel", stop); +} +attachPaneSplitter("split-left", "left"); +attachPaneSplitter("split-right", "right"); // ───── 手机视图切换(单列 + tab) ───── // body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class -// 进入手机视口时清掉 left-collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效) +// 进入手机视口时清掉 collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效) const mqPhone = window.matchMedia("(max-width: 640px)"); function setMobileView(view) { // view ∈ "mv-left" | "mv-mid" | "mv-right" @@ -1286,12 +1398,14 @@ function applyMobileMode() { if (mqPhone.matches) { // 手机:清掉桌面 rail 状态,默认显示任务列表(若未设过) document.body.classList.remove("left-collapsed"); + document.body.classList.remove("right-collapsed"); if (!document.body.matches(".mv-left, .mv-mid, .mv-right")) { setMobileView("mv-left"); } } else { // 桌面/平板:恢复 localStorage 的 collapsed 偏好(平板靠 @media 强制 rail,不需依赖 class) applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); + applyRightCollapsed(localStorage.getItem(LS_RIGHT_COLLAPSED) === "1"); } } mqPhone.addEventListener("change", applyMobileMode);