Files
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

790 lines
42 KiB
HTML
Raw Permalink 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` ⇒ 每列**各自撑自己的高度**,空列就是一个小盒子。*/
/* 🔴🔴 **四列并排等宽**(2026-10-06 用户「之前是这样吗 现在是改乱后的样子」⇒ 恢复)。
⛔ 别再动这四行 —— 我曾两度以「空列白占屏 / 卡片看不到」为由改过列宽,
改成 flex+空列收窄(三窄一宽),用户判为「改乱了」。
📌 判据:**四态列是固定维度,必须并排等宽**(一眼看全四个状态区),
"某个状态是空的"不是布局问题 —— ⛔ 不许因空列去改列宽。*/
.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)}
/* 🔴 「当前目标」标记(一个区常有多个目标,得看得出现在在跑哪个) */
.badge.cur{background:rgba(76,110,245,.2);border-color:rgba(76,110,245,.6);color:#b9c8ff}
.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)—— 2026-10-06 用户:「判据用卡片显示,现在列表字段都挤成竖列了」。
⛔ 保留定义仅作兼容,新代码一律用下面 `.pcard`(卡片式)。 */
.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}
/* ── 🔴🔴 **判据卡片**(2026-10-06 用户:「判据 用卡片显示,现在列表字段都挤成竖列了」)──
病根:旧 `.flow{display:flex}` 把「判据键」和「判据值」**摆在同一行的左右两侧**
⇒ 抽屉宽度只有 ~560px,判据值又长(实测一条 100+ 字)⇒ **被压成窄竖条**,几乎不可读。
✅ 判据卡=**垂直布局**:① 首行=键 + 通过/未过标记(右对齐)② 下面=值全文,
`word-break` 让它自然换行铺满卡宽(⛔ 不再被挤成竖列)。
📌 一卡一条判据,卡与卡之间留白 —— 比"虚线分割的行列表"好读得多。 */
.pcards{display:flex;flex-direction:column;gap:9px}
.pcard{background:var(--panel2);border:1px solid var(--line);border-left:3px solid var(--line);
border-radius:8px;padding:9px 11px}
.pcard.ok{border-left-color:var(--ok)}
.pcard.no{border-left-color:var(--bad)}
.pcard .phead{display:flex;align-items:baseline;gap:9px;margin-bottom:5px}
/* 键:等宽字体(判据键多半是 `V1`/`U27`/`A6` 这类机读标号) */
.pcard .pk{font-family:ui-monospace,Consolas,monospace;font-size:12.5px;
font-weight:600;word-break:break-all}
.pcard .pv{font-size:12.5px;color:var(--dim);word-break:break-word;line-height:1.6}
/* 通过/未过标记:靠右、不换行 */
.pcard .pst{margin-left:auto;flex:none;font-size:11px;border-radius:5px;padding:1px 7px;
border:1px solid var(--line)}
.pcard.ok .pst{background:rgba(63,185,80,.15);border-color:rgba(63,185,80,.5);color:#8fe39a}
.pcard.no .pst{background:rgba(229,83,75,.15);border-color:rgba(229,83,75,.5);color:#f2a09a}
/* 任务细账(工作流分组)也改卡片:名称一行、计数一行(⛔ 不再左右挤) */
.fcard{background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:8px 11px;margin-bottom:7px}
.fcard .fname{font-size:12.5px;font-weight:600;margin-bottom:3px;word-break:break-word}
.fcard .fmeta{font-size:12px;color:var(--dim2)}
</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){
// 🔴🔴 **只印工作区名(`dir`)**(用户 2026-10-06 再次纠正:
// 「工作区下拉筛选为什么要显示目标信息」)。
// ⛔ 别拼 `p.name`(目标名)—— 目标名可能很长(实测那条 30+ 字),
// 把它塞进选项 ⇒ 下拉被撑爆、且**语义错了**(这个控件选的是工作区)。
// ⚠️ 我上一版就写了「有目标 ⇒ 目录名 + 目标名」,注释里明明写着
// 「跟目标没关系」,代码却照样拼了目标名 —— 注释与代码打架,以本条为准。
// 📌 目标信息该显示在**别处**(概要区/卡片),⛔ 不在这个下拉里。
return '<option value="'+i+'">'+esc(p.dir)+'</option>'; }).join("");
if(STATE.cur >= d.projects.length) STATE.cur = -1;
sel.value = String(STATE.cur);
}
render();
}).catch(function(e){
toast("err", '请求失败:'+esc(e));
});
}
/* 🔴🔴 **列键 → 颜色**(2026-10-06 四列改为「目标」后,列键换成 lifecycle 口径)。
⚠️ 列键改名了:`waiting/active/blocked/done`(⛔ 旧的 `pending/running` 已废)。*/
function _stateDot(key){
return key==="done" ? "var(--ok)" : key==="blocked" ? "var(--bad)"
: key==="active" ? "var(--accent)" : "var(--dim2)";
}
/* 🔴🔴🔴 **卡片 = 目标**(2026-10-06 用户三次定案:
「下面的 待执行 执行中 有阻碍 已完成 里面显示的都是目标 目标就是项目」
+「但是你现在 已完成里面显示的 就不是目标信息,连一个目标的名称都没有」)。
⛔ 改前这里渲染的是 `tasks.json` 的**台账条目** ⇒ 标题印出来是任务 id(`3b-v1`/`dark-tokens`),
**一个目标名都没有** —— 用户看的正是这张屏。
✅ 现在服务端 `cards[]` 一条 = 一个目标,字段:`title`(目标名)/`life`/`current`/`_ws`。
📌 卡片三样:**目标名**(主标题,必须是人话标题)|**状态**badge|**工作区名**(末行归属)。
⛔ 没有 `by`/`line`/`artifact`/`t_end` 了 —— 那些是**任务**字段,目标没有。
🔴 当前目标(`current`)用一个小标记区分:一个区常有好几个目标,得看得出"现在在跑哪个"。*/
function _cardHtml(t){
var dot = _stateDot(t.state);
var ws = t._ws
? '<div class="wsrow"><span class="wsname">'+esc(t._ws)+'</span>'+
(t._self ? '<span class="wsself">本工作区</span>' : '')+'</div>'
: '';
var badges = '<span class="badge">'+esc(t.life||"")+'</span>';
if(t.current) badges += '<span class="badge cur">当前</span>';
return '<div class="card'+(t._self?" is-self":"")+'" style="border-left-color:'+dot+
'" data-tid="'+esc(t.id)+'" data-pw="'+esc(t._ws||"")+'">'+
// 🔴 主标题=**目标名**(人话标题);⛔ 别回落到 id(那正是"看着不像目标"的原因)
'<div class="t">'+esc(t.title || "(未命名目标)")+'</div>'+
'<div class="meta">'+badges+'</div>'+
ws+'</div>';
}
function _colHtml(key, label, list){
var inner = list.length ? list.map(_cardHtml).join("")
: '<div class="empty">(此列无目标)</div>';
// 🔴 四态列**一律等宽**(⛔ 别给空列收窄;2026-10-06 用户「现在是改乱后的样子」)。
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; // ⛔ 「全部」不是单个工作区,删不得
// 🔴🔴 **两个维度分开报**(2026-10-06 用户定案:四列是「目标」维度):
// ① 工作区=目录(装了机制的)② 目标=登记过的那件事(**=四列里的卡片**,
// 🔴 一个工作区可有**多个**目标:当前 1 个 + 历史 N 个)
// ③ 任务=目标下的台账条目(细账,⛔ 不再上四列)。
var nWs = ps.length;
var nGoal = 0;
ps.forEach(function(p){ nGoal += (p.n_goals || 0); });
var gcol = {waiting:0, active:0, blocked:0, done:0};
ps.forEach(function(p){
var g = p.goal_by_col || {};
for(var k in gcol) gcol[k] += (g[k] || 0);
});
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>'+gcol.active+'</b></span>');
s.push('<span class="chip">已完成 <b>'+gcol.done+'</b></span>');
if(gcol.blocked) s.push('<span class="chip">有阻碍 <b>'+gcol.blocked+'</b></span>');
// 任务细账(⛔ 与上面分栏:这是"目标之下的工作量")
var taskTot = 0, taskDone = 0;
ps.forEach(function(p){ taskTot += (p.queue&&p.queue.total)||0; taskDone += (p.queue&&p.queue.done)||0; });
if(taskTot) s.push('<span class="chip">任务台账 <b>'+taskDone+' / '+taskTot+'</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>');
// 🔴 有目标但**落不进四列**的(已暂停 / 状态读不出)⇒ 必须说出来,⛔ 不静默丢
// (同族 P0:「作用域不许静默排除」—— 否则"目标怎么少了一个"永远查不出)
var unknown = [];
ps.forEach(function(x){ (x.life_unknown||[]).forEach(function(u){ unknown.push(u); }); });
if(unknown.length)
nt.push('<div class="iw">有 <b>'+unknown.length+'</b> 个目标<b>不在四列里</b>(已暂停/状态读不出):'+
esc(unknown.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 = [];
if(p.life) s.push('<span class="chip">目标状态 <b>'+esc(p.life)+'</b></span>');
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>');
if(p.acc && p.acc.n) s.push('<span class="chip">验收 <b>'+p.acc.pass+' / '+p.acc.n+'</b></span>');
// 任务细账(⛔ 与"目标"分栏:这是目标之下的工作量)
if(q.total)
s.push('<span class="chip">任务 <b>'+q.done+' / '+q.total+'</b></span>');
if(q.blocked) s.push('<span class="chip">其中受阻 <b>'+q.blocked+'</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>(只有机制骨架)⇒ 四列会是空的 —— '+
'用 <code>goalctl declare --title "…"</code> 登记后才会出现目标卡。</div>');
if(p.life && p.life !== "进行中")
nt.push('<div class="iw">该目标状态为「'+esc(p.life)+'」('+esc(fmtWhen(p.life_at))+')。</div>');
if(p.life_raw && p.life_raw !== p.life)
nt.push('<div class="iw">该目标 <code>lifecycle</code> 原始值=「'+esc(p.life_raw)+
'」,已归一为「'+esc(p.life||"(认不出)")+'」。</div>');
setInfo(nt);
// 列(🔴 单目标视图 ⇒ 四列里最多就这一张目标卡,落它自己的状态列)
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; });
// 🔴 抽屉标题=**目标名**(⛔ 不是 id:id 是 `vibe-product@cur` 这种机读串,人看不懂)
document.getElementById("dtitle").textContent = t.title || p.name || tid;
var h = [];
h.push('<dl class="kv">');
// 🔴🔴 卡片=**目标**(2026-10-06):详情页主档印**该目标自己**的字段。
// ⛔ 不要用 `p.xxx`(`p` 是**工作区**,它的 `declared_at`/`topics`/`life` 只属于
// **当前目标**)⇒ 点历史目标会读到当前目标的值(张冠李戴,同 `acc` 那个缺陷)。
// ⛔ 也不再印任务的 `line`/`by`/`t_start`/`t_end`(目标没有这些)。
h.push('<dt>目标</dt><dd>'+esc(t.title || p.name || "—")+'</dd>');
h.push('<dt>状态</dt><dd>'+esc(t.life || label[t.state] || t.state || "—")+'</dd>');
if(t.current) h.push('<dt>当前目标</dt><dd>是</dd>');
if(t.short) h.push('<dt>目标 id</dt><dd>'+esc(t.short)+'</dd>');
h.push('<dt>工作区</dt><dd>'+esc(p.dir || "—")+'</dd>');
if(t.declared_at) h.push('<dt>登记于</dt><dd>'+esc(fmtWhen(t.declared_at))+'</dd>');
if(t.life_at) h.push('<dt>状态更新</dt><dd>'+esc(fmtWhen(t.life_at))+'</dd>');
if(t.topics && t.topics.length) h.push('<dt>任务类别</dt><dd>'+esc(t.topics.join("、"))+'</dd>');
h.push('<dt>目标目录</dt><dd>'+esc(t.goal_dir || p.goal_dir || "—")+'</dd>');
h.push('</dl>');
// 验收判据(🔴🔴 2026-10-06 用户:「判据 用卡片显示,现在列表字段都挤成竖列了」)
// ⇒ **一卡一条**:首行=键 + 通过标记(右对齐);下面=值**全文**(⛔ 不再 `slice(0,120)` 截断
// —— 截断会让用户以为"判据就这么短",而实际一条上百字;卡片布局已能自然换行)。
// 🔴🔴 **判据取卡片自己的 `t.acc`**(⛔ 不是 `p.acc`):一个区可有多个目标,各有各的判据,
// 点历史目标的卡却显示当前目标的判据=**张冠李戴**(本轮实测到的缺陷)。
var acc = t.acc || p.acc || {};
h.push('<div class="sect"><h4>验收判据('+(acc.n||0)+' 条 · 通过 '+(acc.pass||0)+')</h4>');
if(acc.items && acc.items.length){
h.push('<div class="pcards">');
acc.items.forEach(function(a){
h.push('<div class="pcard '+(a.pass?"ok":"no")+'">'+
'<div class="phead"><span class="pk">'+esc(a.k)+'</span>'+
'<span class="pst">'+(a.pass?"过":"未过")+'</span></div>'+
'<div class="pv">'+esc(a.v || "(空)")+'</div></div>');
});
h.push('</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="fcard"><div class="fname">'+esc(f.line)+'</div>'+
'<div class="fmeta">共 '+f.total+' · 完成 '+f.done+' · 未完 '+f.open+'</div></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>