# baoyu-workbench-frontend-pitfalls — archive changelog

## 30a. 多步 Python 替换括号必验——断一次就回滚重来（2026-07-27）

用 Python 对 HTML 做多次 `str.replace` 时，**每次改完立即验括号**。不建议连续 5 个替换后统一验——坏了不知是哪步，只能全回滚。

```bash
python3 -c "c=open('file.html').read();print('OK' if c.count('{')==c.count('}') else 'FAIL')"
```

铁律：做一步→验→OK再做下一步。FAIL→立即从备份回滚。

### 飞书内嵌 WebView 缓存更加顽固

飞书应用内打开的页面有**额外的 WebView 缓存层**，即使 CDN 已刷新、curl 已验证新版，飞书内仍显示旧页面。唯一可靠清除方式：

1. **完全退出飞书**：从后台划掉飞书 App（结束进程，不是点❌关闭）
2. 重新打开飞书 → 进入应用
3. 如仍旧版：用手机浏览器打开同一 URL 验证（浏览器无缓存）→ 确认新版 = 飞书缓存无疑

> 不要依赖"下拉刷新"——飞书 WebView 不一定支持 Pull-to-Refresh。

### 部署路径陷阱：`tcb hosting deploy /tmp/file.html -e <env>` 可能不覆盖根路径。始终用显式路径：
```bash
tcb hosting deploy /tmp/file.html /index.html -e <env-id>
```



## 21. loadData() 数组作字符串拼接 Bug

**症状**：页面登录成功但数据加载报"数据加载失败：customers，请检查文件路径 ../数据/..."。

**根因**：`ENDPOINTS` 存的是 `[api_path, fallback_json_path]` 数组，但 `loadData()` 把 `ep[1]`（整个数组）当字符串拼接：

```javascript
// ❌ BUG：ep[1] 是数组，作为字符串拼接产生非法 URL
var ENDPOINTS = [
  ['customers', ['/api/mcp/customers', '../数据/pricing/_key_customers.json']],
];
var url = ep[1];  // ['/api/mcp/customers', '../数据/...'] ← 数组！
fetch(url + '?v=...')
// → "/api/mcp/customers,../数据/pricing/_key_customers.json?v=..."
```

**修复**：JSON 优先（CloudBase 已部署静态文件），API 作为可选刷新通道：

```javascript
function loadData() {
  var apiBase = API_BASE_URLS[0];  // Tunnel URL
  var fetches = ENDPOINTS.map(function(ep) {
    var key = ep[0], urls = ep[1];
    var apiUrl = urls[0], fallbackUrl = urls[1];
    function tryFetch(url) {
      return fetch(url + '?v=' + Date.now(), { cache: 'no-store' })
        .then(function(r) { if (!r.ok) throw new Error(key); return r.json(); });
    }
    // JSON 优先（CloudBase 静态文件最可靠），API 作为备选加速通道
    if (fallbackUrl) {
      return tryFetch(fallbackUrl).catch(function() {
        if (apiUrl) return tryFetch(apiBase + apiUrl);
        throw new Error(key);
      });
    } else if (apiUrl) {
      return tryFetch(apiBase + apiUrl);
    } else {
      return Promise.reject(new Error(key + ': 无数据源'));
    }
  });
  // ...
}
```

**关键点**：
- **JSON 优先**：不再依赖不稳定的 Cloudflare Tunnel 做初始加载，CDN 静态文件永远可用
- `API_BASE_URLS[0]` 提供 HTTPS 前缀避免 mixed content
- 单端点失败不阻塞整体——`Promise.all` 中任一 reject 即整体失败，但单个 catch 只回退该端点

**回归检测**（每次修改 HTML 后必验）：
```bash
# loadData 必须是 JSON 优先版本（含 fallbackPath）
grep -c "fallbackPath" 销售工作台.html  # 应 >= 2
grep -c "JSON 优先" 销售工作台.html      # 应 = 1
```
如果 fallbackPath 计数为 0 或 1，说明 loadData 退化回了旧版（数组当 URL 的 bug）。必须立即修复。



## 23. 工作台文件无自动备份——回滚即死路

**事实**：`~/Desktop/Hermes输出-工作类/` 目录**不是 Git 仓库**，无 Time Machine 快照，无自动备份 cron，唯一的备份是手动创建的 `销售工作台_backup_20260725.html`。用户请求回滚到某时间点时，以下恢复途径全部不可用：

| 途径 | 状态 |
|------|------|
| Git | ❌ 非 Git 仓库 |
| Time Machine / APFS 快照 | ❌ 未配置 |
| 自动备份 cron | ❌ 不存在（MCP 数据刷新 ≠ 文件备份） |
| 手动备份 | ⚠️ 仅有一份 7/25 的 HTML 备份 |
| Session 历史 | ⚠️ state.db 可能损坏，不可依赖 |
| OneDrive 版本历史 | ❌ 文件不在 OneDrive 同步范围内 |

**回滚请求的标准排查流程**（当前对话中已验证所有路径均失败）：
```bash
# 1. Git
cd ~/Desktop/Hermes输出-工作类 && git log --oneline -10
# 2. Time Machine
tmutil listlocalsnapshots / && tmutil listbackups
# 3. APFS 快照
diskutil apfs listSnapshots /
# 4. 手动备份
find ~/Desktop/Hermes输出-工作类/ -name "*backup*" -o -name "*bak*"
# 5. 文件修改时间（确认目标时间点是否有改动）
find ~/Desktop/Hermes输出-工作类/ -newermt "YYYY-MM-DD HH:MM:SS" -type f
```

**建议**：每次重大修改前手动备份：
```bash
cp 销售工作台.html 销售工作台_backup_$(date +%Y%m%d_%H%M).html
```
或在 `baoyu-workbench-data-pipeline` 技能中增加备份步骤。



## 26. state 对象拼进字符串 → `[object Object]` 全员串号（2026-07-26）

**症状**：所有用户在同一浏览器看到同一份待办/缓存；或服务端收到 `user="[object Object]"`。

**根因**：重构后 `state.user` 从字符串变成对象，但旧的字符串拼接没改：
```javascript
// ❌ 'bior_sw_todos_v2_' + state.user → 'bior_sw_todos_v2_[object Object]'
function getTodoKey() { return TODO_PREFIX + state.user; }
// ❌ 比较也全失效：state.user !== 'gm' 恒为 true（对象≠字符串）
// ❌ 作索引也失效：USER_DEPTS[state.user]、serverStore.users[state.user]
// ✅ 一律用 (state.user||{}).name / (state.user||{}).role
```

**排查命令**：`grep -n "state.user" page.html` 逐个检查拼接/比较/作对象 key 的位置。



## 25. JS 语法验证（node --check 不支持 .html）

`node --check file.html` 报 `ERR_UNKNOWN_FILE_EXTENSION`。改用 Python 验证括号平衡：

```bash
python3 -c "
import re
with open('销售工作台.html') as f:
    html = f.read()
scripts = re.findall(r'<script[^>]*>(.*?)</script>', html, re.DOTALL)
for i, s in enumerate(scripts):
    if not s.strip() or 'cloudbase' in s[:30]: continue  # 跳过外部 SDK
    o, c = s.count('{'), s.count('}')
    if o != c: print(f'⚠ Script block {i}: {o} {{ vs {c} }}')
"
# 无输出 = 通过
```

**完整验证清单**（建议每次改 HTML 后跑一遍）：
```bash
# 1. 括号平衡
python3 -c "..."  # 如上

# 2. 无重复 var 声明
for v in API_BASE_URLS ROLE_KEY TODO_PREFIX AUTO_REFRESH_MS refreshTimer; do
  c=$(grep -c "var $v\s*=" 销售工作台.html)
  [ $c -gt 1 ] && echo "⚠ $v: $c duplicates"
done

# 3. loadData 是 JSON 优先版本
grep -c "fallbackPath" 销售工作台.html  # ≥ 2

# 4. 函数无重复声明
grep -c "function loadData" 销售工作台.html  # = 1
```



## 64. Python HTTP 服务代码缩进——改一行崩全局（2026-07-27 多次血案）

**症状**：`python3 脚本/销售工作台服务.py` 启动即报 `IndentationError: expected an indented block`，服务器无法启动。

**根因**：用 Python `str.replace()` 直接修改服务文件时，新代码段的缩进层级与旧代码不匹配。`销售工作台服务.py` 的 `do_POST` handler 有 **3-4 层嵌套**（类→方法→if→try），每层 4 空格。替换时哪怕多/少一个空格都会导致 `IndentationError`。

**历史记录**（今天连续崩溃 4 次）：
1. 添加 `datetime.now()` 但忘记 `from datetime import datetime`
2. 插入队列代码块时 `try:` 后的缩进不对
3. IMA 搜索代码的 `try/except` 缩进层级混入外层作用域
4. 多次 replace 后整体缩进损坏，只能整段重写

**铁律**：
- **不要连续 replace 多次**——两次以上 replace 在 Python 文件上，缩进必定出问题
- **用整段重写替代多步 replace**：找到 handler 的起止行 → 删除中间全部 → 写入新的完整 handler
- **`python3 -c \"compile(open('file.py').read(),'test','exec');print('OK')\"`** 在每次修改后立即执行
- **加 `from datetime import datetime`** 是高频遗漏——任何包含 `datetime.now()` 的代码必须检查

**恢复方法**：
```python
# 找到 handler 的起止行注释（如 '拜访卡生成 API' 和 '原有 todos API'）
with open('销售工作台服务.py') as f: lines = f.readlines()
# 找到注解行，替换整段为刚写好的干净代码
lines[handler_start:handler_end] = [clean_code_block]
with open('销售工作台服务.py','w') as f: f.writelines(lines)
```

**症状**：赵云浩在手机飞书中点拜访卡链接 → 404 Not Found。

**根因**：拜访卡存在 Mac 本地（`客户管理/拜访卡/`），CloudBase 无此文件。Funnel 不通时无法访问。

**修复**：每次生成拜访卡后部署到 CloudBase：
```bash
tcb hosting deploy "客户管理/拜访卡/{文件名}.html" "客户管理/拜访卡/{文件名}.html" -e bier-sales-d0gatbvlx288724e9
```

**存档区链接**：用相对路径（CloudBase CDN 自动解析），不要硬编码 Funnel URL。

**结论**：`sessionStorage` 是唯一可靠的方案。已在工作台中实现，点击飞书卡片链接 → 登录后自动切订单管理+展开物流。

```javascript
// <script> 第一行：页面加载瞬间存入
(function(){
  var m = window.location.search.match(/[?&]tracking=([^&]+)/);
  if (m) sessionStorage.setItem('bior_tracking', decodeURIComponent(m[1]));
})();

// finishLogin 末尾：登录后恢复
var trk = sessionStorage.getItem('bior_tracking');
if (trk) {
  sessionStorage.removeItem('bior_tracking');
  orderState.filterText = trk;
  setTimeout(function() { switchTab('orders');
    setTimeout(function() { toggleShipmentDetail(trk); }, 800);
  }, 2000);
}
```

**功能**：访前准备 Tab 中为每个客户生成一张拜访卡，聚合多数据源。置于 stat 卡片与准备清单之间。

**数据源交叉**：
- MCP 客户数据 → 客户名/联系人/operators
- ERP `_key_customers.json` → 2026 销售额
- `shipment_status.json` orders → 订单数/最近日期
- `mcp_complaints.json` → 客诉数

**实现模式**：
```javascript
function generateVisitCards() {
  // 1. 构建各数据源映射（customer名→值）
  var erpMap = {}; erpCustomers.forEach(c => erpMap[c.name] = c);
  var compMap = {}; complaints.forEach(c => compMap[c.customer||''] = (compMap[c.customer||'']||0)+1);
  var ordMap = {}; orders.forEach(o => { /* 聚合 cnt/tax/latest */ });

  // 2. 隔离：按 operators 匹配当前用户
  var myCustomers = mcpAll.filter(c =>
    c.owner===userName || (c.operators||[]).indexOf(userName)>=0);

  // 3. 生成卡片网格（grid-template-columns: repeat(auto-fill, minmax(340px,1fr))）
  // 每卡：名称+联系人+销售额+订单+客诉+360档案/飞书客户双按钮
}
```

**渲染时机**：`setTimeout(generateVisitCards, 300)` — 在 `renderVisitPrep` 末尾异步执行，确保 `loadData` 完成。


### 55. 多次替换后 `fullYearTax` 被遗忘行覆盖（2026-07-27 血案）

**症状**：修改 `fullYearTax` 为 `orders.reduce(...)` 后部署，看板仍显示 0。反复部署多次无效。

**根因**：`renderDashboard` 中有**两处** `fullYearTax` 赋值——一处是显式的 `var fullYearTax = ...`，另一处可能是旧备份恢复后残留的 `shipments.full_year_tax` 版本。Python `str.replace` 只匹配一处，另一处漏网。

**强制检测**：
```bash
grep -n "fullYearTax" 工具/index.html | grep "shipments"
# ↑ 所有指向 shipments.full_year_tax 的行都是炸弹——必须全部替换
```

### 56. 客户回退到部门过滤——extractLabel + operators 丢失（2026-07-27）

**症状**：回退备份后，非所属人客户（如佰奥莱博/张立娅的客户）出现在赵云浩客户列表。

**根因链**：
1. 备份（1657版）客户过滤用的是 `getUserMcpDepts()`（部门）
2. `extractLabel` 在备份中不存在 → dept 全部解析为对象 `{key,label}` → 过滤全部失效 → 所有客户全显示
3. `owner`/`operators` 字段也未定义 → 无法按所属人过滤

**预防**：回退后必须验证三点：① `extractLabel` 存在 ② `operators` 在 adaptMcpFields 中 ③ 客户过滤用 operators。缺失任一项都要补。

### 我的活动 Tab 数据隔离

`renderMyActivities` 默认显示 `activitiesData.recent_7d` 全量。需按 `creator` 字段过滤：

```javascript
var activities = (activitiesData.recent_7d || []);
if (user !== 'admin' && (state.user||{}).role !== 'admin') {
  activities = activities.filter(function(a) { return a.creator === user || a.sales === user; });
}
```



## 44. `key_label_value_list` vs `key_label_value` 数组陷阱（2026-07-27）

MCP 多选字段返回值是数组 `[{"key": "...", "label": "..."}]` 而非单个对象。`parse_items` 按 `v['key_label_value']` 或 `v['label']` 取会漏掉数据。

**修复模式**：
```python
# 在 parse_items 循环中，先统一解包数组
if isinstance(v, list) and len(v) > 0:
    if isinstance(v[0], dict) and 'label' in v[0]:
        fields[k] = v[0]['label']; continue  # 取第一个 label
    v = v[0]  # 否则取第一个元素继续处理
```（2026-07-27）⚠️ WebView 会截断 `?` 

三处修改实现：`#orders` / `#customers` 等 URL hash 自动切 Tab。

**关键坑**：飞书内置 WebView 会把 hash 中的 `?` 解析为 search 参数，导致 `#orders?tracking=SFxxx` 的 `?tracking=...` 被截断。**必须用 `:` 替代 `?`**：`#orders:SF5112860018518`。

解析代码：
```javascript
var rawHash = window.location.hash.replace('#', '');
var hash = rawHash, tracking = '';
if (rawHash.indexOf(':') > -1) {
  var parts = rawHash.split(':');
  hash = parts[0];
  tracking = parts.slice(1).join(':'); // 单号可能含特殊字符
}
if (tracking && hash === 'orders') {
  orderState.filterText = tracking;
  _autoExpandTracking = tracking; // 渲染后自动展开物流详情
}
```

完整三步：
1. loadData 成功后检测 hash → switchTab
2. init 中注册 hashchange 监听 → switchTab
3. switchTab 中用 history.replaceState 同步 hash（不触发额外渲染）

飞书卡片按钮对接：`url = WORKBENCH_URL + '#orders:SF5112860018518'`（用 `:` 不是 `?`）



## 90. AI 问答前端 fetch 必须带超时（AbortController）（2026-08-23）

**背景**：`doChatSend` 的 `fetch(CHAT_APIS)` 原本无超时，Funnel 网络偶发挂起时浏览器 spinner 永远转。后端 DeepSeek `urlopen(timeout=90)`，故前端超时设 **120s**（覆盖 90s + 检索 + 网络余量）。`CHAT_TIMEOUT_MS = 120000`。

**实现**：AbortController + `setTimeout(abort, 120000)`，`settle(errMsg)` 幂等收尾（`finished` 标志防重复渲染），非 JSON 响应（死隧道 HTML）→ 降级下一端点。服务是 `ThreadingHTTPServer`，前端超时若 < 90s 会误杀正常慢回答且重试对同一后端二次打 LLM，所以**不能设太短**。

**症状**：全公司(admin)视角下，「📊 H2月度目标 vs 实际」图表里当月(如8月)的实际值，与「📅 8月 当月进展」卡片不一致（图表显示旧ERP快照，卡片显示MCP当月实时）。

**根因**：`getKPI()` 的 **admin 分支**构建 `h2Actuals` 时只从 `threeYearMonthly.json`（`t2026['7']...['12']`）取值，**没有把当月 `current`(=prog.total) 注入 `h2Actuals[h2Idx]`**。而非 admin 分支早有这步：`if (nowMonth >= 7) h2Actuals2[nowMonth - 7] = current;`。两个数据源不同源：
- 卡片 `monthActual = current || h2Actuals[h2Idx]` → `kpi_progress.json.total`（当月MCP实时，171.1万）
- 图表 `h2Actuals[1]` → `three_year_monthly.json.2026.8`（ERP上传快照，115.7万）

**修复**（app.html 与 index.html 双文件，admin 分支 `var h2Idx` 之后）：
```javascript
if (curMon >= 7 && current) h2Actuals[h2Idx] = current; // 当前月用MCP实时prog.total，与当月进展卡片同步
```
同时清理 app.html 里 7月错误的 `t2026['7'] || current || 0` → `t2026['7'] || 0`（`current` 是当月值，不该给别的月兜底）。

**铁律**：getKPI() 的 admin 与非 admin 两分支的 `h2Actuals` 当月注入逻辑必须对称。任何「H2图表当月值 ≠ 当月进展卡片」的报错，先查这一步。附带效应：Q3 当季卡片（`q3Actual` 取 h2Actuals[0..2]）也会同步修正。



## 98. 工作台 AI 助手已接 Hermes API Server（2026-08-30）

**架构**：`ai_chat.py` 的 `_call_hermes()` 调 Hermes API Server（OpenAI 兼容），`_call_deepseek` 保留为**自动回退**（err 时 fallback）。这样 AI 助手获得 Hermes 持久记忆 + 只读工具，不再只有三条铁律 prompt。

**关键参数**：`http://127.0.0.1:8642/v1/chat/completions`，`model='hermes-agent'`，`Authorization: Bearer <API_SERVER_KEY>`（默认 `nas-file-share-token-2026`，在 `~/.hermes/.env`），`max_tokens=4000`，`timeout=60`（agent loop 慢，60s 超时即回退 DeepSeek；设 180 太长会让用户干等才回退）。**不要传 DeepSeek 的 `thinking` 参数**（API Server 不认识）。

**工具集限制**（保证只读）：config.yaml `platform_toolsets.api_server = [web, file, memory, session_search, skills]`，**排除 terminal/execute_code/delegate_task/cronjob/browser**。改后**动态生效**无需重启 gateway，`curl /v1/toolsets` 验证 enabled 状态。

**代价（已实测）**：prompt_tokens ≈ 24K（Hermes 记忆+工具 schema，~10 倍直连）；延迟 ~30s vs 直连 3-10s；file 工具集含 write_file/patch（工具集粒度做不到纯只读，terminal/execute_code 才是真危险项）。

**改 ai_chat.py 后重启**：`tmux kill-session -t workbench` + `tmux new-session -d -s workbench 'cd ~/Desktop/Hermes输出-工作类 && exec ~/.hermes/hermes-agent/venv/bin/python3 脚本/销售工作台服务.py 8765'`（见 #77 tmux 隔离，Gateway 重启不波及）。

**⚠️ `_call_hermes` 必须 try/except，否则超时/断连绕过 DeepSeek 回退（2026-08-30 血案）**：Hermes 慢或断连时 `urlopen` 抛 `BrokenPipeError`/`URLError`，若不 try/except，异常**直接冒泡**到 handle 的 `except Exception` 返回 500（`{ok:false, error:str(e)}`）——而回退逻辑是 `if err:` 判断，异常是「抛出」不是「返回 err」，**根本走不到回退**。前端就显示「⚠️ AI 回答超时或服务不可用」。正确写法：

```python
try:
    with urllib.request.urlopen(req, timeout=60) as r:
        d = json.loads(r.read())
    # 解析 content，空则 return None, 'Hermes 返回空回答'
    return content, None
except Exception as e:
    return None, 'Hermes 调用失败：%s' % e   # ← 返回 err，触发 handle 的 _call_deepseek 回退
```

**判别用户报「连续超时/服务不可用」**：先查 `tmux capture-pane -t workbench -p | grep '[chat] ERROR'`。若 error 是 `Broken pipe` 且 handle 里 `_call_hermes` 无 try/except → 就是它（Hermes 慢/断连冒泡 500）。

**并发排队是 Hermes 单循环的根本限制**：Hermes 日志 `Skipping Relay instrumentation for concurrent Hermes turn` 警告 = 工作台 AI 请求与其他 session（飞书主会话/cron/其他销售员）并发，Hermes 只能**串行排队**。表现「时快时慢」：空闲秒回（实测 9s），忙时排队到 60s 超时回退直连。可选缓解：并发锁（工作台侧检测 Hermes 忙则直接走 DeepSeek 直连，牺牲忙时的记忆增强换秒回）。这是架构限制，try/except+回退只能保证「不显示服务不可用」，不能消除排队。
