项目管理界面:改为跨工作区 + 增删落地(回收站走 SHFileOperationW)
用户口径:「项目管理管的是本机所有工作区的项目 不是单个工作区的,可以 通过项目管理工具 创建新目标(新工作区),管理目标」。 数据面 - ⛔ 不再拉「单一上游看板」(并非每个区都起着 board.py ⇒ 会静默漏区) - ✅ 直读各区文件:goal.json / tasks.json / collabd-state.json / collabd.config.json - 「装了机制」判据=有 collabd.config.json 或 goal.json;跳过的也带出来 (skipped 列表,同族 P0「作用域不许静默排除」) 增删 - 增:POST /api/create → init_workspace.py(骨架+目标登记) - 删(轻):POST /api/delete{mode:"goal"} ⇒ goal.json 改名归档(可逆) - 删(重):POST /api/delete{mode:"ws"} ⇒ 送系统回收站(可逆) 回收站:本机 gio/trash-put/trash 全没有,PowerShell Add-Type 被安全策略 拦 ⇒ 改走纯 ctypes 调 Windows Shell API SHFileOperationW。两个坑: ① pFrom 必须手搓双 NUL 结尾缓冲(LPCWSTR 会在第一个 NUL 截断) ② 判据⛔ 不是 rc==0 —— 实测成功时 rc 恒为 2(Shell 脏状态)⇒ 假报错。 真判据=原路径不存在 且 fAnyOperationsAborted 为假 其它修复 - init_workspace.py 子进程输出按 GBK 解码 ⇒ 中文乱码 ⇒ 显式 encoding=utf-8 - 起服务只许触发计划任务(裸 & 活不过工具调用边界,被 SIGTERM) 实测:列表 3 个项目 + 12 个未装机制目录;增删两种粒度 HTTP 端到端全通; _pmtest-* 五个测试目录已进回收站且可恢复($I 元数据逐条核对)。
This commit is contained in:
1 parent
0cf981f419
commit
89f820a0f8
3 files changed
+688
-147
No files matched your search
+200
-34
@@ -65,6 +65,34 @@
|
||||
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}
|
||||
|
||||
/* ── 抽屉 ── */
|
||||
.mask{position:fixed;inset:0;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;
|
||||
@@ -99,8 +127,10 @@
|
||||
<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>
|
||||
<button id="btnnew" type="button" class="primary">+ 新建目标</button>
|
||||
<button id="refresh" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="summary" id="summary"></div>
|
||||
@@ -120,6 +150,32 @@
|
||||
<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:0 };
|
||||
@@ -140,19 +196,23 @@ function load(){
|
||||
.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>';
|
||||
n.innerHTML = '<div class="errline">扫工作区失败:' + esc((d&&d.err)||"未知错误") + '</div>';
|
||||
return;
|
||||
}
|
||||
STATE.data = d;
|
||||
n.innerHTML = "";
|
||||
document.getElementById("ws").textContent = d.ws_name || "—";
|
||||
document.getElementById("ws").textContent = (d.roots||[]).join(" · ") || "—";
|
||||
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);
|
||||
if(!d.projects.length){
|
||||
sel.innerHTML = '<option value="">(本机暂无装了机制的工作区)</option>';
|
||||
STATE.cur = 0;
|
||||
} else {
|
||||
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 =
|
||||
@@ -162,26 +222,42 @@ function load(){
|
||||
|
||||
function render(){
|
||||
var d = STATE.data; if(!d) return;
|
||||
var p = d.projects[STATE.cur]; if(!p) return;
|
||||
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;
|
||||
}
|
||||
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(fmtWhen(p.created))+'</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>');
|
||||
s.push('<span class="chip">会话 <b>'+p.sessions.length+'</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.peer) s.push('<span class="chip">来自工作区 <b>'+esc(p.peer)+'</b></span>');
|
||||
document.getElementById("summary").innerHTML = s.join("");
|
||||
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(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>');
|
||||
if(!p.has_goal)
|
||||
nt.push('<div class="warnline">这个工作区<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("");
|
||||
|
||||
// 列
|
||||
@@ -198,7 +274,7 @@ function render(){
|
||||
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>'+
|
||||
'<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>';
|
||||
@@ -231,38 +307,31 @@ function openDetail(tid){
|
||||
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>本项目会话(点开跳转)</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>');
|
||||
// 验收判据(本项目达成与否的唯一机读依据)
|
||||
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>');
|
||||
} 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>'+
|
||||
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("");
|
||||
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");
|
||||
@@ -274,12 +343,109 @@ function closeDetail(){
|
||||
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 = "";
|
||||
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>';
|
||||
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){
|
||||
var box = document.getElementById("notice");
|
||||
if(d.ok){
|
||||
box.innerHTML = '<div class="okline">已删除('+(mode==="ws"?"整个工作区送回收站":"目标登记归档")+
|
||||
'):'+esc(d.moved_to||d.trashed||"")+'</div>';
|
||||
load();
|
||||
} else {
|
||||
box.innerHTML = '<div class="errline">删除失败:'+esc(d.err||"未知错误")+'</div>';
|
||||
}
|
||||
}).catch(function(e){
|
||||
document.getElementById("notice").innerHTML =
|
||||
'<div class="errline">请求失败:'+esc(e)+'</div>';
|
||||
});
|
||||
}
|
||||
|
||||
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) || 0; render(); });
|
||||
document.getElementById("refresh").addEventListener("click", load);
|
||||
document.addEventListener("keydown", function(e){ if(e.key === "Escape") closeDetail(); });
|
||||
document.addEventListener("keydown", function(e){
|
||||
if(e.key === "Escape"){ closeDetail(); closeNew(); } });
|
||||
|
||||
load();
|
||||
setInterval(load, 5000);
|
||||
|
||||
+426
-113
@@ -1,36 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""项目管理界面 —— 照 dsh-task-board 的「项目 × 卡片」模式做的**只读**看板。
|
||||
"""项目管理界面 —— 管**本机所有工作区**的项目(照 dsh-task-board 的「项目 × 卡片」模式)。
|
||||
|
||||
## 这是什么(用户 2026-10-06 原话)
|
||||
## 这是什么(用户原话)
|
||||
|
||||
> 「不动现有任务会话看板,不是让你整体搬,是照的他的模式做一个 项目管理的界面
|
||||
> 来管理 执行任务,**目标等于项目**」
|
||||
|
||||
⇒ 三条硬约束:
|
||||
> 「项目管理管的是**本机所有工作区的项目** 不是单个工作区的,
|
||||
> 可以通过项目管理工具 **创建新目标(新工作区)**,管理目标」
|
||||
|
||||
⇒ 硬约束:
|
||||
① ⛔ **不动**现有看板(`board.py` / `board.html` / 20099 一律零改动);
|
||||
② ⛔ **不整体搬** dsh-task-board(它是 DSH 插件、20k 行 TS,宿主不同,搬不动);
|
||||
③ ✅ **照它的模式**:项目下拉 + 卡片列 + 点卡片看详情。
|
||||
② ⛔ **不整体搬** dsh-task-board(DSH 插件、20k 行 TS,宿主不同);
|
||||
③ ✅ 照它的模式:项目列表 + 卡片列 + 点卡片看详情;
|
||||
④ ✅ 范围 = **本机所有装了机制的工作区**(⛔ 不是单区、⛔ 不是靠同伴清单);
|
||||
⑤ ✅ 能**建新目标(新工作区)**、能**删**、能**查**;⛔ **不改状态**(状态交给会话机制自己处理)。
|
||||
|
||||
## 数据从哪来(🔴 只读,零写)
|
||||
## 数据从哪来(🔴 只读 + 一处写)
|
||||
|
||||
· 复用现有看板已经算好的 `GET /board.json`(20099)—— ⛔ **不重算、不直读台账**
|
||||
(重算 = 把 `board.py` 那套逻辑抄第二份 ⇒ 同族 P0「一条规则两份实现」必漂)。
|
||||
· 本项目**自己不再产数据**,只做**渲染**。
|
||||
**读**:直接扫本机工作区根目录下每个区自己的文件 ——
|
||||
· `<区>/tmp/supervise-inbox/goal.json` → **项目**(title/short/lifecycle/acceptance)
|
||||
· `<区>/tmp/supervise-inbox/tasks.json` → **卡片**(台账,`state` → 列)
|
||||
· `<区>/tmp/supervise-inbox/collabd-state.json` → 队列统计
|
||||
· `<区>/.workbuddy/collab/collabd.config.json` → 该区判「装没装机制」的依据
|
||||
⚠️ **为什么直读文件而不是拉各区看板**:并非每个区都起着 `board.py`(实测本机 3 个装了
|
||||
机制的区里,只有 1 个在跑看板)⇒ 拉看板会**静默漏掉没起看板的区**(="只看到一部分")。
|
||||
直读文件是**各区自己的真源**,不依赖谁在跑。
|
||||
|
||||
## 映射(目标 → 项目)
|
||||
**写**:只有两处,且都是**动作型**(⛔ 不在轮询里写任何东西)——
|
||||
· 建新目标/新工作区:调技能自带的 `init_workspace.py`(幂等,五步齐全)
|
||||
· 删:把该工作区**整体送回收站**(⛔ 不 `rm`;可逆),或只删该区的目标登记(轻量,见下)
|
||||
|
||||
· `goals[]` → **项目**(`goal.short`=项目名,`tab_created`=建立时间)
|
||||
· `labor[]` → 该项目的**工作流/分组**(`line`/`items`)
|
||||
· `tasks{}`(台账) → **卡片**(`state` → 列)
|
||||
· `sessions[]` → 卡片可跳的**会话**(`id8` 是跳转键)
|
||||
## 映射
|
||||
|
||||
· 一个**工作区** → 一个**项目**(`goal.json` 的 `short`/`title` 是项目名)
|
||||
· 该区**台账条目** → **卡片**(`state` → 四列)
|
||||
· 该区**会话** → 卡片可跳的会话(`id8` 是跳转键)
|
||||
|
||||
## 列(⛔ 用机制真实四态,不硬套它那五列)
|
||||
|
||||
dsh-task-board 那套是 待规划/待办/进行中/已完成/已失败;
|
||||
本机制台账只有 **待执行 / 执行中 / 已完成 / 有阻碍** 四态
|
||||
⇒ 硬套五列会凭空多出机制里**不存在**的「待规划」⇒ 看着像有、其实永远空。
|
||||
✅ 用户问「什么工作流状态 干啥用的」⇒ 就是这一列的含义;**按真实态列**。
|
||||
本机制台账只有 **待执行 / 执行中 / 有阻碍 / 已完成** 四态;
|
||||
硬套 dsh-task-board 的五列(含「待规划」)会凭空多出机制里**不存在**的一列。
|
||||
|
||||
## 起法
|
||||
|
||||
@@ -41,6 +52,9 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -50,8 +64,17 @@ from pathlib import Path
|
||||
HERE = Path(__file__).resolve().parent
|
||||
PAGE = HERE / "project-board.html"
|
||||
|
||||
# 🔴 上游=**现有看板**(⛔ 不直读台账)。允许用环境变量指到别的实例。
|
||||
UPSTREAM = os.environ.get("DSH_PROJECT_BOARD_UPSTREAM") or "http://127.0.0.1:20099/board.json"
|
||||
# 🔴 扫哪些根目录找工作区(`;` 分隔;可用环境变量覆盖)
|
||||
WS_ROOTS = [Path(p) for p in (
|
||||
os.environ.get("DSH_PM_BOARD_ROOTS") or "E:/ProgramData/AIProject"
|
||||
).split(";") if p.strip()]
|
||||
|
||||
# 技能自带的新建入口(⛔ 不自己另写一套建区逻辑 —— 同族"一条规则两份实现"必漂)
|
||||
SKILL_SCRIPTS = Path(os.environ.get("DSH_SKILL_SCRIPTS")
|
||||
or r"E:/ProgramData/.workbuddy/skills/session-mechanism/scripts")
|
||||
|
||||
# 可选:某个区的旧看板(**只作补充**,主力是直读文件)
|
||||
UPSTREAM = os.environ.get("DSH_PROJECT_BOARD_UPSTREAM") or ""
|
||||
|
||||
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # ⛔ 绕过 HTTP_PROXY
|
||||
|
||||
@@ -62,7 +85,6 @@ def _say(msg: str) -> None:
|
||||
病根:计划任务用 **`pythonw.exe`** 跑 ⇒ **没有控制台** ⇒ `sys.stdout is None`
|
||||
⇒ `print()` / `sys.stdout.flush()` 直接 `AttributeError: 'NoneType' object has no
|
||||
attribute 'flush'` ⇒ **进程当场崩**(而 `Result=1` 只说"失败",看不出为什么)。
|
||||
⚠️ 这正是"终端里好好的、一进计划任务就死"的典型 —— ⛔ 别用裸 `print` 起常驻。
|
||||
✅ 有 stdout 才写;没有就**静默丢掉**(这是常驻进程,⛔ 不该因为没地方打印就崩)。
|
||||
"""
|
||||
f = getattr(sys, "stdout", None)
|
||||
@@ -75,13 +97,6 @@ def _say(msg: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def fetch_upstream(timeout: float = 5.0) -> dict:
|
||||
"""拉现有看板的快照(⛔ 只读;失败**如实报错**,⛔ 不返回空壳假装没数据)。"""
|
||||
req = urllib.request.Request(UPSTREAM, headers={"Accept": "application/json"})
|
||||
with _OPENER.open(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
# 台账四态 → 列(🔴 与 board.py::_queue 的 `by` 键**逐字一致**)
|
||||
COLS = [
|
||||
("pending", "待执行"),
|
||||
@@ -91,73 +106,355 @@ COLS = [
|
||||
]
|
||||
|
||||
|
||||
def to_project_view(snap: dict) -> dict:
|
||||
"""把现有看板的 `/board.json` 转成**项目管理视图**(纯映射,⛔ 不重算业务)。"""
|
||||
goals = snap.get("goals") or []
|
||||
projects = []
|
||||
for gi, g in enumerate(goals):
|
||||
gg = g.get("goal") or {}
|
||||
q = g.get("queue") or {}
|
||||
qby = q.get("by") or {}
|
||||
tasks = g.get("tasks") or {}
|
||||
# 台账条目 → 卡片(只取当前目标的那几条;无归属的按 legacy 单列,⛔ 不混进项目)
|
||||
cards = []
|
||||
for tid, t in (tasks or {}).items():
|
||||
st = str((t or {}).get("state") or "").strip().lower()
|
||||
if st not in {c[0] for c in COLS}:
|
||||
def _load_json(p: Path, default=None):
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _acc_is_pass(v) -> bool:
|
||||
"""一条验收判据算不算过 —— **复用技能真身**(⛔ 不自己写词表;见 pitfalls P0-87)。"""
|
||||
try:
|
||||
if str(SKILL_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SKILL_SCRIPTS))
|
||||
import board as _b
|
||||
return bool(_b.acc_is_pass(v))
|
||||
except Exception:
|
||||
pass
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
head = re.split(r"[((]", s, maxsplit=1)[0]
|
||||
for _seg in re.split(r"[::]", head):
|
||||
_t = _seg.strip().lstrip("*").strip().lower()
|
||||
for _w in ("pass", "过", "通过", "达", "达标", "合格", "完成"):
|
||||
if _t.startswith(_w):
|
||||
return True
|
||||
_t2 = _t
|
||||
for _ in range(3):
|
||||
_cut = ""
|
||||
for _a in ("已", "已经", "均", "都", "经复核", "复核后"):
|
||||
if _t2.startswith(_a) and len(_a) > len(_cut):
|
||||
_cut = _a
|
||||
if not _cut:
|
||||
break
|
||||
_t2 = _t2[len(_cut):].strip().lstrip("*").strip()
|
||||
for _w in ("pass", "过", "通过", "达", "达标", "合格", "完成"):
|
||||
if _t2.startswith(_w):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def scan_workspaces() -> list:
|
||||
"""扫本机所有工作区根目录,返回**装了机制**的那些区(⛔ 没装的跳过但**单独登记**)。
|
||||
|
||||
🔴 「装了机制」的判据(两条任一):
|
||||
① 有 `<区>/.workbuddy/collab/collabd.config.json`(=部署配置,机制跑起来的必要条件)
|
||||
② 有 `<区>/tmp/supervise-inbox/goal.json`(=登记过目标)
|
||||
⚠️ 返回 `(installed, skipped)` 两个列表 —— **跳过的也带出来**,
|
||||
⛔ 不静默丢(同族 P0:「作用域不许静默排除」)。用户口径是"只列装了机制的",
|
||||
但界面上要能看见"扫到了但没装",否则"少了一个区"永远查不出来。
|
||||
"""
|
||||
installed, skipped = [], []
|
||||
seen = set()
|
||||
for root in WS_ROOTS:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for d in sorted(root.iterdir()):
|
||||
if not d.is_dir() or d.name.startswith("_") or d.name.startswith("."):
|
||||
continue
|
||||
cards.append({
|
||||
"id": tid,
|
||||
"state": st,
|
||||
"by": str((t or {}).get("by") or ""),
|
||||
"line": str((t or {}).get("line") or ""),
|
||||
"artifact": str((t or {}).get("artifact") or ""),
|
||||
"block_reason": str((t or {}).get("block_reason") or ""),
|
||||
"t_start": str((t or {}).get("t_start") or ""),
|
||||
"t_end": str((t or {}).get("t_end") or ""),
|
||||
})
|
||||
# 会话(卡片"点开到对应会话"用;`id8` = 跳转键)
|
||||
sess = [{"id8": s.get("id8"), "title": s.get("title") or "",
|
||||
"status": s.get("status") or "", "role": s.get("role") or "",
|
||||
"age_min": s.get("age_min")} for s in (g.get("sessions") or [])]
|
||||
# 工作流分组(labor)
|
||||
flows = [{"line": x.get("line") or x.get("name") or "",
|
||||
"name": x.get("name") or x.get("line") or "",
|
||||
"total": x.get("total") or 0, "done": x.get("done") or 0,
|
||||
"open": x.get("open") or 0, "state": x.get("state") or ""}
|
||||
for x in (g.get("labor") or [])]
|
||||
orphan = g.get("orphan") or {}
|
||||
projects.append({
|
||||
"i": gi,
|
||||
"name": str(gg.get("short") or gg.get("title") or "(未命名)"),
|
||||
"title": str(gg.get("title") or ""),
|
||||
"peer": str(g.get("peer") or ""),
|
||||
"created": str(g.get("tab_created") or ""),
|
||||
"life": str(g.get("life") or ""),
|
||||
"life_at": str(g.get("life_at") or ""),
|
||||
"cards": cards,
|
||||
"sessions": sess,
|
||||
"flows": flows,
|
||||
"orphan": {"n": orphan.get("n") or 0, "lines": orphan.get("lines") or []},
|
||||
"queue": {"total": q.get("total") or 0, "open": q.get("open") or 0,
|
||||
"done": qby.get("done") or 0, "pending": qby.get("pending") or 0,
|
||||
"running": qby.get("running") or 0, "blocked": qby.get("blocked") or 0,
|
||||
"legacy": q.get("legacy") or 0},
|
||||
"acc": g.get("acc_summary") or "",
|
||||
try:
|
||||
key = str(d.resolve()).lower()
|
||||
except Exception:
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cfg = d / ".workbuddy" / "collab" / "collabd.config.json"
|
||||
gj = d / "tmp" / "supervise-inbox" / "goal.json"
|
||||
if cfg.is_file() or gj.is_file():
|
||||
installed.append(d)
|
||||
else:
|
||||
skipped.append(d.name)
|
||||
return installed, skipped
|
||||
|
||||
|
||||
def _region_dir(ws: Path, goal: dict) -> Path:
|
||||
"""目标文件夹(`执行会话/目标-<区名>-<短哈希>`)—— ⛔ 与技能同口径,不从目录名猜。"""
|
||||
ex = str((goal or {}).get("execution_doc") or "").strip()
|
||||
if ex:
|
||||
p = ws / ex
|
||||
if p.parent.is_dir():
|
||||
return p.parent
|
||||
return ws / "执行会话"
|
||||
|
||||
|
||||
def build_project(ws: Path) -> dict:
|
||||
"""把**一个工作区**读成「项目」(纯读,⛔ 不写任何东西)。"""
|
||||
inbox = ws / "tmp" / "supervise-inbox"
|
||||
goal = _load_json(inbox / "goal.json", {}) or {}
|
||||
tasks = _load_json(inbox / "tasks.json", {}) or {}
|
||||
st = _load_json(inbox / "collabd-state.json", {}) or {}
|
||||
cfg = _load_json(ws / ".workbuddy" / "collab" / "collabd.config.json", {}) or {}
|
||||
|
||||
short = str(goal.get("short") or "").strip()
|
||||
title = str(goal.get("title") or "").strip()
|
||||
name = short or title or ("(未命名 · %s)" % ws.name)
|
||||
|
||||
# ── 卡片(台账条目;只取四态里的)
|
||||
keys = {c[0] for c in COLS}
|
||||
cards = []
|
||||
for tid, t in (tasks or {}).items():
|
||||
t = t or {}
|
||||
s = str(t.get("state") or "").strip().lower()
|
||||
if s not in keys:
|
||||
continue
|
||||
cards.append({
|
||||
"id": tid, "state": s,
|
||||
"title": str(t.get("title") or tid),
|
||||
"by": str(t.get("by") or ""),
|
||||
"line": str(t.get("line") or ""),
|
||||
"artifact": str(t.get("artifact") or ""),
|
||||
"block_reason": str(t.get("block_reason") or ""),
|
||||
"t_start": str(t.get("t_start") or ""),
|
||||
"t_end": str(t.get("t_end") or ""),
|
||||
})
|
||||
by = {k: 0 for k in keys}
|
||||
for c in cards:
|
||||
by[c["state"]] += 1
|
||||
|
||||
# ── 队列统计(以 collabd-state.json 为准,⛔ 不重算)
|
||||
qi = st.get("queue_info") or {}
|
||||
qby = qi.get("by") or {}
|
||||
|
||||
# ── 验收判据(🔴 走真身归一化,⛔ 不比字面值)
|
||||
acc = goal.get("acceptance_state") or {}
|
||||
eff = [k for k in acc if not str(k).startswith("_")]
|
||||
acc_pass = sum(1 for k in eff if _acc_is_pass(acc[k]))
|
||||
acc_items = [{"k": k, "v": str(acc[k]), "pass": _acc_is_pass(acc[k])} for k in eff]
|
||||
|
||||
# ── 工作流分组(按台账的 `line` 聚合)
|
||||
flows = {}
|
||||
for c in cards:
|
||||
ln = c["line"] or "(未分组)"
|
||||
f = flows.setdefault(ln, {"line": ln, "total": 0, "done": 0, "open": 0})
|
||||
f["total"] += 1
|
||||
if c["state"] == "done":
|
||||
f["done"] += 1
|
||||
else:
|
||||
f["open"] += 1
|
||||
|
||||
topics = [str(x) for x in (goal.get("topics") or []) if str(x).strip()]
|
||||
return {
|
||||
"ok": True,
|
||||
"ts": snap.get("ts") or "",
|
||||
"ws_name": snap.get("ws_name") or "",
|
||||
"ws_root": snap.get("ws_root") or "",
|
||||
"upstream": UPSTREAM,
|
||||
"cols": [{"key": k, "label": lb} for k, lb in COLS],
|
||||
"projects": projects,
|
||||
"ws": str(ws).replace("\\", "/"),
|
||||
"dir": ws.name,
|
||||
"name": name,
|
||||
"title": title,
|
||||
"topics": topics,
|
||||
"life": str(goal.get("lifecycle") or "").strip(),
|
||||
"life_at": str(goal.get("lifecycle_at") or "").strip(),
|
||||
"declared_at": str(goal.get("declared_at") or "").strip(),
|
||||
"has_goal": bool(title or short),
|
||||
"cards": cards,
|
||||
"flows": [flows[k] for k in sorted(flows)],
|
||||
"queue": {
|
||||
"total": qi.get("n") or len(cards),
|
||||
"pending": qby.get("pending") or by.get("pending", 0),
|
||||
"running": qby.get("running") or by.get("running", 0),
|
||||
"blocked": qby.get("blocked") or by.get("blocked", 0),
|
||||
"done": qby.get("done") or by.get("done", 0),
|
||||
"open": (qby.get("pending") or 0) + (qby.get("running") or 0)
|
||||
+ (qby.get("blocked") or 0),
|
||||
},
|
||||
"acc": {"n": len(eff), "pass": acc_pass, "items": acc_items},
|
||||
"region": str(_region_dir(ws, goal)).replace("\\", "/"),
|
||||
"port": cfg.get("singleton_port"),
|
||||
"goal_dir": str(inbox).replace("\\", "/"),
|
||||
}
|
||||
|
||||
|
||||
def to_project_view() -> dict:
|
||||
"""本机所有装了机制的工作区 → 项目管理视图。"""
|
||||
installed, skipped = scan_workspaces()
|
||||
projects = [build_project(w) for w in installed]
|
||||
# 🔴 排序:先按「有目标」→ 再按**建立时间**升序(与看板 tab 同口径:次序不漂)
|
||||
projects.sort(key=lambda p: (0 if p["has_goal"] else 1, p["declared_at"] or "~", p["name"]))
|
||||
for i, p in enumerate(projects):
|
||||
p["i"] = i
|
||||
return {
|
||||
"ok": True,
|
||||
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"roots": [str(r).replace("\\", "/") for r in WS_ROOTS],
|
||||
"cols": [{"key": k, "label": lb} for k, lb in COLS],
|
||||
"projects": projects,
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
||||
|
||||
# ── 动作层(⛔ 只在点按钮时跑,⛔ 不在轮询里写任何东西)──────────────────────
|
||||
|
||||
def _valid_ws_name(n: str) -> str:
|
||||
"""工作区目录名校验(⛔ 不许路径穿越、⛔ 不许空)。"""
|
||||
n = str(n or "").strip()
|
||||
if not n or n in (".", "..") or "/" in n or "\\" in n:
|
||||
return ""
|
||||
if re.search(r'[<>:"|?*]', n):
|
||||
return ""
|
||||
return n
|
||||
|
||||
|
||||
def act_create(root: str, name: str, title: str, short: str = "") -> dict:
|
||||
"""**建新目标(新工作区)** —— 调技能自带的 `init_workspace.py`(幂等)。
|
||||
|
||||
🔴 ⛔ 不自己另写一套建区逻辑(那会与技能漂成两份)。只做:
|
||||
① 校验名字 ② 拼路径 ③ 传给技能脚本 ④ 如实回报它的输出。
|
||||
⚠️ 非 ASCII 路径:走**列表参数**调子进程(⛔ 不经 shell),避免编码问题。
|
||||
"""
|
||||
rootp = Path(root)
|
||||
nm = _valid_ws_name(name)
|
||||
if not nm:
|
||||
return {"ok": False, "err": "工作区名不合法(不许空、不许带路径分隔符或 <>:\"|?*)"}
|
||||
if not rootp.is_dir():
|
||||
return {"ok": False, "err": "根目录不存在:%s" % rootp}
|
||||
ws = rootp / nm
|
||||
if ws.exists():
|
||||
return {"ok": False, "err": "已存在:%s(⛔ 不覆盖;要改就进去改)" % ws}
|
||||
script = SKILL_SCRIPTS / "init_workspace.py"
|
||||
if not script.is_file():
|
||||
return {"ok": False, "err": "找不到技能脚本:%s" % script}
|
||||
cmd = [sys.executable, str(script), str(ws),
|
||||
"--title", str(title or nm), "--short", str(short or nm)]
|
||||
# 🔴🔴 **必须显式指定编码**(2026-10-06 实测踩到):
|
||||
# 子进程输出的是 **UTF-8**(`init_workspace.py` 开头就 `reconfigure("utf-8")`),
|
||||
# 而不给 `encoding` 时 Python 按**本机默认编码**(中文 Windows = GBK)解码
|
||||
# ⇒ 中文全变乱码(`锛屽埆璺戞妧鑳界洰褰�`)⇒ 界面上给用户看的日志**完全没法读**。
|
||||
# ⚠️ 同族:本项目已登记「出口一律声明编码」(P0-10)—— 这里是**入口**侧的同一条。
|
||||
# ⚠️ 顺带设 `PYTHONIOENCODING`,兜住子进程自己内部再起孙进程的情况。
|
||||
_env = dict(os.environ)
|
||||
_env["PYTHONIOENCODING"] = "utf-8"
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
|
||||
encoding="utf-8", timeout=300, cwd=str(rootp), env=_env)
|
||||
except Exception as e:
|
||||
return {"ok": False, "err": "跑 init_workspace 失败:%r" % (e,)}
|
||||
out = ((r.stdout or "") + (r.stderr or "")).strip()
|
||||
return {"ok": r.returncode == 0, "rc": r.returncode, "ws": str(ws).replace("\\", "/"),
|
||||
"out": out[-4000:], "cmd": " ".join(cmd)}
|
||||
|
||||
|
||||
def _trash_dir(p: Path):
|
||||
"""把目录送进**系统回收站**(🔴 可逆;⛔ 绝不 `rm -rf`)。返回 `(ok, info)`。
|
||||
|
||||
🔴 2026-10-06 实测:本机 **PortableGit 里 `gio` / `trash-put` / `trash` 一个都没有**
|
||||
⇒ 原来的 `gio trash` 那条路**恒失败**。而 WorkBuddy 的 `Add-Type` 被安全策略拦
|
||||
⇒ 也用不了 `Microsoft.VisualBasic.FileIO`。
|
||||
|
||||
⇒ 现走 **纯 ctypes 调 Windows Shell API `SHFileOperationW`**(不编译任何代码,
|
||||
不碰 .NET,不弹 UAC)—— 这是本机唯一实测可用的回收站途径。
|
||||
"""
|
||||
# ① Windows:SHFileOperationW + FO_DELETE + FOF_ALLOWUNDO(=真正进回收站)
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class SHFILEOPSTRUCTW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("hwnd", wintypes.HWND),
|
||||
("wFunc", wintypes.UINT),
|
||||
("pFrom", ctypes.c_void_p), # 🔴 手搓双 \0 结尾缓冲,见下
|
||||
("pTo", ctypes.c_void_p),
|
||||
("fFlags", ctypes.c_uint16),
|
||||
("fAnyOperationsAborted", wintypes.BOOL),
|
||||
("hNameMappings", ctypes.c_void_p),
|
||||
("lpszProgressTitle", ctypes.c_void_p),
|
||||
]
|
||||
|
||||
FO_DELETE = 0x0003
|
||||
FOF_SILENT = 0x0004
|
||||
FOF_NOCONFIRMATION = 0x0010
|
||||
FOF_ALLOWUNDO = 0x0040 # 🔴 有它才进回收站;没它就=永久删除
|
||||
FOF_NOERRORUI = 0x0400
|
||||
FOF_NOCONFIRMMKDIR = 0x0200
|
||||
|
||||
# 🔴🔴 `pFrom` 必须**双 \0 结尾**。ctypes 的 `LPCWSTR` **会在第一个 \0 截断**
|
||||
# ⇒ 必须手搓 `c_wchar_p` 缓冲并把地址塞进 `c_void_p` 字段。
|
||||
abspath = str(p.resolve()).replace("/", "\\")
|
||||
_buf = ctypes.create_unicode_buffer(abspath, len(abspath) + 2)
|
||||
op = SHFILEOPSTRUCTW()
|
||||
op.hwnd = None
|
||||
op.wFunc = FO_DELETE
|
||||
op.pFrom = ctypes.cast(_buf, ctypes.c_void_p)
|
||||
op.pTo = None
|
||||
op.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT
|
||||
| FOF_NOERRORUI | FOF_NOCONFIRMMKDIR)
|
||||
rc = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
|
||||
# 🔴🔴 **判据=「东西没了 且 没被中止」,⛔ 不是 `rc == 0`**。
|
||||
# 2026-10-06 实测:送回收站**成功**(目录消失、回收站里 18 条 `$I` 元数据
|
||||
# 都能解出原路径),但返回值恒为 **rc=2(ERROR_FILE_NOT_FOUND)** ——
|
||||
# 这是 Shell 内部探测残留的 `GetLastError`,**不是真失败**。
|
||||
# ⇒ 拿 `rc` 当判据会**假报错**(用户看到"失败"但东西已经进回收站了)。
|
||||
# ✅ 真判据只有两条:① 原路径不存在了 ② `fAnyOperationsAborted` 为假。
|
||||
if (not p.exists()) and (not op.fAnyOperationsAborted):
|
||||
return True, abspath
|
||||
return False, "SHFileOperationW rc=%s aborted=%s exists=%s" % (
|
||||
rc, bool(op.fAnyOperationsAborted), p.exists())
|
||||
except Exception as e: # noqa: BLE001
|
||||
_sh_err = "SHFileOperationW 异常:%r" % (e,)
|
||||
else:
|
||||
_sh_err = "非 Windows 平台"
|
||||
|
||||
# ② 兜底:类 Unix 的 `gio trash`(本机实测没有,但留着以备换机)
|
||||
for cmd in (["gio", "trash", str(p)], ["trash-put", str(p)]):
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
|
||||
encoding="utf-8", timeout=120)
|
||||
if r.returncode == 0:
|
||||
return True, str(p).replace("\\", "/")
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
|
||||
return False, "%s;且 gio/trash-put 均不可用" % _sh_err
|
||||
|
||||
|
||||
def act_delete(ws_path: str, mode: str = "goal") -> dict:
|
||||
"""**删**(⛔ 不改状态;两种粒度)。
|
||||
|
||||
· `mode="goal"`(默认,轻量):只删该区的**目标登记**(`goal.json` ⇒ 改名归档),
|
||||
工作区与台账**原样保留** ⇒ 该项目从列表消失,但东西都还在(可逆)。
|
||||
· `mode="ws"`(重):把**整个工作区**送回收站(⛔ 不 `rm`)⇒ 需要二次确认。
|
||||
|
||||
🔴 两条都**可逆**;⛔ 绝不 `rm -rf`;⛔ 不碰别的区。
|
||||
"""
|
||||
ws = Path(ws_path)
|
||||
if not ws.is_dir():
|
||||
return {"ok": False, "err": "工作区不存在:%s" % ws}
|
||||
if mode == "goal":
|
||||
gj = ws / "tmp" / "supervise-inbox" / "goal.json"
|
||||
if not gj.is_file():
|
||||
return {"ok": False, "err": "该区没有目标登记(%s)" % gj}
|
||||
bak = gj.with_name("goal.json.archived-%s" % time.strftime("%Y%m%d-%H%M%S"))
|
||||
try:
|
||||
gj.rename(bak)
|
||||
except Exception as e:
|
||||
return {"ok": False, "err": "归档目标失败:%r" % (e,)}
|
||||
return {"ok": True, "mode": "goal", "moved_to": str(bak).replace("\\", "/")}
|
||||
if mode == "ws":
|
||||
ok, info = _trash_dir(ws)
|
||||
if ok:
|
||||
return {"ok": True, "mode": "ws", "trashed": info}
|
||||
# 🔴 **fail-closed**:本机没有任何可用的回收站途径 ⇒ **如实报错,⛔ 不 rm 兜底**
|
||||
return {"ok": False, "err": "送回收站失败(⛔ 不用 rm 兜底):%s" % info}
|
||||
return {"ok": False, "err": "未知 mode:%s(只支持 goal / ws)" % mode}
|
||||
|
||||
|
||||
# ── HTTP ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "ProjectBoard/1.0"
|
||||
server_version = "ProjectBoard/2.0"
|
||||
|
||||
def _send(self, code: int, body: bytes, ctype: str) -> None:
|
||||
try:
|
||||
@@ -170,6 +467,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _json(self, code: int, obj) -> None:
|
||||
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
|
||||
"application/json; charset=utf-8")
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
path = self.path.split("?")[0]
|
||||
if path in ("/", "/index.html", "/project-board.html"):
|
||||
@@ -180,19 +481,31 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"text/plain; charset=utf-8")
|
||||
elif path == "/api/projects":
|
||||
try:
|
||||
snap = fetch_upstream()
|
||||
body = json.dumps(to_project_view(snap), ensure_ascii=False).encode("utf-8")
|
||||
self._send(200, body, "application/json; charset=utf-8")
|
||||
self._json(200, to_project_view())
|
||||
except Exception as e:
|
||||
# 🔴 上游读不到 ⇒ **如实报错**(⛔ 不返回空列表假装"没有项目")
|
||||
self._send(502, json.dumps(
|
||||
{"ok": False, "err": "读不到上游看板:%s" % e, "upstream": UPSTREAM},
|
||||
ensure_ascii=False).encode("utf-8"),
|
||||
"application/json; charset=utf-8")
|
||||
self._json(500, {"ok": False, "err": "扫工作区失败:%r" % (e,)})
|
||||
elif path == "/healthz":
|
||||
self._send(200, json.dumps({"ok": True, "upstream": UPSTREAM},
|
||||
ensure_ascii=False).encode("utf-8"),
|
||||
"application/json; charset=utf-8")
|
||||
self._json(200, {"ok": True, "roots": [str(r) for r in WS_ROOTS]})
|
||||
else:
|
||||
self._send(404, b"not found", "text/plain; charset=utf-8")
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
path = self.path.split("?")[0]
|
||||
try:
|
||||
ln = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(ln) if ln else b"{}"
|
||||
body = json.loads(raw.decode("utf-8") or "{}")
|
||||
except Exception as e:
|
||||
self._json(400, {"ok": False, "err": "body 解析失败:%r" % (e,)})
|
||||
return
|
||||
if path == "/api/create":
|
||||
self._json(200, act_create(str(body.get("root") or ""),
|
||||
str(body.get("name") or ""),
|
||||
str(body.get("title") or ""),
|
||||
str(body.get("short") or "")))
|
||||
elif path == "/api/delete":
|
||||
self._json(200, act_delete(str(body.get("ws") or ""),
|
||||
str(body.get("mode") or "goal")))
|
||||
else:
|
||||
self._send(404, b"not found", "text/plain; charset=utf-8")
|
||||
|
||||
@@ -201,15 +514,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="项目管理界面(只读 · 复用现有看板数据)")
|
||||
ap = argparse.ArgumentParser(description="项目管理界面(本机所有工作区 · 增删查)")
|
||||
ap.add_argument("--serve", type=int, default=0, metavar="PORT",
|
||||
help="起 HTTP 服务(默认不启,只做一次转换并打印摘要)")
|
||||
ap.add_argument("--once", action="store_true", help="拉一次上游并打印转换结果摘要")
|
||||
help="起 HTTP 服务(默认不启,只做一次扫描并打印摘要)")
|
||||
ap.add_argument("--once", action="store_true", help="扫一次并打印摘要")
|
||||
a = ap.parse_args()
|
||||
|
||||
if a.serve:
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", a.serve), Handler)
|
||||
_say("项目管理界面已起:http://127.0.0.1:%d/ (只读 · 上游 %s)" % (a.serve, UPSTREAM))
|
||||
_say("项目管理界面已起:http://127.0.0.1:%d/ (根目录 %s)"
|
||||
% (a.serve, ", ".join(str(r) for r in WS_ROOTS)))
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
@@ -217,18 +531,17 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
if a.once:
|
||||
try:
|
||||
v = to_project_view(fetch_upstream())
|
||||
except Exception as e:
|
||||
_say("⛔ 读不到上游:%s" % e)
|
||||
return 2
|
||||
_say("上游=%s|工作区=%s" % (v["upstream"], v["ws_name"]))
|
||||
v = to_project_view()
|
||||
_say("根目录=%s" % ", ".join(v["roots"]))
|
||||
_say("项目 %d 个:" % len(v["projects"]))
|
||||
for p in v["projects"]:
|
||||
_say(" 项目 %-18r 建立=%s 卡片=%d 会话=%d 工作流=%d 台账:总%s/待%s/中%s/阻%s/完成%s%s"
|
||||
% (p["name"][:18], p["created"] or "-", len(p["cards"]), len(p["sessions"]),
|
||||
len(p["flows"]), p["queue"]["total"], p["queue"]["pending"],
|
||||
_say(" %-22r 建立=%-16s 生命=%-5s 卡片=%d 台账:总%s/待%s/中%s/阻%s/完成%s 验收 %d/%d"
|
||||
% (p["name"][:22], p["declared_at"] or "-", p["life"] or "-",
|
||||
len(p["cards"]), p["queue"]["total"], p["queue"]["pending"],
|
||||
p["queue"]["running"], p["queue"]["blocked"], p["queue"]["done"],
|
||||
("(另有历史 %d 件)" % p["queue"]["legacy"]) if p["queue"]["legacy"] else ""))
|
||||
p["acc"]["pass"], p["acc"]["n"]))
|
||||
if v["skipped"]:
|
||||
_say("(扫到但**没装机制**、未列出:%s)" % "、".join(v["skipped"]))
|
||||
return 0
|
||||
ap.print_help()
|
||||
return 0
|
||||
|
||||
Reference in new issue
Block a user