# cms / 从外部数据导入新 entry 的标准流程（SOP）

本文件是 [`AGENTS.md`](./AGENTS.md) 的**可选配套流程**，只讲"怎么把业务方给的数据模型 + xlsx/csv/导出 JSON 搬成一个新 entry"的操作步骤；产物的强约束（最终必须长什么样）以 `AGENTS.md` 的数据契约为准。

## 概览

落成一个新 entry（zod schema + `zh.json` + `en.json`）本质只有两步翻译动作，不要过度工具化：

- **Step 1：JSON 模型 → zod schema**（在 `data/<entry>/schema.ts` 定义 + 在 `data/index.ts` 注册）
- **Step 2：xlsx + schema → `zh.json` / `en.json`**（写一个一次性 Python 脚本即可）

一次性脚本放到 `cms/temp/_convert.py`，原始数据放 `cms/temp/<原文件>`（`cms/temp/` 已 gitignore，用完即弃、不进仓库）。

**完整流程**：入口判断（A/B）→ 核对输入/产物清单 → 填模板 → Step 1 → Step 2 → 校验与审计 → 收尾报告；踩坑先查文末「典型陷阱」。

## 入口模式（先判断走哪条）

| 模式 | 典型场景 | 输入 | 走法 |
|---|---|---|---|
| A · 有数据列表 | **迁移旧 cms 系统数据** | 数据模型（datasource JSON / 字段说明）+ xlsx/csv/导出 JSON | Step 1 → Step 2（完整流程） |
| B · 仅描述/模型 | **全新 cms 数据对象**（无历史数据） | 用户的文字描述或字段清单，无现成数据 | 只做 Step 1 生成 zod schema；`zh.json` / `en.json` 由用户后续补，或 agent 按描述生成少量样例数据并在报告中标注 `TODO: 待业务方填充` |

模式 B 没有 Step 2，也不需要写转换脚本，但「校验与审计」「收尾报告」仍然要做。

## 输入与产物清单（先看这份，再填下方模板）

**你（用户）提供，统一放 `cms/temp/`**：

| 文件 | 用途 | 用在哪一步 |
|---|---|---|
| `cms/temp/<entry>.model.json` | 数据模型：datasource JSON（低代码/weda 导出，带 `x-*`）或字段清单说明（字段/类型/是否多语言/枚举值等） | Step 1（映射见下方「datasource JSON → zod 映射表」） |
| `cms/temp/<entry>.data.xlsx`（或 `.csv` / `.json`） | 实际数据列表 | Step 2 |

**Agent 生成**：

| 文件 | 是否进 git | 说明 |
|---|---|---|
| `cms/data/<entry>/schema.ts` | ✅ | zod schema 定义（自包含，`export default` 一个 `z.array(row)`） |
| `cms/data/<entry>/index.ts` | ✅ | 条目入口，`export default defineEntry(import.meta.url, schema, locales, opts?)` |
| `cms/data/index.ts` | ✅ | 打包清单（新增条目需在此 import 并注册） |
| `cms/data/<entry>/zh.json` | ✅ | 正式产物 |
| `cms/data/<entry>/en.json` | ✅ | 正式产物 |
| `cms/temp/_convert.py` | ❌ | 一次性转换脚本，用完即弃 |
| `cms/temp/__<entry>_report.md` | ❌ | **本次生成报告**（简版，见文末模板），每次重跑覆盖，方便用户一眼看懂结果与待办 |

报告文件名用双下划线前缀 `__`，便于在 `cms/temp/` 中一眼区分"agent 产出的元信息"与"原始业务数据"。

## 输入描述模板（对照上方清单照填即可，AI 少反问）

用户把下面对应模板填好发给 agent，即可覆盖 Step 1 的全部决策点。拿不准的项 agent 会逐条追问，不会脑补。

### 模板 · 场景 A：迁移旧 cms 数据（有模型 + 数据）

```text
迁移一个旧 cms 数据对象到新 entry：
- 目录名(entry)：<如 template_scf_func>
- 数据模型：cms/temp/<datasource JSON 文件名>   （已放好）
- 数据导出：cms/temp/<xlsx 文件名>              （已放好）
- xlsx 里：语言列 = <列名>，主键列 = <列名>，
  多语言行取值形态 = <zh / en / zh-en 拆两条>
- 字段处理：
  - 丢弃这些旧列：<列名, ...>（不进 schema）
  - 改名映射：<旧列名> → <新字段名>，...
  - 主键改写(若冲突)：<旧标识> → <新主键>，...
- enum 冲突：出现模型里没有的枚举值时 → 扩 enum / 改数据 / 报错  ← 指定一种
- 资源字段(cloud:// 等)：原样保留待我清理 / 其他处理
- 发布站点(targetPlatform)：<default / intl / private>
```

最省事写法：把模型 + 数据两个文件丢进 `cms/temp/`，一句话："按 `cms/AGENTS.md` + `IMPORT-SOP.md`，把 `cms/temp/xxx.json`（模型）和 `cms/temp/yyy.xlsx`（数据）迁成 entry `<名字>`，拿不准的决策点先问我。"

### 模板 · 场景 B：全新数据对象（描述式，无数据）

```text
新建一个 cms entry：
- 目录名(entry)：<snake_case，如 product_faq>
- 用途：<一句话说明这是什么数据>
- 数据形态：多行列表(collection) / 单例配置(single)  ← 二选一
- 字段清单：
  | 字段名 | 类型(string/number/boolean/数组) | 必填? | 是否i18n(中英各译) | 枚举值(若枚举) | 说明 |
  | title | string | 是 | 是 | - | 标题 |
  | order | number | 是 | 否 | - | 排序 |
  | ... |
- 主键字段：<如 identifier>
- 顺序：按 <order> <升/降>序写入 zh.json/en.json（构建期不再排序，产物顺序=文件顺序）
- 发布站点(targetPlatform)：default / intl / private  ← 可多选
- 数据：暂无，请生成 2~3 条样例并标 TODO  /  我稍后自己补
```

## Step 1 · JSON 模型 → zod schema

输入：业务方给的 JSON 数据模型（字段清单、类型、是否多语言、枚举值等）。
输出：`cms/data/<entry>/schema.ts`（zod schema 定义，`export default` 一个 `z.array(row)`）+ `cms/data/<entry>/index.ts`（调用 `defineEntry`）+ 在 `cms/data/index.ts` 中 import 并注册进 `registry`。

**先看参考模板**：`cms/data/_example/schema.ts` 是最精简的完整样例（`.strict()`），照它的结构抄即可。构建期**不对数组数据排序**，产物行顺序取自 `zh.json`/`en.json` 本身的顺序；如需按 `order` 等字段展示，请在 Step 2 生成数据时就把行排好写入文件。各条目**完全隔离**——`schema.ts` 不互相 import，允许重复定义；如果新条目字段与某现有条目相同，直接复制一份再改，不要跨目录 import。

**必须与用户确认这些决策点（不要脑补）**：

| # | 决策点 | 写在哪里 |
|---|---|---|
| 1 | 数据形态：collection（多行）还是 single（单例）？ | `schema.ts` 的 `export default` 用 `z.array(row)` 或 `z.object(...)` |
| 2 | 哪些字段必填？是否禁止多余字段？ | zod 中必填字段不加 `.optional()`；`.strict()` 禁止多余字段（推荐，防止数据脏字段） |
| 3 | 各 enum 字段的允许值集合 | `z.enum([...])`；判定与处理规则见下方「枚举字段」小节 |
| 4 | 排序字段与方向 | 构建期不排序（规则见上文），需在 Step 2 转换脚本中按该字段排好后再写入 `zh.json`/`en.json` |
| 5 | `targetPlatform` 取值 | **必填**，默认 `["default"]`，与「条目→站点维度」一致 |
| 6 | 主键字段命名 | 推荐 `_id`（非强制，可视业务沿用 `identifier` 等其他字段名） |

### 枚举字段（重点，最容易踩坑 · 本主题唯一事实源）

**判定**：字段同时满足 `format: "x-enum"` **且**存在 `x-enum-type` 键，才判定为枚举——**不看 `isEnum` 字段真假**（`isEnum` 可能是 `false` 也仍是枚举）。

**处理**：

1. 枚举代码集合通常不在模型里（`x-enum-type: "general-option"` 只给出选项集合的名字 `x-option-name`，不给出「代码 ↔ 标签」映射）。**必须先向用户/开发者要到该选项集合的完整代码表（code + 标签）**，确认无遗漏后才能：①在 `schema.ts` 写 `z.enum([code, ...])`；②在 Step 2 转换脚本里把 xlsx 中的中文标签换成对应 code 写入 `zh.json`/`en.json`。
2. **禁止**凭 xlsx 里出现的中文猜编码或直接把中文标签当枚举值存入数据——正确存储形态是「代码」不是「标签」（类比 `lang` 存 `zh`/`en`、`targetPlatform` 存 `default`/`intl`，不存中文）。
3. 若要到的选项集合比 xlsx 实际出现的值更多，允许只取用到的子集定义 `z.enum`，但要在报告里注明「当前只覆盖已出现的 N/M 个选项」。
4. 源数据出现模型/枚举里没有的值（冲突）时，**必须先和用户确认**「扩 enum / 改数据 / 报错」三选一，不要擅自决定。

> 真实踩过的坑：`x-enum-type: "general-option"` 字段（如「所属模块」）容易被漏判成普通 `z.string()`，把 xlsx 里的中文展示标签原样写入数据（如 `module` 存成 `"云函数"` 而非 `scf`）。

### datasource JSON → zod 映射表（迁移旧 cms 数据时用）

旧 cms 的数据模型常是 **datasource JSON**（低代码/weda 数据列模型，带 `x-*` 扩展字段，如 `cms/temp/datasource_data-*.json`）。按下表把它翻译成 zod，**不要把 `x-*` 平台元信息写进 schema**：

| datasource JSON | zod 写法 | 说明 |
|---|---|---|
| `schema.required[]` 中的字段 | 不加 `.optional()` | 不在此数组中的字段一律 `.optional()` |
| `properties.<f>.type: "string"` | `z.string()` | |
| `properties.<f>.type: "number"` | `z.number()`（整数加 `.int()`） | |
| `properties.<f>.type: "boolean"` | `z.boolean()` | |
| `properties.<f>.type: "array"` | `z.array(...)` | 元素类型看 `items` / 业务约定 |
| `format: "x-enum"` **且**存在 `x-enum-type` 键 | `z.enum([...])` | ⚠️ 判定与处理规则见上方「枚举字段」小节，**不要凭 xlsx 展示文案脑补** |
| `format: "x-long-text"` | `z.string()`（可加 `.max()`） | 长文本，i18n 时**不要 strip 末尾 `\n`** |
| `x-primary-column` / 主键列 | 映射为业务主键字段（推荐 `_id`，非强制，也可用 `identifier` 等） | 用于 Step 2 唯一性校验 |
| `x-index` | 仅作字段排列参考 | **不是排序依据**（排序规则见上文「先看参考模板」段落） |
| `_id` / `_mainDep` / `x-system:true` 等系统列 | **保留，正常入 schema**（如 `_id`/`_mainDep`/`createdAt`/`updatedAt`/`owner`/`createBy`/`updateBy`/`_openid`） | 旧 cms 数据需要保留全部字段以保证使用处的兼容性；字段类型按 xlsx 实际数据形态定（如日期列若导出为字符串则用 `z.string()`，不要死抠模型里的 `type`），是否必填看该列在 xlsx 中是否存在空值 |
| 所有 `x-*` 键（`x-id`/`x-unique`/`x-keyPath`…） | **丢弃** | 这些是描述"模型字段"的元信息（挂在 `properties.<field>` 上的属性），不是数据列本身，永远不对应 xlsx 中的某一列 |
| `title` / `description`（模型里的） | 仅作字段中文名/注释参考 | 是"列的说明"，不等于业务数据字段 |

> 多语言：datasource 模型里若有 `lang` 字段（`required` 含 `lang`），说明数据按语言分行——对应 `z.enum(['zh','en'])`，Step 2 按 `lang` 拆进 `zh.json` / `en.json`。

## Step 2 · xlsx + schema → zh.json / en.json

直接写一个一次性 Python 脚本 `cms/temp/_convert.py`，结构按下面的骨架。**不需要也不应该**抽出独立工具或配置文件。

```python
# cms/temp/_convert.py
# pip3 install --user openpyxl
import json
from pathlib import Path
import openpyxl

ROOT = Path(__file__).resolve().parents[1]   # cms/
SRC  = ROOT / "temp" / "<原文件>.xlsx"
OUT  = ROOT / "data" / "<entry>"

# —— 列名 → schema 字段 的映射，决策点全部声明在此 ——
NON_I18N_MAP    = { "源列名": "schema字段名", ... }   # 普通 string / 直传字段
I18N_MAP        = { "模板标题": "title", ... }         # i18n 字段（zh / en 各填各的）
INT_COLS        = { "排序": "order", ... }
BOOL_COLS       = { "是否是编译型语言": "isCompile", ... }
JSON_ARRAY_COLS = { "函数类型": "funcTypes", ... }
IDENTIFIER_OVERRIDES = {                               # 主键冲突的显式改写
    "<xlsx 数据标识列的值>": "<新主键>",
}

def build_record(row, lang):
    rec = {}
    for col, key in NON_I18N_MAP.items():
        v = row.get(col)
        if v not in (None, ""):
            rec[key] = v.strip() if isinstance(v, str) else v
    # 主键改写
    if row.get("数据标识") in IDENTIFIER_OVERRIDES:
        rec["identifier"] = IDENTIFIER_OVERRIDES[row["数据标识"]]
    # i18n：长文本不要 strip，末尾 \n 影响 Markdown 渲染
    for col, key in I18N_MAP.items():
        v = row.get(col)
        if v not in (None, ""):
            rec[key] = v
    for col, key in BOOL_COLS.items():    # 解析 是/否/true/false
        ...
    for col, key in INT_COLS.items():
        ...
    for col, key in JSON_ARRAY_COLS.items():   # json.loads 后必须是 list
        ...
    rec["lang"] = lang
    return rec

def main():
    wb = openpyxl.load_workbook(SRC, data_only=True)
    ws = wb[wb.sheetnames[0]]
    header = [c.value for c in ws[1]]
    rows = [{header[j]: ws.cell(i, j+1).value for j in range(len(header))}
            for i in range(2, ws.max_row + 1)]

    zh, en = [], []
    for r in rows:
        code = (r.get("语言(选项标识)") or "").strip()
        if code == "zh":  zh.append(build_record(r, "zh"))
        elif code == "en": en.append(build_record(r, "en"))
        elif code in ("zh-en","en-zh","zh,en","en,zh"):
            # 多语言行 → 拆成两条，分别进 zh / en
            zh.append(build_record(r, "zh"))
            en.append(build_record(r, "en"))
        else:
            raise ValueError(f"未知语言列值: {code!r}")

    # 主键唯一性
    for items, name in ((zh, "zh"), (en, "en")):
        seen = set()
        for it in items:
            if it["identifier"] in seen:
                raise ValueError(f"{name}.json 主键重复: {it['identifier']}")
            seen.add(it["identifier"])

    # 排序：构建期不再排序，顺序需在此处确定后写入 json（按业务 order 字段）
    zh.sort(key=lambda x: (x.get("order", 100), x["identifier"]))
    en.sort(key=lambda x: (x.get("order", 100), x["identifier"]))

    OUT.mkdir(parents=True, exist_ok=True)
    (OUT/"zh.json").write_text(json.dumps(zh, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
    (OUT/"en.json").write_text(json.dumps(en, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
    print(f"OK: zh={len(zh)} en={len(en)}")

if __name__ == "__main__":
    main()
```

**写脚本前必须与用户对齐**（这些决定写到脚本顶部常量里，便于回溯）：

| # | 决策点 | 体现在哪 |
|---|---|---|
| 1 | 哪些列保留、哪些丢弃？ | 不在四张 MAP 里的列就是丢弃 |
| 2 | 主键冲突如何处理？ | `IDENTIFIER_OVERRIDES` 显式改名表 |
| 3 | 语言列含 `zh-en` 等多语言取值的行如何处理？ | `main()` 里拆成两条 |
| 4 | i18n 长文本是否保留末尾 `\n`？ | I18N_MAP 中的字段**不要 strip** |
| 5 | 资源字段含 `cloud://` 等非 http 协议怎么办？ | 默认原样保留并提醒用户清理；不要擅自清空 |

## 校验与审计（每次都要跑）

构建期（`npm run build`）已用 zod **严格校验**数据结构（类型 / 必填 / 多余字段均阻断）。以下两项审计用于覆盖构建期不检查的问题（如 i18n 翻译质量）。

### ① zod 结构校验（构建期已自动执行，也可单独跑）

```bash
cd cms && npx tsx --test data/schema.test.ts
```

该测试从 `data/index.ts` 导入注册表，遍历所有已注册条目，对每条目的 `zh.json` / `en.json` 逐行执行 zod `safeParse`，全部通过即代表数据与 schema 兼容。

### ② i18n 漏翻译审计（三类 + 与 xlsx 行数交叉核对）

```bash
ENTRY=<entry> XLSX=cms/temp/<原文件>.xlsx python3 - << 'PY'
import json, os, warnings
from pathlib import Path
import openpyxl
warnings.simplefilter("ignore")

root  = Path("cms/data") / os.environ["ENTRY"]
zh = json.loads((root/"zh.json").read_text(encoding="utf-8"))
en = json.loads((root/"en.json").read_text(encoding="utf-8"))
pk = "identifier"  # ← 改成本 entry 的主键字段名

# i18n 字段：人工列出需要 zh/en 各自翻译的字段名（按本次 schema 实际填写）
i18n_fields = ["title", "description", "sampleCode", "guide"]  # ← 改成本 entry 实际的 i18n 字段

zh_map = {it[pk]: it for it in zh}
en_map = {it[pk]: it for it in en}

# A：同主键 i18n 字段两边完全相等（疑似漏翻译）
A = []
for ident in sorted(set(zh_map) & set(en_map)):
    same = [f for f in i18n_fields
            if zh_map[ident].get(f) == en_map[ident].get(f)
            and zh_map[ident].get(f) is not None]
    if same: A.append((ident, same))
B = sorted(set(zh_map) - set(en_map))   # 仅 zh
C = sorted(set(en_map) - set(zh_map))   # 仅 en
print(f"i18n 字段: {i18n_fields}")
print(f"A 疑似漏翻译: {len(A)}   B 仅 zh: {len(B)}   C 仅 en: {len(C)}")
for ident, fields in A: print(f"  A · {ident}: {fields}")
for ident in B[:20]: print(f"  B · {ident}")
for ident in C[:20]: print(f"  C · {ident}")

# 与 xlsx 行数交叉核对
wb = openpyxl.load_workbook(os.environ["XLSX"], data_only=True)
ws = wb[wb.sheetnames[0]]
header = [c.value for c in ws[1]]
li = header.index("语言(选项标识)")
xz = xe = 0
for i in range(2, ws.max_row+1):
    code = (ws.cell(i, li+1).value or "").strip()
    if code == "zh": xz += 1
    elif code == "en": xe += 1
    elif code in ("zh-en","en-zh","zh,en","en,zh"): xz += 1; xe += 1
print(f"\nxlsx: zh={xz} en={xe}    json: zh={len(zh)} en={len(en)}")
PY
```

把 A / B / C 列表交给业务方补译，行数对得上就算交付。

## 收尾交付：写一份「本次生成报告」

**每次跑完 Step 1 + Step 2 + 校验 + 审计后，必须生成 `cms/temp/__<entry>_report.md`**，让用户一眼看懂：做了什么、结果如何、还差什么。文件不进 git，每次重跑覆盖。

原则：**能一句话说清就不要列表**，只写用户真正关心的两件事——结果 / 待跟进。没有的段落直接删掉，不要留空壳。

模板（可直接复制，按实际填充；括注部分是填写说明，写时删掉）：

```markdown
# <entry> 生成报告 · YYYY-MM-DD HH:mm

一句话总结：从 <xlsx 文件名> 生成 zh.json（N 条）/ en.json（M 条），zod 校验通过，还有 X 项待业务方跟进。

## ✅ 结果
- 数据：zh N 条 / en M 条，与 xlsx 行数一致
- 产物：schema.ts、index.ts、zh.json、en.json（已在 data/index.ts 注册）
- 校验：zod 通过；漏翻译审计 A/B/C = 0/0/0

## ⚠️ 待跟进（没有就删掉本节）
- [ ] 跑 `cd cms && npm run build` 验证整体构建
- [ ] 漏翻译 N 条：<identifier, ...> → 交业务方补译
- [ ] cloud:// 资源 N 处：<字段> → 需替换为 cms/assets/ 相对路径
- [ ] 其他：<按需补充，一句话写清是什么 + 谁来处理>

## 特殊处理说明（可选，只在本次确实做了特殊决策时写）
- enum 冲突：……　主键改写：<原> → <新>　targetPlatform：……
```

写报告时的取舍：

- **结果**用具体数字（N 条 / M 条 / 校验通过），不要贴大段 stdout；真要留证据就折叠或只贴关键行。
- **待跟进**用勾选框列，每条一句话写清「是什么 + 谁来处理」；构建验证等下一步动作也并进这里；全都做完就把整节删掉，别写「无」。
- **决策细节**（enum 冲突、主键改写表、排序方式等）平时不用展开，只在本次确实做了特殊处理时，用「特殊处理说明」一行带过。

## 典型陷阱（速查索引，处理细节回正文对应小节）

| 现象 | 结论 / 回看 |
|---|---|
| 主键重复 | 同一业务标识被多语言行复用是正常的（拆分后才唯一）；不同业务撞同 ID 才是真冲突 → 走 `IDENTIFIER_OVERRIDES` 改名（Step 2 决策点 2） |
| `zh-en` 拆分行的 i18n 字段两边相同 | 拆分本身没问题，多为源数据只填了中文 → 进入审计类别 A，交业务方补译 |
| 资源字段含 `cloud://` 等非 http 协议 | 构建期不报错也不改写、原样保留 → 需手动替换为 `cms/assets/` 相对路径或 `@/` 别名（详见 Step 2 决策点 5、`AGENTS.md` §2.3） |
| 枚举被当成普通 `string` 漏处理，或与数据冲突 | 判定（`x-enum` + `x-enum-type`，不看 `isEnum`）与处理（要代码表、冲突先确认三选一）见 Step 1「枚举字段」小节 |
| i18n 长文本首尾换行被 strip | 末尾 `\n` 影响 Markdown 渲染，`I18N_MAP` 中的字段**不要 strip**，普通字段可 strip（Step 2 决策点 4） |
| `.strict()` 拒绝未声明字段 | 数据里出现 schema 没定义的字段（如历史遗留拼写错误字段）时**不要删数据**，应在 zod schema 中补上该字段定义 |
