21c8764993
- LayoutGallery 升级:每模板卡片 = 真实渲染缩略图(scale 0.4),点选看完整预览 - demoPageFrom:由 preview.demoBlocks 构造可渲染 demo 页(预览即真实) - playground 新增 dashboard 布局 + stat-grid widget;app-shell 验证 DefaultLayout 兜底 - 三个模板(admin/dashboard/app-shell)全部 live 渲染验证通过
208 lines
7.8 KiB
TypeScript
208 lines
7.8 KiB
TypeScript
/**
|
|
* vibe-registry render —— 极薄 React 渲染内核(可选子路径:vibe-registry/render)
|
|
*
|
|
* 设计(v0.5):
|
|
* - core(registry/page)零依赖;渲染内核单独子路径,谁用谁引 React
|
|
* - 不做运行时编辑器,不重造组件:widget.component 映射已有组件
|
|
* - 预览即真实:画廊与实装共用本内核
|
|
*/
|
|
import React from 'react'
|
|
import type { RegistryApi, LayoutRegistryEntry, LayoutRenderProps } from './registry.ts'
|
|
import type { PageDeclaration, PageBlockDecl } from './page.ts'
|
|
|
|
/** widget 的 React 组件形态(宽松:props 由声明 props/bind 决定) */
|
|
export type WidgetComponent = React.ComponentType<Record<string, unknown>> | React.ElementType
|
|
|
|
/* ── 单 widget 渲染 ─────────────────────────────────────────── */
|
|
|
|
export interface WidgetViewProps {
|
|
readonly widget: string
|
|
readonly registry: RegistryApi
|
|
/** 静态 props */
|
|
readonly props?: Record<string, unknown>
|
|
/** setup 数据(供 bind 取字段) */
|
|
readonly data?: Record<string, unknown>
|
|
readonly bind?: Record<string, string>
|
|
}
|
|
|
|
export function WidgetView({ widget, registry, props = {}, data = {}, bind }: WidgetViewProps): React.ReactElement {
|
|
const entry = registry.store.widgets.get(widget)
|
|
if (!entry) {
|
|
throw new Error(`[vibe-registry/render] 未知 widget:'${widget}' (未注册)`)
|
|
}
|
|
const Comp = entry.component as WidgetComponent | undefined
|
|
if (!Comp) {
|
|
throw new Error(`[vibe-registry/render] widget '${widget}' 未挂组件(component 缺失)`)
|
|
}
|
|
// bind:props 名 ← setup 数据字段
|
|
const bound: Record<string, unknown> = {}
|
|
if (bind) {
|
|
for (const [prop, key] of Object.entries(bind)) bound[prop] = data[key]
|
|
}
|
|
return React.createElement(Comp, { ...props, ...bound })
|
|
}
|
|
|
|
/* ── 整页渲染:声明 + 注册表 → 组件树 ────────────────────────── */
|
|
|
|
export interface PageViewProps {
|
|
readonly page: PageDeclaration
|
|
readonly registry: RegistryApi
|
|
/** setup 已执行完的数据 */
|
|
readonly data?: Record<string, unknown>
|
|
}
|
|
|
|
export function PageView({ page, registry, data = {} }: PageViewProps): React.ReactElement {
|
|
const layout = registry.store.layouts.get(page.layout)
|
|
if (!layout) {
|
|
throw new Error(`[vibe-registry/render] 未知布局:'${page.layout}'`)
|
|
}
|
|
const renderLayout = (layout.render as React.ComponentType<LayoutRenderProps> | undefined) ?? DefaultLayout
|
|
// 按 slot 分组填充 blocks
|
|
const filled: Record<string, React.ReactNode[]> = {}
|
|
for (const b of page.blocks) {
|
|
const slot = b.slot ?? 'content'
|
|
;(filled[slot] ??= []).push(
|
|
React.createElement(WidgetView, {
|
|
key: b.widget + JSON.stringify(b.props ?? {}),
|
|
widget: b.widget,
|
|
registry,
|
|
props: b.props,
|
|
bind: b.bind,
|
|
data,
|
|
}),
|
|
)
|
|
}
|
|
return React.createElement(renderLayout, { slots: layout.slots, blocks: filled, page })
|
|
}
|
|
|
|
/** 缺省布局骨架:无 render 的布局按 slots 顺序纵向排(演示/兜底用) */
|
|
export function DefaultLayout({ slots, blocks }: LayoutRenderProps): React.ReactElement {
|
|
return React.createElement(
|
|
'div',
|
|
{ style: { border: '1px dashed #d1d5db', borderRadius: 8, padding: 12 } },
|
|
...slots.flatMap((slot) => {
|
|
const children = blocks[slot] as React.ReactNode[] | undefined
|
|
if (!children || children.length === 0) return []
|
|
return [
|
|
React.createElement(
|
|
'div',
|
|
{ key: slot, style: { marginBottom: 12 } },
|
|
React.createElement(
|
|
'div',
|
|
{ style: { fontSize: 11, color: '#9ca3af', marginBottom: 4, textTransform: 'uppercase' } },
|
|
`slot: ${slot}`,
|
|
),
|
|
...children,
|
|
),
|
|
]
|
|
}),
|
|
)
|
|
}
|
|
|
|
/* ── 画廊:布局模板 live 预览(类 Vite 首页,点选查看) ─────────── */
|
|
|
|
export interface GalleryProps {
|
|
readonly registry: RegistryApi
|
|
readonly pages?: readonly PageDeclaration[]
|
|
}
|
|
|
|
/** 由布局的 preview.demoBlocks 构造一个可渲染的 demo 页声明 */
|
|
function demoPageFrom(layout: LayoutRegistryEntry): PageDeclaration | null {
|
|
const demo = layout.preview?.demoBlocks ?? []
|
|
if (demo.length === 0) return null
|
|
return {
|
|
layout: layout.id,
|
|
title: layout.preview?.title ?? layout.id,
|
|
blocks: demo.map((d) => ({ widget: d.widget, slot: d.slot, props: d.props })),
|
|
}
|
|
}
|
|
|
|
/** 缩略预览:把真实渲染缩放进卡片(预览即真实) */
|
|
function Thumb({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }): React.ReactElement {
|
|
return React.createElement(
|
|
'div',
|
|
{
|
|
onClick,
|
|
style: {
|
|
width: 420,
|
|
height: 260,
|
|
overflow: 'hidden',
|
|
border: '1px solid #e5e7eb',
|
|
borderRadius: 8,
|
|
cursor: onClick ? 'pointer' : 'default',
|
|
position: 'relative',
|
|
background: '#fff',
|
|
},
|
|
},
|
|
React.createElement(
|
|
'div',
|
|
{ style: { width: 1050, transform: 'scale(0.4)', transformOrigin: 'top left' } },
|
|
children,
|
|
),
|
|
)
|
|
}
|
|
|
|
export function LayoutGallery({ registry }: 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 cards = layouts.map((layout) => {
|
|
const page = demoPageFrom(layout)
|
|
return React.createElement(
|
|
'div',
|
|
{ key: layout.id, style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
|
React.createElement(
|
|
Thumb,
|
|
{ onClick: () => setSelected(layout) },
|
|
page
|
|
? React.createElement(PageView, { page, registry, data: {} })
|
|
: React.createElement('div', { style: { padding: 12, color: '#9ca3af' } }, '(无 demoBlocks)'),
|
|
),
|
|
React.createElement(
|
|
'div',
|
|
{ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
React.createElement(
|
|
'div',
|
|
{},
|
|
React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, layout.preview?.title ?? layout.id),
|
|
React.createElement('div', { style: { fontSize: 11, color: '#9ca3af' } }, `id: ${layout.id} · slots: ${layout.slots.join(' → ')}`),
|
|
),
|
|
React.createElement(
|
|
'button',
|
|
{
|
|
onClick: () => setSelected(layout),
|
|
style: { fontSize: 12, padding: '3px 10px', borderRadius: 6, border: '1px solid #d1d5db', background: '#fff', cursor: 'pointer' },
|
|
},
|
|
'预览',
|
|
),
|
|
),
|
|
)
|
|
})
|
|
|
|
const detail = selected ? demoPageFrom(selected) : null
|
|
const detailEl = selected
|
|
? React.createElement(
|
|
'div',
|
|
{ style: { marginTop: 20 } },
|
|
React.createElement('h3', {}, `${selected.preview?.title ?? selected.id} · 完整预览`),
|
|
detail
|
|
? React.createElement(
|
|
'div',
|
|
{ style: { border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden' } },
|
|
React.createElement(PageView, { page: detail, registry, data: {} }),
|
|
)
|
|
: React.createElement('div', {}, '(此布局无 demo)'),
|
|
)
|
|
: null
|
|
|
|
return React.createElement(
|
|
'div',
|
|
{ style: { fontFamily: 'system-ui, sans-serif', maxWidth: 960, margin: '0 auto', padding: 24 } },
|
|
React.createElement('h1', { style: { fontSize: 20 } }, 'vibe-registry · 布局模板'),
|
|
React.createElement('p', { style: { color: '#6b7280' } }, `已注册 ${layouts.length} 个布局模板 · ${widgets.length} 个 widget · 点击卡片预览(预览即真实)`),
|
|
React.createElement('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(430px, 1fr))', gap: 16 } }, ...cards),
|
|
detailEl,
|
|
)
|
|
}
|