Files
dsh_ai1net_server/交付物/项目管理界面/project-board.html
T
admin 5d0745c470 项目管理界面:修列宽塌陷 + 工作区维度纠偏 + 卡片信息重排
一、修「卡片挤到一起去了」(用户 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)。
2026-10-06 09:38:28 +08:00

724 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>项目管理 · 执行任务</title>
<style>
:root{
--bg:#0f1115; --panel:#171a21; --panel2:#1e222b; --line:#2a2f3a;
--fg:#e6e9ef; --dim:#98a2b3; --dim2:#6b7686;
--accent:#4c6ef5; --warn:#f0a020; --bad:#e5534b; --ok:#3fb950;
--r:10px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.55 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}
a{color:inherit;text-decoration:none}
/* ── 顶栏:项目选择 ── */
header{position:sticky;top:0;z-index:20;background:rgba(15,17,21,.92);
backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.hrow{display:flex;align-items:center;gap:14px;padding:12px 18px;flex-wrap:wrap}
.brand{font-weight:600;letter-spacing:.2px}
.brand small{color:var(--dim2);font-weight:400;margin-left:8px}
select,button{background:var(--panel2);color:var(--fg);border:1px solid var(--line);
border-radius:8px;padding:7px 11px;font:inherit;cursor:pointer}
select:focus,button:focus{outline:2px solid var(--accent);outline-offset:1px}
.spacer{flex:1}
.stamp{color:var(--dim2);font-size:12px}
/* ── 项目概要 ── */
.summary{display:flex;gap:10px;padding:0 18px 12px;flex-wrap:wrap}
.chip{background:var(--panel);border:1px solid var(--line);border-radius:999px;
padding:4px 11px;font-size:12px;color:var(--dim)}
.chip b{color:var(--fg);font-weight:600}
/* ── 列 ── */
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: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)}
/* 🔴 卡片间距压紧(原 `gap:9px` + `padding:10px` 在长列里显得很空)*/
.cards{padding:8px;display:flex;flex-direction:column;gap:6px}
/* ── 统一视图:卡片**末行**显示工作区(🔴 归属靠这一行,⛔ 不靠分区)── */
/* 用户 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: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}
.card .meta{margin-top:6px;color:var(--dim2);font-size:12px;
display:flex;gap:9px;flex-wrap:wrap}
.badge{display:inline-block;border-radius:5px;padding:1px 6px;font-size:11px;
border:1px solid var(--line);color:var(--dim)}
.empty{color:var(--dim2);font-size:12px;text-align:center;padding:18px 6px}
.warnline{margin:0 0 12px;padding:10px 12px;border-radius:8px;
background:rgba(240,160,32,.09);border:1px solid rgba(240,160,32,.35);
color:#f4c67a;font-size:12.5px}
.errline{margin:0 0 12px;padding:10px 12px;border-radius:8px;
background:rgba(229,83,75,.1);border:1px solid rgba(229,83,75,.4);color:#f2a09a}
.okline{margin:0 0 12px;padding:10px 12px;border-radius:8px;
background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.4);color:#8fe39a;
font-size:12.5px;white-space:pre-wrap;word-break:break-word}
pre.out{margin:8px 0 0;padding:10px;border-radius:8px;background:#12141a;
border:1px solid var(--line);color:var(--dim);font-size:11.5px;
max-height:260px;overflow:auto;white-space:pre-wrap}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
button.danger{background:transparent;border-color:rgba(229,83,75,.5);color:#f2a09a}
button.danger:hover{background:rgba(229,83,75,.12)}
button[disabled]{opacity:.5;cursor:not-allowed}
/* ── 新建对话框 ── */
.modal{position:fixed;z-index:40;left:50%;top:50%;transform:translate(-50%,-50%) scale(.98);
width:min(560px,92vw);background:var(--panel);border:1px solid var(--line);
border-radius:12px;box-shadow:0 18px 50px rgba(0,0,0,.5);
opacity:0;pointer-events:none;transition:opacity .14s ease,transform .14s ease}
.modal.on{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1)}
.hint{color:var(--dim);font-size:12.5px;margin:0 0 14px}
.hint b{color:var(--fg)}
.fld{display:flex;flex-direction:column;gap:5px;margin-bottom:11px}
.fld>span{color:var(--dim);font-size:12px}
.fld input,.fld select{background:var(--panel2);color:var(--fg);border:1px solid var(--line);
border-radius:8px;padding:8px 10px;font:inherit;width:100%}
.fld input:focus,.fld select:focus{outline:2px solid var(--accent);outline-offset:1px}
.fnote{margin:2px 0 12px;font-size:12px;color:var(--dim2);min-height:1.2em}
.fnote.bad{color:#f2a09a}
.facts{display:flex;align-items:center;gap:10px}
/* ── 「!」状态图标 + 悬浮说明(⚠️ 提示一律收进这里,⛔ 不铺在工作台上)── */
.info{position:relative;display:inline-flex;align-items:center;justify-content:center;
width:22px;height:22px;border-radius:50%;font-size:13px;font-weight:700;line-height:1;
border:1px solid var(--line);background:var(--panel2);color:var(--dim);cursor:pointer;
user-select:none;flex:none}
.info.idle{opacity:.42} /* 没内容时淡掉,但仍在位 */
.info.fancy:not(.idle){border-color:var(--warn);color:var(--warn)}
.info:focus{outline:2px solid var(--accent);outline-offset:2px}
.infopop{position:absolute;top:calc(100% + 8px);right:0;z-index:30;width:min(480px,92vw);
background:var(--panel);border:1px solid var(--line);border-radius:10px;
box-shadow:0 12px 34px rgba(0,0,0,.34);padding:12px 14px;display:none}
.infopop.on{display:block}
.infopop .ih{font-size:12px;color:var(--dim2);font-weight:600;letter-spacing:.5px;
text-transform:uppercase;margin:0 0 8px}
.infopop .iw{font-size:13px;line-height:1.62;padding:7px 0;border-top:1px dashed var(--line);
color:var(--fg)}
.infopop .iw:first-of-type{border-top:0;padding-top:0}
.infopop .iw b{color:var(--warn)}
.infopop .iw code{background:var(--panel2);padding:1px 5px;border-radius:4px;font-size:12px}
/* ── 一次性操作反馈(自动消失;⛔ 不常驻)── */
.toast{position:fixed;left:50%;bottom:26px;transform:translate(-50%,14px);z-index:60;
max-width:min(680px,92vw);padding:11px 15px;border-radius:10px;font-size:13px;
line-height:1.55;border:1px solid var(--line);background:var(--panel);
box-shadow:0 10px 30px rgba(0,0,0,.3);opacity:0;pointer-events:none;
transition:opacity .16s ease, transform .16s ease;word-break:break-word}
.toast.on{opacity:1;transform:translate(-50%,0);pointer-events:auto}
.toast.ok{border-color:var(--ok)}
.toast.err{border-color:var(--bad);color:#f2a09a}
.toast pre.out{margin:8px 0 0;max-height:150px}
/* 时效警示:数据 > 15 秒没动 ⇒ 转黄(⛔ 不靠用户猜"刷新了没") */
.stamp.is-stale{color:var(--warn)}
/* ── 抽屉 ── */
.mask{position:fixed;inset:0;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;
transition:opacity .15s ease;z-index:40}
.mask.on{opacity:1;pointer-events:auto}
aside{position:fixed;top:0;right:0;height:100%;width:min(560px,94vw);background:var(--panel);
border-left:1px solid var(--line);z-index:50;transform:translateX(100%);
transition:transform .18s ease;display:flex;flex-direction:column}
aside.on{transform:none}
.ahead{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line)}
.ahead .ttl{font-weight:600;flex:1;word-break:break-word}
.abody{overflow:auto;padding:14px 16px 30px}
.kv{display:grid;grid-template-columns:104px 1fr;gap:7px 12px;font-size:13px}
.kv dt{color:var(--dim2)}.kv dd{margin:0;word-break:break-word}
.sect{margin-top:18px}
.sect h4{margin:0 0 8px;font-size:12px;color:var(--dim2);font-weight:600;
text-transform:uppercase;letter-spacing:.6px}
.sess{display:flex;align-items:center;gap:9px;padding:8px 10px;border:1px solid var(--line);
border-radius:8px;margin-bottom:7px;background:var(--panel2);cursor:pointer}
.sess:hover{border-color:var(--accent)}
.sess .sn{flex:1;word-break:break-word;font-size:13px}
.sess .go{color:var(--accent);font-size:12px;flex:none}
.flow{display:flex;align-items:center;gap:9px;padding:6px 0;border-bottom:1px dashed var(--line);font-size:13px}
.flow:last-child{border-bottom:0}
.flow .fn{flex:1}
.flow .fc{color:var(--dim2);font-size:12px}
</style>
</head>
<body>
<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>
<div class="spacer"></div>
<span class="stamp" id="stamp">加载中…</span>
<span class="info fancy" id="infobtn" tabindex="0" role="button" aria-label="状态说明"
title="状态说明">!</span>
<button id="btnnew" type="button" class="primary">+ 新建目标</button>
<button id="refresh" type="button" title="立即重新扫一遍本机所有工作区">刷新</button>
</div>
<div class="summary" id="summary"></div>
</header>
<main>
<div class="cols" id="cols"></div>
</main>
<div class="toast" id="toast"></div>
<div class="mask" id="mask"></div>
<aside id="drawer" aria-hidden="true">
<div class="ahead">
<div class="ttl" id="dtitle">—</div>
<button id="dclose" type="button">关闭</button>
</div>
<div class="abody" id="dbody"></div>
</aside>
<!-- 新建目标(新工作区)对话框 -->
<div class="mask" id="cmask"></div>
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="mtitle">
<div class="ahead">
<div class="ttl" id="mtitle">新建目标(新工作区)</div>
<button id="mclose" type="button">关闭</button>
</div>
<div class="abody">
<p class="hint">在选定的根目录下建一个新工作区,并写入目标登记。走技能自带的建区入口(幂等)。
<b>目录名</b>同时作为工作区名,<b>目标标题</b>是「目标=项目」里的项目名。</p>
<label class="fld"><span>根目录</span><select id="froot"></select></label>
<label class="fld"><span>工作区名(目录名)</span>
<input id="fname" type="text" placeholder="例:my-new-project" autocomplete="off"></label>
<label class="fld"><span>目标标题</span>
<input id="ftitle" type="text" placeholder="例:把 XX 做成 YY" autocomplete="off"></label>
<label class="fld"><span>项目简称(可空 ⇒ 用工作区名)</span>
<input id="fshort" type="text" placeholder="例:XX 项目" autocomplete="off"></label>
<div class="fnote" id="fnote"></div>
<div class="facts">
<button id="mok" type="button" class="primary">创建</button>
<button id="mcancel" type="button">取消</button>
<span class="stamp" id="mstamp"></span>
</div>
</div>
</div>
<script>
"use strict";
var STATE = { data:null, cur:-1, epoch:0, sameHits:0, sig:"" }; // cur=-1 ⇒ 「全部」
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, function(c){
return ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]; }); }
function fmtWhen(s){
if(!s) return "—";
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];
}
/* ── 时效条:数据「多久没动」 ──
🔴 两条口径(照现有看板 board.html::paintAge 的做法):
① 读数由**服务给的 epoch** 算,⛔ 不用本地时钟顶上;
② 超阈值 ⇒ 整块转警示色,让"数据停了"一眼可见(⛔ 不靠用户猜)。 */
var AGE_STALE = 15; // 秒;超过 ⇒ 转警示色
function paintAge(){
var el = document.getElementById("stamp");
if(!el || !STATE.epoch){ return; }
var s = Math.max(0, Math.round(Date.now()/1000 - STATE.epoch));
el.textContent = "数据 " + s + " 秒前";
el.classList.toggle("is-stale", s > AGE_STALE);
}
/* 🔴 轮询节奏=服务的扫描节奏(⛔ 页面不自行加频:加了只是空转,不会更快)。
联动:数据是新的 ⇒ 3 秒一探;连续多次没变化 ⇒ 逐步放到 15 秒(省电、省 CPU),
一旦有变化立刻回到 3 秒。⛔ 不做"页面醒了才刷"这类花活,避免假刷新。 */
var POLL_FAST = 3000, POLL_SLOW = 15000;
function currentPoll(){
return (STATE.sameHits >= 3) ? POLL_SLOW : POLL_FAST;
}
function loop(){
load();
setTimeout(loop, currentPoll());
}
function setInfo(items){
var btn = document.getElementById("infobtn");
var old = document.getElementById("infopop");
if(old) old.parentNode.removeChild(old);
if(!items || !items.length){
btn.classList.add("idle"); // 无内容 ⇒ 淡显、不可点
btn.title = "无特别说明";
btn.setAttribute("aria-disabled","true");
return;
}
btn.classList.remove("idle");
btn.removeAttribute("aria-disabled");
btn.title = items.length + " 条状态说明(点开看)";
var pop = document.createElement("div");
pop.className = "infopop"; pop.id = "infopop";
pop.innerHTML = '<div class="ih">状态说明 · '+items.length+' 条</div>' + items.join("");
btn.parentNode.appendChild(pop);
}
/* ── 操作结果提示(新建/删除这类一次性反馈;⛔ 不常驻工作台)── */
function toast(kind, html, ms){
var el = document.getElementById("toast");
el.className = "toast on " + (kind || "ok");
el.innerHTML = html;
clearTimeout(toast._t);
if(kind !== "err") toast._t = setTimeout(function(){ el.className = "toast"; }, ms || 4200);
}
function toggleInfo(force){
var pop = document.getElementById("infopop");
if(!pop) return;
var on = (typeof force === "boolean") ? force : !pop.classList.contains("on");
pop.classList.toggle("on", on);
}
function load(manual){
if(manual) document.getElementById("stamp").textContent = "扫描中…";
fetch("/api/projects", {cache:"no-store"}).then(function(r){
if(!r.ok) throw new Error("HTTP " + r.status); // ⛔ 别把 404 当空数据静默吞掉
return r.json();
})
.then(function(d){
if(!d || d.ok === false){
toast("err", '扫工作区失败:' + esc((d&&d.err)||"未知错误"));
return;
}
STATE.data = d;
if(manual) toast("ok", "已刷新(" + esc(d.ts || "") + ")", 2200);
document.getElementById("ws").textContent = (d.roots||[]).join(" · ") || "—";
/* 🔴 时效**只用服务给的 epoch** 算(⛔ 不用本地 Date.now() 顶上),
由 setInterval(paintAge) 每秒重画 ⇒ 页面"看起来在动"但数据没变时,
读数会如实爬到 5 秒以上 ⇒ 一眼看出"只是轮询、数据没动"。 */
STATE.epoch = Number(d.epoch || 0) || (Date.now() / 1000);
paintAge();
/* 变化探测:内容没变 ⇒ 逐步拉长轮询间隔(省电);一变立刻回到 3 秒 */
var sig = JSON.stringify(d.projects.map(function(p){
return [p.dir, p.life, p.life_at, p.has_goal, (p.cards||[]).length,
p.queue.done, p.queue.open, p.queue.blocked,
(p.acc||{}).pass, (p.acc||{}).n]; }));
if(sig === STATE.sig){ STATE.sameHits = (STATE.sameHits||0) + 1; }
else { STATE.sig = sig; STATE.sameHits = 0; }
var sel = document.getElementById("psel");
if(!d.projects.length){
sel.innerHTML = '<option value="-1">(本机暂无装了机制的工作区)</option>';
STATE.cur = -1;
} else {
// 🔴🔴 **本下拉=工作区选择器**(用户 2026-10-06 明确纠正:
// 「下拉框是选工作区的 跟目标没关系」)。
// ⇒ ① 选项文字用 **工作区名**(`dir` 目录名),⛔ 不是目标名;
// ② 计数说 **N 个工作区**,⛔ 不说"N 个目标"(那是另一个维度);
// ③ 装了机制的工作区**一律列出**(含还没登记目标的)—— 用户明令
// 「加载机制就可以显示」(⛔ 不许因为没目标就藏起来)。
// ⚠️ 我上一版写成「全部(N 个目标)」,是把两个维度混了 —— 已纠正。
sel.innerHTML = '<option value="-1">全部('+d.projects.length+' 个工作区)</option>'
+ d.projects.map(function(p,i){
// 有目标 ⇒ 目录名 + 目标名;没目标 ⇒ 只目录名(⛔ 不印「未命名」那种看不懂的话)
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);
}
render();
}).catch(function(e){
toast("err", '请求失败:'+esc(e));
});
}
/* 🔴 卡片状态来自台账;这里按「目标」分组渲染。
`STATE.cur === -1` ⇒ **全部目标**(默认):一屏看完本机所有项目的卡片,按目标分段。
`STATE.cur >= 0` ⇒ 只看某一个目标(原行为)。 */
function _stateDot(key){
return key==="done" ? "var(--ok)" : key==="blocked" ? "var(--bad)"
: key==="running" ? "var(--accent)" : "var(--dim2)";
}
function _cardHtml(t){
var dot = _stateDot(t.state), bits = [];
// 🔴 用户 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>');
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>';
}
function _colHtml(key, label, list){
var inner = list.length ? list.map(_cardHtml).join("")
: '<div class="empty">(此列无卡片)</div>';
return '<section class="col"><div class="colhead"><span class="dot" style="background:'+
_stateDot(key)+'"></span><span class="coltitle">'+esc(label)+'</span><span class="count">'+
list.length+'</span></div><div class="cards">'+inner+'</div></section>';
}
function _bindCards(){
Array.prototype.forEach.call(document.querySelectorAll(".card"), function(el){
el.addEventListener("click", function(){
openDetail(el.getAttribute("data-tid"), el.getAttribute("data-pw"));
});
});
}
/* ── 统一视图(⛔ 不按工作区分块):一张四列看板,卡片自带「目标/工作区」标签 ──
用户 2026-10-06:「这里分类不要按照工作区分了,记住 项目管理是统一管理」 */
function renderAll(){
var d = STATE.data, ps = d.projects;
var sums = document.getElementById("summary");
var cols = document.getElementById("cols");
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>'+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>');
if(tot.blocked) s.push('<span class="chip">受阻 <b>'+tot.blocked+'</b></span>');
sums.innerHTML = s.join("");
// 提示(同样收进「!」)
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.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.dir+"("+(x.name||"—")+":"+x.life+")"; }).join("、"))+'</div>');
setInfo(nt);
if(!ps.length){
cols.innerHTML = '<div class="empty">本机没有装了机制的工作区。点右上角「+ 新建目标」建一个。</div>';
return;
}
// 🔴 所有目标的所有卡片汇到一张板;每张卡片标出「目标 · 工作区」
var byState = {};
ps.forEach(function(p){
(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._self = !!p.self; // 🔴 本工作区 ⇒ 卡片换色突出
x._life = p.life || "";
(byState[c.state] = byState[c.state] || []).push(x);
});
});
// 🔴🔴 容器 `#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("");
_bindCards();
}
/* ── 视图 B:单个目标(原行为)── */
function renderOne(){
var d = STATE.data;
var p = d.projects[STATE.cur];
var sums = document.getElementById("summary");
var cols = document.getElementById("cols");
if(!p){ renderAll(); return; }
document.getElementById("btndel").disabled = false;
// 概要
var q = p.queue, s = [];
s.push('<span class="chip">项目名 <b>'+esc(p.name)+'</b></span>');
s.push('<span class="chip">工作区 <b>'+esc(p.dir)+'</b></span>');
s.push('<span class="chip">建立 <b>'+esc(fmtWhen(p.declared_at))+'</b></span>');
s.push('<span class="chip">台账 <b>'+q.total+'</b> 件</span>');
s.push('<span class="chip">已完成 <b>'+q.done+'</b></span>');
s.push('<span class="chip">未完 <b>'+q.open+'</b></span>');
if(q.blocked) s.push('<span class="chip">受阻 <b>'+q.blocked+'</b></span>');
if(p.acc && p.acc.n) s.push('<span class="chip">验收 <b>'+p.acc.pass+' / '+p.acc.n+'</b></span>');
s.push('<span class="chip">工作流 <b>'+p.flows.length+'</b></span>');
if(p.life) s.push('<span class="chip">目标状态 <b>'+esc(p.life)+'</b></span>');
if(p.topics && p.topics.length)
s.push('<span class="chip">任务类别 <b>'+esc(p.topics.join("、"))+'</b></span>');
sums.innerHTML = s.join("");
// ── 提示:一律收进右上角「!」图标里(⛔ 不铺在工作台)──
var nt = [];
if(!p.has_goal)
nt.push('<div class="iw">这个工作区<b>还没有登记目标</b>(只有机制骨架)⇒ 项目名是兜底的,卡片按台账显示。</div>');
if(p.life && p.life !== "进行中")
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){
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("");
// 卡片点开 → 详情(含跳会话)
_bindCards();
}
/* ── 视图分发 ── */
function render(){
if(!STATE.data) return;
if(STATE.cur < 0) renderAll(); else renderOne();
}
function openDetail(tid, wsHint){
var proj = STATE.data.projects;
// 「全部」视图下卡片自带所属工作区;单项目视图下就是当前那个
var p = (wsHint ? proj.filter(function(x){ return x.dir === wsHint; })[0] : null)
|| proj[STATE.cur] || {};
var t = (p.cards || []).filter(function(x){ return x.id === tid; })[0] || {};
var label = {}; STATE.data.cols.forEach(function(c){ label[c.key] = c.label; });
document.getElementById("dtitle").textContent = tid;
var h = [];
h.push('<dl class="kv">');
h.push('<dt>所属项目</dt><dd>'+esc(p.name)+'</dd>');
h.push('<dt>状态</dt><dd>'+esc(label[t.state]||t.state||"—")+'</dd>');
if(t.line) h.push('<dt>工作流</dt><dd>'+esc(t.line)+'</dd>');
if(t.by) h.push('<dt>负责</dt><dd>'+esc(t.by)+'</dd>');
if(t.t_start) h.push('<dt>开始</dt><dd>'+esc(fmtWhen(t.t_start))+'</dd>');
if(t.t_end) h.push('<dt>结束</dt><dd>'+esc(fmtWhen(t.t_end))+'</dd>');
if(t.block_reason) h.push('<dt>受阻原因</dt><dd style="color:#f2a09a">'+esc(t.block_reason)+'</dd>');
if(t.artifact) h.push('<dt>产出</dt><dd>'+esc(t.artifact)+'</dd>');
h.push('<dt>工作区</dt><dd>'+esc(p.ws)+'</dd>');
h.push('</dl>');
// 验收判据(本项目达成与否的唯一机读依据)
h.push('<div class="sect"><h4>验收判据('+((p.acc&&p.acc.n)||0)+' 条 · 通过 '+((p.acc&&p.acc.pass)||0)+')</h4>');
if(p.acc && p.acc.items && p.acc.items.length){
p.acc.items.forEach(function(a){
h.push('<div class="flow"><span class="fn">'+esc(a.k)+'</span>'+
'<span class="fc" style="color:'+(a.pass?"#8fe39a":"#f2a09a")+'">'+
(a.pass?"过":"未过")+' · '+esc(a.v.slice(0,120))+'</span></div>');
});
} else h.push('<div class="empty">(无验收判据)</div>');
h.push('</div>');
// 工作流
h.push('<div class="sect"><h4>工作流(任务类别)</h4>');
if((p.flows||[]).length){
p.flows.forEach(function(f){
h.push('<div class="flow"><span class="fn">'+esc(f.line)+'</span>'+
'<span class="fc">共 '+f.total+' · 完成 '+f.done+' · 未完 '+f.open+'</span></div>');
});
} else h.push('<div class="empty">(无)</div>');
h.push('</div>');
document.getElementById("dbody").innerHTML = h.join("");
document.getElementById("mask").classList.add("on");
document.getElementById("drawer").classList.add("on");
document.getElementById("drawer").setAttribute("aria-hidden","false");
}
function closeDetail(){
document.getElementById("mask").classList.remove("on");
document.getElementById("drawer").classList.remove("on");
document.getElementById("drawer").setAttribute("aria-hidden","true");
}
/* ── 新建目标(新工作区) ── */
function openNew(){
var d = STATE.data || {};
var r = document.getElementById("froot");
r.innerHTML = (d.roots||[]).map(function(x){
return '<option value="'+esc(x)+'">'+esc(x)+'</option>'; }).join("");
document.getElementById("fname").value = "";
document.getElementById("ftitle").value = "";
document.getElementById("fshort").value = "";
document.getElementById("fnote").textContent = "";
document.getElementById("fnote").className = "fnote";
document.getElementById("mstamp").textContent = "";
document.getElementById("mok").disabled = false;
document.getElementById("cmask").classList.add("on");
document.getElementById("modal").classList.add("on");
document.getElementById("fname").focus();
}
function closeNew(){
document.getElementById("cmask").classList.remove("on");
document.getElementById("modal").classList.remove("on");
}
function doCreate(){
var root = document.getElementById("froot").value;
var name = document.getElementById("fname").value.trim();
var title = document.getElementById("ftitle").value.trim();
var short = document.getElementById("fshort").value.trim();
var note = document.getElementById("fnote");
if(!name || /[\/\\<>:"|?*]/.test(name)){
note.className = "fnote bad";
note.textContent = "工作区名不合法(不许空、不许带路径分隔符或 <>:\"|?*)";
return;
}
if(!title){ title = name; }
document.getElementById("mok").disabled = true;
document.getElementById("mstamp").textContent = "创建中…(要跑建区脚本,稍等)";
fetch("/api/create", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({root:root, name:name, title:title, short:short})})
.then(function(r){ return r.json(); })
.then(function(d){
document.getElementById("mok").disabled = false;
if(d.ok){
note.className = "fnote";
note.innerHTML = '✅ 已创建:<b>'+esc(d.ws)+'</b>';
document.getElementById("mstamp").textContent = "";
toast("ok", '创建成功:'+esc(d.ws) + (d.out ? '<pre class="out">'+esc(d.out)+'</pre>' : ""));
setTimeout(function(){ closeNew(); load(); }, 900);
} else {
note.className = "fnote bad";
note.textContent = "创建失败:" + (d.err || "未知错误");
document.getElementById("mstamp").textContent = "rc=" + (d.rc==null?"-":d.rc);
}
}).catch(function(e){
document.getElementById("mok").disabled = false;
note.className = "fnote bad";
note.textContent = "请求失败:" + e;
});
}
/* ── 删除当前项目 ── */
function doDelete(){
var p = (STATE.data||{}).projects && STATE.data.projects[STATE.cur];
if(!p) return;
var m = prompt("删除「"+p.name+"」——选择粒度:\n\n"+
"输入 1 = 只删【目标登记】(工作区与台账原样保留,可逆;项目从列表消失)\n"+
"输入 2 = 把【整个工作区】送回收站(不永久删除,可从回收站还原)\n\n"+
"(不输入或取消 = 什么都不做)", "1");
if(m !== "1" && m !== "2") return;
var mode = (m === "2") ? "ws" : "goal";
if(mode === "ws" &&
!confirm("确认把整个工作区送回收站?\n\n"+p.ws+"\n\n会连同台账、目标、脚本一起移走。")) return;
fetch("/api/delete", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ws:p.ws, mode:mode})})
.then(function(r){ return r.json(); })
.then(function(d){
if(d.ok){
toast("ok", '已删除('+(mode==="ws"?"整个工作区送回收站":"目标登记归档")+
'):'+esc(d.moved_to||d.trashed||""));
load();
} else {
toast("err", '删除失败:'+esc(d.err||"未知错误"));
}
}).catch(function(e){
toast("err", '请求失败:'+esc(e));
});
}
document.getElementById("mask").addEventListener("click", closeDetail);
document.getElementById("dclose").addEventListener("click", closeDetail);
document.getElementById("cmask").addEventListener("click", closeNew);
document.getElementById("mclose").addEventListener("click", closeNew);
document.getElementById("mcancel").addEventListener("click", closeNew);
document.getElementById("mok").addEventListener("click", doCreate);
document.getElementById("btnnew").addEventListener("click", openNew);
document.getElementById("btndel").addEventListener("click", doDelete);
document.getElementById("psel").addEventListener("change", function(e){
STATE.cur = parseInt(e.target.value, 10);
if(isNaN(STATE.cur)) STATE.cur = -1;
render(); });
document.getElementById("refresh").addEventListener("click", function(){ load(true); });
/* 「!」状态说明:点开/再点关、悬浮可看、点外面关 */
(function(){
var btn = document.getElementById("infobtn");
btn.addEventListener("click", function(e){ e.stopPropagation(); toggleInfo(); });
btn.addEventListener("keydown", function(e){
if(e.key === "Enter" || e.key === " "){ e.preventDefault(); toggleInfo(); } });
document.addEventListener("click", function(e){
var pop = document.getElementById("infopop");
if(pop && pop.classList.contains("on") && !btn.contains(e.target)) toggleInfo(false);
});
})();
document.addEventListener("keydown", function(e){
if(e.key === "Escape"){ closeDetail(); closeNew(); toggleInfo(false); } });
loop(); // 轮询节奏会随「数据有没有变」自适应(见 currentPoll)
setInterval(paintAge, 1000); // 时效读数每秒重画(⛔ 不重新取数)
</script>
</body>
</html>