项目管理界面:修列宽塌陷 + 工作区维度纠偏 + 卡片信息重排

一、修「卡片挤到一起去了」(用户 2026-10-06 报)
  真因=**嵌套 grid**:renderAll 写 `<div class="cols">` 而容器 `#cols` 本身就带
  `class="cols"` ⇒ 内层作为外层一个 grid item 先被压到 `min-width:auto`(399px),
  再在 399px 里自己切 4 列 ⇒ 每列 89px、卡片 67px,整块挤在左边一条。
  实测:col 89px / card 67px / 页高 3192px ⇒ 修后 card 377px / 页高 1415px。
  处置:renderAll 只铺列,⛔ 不再包一层。

二、修「卡片上下之间的距离太远」
  grid 默认 `align-items:stretch` ⇒ 12 张卡都在「已完成」、另三列空着却四列等高,
  整页虚胖。改 `align-items:start` ⇒ 每列各自撑高。卡片 gap 9→6、padding 10→8、
  `.col` 的 `min-height:160px`→0。

三、**工作区 / 目标 两个维度纠偏**(用户纠正:「下拉框是选工作区的 跟目标没关系」)
  改前:下拉写「全部(N 个目标)」而 N 取的是 `projects.length`=工作区条数 ⇒ 混了维度。
  改后:① 标签「项目」→「工作区」;② 选项文字=**目录名(+目标名)**;
        ③ 计数=「N 个工作区」;④ 概要条分开报「工作区 3 个 / 其中已登记目标 2 个」;
        ⑤ 装了机制的工作区**一律列出**(含未登记目标的,用户:「加载机制就可以显示」)。

四、修「显示的是目录名,应该是真正名称」
  `name = short or title` ⇒ 登记过 short 的区一律显示短名(`vibe-product`)。
  改 `title or short`;都没有 ⇒ 「未登记目标 · <dir>」(⛔ 不再印「(未命名 · …)」——
  用户原话「未命名 看不懂个是什么」)。
  ⚠️ 核实过:这不是收工操作的副作用(goal.json 的 title/short 收工前后逐字一致)。

五、卡片信息重排(用户:「工作区名称放到卡片下面」)
  末行单列工作区名(等宽字体)+ 本工作区角标;工作区名从 badge 堆里挪出来。
  本工作区卡片换色突出:判定来自服务端 `self`(启动器注入
  `DSH_PM_BOARD_SELF_WS`=启动器上两级),⛔ 前端不硬编码目录名。

六、修时间戳显示成 `1791957640.7257342`
  台账 `t_start`/`t_end` 有两种形态(ISO 串 / **纯数字 Unix 秒**),原 fmtWhen 只认前者,
  不匹配就**原样返回** ⇒ 印出一串乱数字。补:纯数字(1e9~4e9)走 Date 转本地时间。

另:A 方案(收工 vibe-product)已执行并取证 —— `--set-life 已完成` 自动调
supervise_stop,pid 432 真退出(tasklist 查无)、日志 35s 增量 0 字节(收工前每 32s +1357B)。
This commit is contained in:
admin committed 2026-10-06 09:38:28 +08:00
1 parent ee9b4f673b
commit 5d0745c470
3 files changed
+127 -34

No files matched your search

+98 -33
View File
@@ -35,24 +35,43 @@
.chip b{color:var(--fg);font-weight:600}
/* ── 列 ── */
main{padding:16px 18px 60px}
.cols{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
main{padding:16px 18px 40px}
/* 🔴🔴 `align-items:start` —— 用户 2026-10-06:「卡片上下之间的距离太远了」。
真因:grid **默认 `stretch`** ⇒ 12 张卡片全在「已完成」、另三列是空的,
但四列**被拉成等高** ⇒ 空列白占 1100px、整体高度虚胖。
✅ 改 `start` ⇒ 每列**各自撑自己的高度**,空列就是一个小盒子。*/
.cols{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;
align-items:start}
@media(max-width:1100px){.cols{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:640px){.cols{grid-template-columns:1fr}}
.col{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
display:flex;flex-direction:column;min-height:160px}
display:flex;flex-direction:column;min-height:0}
.colhead{display:flex;align-items:center;gap:8px;padding:11px 13px;
border-bottom:1px solid var(--line)}
.dot{width:8px;height:8px;border-radius:50%;flex:none}
.coltitle{font-weight:600;font-size:13px}
.count{margin-left:auto;background:var(--panel2);border:1px solid var(--line);
border-radius:999px;padding:1px 9px;font-size:12px;color:var(--dim)}
.cards{padding:10px;display:flex;flex-direction:column;gap:9px;flex:1}
/* 🔴 卡片间距压紧(原 `gap:9px` + `padding:10px` 在长列里显得很空)*/
.cards{padding:8px;display:flex;flex-direction:column;gap:6px}
/* ── 统一视图:卡片上的「目标」标签(🔴 归属靠标签,⛔ 不靠分区)── */
.badge.goal{border-color:var(--accent);color:var(--accent)}
/* ── 统一视图:卡片**末行**显示工作区(🔴 归属靠这一行,⛔ 不靠分区)── */
/* 用户 2026-10-06:「卡片 标签要显示 工作区名称,不要显示 本机协作这种」
+「工作区名称要不要 放到卡片下面」⇒ 末行单列,⛔ 不混进 badge 堆里 */
.wsrow{display:flex;align-items:center;gap:6px;margin-top:7px;padding-top:6px;
border-top:1px solid var(--line)}
.wsname{font-size:11.5px;color:var(--dim);font-family:ui-monospace,Consolas,monospace;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wsself{margin-left:auto;flex:none;font-size:10.5px;padding:0 5px;border-radius:4px;
background:rgba(76,110,245,.2);border:1px solid rgba(76,110,245,.5);
color:#b9c8ff}
.badge.when{font-family:ui-monospace,Consolas,monospace}
/* 🔴 本工作区的卡片换色突出(用户 2026-10-06:「本工作区的卡片是不得 换个颜色」)*/
.card.is-self{background:rgba(76,110,245,.13);border-color:rgba(76,110,245,.55)}
.card.is-self:hover{border-color:var(--accent)}
.card.is-self .t{color:#c9d4ff}
.card{background:var(--panel2);border:1px solid var(--line);border-left-width:3px;
border-radius:8px;padding:10px 11px;cursor:pointer;
border-radius:8px;padding:9px 11px;cursor:pointer;
transition:transform .08s ease,border-color .12s ease}
.card:hover{transform:translateY(-1px);border-color:var(--accent)}
.card .t{font-weight:500;word-break:break-word}
@@ -160,9 +179,9 @@
<header>
<div class="hrow">
<div class="brand">项目管理<small id="ws">—</small></div>
<label for="psel" class="stamp">项目</label>
<select id="psel" aria-label="选择项目"></select>
<button id="btndel" type="button" class="danger" title="删除当前项目(可选粒度)">删除</button>
<label for="psel" class="stamp">工作区</label>
<select id="psel" aria-label="选择工作区"></select>
<button id="btndel" type="button" class="danger" title="删除当前工作区(可选粒度)">删除</button>
<div class="spacer"></div>
<span class="stamp" id="stamp">加载中…</span>
<span class="info fancy" id="infobtn" tabindex="0" role="button" aria-label="状态说明"
@@ -223,8 +242,23 @@ function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, function(c){
function fmtWhen(s){
if(!s) return "—";
var m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if(!m) return String(s);
var str = String(s).trim();
// 🔴🔴 2026-10-06 实测补:台账里的 `t_start`/`t_end` 有两种形态 ——
// ① `"2026-10-06T09:26:53"`(ISO 字符串)② **纯数字 Unix 秒**(`1791957640.7257342`)。
// 原实现只认 ①,遇到 ② 正则不匹配 ⇒ **原样返回** ⇒ 卡片上印出
// `1791957640.7257342` 一整串(用户看就是一堆乱数字)。
// ✅ 先判纯数字(10 位上下)⇒ 转本地时间;⛔ 不猜、⛔ 不硬编时区。
var n = Number(str);
if(isFinite(n) && n > 1e9 && n < 4e9){ // 2001-09 ~ 2096-11 之间的秒级时间戳
var d = new Date(n * 1000);
if(!isNaN(d.getTime())){
var p = function(x){ return (x<10?"0":"")+x; };
return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate())+" "+
p(d.getHours())+":"+p(d.getMinutes());
}
}
var m = str.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if(!m) return str;
return m[1]+"-"+m[2]+"-"+m[3]+" "+m[4]+":"+m[5];
}
@@ -320,10 +354,18 @@ function load(manual){
sel.innerHTML = '<option value="-1">(本机暂无装了机制的工作区)</option>';
STATE.cur = -1;
} else {
// 🔴 默认「全部」:一屏看完本机所有目标(⛔ 不再默认只显示一个)
sel.innerHTML = '<option value="-1">全部('+d.projects.length+' 个目标)</option>'
// 🔴🔴 **本下拉=工作区选择器**(用户 2026-10-06 明确纠正:
// 「下拉框是选工作区的 跟目标没关系」)。
// ⇒ ① 选项文字用 **工作区名**(`dir` 目录名),⛔ 不是目标名;
// ② 计数说 **N 个工作区**,⛔ 不说"N 个目标"(那是另一个维度);
// ③ 装了机制的工作区**一律列出**(含还没登记目标的)—— 用户明令
// 「加载机制就可以显示」(⛔ 不许因为没目标就藏起来)。
// ⚠️ 我上一版写成「全部(N 个目标)」,是把两个维度混了 —— 已纠正。
sel.innerHTML = '<option value="-1">全部('+d.projects.length+' 个工作区)</option>'
+ d.projects.map(function(p,i){
return '<option value="'+i+'">'+esc(p.name)+'</option>'; }).join("");
// 有目标 ⇒ 目录名 + 目标名;没目标 ⇒ 只目录名(⛔ 不印「未命名」那种看不懂的话)
var lab = p.dir + (p.has_goal && p.name ? '('+p.name+')' : '');
return '<option value="'+i+'">'+esc(lab)+'</option>'; }).join("");
if(STATE.cur >= d.projects.length) STATE.cur = -1;
sel.value = String(STATE.cur);
}
@@ -343,17 +385,26 @@ function _stateDot(key){
function _cardHtml(t){
var dot = _stateDot(t.state), bits = [];
// 🔴 统一视图里,卡片自带「目标 · 工作区」标签(⛔ 不靠分区来体现归属)
if(t._goal) bits.push('<span class="badge goal">'+esc(t._goal)+'</span>');
// 🔴 用户 2026-10-06:「工作区名称要不要 放到卡片下面」⇒ 采纳。
// 卡片结构改成**三段**:① 标题 ② 受阻原因(有才显示)③ **卡片下方一行**=
// `工作区名` + 线/执行者/时间。
// ⚠️ 工作区名**从 `.meta` 里挪出去单列** —— 它是这一屏唯一的"归属"信息,
// 混在其它 badge 里会被忽略。
// 标签取 `_ws`(目录名,如 `ai1net-dsh-server`),⛔ 不是 `_goal`(目标短名,
// 用户明确「不要显示 本机协作这种」)。
if(t.line) bits.push('<span class="badge">'+esc(t.line)+'</span>');
if(t.by) bits.push('<span class="badge">'+esc(t.by)+'</span>');
if(t.t_end) bits.push(esc(fmtWhen(t.t_end)));
else if(t.t_start) bits.push(esc(fmtWhen(t.t_start)));
return '<div class="card" style="border-left-color:'+dot+'" data-tid="'+esc(t.id)+'"'+
' data-pw="'+esc(t._ws||"")+'">'+
var when = t.t_end ? fmtWhen(t.t_end) : (t.t_start ? fmtWhen(t.t_start) : "");
if(when) bits.push('<span class="badge when">'+esc(when)+'</span>');
// 🔴 本工作区的卡片换色突出(2026-10-06 用户要);判定来自服务端 `_self`,⛔ 前端不猜
return '<div class="card'+(t._self?" is-self":"")+'" style="border-left-color:'+dot+
'" data-tid="'+esc(t.id)+'" data-pw="'+esc(t._ws||"")+'">'+
'<div class="t">'+esc(t.title||t.id)+'</div>'+
(t.block_reason ? '<div class="meta" style="color:#f2a09a">受阻:'+esc(t.block_reason)+'</div>' : '')+
(bits.length ? '<div class="meta">'+bits.join("")+'</div>' : '')+
// 🔴 末行=工作区(用户要的「放到卡片下面」)
(t._ws ? '<div class="wsrow"><span class="wsname">'+esc(t._ws)+'</span>'+
(t._self ? '<span class="wsself">本工作区</span>' : '')+'</div>' : '')+
'</div>';
}
@@ -379,16 +430,20 @@ function renderAll(){
var d = STATE.data, ps = d.projects;
var sums = document.getElementById("summary");
var cols = document.getElementById("cols");
document.getElementById("btndel").disabled = true; // ⛔ 「全部」不是单个项目,删不得
document.getElementById("btndel").disabled = true; // ⛔ 「全部」不是单个工作区,删不得
var tot = {total:0, done:0, open:0, blocked:0};
ps.forEach(function(p){
tot.total += p.queue.total; tot.done += p.queue.done;
tot.open += p.queue.open; tot.blocked += p.queue.blocked;
});
// 🔴 两个维度**分开报**(用户 2026-10-06 纠正:「下拉框是选工作区的 跟目标没关系」):
// 工作区=目录(装了机制的);目标=工作区里登记的那件事。
// ⚠️ 我上一版写「目标 N 个」用的是**工作区条数** ⇒ 把两个维度混成了一个数(已改)。
var nWs = ps.length, nGoal = ps.filter(function(p){ return p.has_goal; }).length;
var s = [];
s.push('<span class="chip">目标 <b>'+ps.length+'</b> 个</span>');
s.push('<span class="chip">工作区 <b>'+(d.roots||[]).join(" · ")+'</b></span>');
s.push('<span class="chip">工作区 <b>'+nWs+'</b> 个</span>');
s.push('<span class="chip">其中已登记目标 <b>'+nGoal+'</b> 个</span>');
s.push('<span class="chip">台账合计 <b>'+tot.total+'</b> 件</span>');
s.push('<span class="chip">已完成 <b>'+tot.done+'</b></span>');
s.push('<span class="chip">未完 <b>'+tot.open+'</b></span>');
@@ -399,12 +454,13 @@ function renderAll(){
var nt = [];
var noGoal = ps.filter(function(x){ return !x.has_goal; });
if(noGoal.length)
nt.push('<div class="iw">有 <b>'+noGoal.length+'</b> 个工作区<b>还没登记目标</b>(只有机制骨架):'+
esc(noGoal.map(function(x){ return x.name; }).join("、"))+'</div>');
nt.push('<div class="iw">有 <b>'+noGoal.length+'</b> 个工作区<b>装了机制但还没登记目标</b>:'+
esc(noGoal.map(function(x){ return x.dir; }).join("、"))+
' ⇒ 在那些工作区里用 <code>goalctl declare --title "…"</code> 登记</div>');
var notRun = ps.filter(function(x){ return x.life && x.life !== "进行中"; });
if(notRun.length)
nt.push('<div class="iw">有 <b>'+notRun.length+'</b> 个目标状态<b>不是「进行中」</b>:'+
esc(notRun.map(function(x){ return x.name+"("+x.life+")"; }).join("、"))+'</div>');
esc(notRun.map(function(x){ return x.dir+"("+(x.name||"—")+":"+x.life+")"; }).join("、"))+'</div>');
setInfo(nt);
if(!ps.length){
@@ -418,16 +474,21 @@ function renderAll(){
(p.cards || []).forEach(function(c){
var x = {};
for(var k in c){ x[k] = c[k]; }
x._ws = p.dir; // 供 openDetail 反查所属项目
x._goal = p.name; // 卡片上的标签
x._ws = p.dir; // 🔴 卡片标签=**工作区名**(用户口径);也是 openDetail 反查键
x._goal = p.name; // 目标短名(⛔ 不上卡片标签,仅供详情/排查)
x._self = !!p.self; // 🔴 本工作区 ⇒ 卡片换色突出
x._life = p.life || "";
(byState[c.state] = byState[c.state] || []).push(x);
});
});
cols.innerHTML = '<div class="cols">' + d.cols.map(function(c){
// 🔴🔴 容器 `#cols` **本身**就带 `class="cols"`(已是 4 列 grid)⇒ 这里**只铺列**,
// ⛔ 绝不能再包一层 `<div class="cols">`。2026-10-06 实测踩到:套两层 grid 后,
// 内层作为外层的一个 grid item 先被压到 `min-width:auto`(=内容宽 399px),
// 再在 399px 里自己切 4 列 ⇒ 每列 89px、卡片 67px,**整块挤在左边一条**。
cols.innerHTML = d.cols.map(function(c){
return _colHtml(c.key, c.label, byState[c.key] || []);
}).join("") + '</div>';
}).join("");
_bindCards();
}
@@ -464,9 +525,13 @@ function renderOne(){
nt.push('<div class="iw">该目标状态为「'+esc(p.life)+'」('+esc(fmtWhen(p.life_at))+')。</div>');
setInfo(nt);
// 列
// 列(🔴 同样给每张卡片补 `_ws` / `_self`:单项目视图也要工作区标签+本区高亮)
var byState = {};
p.cards.forEach(function(c){ (byState[c.state] = byState[c.state] || []).push(c); });
p.cards.forEach(function(c){
var x = {}; for(var k in c){ x[k] = c[k]; }
x._ws = p.dir; x._goal = p.name; x._self = !!p.self; x._life = p.life || "";
(byState[c.state] = byState[c.state] || []).push(x);
});
cols.innerHTML = d.cols.map(function(c){
return _colHtml(c.key, c.label, byState[c.key] || []);
}).join("");