From 8fe594ff6c8907b150f41d0000442e7fa0c229d1 Mon Sep 17 00:00:00 2001 From: hellow-da-sb Date: Thu, 3 Sep 2026 01:27:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(codegen):=20P=20=E5=B1=82=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=20=E2=80=94=E2=80=94=20=E6=A8=A1=E6=9D=BF=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=A1=B5=E9=9D=A2=20+=20=E7=94=BB=E5=BB=8A=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 通过 --- examples/playground/pages/page-products.tsx | 27 +++++ examples/playground/scripts/gen-and-save.ts | 38 +++++++ examples/playground/src/generator.tsx | 106 ++++++++++++++++++++ examples/playground/src/main.tsx | 20 ++-- examples/playground/tsconfig.json | 2 +- examples/playground/vite.config.ts | 39 ++++++- src/codegen.ts | 97 ++++++++++++++++++ src/render.ts | 17 +++- 8 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 examples/playground/pages/page-products.tsx create mode 100644 examples/playground/scripts/gen-and-save.ts create mode 100644 examples/playground/src/generator.tsx create mode 100644 src/codegen.ts diff --git a/examples/playground/pages/page-products.tsx b/examples/playground/pages/page-products.tsx new file mode 100644 index 0000000..906b7b2 --- /dev/null +++ b/examples/playground/pages/page-products.tsx @@ -0,0 +1,27 @@ +import { definePage } from 'vibe-registry' + +// 由 vibe-registry 画廊生成 —— 页面只管业务,布局/组件来自注册表 +// 之后 AI / 编辑器可直接修改此文件;改动只落在业务与配置上 +export default definePage({ + layout: "admin", + title: "商品管理", + blocks: [ + { widget: "data-table", slot: "content" }, + ], + setup: async () => ({ + "products": [ + { + "id": "P01", + "name": "键盘", + "price": 299, + "stock": 42, + }, + { + "id": "P02", + "name": "鼠标", + "price": 99, + "stock": 120, + }, + ], + }), +}) diff --git a/examples/playground/scripts/gen-and-save.ts b/examples/playground/scripts/gen-and-save.ts new file mode 100644 index 0000000..119c13f --- /dev/null +++ b/examples/playground/scripts/gen-and-save.ts @@ -0,0 +1,38 @@ +/** + * 用 codegen 生成真实页面源码,写入 pages/,验证产物可被工程编译 + * 不依赖 React/tsx 注册 —— 用轻量模拟注册表 + * 运行:node --experimental-strip-types scripts/gen-and-save.ts + */ +import { createRegistry } from 'vibe-registry' +import { generatePageSource, starterPageFrom } from 'vibe-registry/render' +import { writeFileSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const reg = createRegistry() +reg.registerLayout({ + kind: 'layout', + id: 'admin', + slots: ['sidebar', 'header', 'content', 'footer'], + description: 't', + preview: { + title: 'Admin', + demoBlocks: [{ slot: 'content', widget: 'data-table', props: {} }], + }, +}) +reg.registerWidget({ kind: 'widget', id: 'data-table', description: 't' }) + +const page = starterPageFrom('admin', reg, '商品管理') +const src = generatePageSource(page, { + products: [ + { id: 'P01', name: '键盘', price: 299, stock: 42 }, + { id: 'P02', name: '鼠标', price: 99, stock: 120 }, + ], +}) + +const out = join(dirname(fileURLToPath(import.meta.url)), '..', 'pages', 'page-products.tsx') +mkdirSync(dirname(out), { recursive: true }) +writeFileSync(out, src, 'utf8') +console.log('已生成:', out) +console.log('--- 代码(前 26 行)---') +console.log(src.split('\n').slice(0, 26).join('\n')) diff --git a/examples/playground/src/generator.tsx b/examples/playground/src/generator.tsx new file mode 100644 index 0000000..6ab8594 --- /dev/null +++ b/examples/playground/src/generator.tsx @@ -0,0 +1,106 @@ +/** + * PageGenerator —— P 层闭环的面板组件 + * 流程:选布局 → 填标题 → 生成 definePage 源码 → 复制/保存到 pages/ + */ +import React from 'react' +import type { RegistryApi, LayoutRegistryEntry } from 'vibe-registry' +import { generatePageSource, starterPageFrom } from 'vibe-registry/render' + +export interface PageGeneratorProps { + readonly registry: RegistryApi + readonly layout: LayoutRegistryEntry + /** 生成前对 demo 起步页的微调(可无) */ +} + +const sampleData: Record = { + rows: [ + { id: 'X001', name: '示例数据 1', note: '来自生成器的样例' }, + { id: 'X002', name: '示例数据 2', note: '可改成本文数据源' }, + ], +} + +export function PageGenerator({ registry, layout }: PageGeneratorProps): React.ReactElement { + const [title, setTitle] = React.useState(layout.preview?.title ?? layout.id) + const [code, setCode] = React.useState('') + const [status, setStatus] = React.useState('') + + const generate = React.useCallback(() => { + const page = starterPageFrom(layout.id, registry, title || layout.id) + setCode(generatePageSource(page, sampleData)) + setStatus('') + }, [layout, registry, title]) + + const copy = React.useCallback(async () => { + await navigator.clipboard.writeText(code) + setStatus('✓ 已复制到剪贴板') + }, [code]) + + const save = React.useCallback(async () => { + const slug = (title || layout.id).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '') + const filename = `page-${slug || layout.id}.tsx` + try { + const resp = await fetch('/__vr-save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename, content: code }), + }) + const j = (await resp.json()) as { ok: boolean; path?: string; error?: string } + if (j.ok) setStatus(`✓ 已保存到 ${j.path ?? ''}(HMR 生效)`) + else setStatus(`✗ 保存失败: ${j.error ?? 'unknown'}`) + } catch (e) { + setStatus(`✗ 保存失败: ${String(e)}`) + } + }, [title, layout, code]) + + const styleBtn: React.CSSProperties = { + fontSize: 12, + padding: '5px 12px', + borderRadius: 6, + border: '1px solid #d1d5db', + background: '#fff', + cursor: 'pointer', + } + + return React.createElement( + 'div', + { style: { borderTop: '1px solid #e5e7eb', paddingTop: 16 } }, + React.createElement('h3', { style: { margin: '0 0 8px', fontSize: 14 } }, `从模板生成页面 · ${layout.id}`), + React.createElement( + 'div', + { style: { display: 'flex', gap: 8, alignItems: 'center', marginBottom: 10, flexWrap: 'wrap' } }, + React.createElement('label', { style: { fontSize: 13 } }, '页面标题:'), + React.createElement('input', { + value: title, + onChange: (e: React.ChangeEvent) => setTitle(e.target.value), + style: { padding: '5px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: 13, minWidth: 220 }, + }), + React.createElement('button', { onClick: generate, style: { ...styleBtn, background: '#111827', color: '#fff', border: 'none' } }, '生成'), + code ? React.createElement('button', { onClick: copy, style: styleBtn }, '复制') : null, + code ? React.createElement('button', { onClick: save, style: styleBtn }, '保存到 pages/') : null, + ), + status ? React.createElement('div', { style: { fontSize: 12, color: '#16a34a', marginBottom: 8 } }, status) : null, + code + ? React.createElement( + 'pre', + { + style: { + background: '#0f172a', + color: '#e2e8f0', + borderRadius: 8, + padding: 14, + fontSize: 12, + lineHeight: 1.6, + overflow: 'auto', + maxHeight: 420, + margin: 0, + }, + }, + code, + ) + : React.createElement( + 'div', + { style: { color: '#9ca3af', fontSize: 13, padding: '8px 0' } }, + '填标题后点「生成」,产出标准 definePage 页面源码。', + ), + ) +} diff --git a/examples/playground/src/main.tsx b/examples/playground/src/main.tsx index 09db5ed..229f569 100644 --- a/examples/playground/src/main.tsx +++ b/examples/playground/src/main.tsx @@ -1,14 +1,17 @@ /** - * playground 入口:模板画廊(预览即真实)+ 业务实装示例 + * playground 入口:模板画廊(预览即真实)→ 生成页面(P 层闭环) */ import React from 'react' import { createRoot } from 'react-dom/client' +import type { LayoutRegistryEntry } from 'vibe-registry' import { registry } from 'vibe-registry' import { PageView, LayoutGallery } from 'vibe-registry/render' +import { PageGenerator } from './generator.tsx' import { orderListPage } from './pages.ts' function App(): React.ReactElement { const [data, setData] = React.useState | null>(null) + const [selected, setSelected] = React.useState(null) React.useEffect(() => { let cancelled = false @@ -27,19 +30,24 @@ function App(): React.ReactElement {
vibe-registry playground - 布局模板 · 引入即注册 · 页面只管业务 · 预览即真实 + 布局模板 → 生成页面 · 页面只管业务
- {/* ① 模板画廊:每卡真实渲染,点选看完整预览 */} + {/* ① 模板画廊 + 生成面板(受控联动) */}
- + + {selected ? ( + + ) : ( +
↑ 点选一个布局模板,即可从它生成页面
+ )}
- {/* ② 业务实装:同一布局,页面只管业务(订单列表) */} + {/* ② 实装示例:同一布局,页面只管业务(订单列表) */}

业务实装示例:订单列表页(复用 admin 布局)

- 页面只声明 layout:'admin' + blocks;数据来自 setup —— 与画廊里的 demo 用同一渲染内核 + 页面只声明 layout:'admin' + blocks;数据来自 setup —— 与画廊 demo 用同一渲染内核

{data ? ( diff --git a/examples/playground/tsconfig.json b/examples/playground/tsconfig.json index a8d1be1..9b8f84b 100644 --- a/examples/playground/tsconfig.json +++ b/examples/playground/tsconfig.json @@ -13,5 +13,5 @@ "forceConsistentCasingInFileNames": true, "types": ["vite/client"] }, - "include": ["src", "vite.config.ts"] + "include": ["src", "pages", "scripts", "vite.config.ts"] } diff --git a/examples/playground/vite.config.ts b/examples/playground/vite.config.ts index a366ec8..128112b 100644 --- a/examples/playground/vite.config.ts +++ b/examples/playground/vite.config.ts @@ -1,7 +1,42 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import react from '@vitejs/plugin-react' +import { writeFileSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** P 层闭环的写盘中间件:浏览器 POST 源码 → 落盘 pages/(HMR 自动生效) */ +function savePagePlugin(): Plugin { + const root = dirname(fileURLToPath(import.meta.url)) + return { + name: 'vr-save-page', + configureServer(server) { + server.middlewares.use('/__vr-save', (req, res) => { + let body = '' + req.on('data', (c) => (body += c)) + req.on('end', () => { + try { + const { filename, content } = JSON.parse(body) as { filename: string; content: string } + if (!/^[a-z0-9-]+\.tsx?$/.test(filename)) { + res.statusCode = 400 + res.end('bad filename') + return + } + const target = join(root, 'pages', filename) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, content, 'utf8') + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ ok: true, path: `pages/${filename}` })) + } catch (e) { + res.statusCode = 500 + res.end(JSON.stringify({ ok: false, error: String(e) })) + } + }) + }) + }, + } +} export default defineConfig({ - plugins: [react()], + plugins: [react(), savePagePlugin()], server: { port: 5183, open: false }, }) diff --git a/src/codegen.ts b/src/codegen.ts new file mode 100644 index 0000000..9fcf0e2 --- /dev/null +++ b/src/codegen.ts @@ -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 { + 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) + 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 +} +interface LayoutLike { + readonly preview?: { readonly demoBlocks?: ReadonlyArray } +} +interface RegistryLike { + readonly store: { readonly layouts: ReadonlyMap } +} + +/** 从注册布局的 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 })), + } +} diff --git a/src/render.ts b/src/render.ts index 6f52819..9860a1f 100644 --- a/src/render.ts +++ b/src/render.ts @@ -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> | 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(null) + const [localSelected, setLocalSelected] = React.useState(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' }, }, '预览',