Add resizable web panes

This commit is contained in:
caoqianming 2026-05-25 08:31:31 +08:00
parent f3017480d0
commit f157a4e050
2 changed files with 124 additions and 9 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `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 ### 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 右侧文件列表长名称 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` 不动(运行方式无变化)。 - **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` 不动(运行方式无变化)。

View File

@ -176,9 +176,18 @@
/* ───── 3-pane layout ───── */ /* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; } #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 范式) */ /* 折叠左 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 > * { display: none; }
body.left-collapsed #pane-left > .pane-head:first-child { body.left-collapsed #pane-left > .pane-head:first-child {
display: flex; justify-content: center; align-items: center; 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 > * { display: none; }
body.left-collapsed #pane-left > .pane-head:first-child > #pane-toggle-left { display: inline-block; } 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 { header {
grid-area: head; background: #fff; border-bottom: 1px solid var(--border); grid-area: head; background: #fff; border-bottom: 1px solid var(--border);
@ -211,9 +230,23 @@
#pane-left > .pane-head { flex-shrink: 0; } #pane-left > .pane-head { flex-shrink: 0; }
#task-scroll { flex: 1; min-height: 0; overflow: auto; } #task-scroll { flex: 1; min-height: 0; overflow: auto; }
/* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: 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; } #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 { .pane-head {
padding: 8px 12px; border-bottom: 1px solid var(--border); padding: 8px 12px; border-bottom: 1px solid var(--border);
display: flex; gap: 8px; align-items: center; background: #fafafa; display: flex; gap: 8px; align-items: center; background: #fafafa;
@ -558,7 +591,12 @@
/* ───── responsive: tablet (641-1024px) ───── /* ───── responsive: tablet (641-1024px) ─────
断点内强制 rail(纯 CSS,不写 localStorage;回桌面用户偏好仍生效) */ 断点内强制 rail(纯 CSS,不写 localStorage;回桌面用户偏好仍生效) */
@media (min-width: 641px) and (max-width: 1024px) { @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 > * { display: none; }
#pane-left > .pane-head:first-child { #pane-left > .pane-head:first-child {
display: flex; justify-content: center; align-items: center; display: flex; justify-content: center; align-items: center;
@ -588,7 +626,7 @@
body.mv-mid #pane-mid { display: flex; } body.mv-mid #pane-mid { display: flex; }
body.mv-right #pane-right { display: block; } 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 紧凑化 */
header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; } header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; }
@ -796,6 +834,7 @@
<div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div> <div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div>
</div> </div>
</div> </div>
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>
<!-- middle --> <!-- middle -->
<div id="pane-mid"> <div id="pane-mid">
@ -821,6 +860,7 @@
</div> </div>
</form> </form>
</div> </div>
<div id="split-right" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整文件栏宽度"></div>
<!-- right --> <!-- right -->
<div id="pane-right"> <div id="pane-right">
@ -831,6 +871,7 @@
<button id="btn-src-pick" class="small" title="从其他目录勾选文件 / 目录,复制或移动到当前主目录">选入…</button> <button id="btn-src-pick" class="small" title="从其他目录勾选文件 / 目录,复制或移动到当前主目录">选入…</button>
<button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)"></button> <button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)"></button>
<button id="btn-refresh-files" class="small"></button> <button id="btn-refresh-files" class="small"></button>
<button id="pane-toggle-right" class="small" title="折叠文件列表"></button>
</div> </div>
<div id="file-crumbs" class="crumbs muted">加载中…</div> <div id="file-crumbs" class="crumbs muted">加载中…</div>
<div id="file-list"></div> <div id="file-list"></div>
@ -907,6 +948,9 @@ const LS_TOKEN = "zcbot.token";
const LS_UID = "zcbot.user_id"; const LS_UID = "zcbot.user_id";
const LS_NAME = "zcbot.name"; const LS_NAME = "zcbot.name";
const LS_LEFT_COLLAPSED = "zcbot.left-collapsed"; 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 // ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token
// 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息 // 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息
@ -1255,24 +1299,92 @@ async function doAdminAdd() {
} }
$("ad-go").onclick = doAdminAdd; $("ad-go").onclick = doAdminAdd;
// ───── 左 pane 折叠 toggle(rail 模式 + localStorage 持久化) ───── // ───── pane 折叠 + splitters(rail 模式 + localStorage 持久化) ─────
// 折叠 = pane 收成 40px rail,只留 #pane-toggle-left 一直可点;按钮符号根据状态翻向 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) { function applyLeftCollapsed(collapsed) {
document.body.classList.toggle("left-collapsed", collapsed); document.body.classList.toggle("left-collapsed", collapsed);
const btn = $("pane-toggle-left"); const btn = $("pane-toggle-left");
btn.textContent = collapsed ? "" : ""; btn.textContent = collapsed ? "" : "";
btn.title = 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 = () => { $("pane-toggle-left").onclick = () => {
const next = !document.body.classList.contains("left-collapsed"); const next = !document.body.classList.contains("left-collapsed");
localStorage.setItem(LS_LEFT_COLLAPSED, next ? "1" : ""); localStorage.setItem(LS_LEFT_COLLAPSED, next ? "1" : "");
applyLeftCollapsed(next); 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"); 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) ───── // ───── 手机视图切换(单列 + tab) ─────
// body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class // body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class
// 进入手机视口时清掉 left-collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效) // 进入手机视口时清掉 collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效)
const mqPhone = window.matchMedia("(max-width: 640px)"); const mqPhone = window.matchMedia("(max-width: 640px)");
function setMobileView(view) { function setMobileView(view) {
// view ∈ "mv-left" | "mv-mid" | "mv-right" // view ∈ "mv-left" | "mv-mid" | "mv-right"
@ -1286,12 +1398,14 @@ function applyMobileMode() {
if (mqPhone.matches) { if (mqPhone.matches) {
// 手机:清掉桌面 rail 状态,默认显示任务列表(若未设过) // 手机:清掉桌面 rail 状态,默认显示任务列表(若未设过)
document.body.classList.remove("left-collapsed"); document.body.classList.remove("left-collapsed");
document.body.classList.remove("right-collapsed");
if (!document.body.matches(".mv-left, .mv-mid, .mv-right")) { if (!document.body.matches(".mv-left, .mv-mid, .mv-right")) {
setMobileView("mv-left"); setMobileView("mv-left");
} }
} else { } else {
// 桌面/平板:恢复 localStorage 的 collapsed 偏好(平板靠 @media 强制 rail,不需依赖 class) // 桌面/平板:恢复 localStorage 的 collapsed 偏好(平板靠 @media 强制 rail,不需依赖 class)
applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1");
applyRightCollapsed(localStorage.getItem(LS_RIGHT_COLLAPSED) === "1");
} }
} }
mqPhone.addEventListener("change", applyMobileMode); mqPhone.addEventListener("change", applyMobileMode);