Add resizable web panes
This commit is contained in:
parent
f3017480d0
commit
f157a4e050
|
|
@ -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` 不动(运行方式无变化)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>
|
||||
|
||||
<!-- middle -->
|
||||
<div id="pane-mid">
|
||||
|
|
@ -821,6 +860,7 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="split-right" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整文件栏宽度"></div>
|
||||
|
||||
<!-- right -->
|
||||
<div id="pane-right">
|
||||
|
|
@ -831,6 +871,7 @@
|
|||
<button id="btn-src-pick" class="small" title="从其他目录勾选文件 / 目录,复制或移动到当前主目录">选入…</button>
|
||||
<button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)">⬆</button>
|
||||
<button id="btn-refresh-files" class="small">↻</button>
|
||||
<button id="pane-toggle-right" class="small" title="折叠文件列表">›</button>
|
||||
</div>
|
||||
<div id="file-crumbs" class="crumbs muted">加载中…</div>
|
||||
<div id="file-list"></div>
|
||||
|
|
@ -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=<uuid>:首次签发 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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue