# baoyu-workbench-patterns — details

### ❌ 禁止
```javascript
// NEVER: inline onclick with escaped quotes — 必断裂
'<span onclick="removeImg(\''+key+'\','+i+')">✕</span>'
```


### ✅ 正确方式

**方式A：createElement + onclick**
```javascript
var div=document.createElement('div');
div.className='pos-card';
div.onclick=function(){ /* ... */ };
div.innerHTML='...';
grid.appendChild(div);
```

**方式B：事件代理（推荐）**
```javascript
uz.onclick=function(e){
  e.stopPropagation();
  // ⚠️ 点击目标可能是文本节点（nodeType===3）
  var t=e.target.nodeType===3?e.target.parentElement:e.target;
  
  if(t.classList.contains('del-btn')||t.closest('.del-btn')){
    var idx=t.getAttribute('data-idx');
    removeItem(k, idx); return;
  }
  if(t.classList.contains('add-btn')||t.closest('.add-btn')){
    uploadItem(k); return;
  }
  // default action
};
```

## 图片上传 + localStorage 持久化

```javascript
var STORAGE_KEY='bior_xxx_data';

// 初始化：从 localStorage 恢复
var uploadedData={};
try{uploadedData=JSON.parse(localStorage.getItem(STORAGE_KEY)||'{}');}catch(e){uploadedData={};}

function saveData(){localStorage.setItem(STORAGE_KEY,JSON.stringify(uploadedData));}

// 渲染时读取
var saved=uploadedData[key];
// ⚠️ 类型守卫：防止旧格式崩溃
if(typeof saved==='string') saved=[saved];
if(!Array.isArray(saved)) saved=[];

// 上传
function uploadItem(key){
  var input=document.createElement('input');input.type='file';input.accept='image/*';
  input.onchange=function(e){
    var file=e.target.files[0];if(!file)return;
    var reader=new FileReader();
    reader.onload=function(ev){
      if(!uploadedData[key])uploadedData[key]=[];
      uploadedData[key].push(ev.target.result);
      saveData();
      renderXxx(); // 重渲染
    };
    reader.readAsDataURL(file);
  };
  input.click();
}
```


### 实时API服务器(`api_server.py`)

> 🆕 **MCP 实时数据 v2.0 实战**（2026-07-26）：完整的三级降级 + 缓存 + 自动翻页 + 字段映射 + 客户链接智能路由架构。详见 `references/mcp-data-pipeline-v2.md`。

端口8765，除静态文件外提供实时MCP代理：
- `/api/activities` — 最近100条活动（实时MCP调用）
- `/api/complaints` — 客诉待办列表
- `/api/sales` — 今日销售单
- `/api/ping` — 健康检查

页面fetch这些端点获取实时数据，不用等cron。API不可用时自动降级到JSON缓存。


### 三管齐下修复

**① 实时改用本地JSON**：活动数据改用 cron 刷新的 `mcp_activities.json` 替代实时 `/api/activities`

**② fetchWithTimeout** — 所有fetch加5-8s超时保护：
```javascript
function fetchWithTimeout(url, timeout, fallback){
  return new Promise(function(resolve){
    var timer=setTimeout(function(){resolve(fallback)}, timeout);
    fetch(url).then(function(r){return r.json()})
      .then(function(d){clearTimeout(timer);resolve(d)})
      .catch(function(){clearTimeout(timer);resolve(fallback)});
  });
}
// 用法: fetchWithTimeout('/数据/xxx.json', 5000, {items:[]})
```

**③ 服务器恢复**：
```bash
lsof -ti:8765 | xargs kill -9   # 杀僵尸进程
cd ~/Desktop/Hermes输出-工作类 && python3 api_server.py &  # 重启
```


### 启动

```bash
# 创建 shell 会话（⚠️ 不能直接在 tmux new-session 中运行 hermes）
tmux new-session -d -s turing -x 160 -y 50
# 注入 Hermes 启动命令
tmux send-keys -t turing "hermes --provider kimi-coding --model k3 --toolsets terminal,file,browser" Enter
# 等待 8-10s 初始化，检查 ❯ 提示符确认就绪
```


### 任务委派

```bash
# 发送任务
tmux send-keys -t turing "任务描述..." Enter
# 查看输出
tmux capture-pane -t turing -p | tail -30
```


### 管理脚本

`~/.hermes/scripts/turing.sh` — 完整管理脚本（start/send/log/watch/stop/status/run）。


### 陷阱

| 陷阱 | 现象 | 修复 |
|------|------|------|
| **tmux new-session 直接运行 hermes** | 进程立即退出，会话为空 | 先创建 shell 会话，再 send-keys 注入命令 |
| **k3 浏览器 console 循环** | 连续 browser_console 调用不停止 | kill 临时 HTTP 服务器(8766端口) + 主Agent手动验证 |
| **k3 模型 429 过载** | `API call failed: HTTP 429` | 降级到 `kimi-k2`，通过 `TURING_MODEL=kimi-k2` 环境变量切换 |
| **inline onclick 引号转义** | 折叠/展开无响应 | 用 `addEventListener` + `data-*` 属性替代 |
| **y3State var 重复声明** | 状态不持久 | 确保 var 声明在函数作用域外，初始化在 render 前 |
| **图灵上下文超载** | 6h+ 会话出现 `🗜️ Compacting context` | `/compress` 或重启 tmux 会话 |
| **改错文件** | 更新了 `宝锐销售工作台_生产版-ima.html` 而非 `销售工作台.html` | 任务中明确写完整路径，确认文件最后修改时间 |
| **k3 模型 429 过载** | `API call failed: HTTP 429` | 降级到 `kimi-k2`；`k3` 高峰期不可靠。非 HTML 编程类分析任务（如方案对比、PPT生成）也可放心用 `kimi-k2`，效果不差于 k3。**Ollama 本地模型仅做第三参考，不替代 kimi-k2 作为主降级方案。** |
| **inline onclick 引号转义** | 折叠/展开无响应 | 用 `addEventListener` + `data-*` 属性替代 |
| **HTML 密码需查源码** | 浏览器登录失败，不知道密码 | `grep -n 'pwd\|密码\|biorigm\|bioridir' xxx.html` 直接从源码提取 |

## 三年逐月销售对比模式（2026-07-21）

从 ERP Excel 提取逐年月度数据，渲染为可折叠对比表。


### 同比计算

```javascript
var yoyPrevYear = prevYearSameMonth ? ((curr - prevYearSameMonth) / prevYearSameMonth * 100).toFixed(1) : '—';
```


### 关键文件

- 数据源：`2024全年订单.xlsx` / `2025全年订单.xlsx` (Sheet1)
- 中间JSON：`数据/three_year_monthly.json`
- 渲染：`y3Toggle()` / `y3ToggleAll()` / `y3BindEvents()` (addEventListener 绑定)

## H2月度目标SVG柱状图模式（2026-07-21）

目标 vs 实际分组柱状图，SVG纯绘制，零依赖。


### 注意

- 离职人员（黄明月/龙友波/任鑫等）数据保留但默认折叠
- 商机性质筛选切换时同步更新统计卡

## 多源Excel → 仪表盘三步整合模式（2026-07-21）

> 🆕 **三部门经营分析仪表盘完整模式**（2026-08-06）：含ECharts图表清单、回款对比分析、设计偏好（白色背景、左侧色条卡片、不要雷达图）。详见 `references/three-dept-dashboard-pattern.md`。


### 关键防护

- **人员匹配**：订单销售员名必须在人员清单中存在，否则"未归属"
- **合计行排除**：`单据编号='合计'` 或空必须过滤
- **赠品排除**：`是否赠品=='是'` 或 `价税合计==0` 不计入销售额
- **部门映射**：人员清单 > 订单自带部门字段

## 工作台入口注册

所有子页面通过工作台顶栏 Tab 注册为 `<a>` 链接：

## HTML 生成铁律：write_file 优于 patch（2026-07-13 实战验证）

**问题**：用 `patch` 工具修改含大量引号转义的 `<script>` 内嵌 JS 时，连续触发 `Found N matches`、`Escape-drift`、`old_string not found` 等错误，浪费 >2小时。

**根因**：HTML JS 字符串中 `\\\"`、`\\\\\"` 等转义层级的 quote 嵌套远超 patch 模糊匹配能力。

**铁律**：任何超过 50 行新增/修改的 HTML JS 逻辑，直接用 Python + `write_file` 完整重写文件，不尝试 patch。流程：
1. Python 读入 HTML → 字符串拼接/替换 → write_file 写回
2. `node --check` 验证
3. 如需小修改再用 patch

**典型信号**（立即切换到 write_file）：patch 报 `Escape-drift` 或同名 patch 失败 2 次以上。

## 参考架构

> `sales.biori-ru.com`（Biori Global Sales Hub）为外部专业版参考系统（Next.js + Tailwind + Cloudflare），含产品分析/质量管理/风险看板/AI洞察等模块。详见 `references/biori-global-sales-hub-reference.md`。

## 部署方案

完整对比见 `references/deployment-options.md`。六种方案：Tailscale Funnel / 云服务器 / Cloudflare Pages / 腾讯 CloudBase（国内首选，自带鉴权）/ 群晖 NAS / 飞书小组件。

> 🆕 **CloudBase 部署 + 鉴权迁移实战**（2026-07-25）：将本地 `127.0.0.1:8765` 工作台迁移到腾讯 CloudBase 静态托管，匿名登录 + 数据库验证密码，不占用户配额，¥0/月。详见 `references/cloudbase-deployment.md`。

## Tailscale Serve 部署模式（2026-07-12）

使用 Tailscale Serve 将本地 HTTP 服务器以 HTTPS 暴露给 tailnet 内所有设备。


### 启动

```bash
# HTTP服务器绑定0.0.0.0（必须 — 127.0.0.1仅本机可访问）
python3 -c "import http.server,socketserver;http.server.HTTPServer(('0.0.0.0',8765),…).serve_forever()" &

# Tailscale Serve — ⚠️ 致命陷阱：后端必须是 http:// 绝不是 https://
# Python http.server 只支持 HTTP，配置 https://127.0.0.1 必然 502
tailscale serve --bg http://127.0.0.1:8765
```


### 常见故障

| 症状 | 原因 | 修复 |
|------|------|------|
| HTTP 502 | serve代理了`https://127.0.0.1` | 改为`http://127.0.0.1` |
| 外网不通 | 服务器绑定127.0.0.1 | 绑定0.0.0.0 |
| ACL不生效 | Admin未保存 | ACL页面→Save |

## 新增销售员全系统入驻 → `references/new-salesperson-onboarding.md`

9 系统一致性检查清单 + 交叉验证脚本。触发：新销售员入职、人员权限配置。

> 🆕 **2026-07-29 精简版**：必改 6 文件清单 + `DEFAULT_USERS` 陷阱 + 验证脚本，详见 `references/new-user-onboarding.md`。

## 🔴 认证系统 `state.user` 双格式兼容（2026-07-29）

销售工作台同时存在新旧两套认证：旧 AUTH 硬编码将 `state.user` 存为字符串（`'zhaoyunhao'`），新 CloudBase 登录 API 将其存为对象（`{name, role, dept, email, tabs}`）。代码中 `USER_DEPTS[state.user]` 或 `AUTH[state.user]` 会将对象当 key → `undefined` → 过滤静默失败 → 用户看到全公司数据。

**修复**：统一用 `getUserName()` / `getUserRole()` / `isAdminUser()` 辅助函数，订单管理用 `creatorEmail` 精确匹配替代部门映射。详见 `references/state-user-dual-format.md`。

## 销售员角色过滤模式（2026-07-12）

客户档案/工作台页面按销售员过滤名下客户。数据要求：`_key_customers.json` 每条含 `salesperson` 字段。

```javascript
var USER_ROLE=localStorage.getItem('bior_role')||'solo';
// role='all' → 全部; role='sp:赵云浩' → 仅赵云浩客户
function filterByRole(list){
  if(USER_ROLE.startsWith('sp:')){
    var sp=USER_ROLE.slice(3);
    return list.filter(function(c){return c.salesperson===sp;});
  }
  return list;
}
```

## 销售工作台四Tab模式（2026-07-12）

为销售人员提供整合工作台：顶栏用户选择器 + 四Tab（📊看板/📋客户/🔴客诉/✅待办）+ 统计卡 + Tab内容区。


### 下钻实现（最小改动 5 步）
1. `state` 加 `kpiPerson: null`
2. 加 `function setKpiPerson(v){ state.kpiPerson = v||null; render(); }`
3. `getKPI()` 顶部加 `if (role==='admin' && state.kpiPerson) name = state.kpiPerson;`，并把 admin 全公司分支条件改为 `role==='admin' && !state.kpiPerson` —— admin 下钻时 name 被覆盖，自动走下方个人 KPI 逻辑（含 JOIN_SPLIT 入职拆分）
4. `renderDashboard` 里 `isAdmin` 时构建按部门分组的 `<select>`（`dash.people` 分 销售拓展部/大客户部/生命科学部 optgroup，按 h1_rate 降序），塞进 card-header
5. 下钻时部门对比条隐藏：`(state.kpiPerson ? '' : deptBarHtml(...))`


### 陷阱：dash 作用域
`getKPI()` 里的 `var dash = d.kpiDashboard` 是函数局部变量，`renderDashboard()` 引用 `dash.depts`/`dash.people` 会 ReferenceError。必须在 `renderDashboard` 顶部单独 `var dash = state.data.kpiDashboard || {};`（2026-09 加部门条时曾漏定义报错）。

## H2 KPI 双源加载模式（2026-07-13）

H1 实际达成来自 `my_kpis.json`（31人,含月度明细），H2 月度目标来自 `kpi_dashboard.json`（23人,含6个月度目标+3部门组织架构）。两个 JSON 的 `people` 数组字段不同，需分开匹配。

```javascript
// H1实际：my_kpis.json
var kpi=KPI_DATA.people.find(p=>p.name===sp);
var monthly=kpi?kpi.monthly:{};

// H2目标：kpi_dashboard.json
var h2p=H2_DATA.people.find(p=>p.name===sp);
var h2months=h2p?h2p.h2_months:[];
```

**注意**：张立娅在 `my_kpis.json` 中不存在（H1无数据），但在 `kpi_dashboard.json` 中存在（H2有目标793.4w）。切换用户时需兜底。

## KPI H2 月度目标条形图（2026-07-13）

橙色渐变柱状图展示7-12月目标，区别于蓝色H1实际。

```javascript
['7月','8月','9月','10月','11月','12月'].forEach(function(m,i){
  var v=h2months[i]||0, h=Math.max(4,Math.round(v/h2max*60));
  html+='<div style="background:linear-gradient(180deg,#e37400,rgba(227,116,0,.2));height:'+h+'px"></div>';
});
```

## 登录授权：CloudBase 数据库鉴权系统（2026-07-25 ★新范式）

> 🔴 **旧鉴权已废除**：不再使用硬编码 AUTH 对象（`pwd:'biorigm'`）、`AUTH_LABELS`、或 localStorage 存 role id（`"gm"`）。全部改为 CloudBase 匿名登录 + `user_roles` 集合验证。


### 输入栏

```html
<div class="todo-input" style="flex-wrap:wrap">
  <input id="todoInput" placeholder="待办内容…">
  <select id="todoCat"><option value="prep">访前准备</option>…</select>
  <select id="todoClient"><!-- 动态填充名下客户 --></select>
  <input type="date" id="todoDue">
  <button onclick="addTodo()">+</button>
</div>
```


### 排序规则

过期→今天→未来→无日期，同类按创建倒序。已完成永远排末尾。


### default 设为 director

```javascript
USER=localStorage.getItem('bior_sw_user')||'director';  // 首次访问默认总监视图
```

## 客户文件下拉

```javascript
myCusts().forEach(function(c){
  var o=document.createElement('option'); o.value=c.name; sel.appendChild(o);
});
```


### Tailscale Serve 致命陷阱：后端协议 502

```bash
# ❌ Python http.server 只支持 HTTP，绝对不要用 https:// 前缀
tailscale serve --bg https://127.0.0.1:8765  # → 502 Bad Gateway

# ✅ 正确
tailscale serve --bg http://127.0.0.1:8765
```

```javascript
function exportTodos(){
  var report='【销售日报 - '+USER+'】'+today+'\n📋 待处理:\n';
  active.forEach(function(t,i){
    report+=(i+1)+'. ['+CATS[t.cat]+'] '+t.text+(t.client?' (@'+t.client+')':'')+(t.due?' 📅'+t.due:'')+'\n';
  });
  navigator.clipboard.writeText(report).then(function(){alert('已复制！');});
}
```


### 视图切换
```javascript
var VIEW='list', PROFILE=null;
function showList(){ VIEW='list'; document.getElementById('backBtn').style.display='none'; renderList(); }
function openProfile(name){ VIEW='detail'; document.getElementById('backBtn').style.display='inline-block'; loadAndRender(name); }
```


### 列表视图要素
- 搜索框（`oninput` 实时过滤）
- KPI 总览卡片（客户数、累计采购额）
- 表格列：客户名 | 累计采购 | 决策链状态 | 各维度计数
- 📥 CSV 导出按钮（BOM头 `\uFEFF`，Excel友好）
- 列头可排序


### CSV 导出（从 baoyu-html-audit 技能）
```javascript
function exportCSV(){
  var rows=document.querySelectorAll('table tbody tr'),csv='\uFEFF客户,累计采购,决策链,联系人,商机,项目,客诉\n';
  rows.forEach(function(tr){
    var cells=tr.querySelectorAll('td'),row=[];
    for(var i=0;i<7;i++){row.push('"'+((cells[i]||{}).textContent||'').trim()+'"');}
    csv+=row.join(',')+'\n';
  });
  var a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));
  a.download='客户清单_'+new Date().toISOString().slice(0,10)+'.csv';a.click();
}
```

## 客户档案全维度卡片模式（2026-07-11）

全维度客户档案页 = 列表首页 + 详情视图，整合ERP价格 + 飞书联系人/商机/项目/客诉/决策链/活动。


### IMA风格统计卡片（左彩色边框）
```html
<div style="border-left:3px solid #3b82f6;background:var(--card);padding:8px 12px;border-radius:8px;box-shadow:var(--shadow)">
  <div style="font-size:9px;color:var(--sub)">👤 联系人</div>
  <div style="font-size:16px;font-weight:700">50</div>
</div>
```
四色体系：联系人 #3b82f6 / 商机 #e94560 / 项目 #0d904f / 客诉 #e37400


### 价格单位显示
从ERP `销售单位` 列提取单位映射（ml/U/包/只/T等），价格显示为 `175.00/ml` 格式。


### ❌ 错误做法（导致重复计算）
```python
for p in products:
    if '2024' in p['years']: y2024 += p['total_amt']  # 跨年产品被重复计算！
    if '2025' in p['years']: y2025 += p['total_amt']  # 同产品计入多个年份
```


### ✅ 正确做法
```python
for year, fp in [('2024','2024全年订单.xlsx'), ('2025','2025全年订单.xlsx'), ('2026','2026H1.xlsx')]:
    df = pd.read_excel(fp)
    for _, r in df.iterrows():
        cust_yearly[r['客户']][year] += r['价税合计']
```

输出 `数据/cust_yearly.json`。更新 profile 时注入 `sales_2024/25/26` 字段。

## 产业年度 + 集团层级双重显示（Hero 模式）

档案 Hero 底部逐年行同时显示集团累计和本客户逐年：

```
集团: 圣湘集团 📅1,354w  24:695w 25:561w 26:98w  |  本客户 24:668w 25:469w 26:37w
```

数据源：`cust_yearly.json` + `customer_groups.json` 集团映射。
- `group_sales_2024/25/26`：同集团所有客户累加
- `sales_2024/25/26`：本客户单独值
- 2026 年蓝色高亮（`color:#4fc3f7`）

## 工具管理页面模式（2026-07-22）

统一入口汇总所有 HTML 工具，支持管理模式下的软删除+清理脚本生成。


### 核心机制

- **硬编码工具清单**：29个HTML文件按 name/desc/core 维护在 JS 数组中
- **管理模式开关**：toggle → 每个卡片出现 🗑 删除 / ↩ 恢复按钮
- **localStorage 持久化**：删除记录存 `baorui_tool_removed` 数组，刷新不丢
- **清理脚本生成**：将所有已删除文件生成 `rm -f` bash 脚本，复制→终端运行


### 关键代码

```javascript
var removed = JSON.parse(localStorage.getItem('baorui_tool_removed') || '[]');
function remove(name) { removed.push(name); save(); render(); }
function genScript() {
  var lines = ['#!/bin/bash', 'BASE="$HOME/Desktop/Hermes输出-工作类/工具"'];
  removed.forEach(function(r) { lines.push('rm -f "$BASE/' + r + '"'); });
  // 生成可复制的 bash 脚本
}
```


### 四张表

| 表 | 宝锐定制 |
|----|---------|
| 客户信息表 | 行业类型(IVD/科研/经销)、报证阶段、原料供应商(诺唯赞/菲鹏/翌圣)、匹配货号 |
| 线索商机表 | 商机性质(存量替换/增量新项目/报证)、阶段(送样→验证→小试→商务→成交)、竞品对比 |
| 执行保障表(5C) | 5分类：送样测试/客户拜访/商务报价/技术支持/内部协调，含责任人+完成标志 |
| 业务指导书 | 经验卡：标题/场景/问题/判断/做法/注意事项/来源 |


### 文件

`工具/宝锐三表一书.html` — 四Tab纯HTML，所有字段为可编辑表单，placeholder 均为宝锐诊断原料场景示例。


### 设计要点

- **部门分流顶栏**：🧬诊断原料 / 🔬生命科学 / 💊生物医药 / 🌍国际业务
- **作战卡动态生成**：根据客户商机中的 `type`（存量/增量）匹配不同问题模板
- **待办推进独立**：使用 `copilot_todos` 而非销售工作台的 `baorui_todos`，**完全隔离**
- **经验库种子数据**：初始3条手写示例（赢单/丢单/话术），后续用户通过CRUD维护


### 文件

`工具/宝锐销售Copilot.html` — 413行，零外部依赖，fetch JSON + localStorage CRUD。

## 🗄️ H1/H2 数据分层归档架构（2026-07-24）★新范式

详见 `references/h1-h2-data-archiving.md`。


### 归档规则
- **H1（≤6/30）**：全部归档，仅保留汇总指标
- **H2 已签收＞7天**：自动归档至H1，从实时数据移除
- **H2 在途/未签收**：保留实时，cron每日刷新


### 对比维度

| 维度 | 说明 |
|------|------|
| 修改建议 | 是否切中实际业务痛点 |
| 设计思路 | 可操作性 vs 理论性 |
| 陷阱识别 | 是否覆盖实战常犯错误 |
| 价值主张 | 一句话是否精准有力 |


### 课题组去重
生命科学视图 `_rz-opbvg` 中 `work_item_name` 可能重复，须按名称去重后再展示。


### 关键防护
- 所有 `mcp()` 调用必须 try/except + None 检查
- `list_related_workitem` 返回的 list 要 `or []` 兜底
- 每5个输出进度行，崩溃后可从中断点续跑
- 生成完成后批量更新索引 JSON 的 summary counts


### 进度监控HTML
自刷新页面（3s），从JSON索引读取 `contacts` 字段存在性判断完成度，显示进度条+最新条目列表。

```javascript
setInterval(function(){
  fetch('/数据/pricing/_key_customers.json?v=1').then(function(r){return r.json();}).then(function(key){
    var done=key.filter(function(c){return c.contacts!==undefined;}).length;
    document.getElementById('bar').style.width=Math.round(done/TOTAL*100)+'%';
  });
},3000);
```

## 客户归属隔离的数据模块（新增数据类标准做法）

新增任何「按客户隔离」的数据（客户特殊要求、测试反馈、定制配方…）时：

**🔴 数据文件里绝不写 owner/salesperson 字段。** 权限每次请求实时查 `数据/pricing/_key_customers.json` 的 `salesperson`。理由：人员离职/客户交接后，冗余写在数据里的归属会陈旧——`customer_sp_map.json` 就是反面教材（客户已交接，owner 仍指向已离职销售员）。数据文件只存业务事实，归属只有一份真相源。

| 层 | 做法 |
|----|------|
| 数据 | `数据/<模块>.json`，只存业务字段 + `created_by`/`updated_by`（审计用，**不参与鉴权**） |
| 后端 | 复用 `脚本/ai_chat.py` 的 `_visible_for_user(user)`；写操作校验目标客户归属，越权**直接拒绝** |
| 前端 | 只做展示过滤，**绝不用前端过滤当权限**（可伪造）；页面显示「我可查看客户 N 个」便于用户自查 |

**验证用真实 HTTP 请求跑三方对照**：归属人（可见 + 可写）、admin/gm（全量）、非归属人（0 条 + 越权写入被拒）。只在前端点一遍不算验证——前端是表现，后端才是权限。

**一条业务信息通常有两个落点**：① 客户档案详情页内的区块（空态给「＋添加」深链）② 独立统计表页（支持 `?customer=xxx` 深链预填，从档案页带客户名跳进来直接开新增弹窗）。两处共用同一后端端点，不要各写一套过滤。

## 前端 API 端点探测 + 乐观更新（隧道/本地双环境）

工具页在本地（`127.0.0.1:8765`）和线上（CDN + 隧道）都要能跑，端点不是常量而是候选表：

1. 按运行环境排序候选：`file:`/`127.0.0.1` 打开 → 本地端点优先；线上 → 隧道优先。
2. **探活成功的端点写进 sessionStorage，后续调用直连**。不缓存的话每次调用都要先把坏端点探一遍（探活带超时），单次保存从 <1s 掉到数秒，用户会直接反馈「点了没反应」。
3. **保存成功后乐观更新本地数组并重渲染**，不要整页重新 fetch。实测新增从数秒降到 ~500ms。
4. **🔴 401/403/5xx 必须换下一个基址，不能「拿到 JSON 就 resolve」**。公网 Tunnel 对无效 token 返回的是 `401 + JSON 体`，写成 `.then(r=>r.json()).then(resolve)` 会把它当成「成功响应」——**永远不会试本地端口**，整页卡在「登录已过期」而功能全废，且换个打开方式就正常，根因极难定位。正确：`.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(); })`。全部基址都失败时**返回保存的 lastAuthErr**（否则用户看到“接口不可用”而不是本该看到的“请重新登录”，错误归因跑偏）。
5. **同源优先**：页面若由本地服务提供（`location.origin` 匹配 `127.0.0.1|localhost`），把它放到基址数组第一位——本地实例自洽，不依赖 Tunnel。上传接口同坐这个坑（multipart 的 fetch 也要跳基址；**FormData 重试时要重建**）。
6. 输入型子页（销售填写类）的完整做法：见 `references/collection-form-page.md`（xlsx→schema、两级级联选择、动态表单、自动填充顺序无关、草稿、上传、AI 结构化、权限、演示版/离线版生成、页首「使用流程说明」卡片+范围声明）。

```javascript
var API = sessionStorage.getItem('bior_xxx_api') || '';   // 首次探测成功后写入
async function call(path, opt){ return (await fetch(API + path, opt)).json(); }
// 保存成功后：本地 push + render()，不重拉整表
```

## 浏览器测试工作台页面（飞书门禁 + CDP 限制）

- **飞书门禁**：`index.html`、`客户360档案.html` 等主页面检测 UA，普通浏览器只显示「请在飞书内打开」。测本地/CDN 时用 CDP 覆盖 UA 绕过：`cdp('Emulation.setUserAgentOverride', userAgent='... Mobile/15E148 Lark/7.20.0')`。这是 **target 级**设置——新建 tab 后要重设，同 tab `goto_url` 会保留。
- **CDP `Runtime.evaluate` 单次约 5 秒上限**：含 `sleep`/轮询的等待必须拆成多次短调用（先 reload + 等待，再查询），否则整段超时白跑。
- **CDN 测试域名首访拦截页**：先掉进 CloudBase「风险提醒」页，用 `cdp('Accessibility.getFullAXTree')` 找「确定访问」节点、`DOM.getBoxModel` 取真实坐标点击才进得去。

## 工作台后端接口速查（补 auth 章节）

- **发 token 的端点是 `POST /api/auth/token`**；`/api/login` 只校验身份，**响应里没有 token**。写鉴权/权限测试脚本前先确认这点，否则整轮白跑。
- 新端点注入：在 `脚本/销售工作台服务.py` 的 `do_GET`/`do_POST` 里找同类端点（如 `/api/permissions`）作锚点插入；改完 `launchctl kickstart -k gui/$(id -u)/ai.hermes.workbench` 重启再测。



### MCP 自动填表模式（2026-07-25）

从飞书项目 MCP 拉取客户商机+联系人数据自动填入三表一书：

**架构**：
```
飞书项目MCP → Agent生成JSON缓存 → HTTP API(/api/feishu/customer_data) → 三表一书HTML fetch
```

**API端点**（在`销售工作台服务.py`中添加）：
```python
FEISHU_DATA_FILE = os.path.join(DATA_DIR, 'customer_feishu_data.json')

# GET /api/feishu/customer_data?customer_id=xxx
# 读取本地 JSON 缓存，返回商机列表 + 联系人列表
```

**数据缓存格式**（`数据/customer_feishu_data.json`）：
```json
{"4196204802": {
  "customer_id": 4196204802,
  "customer_name": "圣湘生物科技股份有限公司",
  "opportunities": [{"id": "4207608012", "name": "圣湘-HPV熔解曲线"}, ...],
  "contacts": ["孙青枝", "方红梅", "纪博知", "邓娜（采购）", ...]
}}
```
数据来源：MQL `SELECT name, field_79aa0b FROM 销售管理.客户 WHERE name LIKE '%客户名%'` 获取商机列表；`field_555761`（客户关联联系人）获取联系人列表。

**三表一书前端**：
- URL参数：`宝锐三表一书.html?customer=圣湘生物&customer_id=4196204802`
- 自动显示飞书面板（商机数/联系人数）
- 商机下拉选择→「填入表单」按钮→自动填写Tab1+Tab2
- **关键人物下拉**：从联系人列表选择（而非手动输入），同时保留手动输入框作为备用
- 🔴 **关键人物 ≠ 当前负责人**：三表一书的「关键人物」是客户侧联系人，从飞书项目 `客户.客户关联联系人`（field_555761）获取。**不可用商机的 `当前负责人`**（宝锐内部人员）填充

**工作台入口**：客户页面每行增加📋按钮：
```javascript
(c.fid ? '<a href="/工具/宝锐三表一书.html?customer=' + encodeURIComponent(c.name) +
 '&customer_id=' + c.fid + '" ...>📋</a>' : '')
```
⚠️ 必须用绝对路径`/工具/...`（以`/`开头），不能用相对路径`工具/...`，否则从`/工具/销售工作台.html`跳转会变成`/工具/工具/宝锐三表一书.html`。

## 宝锐销售Copilot四Tab模式（2026-07-23）★新范式

从"访前准备中心"升级为全周期销售Copilot，覆盖访前→访中→访后→经验沉淀。核心理念："让销售拜访前知道问什么、拜访中知道说什么、拜访后知道联合谁推进——AI做参谋，人不被替代，每一次赢单与丢单都变成团队的弹药"。


### 登录代码模式

```javascript
// 加载用户列表到下拉框（必须在匿名登录完成后调用）
function loadUserList() {
  cbDb.collection('user_roles').limit(100).get().then(function(res) {
    var users = res.data || [];
    users.sort(function(a,b) { return (a.name||'').localeCompare(b.name||'','zh'); });
    var sel = document.getElementById('loginRole');
    sel.innerHTML = users.map(function(u) {
      var badge = u.role === 'admin' ? '👑' : u.role === 'manager' ? '📊' : '💼';
      return '<option value="'+esc(u.name)+'">'+badge+' '+esc(u.name)+' - '+esc(u.dept)+'</option>';
    }).join('');
  }).catch(function() { /* Fallback: local user list */ });
}

function doLogin() {
  var name = document.getElementById('loginRole').value;
  var pwd = document.getElementById('loginPwd').value;
  cbDb.collection('user_roles').where({ name: name }).get().then(function(res) {
    var user = res.data[0];
    if (user.password !== pwd) throw new Error('密码错误');
    finishLogin(user);
  });
}

function finishLogin(user) {
  state.user = {
    name: user.name, role: user.role, dept: user.dept,
    tabs: user.tabs || {}, email: user.email || ''
  };
  localStorage.setItem(ROLE_KEY, JSON.stringify(state.user));
  showApp(); loadData();
}
```


### 预置图片回退加载（localStorage 太大时用文件路径）

当预置图片总量超过 localStorage 5MB 限制时，改用文件路径（非 base64）。HTML 在 localStorage 为空时自动从 `image_paths.json` 加载预置图片。

```javascript
// 并行加载数据+预置图片映射
var preloadedImages={};
Promise.all([
  fetch('/数据/xxx.json').then(r=>r.json()),
  fetch('/数据/xxx_images/image_paths.json').then(r=>r.json()).catch(()=>({}))
]).then(function(results){
  DATA=results[0]; preloadedImages=results[1]; render();
});

// 渲染时：localStorage 优先，fallback 到预置文件路径
var savedImgs=uploadedImages[key];
if(typeof savedImgs==='string') savedImgs=[savedImgs];
if(!Array.isArray(savedImgs)) savedImgs=[];
var usePreloaded=(savedImgs.length===0 && preloadedImages[key] && preloadedImages[key].length>0);
var allImgs=usePreloaded?preloadedImages[key]:savedImgs;
// 预置图片为只读（无删除按钮），用户仍可上传新图片覆盖
```

**image_paths.json 格式**: `{"0": ["/数据/xxx_images/img_0_0.jpeg", "/数据/xxx_images/img_0_1.png"], ...}` — key 为记录索引，value 为相对路径数组。

**从 Excel DISPIMG 提取图片**：见 `baoyu-workbench-mcp` → `references/excel-dispimg-extraction.md`

## 数据管道：MCP飞书项目（主数据源）

所有实时数据通过 MCP 直连飞书项目，Python 管道脚本写在 `数据/` 目录。


### 🔴 AUTH 迁移陷阱

| 陷阱 | 现象 | 修复 |
|------|------|------|
| `AUTH[state.user].name` | `Cannot read properties of undefined` | state.user 现在是对象，直接 `state.user.name` |
| `state.user === 'gm'` | 永远 false（对象 ≠ 字符串） | 改为 `state.user.role === 'admin'` |
| loadUserList 在匿名登录前调用 | 数据库无权限 | 必须在 `auth.anonymousAuthProvider().signIn()` then 块内调用 |
| localStorage 存 role id | 旧格式 `"gm"` → JSON.parse 失败 | 改为 `JSON.stringify(state.user)` 存完整对象 |
| `AUTH[state.user].label` | undefined | 改为 `state.user.name` |
| **CloudBase 自动屏蔽 `password` 字段** | 登录永远"密码错误"，前端读到 `***` | 🔴 **必须将密码字段命名为 `pwd`**！！`$rename` 迁移所有文档 |
| **AUTH 对象残留引用** | 登录成功但 `Cannot read properties of undefined (reading 'name')` | 全局搜索 `AUTH[` 清除所有硬编码引用（getKPI/getSalespersonNames/renderVisitPrep等） |

## 增强待办分类（7类+备注）模式（2026-07-13）

```javascript
var CATS={prep:'🔵 访前准备',post:'🟢 访后跟进',complaint:'🔴 客诉',
          custom:'🟣 定制需求',meeting:'🟡 会议邀约',travel:'✈️ 出差计划',other:'⚪ 其他'};
var CAT_COLORS={prep:'#3b82f6',post:'#0d904f',complaint:'#d93025',
                custom:'#8b5cf6',meeting:'#e37400',travel:'#0891b2',other:'#9aa0a6'};
```

待办输入增加 `备注` 字段：`<input id="todoNote" placeholder="备注…">`，存取 `t.note`。

## 待办结论关闭工作流


### Prefect管道模式（mcp_prefect_pipeline.py）
替代裸Python脚本，提供自动重试+日志追踪：
```python
from prefect import flow, task, get_run_logger

@task(retries=3, retry_delay_seconds=5)
def mcp_call(method, args): ...

@task(retries=2)
def pull_july_orders(): ...

@task(retries=1)
def enrich_customers(orders): ...

@flow(name="MCP销售管道")
def mcp_sales_pipeline():
    orders = pull_july_orders()
    orders, cache = enrich_customers(orders)
    result = build_and_save(orders, cache)
    return result
```
优势：自动重试、结构化日志（Task名+时间戳）、Flow ID追踪每次运行。


### 新增 Tab 四步法

```javascript
// 1. TABS数组新增条目
{ id: 'visitPrep', label: '访前准备', icon: '📝' },
{ id: 'myActivities', label: '我的活动', icon: '📅' }
// 2. ENDPOINTS新增数据源（如需要新JSON）
['myActivities', '../数据/my_activities.json']
// 3. render() dispatcher新增case
case 'visitPrep': renderVisitPrep(main); break;
case 'myActivities': renderMyActivities(main); break;
// 4. 新增独立render函数
```

**新 Tab 默认可见**：`renderNav()` 的规则是「admin 全可见；非 admin 只有显式配 `tabs[id]=0` 才隐藏」，所以**新增 Tab 不用改 `user_roles.json`**，所有销售默认能看到。

**四步法之外还有两处必改，漏了就“看不见/点不燃”**：

5. **权限面板要同步登记**：`工具/index.html` 的 `PERM_TAB_LABELS` 数组加上 `['<tabId>', '<名称>']`，否则「数据管理 → 权限」里根本看不到这个 Tab，无法按人开关。（`TABS` 里的 `adminOnly: true` 则限 admin/gm 可见。）
6. **子页用 iframe 内嵌（复杂表单/多字段页面）**：`render<Xxx>(main)` 里塞一个同源 iframe（`/工具/<页>.html?v=YYYYMMDD`），草稿/登录态用 localStorage 共享（`bior_auth_token` / `bior_sw_user_v2`），免二次登录。后端新端点插到 `脚本/销售工作台服务.py` 的 `do_GET`/`do_POST` 同类端点作锚点，**新增 `/api/` 路径会受全局鉴权兜底**（除登录类），这是预期的，不是没写完。
7. **新增 Tab 的页面记得加无缓存 meta**（`Cache-Control:no-cache,no-store`），否则用户强刷前一直看旧版。


### 关键代码模式

```javascript
// 动态作战卡生成
function genBattleCard(){
  var custOpps = DATA.opps.filter(/* 客户名模糊匹配 */);
  var hasNew = !!types['增量'], hasStock = !!types['存量'];
  // 研发侧：新项目适配/存量稳定性/技术痛点
  if(hasNew) rd.push('新项目适配：…试剂测试进展如何？…');
  if(hasStock) rd.push('存量项目稳定性：最近批次有无批间差反馈？…');
  // 采购侧：竞品价格/采购周期
  // 老板侧：报证计划/国产替代意愿
}
```


### 新增Tab四步法

```javascript
// 1. TABS数组新增条目
{ id: 'orders', label: '订单管理', icon: '📦' }

// 2. ENDPOINTS新增数据源
['shipments', '../数据/shipment_status.json']

// 3. render() dispatcher新增case
case 'orders': renderOrders(main); break;

// 4. 新增独立renderOrders()函数
```
