unis_manager/.workbuddy/memory/2026-09-12.md

196 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 2026-09-12
## 构建「软件开发部关键任务追踪看板」
- 数据源:`/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx`
- `周工作情况总结`:稀疏三层结构,时间(周期) → 工作内容(项目) → 内容说明(子任务) → 完成情况(执行记录);仅填完成情况的行是上一条记录的续行,需合并。
- `项目明细表`:客户项目台账(名称/区域/金额/类别/状态说明),部分行 F/G 列错位,需按类别白名单校验。
- 数据跨度 2024-03 ~ 2025-08,共 49 个周期、84 个项目、428 子任务、482 条执行记录、39 条台账。
- 技术栈:FastAPI + SQLAlchemy 2.0(SQLite,DATABASE_URL 可切 MySQL)+ 零依赖原生前端(无构建)。
- 关键实现:
- `Period.sort_key = year*10000 + month*100 + week`,月视图按 (year, month) 聚合周周期,不单独存月记录。
- 导入时自动归档:最近 10 个周期内无更新且非重点项目 → `archived=True`(归档 77 个,活跃 7 个)。
- 状态判定 `_guess_status`:先负面关键词 → 再用百分比(>=100 才 done)→ 最后才用「完成」类词;进度 <100 时强制把 done 降为 in_progress。
- AI 整理:OpenAI 兼容协议 + 本地规则兜底(无 Key 也可用)。
- 坑:
- SQLAlchemy `Table()` 必须传 `Column` 对象,不能传元组。
- 后台起服务要用 Bash 的 `run_in_background`,用 `(&)` 脱离会被沙箱杀掉。
- 前端 `donutChart` 需要 `{name,color,value}`,而接口 `category_dist` 给的是 `count`,必须映射。
- 启动:`./run.sh`(端口 8770),重启后需重新执行 `scripts/import_excel.py --reset` 才会应用新的解析规则。
## 拆分定制交付项目(第二版需求)
- 用户要求:定制项目关心每个子项目的交付阶段(签单/开发/交付完成/计收)与按进度统计收入,且与常规项目数据表分开。
- 新增独立表:`custom_projects`(区域/客户/合同额/阶段/开发进度/手动确认比例/已计收/签单·交付·计收日期)+ `custom_updates`(周期/阶段/本期新增下单/本期新增计收)。
- 迁移脚本 `scripts/migrate_custom.py`:
- 《项目明细表》39 条台账 + 原「定制开发」92 个子任务 → 合并成 119 个 CustomProject(同名合并,金额互补)。
- 子任务名尾部的「(39,400.00)」正则解析为合同额(`normalize_custom_name`)。
- 常规表里的「定制开发」容器整体删除,常规看板只剩 6 个平台产品类项目。
- 执行前先 `cp data/board.db data/board.db.bak`。
- 收入确认(完工百分比法):商机 0 / 签单 10% / 开发中 10%+40%×进度 / 交付完成 70% / 已验收 90% / 已计收 100% / 暂停 30%;`manual_ratio` 可覆盖。定义在 `app/constants.py`。
- 阶段判定坑:文本同时含「已计收」和「进度 60%」时必须判为 developing,否则会被关键词带偏 → `guess_custom_stage` 采用「负面 → 完成 → 在制 → 商务」顺序,并用 `extract_percent` 辅助。
- 区域:从项目名反推省/直辖市(`infer_region`,REGION_KEYWORDS 表),回填 68 个,剩 18 个未标注。
- 现状:合同总额 395 万,按进度可确认 99.8 万(25.3%),已计收为 0(历史数据无计收金额,需后续维护)。
## 启动脚本修复
- 用户报 `./run.sh` → `zsh: permission denied`;`sudo ./run.sh` → `command not found`。原因是文件权限为 644(无 +x),sudo 不会在当前目录找脚本。**不能用 sudo**(会把 data/board.db 变成 root 属主)。
- `chmod +x run.sh` 解决;同时重写 run.sh 使其健壮:
- 自动挑选带依赖的 Python 解释器(`.venv` → workbuddy venv → python3 → /usr/bin/python3),都没有依赖则 pip install。
- 启动前用 `/health` 探测端口,已在运行则直接打印地址并退出(避免重复起进程报错)。
- 首次运行才走建库 + import_excel + migrate_custom。
- 开关:`PORT=` 换端口、`DEV=1` 开 --reload、`NO_OPEN=1` 不自动开浏览器。
- 默认不以 --reload 启动(日常使用更稳),需要热重载时用 DEV=1。
## 新增周期能力 + 年份整体平移
- 需求:录入新周期数据时能新增周期(如「9月第1周」);把数据里 2024→2025、2025→2026(用户原话「2024年改为20205年」为笔误,按整体 +1 理解)。
- 已执行 `python scripts/shift_year.py`:49 个周期全部 +1 年,2024→2025 / 2025→2026,sort_key 重算且校验无重复;执行前备份 `data/board_before_year_shift.bak`。
- **关键坑**:2024 与 2025 年份同时存在,必须按年份从大到小更新,否则 2024→2025 会撞上尚未更新的 2025。
- 周期标签解析统一到 `app/constants.py::parse_period_label`(正则放宽以支持「2025/08-第1周」这种没有「月」字的写法),`scripts/import_excel.py` 改为复用,去掉了重复实现。
- `POST /api/periods` 年份推断规则:**只比较月份**。month >= 最新周期月份 → 同年;month < 最新周期月份 → 次年。
- **踩过的坑**:最初用 `(month, week) <= (latest.month, latest.week)` 判断,导致「9月第1周」在已有「9月第1周」时被判为跨年,重复创建出 2027 年的周期。改为只比月份后,补录同月更早周次也能正确留在同年。
- `DELETE /api/periods/{id}` 只允许删除无任何记录(通用 + 定制)的空周期;设置页新增「周期管理」表格,列出各周期记录数并提供删除按钮。
- 前端三个新建入口:顶部周期下拉、更新录入页(常规/定制各一个)周期下拉、设置页周期管理,都带「+ 新建周期…」。
- 当前状态:50 个周期(2025 年 30 个 / 2026 年 20 个),最新为 2026年9月第1周。
## 项目合并 + 项目库增删改(2026-09-13 补记)
- 需求:把「智空无人机平台」并入「智飞无人机平台」;项目库提供更名 / 删除 / 归档。
- 新增通用合并脚本 `scripts/merge_project.py --from A --to B`(备份 `data/board_before_merge.bak`)。
- **三个必须记住的坑**:
1. `Project`/`Task` 的 relationship 带级联,直接 `db.delete(task)` 会把它名下 Update 的外键**置空**(Task.updates 没配 cascade → 默认 nullify)。所以执行记录改挂一律用 bulk `db.query(Update).filter(...).update({...}, synchronize_session=False)`,不经过 ORM 关系。
2. session 是 `autoflush=False`,改完不 flush 就查询会读到旧数据(第一版统计出"孤儿记录 8 条",实际是重复遍历同一批对象)。改完必须显式 `db.flush()`。
3. 删源项目前要 `db.expire_all()`,否则 identity map 里的旧关系对象会被级联误删已转挂的数据。
- 结果:归并同名子任务 7 个、迁移独有子任务 32 个,执行记录总数 454 条前后一致(无丢失无重复);子任务 428→329。
- 项目库功能:表格行内加 `更名 / 归档 / 删除`,抽屉里也有同名操作;定制交付的项目明细表与抽屉同步支持。
- 后端 `PATCH /projects/{id}` 增加名称非空 + 重名校验(400 明确提示);`DELETE` 返回 `{name, deleted_tasks, deleted_updates}` 供前端提示。
- 删除是硬删除且级联,已用孤儿检查确认干净(tasks/updates/custom_updates 均无悬空外键)。
- 查孤儿数据时踩到:SQL 的 `LIKE '__%'` 里 `_` 是单字符通配符,会匹配所有记录;要查字面下划线得转义或在 Python 侧过滤。
## 入库时间 / 区域规范 / Excel 导出
- 需求三件套:定制明细加入库时间、区域改规范下拉并支持筛选、提供 Excel 导出。
- **入库时间 `custom_projects.entry_date`**(口径:该项目最早记录所在周期的第一天)
- 回填脚本 `scripts/add_entry_date.py`(备份 `data/board_before_entry_date.bak`),119/119 全覆盖,范围 2025-04-14 ~ 2026-08-17。
- 取值优先级:Excel《项目明细表》台账周(优先,项目正式登记入册的地方)→《周工作情况总结》定制开发块 → custom_updates 最早周期 → period_id → 创建日期。
- **踩坑**:一开始只从 custom_updates/period_id 取,结果大部分落到"创建日期"——因为当初导入台账时周期列(合并单元格的稀疏列)**没做向下继承**,period_id 大多是 NULL。改为回查原始 Excel 才补全。
- 周期→日期:`period_first_day()`,以该月第一个周一为第 1 周,第 N 周顺延 7×(N-1) 天。2026-08-01 是周六 → 8月第1周是 08-03,8月第4周是 08-24。
- **区域规范化**:`REGION_GROUPS` 定义 34 个省级行政区并按大区分组(华北/东北/华东/华中/华南/西南/西北),下拉用 optgroup;筛选下拉只列实际有数据的区域。用简称写法与历史数据保持一致,避免迁移;`normalize_region` 会把「内蒙古自治区」等归一。
- **Excel 导出**:`app/export.py` 通用 `build_xlsx()`(表头样式/冻结首行/自动筛选/列宽自适应/金额千分位),`app/routers/export.py` 提供 4 个端点,前端 `download()` 触发。中文文件名用 RFC 5987 编码。
- **SQLAlchemy 加字段的坑**:`Base.metadata.create_all()` 只建新表,**不会给已有表加列**。必须显式 `inspect(engine).get_columns()` 判断后 `ALTER TABLE ... ADD COLUMN`。
## 热加载 + 项目管理改名/操作列可见 + 抽屉周期切换(2026-09-13)
- **热加载**:`run.sh` 的 DEV 分支加 `--reload-include "*.js" "*.css" "*.html" ".env"`(前端和 .env 改动也重启);`app/config.py` 加 `DEV`,`app/main.py` 生成 `BOOT_ID` 并由 `/health` 返回 `{dev, boot}`;新增 `web/livereload.js` 轮询 `/health`,`boot` 变了就 `location.reload()`。
- **坑 1**:macOS 自带 bash 3.2 下空数组 + `set -u` 会 unbound variable,展开要写 `${RELOAD_ARGS[@]+"${RELOAD_ARGS[@]}"}`。
- **坑 2**:`--reload-include` 是按 glob 匹配的,`data/*.db` 不在列表里,所以写库不会触发重启(否则自己把自己刷死)。livereload 只在 `boot` 变化时刷新,轮询本身不会死循环。
- **项目库 → 项目管理**:导航、卡片标题、导出表名/文件名、README 全部改名(`app/routers/export.py` 的 sheet 名和文件名一起)。
- **「更名/归档/删除 看不到」的原因不是功能缺失**,是表格总宽超出容器后被横向截断,最右的操作列掉到可视区外。修法是两手:① 收紧各列(分类 92 / 负责人 76 / 状态 84 / 进度 56 / 标签 96,名称 min-width 170)+ 操作列按钮 `padding: 0 7px`,1280 宽下 `scrollWidth - clientWidth == 0`;② 操作列直接复用已有的 `col-ops` sticky 类(`position: sticky; right: 0`),窗口窄的时候按钮始终吸在右边。
- **坑 3**:`border-collapse: collapse` 时 `position: sticky` 的 `td` 不生效,必须 `border-collapse: separate; border-spacing: 0`。
- 一开始另写了个 `.col-act` 类和 `.col-ops` 一模一样,属于重复,已合并回 `col-ops`。
- **详情抽屉的周期反馈改成可切周期**:`state.dw = {group, range, back}`,`feedUnits()` 按周/按月归组,`FEED_RANGES` 给两套范围(周:本周/近4/近12/全部;月:本月/近3/近6/全部),`‹ ›` 按 `range` 步进翻页,到边界自动 disabled。锚点取顶栏选中周期所在单元,所以抽屉和看板顶部口径一致。
- `renderFeed()` 自己 `$("#dwFeed")` 取宿主节点,切组/切范围/翻页只重渲染这一块,不重新请求接口。
- **坑 4**:`d.timeline` 后端已按 `sort_key` 升序,归组时直接 `map`/`forEach` 保持顺序即可,不要再 `reverse()`(历史进度走势那一块才需要倒序)。
- 「更早还有 7 月」会被读成「7 月份」,月的量词统一用「个月」。
## 项目管理列表页去掉标签列(2026-09-13 下午)
- 需求「列表页不显示标签字段」按**祈使句**理解 = 列表页不要标签列(不是「标签没显示出来」的 bug 报告)。依据:`tags`、`project_tags` 两张表都是 0 行,全库没有标签数据,那一列在 5 行里全是空输入框 + 被截断的 placeholder,只是白占名称列宽度。
- 改动(`web/app.js`):删行内 `<td>` 的 tags 单元格、删表头 `<th>标签</th>`,并连带删掉因此不可达的 `else if (field === "tags")` 保存分支(自己产生的孤儿代码)。抽屉里的标签编辑 `#dwTags` 和周期看板卡片上的标签胶囊**保留**,用户只说了列表页。
- 回归验证:表头 `[项目名称, 分类, 负责人, 状态, 进度%, 最近更新, 操作]`、`scrollWidth-clientWidth == 0`、操作列仍 sticky;`[data-f]` 只剩 `category_id/owner/status/progress`。
- **坑**:`locator.fill()` 只写 DOM value,**不触发 change 事件**,PATCH 根本没发出去,会误判成「保存功能坏了」。要验证行内保存必须走真实键盘:AX `click` 聚焦 → `typeText` → `pressKey Tab`。测完 toast「已保存」、副本库 owner 更新、真实库 owner 与 tasks/updates 计数(329/454)前后一致。
- 写操作用 `DATABASE_URL=sqlite:////tmp/copy.db`(`config.py` 的 `_load_dotenv` 用 `setdefault`,外部 export 优先)起独立实例,绝不连真实库。
- 另:`run.sh ... | tail -30` 会把 uvicorn 日志憋到进程退出才吐,排障时别接 tail。
- **发现 8770 上的服务已停止**(原 PID 17513 不在、端口无监听,不确定是用户自己停的还是上一轮误杀),已用 `NO_OPEN=1 nohup ./run.sh` 重启,`/health` 返回 `dev:false`(不带 --reload)。
## 「8770 还是老系统」排查(2026-09-13 下午)
- 结论:**不是服务端跑旧代码**。`sha256(web/*)` 与 `curl /static/*` 返回的内容逐个比对全部一致(app.js bd9b07b1…、styles.css 817480ff…、index.html a983b5af…)。前端零构建、`StaticFiles` 每次请求现读磁盘,所以**即使有旧 Python 进程在跑,界面也只由 `web/` 目录决定**——界面旧必然是客户端问题。
- 两个叠加原因:
1. 用户 `./run.sh` 其实没启动成功:8770 被我(Codex)起的实例占着,run.sh 的端口探测命中后打印「端口 8770 上已有服务在运行」就 `exit 0`,看起来像启动成功了。**教训:不要长期占着用户的服务端口**,最后 `launchctl remove com.unis.board` 交还端口,只留一个普通 uvicorn(非 launchd)方便 `lsof -ti:8770 | xargs kill`。
2. 浏览器缓存:`/`、`/static/*` 之前只有 ETag/Last-Modified,**没有 Cache-Control**,Chrome 走启发式新鲜度缓存,可能长时间不回源;SPA 又只在加载时读一次 app.js。
- 修法(`app/main.py`):加 `no_cache_frontend` 中间件,对 `/` 和 `/static/*` 发 `Cache-Control: no-cache`。验证:四个静态资源响应头都带 no-cache,带 `If-None-Match` 复访返回 **304**(本地零开销);`/api/*` 不受影响。
- **坑**:Starlette 1.6.0(fastapi 0.141.1)的 `StaticFiles.__init__` **已经没有 `headers` 参数**了(`(directory, packages, html, check_dir, follow_symlink)`),传 `headers=` 直接 TypeError。要加响应头只能上中间件。
- 已缓存的旧副本仍需用户**硬刷一次**(Cmd+Shift+R)强制回源,之后才自动走 no-cache。
## 列表只留「修改」+ 更名收进详情面板 + 周期反馈倒序(2026-09-13 傍晚)
- 需求里的「修改」按钮**原本不存在**(列表上是 详情/更名/归档/删除 和 改名/归档/删除)。按「每行只留 1 个按钮,标签为『修改』,点了开详情面板」实现;改名/归档/删除两个抽屉里本来就有,功能不减。定制交付的「列表」= 项目明细 tab(阶段看板/收入分析不动)。
- `web/app.js`:
- 两处操作单元格压成一个按钮(`data-open` / `data-copen`,处理器原本就有);删掉随之失效的 6 个行内处理器(`[data-rename] [data-arch] [data-delp] [data-crename] [data-carch] [data-cdelp]`)和只被它们调用的 `findCustomRow()`(孤儿)。`openRenameCustomDialog` → 通用 `openRenameDialog(url, name, onDone)`,两处调用传各自的 PATCH 地址。项目管理抽屉的 `#dwRename` 原来是 `prompt()`,跟全站风格不一致,一并改成走同一个 modal。
- **坑**:参数改名时漏了模板里的 `value="${esc(name)}"`,而浏览器里 `name` 是全局 `window.name`,**不会报 ReferenceError**,只会静默渲染成空输入框。`node --check` 也查不出来。改全局作用域里的变量名要专门 grep 一遍裸 `name`。
- **周期反馈倒序**:只在展示层 `win.slice().reverse()` + 月组内 `u.periods.slice().reverse()`。**不能反转 `feedUnits()` 的返回值**——`anchor/first/last/back/step` 全依赖升序下标,反了以后 ‹ › 翻页和「更早还有 N 周」会全错。实测:近 4 周显示 8月第4周→8月第1周,点 ‹ 变 7月第4周→6月第4周 且 meta 从「更早还有 8 周」变「4 周」,语义不变。
- 验证仍走 `DATABASE_URL=sqlite:////tmp/copy.db` 的副本实例(8801):项目管理/定制明细操作列均只剩 `["修改"]` 且 sticky;两条更名链路(含重名报错「已存在同名项目…请换一个名称」保留在 modal 内、确定按钮重新可用)都通过;真实库未动。
## 列表彻底只读 + 详情/修改双模式(2026-09-13 傍晚第二轮)
- 需求「列表保留 详情/修改/归档、详情面板不改基本信息、基础信息按钮与附表更新信息要区分、删除要逐字输入项目名」。**上一轮刚把操作列压成一个「修改」,这一轮又拆回三个**——需求在反复,别再往一个方向优化,改成 `openDrawer(pid, mode)` / `openCustomDrawer(cid, mode)` 双档后,按钮数量怎么变都只是模板里几行。
- 改动(只在 `web/app.js`、`web/styles.css`,Python 一行没动):
- 两张列表彻底去行内编辑:`renderProjects` 删 `[data-pf]` 单元格与 change 处理器,负责人/状态/进度改 `esc()`/`statusPill()`/`NN%`;`renderCustomList` 删 `[data-cf]` 与 `bindCustomListEdit` 里的 PATCH 块,函数更名 `bindCustomList`(调用点同步),金额列统一 `fen()` 并在表头标 `/万`,`stagePill`/日期纯文本。两处星标改 `class="star readonly"` 去掉 `data-star`(快速切重点没了,抽屉里有按钮)。
- 抽屉双档:`mode` 只影响「基础信息」区块——view 出 `.ro` 只读行 + `.sec-actions`「基础信息 · 操作」(修改/重点/归档/删除),edit 出表单 + `.sec-actions`「基础信息 · 编辑」(只有保存/取消 + `#dwErr`/`#cdErr` 行内报错)。附表(周期反馈、历史走势、周期执行记录、阶段流转、收入确认计算值)一律挂 `<span class="sec-kind ro">…只读</span>`,和基础信息的操作条在视觉上分开。
- 改名从独立弹窗并入「修改」表单首行(`#dwName`/`#cdName`,后端两个 PATCH 本来就收 name 且做非空+重名校验),`openRenameDialog` 变成孤儿已删。保存失败写 `#dwErr` 并解禁按钮,成功后回 view 档并 `renderProjects()`。
- 删除:`confirmDialog({danger, okText:"永久删除", requireText: p.name})`,`#mOk` 初始 disabled,`#mConfirmText` 的 input 事件里 `trim() !== requireText` 就继续禁用。定制与项目管理两处都走这条。
- **两个自己发现的设计破绽(已修)**:
1. 定制抽屉「收入确认」区块在 edit 档仍标着「计算值 · 只读」,却渲染了开票/回款输入框 → 徽标改成随 mode 切换(`开票与回款可编辑`),两个输入框包进 `.sec-actions` 并补一句「随上方「保存」一并提交」(保存按钮在基础信息那条操作条里,不然用户不知道点哪提交)。
2. `<div class="form-row"><label>进度</label><input/> %</div>` 里裸的 `%` 是**第三个 grid item**,`.form-row` 只有两列,`%` 会掉到下一行。包一层 `<div style="display:flex">` 解决(`#dwProgress`、`#cdProgress` 各一处)。
- **严重事故(上一轮):`src.index(marker)` 静默删掉 174 行**。用 `src[a:b]` 做区间替换时锚点 `b` 取的是「下一个 marker 的位置」,而那个 marker 在文件里出现在预期之外的位置,于是中间 174 行被整段吞掉。**`node --check` 依然通过**(因为吞掉的是一整个完整函数),语法检查完全查不出来。
- 教训:非 git 仓库里做区间替换,改完必须**比对函数名清单**(`grep -n "function " old new` 做集合差),不能只看语法;优先用「唯一锚点整串替换 + `assert src.count(old)==1`」,别用 index 区间。
- **并发写入风险(本轮实测)**:`web/app.js` 在被本任务修改期间出现 `14:02 → 14:04 → 14:05 → 14:06` 的整文件重写,说明另一个会话也在改同一个文件(不是 git 仓库,没有回滚手段)。开工前 `stat` 连测 45s 确认 mtime 不动了才动手;接手时务必先 `stat` + `grep` 特征标记(`data-pedit`/`data-cedit` 应存在、`openRenameDialog` 应为 0)。
- 验证(副本实例 8801 + `DATABASE_URL=sqlite:////tmp/copy.db`,真实库未动):两张表操作列集合分别 = `{详情/修改/归档}`、归档行 = `{详情/修改/恢复}`;`tbody` 内 `input,select,textarea` 计数 = 0;详情抽屉可编辑控件 = 0;改名为已存在名称 → `#dwErr`「保存失败:已存在同名项目「AI Agent」,请换一个名称」且按钮解禁;真实改名往返成功(列表与抽屉标题同步);删除弹窗输入前缀「NEX云桌面」按钮仍 disabled、输入全名才解禁;侧栏分类/只看重点/含归档/全局搜索、7 个视图逐个点开均正常,`tab.dev.logs()` 空。
## 列表/详情职责边界 + 附表写入分流(2026-09-13 傍晚第三轮)
- 需求四条:① 列表只留 详情/修改/归档;② 详情≠修改(详情面板不改基本信息,外层也不给直接改);③ 详情面板里基础信息的按钮与附表更新信息要区分清楚;④ 删除必须逐字输入项目名。接手时 ①②④ 已由上一轮做完,本轮补 ③ 和两处「外层还能直接写数据」的漏网。
- **边界判断(都记下来,下次别重新猜)**:
- 「项目」= 项目管理 + 定制交付两张表。**每周重点工作的条目不是项目**,它的删除仍用普通危险确认,不要求逐字输入名称(条目名短且可重建,逐个敲全名太折磨人);用户如果要统一,再改。
- **工作看板右栏的环节日期 / 开票 / 回款行内快填保留**。那是「本周快录」区,不是项目详情面板,砍掉会让每周例行录入多跳三次页。用户的原话是「详情面板不做基本信息的修改」,范围限定在详情面板。
- 附表(周期反馈 / 交付记录)**写入统一走「更新录入」**,不在抽屉里复刻一套录入表单:录入页有「粘贴原文 → AI 整理 → 确认写入」的整链路,抽屉里再造一个简版表单必然出现两条口径不一致的写入路径。
- 「+ 录一笔」落在**顶栏当前选中的那一周**(`state.weekId`),和看板其余部分同一个锚点,避免抽屉里选 A 周、顶栏显示 B 周。
- 本轮改动(`web/app.js` + `web/styles.css` + README,Python 没动):
- **定制交付阶段看板卡片的 ★ 曾直接 `PATCH /custom/projects/{id} {is_key}`**,是「外层不再直接写数据」的漏网。改成 `class="star readonly"` + `title="重点标记在详情面板中维护"`,删掉 `data-star` 处理器和 kcard 点击里的 `if (e.target.dataset.star) return;` 守卫;重点标记只剩抽屉里那一个按钮。
- `.sec-actions` 加两个变体:**`.base`**(蓝实线左边框)= 基础信息,两条 edit 条各加 `.sec-hint` 明确「只改基础信息,不会动下面的周期记录」;**`.upd`**(绿底 + 绿虚线边框)= 附表 · 更新记录,放「+ 录一笔「2026年9月第1周」执行记录 / 交付记录」,handler 里 `state.intakeType` + `state.intakePeriodId` + `intakeProjectId`/`customIntakeId` → `closeDrawer()` → `setView("intake")`。
- 周期反馈的范围切换标签从「查看范围」改成「**查看范围(只读筛选)**」,和动作按钮区分开,避免被当成一种写操作。
- **本轮最大的坑:补丁脚本静默 no-op,还打印成功。** 上一轮的 `patch2.py` 写了 `new = old + '''…样式…'''` 但**漏了 `src = new`**,最后 `f.write(src)` 把原文原样写回,脚本照样 print「styles.css patched ok」。结果 `.sec-actions.base/.upd`、`.sec-hint` 三条规则全都不存在,界面能跑但两类操作条**完全没有颜色区分**(即需求 ③ 事实上没生效),而上一轮的自述里写着「已追加」。
- 发现方式极便宜:`diff /tmp/um_bak2/styles.css web/styles.css` 是空的,而 mtime 却是补丁时间。
- 教训:补丁脚本必须 `assert out != src` + 写完后 **grep 特征串复检**,绝不能信脚本自己 print 的成功;改完先看 diff 再看语法。同类 bug 在 `patch1.py` 里没出现,是因为它用 `rep()` 里 `global src; src = src.replace(...)`,写 src 反而是对的 —— 说明「看脚本最后一行写了什么变量」不够,得看**产物**。
- **CUA 浏览器沙箱的硬限制(本轮全部踩过)**:`tab.playwright.evaluate` 里 **`el.click()` 不是函数、`MouseEvent` 不是构造器**、没有 `fetch`、拿不到页面闭包里的 `state`。点击只能走 `tab.playwright.locator(sel).click()` / `getByRole(...).click()`(注意 `getByRole`/`locator` 挂在 `tab.playwright` 上,`tab` 本身没有)。
- strict mode 会因多匹配直接超时:`getByRole('button',{name:'定制交付'})` 命中导航/侧栏分类/录入页签 3 个。用属性选择器收窄,如 `nav [data-view="custom"]`、`#view-custom [data-ctab="list"]`。`locator(sel, {hasText})` 在这层封装里**不生效**。
- `#view-custom` 有 4 个页签,默认「交付流水线」,`#view-custom tbody tr` 第一个表是**卡点项目表**(操作列只有「处理」);要测项目明细必须先点 `[data-ctab="list"]`,否则会误报「定制列表操作列丢了」。
- `[id^=view-]` 元素的 `offsetParent` **永远非 null**(全部报 VISIBLE),判断当前视图别看这个。看 `select` 的选中项 / `.chip.active` 更可靠。
- `tab.getScreenshot()` 返回 Uint8Array,直接 `nodeRepl.emitImage(...)`;`JSON.stringify` 会喷出数万 token。
- 实测(副本实例 8801 + `/tmp/copy.db`,真实库未动;`tab.dev.logs()` 空):两张列表操作列 = `[详情, 修改, 归档]`、`tbody` 内 input/select/textarea = 0;详情抽屉可编辑控件 = 0,修改档 6 个控件 + hint;两条操作条 computedStyle 分别是 `3px solid rgb(37,99,235)` 与 `3px solid rgb(22,163,74)`(绿虚线底);删除闸门:`""`/`NEX`/`NEX云桌` → disabled,`NEX云桌面` 与首尾带空格的同名 → enabled(`trim()` 后比对);两处「+ 录一笔」都正确落到更新录入并预选项目与周期(常规 → `intakeProject`/`intakePeriod`,定制 → `cInProject`/`cInPeriod` 且页签切到「定制交付项目」)。
## 四模块收尾:台账残留退役 + README 按新结构重写(9/13 下午)
- 上一轮把功能做完了,**README 还停在「平台产品类 / 周看板」的旧结构**,本轮唯一硬缺口就是文档;顺手清掉旧结构残留的「项目台账」。
- **台账退役的判断顺序**:先 `sqlite3 data/board.db "select count(*) from ledger_items"` → **0 行**,确认删界面无数据损失,才动代码。删的只有「暴露出去的那层」:`GET /api/ledger` 函数、`/api/meta` 里的 `ledger_categories`、`index.html` 的 `#view-ledger`、`app.js` 的 `renderLedger`/`state.ledger`/`refresh()` 分支。**保留** `LedgerItem` 模型 + `constants.LEDGER_CATEGORIES` + `scripts/import_excel.py` 的台账解析(导入链路是历史 Excel 的完整还原,删它属于扩大范围;要不要连导入一起退役,留给用户拍)。改完 `/api/ledger` 在 8770/8802 都返回 404。
- **README 重写的取舍**:开篇「两套体系」表改成「四模块 + 三支撑页」表;`## 功能` 拆成 `### 1. 工作看板` / `### 2. 项目管理` / `### 3. 定制交付` / `### 4. 每周重点工作`;原「周看板」小节升格成「工作看板」并补第三组清单的来源;原「执行面板」不再单列模块,写成看板底部「统计概览」折叠区(`<details class="ov">` + `GET /api/stats`)。数据模型加 `WeeklyFocus`,并给 `LedgerItem` 标注「只剩模型与导入解析,界面无入口、接口已下线」;API 速览补 `# 每周重点工作` 段、`/api/export/focus`、`/api/categories`,把 `/api/meta` 的说明从「状态字典 / 分类」改成「状态字典 / 优先级 / AI 配置」(**`/api/meta` 从来不含分类**,侧栏分类来自 `GET /api/categories`;`/api/board` 页面主链路已不调用)。
- **本轮自己的坑:列表切片赋值得到 1804 行。** `L[a:b+1] = board` 里 `board` 是**字符串**,Python 会把切片赋值的右值当可迭代对象**逐字符展开**,README 从 344 行炸到 1804 行且 `assert out != src` 照样通过。改成 `board.split("\n")` 才对。教训:切片赋值右值必须是 list;补丁脚本除了 `assert out != src`,还要 **断言行数量级**(`344 -> 384` 一眼合理),否则 diff 阶段才发现就晚了。
- **验证(副本实例 8802 + `/tmp/audit.db`,真实库 mtime 全程未变)**:`node --check` 通过;`grep -o 'function [A-Za-z0-9_$]*'` 多重集 diff 唯一差异是 `renderLedger`(预期删除),无新增孤儿;`py_compile` 通过;`/api/meta` 键 = `statuses/priorities/ai`(无 `ledger_categories`);8770 与 8802 的 `/static/{app.js,styles.css,index.html,livereload.js}` sha1 与磁盘一致;浏览器七视图(board/projects/custom/focus/staff/intake/settings)全部渲染、`tab.dev.logs()` 为空;看板三组 = `重点项目 3 / 定制项目 6 / 每周重点工作 2(1 条逾期)`,KPI 含「本周重点工作 1 · 逾期 1 · 已完成 1」;项目列表行操作列 = `[详情, 修改, 归档]` 且 `tbody` 内控件数 0;详情抽屉 view 档控件 0、两条操作条 `.sec-actions.base`(基础信息·操作)与 `.sec-actions.upd`(附表·更新记录);删除闸门 `initial/partial → disabled`、逐字全名 → `enabled`,取消后未删。
## 每周重点工作改独立录入面板 + 面板动作条分上下(2026-09-13 傍晚第四轮)
- 需求:① 周重点工作的新增改**单独面板**(列表顶部那张内嵌卡撤掉);② 预计完成时间录成「**1周 / 2周**」这种相对周;③ 提供**删除**;④ **所有基础信息相关的按钮放面板上方,更新相关的按钮放列表附表上方**。
- **周口径必须复用既有函数,别再手写一套**。`app/constants.py:period_first_day(y,m,w)` 定义「该月第一个周一为第 1 周」,「入库时间」已经是这个口径。第一版自己在 `focus.py` 里写了个 `_first_monday` 推导,跨月周次的第 5 周算歪,第二轮删掉它改成 `date.fromisoformat(constants.period_first_day(...))` 推日期再回算目标周的 `week`,前后端才一致(前端 `firstMondayDay(y,m)` 是它的镜像,只用来把「3周」显示成「2026年10月第1周」,落库换算以后端为准)。核对样本:2026-08 首个周一=03 ⇒ 8月第4周=08-24(与 README 一致)、09=07、2027-01=04。
- **相对周换算放后端**(`_resolve_period`):`weeks` + `base_period_id` → 目标周;目标周在 `periods` 里不存在就**自动建周**(label `"{m}月第{w}周"`,`sort_key = y*10000+m*100+w`),否则用户选到跨月的第 5 周会直接 400。`period_id` 从必填改成可空并保留旧用法;`weeks`/`period_id` 都不给 → 400,`weeks` 越界 → 422(`Field(ge=1, le=52)`)。PATCH 时前端只在**下拉改了**才发 `weeks`(编辑档首项 `不改(当前 …)` 的 value 是空串),否则改个错别字会把预计完成周悄悄挪走。
- **动作条上下分层的落地方式**:把原来嵌在「基础信息」section 里的 `sec-actions base` 整块**摘出来**当 `baseBar`,前置到 `#drawerBody` 的第一个子元素,加 `.top` 让它排在 `#drawerBody` 第一个子元素。我先做成了 `position:sticky;top:0` + 通栏负 margin + 阴影(吸顶常驻),**用户看完要求「按钮面板不要悬浮,嵌入到面板中」**,最终形态就是 `.sec-actions.top { margin: 0 0 14px; }` —— 继承 `.sec-actions` 的卡片样式(浅底 + 圆角 + 1px 边框 + 蓝色左边框)当普通块放在面板最上方。别再自作主张加 sticky/负 margin/阴影:面板内的动作条属于内容,不该漂在滚动容器上。`sec-actions.upd` 原样留在附表 section 标题下方,不动。项目抽屉 / 定制抽屉 / 重点工作抽屉三处同一套。
- **删除的确认强度按对象分档**(沿用上一轮定的边界):重点工作条目 = 普通危险确认,**不要求逐字输入**;逐字输入项目全名只用在项目管理与定制交付的项目删除上。列表行与抽屉顶部共用同一个 `deleteFocus()`,删完刷新计数、抽屉开着就一起关掉。
- **补丁脚本又踩两次同一个坑**:
- 剪一段代码的 helper 写 `src[:i] + new + src[j+len(end):]` 把**结束锚点一起吃掉了**(`</div>` 少了半个),得写 `src[:i] + new + src[j:]` 保留锚点。
- 结束锚点 ` const body = shown.length ?` 在文件里**不唯一**,唯一性断言只对起始锚点做就会漏。要么加长锚点,要么对两端都 assert。
- 验证(副本实例 8803 + `/tmp/audit2.db`,**8770 真实库 mtime 全程未动**):`weeks=1/2/3` 分别落到本周边/跨月第5周且自动建周;不给时间 400、`weeks=99` 422、老 `period_id` 仍可用;`node --check` + `py_compile` 通过;函数名多重集 diff = +4(`firstMondayDay`/`weekShiftKey`/`dueWeeksHtml`/`deleteFocus`)−1(`weekSelectHtml` 成孤儿后删除);`sec-actions base top` 全库 6 处;浏览器侧列表内嵌卡已消失、行操作 `[详情,修改,归档,删除]`、新增面板下拉 `1周 · 2026年9月第3周 … 12周`、三个抽屉的首个子元素均为 `sec-actions base top` 且 `position:sticky`、view 档可编辑控件 0、`tab.dev.logs()` 为空。
- **待用户拍**(两轮未答,别自己决定):`scripts/import_excel.py` 里台账 `LedgerItem` 的导入解析要不要一起退役。
## 抽屉 Title Bar 改「标准按钮 Bar」(2026-09-13 傍晚第五轮)
- 需求原话:**「Title Bar 重新设计,不要采用当前的方式。就采用标准的按钮 Bar 模式。」** Default 模式下 `request_user_input` 不可用,按最大可能性解释并**在交付时明确声明了这个假设**:改的是**详情抽屉的标题栏**(`.drawer-head`),页面顶部那条周期栏(周/月、周期下拉、搜索、更新录入)没动,已在回复里说明「如指顶部周期栏可继续」。
- **别再自造「基础信息 · 操作」这种带标签的动作条了**。上一轮把动作条从 sticky 改成嵌在正文最上方,用户还是不满意 —— 他要的是**标准对话框按钮栏**:标题在左、动作按钮成排在右、✕ 最右,主按钮在最右、危险动作在最左。正文里那条自造的动作条整块删掉。用户这几轮的收敛方向是一致的:**去装饰、少自造、往通用 UI 约定靠**(去标签列、按钮别悬浮、按钮栏要标准)。
- **落地方式(三个抽屉同一套)**:`index.html` 抽屉头加 `<div class="btn-bar" id="drawerActions"></div>`(在 ✕ 之前)+ 标题包一层 `.drawer-head-text{flex:1;min-width:0}`;`app.js` 加 `drawerActions(html)` 一个 helper,`openFocusDrawer`/`openCustomDrawer`/`openDrawer` 各自把原来的 `topBar`/`baseBar` 变量改成 `drawerActions(...)` 调用;`closeDrawer()` 与三个抽屉的「加载中」处都清空按钮条(不清会残留上一个抽屉的按钮和它的闭包 pid)。**事件绑定一行都不用改**:`$("#dwEdit")` 之类是全局查表,按钮搬到标题栏照样命中。
- 跟着搬的两处:**报错提示**从动作条里挪到 `#drawerBody` 第一个子元素(`<div class="notice err" id="dwErr|cdErr|fdErr" style="display:none">`,只在 edit/create 档渲染,JS 里 `err.textContent=…/style.display=""` 原样可用);**「只改基础信息,不会动下面的周期记录」这类 hint** 挪进它自己的 `.sec-title` 下面一行。按钮文字去掉对象名后缀(`删除项目/删除条目 → 删除`、`恢复项目/恢复条目 → 恢复`),标题栏已经写了对象名。定制抽屉那条「随上方「保存」一并提交」改成「随**标题栏**「保存」一并提交」—— 按钮搬家,指路的文案必须跟着搬。
- **被本次改动变成孤儿的 CSS 要清**:`.sec-actions.base`、`.sec-actions.top`、`.sec-actions .notice` 三条全删(`.sec-actions` 与 `.sec-actions.upd` 留着,附表的「+ 录一笔」还在用)。`.drawer-head` 改 `align-items:center`,`h3` 与 `.drawer-sub` 都加 `nowrap+overflow:hidden;text-overflow:ellipsis`,标题栏才不会被「★ 青岛中车四方园区治安道路项目」这种长名撑成三行。
- **⚠️ 同一 workspace 有两个会话在写文件**:本轮开工时 `web/app.js` 在 15:45:49 / 15:46:14 / 15:46:51 被**另一个 agent** 连续改写(3050 → 3153 行,锚点行号整段漂移)。教训:**动手前先轮询 `shasum` 到连续 60s 不变**,再 `cp` 一份新备份(`/tmp/um_bak6/`),补丁脚本一律「唯一锚点 + `assert count==1` + `assert out!=src`」,改完 `node --check` + grep 特征串复检。绝不要拿着几分钟前读到的行号去改。
- 验证(**全程只在 8770 只读浏览,点的全是取消/关闭,真实库一条没动**):`weekly_focus=1 / max(periods.id)=50 / projects=82 / custom_projects=119` 与开工前一致,`/health` 200。浏览器实测:项目 view 档标题栏按钮 `[删除,归档,取消重点,修改]` 且正文 0 个可编辑控件、正文仅剩 `.sec-actions.upd`;edit 档 `[取消,保存]`、正文首元素 `notice err (display:none)`、hint 已进 section;定制 view `[删除,归档,标记重点,修改]` / edit `[取消,保存]`;重点工作 create `[取消,添加]`、view `[删除,归档,修改]`;`.drawer-head` `align-items:center`、标题 ellipsis、`drawer-head` 高 73px(标题 1 行 + 副标题 1 行);`tab.dev.logs()` 为空。