// 组件橱窗蓝图(REQ-012):组件目录 → 页面蓝图,程序拼装,不经过模型。 // // 为什么走蓝图而不是手写一张展示页:手写的那张页是第二套前端运行壳, // 主题、空间、骨架都得自己维护。走蓝图就只是一张普通的 AI 生成页——BlueprintCompiler // 编译、Registry 发布、页面路径访问,与生成页同一条链路。 // // 它证明的是「契约样张与目录投影能过生成链」:每个条目的样张按契约校验后编成语义展示卡, // 旁边贴契约原文。它**不**渲染组件本体——生成页不挂载 soma-ui 组件运行时(见 buildGroup), // 组件真实的视觉与交互由 Host 的组件页与 soma-ui 视觉矩阵负责。 // // 一页一个包:soma-ui 一页,业务自己的包一页。分组依据是组件目录自带的 // `pack` 与 `layer`,不是手打的标签——手打的标签会漏、会过期,而目录不会。 import { isBlueprintBlockCompatible, type BlueprintBlock, type BlueprintGroup, type SomaPageBlueprint, } from '../ai-page/BlueprintCompiler'; import type { PageCatalogEntry } from '../ai-page/PageDeclarationValidator'; import { selectSomaCatalogExample, formatSomaCatalogExample, type SomaContractExample } from 'soma-core'; /** 说明板组件名:橱窗把契约原文贴在组件本体旁边靠它 */ export const SPEC_PANEL_COMPONENT = 'ContractSpecPanel'; /** 橱窗页 slug 前缀:`showcase-soma-ui`、`showcase-acme-crm` */ export const SHOWCASE_SLUG_PREFIX = 'showcase-'; /** * 橱窗里的一个组件。 * * 字段全部来自组件目录条目(`generatePackCatalog` 的产物): * `sample` 是契约示例值派生出的样张,`spec` 是字段行原文,`usage` 是 ai.example。 */ export interface ShowcaseComponent { name: string; layer: string; version: string; description: string; pack?: string; /** 派生样张:作为该条目的 props 编进语义展示卡(按契约校验,不挂载组件本体) */ sample?: Record; /** 字段行原文 */ spec?: string; /** 用法片段(html / ui / event 三行) */ usage?: string; } export interface BuildShowcaseBlueprintOptions { /** 包名,进标题与 slug */ pack: string; /** 本页收录的组件(已按层排好) */ components: readonly ShowcaseComponent[]; /** 同一包分了多页时的页码(1 起);1 不进 slug */ page?: number; /** 总页数,进副标题 */ pageCount?: number; /** 说明板是否可用(目录里有 ContractSpecPanel 才排它) */ withSpecPanel?: boolean; } const LAYER_LABEL: Record = { atom: '原子件', composite: '复合件', solution: '业务组件', template: '模板件', }; export function showcaseSlug(pack: string, page = 1): string { const base = `${SHOWCASE_SLUG_PREFIX}${pack}`; const slug = page > 1 ? `${base}-${page}` : base; return slug.slice(0, 64).replace(/-$/, ''); } /** * 一个组件排成一组:左边是由可信样张派生的语义展示卡,右边是契约说明。 * * `soma-ai` 不挂载 Host 组件,因此这不是组件运行时预览;真实交互/视觉预览由 * Host 的组件页负责。两列布局让样张语义与契约能同屏阅读。 */ function buildGroup(component: ShowcaseComponent, withSpecPanel: boolean): BlueprintGroup { const live: BlueprintBlock = { component: component.name, ...(component.sample && Object.keys(component.sample).length > 0 ? { props: component.sample } : {}), }; const blocks: BlueprintBlock[] = [live]; if (withSpecPanel) { const tags: Array> = [ { key: 'layer', label: LAYER_LABEL[component.layer] ?? component.layer, tone: 'primary' }, { key: 'version', label: `v${component.version}`, tone: 'neutral' }, ]; if (component.pack) tags.push({ key: 'pack', label: component.pack, tone: 'success' }); blocks.push({ component: SPEC_PANEL_COMPONENT, props: { title: component.name, subtitle: component.description, tags, spec: component.spec ?? '', usage: component.usage ?? '', sample: component.sample ? JSON.stringify(component.sample, null, 2) : '', // 复制入口需要页面接 emit;橱窗是只读页,关掉它而不是给一个点了没反应的按钮 copyable: false, }, }); } return { group: 'grid', columns: withSpecPanel ? 2 : 1, gap: 'lg', align: 'start', title: `${component.name} · ${component.description}`, blocks, }; } export function buildShowcaseBlueprint(options: BuildShowcaseBlueprintOptions): SomaPageBlueprint { const { pack, components, page = 1, pageCount = 1 } = options; const withSpecPanel = options.withSpecPanel !== false; const counts = components.reduce>((acc, item) => { acc[item.layer] = (acc[item.layer] ?? 0) + 1; return acc; }, {}); const breakdown = Object.entries(counts) .map(([layer, count]) => `${LAYER_LABEL[layer] ?? layer} ${count}`) .join(' · '); return { title: `${pack} 组件橱窗`, subtitle: pageCount > 1 ? `${breakdown}(第 ${page} / ${pageCount} 页)·样张由契约示例值派生` : `${breakdown}·样张由契约示例值派生`, slug: showcaseSlug(pack, page), skeleton: 'page', maxWidth: 'wide', blocks: components.map((component) => buildGroup(component, withSpecPanel)), }; } /** * 目录条目 → 橱窗组件,并说明落选原因。 * * 两种落选都不是错误:必填插槽需要 Host 投影内容,语义展示卡表达不了; * 没有样张的组件放上去只有空壳。 */ export interface ShowcaseSelection { picked: ShowcaseComponent[]; skipped: Array<{ name: string; reason: string }>; } export interface ShowcaseSelectionOptions { includeSpecPanel?: boolean; } export interface ShowcaseCatalogEntry extends PageCatalogEntry { version?: string; description?: string; pack?: string; spec?: string; sample?: Record; ai?: { example?: string; examples?: readonly SomaContractExample[] }; } const LAYER_ORDER = ['solution', 'composite', 'atom', 'template']; export function selectShowcaseComponents( entries: readonly ShowcaseCatalogEntry[], options: ShowcaseSelectionOptions = {}, ): ShowcaseSelection { const picked: ShowcaseComponent[] = []; const skipped: Array<{ name: string; reason: string }> = []; for (const entry of entries) { if (entry.name === SPEC_PANEL_COMPONENT && options.includeSpecPanel !== true) { skipped.push({ name: entry.name, reason: '说明板本身就是橱窗的一部分,不再自我展示' }); continue; } if (!isBlueprintBlockCompatible(entry)) { skipped.push({ name: entry.name, reason: '有必填插槽——语义展示卡承载不了', }); continue; } if (!entry.sample || Object.keys(entry.sample).length === 0) { skipped.push({ name: entry.name, reason: '契约里没有示例值,样张派生不出内容(补 example 后自动进橱窗)', }); continue; } picked.push({ name: entry.name, layer: entry.layer ?? 'composite', version: entry.version ?? '0.0.0', description: entry.description ?? '', pack: entry.pack, sample: entry.sample, spec: entry.spec, usage: (() => { // This producer only emits native HTML blueprints. A tree-capable Host // catalog must not silently switch its declaration format. const example = selectSomaCatalogExample(entry, { formats: ['legacy-html'], capabilities: [] }); return example ? formatSomaCatalogExample(example) : entry.ai?.example; })(), }); } picked.sort((a, b) => { const orderA = LAYER_ORDER.indexOf(a.layer); const orderB = LAYER_ORDER.indexOf(b.layer); return (orderA < 0 ? LAYER_ORDER.length : orderA) - (orderB < 0 ? LAYER_ORDER.length : orderB) || a.name.localeCompare(b.name); }); return { picked, skipped }; }