---
name: baoyu-workbench-frontend-pitfalls
description: 工作台前端铁律：搜索框/空状态/函数声明/字段映射/Tunnel运维/邮箱登录/备份恢复/CDN部署/离线登录/JSON优先加载/全面体检/Tailscale端口冲突/双路径部署/进程管理/跨文件合并/CDN缓存/init自调用/看板筛选器权限/KPI税合计全局修复/Manager默认筛选/app.html入口/KPI三层递进/Q3当季卡片bug/目标数据污染检测。新增#107。
---



# 宝锐工作台前端开发铁律

> 涉及「某角色看到全公司数据」或「删除某块内容，所有账户都不展示」：先读 `references/company-wide-scope-and-block-removal.md`（全公司口径数据源清单 + 删块四步：两入口同改 → node --check → 保留下游控件 → 部署后 grep 线上残留=0 + sha 比对）。
>
> 涉及「把某类要求分单独类别统计」「再单列一类」「清洗得不完全，把漏的拿出来」：先读 `references/derived-category-classification.md`（两级关键词规则：强词含备注/弱词只看标题正文；语境排除；互斥 vs 叠加；分桶求和 == 总数的硬校验）。
>
> 涉及「布局不合理 / 排布不合理的排布」「手机上输入框被压扁」「改版面」「新建含动态增删行的表单页」：先读 `references/responsive-form-layout.md`（排查顺序=先量 DOM 再改、视觉模型结论当假设；动态行栅格模板必须写在 CSS 类里否则媒体查询覆盖不了；只写单轴定位会打乱整行；底栏与主内容边线对齐；互斥分支的 toggle 语义与不变量检查；程序化填表必须派发 input）。
>
> 涉及「表格某列超出页面范围 / 列被裁掉 / 要适应窄屏 / 缩放调合适」：先读 `references/wide-table-fit-in-iframe.md`（工作台 iframe 外层 overflow:hidden 会静默剪掉溢出列；四招压缩 + 横滑兜底；验收必须量 1024 宽）。
>
> 涉及「服务被杀了没人拉起」「改了看门狗脚本要验」「定时任务到底有没有生效」：先读 `references/service-watchdog-verification.md`（看门狗 MAP 字段陷阱、必须“真杀一次”才算验证、测脚本时不要用管道接输出）。
>
> 涉及「地址栏改 hash 不切页」「分享带错点的链接打不开对应 Tab」「截图预览四张一模一样」：先读 `references/hash-tab-spa-navigation.md`（hash 标签页必须挂 `hashchange` 监听；截图/探活每页要换 URL 参数）。

## 112. 工作台内嵌页改动的发布闭环 + 免登录渲染验证

**发布闭环（改 `工具/X.html` 后逐条做完，漏一条用户就看到旧版）**：
1. 提取内联 JS 跑 `node --check`。
2. **升工作台 `工具/index.html` 里该页 iframe 的 `?v=`**：iframe 的 URL 不变时浏览器直接吃缓存，页面改对了用户仍是旧样（`src="/工具/X.html?v=20260910g"` → 换新串）。这是「改完没生效」最常见的原因，不是部署失败。
3. 部署页面本身 + 部署 `工具/index.html` → `/workbench/index.html`（双路径，来自同一个本地文件）。
4. 线上**比内容不比状态码**（`curl -H 'Cache-Control: no-cache'`）并断言本地 == 线上逐字节 + 本地改动特征串在线上命中。

**权限/API 页的免登录渲染验证（过不了登录 ≠ 不能验）**：这类页面靠 token 调 `/api/...` 拉数据，浏览器里直接开只会看到「请先登录」。验证办法：用本机 8765 起 http 源打开页面（`file://` 下 fetch 被 CORS 挡），同源 `fetch('/数据/x.json')` 取**真实数据** → 手动赋 `DATA`（补上 `visible_customers` 这类 render 依赖字段）、显示 `#app`、调 `render()`，再断言 KPI 文案、芯片计数、筛选后行数、行内标签数。用真实数据而不是手造样本，才能暴露口径错。

## 111. 看板趋势图：分段图合并为全年 + 分段合计先核对（2026-09-20）

**改动形态**：把「📅 月度目标/达成趋势（H1/H2 分段切换）」与「📊 H2月度目标 vs 实际」两张卡并成一张全年 12 个月图 → 去掉 `state.h1h2` 分段按钮、删掉 H2 卡、`drawMonthlyChart(kpi)` 固定 12 个月，数据由 `getKPI()` 统一提供 `yearTargets`/`yearActuals`（admin 与个人**两个分支**都要加；个人分支 1–6 月目标为 0 是历史行为，不要临时发明）。

**合并两条独立序列前，先算两边合计再跟汇总卡对一遍**。H1 月目标来自 `h1Monthly.total.targets`、H2 月目标来自 `Σ kpiDashboard.depts[].h2_months`，而 KPI 卡的年度目标来自 `kpiDashboard.total_target` —— 三处各自展示时谁也看不出，合成一条曲线立刻自相矛盾（实测 3,255 万 + 4,786 万 = 8,041 万 ≠ 6,950 万，差 1,091 万）。**先把差异算出来报给用户定口径，再决定怎么上线**；不要默默发一条自己都对不上的曲线。

**12 个月标签必须窄屏自适应**：`el.clientWidth < 560` 时把 `1月…12月` 降级成 `1…12`。6 个月的图改成 12 个月后手机端必叠加（这是「分段改整年」新增的坑，不是原有问题）。

**数据源/当月口径全套对照表见 `references/dashboard-kpi-trend-data-sources.md`**（月度目标、逐月实际、年度目标卡片分别取哪个字段；当月为什么必须用 `kpiProgress` 覆盖快照）。同文件另有两节必读：**「月度目标：权威源与口径统一」**（权威源 = 年度拆解表的「汇总」表，按人×12 月；用「部门月合计 == 部门年度目标」判定哪份可信，公司目标下调后月度拆解常没同步）与 **「派生键防回归」**（注入 `workbench_data.json` 的新键必须同步加进 `merge_workbench_data.py` 的 `FILES` 映射并重跑 merge，否则管道重跑静默丢键、页面悄悄退回旧口径；改数据驱动的视图要**连 JSON 一起部署**）。

**验数据驱动改动要走本地服务**：`http://127.0.0.1:8765/工具/index.html` 打开再验（同源能直接 fetch `/数据/workbench_data.json`）。用 `file://` 打开时数据 fetch 失败，图表会**静默渲染成全 0**，极易误判为代码 bug（自己先白查一轮）。判定顺序：「图表全 0」→ 先查 `state.data` 是否真的加载到（长度/键数）→ 再怀疑绘图代码。

**部署回读的假警报**：`curl -sL "$url" | shasum` 在 curl 失败时拿到的是**空串 sha**（`e3b0c442...`），会被误读成「CDN 不一致」。改成 `curl -sL --retry 2 -m 30 "$url" -o /tmp/x` → 先 `wc -c` 确认有字节 → 再比 sha；并 `grep` 线上的新标记（新标题/新函数名）确认「部署的确是本次改动」，只看 sha 定位不到部署了什么。

## 6. 访前准备重点客户快捷入口

### 筛选规则：必须与 MCP `field_17186c` 标签重合

**重点客户 = MCP `field_17186c === 'A类'`**（不是取数组前 N 个）。374 个 A 类客户 / 1700 全量。

```javascript
// ❌ 错误：取前 10 个，不区分是否重点
var top10 = all.slice(0, 10);

// ✅ 正确：按 MCP field_17186c 筛选 A 类
var keyCustomers = all.filter(function(c) { return c.field_17186c === 'A类'; });
var top = keyCustomers.slice(0, 30);
```

### MCP 客户关键字段速查

| 字段 | 含义 | 示例值 |
|------|------|--------|
| `field_17186c` | 客户级别 | A类(374)/B类(136)/C类(884)/D类(306) |
| `field_e62869` | 产品线标签 | `[{label:"分子诊断"}]` / `[{label:"生命科学"}]` |
| `field_c3224b` | 集团简称 | "美吉集团" |
| `field_5ed7ab` | 销售部门 | "诊断原料销售拓展部" |
| `work_item_id` | 飞书项目工作项ID | 7057317049 |

`customer_levels.json` 中的 `level: "重点客户"` 是后续人工标注，与 MCP 原始分类独立。访前准备取 MCP 口径。

### 链接规则：跳转客户详情页，不用 create URL

```javascript
// ✅ 正确：FEISHU_URL + work_item_id 直达客户详情
return '<a href="' + FEISHU_URL + c.work_item_id + '" target="_blank">...</a>';

// ❌ 错误：硬编码 project_key=6593cd71471290e3cc6be6e6 创建活动
// 生命科学客户不在该空间 → "空间不存在"
return '<a href="https://project.feishu.cn/baoyu/workitem/create?project_key=...">...</a>';
```

## 69. 订单下单人必须从飞书「下单人」角色获取，不能用客户 owner（2026-07-28）

**症状**：离职人员（黄文清/李丽佳）名下 58 个客户的所有订单仍显示他们为下单人。

**根因**：`pull_orders.py` 通过 `mcp_customers.json` 的 `owner` 字段映射创建者。离职人员的客户 owner 未更新。

**修复**：调用 `get_workitem_brief` API 获取每个订单的 `role_members`，提取「下单人」角色用户。逐订单查询（186次），用缓存文件 `order_role_cache.json` 避免重复。

```python
# 关键代码
result = mcp("get_workitem_brief", {"project_key": PK, "work_item_id": wid})
role_members = result.get('work_item_attribute', {}).get('role_members', [])
for rm in role_members:
    if rm.get('name') == '下单人':
        members = rm.get('members', [])
        if members: xdr = {'name_cn': members[0].get('name', ''), 'email': members[0].get('email', '')}
```

**缓存**：`order_role_cache.json`（按 orderId 索引），已缓存的订单不会重复查询。

**效果**：黄文清 26 客户→0，李丽佳 32 客户→0。186/186 下单人角色全部命中。

## 47. 飞书链接格式校正（各空间不同）

| 空间 | 正确格式 |
|------|---------|
| 客户(`xsguanli`) | `https://project.feishu.cn/xsguanli/kh/detail/{id}` |
| 客诉(`br-shgl`) | `https://project.feishu.cn/br-shgl/khts/detail/{work_item_id}` |
| 通用(`baoyu`) | `https://project.feishu.cn/baoyu/workitem/{id}` |

**NOT** `baoyu/workitem/{id}` for everything. 定义专用常量避免串用。

## 7. Tunnel 运维（2026-07-26 起：Tailscale Funnel 固定域名）

**当前架构**：`https://macbook-air-2.tail6864e8.ts.net` → 127.0.0.1:8765（Tailscale Funnel，URL 永久固定）。trycloudflare 临时隧道已全部弃用（曾出现 3 个死 URL：times-edinburgh / gazette-pretty / drug-round-concert）。

```bash
# 检测 API 是否可用
curl -s --max-time 10 "https://macbook-air-2.tail6864e8.ts.net/api/todos" | head -c 20
# 无输出/超时 → 检查 Tailscale 是否在线
tailscale status --json | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['Self']['Online'])"
# Funnel 状态
tailscale funnel status
# 恢复：tailscale up → tailscale funnel --bg --https=443 http://127.0.0.1:8765
```

### Tailscale Serve 端口冲突（2026-07-28 已踩坑）

**症状**：`python3 销售工作台服务.py 8765` 启动即报 `OSError: [Errno 48] Address already in use`，但 `lsof -i :8765` 无输出。

**根因**：Tailscale Serve/Funnel 可能已绑定该端口，但 `lsof` 不显示（沙箱进程）。`netstat -anv | grep 8765` 可看到所有者是 `io.tailscale.ipn`。

**诊断**：
```bash
netstat -anv | grep 8765  # → io.tailscale.ipn 即 Tailscale
tailscale serve status     # 查看当前绑定的端口和代理目标
```

**修复**：
```bash
tailscale serve reset      # 清除所有 serve 配置，释放端口
# 然后重启工作台服务
```

> ⚠️ `tailscale serve reset` 会清除所有 Funnel/Serve 配置。如需保留其他服务，用 `tailscale serve --https=<新端口> / http://127.0.0.1:8000` 迁移而非 reset。

## 76. CloudBase 双路径部署——`/index.html` 与 `/workbench/index.html` 是独立文件

这两条路径在 CloudBase 上是**完全独立**的文件，修改一处不会自动同步到另一处：

**本地源文件 → CloudBase 路径映射（铁律）**：
| 本地文件 | CloudBase 路径 | 部署命令 |
|---------|---------------|---------|
| `工具/index.html` | `/workbench/index.html` | `tcb hosting deploy 工具/index.html /workbench/index.html -e ...` |
| `工具/销售工作台.html` | `/工具/销售工作台.html` 或本地使用 | 仅本地 localhost:8765 部署，一般不上 CloudBase |
| `数据/workbench_data.json` | `数据/workbench_data.json` | 由 `cb_sync.sh` 自动部署 |

**⚠️ 关键陷阱**：用户通过飞书工作台入口访问的是 `/workbench/index.html`（由 `工具/index.html` 部署），**不是** `工具/销售工作台.html`。本地改 `销售工作台.html` 后如果不改 `index.html` 并重新部署到 CloudBase，用户永远看不到更新。

```bash
# 主入口（/index.html）—— 一般不用
tcb hosting deploy /tmp/index_workbench.html /index.html -e bier-sales-d0gatbvlx288724e9

# 飞书工作台入口（/workbench/index.html）—— 主力入口，源文件是 工具/index.html
tcb hosting deploy 工具/index.html /workbench/index.html -e bier-sales-d0gatbvlx288724e9
```

**检测**：
```bash
# 比较两处代码是否一致
curl -s -H "Cache-Control: no-cache" "https://bier-sales-...tcloudbaseapp.com/index.html?v=$(date +%s)" | md5
curl -s -H "Cache-Control: no-cache" "https://bier-sales-...tcloudbaseapp.com/workbench/index.html?v=$(date +%s)" | md5
```

用户说"工作台打不开"或"飞书里的工作台有问题"时，先确认他访问的是哪个路径，再部署对应位置。飞书应用通常引用 `/workbench/index.html`。

## 8. replace_all 铁律 + patch 转义陷阱

### replace_all 禁令

HTML/JS 混合文件严禁 replace_all=true——语义相同的代码片段可能在不同函数中，patch 会注入到错误位置。

### patch 工具 Escape-drift 陷阱

patch 工具处理含引号的 HTML 文件时，频繁报 Escape-drift detected 错误。这是工具链序列化问题，不是代码错误。

铁律：HTML 文件改动用 Python execute_code 直接读写替换，不用 patch：
  with open('file.html', 'r') as f: content = f.read()
  content = content.replace(old, new)
  with open('file.html', 'w') as f: f.write(content)
单处简单替换仍可尝试 patch，遇 escape-drift 立即切 Python。

**真实案例**：用 `replace_all=true` 替换 `adaptMcpFields` 中的 `if (key === 'complaints') { return items; }`，结果也匹配了 `renderThreeYearMonthly` 中的 `cumY` 函数体内的相似代码，30 行投诉映射代码被注入到中间，导致：
- JS 语法错误 `Unexpected end of input`
- `init()` 未定义，登录下拉框空白
- 整个页面不可用

**恢复**：从备份 `销售工作台_backup_20260725.html` 恢复 → 部署 → Python 脚本批量安全重加改动。

**铁律**：
1. replace_all 仅用于极其唯一的常量字符串（如 Tunnel URL）
2. 同一语义多处出现时，加前后 3-5 行上下文使 old_string 唯一
3. 改完立即 `python3 -c "...re..."` 确认旧模式已清理 + `node --check` 验 JS 语法
4. 如果已损坏：从备份恢复优先于手动修复

## 11. 从备份恢复后的三步检查清单

`replace_all` 破坏后最可靠恢复路径：备份 → Python 批量应用 → 补齐遗漏。

**恢复后必查**（三道关，缺一不可）：
1. **CloudBase SDK**：备份可能是 v1（无 SDK）。确认文件中有 `<script src="...cloudbase.full.js">`。没有就加。
2. **`CB_ENV` 配置**：v1 备份可能缺 `var CB_ENV = ...; var ROLE_KEY = ...`。在 Config 段补齐。
3. **登录方式**（2026-07-26 以后）：v1 备份有硬编码 `<option>` 下拉框。已改为邮箱文本框登录，见下文第 12 条。

**备份自身可能也有 bug**——部署前必须 `node --check` 验 JS。今天备份就缺了一个 `}`。

**Python 脚本批量操作**：当改动太多无法逐个 patch 时，用 Python 脚本读文件→字符串替换→写回。比 patch 快 10 倍，且不会出现 escape 问题。

## 13. Tunnel 假活检测与故障分类

`ps aux | grep cloudflared` 显示进程在跑 ≠ Tunnel 可用。需按症状分类处理：

### 故障类型 A：QUIC 失败 / HTTP2 降级
```
precheck component="UDP Connectivity" status=fail
suggested_protocol=http2
```
→ `curl -s --max-time 5 "https://<url>/api/todos" | head -c 5` 无输出
→ **处理**：kill + 重启 cloudflared，更新 HTML URL

### 故障类型 B：远端直接终止（2026-07-26 新增）
```
ERR failed to run the datagram handler error="Application error 0x0 (remote)"
ERR Serve tunnel error error="accept stream listener encountered a failure while serving"
ERR Connection terminated connIndex=0
```
→ Tunnel 连接建立后**立即被 Cloudflare 远端终止**，无 URL 产生
→ **这是 Cloudflare 侧问题**，非本地故障。重试无效（会在 precheck 后无限循环）
→ **处理**：等待（可能是 Cloudflare 临时中断），同时确保 JSON 优先模式已生效以保障静态数据可用

### 故障类型 C：返回 HTML 错误页
→ `curl` 返回 `<!doctype html>` 而非 JSON
→ Tunnel 已完全死亡（进程可能在但 URL 被回收）
→ **处理**：同类型 A

**通用检测命令**：
```bash
curl -s --max-time 5 "https://<url>/api/login" -X POST -H "Content-Type: application/json" -d '{"email":"test","password":"test"}' | head -c 20
# 返回 {"ok":...} = 正常
# 返回 <!doctype html> = Tunnel 死
# 无输出 = 假活（类型 A）

## 14. 邮箱登录体系（已被 #74 替代 → 已废弃）

历史方案存档：**详见 `references/deprecated-email-login.md`**。

## 24. 双文件陷阱——两个工作台入口，两种架构

**事实**：工作台有 **两个独立的 HTML 文件**，代码架构不同，修复方法不可互换：

| 文件 | CDN 路径 | 大小 | 登录方式 | ENDPOINTS 格式 | 状态 |
|------|----------|------|----------|----------------|------|
| `销售工作台.html` | `/index.html` | ~120KB | 后端 API（邮箱） | `[key, [apiUrl, jsonPath]]` | 已迁移到后端 API |
| `工具/index.html` | `/工具/index.html` | ~126KB | CloudBase DB (`user_roles`) | `[key, path]` | **旧架构，未迁移** |

### 关键差异

**登录体系**：
- `销售工作台.html`：调用 `POST /api/login` → 后端 `DEFAULT_USERS` 查表 → 返回用户
- `/工具/index.html`：CloudBase 匿名登录 → 查 `user_roles` 集合 → 匹配用户名密码

**ENDPOINTS 结构**：
```javascript
// 销售工作台.html（双元素数组，apiPath + fallbackJson）
['customers', ['/api/mcp/customers', '../数据/pricing/_key_customers.json']]

// /工具/index.html（简单路径，无 API 备选）
['customers', '../数据/pricing/_key_customers.json']
```

**loadData 修复策略不同**：
- `销售工作台.html`：需改为 JSON 优先 + API 备选（如第 21 条）
- `/工具/index.html`：简单路径格式本身不触发数组当 URL 的 bug，但也无降级机制

### `/工具/index.html` 的已知风险

1. **CloudBase 匿名登录可用但密码明文**：user_roles 的 `pwd` 字段明文，匿名登录后任何人可拖全表（见第 27 条）
2. **无后端 API 备选** → 如果 CloudBase DB 不可用，用户无法登录
3. **本地源已建立（2026-07-26）**：`~/Desktop/Hermes输出-工作类/工具/index.html`（备份 `index_backup_20260726.html`），Tunnel URL 已统一到 Tailscale Funnel 固定域名 `https://macbook-air-2.tail6864e8.ts.net`（AUTH_URLS + TODO_APIS 两处），残留 AUTH 对象已删除
4. **待办同步**：TODO_API 已改为 Funnel→localhost 降级链 + 探活锁定 + 防双写（`TODO_APIS`/`pullTodos`/`todoPost`，第 26-29 条同次修复）

### 何时修复哪个文件

- 用户报 "工作台" 问题 → 先确认是哪个入口（`/index.html` 还是 `/工具/index.html`）
- 如果用户说 "入口页面" "导航页" "工具页" → 通常指 `/工具/index.html`
- `/工具/index.html` 的迁移优先级较低——它是旧入口，主工作台 `/index.html` 是主力

## 60. `file_manifest.json` 格式陷阱——对象非数组，name 无扩展名（2026-07-27）

**症状**：访前准备存档区始终显示"暂无拜访卡"，但 manifest 文件中确实有万孚等拜访卡条目。

**根因链**：
1. `regenerate_manifest.sh` 输出的是**对象**（`{路径: {name, type, path}}`），不是数组 `{files: [...]}`
2. 前端用 `manifest.files` 读取 → `undefined` → entries 为空
3. `name` 字段已剥离 `.html`（`f.rsplit('.', 1)[0]`），但前端过滤器要求 `.endsWith('.html')` → 永远 0 匹配

**修复**：
```javascript
// ❌ manifest.files → undefined
var files = manifest.files || [];

// ✅ 对象遍历
Object.keys(manifest).forEach(function(k) {
  var v = manifest[k];
  if (v && v.name) entries.push(v);
});

// ❌ 过滤器要求 .endsWith('.html')
n.endsWith('.html')

// ✅ 不检查扩展名（name 已剥离）
n.indexOf('_拜访卡') > 0 || n.indexOf('_市场调研卡') > 0
```

## 61. Tailscale Funnel 端口不匹配（2026-07-27）

**症状**：`curl` 本地 API → 成功，`curl` Funnel URL → 空响应。

**根因**：Funnel 转发到 `127.0.0.1:8765`，HTTP 服务器运行在端口 `8000`。两者不匹配。

**检测**：
```bash
tailscale serve status
# 输出示例：https://macbook-air-2.tail6864e8.ts.net → http://127.0.0.1:8765
```

**修复**：
```bash
tailscale funnel --bg http://127.0.0.1:8000
```

**验证**：
```bash
curl -s --max-time 5 https://macbook-air-2.tail6864e8.ts.net/api/visit-card -X POST ...
```

## 86. 工作台入口文件：app.html ≠ index.html（2026-08-09）

**症状**：修改 `工具/index.html` 后飞书工作台无变化。

**根因**：飞书工作台的真正入口是 `工具/app.html`（通过 `landing.html?line=da/ls` 访问），不是 `工具/index.html`。

| 文件 | 用途 | 入口方式 |
|------|------|---------|
| `工具/app.html` | **主力工作台**（诊断原料/生命科学双线） | 飞书内 → landing.html → app.html?line=da |
| `工具/index.html` | 旧版入口 | CloudBase `/workbench/index.html`，历史遗留 |

**铁律**：修改看板/客户/订单等核心功能时，**两个文件都要改**（或确认目标后用 `app.html`）。

> 注意：Pitfall #76 描述的是 CloudBase 路径映射（`/index.html` vs `/workbench/index.html`），但源文件层面还有 `index.html` vs `app.html` 的坑——三层路径纠缠极易出错。

## 87. 看板 KPI 三层递进卡片模式（2026-08-09）

将旧版 3 卡（年度目标+环形图 / 本月vs上月 / 半年度进度）改为当月→当季→当年三层递进：

1. **`getKPI()`** 新增 `monthTarget/monthActual/q3Target/q3Actual`，数据来自 `threeYearMonthly` + `kpi_dashboard.json.depts[].h2_months`
2. **`renderDashboard()`** 渲染 3 张新卡片，含内联 progress bar（`height:6px`）+ 环比涨跌色
3. 环形图保留在年度卡片中

详细参考：[kpi-multi-horizon-cards.md](references/kpi-multi-horizon-cards.md)

## 95. 7月入职员工看板 KPI「入职拆分」模式（JOIN_SPLIT）（2026-08-30）

**场景**：新员工 7 月才入职（如大客户1岗位刘子研），全年目标 1300万 = 入职前（1-7月，前任业绩）+ 入职后（8-12月，新员工）。但数据里 `annual_target` 只存了「入职后目标」751.1万，`h1_total` 是入职前达成 507.8万。不拆分的话看板会：① 把前任 507.8万业绩算到新员工头上；② 年度目标显示成 751.1万（其实只是入职后部分，不是全年 1300万）。

**方案**：前端 `JOIN_SPLIT` 配置 + `getKPI()` 拆分 + `renderDashboard` 分支显示：

```javascript
// 入职拆分配置：全年目标 = 入职前目标 + 入职后目标
var JOIN_SPLIT = {
  '刘子研': { join_month: 8, full_year_target: 13000000, pre_join_target: 5489000, post_join_target: 7511000 }
};
```

`getKPI()` 非 admin 分支 return 前：
```javascript
var split = JOIN_SPLIT[name] || null;
var splitInfo = null;
if (split) {
  var h2am = dashPerson ? (dashPerson.h2_actual_months || []) : [];
  var preJoinActual = (dashPerson ? dashPerson.h1_total : 0) + (h2am[0] || 0);   // 1-7月(含7月)
  var postJoinActual = h2am.slice(1).reduce(function(s,v){return s+(v||0);},0);   // 8-12月
  splitInfo = { preJoinActual: preJoinActual, postJoinActual: postJoinActual, preJoinTarget: split.pre_join_target, postJoinTarget: split.post_join_target };
  annual = split.full_year_target;
  ytd = preJoinActual + postJoinActual;
  pct = annual ? Math.round(ytd/annual*1000)/10 : 0;
}
return { ..., split: splitInfo };
```

`renderDashboard` 年度卡片分支：label 显示「🎯 全年目标 1300万」，value 显示「入职后 751.1万」，sub 显示「入职前达成 550万 · 入职后达成 15.2万」，环形图 pct 改用「入职后达成率」（`postJoinActual/postJoinTarget`，donutLabel='入职后达成率'）。

**⚠️ 口径必须先问用户再改（业绩口径做错影响大）**：入职前/入职后分界（7月算哪边）、入职后目标=沿用 annual_target 还是 = 全年目标-入职前达成、入职后达成只算自己签的还是含归属名下所有。本次用户确认：入职前=1-7月(含7月)、入职后目标=751.1万(沿用数据)、入职后达成=含归属名下所有。

**⚠️ 语法坑**：把 `var donutPct=...` 声明插进 `renderDashboard` 的 `main.innerHTML = '...' + '...'` 字符串拼接 `+` 链中间，会 `SyntaxError: Unexpected token 'var'`——**var 声明必须放在 main.innerHTML 赋值语句之前**，不能在字符串拼接表达式里插声明语句。

## 113. 取证探测的输出爆炸防护（浏览器 js 探测 + grep 大文件）

工作台目录里混着**内嵌几百 KB 数据块的 HTML**，探测时一次不慎就是几十万字符输出（冲掉上下文，且结果被 spillover 截断后还得再去捞）。三条铁律：

- **浏览器里搜文本绝不遍历所有元素**：`[].filter.call(document.querySelectorAll('*'), e => /关键词/.test(e.textContent))` 会连 `<script>` 节点一起命中（内联脚本本身就是几十万字符）→ 一次返回 290KB。→ 只查目标容器（`document.querySelector('#main h2')`）或先 `querySelectorAll('h2')` 限定层级再取 `textContent`。
- **不要 `grep -rn "关键词" 工具/*.html`**：数据驱动页（测试台账类）在文件里内嵌整个 JSON，命中一行就是 35 万字符。→ 先 `grep -l`/`grep -c` 定位文件，再对具体文件做带行号的窄匹配，或只读代码区。
- **结果被 spillover 落盘后不要重跑同一条命令**：改从落盘文件里取需要的段（python 切片/grep 该文件），重跑只是再付一次同样的代价。

判断经验：单条命令的预期输出超过几千字符时，先加 `| head -N`、`grep -c`，或只打印计数字段。


## 115. 前端 API 基址探测：判据不能只看「非 404」，候选顺序就是可用性（2026-09-23 历史成交价 Failed to fetch）

现象：工作台「历史成交价」查客户／展开明细多处 `加载失败：Failed to fetch`；而**服务端日志里这些请求都是 200**（所以别先怀疑后端）。

根因链（`工具/下单.html` 的 `probeApi()`）：
1. 判据是 `r.status !== 404 ? 锁定 : 下一个` —— **非 404 即锁定**。而 `:443` 返回 401/200（永远不是 404）→ **锁死 :443**，稳定可靠的 `:8443` 永远轮不到。
2. 该域名 `:443` 由 Caddy 承担（响应头 `Via: 1.0 Caddy`、`Alt-Svc: h3=":443"`、只认 TLS1.3）：**单发可用，并发只扁得住 1 条连接**。浏览器实测并发 8 条 → **0 成功 / 8 失败**；同一时刻 `:8443` → **8/8 成功**。价格页一次并发多请求（options + approvals + customer_skus + 多个 sku_context）→ 必中。
3. 库差异陷阱：老 curl（macOS LibreSSL **不支持 TLS1.3**）打 `:443` 一律 `Connection reset by peer`，而 Chrome 能通。**同一端点两种客户端结论相反 → 两边都要验**（Python OpenSSL3 开 TLS1.3 可直达：`ssl.TLSVersion.TLSv1_3`）。

修法（三层，缺一不可）：
- **判据改严**：只认「**JSON 且带 `ok` 字段**」＝确是本服务接口（静态托管 404 页 / CloudBase「风险提醒」中间页的 200 HTML 都会被排除）。
- **顺序改对**：`API_CAND = ['', ':8443', ':443', '127.0.0.1:8765']` —— 稳定端点在前（`''` 最前是为了本机直连零开销）。
- **自动切换**：`api()` 捕获网络层异常（`e instanceof TypeError` 即 `Failed to fetch`）→ 拉黑该基址、`probeApi(排除它)` 换下一条、**该请求重试一次**；业务错误（`!ok`）不兜底。

两个自己踩的坑：
- 🩸 **不能用 `''`（相对路径）当「探测成功」的返回值**：`''` 是合法基址却 **falsy**，`if (!b) throw e` 会当成「没找到」→ 自愈静默失效、退回原始 `Failed to fetch`。`probeApi` 必顶 resolve `true/false`。
- **判定「是否已探测」用独立标志位 `API_READY`，不要用 `API === ''`**：`''` 是合法值，会在探测成功后每次调用都重新探测（自我递归）。

验收（可复现）：
- `probeBase(':443')` 与 `probeBase(':8443')` 都返回 true → **只能靠顺序决定首选，所以顺序就是功能**（改顺序要写注释防后人改回）。
- 打断测试：`API='https://127.0.0.1:9999'; API_READY=true;` → 调 `apiGet('/api/order/options')` 应 ~0.1s 自愈成功。
- 并发测试：8 条并行请求全部成功、页面无「加载失败」。
- 同类页面排查：`grep -rn "!== 404\|API_CAND\|AUTH_APIS" 工具/*.html`（`活动录入.html` 同款顺序且用 `r.ok` 判据 → 有 token 时会锁 `:443`，已一并改序）。


## 108. AI 助手一键复制对话（2026-09-18）
三个入口（顶部整段 Markdown / 每条右上单条 / 兜底手动复制框）。六坑 + 像素验证 + 四路径部署见 `references/ai-chat-copy-feature.md`。


## 65. 多选筛选器组件（2026-07-28）

工作台三个 Tab（客诉/订单管理/客户）共 11 个筛选器从单选 `<select>` 改为多选。核心实现：

### CSS
```css
.ms-wrap { position: relative; display: inline-block; }
.ms-btn { padding: 6px 10px; border: 1.5px solid var(--border); border-radius: 8px; font-size: 12px; background: #fff; cursor: pointer; }
.ms-drop { position: absolute; top: 100%; left: 0; z-index: 100; background: #fff; border: 1.5px solid var(--border); border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.12); }
.ms-drop.show { display: block; }
.ms-tag { display: inline-block; padding: 1px 6px; border-radius: 10px; font-size: 10px; background: var(--pri-light); color: var(--pri); }
```

### 核心函数
```javascript
var _msInstances = {};
function multiSelect(id, label, options, selected, onChange) {
  _msInstances[id] = { options: options, selected: selected, onChange: onChange };
}
function renderMultiSelect(id, label, options, selected, labelMap) {
  // 渲染按钮（≤3项显示标签，>3项显示"N项"）+ 下拉面板（全选/清除 + 复选框列表）
}
function toggleMsOpt(id, val) {
  var idx = inst.selected.indexOf(val);
  if (idx >= 0) inst.selected.splice(idx, 1);  // 已选→取消
  else inst.selected.push(val);                 // 未选→添加
  inst.onChange(inst.selected);
}
```

### 使用模式
```javascript
// 1. 变量声明：从 'all' 改为 []
var complaintAreaFilter = [];

// 2. 注册（在 render 函数开头）
multiSelect('compArea', '📂 区域', areaList, complaintAreaFilter, function(v){complaintAreaFilter=v;render();});

// 3. 筛选逻辑：从 === 'all' 改为 .length > 0 + .indexOf()
if (complaintAreaFilter.length > 0 && complaintAreaFilter.indexOf(c.area) < 0) return false;

// 4. 渲染：用 renderMultiSelect 替代 <select>
renderMultiSelect('compArea', '📂 区域', areaList, complaintAreaFilter)
```

### 改造清单（2026-07-28 已实施）
| Tab | 筛选器 | 状态 |
|-----|--------|:--:|
| 客诉 | 区域、状态、优先级、类型、处理人 | 多选 |
| 客诉 | 天数 | 保留单选（互斥范围） |
| 订单管理 | 部门、下单人、状态 | 多选 |
| 订单管理 | 日期范围、搜索 | 保留 |
| 客户 | 部门 | 多选 |


## 77. 工作台 HTTP 服务器进程管理

**症状**：`kill $(lsof -t -i :8765)` 后服务不启动，`lsof` 显示端口空闲但 `python3` 报 `Address already in use`。

**原因**：
- 多个后台进程（`terminal background=true`）累积，每个都尝试绑定 8765
- `hermes update` 过程会触发 Gateway 重启，连带杀死所有非 tmux 进程
- Tailscale Serve 可能绑定 8765（见 #7 "端口冲突"）

**推荐启动方式**（用 tmux 隔离）：
```bash
tmux new-session -d -s workbench 'cd ~/Desktop/Hermes输出-工作类 && python3 脚本/销售工作台服务.py 8765'
```

tmux 会话不受 Hermes 进程生命周期影响，Gateway 重启不会波及。

**当前落地方式（以此为准）**：工作台服务已由 launchd 托管（`~/Library/LaunchAgents/ai.hermes.workbench.plist`），改完服务端代码重启用 `launchctl kickstart -k gui/$(id -u)/ai.hermes.workbench`。这是**本地静态服务（8765）**，不是网关；网关重启必须由用户在独立终端执行（网关内执行会被安全策略拦）。

**调试端口占用**：
```bash
# 第一步：netstat 显示完整进程信息（比 lsof 更可靠）
netstat -anv | grep 8765
# 第二步：如果显示 io.tailscale.ipn → 用 tailscale serve reset
# 第三步：如果显示 Python → 用 kill -9 + 重新在 tmux 中启动
```


## 12. 备份文件不可盲信（2026-07-26 血案补充）

备份 `销售工作台_backup_20260725.html` 自身就有 JS 语法错误（614 `{` vs 613 `}`），init 函数内 `if` 块嵌套了 `function showLoginForm()` 和 `function showApp()` 声明，但缺少闭合的 `}`。

**任何从备份恢复的操作前必须先 `node --check` 验 JS**。备份≠正确。


## 18. var 重复声明陷阱

症状：页面加载无报错但行为异常。根因：同一变量在不同位置被 `var` 声明两次（常见于 Config 段 — 先声明再在 AUTH 块后重复）。

**所有已知重复位置**（不止 API_BASE_URLS）：

```javascript
// Config 段（约 L388-390）
var CB_ENV = 'bier-sales-d0gatbvlx288724e9';
var ROLE_KEY = 'bior_sw_user_v2';      // 第 1 次
var TODO_PREFIX = 'bior_sw_todos_v2_';  // 第 1 次
var AUTH = { ... };

// AUTH 块之后（约 L397）—— 以下全重复！
var ROLE_KEY = 'bior_sw_user_v2';       // ❌ 第 2 次
var TODO_PREFIX = 'bior_sw_todos_v2_';  // ❌ 第 2 次

// Data loading 段（约 L552-564）
var API_BASE_URLS = [...];  // 第 1 次
var AUTO_REFRESH_MS = ...;
var refreshTimer = null;

var API_BASE_URLS = [...];  // ❌ 第 2 次
var AUTO_REFRESH_MS = ...;  // ❌ 第 2 次
var refreshTimer = null;     // ❌ 第 2 次
```

**批量检测**（一次性查所有已知变量）：
```bash
for v in API_BASE_URLS ROLE_KEY TODO_PREFIX AUTO_REFRESH_MS refreshTimer CB_ENV; do
  c=$(grep -c "var $v\s*=" page.html)
  [ $c -gt 1 ] && echo "⚠ $v: $c declarations"
done
```
输出 > 1 的变量必须清理。


## 19. 超级管理员快速账号

后端 `DEFAULT_USERS` 支持简单用户名（不限于邮箱格式）。添加 `'admin': {...}` 作为万能账号，方便调试：

```python
DEFAULT_USERS = {
    'admin': {'name': '超级管理员', 'role': 'admin', 'dept': '诊断原料销售部'},
    'liuxinyuan@biori.com': {'name': '刘新元', 'role': 'admin', 'dept': '诊断原料销售部'},
    ...
}
```

修改后重启服务器：`kill $(lsof -t -i :8765)` → `tmux` 重启。

### 部署后验证：curl 必须带 Cache-Control 头（2026-07-27）

`tcb hosting deploy` 报告成功 ≠ 线上已更新。CDN 可能缓存旧版。验证命令：

```bash
# 验证 HTML 部署生效
curl -s -H "Cache-Control: no-cache" "https://bier-sales-...tcloudbaseapp.com/workbench/index.html?v=$(date +%s)" | grep '新增代码特征'
# 验证 JSON 数据部署
curl -s -H "Cache-Control: no-cache" "https://bier-sales-...tcloudbaseapp.com/数据/xxx.json" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['updated'])"


## 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+回退只能保证「不显示服务不可用」，不能消除排队。

## 110. 带鉴权的数据 Tab（上传/台账/文件下载）三个坑（2026-09-19）

以报销 Tab 为例，新增一个「需要后端鉴权 + 文件下载 + 上传」的 Tab 时必踩：

1. **下载不能写 `<a href="...">`** —— 链接请求带不上 `Authorization` 头，接口必 401。统一写 `apiFetch(url) → r.blob() → URL.createObjectURL → 临时 <a download>` 触发保存，用完 `revokeObjectURL`（同理：`<img src>`、`window.open` 都不行）。
2. **并行 fetch 渲染同一区域会竞态**：两个接口分别渲染同一块 DOM 时，先返回的那个可能不带后者的数据（如 list 接口不带 summary）→ 筛选条/图表渲染成空，看起来像「接口坏了」。规矩：**每个响应必须带上该区域需要的全部数据**（或渲染函数自身幂等且从一份共享 state 读），别让两个响应各管一半。
3. **Tab 权限用服务端能力位，不用前端隐藏**：登录/会话里不携带权限时，Tab 渲染后先调一次状态接口（如 `/api/expense/status`）读 `allow`，未开通就渲染「未开通」占位并隐藏上传区——前端隐藏拦不住直接调接口的人。

**上传接口的边上功夫**：多文件 multipart 字段名固定（如 `files`），前端先按扩展名过滤再提示「跳过 N 个」；服务端要回 `saved`/`skipped` 两个数组，用户才知道哪几个没进去。


## 110b. user_roles 锁库 + 用户管理服务端化（2026-09-20）

**问题**：CloudBase `user_roles` 集合 ACL 为 `read:true` → 任何登录用户可读**全公司 47 人的明文密码**；`admin.html` 整个用户管理（登录/列表/改密/新增）直连集合；`/api/auth/token` 还把 `pwd` 回传给客户端。

**修法（顺序很重要）**
1. 先加服务端接口：`/api/users/list`（含 `has_pwd`，不含 pwd）、`/api/users/roster`（任意登录用户）、`/api/users/save`、`/api/users/reset_pwd`（均 admin/gm）。
2. 登录响应**去掉 `'pwd': user.get('pwd','')`**（token 与飞书免登两处）。
3. 客户端 `loadUserList()` 改走 `/api/users/roster`，CloudBase 读作垂底（锁库后会静默失败，不能当主路径）。
4. `admin.html` 改服务端版（登录走 `/api/auth/token`，CRUD 走 `/api/users/*`），彻底移除集合直连。
5. **最后**才锁库：`tcb api tcb ModifySafeRule --body '{"EnvId":"...","CollectionName":"user_roles","AclTag":"CUSTOM","Rule":"{\"read\": false, \"write\": false}"}'` → 锁完必须三个账号回归登录 + 管理页列表。

**要点**：飞书免登走 `/api/feishu/login`（后端换身份），**不读 CloudBase** —— 所以锁库不会打断免登；登录只认本地快照 `数据/user_roles.json`，admin.html 写的就是它。备份页 `init_users.html`/`add_hechaojin.html` 会被锁库打断，已由 admin.html 取代。


## 105/104. 档案页权限泄露 & 集团级归属（2026-09-10）

`var` 赋值语句写在 `loadFromStorage()` 之后 → 登录身份被冲回默认值 → `indexOf('')>=0` 恒真 → 普通销售看到全部 1204 客户。集团卡归属默认取成员并集，`_key_customers.json` 里 `XX集团` 条目才是集团级权威。**详见 `references/permission-isolation.md`**。


## 101. 待办跨设备不同步：todos 集合 ACL PRIVATE + 匿名登录 open_id 隔离（2026-09-05）

**症状**：手机端设置的待办，电脑端看不到（反之亦然）。

**根因**：待办同步靠 CloudBase `todos` 集合，权限是 PRIVATE（仅创建者可读写）。工作台用**匿名登录**，每台设备的匿名 open_id 不同，导致每个设备只能读写自己 open_id 的文档，互相隔离。结果同一 user 在集合里分裂成多个文档（实测 `刘新元` 分裂成 4 个：电脑端29条、手机端3条各自独立）。

**修复**：
1. 改 ACL：`tcb api tcb ModifySafeRule --body '{"EnvId":"bier-sales-d0gatbvlx288724e9","CollectionName":"todos","AclTag":"CUSTOM","Rule":"{\"read\": true, \"write\": \"auth != null\"}"}'`。
   ⚠️ 必须用 `ModifySafeRule`（CUSTOM 自定义规则），**不是** `ModifyDatabaseACL`（后者只支持 PRIVATE/READONLY/ADMINWRITE/ADMINONLY 四个简易值，传 Rule 会报 UnknownParameter）。
2. 合并重复文档：`tcb db nosql execute` 的 DELETE/UPDATE。
   ⚠️ **坑**：MgoCommand 里用 `_id: {"$oid":"..."}` 会报 `InvalidParameter`；改用业务字段定位（如 `user + updated`）才成功。

**预防**：涉及 CloudBase 集合 + 匿名登录的跨设备同步，ACL 一律不能 PRIVATE。参考 `user_roles` 集合的 CUSTOM 规则（`read:true, write:"auth.openid == doc._openid"`）——它是「读放开+写仅创建者」，而 todos 需要「读写都放开」（`read:true, write:"auth != null"`）才能跨设备写同一个文档。


## 37. 非 Admin 用户数据隔离——完整实现（2026-07-27 已修复）

### 症状
赵云浩（大客户部销售员）登录后，客户 Tab 1680 条全量、订单管理 134 条全量、客诉 313 条全量。

### 实现架构
**用户部门 → MCP 部门名映射**（因登记部门名与 MCP 中不一致）：

```javascript
var USER_DEPT_MAP = {
  '大客户部': '诊断原料大客户销售部',
  '销售拓展部': '诊断原料销售拓展部',
  '浙江': '浙江', '广东': '广东',
  '诊断原料销售部': '诊断原料销售拓展部',
};
var USER_AREA_MAP = {
  '大客户部': '大客户', '销售拓展部': '销售拓展部',
  '浙江': '浙江', '广东': '广东',
  '诊断原料销售部': '销售拓展部',
};
```

**隔离辅助函数**：
```javascript
function getUserMcpDepts() {
  var u = state.user || {};
  if (u.role === 'admin') return null;  // admin 看全部
  var dept = u.dept || '';
  var mcp = USER_DEPT_MAP[dept] || dept;
  return [mcp];
}
function getUserAreas() {
  // 同上，用于客诉 area 过滤
}
```

**各 Tab 过滤点**：

| Tab | 过滤方式（2026-07-27 终版） | 过滤字段 |
|-----|---------|---------|
| 客户 | **current_status_operator 包含用户** | `c.operators.indexOf(userName) >= 0` |
| 订单 | **创建者** | `o.creator === userName` |
| 客诉 | **创建者** | `ci.creator === userName` |
| 商机沙盘 | **销售员名** | `o.sales === userName` |
| 访前准备 | 同客户（operators 匹配） | 同客户 |
| 我的活动 | `activities.filter(a => a.creator === user)` | `a.creator` |
| 看板 KPI | `getFilteredCustomers().reduce(sales_2026)` | ERP 汇总（已按 salesperson 过滤） |

### 订单 dept 字段
`pull_orders.py` 必须拉取 `field_5ed7ab`（销售部门），`shipment_pipeline.py` 透传为 `dept`。否则 `getShipmentOrders` 的 `USER_DEPTS` 过滤匹配不到任何订单。

### 验证结果（赵云浩视角，2026-07-27 终版）

| Tab | 修复前 | 修复后 | 隔离核心 |
|------|:--:|:--:|------|
| 客户 | 1680 | **63** | current_status_operator 匹配 |
| 订单 | 525 | **109** | 创建者 |
| 客诉 | 313 | **35** | 创建者 |
| 商机沙盘 | 339 | **29** | salesperson |
| 看板 KPI | 293.9万 | 不变 | ERP 已按 salesperson |

### loadData() 集成

```javascript
// Promise.all 完成后，render() 之前：
state.data = data;
if (data.mcpCustomers) {
  data.mcpCustomers.customers = adaptMcpFields(data.mcpCustomers);
}
```

### 字段不匹配症状速查

| 症状 | 代码用 | MCP 实际字段 | 
|------|--------|-------------|
| 集团列全部"—" | `c.group` | `c.field_c3224b` |
| 部门筛选全部失效 | `c.dept` | `c.field_5ed7ab` |
| 客户级别透视失效 | `c.level` | `c.field_c8e80d` |
| 客户类型/等级透视失效 | `c.ctype` | `c.field_17186c` |
| 联系人全部显示0 | `c.contacts` | `c.field_8c870a` |
| 商机数全部显示0 | `c.opps` | `(c.field_79aa0b \|\| []).length` |
| 所在地显示空 | `c.location` | `c.field_6415cf` |
| 飞书链接跳错空间 | `c.fid` + 旧URL | `c.work_item_id` + `FEISHU_URL` |
| 金额全部"—" | `c.amount` | ERP交叉匹配或销售订单聚合 |

### 新增字段时的检查清单

1. `list_workitem_field_config` 查询 `project_key=6593cd71471290e3cc6be6e6`, `work_item_type=65ae1e403c87b152f3365ca6`
2. 确认字段存在 → 在 `adaptMcpFields()` 中添加映射行
3. `select`/`multi-select` 直接取值，`workitem_related_multi_select` 取数组 `.length`，`number` 直接使用
4. 改完 Python 括号验证 + `tcb hosting deploy` 两处路径


## 78. CloudBase SDK 加载失败 → 登录按钮静默失效（2026-07-31 诊断）

**症状**：登录页正常显示（Logo + 下拉框 + 密码框 + 登录按钮），但点击「登 录」**完全没有反应**——无错误提示、无加载动画、无网络请求。

**根因链**：
1. `static.cloudbase.net/cloudbase-js-sdk/latest/cloudbase.full.js` 加载失败（飞书内置浏览器网络限制/防火墙/代理）
2. `init()` 第 1 行 `cbApp = cloudbase.init({ env: CB_ENV })` 抛出 `ReferenceError: cloudbase is not defined`
3. **`init()` 没有任何 try/catch**，整段 JS 脚本执行中断
4. `doLogin()` 函数永远不会被定义
5. 登录按钮的 `onclick="doLogin()"` 变成一个空调用 → 点击无任何反应

**代码位置**：`工具/index.html`
```javascript
// L518-557: init() 无 try/catch
function init() {
  cbApp = cloudbase.init({ env: CB_ENV });  // ← 这里崩溃，下面全部不执行
  cbDb = cbApp.database();
  // ...
}
// L2889
init();  // ← 页面加载时直接调用，无错误处理
```

**验证方法**：
```javascript
// 浏览器控制台
typeof cloudbase  // → undefined = SDK 未加载
typeof doLogin    // → undefined = 函数未定义
```

**修复方案**：
```javascript
function init() {
  // 1. 检测 SDK 是否加载
  if (typeof cloudbase === 'undefined') {
    document.getElementById('loginSub').textContent = '⚠️ 网络异常，请刷新重试';
    document.getElementById('loginBtn').disabled = true;
    return;
  }
  try {
    cbApp = cloudbase.init({ env: CB_ENV });
    cbDb = cbApp.database();
    // ... 其余 init 逻辑 ...
  } catch(e) {
    document.getElementById('loginErr').textContent = '初始化失败：' + e.message;
    document.getElementById('loginErr').style.display = 'block';
    document.getElementById('loginBtn').disabled = true;
  }
}
```

**触发场景**：
- 飞书内置浏览器网络策略限制 `static.cloudbase.net`
- 公司网络/防火墙拦截腾讯云 CDN
- iOS/Android WebView 的 ATS 或混合内容策略
- 用户切换网络后 CDN 缓存失效

**与外部浏览器的差异**：外部浏览器（Safari/Chrome）通常能正常加载 CloudBase SDK，因此同 URL 在飞书外正常、飞书内无反应是典型特征。


## 22. 离线登录保障——API 全部失败时的硬编码回退（2026-07-26）

**背景**：CloudBase HTTPS 页面登录依赖 Tunnel API 或 localhost API。但 Tunnel 不稳定（cloudflared 进程在但连接失败），localhost HTTP 被浏览器 mixed content 策略拦截。两个 API 全挂时用户无法登录。

**修复**：在 `doLogin()` 的 API 降级链末尾增加离线凭据检查：

```javascript
function doLogin() {
  // ... 输入校验 ...
  
  // 离线用户表（当 API 不可用时使用）
  var OFFLINE_USERS = {
    'admin':                {name:'超级管理员', role:'admin', dept:'诊断原料销售部'},
    'liuxinyuan@biori.com': {name:'刘新元', role:'admin', dept:'诊断原料销售部'},
  };
  var OFFLINE_PWD = 'biori2026';

  function tryLogin(i) {
    if (i >= apiUrls.length) {
      // API 全部失败 → 离线登录
      var u = OFFLINE_USERS[email.toLowerCase()];
      if (u && pwd === OFFLINE_PWD) {
        finishLogin(u);
      } else {
        errEl.textContent = '认证服务不可用，且离线凭据不匹配';
        // ...
      }
      return;
    }
    // ... 正常 API 登录 ...
  }
}
```

**关键点**：
- 离线表只包含核心管理员——不暴露完整用户列表
- `apiUrls` 先试 Tunnel(HTTPS) 再试 localhost(HTTP)，都失败才离线
- 离线凭据与前端的 `finishLogin()` 共享同一数据流，无需额外适配


## 27. CloudBase DB 字段名与代码不一致 → 全员无法登录（2026-07-26）

**症状**：输入正确密码报"密码错误"，控制台无任何 JS 报错。

**根因**：`user_roles` 集合密码字段是 **`pwd`**，代码校验 `user.password`（undefined）→ 恒不等。user_roles 实际字段：`name/dept/role/tabs/email/status/pwd`。

**调试手法**（极强）：在已加载 cloudbase SDK 的页面里直接用 browser_console 查库验证字段名与数据：
```javascript
cbDb.collection('user_roles').where({name:'刘新元'}).get().then(r => JSON.stringify(r.data))
```
⚠️ 安全后果：匿名登录后任何人都能拖全表（含明文 pwd）——长期应哈希或迁后端 DEFAULT_USERS 体系。


## 28. CloudBase DB 查询必须在匿名登录 signIn() 完成之后（2026-07-26）

**症状**：用户下拉框永远走 fallback 硬编码名单（无徽章、无部门）；飞书免登查 user_roles 也失败。

**根因**：`loadUserList()` 在 `auth.anonymousAuthProvider().signIn()` resolve 之前调用，未认证查询必败进 catch。

**模式**：init() 里先 `signIn().then(...)`，所有 cbDb 查询（loadUserList / feishuAutoLogin / 免登身份兑换）放进 then 里；signIn 的 catch 里也要 showLoginForm() 保证用户能到登录界面。


## 74. 统一硬编码登录——所有用户 biori2026 + personnel.json（2026-07-28）

CloudBase DB 认证已废弃。所有销售员统一验证链：密码 `biori2026` → `_personnel.json` 查角色/部门。

```javascript
function doLogin() {
  if (pwd !== 'biori2026') { errEl.textContent = '密码错误'; return; }
  fetch('../数据/pricing/_personnel.json?v=' + Date.now(), { cache: 'no-store' })
    .then(function(r) { return r.json(); })
    .then(function(data) {
      var found = (data.personnel || []).filter(function(p) { return p.name === name; })[0];
      if (found) {
        var role = found.role;
        if (name === '刘新元' || role === '销售总监') role = 'admin';
        else if (role === '销售经理' || role === '大客户经理') role = 'manager';
        else role = 'sales';
        finishLogin({name: found.name, role: role, dept: found.dept || '', email: found.email || '', tabs: {dashboard: true}});
      } else {
        finishLogin({name: name, role: 'sales', dept: '', email: '', tabs: {dashboard: true}});
      }
    });
}
```

**关键**：`_personnel.json` 中 role 是中文职衔 → 必须映射到系统角色。刘新元（销售总监）→ `admin`。不在人员表中的人仍可登录（默认 sales，无部门信息）。


## 84. 看板筛选器权限模型（2026-08-01）

### 权限矩阵

| 角色 | 筛选器可见 | 可选部门 | 可选销售员 | 默认 |
|------|:--:|------|------|------|
| admin / gm | ✅ | 全部3部门 | 全部在职 | 全公司 |
| manager（刘欣） | ✅ | 仅 `销售拓展部` | 本部门全部 | 销售拓展部 |
| manager（吴云） | ✅ | 仅 `生命科学部` | 浙江区域 | **浙江区域** |
| manager（韩远怀） | ✅ | 仅 `生命科学部` | 广东区域 | **广东区域** |
| sales | ❌ | — | — | 仅自己 |

### Manager 默认锁定

Manager 登录后自动应用管辖范围筛选。区域经理（吴云/韩远怀）额外按 `_personnel.json.region` 匹配区域。

```javascript
// Manager 默认筛选
if (dashboardDeptFilter.length === 0 && dashboardSalesFilter.length === 0) {
  dashboardDeptFilter = [mgrDeptName];
  if (mgrRegion.indexOf('浙江') >= 0 || mgrRegion.indexOf('广东') >= 0) {
    dashboardSalesFilter = availableSales.filter(function(s) { return s !== userName; });
  }
}
```

### 筛选联动（全部模块）

筛选后 **所有模块** 联动：
- KPI 统计卡（客户数/价税合计/本月价税/联系人）
- 年度达成率 donut（目标 + 实际均按筛选）
- H1/H2 月度趋势图
- 客户 Top10 + 三年逐月对比
- 数据源：`dash.people[].target/h1_total` + `prog.by_salesperson` + `mcpCustomers.owner/operators` + `custs.salesperson`

### 双选取交集

同时选部门+销售员时，仅显示**同时满足两个条件**的数据。

详见 `references/kpi-tax-fix-all-modules.md`。


## 93. 工作台新增 Tab 的完整改动清单 + 内嵌独立页面免登录（2026-08-30）

新增一个 tab（如「岗前培训」）需改 **4 处**，且 app.html 与 index.html **双文件都要同步**（见 #86）：

1. `TABS` 数组加 `{ id: 'training', label: '岗前培训', icon: '🎓', lines: ['da','ls'] }`
2. `render()` 的 `switch(state.tab)` 加 `case 'training': renderTraining(main); break;`
3. 新增渲染函数 `function renderTraining(main) { main.innerHTML = '<iframe src="/training/index.html" ...>'; }`
4. 双文件都要 `tcb hosting deploy`：`工具/app.html`→`/工具/app.html`、`工具/index.html`→`/workbench/index.html`

**内嵌独立页面的两个关键**：
- **iframe `src` 用绝对根路径** `/training/index.html`（从 CloudBase 根算，不受父页面所在目录影响）。
- **同 origin 免二次登录**：独立页面与工作台同 domain（tcloudbaseapp.com），localStorage 共享。独立页面 `init()` 先读自己的 ROLE_KEY，没有则读工作台的 `localStorage.getItem('bior_test_user_v1')`（app.html 的 key），取到 `name` 就 `cbDb.collection('user_roles').where({name}).get()` 回查拿完整信息（含 email/role）自动登录。⚠️ app.html 的 `finishLogin` 存的是 `{name, role, dept, line}` **无 email**，自动继承时必须回查 user_roles 补 email，不能直接 trust 工作台缓存。

改完照 #25 验语法（`node --check` 提取的内联 JS + 括号平衡），部署后照 #19 `curl -H "Cache-Control: no-cache"` grep 新 tab 标记确认生效。

多用户应用（复用 user_roles 登录 + 新集合 + ACL + 英文路径部署）完整搭建模板见 `baoyu-cloudbase-deployment` 技能的 `references/multi-user-app-pattern.md`。


## 96. CloudBase 集合权限设置：ModifyDatabaseACL 不支持 Rule，自定义规则用 ModifySafeRule（2026-08-30）

**场景**：新建 CloudBase 集合（如培训系统的 training_assignments/training_progress/training_scores），要设成「所有人可读可写」让前端匿名登录能读写。

**根因**：`tcb api tcb ModifyDatabaseACL` 只支持预设 `AclTag`（PRIVATE/READONLY/ADMINWRITE/ADMINONLY），**传 `Rule` 参数报 `The parameter Rule is not recognized`**。自定义规则必须走另一个 API。

**修复**：用 `ModifySafeRule`，`AclTag=CUSTOM` + `Rule`：
```bash
tcb api tcb ModifySafeRule --api-version 2018-06-08 \
  --body '{"EnvId":"bier-sales-d0gatbvlx288724e9","CollectionName":"<集合>","AclTag":"CUSTOM","Rule":"{\"read\":true,\"write\":true}"}' --json
```

**关键事实**：
- 新集合默认 `AclTag=PRIVATE`（仅创建者可读写）→ 管理员读不到员工数据、员工写不了自己的记录。所以建多用户集合后**必须**改权限。
- `user_roles` 实际权限是 `read:true`（所有人可读，匿名登录才能登录）+ `write:auth.openid==doc._openid`（仅创建者写），不是 skill 早先记的 `read:auth!=null/write:auth!=null`。
- 验证：`tcb api tcb DescribeDatabaseACL --api-version 2018-06-08 --body '{"EnvId":"...","CollectionName":"..."}' --json`

## 113. 清单页新增统计维度：默认交叉、四处同步、页面端派生

用户说「把 X 类分单独类别统计」时（如给特殊要求加「NA 类（核酸残留）」）：

1. **先定互斥还是交叉，并写进页面**：默认做**交叉维度** —— 同一条记录既保留原类别、也计入新维度；擅自把记录从原类别挪走会让用户已记住的原类别分布凭空缩水。要互斥必须先跟用户确认。
2. **四处同步，少一处就是半成品**：① 顶部 KPI 卡新增一张（`涉及类别数` 同时 +1）② 类别芯片（带实时计数、点击即筛选）③ 类别下拉（与芯片同一 value 语义，常用哨兵值如 `__NA__`，别混进真实类目）④ 表格行内小标签（原类别后追加，让「它为什么算这一类」逐行可见）。
3. **新维度在页面端派生，不改主数据 JSON**：关键词规则与判定函数写死在页面 JS。JSON 的 `category` 是 ETL/模型产出，改了会被下次重建覆盖；派生维度还能对已上线数据立即生效，无需跑管道。
4. **规则要在页面上「自白」**：统计条写明命中数、占比、按原类别分布、识别依据（哪些关键词）、是否与既有类别重叠。用户一定会核对灰色地带条目（如「无核酸酶水」「背景菌残留」算不算 NA）—— 把命中明细逐条列给他定夺。
5. **上线前用真实 JSON 跑渲染断言**：命中数 / 按原类别分布 / 「筛选后行数 == 命中数」「行内标签数 == 行数」/ 切回「全部」恢复总数。规则的大小写与空格要归一（`NA级` vs `NA 级` vs `na级`），否则漏抓。


## 100. AI 助手文件上传：前端校验别比后端严（2026-09-04）

症状：AI 助手「只上传 PDF、不输入文字」点发送无反应，文件发不出去。

根因：`doChatSend` 里 `if (!q) return` 强制要求输入文字，但后端 `ai_chat.handle()` 的校验是 `if not question and not messages and not files`（files 非空就放行）。前后端校验口径不一致，纯文件上传被前端静默拦截。

修复：`if (!q && !chatFiles.length) return`（允许只传文件）；`q` 为空时不 push 空历史消息。

教训：前端入口校验（必填/格式）永远别比后端严——后端能处理的请求，前端不该拦。排查「上传/提交无反应」时，先对比前后端校验条件是否一致，再查数据链路（本次后端 pymupdf/OCR/funnel 均正常，问题只在前端这行 return）。

关联：AI 助手入口已从「右下角 🤖 气泡 + AI 助手 Tab」双入口，统一为「AI 助手 Tab」单入口（chatFab/chatPanel/toggleChat/sendChat/chatOpen 已删除，appendChatMsg 默认容器 chatBody→aiChatBody）。文件上传只在 Tab 的 📎 里。


## 102. 看板环比基准月错误：kpi.last 写死 6 月而非上月（2026-09-05）

**症状**：看板"环比X月"数值错，月初数据少时尤其扎眼（如 9月 7.94万 环比显示 -98.4%，实际应跟 8月 388万 比）。

**根因**：`getKPI()` 里 `kpi.last` 写死 `h1.total.actuals[5]`（=6月，H1 最后一个月），但卡片标签写的是"环比上个月"。7月碰巧对（环比基准正是6月），8月起所有月份环比基准都错。非 admin 分支同样 `last: monthlyActuals[5]`。

**修复**：last 改为动态取上月实际——
- admin 分支：上月≤6月用 `h1.total.actuals[上月-1]`，上月≥7月用 `threeYearMonthly['2026'][String(上月)]`（注意把 `d.threeYearMonthly` 读取提前到 last 之前，原本在 last 之后）。
- 非 admin 分支：上月≤6月用 `monthlyActuals[上月-1]`，上月≥7月用 `dashPerson.h2_actual_months[上月-7]`（新增 `last2` 变量，return 里 `last: last2`）。
- `index.html` + `app.html` 各改 admin+非admin 两个分支，改完 `tcb hosting deploy` 部署两个文件。

**验证**：`_prevM = _nowM===1?12:_nowM-1` 后，9月→last=8月数据。node 模拟 `_prevM=8 → last=388.76万 → 环比 -98%`（修复前跟6月506万比是 -98.4%）。

工作台使用 `main.innerHTML` 全量重建 DOM。oninput 顺序不可错：

```javascript
// ✅ 正确
oninput="var v=this.value,p=this.selectionStart;state.val=v;
debounce(function(){
  render();                                         // 1. 先重建 DOM
  var el=document.getElementById('searchId');       // 2. 找新元素
  if(el){el.focus();el.selectionStart=el.selectionEnd=p;}  // 3. 恢复
},300)()"

// ❌ 错误：focus 在 render 前，旧元素被销毁
debounce(function(){el.focus();render();},300)()
```


## 3. 表格空数据必须有提示

```javascript
filtered.map(renderRow).join('') || '<tr><td colspan="N"><div class="todo-empty">暂无数据</div></td></tr>'
```


## 4. MCP 字段映射（必读！字段不匹配是客户页全空/全0的根因）

MCP 用 `field_xxxxx`（如 `field_5ed7ab`=部门），前端代码用语义名（`dept`）。**不映射就死**——MCP 数据通过但字段名错配，页面会静默显示空值/0/—，**不会报 JS 错误**，极难排查。

### 完整字段映射表（客户工作项，type_key=65ae1e403c87b152f3365ca6，93个字段）

| 页面语义名 | MCP 字段 | 飞书显示名 | 类型 | 备注 |
|---|---|------|------|------|
| `name` | `name` | 客户名称 | text | 直接可用 |
| `group` | `field_c3224b` | 所属集团客户 | text | — |
| `dept` | `c.field_5ed7ab` | 所属人部门/区域 | select | **⚠️ 值是对象 `{key,label}`，必须取 `.label`！见#36** |
| `level` | `c.field_c8e80d` | 客户级别 | select | 大客户/重点客户 |
| `ctype` | `c.field_17186c` | 客户等级 | select | A/B/C/D/准A类 |
| `contacts` | `field_8c870a` | 联系人数量 | number | — |
| `opps` | `field_79aa0b` | 客户对应商机 | related_multi | 取 `.length`（MQL 可能返回空） |
| `location` | `c.field_6415cf` | 所在地 | text | — |
| `work_item_id` | `work_item_id` | 工作项ID | number | 用于飞书链接 |
| `amount` | **MCP 无此字段** | — | — | 从 `_key_customers.json` 按客户名交叉匹配 |

> 金额（价税合计 `field_51d592`）在**销售订单**类型（type_key=`662612aea6bb7089fea044ef`）中，不在客户类型。短期从 ERP `_key_customers.json` 交叉匹配，长期可从销售订单按客户关联聚合。

### `adaptMcpFields()` 标准实现（不可变模式）

在 `loadData()` 的 `Promise.all` 回调中调用，**返回新对象不修改原始数据**：

```javascript
function adaptMcpFields(mcpData) {
  var custs = (mcpData || {}).customers || [];
  // ERP 金额字典（从 _key_customers.json 按客户名索引）
  var erpAmount = {};
  var erpCusts = state.data.customers || [];
  erpCusts.forEach(function(c) { if (c.amount) erpAmount[c.name] = c.amount; });
  return custs.map(function(c) {
    // ⚠️ select 类型字段值可能是对象 {key,label}，必须提取 label
    var dept = c.field_5ed7ab;
    if (typeof dept === 'object' && dept !== null) dept = dept.label || '';
    var level = c.field_c8e80d;
    if (typeof level === 'object' && level !== null) level = level.label || '';
    var ctype = c.field_17186c;
    if (typeof ctype === 'object' && ctype !== null) ctype = ctype.label || '';
    return {
      name: c.name,
      group: c.field_c3224b || '',
      dept: dept,
      level: level,
      ctype: ctype,
      contacts: c.field_8c870a || 0,
      opps: (c.field_79aa0b || []).length,
      location: c.field_6415cf || '',
      work_item_id: c.work_item_id,
      amount: erpAmount[c.name] || 0,
      _raw: c
    };
  });
}
```


## 36. select 字段值在 adaptMcpFields 中是对象不是字符串（2026-07-27 已修复）

**症状**：部门下拉显示 `[object Object]`，部门筛选全部失效。控制台无报错。

**根因**：`c.field_5ed7ab`（以及 `field_c8e80d`、`field_17186c` 等 select 类型字段）在 pull_customers.py 的 `parse_items` 中被写成 `v['key_label_value']`，这是一个字典 `{key: "xxx", label: "诊断原料大客户销售部"}`。但 `adaptMcpFields` 直接取 `c.field_5ed7ab || ''`，得到的是对象自身 → 渲染成 `[object Object]`。

**影响字段**：`field_5ed7ab`(部门)、`field_c8e80d`(客户级别)、`field_17186c`(客户等级)——所有 `select` 和 `key_label_value` 类型的字段。

**修复**：在 `adaptMcpFields` 中使用 `extractLabel()` 辅助函数：
```javascript
function extractLabel(v) {
  if (!v) return '';
  if (typeof v === 'object' && v.label) return v.label;
  return String(v);
}
// 在 adaptMcpFields 中：
dept: extractLabel(c.field_5ed7ab),
level: extractLabel(c.field_c8e80d),
ctype: extractLabel(c.field_17186c),
```

> ⚠️ 订单数据中 `field_5ed7ab`（销售部门）也有同样的问题——`pull_orders.py` 的 Python 侧需用 lambda 提取 label，不能用 JS 语法。


## 5. JSON 缓存 key 名匹配前端

```python
json.dump({..., 'customers' if cache_key=='customers' else 'items': data}, f)
```


## 46. `kpi` 变量在其定义前使用——看板崩溃（2026-07-27）

**症状**：看板显示"⚠️ 加载失败"，控制台报 `Cannot read properties of undefined (reading 'ytd')`。

**根因**：`renderDashboard` 中：
```javascript
var fullYearTax = kpi.ytd;  // ← kpi 未定义！hoisting 只提升声明不提升赋值
// ... 中间若干行 ...
var kpi = getKPI();         // ← 定义在这里
```

**修复**：将 `var kpi = getKPI() || fallback` 移到 `fullYearTax` 之前，并加 fallback 对象防止 getKPI 返回 undefined。

**ironclad rule**：任何变量必须在**所有使用它的语句之前**完成赋值。JavaScript 的 `var` hoisting 只提升声明不提升赋值，调试极其困难。

### getKPI() 对非标准角色返回 undefined

`getKPI()` 在 `my.people` 和 `dash.people` 中按 `name` 查找，找不到时返回 undefined（而非 fallback 对象）。`personnel.json` 中赵云浩的 role 是"销售工程师"而非"sales"，`dash.people` 可能不包含"销售工程师" → `getKPI()` 返回 undefined → `kpi.ytd` 报 `Cannot read properties of undefined`。

**修复**：调用方加 fallback：`var kpi = getKPI() || {annual:0, ytd:0, pct:0, label:'?', current:0, last:0, h2Targets:[], monthly:{targets:[],actuals:[]}};`

### 订单管理"本年"税合计被 fullTax 覆写为 0

`renderOrders` 中对于 `analysisPeriod === 'year'` 会执行 `ps.taxTotal = fullTax`（即 `data.fullYearTax`）。当前 `fullYearTax` 在 shipment_status.json 中始终为 0 → 年税合计恒 0。

**修复**：去掉 `ps.taxTotal = fullTax;`，让 `ps.taxTotal` 保持 `periodOrders` 的汇总值。全年数据已在 MCP 拉取中覆盖。


## 46b. 看板 KPI 累计达成数据源——必须用 KPI 数据而非 orders 汇总（2026-07-28 修正）

**2026-07-28 重大修正**：此前铁律要求用 `orders.reduce()` 做 `fullYearTax`，但 shipment orders 只包含近期订单（~187条），且金额字段多为0。**累计达成必须用 KPI 数据**：`dash.total_h1`（H1实际）+ `prog.total`（H2当月）。

**renderDashboard 中的正确计算**：
```javascript
// ✅ 正确：KPI 数据（H1实际 2164万 + H2当月 189.6万 = 2353.7万）
var fullYearTax = (dash.total_h1 || 0) + (prog.total || 0);

// ❌ 错误：shipment orders 汇总（近期187条，金额多为0 → 62.5万）
var fullYearTax = orders.reduce(function(s,o){return s+(parseFloat(o.taxTotal||o.amount||0)||0)},0);
```

**验证**：看板"年度目标"卡片的"累计达成"应与 kpi_dashboard.json 的 total_h1 + kpi_progress.json 的 total 一致。


## 70. 合同编号字段 `field_4a1b47` 遗漏（2026-07-28）

**症状**：订单管理表格合同编号列全空。

**根因**：`pull_orders.py` 的 MQL COLUMNS 未包含 `field_4a1b47`（合同编号#），`shipment_pipeline.py` 也未映射 `contractNo` 字段。

**修复**（三处）：
```python
# pull_orders.py: COLUMNS 加上 field_4a1b47
COLUMNS = '..., field_4a1b47'

# pull_orders.py: 输出加字段
'合同编号#': o.get('field_4a1b47', ''),

# shipment_pipeline.py: 透传
"contractNo": o.get("合同编号#", ""),
```


## 73. cb_sync.sh 动态文件精简 + 数据目录归档（2026-07-28）

**原则**：只上传管线产出的动态 JSON，静态文件（H1月度/三年对比/年度KPI）一次性部署。归档不用的分析结果到 `_archive/`。

**精简**：13→9 个动态 JSON + HTML（去掉 h1_monthly/h2_targets/three_year_monthly/my_kpis）。

**数据归档**：41 个未引用 JSON → `数据/_archive/`，78→37 个文件，释放 ~12MB。

**铁律**：每次新增 JSON 产出后检查是否需加入 cb_sync FILES 数组。HTML 部署是独立步骤。


## 48. 订单管理 KPI 卡片：按周期切换（本周/本月/本季/本年）

**最终方案（2026-07-27 终版）**：KPI 卡片使用 `ps.taxTotal`/`ps.total`（period stats），随周期按钮动态切换，**不是始终显示全年**。用户需要分别看各周期数据。

**前提**：`periodOrders` 按 `createDate` 过滤（allOrders 已按 creator 隔离）。全年数据必须已拉取（`pull_orders --days 365`），否则"本年"选项显示的只是 90 天范围。

**关键修复**：`ps.total` 必须统计**全部订单**（含无物流单号的），不只是有物流单号的：
```javascript
periodOrders.forEach(function(o) {
  var s = (o.shipment || {}).status || 'unknown';
  if (ps[s] !== undefined) ps[s]++; 
  ps.total++;  // ← 所有订单都计数，不只是有物流的
  ps.taxTotal += parseFloat(o.taxTotal || o.amount || 0) || 0;
});
```

**Ironclad**：**不要**在"本年"分析中执行 `ps.taxTotal = fullTax`（即 `data.fullYearTax`）——`fullYearTax` 始终为 0，会覆写正确值。


## 49. 订单明细去重——同一 orderId 出现多行

**症状**：订单管理明细表中同一订单号出现 2-12 行（如 `ZHBR260727020` 出现 10 次）。

**根因**：飞书中一个订单对应多个工作项（不同产品行 = 不同 work_item_id）。`pull_orders.py` 只按 `work_item_id` 去重，同单号的不同工作项全保留。

**根因修复（2026-07-28）**：在 `pull_orders.py` 输出时按 `单据编号` 去重：
```python
seen_oid = set()
for o in all_items:
    oid = o.get('name', '')
    if oid in seen_oid: continue
    seen_oid.add(oid)
    # ... 构建记录
```
效果：384→186 条，103 个重复单号归零。

**前端兜底（仍有冗余时）**：
```javascript
var seenOids = {};
var deduped = filtered.filter(function(o){
  if(seenOids[o.orderId]) return false;
  seenOids[o.orderId] = true;
  return true;
});
```


## 67. 无物流订单备货状态（2026-07-28）

**症状**：订单管理表格中无物流单号的订单状态列为空白/`?`，无法区分"刚创建未发货"和"超期未发货"。

**方案**：前端根据创建时间计算天数，分段标记备货状态（备货中/预警/超期/严重超期）。MCP 销售订单**没有**「计划发货日期」字段。

详见 `references/preparation-status-pattern.md`。


## 68. 合同编号字段遗漏（2026-07-28）

**症状**：`cb_sync.sh` 执行成功，但工作台页面仍是旧版。

**根因**：`cb_sync.sh` 的 FILES 数组只包含数据 JSON（`mcp_customers.json`、`shipment_status.json` 等），**不包含 HTML 文件**。修改 `工具/index.html` 后必须单独部署：

> ⚠️ **但 cb_sync.sh 末尾（第 60 行）会单独部署 index.html**：`tcb hosting deploy "$WORKDIR/工具/index.html" /workbench/index.html`，把**本地源文件** `工具/index.html` 自动覆盖到线上 `/workbench/index.html`（每 2 小时）。**所以改工作台 HTML 必须改本地 Desktop 源文件**（`工具/index.html`、`工具/app.html`），改 `/tmp` 或 `tcb hosting download` 下来的副本部署后会被 cb_sync 覆盖回旧版（2026-08-30 血案：改了 /tmp 副本部署成功，2 小时内被覆盖，用户反馈"没有 tab"）。app.html 走 `工具/app.html`→`/工具/app.html`，cb_sync 不部署它，但改本地源文件仍是最稳做法。
```bash
tcb hosting deploy 工具/index.html /workbench/index.html -e bier-sales-d0gatbvlx288724e9
```

**验证**：部署后用 `curl -H "Cache-Control: no-cache"` 检查新代码特征是否存在：
```bash
curl -s -H "Cache-Control: no-cache" "https://bier-sales-...tcloudbaseapp.com/workbench/index.html" | grep -c "multiSelect"
```

> 注意：CloudBase CDN 有 5-10 分钟缓存延迟，部署后 curl 验证可能仍显示旧版。用 `tcb hosting download` 验证 Origin 文件已更新。


## 50. shipment_pipeline 必须输出所有订单（含无物流单号的）

**症状**：赵云浩只有 109 条订单（仅含物流单号），无物流单号的订单不显示 → 价税合计被大量低估。

**根因**：`process_orders()` 在 step 1 就 `if not tn: continue` 跳过了无物流单号的订单。

**修复**：改为收集全量订单，SF 查询仅对有物流单号的部分执行，最终输出全部订单（无物流的 `shipment` 字段为空 `{}`）：

```python
all_orders = []
tracking_orders = []
for o in orders_data:
    all_orders.append(o)
    if tn and tn not in seen_tn:
        tracking_orders.append(o)
# SF 查询仅对 tracking_orders 执行
# 输出 all_orders（含无物流的）
```

**效果**：赵云浩 109 → 488 条，税合计 ¥142 万。


## 79. MCP field_5ed7ab 选项与 HR 部门名不一致（2026-07-31 发现）

**症状**：按 `field_5ed7ab = '生命科学销售部'` 查询客户返回 0 条，但实际有 111 家生命科学客户。

**根因**：MCP `field_5ed7ab` 的选项是**飞书项目配置的固定列表**，与 HR 体系（`_personnel.json`）的部门名不一致：

| 业务线 | MCP field_5ed7ab 实际选项 | HR dept | 客户数 |
|------|------|------|:--:|
| 诊断原料 | `诊断原料销售拓展部` | 销售拓展部 | — |
| 诊断原料 | `诊断原料大客户销售部` | 大客户部 | — |
| **生命科学** | **`浙江`** | 生命科学销售部 | ~70 |
| **生命科学** | **`广东`** | 生命科学销售部 | ~41 |
| 其他 | `南区`,`北区`,`华中区`,`国际业务部`,`生物医药销售部`,`北京区域` | — | — |

> MCP 中**没有**"生命科学销售部"选项。生命科学客户以地域标签标识。查询 MQL：
> ```sql
> SELECT `name`, `field_5ed7ab` FROM `销售管理`.`客户` WHERE `field_5ed7ab` in ('浙江','广东')
> ```

### 业务线双映射模式

所有涉及业务线隔离的页面应使用双映射，分开处理数据源过滤（MCP 实际值）和用户筛选（HR 体系）：

```javascript
var LINE_CONFIG = {
  da: {
    mcpDepts: ['诊断原料销售拓展部','诊断原料大客户销售部'],  // 数据过滤用 MCP 值
    hrDepts: ['大客户部','销售拓展部','诊断原料销售部'],        // 用户筛选用 HR 值
  },
  ls: {
    mcpDepts: ['浙江','广东'],                                    // 数据过滤用 MCP 值
    hrDepts: ['生命科学销售部','生命科学-浙江','生命科学-广东'],  // 用户筛选用 HR 值
  }
};
```

**关键**：永远先用 `list_workitem_field_config` 查字段选项，不要假设 HR 部门名在 MCP 中存在。


## 9. 客诉数据管道：三个致命缺陷（2026-07-27 诊断）
三个缺陷（字段错位 / 去重基准错 / 状态机漏态）的完整诊断与修法见 `references/complaints-pipeline-three-bugs.md`。

## 9b. 客诉数据在另一个项目中

客诉不是 `销售管理` 的 `issue` 表，在 `售后管理`(658288abfb8bd616b17025f1) 的 `khts`(客户反馈单, key=6669433056a98249604376de)。发现方法：`search_project_info` 传空查全部 → 逐个 `list_workitem_types`。


## 10. MQL 字段值类型陷阱

`moql_field_list` 的值有多种格式，`v` 可能为 `None` 必须先判空：
- 字符串：`v['string_value']`
- 整数：`v['long_value']`
- 浮点：`v['double_value']`（如 `field_51d592` 价税合计，订单金额）
- 用户：`v['user_value']` / `v['user_value_list']`
- 选项：`v['key_label_value']` / `v['key_label_value_list']`

**完整解析参考**：`references/mql-value-type-parsing.md` — 包含所有 6 种 value_type 的处理模板和 None 防护。

**`double_value` vs `long_value`**：数字字段在 MCP 中可能是 `long_value`（整数，如 work_item_id）或 `double_value`（浮点，如金额）。两者需要分开处理——`long_value` 丢 `int()`，`double_value` 丢 `float()`。

不处理数组/None 会导致 `field_79aa0b`(商机关联)、`work_item_status`、`current_status_operator` 等字段丢失。


## 75. H1 数据存档 + 前端单文件合并（2026-07-28）

### 问题

订单历史数据（H1）不在 `shipment_status.json` 中，导致"本年"/"本季"视图数据不准。MCP 全量拉取超时（365天数据量过大）。

### 方案：按月拉取 → 存档 → 合并

**月度拉取**：`pull_h1_archive.py --m01` ~ `--m06`（1-6月各一次），输出到 `h1_archive/`。使用客户→销售员映射（不逐条查角色，省时间）。

**合并输出**：`merge_workbench_data.py` 将 H1 存档(4063条) + H2 动态(187条) 合并为单一 `workbench_data.json`（4249条），H1 订单标记 `shipment.status = 'archived'`。

**前端单请求**：`loadData()` 只 fetch `workbench_data.json`（14→1 请求），不再逐文件拉取。

### 数据链路
```
pull_h1_archive.py --m01~m06 → h1_archive/h1_all.json ─┐
                                                         ├→ merge_workbench_data.py → workbench_data.json
pull_orders.py --days 90 → shipment_status.json ────────┘
```

### merge_workbench_data.py 核心逻辑
```python
# 读取 H1 存档 + shipment_status.json
h1_orders = json.load(open('h1_archive/h1_all.json')).get('orders', [])
h2_orders = result['shipments'].get('orders', [])

# 补默认 shipment（H1 无顺丰轨迹）
for o in h1_orders:
    if not o.get('shipment'): o['shipment'] = {'status': 'archived', 'statusLabel': 'H1 归档'}

# 合并去重（H2 同单号覆盖 H1）
seen = set(o.get('orderId','') for o in h1_orders)
merged = h1_orders + [o for o in h2_orders if o.get('orderId','') not in seen]
result['shipments']['orders'] = sorted(merged, key=lambda o: o.get('createDate',''), reverse=True)
```

### 月度校对（每月3号 9:00）
`reconcile_monthly.py` 对比 H1 存档 + H2 动态 vs KPI 目标，生成报告 `h1_archive/reconcile_YYYY-MM.md`。

`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
```

- `references/email-login-migration.md` — 邮箱登录迁移全记录（CloudBase SDK 失效→后端 API 过程）
- `references/complaints-data-reference.md` — 客诉数据字段与 MQL 查询
- `references/feishu-customer-link.md` — 飞书客户链接路由（诊断原料 vs 生命科学）
- `references/mcp-field-mappings.md` — MCP 字段映射表
- references/pipeline-scripts.md — 客诉+客户数据管道脚本（pull_complaints.py / pull_customers.py）
### 商机沙盘隔离 + 混合类型筛选 + 可编辑字段（2026-07-27）

**隔离**：`renderOppSandbox` 对非 admin/manager 按 `o.sales === user.name` 过滤。赵云浩 339→29 条。

**混合类型筛选**：约 60% 商机的 `type` 字段是"增量/存量"，既不能归入纯存量也不能归入纯增量。筛选按钮从 `===` 改为 `indexOf` 匹配：

```javascript
// 聚合时：混合类型同时计入存量和增量
if ((o.type||'').indexOf('存量')>=0) s['存量'] += toWanSmart(o.target, o.sales);
if ((o.type||'').indexOf('增量')>=0) s['增量'] += toWanSmart(o.target, o.sales);

// 筛选时
var filteredOpps = s.opps.filter(function(o) {
  return oppSandboxFilter === 'all' || (o.type||'').indexOf(oppSandboxFilter) >= 0;
});
```

**可编辑字段**：type 标签改为 `contenteditable="true"`，blur 时 `updateOppField(el)` 存 localStorage 并重渲染。Q1-Q4 达成已有 number input。

`renderOppSandbox` 数据来自 `opportunity_sandbox.json`（`d.opportunities`），`o.sales` 字段即销售员名。非 admin/manager 按 `state.user.name` 精确过滤：

```javascript
// 数据隔离：非admin只看自己的商机
var user = state.user || {};
if (user.role !== 'admin' && user.role !== 'manager') {
  opps = opps.filter(function(o) { return o.sales === user.name; });
}
```

商户数据中 `dept` 字段可能为 NaN（数据源问题），不影响按 sales 名称的过滤准确度。每个商机展开后有 Q1-Q4 达成输入框，blur 自动存 localStorage。验证结果：赵云浩 339→29 条。


## 40. MCP `double_value` 0 值被 Python falsy 吞掉（2026-07-27 血案）

**症状**：`shipment_status.json` 中所有订单 `taxTotal`/`amount` 均为 None/0，但 MQL 原始数据中 `field_51d592` 有非零值（如 `double_value: 162000`）。拉取 1442 条订单，价税合计全 0。

**根因链**：
1. `pull_orders.py` 中 `parse_items` → `fields['field_51d592'] = v['double_value']` ✅ 正确存为数字 0
2. 输出映射：`'金额': o.get('field_51d592', '')` → 若值为 0，`o.get('field_51d592', '')` 返回 **`0`（整数）**
3. `shipment_pipeline.py` 中 `o.get('金额', '') or ''` → **Python 中 `0 or ''` = `''`**（0 是 falsy！），金额被吞掉

**同时还有另一个 bug**：MCP 有时把 `double_value` 包在数组里：`[{"double_value": 371396.45}]`。`parse_items` 只处理了 `v['double_value']`，未处理 `v` 是列表的情况。

**修复**（两处）：

```python
# 修复 1：parse_items 加数组解包（pull_orders.py）
if isinstance(v, list) and len(v) > 0: v = v[0]

# 修复 2：输出时用 != '' 替代 or ''（pull_orders.py）
# ❌ '价税合计': o.get('field_51d592', ''),     ← 0 被吞
# ✅ 使用 o['field_51d592'] if 'field_51d592' in o and o['field_51d592'] != '' else ''
'金额': o.get('field_51d592') if o.get('field_51d592') is not None else '',

# 修复 3：shipment_pipeline 中 0 值检查
# ❌ tax_total = o.get('金额', '') or ''
# ✅ tax_total = o.get('金额') if o.get('金额') is not None else ''
```

**铁律**：处理 `double_value` 时必须意识到 **`0` 是合法金额**（免费样品/0 元单），不能用 `or ''` 作默认值判断。同样适用于所有 numeric MCP 字段。


## 62. 存档区客户名模糊匹配——文件名简称 vs MCP 全称（2026-07-27）

**症状**：赵云浩的万孚拜访卡不显示，但清单中有。

**根因**：文件名客户名是简称 `广州万孚`，`getVisibleCustomers()` 返回全称 `广州万孚生物技术股份有限公司`。`indexOf` 严格匹配失败。

**修复**：双向包含 + 逐字重叠匹配：
```javascript
// 双向包含
if (fullName.indexOf(shortName) >= 0 || shortName.indexOf(fullName) >= 0) matched = true;

// 逐字重叠兜底（2字滑动窗口）
if (!matched && shortName.length >= 3) {
  for (var ci = 0; ci < shortName.length - 1; ci++) {
    if (fullName.indexOf(shortName.slice(ci, ci+2)) >= 0) overlap++;
  }
  if (overlap >= 2) matched = true;
}
```


## 43. `moql_field_list` 为 None 导致管道崩溃（2026-07-27）

**症状**：`pull_orders.py --days 365` 在 page 45（2250 条）崩溃：`TypeError: 'NoneType' object is not iterable`。全年数据拉取中断，只有前 2250 条入库。

**根因**：某些 MQL 返回的 items 中 `moql_field_list` 是 `null`。`item.get('moql_field_list', [])` 无法拦截 None（只有 key 不存在才走 default）。

**修复**：
```python
# ❌ 错误：None 不走 default
for f in item.get('moql_field_list', []):

# ✅ 正确：显式处理 None
for f in (item.get('moql_field_list') or []):
```

**铁律**：所有 MQL 解析循环都必须用 `or []` 而不是 `.get(key, [])` 防护 None。


## 35. 联系人数据通过联系人类型反向聚合（2026-07-27）

客户类型的 `field_8c870a`（联系人数量）在 MCP 中全部为空。需要通过联系人类型反向聚合：

1. 拉取全部联系人（`65b8a6cacde27fd415d769ca`，8804条）
2. 每个联系人有 `field_6b3789`（联系人关联客户）→ 取 label（客户名）
3. 按客户名聚合 `name`（联系人名称）→ 列表
4. 输出的 `customer_contacts.json` 被 `adaptMcpFields` 引用，计算 `contacts.length`

```python
# pull_contacts.py 核心: SELECT name, field_6b3789, work_item_id FROM 销售管理.联系人
by_customer[field_6b3789.label].append(name)
```


## 32. 销售订单 → 顺丰物流数据管道（2026-07-27）

订单管理 Tab 的数据源是 `shipment_status.json`，由 `shipment_pipeline.py` 从 `mcp_orders_input.json` 读取 → 调顺丰 API 查轨迹。但 `mcp_orders_input.json` 之前依赖手动 ERP 导出，已断流。

### 修复：MQL 直拉销售订单

`pull_orders.py`（在 `数据/` 目录）用 MQL 从飞书拉取销售订单，输出到 `mcp_orders_input.json`，再触发 `shipment_pipeline.py`。

**销售订单关键字段**（type_key=`662612aea6bb7089fea044ef`，48459 条）：

| MCP 字段 | 飞书显示名 | 取值方式 | 用途 |
|---------|----------|---------|------|
| `name` | 单据编号 | string_value | 订单号 |
| `start_time` | 创建时间 | string_value | 日期 |
| `field_96a245` | 物流单号 | string_value | 顺丰查询 key |
| `field_e1001d` | 销售订单关联客户 | key_label_value.label | 客户名 |
| `field_51d592` | 价税合计 | **double_value** | 金额 |
| `work_item_id` | 工作项ID | long_value | 飞书链接 |

**MQL 查询**：`SELECT name, start_time, field_96a245, field_e1001d, field_51d592, work_item_id FROM 销售管理.销售订单 WHERE start_time >= "YYYY-MM-DD"`

### 管道流程

```
pull_orders.py (--days 90)
  → mcp_orders_input.json
  → shipment_pipeline.py (顺丰 API 查轨迹)
  → shipment_status.json (→ 订单管理 Tab)
```

**已知现象**：
- 旧单号（几个月前）顺丰返回"暂无数据"（正常，顺丰不保留历史轨迹）
- 今天创建的订单物流单号为空（正常，未发货）
- 重复单号自动去重（同一订单多个工作项行）

### 集成到定时刷新

当前顺丰 cron（9:20/15:20）运行 `sf_delivery_watchdog.py`，但未前置拉取订单。长期应把 `pull_orders.py` 作为 `cb_sync.sh` 的前置步骤，或在 `sf_delivery_watchdog.py` 中集成。

Hermes cron 的 `script` 字段**必须是 `~/.hermes/scripts/` 下的相对文件名**，不接受绝对路径或家目录相对路径。

**错误配置**（导致 Silent failure，`Script not found`）：
```
script: /Users/liuxinyuan/Desktop/Hermes输出-工作类/脚本/cb_sync.sh  ❌
script: 脚本/cb_sync.sh                                                 ❌
```

**修复**：在 `~/.hermes/scripts/` 创建**包装脚本**用 `exec` 转发到真实位置：
```bash
#!/bin/bash
# ~/.hermes/scripts/cb_sync.sh
exec bash "/Users/liuxinyuan/Desktop/Hermes输出-工作类/脚本/cb_sync.sh"
```
然后 cron 配置 `script: cb_sync.sh`。

**检测**：`hermes cron list` → 查看上次执行状态是否为 `Script not found`。即使脚本在别处存在且可执行，cron 也只会搜 `~/.hermes/scripts/`。


## 85. KPI 税合计数据源全局修复（2026-08-01）

**核心原则**：shipment_status.json 中 75% 订单 taxTotal=0（MCP pipeline pitfall #40），**所有税合计计算必须改用 KPI 数据源**。

### 三个修复点

| 函数 | 之前 | 之后 |
|------|------|------|
| `renderDashboard` fullYearTax | `orders.reduce()` | `dash.total_h1 + prog.total` |
| `getShipmentOrders` fullYearTax | `shipments.full_year_tax \|\| 0` (始终0) | `dash.total_h1 + prog.total` |
| `renderOrders` 本月/本季/本年 | `ps.taxTotal` (orders汇总) | KPI覆盖（本月=prog.total, 本年=fullTax, 本季=prog.total） |

### 验证命令

```bash
curl -s "<url>/数据/kpi_dashboard.json" | python3 -c "import json,sys;d=json.load(sys.stdin);print(f'H1: {d[\"total_h1\"]/1e4:.1f}万')"
curl -s "<url>/数据/kpi_progress.json" | python3 -c "import json,sys;d=json.load(sys.stdin);print(f'7月: {d[\"total\"]/1e4:.1f}万')"
```

详见 `references/kpi-tax-fix-all-modules.md`。


## 91. app.html 缺失 `var TODO_PREFIX` → 待办 Tab 数据不更新（2026-08-23）

**症状**：用户报"工作台待办 Tab 还没更新"（数据已写入但前端看不到）。切待办 Tab 控制台报 `ReferenceError: TODO_PREFIX is not defined`（getTodoKey 内）。

**根因**：app.html 是双线分拆重构版，从 index.html 拆分时**遗漏了顶层变量 `var TODO_PREFIX = 'bior_sw_todos_v2_'`**（index.html L542 有，app.html 无），但 `getTodoKey()`、`renderClosedFeed()` 等 3 处都在用它。这是 #89（debounce 缺失）同类的「分拆遗漏顶层声明」bug，只是这次是**变量**不是函数。

**主动排查方法（通用，比逐个猜高效）**：对比两个文件的顶层 `var X =` / `function X()` 定义差集，再对每个「app 缺失」的 X 检查是否被使用：

```python
def extract_defs(path):
    js = '\n'.join(re.findall(r'<script[^>]*>(.*?)</script>', open(path, encoding='utf-8').read(), re.DOTALL))
    return (set(re.findall(r'\bvar\s+(\w+)\s*=', js)),
            set(re.findall(r'function\s+(\w+)\s*\(', js)))
app_v, app_f = extract_defs('工具/app.html')
idx_v, idx_f = extract_defs('工具/index.html')
missing = (idx_v - app_v) | (idx_f - app_f)
# 对每个 missing 的 X，re.search(r'\bX\b', app_js) 判断是否被使用；被使用才可能是真 bug
```

**⚠️ 误报过滤（关键）**：差集会混入大量误报，必须逐个看「首处上下文」`js[max(0,u-40):u+40]` 才能区分：
- CSS 属性：`font-size` 里的 `size`、`node-time` 里的 `time`
- HTML 标签：`<table>`、`<svg>`
- 对象方法/属性：`cbApp.auth()` 里的 `auth`、`serverStore.users` 里的 `users`
- 声明方式差异：index 用 `var today = function(){}`，app 用 `function today(){}`（`today` 不是缺失）

判断真缺失的铁律：**既无 `var X =` 定义、又无 `function X(` 定义、且作为独立标识符使用**——本例经此过滤后唯一真缺失就是 `TODO_PREFIX`。

**修复**：app.html 的 `var ROLE_KEY = 'bior_test_user_v1';` 后补 `var TODO_PREFIX = 'bior_sw_todos_v2_';`（值必须与 index.html 一致，否则 localStorage 待办 key 跨文件不共享）。

**附：待办数据双写机制（排查「写了数据前端看不到」的必读）**：
- 后端 `数据/todos.json`（`POST /api/todos`）+ CloudBase `todos` 集合（前端 `cbDb.collection('todos')`）是**两套独立数据**，`cb_sync.sh` 只同步静态 JSON 文件、不碰 CloudBase 集合。
- 前端 `pullTodos()` 里 `if (cbDb) return cbDb.collection('todos')...` —— **CloudBase 可用时直接 return，根本不读后端 todos.json**（`tryPull` 兜底不执行）。
- 所以「只写后端 todos.json」在 CloudBase 可用时前端看不到；必须同时写 CloudBase `todos` 集合：`{user: 用户名, todos: [...], updated: ISO}`（通过浏览器 `cbDb.collection('todos').where({user}).get()` 判存在后 `add`/`doc(id).update`）。


## 97. 飞书 MCP 写操作被禁用（MCPPersonalPreferenceDisabled）（2026-08-30）

**症状**：用 MCP `update_field`（改客户负责人等写操作）返回 `MCPPersonalPreferenceDisabled, message=user has not enabled this MCP feature. Please configure it in your personal preferences`。curl 直连 tools/call 也返回非 JSON（JSON 解析失败）。

**根因**：飞书项目 MCP token（`m-abfb29e8-...`）**只有查询权限**（search_by_mql/get_workitem_brief 等），写操作（update_field/create_workitem/transition_state）被禁用，需用户在飞书项目后台「个人偏好/MCP 设置」启用。

**判别**：查询类 MCP 调用成功、写类全失败 → 就是权限未启用，不是代码/参数问题。

**处理**：告知用户在飞书项目后台启用 MCP 写权限后再批量执行；或用户手动在飞书界面改；或先做「数据层修正 + 过渡映射」等权限开了再改源头。不要反复重试写调用（同一 token 永远被拒）。


## 99. Q3 当季进展卡片三处计算 bug + 目标数据污染检测（2026-08-31）

用户报「刘子研客户的 Q3 当季进展(7-9月)数据不对」。根因不在数据源（`kpi_dashboard.json` 的 `h2_actual_months` 是对的），在 `getKPI()` 非 admin 分支的 Q3 计算有三处 bug，`app.html` 与 `index.html` **双文件同步修**：

**bug1 个人 Q3 实际漏月（核心）**：`h2Actuals2 = [0,0,0,0,0,0]` 只填当月 `h2Actuals2[nowMonth-7]=current`，没从 `dashPerson.h2_actual_months` 取历史月。8 月底看 Q3，7 月实际整月被漏（赵云浩 Q3 实际只显示 8 月 55.8万，漏 7 月 46.6万）。修复：
```javascript
var h2Actuals2 = (dashPerson && dashPerson.h2_actual_months) ? dashPerson.h2_actual_months.slice() : [0,0,0,0,0,0];
if (nowMonth >= 7 && current) h2Actuals2[nowMonth - 7] = current;
```
> 注意 #90 修的是 admin 分支缺 `current` 注入，本条是非 admin 分支根本没取历史月——两者是不同 bug。

**bug2 入职拆分没应用到 Q3 目标**：split 员工（#95 JOIN_SPLIT）的 Q3 目标仍算 7+8+9 月（把入职前的 7 月也算进去），与年度卡片口径不一致。修复：Q3 从入职月算起，`split` 声明须提前到 `q3StartIdx` 之前：
```javascript
var split = JOIN_SPLIT[name] || null;
var q3StartIdx = split ? Math.max(0, (split.join_month || 8) - 7) : 0;
var q3Target2 = (h2Targets || []).slice(q3StartIdx, 3).reduce(function(s,v){return s+(v||0);},0);
var q3Actual2 = h2Actuals2.slice(q3StartIdx, 3).reduce(function(s,v){return s+(v||0);},0);
```
卡片标题同步动态化：`'📊 Q3 当季进展' + (kpi.split ? ' (入职后)' : ' (7-9月)')`。

**bug3 部门拆分条 Q3 实际重复计算**：三部门 `q3A = dp.h2_actual（累计）+ 各人 h2_actual_months[1]（8月）`，把 8 月重复加一次（大客户部 159.8万 被虚增到 230.8万）。修复：`var q3A = ((dp.h2_actual_months||[])[0]||0) + ((dp.h2_actual_months||[])[1]||0) + ((dp.h2_actual_months||[])[2]||0);`

### 目标数据污染检测（h2_weights 权重规律）

`h2_months`（H2 各月目标）按固定权重分配：`基数 × [0.12, 0.15, 0.19, 0.2, 0.19, 0.15]`（`h2_weights`，kpi_dashboard.json 顶层）。**用 `h2_months[1]/0.15` 反推基数，再 `×0.12` 得期望 7 月目标，与实际 `h2_months[0]` 对比**：偏差≈0 = 正常，偏差巨大 = 目标字段被污染。

本次发现：赵云浩/刘子研的 `h2_months[0]` 恰好等于 `my_kpis.json` 的 7 月实际（266787.4 / 222749.8，**精确到分**）——这是「目标字段被实际值污染」的铁证（正常员工如路要峰/刘虹晶/林潇偏差都是 0）。污染导致赵云浩 Q3 目标被低估约 101.6万、大客户部 Q3 目标低估约 174.6万（达成率虚高）。

验证脚本（改完必跑，Python 模拟 JS 计算逻辑）：
```python
import json
kd = json.load(open('数据/kpi_dashboard.json', encoding='utf-8'))
for p in kd['people']:
    hm = p.get('h2_months', [0]*6)
    if hm[1]:
        base = hm[1]/0.15
        print(p['name'], '7月目标', hm[0], '期望', round(base*0.12,1), '偏差', round(hm[0]-base*0.12,1))
```

### 修复值精度：反推基数 T 有 ±1 歧义（必须精确搜索 T，不能只用 hm[1]/0.15）

`base = hm[1]/0.15` 只是**估计**——`hm[1]` 本身是 `round(T×0.15)` 的结果，反推不精确。用 `round(base×0.12)` 算修复值会偏差 ±1（本次实测：刘子研 naive 算得 952082，精确搜索得 **952081**，T=7934010）。检测污染时偏差都是几十万，±1 无关紧要；但**修复（写回正确目标值）时必须搜索整数 T 使全 6 月都满足 `round(T×weight[i])==int(hm[i])`**，再取 `round(T×0.12)`：

```python
weights = [0.12, 0.15, 0.19, 0.20, 0.19, 0.15]
def exact_T(hm):
    approx = int(hm[1] / 0.15)
    for T in range(approx - 100, approx + 100):
        if all(round(T * weights[i]) == int(hm[i]) for i in range(6)):
            return T
    return None
T = exact_T(hm)          # 本次：赵云浩 10686193 / 刘子研 7934010 / 大客户部 18634153
correct_july = round(T * 0.12)
```

修复后写回 `kpi_dashboard.json` 前先备份（`数据/_backup/kpi_dashboard_before_*.json`），并 `tcb hosting deploy 数据/kpi_dashboard.json` 后 `curl -H "Cache-Control: no-cache"` 读回线上确认。h2_months 只影响目标，`h2_actual`/`h2_remain` 不依赖它、无需同步改。

### node --check 验证内联 JS（修正 #25）

#25 说 `node --check file.html` 报 `ERR_UNKNOWN_FILE_EXTENSION` 改用括号平衡。更准做法是**提取 `<script>` 内联块到临时 .js 再 node --check**（抓真语法错误，不止括号）：
```python
import re, subprocess, tempfile, os
html = open('工具/app.html', encoding='utf-8').read()
for i, s in enumerate(re.findall(r'<script(?![^>]*src)[^>]*>(.*?)</script>', html, re.DOTALL)):
    if not s.strip(): continue
    tmp = tempfile.NamedTemporaryFile('w', suffix='.js', delete=False, encoding='utf-8')
    tmp.write(s); tmp.close()
    r = subprocess.run(['node', '--check', tmp.name], capture_output=True, text=True)
    print(i, 'OK' if r.returncode==0 else r.stderr[:200])
    os.unlink(tmp.name)
```


## 100. 月初当月进展误显上月数据——prog.month 必须校验当前月（2026-09-01）

**症状**：9月1日看板「📅 9月 当月进展」显示 8 月的 292.5万、达成率 32.2%（292.5/909.3），用户报「这个数字有问题」。

**根因**：`getKPI()` 里 `var current = prog.total`（`kpi_progress.json` 的 total，即当月 MCP 实时累计）**没校验 `prog.month` 是否等于当前月**。月初时 `kpi_progress.json` 还停留在上月（`month:"2026-08"`、`total:2925169.28`），被直接当成 9 月进展。9月目标 909.3万（`h2_monthly_targets.json`）是对的，错的是分子。

**修复**（app.html 与 index.html 双文件，共 4 处，全部加「prog.month 匹配当前月才采用」校验）：
1. admin 分支 `current`
2. 非 admin 分支 `current`（个人当月进展）
3. `filteredCurrent`（筛选视图当月进展）
4. `getShipmentOrders` 的 `fullYearTax`（物流「全年税合计」）

统一模式（把 `prog.month` 解析成月份数字与当前月比对）：
```javascript
var _nowM = new Date().getMonth() + 1;
var _progM = 0;
if (typeof prog.month === 'string') { var _pm = prog.month.match(/-(\d{2})$/); if (_pm) _progM = parseInt(_pm[1], 10); }
var current = (_progM === _nowM) ? (prog.total || 0) : 0;
```

**铁律**：任何取 `prog.total`/`prog.by_salesperson` 当「当月进展」的地方，必须校验 `prog.month == 当前月`。否则月初（数据还没刷新到新月）必然误显上月数据。`prog.month` 格式 `"2026-08"`，用正则 `/-(\d{2})$/` 提取月份。

**数据层真相**：`kpi_progress.json` 停在旧月是「正常」的——refresh 脚本的「当月」取 ERP 数据里最大的月份，新月没订单就还停在上月。所以**前端必须靠月份校验兜底**，不能指望数据源自动归零。

## 114. 浏览器里 CDN 页面不对，先用 curl 复核再下结论

同一个 CDN 链接，浏览器可能拿到 CloudBase 的**中间页/“风险提醒”页**（整页只有一条“我是开发者，如何去掉当前页面？”的链接），而 `curl` 拿到的是真实内容。因此：
- 判断“部署有没有生效”一律以 `curl -H 'Cache-Control: no-cache'` 拿到的内容为准（查特征串 + 本地/线上逐字节比对），**不要凭浏览器看到的页面判定部署失败**。
- 浏览器看到中间页时，那是托管侧的中间页设置，与页面本身无关：记下现象告知用户（可在 CloudBase 控制台关掉中间页），**不要去改页面代码**。
- 反向同理：链接点击类问题要在**本地同文件**上复现和验证（功能逻辑一致），不要被托管侧差异带偏。


## 52. 缓存破坏代码插进 `function loadData()` 定义导致语法错误

**症状**：登录页下拉框展开后完全空白（无用户列表），JS 全部崩溃。

**根因**：为绕过 CDN 缓存，用 Python 执行 `c.replace("loadData()", "loadData(); console.log('v=...')")`，但 `loadData()` 同时出现在函数定义 `function loadData() {` 中，导致：
```javascript
function loadData(); console.log('v=1785142324') {  // ← 语法错误！
```

**检测**：`node --check` 会报告 `SyntaxError: Unexpected token ';'`。`grep -n "loadData();" 工具/index.html` 检查是否有残留。

**铁律**：CDN 缓存用 URL 参数 `?v=` 破坏，**不要**改代码来破坏缓存。


## 17. Tunnel URL 更新：必须更新 3 处以上

**2026-07-26 起已切 Tailscale Funnel 固定域名，本节仅在未来迁移域名时适用**（如买自有域名后切 Cloudflare Named Tunnel）。

Tunnel 重建后 URL 变化，HTML 中需要同步更新至少 3 处（不是 2 处）：
- `var AUTH_URLS[0]` — 飞书免登回调（`feishuAutoLogin` 函数内）
- `var apiUrls[0]` — 登录认证（`doLogin` 函数内）
- `var API_BASE_URLS[0]` — MCP 数据拉取

`replace_all=true` 可安全用于 Tunnel URL（域名唯一），但改完仍需 `grep -c <old-url>` 确认全部清零。


## 20. CDN 缓存验证 vs Origin 文件

CloudBase CDN 缓存可能阻挡部署验证。区分方法：

```bash
# CDN 边缘节点（可能缓存旧版）
curl -s "https://<cloudbase-url>/index.html" | grep -c '<new-feature>'

# Origin 实际文件
tcb hosting download index.html /tmp/verify.html -e <env-id>
grep -c '<new-feature>' /tmp/verify.html
```

两者不一致 = CDN 未刷新（等 5-10 分钟）。部署后立即用 `tcb hosting download` 验证 Origin。

### CDN 验证


## 29. 审计无本地源的 CloudBase 页面：先下载建源再改（2026-07-26）

`/工具/index.html` 曾只存在于云端、本地无源文件。标准流程：
```bash
curl -s "<线上URL>" -o /tmp/page.html                                     # 1. 下载
cp /tmp/page.html ~/Desktop/Hermes输出-工作类/工具/index.html              # 2. 建立本地源（唯一真相）
cp index.html index_backup_$(date +%Y%m%d).html                           # 3. 备份
# 4. patch 修复 → 括号平衡验证（第25条清单）
tcb hosting deploy index.html /工具/index.html -e bier-sales-d0gatbvlx288724e9  # 5. 显式云路径部署
# 6. CDN 验证：curl -H "Cache-Control: no-cache" <线上URL> | grep -c '<新代码标记>'
# 7. 实测验证：browser_navigate → 真实登录 → 逐 Tab switchTab 并收集 window.onerror
```
逐 Tab 验证 snippet：
```javascript
window._errs=[]; window.onerror=function(m){window._errs.push(String(m));};
['customers','orders','todos'].forEach(function(t){try{switchTab(t);}catch(e){window._errs.push(t+':'+e.message);}});
```


## 89. app.html 缺失 helper 函数 → script 块中断 + 功能崩溃（2026-08-23 血案）

**症状**：AI 助手「真实 AI 回答」链路跑不通——`esc`/`CHAT_APIS` 全部 undefined，点发送报 `esc is not a function`；客户/客诉 Tab 筛选下拉 `renderMultiSelect is not defined`；KPI 环形图 `clamp is not defined`。此前被误判为「Funnel 网络偶发卡住」，实际是代码 bug。

**根因**：app.html 是「双线分拆」重构版，从 index.html 拆分时**漏了 4 个 helper 函数**，但调用点全保留：
1. `debounce`（顶层 `var debouncedSearch = debounce(...)` / `var _debouncedOrderRender = debounce(...)`）——**缺失导致 script 块在首个调用处直接中断**，其后所有 `var` 赋值（含 esc/CHAT_APIS/CHAT_TIMEOUT_MS）全部不执行。这是 AI 回答链路跑不通的真正根因。
2. `clamp`（KPI donut 环形图调用）
3. `showToast`（15 处：AI 问答保存/归档/转待办 + 拜访卡 + 待办）
4. `multiSelect`/`renderMultiSelect`/`toggleMs`/`toggleMsOpt`/`toggleMsAll`（客户/客诉筛选下拉）+ `.ms-*` CSS

**检测**：双文件对比缺失函数——index.html 有但 app.html 缺，且在 app.html 中被调用：
```python
# 提取两个文件 function X() + var X = function 集合做差集
# 对 app 缺失的每个 name，正则查 app_js 里是否有 name( 调用（无定义 → 真缺失）
```

**诊断手法**：`'name' in window`（true 但 `typeof name` 是 undefined）→ 变量「声明了但没赋值」，即 script 中段抛错中断；`typeof name` 完全 undefined 且函数声明也消失 → 语法错误。两者区分开能秒定位。

**修复**：从 index.html 复制缺失函数 + CSS 到 app.html（debounce/clamp/showToast/multiSelect 组件 + `.ms-*`/`.upload-toast`）。

**铁律**：app.html 与 index.html 是两份独立代码，改一个必须 diff 检查另一个是否缺同名函数。任何「某函数 undefined 但同块其它 function 正常」的症状，先怀疑顶层 `var X = undefined_func()` 在 script 中段中断（var 提升让变量存在但未赋值）。


## 92. Desktop 目录文件读取卡死（iCloud 优化存储）→ 用 tcb hosting download 绕过（2026-08-30）

**症状**：`ls ~/Desktop/` 30-180s 超时；`cat`/`head`/`grep`/`wc -l` 读 Desktop 下文件（如 `工具/app.html`）全部卡死；`read_file` 返回 "File not found"；但 `ls ~/`、`ls -ld ~/Desktop`、`stat <文件>` 都秒回。`grep -r` 递归 `数据/` 目录也卡死。

**根因**：Desktop 开启 iCloud「优化 Mac 存储」后，文件被驱逐到云端（本地仅占位符），**读内容**触发按需下载卡住；`stat` 只读元数据所以快。

**判别**：`stat -f "%N size=%z blocks=%b" <文件>` 秒回、但 `head -c 100 <文件>` 卡 → 就是这个问题。

**绕过（首选，线上是权威版本）**：用 `tcb hosting download` 从 CloudBase 拉线上副本到 /tmp，再用 read_file/grep 分析，完全绕过本地 Desktop：
```bash
tcb hosting download workbench/index.html /tmp/wb.html -e bier-sales-d0gatbvlx288724e9
tcb hosting download "工具/app.html" /tmp/app.html -e bier-sales-d0gatbvlx288724e9
```

**新源文件写入位置**：Desktop 读卡顿，但新建 HTML 源文件写 `/Users/liuxinyuan/work/`（非 iCloud、非 TCC 保护）再部署，避免踩同一坑。

**勿用**：`ls ~/Desktop/`、`grep -r` 递归 Desktop/数据 目录（会卡死）；`search_files` 精确到 Desktop 下单个文件也会超时。


## 94. app.html 双线分拆遗漏整套布局 CSS（2026-08-30）

**症状**：客户 tab 搜索框位置错乱（跑到侧边栏下方/页面顶部附近），侧边栏/底部导航样式异常。用户报「搜索框位置不对」。

**根因**：app.html 从 index.html 做「双线分拆」时，遗漏了整套布局 CSS——`.app-sidebar`、`.app-bottom-nav`、`.nav-item`、`.nav-divider`、`.bottom-nav`、`#custToolbar`、`.search-box` 全都没有样式定义（只有 HTML `<aside>`/`<nav>` 结构 + JS 填充，无 CSS）。这是 #89（漏函数）、#91（漏变量）的同类 bug，这次是**漏 CSS 选择器**。缺 CSS 时 aside/nav 退化成块级元素占文档流，把 custToolbar/main 挤下去。

**判别**（一行命令）：`grep -c "\.app-sidebar{" 工具/app.html` 返回 0，但 `grep -c "app-sidebar" 返回 >0`（有 HTML 引用、无 CSS 定义）。

**修复**：从 index.html 复制整套布局 CSS 到 app.html，适配 app.html 的 header 高度（app.html 硬编码 54px，index.html 用 `var(--header-h)` 58px）：
1. `:root` 补 `--header-h: 54px; --sidebar-w: 230px;`
2. 补 `.app-sidebar`（`position:fixed;top:var(--header-h);width:var(--sidebar-w)`）、`.app-bottom-nav`、`.nav-item`、`.nav-divider`、`.bottom-nav`、`#custToolbar`、`.search-box` 全套
3. `.app-main` 从 `max-width:1100px;margin-left:auto;margin-right:auto` 改为 `margin-left:var(--sidebar-w)`
4. `@media(max-width:640px)` 补移动端：`.app-sidebar{display:none}` + `.app-bottom-nav{display:block}` + `.app-main{margin-left:0;padding-bottom:90px}`

**铁律**：app.html 与 index.html 是两份独立代码，拆分/改动后除了 diff 函数（#89）和变量（#91），**还要 diff CSS 选择器**——`<selector>{` 在 index.html 有但 app.html 无，且 app.html 的 HTML/JS 用到了该 class，就是漏 CSS。

**⚠️ 多步 Python 替换的坑（本次踩到）**：同一脚本里先 `replace(anchor, newA)` 再 `replace(anchor, newB)`，第二次的 `anchor` 已被第一次换掉 → `str.replace` 静默不生效（无异常），且后续 `assert html.count(old)==1` 会因中间态文件已被前一步改写而误报。**每次 replace 前先 re-read 文件确认当前真实内容，或一个锚点只 replace 一次**；改完必须 `grep -c` 逐个确认每个 CSS 选择器真的落盘（本次曾因顺序错漏，layout CSS 未写入，靠计数 0 才发现）。

## 103. 飞书文件夹消息网关不处理（2026-09-05）

**症状**：用户说"发完了"，但 agent 本地（`~/.hermes/cache/documents/`、Downloads、飞书容器）都找不到新文件。

**根因**：用户发的是飞书「文件夹」消息（`type=folder`），Hermes 网关只处理 file/document 类型，folder 消息收到后不下载、不展开、直接丢弃（日志只有 `Received raw message type=folder`，无后续 Inbound/media 记录）。

**诊断**：`grep 'Received raw message type=' ~/.hermes/logs/gateway.log | tail -20`，看用户发文件那条是 file/document 还是 folder。

**解决**：让用户**逐个发文件**（file/document 类型），或**打包 zip**（zip 是 file 类型能收）。收到 zip 用 `ditto -x -k` 解压（`unzip` 会 GBK 乱码中文文件名）。


## 53. Feishu open_id 按 App 独立——跨 App 推送必须重新获取（2026-07-27）

**症状**：用 `cli_aaee6a2f90b89bc1`（工作台 App）推送消息，收件人 `receive_id_type=open_id` + 旧 App 的 open_id → HTTP 400。

**根因**：飞书的 `open_id` 是 **per-app** 的。`cli_aaee6a2f90b89bc1` 和 `cli_aac5af01fd625bdd` 对同一用户产生不同 open_id。`batch_get_id` API 也只对已通过 OAuth 登录该 App 的用户返回 open_id——用户必须先在该 App 的上下文（飞书工作台入口）登录一次。

**正确流程**：
1. 用户从飞书工作台入口打开应用（触发 OAuth → 创建 open_id）
2. 用该 App 的 credential 调用 `batch_get_id` 获取 open_id
3. `receive_id_type=open_id` + 该 open_id 发送消息

**App 权限清单**（给全员推送必须全部开通）：机器能力 + `im:message` + `contact:user.id:readonly` + 发布

**Workaround**：`receive_id_type=email` 对非应用创建者不可用（HTTP 400）。只能走 open_id → OAuth 路径。


## 71. 客诉列表按创建时间倒序（2026-07-28）

`renderComplaints()` 中数据加载后加排序：
```javascript
items.sort(function(a, b) { return (b.created || '').localeCompare(a.created || ''); });
```


## 15. MQL 数组解析与 workitem_related_multi_select 限制

`moql_field_list` 的值有三种格式：
- **字符串**：直接赋值
- **`{key_label_value: {key, label}}`**：取 label
- **`[{key_label_value: {...}}, ...]`**：数组，循环展开

`mcp_pull_all` 已处理数组展开，但 **`workitem_related_multi_select` 字段在 MQL 中返回空**（即使有数据）。`field_79aa0b`（客户对应商机）和 `field_ac5caf`（客诉客户）都是这种类型。`feishuCustomerLink` 已内建 fallback：无 opp_ids 时走客户详情链接。


## 16. MQL_MAP 双份维护陷阱

`销售工作台服务.py` 有**两处** `MQL_MAP`（GET handler 和 POST refresh handler），修改时必须两处同时更新。今天漏改第二处导致 refresh 端点一直用旧的 issue 表查询客诉（0 结果）。


## 33. 飞书卡片消息推送 API（2026-07-27）

### 获取 access_token
App: `cli_aac5af01fd625bdd`, Secret: `FxAZsdQWVRuPOIK8byWDccwNTan7AadN`
POST `/open-apis/auth/v3/tenant_access_token/internal`

### 发送卡片（带跳转按钮）
`POST /open-apis/im/v1/messages?receive_id_type=open_id`（推荐 open_id，email 偶发 400）
卡片含 button 元素：`url = WORKBENCH_URL + '#orders'`（跳转订单管理）
已知 open_id：刘新元=`ou_fd1028935f5d87fac78bd6356cbe395e`

### receive_id_type 选择

| 方式 | 稳定性 | 备注 |
|------|:--:|------|
| open_id | ✅ | 可通过已知列表硬编码兜底 |
| email | ⚠️ | 有时 400，需收件人在 App 可用范围内 |
| user_id | ❌ | MCP user_key ≠ API user_id |

### 按销售员推送

详见 `references/feishu-per-creator-push.md`。核心：工作台 App `cli_aaee6a2f90b89bc1` 需开通机器人能力 + `im:message` 权限 + 发布后才能推给全员。watchdog 已实现按创建者分组推送，待权限就绪切换 App ID 即可。

### 飞书桌面端 URL 行为（2026-07-27 实测）

**卡片 `url` 按钮**：桌面端飞书**总是打开外部浏览器**，不能内嵌。

**卡片内联 markdown 链接**（`[📋 查看](url)` 在 `lark_md` 块中）：桌面端同样打开外部浏览器。

**`applink.feishu.cn`**：`https://applink.feishu.cn/client/app_link/open?appId=...&url=...` — 测试无效，飞书只做重定向到外部浏览器。

**结论**：桌面端飞书无法内嵌打开网页。这是平台限制。移动端默认内嵌。

### sessionStorage 跨登录持久化 URL 参数（2026-07-27 终版，已验证可行）

**问题**：飞书卡片点击 → 打开工作台 URL `?tracking=SFxxx` → 用户登录 → URL 参数在登录流程中可能丢失（OAuth 重定向/WebView 截断/飞书吞 hash）。

**失败方案清单**（均不可靠）：
- `window.location.search` 实时读取——OAuth 重定向后 search 变更
- `window.location.hash`——桌面端飞书 WebView 截断 `#` 及之后内容
- `_pendingTracking` 在脚本加载时捕获——时序不够可靠
- `#orders:SFxxx` 格式——飞书 WebView 直接吞掉 `:` 后面
- `finishLogin` 中 `setTimeout` 读 `window.location.search`——OAuth 重定向后 search 已变

**终版方案（sessionStorage — 2026-07-27 已验证）**：

```javascript
// 1. 页面加载瞬间存入 sessionStorage（在 <script> 最开头）
(function(){
  var s = window.location.search;
  var m = s.match(/[?&]tracking=([^&]+)/);
  if (m) sessionStorage.setItem('bior_tracking', decodeURIComponent(m[1]));
})();

// 2. 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);  // 等 loadData 完成
}
```

**为什么 sessionStorage 可靠**：存于浏览器同 tab 生命周期，不受 URL 变更影响。`setItem` 在页面第一行 JS 执行，早于任何重定向/登录逻辑。

**注意**：`finishLogin` 中的 `setTimeout` 延迟需要足够长（2s+）以确保 `loadData` 的 `Promise.all` 完成且 `render()` 已执行。太短会因 DOM 未建立而失败。

### 54. H2月度目标图表用全公司数据——改为传 kpi（2026-07-27）

**症状**：赵云浩看板"📊 H2月度目标 vs 实际"图表显示全公司数据（目标几千万），而非个人数据。

**根因**：`drawH2CompareChart()` 直接读 `state.data.h2Targets` 和 `state.data.kpiProgress`（全局 JSON），而非已按销售员计算的 `kpi.h2Targets`/`kpi.h2Actuals`。

**修复**：
```javascript
// 改函数签名，传入已计算好的个人 kpi
function drawH2CompareChart(kpi) {
  var targets = (kpi.h2Targets || []).slice(0, 6);
  var actuals = (kpi.h2Actuals || [0,0,0,0,0,0]).slice(0, 6);
  // ...
}
// 调用处
drawH2CompareChart(kpi);
```

### 58. 访前准备拜访卡生成——跨数据源聚合（2026-07-27）

**功能**：访前准备 Tab 中提供客户搜索 + 卡片类型选择 + 一键生成 + 存档管理。

**数据源交叉映射**：
- `state.data.mcpCustomers.customers[]` → 客户名/联系人/operators/dept/level
- `state.data.customers`（ERP）→ 2026 销售额 / 累计采购
- `state.data.shipments.orders[]` → 订单数 / 价税合计 / 最近订单日期
- `state.data.complaints.items[]` → 客诉数（按客户名匹配）

**权限分层函数 `getVisibleCustomers()`**：
```javascript
function getVisibleCustomers() {
  if (role === 'admin') return mcpAll;           // 刘新元：全部
  if (role === 'manager') return mcpAll.filter(  // 刘欣：拓展部
    c => c.dept === (dept==='销售拓展部'?'诊断原料销售拓展部':dept));
  if (dept === '浙江' || name === '吴云')        // 吴云：浙江
    return mcpAll.filter(c => c.dept === '浙江');
  if (dept === '广东' || name === '韩远怀')      // 韩远怀：广东
    return mcpAll.filter(c => c.dept === '广东');
  return mcpAll.filter(c =>                      // 普通销售：本人
    c.owner === name || (c.operators||[]).indexOf(name) >= 0);
}
```

### 后端 API: `/api/visit-card`（2026-07-27）

**端点**：POST `/api/visit-card`（在 `销售工作台服务.py` 中）
**请求**：`{"customer": "圣湘生物"}`
**响应**：`{"ok": true, "customer": "圣湘生物", "note": "...", "generated_at": "..."}`

**架构**：API 为调研数据预留接口。已集成到前端 `vpDoResearch()` 中，通过 `VISIT_CARD_API = Tailscale Funnel URL` 调用。前端首先生成静态卡片（MCP/ERP数据），然后异步调 API 获取 IMA 知识库 + web 搜索补充数据。

**IMA 限制**：`search_knowledge` 仅搜索标题，不能搜索内容。详见 `references/ima-api-limitations.md`。

**关键文件**：
- `工具/index.html` → `renderVisitPrep()` + `vpGenerate()` + `vpDoResearch()`
- `脚本/销售工作台服务.py` → `do_POST` 中 `path == '/api/visit-card'` 分支

**服务重启后验证**：
```bash
curl -s --max-time 5 http://127.0.0.1:8000/api/visit-card \
  -X POST -H "Content-Type: application/json" -d '{"customer":"圣湘"}'
# 返回 {"ok": true, ...} 即正常
```

### 存档区权限隔离（2026-07-27 终版）

已生成的拜访卡在存档区必须按销售员隔离：从文件名提取客户名 → 与 `getVisibleCustomers()` 匹配。详见 `references/manifest-and-funnel-pitfalls.md` 第 4 节。

### 请求队列双写机制

访前准备"生成拜访卡" → `localStorage`（即时显示）+ API（Funnel → 本地文件队列）。Hermes Agent 轮询文件队列处理。Funnel 不通时请求仅留 localStorage，Agent 看不到。详见 `references/manifest-and-funnel-pitfalls.md` 第 6 节。


## 31. 飞书 OAuth 回调 URL 不能分享给 Agent（2026-07-27）

飞书免登 URL 携带 `?code=...&state=login` 的 `code` 是**一次性 OAuth 授权码**。Agent 用任何工具（browser/web_extract/curl）访问都会**消耗这个 code**，导致用户端登录失败。

**安全规则**：
- 用户发送带 `code=` 的 URL → 立即告知不可访问，建议用户先完成登录
- 用户需替换为不带 code 的干净 URL（如 `https://.../workbench/index.html`）再让 Agent 检查
- `state=login` 也是登录流标识，同样不应重放

## 109. Tab 分组 + 移动端底栏精简 + AI 页移动端布局（2026-09-19）

用户反馈「Tab 太多」「手机端 AI 页布局不合理」。TABS 每项加 `group`（biz/cust/mine/ai）→ 侧栏按组渲染；移动端底栏只留 4 高频（看板/客户/待办/AI）+「更多」底部抽屉；AI 页移动端把四步闭环+快捷任务收进 `⚙` 折叠区。

**四个坑（实测踩过）**：
1. **移动端 CSS 必须追加在样式表末尾** —— 插进已有 `@media` 块会被**后面出现的基础规则**覆盖（同特异性、后者胜），导致 ⚙ 按钮 `display:none` 失效。
2. **两个断点都要改** —— index.html 用 768px、app.html 用 640px；窄屏下 640 块会覆盖 768 块的同名规则，只改一个无效。
3. **AI 页高度不能只按 `--header-h` 算** —— 实测底栏 64px、顶栏 52px，用 `calc(100dvh - var(--header-h) - 84px)`；验证要断言 `chatInput.bottom <= bottomNav.top`。
4. **按钮图标化时 emoji 要留在 span 外面**：`📋<span class="hbtn-t"> 复制对话</span>`；emoji 若一起包进 span，隐藏后按钮变**空框**。替换锚点要带 `style=`/`title=` 等唯一上下文，否则会命中**注释或弹窗标题**（踩过）。

补丁脚本 `脚本/patch_workbench_tabs_mobile.py`（幂等，含 `TABGROUP_V1` 标记）。


## 107. 客户tab搜索框被侧栏遮挡（2026-09-11）

`#custToolbar` 缺 `margin-left: var(--sidebar-w)`，左 230px 压在 fixed 侧栏下，搜索框左缘点不到。修：补 margin-left + **每个断点显式 `margin-left:0`**（app.html 的 @640 不重置 --sidebar-w，只靠变量会留空档）。**详见 `references/customer-toolbar-occlusion.md`**。


## 106. 搜索框打断中文输入法（2026-09-11）

输入中文到一半整页刷新、候选词消失、焦点丢失：`oninput` 防抖调整页 `render()`，`innerHTML` 重建销毁了 `<input>` 本身 → 输入法组合态中断。修法三段式：①组合期（compositionstart→end）不重渲染，`ev.isComposing` 双保险；②组合事件必须用 `document.addEventListener(..., true)` 委托——inline `oncompositionstart` 在部分内核 property 未编译、根本不触发；③只局部重绘表格容器（`#orderTableWrap`），input 节点存活。**详见 `references/search-input-ime.md`**。


## 102. 看板「环比」基准月写死6月，H2月份环比全错（2026-09-05）

**症状**：看板「环比8月」标签，实际用 6月数据（`h1.total.actuals[5]`）做基准，导致 H2 月份（8月起）环比基准错误（7月碰巧对，因环比正好是6月）。

**根因**：`getKPI()` 里 `var last = (h1.total.actuals[5]) || 0` 写死取 6 月（H1 最后一个月）。非 admin 分支同病（`last: monthlyActuals[5]`）。

**修复**：环比基准动态取上月——上月 ≤6 用 `h1.total.actuals[上月-1]`，上月 ≥7 用 `threeYearMonthly['2026'][上月]`。admin/非admin 两个分支 + `index.html`/`app.html` 两个文件都要改（4处）。

**教训**：任何「环比/月度对比」基准月必须动态算上月，禁止写死固定月份索引（如 `actuals[5]`=6月）。


## 2. 函数重复声明 → 页面永久加载中

症状：spinner 永不消失。`grep -n "function refreshData" page.html`。


## 72. 看板客户数=0——admin 被当普通用户过滤（2026-07-28）

**症状**：管理员登录后看板「客户数」和「联系人」显示 0。

**根因**：`renderDashboard` 中 `mcpFiltered` 按 `userName` 过滤客户，未检查 admin 角色。若 admin 名不在任何客户的 owner/operators 中 → 过滤后为 0。

```javascript
// ❌ admin 也被过滤
var mcpFiltered = userName ? mcpAll.filter(function(c){...}) : mcpAll;

// ✅ admin 看全部
var isAdmin = (state.user || {}).role === 'admin';
var mcpFiltered = isAdmin ? mcpAll : (userName ? mcpAll.filter(...) : mcpAll);
```

**修复**：加 `isAdmin` 判断。联系人计数也受影响（同一行代码）。此 bug 是 #37（数据隔离）的反面——隔离逻辑正确但忘了给 admin 开路。

### 第三种「客户数0」根因：数据文件缺 current_status_operator 字段（2026-08-30）

用户报「客户数 0」时先区分三种根因：① 新人真没客户（正常，别急着排查）；② admin 过滤 bug（本条 #72）；③ **`mcp_customers.json` 数据文件整体缺 `current_status_operator` 字段**（用旧版 `pull_customers.py` 的 COLUMNS 拉的，MQL 里根本没 SELECT 这个字段），导致 `adaptMcpFields` 的 `owner=''`/`operators=[]`，**所有非 admin 用户**客户数全 0——不是只有那一个人。

**判别**（一行命令查字段列表）：
```bash
python3 -c "import json;d=json.load(open('数据/mcp_customers.json'));print(list(d['customers'][0].keys()))"
# 字段列表没有 current_status_operator / owner → 就是它；同时看其他非 admin 用户是否也全 0（系统性数据问题，非单用户）
```

**修复**：重跑当前版 `pull_customers.py`（COLUMNS 已含 `current_status_operator`，MQL 实测返回 `user_value_list`）+ `tcb hosting deploy 数据/mcp_customers.json`。


## 51. `fullYearTax` 从 `shipments.full_year_tax` 取值（值为 0）导致看板累计达成始终 0

**症状**：订单管理"本年"税合计正确（¥335 万），但看板顶部"累计达成"始终 0。`kpi.ytd` 和 `orders.reduce` 都已正确赋值，但看板仍显示 0。

**根因**：`renderDashboard` 中 line ~882 有一行旧代码幸存：
```javascript
var fullYearTax = shipments.full_year_tax || 0;  // ← 值始终为 0
```
备份恢复后这行变回了 `shipments.full_year_tax` 版本（而非 `kpi.ytd` 或 `orders.reduce` 版本）。后续多次修复都没能覆盖这一行，因为所有 grep 和 replace 都针对的是其他位置。

**检测**：
```bash
grep -n "fullYearTax" 工具/index.html | grep "shipments"
# 所有出现都必须审查——renderDashboard、getShipmentOrders、renderOrders 各有定义
```

**修复**：替换为 orders 实时汇总（orders 已在同函数中按 creator 过滤）：
```javascript
var fullYearTax = orders.reduce(function(s,o){return s+(parseFloat(o.taxTotal||o.amount||0)||0)},0);
```


## 42. `var function` 语法错误——整个页面 JS 崩溃（2026-07-27）

**症状**：登录页下拉框展开后完全空白（无用户列表），控制台报 `SyntaxError: Unexpected token 'function'`。所有 JS 均不执行。

**根因**：用 Python 脚本替换 `FEISHU_ACTIVITY_HOME = '...'` 为 `function getFeishuActivityUrl() { ... }` 时，旧代码中有 `var` 前缀残留在新函数声明前，产生 `var function getFeishuActivityUrl()`。

**检测**：
```bash
grep -n "var function" 工具/index.html  # 应无输出
node --check <extracted_script>.js       # 会直接报 SyntaxError
```

**铁律**：替换 `var X = value` 为 `function X()` 时，**必须同时删除 `var`**。替换后立即 `node --check` 验 JS。

**症状**：`shipment_status.json` 的 orders 中没有 `taxTotal`/`amount` 字段，前端看板价税合计始终 0。

**根因**：`shipment_pipeline.py` 的 `process_orders()` 输出 dict（约 line 159-185）只包含订单元数据 + 物流轨迹，**完全没有映射 `金额`/`价税合计` 字段**。即使 `pull_orders.py` 正确拉取了 `field_51d592`，数据在 pipeline 中被丢弃。

**完整数据流**：
```
pull_orders.py → mcp_orders_input.json (含 '金额' 字段)
  → shipment_pipeline.py 读取 → process_orders() 构建输出 dict
  → 必须显式映射: "taxTotal": o.get("金额", "")
  → shipment_status.json → 前端 renderDashboard / renderOrders
```

**修复**：在 `process_orders()` 的输出 dict 中加：
```python
"amount": o.get("金额", ""),
"taxTotal": o.get("金额", ""),
```

**验证**：
```bash
python3 -c "import json;d=json.load(open('数据/shipment_status.json'));\
print(sum(float(o.get('taxTotal') or 0) for o in d['orders']))"
```

### 看板 KPI 隔离
看板顶部 4 个 KPI 卡片（客户数/联系人/价税合计/本月价税）需与订单管理同口径——按 creator 过滤 orders 并实时汇总 `taxTotal`：

```javascript
// renderDashboard: orders 按 creator 过滤（与订单管理一致）
var userName = (state.user || {}).name;
if (userName && ((state.user || {}).role !== 'admin')) {
  orders = orders.filter(function(o) { return o.creator === userName; });
}
// 价税合计：从 orders 实时汇总（MCP 口径，与订单管理同步）
var fullYearTax = orders.reduce(function(s,o){return s+(parseFloat(o.taxTotal||o.amount||0)||0)},0);
// 客户/联系人：也按 creator 过滤 MCP 客户
var mcpFiltered = userName ? mcpAll.filter(function(c){return c.owner===userName||(c.operators||[]).indexOf(userName)>=0;}) : mcpAll;
```

**不要用 `kpi.ytd`（ERP 口径）做价税合计**——ERP 与 MCP 口径不同，数值不一致会让用户困惑。看板所有数据必须与订单管理同一来源。

### 非 Admin Tab 权限
看板默认对非 admin 隐藏，由 `tabs.dashboard` 控制。需在 `finishLogin` 中设 `tabs: {dashboard: true}`。

### 硬编码认证绕过 CloudBase 竞态
特定用户（赵云浩/张立娅）在 `doLogin()` 开头硬编码密码验证 + personnel 文件状态校验，完全跳过 CloudBase DB：

```javascript
if ((name === '赵云浩' || name === '张立娅') && pwd === 'biori2026') {
  return fetch('../数据/pricing/_personnel.json')
    .then(function(r) { return r.json(); })
    .then(function(data) {
      var found = (data.personnel || []).filter(function(p) { return p.name === name && p.status === '在职'; })[0];
      if (!found) throw new Error('人员状态校验失败');
      finishLogin({name: found.name, role: found.role || 'sales', dept: found.dept || '', email: found.email || '', tabs: {dashboard: true}});
    });
}
```

- `references/sf-delivery-pipeline.md` — 顺丰物流推送完整链路（pull_orders / shipment_pipeline / watchdog / 飞书卡片）
- `references/batch-reapply-from-backup.md` — 从备份干净重打补丁模板（逐 replace + 括号验）

## 13. Tunnel 假活检测与故障分类

`ps aux | grep cloudflared` 显示进程在跑 ≠ Tunnel 可用。需按症状分类处理：

### 故障类型 A：QUIC 失败 / HTTP2 降级
```
precheck component="UDP Connectivity" status=fail
suggested_protocol=http2
```
→ `curl -s --max-time 5 "https://<url>/api/todos" | head -c 5` 无输出
→ **处理**：kill + 重启 cloudflared，更新 HTML URL

### 故障类型 B：远端直接终止（2026-07-26 新增）
```
ERR failed to run the datagram handler error="Application error 0x0 (remote)"
ERR Serve tunnel error error="accept stream listener encountered a failure while serving"
ERR Connection terminated connIndex=0
```
→ Tunnel 连接建立后**立即被 Cloudflare 远端终止**，无 URL 产生
→ **这是 Cloudflare 侧问题**，非本地故障。重试无效（会在 precheck 后无限循环）
→ **处理**：等待（可能是 Cloudflare 临时中断），同时确保 JSON 优先模式已生效以保障静态数据可用

### 故障类型 C：返回 HTML 错误页
→ `curl` 返回 `<!doctype html>` 而非 JSON
→ Tunnel 已完全死亡（进程可能在但 URL 被回收）
→ **处理**：同类型 A

**通用检测命令**：
```bash
curl -s --max-time 5 "https://<url>/api/login" -X POST -H "Content-Type: application/json" -d '{"email":"test","password":"test"}' | head -c 20
# 返回 {"ok":...} = 正常
# 返回 <!doctype html> = Tunnel 死
# 无输出 = 假活（类型 A）

## 14. 邮箱登录体系（已被 #74 替代 → 已废弃）

历史方案存档：**详见 `references/deprecated-email-login.md`**。

## 24. 双文件陷阱——两个工作台入口，两种架构

**事实**：工作台有 **两个独立的 HTML 文件**，代码架构不同，修复方法不可互换：

| 文件 | CDN 路径 | 大小 | 登录方式 | ENDPOINTS 格式 | 状态 |
|------|----------|------|----------|----------------|------|
| `销售工作台.html` | `/index.html` | ~120KB | 后端 API（邮箱） | `[key, [apiUrl, jsonPath]]` | 已迁移到后端 API |
| `工具/index.html` | `/工具/index.html` | ~126KB | CloudBase DB (`user_roles`) | `[key, path]` | **旧架构，未迁移** |

### 关键差异

**登录体系**：
- `销售工作台.html`：调用 `POST /api/login` → 后端 `DEFAULT_USERS` 查表 → 返回用户
- `/工具/index.html`：CloudBase 匿名登录 → 查 `user_roles` 集合 → 匹配用户名密码

**ENDPOINTS 结构**：
```javascript
// 销售工作台.html（双元素数组，apiPath + fallbackJson）
['customers', ['/api/mcp/customers', '../数据/pricing/_key_customers.json']]

// /工具/index.html（简单路径，无 API 备选）
['customers', '../数据/pricing/_key_customers.json']
```

**loadData 修复策略不同**：
- `销售工作台.html`：需改为 JSON 优先 + API 备选（如第 21 条）
- `/工具/index.html`：简单路径格式本身不触发数组当 URL 的 bug，但也无降级机制

### `/工具/index.html` 的已知风险

1. **CloudBase 匿名登录可用但密码明文**：user_roles 的 `pwd` 字段明文，匿名登录后任何人可拖全表（见第 27 条）
2. **无后端 API 备选** → 如果 CloudBase DB 不可用，用户无法登录
3. **本地源已建立（2026-07-26）**：`~/Desktop/Hermes输出-工作类/工具/index.html`（备份 `index_backup_20260726.html`），Tunnel URL 已统一到 Tailscale Funnel 固定域名 `https://macbook-air-2.tail6864e8.ts.net`（AUTH_URLS + TODO_APIS 两处），残留 AUTH 对象已删除
4. **待办同步**：TODO_API 已改为 Funnel→localhost 降级链 + 探活锁定 + 防双写（`TODO_APIS`/`pullTodos`/`todoPost`，第 26-29 条同次修复）

### 何时修复哪个文件

- 用户报 "工作台" 问题 → 先确认是哪个入口（`/index.html` 还是 `/工具/index.html`）
- 如果用户说 "入口页面" "导航页" "工具页" → 通常指 `/工具/index.html`
- `/工具/index.html` 的迁移优先级较低——它是旧入口，主工作台 `/index.html` 是主力

## 60. `file_manifest.json` 格式陷阱——对象非数组，name 无扩展名（2026-07-27）

**症状**：访前准备存档区始终显示"暂无拜访卡"，但 manifest 文件中确实有万孚等拜访卡条目。

**根因链**：
1. `regenerate_manifest.sh` 输出的是**对象**（`{路径: {name, type, path}}`），不是数组 `{files: [...]}`
2. 前端用 `manifest.files` 读取 → `undefined` → entries 为空
3. `name` 字段已剥离 `.html`（`f.rsplit('.', 1)[0]`），但前端过滤器要求 `.endsWith('.html')` → 永远 0 匹配

**修复**：
```javascript
// ❌ manifest.files → undefined
var files = manifest.files || [];

// ✅ 对象遍历
Object.keys(manifest).forEach(function(k) {
  var v = manifest[k];
  if (v && v.name) entries.push(v);
});

// ❌ 过滤器要求 .endsWith('.html')
n.endsWith('.html')

// ✅ 不检查扩展名（name 已剥离）
n.indexOf('_拜访卡') > 0 || n.indexOf('_市场调研卡') > 0
```

## 61. Tailscale Funnel 端口不匹配（2026-07-27）

**症状**：`curl` 本地 API → 成功，`curl` Funnel URL → 空响应。

**根因**：Funnel 转发到 `127.0.0.1:8765`，HTTP 服务器运行在端口 `8000`。两者不匹配。

**检测**：
```bash
tailscale serve status
# 输出示例：https://macbook-air-2.tail6864e8.ts.net → http://127.0.0.1:8765
```

**修复**：
```bash
tailscale funnel --bg http://127.0.0.1:8000
```

**验证**：
```bash
curl -s --max-time 5 https://macbook-air-2.tail6864e8.ts.net/api/visit-card -X POST ...
```

## 86. 工作台入口文件：app.html ≠ index.html（2026-08-09）

**症状**：修改 `工具/index.html` 后飞书工作台无变化。

**根因**：飞书工作台的真正入口是 `工具/app.html`（通过 `landing.html?line=da/ls` 访问），不是 `工具/index.html`。

| 文件 | 用途 | 入口方式 |
|------|------|---------|
| `工具/app.html` | **主力工作台**（诊断原料/生命科学双线） | 飞书内 → landing.html → app.html?line=da |
| `工具/index.html` | 旧版入口 | CloudBase `/workbench/index.html`，历史遗留 |

**铁律**：修改看板/客户/订单等核心功能时，**两个文件都要改**（或确认目标后用 `app.html`）。

> 注意：Pitfall #76 描述的是 CloudBase 路径映射（`/index.html` vs `/workbench/index.html`），但源文件层面还有 `index.html` vs `app.html` 的坑——三层路径纠缠极易出错。

## 87. 看板 KPI 三层递进卡片模式（2026-08-09）

将旧版 3 卡（年度目标+环形图 / 本月vs上月 / 半年度进度）改为当月→当季→当年三层递进：

1. **`getKPI()`** 新增 `monthTarget/monthActual/q3Target/q3Actual`，数据来自 `threeYearMonthly` + `kpi_dashboard.json.depts[].h2_months`
2. **`renderDashboard()`** 渲染 3 张新卡片，含内联 progress bar（`height:6px`）+ 环比涨跌色
3. 环形图保留在年度卡片中

详细参考：[kpi-multi-horizon-cards.md](references/kpi-multi-horizon-cards.md)

## 95. 7月入职员工看板 KPI「入职拆分」模式（JOIN_SPLIT）（2026-08-30）

**场景**：新员工 7 月才入职（如大客户1岗位刘子研），全年目标 1300万 = 入职前（1-7月，前任业绩）+ 入职后（8-12月，新员工）。但数据里 `annual_target` 只存了「入职后目标」751.1万，`h1_total` 是入职前达成 507.8万。不拆分的话看板会：① 把前任 507.8万业绩算到新员工头上；② 年度目标显示成 751.1万（其实只是入职后部分，不是全年 1300万）。

**方案**：前端 `JOIN_SPLIT` 配置 + `getKPI()` 拆分 + `renderDashboard` 分支显示：

```javascript
// 入职拆分配置：全年目标 = 入职前目标 + 入职后目标
var JOIN_SPLIT = {
  '刘子研': { join_month: 8, full_year_target: 13000000, pre_join_target: 5489000, post_join_target: 7511000 }
};
```

`getKPI()` 非 admin 分支 return 前：
```javascript
var split = JOIN_SPLIT[name] || null;
var splitInfo = null;
if (split) {
  var h2am = dashPerson ? (dashPerson.h2_actual_months || []) : [];
  var preJoinActual = (dashPerson ? dashPerson.h1_total : 0) + (h2am[0] || 0);   // 1-7月(含7月)
  var postJoinActual = h2am.slice(1).reduce(function(s,v){return s+(v||0);},0);   // 8-12月
  splitInfo = { preJoinActual: preJoinActual, postJoinActual: postJoinActual, preJoinTarget: split.pre_join_target, postJoinTarget: split.post_join_target };
  annual = split.full_year_target;
  ytd = preJoinActual + postJoinActual;
  pct = annual ? Math.round(ytd/annual*1000)/10 : 0;
}
return { ..., split: splitInfo };
```

`renderDashboard` 年度卡片分支：label 显示「🎯 全年目标 1300万」，value 显示「入职后 751.1万」，sub 显示「入职前达成 550万 · 入职后达成 15.2万」，环形图 pct 改用「入职后达成率」（`postJoinActual/postJoinTarget`，donutLabel='入职后达成率'）。

**⚠️ 口径必须先问用户再改（业绩口径做错影响大）**：入职前/入职后分界（7月算哪边）、入职后目标=沿用 annual_target 还是 = 全年目标-入职前达成、入职后达成只算自己签的还是含归属名下所有。本次用户确认：入职前=1-7月(含7月)、入职后目标=751.1万(沿用数据)、入职后达成=含归属名下所有。

**⚠️ 语法坑**：把 `var donutPct=...` 声明插进 `renderDashboard` 的 `main.innerHTML = '...' + '...'` 字符串拼接 `+` 链中间，会 `SyntaxError: Unexpected token 'var'`——**var 声明必须放在 main.innerHTML 赋值语句之前**，不能在字符串拼接表达式里插声明语句。

## 详细资料（按需查阅）

> 以下文件不在本 SKILL.md 内，需要时再读；正文只保留每次都会用到的部分。

- **`references/archive-changelog.md`** — 含 17 节：113. 取证探测的输出爆炸防护（浏览器 js 探测 + grep 大文件）、115. 前端 API 基址探测：判据不能只看「非 404」，候选顺序就是可用性（2026-09-23 历史成交价 Failed to fetch）、108. AI 助手一键复制对话（2026-09-18）、65. 多选筛…
- **`references/auth-permission.md`** — 含 13 节：110. 带鉴权的数据 Tab（上传/台账/文件下载）三个坑（2026-09-19）、110b. user_roles 锁库 + 用户管理服务端化（2026-09-20）、105/104. 档案页权限泄露 & 集团级归属（2026-09-10）、101. 待办跨设备不同步：todos 集合 ACL …
- **`references/data-pipeline.md`** — 含 31 节：113. 清单页新增统计维度：默认交叉、四处同步、页面端派生、100. AI 助手文件上传：前端校验别比后端严（2026-09-04）、102. 看板环比基准月错误：kpi.last 写死 6 月而非上月（2026-09-05）、3. 表格空数据必须有提示、4. MCP 字段映射（必读！字段不匹配是…
- **`references/deploy-cdn.md`** — 含 8 节：114. 浏览器里 CDN 页面不对，先用 curl 复核再下结论、52. 缓存破坏代码插进 `function loadData()` 定义导致语法错误、17. Tunnel URL 更新：必须更新 3 处以上、20. CDN 缓存验证 vs Origin 文件、29. 审计无本地源的 Cloud…
- **`references/feishu-api.md`** — 含 7 节：103. 飞书文件夹消息网关不处理（2026-09-05）、53. Feishu open_id 按 App 独立——跨 App 推送必须重新获取（2026-07-27）、71. 客诉列表按创建时间倒序（2026-07-28）、15. MQL 数组解析与 workitem_related_multi…
- **`references/frontend-ui.md`** — 含 8 节：109. Tab 分组 + 移动端底栏精简 + AI 页移动端布局（2026-09-19）、107. 客户tab搜索框被侧栏遮挡（2026-09-11）、106. 搜索框打断中文输入法（2026-09-11）、102. 看板「环比」基准月写死6月，H2月份环比全错（2026-09-05）、2. 函数…

### 既有参考文件（原正文未引用，此处补齐索引）

- **`references/2026-07-29-songxuewei-onboarding.md`** — 宋学伟上线全链路修复记录（2026-07-29）
- **`references/2026-08-01-app-html-merge.md`** — app.html + index.html 合并踩坑记录 (2026-08-01)
- **`references/2026-08-01-kpi-tax-fix.md`** — KPI 税合计 + 看板筛选器修复 (2026-08-01)
- **`references/2026-09-order-date-and-amount.md`** — 订单管理日期偏一天 + 金额数据源修复（2026-09-07）
- **`references/20260728-session-patterns.md`** — 2026-07-28 会话新增模式
- **`references/ai-assistant-fetch-pitfalls.md`** — AI 助手流式改造 + fetch 错误处理陷阱（2026-09-08）
- **`references/ai-assistant-sse-streaming.md`** — AI 助手多端点降级 + SSE 流式超时修复（2026-09-08 实战）
- **`references/ai-assistant-streaming.md`** — AI助手链路架构 + 「回答超时」根治（流式输出）
- **`references/ai-chat-copy-feature.md`** — AI 助手「一键复制对话」实现与验证（2026-09-18）
- **`references/ai-chat-streaming-fetch.md`** — AI 助手流式 fetch 排坑（2026-09-08）
- **`references/batch-reapply-from-backup.md`** — 从备份干净重打补丁模板
- **`references/blob-download-feishu-webview.md`** — 飞书 webview 打开/下载文件铁律（2026-09-10）
- **`references/complaint-customer-isolation.md`** — MCP `key_label_value_list` 字段格式与客诉客户关联
- **`references/complaints-data-reference.md`** — 客诉（投诉）数据处理参考
- **`references/complaints-pipeline-three-bugs.md`** — 客诉数据管道：三个致命缺陷（2026-07-27 诊断）
- **`references/cust360-and-decision-chain.md`** — 客户 360 弹窗 + 决策链集成（2026-07-29）
- **`references/customer-toolbar-occlusion.md`** — 客户tab搜索框被侧栏遮挡 —— toolbar 缺 margin-left（2026-09-11）
- **`references/daily-brief-architecture.md`** — 销售日常简报架构（2026-07-27）
- **`references/email-login-migration.md`** — 邮箱登录迁移记录
- **`references/feishu-customer-link.md`** — 宝锐飞书客户链接路由逻辑
- **`references/feishu-per-creator-push.md`** — 飞书推送：按销售员分发（2026-07-27 终版）
- **`references/frp-tunnel-tsnet-blocked.md`** — 工作台线上出口：ts.net被墙 → frp反向隧道方案
- **`references/ima-api-limitations.md`** — IMA OpenAPI 能力边界与知识库搜索（2026-07-27实测）
- **`references/kpi-tax-fix-all-modules.md`** — KPI 税合计数据源全局修复 (2026-08-01)
- **`references/life-science-opp-view.md`** — 生命科学 MCP 商机视图查询参考
- **`references/manifest-and-funnel-pitfalls.md`** — 清单格式、Funnel 与存档区铁律（2026-07-27 血案汇总）
- **`references/market-intel-agent.md`** — 诊断原料市场调研 Agent（market_intel_agent.py）
- **`references/mcp-field-5ed7ab-options.md`** — MCP field_5ed7ab 选项清单（2026-07-31）
- **`references/mcp-field-mappings.md`** — MCP 客户工作项字段映射表
- **`references/mql-value-type-parsing.md`** — MQL moql_field_list 值类型解析参考
- **`references/multi-base-api-fallback.md`** — 多基址 API 回退：401 必须换基址，不能「拿到 JSON 就 resolve」
- **`references/new-tab-and-api-fallback.md`** — 新增 Tab 的标准三步 + 多基址回退陷阱
- **`references/order-period-kpi-layout.md`** — 订单管理「本年累计」与周期切换的对应规则
- **`references/permission-isolation.md`** — 权限隔离与集团级归属（2026-09-10）
- **`references/pipeline-scripts.md`** — 宝锐工作台数据管道脚本清单
- **`references/preloaded-images-fallback.md`** — Preloaded Image Fallback Pattern for HTML Dashboards
- **`references/preparation-status-pattern.md`** — 备货状态模式（2026-07-28）
- **`references/rollback-investigation.md`** — 工作台回滚请求排查清单
- **`references/search-input-ime.md`** — 搜索框打断中文输入法：组合输入期间禁止重渲染（2026-09-11）
- **`references/security-hardening-20260729.md`** — 安全加固实录（2026-07-29）
- **`references/sf-delivery-pipeline.md`** — 顺丰物流推送完整链路
- **`references/three-year-compare-permission.md`** — 三年逐月销售对比权限：只给 admin+gm（2026-08-21）
- **`references/toISOString-utc-offset.md`** — toISOString().slice(0,10) 取日期偏一天——UTC 时区坑（2026-09-07）
- **`references/toisostring-timezone-offset.md`** — toISOString() 时区偏移导致日期显示偏一天（2026-09-07）
- **`references/visit-card-api.md`** — 访前准备拜访卡 API（完整版）
- **`references/visit-prep-sync-verification.md`** — 访前准备 Tab 归档同步机制 + 浏览器实测验证（2026-08-22 实测）
- **`references/wps-dispimg-extraction.md`** — WPS Office DISPIMG Image Extraction
- **`references/配置字段解析.md`** — 配置字段解析：标量 vs 数组
