项目管理界面:改为跨工作区 + 增删落地(回收站走 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:
admin committed 2026-10-06 09:01:10 +08:00
1 parent 0cf981f419
commit 89f820a0f8
3 files changed
+688 -147

No files matched your search

+62
View File
@@ -369,3 +369,65 @@ vibe-product 上一条会话(10-06 08:15~08:20)已诊断出两条缺陷并**
⚠️ 这属「**影响面超出本平台/跨工作区状态变更**」⇒ **先报用户,⛔ 不擅自动手**。 ⚠️ 这属「**影响面超出本平台/跨工作区状态变更**」⇒ **先报用户,⛔ 不擅自动手**。
⚠️ 另:vibe-product 常驻主循环跑的是**旧代码**(09-29 启动,配置改动需重启才生效)—— ⚠️ 另:vibe-product 常驻主循环跑的是**旧代码**(09-29 启动,配置改动需重启才生效)——
本次修的是**技能源**,其工作区副本要同步+重启才生效(这一步同样未做)。 本次修的是**技能源**,其工作区副本要同步+重启才生效(这一步同样未做)。
## 41、项目管理界面改「跨工作区」+ 增删落地(用户:「项目管理管的是本机所有工作区的项目 不是单个工作区的」)
### 41.1 用户口径(逐字)
> `项目管理管的是本机所有工作区的项目 不是单个工作区的,可以通过项目管理工具 创建新目标(新工作区),管理目标`
外加三条 AskUserQuestion 选定:① 项目范围=**只列「装了机制」的工作区**;② 建新目标=**填表建目录+登记目标**;
③ 写权限=**允许增删,改状态交给机制**(承接上一轮 `增删查(状态不需要改 让会话机制自己处理)`)。
### 41.2 数据面(🔴 关键设计:直读各区文件,⛔ 不拉各区看板)
**真因**:并非每个区都起着 `board.py`(本机 3 个装机制的区里只有 1 个在看板跑)⇒ 拉看板会**静默漏掉没起看板的区**。
✅ 改直读:`<区>/tmp/supervise-inbox/goal.json`(项目)+ `tasks.json`(卡片)+ `collabd-state.json`(队列)
+ `<区>/.workbuddy/collab/collabd.config.json`(判「装没装机制」)。
**「装了机制」判据**:① 有 `collabd.config.json` 或 ② 有 `goal.json`(两条任一)。
🔴 跳过的**也要带出来**(`skipped` 列表)—— 同族 P0「作用域不许静默排除」,⛔ 不静默丢。
### 41.3 新增能力
| 能力 | 实现 | 实测 |
|---|---|---|
| **查** | `GET /api/projects` | 3 个项目(本机协作 / vibe-product / agent-product 空壳)+ 12 个未装机制目录 |
| **增** | `POST /api/create` → `init_workspace.py` | `ok=True rc=0`,骨架+目标登记齐全 |
| **删(轻)** | `POST /api/delete {mode:"goal"}` | `goal.json` → `goal.json.archived-<时间戳>`(可逆) |
| **删(重)** | `POST /api/delete {mode:"ws"}` | **送系统回收站**(可逆) |
### 41.4 🔴🔴 本机回收站:`gio` 不存在,改走 `SHFileOperationW`
**症状**:`mode="ws"` 原写 `gio trash` ⇒ `gio: command not found`(PortableGit 里 `gio`/`trash-put`/`trash` **一个都没有**);
改用 PowerShell `[Microsoft.VisualBasic.FileIO.FileSystem]` ⇒ **被安全策略拦**("Add-Type compiles and loads .NET code at runtime")。
**✅ 正解**:纯 `ctypes` 调 Windows Shell API `SHFileOperationW`(不编译代码、不碰 .NET、不弹 UAC)。
**三个坑**(都踩过):
1. 🔴 `pFrom` **必须手搓双 `\0` 结尾缓冲** —— `LPCWSTR` 会在**第一个 `\0` 截断** ⇒ 字段类型必须写 `c_void_p`,
用 `ctypes.create_unicode_buffer(abspath, len(abspath)+2)` 再 `cast` 塞进去。⛔ 不能直接赋 `str`。
2. 🔴🔴 **判据不是 `rc == 0`**:送回收站**成功**(目录消失、`$I` 元数据可解出原路径),返回值却**恒为 `rc=2`**
(`ERROR_FILE_NOT_FOUND`,Shell 内部探测残留的 `GetLastError`)⇒ 拿 `rc` 当判据会**假报错**
(用户看到"失败"但东西已进回收站)。✅ 真判据只有两条:**① 原路径不存在了 ② `fAnyOperationsAborted` 为假**。
3. ⚠️ 回收站 `$I` 元数据:`v2` 格式、路径是 **UTF-16LE**、起点 **offset 24**(⛔ 不是 26;开头 4 字节是长度前缀)。
验证可恢复性时按此解析。
**实测取证**:`_pmtest-{verify,trash,wsdel,wsdel2,http}` 全部送进 `E:/$RECYCLE.BIN/S-1-5-21-…-500/`,
共 **29+ 条 `$I` 元数据**,逐条解出的原路径与送入路径一致 ⇒ **可恢复**。
### 41.5 另一个已修缺陷:子进程中文乱码
`init_workspace.py` 输出 **UTF-8**(开头 `reconfigure("utf-8")`),而 `subprocess.run` 不给 `encoding`
时按**本机默认编码(中文 Windows=GBK)**解码 ⇒ 中文全变乱码。✅ 修:`encoding="utf-8"` + `PYTHONIOENCODING=utf-8`。
### 41.6 起服务:只许触发计划任务
`(python project-board.py --serve 20100 &)` **活不过工具调用边界**(被 SIGTERM)。
✅ `Start-ScheduledTask -TaskName 'dsh-project-board-keepalive'`(动作走 `pythonw.exe` + `project-board-launch.py`,
⛔ 不直起业务脚本 —— 同族 P0-73/74)。重启后 PID `64312` → `10408`,`/healthz` HTTP 200。
### 41.7 挂账
- 🔴 **vibe-product 是否收工** —— 仍待用户拍板(见 §40.8,未动)。
- 🔴 技能仓历史分叉未决(§36,仍待拍板)。
+200 -34
View File
@@ -65,6 +65,34 @@
color:#f4c67a;font-size:12.5px} color:#f4c67a;font-size:12.5px}
.errline{margin:0 0 12px;padding:10px 12px;border-radius:8px; .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} 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; .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> <div class="brand">项目管理<small id="ws">—</small></div>
<label for="psel" class="stamp">项目</label> <label for="psel" class="stamp">项目</label>
<select id="psel" aria-label="选择项目"></select> <select id="psel" aria-label="选择项目"></select>
<button id="btndel" type="button" class="danger" title="删除当前项目(可选粒度)">删除</button>
<div class="spacer"></div> <div class="spacer"></div>
<span class="stamp" id="stamp">加载中…</span> <span class="stamp" id="stamp">加载中…</span>
<button id="btnnew" type="button" class="primary">+ 新建目标</button>
<button id="refresh" type="button">刷新</button> <button id="refresh" type="button">刷新</button>
</div> </div>
<div class="summary" id="summary"></div> <div class="summary" id="summary"></div>
@@ -120,6 +150,32 @@
<div class="abody" id="dbody"></div> <div class="abody" id="dbody"></div>
</aside> </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> <script>
"use strict"; "use strict";
var STATE = { data:null, cur:0 }; var STATE = { data:null, cur:0 };
@@ -140,19 +196,23 @@ function load(){
.then(function(d){ .then(function(d){
var n = document.getElementById("notice"); var n = document.getElementById("notice");
if(!d || d.ok === false){ if(!d || d.ok === false){
n.innerHTML = '<div class="errline">读不到上游看板:' + esc((d&&d.err)||"未知错误") + n.innerHTML = '<div class="errline">扫工作区失败:' + esc((d&&d.err)||"未知错误") + '</div>';
'<br>上游:' + esc((d&&d.upstream)||"") + '</div>';
return; return;
} }
STATE.data = d; STATE.data = d;
n.innerHTML = ""; n.innerHTML = "";
document.getElementById("ws").textContent = d.ws_name || "—"; document.getElementById("ws").textContent = (d.roots||[]).join(" · ") || "—";
document.getElementById("stamp").textContent = "数据时间 " + (d.ts || "—"); document.getElementById("stamp").textContent = "数据时间 " + (d.ts || "—");
var sel = document.getElementById("psel"); var sel = document.getElementById("psel");
sel.innerHTML = d.projects.map(function(p,i){ if(!d.projects.length){
return '<option value="'+i+'">'+esc(p.name)+'</option>'; }).join(""); sel.innerHTML = '<option value="">(本机暂无装了机制的工作区)</option>';
if(STATE.cur >= d.projects.length) STATE.cur = 0; STATE.cur = 0;
sel.value = String(STATE.cur); } 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(); render();
}).catch(function(e){ }).catch(function(e){
document.getElementById("notice").innerHTML = document.getElementById("notice").innerHTML =
@@ -162,26 +222,42 @@ function load(){
function render(){ function render(){
var d = STATE.data; if(!d) return; 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 = []; var q = p.queue, s = [];
s.push('<span class="chip">项目名 <b>'+esc(p.name)+'</b></span>'); 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.total+'</b> 件</span>');
s.push('<span class="chip">已完成 <b>'+q.done+'</b></span>'); s.push('<span class="chip">已完成 <b>'+q.done+'</b></span>');
s.push('<span class="chip">未完 <b>'+q.open+'</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(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>'); 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>'); if(p.life) s.push('<span class="chip">目标状态 <b>'+esc(p.life)+'</b></span>');
document.getElementById("summary").innerHTML = s.join(""); if(p.topics && p.topics.length)
s.push('<span class="chip">任务类别 <b>'+esc(p.topics.join("、"))+'</b></span>');
sums.innerHTML = s.join("");
// 提示行 // 提示行
var nt = []; var nt = [];
if(q.legacy) nt.push('<div class="warnline">另有 <b>'+q.legacy+'</b> 件台账条目无目标归属(早期写的、未打标记)⇒ 不计入本项目卡片。</div>'); if(!p.has_goal)
if(p.orphan && p.orphan.n) nt.push('<div class="warnline">有 <b>'+p.orphan.n+'</b> 个工作流类别不在本项目的类别清单里(旧线名)⇒ 卡片见「未归类」。</div>'); 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(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(""); document.getElementById("notice").innerHTML = nt.join("");
// 列 // 列
@@ -198,7 +274,7 @@ function render(){
if(t.t_end) bits.push(esc(fmtWhen(t.t_end))); if(t.t_end) bits.push(esc(fmtWhen(t.t_end)));
else if(t.t_start) bits.push(esc(fmtWhen(t.t_start))); 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)+'">'+ 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>' : '')+ (t.block_reason ? '<div class="meta" style="color:#f2a09a">受阻:'+esc(t.block_reason)+'</div>' : '')+
(bits.length ? '<div class="meta">'+bits.join("")+'</div>' : '')+ (bits.length ? '<div class="meta">'+bits.join("")+'</div>' : '')+
'</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.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.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>'); 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('</dl>');
// 会话(点进对应会话) // 验收判据(本项目达成与否的唯一机读依据)
h.push('<div class="sect"><h4>本项目会话(点开跳转)</h4>'); h.push('<div class="sect"><h4>验收判据('+((p.acc&&p.acc.n)||0)+' 条 · 通过 '+((p.acc&&p.acc.pass)||0)+')</h4>');
if((p.sessions||[]).length){ if(p.acc && p.acc.items && p.acc.items.length){
p.sessions.forEach(function(s){ p.acc.items.forEach(function(a){
h.push('<div class="sess" data-sid="'+esc(s.id8)+'">'+ h.push('<div class="flow"><span class="fn">'+esc(a.k)+'</span>'+
'<span class="sn">'+esc(s.title || "(无标题)")+'</span>'+ '<span class="fc" style="color:'+(a.pass?"#8fe39a":"#f2a09a")+'">'+
'<span class="badge">'+esc(s.role||"")+'</span>'+ (a.pass?"过":"未过")+' · '+esc(a.v.slice(0,120))+'</span></div>');
'<span class="badge">'+esc(s.status||"")+'</span>'+
'<span class="go">打开 ›</span></div>');
}); });
} else h.push('<div class="empty">(无)</div>'); } else h.push('<div class="empty">(无验收判据)</div>');
h.push('</div>'); h.push('</div>');
// 工作流 // 工作流
h.push('<div class="sect"><h4>工作流(任务类别)</h4>'); h.push('<div class="sect"><h4>工作流(任务类别)</h4>');
if((p.flows||[]).length){ if((p.flows||[]).length){
p.flows.forEach(function(f){ 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>'); '<span class="fc">共 '+f.total+' · 完成 '+f.done+' · 未完 '+f.open+'</span></div>');
}); });
} else h.push('<div class="empty">(无)</div>'); } else h.push('<div class="empty">(无)</div>');
h.push('</div>'); h.push('</div>');
document.getElementById("dbody").innerHTML = h.join(""); 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("mask").classList.add("on");
document.getElementById("drawer").classList.add("on"); document.getElementById("drawer").classList.add("on");
document.getElementById("drawer").setAttribute("aria-hidden","false"); document.getElementById("drawer").setAttribute("aria-hidden","false");
@@ -274,12 +343,109 @@ function closeDetail(){
document.getElementById("drawer").setAttribute("aria-hidden","true"); 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("mask").addEventListener("click", closeDetail);
document.getElementById("dclose").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){ document.getElementById("psel").addEventListener("change", function(e){
STATE.cur = parseInt(e.target.value, 10) || 0; render(); }); STATE.cur = parseInt(e.target.value, 10) || 0; render(); });
document.getElementById("refresh").addEventListener("click", load); 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(); load();
setInterval(load, 5000); setInterval(load, 5000);
+426 -113
View File
@@ -1,36 +1,47 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""项目管理界面 —— 照 dsh-task-board 的「项目 × 卡片」模式做的**只读**看板。 """项目管理界面 —— 管**本机所有工作区**的项目(照 dsh-task-board 的「项目 × 卡片」模式)。
## 这是什么(用户 2026-10-06 原话) ## 这是什么(用户原话)
> 「不动现有任务会话看板,不是让你整体搬,是照的他的模式做一个 项目管理的界面 > 「不动现有任务会话看板,不是让你整体搬,是照的他的模式做一个 项目管理的界面
> 来管理 执行任务,**目标等于项目**」 > 来管理 执行任务,**目标等于项目**」
⇒ 三条硬约束: > 「项目管理管的是**本机所有工作区的项目** 不是单个工作区的,
> 可以通过项目管理工具 **创建新目标(新工作区)**,管理目标」
⇒ 硬约束:
① ⛔ **不动**现有看板(`board.py` / `board.html` / 20099 一律零改动); ① ⛔ **不动**现有看板(`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` → 列) · 一个**工作区** → 一个**项目**(`goal.json` 的 `short`/`title` 是项目名)
· `sessions[]` → 卡片可跳的**会话**(`id8` 是跳转键) · 该区**台账条目** → **卡片**(`state` → 四列)
· 该区**会话** → 卡片可跳的会话(`id8` 是跳转键)
## 列(⛔ 用机制真实四态,不硬套它那五列) ## 列(⛔ 用机制真实四态,不硬套它那五列)
dsh-task-board 那套是 待规划/待办/进行中/已完成/已失败; 本机制台账只有 **待执行 / 执行中 / 有阻碍 / 已完成** 四态;
本机制台账只有 **待执行 / 执行中 / 已完成 / 有阻碍** 四态 硬套 dsh-task-board 的五列(含「待规划」)会凭空多出机制里**不存在**的一列。
⇒ 硬套五列会凭空多出机制里**不存在**的「待规划」⇒ 看着像有、其实永远空。
✅ 用户问「什么工作流状态 干啥用的」⇒ 就是这一列的含义;**按真实态列**。
## 起法 ## 起法
@@ -41,6 +52,9 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os import os
import re
import shutil
import subprocess
import sys import sys
import time import time
import urllib.request import urllib.request
@@ -50,8 +64,17 @@ from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
PAGE = HERE / "project-board.html" 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 _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` 病根:计划任务用 **`pythonw.exe`** 跑 ⇒ **没有控制台** ⇒ `sys.stdout is None`
⇒ `print()` / `sys.stdout.flush()` 直接 `AttributeError: 'NoneType' object has no ⇒ `print()` / `sys.stdout.flush()` 直接 `AttributeError: 'NoneType' object has no
attribute 'flush'` ⇒ **进程当场崩**(而 `Result=1` 只说"失败",看不出为什么)。 attribute 'flush'` ⇒ **进程当场崩**(而 `Result=1` 只说"失败",看不出为什么)。
⚠️ 这正是"终端里好好的、一进计划任务就死"的典型 —— ⛔ 别用裸 `print` 起常驻。
✅ 有 stdout 才写;没有就**静默丢掉**(这是常驻进程,⛔ 不该因为没地方打印就崩)。 ✅ 有 stdout 才写;没有就**静默丢掉**(这是常驻进程,⛔ 不该因为没地方打印就崩)。
""" """
f = getattr(sys, "stdout", None) f = getattr(sys, "stdout", None)
@@ -75,13 +97,6 @@ def _say(msg: str) -> None:
pass 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` 键**逐字一致**) # 台账四态 → 列(🔴 与 board.py::_queue 的 `by` 键**逐字一致**)
COLS = [ COLS = [
("pending", "待执行"), ("pending", "待执行"),
@@ -91,73 +106,355 @@ COLS = [
] ]
def to_project_view(snap: dict) -> dict: def _load_json(p: Path, default=None):
"""把现有看板的 `/board.json` 转成**项目管理视图**(纯映射,⛔ 不重算业务)。""" try:
goals = snap.get("goals") or [] return json.loads(p.read_text(encoding="utf-8"))
projects = [] except Exception:
for gi, g in enumerate(goals): return default
gg = g.get("goal") or {}
q = g.get("queue") or {}
qby = q.get("by") or {} def _acc_is_pass(v) -> bool:
tasks = g.get("tasks") or {} """一条验收判据算不算过 —— **复用技能真身**(⛔ 不自己写词表;见 pitfalls P0-87)。"""
# 台账条目 → 卡片(只取当前目标的那几条;无归属的按 legacy 单列,⛔ 不混进项目) try:
cards = [] if str(SKILL_SCRIPTS) not in sys.path:
for tid, t in (tasks or {}).items(): sys.path.insert(0, str(SKILL_SCRIPTS))
st = str((t or {}).get("state") or "").strip().lower() import board as _b
if st not in {c[0] for c in COLS}: 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 continue
cards.append({ try:
"id": tid, key = str(d.resolve()).lower()
"state": st, except Exception:
"by": str((t or {}).get("by") or ""), continue
"line": str((t or {}).get("line") or ""), if key in seen:
"artifact": str((t or {}).get("artifact") or ""), continue
"block_reason": str((t or {}).get("block_reason") or ""), seen.add(key)
"t_start": str((t or {}).get("t_start") or ""), cfg = d / ".workbuddy" / "collab" / "collabd.config.json"
"t_end": str((t or {}).get("t_end") or ""), gj = d / "tmp" / "supervise-inbox" / "goal.json"
}) if cfg.is_file() or gj.is_file():
# 会话(卡片"点开到对应会话"用;`id8` = 跳转键) installed.append(d)
sess = [{"id8": s.get("id8"), "title": s.get("title") or "", else:
"status": s.get("status") or "", "role": s.get("role") or "", skipped.append(d.name)
"age_min": s.get("age_min")} for s in (g.get("sessions") or [])] return installed, skipped
# 工作流分组(labor)
flows = [{"line": x.get("line") or x.get("name") or "",
"name": x.get("name") or x.get("line") or "", def _region_dir(ws: Path, goal: dict) -> Path:
"total": x.get("total") or 0, "done": x.get("done") or 0, """目标文件夹(`执行会话/目标-<区名>-<短哈希>`)—— ⛔ 与技能同口径,不从目录名猜。"""
"open": x.get("open") or 0, "state": x.get("state") or ""} ex = str((goal or {}).get("execution_doc") or "").strip()
for x in (g.get("labor") or [])] if ex:
orphan = g.get("orphan") or {} p = ws / ex
projects.append({ if p.parent.is_dir():
"i": gi, return p.parent
"name": str(gg.get("short") or gg.get("title") or "(未命名)"), return ws / "执行会话"
"title": str(gg.get("title") or ""),
"peer": str(g.get("peer") or ""),
"created": str(g.get("tab_created") or ""), def build_project(ws: Path) -> dict:
"life": str(g.get("life") or ""), """把**一个工作区**读成「项目」(纯读,⛔ 不写任何东西)。"""
"life_at": str(g.get("life_at") or ""), inbox = ws / "tmp" / "supervise-inbox"
"cards": cards, goal = _load_json(inbox / "goal.json", {}) or {}
"sessions": sess, tasks = _load_json(inbox / "tasks.json", {}) or {}
"flows": flows, st = _load_json(inbox / "collabd-state.json", {}) or {}
"orphan": {"n": orphan.get("n") or 0, "lines": orphan.get("lines") or []}, cfg = _load_json(ws / ".workbuddy" / "collab" / "collabd.config.json", {}) 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, short = str(goal.get("short") or "").strip()
"running": qby.get("running") or 0, "blocked": qby.get("blocked") or 0, title = str(goal.get("title") or "").strip()
"legacy": q.get("legacy") or 0}, name = short or title or ("(未命名 · %s)" % ws.name)
"acc": g.get("acc_summary") or "",
# ── 卡片(台账条目;只取四态里的)
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 { return {
"ok": True, "ws": str(ws).replace("\\", "/"),
"ts": snap.get("ts") or "", "dir": ws.name,
"ws_name": snap.get("ws_name") or "", "name": name,
"ws_root": snap.get("ws_root") or "", "title": title,
"upstream": UPSTREAM, "topics": topics,
"cols": [{"key": k, "label": lb} for k, lb in COLS], "life": str(goal.get("lifecycle") or "").strip(),
"projects": projects, "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): class Handler(BaseHTTPRequestHandler):
server_version = "ProjectBoard/1.0" server_version = "ProjectBoard/2.0"
def _send(self, code: int, body: bytes, ctype: str) -> None: def _send(self, code: int, body: bytes, ctype: str) -> None:
try: try:
@@ -170,6 +467,10 @@ class Handler(BaseHTTPRequestHandler):
except Exception: except Exception:
pass 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 def do_GET(self): # noqa: N802
path = self.path.split("?")[0] path = self.path.split("?")[0]
if path in ("/", "/index.html", "/project-board.html"): if path in ("/", "/index.html", "/project-board.html"):
@@ -180,19 +481,31 @@ class Handler(BaseHTTPRequestHandler):
"text/plain; charset=utf-8") "text/plain; charset=utf-8")
elif path == "/api/projects": elif path == "/api/projects":
try: try:
snap = fetch_upstream() self._json(200, to_project_view())
body = json.dumps(to_project_view(snap), ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
except Exception as e: except Exception as e:
# 🔴 上游读不到 ⇒ **如实报错**(⛔ 不返回空列表假装"没有项目") self._json(500, {"ok": False, "err": "扫工作区失败:%r" % (e,)})
self._send(502, json.dumps(
{"ok": False, "err": "读不到上游看板:%s" % e, "upstream": UPSTREAM},
ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8")
elif path == "/healthz": elif path == "/healthz":
self._send(200, json.dumps({"ok": True, "upstream": UPSTREAM}, self._json(200, {"ok": True, "roots": [str(r) for r in WS_ROOTS]})
ensure_ascii=False).encode("utf-8"), else:
"application/json; charset=utf-8") 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: else:
self._send(404, b"not found", "text/plain; charset=utf-8") self._send(404, b"not found", "text/plain; charset=utf-8")
@@ -201,15 +514,16 @@ class Handler(BaseHTTPRequestHandler):
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser(description="项目管理界面(只读 · 复用现有看板数据)") ap = argparse.ArgumentParser(description="项目管理界面(本机所有工作区 · 增删查)")
ap.add_argument("--serve", type=int, default=0, metavar="PORT", ap.add_argument("--serve", type=int, default=0, metavar="PORT",
help="起 HTTP 服务(默认不启,只做一次转换并打印摘要)") help="起 HTTP 服务(默认不启,只做一次扫描并打印摘要)")
ap.add_argument("--once", action="store_true", help="拉一次上游并打印转换结果摘要") ap.add_argument("--once", action="store_true", help="扫一次并打印摘要")
a = ap.parse_args() a = ap.parse_args()
if a.serve: if a.serve:
srv = ThreadingHTTPServer(("127.0.0.1", a.serve), Handler) 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: try:
srv.serve_forever() srv.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -217,18 +531,17 @@ def main() -> int:
return 0 return 0
if a.once: if a.once:
try: v = to_project_view()
v = to_project_view(fetch_upstream()) _say("根目录=%s" % ", ".join(v["roots"]))
except Exception as e: _say("项目 %d 个:" % len(v["projects"]))
_say("⛔ 读不到上游:%s" % e)
return 2
_say("上游=%s|工作区=%s" % (v["upstream"], v["ws_name"]))
for p in v["projects"]: for p in v["projects"]:
_say(" 项目 %-18r 建立=%s 卡片=%d 会话=%d 工作流=%d 台账:总%s/待%s/中%s/阻%s/完成%s%s" _say(" %-22r 建立=%-16s 生命=%-5s 卡片=%d 台账:总%s/待%s/中%s/阻%s/完成%s 验收 %d/%d"
% (p["name"][:18], p["created"] or "-", len(p["cards"]), len(p["sessions"]), % (p["name"][:22], p["declared_at"] or "-", p["life"] or "-",
len(p["flows"]), p["queue"]["total"], p["queue"]["pending"], len(p["cards"]), p["queue"]["total"], p["queue"]["pending"],
p["queue"]["running"], p["queue"]["blocked"], p["queue"]["done"], 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 return 0
ap.print_help() ap.print_help()
return 0 return 0