diff --git a/交付物/项目管理界面/project-board-launch.py b/交付物/项目管理界面/project-board-launch.py index d149dad..373bde1 100644 --- a/交付物/项目管理界面/project-board-launch.py +++ b/交付物/项目管理界面/project-board-launch.py @@ -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()`:计划任务的 **工作目录是 `交付物/项目管理界面`**(实测), +# 不是工作区根 ⇒ 那样算出来的"本工作区"是错的。 +# 正解=从本文件位置**往上两级**:`/交付物/项目管理界面/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) diff --git a/交付物/项目管理界面/project-board.html b/交付物/项目管理界面/project-board.html index e1db0a3..fcb73b2 100644 --- a/交付物/项目管理界面/project-board.html +++ b/交付物/项目管理界面/project-board.html @@ -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 @@
项目管理—
- - - + + +
加载中… "']/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 = ''; STATE.cur = -1; } else { - // 🔴 默认「全部」:一屏看完本机所有目标(⛔ 不再默认只显示一个) - sel.innerHTML = '' + // 🔴🔴 **本下拉=工作区选择器**(用户 2026-10-06 明确纠正: + // 「下拉框是选工作区的 跟目标没关系」)。 + // ⇒ ① 选项文字用 **工作区名**(`dir` 目录名),⛔ 不是目标名; + // ② 计数说 **N 个工作区**,⛔ 不说"N 个目标"(那是另一个维度); + // ③ 装了机制的工作区**一律列出**(含还没登记目标的)—— 用户明令 + // 「加载机制就可以显示」(⛔ 不许因为没目标就藏起来)。 + // ⚠️ 我上一版写成「全部(N 个目标)」,是把两个维度混了 —— 已纠正。 + sel.innerHTML = '' + d.projects.map(function(p,i){ - return ''; }).join(""); + // 有目标 ⇒ 目录名 + 目标名;没目标 ⇒ 只目录名(⛔ 不印「未命名」那种看不懂的话) + var lab = p.dir + (p.has_goal && p.name ? '('+p.name+')' : ''); + return ''; }).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(''+esc(t._goal)+''); + // 🔴 用户 2026-10-06:「工作区名称要不要 放到卡片下面」⇒ 采纳。 + // 卡片结构改成**三段**:① 标题 ② 受阻原因(有才显示)③ **卡片下方一行**= + // `工作区名` + 线/执行者/时间。 + // ⚠️ 工作区名**从 `.meta` 里挪出去单列** —— 它是这一屏唯一的"归属"信息, + // 混在其它 badge 里会被忽略。 + // 标签取 `_ws`(目录名,如 `ai1net-dsh-server`),⛔ 不是 `_goal`(目标短名, + // 用户明确「不要显示 本机协作这种」)。 if(t.line) bits.push(''+esc(t.line)+''); if(t.by) bits.push(''+esc(t.by)+''); - if(t.t_end) bits.push(esc(fmtWhen(t.t_end))); - else if(t.t_start) bits.push(esc(fmtWhen(t.t_start))); - return '
'+ + var when = t.t_end ? fmtWhen(t.t_end) : (t.t_start ? fmtWhen(t.t_start) : ""); + if(when) bits.push(''+esc(when)+''); + // 🔴 本工作区的卡片换色突出(2026-10-06 用户要);判定来自服务端 `_self`,⛔ 前端不猜 + return '
'+ '
'+esc(t.title||t.id)+'
'+ (t.block_reason ? '
受阻:'+esc(t.block_reason)+'
' : '')+ (bits.length ? '
'+bits.join("")+'
' : '')+ + // 🔴 末行=工作区(用户要的「放到卡片下面」) + (t._ws ? '
'+esc(t._ws)+''+ + (t._self ? '本工作区' : '')+'
' : '')+ '
'; } @@ -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('目标 '+ps.length+' 个'); - s.push('工作区 '+(d.roots||[]).join(" · ")+''); + s.push('工作区 '+nWs+' 个'); + s.push('其中已登记目标 '+nGoal+' 个'); s.push('台账合计 '+tot.total+' 件'); s.push('已完成 '+tot.done+''); s.push('未完 '+tot.open+''); @@ -399,12 +454,13 @@ function renderAll(){ var nt = []; var noGoal = ps.filter(function(x){ return !x.has_goal; }); if(noGoal.length) - nt.push('
有 '+noGoal.length+' 个工作区还没登记目标(只有机制骨架):'+ - esc(noGoal.map(function(x){ return x.name; }).join("、"))+'
'); + nt.push('
有 '+noGoal.length+' 个工作区装了机制但还没登记目标:'+ + esc(noGoal.map(function(x){ return x.dir; }).join("、"))+ + ' ⇒ 在那些工作区里用 goalctl declare --title "…" 登记
'); var notRun = ps.filter(function(x){ return x.life && x.life !== "进行中"; }); if(notRun.length) nt.push('
有 '+notRun.length+' 个目标状态不是「进行中」:'+ - esc(notRun.map(function(x){ return x.name+"("+x.life+")"; }).join("、"))+'
'); + esc(notRun.map(function(x){ return x.dir+"("+(x.name||"—")+":"+x.life+")"; }).join("、"))+'
'); 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 = '
' + d.cols.map(function(c){ + // 🔴🔴 容器 `#cols` **本身**就带 `class="cols"`(已是 4 列 grid)⇒ 这里**只铺列**, + // ⛔ 绝不能再包一层 `
`。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("") + '
'; + }).join(""); _bindCards(); } @@ -464,9 +525,13 @@ function renderOne(){ nt.push('
该目标状态为「'+esc(p.life)+'」('+esc(fmtWhen(p.life_at))+')。
'); 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(""); diff --git a/交付物/项目管理界面/project-board.py b/交付物/项目管理界面/project-board.py index 110c035..1678440 100644 --- a/交付物/项目管理界面/project-board.py +++ b/交付物/项目管理界面/project-board.py @@ -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(),