# 工作台「定向收集」表单页模式（输入型子页）

前半台已有大量**只读看板**（数据 → 渲染）。本文件管的是**输入型子页**：销售在飞书里填写 → 后端落盘 → AI 结构化 → 导出文档。典型需求：“让销售把客户测试反馈按格式交上来”。

## 总体结构

```
用户给的 Excel/Word 模板
   ↓ ① 抽成 JSON schema（表单的**唯一数据源**，不要在 HTML 里手写字段）
数据/<模块>_schema.json
   ↓ ② 子页 + Tab 注册（iframe，与 宝锐活动录入.html 同模式）
工具/<收集页>.html
   ↓ ③ 后端三/四个端点（撑在本工作台服务.py）
/api/<模块>/schema  options  save  structure
   ↓ ④ 落盘
数据/<模块>/<ID>.json + _index.json
```

## ① 从 xlsx 模板抽 schema（openpyxl）

抽脚本**存档到 `脚本/extract_<模块>_schema.py`**（不要只放 /tmp）——模板会被用户迭代，重跑一次就能刷新，不用重写逻辑。实操样例见 `脚本/extract_test_report_schema.py`。

```python
# 章节正则要含到「六」以上：模板常有「六、跟进记录」之类，只匹 一-五 会把章节标题当成字段
if re.match(r'^[一二三四五六七八九十]+、', a): sections.append({'title': a, 'fields': []})
# 下拉：用 ws.data_validations 的 sqref 坐标反推每行的选项
# 必填：模板里用 `**` 前缀标记 → required=True，渲染时转成红 *
# 字段类型：有 options → select；“1-5分” 之类 → rating；带单位/数值量纲 → number；含「描述/建议/记录」→ textarea

# 🔴 纸质表单行不进网页字段（签字/签名/盖章/签收）——用户会要求删掉
SKIP_LABEL = re.compile(r'(签字|签章|签名|盖章|签收)')
if SKIP_LABEL.search(a): continue      # 如「客户确认签字：____ 日期：____ 销售确认：____」
```

**抽完先抽样打印**（产品数 / 总字段数 / 前三个字段的 type+options）再写前端。字段量级：20 个方向約 600+ 字段。
schema 由接口实时下发（`_send_json` 带 `no-store`），**改 schema 不用重新部署页面**，刷新即生效。

## ② 表单页渲染铁律

- **绝不因输入而重渲染**。输入/下拉/评分全部只写 `VALUES[key]` 与草稿，**不重建 DOM**（否则中文输入法被打断、焦点丢失）。
- 统一用 `data-k` 委派：`#formBox` 上挂 `input`/`change`/`click` 三个代理，不逐个绑。
- 评分用**按钮组**（`5 个 button[data-val]`），点击只切 class，不重渲染。
- 下拉/搜索选人用**输入框 + 下拉列表**（`.pick`/`.pick-list`），列表只在 `input` 时局部重建；`blur` 用 `setTimeout(180ms)` 延后关闭，否则点不到候选项。
- **已从单据知道的字段不要重复让销售填**：选订单后自动回填客户/销售/日期，选货号后回填名称与规格，并把这一项从必填校验里排除——否则用户会当场反问“这个不是已经选了吗”。

### 🔴 自动填充必须与操作顺序无关

标记「可自动带出」**只看标签是否命中，不看此刻是否已有值**：

```js
// ❌ 错：先选产品、后选订单时，「客户/负责销售」永远补不上（标记时 auto 值为空 → 不置 __auto → 后续同步跳过）
if (f.label.indexOf(k) >= 0 && auto[k]) { f.__auto = true; ... }
// ✅ 对：只要标签命中就置 __auto；有值就先填，没值等订单来了再同步
if (f.label.indexOf(k) >= 0) { f.__auto = true; var v = auto[k]; if (v && !VALUES[f.key]) VALUES[f.key] = v; }
```

配套：订单/客户变化后调 `applyBasicToDOM()`（只改那几项 input.value + `VALUES`，**不整表重渲染**）；草稿恢复时也要回填（注意 schema 是**异步**载入的，等 schema 到了再回填两级选择/字段）。

### UI 密度：多层级选择用「两级级联下拉」，不用卡片网格

用户明确反馈：把 20 个方向平铺成卡片网格「篇幅太大」。改为：

- 一级 `<select id="typeSel">` = 产品类型（`产品名（N）` 带数量）；
- 二级 `<select id="prodSel">` 初始 `disabled`，一级选中后才填充该类型下的方向；
- 该类型只有 1 个方向时**自动选中并直接载入**（少一次点击）；
- 二级下方一行 `.unit` 提示「该类型下共 N 个方向」；
- 清空重填、草稿恢复都要同步重置/回填两级（写一个 `syncProdSelects(sheet)` 复用）。

实测收益：首屏选择区高度 ~700px → **222px**。

## ③ 草稿（销售常用手机填，中途切走是常态）

```javascript
var DRAFT_KEY = 'bior_<模块>_draft_v1';   // 单一 key，存整个表单快照（含选中的单据/货号/值/图片）
// input 事件里 debounce 800ms 落 localStorage；页面头一条 draft-bar 提示“已自动保存 + 已填 N 项”
// 重开页面：检测到草稿先弹「恢复 / 丢弃」，不要静默恢复（可能是别人的旧草稿）
// beforeunload ：有内容时阻止关闭
```

`localStorage` 只做草稿（本机、可能丢）；**正式提交必须走后端落盘**。

## ④ 图片上传

直接复用 `/api/upload`（multipart，存 `数据/上传/<ts>_<名>`，返回 `{original,saved,path}`）。注意：
- **不要把 Content-Type 写死为 application/json**，multipart 必须交给浏览器自己带 boundary；
- 多基址重试时 **FormData 要重建**（被 fetch 消费后不能重用）。

## ⑤ AI 结构化 + 导出 MD

后端一个端点，system prompt 固定五条硬性要求（缺一就会开始编数据）：

1. 只用用户提供的信息，**绝不编造**；缺失项写「未提供」；
2. 保留所有数值与单位原样；
3. 固定输出结构（标题 → 基本信息 → 测试数据 → 评分 → 问题/意向 → 开放反馈 → 结论与建议）；
4. 「结论与建议」必须从填写内容推导，不确定写「需进一步确认」；
5. 直接输出 Markdown，不要解释性前言。

效果实测：会自动指出**填写内部的矛盾**（例：客户写「产量低」但给效率打 4 分），这正是这个功能对销售的价值。导出用 `Blob(['text/markdown'])` + `a.download`，文件名带方向/客户/日期。

## ⑥ 权限

- 选择项（订单/客户）**服务端过滤**：按 `order_sp_map.json` 取销售员，sales 只见自己名下；**名下为空时回退全量**，否则页面直接空白、用户以为坏了。
- 提交列表同理；落盘记录里存 `submitted_by` 供过滤与审计。
- 列表详情端点用 `?id=` 分流（同一路径）：列表只回索引摘要，`?id=` 回完整记录（values/sections/images/md）；非 admin 只能看自己的。

## ⑦ 演示版 / 离线版（自包含单文件）

用户常要“发给人看”或“不联网也能跑”的版本。**不要手工复制一份改**——写生成器 `脚本/build_<模块>_demo.py`，从正式页 + 数据文件生成（正式页改了重跑一次就同步）：

1. **内联数据**：把 `schema` + 订单 + 货号 序列化为 `window.__DEMO__ = {...}` 插到页面 `<script>` 前；订单/货号只保留精简字段（`no/customer/date/sp/dept`、`sku/name/spec/line/cat`）以控体积（实例约 400KB）。
2. **载入函数短路**：`loadSchema()`/`loadOptions()` 开头 `if (window.__DEMO__) { ...直接赋值 + render...; return; }`，不删原有接口分支。
3. **AI 换本地生成**：加 `localStructureMD(payload)`，按固定规则拼 Markdown（基本信息表 + 各段表格 + 评分均分/最低项提示 + 结论），跑 `runAI()` 时 demo 分支直接用它（**2 秒出结果，不联网**）。
4. **🔴 本地生成要跳过第一节「基本信息」**，否则和自建的基本信息表**重复出现两次「一、」**。
5. **隐藏提交按钮**并给一行说明（“演示版不提交到服务器，可直接导出 Markdown”）；顶栏加「演示版」徽标 + 顶部说明条。
6. 说明条要用 `el.textContent.trim()` 判断是否已被草稿提示占用，**不要用 `el.style.display`**（内联 `display:none` 恒为真 → 永远不显示）。
7. 验证必须用 **`file://` 直开**：能跑才叫自包含。

## ⑧ 交付页顶部的「使用流程说明」+ 范围声明

面向销售/演示的页面，用户会要求在**页面开头**放一段流程说明——不要写成正文段落，做成**置顶、可收起的一张卡片**：

1. **范围声明放最前、视觉最重**：一句话说清「本工具用于 ___ 场景」，并**明确写出它不替代什么**（例：「本工具不用于替代日常测试单反馈，日常反馈仍走原流程」）。缺这句，用户会把新工具当成旧流程的替代品用，之后再来纠正。样式用浅黄底+边框（`#fff8e6`/`#f0d080`）与正文区分。
2. **步骤 chips**：①选产品方向 → ②关联订单/货号 → ③填测试内容 → ④上传图片 → ⑤生成结构化报告 → ⑥导出/提交，每步动词开头一句话。
3. **「分工与规则」表**：列 = 步骤｜谁｜做什么｜规则，写清哪步是销售、哪步是 AI、哪步系统自动（用户要求责任到人）。
4. **补充规则列表**：草稿自动保存时机、数据落点、权限边界、校验规则、演示版与正式版差异。
5. 默认展开、可一键收起；卡片内文字**不参与**草稿/表单逻辑，纯静态。

**验证插入位置要从 body 锚点往后搜，不要从文件头搜**：
```python
b = h.find('id="wfBody"'); i = h.find('wf-intro', b); j = h.find('wf-steps', b)   # ✅
# ❌ h.find('wf-intro') 先命中 <head> 里的 .wf-intro{...} CSS 规则 → 误判「位置不对」
```
这段说明写在生成器（`脚本/build_<模块>_demo.py`）里，改一次正式版/演示版同步。

## 自检清单

- [ ] `node --check` 抽出的 `<script>` 通过
- [ ] 无 token 时 curl 得 401（鉴权生效），带测试 token 能拿到真数据条数
- [ ] 浏览器：一级下拉项数 = 产品类型数、二级项数 = 该类型下方向数、单方向类型自动选中；`.frm-row` 数 = 该方向字段数
- [ ] **两种顺序都试**：先选订单后选产品 / 先选产品后选订单——客户、销售、日期都要自动带出（`__auto` 标记 3 项）
- [ ] 选货号 → 名称/规格显示
- [ ] 填一项等 1s → `localStorage` 里看到草稿；刷新 → 弹恢复
- [ ] 点 AI → `textarea` 出现 MD，且数字与填写一致
- [ ] 演示版：`file://` 直开能走完整流程、MD 无重复章节、提交按钮不可见
- [ ] 页首流程说明卡片：范围声明排在步骤条之前、可收起、不遮挡表单
- [ ] 落盘后**回读一条记录**核对 `values/sections/md` 的长度（只看到 `ok:true` 不算通过）
- [ ] 部署三目标（中文页 + `workbench/index.html` 英文副本 + 新子页），并 `shasum -a 256` 与线上比对一致

### 验证用「临时实例 + 注入测试会话」

后端需登录态才能测，不要问用户要密码：起一个临时实例，**导入服务模块后往 `AUTH_SESSIONS` 塞一个假 token**，再在浏览器 `localStorage` 注入同一个 token，即可全链路验证（schema/options/save/structure/list）。`数据/test_reports/` 里的测试记录用完删掉。
⚠️ 临时实例要确认 `lsof -nP -iTCP:<端口> -sTCP:LISTEN` 的 PID 是刚启的那个——端口被旧进程占着时新实例会静默退出，你其实一直在测旧代码（症状：“新加的分支不生效”）。
