一、修「卡片挤到一起去了」(用户 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)。
724 lines
36 KiB
HTML
724 lines
36 KiB
HTML
<!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 ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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>
|