项目管理界面:照 dsh-task-board 模式新建(目标=项目 · 只读复用现有看板 · 端口20100)

用户逐字:「不动现有任务会话看板 不是让你整体搬是照的他的模式做一个 项目管理的界面
          来管理 执行任务,目标等于项目」

- 新增 交付物/项目管理界面/{project-board.py,.html,-launch.py}
- 映射:目标→项目、任务类别→工作流、台账条目→卡片、会话→可跳转
- 数据源=现有看板已算好的 /board.json(⛔ 不重算、⛔ 不直读台账)
- 列=机制真实四态(待执行/执行中/有阻碍/已完成),⛔ 不硬套它那五列
- 现有看板 20099 / board.py / board.html 零改动
- 坑:pythonw.exe 下 sys.stdout is None ⇒ print/flush 崩(终端好、计划任务死)⇒ 加 _say() 兜
This commit is contained in:
admin committed 2026-10-06 08:23:15 +08:00
1 parent 87a9c97b54
commit 1b68b1ac3f
4 files changed
+651

No files matched your search

@@ -0,0 +1,288 @@
<!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 60px}
.cols{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
@media(max-width:1100px){.cols{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:640px){.cols{grid-template-columns:1fr}}
.col{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
display:flex;flex-direction:column;min-height:160px}
.colhead{display:flex;align-items:center;gap:8px;padding:11px 13px;
border-bottom:1px solid var(--line)}
.dot{width:8px;height:8px;border-radius:50%;flex:none}
.coltitle{font-weight:600;font-size:13px}
.count{margin-left:auto;background:var(--panel2);border:1px solid var(--line);
border-radius:999px;padding:1px 9px;font-size:12px;color:var(--dim)}
.cards{padding:10px;display:flex;flex-direction:column;gap:9px;flex:1}
/* ── 卡片 ── */
.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}
.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}
/* ── 抽屉 ── */
.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>
<div class="spacer"></div>
<span class="stamp" id="stamp">加载中…</span>
<button id="refresh" type="button">刷新</button>
</div>
<div class="summary" id="summary"></div>
</header>
<main>
<div id="notice"></div>
<div class="cols" id="cols"></div>
</main>
<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>
<script>
"use strict";
var STATE = { data:null, cur:0 };
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 m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if(!m) return String(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(); })
.then(function(d){
var n = document.getElementById("notice");
if(!d || d.ok === false){
n.innerHTML = '<div class="errline">读不到上游看板:' + esc((d&&d.err)||"未知错误") +
'<br>上游:' + esc((d&&d.upstream)||"") + '</div>';
return;
}
STATE.data = d;
n.innerHTML = "";
document.getElementById("ws").textContent = d.ws_name || "—";
document.getElementById("stamp").textContent = "数据时间 " + (d.ts || "—");
var sel = document.getElementById("psel");
sel.innerHTML = d.projects.map(function(p,i){
return '<option value="'+i+'">'+esc(p.name)+'</option>'; }).join("");
if(STATE.cur >= d.projects.length) STATE.cur = 0;
sel.value = String(STATE.cur);
render();
}).catch(function(e){
document.getElementById("notice").innerHTML =
'<div class="errline">请求失败:'+esc(e)+'</div>';
});
}
function render(){
var d = STATE.data; if(!d) return;
var p = d.projects[STATE.cur]; if(!p) return;
// 概要
var q = p.queue, s = [];
s.push('<span class="chip">项目名 <b>'+esc(p.name)+'</b></span>');
s.push('<span class="chip">建立 <b>'+esc(fmtWhen(p.created))+'</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>');
s.push('<span class="chip">会话 <b>'+p.sessions.length+'</b></span>');
s.push('<span class="chip">工作流 <b>'+p.flows.length+'</b></span>');
if(p.peer) s.push('<span class="chip">来自工作区 <b>'+esc(p.peer)+'</b></span>');
document.getElementById("summary").innerHTML = s.join("");
// 提示行
var nt = [];
if(q.legacy) nt.push('<div class="warnline">另有 <b>'+q.legacy+'</b> 件台账条目无目标归属(早期写的、未打标记)⇒ 不计入本项目卡片。</div>');
if(p.orphan && p.orphan.n) nt.push('<div class="warnline">有 <b>'+p.orphan.n+'</b> 个工作流类别不在本项目的类别清单里(旧线名)⇒ 卡片见「未归类」。</div>');
if(p.life && p.life !== "进行中") nt.push('<div class="warnline">该目标状态为「'+esc(p.life)+'」('+esc(fmtWhen(p.life_at))+')。</div>');
document.getElementById("notice").innerHTML = nt.join("");
// 列
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.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>';
}).join("");
document.getElementById("cols").innerHTML = html;
// 卡片点开 → 详情(含跳会话)
Array.prototype.forEach.call(document.querySelectorAll(".card"), function(el){
el.addEventListener("click", function(){ openDetail(el.getAttribute("data-tid")); });
});
}
function openDetail(tid){
var p = STATE.data.projects[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('</dl>');
// 会话(点进对应会话)
h.push('<div class="sect"><h4>本项目会话(点开跳转)</h4>');
if((p.sessions||[]).length){
p.sessions.forEach(function(s){
h.push('<div class="sess" data-sid="'+esc(s.id8)+'">'+
'<span class="sn">'+esc(s.title || "(无标题)")+'</span>'+
'<span class="badge">'+esc(s.role||"")+'</span>'+
'<span class="badge">'+esc(s.status||"")+'</span>'+
'<span class="go">打开 ›</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.name)+'</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("");
Array.prototype.forEach.call(document.querySelectorAll(".sess"), function(el){
el.addEventListener("click", function(){
var sid = el.getAttribute("data-sid");
if(sid) window.open("/session/"+encodeURIComponent(sid), "_blank");
});
});
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");
}
document.getElementById("mask").addEventListener("click", closeDetail);
document.getElementById("dclose").addEventListener("click", closeDetail);
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(); });
load();
setInterval(load, 5000);
</script>
</body>
</html>