feat(render): M2 — React 渲染内核 + 布局画廊(playground)

- core 新增 render 子路径(vibe-registry/render):PageView + WidgetView + LayoutGallery
- layout 注册项支持 render 实现;缺省兜底 DefaultLayout
- widget 注册项 component 挂真实 React 组件
- PageBlockDecl 增加 slot 字段(块落在布局哪个槽位)
- playground:admin 布局真实骨架 + 组件 + 订单页,dev 预览 + 生产构建通过
- 全量回归:core/qa-orchestrator/playground typecheck 全绿
This commit is contained in:
hellow-da-sb
2026-09-03 00:59:31 +08:00
parent 8c93bde5f6
commit 2277d55ac9
16 changed files with 1628 additions and 4 deletions
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vibe-registry playground</title>
<style>
body { margin: 0; background: #fff; }
* { box-sizing: border-box; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "vibe-playground",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "vibe-registry 渲染验证 playground:画廊 + 真实 React 渲染",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"vibe-registry": "workspace:*"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.8.0",
"vite": "^6.0.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* admin 布局的真实渲染骨架
* layout id 是稳定契约:页面只引用 'admin',骨架长什么样由这里决定
*/
import React from 'react'
import type { LayoutRenderProps } from 'vibe-registry'
export function AdminLayout({ blocks }: LayoutRenderProps): React.ReactElement {
const sidebar = (blocks['sidebar'] as React.ReactNode[] | undefined) ?? []
const header = (blocks['header'] as React.ReactNode[] | undefined) ?? []
const content = (blocks['content'] as React.ReactNode[] | undefined) ?? []
const footer = (blocks['footer'] as React.ReactNode[] | undefined) ?? []
return (
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: 'system-ui, sans-serif' }}>
<aside style={{ width: 200, background: '#1f2937', color: '#f9fafb', padding: 16, flexShrink: 0 }}>
<div style={{ fontWeight: 700, marginBottom: 12 }}>vibe-admin</div>
{sidebar}
</aside>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '12px 24px', borderBottom: '1px solid #e5e7eb', background: '#fff' }}>{header}</div>
<main style={{ padding: 24, background: '#f9fafb', flex: 1 }}>{content}</main>
{footer.length > 0 ? <footer style={{ padding: 12, color: '#9ca3af', fontSize: 12 }}>{footer}</footer> : null}
</div>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
/**
* playground 入口:画廊 + 真实页面渲染(类 Vite 首页体验)
* 演示:import 即注册 → setup 业务 → PageView 渲染
*/
import React from 'react'
import { createRoot } from 'react-dom/client'
import { registry } from 'vibe-registry'
import { PageView, LayoutGallery } from 'vibe-registry/render'
import { orderListPage } from './pages.ts'
function App(): React.ReactElement {
const [data, setData] = React.useState<Record<string, unknown> | null>(null)
React.useEffect(() => {
let cancelled = false
async function load() {
if (!orderListPage.setup) return
const d = (await orderListPage.setup({ query: {}, params: {} })) as Record<string, unknown> | undefined
if (!cancelled) setData(d ?? {})
}
void load()
return () => {
cancelled = true
}
}, [])
return (
<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 }}>import 即注册 · 页面只管业务 · 预览即真实</span>
</section>
<section style={{ padding: 24 }}>
<h2 style={{ marginTop: 0 }}>真实渲染:订单列表页(layout: admin)</h2>
{data ? (
<PageView page={orderListPage} registry={registry} data={data} />
) : (
<div style={{ color: '#6b7280' }}>setup 加载中…</div>
)}
</section>
<section style={{ padding: 24, borderTop: '1px solid #e5e7eb', background: '#f9fafb' }}>
<LayoutGallery registry={registry} />
</section>
</div>
)
}
createRoot(document.getElementById('root')!).render(<App />)
+36
View File
@@ -0,0 +1,36 @@
/**
* playground 页面声明 —— 验证"用户告诉 agent 用 admin 布局,页面只管业务"
*/
import { definePage } from 'vibe-registry'
import './registry.ts' // 集中注册(副作用)
export const orderListPage = definePage({
layout: 'admin',
title: '订单列表',
blocks: [
{ slot: 'sidebar', widget: 'nav-menu', props: { items: [{ label: '订单', active: true }, { label: '商品' }, { label: '客户' }] } },
{ slot: 'header', widget: 'page-header', props: { title: '订单管理', subtitle: '近 30 天全部订单' } },
{
slot: 'content',
widget: 'data-table',
props: {
columns: [
{ key: 'id', title: '订单号' },
{ key: 'customer', title: '客户' },
{ key: 'amount', title: '金额' },
{ key: 'status', title: '状态' },
],
},
bind: { rows: 'orders' },
},
],
setup: async () => {
return {
orders: [
{ id: 'A1001', customer: '张三', amount: '¥ 1,299', status: '已支付' },
{ id: 'A1002', customer: '李四', amount: '¥ 88', status: '待发货' },
{ id: 'A1003', customer: '王五', amount: '¥ 4,560', status: '已完成' },
],
}
},
})
+44
View File
@@ -0,0 +1,44 @@
/**
* playground 集中注册:widget 挂真实组件,layout 挂真实骨架
* 这就是"项目侧注册"的完整形态 —— 之后 AI 生成页面只用 id,不碰实现
*/
import { registerLayout, registerWidget } from 'vibe-registry'
import { AdminLayout } from './layouts/admin.tsx'
import { PageHeader, DataTable, NavMenu } from './widgets.tsx'
// ── 布局注册(带真实渲染骨架)──────────────────────────────
registerLayout({
kind: 'layout',
id: 'admin',
slots: ['sidebar', 'header', 'content', 'footer'],
description: '后台管理骨架',
render: AdminLayout,
preview: {
title: 'Admin · 后台管理',
demoBlocks: [
{ slot: 'sidebar', widget: 'nav-menu', props: { items: [{ label: '订单', active: true }, { label: '商品' }] } },
{ slot: 'header', widget: 'page-header', props: { title: '订单管理', subtitle: '全部订单' } },
{ slot: 'content', widget: 'data-table' },
],
},
})
// ── widget 注册(挂真实组件)──────────────────────────────
registerWidget({
kind: 'widget',
id: 'page-header',
component: PageHeader,
description: '页头(标题 + 副标题)',
})
registerWidget({
kind: 'widget',
id: 'nav-menu',
component: NavMenu,
description: '侧栏导航菜单',
})
registerWidget({
kind: 'widget',
id: 'data-table',
component: DataTable,
description: '通用数据表格(列定义来自声明 props)',
})
+72
View File
@@ -0,0 +1,72 @@
/**
* playground 业务 widget(真实 React 组件)
* 演示:注册表 widget → React 组件 → 渲染内核装配
*/
import React from 'react'
/** 页头 */
export function PageHeader({ title, subtitle }: { title?: string; subtitle?: string }): React.ReactElement {
return React.createElement(
'header',
{ style: { marginBottom: 16 } },
React.createElement('h1', { style: { margin: '0 0 4px', fontSize: 20 } }, title ?? '未命名'),
subtitle ? React.createElement('p', { style: { margin: 0, color: '#6b7280', fontSize: 13 } }, subtitle) : null,
)
}
/** 数据表格 */
export function DataTable({
columns = [],
rows = [],
}: {
columns?: Array<{ key: string; title: string }>
rows?: Array<Record<string, unknown>>
}): React.ReactElement {
return React.createElement(
'table',
{ style: { borderCollapse: 'collapse', width: '100%', fontSize: 13 } },
React.createElement(
'thead',
{},
React.createElement(
'tr',
{},
...(columns as Array<{ key: string; title: string }>).map((c) =>
React.createElement(
'th',
{ key: c.key, style: { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #d1d5db' } },
c.title,
),
),
),
),
React.createElement(
'tbody',
{},
...(rows as Array<Record<string, unknown>>).map((r, i) =>
React.createElement(
'tr',
{ key: i },
...(columns as Array<{ key: string; title: string }>).map((c) =>
React.createElement('td', { key: c.key, style: { padding: '6px 8px', borderBottom: '1px solid #f3f4f6' } }, String(r[c.key] ?? '')),
),
),
),
),
)
}
/** 导航菜单 */
export function NavMenu({ items = [] }: { items?: Array<{ label: string; active?: boolean }> }): React.ReactElement {
return React.createElement(
'ul',
{ style: { listStyle: 'none', margin: 0, padding: 0 } },
...(items as Array<{ label: string; active?: boolean }>).map((it, i) =>
React.createElement(
'li',
{ key: i, style: { padding: '6px 8px', borderRadius: 6, marginBottom: 2, background: it.active ? '#e0e7ff' : 'transparent' } },
it.label,
),
),
)
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { port: 5183, open: false },
})
+7 -1
View File
@@ -6,14 +6,20 @@
"description": "万物皆注册 · 面向 AI 前端的注册式页面框架(M1 概念验证)",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./render": "./src/render.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"vr": "node --experimental-strip-types cli/vr.ts"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"react": "^18.3.0",
"typescript": "^5.8.0"
}
}
+1156
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -1,2 +1,8 @@
packages:
- "examples/*"
allowBuilds:
esbuild: true
onlyBuiltDependencies:
- esbuild
+3 -2
View File
@@ -8,6 +8,7 @@ export type {
ActionRegistryEntry,
SchemaRegistryEntry,
LayoutPreview,
} from './registry.js'
LayoutRenderProps,
} from './registry.ts'
export { definePage } from './page.ts'
export type { PageDeclaration, PageBlockDecl, PageContext, SetupData } from './page.js'
export type { PageDeclaration, PageBlockDecl, PageContext, SetupData } from './page.ts'
+2
View File
@@ -12,6 +12,8 @@ import { type RegistryApi, registry } from './registry.ts'
export interface PageBlockDecl {
/** widget 注册 id,如 'data-table'、'page-header' */
readonly widget: string
/** 落在布局的哪个槽位(缺省 'content';渲染内核按布局 slots 装配) */
readonly slot?: string
/** 静态 props(可选) */
readonly props?: Record<string, unknown>
/** 业务绑定:setup 返回的数据里取哪些字段喂给 widget(键:widget prop 名,值:setup 结果字段) */
+12 -1
View File
@@ -9,6 +9,15 @@
export type RegistryKind = 'layout' | 'widget' | 'action' | 'schema' | 'theme'
/** 布局渲染函数签名(React 无关,render 层传入类型;避免 core 依赖 React) */
export interface LayoutRenderProps<S extends string = string> {
readonly slots: readonly S[]
/** slot → 已填充的 ReactNode 数组 */
readonly blocks: Record<string, readonly unknown[]>
/** 页面声明本身(含 title 等元信息) */
readonly page: { readonly layout: string; readonly title?: string }
}
/** 布局骨架:页面级结构。渲染内核据 slots 填充 blocks。 */
export interface LayoutRegistryEntry {
readonly kind: 'layout'
@@ -18,6 +27,8 @@ export interface LayoutRegistryEntry {
readonly slots: readonly string[]
/** 描述,供画廊/agent 展示 */
readonly description: string
/** 布局渲染实现(slots → 页面骨架)。可选:缺省用画廊的通用骨架 */
readonly render?: (props: LayoutRenderProps) => unknown
/** demo 数据(预览即真实,与实装共用渲染内核) */
readonly preview?: LayoutPreview
}
@@ -37,7 +48,7 @@ export interface LayoutPreview {
export interface WidgetRegistryEntry {
readonly kind: 'widget'
readonly id: string
/** 渲染组件。内核薄,仅做映射层 */
/** 渲染组件(React 组件;render 层消费,core 不依赖 React 类型) */
readonly component?: unknown
/** 关联字段/列 schema id(可选) */
readonly schemaId?: string
+149
View File
@@ -0,0 +1,149 @@
/**
* 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[]
}
function PreviewShell({ title, children }: { title: string; children?: React.ReactNode }): React.ReactElement {
return React.createElement(
'div',
{ style: { border: '1px solid #e5e7eb', borderRadius: 8, padding: 12, background: '#fff' } },
React.createElement('h3', { style: { margin: '0 0 8px', fontSize: 14 } }, title),
React.createElement('div', { style: { fontSize: 12, color: '#6b7280' } }, 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 items = layouts.map((layout) => {
const demo = layout.preview?.demoBlocks ?? []
return React.createElement(
PreviewShell,
{ key: layout.id, title: `${layout.preview?.title ?? layout.id} (${layout.id})` },
React.createElement(
'div',
{},
React.createElement('div', {}, `slots: ${layout.slots.join(' → ')}`),
demo.length
? React.createElement(
'ul',
{},
demo.map((d, i) =>
React.createElement('li', { key: i }, `${d.slot} ← widget:${d.widget} ${d.props ? JSON.stringify(d.props) : ''}`),
),
)
: React.createElement('div', {}, '(无 demo)'),
),
)
})
return React.createElement(
'div',
{ style: { fontFamily: 'system-ui, sans-serif', maxWidth: 720, 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', gap: 12 } }, ...items),
)
}