Skip to content

React 接入 generator-workbench

这篇文档说明 React 生成器如何按推荐方式接入官方壳层 generator-workbench

对于新的标准生成器,推荐架构是:

  • generator-sdk 提供登录、导出、云存档、历史、计费、模板等平台能力
  • 你的 React 代码实现 generator runtime
  • generator-workbench 在 runtime 外层提供统一官方壳层

React 实际接入的是什么

React 不需要一套单独的 React 版 workbench 壳层。

在这个仓库里的推荐模型是:

  1. React 实现 runtime
  2. 宿主页渲染一个 <generator-workbench> custom element
  3. 宿主把 sdkruntimeconfig 赋给这个元素
  4. generator-workbench 把 runtime 挂载到正确的 DOM 容器里

适用场景

当满足以下条件时,优先使用这篇文档:

  • 你的生成器是 React 技术栈
  • 你想复用官方壳层
  • 你希望登录、积分、导出、模板、云存档、历史这些 UI 由 workbench 接管
  • 你希望项目收敛到标准 runtime 模型

以下场景不应把这篇文档作为主路径:

  • 你明确需要完全自定义壳层
  • 你只想在现有 React 页面里调用少量 SDK API
  • 你不打算暴露 mount()getState()getPanelSchema() 等 runtime 接口

架构分工

推荐职责划分如下:

  • React runtime 负责:
    • 业务状态
    • 画布渲染
    • 参数渲染
    • runtime 导出数据
    • PanelSchema
  • generator-workbench 负责:
    • 顶栏
    • 登录 / 头像 / 退出入口
    • 积分 / 计费壳层 UI
    • 模板导入 / 发布入口
    • 云存档 / 历史壳层入口
  • generator-sdk 负责:
    • 鉴权
    • 导出动作
    • 云端接口
    • 历史接口
    • 计费与模板 helper

推荐目录结构

txt
src/
  sdk.js
  main.jsx
  workbench-host.jsx
  runtime/
    state.js
    panel-schema.js
    runtime.js

第 1 步:创建 React 项目

推荐使用 Vite:

bash
npm create vite@latest

或者:

bash
pnpm create vite@latest

建议选择:

  • Framework: React
  • Variant: JavaScript

然后执行:

bash
cd your-project-name
npm install

第 2 步:安装 SDK 和 Workbench

bash
npm install @atomm-developer/generator-sdk @atomm-developer/generator-workbench

generator-workbench 是一个 custom element 壳层。你的 React 应用负责承载这个元素,而不是自己重写一套壳层。

关于 atomm-uiatomm-pro

generator-workbench 内部依赖 atomm-ui,并在部分流程里按需使用 atomm-pro

这并不意味着你的 runtime 必须改写成 Vue。

更准确的边界是:

  • 壳层使用这些依赖去渲染 shell-owned UI
  • 你的 React runtime 仍然可以保持完全 React 化
  • 如果你的业务区也想复用同一套视觉体系,那是另一个单独决策,不要把壳层依赖自动等同成 runtime 依赖

第 3 步:只初始化一次 SDK

创建 src/sdk.js

js
import { GeneratorSDK } from '@atomm-developer/generator-sdk'

export const sdk = GeneratorSDK.init({
  appKey: import.meta.env.VITE_ATOMM_APP_KEY,
  env: import.meta.env.MODE === 'production' ? 'prod' : 'dev',
})

创建 .env.local

bash
VITE_ATOMM_APP_KEY=your_app_key

不要把 SDK 实例放进 React 组件状态中。它应该被视为单例服务。

第 4 步:定义可序列化的 Runtime State

创建 src/runtime/state.js

js
export function createInitialState() {
  return {
    meta: {
      schemaVersion: '1.0.0',
      generatorId: 'react-pendant',
      title: 'React Pendant Generator',
    },
    document: {
      width: 520,
      height: 360,
      unit: 'px',
    },
    params: {
      text: 'generator',
      width: 96,
      height: 128,
      accentColor: '#111111',
    },
  }
}

export function cloneState(value) {
  return JSON.parse(JSON.stringify(value))
}

export function setByPath(target, path, value) {
  const keys = path.split('.')
  let current = target

  for (let index = 0; index < keys.length - 1; index += 1) {
    const key = keys[index]
    if (!current[key] || typeof current[key] !== 'object') {
      current[key] = {}
    }
    current = current[key]
  }

  current[keys[keys.length - 1]] = value
}

runtime state 最好保持 JSON 可序列化,因为后续宿主可能会拿它做恢复、云存档、模板导入导出。

第 5 步:定义 PanelSchema

创建 src/runtime/panel-schema.js

js
export function getPanelSchema() {
  return {
    version: '1.0.0',
    generatorId: 'react-pendant',
    groups: [
      {
        id: 'content',
        title: '内容',
        fields: [
          {
            id: 'text',
            label: '文字',
            type: 'text',
            bind: { path: 'params.text' },
          },
        ],
      },
      {
        id: 'size',
        title: '尺寸',
        fields: [
          {
            id: 'width',
            label: '宽度',
            type: 'number',
            bind: { path: 'params.width' },
          },
          {
            id: 'height',
            label: '高度',
            type: 'number',
            bind: { path: 'params.height' },
          },
        ],
      },
    ],
  }
}

export function applyPanelFilter(schema, panelFilter) {
  if (!panelFilter) {
    return schema
  }

  const includeGroups = panelFilter.includeGroups || null
  const excludeGroups = new Set(panelFilter.excludeGroups || [])
  const includeFields = panelFilter.includeFields || null
  const excludeFields = new Set(panelFilter.excludeFields || [])
  const readonlyFields = new Set(panelFilter.readonlyFields || [])

  const groups = schema.groups
    .filter((group) => !excludeGroups.has(group.id))
    .filter((group) => !includeGroups || includeGroups.includes(group.id))
    .map((group) => ({
      ...group,
      fields: group.fields
        .filter((field) => !excludeFields.has(field.id) && !excludeFields.has(field.bind.path))
        .filter((field) => !includeFields || includeFields.includes(field.id) || includeFields.includes(field.bind.path))
        .map((field) => ({
          ...field,
          readonly:
            field.readonly ||
            readonlyFields.has(field.id) ||
            readonlyFields.has(field.bind.path),
        })),
    }))
    .filter((group) => group.fields.length > 0)

  return {
    ...schema,
    groups,
  }
}

这一步非常关键。模板宿主和 generator-workbench 的模板流程消费的是统一 schema,而不是你私有的一套表单实现。

第 6 步:把 React UI 封装成 Runtime

创建 src/runtime/runtime.js

jsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInitialState, cloneState, setByPath } from './state'
import { getPanelSchema, applyPanelFilter } from './panel-schema'

function CanvasView({ state }) {
  return (
    <div style={{ display: 'grid', placeItems: 'center', minHeight: '100%' }}>
      <div
        style={{
          width: 520,
          height: 360,
          borderRadius: 20,
          border: '1px solid #dbe3f0',
          background: '#fff',
          display: 'grid',
          placeItems: 'center',
        }}
      >
        <div style={{ textAlign: 'center' }}>
          <div style={{ fontSize: 28, color: state.params.accentColor }}>
            {state.params.text}
          </div>
          <div style={{ marginTop: 8, color: '#64748b' }}>
            {state.params.width} x {state.params.height} mm
          </div>
        </div>
      </div>
    </div>
  )
}

function PanelView({ schema, state, onFieldChange }) {
  return (
    <div style={{ padding: 16 }}>
      {schema.groups.map((group) => (
        <section key={group.id} style={{ marginBottom: 16 }}>
          <h3>{group.title}</h3>
          {group.fields.map((field) => (
            <label
              key={field.id}
              style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}
            >
              <span>{field.label}</span>
              <input
                disabled={field.readonly}
                type={field.type === 'number' ? 'number' : 'text'}
                value={String(resolveFieldValue(state, field.bind.path) ?? '')}
                onChange={(event) => {
                  const nextValue =
                    field.type === 'number'
                      ? Number(event.target.value)
                      : event.target.value
                  onFieldChange(field.bind.path, nextValue)
                }}
              />
            </label>
          ))}
        </section>
      ))}
    </div>
  )
}

function FullWorkspace(props) {
  return (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: '320px minmax(0, 1fr)',
        minHeight: '100%',
      }}
    >
      <aside style={{ borderRight: '1px solid #e5e7eb', background: '#fff' }}>
        <PanelView {...props} />
      </aside>
      <main style={{ padding: 24, background: '#f5f7fb' }}>
        <CanvasView state={props.state} />
      </main>
    </div>
  )
}

function resolveFieldValue(state, path) {
  return path.split('.').reduce((current, key) => current?.[key], state)
}

export function createGeneratorRuntime() {
  let state = createInitialState()
  const listeners = new Set()
  const mounts = new Map()

  function emit(event) {
    listeners.forEach((listener) => listener(event))
  }

  function getState() {
    return cloneState(state)
  }

  function renderMount(record) {
    const schema = applyPanelFilter(getPanelSchema(), record.panelFilter)

    const onFieldChange = (path, value) => {
      patchState(
        { [path]: value },
        {
          source: 'user',
        },
      )

      emit({
        type: 'params_change',
        data: {
          field: path.replace(/^params\./, ''),
          value,
          params: cloneState(state.params),
        },
      })
    }

    if (record.mode === 'embed' && record.target === 'canvas') {
      record.root.render(<CanvasView state={state} />)
      return
    }

    if (record.mode === 'embed' && record.target === 'panel') {
      record.root.render(
        <PanelView
          schema={schema}
          state={state}
          onFieldChange={onFieldChange}
        />,
      )
      return
    }

    record.root.render(
      <FullWorkspace
        schema={schema}
        state={state}
        onFieldChange={onFieldChange}
      />,
    )
  }

  function rerender() {
    mounts.forEach((record) => renderMount(record))
  }

  function setState(nextState, options = {}) {
    state = cloneState(nextState)
    rerender()

    if (!options.silent) {
      emit({
        type: 'state-change',
        state: getState(),
        source: options.source || 'host',
      })
    }
  }

  function patchState(patch, options = {}) {
    Object.entries(patch || {}).forEach(([path, value]) => {
      setByPath(state, path, value)
    })

    rerender()

    if (!options.silent) {
      emit({
        type: 'state-change',
        state: getState(),
        patch,
        source: options.source || 'host',
      })
    }
  }

  return {
    version: '1.0.0',
    generatorId: 'react-pendant',
    capabilities: {
      modes: ['full', 'embed'],
      mountTargets: ['full', 'canvas', 'panel'],
      supportsPatchState: true,
      supportsReadonly: true,
      supportsPanelFilter: true,
      supportsPartialPanel: true,
      exports: ['cover', 'download-svg', 'open-in-studio'],
    },
    mount({
      mode,
      target = 'full',
      container,
      state: incomingState,
      panelFilter,
    }) {
      if (incomingState) {
        state = cloneState(incomingState)
      }

      const existing = mounts.get(container)
      const root = existing?.root || createRoot(container)
      const record = {
        root,
        mode,
        target,
        container,
        panelFilter,
      }

      mounts.set(container, record)
      renderMount(record)
      emit({ type: 'ready' })

      return {
        unmount() {
          const current = mounts.get(container)
          current?.root.unmount()
          mounts.delete(container)
        },
      }
    },
    getState,
    setState,
    patchState,
    getPanelSchema({ panelFilter } = {}) {
      return applyPanelFilter(getPanelSchema(), panelFilter)
    },
    async export(action) {
      const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="360" viewBox="0 0 520 360">
  <rect width="520" height="360" rx="20" fill="white" stroke="#dbe3f0" />
  <text x="260" y="170" text-anchor="middle" font-size="28" fill="${state.params.accentColor}">
    ${state.params.text}
  </text>
  <text x="260" y="210" text-anchor="middle" font-size="14" fill="#64748b">
    ${action}
  </text>
</svg>`

      return {
        dataUrl: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`,
        mimeType: 'image/svg+xml',
        ext: 'svg',
        width: 520,
        height: 360,
      }
    },
    subscribe(listener) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
}

这一步就是核心适配层。

generator-workbench 不是直接挂你的 React 应用,而是挂一个 runtime 对象。React 只是通过这个 runtime 对象被渲染到 workbench 提供的容器中。

第 7 步:在 React 里承载 Workbench Custom Element

创建 src/workbench-host.jsx

jsx
import { useEffect, useRef } from 'react'
import { GeneratorWorkbench } from '@atomm-developer/generator-workbench'
import { sdk } from './sdk'
import { createGeneratorRuntime } from './runtime/runtime'

GeneratorWorkbench.defineGeneratorWorkbench()

export default function WorkbenchHost() {
  const elementRef = useRef(null)
  const runtimeRef = useRef(null)

  if (!runtimeRef.current) {
    runtimeRef.current = createGeneratorRuntime()
    window.__GENERATOR_RUNTIME__ = runtimeRef.current
  }

  useEffect(() => {
    const element = elementRef.current
    if (!element) {
      return undefined
    }

    element.sdk = sdk
    element.runtime = runtimeRef.current
    element.config = {
      title: 'React Pendant Generator',
      mode: 'shell',
      readyPolicy: 'runtime-ready',
      exportEnabled: true,
      studioEnabled: true,
      templateEnabled: true,
    }

    let disposed = false

    Promise.resolve(element.mount()).catch((error) => {
      console.error('Failed to mount workbench', error)
    })

    return () => {
      if (!disposed) {
        element.unmount?.()
        disposed = true
      }
    }
  }, [])

  return <generator-workbench ref={elementRef} />
}

然后修改 src/main.jsx

jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import WorkbenchHost from './workbench-host'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <WorkbenchHost />
  </React.StrictMode>,
)

这就是 React 侧推荐的宿主方式:

  • React 渲染 custom element
  • React 通过 ref 给元素赋对象属性
  • custom element 再去挂 runtime

第 8 步:理解 shellfullembed

大部分 React 生成器建议从下面这类配置开始:

js
element.config = {
  mode: 'shell',
}

shell 模式下:

  • workbench 保留官方顶栏
  • runtime 自己决定内部工作区布局
  • runtime 通常以完整工作区方式挂载

如果你有模板页或宿主拆分挂载场景,runtime 还必须支持:

js
runtime.mount({
  mode: 'embed',
  target: 'canvas',
  container,
})

runtime.mount({
  mode: 'embed',
  target: 'panel',
  container,
  panelFilter,
})

这也是为什么上面的 runtime 适配层要支持:

  • mode: 'full'
  • mode: 'embed'
  • target: 'full' | 'canvas' | 'panel'

第 9 步:让 Workbench 接管平台壳层 UI

一旦接入 generator-workbench,除非你明确要自定义,否则不要在 React runtime 里再重复造一套壳层能力。

接入后典型的归属应该是:

  • 登录按钮:workbench
  • 头像 / 退出:workbench
  • 积分角标:workbench
  • 导出入口:workbench
  • 模板导入 / 发布入口:workbench
  • 云存档 / 历史入口:workbench

React runtime 应该聚焦在:

  • state
  • rendering
  • schema
  • export payload

第 10 步:开启可选壳层能力

如果你要启用云存档和历史:

js
element.config = {
  title: 'React Pendant Generator',
  mode: 'shell',
  cloudEnabled: true,
  historyEnabled: true,
  autoSaveEnabled: true,
  getCloudSaveOptions: ({ state }) => ({
    title: 'Draft',
    snapshot: state,
  }),
}

这些能力要求 runtime 至少提供稳定的 state,最好还实现 subscribe(listener)

对于 Open in Studio 和下载,runtime 负责 export(action) 返回导出数据,SDK / workbench 决定如何消费这些导出数据。

TypeScript 说明

如果你的 React 项目是 TypeScript,需要给 custom element 补 JSX 类型:

ts
declare namespace JSX {
  interface IntrinsicElements {
    'generator-workbench': React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement>,
      HTMLElement
    >
  }
}

常见错误

  • generator-workbench 误认为是 runtime 业务逻辑替代品
  • 接入后又在 React runtime 里重写登录、积分、模板壳层 UI
  • 仍然只保留一个 React 页面,而没有暴露 runtime 对象
  • 缺少 getPanelSchema(),却希望模板能力正常工作
  • state 不可序列化
  • 有模板宿主需求,却只实现了完整页面,没有实现 embed 挂载目标

如果你仍然想走原始 SDK 直连路径

这种方式仍然有效,但只适合你明确要自己做壳层的时候。

在那种模式下:

  • React 自己渲染整页
  • 你直接调用 sdk.auth.login()sdk.export.openInStudio() 等 API
  • 你不使用 generator-workbench 作为壳层宿主

只有当壳层自定义优先级高于标准化接入时,才推荐这样做。

上传资源

  1. 执行生产构建,比如 npm run build
  2. 将生成的 dist 目录压缩成 zip 包。
  3. 确保 zip 根目录包含 index.html
  4. 上传到 https://www.atomm.com/generator-upload

一句话总结

React 的推荐接入方式是:

React 实现 runtime contract,generator-workbench 承载官方壳层,generator-sdk 在底层提供平台能力。

MIT Licensed