From 1b68b1ac3ff12a738f976a0199c3103e79a5eb7f Mon Sep 17 00:00:00 2001 From: maogeigei Date: Tue, 6 Oct 2026 08:23:15 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=AE=A1=E7=90=86=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=EF=BC=9A=E7=85=A7=20dsh-task-board=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E6=96=B0=E5=BB=BA=EF=BC=88=E7=9B=AE=E6=A0=87=3D?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=20=C2=B7=20=E5=8F=AA=E8=AF=BB=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E7=8E=B0=E6=9C=89=E7=9C=8B=E6=9D=BF=20=C2=B7=20?= =?UTF-8?q?=E7=AB=AF=E5=8F=A320100=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户逐字:「不动现有任务会话看板 不是让你整体搬是照的他的模式做一个 项目管理的界面 来管理 执行任务,目标等于项目」 - 新增 交付物/项目管理界面/{project-board.py,.html,-launch.py} - 映射:目标→项目、任务类别→工作流、台账条目→卡片、会话→可跳转 - 数据源=现有看板已算好的 /board.json(⛔ 不重算、⛔ 不直读台账) - 列=机制真实四态(待执行/执行中/有阻碍/已完成),⛔ 不硬套它那五列 - 现有看板 20099 / board.py / board.html 零改动 - 坑:pythonw.exe 下 sys.stdout is None ⇒ print/flush 崩(终端好、计划任务死)⇒ 加 _say() 兜 --- .workbuddy/memory/2026-10-06.md | 67 +++++ 交付物/项目管理界面/project-board-launch.py | 58 ++++ 交付物/项目管理界面/project-board.html | 288 ++++++++++++++++++++ 交付物/项目管理界面/project-board.py | 238 ++++++++++++++++ 4 files changed, 651 insertions(+) create mode 100644 交付物/项目管理界面/project-board-launch.py create mode 100644 交付物/项目管理界面/project-board.html create mode 100644 交付物/项目管理界面/project-board.py diff --git a/.workbuddy/memory/2026-10-06.md b/.workbuddy/memory/2026-10-06.md index c25d04c..d6fc2c0 100644 --- a/.workbuddy/memory/2026-10-06.md +++ b/.workbuddy/memory/2026-10-06.md @@ -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/`(对照实验副本)**已删**;计划任务已**重指回正式交付目录**。 + + diff --git a/交付物/项目管理界面/project-board-launch.py b/交付物/项目管理界面/project-board-launch.py new file mode 100644 index 0000000..d149dad --- /dev/null +++ b/交付物/项目管理界面/project-board-launch.py @@ -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()) diff --git a/交付物/项目管理界面/project-board.html b/交付物/项目管理界面/project-board.html new file mode 100644 index 0000000..25283d0 --- /dev/null +++ b/交付物/项目管理界面/project-board.html @@ -0,0 +1,288 @@ + + + + + +项目管理 · 执行任务 + + + +
+
+
项目管理—
+ + +
+ 加载中… + +
+
+
+ +
+
+
+
+ +
+ + + + + diff --git a/交付物/项目管理界面/project-board.py b/交付物/项目管理界面/project-board.py new file mode 100644 index 0000000..20a4b02 --- /dev/null +++ b/交付物/项目管理界面/project-board.py @@ -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())