feat(codegen): P 层闭环 —— 模板生成页面 + 画廊生成面板

- src/codegen.ts:PageDeclaration → definePage 源码(漂亮缩进,语法合法)
- src/render.ts:导出 generatePageSource/starterPageFrom;画廊受控 selected/onSelect
- playground generator.tsx:选布局→填标题→生成→复制/保存到 pages/
- vite 中间件 /__vr-save:浏览器 POST 落盘 pages/(HMR 生效)
- 验证:codegen 产物通过工程 tsc;保存中间件落盘成功;画廊 UI headless 通过
This commit is contained in:
hellow-da-sb
2026-09-03 01:27:11 +08:00
parent 21c8764993
commit 8fe594ff6c
8 changed files with 333 additions and 13 deletions
+97
View File
@@ -0,0 +1,97 @@
/**
* codegen —— PageDeclaration → definePage 源码(纯函数,任何端可用)
*
* 这是 P 层闭环的核心:用户在画廊选布局、填业务,
* 生成一份标准 definePage 页面源码 —— 页面只声明业务,不写布局。
* 产物可被任何 AI / 编辑器继续编辑(AI 词汇表 = 注册表)。
*/
import type { PageDeclaration } from './page.ts'
/** 生成 definePage 源码;sampleData 为 setup 的起步样例(可选) */
export function generatePageSource(page: PageDeclaration, sampleData: Record<string, unknown> = {}): string {
const blockLines = page.blocks.map((b) => {
const parts: string[] = [`widget: ${JSON.stringify(b.widget)}`]
if (b.slot) parts.push(`slot: ${JSON.stringify(b.slot)}`)
if (b.props && Object.keys(b.props).length > 0) parts.push(`props: ${jsonValue(b.props, 8)}`)
if (b.bind && Object.keys(b.bind).length > 0) parts.push(`bind: ${jsonValue(b.bind, 8)}`)
return ` { ${parts.join(', ')} }`
})
const blocksText = blockLines.length > 0 ? blockLines.join(',\n') : ' // TODO: 添加业务块(引用已注册 widget)'
const sections: string[] = [` layout: ${JSON.stringify(page.layout)}`]
if (page.title) sections.push(` title: ${JSON.stringify(page.title)}`)
sections.push(` blocks: [\n${blocksText},\n ]`)
const sampleKeys = Object.keys(sampleData)
if (sampleKeys.length > 0) {
// 箭头函数直接返回对象字面量必须包括号,否则被解析成块语句
sections.push(` setup: async () => (${jsonValue(sampleData, 2).trimEnd()})`)
}
const actionKeys = Object.keys(page.actions ?? {})
if (actionKeys.length > 0) {
const lines = actionKeys.map(
(k) =>
` ${JSON.stringify(k)}: async (payload) => {\n` +
` // TODO: 业务逻辑,或调用注册的 action\n` +
` console.log(${JSON.stringify(k)}, payload)\n` +
` }`,
)
sections.push(` actions: {\n${lines.join(',\n')}\n }`)
}
return `import { definePage } from 'vibe-registry'
// 由 vibe-registry 画廊生成 —— 页面只管业务,布局/组件来自注册表
// 之后 AI / 编辑器可直接修改此文件;改动只落在业务与配置上
export default definePage({
${sections.join(',\n')},
})
`
}
/** 漂亮的 JSON 值序列化:字符串带引号、对象/数组带缩进 */
function jsonValue(v: unknown, indentLevel: number): string {
if (v === null || typeof v !== 'object') return quotePrimitive(v)
const pad = ' '.repeat(indentLevel)
const innerPad = ' '.repeat(indentLevel + 1)
if (Array.isArray(v)) {
if (v.length === 0) return '[]'
const items = v.map((item) => `${innerPad}${jsonValue(item, indentLevel + 1)}`).join(',\n')
return `[\n${items},\n${pad}]`
}
const entries = Object.entries(v as Record<string, unknown>)
if (entries.length === 0) return '{}'
const lines = entries.map(([k, val]) => `${innerPad}${JSON.stringify(k)}: ${jsonValue(val, indentLevel + 1)}`).join(',\n')
return `{\n${lines},\n${pad}}`
}
function quotePrimitive(v: unknown): string {
if (typeof v === 'string') return JSON.stringify(v)
if (v === undefined) return 'undefined'
return String(v)
}
/** 注册表里 layout preview 的最小形态(codegen 只需要这个) */
interface DemoBlockLike {
readonly slot?: string
readonly widget: string
readonly props?: Record<string, unknown>
}
interface LayoutLike {
readonly preview?: { readonly demoBlocks?: ReadonlyArray<DemoBlockLike> }
}
interface RegistryLike {
readonly store: { readonly layouts: ReadonlyMap<string, LayoutLike> }
}
/** 从注册布局的 demoBlocks 生成一个"起步页面"声明(供画廊填业务后生成) */
export function starterPageFrom(layoutId: string, registry: RegistryLike, title: string): PageDeclaration {
const layout = registry.store.layouts.get(layoutId)
const demo = layout?.preview?.demoBlocks ?? []
return {
layout: layoutId,
title,
blocks: demo.map((d) => ({ widget: d.widget, slot: d.slot, props: d.props })),
}
}
+13 -4
View File
@@ -9,6 +9,7 @@
import React from 'react'
import type { RegistryApi, LayoutRegistryEntry, LayoutRenderProps } from './registry.ts'
import type { PageDeclaration, PageBlockDecl } from './page.ts'
export { generatePageSource, starterPageFrom } from './codegen.ts'
/** widget 的 React 组件形态(宽松:props 由声明 props/bind 决定) */
export type WidgetComponent = React.ComponentType<Record<string, unknown>> | React.ElementType
@@ -104,6 +105,9 @@ export function DefaultLayout({ slots, blocks }: LayoutRenderProps): React.React
export interface GalleryProps {
readonly registry: RegistryApi
readonly pages?: readonly PageDeclaration[]
/** 受控选中(供外部联动,如生成面板) */
readonly selected?: LayoutRegistryEntry | null
readonly onSelect?: (layout: LayoutRegistryEntry) => void
}
/** 由布局的 preview.demoBlocks 构造一个可渲染的 demo 页声明 */
@@ -142,10 +146,15 @@ function Thumb({ children, onClick }: { children?: React.ReactNode; onClick?: ()
)
}
export function LayoutGallery({ registry }: GalleryProps): React.ReactElement {
export function LayoutGallery({ registry, selected: selectedProp, onSelect }: GalleryProps): React.ReactElement {
const layouts = registry.listAll().filter((e): e is LayoutRegistryEntry => e.kind === 'layout')
const widgets = registry.listAll().filter((e) => e.kind === 'widget')
const [selected, setSelected] = React.useState<LayoutRegistryEntry | null>(null)
const [localSelected, setLocalSelected] = React.useState<LayoutRegistryEntry | null>(null)
const selected = selectedProp !== undefined ? selectedProp : localSelected
const select = (layout: LayoutRegistryEntry) => {
setLocalSelected(layout)
onSelect?.(layout)
}
const cards = layouts.map((layout) => {
const page = demoPageFrom(layout)
@@ -154,7 +163,7 @@ export function LayoutGallery({ registry }: GalleryProps): React.ReactElement {
{ key: layout.id, style: { display: 'flex', flexDirection: 'column', gap: 6 } },
React.createElement(
Thumb,
{ onClick: () => setSelected(layout) },
{ onClick: () => select(layout) },
page
? React.createElement(PageView, { page, registry, data: {} })
: React.createElement('div', { style: { padding: 12, color: '#9ca3af' } }, '(无 demoBlocks)'),
@@ -171,7 +180,7 @@ export function LayoutGallery({ registry }: GalleryProps): React.ReactElement {
React.createElement(
'button',
{
onClick: () => setSelected(layout),
onClick: () => select(layout),
style: { fontSize: 12, padding: '3px 10px', borderRadius: 6, border: '1px solid #d1d5db', background: '#fff', cursor: 'pointer' },
},
'预览',