# 新增 Tab 的标准三步 + 多基址回退陷阱

> 来源：2026-09-15 新增「测试报告定向收集」Tab 实战。适用任何往工作台加新 Tab 的需求。

## 一、加 Tab 的三步（缺一不可）

1. `工具/index.html` 的 `TABS` 数组加一项：
   ```js
   { id: 'testReport', label: '测试报告收集', icon: '🧪', lines: ['da','ls'] },
   ```
   - `lines`：可见业务线；`adminOnly: true` 仅 admin/gm 可见。
2. `render()` 的 `switch (state.tab)` 加分支：
   ```js
   case 'testReport': renderTestReport(main); break;
   ```
3. `PERM_TAB_LABELS` 加 `['testReport', '测试报告收集']`
   —— **不加则「数据管理 → 权限」页勾选时看不到这个 Tab**。

内容复杂（长表单、多字段、需要上传）时，做成独立子页 `工具/xxx.html` + Tab 内 iframe（参考 `宝锐活动录入.html`），TABS 只放入口。

## 二、多基址回退必须按 HTTP 状态码判断

**错误写法**（会卡死）：
```js
fetch(base + path).then(function(r){ return r.json(); })
  .then(function(d){ API_BASE = base; resolve(d); })
  .catch(function(){ next(); });
```
公网 Tunnel 返回 401 时照样 `resolve`，把错误当结果，**本地兜底端口永远不会被尝试**。

**正确写法**：
```js
.then(function(r){
  if (r.status === 401 || r.status === 403 || r.status >= 500) {
    var e = new Error('http ' + r.status); e.status = r.status; throw e;
  }
  return r.json();
})
.then(function(d){ API_BASE = base; resolve(d); })
.catch(function(e){
  if (e && (e.status === 401 || e.status === 403)) {
    lastAuthErr = {ok:false, error:'登录已过期，请退出后重新登录'};
  }
  next();
});
```
所有基址都失败时，若有 `lastAuthErr` 则 resolve 它（保留真实鉴权提示），否则 reject「接口不可用」。

**基址列表首位放同源 origin**：
```js
var API_BASES = [];
if (/^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(location.origin)) API_BASES.push(location.origin);
API_BASES.push('https://api.biorisales.site:8443', 'http://127.0.0.1:8765');
```
上传（multipart）同理：每次重试要**重建 FormData**，且不要手动设 `Content-Type`。

## 三、前后端字段契约要对齐

实战：前端提交 `sections`（嵌套），后端只读 `values`（扁平）→ **`ok:true` 但记录里 values 为空**。
双保险：后端在 `values` 空时从 `sections` 展开（`label → value`）并同时落盘 `sections`；前端同时发扁平 `values`。

**验收纪律**：新增落盘接口后**必须回读一条记录并打印每个字段的长度/条数**，不能只看 `ok:true`。

## 四、本地验证实例的三个坑

### 1）端口被旧进程占用 → 静默跑旧代码
起临时验证实例（如 8799）时若旧进程没真被杀掉，**新实例 bind 失败直接退出**，你以为在测新代码，实际旧代码在服务（表现为「新加的分支不生效」）。
- 诊断：`lsof -nP -iTCP:<端口> -sTCP:LISTEN` 确认 PID 是刚启动的那个
- `kill -9` 后必须复查端口已释放再启动

### 2）`apiFetch` 遇 401 会清空 token
`工具/index.html` 的 `apiFetch` 在 401 时 `removeItem(AUTH_TOKEN_KEY/ROLE_KEY)` 并踢回登录。用测试 token 打开工作台验证时，启动阶段打向公网 Tunnel 的请求会 401 → token 被清 → 后续全失败。
**做法**：注入 token 后**直接调用目标函数**（如 `openTestReportList()`），别先让页面自己发请求。测试 token 的取法（导入服务模块 → 往 `AUTH_SESSIONS` 塞假 token → 临时端口起实例 → 浏览器 localStorage 注入同一个 token）和「演示版/离线版」生成器，见 `baoyu-workbench-patterns/references/collection-form-page.md`（§验证、§⑦、§⑧）。

### 3）改完 JS 必须用 `?v=<时间戳>` 破缓存
浏览器会缓存 `index.html`，否则测的仍是旧 JS。

## 五、部署（子页也要部署）

CDN 需部署**三个目标**：
```bash
ENV=bier-sales-d0gatbvlx288724e9
tcb hosting deploy 工具/index.html 工具/index.html -e $ENV
tcb hosting deploy 工具/index.html workbench/index.html -e $ENV   # 英文路径副本(飞书用)
tcb hosting deploy 工具/<新子页>.html 工具/<新子页>.html -e $ENV
```
验证：三个 URL 各 200 + `shasum -a 256` 与本地一致 + grep 本次新增的关键字符串。

## 六、配套后端（本地服务）写法

`脚本/销售工作台服务.py` 里加端点：
- `do_GET` 链中 `elif path == '/api/xxx':`（**注意 do_GET 开头有全局鉴权兜底**，所有 `/api/` 除登录外都需 token）
- `do_POST` 链中 `if path == '/api/xxx':`，读 body：`length=int(self.headers.get('Content-Length') or 0); payload=json.loads(self.rfile.read(length) or b'{}')`
- 统一用 `self._send_json(obj, code)`；身份用 `self._auth_user()`（返回 name/role/dept/line）
- 加完 `python3 -m py_compile` + `launchctl kickstart -k gui/$(id -u)/ai.hermes.workbench` 重启
