From 8c93bde5f65af80f419c49722baee8ba85921263 Mon Sep 17 00:00:00 2001 From: hellow-da-sb Date: Thu, 3 Sep 2026 00:48:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20vibe-registry=20M1=20=E2=80=94=20?= =?UTF-8?q?=E4=B8=87=E7=89=A9=E7=9A=86=E6=B3=A8=E5=86=8C=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core: registerLayout/Widget/Action(泛型)/Schema + 校验闸门 - definePage: 页面只管业务,结构来自注册表 - presets: admin / app-shell 预设布局(vr add 物化) - cli: vr list/add/update 防覆盖 - examples/qa-orchestrator: 实战编排验证(workspace:* 依赖) --- .gitignore | 19 ++ README.md | 54 ++++ cli/vr.ts | 146 ++++++++++ docs/design.md | 262 ++++++++++++++++++ examples/qa-orchestrator/package.json | 18 ++ examples/qa-orchestrator/pages/overview.ts | 37 +++ examples/qa-orchestrator/pages/run-history.ts | 27 ++ .../qa-orchestrator/pages/suite-detail.ts | 42 +++ examples/qa-orchestrator/pnpm-lock.yaml | 48 ++++ .../registry/actions/run-suite.ts | 15 + .../registry/actions/stop-run.ts | 13 + examples/qa-orchestrator/registry/index.ts | 16 ++ .../registry/layouts/qa-console.ts | 19 ++ .../registry/schemas/test-case-fields.ts | 17 ++ .../registry/widgets/status-badge.ts | 10 + .../registry/widgets/suite-detail-view.ts | 12 + .../registry/widgets/suite-list.ts | 11 + .../qa-orchestrator/scripts/orchestrate.ts | 61 ++++ examples/qa-orchestrator/tsconfig.json | 15 + package.json | 19 ++ pnpm-lock.yaml | 52 ++++ pnpm-workspace.yaml | 2 + presets/layouts/admin.ts | 23 ++ presets/layouts/app-shell.ts | 22 ++ registry/layouts/admin.ts | 23 ++ scripts/smoke.ts | 72 +++++ src/index.ts | 13 + src/page.ts | 56 ++++ src/registry.ts | 146 ++++++++++ tsconfig.json | 19 ++ 30 files changed, 1289 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cli/vr.ts create mode 100644 docs/design.md create mode 100644 examples/qa-orchestrator/package.json create mode 100644 examples/qa-orchestrator/pages/overview.ts create mode 100644 examples/qa-orchestrator/pages/run-history.ts create mode 100644 examples/qa-orchestrator/pages/suite-detail.ts create mode 100644 examples/qa-orchestrator/pnpm-lock.yaml create mode 100644 examples/qa-orchestrator/registry/actions/run-suite.ts create mode 100644 examples/qa-orchestrator/registry/actions/stop-run.ts create mode 100644 examples/qa-orchestrator/registry/index.ts create mode 100644 examples/qa-orchestrator/registry/layouts/qa-console.ts create mode 100644 examples/qa-orchestrator/registry/schemas/test-case-fields.ts create mode 100644 examples/qa-orchestrator/registry/widgets/status-badge.ts create mode 100644 examples/qa-orchestrator/registry/widgets/suite-detail-view.ts create mode 100644 examples/qa-orchestrator/registry/widgets/suite-list.ts create mode 100644 examples/qa-orchestrator/scripts/orchestrate.ts create mode 100644 examples/qa-orchestrator/tsconfig.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 presets/layouts/admin.ts create mode 100644 presets/layouts/app-shell.ts create mode 100644 registry/layouts/admin.ts create mode 100644 scripts/smoke.ts create mode 100644 src/index.ts create mode 100644 src/page.ts create mode 100644 src/registry.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f297ff2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# 依赖 +node_modules/ +.pnpm-store/ + +# 构建产物 +dist/ +build/ + +# 日志与系统 +*.log +.DS_Store +Thumbs.db + +# 编辑器 +.vscode/ +.idea/ + +# 本地临时 +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3101d1 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# vibe-registry + +> 万物皆注册 · 面向 AI 前端的注册式页面框架(M1 概念验证) + +## 设计 + +- 完整设计文档见 [`docs/design.md`](docs/design.md)(v0.5) +- 一句话:页面 = 注册项的组合;用户/agent 只需说"用 xx 布局",结构全部来自注册表 + +## 当前结构(M1) + +``` +src/registry.ts 万物皆注册的类型基石 + 全局注册表 + 校验(含泛型 action) +src/page.ts definePage 声明层(页面只管业务) +presets/layouts/ 包内预设布局源(vr add 数据源,带 demo 预览) +cli/vr.ts vr list / add / update(防覆盖) +scripts/smoke.ts 校验闸门 + id 唯一性验证 +registry/layouts/ 物化后的资产空间(预设 = 你的源码) +examples/ + qa-orchestrator/ 实战编排:测试编排系统(多页面 + 自定义注册 + workspace 依赖) +``` + +## 实战编排示例(qa-orchestrator) + +验证框架真实可用性:workspace 依赖引入、自定义注册(非预设)、多页面 `definePage` 编排、`setup` 业务数据流。 + +```bash +cd examples/qa-orchestrator +pnpm install # workspace:* 链接根源码 +pnpm typecheck # 类型检查 +pnpm orchestrate # 注册清单 + 3 页面解析树 + setup 数据流验证 +``` + +## 快速上手 + +```bash +pnpm install +pnpm typecheck # 类型检查 +pnpm vr list # 列出可用预设布局 +pnpm vr add admin # 物化预设到 registry/layouts/admin.ts(防覆盖) +pnpm vr update admin # 对比新版,预览 diff(不写文件,除非 --force) +node --experimental-strip-types scripts/smoke.ts # 校验闸门冒烟 +``` + +## 防覆盖原则 + +- 目标已存在且与预设**不同**(被用户改过)→ 拒绝,绝不静默覆盖 +- 内容相同 → already up-to-date +- 显式 `--force` 才允许覆盖 + +## 关键理念速记 + +1. TS 优先 · 2. 极薄渲染内核 · 3. 布局带简易 id + live 预览 +4. 开放可扩展 · 5. 未使用不进产物 · 6. P 层选择预览 · 7. 预设物化进项目 diff --git a/cli/vr.ts b/cli/vr.ts new file mode 100644 index 0000000..3a40c21 --- /dev/null +++ b/cli/vr.ts @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * vr —— vibe-registry CLI(M1) + * + * 用法: + * vr list 列出包内可用预设布局 + * vr add [--force] 物化预设到项目 registry/layouts/(防覆盖) + * vr update [--force] 对比包内新版,拉 diff(防覆盖,不静默改你的代码) + * + * 防覆盖原则(v0.5 决策): + * - 目标文件已存在且与预设不同(被用户改过)→ 拒绝,绝不静默覆盖 + * - 内容相同 → 提示 already up-to-date + * - 显式 --force 才允许覆盖(用户自己确认) + */ +import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join, relative } from 'node:path' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const presetsDir = join(root, 'presets', 'layouts') +const targetDir = join(process.cwd(), 'registry', 'layouts') + +function listPresets(): string[] { + return readdirSync(presetsDir) + .filter((f) => f.endsWith('.ts')) + .map((f) => f.replace(/\.ts$/, '')) + .sort() +} + +function presetFile(id: string): string | undefined { + const f = join(presetsDir, `${id}.ts`) + return existsSync(f) && statSync(f).isFile() ? f : undefined +} + +function targetFile(id: string): string { + return join(targetDir, `${id}.ts`) +} + +function showDiff(target: string, preset: string, id: string): void { + const tLines = target.split(/\r?\n/) + const pLines = preset.split(/\r?\n/) + const max = Math.max(tLines.length, pLines.length) + const diffs: string[] = [] + for (let i = 0; i < max; i++) { + const a = tLines[i] ?? '' + const b = pLines[i] ?? '' + if (a !== b) diffs.push(` L${i + 1}: 你的 "${a.slice(0, 60)}" → 新版 "${b.slice(0, 60)}"`) + if (diffs.length >= 8) break + } + console.log(` 差异预览(最多 8 行,${id}.ts):`) + diffs.forEach((d) => console.log(d)) +} + +function cmdList(): void { + const presets = listPresets() + console.log(`可用预设布局(${presets.length}):`) + presets.forEach((id) => { + const f = presetFile(id)! + const head = readFileSync(f, 'utf8').split(/\r?\n/).slice(0, 3).join(' ') + console.log(` - ${id}`) + console.log(` ${head.replace(/^\/\*\*?\s?/, '').slice(0, 80)}`) + }) +} + +function cmdAdd(id: string, force: boolean): void { + const src = presetFile(id) + if (!src) { + console.error(`✗ 未知预设布局:'${id}'`) + console.error(` 可用:${listPresets().join(', ')}`) + process.exit(1) + } + const content = readFileSync(src, 'utf8') + const dest = targetFile(id) + + if (existsSync(dest)) { + const existing = readFileSync(dest, 'utf8') + if (existing === content) { + console.log(`• '${id}' 已物化且与预设一致(already up-to-date)`) + return + } + if (!force) { + console.error(`✗ 防覆盖:registry/layouts/${id}.ts 已存在且被修改过`) + console.error(` 不静默覆盖。选项:`) + console.error(` 1) 看差异:vr update ${id} (仅预览,不写文件)`) + console.error(` 2) 确认覆盖:vr add ${id} --force`) + process.exit(2) + } + console.log(`⚠ 强制覆盖 registry/layouts/${id}.ts`) + } + mkdirSync(targetDir, { recursive: true }) + writeFileSync(dest, content, 'utf8') + console.log(`✓ 已物化预设 '${id}' → ${relative(process.cwd(), dest)}`) + console.log(` 从此它是你的源码,可直接修改;升级用 vr update ${id}`) +} + +function cmdUpdate(id: string, force: boolean): void { + const src = presetFile(id) + if (!src) { + console.error(`✗ 未知预设布局:'${id}'`) + process.exit(1) + } + const dest = targetFile(id) + if (!existsSync(dest)) { + console.log(`• '${id}' 尚未物化,执行 vr add ${id}`) + return + } + const existing = readFileSync(dest, 'utf8') + const preset = readFileSync(src, 'utf8') + if (existing === preset) { + console.log(`• '${id}' 已是最新版`) + return + } + showDiff(existing, preset, id) + if (force) { + writeFileSync(dest, preset, 'utf8') + console.log(`✓ 已更新 ${id}(--force)`) + } else { + console.log(` 未写文件。确认升级请:vr update ${id} --force`) + } +} + +const [cmd, arg, ...rest] = process.argv.slice(2) +const force = rest.includes('--force') || arg === '--force' + +switch (cmd) { + case 'list': + cmdList() + break + case 'add': + if (!arg || arg === '--force') { + console.error('用法:vr add [--force]') + process.exit(1) + } + cmdAdd(arg, force) + break + case 'update': + if (!arg || arg === '--force') { + console.error('用法:vr update [--force]') + process.exit(1) + } + cmdUpdate(arg, force) + break + default: + console.error('用法:vr list | vr add [--force] | vr update [--force]') + process.exit(1) +} diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..df4a6a3 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,262 @@ +# vibe-registry — 面向 AI 前端的"万物皆注册"页面框架(设计文档 v0.5) + +> 状态:探究中(未定稿) · 作者:dsh-agent 与用户协作 +> 定位:vibe coding 时代,把"AI 前端生成"从自由发挥收束到**受控注册 + 声明式组合** + +--- + +## 1. 问题陈述 + +### 1.1 痛点的三张面孔 + +| 痛点 | 现象 | 根因 | +|---|---|---| +| **重复造轮子** | 让 AI 做个表单/列表/弹窗,它每次从零拼一遍 | AI 无"既有资产"记忆,不查你已有组件 | +| **布局不统一** | 页面间距、栅格、导航、按钮位置随生成批次漂移 | 布局规范只存在于人脑/文档,AI 不会遵守 | +| **无法收敛** | 改一处样式要动 N 个文件,AI 改完这处崩那处 | 约定不成为代码,就没有强制力 | + +### 1.2 为什么组件库没解决 + +组件库(shadcn/antd)解决了"有组件可用",但没解决两件事: + +1. **AI 不会主动用**——提示词劝说无效,生成时照样自创; +2. **页面级结构(布局/区块/页面骨架)没有注册位**——组件库只管原子,不管"一个后台页长什么样"。 + +### 1.3 一句话目标 + +> 让**页面 = 注册项的组合**,让 AI(和人)通过**受控的注册/声明语法**产出页面, +> 而不是通过自由的 JSX——从生成源头锁死重复与漂移。 + +--- + +## 2. 核心理念:万物皆注册(Everything is Registered) + +### 2.1 三个公理 + +1. **一切可复用之物必须注册**:布局、区块、组件、操作、路由、表单字段、数据源……先注册,才可用。 +2. **页面是声明的产物,不是手写的产物**:页面文件只声明"由哪些注册项、以什么配置组合",结构由注册表驱动渲染。 +3. **改动即配置,配置即代码**:任何视觉/结构改动落在注册项配置里,版本可控、可 diff、可被 AI 精确编辑。 + +### 2.2 注册项类型(草案) + +```ts +type RegistryItem = + | { kind: 'layout'; id: string; ...; preview: { demo?: PageDemo; thumbs?: string } } // 页面骨架,带 live 预览数据 + | { kind: 'block'; id: string; ... } // 区块:导航/侧栏/页脚/内容容器 + | { kind: 'widget'; id: string; ...; preview?: WidgetDemo } // 业务组件:表单/表格/弹窗(映射到 UI 库) + | { kind: 'action'; id: string; ... } // 行为:提交/跳转/确认(可复用事件语义) + | { kind: 'schema'; id: string; ... } // 数据/字段注册:表单字段、表格列 + | { kind: 'theme'; id: string; ... } // 设计 token:间距/圆角/色板(轻量覆盖) +``` + +关键: + +- **widget 允许指向任意第三方组件并注册**,万物皆注册的"物"是开放的; +- **预览数据是注册项的一部分**:layout/widget 注册时自带 demo 数据,选择层渲染真实形态, + 预览与实装共用同一渲染内核——**预览即真实,无 mock 漂移**; +- 二次开发定制页面 = 新增 widget/layout 注册并挂入 P 层,天然获得风格/主题一体化(共用 theme + 渲染内核)。 + +--- + +## 3. 架构分层 + +``` +┌─────────────────────────────────────────────┐ +│ P. 选择/预览层(新,用户长时间停留的地方) │ +│ 布局画廊:预设布局 live 预览 + 缩略图 │ +│ IDE 联动(Webview/面板):选中→回写声明 │ +│ 用户只做:挑布局 → 声明业务 → 生成 │ +├─────────────────────────────────────────────┤ +│ A. 声明层(用户/AI 产出) │ +│ page.tsx 声明:layout id + blocks + │ +│ 配置 + 业务回调 │ +├─────────────────────────────────────────────┤ +│ B. 注册表层(单一事实来源) │ +│ registry/**/*.ts:注册项定义与配置 │ +│ (类型安全:TS 推导注册项类型) │ +├─────────────────────────────────────────────┤ +│ C. 极薄 schema 渲染内核 │ +│ 声明 → 按 id 查注册项 → 布局槽位填充 → │ +│ widget 装配 → 输出组件树 │ +├─────────────────────────────────────────────┤ +│ D. 业务接入层(页面"只管业务") │ +│ 页面文件只写:数据获取 / 状态 / 事件回调, │ +│ 结构全部来自 C 层输出 │ +└─────────────────────────────────────────────┘ +``` + +### 3.0 选择/预览层(P 层)定位 + +- **拿包即画廊**:脚手架创建项目时把一批预设布局(源码)放进 `registry/**` 并生成画廊首页——`npm run dev` 打开即见(类 Vite 欢迎首页); +- **默认预设全部带 live 预览**:每个预设布局(admin/dashboard/landing…)内置一份"demo 数据渲染",用户一眼看到成品形态再决定; +- **配合 IDE 联动**(M3 增强):VS Code/Cursor Webview 面板或 HMR 预览路由——选布局、声明业务、实时看效果,**长时间停留在"选择"而不写布局**; +- **声明业务即可**:P 层把用户的"业务描述"折成 `definePage` 的 setup/blocks 声明; +- **预设与自定义一体**:项目 `registry/**` 是唯一资产空间,`vr add` 物化预设、直接改源码,二次开发定制页天然与预设同一渲染内核、风格/主题/布局统一。 + +### 3.1 页面文件长什么样(愿景示例) + +```tsx +// 页面只管业务——不写任何布局 JSX +export default definePage({ + layout: 'app-shell', // ← 注册的布局 + blocks: [ + { widget: 'page-header', props: { title: '订单管理' } }, + { widget: 'filter-bar', bind: { filters } }, + { widget: 'data-table', bind: { columns, rows, onPage } }, + ], + // 业务: + async setup(ctx) { + const rows = await api.listOrders(ctx.query) + return { rows } + }, + actions: { onPage: async (p) => { ... } }, +}) +``` + +AI 生成的"页面代码"被限制成这种形状:**结构来自注册表,AI 只填业务与配置**。 + +--- + +## 4. 面向 AI 的受控生成(AI Gate 草案) + +这是与所有"低代码引擎"的分水岭——**用户是 AI,不是拖拽者**。 + +### 4.1 交互协议:用户只须说"xx 布局" + +``` +用户: "订单列表页,用 admin 布局,带筛选和分页表格" +agent: layout: 'admin' ← 唯一 id 契约 + blocks: [ filter-bar, data-table ] ← 全部来自注册表 + setup() { 业务 } ← 只填业务 +``` + +- 用户**只需报布局 id**(`admin`/`dashboard`/`landing`…),布局的栅格、骨架、样式细节全在注册项里,用户与 agent 都不需要知道实现; +- 所有可用的 id 是**公开面**,agent 通过注册表目录/README 获取,不许自创 id; +- 布局 id 是**稳定契约**:实现可换,id 不变(防止 agent 记错实现造成静默漂移)。 + +### 4.2 受控语法与校验闸门 + +- **受控语法**:AI 只能产出 `definePage/registerItem` 形状的代码; +- **校验闸门**:生成产物过 `validate()`——引用了未注册的 widget/layout → 报错退回,提示"请先注册或改用已有项"; +- **注册优先**:AI 想造新组件,先走"注册申请",注册完再引用; +- **收敛目标**:同一仓库内,所有 AI 生成的页面共享同一注册表 → 布局天然统一。 + +### 4.3 扩展:万物皆可注册(注册是开放集) + +- 布局、widget、schema、action 均可由用户/agent 新增注册; +- 新增注册 = 写一个 `registerXxx({...})` 文件放 `registry/**`,自动纳入类型推导; +- **未使用不进编译和源码**:新注册但没页面引用 → 留在注册源、不进产物(零成本扩展); +- 首次被引用时自动物化,进入编译与源码树。 + +--- + +## 5. 与现有方案的分界(避免"又一个低代码") + +| 方案 | 我们不做 | 我们做 | +|---|---|---| +| 低代码平台(拖拽/配置后台) | 不做可视化画布、不做运行时编辑器 | 配置即代码,存 git,可 review | +| 组件库(shadcn/antd) | 不重新造原子组件 | 组件**注册化包装**,统一暴露 | +| 代码生成器(v0/bolt) | 不只"生成页面",不解决复用 | 生成受控产物,强制复用注册项 | +| headless 框架 | 不做渲染运行时新轮子 | 渲染内核极薄,组合已有库 | + +一句话:**我们不发明 UI,只发明"约束 AI 的注册协议"。** + +--- + +## 6. 已定决策(v0.5,用户拍板) + +1. **声明格式:TS 优先**——`definePage`/`registerItem` 全 TS,类型推导强、AI 好生成;YAML 仅备用于纯数据资产。 +2. **渲染内核:极薄 schema 渲染器**——注册项 → 映射已有组件(antd/shadcn),内核保持 <500 行,不重造 UI。 +3. **预设布局模板:内置多个、均带简易 id 与 live 预览**——如 `app-shell` / `admin` / `dashboard` / `landing` / `form-page`……用户(或用户告诉 agent)**只需说"用 xx 布局"**,agent 按 id 取布局,无需理解布局实现;每个布局附带 demo 预览,选择层直接可看。 +4. **可扩展**:用户可注册自己的布局/widget(文件约定,见 §8),注册是开放集;二次开发定制页可并入现有布局,与常见组件库联动,风格/主题一体化。 +5. **未使用不进编译、不进源码**——只有被页面**实际引用**的注册项才进入编译产物与源码树;未被引用的注册项不物化、不打包。 +6. **选择/预览层(P 层)**:用户长时间停留在"选布局 + 声明业务",由 P 层回写声明文件。 +7. **预设物化进项目 + 拿包即画廊(类 Vite 首页)**——预设与自定义**物理一体**,消灭两套资产: + - **预设不是黑盒包**:CLI `vr add ` 或脚手架创建时,把预设布局**以源码拷贝进**项目 `registry/**`(类 shadcn add + Vite 模板心智); + - 之后预设与自定义**同目录、同源码、同改法**——想改布局细节直接改源码,无"包内预设不可改"; + - **升级**:框架发新版,`vr update` 拉 diff,用户选择是否同步(类 shadcn upgrade); + - **画廊**:项目 `registry/**` 自带 demo 数据,dev 首页列出全部已注册项 live 预览,选中即生成 `definePage` 声明; + - **未使用不进产物**:物化进项目的预设,仍只有被页面**引用**才进入编译产物。 + +### 6.1 注册与物化的统一模型(决策 5 × 7) + +``` +包内预设 registry 源 ──vr add / 脚手架──> 拷贝进项目 registry/**(与自定义同源码) + │ + 项目 registry/**(唯一资产空间) + │ 被页面引用 → 物化进产物 + ▼ + 编译产物 / 源码树(仅物化项) +``` + +- **一个资产空间**:项目 `registry/**` 是唯一事实来源,预设与自定义无差别; +- **物化 = 被引用**:bundler 树摇保证未引用注册项不打包; +- **画廊 = dev 首页**:渲染项目内全部已注册项 live 预览,生产不含画廊。 + + + +### 6.1 决策 5 的机制:按需物化(On-Demand Materialization) + +``` +注册表(全量,dev 可见) ──静态引用分析──> 物化清单(Materialized Set) + │ 只装载被引用项 + ▼ + 编译产物 / 源码树(仅物化项) +``` + +- 触发时机:**生成期**(定义页面的构建/生成脚本时)做全量静态扫描,收集所有 `definePage` 中被引用的 layout/widget/schema id; +- 未被引用的注册项停留在"注册源"里,**不生成运行时文件、不进 bundle**; +- 好处:产物极轻、无死代码、新增预设布局零成本(不用就不存在); +- 张力与对策:见 §6.2。 + +### 6.2 新决策暴露的两个张力(需留意,不阻塞) + +1. **"未使用即不物化" vs "注册即复用"**:一个 widget 被页面 A 注册、但尚未被任何页面引用——它算"未使用"而被裁剪吗? + → 对策:物化判断以**引用**为准;仅注册未引用 = 留在注册源但不进产物(符合你的设计); + "想让它先进产物"需显式 `include`(用于模板扩展场景)。 +2. **布局 id 语义漂移**:agent 记忆里的 `admin` 和你注册的 `admin` 若实现不同,会静默用错布局。 + → 对策:布局 id 是**稳定契约**,实现可换、id 不变;id 表是文档化的公开面,进 README 供 agent 引用。 + + +--- + +## 8. 扩展注册机制(文件约定) + +``` +registry/ ← 唯一资产空间(预设 + 自定义同在此) + layouts/ admin.ts registerLayout('admin', { skeleton: [...] }) + app-shell.ts ← vr add app-shell 从包内拷贝而来 + widgets/ table.ts registerWidget('data-table', { render: Table, schema }) + filter.ts ... + schemas/ order.ts registerSchema('order-fields', ...) + actions/ submit.ts registerAction('submit-order', ...) +``` + +- 每个文件幂等注册一个 id;重名 id = 编译报错(唯一性强制); +- 目录/文件路径仅组织用,**id 才是引用键**(满足"简易 id"诉求); +- **预设 = `vr add` 拷贝进此目录的源码**,与自定义无差别,可直接修改; +- `vr update` 对比包内新预设 diff,用户选择同步(可保留本地改动); +- 未引用的注册文件可留在仓库作为资产,但不出产物(决策 5)。 + +## 9. MVP 范围(建议第一刀) + +**阶段 M1:类型 + 渲染内核 + 静态物化(不接 UI 库)** +- [ ] `registerItem()` + 目录式注册表,类型推导 +- [ ] `definePage()` + 布局槽位填充(首内置:`admin`/`app-shell` 布局示例) +- [ ] 注册项带 demo 预览数据的类型骨架(为 P 层铺路) +- [ ] `validate()` 校验闸门(未注册引用即报错) +- [ ] 静态引用分析:未使用注册项不进产物(决策 5 的最小实现) + +**阶段 M2:接入真实 UI 库 + 引入即预览** +- [ ] widget 映射 antd/shadcn(首批 8-10 个:按钮/表格/表单/弹窗/筛选条/分页…) +- [ ] 引入即注册验证:import 注册模块 → 自动进入画廊 +- [ ] dev 首页(布局画廊):缩略图 + live 预览 + demo 数据(类 Vite 首页) +- [ ] 2-3 个示例后台页,证明"同一注册表,AI 生成即统一" + +**阶段 M3:生成回写 + IDE 联动** +- [ ] 画廊选中布局 → 声明业务 → 生成 `definePage` → 回写文件 +- [ ] VS Code/Cursor Webview 或 HMR 面板接入 + +## 10. 命名候选(未定) + +- `vibe-registry` / `regui` / `page-registry` / 中文语境:万物注册?敬请给方向 diff --git a/examples/qa-orchestrator/package.json b/examples/qa-orchestrator/package.json new file mode 100644 index 0000000..db3f012 --- /dev/null +++ b/examples/qa-orchestrator/package.json @@ -0,0 +1,18 @@ +{ + "name": "qa-orchestrator", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "vibe-registry 实战编排:测试编排系统(多页面 + 自定义注册)", + "scripts": { + "typecheck": "tsc --noEmit", + "orchestrate": "node --experimental-strip-types scripts/orchestrate.ts" + }, + "dependencies": { + "vibe-registry": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.8.0", + "@types/node": "^22.0.0" + } +} diff --git a/examples/qa-orchestrator/pages/overview.ts b/examples/qa-orchestrator/pages/overview.ts new file mode 100644 index 0000000..81f0f0d --- /dev/null +++ b/examples/qa-orchestrator/pages/overview.ts @@ -0,0 +1,37 @@ +/** + * 套件总览页(overview) + * 业务:QA 看板上展示所有套件、总通过率、失败数 + * 布局:qa-console;页面只管业务数据 + */ +import { definePage } from 'vibe-registry' + +export const overviewPage = definePage({ + layout: 'qa-console', + title: '测试编排 · 总览', + blocks: [ + { widget: 'suite-list', bind: { suites: 'suites' } }, + { + widget: 'suite-detail-view', + props: { mode: 'summary' }, + bind: { cases: 'summary' }, + }, + ], + setup: async () => { + // 真实场景:从 API 拉取;这里用模拟数据演示编排 + const suites = [ + { id: 'auth', name: '认证模块', passedRate: 0.92, lastResult: 'passed' }, + { id: 'billing', name: '计费模块', passedRate: 0.71, lastResult: 'failed' }, + { id: 'search', name: '搜索模块', passedRate: 1.0, lastResult: 'passed' }, + ] + const total = suites.length + const failed = suites.filter((s) => s.lastResult === 'failed').length + return { + suites, + summary: { + total, + failed, + passingRate: Math.round((1 - failed / total) * 100), + }, + } + }, +}) diff --git a/examples/qa-orchestrator/pages/run-history.ts b/examples/qa-orchestrator/pages/run-history.ts new file mode 100644 index 0000000..237e771 --- /dev/null +++ b/examples/qa-orchestrator/pages/run-history.ts @@ -0,0 +1,27 @@ +/** + * 运行记录页(run history) + * 业务:全部历史运行 + 失败率统计 + */ +import { definePage } from 'vibe-registry' + +export const runHistoryPage = definePage({ + layout: 'qa-console', + title: '测试编排 · 运行记录', + blocks: [ + { widget: 'suite-list', bind: { suites: 'suites' } }, + { widget: 'suite-detail-view', props: { mode: 'history' }, bind: { cases: 'runs' } }, + ], + setup: async () => { + return { + suites: [ + { id: 'auth', name: '认证模块', passedRate: 0.92, lastResult: 'passed' }, + { id: 'billing', name: '计费模块', passedRate: 0.71, lastResult: 'failed' }, + ], + runs: [ + { id: 'r1', name: 'auth #0421', priority: 'P0', status: 'passed', durationMs: 980 }, + { id: 'r2', name: 'auth #0420', priority: 'P1', status: 'skipped', durationMs: null }, + { id: 'r3', name: 'billing #0419', priority: 'P0', status: 'failed', durationMs: 2100 }, + ], + } + }, +}) diff --git a/examples/qa-orchestrator/pages/suite-detail.ts b/examples/qa-orchestrator/pages/suite-detail.ts new file mode 100644 index 0000000..f13b767 --- /dev/null +++ b/examples/qa-orchestrator/pages/suite-detail.ts @@ -0,0 +1,42 @@ +/** + * 套件详情页(suite detail) + * 业务:展示单个套件的用例列表,提供重跑/停止动作 + * 注意:页面不内联任何"重跑逻辑",只声明引用 run-suite action —— 行为万物皆注册 + */ +import { definePage } from 'vibe-registry' + +export const suiteDetailPage = definePage({ + layout: 'qa-console', + title: '测试编排 · 套件详情', + blocks: [ + { widget: 'suite-list', bind: { suites: 'suites' } }, + { + widget: 'suite-detail-view', + props: { mode: 'detail', suiteId: 'current' }, + bind: { cases: 'cases', suite: 'suite' }, + }, + ], + setup: async (ctx) => { + const suiteId = ctx.params.id ?? 'auth' + const cases = [ + { id: 'a1', name: '登录成功', priority: 'P0', status: 'passed', durationMs: 120 }, + { id: 'a2', name: '登录失败锁定', priority: 'P1', status: 'failed', durationMs: 340 }, + { id: 'a3', name: '令牌过期刷新', priority: 'P1', status: 'running', durationMs: null }, + ] + return { + suites: [ + { id: 'auth', name: '认证模块', passedRate: 0.92, lastResult: 'passed' }, + { id: 'billing', name: '计费模块', passedRate: 0.71, lastResult: 'failed' }, + ], + suite: { id: suiteId, name: `${suiteId} 套件` }, + cases, + } + }, + actions: { + rerun: async (payload: { suiteId?: string }) => { + // 页面只做"把用户意图转成 action 调用",实现仍在注册表 + const { runSuiteAction } = await import('../registry/actions/run-suite.ts') + return runSuiteAction.handler({ suiteId: payload.suiteId ?? 'auth' }, null) + }, + }, +}) diff --git a/examples/qa-orchestrator/pnpm-lock.yaml b/examples/qa-orchestrator/pnpm-lock.yaml new file mode 100644 index 0000000..a210984 --- /dev/null +++ b/examples/qa-orchestrator/pnpm-lock.yaml @@ -0,0 +1,48 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + vibe-registry: + specifier: file:../.. + version: file:../.. + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + +packages: + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vibe-registry@file:../..: + resolution: {directory: ../.., type: directory} + +snapshots: + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vibe-registry@file:../..: {} diff --git a/examples/qa-orchestrator/registry/actions/run-suite.ts b/examples/qa-orchestrator/registry/actions/run-suite.ts new file mode 100644 index 0000000..acd9e6e --- /dev/null +++ b/examples/qa-orchestrator/registry/actions/run-suite.ts @@ -0,0 +1,15 @@ +/** + * run-suite —— 重跑测试套件 action + * 行为注册:页面的"重跑"按钮只声明引用该 action,不内联逻辑 + */ +import { registerAction } from 'vibe-registry' + +export const runSuiteAction = registerAction({ + kind: 'action', + id: 'run-suite', + description: '重跑指定测试套件(全量)', + handler: async (payload: { suiteId: string }, _ctx) => { + // M1:真实执行器后续接入;这里返回执行意图供编排层消费 + return { started: true, suiteId: payload.suiteId, at: Date.now() } + }, +}) diff --git a/examples/qa-orchestrator/registry/actions/stop-run.ts b/examples/qa-orchestrator/registry/actions/stop-run.ts new file mode 100644 index 0000000..0d3e064 --- /dev/null +++ b/examples/qa-orchestrator/registry/actions/stop-run.ts @@ -0,0 +1,13 @@ +/** + * stop-run —— 停止运行中的套件 action + */ +import { registerAction } from 'vibe-registry' + +export const stopRunAction = registerAction({ + kind: 'action', + id: 'stop-run', + description: '停止正在运行的测试套件', + handler: async (payload: { runId: string }, _ctx) => { + return { stopped: true, runId: payload.runId, at: Date.now() } + }, +}) diff --git a/examples/qa-orchestrator/registry/index.ts b/examples/qa-orchestrator/registry/index.ts new file mode 100644 index 0000000..2b23b30 --- /dev/null +++ b/examples/qa-orchestrator/registry/index.ts @@ -0,0 +1,16 @@ +/** + * 资产集中注册入口 + * 模拟脚手架/自动化生成:所有 registry/** 在此汇聚(副作用 import) + * 页面只需 import 本文件即可让全部注册生效 + */ +import './layouts/qa-console.ts' +import './widgets/suite-list.ts' +import './widgets/suite-detail-view.ts' +import './widgets/status-badge.ts' +import './schemas/test-case-fields.ts' +import './actions/run-suite.ts' +import './actions/stop-run.ts' + +// 注册完成后,导出注册表供编排层校验 +import { registry } from 'vibe-registry' +export { registry } diff --git a/examples/qa-orchestrator/registry/layouts/qa-console.ts b/examples/qa-orchestrator/registry/layouts/qa-console.ts new file mode 100644 index 0000000..926d758 --- /dev/null +++ b/examples/qa-orchestrator/registry/layouts/qa-console.ts @@ -0,0 +1,19 @@ +/** + * qa-console —— 测试编排控制台布局(自定义布局,非预设) + * 业务场景:QA 负责人在侧栏切换套件,主区查看运行结果 + */ +import { registerLayout } from 'vibe-registry' + +export const qaConsoleLayout = registerLayout({ + kind: 'layout', + id: 'qa-console', + slots: ['suite-sidebar', 'main-content'], + description: '测试编排控制台:左侧套件列表 + 主内容区', + preview: { + title: 'QA Console · 测试编排控制台', + demoBlocks: [ + { slot: 'suite-sidebar', widget: 'suite-list', props: { suites: 'suiteSummary' } }, + { slot: 'main-content', widget: 'suite-detail-view' }, + ], + }, +}) diff --git a/examples/qa-orchestrator/registry/schemas/test-case-fields.ts b/examples/qa-orchestrator/registry/schemas/test-case-fields.ts new file mode 100644 index 0000000..68d0fc7 --- /dev/null +++ b/examples/qa-orchestrator/registry/schemas/test-case-fields.ts @@ -0,0 +1,17 @@ +/** + * test-case-fields —— 测试用例字段 schema + * 用例表格/表单的列定义来源(万物皆注册:连列都是注册的) + */ +import { registerSchema } from 'vibe-registry' + +export const testCaseFields = registerSchema({ + kind: 'schema', + id: 'test-case-fields', + description: '测试用例字段:名称 / 优先级 / 状态 / 耗时', + fields: [ + { key: 'name', label: '用例名' }, + { key: 'priority', label: '优先级', type: 'P0|P1|P2' }, + { key: 'status', label: '状态', type: 'passed|failed|running|skipped' }, + { key: 'durationMs', label: '耗时(ms)', type: 'number' }, + ], +}) diff --git a/examples/qa-orchestrator/registry/widgets/status-badge.ts b/examples/qa-orchestrator/registry/widgets/status-badge.ts new file mode 100644 index 0000000..93479c5 --- /dev/null +++ b/examples/qa-orchestrator/registry/widgets/status-badge.ts @@ -0,0 +1,10 @@ +/** + * status-badge —— 用例状态徽标 widget(通过/失败/运行中/跳过) + */ +import { registerWidget } from 'vibe-registry' + +export const statusBadge = registerWidget({ + kind: 'widget', + id: 'status-badge', + description: '用例状态徽标:passed / failed / running / skipped', +}) diff --git a/examples/qa-orchestrator/registry/widgets/suite-detail-view.ts b/examples/qa-orchestrator/registry/widgets/suite-detail-view.ts new file mode 100644 index 0000000..f6d8ad5 --- /dev/null +++ b/examples/qa-orchestrator/registry/widgets/suite-detail-view.ts @@ -0,0 +1,12 @@ +/** + * suite-detail-view —— 套件详情 widget + * 渲染套件用例列表 + 状态徽标,支持重跑/停止动作 + */ +import { registerWidget } from 'vibe-registry' + +export const suiteDetailView = registerWidget({ + kind: 'widget', + id: 'suite-detail-view', + schemaId: 'test-case-fields', + description: '套件详情:用例表格 + 状态徽标 + 重跑/停止', +}) diff --git a/examples/qa-orchestrator/registry/widgets/suite-list.ts b/examples/qa-orchestrator/registry/widgets/suite-list.ts new file mode 100644 index 0000000..411ffa4 --- /dev/null +++ b/examples/qa-orchestrator/registry/widgets/suite-list.ts @@ -0,0 +1,11 @@ +/** + * suite-list —— 测试套件列表 widget + * 展示全部套件与通过率,点击进入套件详情 + */ +import { registerWidget } from 'vibe-registry' + +export const suiteListWidget = registerWidget({ + kind: 'widget', + id: 'suite-list', + description: '测试套件列表:名称 + 最近结果 + 通过率', +}) diff --git a/examples/qa-orchestrator/scripts/orchestrate.ts b/examples/qa-orchestrator/scripts/orchestrate.ts new file mode 100644 index 0000000..21f30f0 --- /dev/null +++ b/examples/qa-orchestrator/scripts/orchestrate.ts @@ -0,0 +1,61 @@ +/** + * orchestrate —— 编排层验证脚本 + * 模拟"渲染内核消费前的编排":集中注册 → 校验 → 执行 setup 数据流 → 输出解析树 + * + * 运行:node --experimental-strip-types scripts/orchestrate.ts + */ +import '../registry/index.ts' // ← 集中注册(副作用) +import { registry } from 'vibe-registry' +import { overviewPage } from '../pages/overview.ts' +import { suiteDetailPage } from '../pages/suite-detail.ts' +import { runHistoryPage } from '../pages/run-history.ts' + +function dumpRegistry() { + console.log('═══ 注册表(万物皆注册)═══') + for (const e of registry.listAll()) { + const preview = e.kind === 'layout' && e.preview ? ` 预览:「${e.preview.title}」` : '' + console.log(` ${e.kind.padEnd(7)} ${e.id}${preview}`) + } +} + +async function orchestrate() { + console.log('═══ 编排层 · 解析树 ═══\n') + const pages = [ + { name: 'overview', page: overviewPage, ctx: { query: {}, params: {} } }, + { name: 'suite-detail', page: suiteDetailPage, ctx: { query: {}, params: { id: 'billing' } } }, + { name: 'run-history', page: runHistoryPage, ctx: { query: {}, params: {} } }, + ] + + for (const { name, page, ctx } of pages) { + const layout = registry.store.layouts.get(page.layout) + if (!layout) throw new Error(`页面 ${name} 引用了未注册布局 ${page.layout}(本不该发生,definePage 已校验)`) + + // 业务层:页面只管 setup,数据喂给 blocks + const data = page.setup ? await page.setup(ctx) : {} + + console.log(`◆ 页面「${page.title ?? name}」`) + console.log(` layout: ${page.layout} slots=[${layout.slots.join(', ')}]`) + for (const b of page.blocks) { + const widget = registry.store.widgets.get(b.widget) + const bound = b.bind + ? Object.entries(b.bind) + .map(([prop, key]) => `${prop}←${key}(${data[key] === undefined ? '?' : Array.isArray(data[key]) ? `${(data[key] as unknown[]).length}项` : typeof data[key]})`) + .join(' ') + : '' + console.log(` └ widget: ${b.widget} props=${JSON.stringify(b.props ?? {})} bind=[${bound}]`) + } + console.log('') + } +} + +dumpRegistry() +console.log('') +orchestrate().then( + () => { + console.log('✓ 编排完成:3 页面全部通过校验,setup 数据流正常') + }, + (e) => { + console.error('✗ 编排失败:', e) + process.exit(1) + }, +) diff --git a/examples/qa-orchestrator/tsconfig.json b/examples/qa-orchestrator/tsconfig.json new file mode 100644 index 0000000..b5eeb56 --- /dev/null +++ b/examples/qa-orchestrator/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["registry", "pages", "scripts"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6058ee8 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "vibe-registry", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "万物皆注册 · 面向 AI 前端的注册式页面框架(M1 概念验证)", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "vr": "node --experimental-strip-types cli/vr.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.8.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..dd0eadd --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + + examples/qa-orchestrator: + dependencies: + vibe-registry: + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + +packages: + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..c25addb --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "examples/*" diff --git a/presets/layouts/admin.ts b/presets/layouts/admin.ts new file mode 100644 index 0000000..6be15be --- /dev/null +++ b/presets/layouts/admin.ts @@ -0,0 +1,23 @@ +/** + * 预设布局:admin(后台管理骨架) + * + * ⚠️ 此文件由 vibe-registry 物化而来:vr add admin 会将其拷贝为 + * 项目的 registry/layouts/admin.ts —— 之后它就是你的源码,可随意修改。 + * 升级用 vr update,不会静默覆盖你的改动(防覆盖)。 + */ +import { registerLayout } from 'vibe-registry' + +export default registerLayout({ + kind: 'layout', + id: 'admin', + slots: ['sidebar', 'header', 'content', 'footer'], + description: '后台管理骨架:左侧导航 + 顶栏 + 内容区 + 页脚', + preview: { + title: 'Admin · 后台管理', + demoBlocks: [ + { slot: 'sidebar', widget: 'nav-menu' }, + { slot: 'header', widget: 'page-header', props: { title: '订单管理' } }, + { slot: 'content', widget: 'data-table' }, + ], + }, +}) diff --git a/presets/layouts/app-shell.ts b/presets/layouts/app-shell.ts new file mode 100644 index 0000000..7f69de7 --- /dev/null +++ b/presets/layouts/app-shell.ts @@ -0,0 +1,22 @@ +/** + * 预设布局:app-shell(通用应用外壳) + * + * ⚠️ 此文件由 vibe-registry 物化而来:vr add app-shell 会将其拷贝为 + * 项目的 registry/layouts/app-shell.ts —— 之后它就是你的源码,可随意修改。 + * 升级用 vr update,不会静默覆盖你的改动(防覆盖)。 + */ +import { registerLayout } from 'vibe-registry' + +export default registerLayout({ + kind: 'layout', + id: 'app-shell', + slots: ['header', 'content'], + description: '通用应用外壳:顶栏 + 内容区(营销/简单页面)', + preview: { + title: 'App Shell · 通用外壳', + demoBlocks: [ + { slot: 'header', widget: 'page-header', props: { title: '欢迎' } }, + { slot: 'content', widget: 'hero-banner' }, + ], + }, +}) diff --git a/registry/layouts/admin.ts b/registry/layouts/admin.ts new file mode 100644 index 0000000..6be15be --- /dev/null +++ b/registry/layouts/admin.ts @@ -0,0 +1,23 @@ +/** + * 预设布局:admin(后台管理骨架) + * + * ⚠️ 此文件由 vibe-registry 物化而来:vr add admin 会将其拷贝为 + * 项目的 registry/layouts/admin.ts —— 之后它就是你的源码,可随意修改。 + * 升级用 vr update,不会静默覆盖你的改动(防覆盖)。 + */ +import { registerLayout } from 'vibe-registry' + +export default registerLayout({ + kind: 'layout', + id: 'admin', + slots: ['sidebar', 'header', 'content', 'footer'], + description: '后台管理骨架:左侧导航 + 顶栏 + 内容区 + 页脚', + preview: { + title: 'Admin · 后台管理', + demoBlocks: [ + { slot: 'sidebar', widget: 'nav-menu' }, + { slot: 'header', widget: 'page-header', props: { title: '订单管理' } }, + { slot: 'content', widget: 'data-table' }, + ], + }, +}) diff --git a/scripts/smoke.ts b/scripts/smoke.ts new file mode 100644 index 0000000..5b672e7 --- /dev/null +++ b/scripts/smoke.ts @@ -0,0 +1,72 @@ +/** + * M1 验证脚本:类型 + 注册 + 校验闸门 + * 演示:注册布局/widget → definePage 声明 → 引用校验 + * 运行:node --experimental-strip-types scripts/smoke.ts + */ +import { createRegistry, registerLayout, registerWidget, definePage } from '../src/index.ts' + +const reg = createRegistry() + +// 万物皆注册:布局与组件都要先注册,才有 id 可用 +reg.registerLayout({ + kind: 'layout', + id: 'admin', + slots: ['header', 'content'], + description: '后台骨架', + preview: { title: 'Admin', demoBlocks: [] }, +}) +reg.registerWidget({ + kind: 'widget', + id: 'data-table', + component: 'TableMock', // M1 不接真实 UI,组件占位 + description: '数据表格', +}) +reg.registerWidget({ + kind: 'widget', + id: 'page-header', + component: 'HeaderMock', + description: '页头', +}) + +// 合法页面:全部引用已注册 id +const page = definePage( + { + layout: 'admin', + title: '订单', + blocks: [ + { widget: 'page-header', props: { title: '订单管理' } }, + { widget: 'data-table', bind: { rows: 'orders' } }, + ], + async setup() { + return { orders: [{ id: 1, total: 9.9 }] } + }, + }, + reg, +) +console.log('✓ 合法页面通过校验:', page.title, page.layout, page.blocks.length, 'blocks') + +// 非法页面:引用了未注册 widget → 校验闸门必须抛错 +try { + definePage( + { + layout: 'admin', + blocks: [{ widget: 'magic-graph' }], // ← 未注册 + }, + reg, + ) + console.error('✗ 应当抛错但没有(校验闸门失效!)') + process.exit(1) +} catch (e) { + console.log('✓ 校验闸门拦截未注册引用:', (e as Error).message.split('\n')[0]) +} + +// 重复注册同 id → 必须抛错 +try { + reg.registerLayout({ kind: 'layout', id: 'admin', slots: [], description: 'dup' }) + console.error('✗ 重复注册未抛错(id 唯一性失效!)') + process.exit(1) +} catch (e) { + console.log('✓ 重复注册被拒(id 唯一):', (e as Error).message.split('\n')[0]) +} + +console.log('smoke OK') diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..eee72d4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +export { createRegistry, registerLayout, registerWidget, registerAction, registerSchema, registry } from './registry.ts' +export type { + RegistryApi, + RegistryEntry, + RegistryKind, + LayoutRegistryEntry, + WidgetRegistryEntry, + ActionRegistryEntry, + SchemaRegistryEntry, + LayoutPreview, +} from './registry.js' +export { definePage } from './page.ts' +export type { PageDeclaration, PageBlockDecl, PageContext, SetupData } from './page.js' diff --git a/src/page.ts b/src/page.ts new file mode 100644 index 0000000..8ec4617 --- /dev/null +++ b/src/page.ts @@ -0,0 +1,56 @@ +/** + * definePage —— 页面声明层 + * + * 理念(设计文档 §3.1): + * - 页面只管业务:setup 只做数据/状态,结构全部来自注册表 + * - 布局/组件用 id 引用,不写任何布局 JSX + * - 生成产物受控:引用的 id 必须是已注册项(validate 把关) + */ +import { type RegistryApi, registry } from './registry.ts' + +/** 页面中一个块的声明:引用已注册 widget,并给 props/绑定 */ +export interface PageBlockDecl { + /** widget 注册 id,如 'data-table'、'page-header' */ + readonly widget: string + /** 静态 props(可选) */ + readonly props?: Record + /** 业务绑定:setup 返回的数据里取哪些字段喂给 widget(键:widget prop 名,值:setup 结果字段) */ + readonly bind?: Record +} + +/** 页面声明:布局 id + 块列表 + 业务 */ +export interface PageDeclaration { + /** 布局注册 id,如 'admin' —— 用户只需说"用 admin 布局" */ + readonly layout: string + readonly blocks: readonly PageBlockDecl[] + /** 业务层:返回的数据可供 blocks.bind 使用;只做业务,不碰 UI 结构 */ + readonly setup?: (ctx: PageContext) => Promise> | Record + /** 行为回调:业务动作(bivariant,允许实现方标注收窄的 payload 类型) */ + readonly actions?: Record + /** 页标题等元信息(可选) */ + readonly title?: string +} + +export interface PageContext { + readonly query: Record + readonly params: Record +} + +/** 把声明物化为"已校验的页面对象";未知引用直接抛错(校验闸门) */ +export function definePage(decl: PageDeclaration, reg: RegistryApi = registry): PageDeclaration { + const refs = [ + { kind: 'layout' as const, id: decl.layout }, + ...decl.blocks.map((b) => ({ kind: 'widget' as const, id: b.widget })), + ] + const missing = reg.validateRefs(refs) + if (missing.length > 0) { + throw new Error( + `[vibe-registry] definePage 引用了未注册项:${missing.join(', ')} ` + + `(万物皆注册:请先注册,或用已有注册项)`, + ) + } + return decl +} + +/** 业务 setup 辅助类型:让页面文件只声明"取哪些数据" */ +export type SetupData

= Awaited>> diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..bab0cf6 --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,146 @@ +/** + * vibe-registry core —— 万物皆注册的类型基石 + * + * 设计要点(v0.5): + * - 注册项类型封闭 + id 即引用键 + * - 所有注册项可带 demo 预览数据(预览即真实) + * - 纯 TS、零依赖、不接 UI(渲染内核后续接) + */ + +export type RegistryKind = 'layout' | 'widget' | 'action' | 'schema' | 'theme' + +/** 布局骨架:页面级结构。渲染内核据 slots 填充 blocks。 */ +export interface LayoutRegistryEntry { + readonly kind: 'layout' + /** 简易 id,如 'admin' —— 用户/agent 只需说这个 id */ + readonly id: string + /** 布局所含槽位顺序,渲染时 blocks 依 slot 填入 */ + readonly slots: readonly string[] + /** 描述,供画廊/agent 展示 */ + readonly description: string + /** demo 数据(预览即真实,与实装共用渲染内核) */ + readonly preview?: LayoutPreview +} + +export interface LayoutPreview { + /** 标题文本,画廊首页展示 */ + readonly title: string + /** 缩略示意:每个 slot 放什么 demo block(id 或描述) */ + readonly demoBlocks?: ReadonlyArray<{ + readonly slot: string + readonly widget: string + readonly props?: Record + }> +} + +/** 业务组件注册项(映射到已有 UI 库,不重造原子组件) */ +export interface WidgetRegistryEntry { + readonly kind: 'widget' + readonly id: string + /** 渲染组件。内核薄,仅做映射层 */ + readonly component?: unknown + /** 关联字段/列 schema id(可选) */ + readonly schemaId?: string + readonly description?: string +} + +/** 行为注册项:可复用的事件语义(泛型:payload/结果类型随注册保留,不丢业务语义) */ +export interface ActionRegistryEntry

{ + readonly kind: 'action' + readonly id: string + readonly handler: (payload: P, ctx: unknown) => R | Promise + readonly description?: string +} + +/** 数据/字段注册项:表单字段、表格列 */ +export interface SchemaRegistryEntry { + readonly kind: 'schema' + readonly id: string + readonly fields?: readonly unknown[] + readonly description?: string +} + +export type RegistryEntry = + | LayoutRegistryEntry + | WidgetRegistryEntry + | ActionRegistryEntry + | SchemaRegistryEntry + +/** 注册表集合:kind → id → entry(单一事实来源) */ +export type RegistryStore = { + readonly layouts: ReadonlyMap + readonly widgets: ReadonlyMap + readonly actions: ReadonlyMap + readonly schemas: ReadonlyMap +} + +/* ── 注册与查询 ─────────────────────────────────────────────── */ + +export type RegisterFn = + (entry: E) => E + +export interface RegistryApi { + readonly store: RegistryStore + registerLayout: RegisterFn<'layout', LayoutRegistryEntry> + registerWidget: RegisterFn<'widget', WidgetRegistryEntry> + registerAction: (entry: ActionRegistryEntry) => ActionRegistryEntry + registerSchema: RegisterFn<'schema', SchemaRegistryEntry> + /** 校验:给定引用集合,返回未注册的 id 清单(空 = 通过) */ + validateRefs: (refs: ReadonlyArray<{ readonly kind: RegistryKind; readonly id: string }>) => string[] + /** 画廊用的清单:全部已注册项(dev 用) */ + listAll: () => RegistryEntry[] +} + +/** 创建注册表(单例由 createRegistry 返回) */ +export function createRegistry(): RegistryApi { + const layouts = new Map() + const widgets = new Map() + const actions = new Map() + const schemas = new Map() + + function register( + map: Map, + entry: E, + ): E { + const existed = map.get(entry.id) + if (existed) { + throw new Error(`[vibe-registry] 注册冲突:${entry.kind} '${entry.id}' 已存在(万物皆注册,id 唯一)`) + } + map.set(entry.id, entry as RegistryEntry) + return entry + } + + return { + store: { layouts, widgets, actions, schemas }, + registerLayout: (e) => register(layouts, e), + registerWidget: (e) => register(widgets, e), + registerAction: (entry: ActionRegistryEntry) => { + if (actions.has(entry.id)) { + throw new Error(`[vibe-registry] 注册冲突:action '${entry.id}' 已存在(万物皆注册,id 唯一)`) + } + actions.set(entry.id, entry as ActionRegistryEntry) + return entry + }, + registerSchema: (e) => register(schemas, e), + validateRefs(refs) { + const missing: string[] = [] + for (const r of refs) { + const map = r.kind === 'layout' ? layouts : r.kind === 'widget' ? widgets : r.kind === 'action' ? actions : schemas + if (!map.has(r.id)) missing.push(`${r.kind}:${r.id}`) + } + return missing + }, + listAll() { + return [...layouts.values(), ...widgets.values(), ...actions.values(), ...schemas.values()] + }, + } +} + +/* ── 全局注册表(单例,项目内 import 即用) ────────────────────── */ + +const globalRegistry = createRegistry() +export const registerLayout: RegistryApi['registerLayout'] = (e) => globalRegistry.registerLayout(e) +export const registerWidget: RegistryApi['registerWidget'] = (e) => globalRegistry.registerWidget(e) +export const registerAction: RegistryApi['registerAction'] = (e) => globalRegistry.registerAction(e) +export const registerSchema: RegistryApi['registerSchema'] = (e) => globalRegistry.registerSchema(e) +export const registry: RegistryApi = globalRegistry diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9152222 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "vibe-registry": ["./src/index.ts"] + } + }, + "include": ["src", "cli", "presets", "registry"] +}