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

38 KiB
Raw Permalink Blame History

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() 为空。