项目管理界面:统一视图 + 提示收进「!」 + 数据时效可见
用户口径(2026-10-06 逐字) - 「没安装机制的工作区不显示 黄字描述都放到 !号的图标中,不显示在工作台」 - 「这里分类不要按照工作区分了,记住 项目管理是统一管理」 改动 1. 统一视图(默认「全部」) - ⛔ 删掉「按工作区分块」,改为**一张四列看板**汇总本机所有目标的卡片 - 归属改由**卡片上的「目标」标签**体现(.badge.goal),⛔ 不靠分区 - 选择器默认「全部(N 个目标)」;选单个目标仍可切到单项目视图 - 顺带删掉随分区一起废掉的样式(projgroup/pghead/lane* 等,共 15 行死代码) 2. 提示一律收进右上角「!」图标 - 工作台上去掉所有黄字横幅(原来是 notice 容器) - 「!」无内容时淡显且不可点;有内容时点开弹层、点外面/Esc 关闭 - 未装机制的工作区**彻底不显示**(含原来那行「扫到但没装机制 N 个目录」) ⚠️ 后端仍保留 `skipped` 字段(只给 CLI/取证用),UI 不再展示 3. 数据时效可见 + 自动刷新 - 服务出快照时带 `epoch`;页面读数「数据 N 秒前」**只用服务的 epoch 算**, ⛔ 不用本地 Date.now() 顶上(否则刷新一次就"变新",看不出数据其实没动) - 超 15 秒 ⇒ 读数转黄(.is-stale),一眼看出"只是轮询、数据没动" - 每秒重画时效读数(⛔ 不重新取数) - 轮询自适应:数据有变化 ⇒ 3 秒一探;连续 3 次没变 ⇒ 放到 15 秒;一变立刻回收 - 「刷新」按钮改手动模式:扫描中/已刷新有即时反馈(toast) 4. 操作反馈改一次性 toast(新建/删除/失败),⛔ 不常驻工作台 实测(浏览器侧,强刷新避开缓存) - 6 张卡片全部落在一张四列板上,标签正确(本机协作 ×1 / vibe-product ×11) - 四个列标题只出现一次(不再是每区一套) - 「!」有 1 条内容、默认收起、点击展开、再点关闭 - 时效读数「数据 2 秒前」正常跳动
This commit is contained in:
1 parent
89f820a0f8
commit
d97fb0fe84
2 files changed
+280
-72
No files matched your search
+276
-72
@@ -49,7 +49,8 @@
|
||||
border-radius:999px;padding:1px 9px;font-size:12px;color:var(--dim)}
|
||||
.cards{padding:10px;display:flex;flex-direction:column;gap:9px;flex:1}
|
||||
|
||||
/* ── 卡片 ── */
|
||||
/* ── 统一视图:卡片上的「目标」标签(🔴 归属靠标签,⛔ 不靠分区)── */
|
||||
.badge.goal{border-color:var(--accent);color:var(--accent)}
|
||||
.card{background:var(--panel2);border:1px solid var(--line);border-left-width:3px;
|
||||
border-radius:8px;padding:10px 11px;cursor:pointer;
|
||||
transition:transform .08s ease,border-color .12s ease}
|
||||
@@ -94,6 +95,40 @@
|
||||
.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}
|
||||
@@ -130,17 +165,20 @@
|
||||
<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">刷新</button>
|
||||
<button id="refresh" type="button" title="立即重新扫一遍本机所有工作区">刷新</button>
|
||||
</div>
|
||||
<div class="summary" id="summary"></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="notice"></div>
|
||||
<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">
|
||||
@@ -178,7 +216,7 @@
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
var STATE = { data:null, cur:0 };
|
||||
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]; }); }
|
||||
@@ -190,47 +228,216 @@ function fmtWhen(s){
|
||||
return m[1]+"-"+m[2]+"-"+m[3]+" "+m[4]+":"+m[5];
|
||||
}
|
||||
|
||||
function load(){
|
||||
document.getElementById("stamp").textContent = "加载中…";
|
||||
fetch("/api/projects", {cache:"no-store"}).then(function(r){ return r.json(); })
|
||||
/* ── 时效条:数据「多久没动」 ──
|
||||
🔴 两条口径(照现有看板 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){
|
||||
var n = document.getElementById("notice");
|
||||
if(!d || d.ok === false){
|
||||
n.innerHTML = '<div class="errline">扫工作区失败:' + esc((d&&d.err)||"未知错误") + '</div>';
|
||||
toast("err", '扫工作区失败:' + esc((d&&d.err)||"未知错误"));
|
||||
return;
|
||||
}
|
||||
STATE.data = d;
|
||||
n.innerHTML = "";
|
||||
if(manual) toast("ok", "已刷新(" + esc(d.ts || "") + ")", 2200);
|
||||
document.getElementById("ws").textContent = (d.roots||[]).join(" · ") || "—";
|
||||
document.getElementById("stamp").textContent = "数据时间 " + (d.ts || "—");
|
||||
/* 🔴 时效**只用服务给的 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="">(本机暂无装了机制的工作区)</option>';
|
||||
STATE.cur = 0;
|
||||
sel.innerHTML = '<option value="-1">(本机暂无装了机制的工作区)</option>';
|
||||
STATE.cur = -1;
|
||||
} else {
|
||||
sel.innerHTML = d.projects.map(function(p,i){
|
||||
// 🔴 默认「全部」:一屏看完本机所有目标(⛔ 不再默认只显示一个)
|
||||
sel.innerHTML = '<option value="-1">全部('+d.projects.length+' 个目标)</option>'
|
||||
+ d.projects.map(function(p,i){
|
||||
return '<option value="'+i+'">'+esc(p.name)+'</option>'; }).join("");
|
||||
if(STATE.cur >= d.projects.length) STATE.cur = 0;
|
||||
if(STATE.cur >= d.projects.length) STATE.cur = -1;
|
||||
sel.value = String(STATE.cur);
|
||||
}
|
||||
render();
|
||||
}).catch(function(e){
|
||||
document.getElementById("notice").innerHTML =
|
||||
'<div class="errline">请求失败:'+esc(e)+'</div>';
|
||||
toast("err", '请求失败:'+esc(e));
|
||||
});
|
||||
}
|
||||
|
||||
function render(){
|
||||
var d = STATE.data; if(!d) return;
|
||||
/* 🔴 卡片状态来自台账;这里按「目标」分组渲染。
|
||||
`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 = [];
|
||||
// 🔴 统一视图里,卡片自带「目标 · 工作区」标签(⛔ 不靠分区来体现归属)
|
||||
if(t._goal) bits.push('<span class="badge goal">'+esc(t._goal)+'</span>');
|
||||
if(t.line) bits.push('<span class="badge">'+esc(t.line)+'</span>');
|
||||
if(t.by) bits.push('<span class="badge">'+esc(t.by)+'</span>');
|
||||
if(t.t_end) bits.push(esc(fmtWhen(t.t_end)));
|
||||
else if(t.t_start) bits.push(esc(fmtWhen(t.t_start)));
|
||||
return '<div class="card" style="border-left-color:'+dot+'" data-tid="'+esc(t.id)+'"'+
|
||||
' data-pw="'+esc(t._ws||"")+'">'+
|
||||
'<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>' : '')+
|
||||
'</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;
|
||||
});
|
||||
var s = [];
|
||||
s.push('<span class="chip">目标 <b>'+ps.length+'</b> 个</span>');
|
||||
s.push('<span class="chip">工作区 <b>'+(d.roots||[]).join(" · ")+'</b></span>');
|
||||
s.push('<span class="chip">台账合计 <b>'+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.name; }).join("、"))+'</div>');
|
||||
var notRun = ps.filter(function(x){ return x.life && x.life !== "进行中"; });
|
||||
if(notRun.length)
|
||||
nt.push('<div class="iw">有 <b>'+notRun.length+'</b> 个目标状态<b>不是「进行中」</b>:'+
|
||||
esc(notRun.map(function(x){ return x.name+"("+x.life+")"; }).join("、"))+'</div>');
|
||||
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._life = p.life || "";
|
||||
(byState[c.state] = byState[c.state] || []).push(x);
|
||||
});
|
||||
});
|
||||
|
||||
cols.innerHTML = '<div class="cols">' + d.cols.map(function(c){
|
||||
return _colHtml(c.key, c.label, byState[c.key] || []);
|
||||
}).join("") + '</div>';
|
||||
_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){
|
||||
sums.innerHTML = "";
|
||||
cols.innerHTML = '<div class="empty">本机没有装了机制的工作区。点右上角「+ 新建目标」建一个。</div>';
|
||||
document.getElementById("btndel").disabled = true;
|
||||
return;
|
||||
}
|
||||
if(!p){ renderAll(); return; }
|
||||
document.getElementById("btndel").disabled = false;
|
||||
|
||||
// 概要
|
||||
@@ -249,50 +456,36 @@ function render(){
|
||||
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="warnline">这个工作区<b>还没有登记目标</b>(只有机制骨架)⇒ 项目名是兜底的,卡片按台账显示。</div>');
|
||||
nt.push('<div class="iw">这个工作区<b>还没有登记目标</b>(只有机制骨架)⇒ 项目名是兜底的,卡片按台账显示。</div>');
|
||||
if(p.life && p.life !== "进行中")
|
||||
nt.push('<div class="warnline">该目标状态为「'+esc(p.life)+'」('+esc(fmtWhen(p.life_at))+')。</div>');
|
||||
if(d.skipped && d.skipped.length)
|
||||
nt.push('<div class="warnline">扫到但<b>没装机制</b>、未列出 '+d.skipped.length+' 个目录:'+
|
||||
esc(d.skipped.join("、"))+'</div>');
|
||||
document.getElementById("notice").innerHTML = nt.join("");
|
||||
nt.push('<div class="iw">该目标状态为「'+esc(p.life)+'」('+esc(fmtWhen(p.life_at))+')。</div>');
|
||||
setInfo(nt);
|
||||
|
||||
// 列
|
||||
var byState = {};
|
||||
p.cards.forEach(function(c){ (byState[c.state] = byState[c.state] || []).push(c); });
|
||||
var html = d.cols.map(function(c){
|
||||
var list = byState[c.key] || [];
|
||||
var dot = c.key==="done" ? "var(--ok)" : c.key==="blocked" ? "var(--bad)"
|
||||
: c.key==="running" ? "var(--accent)" : "var(--dim2)";
|
||||
var inner = list.length ? list.map(function(t){
|
||||
var bits = [];
|
||||
if(t.line) bits.push('<span class="badge">'+esc(t.line)+'</span>');
|
||||
if(t.by) bits.push('<span class="badge">'+esc(t.by)+'</span>');
|
||||
if(t.t_end) bits.push(esc(fmtWhen(t.t_end)));
|
||||
else if(t.t_start) bits.push(esc(fmtWhen(t.t_start)));
|
||||
return '<div class="card" style="border-left-color:'+dot+'" data-tid="'+esc(t.id)+'">'+
|
||||
'<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>' : '')+
|
||||
'</div>';
|
||||
}).join("") : '<div class="empty">(此列无卡片)</div>';
|
||||
return '<section class="col"><div class="colhead"><span class="dot" style="background:'+dot+
|
||||
'"></span><span class="coltitle">'+esc(c.label)+'</span><span class="count">'+list.length+
|
||||
'</span></div><div class="cards">'+inner+'</div></section>';
|
||||
cols.innerHTML = d.cols.map(function(c){
|
||||
return _colHtml(c.key, c.label, byState[c.key] || []);
|
||||
}).join("");
|
||||
document.getElementById("cols").innerHTML = html;
|
||||
|
||||
// 卡片点开 → 详情(含跳会话)
|
||||
Array.prototype.forEach.call(document.querySelectorAll(".card"), function(el){
|
||||
el.addEventListener("click", function(){ openDetail(el.getAttribute("data-tid")); });
|
||||
});
|
||||
_bindCards();
|
||||
}
|
||||
|
||||
function openDetail(tid){
|
||||
var p = STATE.data.projects[STATE.cur];
|
||||
/* ── 视图分发 ── */
|
||||
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;
|
||||
@@ -387,9 +580,7 @@ function doCreate(){
|
||||
note.className = "fnote";
|
||||
note.innerHTML = '✅ 已创建:<b>'+esc(d.ws)+'</b>';
|
||||
document.getElementById("mstamp").textContent = "";
|
||||
var box = document.getElementById("notice");
|
||||
if(box) box.innerHTML = '<div class="okline">创建成功:'+esc(d.ws)+
|
||||
(d.out ? '<pre class="out">'+esc(d.out)+'</pre>' : '')+'</div>';
|
||||
toast("ok", '创建成功:'+esc(d.ws) + (d.out ? '<pre class="out">'+esc(d.out)+'</pre>' : ""));
|
||||
setTimeout(function(){ closeNew(); load(); }, 900);
|
||||
} else {
|
||||
note.className = "fnote bad";
|
||||
@@ -419,17 +610,15 @@ function doDelete(){
|
||||
body: JSON.stringify({ws:p.ws, mode:mode})})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
var box = document.getElementById("notice");
|
||||
if(d.ok){
|
||||
box.innerHTML = '<div class="okline">已删除('+(mode==="ws"?"整个工作区送回收站":"目标登记归档")+
|
||||
'):'+esc(d.moved_to||d.trashed||"")+'</div>';
|
||||
toast("ok", '已删除('+(mode==="ws"?"整个工作区送回收站":"目标登记归档")+
|
||||
'):'+esc(d.moved_to||d.trashed||""));
|
||||
load();
|
||||
} else {
|
||||
box.innerHTML = '<div class="errline">删除失败:'+esc(d.err||"未知错误")+'</div>';
|
||||
toast("err", '删除失败:'+esc(d.err||"未知错误"));
|
||||
}
|
||||
}).catch(function(e){
|
||||
document.getElementById("notice").innerHTML =
|
||||
'<div class="errline">请求失败:'+esc(e)+'</div>';
|
||||
toast("err", '请求失败:'+esc(e));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,13 +631,28 @@ 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) || 0; render(); });
|
||||
document.getElementById("refresh").addEventListener("click", load);
|
||||
document.addEventListener("keydown", function(e){
|
||||
if(e.key === "Escape"){ closeDetail(); closeNew(); } });
|
||||
STATE.cur = parseInt(e.target.value, 10);
|
||||
if(isNaN(STATE.cur)) STATE.cur = -1;
|
||||
render(); });
|
||||
document.getElementById("refresh").addEventListener("click", function(){ load(true); });
|
||||
|
||||
load();
|
||||
setInterval(load, 5000);
|
||||
/* 「!」状态说明:点开/再点关、悬浮可看、点外面关 */
|
||||
(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>
|
||||
@@ -285,6 +285,10 @@ def to_project_view() -> dict:
|
||||
return {
|
||||
"ok": True,
|
||||
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
# 🔴 `epoch` = 服务出这份快照时的时间戳(秒)。页面拿它算「数据多久没动了」——
|
||||
# ⛔ 不许页面自己 `Date.now()` 顶数:那样刷新一次就"变新",**只看得出页面在轮询,
|
||||
# 看不出数据其实没变**。参照现有看板 board.html::paintAge 的口径。
|
||||
"epoch": time.time(),
|
||||
"roots": [str(r).replace("\\", "/") for r in WS_ROOTS],
|
||||
"cols": [{"key": k, "label": lb} for k, lb in COLS],
|
||||
"projects": projects,
|
||||
|
||||
Reference in new issue
Block a user