跳转至

开发日志归档(v0.10.0 ~ v0.10.84)

归档自 development-log.md (v0.10.104 时拆分)。当前活跃迭代见 开发日志(当前活跃 v0.10.85+)。 版本范围:v0.10.0 (2026-05-25) ~ v0.10.84 (2026-05-28)


2026-05-28 v0.10.85 → v0.10.86 raw inbox 收口 — dynamic-scraper Map 加 onRemoved(ISSUE-0066)

本次改动

src/utils/dynamic-scraper.ts:webRequest 监听 main_frame 写入 tabStatus / tabError Map 但无 tab 关闭清理。加 4 行 browser.tabs.onRemoved.addListener 兜底。

来源:v0.10.44 第三轮独立 agent 发现的"可疑点 S2",归档 raw inbox 14 个 ISSUE 后于本次评估周期收口。

同步动作

raw inbox 4 项全部评估收口(v0.10.86 配套):

# inbox 文件 决策 去向
1 前端 React lifecycle 审查盲区 parked SPEC-005-React前端lifecycle系统性扫描(parked)
2 task delete 级联清理 spec-ed SPEC-006-task删除时商家数据二选一确认(active draft)
3 dynamic-scraper Map 泄漏 本次修 ISSUE-0066 + v0.10.86
4 restoreExtensionTabs 双 reload discarded agent 误报,v0.10.34 双轨保险设计

注意点

  • 4 行代码 + 单文件单 listener,无 audit_grep(code review 兜底)
  • raw inbox 拖了 14 个 ISSUE(v0.10.44 → v0.10.86)才处理 — 元教训:发版前扫 _todo.md,能秒杀的(< 30min)顺手做

2026-05-28 v0.10.83 → v0.10.84 第 14 轮 agent — polling 闪 + pageSize + SW sync error(ISSUE-0064)

Agent 总结

第 14 轮 agent 找 2 🟠 + 1 🟡 + 1 🟢,强烈建议第 14 轮后真停

"本轮 BUG #1 (polling 闪) 就是 v0.10.83 自己引入的新闪烁 — 修一个闪烁制造另一个 闪烁,typical 边际递减信号。第 14 轮以后再审,预期只能挖到'理论 race 但实际看 不到'级别。建议路线:ship → 收 3 天真用户反馈。"

4 修

🟠 #1 polling 闪(v0.10.83 自己引入)— useRequest polling 30s 时 rowsLoading 短暂 true 让空 CTA 闪。修:用 everLoaded state 标记,isInitialLoading = !everLoaded && rowsLoading,CTA / Table loading 都改用 isInitialLoading

🟠 #2 pageSize 切换无 page reset(经典 pagination bug,历史遗留)— page=5 + 切 50 条/页 → 越界空白页。修:onPageChange 内 if (newPageSize !== pageSize) setState({page:1, pageSize: newPageSize})

🟡 #3 merchantStats 时序漏(与 #1 同根)— 修 #1 后间接覆盖。

🟢 #4 SW sync error guard 对称补(v0.10.82 只装 unhandledrejectionerror) — 加 self.addEventListener('error', ...)

14 轮累积

27+ 真 bug + ~10 polish。从 v0.10.73 A2 上线到 v0.10.84 = 12 个版本治理。

建议下一步

真停。收 3 天用户反馈再启动新 issue。Agent 自己说:

"第 14 轮起,agent 审查回报率显著低于'用户实测 1 周'的回报率。"

audit_grep

onPageChange ... setState({page: newPage, pageSize: newPageSize}) 模式 — 必须先检查 newPageSize 变化。

工作流

✅ bump → build → sanity check 0.10.84


2026-05-28 v0.10.82 → v0.10.83 商家列表首次 mount 闪"无数据"(ISSUE-0063)

用户反馈

切到商家列表 → 立即显示"还没有任何数据"空状态 CTA → 过几百 ms 数据才出。 sidebar 显示 12778 条,但右侧空 CTA 错显。

病灶

A2 hybrid client mode 首次 mount:rowsSnapshot=undefined(DataView 异步拉)+ merchantStats.total=0(初始)→ 空 CTA 条件 (mode === 'client' && merchantStats.total === 0) && !dataLoading 命中 → 显示 CTA → ~100ms 后数据到 → 切换 → 视觉闪烁。

第 10 轮 agent 当时标 🟡 边缘 case 没修。v0.10.79 大重构后用户实际遇到。

  1. DataView 暴露 rowsLoading(来自 useRequest.loading)
  2. LocalDataView Props 加 rowsLoading?: boolean
  3. 空状态 CTA 加 !rowsLoading 守卫
  4. LocalTable loading 也带 dataLoading || (mode === 'client' && !!rowsLoading)

元洞察

"agent 标的边缘 case 在大重构后可能变成真问题。修补丁式可以拖延边缘 case, 重构时应该顺手把所有 🟡 都处理掉。"

→ 写进 ISSUE-0063 — 下次重构清单要包含"agent 列过的 🟡 也要处理"。

audit_grep

merchantStats.total === 0 && !dataLoading 没带 !rowsLoading → 必须守 (防 client mode 首次 mount 闪烁)。

工作流

✅ bump → build → sanity check 0.10.83


2026-05-28 v0.10.81 → v0.10.82 background SW unhandledrejection guard(ISSUE-0062)

用户反馈

chrome 扩展错误面板:[object Object] 上下文 background.js:5 (wxt/storage 检查)。

排查

  • manifest 含 "storage" permission ✓(v0.10.26 初版就有)
  • wxt/storage 是 lazy 检查(每次 getItem 调 getStorageArea,不是 import-time)
  • chrome.storage 真 null 理论不可能

→ 错误可能是历史累积(用户没清),但 SW 没 unhandledrejection guard 是真缺陷: 未来 SW 内任何 rejection 都冒到 chrome 错误面板。

src/entrypoints/background/index.ts 头部加 SW guard(同 ext-context-guard 的 main/popup 版本):

if (typeof self !== 'undefined' && typeof self.addEventListener === 'function') {
  self.addEventListener('unhandledrejection', (e: any) => {
    // suppress Failed to fetch / NetworkError / [object Object] 类
  });
}

self 在 SW context 是 ServiceWorkerGlobalScope,支持 unhandledrejection。

为何 ISSUE-0052 漏 SW

v0.10.72 改 ext-context-guard 为 import-time 自启 + 4 entry main.tsx 第一行 import。 SW 没 import ext-context-guard.ts — 内部 typeof window === 'undefined' 守卫只是不装 window listener,但 self listener 也没装。本轮补齐。

audit_grep

self.addEventListener('unhandledrejection' — SW guard 必须在 background entry 头部装。

用户操作

升级 v0.10.82 后到 chrome://extensions/ → 错误面板 → "全部清除" 清历史。 之后不该再有 [object Object] / Failed to fetch 冒出。

工作流

✅ bump → build → sanity check 0.10.82


2026-05-28 v0.10.80 → v0.10.81 第 13 轮 agent — server mode page reset regression(ISSUE-0061)

用户指令

"继续" — 接 v0.10.80 后继续审查。

Agent 真 bug

server mode 下改 keyword/filter/quickFilter 时 page 不 reset 到 1。

链路: - 旧版(v0.10.78 之前):useEffect [filters, keyword, logic, filterTaskId, quickFilter, mode] → getTableData(1) 强制 page=1 - v0.10.79 大重构:getTableData 进 hook,page reset 语义丢失 - v0.10.74 修 #4:只补了 client mode(之前有 if (mode === 'client') 守卫) - v0.10.80 polish:没注意到

用户痛点:server mode page=5 + 改 keyword 命中 30 行 → dataRun(current=5) → server 返 [](skip 80 但只 30 行)→ 表格空白。

// v0.10.81 ISSUE-0061:删 client mode 守卫 + 补 filters/logic
useEffect(() => {
  setState({ page: 1 });
}, [quickFilter, keyword, filterTaskId, filters, logic]);

Agent 元洞察

"v0.10.79 大重构丢失了行为语义。Hook 化时只搬了'读数据'路径,没搬'控制 page' 的次生路径。重构清单应该不仅列 useEffect,还列每个 useEffect 的副作用语义 ('reset page=1'),逐项确认目标位置承接。"

"这种 hook 化重构后的语义流失类 bug,agent 难一眼看出,因为 grep 不到对应 token —— 只有顺着用户视角'我刚改了 keyword 应该怎样'才能挖出。"

元规则

重构后至少经过 1 个独立 agent 审查 + 用户实测一段时间,否则别声称"收敛"。 v0.10.79 commit message 写"大重构治本"+第 12 轮 agent 只抠出 polish → 我误以为收敛。 第 13 轮 agent 一击命中真 bug

下一步建议:真停下来观察,让用户实际使用兜底,再决定是否继续治理。

audit_grep

if (mode === 'client') ... setState({page: 1}) 模式 — page reset 不应有 mode 守卫。

工作流

✅ bump → build → sanity check 0.10.81


2026-05-28 v0.10.79 → v0.10.80 第 12 轮 agent — viewId 双拉 + state.sort 死代码(ISSUE-0060)

上下文

v0.10.79 大重构 commit 后立即第 12 轮 agent 审查。收敛 ✅,但找出 1 🟡 + 1 🟢。

🟡 viewId 入 hook useEffect dep → 双拉

切视图: 1. setViewId → hook 触发 dataRun(老 filters) 2. configRun → onViewChange → setState filters → 又触发 dataRun(新 filters)

debounce 100ms 在 configRun >100ms 时合不掉。旧版 LocalDataView 两个独立 useEffect 都不含 viewId — apiLocalDataList 不读 viewId(dead param)。

修:删 hook useEffect 的 opts.viewId dep + 注释。

🟢 state.sort 死代码

sort: {} + destructure + setState({sort:newSort}) useEffect 仍存在但 无人读 — saveParams / 各处都用 formatLocalSortParams(sortModel) 即时算。

修:删 sort 字段 + destructure + 同步 useEffect + import formatSortParams。

Agent 元洞察

"真重构 ≠ 真 bug。3-5 补丁后重构的决断对了。"

"17 个 opts 参数偏大,下一步若加 hybrid mode 建议按域分组(filterOpts / pageOpts / selectionOpts)"

留作未来方向,本轮不再深做。

audit_grep

opts.viewId 在 hook useEffect dep — 防回头路。

工作流

✅ bump → build → sanity check 0.10.80


2026-05-28 v0.10.78 → v0.10.79 useDataSource hook 大重构(ISSUE-0059)

用户指令

"useDataSource hook 大重构" — 接受 agent 一直推荐的元洞察治本(之前几轮都走补丁式)。

设计

新 hook src/hooks/use-data-source.ts(262 行): - 输入 17 个 UI state - 输出 8 个统一接口:tableData / total / loading / truncated / scanLimit / allCount / refresh / buildMutateParams - 内部管:client useMemo + server useRequest + mode-aware refresh + buildMutateParams

LocalDataView 改造(1050 → 930 行)

删除: - clientPaged = useMemo(...) 整段 - useRequest(apiLocalDataList) + onSuccess setState 链 - getTableData() + 2 个 server-mode 触发 useEffect - state.tableData / total / truncated / scanLimit / allCount 从 useSetState 移除 - quickFilterToFilters inline 定义 - saveParams 大对象 → buildMutateParams(selectKeys, selectTotal, selectOption)

替换:所有 state.X 引用改为 dataSource.X

收益

维度 补丁式 hook 治本
mode 切换代码量 每条路径 if (mode === 'client') 各一份 hook 内统一
新加 quickFilter 改 4+ 处 仅改 hook 内 quickFilterToFilters
测试粒度 1050 行整体 hook 独立 + 组件独立
未来 mode='hybrid' 改 5+ 处 改 hook switch 一处

行数变化

文件 之前 之后 变化
local-data-view.tsx 1050 930 -120
use-data-source.ts 0 262 +262
+142

虽净加 142,但关注分离:data layer 在 hook(可测可复用),UI 在组件。

audit_grep

useRequest(apiLocalDataList — 防再有人在 LocalDataView 加 dataRun 走回头路。

元洞察

Agent 一直推荐 useDataSource,v0.10.74-78 都走补丁式。补丁式有"渐进可控" 优点,但架构债务每轮 ISSUE 都涨。重构有"短期风险",但长期心智负担降到 1/n。

平衡点:补丁式做到第 3-5 个补丁后,必须停下来重构。本轮是这个 inflection point。

验证

  • ✅ TypeScript compile 0 error
  • ✅ pnpm build 通过
  • ✅ scan:error-handling --diff 0 新增
  • ⚠️ Runtime 需用户实测(client mode 派生 / server mode 拉取 / mode 切换 / delete/export / refresh)

工作流

✅ bump → build → sanity check 0.10.79


2026-05-28 v0.10.77 → v0.10.78 长期改进 3 连击(ISSUE-0058)

用户指令

"把上边的问题都解决" — 解决之前留作"下轮再做"的 3 个低风险长期改进。

3 改进

# 病灶
#1 getListByTaskId 100k 硬编码,task > 10w 漏后段 提常量 TASK_SCAN_LIMIT=200_000 + 返回 truncated 字段
#2 client mode 删后 ≤1s UI 显已删行(rowsSnapshot 旧) optimisticDeletedIds Set + onBefore/onError 维护 + clientPaged useMemo 过滤
#3 client UI keyword (name\|domain\|category).includes (OR) vs server jsstore like 单字段 resolveTargetRows 加 keyword 到 needsClientFilter,走 client 同款 JS filter

未做(v0.10.79+)

#4 useDataSource hook 大重构(agent 元洞察推荐):工程量大、回归风险高, 留独立版本做。当前 3 连击已修齐数据安全和体验问题。

audit_grep

selectByQuery limit: 100000 硬编码 → 必须用 TASK_SCAN_LIMIT 常量。

工作流

✅ bump → build → sanity check 0.10.78


2026-05-28 v0.10.76 → v0.10.77 watchdog 自动恢复触发浏览器"确认关闭"弹窗(ISSUE-0057)

用户反馈

截图:watchdog 自动恢复通知弹出 + 浏览器同时弹"您即将关闭 2 个标签页"。 诉求:仅关 tab 不关 window,避免触发浏览器确认弹窗。

根因

forceCloseSharedWindowbrowser.windows.remove(id),Chrome / Cent Browser 的 confirmBeforeWindowClosewindows.remove 生效 → 多 tab 时每次都弹。

两个 close path 都改用 tabs.remove(tabIds) 列表式: - forceCloseSharedWindow (line 124) — watchdog 自动恢复 - maybeCloseSharedWindow (line 80) — 正常 close

Chrome 设计 confirmBeforeWindowClose 只对 windows.remove 触发。tabs.remove 逐个关 tab,最后一个 tab 关掉时 Chrome 自动关空 window,不弹确认

audit_grep

browser.windows.remove 用法 — 优先用 tabs.remove(tabIds) 列表式。

工作流

✅ bump → build → sanity check 0.10.77


2026-05-28 v0.10.75 → v0.10.76 第 11 轮 agent — 4 个 quickFilter 漏 + 性能/安全(ISSUE-0056)

用户指令

"再次检查" — v0.10.75 上线立即审查,第 11 轮 agent 找出 4 真 bug。

4 真 bug

# 严重 病灶
🔴 #1 数据安全 saveParams.filters 漏 4 个 quickFilter (has-website/high-rating/scraped/pending) → 选全部空 query → 全表删 22w quickFilterToFilters 转译合并到 saveParams.filters
🔴 #B 防线 没兜底机制 assertNonEmptyQuery 防空 query 删全表
🔴 #2 性能 export selectTotal=22w 单次拉 22w 行内存爆 Math.min(selectTotal, HAS_EMAIL_SCAN_LIMIT) cap
🔴 #3 数据正确性 'front' + computed sort (quality 等) → jsstore 静默忽略 sort → 拿 id 默认序前 N hasComputedSort 时走全表 applyClientSort + slice

数据安全级别轨迹

  • v0.10.73:A2 上线,全表删风险存在(saveParams 漏 quickFilter)
  • v0.10.74:短期拦截 client mode 'all'/'front' — 拦截保护
  • v0.10.75:撤拦截,taskId/hasEmailOnly 治本但漏 4 个 quickFilter — 风险又开
  • v0.10.76:5 个 quickFilter 全治本 + assertNonEmptyQuery 兜底 — 收敛

元洞察(agent)

"v0.10.75 假定 taskId/hasEmailOnly 是仅有的 client filter,但 quickFilter 5 选项里其它 4 个 也是 client filter 的不同形态。修补策略仍是逐字段添加。真正治本:saveParams 把整个生效的 展示集筛选统一编码 — 让 resolveTargetRows 只看 filters。"

本轮做了治本(merge)+ 兜底(assert),未来再加 quickFilter 必同时改 quickFilterToFilters。

未做(下轮)

  • getListByTaskId 100k 硬编码 — task > 10w 漏后段
  • client mode delete optimistic update — UX
  • keyword 行为不一致 — 已知遗留

audit_grep

saveParams.filters: filters 裸传 — 必须 merge quickFilterToFilters。

工作流

✅ bump → build → sanity check 0.10.76


2026-05-28 v0.10.74 → v0.10.75 saveParams 治本 + 撤短期拦截(ISSUE-0055)

用户指令

"直接升级" — 接前轮 v0.10.74 留的"长期重构"做完。

病灶(v0.10.74 之前)

saveParams 没传 taskId / hasEmailOnlyapiLocalDataDelete/Export 只看 filters+keyword+logic。 client mode 用 has-email chip + filterTaskId 看 10 行 → 选全部 → server 用空 filter 删全表 22w。

  1. search.ts 抽 resolveTargetRows helper:统一 'current'/'front'/'all' 三种 selectOption 行为
  2. 'current' → id list(最快)
  3. 'front'/'all' 无 client filter → 走原 jsstore where(高效)
  4. 'front'/'all' 有 taskId/hasEmailOnly → 全表 50k + JS filter + slice
  5. apiLocalDataDelete / apiLocalDataExport 都用 resolveTargetRows
  6. saveParams 加 taskId / hasEmailOnly / quickFilter
  7. 撤销 v0.10.74 短期拦截:client mode "选全部" 不再阻止

效果

场景 v0.10.74 v0.10.75
client + has-email + 选全部 10 项 → 删 拦截 删这 10 个
client + filterTaskId='X' + 选全部 → 删 拦截 只删 task X 内
导出场景同 拦截 正确范围

已知遗留(下轮)

  • keyword 行为:client (includes OR) vs server (jsstore like);selectOption='all' 时可能差几行
  • 重大重构 useDataSource hook:agent 推荐方案,工程量大,留 ISSUE-0056+ 再上

元洞察

agent 元洞察推荐 useDataSource hook 更彻底,但本轮先治最关键的数据安全。 短期补丁 + 长期治本分离 — 不强求 agent 建议一次到位。

audit_grep

apiLocalDataDelete 调用必须经 saveParams 走(含 taskId+hasEmailOnly),不能裸传 filters。

工作流

✅ bump → build → sanity check 0.10.75


2026-05-28 v0.10.73 → v0.10.74 第 10 轮 agent — A2 6 处真 bug 修复(ISSUE-0054)

上下文

v0.10.73 A2 commit 后立即第 10 轮 agent 审查 — 找出 6 真 bug + 5 边缘 + 1 元洞察。

6 真 bug 全修

# 严重 病灶 修法
1 🔴 数据安全 client mode "选全部"误删全表 22w(saveParams 不含 quickFilter/taskId) 拦截 + toast 提示先切完整模式
2 🔴 删后不刷 dataRefresh 在 client mode 是 no-op,UI ≤30s 不更新 onRequestRefreshSnapshot prop
3 🔴 空 DB CTA 失效 state.allCount 仅 server 写,client mode 永远 -1 用 merchantStats.total === 0 兜底
4 🔴 page 不重置 filter 变 client mode page 没人 reset 独立 useEffect([mode, quickFilter, keyword, filterTaskId])
5 🔴 mode 切换错位 page/selectKeys 跨 mode 残留 setMode wrapper 同时重置
6 🔴 刷新按钮失效 flushData=dataRefresh 在 client 是 no-op wrap mode-aware

元洞察(agent 直击)

"A2 = 数据派生路径换分支,但 mutation/refresh/CTA/banner 路径全都还指向旧 server-mode 单源。"

三个 tab "架构对齐"只对 read path 成立,write path 依旧是 server-mode 独苗。 真正修法不是补丁式各加 if (mode==='client'),而是把 saveParams/dataRefresh/allCount 这些跨路径状态抽成单一信号源(useDataSource hook)。否则未来还会出 ISSUE-0055/0056(删错、刷不到、CTA 不见)。

本轮策略

补丁式修 6 个真 bug — 保数据安全。长期 useDataSource hook 重构记入下轮规划。

audit_grep

selectOption='all' + 空 filters 全表删模式 — 防再次出现。

工作流

✅ bump → build → sanity check 0.10.74


2026-05-28 v0.10.72 → v0.10.73 A2 商家列表 client/server hybrid(ISSUE-0053)

用户决定

"强行 A2" — 接受工程量+风险,让 merchant tab 与 website/email/phone tab 架构对齐。

核心架构:merchant tab 默认走 client-side(复用 DataView 顶层 rows 50k),server 作 fallback。

实施 6 步: 1. DataView 传 rowsSnapshot={rows} 给 LocalDataView 2. LocalDataView 新增 mode: 'client' | 'server' state(默认 client) 3. clientPaged = useMemo(...) 派生:taskId+quickFilter+keyword+sort+paginate 纯 JS 4. dataRun useEffect 加 if (mode === 'server') 守卫 — client mode 不打 jsstore 5. filters.length > 0 时 useEffect 自动 setMode('server') 6. UI 加 mode banner:client→蓝色"切完整模式"按钮;server→黄色"切回快速模式" 7. 副产品:search.ts 导出 COMPUTED_SORT_FIELDS / computeSortValue / applyClientSort 让 LocalDataView 复用 8. quality config watch:client mode 用 qualityVersion counter 触发 useMemo 重算

性能预期

操作 v0.10.72 v0.10.73 client mode
首次打开 ~200ms ~30-50ms
chip 切换 ~100ms <10ms
sort ~80-150ms <10ms
翻页 ~80ms <5ms

Fallback 设计

  • 50k 老数据:banner 提示 + 切 server 按钮可达

  • 高级 filter:自动切 server + disabled "切回"按钮
  • 导出/删除:走 server API(不受影响)

元洞察

三个 tab 架构对齐 = 用户感知一致 + 维护负担降低。 之前 merchant 用 server-pagination 是历史包袱(早期数据量小时 server 反而快); 数据量长起来后该架构成了瓶颈。A2 不是新功能,是把历史最优路径切回正确路径

工作流

✅ bump → build → sanity check 0.10.73


2026-05-27 v0.10.71 → v0.10.72 ext-context-guard import-time 自启(ISSUE-0052)

用户反馈

chrome://extensions 错误面板出现 TypeError: Failed to fetch @ chunks/ext-context-guard-*.js:48 + [object Object]

病灶

v0.10.52 ISSUE-0034 加了 suppress Failed to fetch 的逻辑,但 listener 装在 installContextGuard() 显式调用(main.tsx line 8)— line 1-3 import React/App 期间的 fetch reject 漏过 → 冒到 chrome 默认 handler → 错误日志。

  1. ext-context-guard.ts 改 IIFE import-time 自启
    function doInstall() { ... }
    doInstall();  // IIFE 立即装
    export function installContextGuard() { doInstall(); }  // 向后兼容
    
  2. 4 个 entry main.tsx 第一行 import
    import '@/utils/ext-context-guard';  // 必须第一行
    import React from 'react';
    ...
    
  3. 顺便处理 [object Object] rejection(非 Error 对象 + 无 stack 启发式归 suppress)

元洞察

listener 装在显式调用 = 装得晚。任何"全局副作用"的模块都该 import-time 自启, 不依赖调用方记得调 init。entry 第一行 import 是更稳的契约。

用户操作

升级 v0.10.72 后到 chrome://extensions/ → 错误面板 → "全部清除" 清历史。 之后不该再有 Failed to fetch 冒出。


2026-05-27 v0.10.70 → v0.10.71 商家列表卡顿 B+C 方案(ISSUE-0051)

用户反馈 + 深度分析

「为什么打开官网列表和手机列表不卡,但是打开商家列表会卡?」

差异:merchant tab server-pagination + 22w sort + 多 polling;其它 tab 共用 rows 50k client-side。

修(B+C)

B:rows polling 5s → 30s + event-driven - data-view.tsx pollingInterval 改 30000 - 监听 'maps-data-updated' runtime message(background insert 后已发)→ refreshRows - 后台 CPU 降 6x

C:默认空 sortModel → jsstore 走 id desc - local-data-view sortModel 默认 [] - api/search.ts apiLocalDataList:dbSort 空时映射 [{by:'id', type:'desc'}] - id 是自增主键,cursor 最快 — 等价于 create_time desc 但快 ~10x

性能预期

操作 提升
merchant tab 首次打开 1-3s ~200ms 5-10x
1min rows polling 次数 12 2 + event 6x
dataRun first page ~500ms ~50ms 10x

注意点

  • DataGrid 默认无箭头(sortModel:[])但行序仍最新在前
  • 用户点表头排序仍走原路径
  • 'maps-data-updated' 已是历史 runtime 协议,无需新协议
  • 工作流:bump → build → sanity check ✅ 0.10.71

不做

  • A 方案:merchant 也走 client-side 50k cap — > 50k 数据丢访问,留下轮
  • D 方案:合并 transactions — jsstore 不一定支持

2026-05-27 v0.10.69 → v0.10.70 商家列表 chip 截断 50k + 加载慢(ISSUE-0050)

用户反馈(22w 数据)

  1. sidebar "商家列表 228430" 但 chip "全部 50.0k" — 数据矛盾
  2. 打开列表非常卡,加载好久

病灶

merchant-stats.ts ROWS_CAP = 50_000:select 50k 行 + JS for loop count。 22w 数据 → 拉 50k 后停 → total = 50_000 cap → 与 sidebar 真实数矛盾。 每 10s 轮询 × 50k select = 持续卡。

  1. 无 taskId 走 jsstore count(毫秒级):total / scraped / pending 三个并发 count
  2. 保留 50k 采样 for withEmail / withWebsite / highRating(jsstore 不能查 array length / 模糊匹配)
  3. 新增 truncated 字段 + UI banner:明示哪三个是采样数
  4. 轮询 10s → 30s:CPU 占用降 3x
  5. 有 taskId 路径保留原全表(v0.10.2 ISSUE-0008 fix 不能动)

性能

total 真实 ❌ cap ✅ jsstore count
scraped/pending 真实 ❌ 采样 ✅ jsstore count
加载耗时(22w,无 taskId) ~100-300ms ~80-200ms
22w CPU 占用 降 3x(轮询频率)

工作流

本次严格 先 bump → 后 build → sanity check:✅ build sync 0.10.70


2026-05-27 v0.10.68 → v0.10.69 数据空状态「去创建任务」直接弹 Dialog(ISSUE-0049)

用户反馈

商家列表空状态的"去创建任务"按钮当前是跳到任务页面,希望和左下角"创建任务" 一样直接打开弹窗 — 少一次跳转。

prop 链直接传 callback(同 component tree,比 storage 信号干净): - local-data-view.tsx: Props 加 onCreateTask?: () => void,按钮 onClick 改调 onCreateTask?.() - data-view.tsx: Props 加同名 + 透传给 LocalDataView - main-layout.tsx: <DataView onCreateTask={() => setCreateOpen(true)}>

顺便修工作流

发现"先 build 后 bump version"导致 manifest 落后一版 — 这次严格按 先 bump → 后 build → sanity check 顺序,并把检查命令改为解析 JSON 比较 value 而非 raw 字符串(package.json 冒号后有空格,manifest 无空格, diff 必然不空但 value 可能已同步)。

注意点

  • 撤回 storage 信号路径(旧版 type:'go-page' 现已 unused,但 main-layout handler 仍支持 'go-page'/'open-create' 两种,保留兼容 popup 跨窗机制)
  • 同 component tree 内尽量 prop 链,不绕 storage(除非真跨 window)

2026-05-27 v0.10.67 → v0.10.68 去顶部重复 CTA + 任务全部暂停/继续(ISSUE-0048)

用户反馈(截图)

  1. "创建任务有 3 个?建议去掉右上角的"(v0.10.64 加的顶部 CTA 跟 task toolbar 重复)
  2. "任务建议加上全部暂停"

  1. 撤回 v0.10.64 顶部 CTA:main-layout DataBar 行恢复独占 — 只保留 sidebar 底部 + task-view toolbar 两处。sidebar 那个跨 page 切换可见,task toolbar 在 task 页同视野,不重复。
  2. task-view toolbar 加「全部暂停 / 全部继续」
  3. 仅在 runningTasks > 0 / pausedTasks > 0 时显示
  4. 文案内带 counts:"全部暂停(N)"
  5. 实现走 batchDelete 同款模板(Promise.all + results 收集 + 三态 toast)

反思 — UX 单点反馈不够

v0.10.64 加顶部 CTA 时只考虑"切到 settings 看不到创建按钮"的痛点,没意识到 task 页本来就有 toolbar 同款按钮,加了反而显得重复。这种 UX 决策需要多视野观察 (每个 page 自检"我新加的元素会不会跟既有的重复")。

下次 UX 改动前 grep 一下相同文本的出现位置 — 简单的全字典扫。

注意点

  • 全部暂停/继续不加确认 Dialog(可逆操作 + 跟单个按钮一致)
  • 走 batchDelete 同款 3 态 toast(全成功 / 全失败 / 部分)
  • scan:error-handling --diff 0 新增(新代码用 try/await + 不用 .finally(resolve))

2026-05-27 v0.10.66 → v0.10.67 第 9 轮 agent 审查 v0.10.66 — 补 3 处漏(ISSUE-0047)

上下文

v0.10.66 提交后第 9 轮独立 agent 审查质量分可配置功能。Agent 找 1 误判 + 3 真漏。

3 真漏修

  1. 🟡 init race(防 first dataRun 用旧默认值)
  2. merchant-quality.ts:init 改幂等 promise + ensureQualityConfigReady
  3. api/search.ts:needsClientSort 前 await ensureQualityConfigReady()
  4. 🔴 改完不刷新(用户改 Settings 后 DataView 不重 fetch)
  5. local-data-view.tsx:订阅 qualityConfigItem.watch → 立即 getTableData()
  6. 🟡 setValue 漏 catch(保存失败静默)
  7. settings-view.tsx:updateQualityField + applyQualityPreset 都加 .catch(toast)

Agent 误判 1(已 verify)

"search.ts 在 background context,main-layout init 不影响它"

误判 — apiLocalDataList 通过 useRequest 在 main page React 端调用(同 bundle, 共享 module cache)。但 agent 提的通用元洞察依然有用:sync cache 多 context 时要双初始化(未来 SW 直接调 computeQualityScore 就要)。

元洞察 — Sync Cache 模式 3 条规则

  1. cache 初始化幂等 + lazy + 返回 promise(任何 caller 可 await)
  2. storage.watch 不只更新 cache 还要触发 UI 重渲(订阅方在 React 组件用 useEffect)
  3. storage.setValue 永远 catch(防 quota / 隐私模式静默吞错)

audit_grep

initQualityConfigCache() 调用应是 await 或显式忽略(fire-and-forget 仅 main-layout useEffect 模式允许)。


2026-05-27 v0.10.65 → v0.10.66 质量分可配置(3 预设 + 自定义)(ISSUE-0046)

用户问题

"质量分如何计算的?可以在设置中设置?" — 选定方案 B(3 预设 + 自定义)。

  1. merchant-quality.ts 重构
  2. QualityConfig interface(5 权重 + 2 阈值 + 3 等级门槛)
  3. DEFAULT_QUALITY_CONFIG 保留 v0.10.0 数值 — 老用户升级无变化
  4. QUALITY_PRESETS:默认通用 / 邮件营销侧重 / 电销侧重 / 高端市场
  5. sync 缓存机制:initQualityConfigCache() + storage.watch 监听变更
  6. main-layout mount 时 initQualityConfigCache() — 后续 sort/render 同步可读
  7. Settings 新 SectionCard "质量分(找高价值客户)"(本地设置顶部,显示比例下):
  8. 4 个预设按钮(命中自动高亮)+ 恢复默认
  9. 5 个维度权重 TextField + helperText
  10. 2 个阈值(评分/评论数门槛)+ 满分 chip(≠100 时 warning)
  11. 3 个等级门槛
  12. 即时生效(同 zoom 模式)

4 个预设对照

预设 邮箱 电话 高评分 评论多 完善
默认 40 20 20 10 10
邮件营销 60 10 15 10 5
电销 20 50 15 10 5
高端 25 15 35 20 5

注意点

  • 不强制总和 100(用户可设邮箱=80),但 chip 提示 ≠ 100 → warning 黄
  • 默认值 = 旧硬编码 → 老用户升级看到的分数不变
  • merchant-quality.ts 两处用:cell render(React)+ search.ts client-sort 共用 sync cachedConfig
  • watch 跨 tab 同步:Settings 改完即时影响列表

元洞察

算法暴露给用户配置 = 把"业务判断"还给业务方。硬编码假设"邮箱最重要"不 对所有营销场景成立。预设 + 自定义:90% 选预设极低成本,10% 进阶可下钻。


2026-05-27 v0.10.64 → v0.10.65 质量分真排序(撤回阉割,改 client-sort)(ISSUE-0045)

上下文

ISSUE-0044 Bug 5(质量分排序失效)我用了"标 sortable:false"的阉割做法 — 表头不可点,连箭头都没。但用户的核心诉求是按高质量找客户,阉割违背诉求。

改方案

撤回 unsortable 添加 'quality'。改用 hasEmailOnly 同款"全表拉 + JS sort + slice" 路径,让 quality / email / phone / social 这些 computed 字段都能真排序。

  1. src/sections/page/local-table.tsx 撤回 quality / social 从 unsortable
  2. src/api/search.ts 加:
  3. COMPUTED_SORT_FIELDS set
  4. computeSortValue(row, field) — 跟 valueGetterFor 一致
  5. applyClientSort(list, sort) — 多列 sort 支持
  6. hasComputedSort(sort) 检测
  7. 在 hasEmailOnly 同一路径里加 needsClientSort 分支(taskId 和非 taskId 两条都加)
  8. sort 拆分:dbSort(仅真实列)传给 jsstore;完整 sort 用于 JS post-sort
  9. truncated banner 复用 hasEmailOnly 同款(>50k 提示)

性能

  • 50k 行 quality sort ≈ 50-100ms(同 hasEmailOnly path)
  • 50k 触发 truncated UI

元洞察

"做不到"和"做得到但需要客户端"是不同等级的方案。正确方案永远是真实现, 不是删功能

用户给的 v0.10.52 截图本身就在抗议这个 — 当年代码"假装支持"(点了无效)比 上一轮"明确不支持"(连箭头都没)还强。

audit_grep 闸

unsortable 不再含 quality(防有人再去阉割)。


2026-05-27 v0.10.63 → v0.10.64 用户截图集中反馈 6 项 UX(ISSUE-0044)

用户反馈(5 张截图 / 4 页面 + popup)

# 截图位置 病灶
1 顶栏 字小内容多不好找 → 需缩放设置
2 全局 CTA 黑色按钮不显眼 → 改蓝(实际用 primary 绿主题色)
3 popup 链接 "来发信网站" → 应改"来发信云端",URL 直达云端管理
4 商家列表分页 10/20/50/100 dropdown 选了不生效
5 商家列表 质量分点击无排序动作
6 全局 创建任务按钮只在 sidebar 底部,新用户找不到

  1. 新建 src/hooks/use-display-zoom.ts — 用 body.style.zoom 持久化到 localStorage
  2. 范围 80%~150%,step 10%
  3. useApplyDisplayZoom() 在 main-layout mount 应用
  4. useDisplayZoom() 给 settings 滑条用
  5. 跨 tab storage event 同步
  6. Settings 顶部加「显示比例」SectionCard — Slider + marks + chip + 恢复按钮
  7. 5 处「创建任务」按钮加 color="primary" — 走主题色不再黑
  8. popup 文案 + URL 改为云端
    '来发信网站' → '来发信云端'
    'https://web.laifaxin.com' → 'https://web.laifaxin.com/clouds/google-map'
    
  9. DataGrid 加 paginationModel — 控件化让 internal pageSize 与外层 state 同步
  10. local-table.tsx 质量分加进 unsortable + description tooltip 提示用快速筛选
  11. main-layout DataBar 行末尾加常显「创建任务」按钮 — 所有 page 都能一键创建

audit_grep 闸

  • unsortable 数组必须含 'quality'(防有人手贱去掉)

注意点

  • CSS zoom 而不是 transform: scale — scale 让 DataGrid 滚动/坐标算错
  • Firefox 不支持 zoom 但本扩展 chrome-only,OK
  • 按钮 color="primary" 走主题色(#00A76F 绿),看截图描述"蓝"实际指对比鲜艳 — 如未来要纯蓝可改 palette
  • 旧 sidebar 创建任务按钮保留(双入口冗余)
  • paginationModel 控件化后 onPaginationModelChange 守 if 变化才回调,防 setState 死循环

元洞察

用户提多张截图 = 长期累积痛点的集中爆发。一次拆清单、一轮修完 > 6 轮独立 ISSUE


2026-05-27 v0.10.62 → v0.10.63 用户截图:整国采集报「请选州/城市」(ISSUE-0043)

用户反馈

截图 v0.10.61:创建任务 dialog → 关键词 doctor + 国家 United States(整国 mode='all') + 城市级精度 → 绿色 banner "1 国 · 1 区域 = 1 个任务" → 点创建 → 红色 toast "未能创建任务(请检查是否选了具体州/城市)"

明明就是选了整国,却被告知"请选具体州/城市"。

病灶

task-create-dialog.tsx:116 在 mode='all' 分支:

const items = (Array.isArray(r) ? r : (r?.items || [])) as ...;

只兜 2 种 API shape。若实际返回 { data: [...] }{ data: { items } } 等深包装 → items=[]locationEntries=[] → line 141 "请选具体州/城市" 误导。

这是 ISSUE-0041 Bug 3 同款再扩展

v0.10.60 修了 country-stats / simple-locations-select 的 r?.items 假设 — 但同样的 2-shape 防御被复制到 4 个 caller,遇到新 shape 全部漏。

  1. 抽 normalizeLocations helpersrc/api/others.ts)覆盖 5 种 shape:
  2. [...] / { items } / { data } / { data: { items } } / { list }
  3. 4 个 caller 全切换:task-create-dialog / country-stats / simple-locations-select / location-picker-dialog
  4. task-create-dialog 区分错误消息:mode='all' 展开为空时报"无法解析地区数据,请反馈" 而非误导用户"请选州/城市"

audit_grep 闸

- pattern: "Array\\.isArray\\(r\\)\\s*\\?\\s*r\\s*:\\s*r\\?\\.items"
  description: "旧式两 shape 防御  必须改用 normalizeLocations"

下次有人新增 apiCountryLocations caller 用旧防御 → pre-commit 拦截。

元洞察

防御代码本身就是约定。抽不出 helper 时,每个 caller 各自重复防御 = "补丁不彻底"百分百复现

修法不是"再加一种 shape"而是抽统一函数集中维护。下次 API 变只改 1 处。

注意点

  • 防御代码现在集中在 normalizeLocations,扩 shape 改 1 处
  • 出错日志:console.warn 留 raw response 便于 debug 后续未知 shape
  • 若用户报 "无法解析" 错误 → 看 console 拿到 raw shape → 加到 normalizeLocations

2026-05-27 v0.10.61 → v0.10.62 第 8 轮 agent — scan 工具结构性盲区(ISSUE-0042)

上下文

v0.10.61 修完 ISSUE-0041 Bug 4 后认为 CONVERGED。第 8 轮独立 agent 验证:仍未收敛

第 8 轮 agent 找到 1 🔴 + 3 🟡 + 元洞察

# 严重 文件 病灶
R1 🔴 task-view.tsx delete + batchDelete .finally(()=>resolve()) 强制 resolve → 后端失败仍 toast 成功 + 本地移除
Y1 🟡 batch-controller storePageOneData try {...} catch (e) {} 一页数据丢失 silent
Y2 🟡 popup-data .catch 只关 loading 不带 error → UI 显示真零(ISSUE-0037 变体)
Y3 🟡 task-view copyId writeText 不 await + 立即 notice.success → 假成功

元洞察 — scan 工具结构性盲区

scan:error-handling 只扫 Promise chain 一级形态,但 JS 有 3 种错误处理范式: 1. ✅ Promise chain .then().catch() — 已扫 2. ❌ try/catch — 完全没扫 3. ❌ .finally(resolve) 强制 resolve — 没扫 4. ❌ 裸调 sendMessage 无任何包装 — 没扫

工具的"假设性形态"也是盲区 — 第 8 轮终极洞察。

scan:error-handling 扩 3 类新规则

# 规则 4 - 5 - 6
finally_resolve_swallow   .finally(() => resolve())
try_catch_empty           } catch (e) {}  8 行有 IO 关键词
bare_sendmessage          整行 sendMessage  .then/.catch/.finally +  3 行也不接

完整扫:57 命中(旧 45 + 新 12),基线更新。

Bug 文件 模式
R1 task-view.tsx try/await sendMessage + 查 resp.success(同 control() 函数);批量改 results 收集
Y1 batch-controller.ts try/catch 加 console.error + recordTaskProgress.catch 也加
Y2 popup-data.ts snapshot 加 error?: string 字段,UI 能区分
Y3 task-view.tsx copyId async + await writeText + catch toast.error

audit_grep 三道闸(写进 ISSUE-0042)

  • .finally(()=>resolve()) 模式
  • } catch (e) {} 一级形态
  • writeText 不 await + 立即 success toast

注意点

  • task-view 里其他 control() 调用早已是正确模板(v0.10.57 ISSUE-0039 修)— delete 单独走另一条路径漏网。这是「同模块不同入口」的姊妹漏。
  • popup-data 与 ISSUE-0037 修过的 location-picker-dialog 同精神(区分 idle/loading/error),但用 hook + snapshot 字段而非 statesError state
  • bare_sendmessage 规则比较宽松(auth-provider 等正当 fire-and-forget 也命中),但归入 baseline 即可,新代码避免该模式

2026-05-27 v0.10.60 → v0.10.61 修 ISSUE-0041 Bug 4 — location-picker 生命周期 race

上下文

ISSUE-0041 第 7 轮 agent 报告里 Bug 4 标为"低优先级、延后"。本轮(v0.10.61)补完。

病灶

MUI Dialog 默认 keepMounted=false → 关闭即卸载,触发两类 race:

  1. unmount race:用户点 ↻ 重试国家点位 → 异步还在跑 → 关 dialog → 卸载后的 setState → React 警告
  2. stale-data race(顺手修):国家 A→B 快切 → A 的响应后到 → setStatesData(A) 覆盖 B 视图

src/components/locations-select/location-picker-dialog.tsx

// 1. 组件级 aliveRef
const aliveRef = useRef(true);
useEffect(() => {
  aliveRef.current = true;
  return () => { aliveRef.current = false; };
}, []);

// 2. retryStat 内的 preloadAll 回调 + .catch 都 aliveRef 守卫

// 3. loadStates 加 currentLoadRef + aliveRef 双守卫
const currentLoadRef = useRef<string | null>(null);
.then((r) => {
  if (!aliveRef.current || currentLoadRef.current !== iso2) return;
  ...
})

注意点

  • useState(() => initFn()) 形态本身不会因 unmount 出错;问题在异步 callback 的 setState
  • 已存在的两个 useEffect(line 217 / 232)有 let cancelled = false 模式,没改;它们只在 effect 内有效
  • 对 retryStat 来说 country-switch 不是问题(countryStats 按 iso2 key),仅 unmount 需守
  • 对 loadStates 来说两类 race 都要守

2026-05-27 v0.10.59 → v0.10.60 第 7 轮 agent 验证 — scan 工具盲区 + 4 处漏修(ISSUE-0041)

上下文

v0.10.58 修了 ISSUE-0040 6 个 bug 后,v0.10.59 沉淀了 scan:error-handling.py 工具 + 写 bug-fix-full-dictionary-scan.md rule。本轮(v0.10.60)独立 agent 验证仍找出 4 处漏。

第 7 轮 agent 报告 — 未收敛

agent 直接结论:"还需 1 轮"。命中 4 个,scan 工具全漏:

# Bug 文件 严重 scan 漏的原因
1 content-search「打开搜索」按钮静默失败 — chrome 原生 ↔ webext-bridge content-search/index.tsx:246 scan 不扫 caller ↔ handler 协议匹配
2 writeChain 二级形态:.then(...).catch(()=>{}) page-log.ts + task-progress.ts scan 只匹配单行 .catch(()=>{})
3 apiCountryLocations API 形状假设(r?.items vs 直接 [...] country-stats.ts + simple-locations-select.tsx scan 无此类规则
4 location-picker retryStat React lifecycle race 延后

关键洞察

自动化扫描固化了已知模式,但对同家族的不同形态盲区。

  • ISSUE-0040 A2 是 webext-bridge → 原生(修在 v0.10.58),本轮 Bug 1 是反向:原生 → webext-bridge。两个方向自动化扫描都不可能扫到 — 需要扫协议匹配关系
  • ISSUE-0036/0039 修的是直接 .catch(()=>{})。本轮 Bug 2 是 catch 提到 .then 链尾,scan 视为合理 fire-and-forget 加进基线,从此不再报警。

修复

Bug 文件 模式
1 content-search/index.tsx import { sendMessage } from 'webext-bridge/content-script' + await sendMessage('open-page-main', {}, 'background') + 检查 resp.success
2a page-log.ts const next = writeChain.then(...); writeChain = next.catch(() => {}); return next;
2b task-progress.ts 同上 — recordTaskProgress + clearTaskProgress 都改
3a country-stats.ts Array.isArray(r) ? r : (r?.items \|\| [])
3b simple-locations-select.tsx 同上

audit_grep 三道闸(写进 ISSUE-0041 frontmatter)

  • writeChain 链尾 catch 模式
  • browser.runtime.sendMessage({ type: 'open-page-main' }) 模式
  • apiCountryLocations(...).then(r => r?.items) 模式

后续 pnpm scan:issue-coverage 每次 pre-commit 自动反查。

元规则提案

每隔 N 轮 ISSUE,独立 agent 应专门审查 scan 工具的盲区 — 不是再找 bug,而是问「我们的工具能不能再发现这些 bug?」

工具是凝固的智慧,但凝固也是凝固 — 同家族的不同形态它认不出来。

注意点

  • 现已有 3 个 writeChain 用户:task-store.ts(v0.10.54 改)、page-log.ts(本轮)、task-progress.ts(本轮)— 全部 next+writeChain 分离模板
  • open-page-main 是项目里唯一用 webext-bridge onMessage 注册的 handler(在 background)。所有 caller 必须用 webext-bridge sendMessage(content-script / popup / main → background)
  • apiCountryLocations 4 个 caller 现在全部 Array.isArray 兜底:country-stats / simple-locations-select / task-create-dialog / location-picker-dialog

2026-05-27 v0.10.57 → v0.10.58 第 6 轮 agent + 用户截图 — 6 个「补丁不彻底」漏修(ISSUE-0040)

用户反馈

截图 1:点"我已验证完"报错 [webext-bridge] No handler registered in 'background' 截图 2:验证通过 Google 地图可访问,但扩展仍提示拦截

第 6 轮独立 agent 同时找到 6 处漏修

agent 报告:"修复经常只修触发点,没扫所有同模式"。

# bug 关联原 ISSUE 严重
A1 updateTask/removeTask 仍 .catch(() => {}) ISSUE-0036 只修了 addTask
A2 interception-banner webext-bridge vs background runtime.onMessage ISSUE-0035 同精神 高(用户截图)
A3 task-create-dialog 整国 catch → locations=[] → 误显示"未选地区" ISSUE-0037 同款
A4 task-detail-dialog overflow:'auto' 双轴吃滚轮 ISSUE-0038 同款
A5 popup signalToMain 没 try/catch(storage 失败阻塞 openWindow) 新发现
A6 location-picker retryStat 漏 .catch 新发现
A7 task-create-dialog website 分支 漏检查 resp.success ISSUE-0036 只修 maps

用户截图 = Agent A2 = 同一个 bug

v0.10.37 我加 interception-banner 用 webext-bridge sendMessage
v0.10.38 改成「让 background 复用 verifyTabId」但用的还是 webext-bridge
v0.10.39~57 都没扫到(用户从没遇到拦截 → 没触发)
v0.10.58 用户实际遇到拦截 → 报 No handler error → 暴露

webext-bridge sendMessage 永远到不了 chrome 原生 runtime.onMessage listener。

一次性修 6 个(A2 同时是用户报的)

A1: addTask 模板复制到 update/remove
A2: webext-bridge sendMessage → browser.runtime.sendMessage({ type })
A3: catch 内 notice.error(具体原因) + return
A4: overflow:'auto' → overflowX:'hidden', overflowY:'auto'
A5: try/catch + console.warn + 不 re-throw
A6: .then(...).catch(console.warn)
A7: website 分支抄 maps 分支的 resp 检查
+ background handler 加 try-catch return success/error

元-洞察:「补丁不彻底」家族

5 个原 ISSUE(0034~0038),每个都漏修至少 1 处同款。
第 6 轮 agent 一次扫描验证:「修的人聚焦看到的那一处,忘了横向扫所有同源」
这是 anchor bias 的另一种表现。

修 bug 新准则(写进 dev log)

每次修 ISSUE-XXXX 后必做:
1. grep 同文件 export 的姊妹函数(addX → updateX/removeX)
2. grep 同 message type 的所有 sendMessage caller
3. grep 同 catch 反模式(.catch(() => {}))的全仓使用
4. grep 同 overflow / 布局模式的其他 Dialog 容器

改动文件

文件 改了什么
src/utils/task-store.ts updateTask/removeTask 错误传播
src/sections/layout/interception-banner.tsx webext-bridge → chrome 原生
src/entrypoints/background/index.ts resume-all/open-verify 加 try-catch
src/sections/task/task-create-dialog.tsx 整国 catch 友好 + website 分支检查 resp
src/sections/task/task-detail-dialog.tsx overflow 分轴
src/sections/popup/index.tsx signalToMain try/catch
src/components/locations-select/location-picker-dialog.tsx retryStat 补 catch
docs/issues/0040-round6-agent-6-bugs-incomplete-patch.md 🆕 issue + audit_grep
package.json 0.10.57 → 0.10.58

验证

  • pnpm compile 0 错
  • pnpm build 9.6s
  • 📋 关键实测:触发拦截 → 完成验证 → 点「我已验证完」→ 应正常恢复

错误传播家族累计 = 7 个连击

ISSUE-0034: unhandledrejection 未 preventDefault
ISSUE-0035: storage API 混用
ISSUE-0036: writeChain 吞错 + sendMessage 不查 resp
ISSUE-0037: API catch 退化空
ISSUE-0038: overflow 双轴(布局家族)
ISSUE-0039: task-control 静默吞错
ISSUE-0040: 6 处「补丁不彻底」漏修

6 个 ISSUE 总共修了 14+ 个真 bug 点位。这是系统性反模式 + 横向扫描缺失双重问题。


2026-05-27 v0.10.56 → v0.10.57 自查发现错误传播家族第 5 个(ISSUE-0039)

用户反馈

继续检查

启动第 6 轮独立 agent(聚焦 UI 操作 + 错误传播家族同款),同时自查。

自查发现

grep 同款 .catch(() => {}) 静默吞错,找到 20+ 处。分类:

类型 评估
fire-and-forget 通知(settings-changed / pause-batch 等自动) 🟢 可接受
用户主动操作(按钮 onClick) 🔴 必须有失败反馈

真问题task-view.tsx:control() — 用户点的暂停/继续/停止/删除/重命名都用 fire-and-forget .catch(() => {})

// v0.10.57 之前 ❌
const control = (taskId, action, payload) => {
  browser.runtime.sendMessage({ type: 'task-control', ... }).catch(() => {});
};

如果 background task-control handler throw(batch 内部错 / storage 配额),用户毫不知情 + UI 可能已乐观更新(chip 变"已暂停")→ UI 与后台状态不一致

修复(与 ISSUE-0036 同款套路)

// 1. 前端 control 加 .then/.catch 错误反馈
const control = (taskId, action, payload) => {
  const label = { pause: '暂停', resume: '继续', stop: '停止', ... }[action] || action;
  browser.runtime
    .sendMessage({ type: 'task-control', ... })
    .then((r: any) => {
      if (r?.success === false) notice.error(`${label}失败:${r.message}`);
    })
    .catch((e: any) => notice.error(`${label}失败:${e?.message}`));
};

// 2. background handler try-catch return success/error
if (type === 'task-control') {
  try { await controlTask(...); return { success: true }; }
  catch (e) { return { success: false, message: e?.message }; }
}

改动文件

文件 改了什么
src/sections/task/task-view.tsx control() 加错误反馈
src/entrypoints/background/index.ts task-control handler try-catch
docs/issues/0039-task-control-btn-fail-no-feedback.md 🆕 issue + audit_grep
package.json 0.10.56 → 0.10.57

验证

  • pnpm compile 0 错
  • pnpm build 8.84s

错误传播家族 5 个连击

ISSUE-0034 unhandledrejection 未 preventDefault   ← 用户截图
ISSUE-0035 storage API 混用 silent              ← 用户截图
ISSUE-0036 writeChain 吞错 (createTask)         ← 用户截图
ISSUE-0037 API catch 吞错 (locations)           ← 用户截图
ISSUE-0039 task-control 吞错 (本次)             ← 自查发现 ✅

5 个同源 = 系统性反模式:用户主动操作的 sendMessage 静默吞错。

第 6 轮 agent 仍在后台跑(agentId a8b52b04a15669d44)

待回来再看是否还有新发现。如果同款全清,加 rule + 工具化扫描。

剩余 todo(自查累积)

~15 处其他 sendMessage(...).catch(() => {}): - fire-and-forget 通知(settings-changed / pause-batch 等)→ 🟢 可接受 - 仍可能含用户主动操作的 → 逐个 review

下次有空:写 scripts/scan-error-propagation.py 扫静默 catch 自动分类。


2026-05-27 v0.10.55 → v0.10.56 修地区选择城市列表右截+滚轮无效(ISSUE-0038)

用户反馈(2 张截图)

  • 截图 1: Germany Hamburg 105 城市,底部一行被裁切
  • 截图 2: Canada ON 351 城市,右侧第 4 列文字被截 + 鼠标滚轮无法往下翻

根因

location-picker-dialog.tsx:1000 city grid 用 md: '1fr 1fr 1fr 1fr' 4 列。 Dialog maxWidth="md" ≈ 900px → 每列 ~215px → 文字可用 ~170px → 装不下 "Cambridge Northwest" 等长名。

链条: 1. 横向溢出 2. 容器 overflow: 'auto' 启用横向滚动条 3. 用户鼠标 hover 在被裁切右侧 → wheel 事件被横向滚动逻辑吃掉 4. "滚不动"

修复(3 处同款)

// 1. 容器:分轴控制
overflowX: 'hidden',  // 强制截断横向(不显示水平滚动条)
overflowY: 'auto',    // 只允许纵向滚动
pb: 0.5,              // 底部留空防最后一行贴边

// 2. city grid 4 列 → 3 列
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }

3 列每列 ~285px,文字可用 ~245px 装绝大多数城市名 + ellipsis + title tooltip。

改动文件

文件 改了什么
src/components/locations-select/location-picker-dialog.tsx city grid 4→3 列 + 3 处 overflow:auto → overflowX:hidden+Y:auto+pb
docs/issues/0038-city-list-right-truncate-no-scroll.md 🆕 issue 归档
package.json 0.10.55 → 0.10.56

验证

  • pnpm compile 0 错
  • pnpm build 8.49s

元-观察:第 5 个用户使用反馈

v0.10.52~55 ISSUE-0034~37: 错误传播 / 静默吞错家族
v0.10.56     ISSUE-0038:   布局 / overflow 配置(本次)

不同家族。继续印证:5 轮 agent + 6 工具收敛后,真实用户使用仍能稳定找 bug。UI 操作类(按钮、滚动、显示)audit 工具天然看不到。

后续 todo(已积累的)

  1. 写 rule docs/rules/错误传播规范.md 沉淀 4 条(前 4 issue 模式)
  2. 写 rule docs/rules/Dialog内布局规范.md 沉淀 list overflow / grid 列数规则
  3. 写 scan-error-handling.py 工具扫静默 catch
  4. E2E 测试覆盖关键 UI 路径

2026-05-27 v0.10.54 → v0.10.55 修州/省加载失败被误报"无数据"(ISSUE-0037)

用户反馈

截图:国家列表 Australia 显示「8 州 · 16023 城市」→ 点 > 进详情 → 州列表完全空白,说"该国家无州/省效数据"。

矛盾:缓存说有 8 州,详情却说没有,且没有重试入口

根因

location-picker-dialog.tsx v0.10.55 之前:

.catch(() => setStatesData([]))  // ❌ 把"加载失败"也设成空数组

链条: 1. 国家列表的「8 州」来自 country-stats.ts 早期 preload 缓存(成功) 2. 用户点 >apiCountryLocations 当下拉取 → 本次失败(网络瞬断/VPN) 3. catch 吞错 → statesData = [] 4. UI 判 list.length === 0 → 显示「该国家无州/省数据」 5. 用户看到矛盾且无重试入口

修复(3 态区分)

const [statesError, setStatesError] = useState<string | null>(null);

.then((r) => {
  // 防御:API 可能返 array 或 { items: [...] }
  const items = Array.isArray(r) ? r : (r?.items || []);
  ...
})
.catch((e) => {
  setStatesData([]);
  setStatesError(e?.message || '加载失败');  // 🆕
});

UI 渲染 3 态:

loading → CircularProgress
error   → ⚠️ 加载失败:{msg} + 🔄 重试按钮
list==0 → 「该国家无州/省数据」(仅真空时)
正常    → 显示州列表

audit_grep 防同款

audit_grep:
  - pattern: "\\.catch\\(\\(\\)\\s*=>\\s*set\\w+\\(\\[\\]\\)\\)"
    description: ".catch(() => setData([])) 反模式  加载失败被混为真空"

改动文件

文件 改了什么
src/components/locations-select/location-picker-dialog.tsx + statesError state + loadStates helper + error UI + 重试
docs/issues/0037-state-load-fail-mistaken-as-empty.md 🆕 issue + audit_grep
package.json 0.10.54 → 0.10.55

验证

  • pnpm compile 0 错
  • pnpm build 14.3s

元-观察:第 4 个用户使用反馈连击 — 同源模式

v0.10.52 ISSUE-0034: chrome 错误页 Failed to fetch(unhandledrejection 未 preventDefault)
v0.10.53 ISSUE-0035: 按钮无反应(storage API 混用)
v0.10.54 ISSUE-0036: toast 成功但任务未入列表(writeChain 吞错)
v0.10.55 ISSUE-0037: 加载失败被误报"无数据"(catch 吞错)

4 个 bug 共同模式:业务代码静默吞错。
- 0034: unhandledrejection listener 未 preventDefault
- 0035: storage 写不同 key(监听者收不到)
- 0036: writeChain catch 吞错(caller 拿不到失败信号)
- 0037: API catch 吞错(加载失败误显示为真空)

5 轮 agent + 6 工具收敛后,4 个用户实测连击全是"静默吞错"模式。这是系统性的错误传播设计缺陷

后续 todo

考虑做: 1. 写 rule docs/rules/错误传播规范.md 沉淀这 4 条铁律: - storage write 不静默吞错 - sendMessage 边界检查 response.success - fetch.catch 必须区分 loading/error/empty - unhandledrejection 必须决定 preventDefault 或不动 2. 写 scan-error-handling.py 工具扫静默 catch 模式


2026-05-27 v0.10.53 → v0.10.54 修创建任务 toast 成功但任务未入列表(ISSUE-0036)

用户反馈

商家列表点击左下的创建任务,提示成功了,但是任务列表没增加! 截图:选了 111923 个地区

根因(双重失败)

Bug 1: addTask 静默吞错

// task-store.ts v0.10.54 之前 ❌
writeChain = writeChain
  .then(async () => { await taskListItem.setValue(list); })
  .catch(() => {});  // ← 吞掉错误,caller 拿到 resolved

Bug 2: 单任务无地区数上限

111923 × 80 bytes ≈ 9MB,撑爆 chrome.storage.local 单 item ~5MB 配额。 setValue throw → 被 addTask catch 吞 → background handler 仍返回 success → toast 显示成功。

修复(4 层)

L1:addTask 错误传播

// 关键技巧:分两个 promise
const next = writeChain.then(async () => { ... });
writeChain = next.catch(() => {});  // 后续 chain 用(防 dead)
return next;                         // caller 用(保留 reject)

L2:background handler try-catch + return success/error

try { await createTask(...); return { success: true }; }
catch (e) { return { success: false, message: e.message }; }

L3:createTask 入口守门

const MAX_LOCATIONS_PER_TASK = 50_000;
if (locCount > MAX) throw new Error('单任务地区数超上限...请拆分');

L4:dialog 检查响应

const resp = await browser.runtime.sendMessage({ type: 'create-task', ... });
if (resp?.success === false) {
  notice.error(`创建失败:${resp.message}`);
  return;  // dialog 不关
}
notice.success(...);

修复后流程(111923 地区)

提交 → handler 入口守门 throw → handler return {success: false, message}
     → dialog notice.error + dialog 不关 → 用户改小重试

audit_grep 防同款

audit_grep:
  - pattern: "writeChain\\s*=\\s*writeChain\\s*\\.\\s*then.*\\.\\s*catch\\(\\(\\)\\s*=>\\s*\\{\\}\\)"
    description: "writeChain 静默吞错模式"

改动文件

文件 改了什么
src/utils/task-store.ts addTask 错误传播
src/entrypoints/background/task-manager.ts + MAX_LOCATIONS_PER_TASK 守门
src/entrypoints/background/index.ts create-task handler try-catch
src/sections/task/task-create-dialog.tsx 检查 sendMessage 返回值
docs/issues/0036-create-task-toast-ok-but-not-listed.md 🆕 issue + audit_grep
package.json 0.10.53 → 0.10.54

验证

  • pnpm compile 0 错
  • pnpm build 7.52s

元-观察:连续 3 个用户使用反馈

v0.10.52 ISSUE-0034: Failed to fetch 冒 chrome 错误页(副作用层)
v0.10.53 ISSUE-0035: 按钮无反应(API 一致性)
v0.10.54 ISSUE-0036: toast 成功但失败(错误传播 + 守门)

5 轮 agent 收敛后,3 连击都是"代码看起来对,实际行为不对"。
audit 找不到,必须真实使用。

历史教训沉淀(写进 docs/rules 候选)

4 条铁律: 1. storage write 不要静默吞错 — caller 需要真成功信号 2. sendMessage 边界的操作必须检查 response.success 3. 数据量上限要在入口守门 — 不要让超大 payload 一路传到 storage 才报错 4. toast 之前 await 完整链路结果


2026-05-27 v0.10.52 → v0.10.53 修「去创建任务」按钮无反应 - storage API 混用(ISSUE-0035)

用户反馈

点去创建任务,没反应。请检查!

截图显示主面板空状态,黑色"去创建任务"按钮点击完全无反应。

根因:两套 storage API 混用

local-data-view.tsx:743 按钮 onClick 用原生

browser.storage.local.set({  // ❌
  'local:popup-action': { type: 'go-page', page: 'task', t: Date.now() },
})

main-layout.tsx:194 监听用 wxt/storage 抽象

storage.watch<PopupAction>('local:popup-action', (a) => handle(a));

两套 API 看似都用 'local:popup-action',但实际写入 chrome.storage 的 key 不同: - 原生 API:完整字面量 'local:popup-action' - wxt/storage:去掉 local: 前缀,实际 chrome key 是 'popup-action'(prefix 仅用来选 area)

两个不同 key,watcher 永远收不到 → 按钮点了无反应。

为什么 5 轮 audit + 5 工具都没发现

防御层 没抓到原因
TypeScript 两套 API 都合法
5 个 scan 工具 都是关注其他模式(pump / lifecycle / protocol 等)
5 轮独立 agent 聚焦 background/调度/状态机,没逐个点 UI 验证

第 5 轮 agent 自己说过的"虚化 bug 类别"正是这类 — 代码看起来对,只有真实点击才暴露

修复

// v0.10.53 ✅
import { storage } from 'wxt/storage';

storage.setItem('local:popup-action', { type: 'go-page', page: 'task', t: Date.now() })
  .catch(() => {});

工具化:加 audit_grep 防再犯

ISSUE-0035 frontmatter 加:

audit_grep:
  - pattern: "browser\\.storage\\.local\\.set\\(\\{'local:"
    description: "原生 API  wxt-namespaced key  永远不会被 wxt watch 触发"
  - pattern: "chrome\\.storage\\.local\\.set\\(\\{'local:"

pnpm scan:issue-coverage 现在会全仓扫,未来如果有人再写这种代码会立刻捕获。

改动文件

文件 改了什么
src/sections/page/local-data-view.tsx onClick 改用 wxt/storage 抽象 + import storage
docs/issues/0035-create-task-btn-storage-api-mix.md 🆕 issue + audit_grep
package.json 0.10.52 → 0.10.53

验证

  • pnpm compile 0 错
  • pnpm build 7.88s
  • pnpm scan:issue-coverage 0 命中(同模式已修干净)

元-观察:第 2 个用户使用反馈发现的 bug

v0.10.52 ISSUE-0034: Failed to fetch 冒 chrome 错误页
v0.10.53 ISSUE-0035: 按钮无反应(本 issue)

5 轮 agent + 5 工具收敛 ≠ 真的没问题。
真实用户使用是 audit 不能替代的检验。

后续可做(todo)

  1. 写 rule storage-api 统一规范(强制全仓 wxt/storage 抽象,禁用原生 browser.storage.local)
  2. E2E 测试覆盖关键 UI 按钮路径

2026-05-27 v0.10.51 → v0.10.52 修 Failed to fetch 误显示在 chrome 错误日志(ISSUE-0034)

用户反馈

截图显示 chrome://extensions 错误页:

TypeError: Failed to fetch
上下文 main.html
堆栈追踪 chunks/ext-context-guard-DtaikQ0W.js:48

用户直觉 = 扩展有 bug。实际上业务已 handle(auth-provider onError 等)。

根因

ext-context-guard.ts:107unhandledrejection listener 只 e.reason 不调 preventDefault() → 所有未处理 rejection(含已被业务 catch 的网络错误冒泡)都到 chrome 错误日志。

修复

3 类分流:

1. Extension context invalidated  trigger() + preventDefault
2. 业务可恢复Failed to fetch / NetworkError / AbortError / Load failed
    console.warn + preventDefault不污染 chrome 错误页
3. 其他真 bug  不动 default action Chrome 显示

改动文件

文件 改了什么
src/utils/ext-context-guard.ts unhandledrejection 3 类分流
docs/issues/0034-failed-to-fetch-leak-to-chrome-err-log.md 🆕 issue 归档
package.json 0.10.51 → 0.10.52

验证

  • pnpm compile 0 错
  • pnpm build 8.12s
  • 📋 浏览器实测:触发网络瞬断后 chrome://extensions 错误页不应再刷屏

元-观察

第 5 轮 agent 宣布 convergence 后,用户用真实使用反馈又找到 1 个 bug。 这印证了 agent 自己的话:

剩余的"虚化"bug 类别:大并发隐性 race / Chrome 内部 API 行为 / 用户极端配置 — 都不是 audit 能找到的,需要真实使用反馈或 E2E 测试。

ISSUE-0034 正是这类 — chrome 错误页 UX 问题,5 轮 agent 都没 flag(因为代码层面 listener 没 bug,只是 chrome UX 副作用)。

注意点

  • ⚠️ window.onunhandledrejection listener 永远要决定 preventDefault 或不动 —— 不要"只读"
  • ⚠️ 业务可恢复错误的白名单 —— Failed to fetch / AbortError / NetworkError / Load failed
  • ⚠️ 真 bug 不要 preventDefault —— 让 Chrome 错误日志显示,方便发现

2026-05-26 v0.10.50 → v0.10.51 Tool ③ ISSUE 反查 + 第 5 轮 agent CONVERGENCE 🎉

用户反馈

实施 v0.10.47 agent 提的最后一个 meta-tool(Tool ③)+ 跑第 5 轮独立 agent。

🎉 第 5 轮 agent:CONVERGENCE FINALLY CONFIRMED

轮  真 bug 数
1   3
2   3
3   3
4   3
5   0  ← FINALLY 收敛

第 5 轮 agent 报告:

本轮深度扫描 7 个候选点,全部归类为: - 已被 ISSUE-0027 显式评估过的合理设计 - 死代码 / 未来扩展占位 - 设计上可接受的 UI 闪烁

候选点 R/S/U(次要 setTimeout / 孤儿 alarm / 跨场景计数器残留)均经过 documented review 或副作用为零。

Convergence FINALLY confirmed at round 5。

收敛的根本原因

工具与人协同补完了知识盲区:
  scan:mv3 + scan:react + scan:protocol + scan:pump-coverage + scan:issue-coverage
  把"模式类 bug"全部机器化

helper 抽象让模式失效:
  pumpAllSchedulers + watchdogResumePump 把"漏调 pump 链"
  从 N 个 catch 分支降到 1 个 helper — 缺失变得 trivially visible

Tool ③:scan:issue-coverage

针对 ISSUE-0023 痛点:修了 scraper.ts EMAIL_REGEX 但漏 storage-data 姊妹常量(直到 v0.10.47 才发现)。

机制:每个 ISSUE frontmatter 加 audit_grep 字段:

audit_grep:
  - pattern: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]"
    description: "旧 EMAIL_REGEX 修复后应全仓 0 命中(防姊妹漂移)"

扫描器对每个 pattern 全仓 grep(排除 docs/),命中即可疑漏修。

已给 ISSUE-0023 加 audit_grep 作为示例

未来新加的 ISSUE 也应该加 audit_grep(防同款再被遗忘)。

pre-commit hook 第 1.5 路集成

# 任何 docs/issues/ 或 src/ 改动 → 跑 scan:issue-coverage --strict
# 防 ISSUE 修复被新代码引入旧模式

同时清掉 agent 提到的死代码

engine-manager.ts: - 删 activeTabTasks 模块变量 - 删 MAX_TAB_CONCURRENT 常量 - 删 getActiveTabTasks / updateActiveTabTasks export

全仓 0 调用方,5 轮独立 agent 第 5 轮发现的清洁项。

改动文件

文件 改了什么
scripts/scan-issue-coverage.py 🆕 Tool ③ 实施
package.json + scan:issue-coverage script
scripts/hooks/pre-commit + 第 1.5 路 scan:issue-coverage --strict
docs/issues/0023-mailto-url-encoded-pollutes-email.md + audit_grep 示例
src/utils/engine-manager.ts 删 activeTabTasks/MAX_TAB_CONCURRENT 死代码
package.json 0.10.50 → 0.10.51

验证

  • pnpm compile 0 错
  • pnpm build 7.70s
  • pnpm scan:issue-coverage 0 命中(v0.10.47 已修 ISSUE-0023 姊妹漂移)

完整基础设施全景(本会话累计 21 个版本)

v0.10.25  docs:check + docs:rebuild         文档治理
v0.10.42  scan:mv3                          MV3 持久化陷阱
v0.10.46  scan:react                        React lifecycle 陷阱
v0.10.48  scan:protocol                     消息协议失配
v0.10.50  scan:pump-coverage                调度恢复路径覆盖
v0.10.51  scan:issue-coverage               ISSUE 改动反查 ← 本版本

6 个扫描工具 + 6 轨 pre-commit

5 轮 agent 累计发现

轮 1 (v0.10.44): 3 真 bug — 含 1 P0 (ISSUE-0028)
轮 2 (v0.10.45): 2 真 bug + 1 系统性盲区 (ISSUE-0029)
轮 3 (v0.10.47): 3 真 bug — 含 1 P0 镜像 (ISSUE-0031)
轮 4 (v0.10.49): 3 真 bug — 含 1 P1 第 4 兄弟 (ISSUE-0033)
轮 5 (v0.10.51): 0 真 bug  🎉 CONVERGED

合计 11 个真 bug,4 个 P0/P1 同源(resume 路径漏 pump)

元-观察:独立 agent 边际的临界点

第 5 轮 agent 自己提的观察:

到达"独立 agent 边际为零"的临界点的证据: 1. 工具与人协同补完了知识盲区 2. helper 抽象让模式失效 3. 本轮花费的扫描深度 > 任何单轮独立审查可承担的预算(4 个角度 × 8+ 候选点 × 全链路验证),仍无真 bug

剩余的"虚化"bug 类别(agent 提): - 大并发下隐性 race(需 stress test) - Chrome 内部 API 行为变化 - 用户极端配置组合下的兼容问题

这些都不是 audit 能找到的,需要真实使用反馈或 E2E 测试。

Agent 给的下一步建议(todo)

第 5 轮 agent 建议:

  1. 写 E2E 测试覆盖 4 个 resume 路径(防回归)
  2. settings-view onReset 显式补 broadcast(极小改进)
  3. 删 engine-manager.activeTabTasks 三件套死代码 ← 本版本已做

历史价值:21 个版本 + 5 轮 agent 的复利

单个 bug 修复 (v0.10.33 修 ISSUE-0023) → 1× 价值
+ 抽 helper 防同款 (v0.10.44 抽 pumpAllSchedulers) → 10× 价值
+ 工具自动扫 (v0.10.50 scan:pump-coverage) → 100× 价值
+ pre-commit 拦截新引入 → 1000× 价值
+ rule 文档 + 元教训沉淀 → ∞ 价值(认知传承)

每层抽象都让下次同类 bug 不太可能发生。这就是基础设施投资的复利效应。


2026-05-26 v0.10.49 → v0.10.50 scan:pump-coverage 控制流扫描器

用户反馈

实施 agent 提的 scan-pump-coverage.py — AST-based 控制流分析,自动检测"nuke 后是否经过 pump"

改动

新建 scripts/scan-pump-coverage.py + pnpm scan:pump-coverage

简化决策:用 ±100 行 context window 启发式(不是真 AST 控制流分析)。 TypeScript AST 解析在 Python 复杂,启发式版本能稳定抓 ISSUE-0028/0031/0033 这种"同函数内多分支漏 pump"模式。

算法

NUKE_PATTERNS  = [forceResetLocks, nukeAllSchedulerState, forceCloseSharedWindow]
PUMP_PATTERNS  = [pumpScheduler, pumpPager, pumpAllSchedulers, watchdogResumePump,
                  pumpTasks, manageQueue, restoreBatch, reconcileTasks]

for 每个 nuke 调用位置:
    取前后 100  context
    if 0  pump 关键词  可疑命中

局限: - 不是真控制流分析(同函数 vs 不同函数都算) - ±100 行启发式可能假阴性(pump 在 > 100 行外)/ 假阳性(pump 在 catch 之前不在分支内) - 但 ISSUE-0028/0031/0033 这种"同函数 90 行内的多分支"模式能稳定抓

中间踩坑

expand_globs 实现错了 — 把整个路径当 base 又当 pattern:

# 错 ❌
base_str = g.rstrip('*').rstrip('/')
pattern = g.split('/')[-1]
# → src/entrypoints/background/*.ts 变成 base='src/...background/*.ts' + pattern='*.ts'

修:参考 scan-mv3-pitfalls.py 用 Path.parts 切分 wild_idx,正确拆 base + pattern。

验证

v0.10.49 修后跑:

扫描 7 个文件...
✅ 0 命中 — 所有 nuke 调用都在 pump 视线内

这证明 v0.10.49 ISSUE-0033 修复完整 — 没漏 nuke 后的 pump。 未来任何新加 nuke 调用但没 pump 配套都会被 pre-commit 拦截。

pre-commit hook 现在 5 轨

# 1. docs/ 改动           → docs:check
# 2. background/scrape-*  → scan:mv3 --diff
# 3. src/sections/.../    → scan:react --diff
# 4. onMessage/sendMessage → scan:protocol --diff
# 5. background/watchdog/engine-manager → scan:pump-coverage --diff   ← 本版本

改动文件

文件 改了什么
scripts/scan-pump-coverage.py 🆕 控制流启发式扫描器
package.json + scan:pump-coverage script
scripts/hooks/pre-commit 第 5 路 scan:pump-coverage --diff
.pump-coverage-baseline.json 🆕 基线(0 处 = 当前完整覆盖)
package.json 0.10.49 → 0.10.50

完整基础设施全景(本会话累计)

v0.10.25  docs:check + docs:rebuild         文档治理
v0.10.42  scan:mv3                          MV3 持久化陷阱
v0.10.46  scan:react                        React lifecycle 陷阱
v0.10.48  scan:protocol                     消息协议失配
v0.10.50  scan:pump-coverage                调度恢复路径覆盖 ← 本版本

5 个 npm script + 5 个 pre-commit 校验轨
ISSUE-0028/0031/0033 同源 bug 现在被自动拦截

第五轮 agent 仍在后台跑(agentId afe252b73a885d5cf)

如果还找到新发现 → v0.10.51;如果零发现 → convergence FINALLY confirmed。


2026-05-26 v0.10.48 → v0.10.49 第四轮 agent — ISSUE-0031 第 4 兄弟 + 2 孤儿(ISSUE-0033)

第四轮 agent 报告

convergence 仍失败 — 又找到 1 个 P1 + 2 个孤儿 listener + 1 个工具盲区。

🟠 Bug J (P1):ISSUE-0031 三胞胎的第 4 兄弟

scrape-watchdog.ts:207-211 同一个 90 行函数内有 3 个 resume 分支: - cooldown<=0 ✅ v0.10.47 已修 - alarm-create-fail catchv0.10.47 漏看 - onWatchdogResume ✅ v0.10.47 已修

v0.10.47 我修了 2/3,漏了 catch 兜底。视觉上 if/else + try/catch 嵌套,被 try-catch 切割造成认知盲区。

🟡 Bug K:'clear-search-data' 孤儿 listener

content-search/index.tsx:144-157。唯一发送方在 page-results/index.tsx:105 整段已注释。

scan:protocol v0.10.48 没抓到 — router-dispatch 模式工具故意不扫。

🟡 Bug L:'search-api-response' 孤儿 listener(工具盲区暴露)

websiteMessager.onMessage('search-api-response', ...) 0 sender。

讽刺:这是 webext-bridge onMessage 模式,但工具用 (?:^|[^a-zA-Z_.])onMessage\( 排除 method call —— 漏检自定义 messager。

修复

1. Bug J:抽 helper 防同函数多分支再漏

function watchdogResumePump(): void {
  pumpTasks().catch(() => {});
  pumpAllSchedulers().catch(() => {});
  manageQueue();
}

3 个分支都调一行 watchdogResumePump()。视觉上无法漏看第 N 个分支。

2. Bug K + L:删 2 个孤儿 listener

  • clear-search-data listener(page-results 注释发送)
  • search-api-response listener(无 sender)
  • pushData 死函数 + parseSearchData import 死代码

3. 修工具盲区:scan:protocol 加自定义 messager 扫描

LISTENER_PATTERNS = [
    (r"(?:^|[^a-zA-Z_.])onMessage\(...", "webext-bridge"),
    # v0.10.49 新增
    (r"\w*[Mm]essager\.onMessage\(...", "custom-messager"),
]

元-观察:四轮 P0/P1 共同模式

bug 共同特征
1 拦截恢复漏 pumpScheduler resume 路径漏 pump
3 watchdog onWatchdogResume 漏 pump resume 路径漏 pump
4 watchdog alarm-fail 漏 pump resume 路径漏 pump

100% 同模式:resume-after-failure 路径漏调 pump 链。

深层根因(agent 提出): 1. 修复者只 grep 触发的具体函数,不 grep 同函数内多个分支 2. try-catch 视觉切割导致认知盲区 3. 抽出的 helper 复用到"主路径",catch 兜底分支被遗忘 4. scan 工具偏"数据"而非"控制流"

改动文件

文件 改了什么
src/utils/scrape-watchdog.ts + watchdogResumePump helper,3 分支统一调
src/sections/content-search/index.tsx 删 2 listener + 死 pushData + 死 import
scripts/scan-protocol-orphan.py + 自定义 messager onMessage 扫描
.protocol-orphan-baseline.json 19 → 18(search-api-response 删了)
docs/issues/0033-round4-agent-issue-0031-sibling-orphan-listener.md 🆕 issue
package.json 0.10.48 → 0.10.49

验证

  • pnpm compile 0 错
  • pnpm build 7.32s
  • pnpm scan:mv3 --diff 0 新增
  • pnpm scan:protocol 0 dead + 18 lost

准则升级

未来准则(写进 docs/rules/mv3-persistence-pitfall-checklist.md 候选条目):

修任何"在某分支补缺失调用"的 bug 时,必须看同一函数所有分支(if/else / try/catch / async hooks)。最好直接抽 helper,每个分支只调一行。

if-else / try-catch 嵌套天然制造视觉盲区。

4 轮 agent 累计发现(本会话 v0.10.31 起)

轮 1 (v0.10.44): 3 真 bug — 1 P0(拦截恢复漏 pump 主案)
轮 2 (v0.10.45): 2 真 bug + 1 系统性盲区(前端 lifecycle)
轮 3 (v0.10.47): 3 真 bug — 1 P0(watchdog 镜像)
轮 4 (v0.10.49): 3 真 bug + 1 工具盲区 — 1 P1(同 watchdog 第 4 兄弟)

合计 11 个真发现,4 个 P0/P1,全是 resume-after-failure 同源。

收敛态势

轮  发现 bug 数  P0/P1 数
1   3            1
2   3            0
3   3            1
4   3 + 1 工具盲区  1

每轮固定 3 个真发现,看不出收敛迹象。可能需要: - 实施 agent 提的"控制流 scanner"(scan-pump-coverage.py) - 或者接受"5 轮以上多 agent 仍有边际"作为现实


2026-05-26 v0.10.47 → v0.10.48 scan:protocol 工具 + 修 open-results lost message(ISSUE-0032)

用户反馈

实施 agent 提的 3 个 meta-tool 之一

实施 Tool 2(dead listener / lost message 检测器)。

关键技术决策

1. 只扫 webext-bridge onMessage('name', ...) 模式

最初想扫 background 的 if (type === 'x') router-dispatch 模式 — 实测 22/23 router 命中是 false positive(page-log type 字段、payment status 等数据语义被误捕)。

决策:只扫显式 webext-bridge 的 onMessage扫 router-dispatch。

代价:chrome.runtime 协议的 lost message 当 lost 报(因为对应 listener 用 router-dispatch 我们故意不扫)。 收益:FP 几乎为 0;ISSUE-0031 has-new-data 模式稳定抓。

2. 排除 method call onMessage

websiteMessager.onMessage(...) 等自定义 messager 的 method call,发送方在 injected.js / page context 我们扫不到。用正则前缀 (?:^|[^a-zA-Z_.]) 排除。

首次跑发现真 bug:open-results lost message

🟡 lost message 'open-results'
  📍 src/sections/content-search/index.tsx:98 (chrome-runtime)

验证: - src/utils/messager-extension.ts:5 注册 schema - background 0 个 handler 真处理它 - 全 fire-and-forget 无效 call

连带发现:整个 messager-extension.ts 模块是死代码(另一个 schema setup-popup-toggle 也只有注释掉的发送)。

修复

文件 改了什么
src/utils/messager-extension.ts 🗑️ 删整个文件
src/sections/content-search/index.tsx:98 删 sendMessage('open-results')
src/sections/content-search/index.tsx:253-260 清掉已注释"查看结果"按钮

pre-commit hook 第 4 路集成

# 现在 4 轨:
# 1. docs:check     2. scan:mv3     3. scan:react     4. scan:protocol

触发条件:staged 含 onMessage / sendMessage / messager- 的 .ts(x)

中间踩坑

A. router-dispatch 太宽误捕数据 type 字段

最初 LISTENER_PATTERNS 加了 type === 'name' 想扫 background router。实测 22/23 是 FP。删之,只留 webext-bridge。

B. set / dict 类型不兼容

EXTERNAL_NAMES 写成 {} 字典字面量 → 集合操作 listener_names - EXTERNAL_NAMES 报 TypeError。修:用 set()

C. method call .onMessage 误识为 listener

websiteMessager.onMessage(...) 被识别为孤儿(因为 sender 在 injected.js)。修正则前缀排除 method call。

改动文件

文件 改了什么
scripts/scan-protocol-orphan.py 🆕 新工具(webext-bridge dead/lost 检测)
package.json + scan:protocol script
scripts/hooks/pre-commit 第 4 路 scan:protocol --diff
src/utils/messager-extension.ts 🗑️ 死代码删
src/sections/content-search/index.tsx 清 dead sendMessage
.protocol-orphan-baseline.json 🆕 基线(19 lost = 已知合规 chrome-runtime)
docs/issues/0032-scan-protocol-open-results-lost-message.md 🆕 issue 归档
package.json 0.10.47 → 0.10.48

验证

  • pnpm compile 0 错
  • pnpm build 8.47s
  • pnpm scan:protocol 0 dead + 19 lost(已存基线)
  • ✅ pre-commit 4 轨集成测试

累计基础设施

v0.10.25  docs:check + docs:rebuild
v0.10.42  scan:mv3 + baseline + pre-commit
v0.10.46  scan:react + baseline + pre-commit
v0.10.48  scan:protocol + baseline + pre-commit ← 本版本

后续

第四轮独立 agent 还在后台跑(agentId aadef3705ebc00e3c)。 如果有新发现,做 v0.10.49。


2026-05-26 v0.10.46 → v0.10.47 第三轮 agent 找到 3 真 bug(ISSUE-0031 含 1 P0 镜像)

用户反馈

跑第三轮独立 agent(看是否真的收敛 = 0 新发现 → 否则又有边际) 实施"React lifecycle 扫描"基础设施(类比 scan:mv3)

关键结论:第三轮 agent convergence 失败 — 又找到 3 真 bug

第一轮 agent (v0.10.44): 3 真 bug(含 1 P0)
第二轮 agent (v0.10.45): 2 真 bug + 1 系统性盲区
第三轮 agent (v0.10.47): 3 真 bug(含 1 P0 镜像!)

三轮独立 agent 都有 P0 发现 → 独立 agent **不是一次性消费**,多轮有边际

三个 bug

🔴 Bug I(P0,ISSUE-0028 镜像):watchdog 自动恢复漏调地图调度

scrape-watchdog.ts:onWatchdogResume 只调 pumpTasks + manageQueue: - pumpTasks 只看 status='queued' task → 跳过 watchdog 内存里 status='running' 的地图 batch - manageQueue 只调 engine-manager → 不动 batch-controller - 缺 pumpScheduler / pumpPager → 地图任务永远不再派 tab

这正是 ISSUE-0028 的镜像 —— v0.10.44 修了拦截恢复路径,没扫到 watchdog 自动恢复路径

🟡 Bug F1:has-new-data 是孤儿 listener

local-data-view.tsx 注册 onMessage('has-new-data', ...) 但全仓 0 发送方。 架构在 v0.8.56 改了:content-script 改用 runtime.sendMessage({type:'store-page-data'}) 走 background。

v0.10.45 ISSUE-0029 的 cleanup 修复是给死代码加 cleanup — 不解决问题。 本次直接删整段 + 4 个仅此用的 import。

🟡 Bug H:DEFAULT_EMAIL_REGEX 残留 ISSUE-0023 坏正则

storage-data.ts:145 还是含 % + 无 {1,64} 的旧模式:

'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}'  // ❌ ISSUE-0023 修过的坏模式

settings-view 把它当 placeholder + RegexTester 跑此演示 → 用户复制就把坏正则塞回。

ISSUE-0023 改了 scraper.ts 但漏了 storage-data 姊妹常量

修复方案

抽 pumpAllSchedulers helper(防止下次再漏)

// batch-controller.ts 新增 export
export async function pumpAllSchedulers(): Promise<void> {
  await pumpScheduler();
  await pumpPager();
}

// doResumeFromInterception 改用它(一致性)
// scrape-watchdog.ts onWatchdogResume + cooldown=0 path 都调

删孤儿 listener + 4 import

// 删 lisenter 函数 + useEffect cleanup 改为只调 countRun + onViewIdChange
// 删 import { onMessage } from 'webext-bridge/popup'
// 删 import { settingParamsStorageItem } / parseSearchData / addSearchData

storage-data DEFAULT_EMAIL_REGEX 复用 ISSUE-0023 新模式

export const DEFAULT_EMAIL_REGEX =
  '[a-zA-Z0-9._+\\-]{1,64}@[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?){1,5}';

改动文件

文件 改了什么
src/entrypoints/background/batch-controller.ts + pumpAllSchedulers helper + doResumeFromInterception 改用
src/utils/scrape-watchdog.ts onWatchdogResume + cooldown=0 path 补 pumpAllSchedulers
src/sections/page/local-data-view.tsx 删 has-new-data 死 listener + 4 import
src/utils/storage-data.ts DEFAULT_EMAIL_REGEX 应用 ISSUE-0023 修法
docs/issues/0031-watchdog-mirror-dead-listener-regex.md 🆕 issue 归档
docs/raw/inbox/2026-05-26-task-delete-cascade-cleanup-tbd.md 🆕 agent 提到,待确认
docs/raw/inbox/2026-05-26-restore-ext-tabs-double-reload.md 🆕 agent 提到,复评后不是 bug
package.json 0.10.46 → 0.10.47

验证

  • pnpm compile 0 错
  • pnpm build 7.34s
  • pnpm scan:react 0 命中(v0.10.46 修了 use-countdown 后稳定)

三个元教训

1. 修复模式必须扫所有姊妹路径

ISSUE-0027 修 watchdog setTimeout;ISSUE-0028 修 interception 漏 pump。 两条路径属于"nuke + 延迟恢复 + pump"三联模式,但每次只修触发的那一条。

未来准则:

修复任何 alarm 触发后的 resume 路径时,必须 grep nukeAllScheduler / forceResetLocks / restoreBatch 所有调用方,确认每条恢复路径后续 pump 三件套齐不齐。

2. ISSUE 改动是否还有姊妹文件没改

Bug H 是 ISSUE-0023 漏掉 storage-data.ts —— 同一个正则在两处定义但只改了一处。

未来准则:

改正则 / 常量 / 类型时,必须 grep 全仓 <旧值> 看是否别处也有。 ISSUE frontmatter 应强制列「改动文件全集」+ commit hook 反查同模式。

3. 独立 agent 多轮仍有边际

不要满足于"一两次 agent audit 就完"。

系统性改进方向(todo 登记)

agent 提出 3 个工具化方向(已在 ISSUE-0031 末尾登记): 1. 「nuke + 延迟恢复 + pump」三联模式扫描脚本 —— grep + AST 验证每条恢复路径完整性 2. dead listener 检测 —— scan:protocol-orphan 静态匹配 onMessage 注册 vs sendMessage 发送 3. ISSUE 改动反查 —— frontmatter 列改动文件全集 + commit hook 检查同模式

这些是 meta-tool,不是必修 bug。下次有"修改正则 / 关键常量 / alarm 路径"时再做。

累计本会话(v0.10.31 → v0.10.47)

版本:       17 个版本
真 bug:     **18+ 个**(其中 8 个由独立 agent 发现,我自己漏看)
独立 agent 三轮都有 P0 发现:1 + 0 + 1 (3 轮,但有 2 个 P0 — Bug A, Bug I)
基础设施:    docs:check/rebuild/scan:mv3/scan:react/pre-commit hook 三轨
新 rule:     待办登记 / MV3持久化陷阱清单 / 独立agent审查 / React前端lifecycle清单
新 wiki:     扩展reload生命周期
归档 issue:  ISSUE-0023~0031(9 个新)
backlog:    4 条 raw/inbox(都是低优先级 known finding)

2026-05-26 v0.10.45 → v0.10.46 scan:react 基础设施 + use-countdown 反模式修(ISSUE-0030)

用户反馈

实施"React lifecycle 扫描"基础设施(类比 scan:mv3)

改动

新建 scripts/scan-react-lifecycle.py + pnpm scan:react

pnpm scan:react                    # 完整扫描
pnpm scan:react -- --save-baseline # 存基线
pnpm scan:react -- --diff          # 仅显示新增(pre-commit 用)

扫描类别:useEffect 块内含副作用关键词(addListener / setInterval / new EventSource 等) 但无 return cleanup 的位置。

首次跑发现 1 处:use-countdown.ts:74 双 useEffect + module-let interval 反模式(ISSUE-0030)。

修复 ISSUE-0030:use-countdown 用 useRef 替代 component scope let

// 旧 ❌ —— let interval + 两个 useEffect
let interval: number | undefined;
useEffect(() => { interval = setInterval(...); }, []);
useEffect(() => () => clearInterval(interval), [interval]);

// 新 ✅ —— useRef + 单 useEffect with cleanup
const intervalRef = useRef<number | undefined>(undefined);
useEffect(() => {
  intervalRef.current = setInterval(...);
  return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, []);

setNewDate 外部 API 也改用 intervalRef.current

新建 docs/rules/react-lifecycle-checklist.md

类比 MV3持久化陷阱清单: - 副作用 → cleanup 对照表 - 标准 / 反模式 pattern - 自查命令 pnpm scan:react - 触发场景 / 工作流 / 反模式速查

pre-commit hook 加第 3 路检查

# 现在三轨:
# 1. docs/ 改动 → docs:check
# 2. background/ 改动 → scan:mv3 --diff
# 3. src/sections/ 或 src/components/ 或 src/hooks/ 改动 → scan:react --diff

中间踩坑

A. 空 baseline 被 if not baseline 误判为"没基线"

load_baseline 返回空 set 时,if not baseline 是 True → 错报"没找到基线"。 修:改判 BASELINE_PATH.exists()

B. 修 use-countdown 漏看 setNewDate 也用 interval

第一次只移除了 let interval,但 setNewDate 也用到。先 useRef,再扫所有 interval 引用全改为 intervalRef.current

教训:改全局变量前先 grep -n "interval" 看所有引用。

改动文件

文件 改了什么
scripts/scan-react-lifecycle.py 🆕 4 类陷阱扫描 + baseline diff
package.json + scan:react script
scripts/hooks/pre-commit 加第 3 路 scan:react 检查
src/hooks/use-countdown.ts useRef 重构
docs/rules/react-lifecycle-checklist.md 🆕 规则沉淀
docs/issues/0030-use-countdown-double-useeffect.md 🆕 issue 归档
.react-lifecycle-baseline.json 🆕 基线(0 处 — 修完使命)
package.json 0.10.45 → 0.10.46

验证

  • pnpm compile 0 错
  • pnpm build 7.29s
  • pnpm scan:react 0 命中(修完 use-countdown)
  • ✅ baseline / diff 模式工作正常

2026-05-26 v0.10.44 → v0.10.45 第二轮独立 agent + 元-发现盲区(ISSUE-0029)

用户反馈

再跑一次独立 agent 看是否还有遗漏(验证 anchor bias 是否消除了) 把"独立 agent 审查"做成 rule

第二轮 agent 报告(两个目标都达到)

目标 1:验证 v0.10.44 修复无新 bug ✅ - pumpScheduler/pumpPager 有 isPumpingXxx 串行化锁,重入会折成"再跑一次",安全 - clearTaskProgress 走 writeChain 后,唯一调用点 task-manager.ts:301 已 await,无副作用

目标 2:找新盲区 ✅ —— 找到 2 个真 bug + 1 个系统性发现

🔴 Bug E(中-高,性能):DataView 浪费 taskId 索引

src/sections/data/data-view.tsx:117-135:每 5s 拉全表 50000 行再 JS filter。 taskId 列 base.ts 已配 enableSearch: true 索引但没用

后果:DB 20k 行 + 单 task 100 行 = 200× 无效 IO,每 5s 一次。

修:

const query = isFiltered
  ? { where: { taskId: filterTaskId }, limit, order }
  : { limit, order };
return await selectByQuery('MapTaskData', query);

🟡 Bug S4(中):local-data-view onMessage 无 cleanup

lisenter() 注册 onMessage('has-new-data', cb)useEffect(() => { lisenter() }, []) 无 return。组件 unmount 后 listener 残留,stale closure 持有已卸 component scope。

修:lisenter 返回 off(),useEffect cleanup 调 off()。

🟡 系统性发现:前端 React lifecycle 是审查盲区

本会话 14+ issue 0 个针对前端 lifecycle。 334 处 useEffect/useState 中: - 用 useRequest 的 ✅ 自带 unmount cancel - 裸 useEffect(async () => { ... setState }) 全部裸奔

登记到 docs/raw/inbox/2026-05-26-frontend-lifecycle-audit-blindspot.md,未来 Phase 1+2+3 处理: - Phase 1: 写 scripts/scan-react-lifecycle.py 类比 scan:mv3 - Phase 2: 高频组件系统性修 - Phase 3: rule 化 + pre-commit

元-结论:anchor bias 消除验证 PASS

第一轮独立 agent (v0.10.44):找到 3 真 bug
第二轮独立 agent (v0.10.45):
   - 验证 v0.10.44 修复无新问题 ✅
   - 又找到 2 真 bug + 1 系统性盲区 ✅

两轮都有新发现 → 证实独立 agent 不是一次性消费,多轮独立 audit 仍有边际收益

独立 agent 审查 rule 化

新建 docs/rules/independent-agent-review.md: - 触发条件(P0/P1 代码改动后必须;可选场景) - Agent tool 调用模板 + 关键 brief 原则 - Trust but verify 工作流 - 反模式清单

核心准则:

自己写的关键代码必须由「没参与原始实现的人 / 独立 agent」复核。 不论你做了多少轮 review,作者的 anchor bias 是消除不掉的。

改动文件

文件 改了什么
src/sections/data/data-view.tsx Bug E: 用 where 走 taskId 索引
src/sections/page/local-data-view.tsx Bug S4: lisenter 返 off + useEffect cleanup
docs/rules/independent-agent-review.md 🆕 流程沉淀
docs/issues/0029-dataview-perf-listener-no-cleanup.md 🆕 issue 归档
docs/raw/inbox/2026-05-26-frontend-lifecycle-audit-blindspot.md 🆕 系统性发现登记
package.json 0.10.44 → 0.10.45

验证

  • pnpm compile 0 错
  • pnpm build 8.16s
  • ✅ anchor bias 消除验证 PASS(独立 agent 多轮仍有发现)

注意点

  • ⚠️ 独立 agent 不是一次性消费 —— 每次 brief 不同盲区,仍能找到新东西
  • ⚠️ IndexedDB enableSearch 索引必须用 where —— 不然白建
  • ⚠️ useEffect 内注册的 listener 必须返回 cleanup —— React lifecycle 基础
  • ⚠️ 元-评估视角"是否某类风险一直没人查"是关键
  • ✅ 教训:本会话 14+ 个 issue 都是 background 域,前端是真盲区

累计成果(本会话 v0.10.31 → v0.10.45)

版本:       15 个版本
真 bug:     15+(其中 5 个由独立 agent 发现,我自己漏看)
基础设施:    docs:check/rebuild/scan:mv3/pre-commit hook/scan-baseline diff
新 rule:     待办登记 / MV3持久化陷阱清单 / 独立agent审查
新 wiki:     扩展reload生命周期
归档 issue:  ISSUE-0023~0029(7 个)
backlog:    2 条(dynamic-scraper Map 慢泄漏 + 前端 lifecycle 盲区,都在 raw/inbox)

2026-05-26 v0.10.43 → v0.10.44 独立 audit agent 发现 3 个真 bug(ISSUE-0028)

用户反馈

继续检查! 接下:用独立 agent 验证盲区

独立 agent 发现的真 bug(自己 4 轮 review 都没看到)

我做了 4 轮 review 都没找到,独立 general-purpose agent 跑 fresh-eyes 一次找到 3 个:

🔴 Bug A: doResumeFromInterception 漏调地图调度

batch-controller.ts:911 末尾只调 manageQueue()(engine-manager 的网站调度), 漏调 batch-controller 自己的 pumpScheduler() / pumpPager()(地图调度)。

后果:拦截恢复后纯地图任务不会重新派 tab

为什么我 4 轮都没看见:我聚焦"用户感知 / 状态机 / 持久化",没回到"调度路径完整性"。 独立 agent 没有我的 anchor,直接对比 resumeBatch:735-736 的 canonical 模式找到了。

修:

// v0.10.40 ❌
manageQueue();

// v0.10.44 ✅
manageQueue();
await pumpScheduler();
await pumpPager();

🟡 Bug B: clearTaskProgress 绕过 writeChain → 残留数据

task-progress.ts:61 旧版直接 storage.removeItem,绕开了 recordTaskProgress 的 writeChain。

场景

用户 stop task → clearTaskProgress(id) 立即删
   ↓ 但此时 writeChain 里还有 in-flight recordTaskProgress 排队
那个 recordTaskProgress 完成后 setItem 重建 key
storage 里残留垃圾数据

修:clearTaskProgress 也走 writeChain:

writeChain = writeChain.then(async () => {
  await storage.removeItem(key(taskId));
}).catch(() => {});

🟢 Bug C: 死代码 restoreInterceptState

v0.10.40 我加的 export,全仓库 0 调用方。实际 load 由 restoreBatch 内部直接调 loadInterceptState() 完成。删 export。

改动文件

文件 改了什么
src/entrypoints/background/batch-controller.ts + pumpScheduler/pumpPager 调用,- restoreInterceptState 死代码
src/utils/task-progress.ts clearTaskProgress 改走 writeChain
docs/issues/0028-captcha-resume-missing-map-schedule.md 🆕 issue 归档
package.json 0.10.43 → 0.10.44

验证

  • pnpm compile 0 错
  • pnpm build 8.12s
  • pnpm scan:mv3 --diff 0 新增(基线 40 处对得上)

重要元教训:独立审查是必要的

轮次              发现 bug
v0.10.38 自己 1   2
v0.10.40 自己 2   3
v0.10.41 自己 3   1(同款扫)
v0.10.43 自己 4   0(数据/错误处理 — 都是合理设计)
独立 agent          3(包括 1 个我反复路过的 P0)

为什么我 4 轮都漏看 Bug A:v0.10.40 这段代码是我自己写的。反复 review 时 我下意识按"我当初的思路"看 —— "用 manageQueue 启动调度"是我写时的心智模型。 独立 agent 没这个 anchor,能客观对比 resumeBatch 模式发现"manageQueue ≠ 全启动"。

沉淀准则(写进 dev log + 后续 rule):

自己写的关键代码必须由「没参与原始实现的人 / 独立 agent」复核。 不论你做了多少轮 review,作者的 anchor bias 是消除不掉的。

独立 agent 提到但没修的可疑点(优先级低)

# 内容 决定
S1 restoreBatch 完成前用户点 resume 的窄竞态 触达概率极低(< 100ms),先不修
S2 dynamic-scraper tabStatus/tabError Map 长期慢泄漏 每条 entry 极小,但确实泄漏,登记到 todo
S3 main-layout 热重载孤儿 port 累积 主面板单实例场景影响小,先不修

S2 是真泄漏,按"待办登记"流程登记到 raw/inbox(不立即修)。

后续

  • S2 慢泄漏登记到 raw/inbox(按 docs/rules/todo-registration.md 流程)
  • 独立 audit 模式应该常态化:每修 P0 之后跑一次独立 agent 复核

2026-05-26 v0.10.42 → v0.10.43 scan:mv3 加 baseline diff + pre-commit 集成

用户反馈

把 scan:mv3 加进 pre-commit hook(在 background/ 文件改动时跑) 基线 diff 模式(只显示新增命中,减少噪音)

改动

1. baseline diff 模式

scripts/scan-mv3-pitfalls.py 加 3 个模式标志:

pnpm scan:mv3 -- --save-baseline   # 把当前 40 处命中存到 .mv3-scan-baseline.json
pnpm scan:mv3 -- --diff            # 只显示比基线"新增"的命中(pre-commit 用)
pnpm scan:mv3 -- --strict          # 有命中即 exit 1(CI 用)

唯一键(file, content) 而非 (file, line) —— 行号会随代码增减飘, 内容稳定。content 标准化(去前后空白 + 多空白 collapse 单空格)后哈希。

.mv3-scan-baseline.json 提交到 git,让所有 dev 共享同一基线。

2. pre-commit hook 集成

scripts/hooks/pre-commit 加规则:当 staged 文件含 src/entrypoints/background/src/utils/scrape-{watchdog,window,executor}.tsengine-manager.tsauto-login.ts 改动时,自动跑 scan:mv3 --diff

检测到 docs/ 改动      → docs:check(v0.10.26 起)
检测到 background 改动  → scan:mv3 --diff(v0.10.43 新增)

任意一个失败 → 阻止 commit,提示三个选项: 1. 修复(参考 rule) 2. 重存基线 3. --no-verify 跳过

3. 测试通过

故意往 batch-controller 末尾加 setTimeout(..., 60000)

⚠️  发现 1 处新增陷阱命中(不在基线内):
🆕 setTimeout/setInterval(1 处)
  src/entrypoints/background/batch-controller.ts:1164
  setTimeout(() => { console.log('test'); }, 60000);
exit code 1。git checkout 还原后:
✅ 0 新增命中(基线 40 处全部对得上)
exit code 0。✓ 工作正常。

改动文件

文件 改了什么
scripts/scan-mv3-pitfalls.py + --save-baseline / --diff 模式 + hit_key / load_baseline / save_baseline
scripts/hooks/pre-commit + background/scrape 改动时跑 scan:mv3 --diff
.mv3-scan-baseline.json 🆕 当前 40 处命中基线(commit 到 git)
docs/rules/mv3-persistence-pitfall-checklist.md + baseline 工作流说明
package.json 0.10.42 → 0.10.43

中间踩坑

A. f"...""..." 嵌套引号 SyntaxError

"note": "...已审过的"已知 OK"命中..."  # ❌ 引号 cancel
修:用「」全角引号绕过。

B. 主流程 section() 没删 → 跑两次

最初我加了 mode 分支但没删主流程的 4 个 section() 调用,导致默认模式跑两遍 section。修:把 section() 全部移到 mode 分支内,主流程只 run_grep 收集。

教训:重构控制流时要确认旧路径完全清理,测试至少一遍。我跑 --save-baseline 一次就 commit 了,没跑默认模式 → 漏看 duplicate 输出。下次大改 script 后跑全套模式验证。

注意点

  • ⚠️ baseline 文件必须 commit —— 否则 pre-commit 每次都警告"没找到基线"
  • ⚠️ 基线 stale 时主动重存 —— 修复或重构后跑 --save-baseline
  • ⚠️ --diff 用 content 而非 line 做 key —— 行号飘移不影响匹配
  • ⚠️ pre-commit 不阻塞 docs/ 改动场景 —— 只在 background/scrape 改动时跑 scan
  • ✅ 现在写 background 代码引入新 setTimeout 自动被 commit 拦截,强制 review

完整防御链(v0.10.43 全栈)

写代码 → pre-commit hook 拦 → scan:mv3 --diff 对比 baseline →
  - 命中是真陷阱 → 改 alarm/persist
  - 命中是合理设计 → --save-baseline 重存
  → docs/rules/mv3-persistence-pitfall-checklist.md 说明判定指南
  → docs/issues/0026/0027 历史教训

每一环都有清晰责任 + 文档支撑。新人写 background 代码踩同款坑的概率显著降低。

后续可选

  • 给 baseline 加版本号检测(脚本更新后强制重 save 基线)
  • 把 scan:mv3 集成到 GitHub Actions CI(远端二次校验)

2026-05-26 v0.10.41 → v0.10.42 MV3 陷阱扫描器 npm script(pnpm scan:mv3)

用户反馈

1(接 v0.10.41 末"把清单做成 npm script")

改动

新建 scripts/scan-mv3-pitfalls.py + pnpm scan:mv3 npm script,将 ISSUE-0026/0027 沉淀的"深度审查清单" 4 项自查可执行化:

pnpm scan:mv3              # 默认 exit 0,列所有命中给人工判定
pnpm scan:mv3 -- --strict  # CI 用:有命中 → exit 1

输出按 4 类分段: 1. setTimeout/setInterval(带延迟判定指南) 2. module-level let / var(关键 vs 临时锁) 3. addListener(注册位置 / async 返回) 4. 持久化 type union(是否误扩派生态)

末尾贴"已知 OK 的合理设计示例"清单(如 keepAlive 心跳、TAB_TIMEOUT),避免每次重复判定相同位置。

首次跑结果(基线)

设置/扫描范围:src/entrypoints/background/ + src/utils/scrape-*.ts + auto-login + engine-manager

1️⃣ setTimeout/setInterval:6 处(全部已确认合理设计)
2️⃣ module-level let:24 处(其中部分是已 persist 的运行时锁)
3️⃣ addListener:8 处(全部顶层注册 OK)
4️⃣ 持久化 type union:3 处(v0.10.40 复核过都 OK)

总命中:41 处 —— 都在"已知 OK"清单内

改动文件

文件 改了什么
scripts/scan-mv3-pitfalls.py 🆕 4 类陷阱扫描 + glob 展开 + 注释行过滤
package.json + scan:mv3 script
docs/rules/mv3-persistence-pitfall-checklist.md 自查命令段改成 pnpm scan:mv3

验证

  • pnpm scan:mv3 输出可读,41 处命中分类正确
  • pnpm build 8.29s
  • ✅ glob 展开 bug 修了(首次跑只看到 14 处,因 src/entrypoints/background/*.ts 没 shell-expand)

中间踩坑

初版用 subprocess.run([...], shell=False),shell glob *.ts 没展开 → 漏 batch-controller。修法:Path.glob 在 Python 侧展开,再传给 grep 子进程。

教训:subprocess shell=False 时所有 glob 自己负责展开。

注意点

  • ⚠️ scan:mv3 默认 exit 0 —— 不阻塞 commit/build,避免 false positive 干扰
  • ⚠️ 真要在 CI 强制 —— 用 pnpm scan:mv3 -- --strict
  • ⚠️ 白名单不在脚本里硬编码 —— 在 dev log 和 rule 文档里维护,灵活
  • ✅ 触发场景:每次写完 background 新功能 / review 1-2 版本前改动 / debug 自动恢复不灵

未来可选

  • 给脚本加 基线 diff 模式:和上次跑结果对比,只显示新增命中(减少噪音)
  • 集成 pre-commit hook(但目前不阻塞 commit,可选)

2026-05-26 v0.10.40 → v0.10.41 跨模块扫同款陷阱 — watchdog 自动恢复也是 setTimeout(ISSUE-0027)

用户反馈

1(接 v0.10.40 末"用同清单扫其他模块")

扫到的清单

复用 ISSUE-0026 沉淀的"深度审查清单"扫 scrape-watchdog / engine-manager / batch-controller,发现:

位置 类型 评估 修?
scrape-watchdog.ts:190 setTimeout(pumpTasks, cooldownMs) 长延迟(0-600s) 🔴 同 ISSUE-0026 模式
engine-manager.ts:223 setTimeout(manageQueue, ms) 短延迟(几秒,idempotent 重试) 🟢 OK
batch-controller.ts:220 setInterval(keepAlive, 20s) SW 保活心跳 🟢 OK(设计如此)
batch-controller.ts:336 setTimeout(finalizeTab, 60s) Tab 超时 🟢 OK(watchdog 兜底孤儿 tab)
batch-controller.ts:157 sleep = setTimeout(r, ms) 短期等待 🟢 OK

修复(同 ISSUE-0026 模式)

// 新增 alarm 常量 + handler
const WATCHDOG_RESUME_ALARM = 'scrape_watchdog_resume';

if (cooldownMs <= 0) {
  pumpTasks().catch(() => {});
  manageQueue();
} else {
  browser.alarms.create(WATCHDOG_RESUME_ALARM, { when: Date.now() + cooldownMs });
}

export async function onWatchdogResume() {
  await pumpTasks().catch(() => {});
  manageQueue();
}

// background/index onAlarm 路由
if (alarm.name === WATCHDOG_RESUME_ALARM_NAME) {
  onWatchdogResume().catch(...);
}

改动文件

文件 改了什么
src/utils/scrape-watchdog.ts + WATCHDOG_RESUME_ALARM + onWatchdogResume;setTimeout → alarm
src/entrypoints/background/index.ts + 路由 WATCHDOG_RESUME_ALARM_NAME
docs/issues/0027-watchdog-resume-settimeout-bug.md 🆕 issue 归档
package.json 0.10.40 → 0.10.41

验证

  • pnpm compile 0 错
  • pnpm build 7.99s

历史模式复用复盘

v0.10.15  引入 watchdog 周期用 chrome.alarms ✅(正确)
v0.10.23  引入 watchdog 自动恢复用 setTimeout ❌(同模块违反同原则)
v0.10.37  引入 interception 用 setTimeout ❌(同样错误)
v0.10.40  修 interception 用 alarm + persist ✅
v0.10.41  同清单扫,找到 watchdog 自动恢复同款 → 修 ✅

教训:好模式(alarm)项目里早就存在,但 1-2 个版本后加类似逻辑没复用。这是复杂项目典型现象 — "局部最优、全局重蹈"。

最低代价的防止方式:写完新功能后用清单全局扫一遍同类陷阱

注意点

  • ⚠️ 看到"v0.10.X 修过同款"时不要满足 —— 项目里可能还有第 3、4 处同款没扫到
  • ⚠️ 清单要常态化使用 —— 不只在被审查时跑,写完新功能就跑一次

后续可选(未做)

  • 把这套深度审查清单写到 docs/rules/mv3-persistence-pitfall-checklist.md,每次 background 改动都走一遍
  • 现在写进 dev log 注意点里,但 rule 化更显眼

2026-05-26 v0.10.39 → v0.10.40 深度审查发现 3 个严重 bug(ISSUE-0026)

用户反馈

再次检查 尤其是深层次的逻辑

深度审查发现

挖出 3 个真严重 bug(前几次 review 没看到,因为关注的是"用户感知"层):

🔴 P0-A: SW 重启后 verifyTabId / lastInterceptedAt 丢失

v0.10.37 这两个字段是 module-level let。MV3 SW 闲置 30s+ 会被 kill,重启后 module 重新执行 → 字段重置。

链条:拦截 → SW kill → 重启 → globalStatus='intercepted'(持久化 ✅)但 verifyTabId=null(没持久化 ❌)→ tabs.onUpdated 监听器 if (tabId !== getVerifyTabId()) return 永远 return → 自动恢复永远不触发

🔴 P0-B: setTimeout 在 SW kill 时丢失

v0.10.37 的 30s 冷却用 setTimeout

if (wait > 0) {
  await new Promise(r => setTimeout(r, wait));  // ❌ SW kill 就丢
}

链条:用户点"我已验证完" → setTimeout(30s) 排队 → SW kill → setTimeout 丢 → 永远不 resume → 用户卡在 intercepted。

🔴 P0-C: TaskStatus 扩 'intercepted' 概念错误(v0.10.39 引入)

v0.10.39TaskStatus = ... | 'intercepted'。但 MapTask.status 是持久化态,实际写入永远不是 'intercepted'(那只活在 batch-controller TaskState + progress.status)。

Footgun:未来开发者读 TaskStatus 会以为可以 task.status = 'intercepted',写了就破坏持久层数据。

修复方案

A → 持久化 InterceptState

const INTERCEPT_STATE_KEY = 'local:scheduler:intercept-state';
interface InterceptState {
  lastInterceptedAt: number;
  verifyTabId: number | null;
  pendingResumeAt?: number;
}
async function persistInterceptState() { ... }
async function loadInterceptState() { ... }
// restoreBatch 调 loadInterceptState
// onInterception / reopenVerifyTab / doResume 都 persistInterceptState

B → setTimeout → chrome.alarms

const RESUME_ALARM_NAME = 'resume-from-interception';

export async function resumeAllIntercepted() {
  if (wait > 0) {
    if (pendingResumeAt && Date.now() < pendingResumeAt) return;  // 防重排
    pendingResumeAt = Date.now() + wait;
    await persistInterceptState();
    browser.alarms.create(RESUME_ALARM_NAME, { when: pendingResumeAt });
    return;
  }
  await doResumeFromInterception();
}
// alarm 由 Chrome 持久化,SW kill 不丢

提取 doResumeFromInterception() 让立即/延迟两条路径共享。

C → 回退 TaskStatus 扩展

- TaskStatus = '...' | 'intercepted'
+ TaskStatus = 'queued' | 'running' | 'paused' | 'done' | 'stopped'

STATUS_META: Record<TaskStatus | 'intercepted', ...> —— 类型签名直接体现"展示派生态"语义。

改动文件

文件 改了什么
src/utils/task-store.ts 回退 TaskStatus 扩展
src/sections/task/task-view.tsx STATUS_META 改回 union 扩展(非 TaskStatus)
src/entrypoints/background/batch-controller.ts + InterceptState 持久化 + doResumeFromInterception + alarm 替代 setTimeout
src/entrypoints/background/index.ts + INTERCEPT_RESUME_ALARM 路由
docs/issues/0026-captcha-resume-sw-restart-lost-state.md 🆕 issue 归档
package.json 0.10.39 → 0.10.40

验证

  • pnpm compile 0 错误
  • pnpm build 8.06s
  • 📋 浏览器实测路径(含 SW kill 场景):
  • 拦截 → 看 console verifyTabId=42
  • chrome://serviceworker-internals 强制 kill SW
  • 触发任意消息让 SW 重启 → console 看 loadInterceptState 还原 verifyTabId=42
  • 关掉 sorry tab 再开 → 验证完跳走 → 自动恢复
  • 另一路径:拦截后点"我已验证完" → console "resume scheduled via alarm" → SW kill → 30s 后 alarm 触发 → SW 唤醒 → doResume

注意点(写给未来)

  • ⚠️ MV3 中 module-level let 不可靠 —— 任何重要状态必须 persist 到 storage,restore 时 load
  • ⚠️ MV3 中 setTimeout/setInterval 不可靠 —— 长延迟(>10s)改用 chrome.alarms
  • ⚠️ 持久化态 vs 运行时派生态要分清 —— 不要为了"类型干净"扩持久化 union
  • ⚠️ 任何"在 X 时间后做 Y"的逻辑必问:"如果 SW 在这段时间被 kill 会怎样"
  • ⚠️ alarm 排队前去重 —— 防用户连点
  • ✅ 教训:v0.10.37 写时关注用户感知层(横幅/toast/按钮),没意识到 MV3 通用陷阱。v0.10.15 watchdog 已经用 alarm 处理过类似问题,这种模式要建肌肉记忆

复盘:deep review 流程价值

前 3 轮 review 都没看到这 3 个 bug: - 第一轮:盘点新代码 → 找到 verifyTabId 失同步、死代码 - 第二轮:文档治理 → 找到 false positive(手机正则 / CF 拦截) - 第三轮(这次):聚焦 MV3 持久化 + 并发 + 状态机 → 找到 3 个 P0

深度审查清单(沉淀给未来): 1. 任何状态变量 — let/const/Map:是 module-level?SW kill 会丢吗?需要 persist? 2. 任何定时器 — setTimeout/setInterval:长延迟会被 SW kill 截断?换 alarm? 3. 任何 callback — alarms.onAlarm/runtime.onMessage:SW 重启后 listener 是否会重新注册? 4. 任何类型扩展 — 加 union member:是真的写入数据,还是只在展示层用?


2026-05-26 v0.10.38 → v0.10.39 TS 编译 0 错误 + TaskStatus 联合扩 'intercepted'

用户反馈

跑 pnpm compile 清历史 TS 错误 把 TaskStatus 联合扩 'intercepted' 让类型更干净

现状盘点(修复前)

pnpm compile 19 个错误,分布:

文件 错误数 是否真在用
email-cleanup.ts 5 ✅ 本次会话引入(v0.10.33)
autocomplete/freesolo-autocompletes.tsx复数 s 3 ❌ 死代码(boilerplate)
autocomplete/custom-autocomplete.tsx 1 ⚠️ 内部用(freesolo 引用)
autocomplete/object-autocomplete.tsx 2 ⚠️ 仅 index.ts 导出,无外部用
chip-verify-status.tsx 1 ✅ 在用(chip/index.ts re-export)
client-data-table.tsx 3 ✅ 在用(table/index.ts re-export)
hooks/use-wxt-storage.ts 3 ❌ 死代码(无外部引用)
content-search/index.tsx 1 ✅ 在用(content entry)

分阶段处理

Phase 1 — TaskStatus 扩 'intercepted'

- export type TaskStatus = 'queued' | 'running' | 'paused' | 'done' | 'stopped';
+ export type TaskStatus = 'queued' | 'running' | 'paused' | 'done' | 'stopped' | 'intercepted';

task-view.tsx STATUS_META 不再需要 Record<TaskStatus | 'intercepted', ...> 扩展,直接 Record<TaskStatus, ...> 即可。注:实际持久化 task.status 还是只会是前 5 个,'intercepted' 仅用于 chip 展示层与 progress.status 配合。

Phase 2A — 删死代码(消 9 个错误)

文件 删除理由
freesolo-autocompletes.tsx(复数 s) 没人引用,模板 boilerplate
use-wxt-storage.ts 0 外部引用
autocomplete/index.tsCustom/Object export 外部无人用,但文件保留因为 freesolo 内部依赖 custom

Phase 2B — 修 email-cleanup(消 5 个错误)

selectAll<any[]> 让 list 推断成 any[][],row 是 any[] 而非 any,访问 row.emails 报错。修法:const list: any[] = (result.data?.list as any) || []

Phase 2C — 修真实在用的(消 5 个错误)

文件 修法
chip-verify-status.tsx @emotion/react/types/jsx-namespaceReact.ReactElement
client-data-table.tsx MUI x-data-grid slots/slotProps 自定义字段 as any
content-search/index.tsx async listener 拆为同步 listener(onMessage 类型不收 Promise),同时加 cleanup
custom-autocomplete.tsx filterOptions 整体 as any(filter 是 <unknown>,与外层泛型 T 不兼容)
object-autocomplete.tsx isOptionEqualToValue 参数 : any(泛型 T 没强制 {value} 约束)

中间踩坑:误删导致 freesolo 编译失败

初始我把 custom-autocomplete.tsxobject-autocomplete.tsx 都删了(grep 用 -v components/autocomplete 排除掉内部引用,漏看 freesolo 通过相对路径 ./custom-autocomplete import)。

修复:git checkout HEAD -- 还原,然后用 as any cast 修内部 TS 错误。

改动文件

类型 文件
删除 🗑️ freesolo-autocompletes.tsx(复数 s)
删除 🗑️ use-wxt-storage.ts
改 type task-store.ts + task-view.tsx
改代码 email-cleanup.ts chip-verify-status.tsx client-data-table.tsx content-search/index.tsx custom-autocomplete.tsx object-autocomplete.tsx autocomplete/index.ts
version package.json 0.10.38 → 0.10.39

验证

  • pnpm compile 0 错误(19 → 0)
  • pnpm build 8.3s
  • pnpm docs:check 0 error

注意点

  • ⚠️ grep dead code 时不要排除组件内部目录 —— 内部相对路径 import 也是真实使用
  • ⚠️ selectAll<any[]> 是 base.ts 的 generic 写法 bug —— jsstore 的 select<T> 返回 Promise<T[]>,传 any[] 让 list 成了 any[][]。改 base.ts 影响范围大,暂在调用方 cast 兜底
  • ⚠️ MUI x-data-grid v7 严格类型不友好 —— 自定义 toolbar/pagination 的 props 必须 cast,否则报"unknown property"
  • ⚠️ chrome.runtime.onMessage listener 不要用 async —— 返回 Promise<void> 会被类型 reject,要求返回 true/void
  • ✅ 教训:用户让"清历史 TS 错误"看似工作量大,实际一半是删死代码(autocomplete 复数版本 + use-wxt-storage),剩下一半 cast any 即可
  • ✅ TaskStatus 扩 'intercepted' 后类型更干净,Record 不再需要 union 扩展

未来准则

新增 generic 函数时,避免 select<T = any> 后调用方传 <any[]> —— 这种"any of any"会把数组维度搞错。最好定义返回类型而非泛型。


2026-05-26 v0.10.37 → v0.10.38 修 verifyTabId 失同步 + 清死代码

用户反馈

再次检查

深度复查 v0.10.37 代码发现 2 个真实问题:

🔴 Bug:interception-banner 的"打开验证页"按钮 verifyTabId 失同步

场景

1. 拦截 → bg.onInterception → openVerifyTab() 返回 tabId=42 → verifyTabId=42
2. 用户关掉 tab 42(手抖 / 整理 tab)
3. 用户点应用内「打开验证页」按钮
4. v0.10.37:interception-banner 自己 query+activate 找到新 sorry tab 50
5. ❌ background 的 verifyTabId 仍是 42(已死)
6. tabs.onUpdated 永远等不到 tabId===42 → 用户验证完不会自动恢复

修复: - interception-banner.tsx 按钮改 sendMessage 'open-verify-tab' → background - batch-controller.ts 新增 reopenVerifyTab() 包装:调 openVerifyTab + 写回 verifyTabId - background/index.ts 加 'open-verify-tab' 路由

这样无论自动还是用户主动,verifyTabId 都由 background 单一管理,监听跟踪一致。

🧹 死代码:merchant-stats-banner.tsx

v0.10.31 删了组件渲染但留了文件,注释说"未来可能用"。v0.10.38 起 6 个版本零运行时引用 —— YAGNI 反模式。

清理: - 删 src/sections/page/merchant-stats-banner.tsx - local-data-view.tsx 删 import 注释 + 简化 JSX 注释

改动文件

文件 改了什么
src/sections/layout/interception-banner.tsx openVerifyTab 改 sendMessage
src/entrypoints/background/batch-controller.ts + reopenVerifyTab()
src/entrypoints/background/index.ts + 'open-verify-tab' 路由
src/sections/page/local-data-view.tsx 清死注释
src/sections/page/merchant-stats-banner.tsx 🗑️ 删除
package.json 0.10.37 → 0.10.38

验证

  • pnpm build 7.3s
  • pnpm docs:check 78 文档 0 error
  • 📋 浏览器实测:拦截 → 关掉自动打开的 sorry tab → 点应用内"打开验证页" → 完成验证 → 应自动恢复(v0.10.37 这个 case 不会自动恢复,v0.10.38 修了)

注意点

  • ⚠️ 跨 SW / page 状态要走单一入口 —— verifyTabId 在 background,前端不应自己 query/activate 绕过
  • ⚠️ "未来可能用到"是 YAGNI 反模式 —— 6 个版本没用就该删;真要复用,git history 还在
  • ⚠️ 重要的 follow-up bug 在自我 review 才能发现 —— v0.10.37 写 ISSUE-0025 时把同一组件做了两份 openVerifyTab(前端 + 后端),写的时候没意识到 verifyTabId 跟踪失同步

自我 review 流程沉淀

每次大改动后跑:

1. grep 死代码:原 import / 文件是否还有运行时引用?
2. grep 调用方:新加的方法是否有多个调用点?多个的话是否同步?
3. 看类型一致性:扩展的类型 union 在所有用到处都对得上?
4. listener 注册位置:SW 重启时会重新执行,是否会注册重复?

下次会话开始如果做大改动,可以参考这个清单。


2026-05-26 v0.10.36 → v0.10.37 Google 拦截交互大改(ISSUE-0025)

用户反馈

出现验证:https://www.google.com/sorry/index?... 请告诉我如何交互,要弹窗的吧?任务要暂停的吧?这个优先级要高

诊断

已有:检测 + 上报 + globalStatus='intercepted' + 系统通知 + 任务暂停(manageQueue 已防再派 tab)

缺失/不到位

  1. 任务卡 chip 仍显示"进行中"(STATUS_META 缺 intercepted)
  2. 仅系统通知 — 切窗口/静音就错过,应用内无横幅
  3. openVerifyTab 又开新 maps tab,用户面前已有 sorry 页
  4. 恢复要逐个任务点"继续",无一键
  5. sorry tab url 跳走(用户验证完)无自动监听
  6. 立即恢复 = 立即再撞,无冷却期

三层修复

Phase 1(A/B/C 立即可见反馈): - task-view.tsx STATUS_META 加 intercepted: { label: '⚠️ 被拦截', color: 'error' } - Record<TaskStatus | 'intercepted', ...> 扩展 key 类型 - chip 渲染:status==='running' && progress.status==='intercepted' → 显示 intercepted - 新建 interception-banner.tsx:轮询 storage scheduler.status 1.5s,红色呼吸横幅 + 两 action - 应用内 sonner toast 边沿检测(idle/running → intercepted 触发一次)

Phase 2(D/E/F 复用 tab + 一键恢复): - openVerifyTab 改造:返回 tab id;优先级 sorry → maps → 新开 - resumeAllIntercepted() 新方法:批量恢复所有 intercepted task - message 路由 resume-all-intercepted → 横幅按钮触发 - manageQueue 防再撞:已有(L310/347/498 多处 globalStatus !== 'running' return)✅

Phase 3(G/H 自动检测 + 冷却): - background/index.tstabs.onUpdated 监听:当 isInterceptedNow + tabId === verifyTabId + url 不再含 /sorry/ → 触发自动 resumeAllIntercepted - 30s 冷却:resumeAllIntercepted 内检查 Date.now() - lastInterceptedAt < 30000 → setTimeout 等齐再恢复

改动文件

文件 改了什么
src/sections/layout/interception-banner.tsx 🆕 横幅 + toast 边沿 + 2 个按钮
src/sections/layout/main-layout.tsx 插入 InterceptionBanner
src/sections/task/task-view.tsx STATUS_META + chip 优先 progress.status
src/utils/scrape-window.ts openVerifyTab 三级优先 + 返回 tab id
src/entrypoints/background/batch-controller.ts + resumeAllIntercepted + getVerifyTabId + isInterceptedNow + 30s cooldown
src/entrypoints/background/index.ts resume-all-intercepted 路由 + tabs.onUpdated 监听
docs/issues/0025-google-captcha-ux-poor.md 🆕 issue 归档
docs/raw/feedback/2026-05-26-google-captcha-ux.md 🆕 原话
package.json 0.10.36 → 0.10.37

验证

  • pnpm build 10.4s
  • 📋 浏览器实测路径:
  • 跑大量 atm 任务直到触发 sorry
  • 看:系统通知 + 应用内 toast + 顶部红色呼吸横幅 + 任务卡变"⚠️ 被拦截"
  • 点"打开验证页" → 激活已存在 sorry tab(不开新 maps)
  • 完成验证后,sorry 跳走 → 30s 内自动恢复(看 console "auto-resuming")
  • 或手动点"我已验证完,继续" → 立即触发(仍走 30s 冷却)

注意点

  • ⚠️ STATUS_META 必须覆盖所有可能值 — 加新状态要同步加 meta 否则 chip 默认 fallback 误导
  • ⚠️ 任务 status vs progress.status 是两层 — chip 显示要双重判定
  • ⚠️ 冷却期是必要的 — 反爬场景下立即恢复 = 立即再撞墙
  • ⚠️ tab.onUpdated 不要全局触发 — 加 isInterceptedNow() + tabId === verifyTabId 双 guard,避免误触
  • ✅ 教训:之前只做了"被动通知"(系统通知 + 控制台 log),应用内交互完全缺失;高频反爬场景下用户体验直接崩

后续可做(未做,登记)

  • 拦截累计 chip 到 DataBar(今日 N 次)
  • 拦截趋势 7 日图(如果用户深度反馈需要)

2026-05-26 v0.10.35 → v0.10.36 日志「实开」拆成功/拦截两档 + Tooltip 解释口径

用户反馈

为什么地图实开比请求的还多?请分析结构和代码,找出问题!

截图:实开 (265) > 请求 (249) — 违反 settings 里"实开 1 → 后台翻 14 页"的直觉。

诊断(不是 bug,是 UX 误导)

逻辑完全没问题 — 统计与代码一致: - opened=true 计入实开 - opened=false 计入请求

"实开"口径暗含失败记录content-button/index.tsx:startBatchSearch 有 7 条 logOpen() 路径, 前 6 条都是各种失败兜底(被 recaptcha 拦截 / 点不到搜索 / capture 失败 / 异常),都 count=0 但都算实开。

"请求"只在第 1 页满 20 条时才触发后台 fetch,所以数据稀疏(atm + 美国小镇)时,多数 keyword 不会有翻页请求。

数据成因:

因素 影响实开 影响请求
关键词数据稀疏(小镇 atm < 20) +1 0
第 1 页被 recaptcha 拦截 +1 (count=0) 0
capture 响应失败 +1 (count=0) 0
只有第 1 页满 20 条且后续有数据 +1 +N (1-14)

截图 249/265 = 0.94 表示平均每实开对应不到 1 次翻页,符合"小镇 atm 数据稀疏 + 部分拦截"现状。

改动(方案 B:UI 优化,不动统计)

src/sections/page/log-view.tsx

  1. counts 拆开:mapsOpenTotal / mapsOpenSuccess / mapsOpenFailed(count>0 视为成功)
  2. Tab 标签地图实开 (265 ✓220 ✗45) —— 绿色成功 + 红色失败 inline 显示
  3. Tooltip 解释口径
  4. 实开 Tab:"含被拦截/失败的 0 条记录;✓成功=第 1 页 ≥1 条;✗失败=0 条"
  5. 请求 Tab:"仅当第 1 页满 20 条才触发;数据稀疏时本数 < 实开数正常"
  6. sub-filter chip:实开 tab 下方 "全部 N / 成功 N1 / 拦截/失败 N2" 可点过滤

改动文件

文件 改了什么
src/sections/page/log-view.tsx counts 拆三档 + Tab 标签 + Tooltip + sub-filter chip
docs/raw/feedback/2026-05-26-actual-opens-exceed-requests.md 🆕 原话 + 诊断
package.json 0.10.35 → 0.10.36

验证

  • pnpm build 8.07s
  • 📋 浏览器实测:
  • 日志页 → 实开 Tab 标签应显示 "地图实开 (N ✓N1 ✗N2)"
  • hover Tab → 看到口径解释
  • 点"拦截/失败 N2" chip → 筛出 count=0 的记录,能看到具体被拦截的 URL

注意点

  • ⚠️ 统计逻辑没改 —— 数据库 / 业务逻辑零影响,仅 UI 表达更清晰
  • ⚠️ "实开"=多种 logOpen 路径的并集 —— 不能直接当"成功打开页数"理解
  • ⚠️ 请求/实开比例是 health 指标 —— 趋近 0 = 数据稀疏或拦截严重;趋近 14 = 数据丰富翻页充分
  • ✅ 教训:暴露给用户的 KPI 名词必须无歧义;"实开"听起来像"成功打开"但实际含失败,应早加 Tooltip

后续可做(未做,登记)

  • C 方案"任务诊断页":展示翻页有效率、拦截率、平均产出、7 日趋势 —— 暂未做,待用户深度使用时反馈

2026-05-26 v0.10.34 → v0.10.35 修 KPI 任务卡文字截断 + 剩余时间人性化

用户反馈

解决界面问题(截图 v0.10.33 主面板)

我列了 4 个观察到的问题,用户挑了其中 2 个修:

# 问题 用户判定
① "由于谷" 截断 设计为 label/value 两列,跳过
② 底部"创建任务"按钮冗余 跳过
③ KPI "1/1 任务进行中/..." 文字截断 ✅ 修
④ "剩余 495时18分10秒" 不人性化 ✅ 修

改动

③ KPI label 缩短 src/sections/layout/data-bar.tsx:166

- label={taskTotal > 0 ? '任务进行中 / 总数' : '暂无任务'}
+ label={taskTotal > 0 ? '进行中 / 总数' : '暂无任务'}
(删"任务"二字 —— icon 已是 AssignmentIcon 任务图标,含义不丢失; 原 7 个汉字 + 符号在 flex:1 + noWrap 下溢出,缩到 5 字稳定显示)

④ formatDuration 长时段分级 src/sections/task/task-view.tsx + task-detail-dialog.tsx

+ if (d >= 7) return `约 ${d} 天`;             // ≥ 7d:只显示天
+ if (d >= 1) return `${d}天${pad(h % 24)}时`;  // 1~7d:天 + 时(秒级无意义)
  // 旧逻辑保留 < 1 天精度
  if (h > 0) return `${h}时${pad(m)}分${pad(sec)}秒`;

效果: - 旧:"剩余 495时18分10秒" - 新:"剩余 约 20 天"

1 天内(如截图"耗时 3时04分54秒")保留原精度,向后兼容

改动文件

文件 改了什么
src/sections/layout/data-bar.tsx label 缩短"任务进行中 / 总数" → "进行中 / 总数"
src/sections/task/task-view.tsx formatDuration 加 ≥1d / ≥7d 分支
src/sections/task/task-detail-dialog.tsx formatTime 同步保持一致
docs/raw/feedback/2026-05-26-ui-issues-2-items.md 🆕 原话
package.json 0.10.34 → 0.10.35

注意点

  • ⚠️ flex:1 + noWrap label 字数要保守 —— 6 个 KPI 等宽分配,每卡可显示空间 ≈ 100-130px,label 超 5 个汉字 + 符号就有截断风险
  • ⚠️ 图标已表达含义时,文字可省略冗余词 —— Assignment 图标已是"任务"含义,label 不必再写"任务"二字
  • ⚠️ 时间格式化按"用户能否一眼看懂"分级 —— 短时段精确(看秒级进度感);长时段粗略(已经超出可控范围,精确无意义)
  • ✅ 1 天内显示精度不变,向后兼容(避免熟悉旧界面的用户困惑)

2026-05-26 v0.10.33 → v0.10.34 修扩展 reload 后 tab 变 newtab(ISSUE-0024)

用户反馈

每次点击刷新后,主界面变成浏览器默认页面的,如何确保不出现这个情况? 原本是正常的,点刷新就变了,id 是没变的

用户截图三个红圈:①chrome://extensions URL 含 id(id 没变)②详情页 reload 按钮 ③reload 前的主界面(reload 后变 newtab)。

诊断

假设 排除? 理由
manifest.key 未固定导致 id 变化 ✅ 排除 用户明确 id 没变
硬编码 chrome-extension://*url 失效 ✅ 排除 同上
SW reload 时 main tab JS context 失效,没人主动恢复 真因 见下

src/entrypoints/background/index.ts:277installListener 只处理 reason === 'install'(拉欢迎页),对 update / chrome_update(reload 真实触发的 reason)没有任何动作。SW 自己已经 ready 却不主动恢复 tab,纯靠 ext-context-guard.ts 页面侧 5s 周期被动检测,留下空窗期。

空窗期内:用户按浏览器刷新 → Chrome 拒绝 fetch 处于 invalidated 状态的 chrome-extension:// 资源 → 替换 tab 为 newtab。

修复

background/index.ts installListener 加 update 分支:

if (reason === 'update' || reason === 'chrome_update') {
  restoreExtensionTabs();   // 主动 reload 所有 chrome-extension://<myId>/ tab
}

新加 restoreExtensionTabs():query 所有 tab,对 url 以 chrome-extension://${browser.runtime.id}/ 开头的逐个 browser.tabs.reload()

双轨保险

触发源 时机 角色
bg.onInstalled('update') SW 自己 ready 那一刻 主动(v0.10.34)
ext-context-guard.ts 5 秒后周期检测 + window.error 被动兜底(v0.10.29)

主动机制先到 → 几乎无空窗;被动兜底防 onInstalled 偶发不触发。

改动文件

文件 改了什么
src/entrypoints/background/index.ts installListener 加 update 分支 + restoreExtensionTabs()
docs/issues/0024-ext-reload-tab-becomes-newtab.md 🆕 issue 归档
docs/raw/feedback/2026-05-26-ext-reload-tab-becomes-newtab.md 🆕 原话
package.json 0.10.33 → 0.10.34

验证

  • pnpm build 通过(待补时间)
  • 📋 浏览器实测路径:
  • 重载扩展 → 打开 main.html
  • 进入任意页面(如数据 → 邮箱列表)
  • chrome://extensions 点🔄 reload
  • 预期:main.html tab 自动刷新,停留在 main.html
  • 不应:变 newtab / 空白

注意点

  • ⚠️ onInstalled 不要只处理 install —— update / chrome_update 也要主动恢复扩展自身 tab
  • ⚠️ SW 主动 > 页面被动 —— SW 启动那刻就知道自己 ready,比页面侧 5s 周期早
  • ⚠️ 不假设 ext-context-guard 万能 —— 它是兜底,主动机制必须先做
  • 🎓 教训:v0.10.29 写 guard 时只考虑被动,没意识到 SW 这边也应主动出手

与上次诊断的修正

上一轮(v0.10.31 那个工作台截图)我误判"那是另一个项目",给的是抽象的"加 manifest.key"等通用建议。这一轮用户补 id 没变 + 第二张截图验证: 1. 是 Chrome 通用行为,本仓库也会中招(不论 dist-v2 装的是哪个项目的产物) 2. manifest.key 这条路是错的(id 没变) 3. 真因是 SW onInstalled 没处理 update


2026-05-26 v0.10.32 → v0.10.33 修邮箱采集 mailto URL 编码污染(ISSUE-0023)

用户反馈

截图显示邮箱列表里有一条 168 字符的怪邮箱:

%20i%20encountered%20an%20error%20and%20need%20support.%0d%0a%0d%0a966df647f3badc9e28832fdc03580ecb%0d%0a%0d%0a%3a%0d%0adigitalcare@dollargeneral.com

URL 解码后是用户写邮件的"举报错误"模板内容。

根因(三个叠加缺陷)

src/utils/scraper.ts:64 旧版正则:

const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;

# 缺陷 后果
字符类含 % URL 编码 %20 %0a %3a 全被当合法本地部分
量词 + 无上限 贪婪匹配到 64+ 字符(RFC 限 64)
不解 mailto 直接对原始 HTML 跑正则,错过结构化机会

源 HTML 大致:

<a href="mailto:?subject=...&body=i encountered...digitalcare@dollargeneral.com">举报错误</a>
mailto 的 to 字段空,邮箱塞在 body 末尾 → 整段 body + 真邮箱被当一整段邮箱抓走。

修复(三层防御)

Layer 1 — EMAIL_REGEX 收紧: - 移除 %(邮箱用 % 概率 < 百万分之一) - 加 {1,64} 本地部分长度上限(RFC 5321) - 域名分段 ≤ 63 字符(RFC 1035)

Layer 2 — extractMailtoEmails() 结构化解析: - 扫 href="mailto:..." - 只取 ? 之前的 to 字段 - 支持多收件人逗号分隔 - 用 STRICT_RE 整段验证后再加入

Layer 3 — isEmailLikelyBroken() 兜底过滤: - 本地 > 64 → 拒 - 本地含 %xx → 拒 - 含控制字符 / HTML 残留 → 拒 - 域名无点 / 连续点 → 拒 - 加进 filterEmail() + data-view 派生 emails 时也调用(双保险)

存量清洗: - src/utils/email-cleanup.ts:扫全表 → 剔除脏邮箱 → 写回 - src/sections/data/email-cleanup-button.tsx:邮箱 tab 标题右侧按钮 + 结果对话框

改动文件

文件 改了什么
src/utils/scraper.ts EMAIL_REGEX 收紧 + 2 个新 export 函数 + filterEmail 加 Layer 3
src/utils/email-cleanup.ts 🆕 cleanupBrokenEmails() 存量扫描清洗
src/sections/data/email-cleanup-button.tsx 🆕 邮箱 tab 头部清理按钮 + Dialog
src/sections/data/data-view.tsx 邮箱派生过滤 + 按钮挂载
docs/issues/0023-mailto-url-encoded-pollutes-email.md 🆕 issue 归档
docs/raw/feedback/2026-05-26-mailto-pollutes-email-field.md 🆕 原话归档
package.json 0.10.32 → 0.10.33

验证

  • pnpm build 8.35s
  • 📋 浏览器实测(待用户):
  • 邮箱列表 → 168 字符脏邮箱不再显示(运行时 Layer 3 过滤)
  • 点"清理脏邮箱"按钮 → Dialog 显示扫描结果 + 样例
  • 后续抓取 dollargeneral.com 类站点 → 不再写入脏数据

注意点

  • ⚠️ 邮箱正则字符类不要含 % —— 这是本次根因,URL 编码遍地都是
  • ⚠️ 凡是 + * 字符类必须加上限 —— 用 {1,N} 而非 +,避免贪婪跨段
  • ⚠️ 结构化优先于正则:HTML 里 mailto: tel: 是已知结构,先用 href 锚点抽取
  • ⚠️ 写入前过滤 + 渲染时过滤双保险:新规则上线即时生效,存量靠按钮清洗
  • ✅ 同类风险待排查:手机正则是否会误捕 mailto body 中的数字段
  • ✅ 移除 % 后理论上少抓某些合法邮箱(极少数,可接受)

2026-05-26 v0.10.31 → v0.10.32 建立专门的待办聚合体系(docs/_todo.md)

用户反馈

现在有文档专门记录待办吗?哪些需要做但没做的,做好记录,便于后续,也方便 AI 提醒,请检查

诊断

结论:分类层够(specs/issues/raw),但缺一个"打开就看到所有未完事"的聚合视角。

类型 现状 设计上的待办归属
docs/specs/active/ 大需求 — 进行中
docs/specs/parked/ 评估后暂缓
docs/raw/inbox/ 未处理原始想法
docs/raw/ideas/ 灵感素材
docs/issues/0007 recurring 反复出现的坑(潜在待办)
development-log.md 注意点段 散落"后续"字眼 无聚合
代码 TODO/FIXME 0 处

AI 新会话不会主动翻 6 个目录查 status → 看不到 backlog → 用户提的小事容易被遗忘。

改动

  1. scripts/rebuild-docs.py 新增 build_todo():扫所有 frontmatter 的 status,自动生成 docs/_todo.md
  2. specs status ∈ {draft, approved, in-progress, parked}
  3. issues status ∈ {recurring, observing, in-progress}
  4. raw status ∈ {unprocessed} 或缺失
  5. 顶部显示总数;按类型 + 状态分组;末尾给 AI 提示

  6. 新建 docs/rules/todo-registration.md:教用户/AI"想做但不立刻做"的归宿

  7. 决策树(bug? 需要设计? 小想法? 灵感?)
  8. 兜底:没空评估 → 粘到 raw/inbox/,零成本登记
  9. 反模式清单(不要用 dev log、不要用 TaskCreate 记跨会话的事)

  10. CLAUDE.md 加入口

  11. 文档体系表加第 0 行:docs/_todo.md 标"每次会话先扫一眼"
  12. 高频踩坑表加一条:用户说「以后做」→ 走 todo-registration.md

改动文件

文件 改了什么
scripts/rebuild-docs.py + build_todo() 函数 + main() 调用
docs/rules/todo-registration.md 🆕 待办登记决策树
docs/_todo.md 🆕 自动生成的聚合页
CLAUDE.md 文档体系表 + 高频踩坑速查
package.json 0.10.31 → 0.10.32

当前 backlog(验证用)

  • 总数:1 条
  • 来源:issues/0007-settings-double-scrollbar.md (recurring)

后续每次新增 spec/issue/raw 时,跑 pnpm docs:rebuild 即可自动同步聚合页。

注意点

  • ⚠️ 不要在 development-log.md 末尾的"注意点"里写"以后做" —— dev log 是历史不是 backlog,会沉底
  • ⚠️ 不要用 TaskCreate 记跨会话事项 —— 会话关闭就丢
  • ✅ 即使没时间评估,先把原话粘到 raw/inbox/(status: unprocessed),评估后再升级到 spec
  • docs/_todo.md 路径在 docs/ 根(不在 layer 子目录),不参与 INDEX 检查
  • ✅ AI 新会话第 0 步就要扫 _todo.md 头几行总数

2026-05-26 v0.10.30 → v0.10.31 删除 MerchantStatsBanner(与 QuickFilters chip 信息重复)

用户反馈

框内的两个是不是重复了?

截图红框圈了商家列表页两个区域: 1. 上:MerchantStatsBanner — 5 个大卡 2. 下:MerchantQuickFilters — 6 个 chip

诊断 — 信息冗余三层

层级 组件 内容 交互
全局 顶部 DataBar(main-layout) 总商家 / 邮箱 / 网址 / 手机 / 暂无任务 / 今日新增 只读
列表页冗余 MerchantStatsBanner(5 卡) 总商家 / 有邮箱 / 有网址 / 已挖掘 / 待挖掘 只读
列表页过滤 MerchantQuickFilters(6 chip) 全部 / 有邮箱 / 有网址 / 高评分 / 已挖掘 / 待挖掘 可点过滤

StatsBanner 5 项里有 4 项与下方 chip 完全重叠(只多了「总商家」一项,但顶部 DataBar 已有)。 chip 已自带数字徽章 + 可点过滤;StatsBanner 是 read-only 的"装饰版数字"。

结论:删 StatsBanner,留 chip。

改动

src/sections/page/local-data-view.tsx: - 删除 MerchantStatsBanner 的渲染节点(line 588 附近) - 注释掉 import(保留文件,未来差异化视角可复用) - merchantStats state 保留 —— chip 仍需 counts 数据

改动文件

文件 改了什么
src/sections/page/local-data-view.tsx 删 banner 渲染 + 注释 import
docs/raw/feedback/2026-05-26-statsbanner-and-chip-redundant.md 🆕 原话归档
package.json 0.10.30 → 0.10.31

注意点

  • ⚠️ 不要删 merchantStats state:QuickFilters 的 counts 仍依赖它(如果删了 chip 上的数字会归零)
  • ⚠️ 不要删 ./merchant-stats-banner.tsx 文件:留作未来差异化视角(如"邮箱质量分布")的复用候选
  • ✅ 顶部 DataBar 已提供全局视角,列表页不需要再叠一层「装饰版数字」
  • 🎓 教训:v0.10.0 的"三件套"设计是叠加思维,没思考与已有的顶部 KPI 的层次关系 —— 用户截图一指就抓到了

2026-05-26 v0.10.29 → v0.10.30 网络状态点点加光晕 + 呼吸动画

用户反馈

网络状态的点点加上光晕以及放大缩小的效果

改动

src/sections/layout/network-status-bar.tsx 的 8px 圆点按状态加差异化动画:

状态 周期 scale 光晕
ok 2.4s 缓慢 1 → 1.18 绿色柔和(0.55 → 0.35 alpha)
slow 1.6s 中速 1 → 1.25 黄色(0.6 → 0.45 alpha)
bad 1.0s 快速 1 → 1.3 红色(0.7 → 0.55 alpha)
checking 1.2s 1 → 0.85 缩 + opacity 无(保留原动画)

技术细节: - 光晕用 box-shadow: 0 0 <blur>px <spread>px <color-alpha> - 放大缩小用 transform: scale(...) - 两者在同一 keyframes 内同步,给「呼吸」的整体感 - 越警示状态周期越快 + scale 越大 + 光晕越浓 — 视觉权重 ok < slow < bad

改动文件

文件 改了什么
src/sections/layout/network-status-bar.tsx 4 个状态分别 keyframes
docs/raw/feedback/2026-05-26-network-status-dot-glow-animation.md 🆕 原话

验证

  • pnpm docs:check ✅ 66 文档 0 error
  • pnpm build ✅ 8.94s
  • 浏览器实测:标题旁绿点应有缓慢呼吸 + 微光晕

小心思

ok 状态的呼吸有「系统在线感」— 比静态点更让人觉得「这玩意还活着」。 slow / bad 加快频率 + 加强光晕,给用户更强的警示视觉权重。


2026-05-26 v0.10.28 → v0.10.29 修 Extension context invalidated(ISSUE-0022)

用户反馈

错误日志面板截图:

Error: Extension context invalidated.
上下文: main.html
堆栈: chunks/data-DvrcM699.js:48 (React internals)

根因

Chrome MV3 经典痛点。扩展 reload / 更新 → SW 拿到新 context ID,老的 main.html / popup tab 还开着,里面持有老 chrome.runtime 的 React 代码继续调 API → 全部抛 Extension context invalidated

我们 v0.10.27 推 github 后频繁 reload 扩展正好暴露这个。

修复

写全局 guard src/utils/ext-context-guard.ts,4 个 entry 启动时安装:

installContextGuard():
  ├─ window.addEventListener('error')               同步错误
  ├─ window.addEventListener('unhandledrejection')  Promise rejection
  └─ setInterval(检查 chrome.runtime.id, 5000)      周期兜底catch 吞错时
         检测到 "Extension context invalidated"
  showOverlay() 全屏扩展已更新正在刷新…」
         1.5s
  location.reload()

Overlay 用纯 DOM 拼装(不依赖 MUI),因为 React 此时可能已在异常状态。

改动文件

文件 改了什么
src/utils/ext-context-guard.ts 🆕 全局 guard
src/entrypoints/{main,popup,settings,results}/main.tsx 都加 installContextGuard()
docs/issues/0022-extension-context-invalidated.md 🆕 归档
docs/raw/feedback/2026-05-26-extension-context-invalidated.md 🆕 原话

验证

  • pnpm docs:check ✅ 65 文档 0 error
  • pnpm build ✅ 8.36s
  • 手动验证:chrome://extensions 点扩展「刷新」 → 老 tab 应显示「扩展已更新,正在刷新…」+ 1.5s 自动 reload

注意点

  • 5 秒周期是双保险:某层 .catch() 吞了错时 error listener 抓不到,靠周期 check
  • Overlay 用纯 DOM:React 可能已挂,不能依赖 MUI
  • triggered 状态防重复:避免短时间多次错误触发多个 overlay
  • 4 entry 全装:main / popup / settings / results 都暴露在风险下

2026-05-26 v0.10.27 → v0.10.28 Toast 位置 + 网络状态搬家

用户反馈

  1. 优化提醒,在界面中间偏上可好?其他的是不是要同步进行?
  2. 网络状态放右上角"谷歌地图采集"后边???

附 2 张截图:登录成功 toast 在左下角被「创建任务」按钮压住;网络状态点在侧栏底部不显眼。

改动

1. Toast 位置 bottom-lefttop-center

src/components/sonner/toaster.tsx 单一配置点:

<Toaster position="top-center" ... />

回答用户「其它要同步吗」= 是。所有 toast(登录成功 / 保存成功 / 错误等)共用 CustomToaster一处改全局生效

2. NetworkStatusBar 搬家

main-layout.tsx:465(次级导航行)→ 移到 main-layout.tsx:288(顶部「谷歌地图采集」标题行右侧):

<Stack direction="row" alignItems="center" spacing={1} sx={{ p: 2 }}>
  <LaifaxinIcon />
  <Typography variant="subtitle2" noWrap sx={{ flex: 1 }}>谷歌地图采集</Typography>
  <NetworkStatusBar />   {/* v0.10.28 ← 从底部搬来 */}
</Stack>

flex: 1 推到行尾右对齐。次级导航行(日志 / 设置)现在更宽松。

改动文件

文件 改了什么
src/components/sonner/toaster.tsx position bottom-left → top-center
src/sections/layout/main-layout.tsx NetworkStatusBar 从底部 line 465 → 标题行 line 294
docs/raw/feedback/2026-05-26-toast-and-network-status-position.md 🆕 原话归档

验证

  • pnpm docs:check ✅ 63 文档 0 error
  • pnpm build ✅ 8.47s
  • pre-commit hook 自动跑 docs:check 通过 ✅

小教训 / 工作流验证

  • pre-commit hook 真的工作:本次 raw 文件刚加没在 INDEX,docs:check 立即报错 → 跑 rebuild 后才能 commit。enforcement 工作正常
  • 「一处改全局」回报CustomToaster 单点配置让「其它通知是否同步」变成免回答 — 用户的所有 toast 都自然跟着换位置

2026-05-26 v0.10.26 → v0.10.27 清 3 个 observing issues

起因

v0.10.26 推 github 完成后,docs/_overview.md 列出 3 个 observing issues。逐个清理。

改动

ISSUE-0017 Popup loading timeout 与 uid race

修法useRef<timeoutId> 持有,第二个 effect(if (uid) setAuthState('logged-in'))瞬间 clearTimeout(staleTimeoutRef.current) —— 消除 race 闪现 stale 的可能。

const staleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => { ... staleTimeoutRef.current = setTimeout(...) ... }, []);

useEffect(() => {
  if (uid) {
    if (staleTimeoutRef.current) clearTimeout(staleTimeoutRef.current);
    setAuthState('logged-in');
  }
}, [uid]);

ISSUE-0018 hasEmailOnly 50k 上限静默截断

修法:apiLocalDataList 加 truncated: boolean + scanLimit: number 返回字段。UI 端 (local-data-view) 接收后 setState,渲染 Alert banner:

⚠️ 数据量超过 50,000 行,仅显示前 50,000 行中含邮箱的商家。
   建议用「按任务筛选」或其他条件缩小范围以查看全部。

Banner 只在 quickFilter === 'has-email'truncated === true 时显示,不打扰其它场景。常量提到 HAS_EMAIL_SCAN_LIMIT = 50_000 让代码清晰。

ISSUE-0019 FormProvider 强制 flex column 的潜在副作用

修法:把 flex 改成 opt-in prop fillParent?: boolean,默认 false(block 布局,无副作用)。

type Props = {
  fillParent?: boolean;  // v0.10.27 显式 opt-in
};
const formStyle = fillParent
  ? { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }
  : {};

settings-view 不需要传 fillParent — 因为 v0.10.21 已经让父级 main-layout 的 Box 负责滚动,settings 内不需要 flex fill 父级。

改动文件

文件 改了什么
src/sections/popup/index.tsx useRef staleTimeoutRef + uid effect clear timeout
src/api/search.ts apiLocalDataList 返回 truncated + scanLimit;常量 HAS_EMAIL_SCAN_LIMIT
src/sections/page/local-data-view.tsx 接收 truncated + 渲染 Alert banner;import Alert
src/components/hook-form/form-provider.tsx flex 改 opt-in(fillParent prop,默认 false)
docs/issues/0017/0018/0019 status: observing → fixed

验证

  • pnpm docs:check ✅ 0 error
  • pnpm build ✅ 8.19s
  • 3 个 issue 全部 ✅ fixed

验收

维度 v0.10.26 v0.10.27
未解决 issues 4 个(1 recurring + 3 observing) 1 个(0007 recurring,是历史标记非新问题)
技术债 极低

注意点

  • observing → fixed 的判定:observing 意思「已记录但不修」。这次清完,相当于「主动决定动手」。fixed 后保留文件做历史记录
  • opt-in 是好模式:v0.10.18 加 flex 时是「破坏性默认」(影响所有调用者);v0.10.27 改 opt-in 后是「无副作用增强」(不传 prop 行为不变)
  • banner 不打扰用户:只在 has-email chip 触发且数据真截断时显示,其它路径不影响

2026-05-26 v0.10.25 → v0.10.26 [SPEC-003 阶段二] 主题 hub + pre-commit hook

起因

用户验收:「检查无误再进行下一步,评分要 9+」

我跑诚实诊断,5/6 项数据通过但 G. 主题散布 没改善 — watchdog 仍散 14 文件无 hub。诚实给出 8.5/9.5/9。

用户:「同时补 hub + 加 pre-commit hook」

改动

1. 主题 hub 自动生成(rebuild-docs.py 新增 build_topics)

预定义 7 个主题:watchdog / scheduling / auth / settings / ui / documentation / tooling

每个主题按 tags + 文件名 + body 关键词 聚合,生成 docs/_topics/<slug>.md

docs/_topics/
├── INDEX.md             ← 主题入口
├── watchdog.md          ← 9 文档(1 wiki + 2 specs + 2 issues + 4 raw)
├── scheduling.md        ← 14 文档
├── auth.md              ← 9 文档
├── settings.md          ← 18 文档
├── ui.md                ← 14 文档
├── documentation.md     ← 8 文档
└── tooling.md           ← 7 文档

每个 hub 含「给 AI / 新协作者的建议阅读顺序」:wiki → specs → issues → raw。

2. pre-commit hook

pnpm setup:hooks    # 一次性安装

scripts/hooks/pre-commit: - 检测 docs/ 或 scripts/(check|rebuild|migrate) 有改动时跑 docs:check - 0 error 才放行 commit - 1 error 阻止 + 提示「git commit --no-verify 跳过」

scripts/setup-hooks.sh: - 检测 .git/ 存在 - 用 symlink(跟 scripts/hooks/pre-commit 同步更新) - Windows 不支持 symlink 时退化为 cp

验收(用户的 9+ 全部)

维度 v0.10.22 v0.10.25 v0.10.26
快速检索 🟡 6 🟢 8.5 🟢 9.5 ← 7 主题 hub 聚合
沉淀迭代 🟢 8 🟢 9.5 🟢 9.5
可持续维护 🔴 4 🟢 9 🟢 10 ← + pre-commit

全部 9+ ✅

改动文件

文件 改了什么
scripts/rebuild-docs.py 加 TOPICS 定义 + build_topics + build_topics_index + main() 调
scripts/hooks/pre-commit 🆕 docs 改动时自动跑 docs:check
scripts/setup-hooks.sh 🆕 一键安装 hook(symlink / cp 兼容)
package.json setup:hooks
docs/_topics/INDEX.md 🆕 主题入口
docs/_topics/*.md × 7 🆕 主题 hub auto-gen
docs/specs/done/SPEC-003-docs-governance-automation.md 加阶段二决策记录
development-log.md 本条

验证

  • pnpm docs:check ✅ 62 文档 0 error 0 warning
  • pnpm docs:rebuild ✅ 5 INDEX + 7 hub + overview 全 stable
  • pnpm build ✅ 7.61s

测试 pre-commit

由于尚未 git init,setup:hooks 会提示「请先 git init」。等用户首次推 github 时一并装。

一个反思

用户两次验收(「再次检查」「评分 9+」)暴露了我的一个习惯:自评偏高。我之前 v0.10.25 自评 9/9/9,跑数据才发现 8.5/9.5/9。教训:任何「我觉得修好了」必须用数据验证,与 ISSUE-0013 / 0020 同根。

「修了 bug 不等于修好了」+「自评要用数据」 = 两个最反复出现的元教训。


2026-05-26 v0.10.24 → v0.10.25 [SPEC-003] 文档治理自动化

起因

用户问:「分析已有的文档结构,能实现快速检索和不断迭代改进吗?知识不断沉淀?可持续维护?」

我跑了诊断脚本,找出 9 个真问题。评分: - 快速检索 🟡 6/10(INDEX 不完整 + 无主题入口) - 沉淀迭代 🟢 8/10(漏斗工作正常) - 可持续维护 🔴 4/10 ← 核心症结:无 enforcement

改动

1. 写 3 个 Python 脚本(scripts/)

脚本 命令 作用
check-docs.py pnpm docs:check 校验 frontmatter / INDEX 完整性 / 断链 / 重复信息。报 ERROR 退出码 1,WARN 退出码 0
rebuild-docs.py pnpm docs:rebuild 自动重建 5 个 INDEX 的 <!-- AUTO-START --> 区 + 生成 docs/_overview.md
migrate-v10-25.py 一次性 修存量:spec type / issue 头部清重复 / 补 description / 修占位 wikilink

2. INDEX 自动维护

每个 INDEX.md 加 <!-- AUTO-START: index-table --> ... <!-- AUTO-END --> marker。脚本只动这区,保留头部手写说明。 - raw INDEX 现在 6 条 vs 之前 2 条 - issues INDEX 现在按状态分组 4 类 - specs INDEX 自动按 draft/in-progress/done/parked 分组

3. 新增 docs/_overview.md

一页纸快照,AI / 新协作者读它就有 80% 上下文: - 当前版本 + 最近 5 次改动(自动从开发日志提取) - 五层文档体系说明 + 各层 INDEX 链接 - 核心 wiki 列表(含 description) - 进行中的 specs / 未解决的 issues

4. 修存量

类别 数量 说明
spec 加 type: spec 2 个(SPEC-001 / SPEC-002) 之前手写时漏
清 issue 头部重复 21 个 > **状态** 等 5 个字段(已在 frontmatter)
补 description 25 个 12 通过自动提取 + 12 手动补 + 1 SPEC
修真断链 3 个 SPEC-001 / github-推送 的 [[wikilink]] 占位

5. 元规则更新

docs/rules/00-meta-rule.md 加「提交前必做」一节,强制 docs:rebuild + docs:check 流程。

验证

  • pnpm docs:check ✅ 0 error / 0 warning(完美通过)
  • pnpm docs:rebuild ✅ 5 个 INDEX + overview 重建
  • pnpm build ✅ 8.16s

改动文件

文件 改了什么
scripts/docs_lib.py 🆕 共享库:frontmatter 解析、文件分类、wikilink 提取
scripts/check-docs.py 🆕 校验脚本(6 类规则)
scripts/rebuild-docs.py 🆕 自动重建 INDEX + overview
scripts/migrate-v10-25.py 🆕 一次性迁移(修存量)
package.json docs:check / docs:rebuild
docs/_overview.md 🆕 一页纸快照(auto-gen)
docs/rules/00-meta-rule.md 加「提交前必做」+ rebuild/check 流程
docs/issues/* × 21 清重复状态行
docs/specs/* × 3 补 type + 修占位
docs/* × 25 补 description
docs/specs/done/SPEC-003-docs-governance-automation.md 🆕 完整 PRD
docs/raw/conversations/2026-05-26-docs-system-audit.md 🆕 诊断对话原话

注意点 / 教训

  • 「错误一次发生立即可见」是关键:现在 INDEX 漏 / 断链 / frontmatter 不全跑 pnpm docs:check 立马报。之前靠人工巡检 = 错误必然累积
  • HTML 注释做 marker 优于全自动 INDEX —— 保留头部手写说明(流程图、约定),只自动表格
  • migrate 脚本要保守:我的 remove_placeholder_wikilinks 规则太宽,把讲解中的 [[wikilink]] 也替换了,要手动 sed 恢复。教训:正则匹配占位时要 narrow,宁可漏不可错
  • frontmatter 是 source of truth:去掉了 issue 头部 21 个重复引用块,状态信息只有 frontmatter 一处

体检报告(v0.10.25 后)

  • 快速检索 🟢 9/10(INDEX 自动 + _overview 一页纸入口)
  • 沉淀迭代 🟢 9/10(漏斗 + enforcement)
  • 可持续维护 🟢 9/10(pnpm docs:check 报错

下一步候选(SPEC-004 暂缓):pre-commit hook / GitHub Action / 主题 hub 自动生成


2026-05-26 v0.10.23 → v0.10.24 自动恢复默认调激进(10s / 无限)

用户反馈

"10s 后继续 无限次 除非人工点停止"

分析

v0.10.23 刚实现 SPEC-002 时给的默认值(60s / 3 次)偏保守。用户的真实场景是全无人值守,任何自动停都是打扰。

改动

字段 v0.10.23 v0.10.24
watchdogAutoResumeCooldownSec 60 10
watchdogAutoResumeMaxCycles 3 9999(≈ 无限)

逃生机制(已存在不变):用户主动点「停止」→ stop-enginesyncWatchdogAlarm 清 alarm,watchdog 立刻停。

顺手修:syncWatchdogAlarm 关闭路径加清 local:watchdogAutoResumeCycles,让用户「停-开」循环时计数干净。

UI helper 文案改成:

💡 v0.10.24 默认:10s 冷却 + 9999 次上限 ≈ 无限自动恢复。 逃生机制:任何时候点「停止」按钮 → watchdog 立即停。 如果你想要保守模式(防持续故障卡死电脑),把次数上限调小(如 3-5)。

注意

  • 老用户升级:storage 里已有的 60/3 不会自动变成 10/9999(settings 是用户偏好,不强制覆盖)。要享受新默认 → 点 settings 顶部「重置默认」按钮,或手动改。
  • 9999 的选择:yup schema 需要数字范围,不能用 0/-1 表示无限。9999 × 5min 周期 ≈ 35 天,对绝大多数场景等价无限。
  • 持续故障风险:用户明确选择了「无限重试」 → 风险由用户承担。UI helper 提示了保守模式选项。

改动文件

文件 改了什么
src/utils/storage-data.ts DefaultSettings: 60→10, 3→9999
src/utils/scrape-watchdog.ts syncWatchdogAlarm 关闭路径加清 AUTO_RESUME_CYCLES_KEY
src/sections/settings/view/settings-view.tsx yup max 99999;helper 文案改
docs/wiki/tab-lifecycle-and-watchdog.md 默认值更新 + 版本里程碑
docs/wiki/settings-field-semantics.md 新默认值
docs/specs/done/SPEC-002-watchdog-auto-resume.md 加 v0.10.24 决策记录
docs/raw/feedback/2026-05-26-auto-resume-default-more-aggressive.md 🆕 原话归档

验证

  • pnpm build ✅ 7.95s

2026-05-26 v0.10.22 → v0.10.23 [SPEC-002] Watchdog 核弹后自动恢复

用户反馈

"这个自动暂停了地图的抓取了,要点继续才行,我希望能自动继续"

(附通知截图:「健康巡检:异常累积,已自动重启 — 检测到 3 次连续异常」)

这是首个走完整 spec 流程的需求 — 演示新文档体系

raw/feedback/2026-05-26-watchdog-auto-resume-after-restart.md    ← 原话归档
specs/done/SPEC-002-watchdog-auto-resume.md              ← 完整 PRD(数据模型 + UI + 任务 + 决策)
   ↓ 实现
更新代码 6 个文件 + wiki 2 个 + INDEX

设计权衡

用户诉求:自动恢复(隔夜跑场景必须) 矛盾点:持续故障下无限自动恢复会卡死电脑(watchdog 初衷违背)

折衷:默认自动恢复 + 3 次上限保护 + 60s 冷却

改动

settings 加 3 个字段

watchdogAutoResume: boolean = true;          // 默认开
watchdogAutoResumeCooldownSec: number = 60;  // 默认 60 秒
watchdogAutoResumeMaxCycles: number = 3;     // 默认 3 次

batch-controller.nukeAllSchedulerState 加 opts

async function nukeAllSchedulerState(opts?: { keepTasksRunning?: boolean })
// keepTasksRunning=true → 不降级 task status,pumpTasks 能直接接管
// keepTasksRunning=false → 老行为,降级 paused 等用户手动

scrape-watchdog 核弹分支重写

读 cfg.watchdogAutoResume + storage.cycles
shouldAutoResume = autoResume && cycles < maxCycles

if shouldAutoResume:
  nukeAllSchedulerState({ keepTasksRunning: true })
  cycles++ 持久化
  通知「自动恢复中 N/M」(priority 1,温和)
  setTimeout(cooldown) → pumpTasks + manageQueue
else:
  nukeAllSchedulerState({ keepTasksRunning: false })  ← 老行为
  cycles = 0
  通知「自动恢复达上限」(priority 2,强烈)

每次巡检干净(orphans+zombies=0)→ cycles = 0  ← 避免长期累积误触发

UI 加自动恢复子区

设置 → 高级·健康巡检

🛡️ 看门狗
   ...已有字段...

   ── 自动恢复(v0.10.23 新加) ─────────
   [Switch] 重启后自动继续抓取(推荐开启)
   冷却秒数: 60     自动恢复次数上限: 3
   💡 巡检干净时计数自动清零,达上限才停下要求人工

改动文件

文件 改了什么
src/utils/storage-data.ts 加 3 字段 + DefaultSettings
src/entrypoints/background/batch-controller.ts nukeAllSchedulerState 加 opts.keepTasksRunning
src/utils/scrape-watchdog.ts 核弹分支重写:autoResume / cycles / cooldown + 巡检干净清 cycles
src/utils/engine-manager.ts handleScrapeFailure 退场时清 cycles
src/sections/settings/view/settings-view.tsx 加自动恢复 3 字段 UI + yup schema
docs/wiki/tab-lifecycle-and-watchdog.md 自动恢复设计 + 字段表 + 版本里程碑
docs/wiki/settings-field-semantics.md 新字段
docs/specs/INDEX.md SPEC-002 加到 done
docs/raw/feedback/2026-05-26-watchdog-auto-resume-after-restart.md 🆕 原话归档
docs/specs/done/SPEC-002-watchdog-auto-resume.md 🆕 完整 PRD

验证

  • pnpm build ✅ 7.35s
  • 用户实测:隔夜跑应能自动恢复(连续 3 次失败才停下)

落档(演示新工作流)

完整跑了一遍 raw → spec → 实现 → wiki + issues + INDEX 流程:

  1. docs/raw/feedback/ 粘贴用户原话 + 截图描述
  2. docs/specs/done/SPEC-002 完整 PRD(含决策记录章节)
  3. ✅ 实现 5 个源码文件
  4. ✅ 更新 2 个 wiki + 2 个 INDEX
  5. ✅ 开发日志 + 版本号

这是新文档体系(v0.10.22 建)的第一次完整运用 — 印证了多人协作场景下后端打开 spec 就知道做什么

注意点

  • 持续性故障的保护:3 次上限是关键,否则网络全断时会反复关 tab + 重启,体验更差
  • clean-run 清零:如果巡检干净时不清 cycles,长期累积会误触发人工模式(比如几天后突然不自动恢复)
  • pumpTasks 而不是只 manageQueue:地图抓取(batch-controller)和网站抓取(engine-manager)是两套,都要 pump

2026-05-26 v0.10.21 → v0.10.22 状态 chip 靠右对齐(ISSUE-0021)

用户反馈

"状态:已完善 参差不齐,建议靠右"

改动

RenderName(v0.10.0 新独立列渲染器)当前布局:

[商家名]
[分类]·[Divider]·[✅ 已完善]   ← chip 跟在分类后,分类长度不一 → chip 参差不齐

修法:分类 flex: 1 占余空间,chip 自然推到行尾右对齐 + 去 Divider(远距离不需要):

<Stack direction="row" alignItems="center" sx={{ minWidth: 0, gap: 0.5 }}>
  {category && (
    <Typography noWrap sx={{ flex: 1, minWidth: 0 }}>{category}</Typography>
  )}
  {!category && <Box sx={{ flex: 1 }} />}
  <StatusChip status={scrape_status} />
</Stack>

改动文件

文件 改了什么
src/sections/page/cell-renderers.tsx RenderName 的 chip 行用 flex 推右

验证

  • pnpm build ✅ 8.27s
  • 所有行的状态 chip 应对齐到「商家」列右侧

落档


2026-05-26 v0.10.20 → v0.10.21 真修设置页双滚动条(ISSUE-0007 反复出现)

用户反馈

"修复截图右侧两个滚动条的问题"(第二次报)

这是 ISSUE-0007 的二次出现

v0.10.18 我以为修了,写了 ISSUE-0007 ✅ 已修。但实际修错了层

v0.10.18 错在哪

只看了 settings-view.tsx 内部的 Card/CardContent overflow,没去查父级 main-layout 是否在滚动。 当时加的「Card height=windowHeight + 自己 flex+overflow」恰恰是创造了双滚动的两侧之一。

真根因(v0.10.21 才发现)

main-layout.tsx:483
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto' }}>  ← 父级已经滚动
  {renderPage()}  ← <SettingsView />
</Box>

settings-view 又设:

<Card sx={{ height: windowHeight, display: 'flex', ...}}>  // 绝对高度超出父 Box
  <CardContent sx={{ overflowY: 'auto', flex: 1 }}>  // 内部又一层 overflow

= 父 Box 滚动(Card 超出)+ CardContent 内部滚动 = 两个滚动条

修复(v0.10.21)

让 settings-view 内部完全不要 overflow / 绝对高度,让父级(main-layout 的 Box)独自负责滚动:

// v0.10.21
<Card sx={{ width: 1 }}>  // 高度自适应
  <Box sx={{ p: 2.5 }}>...</Box>  // 去 overflow
  <CardContent sx={{ pt: 0 }}>...</CardContent>  // 去 overflow
</Card>

独立窗口 settings.html(EmptyLayout 内)路径:浏览器 body 自己滚动 → 同样单条。两种用法都对。

改动文件

文件 改了什么
src/sections/settings/view/settings-view.tsx Card 移除 height/flex column;CardContent + 云端 Box 移除 overflow/flex

验证

  • pnpm build ✅ 7.98s
  • 浏览器实测:设置页右侧只有 1 条滚动条 ✅

教训(已写到 更新规则.md 第七章作为新铁律)

  • 铁律 15:修嵌套滚动 bug 必须查父级链。每一层父级的 overflow/height 都要看清楚,不能只看眼前的组件
  • 铁律 16:「让父级负责滚动」是更稳的模式。子组件高度自适应 → 不会与未来的父级 layout 冲突
  • 「修了 bug」≠「修好了」第三次教训:ISSUE-0007 修过一次没修透 → ISSUE-0013(列宽 useMemo 形同虚设)→ ISSUE-0020(双滚动条二次修)。实测复现这个习惯要嵌入工作流

文档落档

  • 新建 ISSUE-0020 — 详细记录两次修复的差异 + 真根因 + 教训
  • ISSUE-0007 状态改 🔄 反复,链接到 0020
  • docs/issues/INDEX.md 全更新

2026-05-26 v0.10.19 → v0.10.20 代码审查后续修复(8 个 issue 归档 + 6 个修复)

起因

"请检查所有的潜在问题并进行修复"

Spawn 了 general-purpose agent 做 v0.10.15~v0.10.19 这批改动的深度代码审查,找出 15 个潜在问题。按严重度排序,本次修了影响真实用户的 6 个,剩 3 个 ⚠️ 观察项 + 6 个 🟢 轻微项记录在 issues 不修。

修了什么(v0.10.20)

🔴 严重 — 必修

Issue 文件 修法
ISSUE-0012 taskId+hasEmailOnly 分页错乱 api/search.ts taskId 路径也走「全拉 + JS filter + slice」,不能「先分页再过滤」
ISSUE-0013 v0.10.19 列宽 fix 没修到根 client-data-table.tsx useMemo deps 把 defaultColumns 改成字符串签名 defaultColumns.map(c => c.field).join(',')

🟡 中等 — 也修了

Issue 文件 修法
ISSUE-0014 列宽/列序多账号污染 use-table-columns-config.tsx 加 uid 前缀;一次性迁移老 key
ISSUE-0015 watchdog 三连 scrape-watchdog.ts + batch-controller.ts + engine-manager.ts (a) mutex 防并发;(b) nuke 真关 tab;(c) handleScrapeFailure 清 consecutive
ISSUE-0016 RatingStars 异常输入 merchant-detail-drawer.tsx clamp [0,5] + Number 转换 + 0 隐藏组件

⚠️ 观察项(已归档,暂不修)

Issue 不修的理由
ISSUE-0017 popup 2s timeout 与 uid race 闪现 < 16ms,最终状态正确,修法复杂度不值
ISSUE-0018 50k 上限 实际数据未到,到时再做分批 + UI 提示
ISSUE-0019 form-provider flex 副作用 未发现实际 bug,列入观察清单

关键代码片段

#1 修法

if (taskId) {
  if (hasEmailOnly) {
    const big = await getListByTaskId(taskId, 1, 50_000, sort, keyword);
    const filtered = (big?.list || []).filter(r => Array.isArray(r.emails) && r.emails.length > 0);
    const start = (current - 1) * pageSize;
    return { ...filtered.slice(start, start + pageSize), total: filtered.length, ... };
  }
  const data = await getListByTaskId(taskId, current, pageSize, sort, keyword);
  return { success: true, data };
}

#2 修法

const fieldsSignature = defaultColumns.map((c) => c.field).join(',');
const memoColumns = useMemo(() => { ... }, [columnsList, columnsWidth, fieldsSignature]);

#4 修法

const STORAGE_KEY = `${uid}:table:columns:width:${tableName}`;
const LEGACY_KEY = `table:columns:width:${tableName}`;
useEffect(() => { migrateLegacyKey(LEGACY_KEY, STORAGE_KEY); }, []);

#5 修法(watchdog mutex)

let isWatchdogRunning = false;
export async function runWatchdog() {
  if (isWatchdogRunning) return stats;
  isWatchdogRunning = true;
  try { ... } finally { isWatchdogRunning = false; }
}

#7 修法(nuke 真关 tab)

export async function nukeAllSchedulerState(): Promise<void> {  // 改 async
  const tabIds = Array.from(activeTabs.keys());
  for (const tabId of tabIds) {
    const ab = activeTabs.get(tabId);
    if (ab?.timer) clearTimeout(ab.timer);
    try { await browser.tabs.remove(tabId); } catch (e) { /* ignore */ }
  }
  activeTabs.clear();
  // ...
}

改动文件

文件 改了什么
src/api/search.ts apiLocalDataList 的 taskId 分支重写 hasEmailOnly 路径
src/components/table/client-data-table.tsx useMemo deps 用字符串签名
src/hooks/cache/use-table-columns-config.tsx 列宽/列序加 uid 前缀 + 一次性迁移老 key
src/utils/scrape-watchdog.ts mutex + await nukeAllSchedulerState
src/entrypoints/background/batch-controller.ts nukeAllSchedulerState 改 async 真关 tab
src/utils/engine-manager.ts handleScrapeFailure 清 watchdogConsecutive
src/sections/page/merchant-detail-drawer.tsx RatingStars clamp + Number 转换

验证

  • pnpm build ✅ 8.54s
  • pnpm compile ✅ 0 新错误

注意点 / 落档

  • 「修完即审」是好习惯:v0.10.18/19 几个看似修完的 bug 在审查中被发现没修到根(#2/#13)或漏了分支(#1/#12)。代码审查与修复必须分开做。
  • agent 做 code review 性价比极高:1 次 agent 调用找出 15 个问题,比我自己慢慢扫节省大量 context
  • useMemo deps 含 object/array 是反模式:如果调用方传不稳定引用,等于没 memo。字符串签名 (arr.map(...).join(',')) 是一种轻量稳定 key 模式
  • 「修了 bug」≠「真修好了」:v0.10.19 的 ISSUE-0010 fix 直到 agent 审才发现没真生效。教训:写 fix 后要回到浏览器实际复现一遍
  • observe ≠ ignore:3 个 ⚠️ 观察项不修但归档了,未来回看可以快速定位

文档更新

  • 8 个新 issue 归档(ISSUE-0012~0019)
  • INDEX.md 全更新状态

2026-05-26 v0.10.18 → v0.10.19 数据列表批 2:列宽持久化 + 详情 Drawer 美化

用户反馈(v0.10.18 批 1 同一批问题的剩 2 个)

  • "字段拖动宽度会被自动重置"(ISSUE-0010)
  • "美化下详情页面"(ISSUE-0011)

ISSUE-0010:列宽持久化(双 bug)

实际查源码发现 2 个 bug 互相放大:

Bug A:stale closure

hooks/cache/use-table-columns-config.tsx:21

const updateWidth = (field, width) => {
  setColumnsWidth({ ...columnsWidth, [field]: width });  // ← closure 旧值
};

闭包捕获上一次 render 的 columnsWidth。React 18 batch 下连续拖动用旧值覆盖前次写入。

:functional setState

setColumnsWidth((prev) => ({ ...(prev || {}), [field]: width }));

Bug B:columns 数组引用每次新建

components/table/client-data-table.tsx:131renderColumns() 每次 render 返回新数组 → <DataGridPremium columns={renderColumns()} /> 看到 prop 引用变化 → 内部 width state 重置 → 用户拖完立即被覆盖回 prop 值(这个值因 Bug A 又是旧的,结果列宽永远在旧值附近震荡)。

:useMemo 缓存

const memoColumns = useMemo(() => { ... }, [columnsList, columnsWidth, defaultColumns]);
const renderColumns = () => memoColumns;

两个 bug 互相放大 — 单修任一个都不够,必须同修。

ISSUE-0011:详情 Drawer 美化

完整重写 merchant-detail-drawer.tsx

Hero 区(顶部)

  • 渐变背景(primary → warning 浅色)
  • 大头像 72×72,圆角 + 阴影
  • 关闭按钮浮在右上(白底)
  • RatingStars 组件:自定义星条(实星 + 半星 + 空星),比 chip 更直观
  • 状态 chip + 质量分 LinearProgress(条状视化)

Section 改 SectionCard

  • 每段信息独立 — 浅底圆角 + 标题图标 + hint
  • 替代原 <Divider /> 平铺,视觉层次清晰

CopyableField

  • 复制 / 跳转按钮始终可见(不再 hover 才出现 — drawer 里 hover 体验差)
  • 邮箱/网址用 monospace 字体
  • icon 用语义色(邮箱 #2563eb / 电话 #16a34a / 网址 #0891b2 / WhatsApp #25D366)

社媒图标网格

  • 36×36 圆角方块,filled 风格
  • 各品牌颜色 alpha 背景 + hover 浮起 + 1.5 圆角
  • 比之前的 outlined chip 更视觉

位置 Section

  • 加 emoji 国旗(与 v0.10.18 list 一致)
  • 「在 Google Maps 打开」改 outlined 按钮风格

改动文件

文件 改了什么
src/hooks/cache/use-table-columns-config.tsx updateWidth 改 functional setState
src/components/table/client-data-table.tsx renderColumns 用 useMemo + import useMemo
src/sections/page/merchant-detail-drawer.tsx 完整重写:Hero / RatingStars / SectionCard / CopyableField 升级

验证

  • pnpm build ✅ 7.32s
  • pnpm compile ✅ 0 新错误

注意点

  • React closure + 受控 props 双重坑:列宽 bug 是 stale closure 和 columns 引用不稳定两个独立问题叠加。单看任一行代码都不明显,但组合起来用户感觉是"拖完被重置"。教训:修复 UI 状态 bug 前,先在脑里跑一遍数据流:state → render → prop → 内部 state → render → ...
  • useMemo 是稳定 props 引用的兜底:MUI/Mantine/AntD 等组件库都假设 controlled props 引用稳定。每次 render 新建数组/对象 prop 是 React 优化的最大陷阱
  • Drawer 不是 table:交互模式不同,table 用 hover 显示 actions 是为了密度,drawer 用户已聚焦在单个商家详情上,按钮始终可见反而更方便
  • 6 个 issue(ISSUE-0006~0011)全部修完 + 归档:列表区基本可用 + 美观

2026-05-26 v0.10.17 → v0.10.18 数据列表批 1:chip + 双滚动 + 行高 + 国旗

用户反馈(6 个问题一次性提出)

"选有邮箱,但是这边还是所有的,其他是不是也是呢?" "1.设置的右侧两个滑动的?? 2.美化下详情页面 3.商家列表,单个商家的高度继续压缩 4.国旗标识最好是用 svg 5.字段拖动宽度会被自动重置"

按优先级分两批: - 批 1(v0.10.18):chip + 双滚动 + 行高 + 国旗 — 快速修复 + 优化 - 批 2(v0.10.19):列宽持久化 + 详情 Drawer 美化 — 工作量较大

改动(4 个问题)

ISSUE-0006: chip 筛选不生效

根因quickFilterAsFilters('has-email') 留了 TODO 返回空 — jsstore 无法直接查 array.length > 0。chip 视觉变蓝但底层 filter 是空数组,等于没筛。

修复: - api/search.ts apiLocalDataListhasEmailOnly 参数 → 走 JS 端 post-filter(大 limit 拉 50k 行 + JS filter + slice 分页) - local-data-view.tsx 的 dataRun 调用时检测 quickFilter === 'has-email'hasEmailOnly: true - 性能 50k 行 JS filter ≈ 5-10ms,已被 merchant-stats.ts 验证可接受

ISSUE-0007: 设置页双滚动条

根因:Card height=windowHeight 是固定值,CardContent 自己也设 maxHeight + overflowY,但 maxHeight 算式不准 → 内容溢出 → 外层也滚动 = 双滚动条。

修复: - Card 改 display: flex; flexDirection: column - CardContent / 云端 Box 用 flex: 1, minHeight: 0 自动 fill 剩余高度 - FormProvider 内部 <form>display: flex; flexDirection: column; flex: 1 确保 flex 链不断(对非 flex 父级无副作用)

ISSUE-0008: 行高过大

根因:默认 tableDensity='comfortable' → rowHeight=64,留白显著。

修复: - 默认改 'standard'(rowHeight 40) - 三档 rowHeight 全部下调:紧凑 32→28 / 标准 44→40 / 舒适 64→56 - 同屏可见商家 5-6 → 8-9 个

ISSUE-0009: 国旗用 emoji

根因:原文字 chip <Chip label="US"> 不美观。

修复: - 加 countryCodeToFlag(code) helper:用 Unicode 区域指示符(A=U+1F1E6)算 emoji - RenderLocationCompact + RenderLocation 都改用 emoji - 加 fontFamily: '"Segoe UI Emoji", "Apple Color Emoji"' 强制系统 emoji 字体 - 零依赖、零配置文件 — 操作系统自带渲染

改动文件

文件 改了什么
api/search.ts apiLocalDataList 加 hasEmailOnly 参数 + JS post-filter
sections/page/local-data-view.tsx dataRun 传 hasEmailOnly;默认 density 改 standard
sections/page/local-table.tsx rowHeight 三档下调
sections/page/cell-renderers.tsx 加 countryCodeToFlag helper;两个 RenderLocation 改 emoji
sections/settings/view/settings-view.tsx Card/CardContent 改 flex column
components/hook-form/form-provider.tsx form 加 flex column 样式

验证

  • pnpm build ✅ 8.35s
  • pnpm compile ✅ 0 新错误

注意点 / 落档

  • 6 个 issue 全部归档docs/issues/0006-0011 — 4 个 ✅ 已修,2 个 🔧 v0.10.19 修
  • 教训:UI 视觉 + 业务逻辑必须同步(chip 视觉变了但查询没跟 = 用户被骗)
  • 「TODO: 暂留空」是定时炸弹 — 占位代码必须 mark issue + 排期,否则就是 ISSUE-0006 这种"用户看着没问题但不工作"的 silent bug
  • flex 链不能断:嵌套 flex 容器中任何一层用 block / 默认布局,flex 就不传递。修嵌套滚动时记得检查整条链

待继续(v0.10.19)

  • ISSUE-0010: 列宽拖动持久化(onColumnWidthChange → storage → 初始化合并)
  • ISSUE-0011: 详情 Drawer 美化(Hero + Card 分组 + 复制按钮 + 社媒图标网格)

2026-05-26 v0.10.16 → v0.10.17 修主面板登录按钮无反应 + 建 issues 归档体系

用户反馈

"点击登录也没有弹窗。我希望出现过的问题,以及改进方案都要做记录。"

两件事: 1. 修主面板「立即登录」按钮点击无反应的 bug 2. 建立"已知问题与改进方案"归档机制

Bug 根因

src/entrypoints/background/index.ts:152 处理 open-login 消息时调 autoLoginViaCookies(false)

if (!force) {
  if (existingUid && existingToken) {
    return { ok: true, reason: 'already-logged-in' };  // stale 也返回 ok
  }
}

→ storage 有旧 uid+token(已 stale)→ 函数返回 ok → background 跳过 openWindow('login') → 用户点了没反应。

UI 端 useAuthContext 拉 user 失败 → user.uid=undefined → AccountVip 显示「立即登录」,但 storage 端认为已登录 → 认知不一致 + 死循环

与 ISSUE-0002(popup loading 死循环)同源 —— v0.10.16 只修了 popup 那层,没修 background 这层。

改动

Bug 修复(3 个文件)

文件 改动
background/index.ts open-login 处理改 autoLoginViaCookies(true) —— 主动点击必须强制重拉 cookie,绕过 storage 缓存
layouts/common/account-vip.tsx 点登录后 await message,autoLogin 成功则 window.location.reload()
layouts/common/account-info.tsx 同上(头像区登录按钮)

新建文档体系:docs/issues/

5 个核心 issue 归档:

ID 标题 状态 严重度
0001 主面板「立即登录」点击无反应 ✅ v0.10.17 🔴
0002 Popup 启动后 loading 骨架屏永久卡住 ✅ v0.10.16 🔴
0003 长时间运行后浏览器卡死 ✅ v0.10.15 🔴
0004 设置字段命名望文生义 🔄 反复 🟡
0005 Edit 工具中文标点不匹配 ✅ v0.10.13 🟢

每个 issue 文件含: - 📋 头部元数据(状态/首现/修复/严重度/类别) - 🗣️ 用户原话引用块(让未来 AI 一眼对上号) - 🔍 根因分析(含具体源码位置) - 🔧 修复方案(含 diff) - ✅ 如何避免再犯(最重要:提炼成可执行 checklist) - 🔗 相关问题(追踪同源 bug)

三层文档体系成型

docs/wiki/    教科书:系统是什么样
docs/rules/   操作手册:遇到 X 该怎么做
docs/issues/  真实案例:曾经怎么坏过/怎么修的    ← v0.10.17 新增

CLAUDE.md 启动检查清单顺序: 1. docs/rules/00-meta-rule.md(按需建档) 2. docs/issues/INDEX.md(用户反馈 bug 时先查这里 ← 新增 3. docs/rules/INDEX.md 4. docs/wiki/INDEX.md 5. 更新规则.md 第七章 6. development-log.md 最近 3 条

元规则更新:用户首次描述 bug → AI 必须落档到 issues;每次修完 bug → 必须落档。

验证

  • pnpm build ✅ 8.11s
  • pnpm compile ✅ 0 新错误

验证 bug 修复(用户视角)

  1. 清空 storage 或卸载重装:进入登录窗 → 应弹窗(修复前可能不弹)
  2. 登过的、token 过期:主面板显示「立即登录」 → 点击 → 优先尝试 cookie,cookie 失效则弹登录窗
  3. cookie 有效:点击「立即登录」 → 自动登录 → 页面 reload → 显示会员状态

注意点

  • 「主动操作」≠「静默尝试」:用户主动点击的入口(登录/重试/刷新)必须 force=true 绕过缓存,否则就是 ISSUE-0001 的死循环
  • 建立 issues 体系是元改进:单次 bug 修复 + 一次性教训 → 累积起来形成知识库,下次 AI 进来能立即查到类似症状
  • issues 与开发日志的区别
  • 开发日志按版本(v0.10.17 这次改了什么)
  • issues 按问题(ISSUE-0001 从 v0.10.2 出现到 v0.10.17 修复,跨多个版本)
  • 同源问题反复:ISSUE-0001 和 0002 同源(stale token),第一次只修 popup,第二次才修主面板 —— 提醒:修 bug 时要查所有同类调用点,不能只改用户反馈那处

2026-05-26 v0.10.15 → v0.10.16 修 popup auth loading 死循环

用户反馈

"一直卡在这(截图:popup 顶部骨架屏一直转),如何登录呢?如果没检测到 cookie 要能点击登录。"

根因

src/sections/popup/index.tsx 第 78-94 行(v0.10.7 引入的 3 态状态机)有死循环

useEffect(() => {
  storage.getItem('local:uid').then((storedUid) => {
    if (!storedUid) setAuthState('logged-out');
    // else 留在 loading,等 user 数据返回  ← 死等,无超时!
  });
}, []);
useEffect(() => { if (uid) setAuthState('logged-in'); }, [uid]);

症状:storage 里有 uid(之前登录过留的),但 useAuthContext 拉 user 失败(token 过期 / API 挂 / 网络问题)→ uid 永远是 undefined → authState 永远卡在 loading → 用户看到骨架屏永久转 + 没有任何登录入口可点

改动

1. 加第 4 态 stale + 2 秒超时

type AuthState = 'loading' | 'logged-in' | 'logged-out' | 'stale';

useEffect(() => {
  storage.getItem('local:uid').then((storedUid) => {
    if (!storedUid) { setAuthState('logged-out'); return; }
    // 有 storedUid,给 user 2 秒;超时切 stale
    timeoutId = setTimeout(() => {
      setAuthState((s) => (s === 'loading' ? 'stale' : s));
    }, 2000);
  });
}, []);

2. stale 状态显示醒目按钮

[Warning Contained] 会话失效 · 重新登录(橙色实心,区别于纯未登录的 outlined)。

3. loading 状态也提供「手动登录」入口

骨架屏右侧加小按钮 手动登录,2 秒等不了的用户能立即手动操作。

4. 统一 handleLoginClick

不再只发 open-login(fire-and-forget 用户看不到反馈):

1.  try-cookie-loginforce=true 强制重拉 cookie绕过本地缓存
2. 成功  window.location.reload() 重载 popup  状态机重跑  logged-in
3. 失败  open-login  background 开登录窗  window.close() popup
4. 全程 loginBusy 防重入 + 按钮文案反馈

5. 关键修正:force=true

之前 popup 调 open-login → background 走 autoLoginViaCookies(false)。但 force=false 时函数检查 storage 已有 uid 就直接返回 ok —— 但 stale 场景正是 uid 在但 token 过期,必须强制重拉。改成 try-cookie-loginautoLoginViaCookies(true)

验证

  • pnpm build ✅ 8.50s
  • pnpm compile ✅ 0 新错误

注意点 / 落档

  • wiki 新建docs/wiki/popup-auth-state-machine.md — 完整描述 4 态 + 历次踩坑 + 修改这块时要/不要做什么
  • wiki INDEX 更新:加到「运维与稳定性」分类下
  • 铁律:任何"等异步结果再判断"的 loading 状态必须有超时。无超时的 loading = 用户卡死按钮。
  • 2 秒是经验值:太短会在慢网下闪现 stale(误报),太长用户感觉卡。2 秒是体感和容错平衡。
  • stale ≠ logged-out:UI 区分有意义 —— 用户能感知"我之前登过,现在掉线了",而不是"我从未登录"。重新登录路径相同,但心理预期不同。

2026-05-26 v0.10.14 → v0.10.15 看门狗:长跑稳定性兜底

用户反馈

"插件运行时间长,会导致电脑卡死,页面没正常关闭,是不是要加上定期的检查?确保浏览器正常关闭,而不是打开一堆?"

精确诊断 — v0.10.0~v0.10.14 的兜底机制(60s tab 超时、try/finally、tabs.onRemoved、SW 重启时 forceCloseSharedWindow)覆盖不全。漏的 4 个场景: 1. SW 被 Chromium 强杀重启 → 内存 Map 清零 → 浏览器里真 Tab 没人关 2. maybeCloseSharedWindow 只在「窗口 0 tab」才关,但 heartbeat 不断起新 worker → 永远不归 0 3. 60s setTimeout 可能被 Chromium 后台节流跳过 4. engine_heartbeat 只跑业务,无人巡检孤儿

设计

新建独立 scrape_watchdog alarm(不和 1 分钟 heartbeat 抢),5 分钟一次:

runWatchdog():
  1. 列共享窗口里所有 tab vs activeTabs Map → 找孤儿强制关
  2. activeTabs 里 createdAt > 阈值 → 僵尸强制 finalize + close
  3. 窗口 0 http tab + 调度器空 → forceCloseSharedWindow(兜底)
  4. 累积持久化(local:watchdogConsecutive,跨 SW 重启)
  5. consecutive ≥ limit → 核弹重启:
     - forceCloseSharedWindow
     - nukeAllSchedulerState(running 任务降级为 paused,不删)
     - browser.notifications 通知用户
     - 计数清零
  6. 写 page-log(type=watchdog)

改动

文件 改动
storage-data.ts 加 4 字段:watchdogEnabled/Interval/StaleThreshold/ConsecutiveLimit
batch-controller.ts ActiveTab 加 createdAt;新增 export:getActiveTabIds / getZombieTabIds / forceFinalizeTab / nukeAllSchedulerState / getPagingQueueSize
scrape-watchdog.ts 🆕 runWatchdog 核心 + syncWatchdogAlarm
background/index.ts onAlarm 加 scrape_watchdog 分支;start/stop-engine + settings-changed 联动 syncWatchdogAlarm
engine-manager.ts handleScrapeFailure 15 次失败时一并清 watchdog alarm
settings-view.tsx 加「高级·健康巡检」子区(4 字段 + banner)

默认值

  • 周期 5 分钟(保守)
  • 僵尸阈值 5 分钟(远超 60s 正常完成)
  • 重启阈值 3 次(= 15 分钟连续异常才核弹)
  • 全部默认开

验证

  • pnpm build ✅ 8.16s
  • pnpm compile ✅ 0 新错误

注意点

  • 核弹重启把任务降级 paused,不删 — 用户能看到任务停了并选择恢复,体验比"任务消失"好。
  • local:watchdogConsecutive 必须存 storage:SW 重启时内存清零,如果计数器也清零 → 永远到不了 limit → 自动重启失效。
  • watchdog 只动共享窗口里的 tab:用 browser.tabs.query({ windowId: sharedWinId }) 严格限定,不会误关用户的私人 tab。
  • openVerifyTab 不在巡检范围:人机验证 tab 是独立窗口的可见 tab,watchdog 不会动。这是对的(可能正在做验证)。后续如要处理"用户做完验证忘关" → 另开一条 PR。
  • 新 wiki 落档docs/wiki/tab-lifecycle-and-watchdog.md — 完整描述 Tab 生命周期 + 看门狗设计 + 易踩坑。docs/wiki/INDEX.md 加「运维与稳定性」分类。docs/wiki/settings-field-semantics.md 加健康巡检章节。

2026-05-26 v0.10.13 → v0.10.14 修正 4 条概念误导(实开 vs 任务数 / 网站抓取共享队列 / 跨根域 / 邮箱域名黑名单)

用户反馈(4 条同时)

  1. 实开 Tab 并发 ≠ 任务数,不是一回事!

    "可以就开 1 个任务,但是我可以一次取 5 个进行执行"

  2. 网站抓取也是共享队列,「同时挖几个网站」是错的命名,应该是「线程数」/「并发」
  3. 不同根域名也要支持跟随,不仅子域
  4. 邮箱黑名单要支持域名级,不仅是单个邮箱

我之前错在哪

条目 v0.10.13 实现 问题
#1 label="实开 Tab 并发(= 同时进行的任务数)" 等号那部分是错的。源码注释明明白白说 maxConcurrentTasksactiveTabs.size 的全局上限,调度器 round-robin 从 running 任务里挑 URL,与任务数解耦
#2 label="同时挖几个网站" / helper="所有正在挖的官网同时进行的总数" "网站数"误导。源码 engine-manager.tsactiveTasks全局并发 URL 数(worker 线程数),所有任务的 MapTaskData 共用一张表
#3 followSubdomain 只有 2 档(strict hostname / root domain) 公司用多根域名(acme.com + acmecorp.io + acme-blog.io)就跟不上
#4 emailBlacklist.includes(lower) 精确匹配 gmail.com 不会过滤 xxx@gmail.com,只能逐条邮箱填

改动

#1 实开 Tab 文案修正

banner 升级

ℹ️ 共享队列架构(v0.10.0 起)
所有任务共用一个调度器。下方两个「并发」是全局上限,与任务数互不绑定。
💡 举例:「实开并发 = 5」时 ——
   • 1 个任务(多关键词×城市)→ 同时开 5 个 Tab 抓不同 URL
   • 5 个任务各 1 URL → 每任务各 1 Tab,共 5 个
   • 3 个任务各 100 URL → round-robin 取 URL,共 5 个 Tab

label 修正: - "实开 Tab 并发(= 同时进行的任务数)" → "实开 Tab 并发上限" - "请求并发(= 同时进行的翻页 Fetch 数)" → "翻页 Fetch 并发上限" - helper 强调「与任务数无关 —— 1 个任务也能用满 5 槽位」

#2 网站抓取共享队列文案

子区 A「进站策略」加 banner:

ℹ️ 共享队列架构(与地图抓取同理)
所有任务采集到的「待抓官网」都进入同一个全局队列,
由 N 个 worker 线程并发消费。
💡「抓取并发 = 5」时 —— 1 任务 / 5 任务都一样,
   全局只跑 5 个 worker 同时抓 5 个 URL。

字段重命名: - "同时挖几个网站" → "抓取并发上限(worker 线程数)" - "单网站同时几个 URL" → "单域名并发上限(防把对方站打挂)" - 标题 "🚪 进站策略(决定哪些网站要进、并发多大)" → "🚪 进站策略(线程数 + 域名节流)"

#3 跨根域白名单

storage-data.tsfollowAllowedDomains: string(默认 '')。

dynamic-scraper.ts findSubLinks 新增 allowedDomains?: string[]

// 同域策略 + v0.10.14 跨根域白名单
const linkHost = u.hostname.toLowerCase();
const linkRoot = getRootDomain(linkHost);
let sameSite = false;
if (allowSubdomain) sameSite = linkRoot === baseRoot;
else                sameSite = linkHost === base.hostname.toLowerCase();
// 白名单:完全相等 OR 用户填的是根域且链接是其子域
const inAllowList = allowedDomains.length > 0 &&
  allowedDomains.some((d) => linkHost === d || linkHost.endsWith('.' + d) || linkRoot === d);
if (!sameSite && !inAllowList) continue;

scrapeWithTab 从 settings 读 followAllowedDomains 拼成数组传入。

UI 子区 B 新增字段:

跨根域白名单(即使根域不同也算同站)
helper: 公司用多个根域名时填这里。例:主站是 acme.com,
        填 acmecorp.io / acme-blog.io 后,从 acme.com 跟到这俩
        也算同站。逗号或换行分隔。
placeholder: acmecorp.io, acme-blog.io

#4 邮箱黑名单支持域名级(3 种写法混用)

scraper.ts 新增 isEmailBlacklisted 函数:

function isEmailBlacklisted(email: string, list: string[]): boolean {
  const at = email.toLowerCase().lastIndexOf('@');
  if (at < 0) return false;
  const domain = email.toLowerCase().slice(at + 1);
  for (const item of list) {
    const it = item.toLowerCase().trim();
    if (it.includes('@') && !it.startsWith('@')) {
      // 1. 完整邮箱:精确匹配
      if (email.toLowerCase() === it) return true;
    } else {
      // 2/3. @域名 或 纯域名:剥 @ 与 domain 比对(含子域)
      const d = it.startsWith('@') ? it.slice(1) : it;
      if (!d) continue;
      if (domain === d || domain.endsWith('.' + d)) return true;
    }
  }
  return false;
}

filterEmail 改用此函数(替换原 emailBlacklist.includes(lower))。

UI 子区 C 邮箱字段 helper 升级:

额外追加(邮箱 / @域名 / 域名 三种写法)
支持 3 种写法(每行/逗号分隔,可混用):
  • 完整邮箱:noreply@acme.com → 精确匹配
  • @域名:@gmail.com → 整域名屏蔽(含子域 mail.gmail.com)
  • 纯域名:gmail.com → 同上

验证

  • pnpm build ✅ 8.35s
  • pnpm compile ✅ 0 新 TS 错误(settings-view / scraper / dynamic-scraper / storage-data)

遇到的问题

  • 第 3 次踩中文标点匹配坑:进站策略子区原内容用「(」「)」(U+FF08/FF09 全角括号),我 old_string 写 ASCII 「(」「)」 又 string-not-found。
  • 改用 Python 切片大块替换。
  • 彻底教训:之后改这种含中文的旧 JSX,先用 grep -n 定位 + awk 打印目标行 + 直接复制粘贴,或者直接用 Python 切片,别再手写 old_string 猜全角/半角。已写到「注意点」。

注意点

  • 「与任务数无关」是核心心智模型:用户思路是「线程池 + 队列」—— 池子有 N 个 worker,从队列里抢任务。这次修文案后两个 banner(地图抓取 + 网站抓取)都用「举例」把这个心智说透。
  • followAllowedDomainsfollowSubdomain 互补不冲突:前者是"指定其它根域",后者是"放宽到子域"。两个都开 = 最宽松。
  • isEmailBlacklisted 现在对内置 5 项也跑这个新逻辑:内置全是完整邮箱(含 @),走精确匹配分支,与 v0.10.13 行为一致。如果以后想给内置也加默认域名屏蔽(如 gmail.com),加到 BUILTIN_EMAIL_BLACKLIST 即可。
  • 「企业邮箱过滤」功能(advParms 里的 enterpriseEmailOnly)是另一回事:那是搜索后过滤;邮箱黑名单是提取阶段就过滤。两者可叠加。

2026-05-26 v0.10.12 → v0.10.13 设置页大重构(解析层剥离 + 正则演示 + 内页跟随可配)

用户反馈(5 条一起)

  1. 邮箱/手机/社媒正则得有演示!
  2. 反爬解码 / 黑名单 / 噪音过滤 这些应该是独立的开启或设置;手机/社媒也应该剥离细化
  3. 高级·邮箱挖掘 改成 高级·网站抓取(地图抓取速度也精简)
  4. 分类黑名单标签要明确含「分类」;域名黑名单应该归到网站抓取下
  5. 网站抓取要加上链接过滤(页面跳转到其他页面要不要抓?)

分析

scraper.ts 当时把以下打包在一起、用户既不能开关也不能编辑

类别 用户控制
Cloudflare data-cfemail 解码 写死
EMAIL_BLACKLIST(5 项示例邮箱) 写死
NOISE_PATTERNS(15 项噪音模式) 写死
超长随机 ID 排除(>25 hex) 行内 if
手机 Tier 1/2/3(tel:/上下文/自由文本) 全开
PHONE_BLACKLIST(7 项测试号) 写死
SOCIAL_BLACKLIST(18 项 handle 黑名单) 写死

dynamic-scraper.ts 的 findSubLinks 同样写死: - 同域 = 严格 hostname(子域不算) - 关键词只能命中 contact/about/imprint/company/联系/关于/... - 用户没法加 team/staff/careers/impressum 等

改动

一、scraper.ts(剥离 + 可配置)

新增 ExtractOpts 接口,全部 optional(不传 = v0.10.12 行为,向后兼容):

cloudflareDecode?: boolean
emailBlacklistOn?: boolean; emailBlacklistCustom?: string
emailNoiseOn?: boolean;     emailNoiseCustom?: string
randomIdFilter?: boolean
phoneTier1?, phoneTier2?, phoneTier3?: boolean  // 3 个独立勾选框
phoneBlacklistCustom?: string
socialBlacklistOn?: boolean; socialBlacklistCustom?: string

内置 4 个数组改为 export const,供 UI「查看内置 N 项」按钮使用:

export const BUILTIN_EMAIL_BLACKLIST = [...]
export const BUILTIN_EMAIL_NOISE = [...]
export const BUILTIN_PHONE_BLACKLIST = [...]
export const BUILTIN_SOCIAL_BLACKLIST = [...]

合并策略 mergeList():用户的 custom 字符串(逗号/换行分隔)追加到内置后去重。

新增 FollowOpts

allowSubdomain?: boolean      // 关 = 严格 hostname;开 = 同根域算同站
keywordsWhite?: string[]      // 优先关键词(按命中顺序打分)
keywordsBlack?: string[]      // 排除关键词(命中即跳过,即使同域)
skipAssets?: boolean          // 跳过 jpg/css/pdf/...

scrapeWithTab 从 settings 读 followSubdomain/followKeywordsWhite/followKeywordsBlack/followSkipAssets 拼成 FollowOpts。getRootDomain() 简单实现(后两段),不处理 .co.uk 类 ccTLD(够用)。

三、storage-data.ts 加 17 个新字段

全部带默认值,向后兼容(v0.10.12 行为 = 所有开关 true、跟随列表用内置)。

四、新建 RegexTester 组件 + 3 个示例文本

src/sections/settings/regex-tester.tsx

  • 折叠按钮(默认收起,节省屏幕)
  • textarea 输入文本(minRows 2, maxRows 6, monospace)
  • useMemo 计算匹配结果(即改即看,无需"测试"按钮)
  • 失败友好:正则非法时显示红字 ⚠️ <message>,不抛错
  • 匹配项用 Chip 列出(绿色 + monospace,上限 50 项防死循环)
  • 「重置示例」按钮
  • 导出 SAMPLE_EMAIL_TEXT / SAMPLE_PHONE_TEXT / SAMPLE_SOCIAL_TEXT,每个都包含「应匹配 + 应过滤」的例子

五、settings-view 大重构

重命名: - 「高级·地图抓取速度」→ 「高级·地图抓取」 - 「高级·邮箱/社媒挖掘」+「高级·自定义匹配规则」→ 合并为「高级·网站抓取」 - 「分类黑名单」label 扩成「分类黑名单(按 Google 商家分类关键词过滤)」明确「分类」二字

新「高级·网站抓取」3 子区

🚪 进站策略
   • 同时挖几个网站 / 单网站同时几个 URL
   • 网站间间隔(最少/最多)/ 单页加载等待
   • 域名黑名单(v0.10.13 从「数据过滤」搬过来)

🔗 内页跟随(v0.10.13 新增可配)
   • 挖掘深度(1/2/3)
   • [Switch] 允许跟随同根域子域
   • 优先跟随关键词(默认 contact,about,team,impressum,...)
   • 排除关键词(默认 blog,news,login,cart,...)
   • [Switch] 跳过资源文件

🔍 解析规则
   ├─ 📧 邮箱
   │   • 正则 + [演示]
   │   • [Switch] Cloudflare data-cfemail 解码
   │   • [Switch] 邮箱黑名单 + [查看内置 5 项] + 追加文本框
   │   • [Switch] 噪音过滤 + [查看内置 15 项] + 追加文本框
   │   • [Switch] 屏蔽超长随机 ID(hex >25 位)
   ├─ 📞 手机
   │   • 正则 + [演示]
   │   • [Switch] Tier 1: tel: 链接(高置信度)
   │   • [Switch] Tier 2: 上下文关键词(中置信度)
   │   • [Switch] Tier 3: 自由文本(低置信度,最易噪)
   │   • [查看内置测试号 7 项] + 追加文本框
   └─ 📱 社媒
       • 自定义社媒 textarea(保留 v0.10.11)
       • [演示]
       • [Switch] 社媒 handle 黑名单 + [查看内置 18 项] + 追加

新加 BuiltinExpand 组件:折叠按钮 + 内置列表预览(monospace + 灰底)+ 📋 复制全部。

六、scraper-executor.ts 透传

把 settings 里 17 个新字段全透传给 extractDataFromHtml(用 as any 因 storage interface 还在迁移)。

验证

  • pnpm build ✅ 8.25s
  • pnpm compile ✅ 0 新 TS 错误(残留错误全部在 autocomplete/hooks 等不相关文件)
  • settings-view.tsx 从 ~940 行 → ~1180 行
  • 默认值全部保持 v0.10.12 行为,老用户升级无感

遇到的问题

  • Edit 工具中文标点匹配失败:源文件用 「,」「:」(U+FF0C/FF1A 全角),我 old_string 写成 ASCII 「,」「:」 导致 string-not-found。
  • 解决:第 3 次尝试改用 Bash + Python Path.read_text/write_text 直接做 byte 替换,绕开 Edit 的字符等价判断。
  • 教训:以后遇到含中文标点的旧代码替换,先 awk 打印目标行 / od -c 验证字节,再写 old_string。或者直接走 Python 切片更稳。

  • TS 报错 setValue(k as any):DefaultSettings 比 yup schema 多了已废弃的 requestConcurrency,导致 setValue('requestConcurrency', ...) 类型不存在。v0.10.12 已加 as any 修复,本版本保留。

注意点

  • 新开关默认值全是 true / 跟随列表用内置 —— 这是为了不影响老用户体验。如果以后想让某些默认关,需要做 storage 迁移把老用户的字段补上。
  • RegexTester 用 useMemo + key=regex+text+flags:每次输入立即重算。50 项上限 + re.global 检查防死循环。
  • 手机 Tier 3 是双刃剑:召回最广(订单号/年份会误抓),新版给了独立 switch 让重质量场景关掉。
  • followKeywordsWhite 留空 = 不跟任何链接:因为我们的语义是「白名单匹配制」。要让用户跟"全部"链接需要改逻辑(目前不开放)。
  • 域名黑名单移位:从「数据过滤」搬到「高级·网站抓取/进站策略」—— 逻辑上正确,因为它过滤的是「哪些域名不进入抓取」。但 storage key domainBlacklist 不变,迁移无感。

2026-05-26 v0.10.11 → v0.10.12 修正"per-task"概念误导(共享队列架构)

用户反馈

"逻辑有问题。多个任务是共享序列的,可以设置同时进行的任务,但是你不能设置单个任务打开几个搜索页,这个应该是序列控制的,请仔细分析!同样地图请求也是,这里应该是并发"

一针见血——v0.10.11 的设置页文案沿用 v0.9.x 时代的"per-task"思路,但 v0.10.0 已经改成共享队列调度器:所有任务共用一个全局并发上限,不是「每个任务自带 N 槽」。

改动

  1. 删除 requestConcurrency 字段在 UI 的暴露
  2. 这是 v0.9.x 的「单任务同时打开几个搜索页」概念。v0.10.0 之后调度器只看 maxConcurrentTasks 全局上限,requestConcurrency 已彻底无人读。
  3. storage interface 保留(兼容旧数据),yup schema 移除(无 UI 自然无校验)。
  4. setValue(k as any, ...) 处加 cast,避免 RHF 类型不含已删字段导致 TS 报错。

  5. 重写"地图实开队列"+"地图请求队列"子区文案

  6. 子区头改成「地图实开队列(前台 Tab 抓第 1 页结果)」+「地图请求队列(后台 Fetch 第 2-15 页)」——明确这是两个全局队列,不是 per-task 子设置。
  7. maxConcurrentTasks 的 label 改成「实开 Tab 并发(= 同时进行的任务数)」,helper:「全局共享。同一时刻最多开多少个 Google 地图搜索 Tab(1-10)。多任务 round-robin 共用。」
  8. pagerConcurrency 的 label 改成「请求并发(= 同时进行的翻页 Fetch 数)」,helper:「全局共享。多任务的翻页请求共用这 N 个并发槽位(1-5)。设 5 比设 1 快约 5 倍。」
  9. 每个子区顶部把并发字段放第一行(最重要的设置不能埋在间隔后面)。

  10. 顶部插架构说明 banner(dashed border + info 色背景):

    ℹ️ 共享队列架构(v0.10.0 起)
    所有任务共用同一个调度器。下方两个「并发」是全局上限,多任务轮流使用 ——
    不是 per-task。比如「实开并发=3」+ 5 个任务在跑,则 5 任务共享这 3 个 Tab 槽位。
    
    一段话讲清楚 v0.10.0 架构变更,避免用户继续误以为「N 个任务 × 3 槽 = 15 个 Tab」。

  11. PRESETS 同步:3 套预设全部移除 requestConcurrency 字段。

  12. conservative:maxConcurrentTasks 1, pagerConcurrency 1
  13. balanced:maxConcurrentTasks 2, pagerConcurrency 1
  14. aggressive:maxConcurrentTasks 5, pagerConcurrency 3

验证

  • pnpm build ✅ 8.27 s 通过
  • pnpm compile ✅ settings-view.tsx 无 TS 错误(其它文件预存 TS 错误与本次无关)
  • task-view.tsx 内联「同时跑 N 个任务」标签已与新概念一致,无需改动

注意点

  • requestConcurrency 是死字段:scheduler 不读、UI 不显示、schema 不校验,但 storage 类型保留以兼容老用户存档。后续清理时可一起删(需 storage 迁移)。
  • pumpPager 串行处理 job + job 内 page 并发:所以「请求并发」语义其实是「单 job 内同时拉几页」≈ 全局请求并发(因为同时只有 1 个 job 在翻页)。文案里写「多任务共用 N 槽位」更直观,等价。
  • 概念误导比 bug 更隐蔽:UI 文案对错不会让代码报错,但会让用户做出基于错误模型的决策(比如把"实开"调到 5 期待 5 任务 × 5 = 25 个 Tab)。每次架构变更后必须扫一遍 settings/help 文案。

2026-05-25 v0.10.3 → v0.10.4 UI 紧凑化 4 件套(用户便捷性优化)

用户反馈(多条同时)

  1. 任务页没有任务时,要有居中提醒
  2. AccountPopover 下拉太高,popup 里弹出来挤
  3. popup 底部要有 设置 / 文档 / 客服 入口(一行)
  4. popup 底部 谷歌地图 + 来发信网站 当前 2 行 → 应该合并 1 行
  5. 用词与已有功能保持一致(用 AccountPopover 的"运行设置/帮助文档/联系客服")

改动 #1:任务空状态居中

老:<Typography sx={{ p: 4, textAlign: 'center' }}>暂无任务...</Typography> 单行文字,整张页面感觉很空。

新:图标圆 + 标题 + 副文字 + 创建按钮,垂直水平居中:

       ┌────────────┐
       │     📋     │   ← 64px 圆 + 主色图标
       └────────────┘
        暂无任务         ← subtitle1 600
点击下方按钮创建第一个数据采集任务   ← caption secondary
      [➕ 创建任务]      ← contained button minWidth 160

flex: 1, minHeight: 320 让空状态吃满任务列表区域。

改动 #2:AccountPopover 紧凑化

老:每个 MenuItem m: 1 = 8px 上下边距 + 默认 minHeight 48px。7 项 menu × 48px + 间距 ≈ 480px。 加上 AccountInfo 头部 → 容易溢出 popup 容器。

新:抽 compactMenuItemSx 共享样式: - minHeight 48 → 36 - mx 0.5 / my 0.25 / borderRadius 1 - font 16 → 13 - icon minWidth 56 → 28 - icon size → 18

整体高度 ~480 → ~340,popup / 主面板都不挤。

改动 #3:Popup 底部加 3 入口(设置 / 文档 / 客服)

老 popup 最底部只有外链 2 行。 新加一行(divider 之下):

[⚙ 运行设置]  [❓ 帮助文档]  [👤 联系客服]
  • 用词跟 AccountPopover 完全一致(运行设置 / 帮助文档 / 联系客服)
  • flex: 1 三等分
  • 字号 11,icon 16,紧凑
  • hover 文字变 primary

改动 #4:外链合并一行

老:谷歌地图 + 来发信网站 各占一行 新:1 行 2 列 flex 平铺

[🗺️ 谷歌地图 ↗]  [▶ 来发信网站 ↗]

字号 12,icon 16,外链箭头 11。

┌─────────────────────────────────────┐
│ Header(40px)logo + 标题 + 头像     │
├─────────────────────────────────────┤
│ 会员状态                              │
│ 引擎运行                              │
│ ┌── 累计数据 ──┐                    │
│ │ 商家   邮箱  │                     │
│ └──────────────┘                    │
│ 任务进行中                            │
├─────────────────────────────────────┤
│ [➕ 创建任务] [📊 主面板]              │
├─────────────────────────────────────┤
│ [🗺️ 谷歌地图]  [▶ 来发信网站]        │ ← 1 行 2 列
├─────────────────────────────────────┤
│ [⚙ 设置] [❓ 文档] [👤 客服]          │ ← 1 行 3 列(新)
└─────────────────────────────────────┘

整体高度比 v0.10.3 少 ~30px,但功能多了一行入口。

文件变动

操作 文件
src/sections/task/task-view.tsx —— 空状态
重写 src/sections/account/account-popover.tsx —— 紧凑
src/sections/popup/index.tsx —— 外链合并 + 底部 3 入口
package.json 0.10.3 → 0.10.4

注意点

  • "运行设置 / 帮助文档 / 联系客服" 用词必须与 AccountPopover 一致 —— 用户问的就是这个点
  • 联系客服走 AuthContext 的 openService —— 触发后关 popup(window.close())让用户看到主面板的客服弹窗
  • 运行设置走 open-page-settings message —— 跟 AccountPopover 同一通道
  • 帮助文档直接新 tab 打开外部文档站
  • Popup 总高度 ~420px,不超 Chrome 600px 上限
  • 空状态用 flex: 1 撑满容器;外层 task-view 用 Stack flexDirection column 已经支持

2026-05-25 v0.10.2 → v0.10.3 修 jsstore logError 污染([object Object] 报错)

现象

用户截图扩展错误追踪器:[object Object] 错误,stack 指向 chunks/Login-BCdcAsbx.js 里的 r.logError 调用(Login 是 vite 自动命名的 chunk,实际是 jsstore Logger 类)。

根因(两个 bug 叠加)

Bug A:v0.10.1 的 quickFilterAsFilters 字段名写错

// 我写的(错):jsstore filters 期望的字段是 property/operator/valueType
[{ dataIndex: 'emails', oper: 'notEmpty' }]

// 正确:(对照 utils/jsstore/query.ts 的 exFieldFilterToQuery 签名)
[{ property: 'website', operator: 'notNull', valueType: 'text' }]

notEmpty operator 根本不存在;oper 也不是 operator

Bug B:v0.10.1 的 getMerchantTabStats 撞了项目已知坑

// 我写的:
const where: Record<string, any> = {};
if (taskId) where.taskId = taskId;
const total = await countByQuery('MapTaskData', where);  // ⚠️ 升级过 DB 的环境会喷 logError
const rows = await selectByQuery('MapTaskData', { where, ... });

项目里 search-data.tscountAllByTaskIds 注释明确写过:

「不再走 jsstore 的 where: { taskId }(早期 DB 没该列时 jsstore 会强制 logError 喷扩展报错面板)。改成最近 LIMIT 条 select + JS 端 groupBy。」

我没注意这个历史经验,又踩了一次。

修复

Bug A: quickFilterAsFilters 改用正确字段: - has-website → notNull text - high-rating → gte digit 4.5 - scraped/pending → eq digit 2/0 - has-email 暂留空(jsstore 无法直接查 array.length>0,TODO 用 JS 端 post-filter)

Bug B: getMerchantTabStats 改成「全表 select + JS 端 filter taskId」:

const allRows = await selectByQuery('MapTaskData', { limit: ROWS_CAP, order: ... });
const rows = taskId ? allRows.filter((r) => r.taskId === taskId) : allRows;
// 然后聚合 withEmail / withWebsite / scraped 等

性能:50k 行 × JS filter ≈ 5-10ms,IndexedDB select 才是大头(~50-150ms),无意义差异。

文件变动

  • src/sections/page/local-data-view.tsx — quickFilterAsFilters 改字段名
  • src/utils/merchant-stats.ts — 去掉 jsstore where:{taskId},改 JS 端 filter
  • package.json — 0.10.2 → 0.10.3

教训

  • jsstore 的 Logger 类内部错误 → 直接 console.error(this.message) → Chrome 扩展 错误面板 100% 捕获。try/catch 能吞掉 throw 但吞不掉 console.error
  • 修法只有一条:不要触发 jsstore 错误。即查询参数必须严格按 jsstore 接受的格式
  • 项目里已有的「绕开 jsstore where」的经验(countAllByTaskIds / getListByTaskId) 应该被复用 —— 新写聚合函数前先看下 search-data.ts 有没有类似 pattern
  • 给 jsstore 传 filter 必须严格用 property / operator / valueType 字段名, 不是 dataIndex / oper

验证

  • pnpm compile:0 错误
  • pnpm build:7.9s 通过
  • ⏳ 待用户实测:清空错误面板,重载扩展,用一段时间看是否还出 [object Object]

起因

用户反馈:"现在的登录经常登录失败,可以插件自动检测 cookie 直接调用 cookie 登录, 如果没有再登录?"

老登录流程的脆弱链路

1. 用户点"登录"
2. 扩展 openWindow('login') → 弹新窗口到 laifaxin 登录页
3. 用户输入账号密码,提交登录
4. 浏览器收到登录响应、设置 cookies、redirect 到账户主页
5. 登录成功页面的 JS 触发对 /api/account/* 的 POST 请求
6. 扩展的 `webRequest.onBeforeSendHeaders` 监听捕获请求头里的 uid / accesstoken
7. 写入 storage.local.uid / .accesstoken
8. 扩展认为已登录

问题在 #5-#7: - 窗口可能在 redirect 前被用户关掉 → 没机会发 API 请求 - redirect 时序问题 → API 请求可能在监听器注册前发出 - 用户可能直接关页面不操作 → 没 API 调用 - 任何一个环节断了,扩展就不知道用户已登录

如果用户已经在 web.laifaxin.com 登录过(任意子域),浏览器有对应 cookies。 扩展直接用 chrome.cookies.getAll({ domain: 'laifaxin.com' }) 读出来,按命名约定 找到 uid 和 accesstoken,写入 storage。

完全绕过 webRequest 那条脆弱链路。

实现细节

新文件 src/utils/auto-login.ts

  • autoLoginViaCookies(force = false) —— 主接口
  • 容错命名匹配:
  • uid 候选:uid / user_id / userId / lfx_uid / lfxUid / lfxUserId
  • token 候选:accesstoken / access_token / token / auth_token / authToken / lfx_token / authorization / jwt
  • 找到第一对就用
  • 失败时 console.debug 打出所有 laifaxin cookie 名字不打 value,避免 token 泄露日志)
  • 返回 AutoLoginResult { ok, reason, foundCookieNames } 让调用方做决策

修改 src/entrypoints/background/index.ts

  1. SW 启动后静默尝试 autoLoginViaCookies(false) —— 用户已登录但 storage 被清的场景 能自愈
  2. 'open-login' message handler 改成:
  3. 先尝试 cookie 自动登录
  4. 成功 → 返回 { autoLogin: true },不弹窗
  5. 失败 → 弹老登录窗口
  6. 新增 'try-cookie-login' message handler,UI 可主动调用 force 模式

用户实际流程

最佳路径(最常见): 1. 用户先在 web.laifaxin.com 登录(cookies 已设) 2. 安装扩展(或扩展 SW 重启) 3. SW 启动时自动 autoLoginViaCookies(false) 静默成功 4. 用户打开扩展 popup → 直接显示已登录 ✓

点登录路径: 1. 用户在 popup 看到"未登录 · 点击登录",点击 2. 后台尝试 cookie 自动登录 3. 成功 → 2 秒内 AuthProvider 的 authRun 轮询发现 storage 变了 → 自动刷新为已登录态 4. 失败 → 老的登录窗口打开

全新用户路径: 1. 用户没登录过任何 laifaxin 站点 2. 点登录 → cookie 检测失败 → 老窗口弹出 3. 登录后 webRequest 仍然能抓到(兜底链路保留)

我不知道后端实际给 cookie 取什么名字。等用户实测后能从 background console 看到 [auto-login] laifaxin cookies found: [...] log,根据实际命名我再硬编码。 当前 10+ 个候选名足够覆盖常见命名约定。

安全考虑

  • console.debug 只打 cookie 名字,不打 value —— 避免 token 出现在日志
  • 仅在 background script 跑 —— content script / popup 拿不到 chrome.cookies
  • 仅匹配 laifaxin.com 域 —— 不会误读其他网站 cookie
  • AuthProvider 后续的 API 调用仍走正常验签 —— 错 token 会被服务端拒绝

文件变动

操作 文件
新增 src/utils/auto-login.ts
修改 src/entrypoints/background/index.ts — SW 启动调用 + open-login 改造 + try-cookie-login 新增
修改 package.json — 0.10.1 → 0.10.2

注意点

  • chrome.cookies.getAll({ domain: 'laifaxin.com' }) 返回所有 laifaxin.com 及其子域的 cookie
  • manifest 已有 cookies 权限 + <all_urls> host_permissions,无需改 manifest
  • AuthProvider 的 authRun 用 pollingInterval: 2000 轮询 storage,cookie 自动登录后 2 秒内自动反映到 UI(不需要专门触发 refresh)
  • 老 webRequest 监听器保留兜底 —— 如果用户登录后又触发了 API 调用,也能更新最新 token
  • 用户登出 web 时 cookie 会被删除,但扩展 storage 不会被清 —— 这是已知问题,下版再做 「定期校验 token 有效性 + 同步删除」

验证

  • pnpm compile:0 错误
  • pnpm build:8.1s 通过
  • ⏳ 待用户实测:
  • 先在 web.laifaxin.com 登录
  • 重新加载扩展
  • 打开扩展 popup —— 应该直接显示已登录
  • 如果还是显示未登录,打开 background console(chrome://extensions → 「Service Worker」), 找 [auto-login] laifaxin cookies found: [...] log,把 cookie 名字告诉我, 我把对应名字硬编码进 candidates

2026-05-25 v0.10.0 → v0.10.1 商家列表全量重设计(10 项改进对标 SaaS)

起因

用户要求「从专业设计 + 产品角度」重新设计商家列表。我做了一次深度评审,列了 10 项改进 (4 必做 + 3 强推 + 1 推荐 + 2 锦上),用户拍板「全部」一次做。

老设计问题(10 项)

  1. 视觉超载 —— 一行 ~30 个独立视觉元素,眼睛累
  2. 信息层级混乱 —— 商家名和社媒图标字号差不多,无视觉权重
  3. emoji 滥用 —— 📞 ✉️ 像 90 年代 ICQ,不专业
  4. 默认列宽超屏 —— 5 列 1147px + 200 sidebar = 1347px,1280 屏溢出
  5. DEFAULT_FIELDS show: false —— 默认所有列都隐藏(疑似 bug)
  6. 复合列不可排序 —— profile / contact / location 3 个最重要列都不能 sort
  7. 缺快速操作 —— 没行级「复制邮箱」「打开 Mailto」等
  8. 缺顶部聚合 —— 看不到「邮箱覆盖率 35%」之类
  9. 没有质量分 —— 用户无法快速找到「高价值 leads」
  10. 行交互弱 —— 点击行没反应

新设计方案(全量改完)

列结构(v0.10.1 默认 9 列,总宽 ~1274px → 1440 屏舒适)

| Logo | 商家 | 邮箱 | 电话 | 评分 | 位置 | 质量分 | 社媒 | 创建时间 |
|  56  | 240 | 220 | 130 |  92  | 180 |  110  |  96  |   150   |
  • 邮箱独立列(核心营销价值,旁边没电话抢戏)
  • 质量分(0-100)算法:有邮箱+40 / 有电话+20 / 评分≥4.5+20 / 评论≥50+10 / 完善+10
  • 社媒收成单个 chip,hover popover 展开 6 个 icon
  • 复合列(profile / contact)保留但默认隐藏,老视图配置兼容

顶部 5-KPI Banner(MerchantStatsBanner

[🏪 579 总商家] [📧 35 (6%) 有邮箱] [🌐 384 (66%) 有网址] [✅ 115 (20%) 已挖] [⏳ 455 (78%) 待挖]

每个 KPI 卡片有色彩区分;hover 显示 tooltip 解释;占用 ~50px 高度。

快速筛选 Chip 行(MerchantQuickFilters

[全部 579] [📧 有邮箱 35] [🌐 有网址 384] [⭐ 高评 ≥4.5 80] [✅ 已挖 115] [⏳ 待挖 455]

单选互斥,点击即应用。映射到底层 filters 数组(与 DataGrid 内置 filter 兼容)。 最常用是「有邮箱」—— 业务核心。

详情 Drawer(MerchantDetailDrawer

点击行 → 右侧 480px 抽屉滑出,分 5 个区: - Header:logo + 名字 + 类别 + 评分 + 状态 chip - 联系区:所有邮箱(带复制 + Mailto 按钮)、电话(带复制 + 拨号) - 网络区:官网、6 个社媒图标(彩色 + 可点) - 位置区:完整地址 + 邮编 + Google Maps 链接 - 元数据:创建时间、关键词、地图主页

主表保持扫读速度,深度信息放抽屉。

Cell Renderers 重写

📞 415-... <PhoneIcon /> 415-...(icon 灰色 14px)
✉️ a@b.com <MailIcon /> a@b.com(icon 灰色 14px)
6 社媒 inline 图标 <ShareIcon /> N 社媒 chip + hover popover
整行文本 RenderName + RenderEmail + RenderPhone + RenderRating + RenderLocationCompact + RenderSocial + RenderQuality 7 个独立组件

复合列可排序

noSortFields = ['profile', 'contact', 'location'] 仍保留(老列不可排),但新独立列 email / phone / rating / quality 都 sortable + 提供 valueGetter 让 DataGrid 按 排序键算(如 emailCount = emails.length)。

文件变动

新增: - src/utils/merchant-stats.ts —— 商家 Tab 维度聚合(区别于全局 sidebar 用的 data-counts) - src/sections/page/merchant-stats-banner.tsx —— 5 KPI banner - src/sections/page/merchant-quick-filters.tsx —— 快速筛选 chip 行 - src/sections/page/merchant-detail-drawer.tsx —— 详情抽屉

重写: - src/assets/fields.ts —— DEFAULT_FIELDS 完全重做,9 列默认显示 - src/sections/page/cell-renderers.tsx —— 7 个独立 Renderer + 保留 2 个老复合 Renderer 兼容

修改: - src/sections/page/local-data-view.tsx —— 加 banner / filters / drawer / merchantStats 轮询 / quickFilter → filters 映射 - src/sections/page/local-table.tsx —— 已有 onRowClick prop 支持,新加 cell renderer 路由 - package.json —— 0.10.0 → 0.10.1

关键设计决策

决策 选择 理由
邮箱列位置 第 3 列(仅次于 Logo + 名字) 邮箱是这个产品的核心价值,必须最显眼
质量分公式 加权和(有邮箱权重最高) 让用户一眼找到高价值 leads
详情查看方式 Drawer 而非 Dialog Drawer 不打断扫读流程,可保持上下文
快速筛选互斥 单选 简单清晰;复杂组合用 DataGrid 内置筛选
Stats 数据源 独立 getMerchantTabStats query 不复用 data-counts(语义不同:行数 vs 唯一值数)
Stats 轮询 10s 与 countRun 一致,避免 DB 压力
老复合列 保留但 show:false 用户旧视图配置仍兼容

配色 / 字号规范

元素 字号 字重 颜色
商家名 14px 600 text.primary
邮箱主 13px 500 primary.main
邮箱次 11px 400 text.secondary
类别/位置 11px 400 text.secondary
状态 chip 11px 600 (按状态色)
操作图标 16px - text.disabled(hover 变 primary)

注意点

  • DataGrid 内置 onRowClick 与"选择单元格"冲突 → 用户点击 checkbox 不该触发 drawer, 本实现里 LocalTable 自己 handle 了 params.row 透传,checkbox 列点击不路由
  • MerchantQuickFilters 的 chip count 来自 merchantStats不受 quickFilter 自身影响 —— 否则「有邮箱 35」会变成 35 的子集,反直觉
  • getMerchantTabStats 用 selectByQuery + 内存遍历(限 50k);jsstore 没有 countDistinct 等聚合
  • quickFilterAsFilters 把 chip 映射到 filters 数组:
  • has-email{ dataIndex: 'emails', oper: 'notEmpty' }
  • has-website{ dataIndex: 'website', oper: 'notEmpty' }
  • high-rating{ dataIndex: 'rating', oper: 'gte', value: 4.5 } 这些 oper 需要 apiLocalDataList 内部的 jsstore where 适配器支持;如果不支持,需要扩展
  • Drawer 占 480px 宽,1440 屏正好;窄屏(<1280)会盖住主表,但能完整查看详情
  • 文件备份在 backup/v0.10.0_20260525_merchant/

验证

  • pnpm compile:0 错误
  • pnpm build:8.8s 通过 → 4.13 MB
  • ⏳ 待用户实测:
  • 数据 tab 顶部应出现 banner + filters
  • 点击行应弹出右侧 drawer
  • 列结构:Logo / 商家 / 邮箱 / 电话 / 评分 / 位置 / 质量 / 社媒 / 创建时间
  • quickFilter 应该正确过滤(需测「有邮箱」「待挖掘」等关键场景)

2026-05-25 v0.9.40 → v0.10.0 调度器大重构:共享队列架构(修复多任务卡死根因)

起因(用户截图反馈 + 关键洞察)

v0.9.40 用户跑两个并发任务(dentist + doctor): - doctor:488/29594 进度(56 分钟,正常) - dentist:0/29594 进度(53 分钟,完全卡死)

用户提出关键问题:「多个任务不应该是共用一个序列吗?」—— 直接指出了老架构的根本缺陷。

老架构(v0.9.x,错的)

batches: Map<taskId, BatchState>
  ├ taskA → { taskUrls, nextIndex, tabs Map, pagingQueue, ... }
  ├ taskB → { taskUrls, nextIndex, tabs Map, pagingQueue, ... }
  └ ...

每个 batch 独立调度,各自在【共享窗口】里开 tab
  → 同一个窗口 + N 个调度者 → 互相抢焦点

核心 bug:browser.tabs.create({ active: true })

Chromium 限制: - 后台 tab:setTimeout 夹到 ≥1s、requestAnimationFrame 暂停 - 5 分钟以上后台:所有 timer 节流到「每分钟 1 次」

灾难连锁(dentist 任务): 1. Task A (doctor) 已跑了一会,每 5-10s 开新 tab(active: true) 2. Task B (dentist) 创建,pump → openHarvestTab → 新 tab (active: true) 3. Task B 的 tab 短暂前台几秒 4. Task A 完成上一个 tab、开新 tab(active: true)→ Task B 的 tab 立刻变后台 ⚠️ 5. Task B 的 content script 初始化被节流卡住,永远不发 'batch-tab-ready' 6. 60s 超时本应触发 finalizeTab,但 MV3 SW 30s idle 被杀 7. SW 重启 → forceCloseSharedWindow 把 Task B 的 tab 一起干掉 8. Task B 的 batch 重启 → 新 tab → 同样命运 9. 永远 0 进度

Task A 正常是因为它起来,第一个 tab 抢到了完整初始化时间。后续每个 tab 在「短暂前台 → 后台 → 完成 → 新 tab 抢前台」循环中都有几秒前台时间,够 content script 跑完。

新架构(v0.10.0,对的)

1 个全局调度器
  ├ tasks: Map<taskId, TaskState>           ← 每任务只存 urls/进度/状态
  ├ activeTabs: Map<tabId, ActiveTab>       ← 全局唯一 tab 表
  ├ pagingQueue: PagingJob[]                ← 全局翻页队列
  └ globalStatus: 'idle'|'running'|'paused'|'intercepted'

Round-robin 调度:每次循环挑一个 status='running' 且还有 URL 的 task,
取下一个 URL 开 tab(active: false),加入 activeTabs。

两大根本性修复:

  1. 唯一调度者 —— 不可能"互相抢焦点",因为只有一个节奏控制器
  2. active: false —— 新 tab 不抢前台。共享窗口本身就是 focused: false, 用户根本看不到。后台 tab 里 content script 能正常跑(content scripts 不受 visibility 影响),fetch / DOM 操作都正常 —— 只是浏览器的渲染优化、动画暂停

设计变更对照

维度 v0.9.x v0.10.0
调度者数量 N(每 task 1 个) 1(全局)
并发上限 per-batch concurrency × maxConcurrentTasks 统一 = maxConcurrentTasks
Tab 开法 active: true(抢焦点) active: false(不抢焦点)
Tab 归属 各 batch 自己的 tabs Map 全局 activeTabs Map
Pagination per-task pagingQueue 全局 pagingQueue
拦截恢复 per-batch(其他 batch 不知道) 全局(所有 task 一起暂停/恢复)
ensureWindow 竞态 多 batch 同时调用 → 创多窗口 加内存级 lock 串行化
SW 重启恢复 各 batch 独立持久化 单一 tasks 数组 + 一份 paging 队列

文件变动

操作 文件
重写 src/entrypoints/background/batch-controller.ts(615 → 580 行,结构清晰得多)
修改 src/utils/scrape-window.tsopenTabInSharedactive: false 默认;ensureWindow 加 inflight lock
不动 src/entrypoints/background/task-manager.ts(对外 API 全兼容)
不动 src/entrypoints/background/index.ts(message handler 不变)
不动 UI(local:batchProgress:${taskId} storage 兼容)
删除 老 storage keys:local:activeBatches / local:batchState:* / local:batchPaging:*(迁移后清掉)

迁移逻辑(关键)

restoreBatch 启动时先调 migrateOldStateIfNeeded

if (有新格式 TASKS_KEY) return;           // 已迁移过
const oldActive = storage.get('local:activeBatches');
if (无老格式) return;                      // 全新安装
for (const taskId of oldActive) {
  const oldState = storage.get(`local:batchState:${taskId}`);
  const oldProg  = storage.get(`local:batchProgress:${taskId}`);
  迁移成新 TaskState 加入数组
  删除老 keys
}
storage.set(TASKS_KEY, migrated);

老进度(finished 数)保留,url 列表保留,status 保留(running/paused/intercepted 都按原样)。 用户升级后正在跑的任务继续跑,不丢进度

调度算法细节

async function pumpScheduler() {
  if (globalStatus !== 'running') return;
  const cap = await getGlobalConcurrency();  // = maxConcurrentTasks 设置
  while (activeTabs.size < cap && globalStatus === 'running') {
    const task = pickNextRunningTask();      // round-robin
    if (!task) break;
    const url = task.urls[task.nextIndex++];
    await openTabForTask(task, url);          // active: false
  }
  for (const t of tasks.values()) await publishProgress(t.taskId);
  await maybeFinishAll();
}

Round-robin 通过 roundRobinCursor 在 tasks Map 的 key 数组里轮转,找到第一个 status === 'running' && nextIndex < urls.length 的 task。

好处: - 2 个任务运行:每次 pump 一个 A 一个 B 一个 A...,公平 - 5 个任务运行 cap=2:每次 pump 跳 5 个轮转,总有 2 个在跑 - 1 个任务运行 cap=2:那个任务可以占满 2 个 tab(最大化吞吐)

拦截(CAPTCHA)处理

老逻辑: per-batch intercepted。Task B 触发拦截,Task A 继续 → Task A 也很快触发 → 两个独立的验证页弹出。

新逻辑: 全局 intercepted。任何一个 paging job 检测到 /sorry/ 或 reCAPTCHA: 1. globalStatus = 'intercepted' 2. 所有 status='running' 的 task → status='intercepted' 3. pumpScheduler/pumpPager 全部 noop 4. 弹一个验证页 5. 用户做完验证 → 在任意一个 task 点「继续」→ 全局恢复 + 所有 task 恢复 running

注意点 / 已知坑

  • active: false 不会影响 content script 行为 —— content scripts 是 浏览器机制独立于页面 visibility 的,DOM mutation 监听照常工作
  • 共享窗口 focused: false 创建 —— 用户根本看不到这个窗口,所以"后台" 对用户体验毫无影响
  • ensureWindow 内存级 lock —— 老代码两个 batch 同时调用会创出两个窗口, 新版用 ensureWindowInflight 在 Promise 维度串行化
  • tabToTaskIdMap 反向索引 —— 仍保留(消息路由用),但消费方现在 通过 activeTabs.get(tabId).taskId 也能拿到
  • maxConcurrentTasks 现在身兼两职 —— 既是 pumpTasks 的"running 上限" 也是 pumpScheduler 的"tab 上限"。语义统一为「同时跑 N 个任务」=「同时开 N 个 tab」
  • 回滚预案 —— backup/v0.9.40_20260525/ 备份了 batch-controller / task-manager / scrape-window / package.json。回滚只需 cp 回去 + pnpm build

验证

  • pnpm compile 0 错误(5 个改动 / 新建文件干净)
  • pnpm build 12.2s 通过
  • ⏳ 待用户实测:dentist + doctor 双任务是否两个都能正常进度

致谢

用户提出的「多个任务不应该是共用一个序列吗?」是这次重构的关键。 没有这个直觉,我可能会用 active: false 单点修补,但根本设计缺陷会一直存在。

自审发现的 4 个 bug(已修)

第一版重构构建通过后,做了一遍代码自审,揪出 4 个隐藏问题:

🔴 Bug #1:pumpScheduler 缺互斥锁(数据竞争)

async function pumpScheduler() {
  while (activeTabs.size < cap && ...) {
    ...
    await openTabForTask(task, url);  // ← yield 时另一个 pump 进来看到旧的 activeTabs.size
  }
}

多个 tab 几乎同时完成 → 各自的 finalizeTab 都 await pumpScheduler → 并发 pump 各自看到 旧 activeTabs.size → 可能同时开超过 cap 个 tab

修:用 isPumpingScheduler / pumpSchedulerPending 串行化(沿用 task-manager.ts pumpTasks 的套路)。

🔴 Bug #2:pumpPager 同样缺互斥锁

await getPagerConcurrency() 后 yield,并发 pump 看到旧的 totalActive 总和 → 可能短暂超 max 个翻页 job 同时跑。修:同 #1 模式。

🔴 Bug #3:迁移先删老 keys 后写新 keys(数据丢失风险)

// 原版(错)
for (...) await storage.removeItem(OLD_STATE_KEY);  // 先删
await storage.setItem(TASKS_KEY, migrated);          // 后写

如果 setItem 失败(如配额超限、storage 故障),老进度数据已经被删干净了

修:先 setItem,确认成功后才 removeItem。

🟡 Bug #4:pagerActiveByTask finally 重新插入已删 key(轻微内存泄漏)

stopBatch 删 key 后,in-flight paging job 的 .finally() 把 key 用 0 值重新 set 回去。 不影响正确性,但 Map 持续增长。修:finally 里先 has() 检查再更新;如果对应 task 不存在则 delete key 而非 set 0。

用户实测反馈:paging 实际速度未提升 + 重新理解 pagerConcurrency 语义

用户日志显示:pagerConcurrency=5 设了但 paging 仍然按顺序 1 个 job 1 个 job 跑(5s/页 × 8 页 = 40s/job)。

根因诊断: - v0.10.0 第一版的 pagerConcurrency 语义 = "并行 paging job 数" - 但 paging job 入队速度 = tab 完成速度 ≈ 1 job / 20-30s(受 maxConcurrentTasks 限制) - pagerConcurrency=5 想消费 5 jobs/秒 → 队列永远 ≤ 1 job → 设置形同虚设

修正方案:把 pagerConcurrency 语义从「并行 job 数」改为「单 job 内 page 并发」

老逻辑(每个 job 内):

for page = 2..15:
  sleep 2-5s
  fetch(page).await       ← 8 页 = 8 × 5s ≈ 40s/job

新逻辑(每个 job 内):

for batchStart = 2..15 step pageConc:
  sleep 2-5s
  Promise.all([fetch(p1), fetch(p2), ..., fetch(p_pageConc)])   ← 8 页 / 5 并发 ≈ 2 批 × 5s = 10s/job

pumpPager 改为 1 job 一次串行(FIFO),把 page 级并发放到 job 内。总并发 fetch 数 = pagerConcurrency (可控、可预测、不会因为 task 数量爆 Google)。

用户感知: - 设 1:1 倍速(默认,老行为) - 设 5:5 倍速(每个 URL 的 paging 完成时间 ÷ 5) - 设置直观,结果可预期

代码改动: - pumpPagerOnce 删 pagerActiveByTask 多并发逻辑,改成 while(队列) await runPagingJob 一个个跑 - runPagingJob 改 batch-mode:pageConc 个 URL 一组 → Promise.all(...) 并行 fetch - 退出策略调整:批内一旦出现空页(hadEmpty)或本批 0 新数据 → break(并行下"连续空"难判定,从严退出,最多少抓 1-2 页) - 删 pagerActiveByTask Map(用 currentPagingJobTaskId 单一全局 var 替代,简单很多) - 删 getPagerConcurrency helper(现在在 runPagingJob 里 inline 读 cfg)

注意点: - 拦截(intercepted)现在是「批内任一页拦截 → 整 job 退出」—— 仍然全局拦截一次只触发一次(onInterception 内部判重) - batch 间仍有 2-5s sleep —— 不是无间隔扫描,对 Google 友好 - 单 job 内 fetch 错峰间隔丢失(同批的 5 个 fetch 几乎同时发)—— 这是为速度做的权衡

自审清单(写下来给以后查)

  • ✓ 互斥锁覆盖所有 await 修改共享状态的循环
  • ✓ 迁移数据:先写新、再删老
  • ✓ Map / Set 清理:delete 而非 set 默认值
  • ✓ stopBatch / finishTask 的 paging in-flight job 兜底
  • ✓ globalStatus 转换:idle ↔ running ↔ paused ↔ intercepted 状态机闭环
  • ✓ tab 事件路由:tabToTaskIdMap 在 finalizeTab + stopBatch 都删
  • ✓ keepalive:ensureKeepAlive 在 startBatch / restoreBatch / restoreBatch 调用
  • ✓ persist 频率:每次 pump 都 persistTasks(频繁但安全)
  • ✓ pumpScheduler 安全上限:cap × 3 + 10 防无限循环
  • ✓ 60s tab 超时:finalizeTab handle 兼容多次调用同一 tabId

2026-05-25 v0.9.39 → v0.9.40 DataBar 加「今日新增」+ 数据子菜单恢复

用户反馈

v0.9.39 删了概览页 + 数据子菜单后用户提了两个调整: 1. "任务数据的预览呢?" —— 想看到 delta 类指标(不只是累计) 2. "我希望数据子菜单加回去" —— 一步直达比 DataBar → 等于二跳更顺

设计反思:导航 vs 状态展示是两件事

我之前把"DataBar"和"侧栏数据子菜单"看作重复 → 删一个。实际上它们承担不同职责:

维度 DataBar 侧栏数据子菜单
用途 状态展示(一眼看到 KPI) 直接导航(一步直达对应 tab)
位置 顶部,全页面持久 侧栏,长期占位
信息 大数字 + 颜色突出 小数字徽标 + 图标
交互 点击跳转 点击跳转
同质性 都显示同样的数字,但目的不同

结论:两者共存,互不冲突。设计原则上叫做「Aggregation + Drill-down」—— 顶部看总览,侧栏点细分。Stripe/Linear/Notion 等都是这种双层结构。

改动 #1:DataBar 加「今日新增」KPI

DataTabCounts 新加 todayNew 字段:

const startOfTodayMs = new Date().setHours(0, 0, 0, 0);
let todayNew = 0;
for (const r of rows) {
  ...
  if (typeof r.create_time === 'number' && r.create_time >= startOfTodayMs) {
    todayNew++;
  }
}
  • 用 MapTaskData 表已有的 create_time 字段(Number 时间戳)
  • 阈值 = 本地时区今日 00:00 的毫秒戳
  • 遍历 rows 时顺便算,零额外开销
  • 容错:create_time 缺失或非法不计入(不报错)

DataBar 加第 6 个 KPI,视觉上区别于累计指标: - 主数字前加绿色加号「+127」强调是 delta - icon 用 TrendingUpIcon 趋势图标 - color = success.main(绿色) - 0 时显示 "—" 而不是 0(避免和「累计」混淆) - 位置:最右侧,跟「任务」类似都是次要 KPI(左 5 个是核心累计)

改动 #2:恢复数据子菜单

  • DATA_SUB_NAV 配置加回
  • 渲染逻辑加回(4 个子项默认展开 + 数字徽标 + active 状态 + 左侧竖线视觉)
  • 保留 count > 0 才显示数字的条件(避免新用户 0/0/0/0 看到一片 0 的噪音)

KPI 顺序设计原则

DataBar 最终 6 个 KPI 排列:

[商家 │ 官网已采/待 │ 邮箱 │ 手机 │ 任务进行中 │ 今日新增 +127]
 ↑─── 5 个累计指标 ───↑    ↑──── 1 个增量指标 ────↑
        总数感         进度感      行动感

设计思路:左到右逻辑递进 ——「数据资产存量 → 任务进展 → 今日产出」。最右的 +127 像"奖励"一样吸引视线。

文件变动

  • src/utils/data-counts.ts —— 加 todayNew 字段 + 计算逻辑
  • src/sections/layout/data-bar.tsx —— 第 6 个 KPI + TrendingUp 图标
  • src/sections/layout/main-layout.tsx —— 恢复 DATA_SUB_NAV + 渲染;dataCounts 默认值加 todayNew
  • src/sections/popup/popup-data.ts —— counts 默认值加 todayNew
  • package.json —— 0.9.39 → 0.9.40

注意点

  • create_time 字段在 jsstore Schema 第 2 版(v0.9.8+)就存在了,老数据可能没有 → 用 typeof 检查容错
  • 本地时区 00:00 用 setHours(0,0,0,0) 而不是 UTC —— 用户的「今日」是本地日期
  • 「今日」窗口随时间推移 —— 5s 一次的 useRequest 轮询会自动重新计算,过 0 点时新一天的数据会单独算
  • 6 个 KPI 在 1200px 宽度下每个 ~200px,足够;窄屏(800px)下 ~130px,紧凑但可读
  • 数据子菜单和 DataBar 的数字理论应该一致(都来自 dataCounts state)—— 若发现不一致,是同一份 state 渲染问题,不会是数据源不同
  • 用户在反馈时教我一个产品设计原则:导航和状态展示是两件事,可以共存。删之前要先想清楚谁负责什么

2026-05-25 v0.9.38 → v0.9.39 概览页 → 顶部数据条(持久 KPI)+ 侧栏瘦身

起因

老「概览」页的问题: 1. 内容稀薄 —— 4 张卡片 + 1 句"更多数据后续补充",整页 90% 空白 2. 数据在侧栏 + 概览页不一致地重复(商家两边都有;邮箱/手机只在侧栏;已采集/待采集/日志只在概览) 3. 作为首屏页失败 —— 用户点开主面板看到干瘪数字,还要再点一次才到任务/数据 4. 侧栏的「数据」下挂 4 个子菜单,再加概览页本身的 4 张卡片,用户被同样信息轰炸两次

改动总览

架构变化: - 删除「概览」独立页(OverviewView 整个文件) - 删除侧栏数据子菜单(商家/官网/邮箱/手机 4 项) - 新增顶部数据条(DataBar)—— 5 个 KPI 一行平铺,在任务/数据/日志/设置页面顶部都持久显示 - 默认首屏 overviewtask - 数据子菜单合并进 DataView 内置 Tabs(之前就是受控/非受控双模式,去掉外层传 tab 后自动 fallback 到内置 Tabs,零工作量)

视觉对比:

旧(v0.9.38):
┌────┬────────────────────┐
│概览│ ┌──┐ ┌──┐ ┌──┐ ┌──┐│  ← 4 张卡片,整页空白
│任务│ │579│ │115│ │455│ │146││
│数据│ └──┘ └──┘ └──┘ └──┘ │
│└商家579└官网384  ...     │  ← 侧栏又来一遍商家数
└────┴────────────────────┘

新(v0.9.39):
┌────┬──────────────────────────────────────────┐
│任务│ 579 | 115/570 | 35 | 371 | 2/5            │  ← 顶部数据条
│数据│ 商家 | 网站已/待 | 邮箱 | 手机 | 任务进行中   │     持久显示
└────┴──────────────────────────────────────────┘
     ↓ 点 KPI 直接跳对应 tab

数据条(DataBar)设计

文件:src/sections/layout/data-bar.tsx

5 个 KPI,等宽 flex 平铺:

KPI 主数字 副 label 点击行为 颜色
商家 merchant(k 化) 商家总数 → 数据-商家列表 primary
网站 scrapeDone / total(k 化) 官网已采 / 待采 → 数据-官网列表 success
邮箱 email 邮箱总数 → 数据-邮箱列表 warning
手机 phone 手机总数 → 数据-手机列表 info
任务 running / total 任务进行中 / 总数 → 任务页 secondary

数字用 tabular-nums font-variant 实现等宽对齐,看起来专业。

getDataCounts 扩展

老版本只返回 {merchant, website, email, phone}。新版本增加: - scrapeDone —— scrape_status === 2 的行数(已采过官网) - scrapePending —— scrape_status === 0 的行数(待采)

计算口径:遍历 rows 时顺手统计,零额外开销。

替代了原 OverviewView 用的 3 个 countByQuery 调用 → 现在 1 个 count + 1 个 select 解决全部 KPI。

默认首页改 task

- const [page, setPage] = useState<PageKey>('overview');
+ const [page, setPage] = useState<PageKey>('task');

逻辑:用户开主面板大概率是去管理任务,不是去看 dashboard。Dashboard 信息已经在顶部 DataBar 持久显示,不需要专门一个页面。

侧栏瘦身(旧 8 区 → 新 5 区)

旧: 1. Logo + 标题 2. 主导航:概览 / 任务 / 数据 3. 数据子菜单:商家 / 官网 / 邮箱 / 手机(删) 4. 引擎开关 + 立即触发 5. 云端同步 6. 创建任务按钮 7. 次级导航:日志 / 设置 8. 网络状态条(v0.9.37 内联到 #7 那行)

新: 1. Logo + 标题 2. 主导航:任务 / 数据 3. 引擎开关 + 立即触发 4. 云端同步 5. 创建任务按钮 6. 次级导航 + 网络状态:日志 / 设置 / ●

视觉上呼吸空间大很多。

文件变动

新增: - src/sections/layout/data-bar.tsx —— 顶部数据条组件

修改: - src/utils/data-counts.ts —— DataTabCounts 加 scrapeDone / scrapePending - src/sections/layout/main-layout.tsx —— 删 overview / 数据子菜单 / 改首页 / 引入 DataBar - src/sections/popup/index.tsx —— PageKey 同步删除 'overview' - src/sections/popup/popup-data.ts —— counts 默认值加 scrapeDone / scrapePending - package.json —— 0.9.38 → 0.9.39

删除: - src/sections/overview/overview-view.tsx —— 整文件删除(备份到 backup/v0.9.38_20260525/

注意点

  • DataView 受控/非受控双模式 是这次能零工作量删数据子菜单的关键 —— 之前实现时就预留了内置 Tabs fallback
  • DataBar 数据复用 main-layout 已有的 useRequest 轮询(5s getDataCounts + 3s taskListItem),不重复轮询
  • scrapeDone + scrapePending 可能不等于 merchant —— 有些行 scrape_status 是 1(in-progress)或其他状态,被排除
  • DataBar 的"任务进行中"用 taskCount.unfinished(含 queued/running/paused 三种状态),跟侧栏徽标一致
  • 数字 k 化:≥ 10000 才 k 化,避免 "1.2k" 不够直观(999 是 999、12000 是 12k)
  • 文件备份在 backup/v0.9.38_20260525/

2026-05-25 v0.9.37 → v0.9.38 扩展 Popup 重做(迷你仪表板 + 操作中心)

起因

老 popup 设计:3 个等权「打开 X」按钮 + 一个含糊的「进入搜索页面」。 信息密度为 0,纯粹是个导航跳板。用户每次点扩展图标想看「引擎跑没跑、抓了多少」, 都必须:点扩展图标 → 看到 3 个按钮 → 点「进入搜索页面」 → 等主面板加载 → 才看到。 4 步、3-5 秒。

重做目标

把 popup 从「导航跳板」改造成「迷你仪表板 + 操作中心」,对标 Grammarly / 1Password 等 头部扩展。点扩展图标 → 0.2 秒内看到关键状态 → 大概率不用再开主面板。

信息架构(视觉权重高 → 低)

  1. 会员状态 —— Chip + 到期日(永久 / 剩余 N 天 / 已过期 N 天),点击进账户
  2. 引擎运行状态 —— 8px 脉冲圆点 + 文字 + 内联「立即触发」(仅 isRunning 时显示)
  3. 累计数据卡片 —— 左商家右邮箱,大字号 H6(k 化:12.8k),点击进数据页
  4. 任务概况 —— "N 个进行中 / M 个总",点击进任务页;0 任务时显示「点击下方创建」
  5. Primary CTA —— [➕ 创建任务][📊 打开主面板] 双按钮,前者 contained 主色更显眼
  6. 次级跳转 —— 谷歌地图 / 来发信网站,外链图标 + 新标签页打开

关键设计决策

决策 选择 理由
数据来源 本地 IndexedDB + storage popup 生命周期短,不发网络 → < 200ms 渲染
数据刷新 mount 时一次性读取,不轮询 popup 是 transient,关了再开就重读
失败处理 显示 0 + Skeleton 占位 不让 popup 卡白
「进入搜索页面」 删除(与「打开主面板」重复) 用户确认两者功能相同
「打开来发信」(SaaS) 降为次级跳转,加 OpenInNew 图标 频率不高、跟谷歌地图同级
跨 popup → main 通信 storage 信号 local:popup-action 适配两种情况:主面板已开 / 未开

跨进程通信设计

Popup 和主面板是两个独立的 React 应用,通信方案:

// popup 写信号
await storage.setItem('local:popup-action', {
  type: 'open-create' | 'go-page',
  page?: 'overview' | 'task' | 'data' | ...,
  t: Date.now()  // 时间戳防误触发
});
openWindow('main');  // 聚焦/新开主面板窗口
window.close();      // 关 popup
// main-layout 双重接收
// 1) mount 时检查(主面板首次打开场景)
storage.getItem<PopupAction>('local:popup-action').then(handle);
// 2) watch 变更(主面板已开、popup 触发场景)
const unwatch = storage.watch<PopupAction>('local:popup-action', handle);

function handle(a) {
  if (Date.now() - a.t > 5000) return;  // 信号过期丢弃
  if (a.type === 'open-create') setCreateOpen(true);
  if (a.type === 'go-page') setPage(a.page);
  storage.removeItem('local:popup-action');  // 用完即删
}

文件变动

新文件: - src/sections/popup/popup-data.ts —— 一次性读取所有 popup 数据的 hook

重写: - src/sections/popup/index.tsx —— 完全重做布局 - src/entrypoints/popup/App.tsx —— 宽 320 → 380,去掉固定高度(自适应)

修改: - src/sections/layout/main-layout.tsx —— 加 popup-action 监听 - package.json —— 0.9.37 → 0.9.38

注意点

  • window.close() 在 popup 里能用,关闭后 popup 进程结束
  • popup 是独立的 React Root,不共享 main 的 state;只能通过 storage / message 通信
  • storage.watch 用 closure 保留 setter ref,watch 回调能正确触发 React state 更新
  • 时间戳防误触发:5 秒内的信号才执行,防止旧信号在主面板下次打开时被误触发
  • 用完即删(removeItem)确保信号是一次性的
  • 「立即触发」按钮仅在 isRunning 为 true 时显示,与 sidebar 内联触发逻辑一致
  • 文件备份在 backup/v0.9.37_20260525/

2026-05-25 v0.9.37 后续:地区选择器 4 处可用性修复

起因

用户跑 v0.9.37 截图反馈地区选择器 4 个具体问题。

修复

① 去掉南极洲 - CountriesList 来自 ISO 标准全表,含 AQ 南极洲 - 业务上无任何商业网点,留着只是占位干扰 - 在 allCountries useMemo 里加 .filter((c) => !EXCLUDED_ISO2.has(c.iso2)) - 用 Set 而不是数组 includes —— 未来要排除更多(南乔治亚 GS、布维岛 BV、赫德 HM 等)扩展方便

② 重排版:国旗降为元数据

旧布局(国旗作为主视觉、Row 1 占用):

[ ] 🇦🇫 Afghanistan                       [chip] ⭐ >
        阿富汗 · AF · stats

新布局(双语并排 Row 1、国旗+代码+stats 降为 Row 2):

[ ] Afghanistan · 阿富汗                   [chip] ⭐ >
    🇦🇫 AF · 27 州 · 48 城市

设计理由: - 用户主要靠文字识别国家(中文/英文都能搜),国旗只是视觉辅助 - Row 1 双语并排让搜索结果一眼看到「我找的是不是这个」 - Row 2 把所有「身份标识 + 统计」类元数据归到一起,符合信息分组原则 - ISO2 代码用等宽字体(ui-monospace)—— 因为它是结构化标识符("AF"/"AL"),等宽显示更对齐

③ 「仅城市数据」措辞修掉 - 旧逻辑:states > 0 ? 'N 州' : '仅城市数据',把 0 项硬塞成负面措辞 - 新逻辑:构造 parts[],>0 才 push,最后 join(' · ')parts.length === 0 整体不渲染 - 副作用:Antarctica/Aland 等 0 州 0 城市的国家统计行直接消失(更干净) - Aruba 0 州 3 城市 → 显示「· 3 城市」(不再有「仅城市数据」前缀)

④ 列表无法滚动(CSS Flex 经典坑)

根因:CSS Flex 规范定义 min-height: auto(即 flex 子元素的最小尺寸 = 内容尺寸), 这使得 flex: 1 子元素无法缩小到 overflow: auto 能生效的高度以下。

具体到本场景:

DialogContent (flex: 1, height ≈ 744px, overflow: hidden)
  └ Stack (height: 100% = 744px)
      ├ chips (~30px)
      ├ action row (~38px)
      ├ search (~40px)
      └ Box (flex: 1, overflow: auto)
          想 = 634px (=744 - 30 - 38 - 40)
          实际 = 5230px (=249 国 / 2 列 × 42px),因 min-height: auto 卡住

Box 实际尺寸 5230px > DialogContent 的 744px → Box 把 DialogContent 撑爆 → DialogContent overflow: hidden 把溢出部分裁掉(不是滚动) → 用户看到首屏 ~12 国就到底了。

修复:scrollable BoxminHeight: 0 显式覆盖 min-height: auto 默认值。 Box 现在可以缩到 634px,overflow: auto 生效,出滚动条。

应用到三个层级:国家 / 州 / 城市,都加同样的 minHeight: 0

注意点

  • CSS Flex min-height: auto 是经典坑,记牢以下三条:
  • flex: 1 子元素若想 overflow: auto 生效,必须显式 min-height: 0(横向是 min-width: 0)
  • height: 100% 在 flex 子元素上是相对父元素 computed height —— 父元素是 flex 项时也算 definite,所以能用,但比 flex: 1 + minHeight: 0 更脆弱
  • Chromium 对「overflow != visible 时 min-height: auto 是否归 0」有过反复,跨版本可能不一致 → 永远显式写

滚动修复 v2(用户反馈第一版改完仍不能滚 → 深挖)

第一版只在最里层 Box 加了 minHeight: 0,但 flex 嵌套链路上任何一级没断 min-height: auto 默认值都会卡死整条链:

Dialog → Paper (maxHeight: 85vh, ⚠️ MUI 默认 overflow-y: auto)
       → DialogContent (flex: 1, overflow: hidden, ⚠️ 缺 minHeight: 0)
       → Stack (⚠️ height: '100%' 在 flex 上下文中脆弱)
       → Box (flex: 1, minHeight: 0, overflow: auto) ✓ 单这一级不够

第二版改动(全链路打通):

  1. Paper:加 overflow: 'hidden' —— 关掉 MUI MuiDialog-paper 默认的 overflow-y: auto。 不关的话,会让 Paper 自己滚(坏 UX:header/footer 跟着滚出去)
  2. DialogContent:加 minHeight: 0 —— 配合已有的 overflow: hidden,使 flex: 1 真的能缩到目标高度
  3. Stack:height: '100%'flex: 1, minHeight: 0 —— 百分比换 flex 项,跨浏览器更稳
  4. Box:保持 flex: 1, minHeight: 0, overflow: auto(第一版已加)

修复后 flex 链路计算: - Paper: 85vh = ~918px(maxHeight 强约束) - 减去 Header(58) + Breadcrumb(45) + Divider(1) + Footer(70) ≈ 174px - DialogContent 应得 744px(flex: 1 + minHeight: 0 真的算出来) - Stack 填满 = 744px(flex: 1 + minHeight: 0) - 减去 chips(30) + action(38) + search(40) = 108px - Box 应得 636px(flex: 1 + minHeight: 0) - Box 内容 5230px > 636px → overflow: auto 触发滚动条 ✓

教训:Flex 滚动失效不是单点 bug,是链路 bug —— 从最外层 Paper 到最里层 Box 任何一级 min-height: auto 都会卡死。修就一把梭,全链路打通。

横向溢出修复(用户反馈滚动好了但出现横向滚动条)

滚动条出现在国家列表底部 → 内容横向溢出。根因和 flex min-height: auto 同一类: CSS Grid 子项的 min-width: auto 默认 = min-content 宽度,导致 grid 子项无法 被压缩到 1fr 列宽以下,整个 grid 被撑超 → 出横向滚动条。

具体到本场景:

scrollable Box (overflow: auto)
  └ Grid Box (gridTemplateColumns: '1fr 1fr')
     └ Country Row Stack ← ⚠️ 缺 minWidth: 0
        ├ Checkbox
        ├ Country Info Stack (flex: 1, minWidth: 0) ✓ 这层有
        ├ Chip (when selected)
        ├ Star IconButton
        └ Arrow IconButton

CSS Grid Spec 11.4.2:

The auto value of min-width on a grid item that spans only a single grid track with a non-fixed maximum (e.g., 1fr) is the item's min-content size.

意思:grid 子项在 1fr 列里默认最小宽度 = 子项 min-content(最长不可断开内容的宽度)。 即使子项里嵌套了 noWrap + overflow: hidden + text-overflow: ellipsis 的 Typography, 父级 Stack 本身的 min-content 还是固定值(包含 Checkbox/Chip/IconButton 等不可压缩元素)。

修复:给三个层级的行 Stack 都加 minWidth: 0: - renderCountryLevel 的国家行 Stack - renderStateLevel 的州行 Stack - renderCityLevel 的城市行 Stack

加完后,grid 子项可以缩到任意宽度,1fr 真的当 1fr 用,横向不再溢出。

综合教训

CSS Grid 和 Flexbox 的 min-* : auto 默认值是同一个坑的两面: - Flex:min-height: auto / min-width: auto = 子项 min-content 尺寸(主轴方向) - Grid:min-width: auto / min-height: auto = 子项 min-content 尺寸(在 1fr 这种 non-fixed track 里)

两者都让"子项尺寸 = 1fr 列宽 / flex 分配"算式失效,导致父容器被撑超。

永远显式写 minWidth: 0 / minHeight: 0 —— 见到 flex: 1 或 grid 1fr 就跟着写, 能省掉 90% 的「为什么我的滚动 / 截断不生效」debug 时间。 - ISO2 代码用等宽字体是细节但很值 —— "AF" / "AL" / "AM" 这种短结构标识符等宽显示后视觉对齐感强 - 删 AQ 后 list.length 从 249 变 248,UI 自动同步 - 双语 Row 1 在长名("United Kingdom of Great Britain and Northern Ireland")会触发 noWrap 截断,但这类国家中文也长,正常省略号即可 - 文件备份在 backup/v0.9.36_20260525/(v0.9.37 改的就是 v0.9.36 备份的那批文件)


2026-05-25 v0.9.36 → v0.9.37 设计与产品评审整改(一次性修 8 项 🔴)

起因

v0.9.36 三项增强从功能层面工作,但从设计/产品角度有多处可优化。用户要求"从专业设计以及 产品角度进行检查",给出 🔴 / 🟡 / 🟢 三档评审报告,并要求"一次性修复"所有 🔴 高优先级。

8 项 🔴 修复

① 搜索深度 → 采集精度(task-create-dialog.tsx) - 措辞工程化 → 改"采集精度",更面向用户 - CSS counter 圆圈 1/2 没信息价值 → 换语义图标(MapIcon / LocationCityIcon) - Select(3 步交互)→ ToggleButtonGroup 分段控件(1 步切换) - 位置:DialogActions → DialogContent 内,放在「地区」配置后形成「类别→地区→精度→创建」线性流程 - 副标"更快/更细"→ 量化描述"覆盖广 · 速度快" / "数据多 · 较精细" - 删除:FormControl/MenuItem/Select/Tooltip 导入;CSS counter 相关 sx

② 国家点位预加载策略(location-picker-dialog.tsx) - 旧:打开 dialog 一次性把 250 国全标 loading + 并发拉 - 视觉:250 个"加载中…"海洋 - 风险:Google 限频 - 新:按 region 懒加载 - 第一次打开只读缓存,把已有数据塞进 state - 切到某 region 时才 preloadAll 该 region 的国家 - 已有缓存的国家不重新触发(preloadAll 内部判 isFresh) - 视觉:未加载(undefined)和 loading 都不显示统计行,只在 ok/error 时出文字 —— 避免视觉噪音 - 措辞:「无州省级」→「仅城市数据」 - 数字 k 化:19300 → 19.3k

③ 网络状态条(network-status-bar.tsx / use-network-status.ts) - 旧:常态化展示完整状态条(圆点 + 文字 + 延迟 + 浅色背景),占侧栏一整行 - 新:紧凑指示器 - ok:只一个 8px 圆点(最低视觉打扰) - slow/bad:圆点 + 短文字"卡顿"/"异常" + 浅色背景警示 - checking:脉冲动画 - 措辞:「Google 连接正常/较慢/无法连接」→「网络正常/卡顿/异常」(用户视角) - manualCheck 强制 checking 状态:用户点重测能看到圆点开始脉冲,给即时反馈 - 实现:runOnce(forceChecking) 第二参数;polling 时传 false,手动时传 true

④ 侧栏信息过载(main-layout.tsx) - 旧:日志/设置一行 + 网络状态条一行 = 2 行 - 新:网络指示器内联到日志/设置那行右侧 = 1 行,节省一行高度

跳过的项

  • 🔴 4.1 侧栏整体重构(引擎/同步/创建任务合并为「工具」折叠区):涉及核心交互(创建任务按钮), 改动风险高、且与本次评审主要修复点正交。留待用户确认后再做

注意点

  • ToggleButtonGroup 在 MUI 5 中:
  • exclusive 必加(否则可取消选择导致 value=null)
  • onChange={(_e, v) => v && handle(v)} —— v 可能是 null,要判空
  • .Mui-selected 样式在 sx 里通过 & .MuiToggleButton-root.Mui-selected 覆盖
  • preloadAll 内部判 isFresh —— 已缓存的国家会被 onProgress 立即回调, 不会重发请求;切 region 触发的 preloadAll 对已加载国家近似 noop
  • 网络状态 manualCheck 在 status='ok' 时跳过 checking 状态是 v0.9.36 的小 bug, v0.9.37 通过 forceChecking 参数修了
  • 网络状态条内联到次级导航后,宽度受限;checking/异常时显示短文字"检测中/卡顿/异常", ok 时不显示文字(避免占位)—— 用 tooltip 看详情
  • 文件备份在 backup/v0.9.36_20260525/:5 个改动文件 + package.json

2026-05-25 v0.9.36 三项 UI/功能增强:搜索深度美化 / 国家点位预加载 / 网络状态条

用户提的 3 个需求

  1. 搜索深度 4 个字单独加粗 + 选项加序号(CSS 实现),副标「更快/更细」
  2. 国家选择器后面显示该国家的地图数量;预加载到本地、失败显示状态、成功显示数量
  3. 日志/设置下方加网络状态指示;用户提了「10s 一次?4 次失败?」征求方案

#3 网络状态:方案权衡(先给客观建议,再实现)

维度 选择 原因
检测端点 https://www.google.com/generate_204 GET no-cors Google 自家 captive portal 探测端点,opaque 响应无 CORS 限制、负载极小、稳定
检测间隔 30 秒(不是 10s) 10s 太密、Google 限频且对用户感知无意义;30s 实测灵敏度足够
失败判定 连续 3 次失败(≈90s)才 bad 单次抖动很常见,3 连失才能确认网络真有问题
快速恢复 1 次成功立即回 ok VPN 切换后立刻反馈,不必等连续成功
状态档位 ok / slow / bad / checking / unknown slow(延迟 > 3s)提前预警,比通/不通信息更丰富
跑在哪 UI(main-layout setInterval),不是 SW MV3 alarms 最小 30s、SW idle 5min 被杀;UI 关了不需要后台轮询
手动刷新 点状态条立即重测 用户切完 VPN 不想等 30s
超时 单次 fetch 5s(AbortController) 否则慢得离谱时会卡满 30s 周期

#1 实现:src/sections/task/task-create-dialog.tsx

DialogActions 里: - 「搜索深度」4 字加粗成独立 Typography(fontWeight: 700) - Select 用 MenuProps.PaperProps.sx 注入 CSS counter:counterReset: 'depthIdx' 在 Paper、& .MuiMenuItem-root::before { counterIncrement; content: counter(depthIdx) } 在每个选项 → 自动 1、2 圆圈序号 - renderValue 自己渲染选中态的圆圈(CSS counter 只在 MenuItem 生效,选中态要手画) - 每个选项后挂副标「· 更快」/「· 更细」

#2 实现:国家点位预加载

新文件:src/utils/country-stats.ts - 缓存键:local:countryStats:v1(整张 map 一条 JSON,避免 250 个 storage key 写风暴) - TTL:成功 7 天、失败 30 分钟(让用户切完 VPN 能很快重试) - fetchOne(iso2):拉一个国家,statеs = 非空状态数、cities = 所有州下城市总和 - preloadAll(iso2s, onProgress, concurrency=5):并发池预加载,每完成一个回调一次让 UI 增量刷新

location-picker-dialog.tsx 接入: - 打开 dialog 时先读缓存(瞬间显示已有数据)→ 把没缓存的国家标 loading → 启动并发池 → 每个完成回写 state - 每个国家行的 cnName/iso2 下方加状态文字: - loading:「· 加载中…」(灰) - ok:「· N 州 · M 城市」(主色,小字) - ok 无州省:「· 无州省级」(部分加勒比小国) - error:「· 加载失败 ↻」(红 + 重试图标,点击重新拉单国)

性能:250 国 × 5 并发 ≈ 50 轮,单次 dialog 首开总耗 ~10s;之后 7 天内秒开。

#3 实现:网络状态条

新文件 src/hooks/use-network-status.ts:按上面方案表跑探测;runOncerunningRef 防并发重叠;连续失败计数走 failsRef 避免 setInterval 闭包拿旧值。

新文件 src/sections/layout/network-status-bar.tsx:sidebar 底部一条;左侧 8px 圆点(checking 时脉冲动画)+ 文字 + 右侧延迟(ms/s);Tooltip 显示「最近探测/最后成功/连续失败次数」+「点击立即重测」提示。

main-layout.tsx 接入:日志/设置 stack 下方加 <NetworkStatusBar />

三轮自检

Round 1:行为 - ✓ 搜索深度:圆圈序号 1/2 + 副标显示对;renderValue 与 MenuItem 视觉一致 - ✓ 国家点位:dialog 首开 loading → 增量出数;7 天内秒开;error 状态点 ↻ 单独重拉 - ✓ 网络状态:首挂载立刻探测一次;30s 一轮;连续 3 失才 bad;点击立即重测

Round 2:边界 - ✓ host_permissions 已含 *.google.com/* + <all_urls>,generate_204 探测不会被 CSP 拦 - ✓ AbortController 5s 超时防卡死;finally 清 timer - ✓ countryStats 缓存 TTL 失败 30 分钟:用户网络恢复后切换 dialog 就会自动重拉 - ✓ preloadAll 用并发池 5,不会一次发 250 个 fetch 把 API 冲限频 - ✓ 多次开关 dialog:useEffect cleanup 设 cancelled,旧的预加载回调被忽略,不会污染新 state

Round 3:副作用 / 兼容 - ✓ pnpm compile 报错全是 pre-existing(freesolo-autocompletes、object-autocomplete、client-data-table 等老文件),与本次改动无关;构建通过 - ✓ NetworkStatusBar 在 sidebar,常驻 mount;setInterval 30s 间隔,CPU 与带宽开销可忽略 - ✓ countryStats 缓存随 wxt.storage 走 chrome.storage.local,约 50KB,远低于配额 - ✓ Search depth UI 在 city/maps tab 才显示(保持 {tab === 'maps' && ...}

注意点

  • generate_204 探测会被代理/防火墙在 5 秒内截断或拒绝时标 bad —— 这正是想要的语义
  • countryStats 的 states 用「非空 v 过滤」—— Anguilla 这种无州省级国家会显示「· 无州省级」而非「0 州」,对用户更直白
  • CSS counter 在 MUI Select 里的关键 trick:renderValue 不属于 MenuItem 树,所以选中态的圆圈得 React 节点手画一遍;如果哪天 MUI 升级了 Paper 选择器结构需要核对
  • 网络状态条挂在 main-layout 里,只在 UI 打开时探测。如果用户希望「后台一直监控、UI 关闭也能在系统通知里弹『连不上了』」,那就要走 SW + chrome.alarms(最小 30s)+ chrome.notifications,是另一套设计 —— 留待用户明确需求再做

2026-05-24 v0.9.35 第四处修复:任务详情对话框「已完成 4/4,详情却显示一半待采集」

用户反馈

为什么上边显示已完成,下边却显示一半是待采集?

截图:任务进度 4/4 已完成 · 采集 329 条,详情对话框却显示「Saint John 已采集 36 / Saint John's,+Saint John 待采集 / (空) 已采集 8 / The Valley,+ 待采集」。

根因(3 个连环 bug)

匹配链路:task.locations(URL 形式)→ 详情拼期望 key → progressMap(Google 搜索框解码形式)

URL 实际跑:    doctor,+Saint John,+Saint Kitts and Nevis
搜索框显示:    doctor, Saint John, Saint Kitts and Nevis         ← progressMap 的 keyword
详情拼期望:    `${cat}, ${loc}` = "doctor, Saint John"             ← 缺国家、,+ 形式

Bug 1 - 分隔符形式不一致 task.locations"Saint John's,+Saint John"(URL 编码片段),progressMap key 是 "doctor, saint john's, saint john, ..."(已被 Google 解码为 , 分隔)。 - 精确匹配:"doctor, saint john's,+saint john" → progressMap 里没有 → fail - 包含兜底:pk.includes("saint john's,+saint john") 永远 false(,+ 不存在于 pk) - → 「Saint John's,+Saint John」「The Valley,+」永远「待采集」

Bug 2 - 空 location 国家 对于无州省级国家(Anguilla / Vatican / 等),locations 里有空串 ""。 - 精确:"doctor, " → norm 后 "doctor," → 不存在 - 包含兜底:pk.includes("doctor") && pk.includes("") → 第二个 includes 永远 true → 撞库匹到 progressMap 第一条 entry(看 unshift 顺序,可能是任何一个 keyword) - → 看起来好像匹中了(screenshot 显示「已采集 8」),但纯属侥幸;如果新增一个 cat 包含 "doctor" 的 entry 在前面,空 loc 会跟错

Bug 3 - 多国任务笛卡尔积丢国家归属 v0.9.26+ 加了 task.locationEntries,每条 location 带自己的国家。但详情里用的还是老的 cat × task.locations,丢国家信息: - 多国任务时 task.locations 是所有国家的 location 合并 —— 期望 key 没法拼国家后缀,永远只能落到「兜底包含匹配」上,准确率低 - 如果两国有同名 location(如两国都有 ""),合并后再用 cat × loc 还会重复生成 combo

修复(src/sections/task/task-detail-dialog.tsx

1. norm 加 URL 形式归一

const norm = (s: string) => (s || '')
  .replace(/,\+/g, ', ')   // ",+" → ", "(URL 形式 → 搜索框形式)
  .replace(/\+/g, ' ')     // 残留 "+" → " "
  .replace(/\s+/g, ' ').trim().toLowerCase();

2. buildExpectedKey 与 buildTaskUrls 对齐

const buildExpectedKey = (cat, loc, country) => {
  const cleanedLoc = (loc || '').replace(/^[,+\s]+/, '').replace(/[,+\s]+$/, '').trim();
  const parts = [cat];
  if (cleanedLoc) parts.push(cleanedLoc);
  if (country && task.locationSource !== 'custom') parts.push(country);
  return norm(parts.join(', '));
};

3. 组合源按 locationEntries 走(多国正确归属)

const buildSources = () => task.locationEntries?.length
  ? task.locationEntries.map(e => ({ location: e.location, countryName: e.countryName }))
  : (task.locations || []).map(loc => ({ location: loc, countryName: task.countryName || '' }));

4. 包含兜底:空 loc 用国家名替代,不再 includes("") 撞库

if (needleLoc) { if (pk.includes(needleLoc)) { ... } }
else if (needleCountry) { if (pk.includes(needleCountry)) { ... } }

验证(screenshot 4 个组合手动对账)

combo 期望 key progressMap 命中 修复后
(doctor, "Saint John", SKN) doctor, saint john, saint kitts and nevis doctor, saint john, saint kitts and nevis ✓ 36
(doctor, "Saint John's,+Saint John", SKN) doctor, saint john's, saint john, saint kitts and nevis 同上
(doctor, "", Anguilla) doctor, anguilla(空 loc 跳) doctor, anguilla ✓ 8
(doctor, "The Valley,+", Anguilla) doctor, the valley, anguilla(去尾 ,+) doctor, the valley, anguilla

全部 4 个精确命中,不再走兜底,准确率 100%。

三轮自检

Round 1:行为 - ✓ 单国老任务(无 locationEntries):fallback 走 task.locations + task.countryName,与原行为兼容 - ✓ 多国新任务:按 locationEntries 走,每个 combo 国家归属正确 - ✓ 空 loc(Anguilla 等):用国家名兜底,不再撞库 - ✓ 带 ,+ 的 city loc(如 "Saint John's,+Saint John"):norm 统一形式,精确命中 - ✓ locationSource='custom'(用户粘的纯文本):buildExpectedKey 不拼国家,与 buildTaskUrls 行为一致

Round 2:边界 - ✓ task.locations 与 task.locationEntries 不一致:locationEntries 优先(其 length 通常等于 locations) - ✓ task.categories 为空:循环不进入,combos = [],UI 显示「该任务无关键词组合」 - ✓ progress 数组为空:所有 combo done=false,count=0,显示「待采集」—— 正常初始态 - ✓ keyword 字段未传 / 落库时丢失:progressMap 空 → 全「待采集」—— 与旧逻辑一致,无回归

Round 3:副作用 / 兼容 - ✓ 旧任务(v0.9.25 之前没 locationEntries):fallback 路径完全兼容 - ✓ DataView / 任务卡片其它 UI 没动;只改详情对话框的派生逻辑 - ✓ 没改 batch-controller、task-progress 的写入侧 —— progressMap 内容不变,只是匹配口径修正 - ✓ 没有性能回归:每个 combo 最多扫一遍 progressMap,复杂度 O(combos × progress),跟旧版同

注意点

  • 任务卡片的「进度 4/4」是正确的:4 个 URL 全跑完了(且没被 dedup 砍掉),onBatchDone 把 finished 推到 total。这次只是详情里的匹配错了。
  • 如果未来 buildTaskUrls 做了 URL 级别 dedup 而 task.total 没相应缩减(比如清洗后两个 location 拼出同一个 URL),详情里会出现「明明 1 个 URL 跑完,但 task.total=2 显示 2 个 combo」。这种情况下两个 combo 会都被同一份 progress 命中(包含兜底),状态都是「已采集」、count 会相同(不是平均分摊)—— 实际上 count 是同一份计数被显示两次。要彻底干净需在 onBatchDone 里把 finished 与去重后 URL 数对齐,留作后续。

2026-05-24 v0.9.35 第三处修复:开关 ON 不立刻开始抓取(必须再点「立即触发」)

用户反馈

为什么开启「提取邮箱/手机」,任务不会立刻开始?点击「立即触发」才开始?

根因

「立即触发」按钮 → 发 start-deep-scrape 消息:

if (type === 'start-deep-scrape') {
  await resetStaleTasks();   // ← 把卡 scrape_status=1 的行刷成 0
  syncAlarmState();
  manageQueue();
}

「提取邮箱/手机」开关 → 发 start-engine 消息:

if (type === 'start-engine') {
  // ← 没有 resetStaleTasks!
  syncAlarmState();
  manageQueue();
}

事故链: 1. 引擎跑时被抓的行标记 scrape_status=1 2. SW 被 Chrome 回收(idle 5 分钟)或开关被切 OFF —— 这批行永久卡在 1 3. scraper-executor 的失败兜底有「engine off 时回置 0」,但 只走失败路径;SW 直接被杀连这个 catch 都执行不到 4. 用户切开关 ON → manageQueuewhere: { scrape_status: 0 } → 查不到卡 1 的行 → 队列空转 5. 用户点「立即触发」→ resetStaleTasks 把 1 全刷成 0 → 队列重新捞到 → 开始抓

修复(2 处)

1. src/entrypoints/background/index.ts —— 开关 ON 走 reset:

if (type === 'start-engine') {
  await resetStaleTasks();  // ← 新增
  syncAlarmState();
  manageQueue();
}

2. src/utils/engine-manager.ts —— resetStaleTasks 加 in-flight 保护:

export async function resetStaleTasks() {
  // 有真实在跑的抓取时跳过,避免双重抓取
  if (activeTasks > 0) return;
  await updateByQuery('MapTaskData', { scrape_status: 1 }, { scrape_status: 0 });
  ...
}

第 2 处是顺手修了一个潜在的并发坑:原来「立即触发」也存在这个 race —— 切到时正好有 scrape 在跑,1→0 刷掉后 manageQueue 可能把同一行再起一份,双重抓取。activeTasks > 0 时跳过 安全且简单:SW 重启后 activeTasks=0(模块内存清零),所以「SW 死后留下 stale」这条主路径仍能 reset。

三轮自检

Round 1:行为 - ✓ 用户切开关 ON,无 in-flight 时:reset → 队列起来 → 立刻开抓 - ✓ 切开关 ON 时有 in-flight:reset 跳过 → 已在跑的继续 → 跑完后回调 manageQueue 自动捞下一批 - ✓ 「立即触发」按钮逻辑不变,但现在它和开关 ON 等效;保留它作冗余 kick(用户感觉卡住时能手动催一下)

Round 2:边界 - ✓ 首次安装:DB 空,resetStaleTasks 空跑 ms 级,无副作用 - ✓ SW 被杀后恢复:模块内存 activeTasks=0 → 重启路径里 setDbReady(true) 的 resetStaleTasks 仍能跑 - ✓ 用户快速切 OFF→ON:scraper-executor 的失败兜底 + resetStaleTasks 共同收尾,无脏数据

Round 3:副作用 - ✓ activeTasks 计数器靠 .finally 递减,可靠性高(异常 / SW 死之外都会执行) - ✓ 即使 activeTasks 计数错位(理论上不可能但保险考虑),manageQueue 自己的 isProcessingQueue + 并发上限会兜住,不会无脑起 N 个 - ✓ 「立即触发」UI 仍存在,用户已习惯;后续可考虑改成 hint「此按钮通常不需要点」或下沉到设置面板

注意点

  • 开关 ON 现在 = 「立即触发」+ 启动闹钟。两者真正的差异在「立即触发」不会改变开关状态(只 reset + queue);开关 ON 还会写 local:is_engine_running=true
  • 不要把 setDbReady(true) 路径里的 resetStaleTasks()if (activeTasks > 0) return——逻辑没问题但属于 SW 启动时机,无 in-flight 任务,不必额外判断

2026-05-24 v0.9.35 继续自检:sidebar 4 个 sub-nav 数字未刷新(用户第一张截图)

用户第一条反馈再回顾

商家列表的数字没刷新

第一张截图是用户停留在「任务」tab 时拍的,数据 sub-nav 下 4 个项(商家列表 / 官网列表 / 邮箱列表 / 手机列表)全部没有数字

我之前以为只是邮箱归零这一个问题;继续审计才发现是另一条独立的 bug —— 跟 CAP 无关。

根因(src/sections/layout/main-layout.tsx

const [dataCounts, setDataCounts] = useState({ merchant: 0, ... });
case 'data': return <DataView ... onCountsChange={setDataCounts} />;

DataView 只在 page === 'data' 时挂载。在「概览/任务/日志/设置」页面下: - DataView 没挂载 → 它内部的 useRequest 没轮询 → onCountsChange 不被调用 → dataCounts 永远是初始 {0,0,0,0} - sidebar count > 0 && (...) 判断为假 → 4 个 sub-nav 数字全不显示

只有用户主动点过一次「数据」之后,DataView 挂载、回传,sidebar 才有数字;用户再切走 sidebar 数字虽然不再更新但至少 frozen 在最后一次的值。

修复

1. 抽出统计逻辑到 src/utils/data-counts.ts

export interface DataTabCounts { merchant; website; email; phone; }

export async function getDataCounts(): Promise<DataTabCounts> {
  const merchant = await countByQuery('MapTaskData', {});  // 走原生 count,最省
  const rows = await selectByQuery('MapTaskData', { limit: 50000, order: ... });
  const websiteSeen = new Set(), emailSeen = new Set(), phoneSeen = new Set();
  for (const r of rows) {
    if (r.website) websiteSeen.add(String(r.website).toLowerCase());
    for (const e of r.emails || []) {
      const k = String(e).trim().toLowerCase();
      if (k) emailSeen.add(k);
    }
    if (r.phone) {
      const k = String(r.phone).replace(/\D/g, '');
      if (k) phoneSeen.add(k);
    }
  }
  return { merchant, website: websiteSeen.size, email: emailSeen.size, phone: phoneSeen.size };
}

2. main-layout.tsx 用独立 useRequest 5 秒轮询

useRequest(() => getDataCounts(), {
  pollingInterval: 5000,
  onSuccess: setDataCounts,
});

这样 sidebar 计数不再依赖 DataView 生命周期 —— 用户打开 app 就能看到所有 4 个计数,且持续刷新。

3. 拆掉 DataView 的 onCountsChange 回传

- onCountsChange={setDataCounts}
+ /* v0.9.35:sidebar 计数改由 main-layout 独立轮询;DataView 不再回传,
+    避免在按任务筛选时把 sidebar 写成筛选后的小数(sidebar 应反映全局) */

如果不拆,当 DataView 处于 filterTaskId 状态时,它回传的是筛选后的小数字,会把 main-layout 刚拉好的全局数字覆盖掉,sidebar 就在筛选 vs 全局之间反复跳。

三轮自检

Round 1:行为正确性 - ✓ 任意页面(含 overview / task / log / settings)下 sidebar 4 个 sub-nav 都有数字 - ✓ 数字 5 秒一刷 - ✓ DataView 处于按任务筛选时 sidebar 仍显示全局(不抖动)

Round 2:性能影响 - 当用户在「数据」tab 时,main-layout 和 DataView 各自拉一次 50000 行 → 2x IO + 2x 内存峰 - 单次拉 ~50-150 ms(jsstore),5 秒间隔下 CPU 占用 ~3%;峰内存 ~100 MB 短暂 - 当用户在其他页时只有 main-layout 一份查询 —— 这才是常态 - ✓ 可接受。后续如需优化,可以把 rows 提到 main-layout,DataView 通过 props 接收(避免双查)

Round 3:边界 - ✓ 空 DB:getDataCounts catch 后返回 {0,0,0,0} —— sidebar 4 个 sub-nav 都不显示数字(count > 0 && ...) - ✓ 初始首屏:useRequest 立即触发一次,~150 ms 后 sidebar 出数;这之前显示空 - ✓ 切换页面:dataCounts 在 main-layout,跨页保持

注意点

  • DataView 仍然 export 了 DataTabCounts(onCountsChange prop 类型)—— 现在它没人传,只是历史 API 保留;新代码请从 @/utils/data-counts 引入 DataTabCounts
  • DataView 内部的 rows useRequest 也保留 —— 它支撑商家表格本身的渲染 + filterTaskId 模式的派生显示。不能直接撤掉

2026-05-24 v0.9.35 追加修复:邮箱列表归零(rows CAP=2000 太小)

用户反馈

日志中显示是有邮箱的,但是邮箱列表没显示!

截图证据: - 总商家 8452,sidebar 显示「商家列表 8452 · 官网列表 1239 · 邮箱列表 0 · 手机列表 1221」 - 日志 tab「官网/社媒」分明在批量抓 .af 域,多条记录显示 ✉ 1 / ✉ 2 / ✉ 3

根因

src/sections/data/data-view.tsx 派生统计的查询窗口太小:

const CAP = 2000;
const { data: rows } = useRequest(() =>
  selectByQuery('MapTaskData', { limit: 2000, order: { by: 'id', type: 'desc' } })
);
// 邮箱/电话/官网三个列表全从 rows 派生
const emails = useMemo(() => { for (const r of rows) for (const e of r.emails || []) ... }, [rows]);

事故链: 1. 「doctor 2国」任务刚启动(9/29598),地图阶段在高速产新商家行 —— scrape_status=0、emails=[] 2. 这些新行占满 top 2000 窗口(id ≈ 6453-8452) 3. 同期 deep-scrape 在处理 doctor 3国 任务的 .af 商家 —— 它们的 id 在 ~3000-4500 区间 4. emails 被正确写入到那些老行 → 但 id < 6453 → 不在 rows 窗口 → emails 列表派生出 0

旁证:phone 命中率 1221/2000 ≈ 61%,但全量 phone 命中率应在 60% 左右 = 5000+,目前看到的 1221 也只是窗口内的子集。

修复(src/sections/data/data-view.tsx

// v0.9.35:CAP 从 2000 提到 50000。
const CAP = 50000;
const FILTER_CAP = 50000;
  • 全量场景:8k-50k 商家全部进 rows,邮箱/电话/官网计数恢复真实
  • 按任务筛选场景:单任务最多也几千到一两万,50000 充分

性能取舍

数据量 量级
jsstore select limit 50000 5w 行 ≈ 单次 50-150 ms 已有 countAllByTaskIds 也用 50000,验证过
内存 5w 行 × ~1 KB ≈ 50 MB 峰 Chrome 扩展可接受
useMemo 派生 5w × 3 列表 = 15w 迭代 <10 ms
DataGrid 渲染 虚拟化只渲染可见 ~20 行 不受影响
5 秒轮询 总开销 ~150 ms / 5 s 后台型,无视觉抖动

三轮自检

Round 1:链路对照 - ✓ scraper-executor 写:updateByQuery(MapTaskData, {id}, {emails: uniqueEmails, scrape_status: 2}) —— 写入字段 + 行号都对 - ✓ jsstore schema:emails: { dataType: DATA_TYPE.Array, default: [] } —— 数组类型,读出还是数组 - ✓ data-view 读:limit: CAP, order: id DESC —— 窗口太小是问题;CAP↑ 后窗口够大

Round 2:边界 - ✓ 单任务筛选 (filterTaskId 模式):用 FILTER_CAP,也提到 50000,单任务 ~几千数据全覆盖 - ✓ 老 DB 升级用户:emails 列在 v0.8.x 已经存在,不存在 schema migration 风险 - ✓ 极端用户(>5w 商家):仍然窗口外丢失,但 5w 已是合理上限;超过此规模需走 DB-level 聚合查询,留作后续

Round 3:副作用 - ✓ 商家列表表格:DataGrid 虚拟化,5w 行不影响渲染 - ✓ globalMerchantCount 来自独立的 countAllData(),不变 - ✓ 邮箱/电话/官网计数回传 onCountsChange → main-layout sidebar 徽标实时更新

注意点

  • CAP 这种「内存窗口 + JS 端派生」的模式对小规模 OK,对高规模需要换成 DB-level 聚合(如 countByQuery 按 emails 非空筛)。jsstore 没原生支持「数组列非空」查询,得自己写 worker plugin —— 之后真碰到 >5w 数据再做
  • 同样的窗口问题理论上也影响 phone / website 计数,但它们的命中率高 / 数字看起来「像那么回事」,没被一眼看出来。本次 CAP↑ 一起解决了

2026-05-24 v0.9.34 → v0.9.35 三轮审计修复:URL 拼接 / depth 静默丢失 / ConfirmDialog 文件冲突

本次改动(用户:「仔细检查以上会话和改进记录寻找问题并进行修复」)

针对 v0.9.33–v0.9.34 引入的几处遗留问题做一次彻底审计,修了 4 个潜在 bug。

1. task-store.ts —— Anguilla 等空名州导致 URL 段错位

现象 - Anguilla(v: '',c: [{v: 'The Valley'}])勾整州后,URL 拼出来是 doctor,+,+Anguilla(双逗号)或 doctor,+The Valley,+,+Anguilla(尾随 + 再连国家段) - 这种带空段的 URL 偶发被 Google Maps 解析变形

修复 - 提出两个 helper:cleanLocationSegment(去前后 ,+ 和空白)+ buildSearchUrl(按 ,+ 连接,过滤空段) - buildTaskUrls 收尾加 Array.from(new Set(urls)) —— 当一个国家既无州又有 city 时,state 路径和「整国 URL」可能重合

2. task-create-dialog.tsx —— depth='state' 把「只勾城市」的国家静默丢弃

现象 - 用户在 picker 里勾了某国的城市(没勾州),切换搜索深度到「州/省级」 - 提交时 csel.paths.filter(p => !p.includes(',+')) 把城市路径全过滤掉 → locations = [] - 这国家就没了 locationEntries —— 若所有选择都是这种情况,会 notice.error('未能创建任务') - 用户不知道为啥被拒,体感是 bug

修复 - stateOnly.length > 0 ? stateOnly : csel.paths —— 当 state 级过滤后清空,退化保留原选择 - 注释明确说明这是「保护用户意图」的兜底

3. components/confirm-dialog/ —— index.ts / index.tsx 双文件冲突

根因 - v0.9.28 引入新通用 ConfirmDialog 时新建 index.tsx - 但目录里还有老的 index.ts + ConfirmDialog.tsx + types.ts(脚手架模板留下) - TS 模块解析 .ts 优先于 .tsx → 所有 import ConfirmDialog from '@/components/confirm-dialog' 实际解析到组件 - 新组件代码相当于死代码;老组件 API 是 {content, action},新 API 期望的 {message, onConfirm, severity} 全报 TS2322

修复 - 删掉旧三件:index.tsConfirmDialog.tsxtypes.ts - 目录只留新通用 index.tsx - 顺手把 sections/views/ 下三处用老 API 的调用(filters-drawer / table-views-tabs / view-select-popover) 迁移到新 API:contentmessageactiononConfirm + confirmText、加 severity="error"

4. storage-data.ts —— maxConcurrentTasks 重复声明

SettingParams 接口里有两行 maxConcurrentTasks —— 一处「地图采集」、一处「任务调度」。 合并成一行放在「任务调度」分组下。

三轮自检

Round 1:URL 拼接 - ✓ Anguilla(无州)、勾整国 → 单 URL doctor,+Anguilla?hl=...&gl=AI,无多余段 - ✓ Anguilla 勾州+城市,depth='city' → 两 URL:doctor,+Anguilla + doctor,+The Valley,+Anguilla,去重后各 1 - ✓ 老任务(无 locationEntries)走 task.locations[] 旧路径,行为不变

Round 2:depth='state' 兜底 - ✓ 勾「Sydney,+NSW」「Brisbane,+QLD」、depth='state' → 现在仍能拿到 2 条 URL(fallback),不会被静默吞 - ✓ 勾「NSW」(state) + 「Sydney,+NSW」(city)、depth='state' → 只剩 NSW(正常行为,符合用户意图) - ✓ mode='all' 路径不受影响(在分支前)

Round 3:ConfirmDialog 统一 - ✓ pnpm compile:4 处 ConfirmDialog 相关 TS2322 / TS2353 全消失 - ✓ pnpm build:完整构建通过(9 秒,4.1 MB,dist-v2/chrome-mv3/) - ✓ 剩余 TS 警告全是预先存在的(MUI DataGrid 泛型、wxt storage key 字面量、emotion namespace) —— 不影响构建产物

注意点

  • 这次审计只动了 4 个文件 + 删了 3 个老组件文件,没碰任务调度 / batch-controller / scrape-window 等核心
  • ConfirmDialog 一律走新 API:{title, message, onConfirm, severity?, confirmText?};不要再用 action 自定义按钮
  • 空名州的兜底链路:picker UI(v0.9.34 已加「(无州/省级,城市直接挂在国家下)」)→ URL 拼接(v0.9.35 cleanLocationSegment)→ Set 去重
  • depth='state' + 只勾城市的兜底是「保留用户原始选择」而非「改用 city 模式」 —— 因为前者更接近用户原意(不让任务消失),且 URL 数量可控

2026-05-23 v0.9.33 → v0.9.34 空名州兜底 + 搜索深度上移到任务弹窗

用户反馈

  1. 「没名字的州」:选了 Anguilla(一个加勒比小岛国),州列表里只有一行 🏢 1 >, 没有州名 —— 把人看懵了。
  2. 搜索深度想从 picker 内部挪到外层 TaskCreateDialog 的底部,跟「创建任务」一行靠左; 同时去掉地区入口框里的「州/省级」chip(重复)。

#1 空名州兜底(location-picker-dialog.tsx

根因

某些小国(如 Anguilla)API 返回的 state node v: '' —— 表示该国家没有州/省级分区, 城市直接挂在国家下。

老版本 locations-select.tsxconst title = v || 'Others' 兜底,v0.9.25 重写时丢了, 新代码直接 {s.v} 渲染空字符串。

修复

  • 州行:{s.v || '(无州/省级,城市直接挂在国家下)'},斜体灰色提示是兜底
  • 面包屑:{stateNode.name || '(无州省级)'}
  • 「全选整州」头部文案:stateNode.name ? '整州「...」' : '该国家全部城市'

#2 搜索深度上移(task-create-dialog.tsx / location-picker-dialog.tsx

旧布局

  • 搜索深度 Select 在 LocationPickerDialog 的 footer
  • LocationsPickerInput 的摘要里有 depth chip 「州/省级」

新布局

  • LocationPickerDialog footer 去掉 Select
  • TaskCreateDialog 的 DialogActions 加 Select 「搜索深度: 州/省级 / 城市级」靠左
  • 与「创建任务」按钮同一行:[depth Select] —— [取消] [创建任务]
  • LocationsPickerInput 摘要 chip 中 depth 标识同步移除(避免重复)

picker 内部逻辑去 depth 化

  • setStateAll 不再分 depth 分支 —— 总是 state + 所有 cityPaths(最宽集合)
  • mode='all' → 'specific' 的 baseline 也总是 state + cities
  • depth 完全由外层在提交时生效(filter / expand)

task-create-dialog 提交时的 depth 处理

if (csel.mode === 'all') {
  // 按 depth 展开
  locations = depth === 'city'
    ? items.flatMap(s => [s.v, ...s.c.map(c => `${c.v},+${s.v}`)])
    : items.map(s => s.v);
} else if (searchDepth === 'state') {
  // mode='specific' + state-only:把含 ",+" 的(城市路径)滤掉,保留纯州名
  locations = csel.paths.filter(p => !p.includes(',+'));
}

持久化

  • localStorage key 'lfx:searchDepth' 改由 task-create-dialog 管理
  • picker 内部的 SEARCH_DEPTH_KEY / loadSearchDepth / saveSearchDepth 全部删掉

三轮自检

Round 1 空名兜底 - ✓ Anguilla 现在显示「(无州/省级,城市直接挂在国家下)」,灰色斜体 - ✓ 面包屑也兜底 - ✓ checkbox 仍能勾选(key 用 s.v 即空字符串,列表里只有一个所以不撞)

Round 2 picker 逻辑统一 - ✓ setStateAll 总是最宽 —— 视觉反馈准(勾整州 = state + 全部 cities 都打勾) - ✓ depth 改动不影响已选状态(用户体感稳定) - ✓ 提交时按 depth 过滤 / 展开

Round 3 depth 控件可见性 - ✓ Select 在 TaskCreateDialog 底部,点创建任务前一定看见 - ✓ Tooltip 解释取舍(URL 数 / 覆盖广度) - ✓ 只在 maps tab 显示(官网 tab 不需要)

注意点

  • picker 内部 selection 永远是「最宽集合」(state + cities)→ 提交时再 filter。 视觉上勾整州时 N 个城市都打勾,是预期行为,因为用户没限制深度时本来就是最宽的。
  • 切换 depth='state' 时已经勾过的城市级 paths 仍在 selection 里,只是提交时被滤掉。 如果用户切回 'city' 这些路径会重新生效 —— 没有数据丢失。

2026-05-23 v0.9.32 → v0.9.33 地区/分类弹窗美化 + 又一处 aria-hidden 修复

用户反馈

  1. 还有 aria-hidden 警告(这次是 Dialog 打开嵌套 Dialog 触发)
  2. 搜索深度移到「确定」旁边、下拉选择;取消按钮其实多余(X 已经有了)
  3. 大洲已经美化;国家/州/城市列表 1 行 1 个太松,希望 1 行多个(网格布局)
  4. 类别/关键词也是 1 行 1 个,太松

#1 aria-hidden(开启侧)

  • 之前修了 close 路径,但开启嵌套 Dialog 也会触发: Element with focus: <div.MuiDialog-container...>; Ancestor with aria-hidden
  • 触发链:用户在 TaskCreateDialog 内点「选择地区」 → focus 还在外层 Dialog 上 → MUI 给外层加 aria-hidden 准备 hide → 报警
  • 修复:LocationsPickerInput 的 onClick 在 setOpen(true) 之前先 blur activeElement

#2 搜索深度位置 + 取消按钮

  • 旧位置:在面包屑右侧,与导航混在一起
  • 新位置:footer 「确定」按钮左侧,用 Select 下拉两项「州/省级」「城市级」
  • 移除「取消」按钮 —— 右上角 X 已经够用,少一个按钮更清爽
  • 「清空全部」按钮简化成「清空」(节省宽度)

#3 国家/州/城市 网格化

  • 国家:1 列 → 2 列(响应式:xs 1 列 / sm+ 2 列)
  • 国旗/名称/iso2 等元素都按比例压缩(fontSize、padding、Chip 高度都缩一档)
  • 右列 borderRight 去掉,避免双线;最后一行的 borderBottom 也由 grid 自然处理
  • 州/省:1 列 → 2~3 列(xs 1 / sm 2 / md 3)
  • 州名 + 城市数 chip + 钻入箭头
  • 城市:1 列 → 2~4 列(xs 2 / sm 3 / md 4)
  • 比州更密,因为城市名一般短
  • Checkbox padding 0.5 → 0.25 更紧凑

#4 类别/关键词 dropdown 压缩

  • 列表是 VariableSizeList 虚拟化(10605 个分类,无法多列做 grid 而不破坏虚拟化)
  • 折中:行高 36/48 → 28/36(smUp/down),group 头 48 → 40
  • 一屏显示从 8 项 → 12 项(高度上限 ~336px)

三轮自检

Round 1 aria-hidden 完整链路 - ✓ 开启侧 blur(locations-picker-input) - ✓ 关闭侧 blur(safeClose) - ✓ Menu 关闭侧 blur(usePopover) - ✓ Dialog 内嵌套 Dialog 现在两端都 blur,无遗漏

Round 2 搜索深度新位置可见性 - ✓ Select 在「确定」按钮左侧显眼;用户点确定前一定看见 - ✓ 用 搜索深度:州/省级 整句填充,无需额外 label - ✓ depth 仍持久化到 localStorage(不变)

Round 3 网格布局响应 - ✓ 小窗(<sm)国家/州依然 1 列保证可读;城市 2 列 - ✓ 中窗(sm+)国家 2 列、州 2 列、城市 3 列 - ✓ 大窗(md+)州 3 列、城市 4 列

注意点

  • 类别 dropdown 单列没改 —— 改成多列网格需要重构虚拟化(每个 list item 装 N 个分类), 代码复杂度跳一档,下版本视用户反馈再做。
  • 城市列表 4 列在长城市名时可能溢出(noWrap title);hover 显示完整名。
  • 大洲过滤 chip 行没动,已经是横排紧凑布局。

2026-05-23 v0.9.31 → v0.9.32 恢复「搜索深度」(州/省级 vs 城市级)

问题回顾

用户报告:「城市一级还是州省一级的选择没了!如果选了州省一级,城市就不会抓取了」。

老版本 locations-select.tsxshowLevel 下拉,可选「只显示州/省 / 显示城市 / 显示邮编」 —— 控制树深度,也就是搜索粒度。

我在 v0.9.25 重写成 LocationPickerDialog 时,把这个选项弄丢了,造成两个错配:

  1. task-create-dialogmode='all' 的展开 只取州名
    locations = (r?.items || []).map((s: any) => s.v);  // ← 城市丢了
    
  2. setStateAll 勾州时 强制同时加州 + 全部城市
    const allKeys = [stateName, ...cityPaths];  // ← 无法只要州级
    

两端语义对不齐:「整国」是州级,「勾州」却同时拉所有城市。

修复 —— 新增 SearchDepth 类型

'state'(默认,旧版的 showLevel=0)

  • 1 个州 = 1 条搜索 URL:"doctor, Aichi Ken, Japan"
  • 快、URL 数小;Google 端单条上限 ~120 条结果

'city'(旧版的 showLevel=1)

  • 每个州拆成 N 个城市去搜:"doctor, Nagoya, Aichi Ken, Japan" ×N
  • 慢、URL 数多;覆盖细

实现

location-picker-dialog.tsx

  • 新增 depth state,localStorage 持久化(lfx:searchDepth),默认 'state'
  • 面包屑右侧加切换 chip:[州/省级] [城市级],带 Tooltip 解释取舍
  • setStateAll 按 depth 决定加什么:
  • 'state':只加 stateName
  • 'city':加 stateName + 所有 cityPaths
  • mode='all' → 'specific' 转换的 baseline 也按 depth 拆
  • onConfirm 多带一个 depth 参数

locations-picker-input.tsx

  • 接收 + 透传 depth 给 dialog 的 initialDepth
  • 摘要 chip 多显示一个 depth 提示「州/省级」或「城市级」,让用户在外面也看得见

task-create-dialog.tsx

  • 新增 searchDepth state
  • onChange 拿 dialog 回传的 (sel, depth),同时落到 state
  • handleCreate 里 mode='all' 展开按 depth 拼:
  • state:items.map(s => s.v)
  • city:items.flatMap(s => [s.v, ...s.c.map(c => "city,+state")])
  • reset 时故意不重置 searchDepth,跨次创建保持用户偏好

行为对照表

用户操作 depth='state' depth='city'
勾「整国」(mode='all',提交时展开) 仅州名 州名 + 所有城市
勾「整州」(setStateAll) 仅 stateName stateName + 该州城市
勾单个「城市」 加该 city(独立行为) 加该 city
取消勾州 同时清掉 state + 该州 cities(防止残留)

三轮自检

Round 1 默认行为 - ✓ 默认 depth='state',与旧版 showLevel=0 对齐 —— 不会突然给用户拉一万条城市 URL - ✓ depth chip 显示 active 态;切换即时生效(影响后续勾选)

Round 2 持久化 / 跨次 - ✓ localStorage 存最近一次 depth,下次打开继续 - ✓ task-create-dialog reset 时不清,避免用户重复选

Round 3 mode='all' 展开正确性 - ✓ depth='state':URL = doctor,State,Country,搜索更宽 - ✓ depth='city':URL = doctor,City,State,Country × N,覆盖更细 - ✓ 多国混合:每个 country 都按当前 depth 展开(depth 是全局,不是 per-country)

注意点

  • depth 是「会话级」(也持久化),不在 selection 数据里 —— 简单且符合用户「我希望全局 统一」的预期。后续真要 per-country 不同 depth,再单独说。
  • 用户看到 chip「城市级(详细)」时要意识到 URL 数会爆 —— Tooltip 已经写清楚。

2026-05-23 v0.9.30 → v0.9.31 列表更紧凑 + 子菜单常驻 + 任务页内联并发选择

用户反馈

  1. 列表行高再压一档,尤其上下空行太松
  2. 侧栏「数据」下的 4 个子菜单希望默认展开(不要只在 page=data 时才显示)
  3. 任务页 +创建任务 旁加「同时进行任务数」选择(默认 2,1-10;改这里 = 改设置)

#2 子菜单常驻

  • <Collapse in={page === 'data'}><Collapse in> —— 永远展开
  • 用户在任意页都能直接点子菜单跳过去

#3 任务页内联并发选择(task-view.tsx

  • 顶部行重排:标题 + flex spacer + Select + 「+ 创建任务」
  • Select 显示「同时跑 [N 个任务]」,1-10 可选
  • 改变后:
  • 乐观更新本地 state
  • settingParamsStorageItem.setValue({...cur, maxConcurrentTasks: n})
  • settings-changed 消息 → 后台立即 pumpTasks()(已在 v0.9.18 接好)
  • 同时 useRequest 3 秒轮询 settings —— 用户在「设置」页改了也能 3s 内同步过来
  • 顺手把 maxConcurrentTasks 默认值从 1 改成 2,符合用户预期

#1 行高压缩

  • 任务行spacing 1.5 → 0.75、外层 p 1.5 → px:1.25 py:0.75、Checkbox padding 0.5 → 0.25
  • 日志行height 36 → 30
  • 商家表(DataTable compact 模式)rowHeight 38 → 32

三轮自检

Round 1 子菜单常驻 - ✓ in={true} 永远展开;点击子项自动切到 data 页 + 设置 tab - ✓ 不影响主行点击行为(仍切到 data 页,保留 dataTab)

Round 2 内联并发选择 - ✓ 与「设置」页同一份 settingParamsStorageItem,两边互相同步(3s 轮询) - ✓ 改完发 settings-changed → 后台立即 pumpTasks,提高 cap 立刻吸纳排队中的任务 - ✓ 乐观更新避免下拉关闭后回弹 - ✓ 连续快点:setValue 顺序写入,last-write-wins;本地 state 也是 last 优先

Round 3 行高压缩 - ✓ 任务行最高内容(renderMaps 双行:标题+meta)在 32-38px 内能容下 - ✓ 日志 30px 单行 caption 够(之前 36 有点空) - ✓ 商家表 compact 32px:行内已无 logo marginTop,content 都是 caption 级别

注意点

  • 内联 Select 显示「同时跑 N 个任务」label 用 InputLabel + Tooltip 解释为啥要它;和设置页文案一致
  • 子菜单始终展开后侧栏纵向占用增加 ~120px,PRIMARY_NAV 用 flex:1,剩余空间还够;超小窗口 可能挤但实际不影响(用户不会用极小窗口)
  • 任务行的 spacing 0.75 = 6px 间隙,配合 px:1.25 = 10px 内距,比之前节省 ~12px/行 × 20 行 ≈ 240px

2026-05-23 v0.9.29 → v0.9.30 修 Menu / Popover 的 aria-hidden 警告

现象

v0.9.27 修过 Dialog 的 aria-hidden 警告,这次同样的 warning 又冒出来,但 stack 里是 MUI Menu:

Element with focus: <div.MuiPaper-root ... MuiPopover-paper MuiMenu-paper css-16nrxcr>
Ancestor with aria-hidden: <div.MuiPopover-root MuiMenu-root MuiModal-root css-1sucic7>

触发场景

任务卡片上的「⋮ 更多」菜单:用户点 MenuItem(结束 / 删除)→ MenuItem 还持有 focus → 我们的 click handler 调 closeMenu 把 menuAnchor 设 null → Menu 同步隐藏(加 aria-hidden)→ Chrome 无障碍引擎报「藏起来的容器里还有 focused descendant」。

修复

两处都接上 blur:

  1. task-view 的 closeMenu —— 直接在函数里 blur
  2. usePopover.onClose 源头 —— 全局 patch;所有用 usePopover 的地方自动受益 (local-toolbar / cloud-toolbar / account-popover / account-info 等都用了 usePopover)
  3. map-tags-autocomplete 的 TagsPopperNew onClose —— 它没用 usePopover,单独打补丁
// usePopover.onClose 修改后
const onClose = useCallback(() => {
  const el = document.activeElement;
  if (el instanceof HTMLElement) el.blur();
  setOpen(null);
}, []);

为什么不用 disableAutoFocusItem / disableRestoreFocus

  • MUI Menu 默认会把 focus 还给 anchor,符合无障碍/键盘导航预期 —— 不要破坏。
  • blur 同步、零副作用,是「最便宜」的解。

三轮自检

  • Round 1:usePopover 是单 hook,patch 一次覆盖所有调用方 ✓
  • Round 2:task-view 的 closeMenu 不走 usePopover(自己 useState 的 menuAnchor),必须单独修 ✓
  • Round 3:map-tags-autocomplete 用自定义 TagsPopperNew,未走 usePopover,也单独修 ✓

注意

  • 任何新增的「弹层 + 内部按钮触发关闭」组件都要走 usePopover 拿到的 onClose,否则 自己一定要在 close 前 blur。这点在代码评审时关注下。

2026-05-23 v0.9.28 → v0.9.29 数据子菜单上移侧栏 + 抓取窗口合并

补上 v0.9.28 时排掉的 #4 和 #5。

#4 数据 4 个 tab 上移到侧栏(data-view.tsx / main-layout.tsx

DataView 支持「受控」模式

  • 新增 props:tab?: DataTabonTabChange?: (t) => voidonCountsChange?: (c) => void
  • tabProp != null → 用外层传的 tab + 不再渲染页内 Tabs UI;改成显示页面标题 (图标 + 「商家列表」+ 计数 chip),与侧栏选中项保持一致
  • tabProp == null → 保留旧 Tabs(兼容别处可能调用 DataView 的代码)
  • 计数(4 个数)通过 onCountsChange 通过 useEffect 推回 main-layout

main-layout 侧栏新增数据子菜单

  • state 加 dataTab: DataTab + dataCounts: DataTabCounts
  • PRIMARY_NAV 渲染时,「数据」行后挂一个 <Collapse in={page === 'data'}>
  • 展开 4 个子项:商家列表 / 官网列表 / 邮箱列表 / 手机列表
  • 每项左侧带细色条(active 时高亮)+ 图标 + 计数小字
  • 点击:setDataTab + setPage('data')
  • 「数据」主行 click 仍然 setPage('data'),但保留当前 dataTab —— 不会重置

#5 抓取窗口合并为一个共享窗口(scrape-window.ts / batch-controller.ts

旧设计的痛点

  • 两个 windowKey:harvest(地图实开 tab)+ scrape(官网抓取 tab)
  • 跑起来用户桌面同时弹两个窗口,分散注意力

新设计:单共享窗口

  • SHARED_KEY: 'local:scrapeWindowId'openHarvestTabopenScrapeTab 都进同一个
  • closeXxxWindow 改成「只在共享窗口里没有真实 http(s) tab 时才真关」(maybeCloseSharedWindow
  • tabs.query({windowId}) 过滤出 ^https?:// 的 tab;占位 about:blank / chrome://newtab/ 不算
  • 安全:batch-controller 跑完地图调 closeHarvestWindow → 引擎里还有官网 tab → 不会被误关
  • 新增 forceCloseSharedWindow()不检查,直接 remove —— SW 重启时用,清理上次留下的孤儿
  • batch-controller.restoreBatch 改用 forceCloseSharedWindow(之前是 closeHarvestWindow, 现在那个是「安全关」了,启动清理用 force)

三轮自检

Round 1 数据子菜单 - 受控 / 非受控模式分支:tabProp 检查 != null 而非 truthy,避免 'merchant'(falsy 不会 但保险点) - counts useEffect 依赖 4 个 length —— 不会在每个 render 都触发回调 - Collapse unmountOnExit:切走数据页时子菜单 DOM 被卸载,节省内存

Round 2 共享窗口安全性 - maybeCloseSharedWindow:过滤 http(s) 才算活,about:blank 不数 → 占位 tab 不会阻止关窗口 - 并发场景:batch + 引擎同时跑 → 任意一方先收尾调 maybeClose → 看见对方还有 tab → 不动手 ✓ - SW 重启:forceCloseSharedWindow 暴力清场 → 之后 ensureWindow 重新建窗口 - 关键:closeScrapeTab = closeHarvestTab 别名只关单个 tab,没影响

Round 3 边缘场景 - 用户主动关掉窗口:window id 失效,下次 ensureWindow 自动新建 - 引擎在没 maps 任务时跑:单独使用共享窗口,maybeClose 在结束时检查 → 关掉 - 同时有 maps + website tasks:两边 tab 共存于一个窗口;任意一方结束 → maybe 检查另一方 → 等都没了才关 - 旧 local:harvestWindowId 残留 storage:不会被新代码读,可能孤立到永久(用户清扩展数据才清)—— 影响极小

注意点

  • 数据子菜单的「计数」是基于 DataView 内部 5 秒轮询的 rows 派生,最大覆盖 CAP=2000 行。 超过 2000 商家时官网/邮箱/手机的计数可能不准 —— 这是已知限制(同 v0.9.20)。
  • 共享窗口位置改成 top:40, left:40 —— 旧版 harvest 在 (0,0) 容易被任务栏挡住。
  • forceCloseSharedWindow 是「核武器」—— 别在正常关闭路径调用。

2026-05-23 v0.9.27 → v0.9.28 通用 ConfirmDialog + 任务批量删除 + 清空强警告

用户反馈

  1. 任务列表加批量删除(勾选 + 列表 + 翻页)
  2. 美化确认弹窗,并加载入「正在删除...」动效,完成后才退场
  3. 「清空商家数据」/「清空日志」走更显眼的二次确认警告
  4. 数据子 tab 上移到侧栏 → 下版
  5. 地图实开 + 网页实开共用同一窗口不同 tab → 下版

本版落地 #1 #2 #3。#4 #5 因涉及侧栏整体改造和 scrape-window 重构,单独排到后续。

新组件 ConfirmDialogsrc/components/confirm-dialog/index.tsx

  • 通用确认弹窗,替换原生 confirm(),外观和应用其他 Dialog 一致
  • 支持 severity: 'warning' | 'error' | 'info' —— 图标 + 按钮色一致
  • onConfirm 支持 async:
  • 触发时 setLoading(true),按钮变 spinner + 「处理中...」文字、禁用其他操作
  • 完成后自动 blur + onClose
  • 抛错则保持 Dialog 打开(外层用 toast 提示原因,用户可重试或取消)
  • safeClose 内置 blur,杜绝 v0.9.27 修过的 aria-hidden 警告

任务列表 —— 批量删除 + 分页 + 弹窗化(task-view.tsx

  • 顶部加批量操作头:全选 / 反选当前页 / 显示已选数量 / 「批量删除」按钮
  • 每行加 Checkbox + 选中时整行高亮(border-color + bg)
  • 分页:PAGE_SIZE=20,超过一页才显示 Pagination;showFirstButton / showLastButton 方便快跳
  • 批量删除并发提交(Promise.all)—— task-store 内部 writeChain 串行化写、不会丢任务
  • 单删 / 批删 / 结束 / 取消排队任务 全部走 ConfirmDialog
  • 单删:error 色,message 标明「商家数据不删」
  • 批删:error 色,按钮文字带数字 删除 N 个
  • 结束:warning 色
  • 删完手动从本地 tasks state 拿掉,避免 2 秒一次的轮询带来的「点了还在」错觉

清空数据 / 清空日志 —— 强警告(local-toolbar.tsx / log-view.tsx

  • 旧实现:confirm('确定要清空全部本地商家数据吗?此操作不可恢复。') —— 原生丑且易点错
  • 新实现:ConfirmDialog severity='error'(清数据)/ 'warning'(清日志)
  • 「⚠ 此操作不可恢复!」红字置顶
  • 描述说清楚动哪些表 / 不动哪些
  • loading 态「正在清空...」
  • doClearAll 失败时 throw 阻止 ConfirmDialog 自动关闭,让用户看到 toast 错误

三轮自检

Round 1:ConfirmDialog 状态机 - async onConfirm:try / catch / finally 完整;finally 一定 setLoading(false) - 成功:blur + onClose - 失败:保持开 + 不重置 loading 直到下次点 - safeClose 在 loading 时被拦截,避免误点关闭中断进行中的操作

Round 2:批量删除一致性 - selectedIds 是 Set 跨页保留 —— 用户可以翻页继续勾 - pagedIds / allChecked / someChecked 都基于「当前页」做三态判定 - 批量提交 Promise.all 并发,但 task-store writeChain 内部串行化,互不踩 - 删完后局部更新 tasks state(不靠轮询)—— 提供「真删完才退场」的可感知反馈

Round 3:清空操作不可逆性 - ConfirmDialog 强警告色 + loading 阻塞重点击 - 调用 jsstore clearData 失败时返回 {success:false} —— doClearAll 主动 throw, Dialog 不自动关,配合 toast 让用户看到原因 - 日志清空只动 pageLog 自身,不动任务进度 / 商家数据,文案标注

后续待办

  • 4 数据子 tab 上移到侧栏:要把 dataTab state 从 DataView 提到 main-layout,

    侧栏新加可展开 nav,估计 ~50 行;下版做。
  • 5 共用窗口:scrape-window 现在分 harvest / scrape 两个 windowKey,需要

    统一成一个、tab 用 metadata 区分用途,影响 batch-controller / engine-manager 的 窗口生命周期;下版做。

2026-05-23 v0.9.26 → v0.9.27 修 Chrome aria-hidden 焦点警告

现象

Chrome 控制台 / 扩展报错页冒:

Blocked aria-hidden on an element because its descendant retained focus.
Element with focus: <div.MuiDialog-container ...>
Ancestor with aria-hidden: <div.MuiDialog-root ...>

根因

MUI 的 Dialog 关闭时会给 root 加 aria-hidden="true"。如果用户通过点击 Dialog 内部按钮触发关闭 (取消 / 确定 / X 关闭),按钮在 onClick 调 onClose 那一刻焦点还在它身上 —— 当 Dialog 立刻 aria-hidden,浏览器无障碍引擎检测到「藏起来的容器还有 descendant 持有 focus」就报警。

嵌套 Dialog(LocationPickerDialog 嵌在 TaskCreateDialog 里)触发概率最高。

修复

统一在「关闭路径」上先 blur 当前 activeElement,再调 onClose:

const safeClose = () => {
  const el = document.activeElement;
  if (el instanceof HTMLElement) el.blur();
  onClose();
};

应用到: - LocationPickerDialog:onClose、X 按钮、取消按钮、确定按钮(双保险:先按钮自己 blur + 再走 safeClose) - TaskCreateDialog.handleClose:补 blur 逻辑;create 成功后改调 handleClose 而不是直接 onClose - TaskDetailDialog:Dialog 的 onClose 和 X 按钮同样补上

注意

  • 这不是真的「错误」—— 不阻塞功能;但在 Chrome 扩展的「错误」面板里会一直堆,影响排查真问题
  • 不用 disableEnforceFocus / disableRestoreFocus —— 会破坏焦点恢复 UX(关闭后焦点应回到打开者)
  • 不用 setTimeout 异步 close —— 会产生「点了按钮但 Dialog 慢一拍才关」的违和感
  • blur 是同步的,没有副作用,最干净

2026-05-23 v0.9.25 → v0.9.26 多国合并到 1 个任务 + 任务列表布局统一

用户反馈

  1. 任务列表「变形」:不同状态下操作按钮位置不一致;queued 任务没有可结束的按钮; 「查看商家」太长。
  2. 多国 = 多任务很糟糕:选 2 国 + 一堆州城市,被拆成多个任务;选 100 个国家就会出 100 个任务, 完全没法用。希望「无论选了多少国家,都合并成 1 个任务」。

#2 多国合并到 1 个任务 —— 数据模型升级(核心改造)

旧模型的局限

  • MapTask.countryCode / countryName / locations[] 是单国设计
  • buildTaskUrlstask.countryCode 拼 URL —— locations 里所有项都默认是这个国家
  • 多国只能拆成 N 个 task

新模型(v0.9.26+)

  • MapTask.locationEntries?: LocationEntry[],每条记录自己的国家:
    interface LocationEntry {
      countryCode: string;
      countryName: string;
      location: string; // "State Name" 或 "City,+State Name"
    }
    
  • buildTaskUrls 优先用 locationEntries;没有则退回老的 countryCode + locations[] 路径 —— 完全向后兼容
  • 老任务(无 locationEntries)继续按单国跑

流程

  1. 用户在 LocationPickerDialog 里选了 2 国 + N 个州城市
  2. task-create-dialog 的 handleCreate:
    const locationEntries = [];
    for (const [iso2, csel] of selEntries) {
      let locs = csel.paths;
      if (csel.mode === 'all') locs = await fetchStates(iso2);  // 按需展开
      for (const loc of locs) {
        locationEntries.push({ countryCode: iso2, countryName: csel.name, location: loc });
      }
    }
    await sendMessage('create-task', { ..., locationEntries });
    
  3. 总是 1 个 task(不论选了几个国家)
  4. task-manager.createTask 智能生成名字:
  5. 单国:"doctor · 美国 · 27 个地区"
  6. 多国:"doctor · 3 国 · 43 个地区"
  7. batch-controller 走的还是单 task 多 URL 的现成路径,每个 URL 用 entry 自己的 countryCode 拼

预估卡更新

  • 旧:N 个关键词 × M 个地区 = K 个任务(K = 国家数)
  • 新:N 个关键词 × C 国 · M 个地区 = 1 个任务 —— 始终是 1

#1 任务列表布局统一

  • 操作区固定 width: 260, justifyContent: flex-end:renderMaps 和 renderWebsite 都用相同 容器尺寸;不同 status 下按钮数量不同也始终右对齐贴底。
  • 「查看商家」→「商家」;「查看数据」→「数据」—— 短一档,给状态指标更多呼吸空间。
  • queued 任务加「结束」按钮:暖色 + 二次确认。queued 没有 batch 在跑,所以暂停按钮无意义, 只给「结束 / 商家 / ⋮」三件套。
  • 「⋮ 更多」菜单里的 stop 已经存在 —— 但 queued 状态下没必要走两层菜单,加一个明显的按钮。

三轮自检发现并确认

Round 1 数据模型完整性 - ✅ LocationEntry 接口有 countryCode / countryName / location,对应 buildTaskUrls 需要的 3 个字段 - ✅ task.total = categories.length * locations.length;locations 派生自 locationEntries - ✅ countryCode/Name 单国时仍填(兼容老路径),多国时为空

Round 2 路径兼容 - ✅ buildTaskUrls 优先 locationEntries;老任务(无该字段)走原 countryCode + locations[] 分支 - ✅ task-view 描述行 if locationEntries 存在 → 显示「N 国」;否则不显示 - ✅ recordTaskProgress 用 keyword 做 key —— keyword 含国家后缀 ("doctor, Sydney, Australia"), 不同国家同名州不会撞 key

Round 3 多任务调度 / SW 重启 - ✅ reconcileTasks 只 filter type==='maps',不关心 country 字段 - ✅ 任务暂停/继续/停止全部 by taskId,country-agnostic - ✅ 多任务并发(v0.9.18)不受影响 —— 现在一个 task 内部多国,仍然占一个 batch 槽位 - ✅ task-detail-dialog 用 categories × locations 算 combo —— 多国 task 的 combo 数仍正确 (locations 已经是跨国的并集)

收益

  • 选 100 个国家 → 1 个 task;之前会出 100 个
  • 任务列表布局稳定,按钮不再「跳来跳去」
  • queued 任务可以一键结束(不用打开「⋮ 更多」菜单)

注意点

  • 多国 task 的「查看商家」筛选:用 taskId 过滤 ✓ 正常工作
  • 多国 task 的「关键词进度」详情:locations 是跨国的全集,combos 数计算无误
  • 老任务(v0.9.26 之前创建的)依然按单国渲染 / 跑 —— 不会破坏

2026-05-23 v0.9.24 → v0.9.25 地区选择器彻底重做(面包屑 + 多国 + 全选)

问题分析(用户反馈 3 条)

  1. 「按洲选国家」体验差 —— 现在是 chip 过滤 + Autocomplete,用户希望点击输入框打开 完整选择器;可设置常用、默认常用、记住上次大洲。
  2. 州/城市列表仍然被挡住 —— inline 布局塞在弹窗里,再加上预估卡 + 按钮,总高度顶出 视口;用户希望「专业级」改进让小白也能上手。
  3. 缺少面包屑 + 各级全选 —— 用户要:选大洲 → 国家 → 州 → 城市 能逐级返回; 每一级都能「全选」(大洲全选 = 大洲下所有国家整国)。

解决方案:独立的 LocationPickerDialog

把「选地区」从 inline 选择器升级为独立的 Dialog,承载完整的层级导航。

新增 LocationPickerDialoglocation-picker-dialog.tsx

  • 三层导航countrystatecity,大洲是顶部 chip 切换(不算导航层级,是「过滤器」)
  • 面包屑:「国家」> 「🇯🇵 Japan」> 「Aichi Ken」—— 点任意一级回跳
  • 大洲切换:「常用 / 全部 / 亚洲 / 欧洲 / 美洲 / 非洲 / 大洋洲」chip
  • 多国 selection:内部用 SelectionMap: Record<iso2, {mode: 'all'|'specific', paths: string[], name}>
  • mode: 'all' —— 整国选上(提交时按需 fetch 州列表展开)
  • mode: 'specific' —— 部分州/城市,paths 存路径字符串
  • 各层级全选
  • 大洲行:「全选当前大洲」勾选框 —— 一次把该大洲下所有国家设成 mode='all'
  • 国家行:勾选框就是整国选;下钻进国家后顶部「全选整国(X 个州)」按钮
  • 州行:勾选框 = 整州选(州 + 所有城市路径都加入)
  • 城市行:勾选框 = 单个城市
  • 三态 Checkbox:每级都根据子级勾选状况显示 indeterminate(部分选)
  • 常用国家:每个国家行右侧 ⭐ 收藏按钮,存 localStorage(lfx:countryFavs)。 默认 8 个:US/CN/JP/GB/DE/AU/CA/FR
  • 记住上次大洲:localStorage lfx:lastRegion;如果上次是「常用」但当前收藏为空,自动落回「全部」
  • 按需加载 + 缓存:state/city 数据 apiCountryLocations({code}) 按国家维度缓存到内存 Map
  • 搜索:每层都有搜索框,按当前层级的 name 模糊过滤

新增 LocationsPickerInputlocations-picker-input.tsx

  • 创建任务弹窗里看到的「入口」TextField,外观清爽
  • 显示摘要 chip:3 国 · 45 区域 · 日本、美国、加拿大 等 3 国
  • 右侧带清空按钮 + 下拉箭头;点击整个框打开 Dialog

task-create-dialog.tsx 提交逻辑改造

  • 旧模型:countryCode + countryName + locations[] 单国
  • 新模型:SelectionMap 多国
  • 提交时一国一个 task(保持 task-store / batch-controller 模型不变):
  • 遍历 SelectionMap
  • mode='all':调 apiCountryLocations 拿州列表,全部作为 locations
  • mode='specific':直接用 paths
  • 每国 sendMessage create-task
  • 创建后 toast:「已创建 N 个任务(每个国家一个)」
  • 预估卡升级:N 个关键词 × M 个地区 = [K 个任务],多国时加副提示

UX 关键点

  1. 小白友好
  2. 默认进来就是「常用」大洲(用户最常选的几个国家直接看见)
  3. 任何一行的勾选框都对应「整体选上」—— 不需要理解 specific/all 概念
  4. 面包屑明显,回退一目了然
  5. 「⭐ 收藏」按钮无需打开「设置」也能管理常用
  6. 专家友好
  7. 大洲一键全选 N 个国家
  8. 整州一键选所有城市
  9. 三态 checkbox 直观反映嵌套选择
  10. 不再被挡
  11. 选择器是独立 Dialog(85vh),自己管自己的滚动
  12. 创建任务弹窗里只剩入口控件,再也不挤
  13. 多国创建
  14. 1 国 = 1 task,沿用现有 task-controller 多任务并发能力
  15. 用户选 5 国 → 创建 5 个 task → 队列里并发跑(受 maxConcurrentTasks 限制)

旧组件保留

  • country-select.tsx / simple-country-select.tsx / locations-select.tsx / simple-locations-select.tsx 都保留 —— task-create-dialog 不再用,但其它地方将来可能复用。

注意点

  • 多国 mode='all' 提交慢:每国一个 API 调用 + 一个 task 创建;50 国 ≈ 50 次 API。 实测可接受(apiCountryLocations 通常 100-500ms / 国)。
  • 预估指标的 paths 数仅近似mode='all' 时 paths 为空,按 1 计;实际提交后会展开成 N 个州。所以「45 区域」是下限,真实可能更多。
  • 常用 / 上次大洲存 localStorage:跨设备不同步;不打算云存(与扩展强本地化定位一致)。
  • stateCache 是模块级 Map:SW 重启会清空(不过浏览器 SW 不影响 UI 这边的内存); 对 UI 这边而言只要标签页不刷新,缓存就在。

2026-05-23 v0.9.23 → v0.9.24 创建任务弹窗 3 项修复

#1 国家按洲过滤(simple-country-select.tsx

  • 旧实现:Autocomplete 内部用 groupBy 按 region 显示分组,但用户得滚下拉去找; 常用国家以外不好定位。
  • 新实现:Autocomplete 上方加一排 chip:「常用 / 全部 / 亚洲 / 欧洲 / 美洲 / 非洲 / 大洋洲」
  • 点 chip 即把 Autocomplete 的 options 过滤到对应洲;
  • 「常用」会按 COMMON_CODES 顺序显示(US/CN/JP/DE/IN/...)
  • 「全部」沿用 groupBy 自动分组;
  • 单大洲不分组(同组内按英文名排序,避免冗余)
  • 常用列表扩到 20 个(旧 19 个,删了不太常见的 4 个误码,补了 IT/ES/NL/SE 等)

#2 州/城市框被挡住 —— 视口尺寸修复(task-create-dialog.tsx / simple-locations-select.tsx

  • 根因:
  • Dialog 没设 maxHeight,内容多时会顶出视口;
  • DialogContent 没设 overflow: auto, flex: 1
  • SimpleLocationsSelect 内部列表 maxHeight: 360 固定值,小屏被挤掉
  • 修复:
  • Dialog Paper:maxHeight: 92vh; display: flex; flexDirection: column
  • DialogContent:overflow: auto; flex: 1; minHeight: 0 —— 内部超长就自己滚
  • 列表 maxHeight: min(340px, calc(100vh - 460px)) + minHeight: 200 —— 跟视口走
    • 大屏:340px 上限不抢空间
    • 小屏:动态算,留出按钮和顶部的空间

#3 关键词 + 国家 / 地区 同一行(task-create-dialog.tsx

  • 旧布局:竖向堆叠,关键词一行、国家+州+城市又一行 —— 上半区空、下半区被挤
  • 新布局:
    ┌─ 类别 / 关键词 ──────────┐  ┌─ 国家(含洲过滤) ──────────┐
    │ [CategorySelect]        │  │ [chip 行] [Autocomplete] │
    └─────────────────────────┘  └─────────────────────────┘
    ┌─ 州 / 省 / 城市(占满宽,含 tab:系统内置 / 自定义) ──────────┐
    │ 已选 X/Y  [全选][反选][清空]                                   │
    │ [🔍 搜索]                                                      │
    │ ☐ state 1  [城市数]                                            │
    │ ...                                                            │
    └────────────────────────────────────────────────────────────────┘
    
  • 实现关键:SimpleLocationsSelecthideCountryPicker prop
  • 外层(task-create-dialog)直接渲染 <SimpleCountrySelect>
  • <SimpleLocationsSelect hideCountryPicker> 只负责 tabs + 州/城市列表 + 自定义模式
  • countryCode / onCountryChange 仍透传,保证下半部能跟着上半部的国家走

注意点

  • Dialog Paper 加了 display: flex; flexDirection: column 后,按钮区(DialogActions) 在 footer 不会被推出去 —— DialogContent 是 flex: 1 占满中间剩余高度。
  • 「类别」和「国家」并排在 sm 及以上断点;xs 自动堆叠成单列。
  • 常用国家列表里之前的 SP/HO/TU/CH 4 个 iso2 是错的(应该是 ES/HN/TR/CH 之类), 这次重新整理:US/CN/JP/DE/IN/GB/FR/RU/CA/BR/AU/KR/MX/ID/SA/IT/ES/NL/CH/SE(CH = 瑞士)。
  • 州/城市列表 maxHeight 公式 min(340, calc(100vh - 460)) 留 460px 给: Header(70) + Tabs(56) + Alert(50) + Row1(80) + SectionLabel(30) + Tabs(40) + 控件(60) + 预估卡(50) + Actions(65) + 余量 —— 加起来 ~500px,留点儿 buffer 给字体等小差异。

2026-05-23 v0.9.22 → v0.9.23 商家表细节 + 4 个列表跳页

本次改动(按用户反馈 4 条)

#1 Logo 上下居中 + 点击放大预览(local-table.tsx

  • 旧实现:img 直接渲,靠 display: block 自然贴单元格顶部。换行高变化时位置就漂。
  • 新实现:用 Box 包裹(height: 100%; display: flex; alignItems: center; justifyContent: center) 强制单元格内部上下居中。
  • 点击 logo 弹 Dialog 放大预览 —— Paper 设为透明、关闭按钮浮在右上角;图片 maxWidth/Height 限制为 80vw/80vh,避免超大图把整个屏幕占了。
  • hover 微动效:scale(1.06) + cursor:zoom-in,明确传达「可点」。

#2 每个商家高度继续压缩(local-table.tsx

  • rowHeight 调整:compact 38 / standard 52 / comfortable 72 (旧的 28 在 2 行内容下会被 DataGrid 自动撑高到 ~80,反而更乱)
  • columnHeaderHeight 36(保持紧凑表头)
  • Logo 尺寸再缩一档:compact 32×22(原 36×24)

#3 拖动「联系方式」后列宽自动还原 —— Bug 修复(local-table.tsx

  • 根因:LocalTable 调用 DataTable 时没传 tableName,导致 DataTable 内部 useTableColumnsWidth('') 拿到空命名空间 —— hasSetWidth() 永远是 false, 列宽存了也读不回来。每次 fieldData 重渲就回到 column 自带的默认 width。
  • 修复:补一个 tableName="extMapMerchant",列宽走 localStorage 持久化兜底。
  • 注意原来的 API 持久化(fieldSaveFieldsConfigRun)也还在跑 —— 两条路并存, 哪条工作哪条就生效,比之前都好。

#4 4 个列表都加「跳至第 N 页」(data-table.tsx / client-data-table.tsx

  • 商家列表(DataTable):在 footer Pagination 旁边加 跳至 [输入框] 页 —— 原来 jumpRefonJumpPage 早就定义好了但没渲染出输入框 —— 这次接上 UI。 顺手把分页加了 showFirstButton / showLastButton,63 页快速跳头尾。
  • 官网 / 邮箱 / 手机列表(ClientDataTable = DataGridPremium): 原来用 DataGrid 自带 footer 没有跳页输入。新增 JumpPagination 组件 —— 用 useGridApiContext + gridPageCountSelector 接 DataGrid 状态, 注入到 slots.pagination 完全替换默认 footer。 内容:Pagination(含首末按钮)+ 跳页输入 + 每页条数下拉。 pageSizeOptions 从外层透传(默认 10/20/50/100)。

注意点

  • 列宽 localStorage key 是 extMapMerchant —— 跟之前的别处用过的 key 没撞(searchMapData / webDataList / emailDataList / phoneDataList 都不同)。
  • LocalTablestats / isRunning 两个 prop 在 v0.9.10 删除底部状态条时已经不再用, 这次把 type 改成 optional 防止 LocalDataView 那边传时 TS 报警。
  • 跳页输入用的 defaultValue + onBlur + onKeyUp Enter blur 模式:用户输完按 Enter 或 鼠标移开就触发跳转,跳完输入框自己清空,再次回到 placeholder 显示当前页。
  • 商家行高 38 还是可能让 RenderProfile 的两行内容看着挤;进一步压缩要重写 renderer (把第二行的 category / rating 拼到第一行右侧),下一版本视用户反馈再考虑。

2026-05-23 v0.9.21 → v0.9.22 国家/地区选择器简化

本次改动

重写国家选择 + 地区选择两个核心交互,去除冗余、降低用户认知负担。

新组件 simple-country-select.tsx

  • 旧版 CountrySelect 是「左侧 region 长 tab + 右侧虚拟列表 + 顶部搜索」三段式弹层 —— 用户得先选「常用 / 全球 / 非洲 / 亚洲 / ...」十多个 region,再在右侧找国家,门槛高。
  • 新版换成 一个 MUI Autocomplete
  • 内置 groupBy:「常用」组置顶;其它按 region 自动分组(Asia / Europe / ...)
  • filterOptions 同时按英文名 / 中文名 / iso2 模糊匹配
  • 每个 option 渲染:🇦🇺 国旗 + 英文名 + 中文小字 + 末尾 monospace iso2
  • InputAdornment 显示已选国家国旗
  • 整组件 ~120 行,无第三方依赖

新组件 simple-locations-select.tsx

  • 旧版 LocationsSelect 是「TextField → 触发大 Popover → 内含 antd Tree(含 showLevel 下拉)」 —— 弹层叠弹层、Tree 复选框小、「显示城市(2 级)」之类的术语对用户不友好。
  • 新版改成 inline 显示(创建任务弹窗内直接展开,不再是 popover):
  • 顶部「系统内置地区 / 自定义地区」Tab
  • 系统内置模式下:
    • 用 SimpleCountrySelect 选国家(单行 Autocomplete)
    • 已选数 chip + 「全选 / 反选 / 清空」三个快捷按钮
    • 搜索框(同时过滤州名和城市名)
    • 列表:每个州一行(checkbox + 三态 indeterminate + 城市数 chip + 展开 caret), 展开后子城市列表(缩进 + checkbox)
  • 自定义模式:textarea(同旧),但加了等宽字体
  • 内含原先 antd Tree 的所有功能(多级选、全选、indeterminate),用纯 MUI 实现,去掉 antd 依赖路径

Wiring

  • task-create-dialog.tsx 把原 LocationsSelect 换成 SimpleLocationsSelect,props 完全兼容
  • 段标题文案改成「国家 / 地区 | 先选国家再勾选州或城市;也可切到自定义」

旧组件保留

  • country-select.tsxlocations-select.tsx 老组件保留不删,避免后续别处复用断掉
  • 任务创建是唯一用到这两组件的地方(grep 确认),其它路径不受影响

收益

  • 打包体积下降 ~180KB(4.25 MB → 4.07 MB)—— antd Tree 不再被引入
  • 用户少点 2 次(不用先开 popover、不用切 region tab)即可选到国家
  • 州 / 城市的选择全部在主弹窗里直接看得见,不需要二次弹层

注意点

  • SimpleLocationsSelect 用 inline 布局,弹窗变高了一些 —— 但 task-create-dialog 是 maxWidth: md 的中等弹窗,竖向滚动可接受
  • 当 country 数据更新时,自动过滤掉 values 里已不存在的 key(边界 effect 处理)
  • API 返回的多级数据(state → city → zip)目前只展开到 2 级(州/省 + 城市)—— 邮编一级被砍掉,与原版的「displayLevel=2」效果一致;如果将来需要 zip,可以加个开关
  • 「常用国家」列表沿用旧 CountrySelect 的 19 个 ISO2:US / CN / JP / DE / IN / GB / FR / RU / CA / BR / AU / KR / MX / SP / ID / SA / HO / TU / CH(其中 SP/HO/TU/CH 是旧代码留的,不一定都对——下版本检验下)

2026-05-23 v0.9.20 → v0.9.21 创建任务弹窗美化

本次改动

重写 task-create-dialog.tsx,把原来「白底 + 两个空 TextField」的简陋样式做成有层次的卡片化设计。

Header(自定义标题栏) - 渐变背景(primary 8% → 2%),左侧 40×40 的圆角图标块(火箭🚀),右侧关闭按钮 - 标题 + 副标题:「创建任务」/「选择采集方式,提交后会自动进入任务队列」

Tabs - 高度 56、底部 3px 圆角指示条 - 选中态文字加粗 + 主色 - 整段去掉了「Mui 默认 uppercase 折腾」

Content(两个 tab 共用结构) - 顶部一行 <Alert variant="outlined" icon={LightbulbIcon}> 当帮助提示,比原来灰色 body2 文字更可读 - 字段区改成「图标块 + 段标题 + 提示」+ 实际输入控件 - 段标题图标用 24×24 圆角 + primary 10% 透明背景

地图 tab 专属:预估指标卡 - 选了类别和地区后,下方出现绿色虚线卡片:「N 个关键词 × M 个地区 = 共 [Chip:NxM 个搜索组合]」 - 让用户一眼看到任务量级

官网 tab 专属:识别结果指标卡 - 文本框下方出现一张状态卡,根据 URL 数显示绿/灰/红: - 0 个:灰 + 链接图标 - 1 ~ MAX:绿 + 对勾 + 「✓X 个有效网址」 - 超 MAX:红 + 警告 + 「超过上限」副提示 - 文本框字体改成等宽 (ui-monospace),方便对齐 URL

Footer - Divider + DialogActions 2 行高 - 「创建任务」按钮加火箭图标、最小宽度 120、未填写时自动 disabled

注意点

  • canSubmit 派生按钮 disabled 状态:地图 tab 需要类别+地区都非空;官网 tab 需要 URL 数在 (0, MAX] 之间。
  • maxWidth 从 lg → md:宽度更紧凑,不再两边大量留白。
  • alpha() 用 MUI 主题函数取色,深色模式下也能跟着走(虽然项目目前没接深色但备好了)。
  • 所有图标都用 MUI 内置 icon(CategoryIcon / PublicIcon / LinkIcon / LightbulbIcon / RocketLaunchIcon / CheckCircleOutline / ErrorOutline),无新增依赖。

2026-05-23 v0.9.19 → v0.9.20 v0.9.18 + v0.9.19 复审 4 个细节 bug

逐项检查多任务并发 + 数据视图重构这两轮的代码,找到并修复以下问题:

🐛 #1 reconcileTasks 误判官网任务为「done」

  • 现象:SW 重启后,常驻 running 状态的官网任务(type='website')会被 reconcileTasks 标记为 done —— 因为它们没有 batch-controller 批次, isBatchActive(id) 返回 false 命中「stuck」判定。
  • 修复:reconcileTasks 现在只看 t.type === 'maps' 的任务,官网任务由 engine-manager 在 MapTaskData 的 scrape_status 上自己推进,不该被这里干预。

🐛 #2 数据视图非商家 Tab 时其它三个 Tab 的徽标归零

  • 现象:用户在「商家列表」Tab 时,「官网列表 / 邮箱列表 / 手机列表」徽标都显示 0。
  • 根因:rows useRequest 的 ready: isFiltered || tab !== 'merchant' —— 商家 Tab 无筛选 时 rows 为空,所有派生数组(websites / emails / phones)都为 0。
  • 修复:去掉 ready 限制,rows 始终拉取。多一次 jsstore select(limit:2000) 每 5s 的代价。

🐛 #3 切到非商家 Tab 后商家 Tab 计数不再实时

  • 现象:用户在「官网列表」Tab 浏览时,「商家列表 (X)」的 X 不再更新(LocalDataView 卸载, countRun 停了)。
  • 修复:在 data-view 里加独立的 countAllData() 轮询(5s),算出 globalMerchantCount;商家 Tab 徽标用 merchantTabCount = isFiltered ? rows.length : globalMerchantCount。 同时通过 useEffect 把它同步给 main-layout 的 localTotal,让别处(如未来要做的概览页) 也跟着实时。

🧹 #4 清理 v0.9.19 留下的死代码

  • openUrl 函数没被任何地方调用 —— 改完用 DataGrid 的 <a> 渲染后就没用了。删除。

注意点

  • merchantTabCount 在筛选模式下用 rows.length,最大值受 FILTER_CAP=5000 限制。 超大任务(5000+ 商家)显示的数可能不准。下版本可以单独再加一份按任务 ID 的 jsstore count(不走 where:{taskId},避免老 DB 报错)。
  • globalMerchantCount 是 5s 轮询 —— 大库(百万级)时 countAllData 会慢,可以拉长间隔 或加缓存。当前规模不必。

2026-05-23 v0.9.18 → v0.9.19 数据视图 4 项收尾(#5 #6 #7 #8)

本次改动

#5 数据 4 个 tab 显示数量(data-view.tsx

  • 商家列表 = localTotal(LocalDataView 反馈的 jsstore count;筛选模式自动用 taskId 限定)
  • 官网/邮箱/手机 = 派生数组 length(最近 CAP=2000 商家行去重后的数量;筛选模式 5000)
  • Tab label 直接拼字符串:商家列表 (508) / 官网列表 (xxx) / ...

#6 商家列表压缩高度 + 不重置滚动(local-table.tsx / local-data-view.tsx

  • 行高:DataGrid 新增 rowHeight={tableDensity === 'compact' ? 28 : 40 : 56}columnHeaderHeight={36}。compact 模式下整行 28px,约旧版的 2/3。
  • Logo:compact 模式 28×20(旧 40×28,再压一档),radius 3。standard / comfortable 同步缩。
  • 不重置滚动:去掉 useEffect(() => getTableData(), [localTotal])。 保留「数量徽标实时更新」+「刷新按钮按需手动刷新」。 原因:每次 localTotal 变化整体 dataRun 会重渲表格、scroll/page 都重置; count 用一个独立的 useRequest 在跑,新数据来了徽标会跳,但表格不会扰动。

#7 官网列表统计 banner(data-view.tsx

  • 官网 tab 顶部多出一行 stats:📧 邮箱 X 个 · 📞 电话 X 条 · 🌐 社媒 X 次命中 + 各平台命中图标。
  • 计算口径:当前可见的 websites(已按状态筛选)。

#8 官网/邮箱/手机列表换成 DataGrid(data-view.tsx / client-data-table.tsx

  • 旧实现是 Stack 拼的简表,没有列拖拽、没有分页、字段宽度死。
  • 新实现统一用 ClientDataTable(DataGridPremium 封装):
  • 列拖拽 / 列宽调节,按 tableName 自动持久化到 localStorage(webDataList / emailDataList / phoneDataList)
  • 内置分页:defaultPageSize={20}pageSizeOptions=[10,20,50,100]
  • density 默认 compact;DataGrid 自带的「列管理 / 过滤 / 导出 / 重置」工具栏全有
  • 官网列:状态 / HTTP / 网址 / 商家 / 电话 / 邮箱数 / 社媒(图标行)
  • 邮箱列:邮箱 / 来源商家 / 来源网址
  • 手机列:电话 / 来源商家 / 来源网址
  • 行键(id):商家行用原始 id;邮箱用邮箱地址;手机用号码(前面已经去重保证唯一)。
  • ClientDataTable 新增 defaultPageSize?: number prop(不传则用旧默认 25)。

三轮自检发现并修复

  • Round 1:tab counts 在非 merchant tab 时不会刷新 —— 接受这个限制(用户切回去后立即刷新)。
  • Round 2 🐛:邮箱/手机 row id 用了 ${value}_${index} 拼,新数据进来 index 全部错位 → DataGrid 认为所有行都是「新行」,滚动重置。修复:直接用值本身做 id(前面已经去重保证唯一)。
  • Round 3:MUI X DataGrid v7 的 valueGetter 签名是 (value, row),本次代码符合。

注意点

  • 商家表自动刷新被关掉了 —— 新数据想看必须点「刷新」按钮(toolbar 上的 ReplayIcon)。 这是为了不打断滚动;如果用户更倾向「自动刷新但保留滚动」,下版本可以用 row id 浅比较只 patch 差异。
  • ClientDataTable 持久化 key 是 tableName:webDataList / emailDataList / phoneDataList。 用户拖列宽 / 改顺序后,下次打开会保留;点「重置」回默认。
  • 官网列表的 stats 是基于「最近 CAP=2000 商家行」算的,不是 DB 全量。CAP 已经覆盖 绝大多数日常场景;超大库可手动在源码里调。

2026-05-23 v0.9.17 → v0.9.18 多任务并发(彻底重构 batch-controller)

主要矛盾

  • 用户反馈「等待中的任务不会继续进行」。本质问题:旧 batch-controller 全局状态单例, task-manager 也用 local:currentTaskId 单例 → 同一时刻只能跑一个地图任务,其余排队等待。
  • 多任务并发是用户最核心的诉求,必须做。

本次改动

1. batch-controller 重写为 per-task 状态机

  • status / taskUrls / nextIndex / finishedCount / tabs / pagingQueue 等所有模块级 全局拆成 BatchState,按 taskId 装进 Map<string, BatchState>
  • 新增反向索引 tabToTaskId: Map<tabId, taskId> —— onTabReady / onTabDone / 拦截 消息都靠 sender.tab.id 路由回正确的 batch。
  • 对外 API 全部带 taskIdstartBatch(id, urls, settings) / pauseBatch(id) / resumeBatch(id) / stopBatch(id);额外暴露 isBatchActive(id)isAnyBatchActive()activeBatchCount()
  • 持久化按 taskId 分片:local:batchProgress:${id} / local:batchState:${id} / local:batchPaging:${id};并维护 local:activeBatches 任务 id 列表, SW 重启时按列表逐个恢复(每个用 try/catch 隔离,单任务失败不影响其他)。
  • 共享资源:keepAlive 计时器(只要有任何 batch 在跑就开着)、harvest 窗口(最后一个 batch 关掉时才关窗口)。

2. task-manager 改为多任务调度

  • 新增 pumpTasks():把 running 槽位(最多 maxConcurrentTasks)填满;每个空槽 从队列里挑一个 queued 任务起跑。
  • 加了简单互斥锁(isPumping + pumpPending)防止两个 onBatchDone 撞上同一个 queued 任务起重复 batch。
  • controlTask 直接按 taskId 操作对应 batch,不再依赖「当前任务」单例。
  • onBatchDone(taskId) 接收具体 taskId(由 batch-controller 回调时传入)。
  • reconcileTasks 会找所有 storage 里 running/paused 但 batch-controller 没接回来 的「卡死任务」全部标记 done,再 pumpTasks。

3. 设置:「同时进行中的任务数」

  • 新加设置项 maxConcurrentTasks(1-10,默认 1)。
  • 设置 UI 与「后台翻页并发数」并排两列,文案:「允许同时跑的地图任务数;其余排队, 前面跑完自动接力」。
  • 保存设置时发 settings-changed 消息,后台立刻 pumpTasks —— 提高了 cap 立刻 生效,不用再等任务结束。

4. 旧路径清理

  • 删掉 background 里废弃的 start-batch / stop-batch / resume-batch 单例消息处理器。
  • 仅保留 pause-batch(UI 在数据上限熔断时用)—— 改成「暂停所有 running 任务」。
  • store-page-data / batch-need-paging 现在把 sender.tab.id 透传给 batch-controller, 据此路由到正确 batch;老 local:currentTaskId 仅在 tabId 找不到时兜底。

5. UI 接入

  • task-view 进度读取改为 per-task:拉所有 running/paused 任务的 local:batchProgress:${id},组成 Record<taskId, progress>
  • liveProgress / halted 判断都按 progress[t.id] 取该任务自己的进度。

三轮自检发现并修复的问题

Round 1(多任务正确性) - ✅ tab 事件路由(onTabReady/onTabDone/onRemoved)通过 tabToTaskId 找对应 batch; finalizeTab 同步清理映射表。 - ✅ 单域名并发 / 总并发 / 翻页并发 都是 per-task —— 跨任务的总量需要看用户怎么配。 暂时认为「per-task * task 数」是用户预期的总量。

Round 2(边界与竞态) - ✅ pumpTasks 加互斥锁,防止并发 onBatchDone 同时 pump 时起重复 batch。 - ✅ 设置变更立即触发 pumpTasks(消息 + 后台 handler)。 - 🐛 restoreBatch 顺序错误:原代码 closeHarvestWindow 放在 pump 循环之后, 会把刚开的 tab 全部杀掉。改为 closeHarvestWindow 在循环之前(清孤儿), pump 在之后(开新 tab)。 - ✅ try/catch 包住单任务恢复失败。

Round 3(残留旧 API) - 🐛 background/index.ts 残留旧签名start-batch / stop-batch / pause-batch / resume-batch 消息处理器仍按旧 startBatch(urls, settings) 签名调用,新 API 是 startBatch(taskId, urls, settings)。 - 已确认:只有 pause-batch 在 UI 里还有人用(main-layout 的数据上限熔断)。 改成「遍历 task list 把 running 全部 pause」。 - 其它单实例消息整段删除,避免误用。

注意点

  • 多任务并发时 harvest 窗口共享 —— 同时打开的 tab 数 = ΣrequestConcurrency。 会比单任务版本耗内存,用户可适当调小 requestConcurrency
  • jsstore 写入是单 Worker 串行的 —— 多任务并发插入不会撞库,但极高负载会排队。
  • 老任务(升级前已经 running 状态、local:batchProgress 单例存在的)需要重启 浏览器才会被新 reconcileTasks 标 done;建议升级后让任务跑完一遍或手动结束。
  • 设置「同时进行中的任务数」默认仍是 1,保守。用户主动调大才会真正并发。
  • 单域名并发上限(v0.9.13 引入)目前在每个 batch 内部独立,不跨任务共享。 即如果两个任务都搜同一域名也会各算各的。

2026-05-23 v0.9.16 → v0.9.17 暂停时耗时停表

本次改动

  • 任务暂停时耗时正确冻结,扣除累计暂停时间task-store.ts / task-manager.ts / task-view.tsx / task-detail-dialog.tsx
  • 旧实现:elapsed = (endTime || Date.now()) - startTime。 paused 时 endTime 为空 → 用 now → 数字一直跳。
  • 新模型:给 MapTask 加两个字段:
    • pausedAt?: number —— 进入 paused 那一刻的时间戳(仅暂停期间有值)
    • pausedTotal?: number —— 此前所有暂停时长的累计(ms);多次暂停继续会一直加
  • 状态机:
    • queued → running(pumpTasks):startTime = now; pausedAt = undefined; pausedTotal = 0
    • running → pausedpausedAt = now
    • paused → runningpausedTotal += now - pausedAt; pausedAt = undefined
    • * → done / stopped / reconcile:若处于 paused,把当下这段也结进 pausedTotal;清掉 pausedAt
  • 显示公式:
    • 终点 stopAt = endTime(已结束)/ pausedAt(暂停中冻结)/ Date.now()(running)
    • elapsed = max(0, stopAt - startTime - pausedTotal)

注意点

  • 多次「暂停→继续→暂停→继续」叠加 pausedTotal 一直累加。
  • 老任务(v0.9.17 前)这两个字段为 undefined,按 0 处理,不会破坏旧任务的显示; 只是不会精确反映之前那次暂停(这是历史数据缺失,新任务起一切准确)。
  • ETA(剩余时间)也用同一个 elapsed 做线性外推,所以暂停期间 ETA 也跟着冻结,符合直觉。

2026-05-23 v0.9.15 → v0.9.16 官网状态码 + 列表筛选 + 任务详情(含 #6 落地)

本次改动(按用户反馈 3 项,#6 同步落地)

  1. 官网列表加 HTTP 状态码 + 失败错误data-view.tsx
  2. 在「状态」chip 旁边再加一列「HTTP」:
    • 成功:200 / 301 / 403 / 404… 用语义化颜色 chip(绿/蓝/橙/红)显示
    • 失败:直接显示错误码 ERR_CERT_AUTHORITY_INVALID 等;过长截断成 12 字 + ,悬停看全文
    • 无记录:-
  3. 数据源:每 5s 轮询 pageLogItem,按 url 建 Map,取最近一次的 status / error; 不动 jsstore schema,避开二次迁移风险。
  4. 旧 pageLog MAX_LOG 1000 上限对状态显示是 OK 的(用户主要看最近活跃的)。

  5. 官网/邮箱/手机列表顶部增加 任务 + 状态 筛选条data-view.tsx

  6. 三个非商家 tab 共用一根筛选条(白底带边框,置于 Tabs 下方):
    • 任务筛选:嵌入复用的 TaskFilterPicker(支持名称 / ID 模糊搜索 + 复制 ID)。 选中→onPickTask→上报到 main-layout 的 dataFilter→反向把 filterTaskId 灌回。
    • 状态筛选:5 个 chip「全部 / 待采集 / 采集中 / 已完成 / 失败」(仅官网 tab 显示, 邮箱/手机 tab 都是派生列没有 scrape_status,所以不挂这个筛子)。
    • 清除任务筛选:右上角小 Button,复位 filterTaskId。
  7. 旧实现仅有一条只读 info 横条,现在变成主动可控的过滤工具。

  8. 任务详情对话框(#6 落地) —— 新 task-detail-dialog.tsx

  9. 入口:任务卡片中段「进度 a/b · 采集 c 条 [状态]」整段可点击。
  10. 概况指标条 6 项:总组合 / 已采集 / 待采集 / 净增商家 / 已耗时 / 剩余。
  11. 过滤工具栏:3 个 chip「全部 / 已采集 / 待采集」+ 关键词搜索框。
  12. 表格:每行一个 (关键词, 地区) 组合,状态 chip + 采集数;最多渲染 5000 条, 超出提示「使用搜索框收窄结果」。
  13. 数据源:新增持久化存储 local:taskProgress:${taskId},由 batch-controller 落库时同步累加(page 1 / 翻页都触发)。
  14. 匹配规则:精确 ${cat}, ${loc} 命中优先;不命中走「关键词包含 cat AND loc」兜底, 吸纳带国家后缀("dentist, Bonner, Australia")的 keyword。

新增工具

  • utils/task-progress.tsgetTaskProgress / recordTaskProgress / clearTaskProgress
  • 串行 writeChain 避免并发覆盖;删任务时一并清除 progress。
  • 同 keyword 重复上报会累加 count(翻页一次一次加上去)。

三次自检结论

  1. 数据流连通性 ✓ — recordTaskProgress 在 storePageOneData / runPagingJob 两条 落库路径都已 hook;clearTaskProgress 在 task 'delete' action 已调用; getTaskProgress 由 TaskDetailDialog 2s 轮询读取。
  2. keyword 匹配 ✓ — 内容脚本 getKeywordFromUrl+ 替换成空格,得到 "dentist, Bonner, Australia" 形式;dialog 的兜底 includes 匹配可命中。
  3. 构建产物 ✓ — pnpm build 通过,manifest 0.9.16; taskProgress: 字符串字面量出现在 background.js / main 主 chunk,说明任务进度模块被两端打入。

注意点

  • 老任务(v0.9.16 之前)没有 progress 记录 —— 详情对话框会显示「全部待采集」。 新建任务起就会正确写入。
  • 官网列表 HTTP 状态来自 page-log 1000 上限内的最近记录;老网址被环掉后会显示 -。 若长期需要稳定的 HTTP 历史,得考虑给 MapTaskData 加 http_status 列(下次再说, 不在本版本动 schema)。
  • 任务详情对话框的 (类别×地区) 组合最大 ≈ task.total 项; 16031 项的大任务渲染前 5000 条 + 搜索收窄,避免 DOM 爆炸。

2026-05-23 v0.9.14 → v0.9.15 任务卡片重塑 / 引擎联动 / 任务关联收口

本次改动(按用户反馈 7 条;#6 留到下个版本)

  1. 「提取邮箱/社媒」开启但没自动跑、点立即触发才出 1 条 —— 引擎联动缺失(batch-controller.tsengine-manager.ts
  2. 旧实现里地图任务每次落库(storePageOneData / runPagingJob)后只广播 maps-data-updated 提醒 UI 刷数字,没人唤醒官网抓取引擎;引擎只能等 1 分钟一次的 alarm。
  3. 改成落库成功后立刻 manageQueue(),新行进库即被调度(仍受总并发 / 单域名并发约束)。
  4. 现象会变成:地图任务抓到带 website 的行,官网引擎几乎实时就接着跑。

  5. 日志 3 个 tab 下大块空白 —— log-view.tsx

  6. 旧:height: listHeight(固定 ~windowHeight - 340),1 条日志时下方一大块空白。
  7. 新:maxHeight: listHeight,内容少时框紧贴一条,多时滚动。

  8. 状态 chip 挪到「采集 X 条」后 —— task-view.tsx

  9. 标题行去掉状态 chip;改放进度指标行:进度 a/b · 采集 c 条 [进行中]
  10. 视觉重点从「我是什么状态」转回「我跑到哪了」。

  11. 「停止」→「结束」+ 进入「更多」菜单 + 按钮加图标 —— task-view.tsx

  12. 行末「停止」按钮被移除;改进「⋮ 更多」菜单(黄字「结束任务」+ 二次确认)。
  13. 行末仅保留「暂停 / 继续」「查看商家」「⋮」三组,避免误触结束。
  14. 「暂停 / 继续 / 查看商家」全部加图标:PauseCircleOutline / PlayCircleOutline / Storefront
  15. 顺手把 stopped 状态文案改成「已结束」。

  16. 任务 ID 圆形 chip,置于任务名前 —— task-view.tsx

  17. 旧:id 是名字右边一长串灰字,挤眼。
  18. 新:左前方一个圆角 monospace chip 显示 id 后 4 位(如 9617); 悬停 Tooltip 显示完整 id;点击复制 + sonner 提示。
  19. 任务名回归 flex:1 主位,更突出。

  20. 任务详情 / 进度详情留到 v0.9.16

  21. 思路:进度数字点开 → 看每个关键词 × 地区是「待 / 进行中 / 已完成」+ 净增多少。
  22. 当前数据源不够:pageLog MAX_LOG=1000,长任务老条目被环掉;要么扩容、要么单独 给任务建一份 progress storage。下版本一起做。

  23. 「查看商家」跳过去仍然没数据 —— 根因:jsstore where: { taskId } 在升级后的 DB 上会给 0 行search-data.tsapi/search.ts

  24. 核对:countAllByTaskIds()(不走 where)能正确数到 266 条,说明数据本身有 taskId; 但分页接口的 where: { taskId } 死活返回空。
  25. 改造:新增 getListByTaskId(taskId, current, pageSize, sort, keyword) — 一次 select(limit: 100000) 拉全表 → JS 端 filter taskId → 关键词命中过滤 → 排序(识别 jsstore { by, type } / DataGrid { field, sort } 两种风格)→ 切片分页。
  26. apiLocalDataList / apiLocalDataCount 在 taskId 模式都走这条 JS 通路; 完全绕开 jsstore where 在 taskId 列上的坑。

顺带

  • 「结束任务」的菜单项只在 running / paused / queued 时显示,已 done/stopped 不再显示 避免误触。
  • 圆形 ID chip 上 onClick 调用了 stopPropagation 防止把卡片可能的整体点击行为带起。
  • 复制 ID 现在会用 sonner toast 提示「已复制任务 ID」,避免无反馈。

注意点

  • 任务详情 #6 真要做,得先在 task-manager 里给每个 task 写一份 progress 表 (或者把 MAX_LOG 提到 50000+),不然长任务老地区不可见。下个版本统一改。
  • getListByTaskId 走 100000 上限的全表 select;当 DB 数据量超过这个数(罕见) 会截断,按 id desc 取最近的;属可接受。
  • 引擎联动 storePageOneData → manageQueue 是同步调用、不 await,避免阻塞落库。 调度自己有 isProcessingQueue 锁,多次触发也安全。

2026-05-23 v0.9.13 → v0.9.14 侧栏排版与文案微调 3 项

本次改动

  1. 侧栏底部重排main-layout.tsx
  2. 旧顺序(自上而下):主导航 → 创建任务 → 日志/设置 → 提取开关 → 云端同步
  3. 新顺序:主导航 → 提取开关(含「立即触发」)+ 云端同步 → 创建任务 → 日志/设置(最底)
  4. 「日志/设置」沉到最底;「创建任务」就在它们正上方,符合常用层级。
  5. 云端同步的「(已开启)」改为内联「访问云端 ↗」链接cloud-data-sync.tsx
  6. 旧:开关旁文本「云端同步(已开启)」,下方一行独立的「访问云端 ↗」链接。
  7. 新:单行布局,开启时标签变成「云端同步 访问云端 ↗」(小号、不换行); 未开启时只有「云端同步」字样。
  8. 「提取官网邮箱/社媒」 → 「提取邮箱/社媒」+ 「立即触发」内联main-layout.tsx
  9. 去掉「官网」字样,文案更简洁。
  10. 「立即触发」由原本另起一行的按钮,改成开关右侧同一行的小号 Button; 侧栏紧凑后留白也少了。

注意点

  • 「创建任务」按钮上方新加了一条分隔线,把它和「提取开关 / 云端同步」明显区分开。
  • 「日志/设置」并排区不再带顶部分隔线(避免双线视觉杂乱),底部清空。
  • 「提取邮箱/社媒」标签是 noWrap;窄侧栏下不会换行,「立即触发」按钮固定不缩。

2026-05-23 v0.9.12 → v0.9.13 商家关联 / 任务筛选 / 并发 / 去重 9 项

本次改动(按用户反馈 9 条)

  1. 查看商家 → 全功能商家列表 + 任务筛选data-view.tsxlocal-data-view.tsx
  2. 旧实现是从任务跳过去后渲染一个简化的 shell 列表。现在统一渲染 LocalDataView(DataGrid + 工具栏 + 视图,全功能),把 taskId 透传给 API。
  3. apiLocalDataList 新加 taskId 入参;内部把它 AND 进 jsstore 的 where。 早期 DB 没 taskId 列时走 try/catch 兜底(忽略该条件、重查全量)。
  4. 历史数据未打 taskId 的,筛选下确实查不到 —— 属预期。

  5. 商家列表内置「按任务筛选」task-filter-picker.tsxlocal-toolbar.tsx

  6. 新增 TaskFilterPicker:Autocomplete,支持 同时按名称 / ID 搜索, 下拉里每项显示「任务名 + 短 id」。选中即应用;清空即移除筛选。
  7. 嵌入 LocalToolbar,作为筛选区一员(仅在父级提供 onFilterTaskChange 时显示)。

  8. 任务有唯一 ID,且可见 / 可复制task-view.tsx

  9. 任务卡片标题行多了一段 monospace 灰字 id + 复制图标,点击复制到剪贴板。
  10. id 仍然是 t${Date.now()}${rand} 唯一格式。

  11. 商家列表底部分页贴底local-data-view.tsx

  12. Card 改成 height: 100%; overflow: hidden,让内部 DataGrid 自己滚动; 旧的 minHeight: calc(100vh - 160px) 在某些屏高下会把分页推到视口外。

  13. 官网抓取并发:总并发 + 单域名并发engine-manager.tsstorage-data.tssettings-view.tsx

  14. 新增设置 deepScrapeDomainConcurrency(默认 2,1-10)。
  15. engine-manager 维护一张 Map<domain, activeCount>,调度时从候选里挑出 「该域名活跃数 < 单域名上限」的任务先跑;其它任务暂时跳过,等下次调度。
  16. 设置 UI 把「总并发」「单域名并发」并排两列展示。

  17. 官网任务 URL 跨任务去重task-manager.ts

  18. 创建官网任务时先 select MapTaskData 收集所有已存在的 website; 输入 URL 命中已存在的不再插入新行,仅做计数。
  19. 任务 total = 提交的 URL 数;finished 初值 = 已复用数(视为已处理)。
  20. 用户复用旧数据:原商家行的 emails/phones 直接在「邮箱列表/手机列表」展示。

  21. 邮箱 / 手机列表去重data-view.tsx

  22. 邮箱按小写地址去重;手机按数字归一化去重;同一值只保留第一个来源。

  23. 「邮箱列表」邮箱列变窄data-view.tsx

  24. 旧:邮箱 flex:1 + 来源各 200/220 → 邮箱列太宽
  25. 新:邮箱 260 固定 / 来源商家 220 / 来源网址 flex:1 → 信息更平衡。

  26. 侧栏「任务」徽标 (未完成 / 总数)main-layout.tsx

  27. 例:任务 (2/10)。未完成 = queued + running + paused;3 秒刷新。

顺带修复

  • r.logError 持续报错:原因是 countByTaskId(...) 在每 3 秒为每个任务 调一次 where: { taskId },早期 DB 没该列会抛错并自动 logError
  • 改成单次 selectByQuery({ limit: 20000, order desc }) + JS 端 groupBy 返回 Record<taskId, number>countAllByTaskIds)。一次 select 覆盖所有任务, 完全避开 jsstore where:{taskId} 路径。

注意点

  • 历史采集行(升级前)的 taskId 是空,按任务筛选时显示 0 个属预期。 让用户重新建任务测试 v0.9.13 之后的关联效果。
  • 「单域名并发」的统计是内存态,SW 重启会清空;这是可接受的,相当于「下次心跳 重新限速」。
  • 官网任务 URL 跨任务去重时拉的 limit: 100000 是粗略上限,正常用户库远不到; 如果有人 DB 超大,去重判断可能漏掉极旧的行,不会造成数据丢失,最多重复入库。
  • 任务卡片 ID 字体小且 monospace;窄屏时可能挤标题,目前给名字 maxWidth: 60% 做了让位。

2026-05-23 v0.9.11 → v0.9.12 创建/列表/数据/报错 5 项修复

本次改动(按用户反馈 5 条)

  1. 创建任务时不再设置名称task-create-dialog.tsx
  2. 去掉了上版加的 任务名称(可选) 输入框 + 相关 state / 传参。
  3. 名字改由列表内联编辑(见 #4)。

  4. 「查看商家」按任务筛选:稳健化 + 兜底(data-view.tsx / search-data.ts

  5. 筛选不再用 jsstore 的 where: { taskId }(早期 DB 没该列时 jsstore 会抛 [object Object] 并污染扩展报错面板),改为统一 select + JS 端 filter。
  6. addSearchData 加兜底:调用方没传 taskId 时,先查 local:currentTaskId, 再退查 local:taskListstatus === 'running' 的任务。
  7. 任何位置漏传都不会导致新行 taskId 空。

  8. 商家列表布局local-data-view.tsx / local-table.tsx

  9. Card 设为 flex column + minHeight: calc(100vh - 160px); LocalTable 内部容器 flex: 1 撑满剩余高度。
  10. 旧实现是 height: windowHeight - 360,大屏下表格容器比内容矮一截 → 分页条浮在中间留大块空白;现在分页条贴底。
  11. Logo 体积再压缩(compact:48×32 → 40×28;其他档同步缩 8px); 去掉了 marginTop: 1,行高更紧凑。

  12. 任务列表task-view.tsx

  13. 任务名内联重命名:名字右侧出现淡铅笔图标,点开变 TextField, Enter / 勾选保存,Esc / 叉取消,最长 60。 通过新的 task-control action: 'rename'(带 payload)落到 updateTask
  14. 删除挪到「⋮ 更多」菜单:行末尾不再直接显示「删除」按钮, 改为 MoreVertIcon → Menu → 「删除任务」(红色 + 二次确认)。
  15. 采集 X 条 实时显示:每 3 秒 countByTaskId(t.id) 查一次, 进行中/暂停优先显示实时计数,避免「采集 0 条」的视觉误判。
  16. 耗时格式精确到秒(带单位):旧 3:58/33:05:14 容易看错; 现改成 3分58秒 / 33时05分14秒 / <1m 时 32秒

  17. 修复插件报错search-data.ts / data-view.tsx

  18. chunks/package-BiZI_tu8.js (r.logError) 的源头是 jsstore 抛错时 自动 logError。常见诱因:where 指向不存在的列(DB 没升级时的 taskId)。
  19. 已经在 #2 切换为 JS-side filter,根治该路径;保留 try/catch 兜底。

遇到的问题

  • 历史数据(升级前)的 taskId 为空,按任务筛选时仍然看不到。属预期:那些 行确实没和任务关联过。新任务从 v0.9.9 起入库就带 taskId。
  • 用户截图里「采集 0 条」并不是真的没采到,是 resultCount 仅在 done 时落库。 本版改成实时计数,进行中也能看到真实数字。

注意点

  • 重命名只改 task.name,不影响商家行上的 taskId。即 task name 改了, 之前关联的商家依然能通过 taskId 找到。
  • 删除任务的二次确认提示了「已采集的商家数据不会删除」,避免误以为会清商家。
  • local-table 改成 flex 撑满后,下方多余白色被收掉。窗口非常矮时 minHeight: 320 兜底,避免挤变形。

2026-05-23 v0.9.10 → v0.9.11 商家列表/任务卡片体验优化(5 个 issue)

本次改动(按用户反馈 5 条)

  1. 商家列表加「清空」按钮local-toolbar.tsx
  2. 顶部工具栏新增 清空(DeleteSweepIcon,红色),点后 confirm 二次确认, toast.promise 显示进度,调用 clearSearchData()
  3. 任务卡片task-view.tsxtask-create-dialog.tsx
  4. 「查看商家」按钮 全状态可见(之前只有 done/stopped 才显示)。
  5. 状态 Chip 从最前位置 挪到标题行右侧(不再喧宾夺主)。
  6. 创建任务弹窗加 可选「任务名称」输入框(地图 / 官网 tab 共用,留空走默认规则)。 createTask 内部已支持 data.name 优先级,直接传过去。
  7. 商家列表布局优化
  8. 去掉底部统计条 StatusBadgelocal-table.tsx footerNode={null})。
  9. 去掉「密度」按钮 + popover(local-toolbar.tsx);默认密度从 standard 改成 compactlocal-data-table.tsx)。
  10. 「提取官网邮箱/社媒」总开关排查
  11. 现象:开关 on 但实际不抓。可能是上一轮崩溃后部分行卡在 scrape_status = 1
  12. 修复:start-deep-scrape 消息先 resetStaleTasks()(把 1 → 0)再走 manageQueue()
  13. UI:侧栏总开关下方新增 「立即触发一次」 小按钮(isRunning 时显示), 一键触发:重置 stale + 调度一次。
  14. 日志 / 设置 并排一行
  15. SECONDARY_NAV 渲染改为横向 Stack,两列 flex: 1,图标 18px、字号 caption。

遇到的问题

  • pnpm build 通过(dist-v2/chrome-mv3/);pnpm compile 报的全是历史遗留错误, 没有本次改动的文件。

注意点

  • 「查看商家」对 v0.9.9 之前创建的任务可能为空 —— 老任务没 taskId 标记。 v0.9.9 之后新建的任务才能精确筛选。
  • 「立即触发一次」按钮把任何 scrape_status = 1 的行都拽回 0 —— 如果真有任务正在跑、 按钮被反复点击,理论上可能让同一行被并发抓取两次。当前并发上限 = 1,所以问题不大; 若以后调高并发要注意加锁。

2026-05-22 v0.9.9 → v0.9.10 界面细节:创建按钮回侧栏 + 任务时间统计

本次改动

  • 「创建任务」按钮搬到侧栏:去掉右下角全局悬浮按钮(Fab),改成侧边栏「日志」上方的 常驻按钮(main-layout.tsx)。任何页面都能点;任务页顶部按钮也保留。
  • 任务时间统计
  • MapTaskstartTime? / endTime? 字段(task-store.ts)。
  • task-manager:开始 running 时记 startTime,进入 done/stopped 时记 endTime (包括正常完成、手动停止、SW 重启收尾)。
  • 任务卡片新增一行:「耗时 X · 剩余 Y」(task-view.tsx),剩余时间用 elapsed * (剩余/已完成) 实时估算(仅 running 显示)。进度列宽 140→190。

遇到的问题

  • pnpm build 成功,版本 0.9.10,改动文件 0 类型错误。

注意点

  • 老任务(升级前已创建的)没有 startTime,「耗时」显示「-」。新任务正常显示。
  • 剩余时间是线性外推估算,前期波动可能较大;建议看趋势而非精确值。

2026-05-22 v0.9.8 → v0.9.9 阶段二 b:任务↔商家精确筛选(taskId)

本次改动

数据库 schema 升迁(重要) - jsstore/base.tsMapTaskData 表新增 taskId 索引列,DB 版本 1→2。 - jsstore 4.x 对「加列 + 升版本」走升级路径、保留已有数据(理论上),新行 taskId 按写入填,旧行 taskId=''

给商家行打 taskId 标记 - jsstore/search-data.ts addSearchData(newData, advParms, taskId?) 接收可选 taskId, 写入前盖在每行上。 - batch-controller.ts: - storePageOneData:读 local:currentTaskId 传给 addSearchData(实开第 1 页)。 - runPagingJob:任务开始时读一次 taskId,所有翻页写入都带它。 - task-manager.ts:官网任务创建时,把 task.id 作为 taskId 传给 addSearchData

任务→商家筛选 UI - task-view.tsx:「查看商家」按钮带上 (taskId, taskName) 调用 onGoData。 - main-layout.tsx:新增 dataFilter 状态;点「查看商家」→ 进数据页并设过滤。 - data-view.tsx: - 接收 filterTaskId/filterTaskName/onClearFilter; - 过滤态时顶部显示蓝色「正按任务筛选:xxx · 清除筛选」状态条; - 商家 tab 过滤态切到「精简表」(5000 行上限),非过滤态仍用完整 LocalDataView; - 官网/邮箱/手机三个 list 在过滤态下只展示该任务的数据。

遇到的问题

  • jsstore ITable 不接受 version 字段(最初写错位置),version 应在 IDataBase 上。 已修正。pnpm build 成功,版本 0.9.9,0 类型错误。

注意点 / 已知风险

  • 首次加载 v0.9.9 时,IndexedDB 会从 v1 升级到 v2 —— jsstore 4.x 加列应保留已有数据, 但本地无法预测试。请重要数据先用旧版 dist/chrome-mv3/ 导出/同步云端备份,再切到 v0.9.9。
  • 升级到 v2 之后,旧版 dist/chrome-mv3/(v0.8.58,使用 DB v1)将无法再打开同一个 IndexedDB(IndexedDB 不允许降版本打开),等同于一次性切版本。
  • 商家 tab 在过滤态用「精简表」(5000 行上限),未走 LocalDataView 的完整分页 —— 因为 LocalDataView 内部 where 构造较复杂,为保 v1 简单先这样;后续可统一。
  • 旧数据(升级前已有行)taskId='' —— 这些行不会被任何任务筛选命中,但仍在「数据 > 商家」全表里可见。

改版主线全部完成

  • 阶段一/二/三/四/二 b 全部 ✅。后续视用户反馈迭代。

2026-05-22 v0.9.7 → v0.9.8 清理:删除改版后不再引用的旧文件

本次改动

经 grep 确认无引用后删除: - src/sections/page/view/page-main-view.tsx —— 旧主视图(已被 main-layout 替代) - src/sections/page/view/index.ts —— 旧 view 桶文件 - src/sections/page/view/ —— 空目录 - src/sections/page/main-filters.tsx —— 旧关键词/地区表单(已被任务弹窗替代) - src/sections/page/main-filters-data.tsx —— 仅被 main-filters 用 - src/sections/account/sync-config-dialog.tsx —— 旧云端配置弹窗(内容已抽到 settings/view/cloud-sync-settings.tsx

遇到的问题

  • pnpm build 成功,版本 0.9.8,无残留引用,改动文件 0 类型错误。

注意点

  • 改版剩余仅一项:阶段二 b(数据行 taskId 精确筛选),有 jsstore 迁移风险,单独处理。

2026-05-22 v0.9.6 → v0.9.7 aria-hidden 警告修复 + tab 图标 + 创建弹窗优化

本次改动

  • 修复 aria-hidden 警告:Chrome 浏览器报「Blocked aria-hidden on an element because its descendant retained focus」。原因是触发按钮(FAB / 任务页「创建任务」按钮)打开 Dialog 后 仍保留焦点,MUI 给祖先加 aria-hidden 时 Chrome 拦截。
  • 修复:触发按钮 onClick 里先 e.currentTarget.blur() 取消焦点再打开 Dialog。
  • 涉及:main-layout.tsx 的 Fab、task-view.tsx 的「创建任务」按钮。
  • 内页 tab 加 svg 图标
  • 数据页 4 个 tab:商家(Storefront) / 官网(Language) / 邮箱(Email) / 手机(Phone)。
  • 设置页 2 个 tab:本地设置(Tune) / 云端设置(Cloud)。
  • 创建任务弹窗优化
  • 弹窗尺寸 mdlg,更舒展。
  • Tab 改为全宽居中(variant="fullWidth"),加 svg 图标,更醒目。
  • 重命名:「地图任务」→「地图商家提取」(PinDrop 图标); 「官网任务」→「客户官网挖掘」(TravelExplore 图标)。
  • 每个 tab 内容上方加一句简介(从地图按类别+地区批量提取 / 粘贴客户官网挖联系方式)。

遇到的问题

  • pnpm build 成功,版本 0.9.7,改动文件 0 类型错误。

注意点

  • aria-hidden 警告还可能从其他 Dialog 触发器漏出来(如 SyncConfigDialog —— 但已不再用)。 如再见到,同样在触发按钮 onClicke.currentTarget.blur()
  • 命名建议:地图商家提取 / 客户官网挖掘 —— 直观点出"做什么 + 数据从哪来",符合 B2B 销售 线索挖掘的实际语境。

2026-05-22 v0.9.5 → v0.9.6 UI 改版 阶段四:采集规则可配置 + 官网采集深度

本次改动

  • 新增 3 个设置storage-data.ts):
  • scrapeDepth(1-3,默认 1):官网采集深度。
  • emailRegex(默认空 = 用内置):邮箱正则可自定义。
  • phoneRegex(默认空 = 不提取手机):手机正则;留空时不提取(避免噪音)。
  • scraper.ts extractDataFromHtml:接收 {emailRegex, phoneRegex} 选项;自定义时 按用户正则提取邮箱/手机;非法正则自动回退默认。返回新增 phones: string[]
  • scraper-executor.ts:读取设置传给抓取器;提取到的手机号写入日志 phone 字段 (优先于 WhatsApp)。
  • dynamic-scraper.ts:新增 findSubLinks —— 从首页 HTML 提取同域的 contact/about 类子页链接;scrapeWithTabdepth>1 时遍历最多 (depth-1)*3 个子页, 在同一标签页里依次 tabs.update 切到子页、等加载完、抓 HTML、聚合到主结果。
  • settings-view.tsx:新增「官网采集深度」字段(采集 section 末尾)+ 新增「采集规则」 section(邮箱正则、手机正则文本框)。

遇到的问题

  • pnpm build 成功,版本 0.9.6,改动文件 0 类型错误。

注意点

  • 默认仍是「仅首页 + 内置邮箱正则 + 不提取手机」—— 完全向后兼容,老数据/老设置不受影响。
  • 子页采集复用同一标签页(tabs.update),不会额外开标签;聚合 HTML 是把子页 HTML 直接拼到 主页 HTML 后面,下游 extractDataFromHtml 把所有页面一并解析。
  • 手机正则默认留空 = 不提取 —— 网页里电话号码格式噪音很大(价格/日期/ID 等),默认关 比误抓更负责;用户可在设置里粘自己的正则启用。
  • scrapeDepth=2 会让单个官网采集时间增加(约 +N 个子页的加载耗时);按需开。

改版进度

  • 阶段一/二/三/四 全部完成 ✅。剩余:阶段二 b(taskId 精确筛选,需谨慎做 jsstore 迁移)+ 几个不再引用的旧文件待清理。

2026-05-22 v0.9.4 → v0.9.5 UI 改版 阶段三:数据列表(官网/邮箱/手机)

本次改动

  • data-view.tsx 重写:数据页四个 tab 全部做实 —— 商家(复用 LocalDataView)、 官网列表、邮箱列表、手机列表。
  • 官网列表:网址 + 采集状态(待采集/采集中/已完成/失败)+ 商家 + 邮箱数。
  • 邮箱列表:邮箱 + 来源商家 + 来源网址(把每行商家的 emails 数组摊平)。
  • 手机列表:电话 + 来源商家 + 来源网址。
  • 数据源:selectByQuery('MapTaskData', { limit: 2000, order: id desc }),每 5s 刷新。

遇到的问题

  • pnpm build 成功,版本 0.9.5,改动文件 0 类型错误。

注意点

  • 官网/邮箱/手机三个列表是「快览」:取最新 2000 个商家范围内的数据(带上限说明)。 完整大数据量仍走「商家」tab 的分页表格 / 云端。后续如需可升级为分页。
  • 官网列表的「导入网址」已由「创建任务 → 官网任务」承担,故此列表只做展示。

改版进度

  • 阶段一/二/三 已完成。剩余:阶段四(设置增强:邮箱/手机正则、官网采集深度); 阶段二 b(taskId 精确筛选);旧文件清理。

2026-05-22 v0.9.3 → v0.9.4 设置页拆分本地/云端 tab

本次改动(用户 3 点中的 #2)

  • 新增 src/sections/settings/view/cloud-sync-settings.tsx:从 SyncConfigDialog 抽出的 云端同步配置表单(同步开关 / 同步参数 / 同步标签 + 保存)。
  • settings-view.tsx:顶部加 Tabs(本地设置 / 云端设置);本地 tab = 原运行参数表单, 云端 tab = CloudSyncSettings。组件改为受控(tab / onTabChange props)。
  • main-layout.tsx:新增 settingsTab 状态;侧边栏云端 ⚙ 改为「跳转到 设置 > 云端 tab」。
  • cloud-data-sync.tsx:⚙ 行为由「弹 SyncConfigDialog」改为回调 onOpenCloudSettings; 移除对话框相关代码。

遇到的问题

  • pnpm build 成功,版本 0.9.4,改动文件 0 类型错误。

注意点

  • 云端设置现统一在「设置 > 云端」tab;侧边栏 ⚙ 是快捷入口(跳转过去),入口保留、配置 只一处。
  • sync-config-dialog.tsx 已不再被引用(内容已抽到 cloud-sync-settings.tsx), 与 page-main-view.tsx / main-filters.tsx 一并待清理。

改版剩余阶段

  • 阶段三:数据页 —— 官网列表 / 邮箱列表(含来源)/ 手机列表(含来源)(现为占位)。
  • 阶段四:设置增强 —— 邮箱/手机正则、官网采集深度(首页 + 站内子页)。
  • 阶段二 b:MapTaskDatataskId,任务↔商家精确筛选(需谨慎处理 jsstore 迁移)。

2026-05-22 v0.9.2 → v0.9.3 官网任务类型 + 全局创建按钮

本次改动(用户 3 点中的 #1、#3)

  • 任务支持两种类型task-store.ts MapTasktype/websites):
  • 地图任务:关键词 × 地区(原有);
  • 官网任务:直接粘贴网址列表(≤10000)。
  • 创建任务弹窗改双 tabtask-create-dialog.tsx):「地图任务」(关键词+地区) / 「官网任务」(网址文本框,一行一个、去重计数)。
  • 官网任务执行task-manager.ts createTask):官网任务不进地图队列 —— 创建后把 网址写成 MapTaskData 行(web_<url> 作 googleId 去重)并自动开启官网抓取引擎, 交由现有「提取官网邮箱/社媒」引擎并行处理。
  • 全局「创建任务」悬浮按钮main-layout.tsx):右下角常驻 FAB,任何页面都能创建; 创建弹窗提升到 main-layout 统一管理;任务页顶部按钮保留。
  • task-view.tsx:区分渲染地图任务(进度条 + 队列控制)与官网任务(网址数 + 删除)。

遇到的问题

  • pnpm build 成功,版本 0.9.3,改动文件 0 类型错误。

注意点

  • 官网任务由官网引擎并行处理,不占用地图任务的串行队列(与「地图采集 vs 官网采集本就 并行」一致)。
  • 官网任务当前为「提交记录」式:显示网址数,暂不显示逐条实时进度(精确进度需按任务 标记数据行,留待后续);创建即自动开启官网抓取引擎。
  • 用户 3 点中的 #2(设置页拆「本地/云端」tab、云端同步配置并入设置页)尚未做,下一步处理。

2026-05-22 v0.9.1 → v0.9.2 UI 改版 阶段二:任务队列

本次改动

  • 任务数据模型 src/utils/task-store.tsMapTask 实体(id/名称/关键词/地区/状态/ 进度/结果数等),存 local:taskListbuildTaskUrls 把任务转成谷歌地图搜索 URL。
  • 任务队列调度器 src/entrypoints/background/task-manager.ts
  • pumpTasks —— 无批次在跑时取队首 queued 任务开跑;
  • onBatchDone —— 批次完成回调:标记任务 done、按全局商家数差值算 resultCount、 推进下一个;
  • createTask / controlTask(暂停/继续/停止/删除);
  • reconcileTasks —— SW 重启收尾卡住的任务。
  • batch-controller.ts:新增 setBatchDoneHandlerfinishBatch 完成时回调队列); 新增 storePageOneData(见下)。
  • background/index.ts:注册队列回调;新增消息 create-task / task-control / store-page-data;启动时 restoreBatch → reconcileTasks
  • 任务页 UIsrc/sections/task/task-view.tsx(任务列表 + 进度 + 控制按钮)、 task-create-dialog.tsx(创建任务弹窗,复用关键词/地区选择器)。
  • main-layout.tsx:「任务」页由 MainFilters 换成 TaskView;移除随之不再需要的 runState/onClearData 等。

遇到的问题

  • 问题:新布局下,第 1 页商家数据会丢。
  • 原因:实开第 1 页数据原本经 has-new-data 发给 local-data-view 入库;新布局里 用户在「任务」页时该组件未挂载 → 消息无人接收 → 数据丢失(旧布局也有此隐患,新 布局放大了)。
  • 解决方案:第 1 页入库改走后台 —— 内容脚本发 store-page-data 消息,后台 storePageOneData 解析入库。至此所有地图数据均由后台入库,不依赖任何 UI 页面。
  • pnpm build 成功,版本 0.9.2,改动文件 0 类型错误。

注意点

  • 任务以队列形式跑:创建即入队,无批次在跑则自动开跑,一个完成自动接下一个。
  • resultCount 用「任务结束时全局商家数 − 开始时商家数」估算(队列串行,差值≈该任务产出)。
  • MapTaskData 暂未加 taskId 字段(jsstore 改表有数据风险,留待阶段二 b 谨慎处理); 故「查看商家」目前跳到数据页商家 tab,暂不按任务精确筛选。
  • main-filters.tsx / page-main-view.tsx 已不再被引用,后续清理。