# Form 校验引擎（APID-02）设计

> **状态**：design approved（owner 委托架构决策，Claude 自查 CE 机制后采用推荐方案 C）· 2026-07-21
> **对齐**：backlog [[INFRA-F68]] APID-02（P0，产品构建层）· PROJECT_GOAL 能力 1（dev 可消费）+ 能力 4（页面级合成底座）
> **排期依据**：tracker §排期原则权重 #2「依赖解锁」——解锁 FORM-01 表单方法论 + PAT-01 pattern 层
> **enforcement 归属**：新增组件走 0.x→1.x minor（向后兼容加法）；`.vue` 改动受视觉门（VISUAL_COMMIT_APPROVED）

---

## 1. 目标与范围

在成熟的展示外壳 `FormItem` 之上，补一层缺失的**行为编排层**（容器 + 数据模型 + 规则 + 校验器 + 触发），让消费者能声明式定义表单校验，对齐 Element Plus `el-form` 心智。

### MVP 收入（owner 拍板）
- `Form` 容器：`model`（数据对象）+ `rules`（校验规则）
- `FormItem` 扩展：`prop`（字段路径）+ `rules`（字段级规则）
- 内置校验器：`required` / `type`(string·number·integer·email·url) / `min`·`max`·`len`（数值域 + 字符串长度）/ `pattern`（正则）/ 自定义同步 `validator` 函数
- 触发时机：`blur` / `change` / `submit`（rule 级 `trigger` 配置，默认 `change`）
- 方法：`validate()` / `validateField()` / `resetFields()` / `clearValidate()`
- 校验结果自动回填各 FormItem 既有 `error` + `status` prop

### 显式不做（列 v1.x 后续增量，非本轮）
- 异步/远程校验器 + `validating`(loading) 态 → **需 Figma 新视觉源**，落多级态档
- 跨字段联动校验（如"确认密码"）
- 动态增删字段（FormList / 数组表单）
- warning / success 多级校验态 → 需 Figma 新视觉源

### 态范围（owner 拍板 A = 二值 pass/fail）
校验只有"通过 / 不通过"，不通过写既有 `error`(string) + `status='Error'`。**零新增 Figma 源**——FormItem 的 Error 视觉已是 Figma 源（Normal/Error 64 变体，verified）。

---

## 2. Figma-first 合规判定（硬规则 #1 / FIGMA_AS_SOURCE_OF_TRUTH）

- **FormItem 错误态视觉无需新 Figma 源**：`error` + `status=Error` + `required` 星号 + 红 message + CANONICAL-019（label 不变红）均已实现 + Figma 覆盖 Normal/Error。引擎只在运行时把结果写进这些既有 prop。
- **Form 容器 = 合法「拓扑合成」divergence**：Form 包多个 FormItem Figma 单元，无独立 Figma 视觉（唯一"视觉"是字段间纵向 gap = layout token）。这属 FIGMA_AS_SOURCE_OF_TRUTH 合法 divergence 表的「拓扑合成」行（同 `steps-item-container-topology` / `breadcrumb-item-container-topology` / button-eight-sets 范式）。
  - **动作**：在 `src/design-system/translation/divergences-decisions.json` 登记 `form-container-topology`（性质：拓扑合成；code 提供合成层包 Figma FormItem 单元）。
- **结论**：MVP 零新增 Figma 源，Figma-first 门不成立；仅需 1 条 divergence 登记。

---

## 3. 架构（三层解耦 · isolation 原则）

| 模块 | 职责 | 依赖 |
|---|---|---|
| `src/canonical/composables/form/validators.ts` | 纯函数校验器 + rule[] runner（值 → `error string \| ''`） | **零框架依赖**（TDD 首要靶） |
| `src/canonical/composables/form/formEngine.ts` | **框架中立引擎核** `createFormEngine`：字段注册表 + error 状态 + `validate/validateField/resetFields/clearValidate` + **`subscribe(listener)`** pub/sub | 只依赖 validators.ts；**零 Vue 组件依赖** |
| `src/canonical/composables/useFormContext.ts` | **CE-safe accessor**：FormItem 向上找父 Form 的 engine。三分支复刻 `useHasSlot()`（SFC → `inject`；CE → `getCurrentInstance().ce` 取 host + `host.closest('tvu-form')` 读父 host 上的 engine） | — |
| `src/canonical/Form.vue`（+ `src/components/Form/` base） | 容器：props `model`/`rules`/`labelWidth?`/`layout?`/`disabled?`；创建 engine；CE 下把 engine 挂到 host（`host._tvuFormEngine`）供子发现；`defineExpose` 暴露 4 方法 | engine + accessor |
| `src/canonical/FormItem.vue`（+ base，**扩展现有**） | 加 `prop?`/`rules?`；在 Form 内经 accessor 注册 + **`subscribe` 引擎更新本地 ref** → 绑既有 `error`/`status`。**独立使用（无父 Form）行为完全不变 = 向后兼容** | accessor + engine |
| `react-pilot` Form/FormItem bindings | 复用**同一引擎核**（硬规则 #8 双框架 parity，避免逻辑漂移） | engine 核 |

### 为什么引擎核用显式 `subscribe()` 而非 Vue 响应式
跨 `defineCustomElement` 边界的 error 状态**必须响应式**（blur/change/validate 后更新），但 `useHasSlot` 文档明确记载「响应式转发链在 CE 下传不及时」。故引擎核不依赖 Vue 响应式穿透边界——FormItem `subscribe` 引擎、在回调里更新本地 ref。这也让引擎逻辑能脱离组件挂载直接 TDD，并被 React pilot 复用。

---

## 4. 公开 API（el-form 风格，对齐参考站）

```vue
<Form :model="state" :rules="rules" ref="formRef">
  <FormItem prop="email" label="Email" required>
    <InputBoxLine v-model="state.email" />
  </FormItem>
  <FormItem prop="port" label="Port" :rules="[{ type:'integer', min:1, max:65535, message:'1-65535' }]">
    <InputNumber v-model="state.port" />
  </FormItem>
</Form>
```

```ts
// rules 形态（el-form 对齐）
interface FormRule {
  required?: boolean
  type?: 'string' | 'number' | 'integer' | 'email' | 'url'
  min?: number      // number: 值下限；string: 长度下限
  max?: number
  len?: number      // 精确长度
  pattern?: RegExp
  validator?: (value: unknown, model: Record<string, unknown>) => true | string  // sync
  message?: string  // 覆盖默认文案
  trigger?: 'blur' | 'change' | ('blur' | 'change')[]  // 默认 'change'
}
type FormRules = Record<string, FormRule | FormRule[]>

// formRef 暴露方法
formRef.validate(): Promise<{ valid: boolean; errors: Record<string, string> }>
formRef.validateField(prop: string): Promise<string /* error or '' */>
formRef.resetFields(): void
formRef.clearValidate(props?: string | string[]): void
```

- 引擎**直接读 `model[prop]` 跑规则**——不需要子组件上报值（关键简化）。
- 规则合并（消歧）：FormItem 的 `rules`（字段级）与 Form 的 `rules[prop]`（表级）**合并**，两者都跑，字段级追加在表级之后（不覆盖）。

---

## 5. 数据流与触发

1. FormItem 经 accessor 向 engine `registerField(prop, getRules)`（unmount 时 `unregisterField`）。
2. **change 触发**：Vue Form 适配器 `watch` `model[prop]` → `engine.validateField(prop,'change')`。
3. **blur 触发**：FormItem 在插槽 wrapper 元素挂原生 `focusout`（冒泡、CE-safe DOM）→ `engine.validateField(prop,'blur')`。
4. **submit 触发**：`formRef.validate()` → engine 校验全部注册字段。
5. engine 跑规则 → error map → 通知 subscribers → 各 FormItem 回填 `error`+`status`。

---

## 6. 测试策略（TDD · 硬规则 #8 双框架 parity）

| 层 | 测试 | 备注 |
|---|---|---|
| `validators.ts` | 纯 vitest 单测：每个校验器 + 边界（空值/类型误配/域边界） | TDD 首写 |
| `formEngine.ts` | 纯 vitest 单测：register/validate/validateField/reset/clearValidate、rule[] 数组、trigger 过滤、subscribe 通知 | 脱离组件 |
| `useFormContext.ts` | 跨 SFC / shadow-DOM CE / light-DOM CE 三态（复刻 `useHasSlot` 测试范式） | **第一步 spike 靶** |
| `Form.vue` / `FormItem.vue` | 组件测（SFC）+ **CE 跨边界响应式订阅 spike**（截图/DOM 断言验证 error 回填在 CE 构建里真生效） | de-risk 优先 |
| 双框架 parity | Vue playground `FormPage.vue` + React pilot Form demo：多字段真实校验 + slot 投影 + 一个 boolean（`disabled`）live 切换 | 硬规则 #8 gate |
| Sprint 收尾 | D/E/F/G/H/K audit + `demo-slot-boolean` + render-drift-gate | 收尾必跑 |

### 关键风险（plan 第一步 de-risk）
**向上 CE accessor + 跨边界响应式订阅**是最高风险块（比 useHasSlot 一次性读更难）。plan 第一步必须先跑 spike 验证：FormItem 能在 shadow/light CE 构建里 `closest('tvu-form')` 找到父 engine 并响应式收到 error 更新。spike 不通过则回退 A 的方案 B 兜底（显式 `:form` prop 穿线）。

---

## 7. 交付清单

- `changeset`：**minor**（新 Form 组件 + FormItem 加新 prop，向后兼容加法）
- `divergences-decisions.json`：登记 `form-container-topology`
- `component-affordances.json`：新增 Form 条目 + 更新 FormItem `composition.contained_by: [Form]` + `key_events`（FormItem 触发点）；跑 `generate:component-affordances` 重生成 `.md`
- Docs：`FormPage.vue`（Vue）+ `react-pilot` Form demo；注册 DocsShell/navigation/CanonicalPageId + site-review-manifest 声明
- `src/index.ts`：导出 Form（canonical）
- 视觉门：Form/FormItem `.vue` 改动 → commit 时 `VISUAL_COMMIT_APPROVED`（owner 审）

---

## 8. 分阶段实施（供 writing-plans 细化）

- **Stage 0（spike · de-risk）**：CE 跨边界 accessor + 响应式订阅最小验证。不过 → 回退兜底方案。
- **Stage 1（引擎核，TDD）**：`validators.ts` + `formEngine.ts` 纯逻辑 + 单测（无组件）。
- **Stage 2（Vue 组件）**：`Form.vue` + FormItem 扩展 + `useFormContext`；SFC 组件测。
- **Stage 3（触发接线）**：change/blur/submit 三触发 + resetFields/clearValidate。
- **Stage 4（双框架 + docs）**：React pilot binding（复用引擎核）+ FormPage + 双框架 parity gate。
- **Stage 5（交付）**：divergence + affordance + changeset + 收尾 audit + 视觉门 commit。
