# VP-TSX：用 TSX 生成工程 JSON（默认产出路径）

大模型直接吐几千行工程 JSON 容易闭合错误、重复段落膨胀、属性名臆造。**默认用 VP-TSX**：写一份 `*.vp.tsx`（组件面 = 平台 widget 目录，类型自动生成自 widget schema），执行即产出可导入的 `{ project, connections, connectionData, dataRules }`。只有微型工程（一两个组件的片段）才值得直接手写 JSON。

**红利**：类型即时纠错（值/枚举/元组 tsc 报错，臆造选项名由 compile 对照 schema 拦截并给相近候选）；循环/变量/函数消除重复（8 张 KPI 卡 = 一个 `map()`）；可写注释。

**边界**：本文是 TSX 写法权威（离线从零生成 + 读懂导出代码）。live 页面双向环路（`page_export_tsx` / `page_apply_project`）的工作流见 [page-live-loop.md](page-live-loop.md)。真源始终是页面 JSON：TSX 是每次导出即再生的代码视图。只改一两个属性仍是 `page_get_schemas` → `page_batch_set_options` 最快。

## 产出链（在 `vjvp/` 所在目录执行）

```bash
# 0. schema 变更后重新生成类型（types.generated.ts 已入库，通常跳过）
pnpm exec tsx vjvp/scripts/vptsx/gen-types.mts

# 1. 类型检查（值/枚举/元组错误在这里报，带行号）
#    ⚠️ tsc -p vjvp/scripts/vptsx/tsconfig.json 只查 runtime.ts + scripts/vptsx/examples/（include 写死），查不到你的文件；
#    检查自己的 .vp.tsx 用单文件命令（命令行给文件时 tsconfig 被忽略，选项须写全）：
pnpm exec tsc --noEmit --strict --skipLibCheck --jsx react --jsxFactory h --jsxFragmentFactory Fragment --target ES2022 --module ESNext --moduleResolution Bundler <你的工程.vp.tsx>

# 2. 执行 + 选项名检查 + 校验器终审（默认写 <输入名>.vpproj.json）
pnpm exec tsx vjvp/scripts/vptsx/compile.mts <你的工程.vp.tsx>
```

ERROR 必须修掉再交付。新写的 `*.vp.tsx` 放哪都行（相对导入 runtime 即可）；技能自带示例在 `scripts/vptsx/examples/energy-mini.vp.tsx`，先照抄骨架。外部工作区（技能被拷走）需 `npm i -D tsx typescript`。

## 文件模板（必须的头两行 pragma + default export）

```tsx
/** @jsx h */
/** @jsxFrag Fragment */
import { bind, defineProject, h, jsonConnection } from '<相对路径>/scripts/vptsx/runtime'

export default defineProject({
  name: '工程名',
  connections: [/* jsonConnection(...).connection 或原始连接对象 */],
  connectionData: {/* ...jsonConnection(...).connectionData（仅离线 compile.mts）；live page_apply_project 写字面量，见 page-live-loop.md */},
  screen: (
    <screen uid="screen_main" name="主页面" layout-profile="canvas-scale" fullscreen-mode="fill-uniform" fill-auto-expand={true}
      size={[1920, 1080]} background-color="linear-gradient(...)">
      <basic-text position={[560, 24]} size={[800, 64]} text="标题"
        text-font={{ size: 40, bold: true, color: 'linear-gradient(90deg,#7DE3FF,#2E8BFF)' }} />
    </screen>
  ),
})
```

- **pragma 两行不可省**（`@jsx h` / `@jsxFrag Fragment`）：tsx 运行器按它选 JSX 工厂，缺了报 `React is not defined`。
- **全文件只允许一条 import**（runtime）：live 的 `page_apply_project` 有单 import 门禁；离线编译也照此约定。循环、常量、函数都写在文件内。

## runtime API 全表

来自本技能运行时 `scripts/vptsx/runtime.ts`（源码即权威，签名如下）：

| 导出 | 签名 / 用途 |
|---|---|
| `defineProject(spec)` | 组装信封。`spec`: `{ name, screen, screens?, windows?, foregroundScreen?, backgroundScreen?, params?, settings?, connections?, connectionData?, dataRules? }`；screen/screens/windows 传 `<screen>`/`<window>` JSX 节点 |
| `h` / `Fragment` | JSX 工厂（pragma 指定）；一般不手调 |
| `bind(conn, tbl, ...fields)` | 字段绑定三元组：`bind('c','t','A')` → `[['c','t','A']]`；`bind('c','t','A','B')` 多选 |
| `jsonConnection({ uid, name, table:{uid,name,fields}, rows })` | 内联静态数据连接（connectorType:"json"，字段 uid=列名）。返回 `{ connection, connectionData }`：`connections: [x.connection]`、`connectionData: { ...x.connectionData }`。⚠️ 仅本技能 runtime；live `page_apply_project` 服务端把 import 改写到平台自带 runtime，那里返回 `{ connection, data }`（无 `connectionData`，展开得 `{}`→无行数据），live 下 `connectionData` 写字面量 `{ conn: [{ tableId, tableName, fields, rows }] }` |
| `interactions(...groups)` | `options.interactions` 完整值：`{ version:2, groups }` |
| `on(trigger, ...actions)` | 一组交互；`trigger` 为字符串或 `{ name, options }`（触发器带参时用对象，如 `on({ name:'timer', options:{ mode:'interval', interval:5 } }, ...)`） |
| `act(name, options?)` | 一个动作：`act('switchScreen', { screenUID: [...] })` |
| `resetUids()` | 重置自动 uid 计数（同进程编译多份工程时用） |
| 类型导出 | 节点/信封：`VpNode` `VpPiece` `VpCommonProps` `VpScreenProps` `VpFont` `VpProjectEnvelope`；绑定/连接：`VpFieldTriple` `VpFieldValue` `VpConnectionField`；交互：`VpTrigger` `VpAction`；piece 判别联合 `VpScene3dPiece` / `VpMap2dPiece` / `VpScada2dPiece` / `VpKnownPiece` / `VpHostPieceMap` / `VpPieceNode` / `VpPieceOptionsMap`（后 7 个转出自 types.generated） |

**没有 helper 的连接器（mock/api/websocket/postMessage/csv…）直接写原始 JSON 对象**进 `connections`（形态见 [data.md](data.md)），`connectionData` 手写一帧初始 rows：

```tsx
const mockConn = {
  uid: 'conn_kpi', name: '实时KPI(Mock)', type: 'mock', connectorType: 'mock', refreshInterval: 4,
  config: { mock: { rowCount: 1, fields: [
    { name: '在线用户', alias: '在线用户', kind: 'wave', base: 1300, amplitude: 380, periodSec: 18, decimals: 0 },
  ] } },
  tables: [{ uid: 'tbl_kpi', name: 'KPI', fields: [{ uid: '在线用户', alias: '在线用户', type: 'number' }] }],
}
// defineProject({ connections: [mockConn], connectionData: { conn_kpi: [{ tableId: 'tbl_kpi', rows: [{ 在线用户: 1280 }] }] }, ... })
```

## JSON → TSX 机械映射规则（家族文档任何 JSON 片段都按此转写）

参考文档（widgets/ 各家族、data.md、interactions.md）里的持久化形态是 JSON；转成 TSX 是**机械规则**，无一例外：

| JSON 里的… | TSX 里写成… |
|---|---|
| 节点 `"type": "chart-bar"` | 标签名 `<chart-bar …/>`；页面/窗口是 `<screen>` / `<window>` |
| `options` 里的每个键值 | 同名 kebab 属性：`"bar-color": "#3aa1ff"` → `bar-color="#3aa1ff"`；非字符串用花括号 `size={[420,300]}`、`show-legend={true}`、`text-font={{ size:20 }}` |
| `options.interactions` | `interactions={interactions(on(...))}` 属性（helper 组装后仍落 options.interactions） |
| `widgets: [...]`（容器子组件） | JSX children：`<group-panel>…子组件…</group-panel>` |
| 节点顶层字段（与 options 平级） | **同名保留 props**：`uid` `name`（NODE_PROPS 成员）+ `unitTemplate` `pieces` `conditionalStyles` `stateOverrides` `sizeModeOverrides` `states` `state` `enabled` `locked` `templateSubtypeLabel` `templateAlias` `masterId` `masterVersion` `props` `overrides` `master` `privateData` `extensions` `pending` `guideLines`（后 20 个与 runtime `NODE_PASSTHROUGH` 一一对应），值直接写 JS 字面量 |
| 信封顶层 `project.name/params/settings`、`connections`、`connectionData`、`dataRules` | `defineProject({...})` 的同名字段（JSON 字面量原样放入） |
| 多页/窗口/前景背景层 | `screens: [<screen/>…]`、`windows: [<window/>…]`、`foregroundScreen: <screen/>` |

反向同理：`page_export_tsx` 导出的代码就是按这套规则从 JSON 再生的（属性值全是 JSON 字面量），读导出代码 = 读 JSON。

**uid 纪律**：省略 uid 自动生成 `type-N`（同类型递增）。想保留/对齐的组件、被引用的节点（交互 `targetPath` / dataRules / `link-section` / `feature-panel-uid` 等）**必须显式写 uid**。判定是 **truthy 检查**——`uid=""`/`uid={0}` 这类 falsy 值同样走 autoUid（不是 `??` 语义），别指望空串占位。

## 非平凡场景配方

### 1. data-repeater 的 unitTemplate（模板放顶层 prop，不是 children）

```tsx
<data-repeater uid="w_rep" position={[80, 200]} size={[1760, 800]}
  unit-size={[330, 170]} overflow="scroll-y" item-fields={bind('conn_x', 'tbl_x', '名称')}
  unitTemplate={
    <group-panel uid="w_rep__unit" position={[0, 0]} size={[330, 170]} background={true}>
      <basic-text uid="tpl_name" position={[22, 18]} size={[286, 32]} value-field={bind('conn_x', 'tbl_x', '名称')} />
    </group-panel>
  } />
```

`unitTemplate` 值就是一个 JSX 节点（编译为 group-panel 节点对象）；模板内子组件普通字段绑定自动取本行（行作用域见 [widgets/containers.md](widgets/containers.md) §5b）。

### 2. component-instance（母版-实例，全在保留 props）

```tsx
<component-instance uid="inst_kpi" masterId="master_kpi" masterVersion={1}
  position={[300, 380]} size={[460, 320]}
  props={{ title: '在线用户数', valueField: bind('conn_kpi', 'tbl_kpi', '在线用户') }}
  overrides={{ m_title: { 'text-font': { size: 20, color: '#00FFC6' } } }}
  master={masterDef} />
```

`masterDef` 是母版对象（`{ masterId, name, version, propsSchema, node }`，node 可用 JSX 变量），形态见 [widgets/containers.md](widgets/containers.md) §5bb。

### 3. builder 型 pieces（map-2d / scada-2d / scene-3d）：数组 prop + 类型标注

pieces 是节点顶层 prop（不是 children）。**模块顶层构造 pieces 时标注判别联合类型**，字面量才不拓宽、选项键与枚举获得编译期校验：

```tsx
import { defineProject, h, type VpScene3dPiece } from '<...>/runtime'

const scenePieces: VpScene3dPiece[] = [
  { uid: 'scene3d-scene', type: 'scene3d-scene', options: { 'bg-mode': 'color', 'scene-bg': '#0a0f1c' } },
  { uid: 'scene3d-camera', type: 'scene3d-camera', options: { position: [16, 12, 16], target: [0, 1, 0] } },
  { uid: 'p_box', type: 'scene3d-shape', options: { shape: 'box', position: [0, 1, 0], 'data-key': 'PUMP' } },
]
// <scene-3d uid="w_scene" scene3d-initialized={true} pieces={scenePieces} … />
```

同理 `VpMap2dPiece[]`（map-body 必须放第一个）、`VpScada2dPiece[]`（图元用 geo-x/y/w/h）。内联写 `pieces={[...]}` 也可（types.generated 按宿主校验），配方与选项详见各家族文档。

### 4. 条件样式 / 多状态 / 设备差异（顶层 props + dataRules）

```tsx
export default defineProject({
  // 数据规则：JSON 字面量数组（形态见 data.md §dataRules）
  dataRules: [{ uid: 'dc_alarm', name: '告警', rules: [[{ connection: 'conn_x', table: 'tbl_x', field: '状态', func: '=', args: '告警', method: 'some' }]] }],
  screen: (
    <screen uid="screen_main" layout-profile="canvas-scale" fullscreen-mode="fill-uniform" fill-auto-expand={true} size={[1920, 1080]}>
      <basic-text uid="t_state" position={[100, 100]} size={[300, 60]}
        text-font={{ size: 40, color: '#ffffff' }}
        conditionalStyles={[{ paths: ['text-font'], cases: [
          { ruleId: 'dc_alarm', value: { size: 40, color: '#ff4d4f' } },
          { fallback: true, value: { size: 40, color: '#52c41a' } },
        ] }]} />
      {/* 多状态：states 声明 + stateOverrides 按状态覆盖；设备差异 sizeModeOverrides 同理 */}
      <basic-card uid="k1" position={[100, 200]} size={[220, 120]}
        states={[{ uid: '' }, { uid: 's_warn', name: '警示' }]}
        stateOverrides={{ s_warn: { 'text-font': { color: '#FFC940' } } }}
        sizeModeOverrides={{ SMALL: { size: [160, 90] } }} />
    </screen>
  ),
})
```

### 5. 多屏 + 窗口（画面复用）

```tsx
export default defineProject({
  name: '多页工程',
  screen: mainScreen,                       // JSX 变量随意抽取
  screens: [<screen uid="screen_2" …>…</screen>],
  windows: [
    <window uid="win_detail" name="设备详情" size={[560, 400]}>
      <basic-text uid="wd_title" position={[24, 20]} size={[300, 36]} text="详情" />
    </window>,
  ],
  …
})
// 打开窗口：act('switchWindow', { windowUid: [{ targetPath: ['win_detail'] }], operate: 'open' })
// 页面内嵌复用：<window-instance window={[{ targetPath: ['win_detail'], refType: 'element' }]} content-fit="contain" … />
```

### 6. 循环 / 工厂消重复（TSX 的核心红利）

```tsx
const KPIS = [
  { name: '总能耗', value: 12680, unit: 'kWh', color: '#00E5FF' },
  { name: '光伏出力', value: 4210, unit: 'kW', color: '#38BDF8' },
]
const kpiCard = (k: (typeof KPIS)[number], i: number) => (
  <basic-card uid={`kpi_${i}`} position={[24 + i * 240, 120]} size={[220, 120]}
    static-value={k.value} unit-text={k.unit} text-font={{ size: 34, bold: true, color: k.color }} />
)
// <screen …>{KPIS.map(kpiCard)}</screen>
```

工厂每次调用要产**不同 uid**；编译产物是展开后的多份独立节点。百分比自适应页把 `geometry-unit="percent"` 抽成常量对象展开：`{...PCT}`。

## 校验层次（谁兜什么）

| 层 | 兜住 | 说明 |
|---|---|---|
| `tsc`（types.generated.ts） | 值类型 / select 枚举 / vector 元组长度 / 未知**组件标签** | kebab 属性名被 TS 当 data-* 豁免多余属性检查，臆造名 tsc **不报** |
| `compile.mts` 选项名检查 | 臆造/拼错的**选项名**（含 unitTemplate/pieces 递归） | 对照 widget-schema，报错带相近候选 |
| `validate-project-json.mjs` | 绑定三元组可解析、连接一致性、position/size 缺失、map-body 等 | 与手写 JSON 同一终审 |

## 配对示例（学写法直接抄）

`data/` 示例库的工程**均有 `.vp.tsx` 与 `.vpproj.json` 成对**：手写 TSX 源（map3d/map3dcomp，含循环/工厂/注释惯例）+ 机器导出 TSX（其余目录，从 JSON 生成并经回编译等值验证）。检索入口见 [examples/catalog.md](../examples/catalog.md)。

## 维护

- 权威链：`defineOptions()` → `widget-schema.generated.json` → `types.generated.ts` / `get_widget_metadata` / `page_get_schemas`。本文档只教工作流与映射规则，**不要把全量属性抄进文档**。
