ui(dev SPA): 任务行 meta 数字槽位等宽 + 折叠按钮挪 pane-head + rail 模式 + time-ago 锁宽完成跨行对齐

- meta 加 tabular-nums + .num 槽位 (min-width:44px + text-align:right) + fmtTokens (1.2k/123k/1.2M)
- .num.right-group 把 [N条][Ntok][time] 整组用 margin-left:auto 推右
- time-ago 加 min-width:64px 锁宽: 整组右锚点稳定后, 跨行"条/tok"后缀才真正垂直对齐
- 折叠按钮挪到 pane-head 紧贴 ↻ 刷新; 折叠态改 VS Code rail 模式 (40px 列 + 只留 toggle 一直可点)
- 删 header #hd-toggle-left 冗余按钮 + header .icon-btn CSS (rail 模式下不需要)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-20 11:30:14 +08:00
parent 5b67d29f59
commit 775962d68a
2 changed files with 40 additions and 21 deletions

View File

@ -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 左 pane 280→320px + header 折叠 toggle + 任务列表行去 id8 / meta nowrap 防 CJK 断行) 最后更新:2026-05-20(dev SPA 左 pane 折叠改 40px rail 模式 + time-ago 锁宽让 N条/Ntok 跨行对齐 + 删 header 冗余按钮)
--- ---
@ -23,6 +23,8 @@
### 2026-05-20 ### 2026-05-20
- **dev SPA 左 pane 折叠改 rail 模式 + 删 header 冗余按钮 + time-ago 锁宽完成跨行对齐**:用户反馈 ① "原来 zcbot 旁的折叠按钮不要了,没用处" + ② "数字对齐那块现在是不是每块内容左侧对齐?"(实际是右对齐但因 time-ago 宽度变化导致 N 条/N tok 右边界也跟着抖,跨行没真对齐)。两件套:① 折叠模式从「pane display:none」改 VS Code 范式 rail —— `body.left-collapsed #app.ready { grid-template-columns: 40px 1fr 320px }` + `#pane-left > * { display: none }`(藏全部直接子) + override 第一行 pane-head 重显且只留 `#pane-toggle-left`(`> *:not(#pane-toggle-left) { display: none }`,选择器特异性 2 ids 压 1 id);pane-head 第一行用 `position: static` 取消 sticky / `border-bottom: none` / `background: transparent` 看起来更像 rail 非"卡片"。按钮符号根据 `body.left-collapsed` 在 `applyLeftCollapsed` 里翻向(展开态 `` 折叠态 ``)。彻底删 `#hd-toggle-left` + `header .icon-btn` CSS 块,header 不再背 expand 入口的债。② time-ago 加 `flex-shrink: 0; text-align: right; min-width: 64px` 锁宽,**这才是真正解决跨行对齐的关键**:此前 `.num.right-group``margin-left: auto` 把 [N 条][N tok][time] 整组推右,但 time 自身宽度浮动 30~70px(刚刚 / 10 小时前 / 2025-12-05)→ time 左边界抖 → N tok 右边界抖 → N 条 右边界抖,逐级传染。锁 time 宽后整组位置稳定,槽内 `text-align: right` 才能让"条/tok"后缀跨行真正垂直对齐。删 `.badge .time-ago { flex-shrink: 0 }` 合并里的 time-ago(已独立给规则)。**没动**:fmtTokens / 桶分级 / tabular-nums / `.num min-width: 44px`(上一轮已正确)、右 pane / chat 中列。
- **dev SPA 任务行 meta 数字槽位跨行对齐 + 折叠按钮位置调整**:用户报"N 条 / N tok 数字宽窄不一,看着不齐";又说"折叠按钮应该贴刷新按钮"。两件套:① meta CSS 加 `font-variant-numeric: tabular-nums` + `align-items: baseline`,新 `.num` 子选择器 `flex-shrink: 0; text-align: right; min-width: 44px`(右对齐让 `条` / `tok` 后缀跨行垂直对齐);N 条 span 戴 `right-group` 类拿 `margin-left: auto`,把 [N 条][N tok][time-ago] 整组挤右侧,左侧只剩 badge + skill;原 time-ago 上的 inline `margin-left:auto` 移除避免双 push 失效。新 `fmtTokens(n)` helper:<1k 原数 / <10k `1.2k` / <1M `123k` / >=1M `1.2M`,bound 槽位宽度;`title=` hover 出 `123,456 tokens` 完整值(`Number.toLocaleString()`)。② 折叠按钮拆双入口 — `#pane-toggle-left` 放第一行 pane-head 紧贴刷新按钮(展开态用,点击折叠);`#hd-toggle-left` 留 header 但 `style="display:none"` 默认隐藏,仅折叠态显示(用户路径:折叠后 pane display:none → 无法在 pane 内点展开 → 必须 header 保留 expand 入口)。`applyLeftCollapsed(collapsed)` 控制 hd 按钮 display,两按钮共享 `toggleLeftCollapsed()` 实现;每按钮符号固定(pane 内 `` 一直是折叠方向,header 内 `` 一直是展开方向),不再翻向(语义更清)。**没动**:右 pane / chat 列宽、`/v1/tasks` 后端、id8 仍在 row title hover(上次改的不动)、CSS `.small` 等。
- **dev SPA 左 pane 调宽 280→320px + header 折叠 toggle + 任务行精简 meta**:用户报 280px 下底行(badge/skill/N条/Ntok/time/id8)被 flex shrink 后 CJK 字符断行(像"10 小时前"裂成两行)。三件套修:① `#app.ready grid-template-columns` `280px → 320px`(右 pane / chat 不动,从 chat 借 40px,任务名 / 描述 / wd 都更舒展);② header 最左插 `<button id="hd-toggle-left" class="icon-btn"></button>`,点击 toggle `body.left-collapsed` → CSS `grid-template-columns: 0 1fr 320px` + `#pane-left { display: none }`(列归零腾给 chat,折叠态 chevron 翻 ``);state 存 `localStorage zcbot.left-collapsed`,boot 即应用,刷新保持。IntersectionObserver 留着不重建(display:none 期间 sentinel 0 高度自然不触发,展开后重算 layout 若 sentinel 在视口自然续传);③ 任务行删 `id8` span(8 位 hex 调试时才用),挪到 row `title=` hover 出 `${name}\n${task_id}` 完整 id 仍可查;`.task-row .meta > *` 全加 `white-space: nowrap; overflow: hidden; text-overflow: ellipsis` 防内部 CJK 字符破断;badge + time-ago 加 `flex-shrink: 0` 保两端不缩;wd / desc 副行恢复 inline 三件套 `overflow:hidden;text-overflow:ellipsis;white-space:nowrap`(它们是单文本带不是 flex 子元素行,`> *` CSS 不命中文本节点)。**没动**:右 pane 320px 不变(文件预览常用)、chat 中列 1fr(自适应剩余);折叠按钮没做右 pane 对应版(用户没要)。 - **dev SPA 左 pane 调宽 280→320px + header 折叠 toggle + 任务行精简 meta**:用户报 280px 下底行(badge/skill/N条/Ntok/time/id8)被 flex shrink 后 CJK 字符断行(像"10 小时前"裂成两行)。三件套修:① `#app.ready grid-template-columns` `280px → 320px`(右 pane / chat 不动,从 chat 借 40px,任务名 / 描述 / wd 都更舒展);② header 最左插 `<button id="hd-toggle-left" class="icon-btn"></button>`,点击 toggle `body.left-collapsed` → CSS `grid-template-columns: 0 1fr 320px` + `#pane-left { display: none }`(列归零腾给 chat,折叠态 chevron 翻 ``);state 存 `localStorage zcbot.left-collapsed`,boot 即应用,刷新保持。IntersectionObserver 留着不重建(display:none 期间 sentinel 0 高度自然不触发,展开后重算 layout 若 sentinel 在视口自然续传);③ 任务行删 `id8` span(8 位 hex 调试时才用),挪到 row `title=` hover 出 `${name}\n${task_id}` 完整 id 仍可查;`.task-row .meta > *` 全加 `white-space: nowrap; overflow: hidden; text-overflow: ellipsis` 防内部 CJK 字符破断;badge + time-ago 加 `flex-shrink: 0` 保两端不缩;wd / desc 副行恢复 inline 三件套 `overflow:hidden;text-overflow:ellipsis;white-space:nowrap`(它们是单文本带不是 flex 子元素行,`> *` CSS 不命中文本节点)。**没动**:右 pane 320px 不变(文件预览常用)、chat 中列 1fr(自适应剩余);折叠按钮没做右 pane 对应版(用户没要)。
- **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 左侧任务列表 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 保留(调试参考)。

View File

@ -141,22 +141,22 @@
/* ───── 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 { display: grid; grid-template-columns: 320px 1fr 320px; grid-template-rows: auto 1fr; grid-template-areas: "head head head" "left mid right"; }
/* 折叠左 pane:整列归零 + pane 隐藏(配合 header toggle 按钮 + localStorage 持久化) */ /* 折叠左 pane:rail 模式,列收成 40px,pane 内只留一个展开按钮(类 VS Code 范式) */
body.left-collapsed #app.ready { grid-template-columns: 0 1fr 320px; } body.left-collapsed #app.ready { grid-template-columns: 40px 1fr 320px; }
body.left-collapsed #pane-left { display: none; } body.left-collapsed #pane-left > * { display: none; }
body.left-collapsed #pane-left > .pane-head:first-child {
display: flex; justify-content: center; align-items: center;
padding: 6px 4px; border-bottom: none; background: transparent;
position: static; /* 取消 sticky,rail 太窄不需要滚 */
}
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; }
header { header {
grid-area: head; background: #fff; border-bottom: 1px solid var(--border); grid-area: head; background: #fff; border-bottom: 1px solid var(--border);
padding: 8px 14px; display: flex; align-items: center; gap: 12px; padding: 8px 14px; display: flex; align-items: center; gap: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,.03); box-shadow: 0 1px 2px rgba(0,0,0,.03);
} }
/* header 小图标按钮(折叠 toggle 等):透明常态 + 悬浮微底 */
header .icon-btn {
background: transparent; border: 1px solid transparent;
color: var(--muted); padding: 2px 8px; font-size: 16px; line-height: 1.2;
border-radius: 4px; cursor: pointer;
}
header .icon-btn:hover { background: var(--hover); color: var(--text); border-color: var(--border); }
header .brand { display: flex; align-items: center; gap: 8px; } header .brand { display: flex; align-items: center; gap: 8px; }
header .brand .logo { header .brand .logo {
width: 24px; height: 24px; border-radius: 6px; width: 24px; height: 24px; border-radius: 6px;
@ -234,10 +234,16 @@
.task-row.active { background: var(--accent-soft); border-left: 3px solid var(--accent); padding-left: 9px; } .task-row.active { background: var(--accent-soft); border-left: 3px solid var(--accent); padding-left: 9px; }
.task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px; .task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* meta 行:flex nowrap + 每个子项 nowrap,防 CJK 字符在窄 pane(280px)被 shrink 后断行 */ /* meta 行:flex nowrap + 每个子项 nowrap,防 CJK 字符在窄 pane(320px)被 shrink 后断行 */
.task-row .meta { font-size: 11px; color: var(--muted); display: flex; gap: 8px; min-width: 0; } /* tabular-nums 让数字等宽(条 / tok 计数跨行对齐) */
.task-row .meta { font-size: 11px; color: var(--muted); display: flex; gap: 8px; min-width: 0;
align-items: baseline; font-variant-numeric: tabular-nums; }
.task-row .meta > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } .task-row .meta > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.task-row .meta .badge, .task-row .meta .time-ago { flex-shrink: 0; } /* 状态徽章 + 相对时间不缩 */ .task-row .meta .badge { flex-shrink: 0; }
/* 数字槽位:固定 min-width + 右对齐;time-ago 也锁宽 → 整个右侧组位置稳定,跨行"条/tok"才能对齐 */
.task-row .meta .num { flex-shrink: 0; text-align: right; min-width: 44px; }
.task-row .meta .num.right-group { margin-left: auto; } /* 把数字+时间整组挤到右侧 */
.task-row .meta .time-ago { flex-shrink: 0; text-align: right; min-width: 64px; }
.task-row .badge { .task-row .badge {
display: inline-block; padding: 0 6px; border-radius: 8px; font-size: 11px; display: inline-block; padding: 0 6px; border-radius: 8px; font-size: 11px;
background: #eef; color: #336; background: #eef; color: #336;
@ -549,7 +555,6 @@
<!-- ───── main 3-pane ───── --> <!-- ───── main 3-pane ───── -->
<div id="app"> <div id="app">
<header> <header>
<button id="hd-toggle-left" class="icon-btn" title="折叠任务列表"></button>
<div class="brand"> <div class="brand">
<div class="logo">Z</div> <div class="logo">Z</div>
<div class="title">zcbot</div> <div class="title">zcbot</div>
@ -573,6 +578,7 @@
<option value="abandoned">已废弃</option> <option value="abandoned">已废弃</option>
</select> </select>
<button id="btn-refresh-tasks" class="small" title="刷新"></button> <button id="btn-refresh-tasks" class="small" title="刷新"></button>
<button id="pane-toggle-left" class="small" title="折叠任务列表"></button>
</div> </div>
<div class="pane-head" style="gap: 6px;"> <div class="pane-head" style="gap: 6px;">
<input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:1; padding: 3px 6px;" /> <input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:1; padding: 3px 6px;" />
@ -813,6 +819,16 @@ function fmtTime(iso) {
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; } try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
} }
// 紧凑 token 显示:<1k 原数,<10k 一位小数 k,>=10k 整数 k,>=1M 一位小数 M
// 目的:让列表行 "N tok" 槽位宽度有上限,跨行对齐
function fmtTokens(n) {
n = n || 0;
if (n < 1000) return String(n);
if (n < 10000) return (n / 1000).toFixed(1) + "k";
if (n < 1000000) return Math.round(n / 1000) + "k";
return (n / 1000000).toFixed(1) + "M";
}
// 相对时间(任务列表用):刚刚 / N 分钟前 / N 小时前 / 昨天 HH:MM / MM-DD / YYYY-MM-DD // 相对时间(任务列表用):刚刚 / N 分钟前 / N 小时前 / 昨天 HH:MM / MM-DD / YYYY-MM-DD
function fmtTimeAgo(iso) { function fmtTimeAgo(iso) {
if (!iso) return ""; if (!iso) return "";
@ -954,14 +970,15 @@ function logout() {
} }
$("hd-logout").onclick = logout; $("hd-logout").onclick = logout;
// ───── 左 pane 折叠 toggle(localStorage 持久化) ───── // ───── 左 pane 折叠 toggle(rail 模式 + localStorage 持久化) ─────
// 折叠 = pane 收成 40px rail,只留 #pane-toggle-left 一直可点;按钮符号根据状态翻向
function applyLeftCollapsed(collapsed) { function applyLeftCollapsed(collapsed) {
document.body.classList.toggle("left-collapsed", collapsed); document.body.classList.toggle("left-collapsed", collapsed);
const btn = $("hd-toggle-left"); const btn = $("pane-toggle-left");
btn.textContent = collapsed ? "" : ""; btn.textContent = collapsed ? "" : "";
btn.title = collapsed ? "展开任务列表" : "折叠任务列表"; btn.title = collapsed ? "展开任务列表" : "折叠任务列表";
} }
$("hd-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);
@ -1072,9 +1089,9 @@ function renderTaskList(tasks, append = false) {
<div class="meta"> <div class="meta">
<span class="badge ${t.status}">${statusLabel}</span> <span class="badge ${t.status}">${statusLabel}</span>
${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""} ${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""}
<span>${t.n_messages || 0} 条</span> <span class="num right-group">${t.n_messages || 0} 条</span>
<span>${t.tokens || 0} tok</span> <span class="num" title="${(t.tokens || 0).toLocaleString()} tokens">${fmtTokens(t.tokens)} tok</span>
<span class="muted time-ago" style="margin-left:auto;" title="${escapeHtml(fmtTime(t.updated_at))}">${escapeHtml(fmtTimeAgo(t.updated_at))}</span> <span class="muted time-ago" title="${escapeHtml(fmtTime(t.updated_at))}">${escapeHtml(fmtTimeAgo(t.updated_at))}</span>
</div> </div>
</div> </div>
<button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作"></button> <button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作"></button>