Skip to content

保存、加载与只读预览

Designer 不自动持久化页面。宿主用 exportSchema() 获取可传输快照,用 importSchema() 恢复外部输入。

仓储边界

贯穿示例使用 revision 保护草稿:

ts
import type { DocumentSchema } from '@dragcraft/designer'

export interface SavedPage {
  id: string
  revision: number
  schema: DocumentSchema
}

export interface SavePageInput {
  id: string
  revision: number
  schema: DocumentSchema
}

export interface PageRepository {
  load: (id: string) => Promise<SavedPage | null>
  save: (input: SavePageInput) => Promise<SavedPage>
}

export class PageRevisionConflictError extends Error {
  constructor(id: string) {
    super(`页面 ${id} 已被其他编辑会话更新`)
    this.name = 'PageRevisionConflictError'
  }
}

function clonePersistedValue<T>(value: T): T {
  return JSON.parse(JSON.stringify(value)) as T
}

export function createMemoryPageRepository(seed: SavedPage[] = []): PageRepository {
  const pages = new Map(seed.map(page => [page.id, clonePersistedValue(page)]))

  return {
    async load(id) {
      const page = pages.get(id)
      return page ? clonePersistedValue(page) : null
    },
    async save(input) {
      const current = pages.get(input.id)
      if (current && current.revision !== input.revision)
        throw new PageRevisionConflictError(input.id)

      const page: SavedPage = {
        id: input.id,
        revision: (current?.revision ?? 0) + 1,
        schema: clonePersistedValue(input.schema),
      }
      pages.set(page.id, page)
      return clonePersistedValue(page)
    },
  }
}

真实服务校验页面归属、revision、type 白名单、业务 props、资源 URL 与 region 约束。旧 revision 保存必须拒绝,不能覆盖新草稿。

恢复结果

ts
const result = designer.importSchema(draft.schema)
if (result.status === 'rejected')
  showDiagnostics(result.diagnostics)

degradedconflicted 保留文档,分别提示未知 type 或结构冲突;rejected 保留导入前的有效文档。

独立 Runtime

Runtime 按 type 解释 Schema,不使用 DcDesigner。Guide Project 的参考 Runtime 说明了将导出的纯数据交给宿主组件树的边界:

ts
import type { DocumentSchema, NodeDefinition } from '@dragcraft/designer'
import type { Component, PropType, VNodeChild } from 'vue'
import type { RuntimeLayoutEdge, RuntimeLayoutEntry } from './layout'
import type { RuntimeRegions, RuntimeRegistry } from './registry'
import { defineComponent, h } from 'vue'
import { createFrameworkLayerStyle, createRuntimeLayoutPlan } from './layout'

export const DefaultRuntimeFallback = defineComponent({
  name: 'GuideRuntimeFallback',
  props: {
    node: { type: Object as PropType<NodeDefinition>, required: true },
  },
  setup(props) {
    return () => h('p', {
      class: 'guide-runtime-fallback',
      role: 'status',
    }, `无法渲染物料 ${props.node.type}(${props.node.id})`)
  },
})

export function createRuntimeNodeRenderer(
  registry: RuntimeRegistry,
  schema: DocumentSchema,
  fallback: Component = DefaultRuntimeFallback,
): (node: NodeDefinition) => VNodeChild {
  const nodesById = new Map(schema.nodes.map(node => [node.id, node]))
  const renderNode = (node: NodeDefinition): VNodeChild => {
    const definition = registry[node.type]
    const container = schema.structure.containers[node.id]
    let content: VNodeChild

    if (!definition || (container && definition.kind !== 'container')) {
      content = h(fallback, { node })
    }
    else if (definition.kind === 'container') {
      if (!container) {
        content = h(fallback, { node })
      }
      else {
        const regions = Object.fromEntries(
          Object.entries(container.regions).map(([regionId, childIds]) => [
            regionId,
            childIds.flatMap((childId) => {
              const child = nodesById.get(childId)
              return child ? [renderNode(child)] : []
            }),
          ]),
        ) as RuntimeRegions

        content = h(definition.component, {
          node,
          regions,
        })
      }
    }
    else {
      content = h(definition.component, {
        ...node.props,
        style: node.style?.content,
      })
    }

    return h('div', {
      'class': 'guide-runtime-node',
      'data-runtime-node-id': node.id,
      'data-runtime-node-type': node.type,
      'style': node.style?.container,
    }, [content])
  }

  return renderNode
}

function renderEntries(
  entries: RuntimeLayoutEntry[],
  renderNode: (node: NodeDefinition) => VNodeChild,
): VNodeChild[] {
  return entries.map(entry => renderNode(entry.node))
}

function chromeEntries(
  entries: RuntimeLayoutEntry[],
  edge: RuntimeLayoutEdge,
  fixed: boolean,
): RuntimeLayoutEntry[] {
  return entries.filter(entry => entry.placement.kind === 'chrome'
    && entry.placement.edge === edge
    && (entry.placement.position === 'fixed') === fixed)
}

export const RuntimePage = defineComponent({
  name: 'GuideRuntimePage',
  props: {
    schema: { type: Object as PropType<DocumentSchema>, required: true },
    registry: { type: Object as PropType<RuntimeRegistry>, required: true },
    fallback: { type: Object as PropType<Component>, default: () => DefaultRuntimeFallback },
  },
  setup(props) {
    return () => {
      const plan = createRuntimeLayoutPlan(props.schema, props.registry)
      const renderNode = createRuntimeNodeRenderer(props.registry, props.schema, props.fallback)
      const flowRegions = [...plan.flow.entries()].map(([regionId, entries]) => h('section', {
        'class': 'guide-runtime-region',
        'data-runtime-region': regionId,
      }, renderEntries(entries, renderNode)))
      const renderChrome = (edge: RuntimeLayoutEdge, fixed: boolean) => {
        const entries = chromeEntries(plan.chrome, edge, fixed)
        return entries.length === 0
          ? null
          : h('div', {
              'class': {
                'guide-runtime-edge': true,
                'guide-runtime-edge--fixed': fixed,
              },
              'data-runtime-edge': edge,
            }, renderEntries(entries, renderNode))
      }
      const layerVNodes = [...plan.layers.entries()].flatMap(([layer, entries]) => entries.map((entry) => {
        if (entry.placement.kind !== 'layer')
          return null
        return h('div', {
          'class': 'guide-runtime-overlay-entry',
          'data-runtime-overlay': layer,
          'data-runtime-overlay-mode': entry.placement.mode,
          'style': createFrameworkLayerStyle(entry.placement),
        }, [renderNode(entry.node)])
      }))

      return h('main', {
        class: 'guide-runtime-page',
        style: {
          '--guide-runtime-inset-block-start': plan.insets['block-start'],
          '--guide-runtime-inset-block-end': plan.insets['block-end'],
          '--guide-runtime-inset-inline-start': plan.insets['inline-start'],
          '--guide-runtime-inset-inline-end': plan.insets['inline-end'],
        },
      }, [
        h('div', { class: 'guide-runtime-scrollport' }, [
          h('div', {
            class: 'guide-runtime-surface',
            style: props.schema.page.style?.surface,
          }, [
            renderChrome('block-start', false),
            h('div', { class: 'guide-runtime-inline-layout' }, [
              renderChrome('inline-start', false),
              h('div', { class: 'guide-runtime-content' }, flowRegions),
              renderChrome('inline-end', false),
            ]),
            renderChrome('block-end', false),
          ]),
        ]),
        renderChrome('block-start', true),
        renderChrome('block-end', true),
        renderChrome('inline-start', true),
        renderChrome('inline-end', true),
        h('div', { class: 'guide-runtime-overlays' }, layerVNodes),
      ])
    }
  },
})

小程序、原生应用或其他运行时可消费同一 Schema,但自行实现目标平台组件、布局和未知 type 策略。