/** * VP-TSX 运行时 —— 可执行 builder:h() 把 JSX 树直接组装成 vpproj 工程 JSON 节点。 * * 定位:只服务「从零生成工程」。TSX 是受约束的编译器输入(组件面 = 平台 widget * 目录,类型自动生成自 widget schema),不是通用前端代码;产物是编辑器可导入的 * `{ project, connections, connectionData, dataRules }` 信封,真源仍是工程 JSON。 * * 相对直接手写 JSON 的红利:类型即时纠错(臆造属性名/错误枚举编译报错)、 * 可用循环/变量/函数消除重复(8 张 KPI 卡是一个 map() 而非 800 行 JSON)、可写注释。 */ import './types.generated' // piece 判别联合(types.generated 生成):TSX 里模块顶层构造 pieces 的辅助函数 // 请标注返回类型(如 VpScene3dPiece[]),否则字面量拓宽后失去编译期校验。 export type { VpKnownPiece, VpPieceNode, VpPieceOptionsMap, VpHostPieceMap, VpScene3dPiece, VpMap2dPiece, VpScada2dPiece, } from './types.generated' // --- 节点与信封形态 --------------------------------------------------------- /** * builder 子图元的宽松节点形态(VpNode 输出面用)。 * TSX 里写 pieces 时用的是 types.generated 的判别联合(VpKnownPiece / VpHostPieceMap, * 按 type 收窄校验选项键与枚举值),此处仅作为工程 JSON 的落地形态。 */ export interface VpPiece { uid: string type: string name?: string enabled?: boolean locked?: boolean options?: Record /** 嵌套子图元(如 map-2d 的图层挂在 map-body 下) */ pieces?: VpPiece[] } export interface VpNode { uid: string type: string name?: string options: Record widgets?: VpNode[] /** builder 型(map-2d / scada-2d / scene-3d)子图元 */ pieces?: VpPiece[] /** data-repeater 专用:单元模板(group-panel 节点,与 options 平级) */ unitTemplate?: VpNode conditionalStyles?: unknown stateOverrides?: Record sizeModeOverrides?: Record states?: unknown /** 当前激活状态 uid(多状态) */ state?: string enabled?: boolean locked?: boolean /** 来源模板标注(属性栏展示用) */ templateSubtypeLabel?: string templateAlias?: string /** 组合组件实例(母版-实例复用):masterId + 实例 props/overrides + 内嵌母版 master */ masterId?: string masterVersion?: unknown props?: Record overrides?: Record master?: unknown /** 组件私有数据表 */ privateData?: unknown /** 二次开发脚本(代码面板) */ extensions?: unknown /** 页面根:待处理/暂存区组件子树 */ pending?: unknown /** 页面根:编辑参考线 */ guideLines?: Record } /** 字体复合值(宽松:编辑器接受渐变串等扩展键) */ export interface VpFont { family?: string size?: number color?: string bold?: boolean italic?: boolean underline?: boolean weight?: string | number [k: string]: unknown } /** 字段绑定三元组:[连接uid, 表uid, 字段uid];多选放多个三元组 */ export type VpFieldTriple = [string, string, string] export type VpFieldValue = VpFieldTriple[] | VpFieldTriple /** 所有组件共有的节点级 props(与 options 平级的顶层字段 + JSX 语法件) */ export interface VpCommonProps { uid?: string name?: string children?: unknown /** 事件交互(触发器→动作),写入 options.interactions;用 interactions()/on()/act() 组装 */ interactions?: unknown /** 条件样式(节点顶层,与 options 平级):{paths, cases} */ conditionalStyles?: unknown stateOverrides?: Record sizeModeOverrides?: Record states?: unknown /** data-repeater 专用:单元模板(必须是 group-panel 节点) */ unitTemplate?: VpNode // 注意:pieces 不在公共 props 里——只有 builder 组件(map-2d / scada-2d / scene-3d) // 才有 pieces,其类型由 types.generated 按宿主生成(VpHostPieceMap,判别联合强校验)。 /** 当前激活状态 uid(多状态) */ state?: string enabled?: boolean locked?: boolean templateSubtypeLabel?: string templateAlias?: string /** 组合组件实例(母版-实例复用,type=component-instance):masterId + props/overrides + 内嵌母版 master */ masterId?: string masterVersion?: unknown props?: Record overrides?: Record master?: unknown /** 组件私有数据表 */ privateData?: unknown /** 二次开发脚本(代码面板) */ extensions?: unknown /** 页面根:待处理/暂存区组件子树 */ pending?: unknown /** 页面根:编辑参考线 */ guideLines?: Record } /** 页面/窗口 props:常用项带类型,其余选项直接透传 options(页面选项不在 widget schema 里) */ export interface VpScreenProps { uid?: string name?: string children?: unknown /** 布局画像(必写):固定大屏 canvas-scale / 百分比 canvas-percent / 流式 flow-fluid */ 'layout-profile'?: 'canvas-scale' | 'canvas-percent' | 'flow-fluid' /** 铺满方式(必写;演示推荐 fill-uniform + fill-auto-expand):全部 8 值见 envelope.md §4.1 */ 'fullscreen-mode'?: | 'scaling' | 'stretch' | 'adaptive' | 'responsive' | 'fill-uniform' | 'fit-width' | 'fit-height' | (string & {}) /** 仅 fill-uniform / responsive:把无锚点的主视觉拉满剩余画布 */ 'fill-auto-expand'?: boolean size?: [number, number] 'background-color'?: string 'container-layout'?: 'static' | 'fluid' 'flex-direction'?: 'row' | 'column' 'fluid-wrap'?: 'wrap' | 'nowrap' padding?: [number, number, number, number] [k: string]: unknown } // --- h 工厂 ----------------------------------------------------------------- export const Fragment = '#fragment' /** * 透传到节点顶层的持久化字段(与 options 平级,VpNode/工程 JSON 同名)。 * 覆盖复用与编辑器全部持久化面:组合组件母版-实例(masterId/masterVersion/props/overrides/master)、 * 二开脚本 extensions、私有数据 privateData、多状态 state、enabled/locked、模板来源标注、 * 页面根 pending(暂存区)/guideLines(参考线)。 * ⚠️ 与 core serializeTsx 的 NODE_EXTRA_PROPS、diffProject 的 NODE_BLOB_PROPS 三处同步; * 漏一处 = 导出丢字段 / diff 盲区(曾发生在 masterId 上)。 */ const NODE_PASSTHROUGH = [ 'unitTemplate', 'pieces', 'conditionalStyles', 'stateOverrides', 'sizeModeOverrides', 'states', 'state', 'enabled', 'locked', 'templateSubtypeLabel', 'templateAlias', 'masterId', 'masterVersion', 'props', 'overrides', 'master', 'privateData', 'extensions', 'pending', 'guideLines', ] as const /** 节点级保留 props:不进 options,落节点顶层字段 */ const NODE_PROPS = new Set(['uid', 'name', 'children', 'key', ...NODE_PASSTHROUGH]) type VpChild = VpNode | VpChild[] | null | undefined | false const counters = new Map() /** 重置自动 uid 计数(同进程编译多份工程时用;同一输入产出确定性 uid) */ export function resetUids(): void { counters.clear() } function autoUid(type: string): string { const n = (counters.get(type) ?? 0) + 1 counters.set(type, n) return `${type}-${n}` } function flattenChildren(children: VpChild[], into: VpNode[]): void { for (const c of children) { if (c == null || c === false) continue if (Array.isArray(c)) { flattenChildren(c, into) continue } if (c.type === Fragment) { into.push(...(c.widgets ?? [])) continue } into.push(c) } } /** * JSX 工厂(tsconfig: jsxFactory=h, jsxFragmentFactory=Fragment)。 * 除保留 props 外的所有属性原样进 options(kebab-case 选项名与 schema 一致); * `interactions` 也写入 options.interactions(工程 JSON 的真实位置)。 */ export function h(type: string, props?: Record | null, ...children: VpChild[]): VpNode { const widgets: VpNode[] = [] flattenChildren(children, widgets) if (props && props.children !== undefined) flattenChildren([props.children as VpChild], widgets) if (type === Fragment) { return { uid: autoUid('fragment'), type: Fragment, options: {}, widgets } } const p = props ?? {} const options: Record = {} for (const [k, v] of Object.entries(p)) { if (NODE_PROPS.has(k) || v === undefined) continue options[k] = v } const node: VpNode = { uid: typeof p.uid === 'string' && p.uid ? p.uid : autoUid(type), type, options, } if (typeof p.name === 'string' && p.name) node.name = p.name if (widgets.length) node.widgets = widgets for (const k of NODE_PASSTHROUGH) { if (p[k] !== undefined) (node as unknown as Record)[k] = p[k] } return node } // --- 数据绑定与连接 ---------------------------------------------------------- /** 字段绑定:bind(conn, tbl, 'A') → [[conn,tbl,'A']];bind(conn, tbl, 'A', 'B') 多选 */ export function bind(conn: string, table: string, ...fields: [string, ...string[]]): VpFieldTriple[] { return fields.map((f) => [conn, table, f] as VpFieldTriple) } export interface VpConnectionField { uid: string alias?: string type?: 'string' | 'number' | 'boolean' | (string & {}) [k: string]: unknown } /** * 内联静态数据连接(connectorType:"json",字段 uid=列名)。 * 返回 [connection, connectionData 片段],defineProject 里直接展开。 */ export function jsonConnection(spec: { uid: string name: string table: { uid: string; name: string; fields: VpConnectionField[] } rows: Array> }): { connection: Record; connectionData: Record } { return { connection: { uid: spec.uid, name: spec.name, type: 'json', connectorType: 'json', config: { copyToRoot: true, fileName: '' }, tables: [{ uid: spec.table.uid, name: spec.table.name, fields: spec.table.fields }], }, connectionData: { [spec.uid]: [{ tableId: spec.table.uid, tableName: spec.table.name, fields: spec.table.fields, rows: spec.rows, }], }, } } // --- 事件交互(触发器→动作) ------------------------------------------------- export interface VpTrigger { name: string options?: Record } export interface VpAction { name: string options?: Record } /** 动作:act('switchScreen', { screen: [...] }) */ export function act(name: string, options: Record = {}): VpAction { return { name, options } } /** 一组交互:on('click', act(...), act(...));trigger 可带 options */ export function on(trigger: string | VpTrigger, ...actions: VpAction[]) { const t: VpTrigger = typeof trigger === 'string' ? { name: trigger, options: {} } : trigger return { trigger: t, interactions: actions.map((action) => ({ enable: true, trigger: t, action })), } } /** options.interactions 的完整值:interactions(on('click', act(...)), on('appear', ...)) */ export function interactions(...groups: ReturnType[]): { version: 2; groups: ReturnType[] } { return { version: 2, groups } } // --- 工程信封 ----------------------------------------------------------------- export interface VpProjectEnvelope { project: Record connections: unknown[] connectionData: Record dataRules: unknown[] } /** * 组装可导入信封。screen/screens/windows 传 / JSX 节点。 * connections/connectionData 可用 jsonConnection() 的返回展开。 */ export function defineProject(spec: { name: string screen: VpNode screens?: VpNode[] windows?: VpNode[] foregroundScreen?: VpNode backgroundScreen?: VpNode params?: Record settings?: Record connections?: unknown[] connectionData?: Record dataRules?: unknown[] }): VpProjectEnvelope { const project: Record = { name: spec.name, screen: spec.screen, screens: spec.screens ?? [], params: spec.params ?? {}, settings: spec.settings ?? {}, } if (spec.windows?.length) project.windows = spec.windows if (spec.foregroundScreen) project.foregroundScreen = spec.foregroundScreen if (spec.backgroundScreen) project.backgroundScreen = spec.backgroundScreen return { project, connections: spec.connections ?? [], connectionData: spec.connectionData ?? {}, dataRules: spec.dataRules ?? [], } }