Files
dsh_ai1net_server/交付物/项目管理界面/project-board.py
T
admin d97fb0fe84 项目管理界面:统一视图 + 提示收进「!」 + 数据时效可见
用户口径(2026-10-06 逐字)
- 「没安装机制的工作区不显示  黄字描述都放到 !号的图标中,不显示在工作台」
- 「这里分类不要按照工作区分了,记住 项目管理是统一管理」

改动
1. 统一视图(默认「全部」)
   - ⛔ 删掉「按工作区分块」,改为**一张四列看板**汇总本机所有目标的卡片
   - 归属改由**卡片上的「目标」标签**体现(.badge.goal),⛔ 不靠分区
   - 选择器默认「全部(N 个目标)」;选单个目标仍可切到单项目视图
   - 顺带删掉随分区一起废掉的样式(projgroup/pghead/lane* 等,共 15 行死代码)

2. 提示一律收进右上角「!」图标
   - 工作台上去掉所有黄字横幅(原来是 notice 容器)
   - 「!」无内容时淡显且不可点;有内容时点开弹层、点外面/Esc 关闭
   - 未装机制的工作区**彻底不显示**(含原来那行「扫到但没装机制 N 个目录」)
     ⚠️ 后端仍保留 `skipped` 字段(只给 CLI/取证用),UI 不再展示

3. 数据时效可见 + 自动刷新
   - 服务出快照时带 `epoch`;页面读数「数据 N 秒前」**只用服务的 epoch 算**,
     ⛔ 不用本地 Date.now() 顶上(否则刷新一次就"变新",看不出数据其实没动)
   - 超 15 秒 ⇒ 读数转黄(.is-stale),一眼看出"只是轮询、数据没动"
   - 每秒重画时效读数(⛔ 不重新取数)
   - 轮询自适应:数据有变化 ⇒ 3 秒一探;连续 3 次没变 ⇒ 放到 15 秒;一变立刻回收
   - 「刷新」按钮改手动模式:扫描中/已刷新有即时反馈(toast)

4. 操作反馈改一次性 toast(新建/删除/失败),⛔ 不常驻工作台

实测(浏览器侧,强刷新避开缓存)
- 6 张卡片全部落在一张四列板上,标签正确(本机协作 ×1 / vibe-product ×11)
- 四个列标题只出现一次(不再是每区一套)
- 「!」有 1 条内容、默认收起、点击展开、再点关闭
- 时效读数「数据 2 秒前」正常跳动
2026-10-06 09:17:17 +08:00

556 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""项目管理界面 —— 管**本机所有工作区**的项目(照 dsh-task-board 的「项目 × 卡片」模式)。
## 这是什么(用户原话)
> 「不动现有任务会话看板,不是让你整体搬,是照的他的模式做一个 项目管理的界面
> 来管理 执行任务,**目标等于项目**」
> 「项目管理管的是**本机所有工作区的项目** 不是单个工作区的,
> 可以通过项目管理工具 **创建新目标(新工作区)**,管理目标」
⇒ 硬约束:
① ⛔ **不动**现有看板(`board.py` / `board.html` / 20099 一律零改动);
② ⛔ **不整体搬** dsh-task-board(DSH 插件、20k 行 TS,宿主不同);
③ ✅ 照它的模式:项目列表 + 卡片列 + 点卡片看详情;
④ ✅ 范围 = **本机所有装了机制的工作区**(⛔ 不是单区、⛔ 不是靠同伴清单);
⑤ ✅ 能**建新目标(新工作区)**、能**删**、能**查**;⛔ **不改状态**(状态交给会话机制自己处理)。
## 数据从哪来(🔴 只读 + 一处写)
**读**:直接扫本机工作区根目录下每个区自己的文件 ——
· `<区>/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`;可逆),或只删该区的目标登记(轻量,见下)
## 映射
· 一个**工作区** → 一个**项目**(`goal.json` 的 `short`/`title` 是项目名)
· 该区**台账条目** → **卡片**(`state` → 四列)
· 该区**会话** → 卡片可跳的会话(`id8` 是跳转键)
## 列(⛔ 用机制真实四态,不硬套它那五列)
本机制台账只有 **待执行 / 执行中 / 有阻碍 / 已完成** 四态;
硬套 dsh-task-board 的五列(含「待规划」)会凭空多出机制里**不存在**的一列。
## 起法
python project-board.py --serve 20100 # ⛔ 与 20099 各起各的,互不影响
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
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"
# 🔴 扫哪些根目录找工作区(`;` 分隔;可用环境变量覆盖)
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
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` 只说"失败",看不出为什么)。
✅ 有 stdout 才写;没有就**静默丢掉**(这是常驻进程,⛔ 不该因为没地方打印就崩)。
"""
f = getattr(sys, "stdout", None)
if f is None:
return
try:
f.write(msg + "\n")
f.flush()
except Exception:
pass
# 台账四态 → 列(🔴 与 board.py::_queue 的 `by` 键**逐字一致**)
COLS = [
("pending", "待执行"),
("running", "执行中"),
("blocked", "有阻碍"),
("done", "已完成"),
]
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
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 {
"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"),
# 🔴 `epoch` = 服务出这份快照时的时间戳(秒)。页面拿它算「数据多久没动了」——
# ⛔ 不许页面自己 `Date.now()` 顶数:那样刷新一次就"变新",**只看得出页面在轮询,
# 看不出数据其实没变**。参照现有看板 board.html::paintAge 的口径。
"epoch": time.time(),
"roots": [str(r).replace("\\", "/") for r in WS_ROOTS],
"cols": [{"key": k, "label": lb} for k, lb in COLS],
"projects": projects,
"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/2.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 _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"):
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:
self._json(200, to_project_view())
except Exception as e:
self._json(500, {"ok": False, "err": "扫工作区失败:%r" % (e,)})
elif path == "/healthz":
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")
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, ", ".join(str(r) for r in WS_ROOTS)))
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
return 0
if a.once:
v = to_project_view()
_say("根目录=%s" % ", ".join(v["roots"]))
_say("项目 %d 个:" % len(v["projects"]))
for p in v["projects"]:
_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"],
p["acc"]["pass"], p["acc"]["n"]))
if v["skipped"]:
_say("(扫到但**没装机制**、未列出:%s)" % "、".join(v["skipped"]))
return 0
ap.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())