开发日志归档(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 只装 unhandledrejection 漏 error) — 加 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 大重构后用户实际遇到。
修¶
- DataView 暴露
rowsLoading(来自 useRequest.loading) - LocalDataView Props 加
rowsLoading?: boolean - 空状态 CTA 加
!rowsLoading守卫 - 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,避免触发浏览器确认弹窗。
根因¶
forceCloseSharedWindow 用 browser.windows.remove(id),Chrome / Cent Browser
的 confirmBeforeWindowClose 对 windows.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 / hasEmailOnly,apiLocalDataDelete/Export 只看 filters+keyword+logic。
client mode 用 has-email chip + filterTaskId 看 10 行 → 选全部 → server 用空 filter 删全表 22w。
修¶
- search.ts 抽
resolveTargetRowshelper:统一 'current'/'front'/'all' 三种 selectOption 行为 - 'current' → id list(最快)
- 'front'/'all' 无 client filter → 走原 jsstore where(高效)
- 'front'/'all' 有 taskId/hasEmailOnly → 全表 50k + JS filter + slice
- apiLocalDataDelete / apiLocalDataExport 都用 resolveTargetRows
- saveParams 加 taskId / hasEmailOnly / quickFilter
- 撤销 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 → 错误日志。
修¶
- ext-context-guard.ts 改 IIFE import-time 自启:
- 4 个 entry main.tsx 第一行 import:
- 顺便处理
[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 数据)¶
- sidebar "商家列表 228430" 但 chip "全部 50.0k" — 数据矛盾
- 打开列表非常卡,加载好久
病灶¶
merchant-stats.ts ROWS_CAP = 50_000:select 50k 行 + JS for loop count。
22w 数据 → 拉 50k 后停 → total = 50_000 cap → 与 sidebar 真实数矛盾。
每 10s 轮询 × 50k select = 持续卡。
修¶
- 无 taskId 走 jsstore count(毫秒级):
total / scraped / pending三个并发 count - 保留 50k 采样 for
withEmail / withWebsite / highRating(jsstore 不能查 array length / 模糊匹配) - 新增
truncated字段 + UI banner:明示哪三个是采样数 - 轮询 10s → 30s:CPU 占用降 3x
- 有 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)¶
用户反馈(截图)¶
- "创建任务有 3 个?建议去掉右上角的"(v0.10.64 加的顶部 CTA 跟 task toolbar 重复)
- "任务建议加上全部暂停"
修¶
- 撤回 v0.10.64 顶部 CTA:main-layout DataBar 行恢复独占 — 只保留 sidebar 底部 + task-view toolbar 两处。sidebar 那个跨 page 切换可见,task toolbar 在 task 页同视野,不重复。
- task-view toolbar 加「全部暂停 / 全部继续」:
- 仅在
runningTasks > 0/pausedTasks > 0时显示 - 文案内带 counts:"全部暂停(N)"
- 实现走 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 真漏修¶
- 🟡 init race(防 first dataRun 用旧默认值)
- merchant-quality.ts:init 改幂等 promise + ensureQualityConfigReady
- api/search.ts:needsClientSort 前
await ensureQualityConfigReady() - 🔴 改完不刷新(用户改 Settings 后 DataView 不重 fetch)
- local-data-view.tsx:订阅
qualityConfigItem.watch→ 立即 getTableData() - 🟡 setValue 漏 catch(保存失败静默)
- 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 条规则¶
- cache 初始化幂等 + lazy + 返回 promise(任何 caller 可 await)
- storage.watch 不只更新 cache 还要触发 UI 重渲(订阅方在 React 组件用 useEffect)
- 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 预设 + 自定义)。
修¶
merchant-quality.ts重构:- 加
QualityConfiginterface(5 权重 + 2 阈值 + 3 等级门槛) DEFAULT_QUALITY_CONFIG保留 v0.10.0 数值 — 老用户升级无变化QUALITY_PRESETS:默认通用 / 邮件营销侧重 / 电销侧重 / 高端市场- sync 缓存机制:
initQualityConfigCache()+storage.watch监听变更 - main-layout mount 时
initQualityConfigCache()— 后续 sort/render 同步可读 - Settings 新 SectionCard "质量分(找高价值客户)"(本地设置顶部,显示比例下):
- 4 个预设按钮(命中自动高亮)+ 恢复默认
- 5 个维度权重 TextField + helperText
- 2 个阈值(评分/评论数门槛)+ 满分 chip(≠100 时 warning)
- 3 个等级门槛
- 即时生效(同 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 字段都能真排序。
修¶
src/sections/page/local-table.tsx撤回quality/social从 unsortablesrc/api/search.ts加:COMPUTED_SORT_FIELDSsetcomputeSortValue(row, field)— 跟 valueGetterFor 一致applyClientSort(list, sort)— 多列 sort 支持hasComputedSort(sort)检测- 在 hasEmailOnly 同一路径里加
needsClientSort分支(taskId 和非 taskId 两条都加) - sort 拆分:
dbSort(仅真实列)传给 jsstore;完整 sort 用于 JS post-sort - 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 底部,新用户找不到 |
修¶
- 新建
src/hooks/use-display-zoom.ts— 用body.style.zoom持久化到 localStorage - 范围 80%~150%,step 10%
useApplyDisplayZoom()在 main-layout mount 应用useDisplayZoom()给 settings 滑条用- 跨 tab storage event 同步
- Settings 顶部加「显示比例」SectionCard — Slider + marks + chip + 恢复按钮
- 5 处「创建任务」按钮加
color="primary"— 走主题色不再黑 - popup 文案 + URL 改为云端:
- DataGrid 加
paginationModel— 控件化让 internal pageSize 与外层 state 同步 local-table.tsx质量分加进 unsortable +descriptiontooltip 提示用快速筛选- 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' 分支:
只兜 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 全部漏。
修¶
- 抽 normalizeLocations helper(
src/api/others.ts)覆盖 5 种 shape: [...]/{ items }/{ data }/{ data: { items } }/{ list }- 4 个 caller 全切换:task-create-dialog / country-stats / simple-locations-select / location-picker-dialog
- 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:
- unmount race:用户点 ↻ 重试国家点位 → 异步还在跑 → 关 dialog → 卸载后的 setState → React 警告
- 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-bridgeonMessage注册的 handler(在 background)。所有 caller 必须用 webext-bridgesendMessage(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 compile0 错 - ✅
pnpm build9.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 compile0 错 - ✅
pnpm build8.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 compile0 错 - ✅
pnpm build8.49s
元-观察:第 5 个用户使用反馈¶
不同家族。继续印证:5 轮 agent + 6 工具收敛后,真实用户使用仍能稳定找 bug。UI 操作类(按钮、滚动、显示)audit 工具天然看不到。
后续 todo(已积累的)¶
- 写 rule
docs/rules/错误传播规范.md沉淀 4 条(前 4 issue 模式) - 写 rule
docs/rules/Dialog内布局规范.md沉淀 list overflow / grid 列数规则 - 写 scan-error-handling.py 工具扫静默 catch
- E2E 测试覆盖关键 UI 路径
2026-05-27 v0.10.54 → v0.10.55 修州/省加载失败被误报"无数据"(ISSUE-0037)¶
用户反馈¶
截图:国家列表 Australia 显示「8 州 · 16023 城市」→ 点
>进详情 → 州列表完全空白,说"该国家无州/省效数据"。
矛盾:缓存说有 8 州,详情却说没有,且没有重试入口。
根因¶
location-picker-dialog.tsx v0.10.55 之前:
链条:
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 态:
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 compile0 错 - ✅
pnpm build14.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 入口守门¶
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 compile0 错 - ✅
pnpm build7.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 抽象:
两套 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 compile0 错 - ✅
pnpm build7.88s - ✅
pnpm scan:issue-coverage0 命中(同模式已修干净)
元-观察:第 2 个用户使用反馈发现的 bug¶
v0.10.52 ISSUE-0034: Failed to fetch 冒 chrome 错误页
v0.10.53 ISSUE-0035: 按钮无反应(本 issue)
5 轮 agent + 5 工具收敛 ≠ 真的没问题。
真实用户使用是 audit 不能替代的检验。
后续可做(todo)¶
- 写 rule
storage-api 统一规范(强制全仓 wxt/storage 抽象,禁用原生 browser.storage.local) - E2E 测试覆盖关键 UI 按钮路径
2026-05-27 v0.10.51 → v0.10.52 修 Failed to fetch 误显示在 chrome 错误日志(ISSUE-0034)¶
用户反馈¶
截图显示 chrome://extensions 错误页:
用户直觉 = 扩展有 bug。实际上业务已 handle(auth-provider onError 等)。
根因¶
ext-context-guard.ts:107 的 unhandledrejection 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 compile0 错 - ✅
pnpm build8.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¶
第 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 路集成¶
同时清掉 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 compile0 错 - ✅
pnpm build7.70s - ✅
pnpm scan:issue-coverage0 命中(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 建议:
- 写 E2E 测试覆盖 4 个 resume 路径(防回归)
- settings-view onReset 显式补 broadcast(极小改进)
- 删 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 修后跑:
这证明 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 catch ❌ v0.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-datalistener(page-results 注释发送) - 删
search-api-responselistener(无 sender) - 删
pushData死函数 +parseSearchDataimport 死代码
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 compile0 错 - ✅
pnpm build7.32s - ✅
pnpm scan:mv3 --diff0 新增 - ✅
pnpm scan:protocol0 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 同源。
收敛态势¶
每轮固定 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¶
验证:
- 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 路集成¶
触发条件: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 compile0 错 - ✅
pnpm build8.47s - ✅
pnpm scan:protocol0 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} 的旧模式:
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 compile0 错 - ✅
pnpm build7.34s - ✅
pnpm scan:react0 命中(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 compile0 错 - ✅
pnpm build7.29s - ✅
pnpm scan:react0 命中(修完 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 compile0 错 - ✅
pnpm build8.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 模式找到了。
修:
🟡 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 compile0 错 - ✅
pnpm build8.12s - ✅
pnpm scan:mv3 --diff0 新增(基线 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}.ts、
engine-manager.ts、auto-login.ts 改动时,自动跑 scan:mv3 --diff。
任意一个失败 → 阻止 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);
git checkout 还原后:
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¶
修:用「」全角引号绕过。
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 项自查可执行化:
输出按 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 build8.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 compile0 错 - ✅
pnpm build7.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:
链条:用户点"我已验证完" → setTimeout(30s) 排队 → SW kill → setTimeout 丢 → 永远不 resume → 用户卡在 intercepted。
🔴 P0-C: TaskStatus 扩 'intercepted' 概念错误(v0.10.39 引入)¶
v0.10.39 把 TaskStatus = ... | '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 compile0 错误 - ✅
pnpm build8.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.ts 的 Custom/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-namespace → React.ReactElement |
client-data-table.tsx |
MUI x-data-grid slots/slotProps 自定义字段 as any |
content-search/index.tsx |
把 async listener 拆为同步 listener(onMessage 类型不收 Promise |
custom-autocomplete.tsx |
filterOptions 整体 as any(filter 是 <unknown>,与外层泛型 T 不兼容) |
object-autocomplete.tsx |
isOptionEqualToValue 参数 : any(泛型 T 没强制 {value} 约束) |
中间踩坑:误删导致 freesolo 编译失败¶
初始我把 custom-autocomplete.tsx 和 object-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 compile0 错误(19 → 0) - ✅
pnpm build8.3s - ✅
pnpm docs:check0 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 build7.3s - ✅
pnpm docs:check78 文档 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)
缺失/不到位:
- 任务卡 chip 仍显示"进行中"(STATUS_META 缺 intercepted)
- 仅系统通知 — 切窗口/静音就错过,应用内无横幅
- openVerifyTab 又开新 maps tab,用户面前已有 sorry 页
- 恢复要逐个任务点"继续",无一键
- sorry tab url 跳走(用户验证完)无自动监听
- 立即恢复 = 立即再撞,无冷却期
三层修复¶
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.ts 加 tabs.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 build10.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:
- counts 拆开:mapsOpenTotal / mapsOpenSuccess / mapsOpenFailed(count>0 视为成功)
- Tab 标签:
地图实开 (265 ✓220 ✗45)—— 绿色成功 + 红色失败 inline 显示 - Tooltip 解释口径:
- 实开 Tab:"含被拦截/失败的 0 条记录;✓成功=第 1 页 ≥1 条;✗失败=0 条"
- 请求 Tab:"仅当第 1 页满 20 条才触发;数据稀疏时本数 < 实开数正常"
- 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 build8.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
④ 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:277 的 installListener 只处理 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 旧版正则:
| # | 缺陷 | 后果 |
|---|---|---|
| ① | 字符类含 % |
URL 编码 %20 %0a %3a 全被当合法本地部分 |
| ② | 量词 + 无上限 |
贪婪匹配到 64+ 字符(RFC 限 64) |
| ③ | 不解 mailto | 直接对原始 HTML 跑正则,错过结构化机会 |
源 HTML 大致:
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 build8.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 → 用户提的小事容易被遗忘。
改动¶
scripts/rebuild-docs.py新增build_todo():扫所有 frontmatter 的 status,自动生成docs/_todo.md:- specs status ∈ {draft, approved, in-progress, parked}
- issues status ∈ {recurring, observing, in-progress}
- raw status ∈ {unprocessed} 或缺失
-
顶部显示总数;按类型 + 状态分组;末尾给 AI 提示
-
新建
docs/rules/todo-registration.md:教用户/AI"想做但不立刻做"的归宿 - 决策树(bug? 需要设计? 小想法? 灵感?)
- 兜底:没空评估 → 粘到
raw/inbox/,零成本登记 -
反模式清单(不要用 dev log、不要用 TaskCreate 记跨会话的事)
-
CLAUDE.md加入口: - 文档体系表加第 0 行:
docs/_todo.md标"每次会话先扫一眼" - 高频踩坑表加一条:用户说「以后做」→ 走
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 |
注意点¶
- ⚠️ 不要删
merchantStatsstate: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 errorpnpm 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 errorpnpm 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 位置 + 网络状态搬家¶
用户反馈¶
- 优化提醒,在界面中间偏上可好?其他的是不是要同步进行?
- 网络状态放右上角"谷歌地图采集"后边???
附 2 张截图:登录成功 toast 在左下角被「创建任务」按钮压住;网络状态点在侧栏底部不显眼。
改动¶
1. Toast 位置 bottom-left → top-center¶
src/components/sonner/toaster.tsx 单一配置点:
回答用户「其它要同步吗」= 是。所有 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 errorpnpm 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:
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 errorpnpm 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¶
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 warningpnpm docs:rebuild✅ 5 INDEX + 7 hub + overview 全 stablepnpm 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-engine → syncWatchdogAlarm 清 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 流程:
- ✅
docs/raw/feedback/粘贴用户原话 + 截图描述 - ✅
docs/specs/done/SPEC-002完整 PRD(含决策记录章节) - ✅ 实现 5 个源码文件
- ✅ 更新 2 个 wiki + 2 个 INDEX
- ✅ 开发日志 + 版本号
这是新文档体系(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 新独立列渲染器)当前布局:
修法:分类 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 应对齐到「商家」列右侧
落档¶
- 新建 ISSUE-0021
- INDEX.md 加一行
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.54spnpm compile✅ 0 新错误
注意点 / 落档¶
- 「修完即审」是好习惯:v0.10.18/19 几个看似修完的 bug 在审查中被发现没修到根(#2/#13)或漏了分支(#1/#12)。代码审查与修复必须分开做。
- agent 做 code review 性价比极高:1 次 agent 调用找出 15 个问题,比我自己慢慢扫节省大量 context
useMemodeps 含 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
Bug B:columns 数组引用每次新建¶
components/table/client-data-table.tsx:131 的 renderColumns() 每次 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.32spnpm 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 apiLocalDataList 加 hasEmailOnly 参数 → 走 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.35spnpm 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)
三层文档体系成型¶
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.11spnpm compile✅ 0 新错误
验证 bug 修复(用户视角)¶
- 清空 storage 或卸载重装:进入登录窗 → 应弹窗(修复前可能不弹)
- 登过的、token 过期:主面板显示「立即登录」 → 点击 → 优先尝试 cookie,cookie 失效则弹登录窗
- 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-login(force=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-login 走 autoLoginViaCookies(true)。
验证¶
pnpm build✅ 8.50spnpm 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.16spnpm 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 条同时)¶
- 实开 Tab 并发 ≠ 任务数,不是一回事!
"可以就开 1 个任务,但是我可以一次取 5 个进行执行"
- 网站抓取也是共享队列,「同时挖几个网站」是错的命名,应该是「线程数」/「并发」
- 不同根域名也要支持跟随,不仅子域
- 邮箱黑名单要支持域名级,不仅是单个邮箱
我之前错在哪¶
| 条目 | v0.10.13 实现 | 问题 |
|---|---|---|
| #1 | label="实开 Tab 并发(= 同时进行的任务数)" | 等号那部分是错的。源码注释明明白白说 maxConcurrentTasks 是 activeTabs.size 的全局上限,调度器 round-robin 从 running 任务里挑 URL,与任务数解耦 |
| #2 | label="同时挖几个网站" / helper="所有正在挖的官网同时进行的总数" | "网站数"误导。源码 engine-manager.ts 的 activeTasks 是全局并发 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.ts 加 followAllowedDomains: 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.35spnpm 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(地图抓取 + 网站抓取)都用「举例」把这个心智说透。
followAllowedDomains与followSubdomain互补不冲突:前者是"指定其它根域",后者是"放宽到子域"。两个都开 = 最宽松。isEmailBlacklisted现在对内置 5 项也跑这个新逻辑:内置全是完整邮箱(含 @),走精确匹配分支,与 v0.10.13 行为一致。如果以后想给内置也加默认域名屏蔽(如 gmail.com),加到 BUILTIN_EMAIL_BLACKLIST 即可。- 「企业邮箱过滤」功能(advParms 里的 enterpriseEmailOnly)是另一回事:那是搜索后过滤;邮箱黑名单是提取阶段就过滤。两者可叠加。
2026-05-26 v0.10.12 → v0.10.13 设置页大重构(解析层剥离 + 正则演示 + 内页跟随可配)¶
用户反馈(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 字符串(逗号/换行分隔)追加到内置后去重。
二、dynamic-scraper.ts findSubLinks 可配置化¶
新增 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.25spnpm 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 槽」。
改动¶
- 删除
requestConcurrency字段在 UI 的暴露 - 这是 v0.9.x 的「单任务同时打开几个搜索页」概念。v0.10.0 之后调度器只看
maxConcurrentTasks全局上限,requestConcurrency已彻底无人读。 - storage interface 保留(兼容旧数据),yup schema 移除(无 UI 自然无校验)。
-
setValue(k as any, ...)处加 cast,避免 RHF 类型不含已删字段导致 TS 报错。 -
重写"地图实开队列"+"地图请求队列"子区文案:
- 子区头改成「地图实开队列(前台 Tab 抓第 1 页结果)」+「地图请求队列(后台 Fetch 第 2-15 页)」——明确这是两个全局队列,不是 per-task 子设置。
maxConcurrentTasks的 label 改成「实开 Tab 并发(= 同时进行的任务数)」,helper:「全局共享。同一时刻最多开多少个 Google 地图搜索 Tab(1-10)。多任务 round-robin 共用。」pagerConcurrency的 label 改成「请求并发(= 同时进行的翻页 Fetch 数)」,helper:「全局共享。多任务的翻页请求共用这 N 个并发槽位(1-5)。设 5 比设 1 快约 5 倍。」-
每个子区顶部把并发字段放第一行(最重要的设置不能埋在间隔后面)。
-
顶部插架构说明 banner(dashed border + info 色背景):
一段话讲清楚 v0.10.0 架构变更,避免用户继续误以为「N 个任务 × 3 槽 = 15 个 Tab」。 -
PRESETS 同步:3 套预设全部移除
requestConcurrency字段。 - conservative:maxConcurrentTasks 1, pagerConcurrency 1
- balanced:maxConcurrentTasks 2, pagerConcurrency 1
- 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 件套(用户便捷性优化)¶
用户反馈(多条同时)¶
- 任务页没有任务时,要有居中提醒
- AccountPopover 下拉太高,popup 里弹出来挤
- popup 底部要有 设置 / 文档 / 客服 入口(一行)
- popup 底部 谷歌地图 + 来发信网站 当前 2 行 → 应该合并 1 行
- 用词与已有功能保持一致(用 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。
Popup 最终结构¶
┌─────────────────────────────────────┐
│ 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-settingsmessage —— 跟 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.ts 的 countAllByTaskIds 注释明确写过:
「不再走 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 端 filterpackage.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]
2026-05-25 v0.10.1 → v0.10.2 Cookie 自动登录(修复"登录经常失败")¶
起因¶
用户反馈:"现在的登录经常登录失败,可以插件自动检测 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 调用 - 任何一个环节断了,扩展就不知道用户已登录
新方案:直接从浏览器 cookie 读¶
如果用户已经在 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:
- SW 启动后静默尝试
autoLoginViaCookies(false)—— 用户已登录但 storage 被清的场景 能自愈 'open-login'message handler 改成:- 先尝试 cookie 自动登录
- 成功 → 返回
{ autoLogin: true },不弹窗 - 失败 → 弹老登录窗口
- 新增
'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 名字¶
我不知道后端实际给 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 项)¶
- 视觉超载 —— 一行 ~30 个独立视觉元素,眼睛累
- 信息层级混乱 —— 商家名和社媒图标字号差不多,无视觉权重
- emoji 滥用 ——
📞 ✉️像 90 年代 ICQ,不专业 - 默认列宽超屏 —— 5 列 1147px + 200 sidebar = 1347px,1280 屏溢出
- DEFAULT_FIELDS show: false —— 默认所有列都隐藏(疑似 bug)
- 复合列不可排序 —— profile / contact / location 3 个最重要列都不能 sort
- 缺快速操作 —— 没行级「复制邮箱」「打开 Mailto」等
- 缺顶部聚合 —— 看不到「邮箱覆盖率 35%」之类
- 没有质量分 —— 用户无法快速找到「高价值 leads」
- 行交互弱 —— 点击行没反应
新设计方案(全量改完)¶
列结构(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)¶
每个 KPI 卡片有色彩区分;hover 显示 tooltip 解释;占用 ~50px 高度。
快速筛选 Chip 行(MerchantQuickFilters)¶
单选互斥,点击即应用。映射到底层 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。
两大根本性修复:
- 唯一调度者 —— 不可能"互相抢焦点",因为只有一个节奏控制器
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.ts — openTabInShared 加 active: 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 compile0 错误(5 个改动 / 新建文件干净) - ✅
pnpm build12.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 内):
新逻辑(每个 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 像"奖励"一样吸引视线。
文件变动¶
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 默认值加 todayNewsrc/sections/popup/popup-data.ts—— counts 默认值加 todayNewpackage.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 一行平铺,在任务/数据/日志/设置页面顶部都持久显示
- 默认首屏 overview → task
- 数据子菜单合并进 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 秒内看到关键状态 → 大概率不用再开主面板。
信息架构(视觉权重高 → 低)¶
- 会员状态 —— Chip + 到期日(永久 / 剩余 N 天 / 已过期 N 天),点击进账户
- 引擎运行状态 —— 8px 脉冲圆点 + 文字 + 内联「立即触发」(仅 isRunning 时显示)
- 累计数据卡片 —— 左商家右邮箱,大字号 H6(k 化:12.8k),点击进数据页
- 任务概况 —— "N 个进行中 / M 个总",点击进任务页;0 任务时显示「点击下方创建」
- Primary CTA —— [➕ 创建任务][📊 打开主面板] 双按钮,前者 contained 主色更显眼
- 次级跳转 —— 谷歌地图 / 来发信网站,外链图标 + 新标签页打开
关键设计决策¶
| 决策 | 选择 | 理由 |
|---|---|---|
| 数据来源 | 本地 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 占用):
新布局(双语并排 Row 1、国旗+代码+stats 降为 Row 2):
设计理由:
- 用户主要靠文字识别国家(中文/英文都能搜),国旗只是视觉辅助
- 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 Box 加 minHeight: 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) ✓ 单这一级不够
第二版改动(全链路打通):
- Paper:加
overflow: 'hidden'—— 关掉 MUIMuiDialog-paper默认的overflow-y: auto。 不关的话,会让 Paper 自己滚(坏 UX:header/footer 跟着滚出去) - DialogContent:加
minHeight: 0—— 配合已有的overflow: hidden,使 flex: 1 真的能缩到目标高度 - Stack:
height: '100%'→flex: 1, minHeight: 0—— 百分比换 flex 项,跨浏览器更稳 - 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 个需求¶
- 搜索深度 4 个字单独加粗 + 选项加序号(CSS 实现),副标「更快/更细」
- 国家选择器后面显示该国家的地图数量;预加载到本地、失败显示状态、成功显示数量
- 日志/设置下方加网络状态指示;用户提了「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:按上面方案表跑探测;runOnce 用 runningRef 防并发重叠;连续失败计数走 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 消息:
事故链:
1. 引擎跑时被抓的行标记 scrape_status=1
2. SW 被 Chrome 回收(idle 5 分钟)或开关被切 OFF —— 这批行永久卡在 1
3. scraper-executor 的失败兜底有「engine off 时回置 0」,但 只走失败路径;SW 直接被杀连这个 catch 都执行不到
4. 用户切开关 ON → manageQueue 查 where: { scrape_status: 0 } → 查不到卡 1 的行 → 队列空转
5. 用户点「立即触发」→ resetStaleTasks 把 1 全刷成 0 → 队列重新捞到 → 开始抓
修复(2 处)¶
1. src/entrypoints/background/index.ts —— 开关 ON 走 reset:
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 秒轮询
这样 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 内部的
rowsuseRequest 也保留 —— 它支撑商家表格本身的渲染 + 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)¶
- 全量场景: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.ts、ConfirmDialog.tsx、types.ts
- 目录只留新通用 index.tsx
- 顺手把 sections/views/ 下三处用老 API 的调用(filters-drawer / table-views-tabs / view-select-popover)
迁移到新 API:content → message、action → onConfirm + 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 空名州兜底 + 搜索深度上移到任务弹窗¶
用户反馈¶
- 「没名字的州」:选了 Anguilla(一个加勒比小岛国),州列表里只有一行
🏢 1 >, 没有州名 —— 把人看懵了。 - 搜索深度想从 picker 内部挪到外层 TaskCreateDialog 的底部,跟「创建任务」一行靠左; 同时去掉地区入口框里的「州/省级」chip(重复)。
#1 空名州兜底(location-picker-dialog.tsx)¶
根因¶
某些小国(如 Anguilla)API 返回的 state node v: '' —— 表示该国家没有州/省级分区,
城市直接挂在国家下。
老版本 locations-select.tsx 有 const 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 修复¶
用户反馈¶
- 还有 aria-hidden 警告(这次是 Dialog 打开嵌套 Dialog 触发)
- 搜索深度移到「确定」旁边、下拉选择;取消按钮其实多余(X 已经有了)
- 大洲已经美化;国家/州/城市列表 1 行 1 个太松,希望 1 行多个(网格布局)
- 类别/关键词也是 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.tsx 有 showLevel 下拉,可选「只显示州/省 / 显示城市 / 显示邮编」
—— 控制树深度,也就是搜索粒度。
我在 v0.9.25 重写成 LocationPickerDialog 时,把这个选项弄丢了,造成两个错配:
task-create-dialog里mode='all'的展开 只取州名:setStateAll勾州时 强制同时加州 + 全部城市:
两端语义对不齐:「整国」是州级,「勾州」却同时拉所有城市。
修复 —— 新增 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¶
- 新增
depthstate,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¶
- 新增
searchDepthstate - 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 列表更紧凑 + 子菜单常驻 + 任务页内联并发选择¶
用户反馈¶
- 列表行高再压一档,尤其上下空行太松
- 侧栏「数据」下的 4 个子菜单希望默认展开(不要只在 page=data 时才显示)
- 任务页 +创建任务 旁加「同时进行任务数」选择(默认 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 padding0.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:
- task-view 的 closeMenu —— 直接在函数里 blur
usePopover.onClose源头 —— 全局 patch;所有用 usePopover 的地方自动受益 (local-toolbar / cloud-toolbar / account-popover / account-info 等都用了 usePopover)- 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?: DataTab、onTabChange?: (t) => void、onCountsChange?: (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',openHarvestTab和openScrapeTab都进同一个 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 + 任务批量删除 + 清空强警告¶
用户反馈¶
- 任务列表加批量删除(勾选 + 列表 + 翻页)
- 美化确认弹窗,并加载入「正在删除...」动效,完成后才退场
- 「清空商家数据」/「清空日志」走更显眼的二次确认警告
- 数据子 tab 上移到侧栏 → 下版
- 地图实开 + 网页实开共用同一窗口不同 tab → 下版
本版落地 #1 #2 #3。#4 #5 因涉及侧栏整体改造和 scrape-window 重构,单独排到后续。
新组件 ConfirmDialog(src/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 上移到侧栏:要把
侧栏新加可展开 nav,估计 ~50 行;下版做。dataTabstate 从 DataView 提到 main-layout,¶ -
5 共用窗口:scrape-window 现在分
统一成一个、tab 用 metadata 区分用途,影响 batch-controller / engine-manager 的 窗口生命周期;下版做。harvest/scrape两个 windowKey,需要¶
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 个任务 + 任务列表布局统一¶
用户反馈¶
- 任务列表「变形」:不同状态下操作按钮位置不一致;queued 任务没有可结束的按钮; 「查看商家」太长。
- 多国 = 多任务很糟糕:选 2 国 + 一堆州城市,被拆成多个任务;选 100 个国家就会出 100 个任务, 完全没法用。希望「无论选了多少国家,都合并成 1 个任务」。
#2 多国合并到 1 个任务 —— 数据模型升级(核心改造)¶
旧模型的局限¶
MapTask.countryCode/countryName/locations[]是单国设计buildTaskUrls用task.countryCode拼 URL —— locations 里所有项都默认是这个国家- 多国只能拆成 N 个 task
新模型(v0.9.26+)¶
MapTask.locationEntries?: LocationEntry[],每条记录自己的国家:buildTaskUrls优先用locationEntries;没有则退回老的countryCode + locations[]路径 —— 完全向后兼容- 老任务(无 locationEntries)继续按单国跑
流程¶
- 用户在 LocationPickerDialog 里选了 2 国 + N 个州城市
- 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 }); - 总是 1 个 task(不论选了几个国家)
- task-manager.createTask 智能生成名字:
- 单国:
"doctor · 美国 · 27 个地区" - 多国:
"doctor · 3 国 · 43 个地区" - 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 条)¶
- 「按洲选国家」体验差 —— 现在是 chip 过滤 + Autocomplete,用户希望点击输入框打开 完整选择器;可设置常用、默认常用、记住上次大洲。
- 州/城市列表仍然被挡住 —— inline 布局塞在弹窗里,再加上预估卡 + 按钮,总高度顶出 视口;用户希望「专业级」改进让小白也能上手。
- 缺少面包屑 + 各级全选 —— 用户要:选大洲 → 国家 → 州 → 城市 能逐级返回; 每一级都能「全选」(大洲全选 = 大洲下所有国家整国)。
解决方案:独立的 LocationPickerDialog¶
把「选地区」从 inline 选择器升级为独立的 Dialog,承载完整的层级导航。
新增 LocationPickerDialog(location-picker-dialog.tsx)¶
- 三层导航:
country→state→city,大洲是顶部 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 模糊过滤
新增 LocationsPickerInput(locations-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拿州列表,全部作为 locationsmode='specific':直接用 paths- 每国 sendMessage
create-task - 创建后 toast:「已创建 N 个任务(每个国家一个)」
- 预估卡升级:
N 个关键词 × M 个地区 = [K 个任务],多国时加副提示
UX 关键点¶
- 小白友好:
- 默认进来就是「常用」大洲(用户最常选的几个国家直接看见)
- 任何一行的勾选框都对应「整体选上」—— 不需要理解 specific/all 概念
- 面包屑明显,回退一目了然
- 「⭐ 收藏」按钮无需打开「设置」也能管理常用
- 专家友好:
- 大洲一键全选 N 个国家
- 整州一键选所有城市
- 三态 checkbox 直观反映嵌套选择
- 不再被挡:
- 选择器是独立 Dialog(85vh),自己管自己的滚动
- 创建任务弹窗里只剩入口控件,再也不挤
- 多国创建:
- 1 国 = 1 task,沿用现有 task-controller 多任务并发能力
- 用户选 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 [城市数] │ │ ... │ └────────────────────────────────────────────────────────────────┘ - 实现关键:
SimpleLocationsSelect加hideCountryPickerprop - 外层(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/CH4 个 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 旁边加
跳至 [输入框] 页—— 原来jumpRef和onJumpPage早就定义好了但没渲染出输入框 —— 这次接上 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都不同)。 LocalTable的stats/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.tsx和locations-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?: numberprop(不传则用旧默认 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 全部带
taskId:startBatch(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 = 0running → paused:pausedAt = nowpaused → running:pausedTotal += now - pausedAt; pausedAt = undefined* → done / stopped / reconcile:若处于 paused,把当下这段也结进 pausedTotal;清掉 pausedAt
- 显示公式:
- 终点 stopAt =
endTime(已结束)/pausedAt(暂停中冻结)/Date.now()(running) - elapsed = max(0, stopAt - startTime - pausedTotal)
- 终点 stopAt =
注意点¶
- 多次「暂停→继续→暂停→继续」叠加 pausedTotal 一直累加。
- 老任务(v0.9.17 前)这两个字段为 undefined,按 0 处理,不会破坏旧任务的显示; 只是不会精确反映之前那次暂停(这是历史数据缺失,新任务起一切准确)。
- ETA(剩余时间)也用同一个 elapsed 做线性外推,所以暂停期间 ETA 也跟着冻结,符合直觉。
2026-05-23 v0.9.15 → v0.9.16 官网状态码 + 列表筛选 + 任务详情(含 #6 落地)¶
本次改动(按用户反馈 3 项,#6 同步落地)¶
- 官网列表加 HTTP 状态码 + 失败错误(
data-view.tsx) - 在「状态」chip 旁边再加一列「HTTP」:
- 成功:200 / 301 / 403 / 404… 用语义化颜色 chip(绿/蓝/橙/红)显示
- 失败:直接显示错误码
ERR_CERT_AUTHORITY_INVALID等;过长截断成 12 字 +…,悬停看全文 - 无记录:
-
- 数据源:每 5s 轮询
pageLogItem,按 url 建 Map,取最近一次的 status / error; 不动 jsstore schema,避开二次迁移风险。 -
旧 pageLog MAX_LOG 1000 上限对状态显示是 OK 的(用户主要看最近活跃的)。
-
官网/邮箱/手机列表顶部增加 任务 + 状态 筛选条(
data-view.tsx) - 三个非商家 tab 共用一根筛选条(白底带边框,置于 Tabs 下方):
- 任务筛选:嵌入复用的
TaskFilterPicker(支持名称 / ID 模糊搜索 + 复制 ID)。 选中→onPickTask→上报到 main-layout 的 dataFilter→反向把 filterTaskId 灌回。 - 状态筛选:5 个 chip「全部 / 待采集 / 采集中 / 已完成 / 失败」(仅官网 tab 显示, 邮箱/手机 tab 都是派生列没有 scrape_status,所以不挂这个筛子)。
- 清除任务筛选:右上角小 Button,复位 filterTaskId。
- 任务筛选:嵌入复用的
-
旧实现仅有一条只读 info 横条,现在变成主动可控的过滤工具。
-
任务详情对话框(#6 落地) —— 新
task-detail-dialog.tsx - 入口:任务卡片中段「进度 a/b · 采集 c 条 [状态]」整段可点击。
- 概况指标条 6 项:总组合 / 已采集 / 待采集 / 净增商家 / 已耗时 / 剩余。
- 过滤工具栏:3 个 chip「全部 / 已采集 / 待采集」+ 关键词搜索框。
- 表格:每行一个 (关键词, 地区) 组合,状态 chip + 采集数;最多渲染 5000 条, 超出提示「使用搜索框收窄结果」。
- 数据源:新增持久化存储
local:taskProgress:${taskId},由 batch-controller 落库时同步累加(page 1 / 翻页都触发)。 - 匹配规则:精确
${cat}, ${loc}命中优先;不命中走「关键词包含 cat AND loc」兜底, 吸纳带国家后缀("dentist, Bonner, Australia")的 keyword。
新增工具¶
utils/task-progress.ts—getTaskProgress / recordTaskProgress / clearTaskProgress- 串行 writeChain 避免并发覆盖;删任务时一并清除 progress。
- 同 keyword 重复上报会累加 count(翻页一次一次加上去)。
三次自检结论¶
- 数据流连通性 ✓ —
recordTaskProgress在 storePageOneData / runPagingJob 两条 落库路径都已 hook;clearTaskProgress在 task 'delete' action 已调用;getTaskProgress由 TaskDetailDialog 2s 轮询读取。 - keyword 匹配 ✓ — 内容脚本
getKeywordFromUrl把+替换成空格,得到"dentist, Bonner, Australia"形式;dialog 的兜底 includes 匹配可命中。 - 构建产物 ✓ —
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 条 —— 引擎联动缺失(
batch-controller.ts、engine-manager.ts) - 旧实现里地图任务每次落库(
storePageOneData/runPagingJob)后只广播maps-data-updated提醒 UI 刷数字,没人唤醒官网抓取引擎;引擎只能等 1 分钟一次的 alarm。 - 改成落库成功后立刻
manageQueue(),新行进库即被调度(仍受总并发 / 单域名并发约束)。 -
现象会变成:地图任务抓到带 website 的行,官网引擎几乎实时就接着跑。
-
日志 3 个 tab 下大块空白 ——
log-view.tsx - 旧:
height: listHeight(固定 ~windowHeight - 340),1 条日志时下方一大块空白。 -
新:
maxHeight: listHeight,内容少时框紧贴一条,多时滚动。 -
状态 chip 挪到「采集 X 条」后 ——
task-view.tsx - 标题行去掉状态 chip;改放进度指标行:
进度 a/b · 采集 c 条 [进行中]。 -
视觉重点从「我是什么状态」转回「我跑到哪了」。
-
「停止」→「结束」+ 进入「更多」菜单 + 按钮加图标 ——
task-view.tsx - 行末「停止」按钮被移除;改进「⋮ 更多」菜单(黄字「结束任务」+ 二次确认)。
- 行末仅保留「暂停 / 继续」「查看商家」「⋮」三组,避免误触结束。
- 「暂停 / 继续 / 查看商家」全部加图标:
PauseCircleOutline/PlayCircleOutline/Storefront。 -
顺手把 stopped 状态文案改成「已结束」。
-
任务 ID 圆形 chip,置于任务名前 ——
task-view.tsx - 旧:id 是名字右边一长串灰字,挤眼。
- 新:左前方一个圆角 monospace chip 显示 id 后 4 位(如
9617); 悬停 Tooltip 显示完整 id;点击复制 + sonner 提示。 -
任务名回归 flex:1 主位,更突出。
-
任务详情 / 进度详情(留到 v0.9.16)
- 思路:进度数字点开 → 看每个关键词 × 地区是「待 / 进行中 / 已完成」+ 净增多少。
-
当前数据源不够:
pageLogMAX_LOG=1000,长任务老条目被环掉;要么扩容、要么单独 给任务建一份 progress storage。下版本一起做。 -
「查看商家」跳过去仍然没数据 —— 根因:jsstore
where: { taskId }在升级后的 DB 上会给 0 行(search-data.ts、api/search.ts) - 核对:
countAllByTaskIds()(不走 where)能正确数到 266 条,说明数据本身有 taskId; 但分页接口的where: { taskId }死活返回空。 - 改造:新增
getListByTaskId(taskId, current, pageSize, sort, keyword)— 一次select(limit: 100000)拉全表 → JS 端 filter taskId → 关键词命中过滤 → 排序(识别 jsstore { by, type } / DataGrid { field, sort } 两种风格)→ 切片分页。 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 项¶
本次改动¶
- 侧栏底部重排(
main-layout.tsx) - 旧顺序(自上而下):主导航 → 创建任务 → 日志/设置 → 提取开关 → 云端同步
- 新顺序:主导航 → 提取开关(含「立即触发」)+ 云端同步 → 创建任务 → 日志/设置(最底)
- 「日志/设置」沉到最底;「创建任务」就在它们正上方,符合常用层级。
- 云端同步的「(已开启)」改为内联「访问云端 ↗」链接(
cloud-data-sync.tsx) - 旧:开关旁文本「云端同步(已开启)」,下方一行独立的「访问云端 ↗」链接。
- 新:单行布局,开启时标签变成「云端同步 访问云端 ↗」(小号、不换行); 未开启时只有「云端同步」字样。
- 「提取官网邮箱/社媒」 → 「提取邮箱/社媒」+ 「立即触发」内联(
main-layout.tsx) - 去掉「官网」字样,文案更简洁。
- 「立即触发」由原本另起一行的按钮,改成开关右侧同一行的小号 Button; 侧栏紧凑后留白也少了。
注意点¶
- 「创建任务」按钮上方新加了一条分隔线,把它和「提取开关 / 云端同步」明显区分开。
- 「日志/设置」并排区不再带顶部分隔线(避免双线视觉杂乱),底部清空。
- 「提取邮箱/社媒」标签是
noWrap;窄侧栏下不会换行,「立即触发」按钮固定不缩。
2026-05-23 v0.9.12 → v0.9.13 商家关联 / 任务筛选 / 并发 / 去重 9 项¶
本次改动(按用户反馈 9 条)¶
- 查看商家 → 全功能商家列表 + 任务筛选(
data-view.tsx、local-data-view.tsx) - 旧实现是从任务跳过去后渲染一个简化的 shell 列表。现在统一渲染
LocalDataView(DataGrid + 工具栏 + 视图,全功能),把 taskId 透传给 API。 apiLocalDataList新加taskId入参;内部把它 AND 进 jsstore 的 where。 早期 DB 没 taskId 列时走 try/catch 兜底(忽略该条件、重查全量)。-
历史数据未打 taskId 的,筛选下确实查不到 —— 属预期。
-
商家列表内置「按任务筛选」(
task-filter-picker.tsx、local-toolbar.tsx) - 新增
TaskFilterPicker:Autocomplete,支持 同时按名称 / ID 搜索, 下拉里每项显示「任务名 + 短 id」。选中即应用;清空即移除筛选。 -
嵌入
LocalToolbar,作为筛选区一员(仅在父级提供 onFilterTaskChange 时显示)。 -
任务有唯一 ID,且可见 / 可复制(
task-view.tsx) - 任务卡片标题行多了一段 monospace 灰字 id + 复制图标,点击复制到剪贴板。
-
id 仍然是
t${Date.now()}${rand}唯一格式。 -
商家列表底部分页贴底(
local-data-view.tsx) -
Card 改成
height: 100%; overflow: hidden,让内部 DataGrid 自己滚动; 旧的minHeight: calc(100vh - 160px)在某些屏高下会把分页推到视口外。 -
官网抓取并发:总并发 + 单域名并发(
engine-manager.ts、storage-data.ts、settings-view.tsx) - 新增设置
deepScrapeDomainConcurrency(默认 2,1-10)。 - engine-manager 维护一张
Map<domain, activeCount>,调度时从候选里挑出 「该域名活跃数 < 单域名上限」的任务先跑;其它任务暂时跳过,等下次调度。 -
设置 UI 把「总并发」「单域名并发」并排两列展示。
-
官网任务 URL 跨任务去重(
task-manager.ts) - 创建官网任务时先
select MapTaskData收集所有已存在的 website; 输入 URL 命中已存在的不再插入新行,仅做计数。 - 任务
total= 提交的 URL 数;finished初值 = 已复用数(视为已处理)。 -
用户复用旧数据:原商家行的 emails/phones 直接在「邮箱列表/手机列表」展示。
-
邮箱 / 手机列表去重(
data-view.tsx) -
邮箱按小写地址去重;手机按数字归一化去重;同一值只保留第一个来源。
-
「邮箱列表」邮箱列变窄(
data-view.tsx) - 旧:邮箱 flex:1 + 来源各 200/220 → 邮箱列太宽
-
新:邮箱 260 固定 / 来源商家 220 / 来源网址 flex:1 → 信息更平衡。
-
侧栏「任务」徽标 (未完成 / 总数)(
main-layout.tsx) - 例:
任务 (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 条)¶
- 创建任务时不再设置名称(
task-create-dialog.tsx) - 去掉了上版加的
任务名称(可选)输入框 + 相关 state / 传参。 -
名字改由列表内联编辑(见 #4)。
-
「查看商家」按任务筛选:稳健化 + 兜底(
data-view.tsx/search-data.ts) - 筛选不再用 jsstore 的
where: { taskId }(早期 DB 没该列时 jsstore 会抛[object Object]并污染扩展报错面板),改为统一 select + JS 端 filter。 addSearchData加兜底:调用方没传taskId时,先查local:currentTaskId, 再退查local:taskList里status === 'running'的任务。-
任何位置漏传都不会导致新行
taskId空。 -
商家列表布局(
local-data-view.tsx/local-table.tsx) - Card 设为
flex column+minHeight: calc(100vh - 160px); LocalTable 内部容器flex: 1撑满剩余高度。 - 旧实现是
height: windowHeight - 360,大屏下表格容器比内容矮一截 → 分页条浮在中间留大块空白;现在分页条贴底。 -
Logo 体积再压缩(compact:48×32 → 40×28;其他档同步缩 8px); 去掉了
marginTop: 1,行高更紧凑。 -
任务列表(
task-view.tsx) - 任务名内联重命名:名字右侧出现淡铅笔图标,点开变 TextField,
Enter / 勾选保存,Esc / 叉取消,最长 60。
通过新的
task-controlaction: 'rename'(带 payload)落到updateTask。 - 删除挪到「⋮ 更多」菜单:行末尾不再直接显示「删除」按钮,
改为
MoreVertIcon→ Menu → 「删除任务」(红色 + 二次确认)。 - 采集 X 条 实时显示:每 3 秒
countByTaskId(t.id)查一次, 进行中/暂停优先显示实时计数,避免「采集 0 条」的视觉误判。 -
耗时格式精确到秒(带单位):旧
3:58/33:05:14容易看错; 现改成3分58秒/33时05分14秒/<1m 时 32秒。 -
修复插件报错(
search-data.ts/data-view.tsx) chunks/package-BiZI_tu8.js (r.logError)的源头是 jsstore 抛错时 自动logError。常见诱因:where指向不存在的列(DB 没升级时的 taskId)。- 已经在 #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 条)¶
- 商家列表加「清空」按钮(
local-toolbar.tsx) - 顶部工具栏新增
清空(DeleteSweepIcon,红色),点后confirm二次确认,toast.promise显示进度,调用clearSearchData()。 - 任务卡片(
task-view.tsx、task-create-dialog.tsx) - 「查看商家」按钮 全状态可见(之前只有 done/stopped 才显示)。
- 状态 Chip 从最前位置 挪到标题行右侧(不再喧宾夺主)。
- 创建任务弹窗加 可选「任务名称」输入框(地图 / 官网 tab 共用,留空走默认规则)。
createTask内部已支持data.name优先级,直接传过去。 - 商家列表布局优化
- 去掉底部统计条
StatusBadge(local-table.tsxfooterNode={null})。 - 去掉「密度」按钮 + popover(
local-toolbar.tsx);默认密度从 standard 改成compact(local-data-table.tsx)。 - 「提取官网邮箱/社媒」总开关排查
- 现象:开关 on 但实际不抓。可能是上一轮崩溃后部分行卡在
scrape_status = 1。 - 修复:
start-deep-scrape消息先resetStaleTasks()(把 1 → 0)再走manageQueue()。 - UI:侧栏总开关下方新增 「立即触发一次」 小按钮(
isRunning时显示), 一键触发:重置 stale + 调度一次。 - 日志 / 设置 并排一行
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)。任何页面都能点;任务页顶部按钮也保留。 - 任务时间统计:
MapTask加startTime?/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.ts:MapTaskData 表新增 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)。 - 创建任务弹窗优化:
- 弹窗尺寸
md→lg,更舒展。 - Tab 改为全宽居中(
variant="fullWidth"),加 svg 图标,更醒目。 - 重命名:「地图任务」→「地图商家提取」(
PinDrop图标); 「官网任务」→「客户官网挖掘」(TravelExplore图标)。 - 每个 tab 内容上方加一句简介(从地图按类别+地区批量提取 / 粘贴客户官网挖联系方式)。
遇到的问题¶
pnpm build成功,版本 0.9.7,改动文件 0 类型错误。
注意点¶
- aria-hidden 警告还可能从其他 Dialog 触发器漏出来(如
SyncConfigDialog—— 但已不再用)。 如再见到,同样在触发按钮onClick加e.currentTarget.blur()。 - 命名建议:地图商家提取 / 客户官网挖掘 —— 直观点出"做什么 + 数据从哪来",符合 B2B 销售 线索挖掘的实际语境。
2026-05-22 v0.9.5 → v0.9.6 UI 改版 阶段四:采集规则可配置 + 官网采集深度¶
本次改动¶
- 新增 3 个设置(
storage-data.ts): scrapeDepth(1-3,默认 1):官网采集深度。emailRegex(默认空 = 用内置):邮箱正则可自定义。phoneRegex(默认空 = 不提取手机):手机正则;留空时不提取(避免噪音)。scraper.tsextractDataFromHtml:接收{emailRegex, phoneRegex}选项;自定义时 按用户正则提取邮箱/手机;非法正则自动回退默认。返回新增phones: string[]。scraper-executor.ts:读取设置传给抓取器;提取到的手机号写入日志phone字段 (优先于 WhatsApp)。dynamic-scraper.ts:新增findSubLinks—— 从首页 HTML 提取同域的 contact/about 类子页链接;scrapeWithTab在depth>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/onTabChangeprops)。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:
MapTaskData加taskId,任务↔商家精确筛选(需谨慎处理 jsstore 迁移)。
2026-05-22 v0.9.2 → v0.9.3 官网任务类型 + 全局创建按钮¶
本次改动(用户 3 点中的 #1、#3)¶
- 任务支持两种类型(
task-store.tsMapTask加type/websites): - 地图任务:关键词 × 地区(原有);
- 官网任务:直接粘贴网址列表(≤10000)。
- 创建任务弹窗改双 tab(
task-create-dialog.tsx):「地图任务」(关键词+地区) / 「官网任务」(网址文本框,一行一个、去重计数)。 - 官网任务执行(
task-manager.tscreateTask):官网任务不进地图队列 —— 创建后把 网址写成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.ts:MapTask实体(id/名称/关键词/地区/状态/ 进度/结果数等),存local:taskList;buildTaskUrls把任务转成谷歌地图搜索 URL。 - 任务队列调度器
src/entrypoints/background/task-manager.ts: pumpTasks—— 无批次在跑时取队首queued任务开跑;onBatchDone—— 批次完成回调:标记任务done、按全局商家数差值算resultCount、 推进下一个;createTask/controlTask(暂停/继续/停止/删除);reconcileTasks—— SW 重启收尾卡住的任务。batch-controller.ts:新增setBatchDoneHandler(finishBatch完成时回调队列); 新增storePageOneData(见下)。background/index.ts:注册队列回调;新增消息create-task/task-control/store-page-data;启动时restoreBatch → reconcileTasks。- 任务页 UI:
src/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已不再被引用,后续清理。