唯杰地图 logo唯杰地图
VJMAP
EN

runtime.ts

vjvp/scripts/vptsx/runtime.ts 13.0 KB381 行
原文
/**
 * 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<string, unknown>
  /** 嵌套子图元(如 map-2d 的图层挂在 map-body 下) */
  pieces?: VpPiece[]
}

export interface VpNode {
  uid: string
  type: string
  name?: string
  options: Record<string, unknown>
  widgets?: VpNode[]
  /** builder 型(map-2d / scada-2d / scene-3d)子图元 */
  pieces?: VpPiece[]
  /** data-repeater 专用:单元模板(group-panel 节点,与 options 平级) */
  unitTemplate?: VpNode
  conditionalStyles?: unknown
  stateOverrides?: Record<string, unknown>
  sizeModeOverrides?: Record<string, unknown>
  states?: unknown
  /** 当前激活状态 uid(多状态) */
  state?: string
  enabled?: boolean
  locked?: boolean
  /** 来源模板标注(属性栏展示用) */
  templateSubtypeLabel?: string
  templateAlias?: string
  /** 组合组件实例(母版-实例复用):masterId + 实例 props/overrides + 内嵌母版 master */
  masterId?: string
  masterVersion?: unknown
  props?: Record<string, unknown>
  overrides?: Record<string, unknown>
  master?: unknown
  /** 组件私有数据表 */
  privateData?: unknown
  /** 二次开发脚本(代码面板) */
  extensions?: unknown
  /** 页面根:待处理/暂存区组件子树 */
  pending?: unknown
  /** 页面根:编辑参考线 */
  guideLines?: Record<string, unknown>
}

/** 字体复合值(宽松:编辑器接受渐变串等扩展键) */
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<string, unknown>
  sizeModeOverrides?: Record<string, unknown>
  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<string, unknown>
  overrides?: Record<string, unknown>
  master?: unknown
  /** 组件私有数据表 */
  privateData?: unknown
  /** 二次开发脚本(代码面板) */
  extensions?: unknown
  /** 页面根:待处理/暂存区组件子树 */
  pending?: unknown
  /** 页面根:编辑参考线 */
  guideLines?: Record<string, unknown>
}

/** 页面/窗口 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<string>(['uid', 'name', 'children', 'key', ...NODE_PASSTHROUGH])

type VpChild = VpNode | VpChild[] | null | undefined | false

const counters = new Map<string, number>()

/** 重置自动 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<string, unknown> | 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<string, unknown> = {}
  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<string, unknown>)[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<Record<string, unknown>>
}): { connection: Record<string, unknown>; connectionData: Record<string, unknown> } {
  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<string, unknown>
}
export interface VpAction {
  name: string
  options?: Record<string, unknown>
}

/** 动作:act('switchScreen', { screen: [...] }) */
export function act(name: string, options: Record<string, unknown> = {}): 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<typeof on>[]): { version: 2; groups: ReturnType<typeof on>[] } {
  return { version: 2, groups }
}

// --- 工程信封 -----------------------------------------------------------------

export interface VpProjectEnvelope {
  project: Record<string, unknown>
  connections: unknown[]
  connectionData: Record<string, unknown>
  dataRules: unknown[]
}

/**
 * 组装可导入信封。screen/screens/windows 传 <screen>/<window> JSX 节点。
 * connections/connectionData 可用 jsonConnection() 的返回展开。
 */
export function defineProject(spec: {
  name: string
  screen: VpNode
  screens?: VpNode[]
  windows?: VpNode[]
  foregroundScreen?: VpNode
  backgroundScreen?: VpNode
  params?: Record<string, unknown>
  settings?: Record<string, unknown>
  connections?: unknown[]
  connectionData?: Record<string, unknown>
  dataRules?: unknown[]
}): VpProjectEnvelope {
  const project: Record<string, unknown> = {
    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 ?? [],
  }
}