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

一、修「卡片挤到一起去了」(用户 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

@@ -31,6 +31,13 @@ TARGET = os.path.join(HERE, "project-board.py")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
os.environ.setdefault("DSH_PROJECT_BOARD_UPSTREAM", "http://127.0.0.1:20099/board.json")
# 🔴 「本工作区」=**拥有一这层的那个工作区**(用户 2026-10-06 要:本工作区的卡片换色突出)。
# ⛔ 不许靠 `Path.cwd()`:计划任务的 **工作目录是 `交付物/项目管理界面`**(实测),
# 不是工作区根 ⇒ 那样算出来的"本工作区"是错的。
# 正解=从本文件位置**往上两级**:`<ws>/交付物/项目管理界面/project-board-launch.py`。
os.environ.setdefault("DSH_PM_BOARD_SELF_WS",
os.path.dirname(os.path.dirname(HERE)))
def port_listening(port: int) -> bool:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+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("");
+22 -1
View File
@@ -76,6 +76,12 @@ SKILL_SCRIPTS = Path(os.environ.get("DSH_SKILL_SCRIPTS")
# 可选:某个区的旧看板(**只作补充**,主力是直读文件)
UPSTREAM = os.environ.get("DSH_PROJECT_BOARD_UPSTREAM") or ""
# 🔴 「本工作区」=**起这个服务的那个工作区**(2026-10-06 用户要:本工作区的卡片换色突出)。
# ⛔ 不许在前端硬编码目录名 —— 换机 / 改名就漂。
# 取法:环境变量优先(launch 脚本可显式注入),否则用**进程 cwd**(计划任务的动作 cwd
# 就是工作区根)⇒ 两者都拿不到就 `""`(前端一律不高亮,⛔ 不猜)。
SELF_WS = (os.environ.get("DSH_PM_BOARD_SELF_WS") or "").strip() or str(Path.cwd())
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # ⛔ 绕过 HTTP_PROXY
@@ -200,7 +206,12 @@ def build_project(ws: Path) -> dict:
short = str(goal.get("short") or "").strip()
title = str(goal.get("title") or "").strip()
name = short or title or ("(未命名 · %s)" % ws.name)
# 🔴🔴 **`title` 优先,`short` 兜底**(2026-10-06 用户纠正)。
# 改前是 `short or title` ⇒ 凡登记过 `short` 的区,界面一律显示**短名**
# (`vibe-product` 这种目录名),用户原话:「上面的这个…显示的是 目录名,
# 应该是真正名称」。`short` 是给**文件名/路径**用的短标识,⛔ 不是给人读的名字。
# ⇒ 有 `title` 就显示 `title`;没 `title` 才回落 `short`;都没有 ⇒ 标「未登记目标」。
name = title or short or ("未登记目标 · %s" % ws.name)
# ── 卡片(台账条目;只取四态里的)
keys = {c[0] for c in COLS}
@@ -246,10 +257,20 @@ def build_project(ws: Path) -> dict:
f["open"] += 1
topics = [str(x) for x in (goal.get("topics") or []) if str(x).strip()]
# 🔴 「本工作区」标记(2026-10-06 用户要:本工作区的卡片换个颜色突出)。
# ⛔ 不在前端硬编码目录名 —— 那份名单会随换机/改名漂掉。
# 判据=**服务进程自己的启动目录**(launch 脚本就是从该工作区起的),
# 与 `SelfWs` 同源;比不出来就一律 False(⛔ 不猜)。
try:
_self_ws = Path(SELF_WS).resolve()
_is_self = ws.resolve() == _self_ws
except Exception: # noqa: BLE001
_is_self = False
return {
"ws": str(ws).replace("\\", "/"),
"dir": ws.name,
"name": name,
"self": _is_self,
"title": title,
"topics": topics,
"life": str(goal.get("lifecycle") or "").strip(),