# baoyu-workbench-frontend-pitfalls — details

## 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'])"



## 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 数组
