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
@@ -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,
},
],
}),
})
@@ -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'))
+106
View File
@@ -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<string, unknown> = {
rows: [
{ id: 'X001', name: '示例数据 1', note: '来自生成器的样例' },
{ id: 'X002', name: '示例数据 2', note: '可改成本文数据源' },
],
}
export function PageGenerator({ registry, layout }: PageGeneratorProps): React.ReactElement {
const [title, setTitle] = React.useState<string>(layout.preview?.title ?? layout.id)
const [code, setCode] = React.useState<string>('')
const [status, setStatus] = React.useState<string>('')
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<HTMLInputElement>) => 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 页面源码。',
),
)
}
+14 -6
View File
@@ -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<Record<string, unknown> | null>(null)
const [selected, setSelected] = React.useState<LayoutRegistryEntry | null>(null)
React.useEffect(() => {
let cancelled = false
@@ -27,19 +30,24 @@ function App(): React.ReactElement {
<div>
<section style={{ borderBottom: '1px solid #e5e7eb', padding: '12px 24px', background: '#111827', color: '#fff' }}>
<strong>vibe-registry playground</strong>
<span style={{ marginLeft: 12, color: '#9ca3af', fontSize: 12 }}>布局模板 · 引入即注册 · 页面只管业务 · 预览即真实</span>
<span style={{ marginLeft: 12, color: '#9ca3af', fontSize: 12 }}>布局模板 → 生成页面 · 页面只管业务</span>
</section>
{/* ① 模板画廊:每卡真实渲染,点选看完整预览 */}
{/* ① 模板画廊 + 生成面板(受控联动) */}
<section style={{ padding: 24, background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
<LayoutGallery registry={registry} />
<LayoutGallery registry={registry} selected={selected} onSelect={setSelected} />
{selected ? (
<PageGenerator key={selected.id} registry={registry} layout={selected} />
) : (
<div style={{ color: '#9ca3af', fontSize: 13, padding: '12px 0' }}>↑ 点选一个布局模板,即可从它生成页面</div>
)}
</section>
{/* ② 业务实装:同一布局,页面只管业务(订单列表) */}
{/* ② 实装示例:同一布局,页面只管业务(订单列表) */}
<section style={{ padding: 24 }}>
<h2 style={{ marginTop: 0 }}>业务实装示例:订单列表页(复用 admin 布局)</h2>
<p style={{ color: '#6b7280', fontSize: 13, marginTop: 0 }}>
页面只声明 layout:&apos;admin&apos; + blocks;数据来自 setup —— 与画廊里的 demo 用同一渲染内核
页面只声明 layout:&apos;admin&apos; + blocks;数据来自 setup —— 与画廊 demo 用同一渲染内核
</p>
{data ? (
<PageView page={orderListPage} registry={registry} data={data} />
+1 -1
View File
@@ -13,5 +13,5 @@
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
"include": ["src", "pages", "scripts", "vite.config.ts"]
}
+37 -2
View File
@@ -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 },
})