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:
@@ -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'))
|
||||
@@ -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 页面源码。',
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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:'admin' + blocks;数据来自 setup —— 与画廊里的 demo 用同一渲染内核
|
||||
页面只声明 layout:'admin' + blocks;数据来自 setup —— 与画廊 demo 用同一渲染内核
|
||||
</p>
|
||||
{data ? (
|
||||
<PageView page={orderListPage} registry={registry} data={data} />
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src", "pages", "scripts", "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 },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user