feat: vibe-registry M1 — 万物皆注册框架骨架
- core: registerLayout/Widget/Action(泛型)/Schema + 校验闸门 - definePage: 页面只管业务,结构来自注册表 - presets: admin / app-shell 预设布局(vr add 物化) - cli: vr list/add/update 防覆盖 - examples/qa-orchestrator: 实战编排验证(workspace:* 依赖)
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 日志与系统
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 本地临时
|
||||
*.tmp
|
||||
@@ -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. 预设物化进项目
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* vr —— vibe-registry CLI(M1)
|
||||
*
|
||||
* 用法:
|
||||
* vr list 列出包内可用预设布局
|
||||
* vr add <layout> [--force] 物化预设到项目 registry/layouts/(防覆盖)
|
||||
* vr update <layout> [--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 <layout> [--force]')
|
||||
process.exit(1)
|
||||
}
|
||||
cmdAdd(arg, force)
|
||||
break
|
||||
case 'update':
|
||||
if (!arg || arg === '--force') {
|
||||
console.error('用法:vr update <layout> [--force]')
|
||||
process.exit(1)
|
||||
}
|
||||
cmdUpdate(arg, force)
|
||||
break
|
||||
default:
|
||||
console.error('用法:vr list | vr add <layout> [--force] | vr update <layout> [--force]')
|
||||
process.exit(1)
|
||||
}
|
||||
+262
@@ -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 <layout>` 或脚手架创建时,把预设布局**以源码拷贝进**项目 `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` / 中文语境:万物注册?敬请给方向
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
Generated
+48
@@ -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:../..: {}
|
||||
@@ -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() }
|
||||
},
|
||||
})
|
||||
@@ -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() }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -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' },
|
||||
],
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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: '套件详情:用例表格 + 状态徽标 + 重跑/停止',
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* suite-list —— 测试套件列表 widget
|
||||
* 展示全部套件与通过率,点击进入套件详情
|
||||
*/
|
||||
import { registerWidget } from 'vibe-registry'
|
||||
|
||||
export const suiteListWidget = registerWidget({
|
||||
kind: 'widget',
|
||||
id: 'suite-list',
|
||||
description: '测试套件列表:名称 + 最近结果 + 通过率',
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+52
@@ -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: {}
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- "examples/*"
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
+56
@@ -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<string, unknown>
|
||||
/** 业务绑定:setup 返回的数据里取哪些字段喂给 widget(键:widget prop 名,值:setup 结果字段) */
|
||||
readonly bind?: Record<string, string>
|
||||
}
|
||||
|
||||
/** 页面声明:布局 id + 块列表 + 业务 */
|
||||
export interface PageDeclaration {
|
||||
/** 布局注册 id,如 'admin' —— 用户只需说"用 admin 布局" */
|
||||
readonly layout: string
|
||||
readonly blocks: readonly PageBlockDecl[]
|
||||
/** 业务层:返回的数据可供 blocks.bind 使用;只做业务,不碰 UI 结构 */
|
||||
readonly setup?: (ctx: PageContext) => Promise<Record<string, unknown>> | Record<string, unknown>
|
||||
/** 行为回调:业务动作(bivariant,允许实现方标注收窄的 payload 类型) */
|
||||
readonly actions?: Record<string, { bivarianceHack(payload: never, ctx: PageContext): unknown }['bivarianceHack']>
|
||||
/** 页标题等元信息(可选) */
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
readonly query: Record<string, string | undefined>
|
||||
readonly params: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
/** 把声明物化为"已校验的页面对象";未知引用直接抛错(校验闸门) */
|
||||
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<P extends PageDeclaration> = Awaited<ReturnType<NonNullable<P['setup']>>>
|
||||
+146
@@ -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<string, unknown>
|
||||
}>
|
||||
}
|
||||
|
||||
/** 业务组件注册项(映射到已有 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<P = unknown, R = unknown> {
|
||||
readonly kind: 'action'
|
||||
readonly id: string
|
||||
readonly handler: (payload: P, ctx: unknown) => R | Promise<R>
|
||||
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<string, LayoutRegistryEntry>
|
||||
readonly widgets: ReadonlyMap<string, WidgetRegistryEntry>
|
||||
readonly actions: ReadonlyMap<string, ActionRegistryEntry>
|
||||
readonly schemas: ReadonlyMap<string, SchemaRegistryEntry>
|
||||
}
|
||||
|
||||
/* ── 注册与查询 ─────────────────────────────────────────────── */
|
||||
|
||||
export type RegisterFn<K extends RegistryKind, E extends { readonly kind: K; readonly id: string }> =
|
||||
(entry: E) => E
|
||||
|
||||
export interface RegistryApi {
|
||||
readonly store: RegistryStore
|
||||
registerLayout: RegisterFn<'layout', LayoutRegistryEntry>
|
||||
registerWidget: RegisterFn<'widget', WidgetRegistryEntry>
|
||||
registerAction: <P, R>(entry: ActionRegistryEntry<P, R>) => ActionRegistryEntry<P, R>
|
||||
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<string, LayoutRegistryEntry>()
|
||||
const widgets = new Map<string, WidgetRegistryEntry>()
|
||||
const actions = new Map<string, ActionRegistryEntry>()
|
||||
const schemas = new Map<string, SchemaRegistryEntry>()
|
||||
|
||||
function register<K extends RegistryKind, E extends { readonly kind: K; readonly id: string }>(
|
||||
map: Map<string, RegistryEntry>,
|
||||
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: <P, R>(entry: ActionRegistryEntry<P, R>) => {
|
||||
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
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user