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 React from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import type { LayoutRegistryEntry } from 'vibe-registry'
|
||||||
import { registry } from 'vibe-registry'
|
import { registry } from 'vibe-registry'
|
||||||
import { PageView, LayoutGallery } from 'vibe-registry/render'
|
import { PageView, LayoutGallery } from 'vibe-registry/render'
|
||||||
|
import { PageGenerator } from './generator.tsx'
|
||||||
import { orderListPage } from './pages.ts'
|
import { orderListPage } from './pages.ts'
|
||||||
|
|
||||||
function App(): React.ReactElement {
|
function App(): React.ReactElement {
|
||||||
const [data, setData] = React.useState<Record<string, unknown> | null>(null)
|
const [data, setData] = React.useState<Record<string, unknown> | null>(null)
|
||||||
|
const [selected, setSelected] = React.useState<LayoutRegistryEntry | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -27,19 +30,24 @@ function App(): React.ReactElement {
|
|||||||
<div>
|
<div>
|
||||||
<section style={{ borderBottom: '1px solid #e5e7eb', padding: '12px 24px', background: '#111827', color: '#fff' }}>
|
<section style={{ borderBottom: '1px solid #e5e7eb', padding: '12px 24px', background: '#111827', color: '#fff' }}>
|
||||||
<strong>vibe-registry playground</strong>
|
<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>
|
||||||
|
|
||||||
{/* ① 模板画廊:每卡真实渲染,点选看完整预览 */}
|
{/* ① 模板画廊 + 生成面板(受控联动) */}
|
||||||
<section style={{ padding: 24, background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
|
<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>
|
||||||
|
|
||||||
{/* ② 业务实装:同一布局,页面只管业务(订单列表) */}
|
{/* ② 实装示例:同一布局,页面只管业务(订单列表) */}
|
||||||
<section style={{ padding: 24 }}>
|
<section style={{ padding: 24 }}>
|
||||||
<h2 style={{ marginTop: 0 }}>业务实装示例:订单列表页(复用 admin 布局)</h2>
|
<h2 style={{ marginTop: 0 }}>业务实装示例:订单列表页(复用 admin 布局)</h2>
|
||||||
<p style={{ color: '#6b7280', fontSize: 13, marginTop: 0 }}>
|
<p style={{ color: '#6b7280', fontSize: 13, marginTop: 0 }}>
|
||||||
页面只声明 layout:'admin' + blocks;数据来自 setup —— 与画廊里的 demo 用同一渲染内核
|
页面只声明 layout:'admin' + blocks;数据来自 setup —— 与画廊 demo 用同一渲染内核
|
||||||
</p>
|
</p>
|
||||||
{data ? (
|
{data ? (
|
||||||
<PageView page={orderListPage} registry={registry} data={data} />
|
<PageView page={orderListPage} registry={registry} data={data} />
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"types": ["vite/client"]
|
"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 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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), savePagePlugin()],
|
||||||
server: { port: 5183, open: false },
|
server: { port: 5183, open: false },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -9,6 +9,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import type { RegistryApi, LayoutRegistryEntry, LayoutRenderProps } from './registry.ts'
|
import type { RegistryApi, LayoutRegistryEntry, LayoutRenderProps } from './registry.ts'
|
||||||
import type { PageDeclaration, PageBlockDecl } from './page.ts'
|
import type { PageDeclaration, PageBlockDecl } from './page.ts'
|
||||||
|
export { generatePageSource, starterPageFrom } from './codegen.ts'
|
||||||
|
|
||||||
/** widget 的 React 组件形态(宽松:props 由声明 props/bind 决定) */
|
/** widget 的 React 组件形态(宽松:props 由声明 props/bind 决定) */
|
||||||
export type WidgetComponent = React.ComponentType<Record<string, unknown>> | React.ElementType
|
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 {
|
export interface GalleryProps {
|
||||||
readonly registry: RegistryApi
|
readonly registry: RegistryApi
|
||||||
readonly pages?: readonly PageDeclaration[]
|
readonly pages?: readonly PageDeclaration[]
|
||||||
|
/** 受控选中(供外部联动,如生成面板) */
|
||||||
|
readonly selected?: LayoutRegistryEntry | null
|
||||||
|
readonly onSelect?: (layout: LayoutRegistryEntry) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 由布局的 preview.demoBlocks 构造一个可渲染的 demo 页声明 */
|
/** 由布局的 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 layouts = registry.listAll().filter((e): e is LayoutRegistryEntry => e.kind === 'layout')
|
||||||
const widgets = registry.listAll().filter((e) => e.kind === 'widget')
|
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 cards = layouts.map((layout) => {
|
||||||
const page = demoPageFrom(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 } },
|
{ key: layout.id, style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Thumb,
|
Thumb,
|
||||||
{ onClick: () => setSelected(layout) },
|
{ onClick: () => select(layout) },
|
||||||
page
|
page
|
||||||
? React.createElement(PageView, { page, registry, data: {} })
|
? React.createElement(PageView, { page, registry, data: {} })
|
||||||
: React.createElement('div', { style: { padding: 12, color: '#9ca3af' } }, '(无 demoBlocks)'),
|
: React.createElement('div', { style: { padding: 12, color: '#9ca3af' } }, '(无 demoBlocks)'),
|
||||||
@@ -171,7 +180,7 @@ export function LayoutGallery({ registry }: GalleryProps): React.ReactElement {
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
onClick: () => setSelected(layout),
|
onClick: () => select(layout),
|
||||||
style: { fontSize: 12, padding: '3px 10px', borderRadius: 6, border: '1px solid #d1d5db', background: '#fff', cursor: 'pointer' },
|
style: { fontSize: 12, padding: '3px 10px', borderRadius: 6, border: '1px solid #d1d5db', background: '#fff', cursor: 'pointer' },
|
||||||
},
|
},
|
||||||
'预览',
|
'预览',
|
||||||
|
|||||||
Reference in New Issue
Block a user