项目管理界面:照 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:
1 parent
87a9c97b54
commit
1b68b1ac3f
4 files changed
+651
No files matched your search
@@ -218,6 +218,73 @@
|
||||
- **C**:真要在 **DSH** 里用(那与本技能无关,装进 DSH 即可)
|
||||
⇒ 三选一才谈得上下一步,⛔ 别猜。
|
||||
|
||||
---
|
||||
|
||||
## 三十九、新建「项目管理界面」(照 dsh-task-board 模式 · 目标=项目)— 已交付在跑
|
||||
|
||||
### 用户纠正我(逐字)
|
||||
|
||||
> 「**不动现有任务会话看板**,**不是让你整体搬**,是照的他的模式做一个 **项目管理的界面**
|
||||
> 来管理 执行任务,**目标等于项目**」
|
||||
|
||||
⇒ ⚠️ 我上一轮把问题问成"要不要移植/怎么移植"=**问错了方向**:用户要的是**新做一个界面**,
|
||||
⛔ 不是搬迁、⛔ 不是改现有看板。
|
||||
|
||||
### 交付物(新建,⛔ 现有看板零改动)
|
||||
|
||||
📁 `交付物/项目管理界面/`(**3 个文件**)
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `project-board.py` | 只读服务 + 映射层(`--serve 20100`/`--once` 打摘要) |
|
||||
| `project-board.html` | 界面:项目下拉 + 四列卡片 + 点开抽屉 |
|
||||
| `project-board-launch.py` | **启动器**(计划任务动作指向它,⛔ 不直起业务脚本) |
|
||||
|
||||
- **端口 20100**(现有看板 20099 **不动**);计划任务 **`dsh-project-board-keepalive`**(5 分钟判活)。
|
||||
- ✅ 实测:20100 `HTTP 200`;`/api/projects` 返回 2 个项目/12 张卡片;20099 同时 `HTTP 200`(互不影响)。
|
||||
|
||||
### 🔴 映射关系(唯一真源 = 现有看板的 `/board.json`,⛔ 不重算)
|
||||
|
||||
| 它的概念 | 本机制 | 数据来源 |
|
||||
|---|---|---|
|
||||
| 项目 | **目标** | `goals[]`(`goal.short`/`tab_created`) |
|
||||
| 工作流分组 | **任务类别** | `labor[]` |
|
||||
| 卡片 | **台账条目** | `tasks{}`(`state` → 列) |
|
||||
| 点卡片 → 会话 | **会话跳转** | `sessions[].id8` |
|
||||
|
||||
🔴 **关键决定:⛔ 不直读台账、⛔ 不重算** —— 复用现有看板**已经算好的** `/board.json`
|
||||
(重算 = 把 `board.py` 那套逻辑抄第二份 ⇒ 同族 P0「一条规则两份实现」必漂)。
|
||||
|
||||
### 🔴 列用**机制真实四态**,⛔ 不硬套它那五列
|
||||
|
||||
- 它:待规划/待办/进行中/已完成/已失败(5 列)
|
||||
- 本机制台账只有 **待执行/执行中/有阻碍/已完成**(4 态)
|
||||
- ⇒ 硬套 5 列会**凭空多出机制里不存在的「待规划」**(看着像有、其实永远空)⇒ ⛔ 不做。
|
||||
- ⚠️ 用户问「什么工作流状态 干啥用的」⇒ 就是这一列的含义,已当面解释。
|
||||
|
||||
### 🔴🔴 本轮踩的真坑:`pythonw.exe` 下 `sys.stdout is None`(终端好、计划任务死)
|
||||
|
||||
- **症状**:计划任务 `Result=1`,**20100 起不来**;但**前台跑同一脚本一切正常**。
|
||||
- **根因**:计划任务用 `pythonw.exe`(**无控制台**)⇒ **`sys.stdout is None`**
|
||||
⇒ 我写的 `sys.stdout.flush()` 抛 `AttributeError: 'NoneType' object has no attribute 'flush'`
|
||||
⇒ 进程**当场崩**。而 `Result=1` 只说"失败",**看不出为什么**。
|
||||
- **诊断方法(✅ 可复用)**:加一个**只写异常到文件**的 wrapper(`launch-diag.py`),
|
||||
指向计划任务动作 ⇒ 异常立刻现形(⛔ 别靠猜 `Result=1`)。
|
||||
- **修法**:加 `_say()` —— **有 stdout 才写、没有就静默丢**;⛔ 常驻进程不许因为没地方打印就崩。
|
||||
- 🔴 **同族铁律**:**`pythonw.exe` 下 `print`/`sys.stdout` 一律不可靠**
|
||||
⇒ 凡"计划任务跑的脚本"⛔ 不许裸用 `print`。
|
||||
|
||||
### ⚠️ 另一条弯路(别重蹈)
|
||||
|
||||
- 我一度以为是**中文路径**(`交付物/项目管理界面`)导致计划任务失败 ⇒ 复制到 ASCII 路径 `tmp/pm-board` **对照**
|
||||
⇒ **同样 `Result=1`** ⇒ 证明**与路径无关**,真因是上面那条。
|
||||
- ⇒ 教训:**路径猜测**要有对照实验才下结论(我用了一轮,但及时纠正并清理了临时副本)。
|
||||
|
||||
### 清理
|
||||
|
||||
- `tmp/pm-board/`(对照实验副本)**已删**;计划任务已**重指回正式交付目录**。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""项目管理界面的**启动器**(计划任务动作指向它,⛔ 不许直起 `project-board.py`)。
|
||||
|
||||
## 为什么必须有这一层(同族 P0-73/74)
|
||||
|
||||
· 计划任务的**动作没有 env 字段** ⇒ 变量只能在**进程内**设;
|
||||
· 直接用 `python.exe` 起 ⇒ 控制台程序 ⇒ 计划任务**每次触发闪一个黑窗口**;
|
||||
· 直起业务脚本 ⇒ 将来加变量/判活时**没有落点**,只能再开第二条旁路(闪黑窗的真因)。
|
||||
|
||||
## 本启动器做什么
|
||||
|
||||
① 设好编码与上游地址(⛔ 不依赖宿主 env);
|
||||
② 调 `project-board.py --serve 20100`;
|
||||
③ **判活**:端口已在听 ⇒ 直接退出 0(⛔ 不重复起第二实例)。
|
||||
|
||||
⚠️ 必须用 `pythonw.exe` 跑本文件(无控制台 ⇒ 不闪窗)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import runpy
|
||||
|
||||
PORT = int(os.environ.get("DSH_PROJECT_BOARD_PORT") or "20100")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TARGET = os.path.join(HERE, "project-board.py")
|
||||
|
||||
# ── 变量只在这里设(计划任务动作没有 env 字段)──
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
os.environ.setdefault("DSH_PROJECT_BOARD_UPSTREAM", "http://127.0.0.1:20099/board.json")
|
||||
|
||||
|
||||
def port_listening(port: int) -> bool:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(0.4)
|
||||
try:
|
||||
return s.connect_ex(("127.0.0.1", port)) == 0
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if port_listening(PORT):
|
||||
return 0 # 已在跑 ⇒ 静默退出(⛔ 不起第二实例)
|
||||
sys.argv = [TARGET, "--serve", str(PORT)]
|
||||
try:
|
||||
runpy.run_path(TARGET, run_name="__main__")
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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 ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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>
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""项目管理界面 —— 照 dsh-task-board 的「项目 × 卡片」模式做的**只读**看板。
|
||||
|
||||
## 这是什么(用户 2026-10-06 原话)
|
||||
|
||||
> 「不动现有任务会话看板,不是让你整体搬,是照的他的模式做一个 项目管理的界面
|
||||
> 来管理 执行任务,**目标等于项目**」
|
||||
|
||||
⇒ 三条硬约束:
|
||||
① ⛔ **不动**现有看板(`board.py` / `board.html` / 20099 一律零改动);
|
||||
② ⛔ **不整体搬** dsh-task-board(它是 DSH 插件、20k 行 TS,宿主不同,搬不动);
|
||||
③ ✅ **照它的模式**:项目下拉 + 卡片列 + 点卡片看详情。
|
||||
|
||||
## 数据从哪来(🔴 只读,零写)
|
||||
|
||||
· 复用现有看板已经算好的 `GET /board.json`(20099)—— ⛔ **不重算、不直读台账**
|
||||
(重算 = 把 `board.py` 那套逻辑抄第二份 ⇒ 同族 P0「一条规则两份实现」必漂)。
|
||||
· 本项目**自己不再产数据**,只做**渲染**。
|
||||
|
||||
## 映射(目标 → 项目)
|
||||
|
||||
· `goals[]` → **项目**(`goal.short`=项目名,`tab_created`=建立时间)
|
||||
· `labor[]` → 该项目的**工作流/分组**(`line`/`items`)
|
||||
· `tasks{}`(台账) → **卡片**(`state` → 列)
|
||||
· `sessions[]` → 卡片可跳的**会话**(`id8` 是跳转键)
|
||||
|
||||
## 列(⛔ 用机制真实四态,不硬套它那五列)
|
||||
|
||||
dsh-task-board 那套是 待规划/待办/进行中/已完成/已失败;
|
||||
本机制台账只有 **待执行 / 执行中 / 已完成 / 有阻碍** 四态
|
||||
⇒ 硬套五列会凭空多出机制里**不存在**的「待规划」⇒ 看着像有、其实永远空。
|
||||
✅ 用户问「什么工作流状态 干啥用的」⇒ 就是这一列的含义;**按真实态列**。
|
||||
|
||||
## 起法
|
||||
|
||||
python project-board.py --serve 20100 # ⛔ 与 20099 各起各的,互不影响
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
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"
|
||||
|
||||
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # ⛔ 绕过 HTTP_PROXY
|
||||
|
||||
|
||||
def _say(msg: str) -> None:
|
||||
"""🔴🔴 **稳健的输出**(2026-10-06 实测栽过)。
|
||||
|
||||
病根:计划任务用 **`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)
|
||||
if f is None:
|
||||
return
|
||||
try:
|
||||
f.write(msg + "\n")
|
||||
f.flush()
|
||||
except Exception:
|
||||
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", "待执行"),
|
||||
("running", "执行中"),
|
||||
("blocked", "有阻碍"),
|
||||
("done", "已完成"),
|
||||
]
|
||||
|
||||
|
||||
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}:
|
||||
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 "",
|
||||
})
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "ProjectBoard/1.0"
|
||||
|
||||
def _send(self, code: int, body: bytes, ctype: str) -> None:
|
||||
try:
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
path = self.path.split("?")[0]
|
||||
if path in ("/", "/index.html", "/project-board.html"):
|
||||
try:
|
||||
self._send(200, PAGE.read_bytes(), "text/html; charset=utf-8")
|
||||
except Exception as e:
|
||||
self._send(500, ("页面读不到:%s" % e).encode("utf-8"),
|
||||
"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")
|
||||
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")
|
||||
elif path == "/healthz":
|
||||
self._send(200, json.dumps({"ok": True, "upstream": UPSTREAM},
|
||||
ensure_ascii=False).encode("utf-8"),
|
||||
"application/json; charset=utf-8")
|
||||
else:
|
||||
self._send(404, b"not found", "text/plain; charset=utf-8")
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="项目管理界面(只读 · 复用现有看板数据)")
|
||||
ap.add_argument("--serve", type=int, default=0, metavar="PORT",
|
||||
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))
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
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"]))
|
||||
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"],
|
||||
p["queue"]["running"], p["queue"]["blocked"], p["queue"]["done"],
|
||||
("(另有历史 %d 件)" % p["queue"]["legacy"]) if p["queue"]["legacy"] else ""))
|
||||
return 0
|
||||
ap.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user