@nvoo/schema
v1.0.0
Published
Nvoo — Schema-driven UI engine. One declaration drives table, search form, mobile card, and beyond.
Readme
@nvoo/schema
Schema-driven UI 契约层 + 列表查询状态机。
一份 schema 声明,多端独立渲染器消费(PC 表格 / H5 卡片 / 查询表单 / 工具栏 / 行内操作)。渲染器实现在
@nvoo/ui-layout的pc/h5子入口。
设计哲学
- 协议与渲染解耦:本包只定义"数据形状"和"状态机",不引入任何 UI 组件。
- 跨端复用同一份 schema:
control是控件语义名(如"select"),由各端 Renderer 自行映射到具体组件(PC →a-select、H5 →van-picker)。 - 声明优先,逃生口完备:99% 场景靠声明式配置完成;剩下 1% 边缘场景提供
render/transform/normalize等函数逃生口。
目录
安装
pnpm add @nvoo/schema
# peerDep
pnpm add vue@^3.5.0package.json 的 exports 含 development 条件,Vite dev 模式下直接走源码,无需预构建。
快速上手
import { useListQuery, createListAdapter } from "@nvoo/schema";
// 1. 用 adapter 把后端真实接口适配成统一协议
type User = { id: number; name: string; status: number };
const fetchUsers = createListAdapter<
{ content: User[]; count: number }, // 后端原始响应
{ pageNum: number; pageSize: number; username?: string }, // 后端原始入参
User
>({
request: params => api.get("/users", { params }),
keys: {
currentKey: "pageNum",
pageSizeKey: "pageSize",
dataKey: "content",
totalKey: "count"
}
});
// 2. 用 useListQuery 持有列表状态
const {
data,
total,
current,
pageSize,
loading,
hasMore,
query,
search,
changePage,
loadMore,
refresh,
reset
} = useListQuery<User>({
request: fetchUsers,
defaultPageSize: 20,
initialQuery: { status: 1 }
});useListQuery 不关心 UI 渲染;PC Renderer 与 H5 Renderer 各自消费它,业务侧也可独立使用(脱离 schema 体系)。
核心能力
| 能力 | 说明 |
| -------------------- | ------------------------------------------------------------------------------- |
| 双数据策略 | overwrite(PC 表格,翻页覆盖)/ append(H5 触底加载,翻页追加) |
| 后端协议适配 | 字段名变换 / dot 嵌套路径 / query 嵌入指定 key / 完全自控 transform & normalize |
| 跨端 schema 契约 | 一份声明同时驱动表格列、查询表单、H5 卡片、行内操作、工具栏按钮 |
| 字典一体化 | 列声明 dict: "user_status" 自动派生到表格单元格 / 表单控件 / H5 标签 |
| 控件语义化 | control: "select" 而非 component: ASelect,保证跨端可复用 |
| 函数式逃生口 | render / transform / normalize / confirm 函数式签名覆盖边缘场景 |
Composables API
useListQuery
列表查询核心状态机,与 UI 完全解耦。
function useListQuery<T = Record<string, unknown>>(
options: UseListQueryOptions<T>
): UseListQueryReturn<T>;UseListQueryOptions<T>
| 字段 | 类型 | 默认 | 说明 |
| ----------------- | ------------------------- | ------------- | ----------------------------- |
| request | ListRequestFn<T> | — | 必填,统一签名的请求函数 |
| defaultPageSize | number | 10 | 初始每页条数 |
| initialQuery | Record<string, unknown> | {} | 初始查询条件(如默认近 7 天) |
| strategy | "overwrite" \| "append" | "overwrite" | 数据策略:PC 表格 / H5 触底 |
UseListQueryReturn<T>
| 字段 | 类型 | 说明 |
| ------------ | -------------------------------------- | ---------------------------------------------- |
| data | Ref<T[]> | 当前页数据(append 下为累计) |
| total | Ref<number> | 总条数 |
| current | Ref<number> | 当前页码(1-based) |
| pageSize | Ref<number> | 每页条数 |
| loading | Ref<boolean> | 加载状态 |
| hasMore | ComputedRef<boolean> | data.length < total,触底判断用 |
| query | Record<string, unknown> | 当前查询条件,reactive 可双向绑定 |
| search | () => Promise<void> | 重置到第 1 页并查询 |
| changePage | (current, pageSize) => Promise<void> | 翻页(overwrite 专用) |
| loadMore | () => Promise<void> | 触底加载下一页并追加(仅 append) |
| refresh | () => Promise<void> | overwrite 重取当前页;append 重置到第 1 页 |
| reset | () => Promise<void> | 清空 query 回到 initialQuery 并查询 |
注意:
search/reset总是覆盖数据;loadMore在非append模式下是 no-op。
createListAdapter
后端协议适配器工厂。把后端原始签名(Spring Page 的 content/count、pageNum/pageSize 等)转换为本框架统一的 ListRequestFn<T> 协议。
function createListAdapter<RawResp, RawReq extends Record<string, unknown>, T>(
options: ListAdapterOptions<RawResp, RawReq, T>
): ListRequestFn<T>;ListAdapterKeys
字段映射配置,所有字段都可选:
| 字段 | 默认 | 说明 |
| ------------- | ------------ | -------------------------------------------------- |
| currentKey | "current" | 入参当前页字段名,如 "pageNum" / "page" |
| pageSizeKey | "pageSize" | 入参每页条数字段名,如 "size" / "perPage" |
| queryKey | undefined | query 是否嵌入到指定 key(不设默认 spread 到顶层) |
| dataKey | "data" | 出参数据数组路径,支持 dot:"result.list" |
| totalKey | "total" | 出参总条数路径,支持 dot:"result.total" |
ListAdapterOptions<RawResp, RawReq, T>
| 字段 | 类型 | 优先级 |
| ------------------ | --------------------------------------- | ---------------------- |
| request | (params: RawReq) => Promise<RawResp> | — |
| keys | ListAdapterKeys | 基础映射 |
| transformRequest | (params: ListRequestParams) => RawReq | 高于 keys |
| normalize | (raw: RawResp) => ListResponse<T> | 最高,完全自控出参 |
适配示例
// 场景 1:扁平字段名变换
createListAdapter({
request: api.list,
keys: { currentKey: "pageNum", dataKey: "content", totalKey: "count" }
});
// 场景 2:嵌套路径
createListAdapter({
request: api.list,
keys: { dataKey: "result.records", totalKey: "result.total" }
});
// 场景 3:query 嵌入指定 key(后端 DTO 形如 { pageNum, pageSize, query: {...} })
createListAdapter({
request: api.list,
keys: { queryKey: "query" }
});
// 场景 4:完全自控(逃生口)
createListAdapter({
request: api.list,
transformRequest: p => ({
page: p.current,
size: p.pageSize,
...p.query
}),
normalize: raw => ({
data: raw.data.items,
total: raw.data.totalCount
})
});Schema 类型契约
ListPageSchema
一份声明驱动 PC 表格 / H5 卡片 / 查询表单 / 工具栏。
interface ListPageSchema<T> {
columns: ColumnConfig<T>[]; // 列声明
request: ListRequestFn<T>; // 数据请求函数
rowKey?: keyof T & string; // 行 key(默认 "id")
actions?: ActionConfig<T>[]; // 工具栏按钮(新增/导出/批量删除)
defaultPageSize?: number; // 默认 10
mobileMode?: "card" | "list"; // 移动端展示,默认 "card"
rowSelection?: RowSelectionConfig<T>; // 行选择配置
}RowSelectionConfig:
| 字段 | 默认 | 说明 |
| ------------------ | ------- | --------------------------------------- |
| type | "M" | M 多选 / S 单选 / N 关闭 |
| keepSelected | false | 翻页是否保留已选 |
| getCheckboxProps | — | 行级禁用判断 |
| onChange | — | 选择变化通知(Renderer 自管 keys/rows) |
ColumnConfig
一份列声明派生四处视图:PC 表格列、PC 查询表单(从 search 派生)、H5 卡片字段、H5 状态标签。
| 字段 | 类型 | 说明 |
| --------------- | ----------------------------------- | ---------------------------------------------------------------------- |
| title | string | 列标题 |
| dataIndex | string \| [string, string] | 单字段或范围合并列(渲染成 "start ~ end") |
| valueType | ColumnValueType | text/number/date/dateTime/select/tags/switch |
| dict | string | 字典 key,运行时通过 useDictionary 拉取。一处声明 → 三处自动派生 |
| dictStatusMap | Record<string, NvStatus> | 字典项着色补充(字典未返回 status 时) |
| valueEnum | ValueEnum | 静态枚举(逃生舱,无字典场景) |
| hideInTable | boolean | PC 表格隐藏 |
| hideInMobile | boolean | 移动端卡片隐藏 |
| mobileTitle | boolean | 移动端卡片标题字段(建议仅 1 个) |
| width | number \| string | 列宽(数字→px) |
| align | "left" \| "center" \| "right" | 默认 left |
| ellipsis | boolean | 内容超出省略 |
| fixed | "left" \| "right" | 固定列 |
| search | false \| FormFieldConfig | 派生查询字段,false/不配不参与查询 |
| render | (value, row, index) => VNodeChild | 自定义单元格渲染(逃生口) |
| actions | ActionConfig<T>[] | 列级操作按钮组(声明后该列变成 ActionCell) |
优先级:
dict > valueEnum;actions一旦配置,valueType/render/dict/valueEnum失效。
FormFieldConfig
查询表单 / 编辑表单通用字段声明,跨端复用。
| 字段 | 类型 | 说明 |
| -------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| control | string | 控件语义名:内置 input/textarea/number/select/switch/date/dateRange/dateTime/dateTimeRange;或业务自定义(通过 registerFormControl 注册) |
| field | string \| string[] | 提交参数名;范围查询用 [start, end] |
| label | string | 标签文案 |
| placeholder | string \| [string, string] | 占位文案;不填自动派生 |
| defaultValue | unknown | 默认值;范围类型是 tuple |
| dict | string | 字典 key(select 优先) |
| options | OptionItem[] | 静态选项(无 dict 时) |
| remote | RemoteOptionSource | 远程搜索(优先级低于 dict) |
| props | Record<string, unknown> | 控件 props 透传 |
| primary | boolean | H5 顶栏快捷搜索(仅第一个 primary 生效) |
| hidden | (formValues) => boolean | 条件隐藏 |
| transform | (value, formValues) => Record<string, unknown> | 值 → 提交参数的最终变换 |
数据源优先级:
dict > options > remote。 解析顺序:localcontrolsprop > global registry > 内置默认。
ActionConfig
工具栏按钮 / 行内操作 / 卡片右下角操作共用一份声明。
| 字段 | 类型 | 说明 |
| --------------------- | -------------------------------------------- | --------------------------------------------- |
| key | string | 操作 key(埋点 / 测试 selector) |
| label | string | 按钮文字 |
| icon | string \| VNodeChild \| (() => VNodeChild) | 图标 |
| attrs | Record<string, unknown> | antd a-button 属性透传(仅工具栏生效) |
| status | (ctx) => boolean | 是否显示(默认恒显示) |
| confirm | ConfirmConfig \| ConfirmFn | 确认弹窗:配置对象或函数式逃生口 |
| handler | (ctx) => unknown \| Promise<unknown> | 点击回调,返回 Promise 时自动 refresh |
| refreshAfter | boolean(默认 true) | handler 完成后是否自动 ctx.refresh() |
| clearSelectionAfter | boolean(默认 false) | handler 完成后是否自动 ctx.clearSelection() |
ActionContext:
| 字段 | 说明 |
| ---------------- | ------------------------------------- |
| record | 行内场景:当前行;工具栏:undefined |
| selectedRows | 工具栏:已选行;行内:[] |
| refresh | 触发列表刷新 |
| clearSelection | 清空行选择 |
典型
refreshAfter: false场景:查看详情、复制文本、打开弹窗。 典型clearSelectionAfter: true场景:批量删除 / 批量发布。
通用 API 协议
types/common/api.ts 定义了跨页面复用的请求/响应类型:
interface ListRequestParams {
current: number; // 1-based
pageSize: number;
query: Record<string, unknown>; // search 展开后填充
}
interface ListResponse<T> {
data: T[];
total: number;
}
type ListRequestFn<T> = (params: ListRequestParams) => Promise<ListResponse<T>>;
interface ApiResponse<T> {
// 通用后端响应包装(按需用,非强制)
code: number;
data: T;
message?: string;
}设计原则与约定
- schema 不指定具体组件 —— 用
control语义名代替component,否则破坏跨端复用。需要换组件走 Renderer 的 registry。 - 优先用 dict,慎用 valueEnum —— 字典是动态的(运行时拉取),枚举是静态的。除非真的没有任何后端支撑,否则一律
dict。 - 范围查询统一用 tuple ——
field: ["startTime", "endTime"],控件返回的 tuple 自动拆开。 - 状态机与 UI 解耦 ——
useListQuery不引用任何 Vue 组件;可在测试 / Node 环境 / 非 schema 项目独立使用。 - 逃生口要收敛使用 ——
render/transform/normalize是兜底,不是首选。大量使用说明 schema 设计需要补字段。
文件结构
src/
├── index.ts # 统一出口
├── composables/
│ ├── useListQuery.ts # 列表查询状态机
│ └── createListAdapter.ts # 后端协议适配器工厂
└── types/
├── index.ts # 类型统一出口
├── common/ # 跨页面原子协议
│ ├── api.ts # ListRequestParams / ListResponse / ListRequestFn
│ ├── form.ts # FormFieldConfig / FormControlType / OptionItem
│ └── action.ts # ActionConfig / ActionContext / ConfirmConfig
└── list-page/ # 列表查询页领域协议
├── column.ts # ColumnConfig / ValueEnum
├── schema.ts # ListPageSchema / RowSelectionConfig
└── index.tsLicense
MIT
