@ahoo-wang/wow-view-engine
v9.6.2
Published
Configurable data view engine for Wow-based business applications: definitions in code, view configs as data, records, analyses and dashboards.
Maintainers
Readme
Wow View Engine
随 Wow 9.2.0 发布,与 Wow 同一个 tag、同一个版本号。
兼容性。 从 9.2.0 起,补丁版本(
9.2.x)不破坏公开面:每个入口的导出,包括后端实现的ViewStore端口;CSS 合同(什么是公开的);消息键与 issue code;以及wow-view-engine命令。次版本可以破坏,它的发布说明逐条列出每个破坏与迁移步骤;Wow 包请停在同一个次版本上,见版本范围。破坏就是干净的改动,从不带兼容层。用户以旧形状保存的视图照常打开:引擎在读取时迁移存储的配置。docs/design/ 是模型的唯一依据,本页只说现在的 API。
Wow View Engine 是面向 Wow 业务应用的数据视图引擎。 业务应用用代码声明一份数据"能被怎样观察":字段、类型、操作符、可用的维度与指标。用户在界面上决定"这一次怎样观察":筛选、列、排序、维度与指标、图表、面板组合。引擎把这种观察方式编译成 Wow 查询、执行、渲染,并把有价值的观察方式保存下来供下次直接打开。
它是 @ahoo-wang/wow-client 之上的展示层,也是 @ahoo-wang/fetcher-viewer 的继任者。
解决什么问题
业务系统里的大多数页面是同一种页面:一张列表,带筛选、排序、分页,偶尔加一张统计图。每个业务对象(订单、库存、客户、工单)都要写一套。运营每提一次"再加一个筛选条件""按仓库拆开看一下""把这几张表放到一个概览页",都要改代码、排期、发版。
数据没有变,变的只是观察方式。问题在于观察方式被写死在页面代码里:用户不能自己调整,研发被重复劳动占满,产品把展示层的每次调整都当成需求。
目标
- 用户目标。 在已声明的能力范围内自己调整数据范围、组织方式与呈现方式,把常用的观察方式保存下来,下次一键重开。
- 研发目标。 一个业务对象接入一次,即一份定义加一个查询客户端,之后记录视图、分析视图、仪表盘三类视图不再需要写页面;筛选编辑、查询协调、结果呈现、保存恢复与冲突处理由引擎统一提供。
- 演进目标。 新增业务对象只增加定义,不在引擎里加业务分支;自定义布局通过无样式钩子复用同一套行为,不复制一份逻辑。
价值
| 对象 | 得到什么 | | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | 业务用户 | 不等排期就能得到需要的视图;常用视图保存后直接打开;同一份数据既能看记录,也能按维度看指标,还能看概览 | | 研发 | 列表类页面从"每个对象一套"变为"每个对象一份定义";筛选、分页、排序、保存、冲突只实现一次;定义可由 generator 从 Wow 元数据生成 | | 产品 | 支持范围内的展示调整不再是需求而是配置;"保存与共享视图"可以作为产品能力交付给客户 |
场景
| 场景 | 视图 | 用户做什么 | | -------------------------- | ------------------------------- | -------------------------------------------------------------------------------------- | | 仓管每天找待出库订单 | Record | 筛选状态为待出库,按创建时间排序,只显示需要的列,保存为"今日待出库" | | 主管比较各仓库积压 | Analysis | 按仓库分组、计数并合计金额,切换为柱状图 | | 运营周会看整体情况 | Dashboard | 把上面两个视图放进一个面板页,用全局时间范围同时约束两者 | | 业务页面里嵌一块数据 | EmbeddedView、EmbeddedDashboard | 开发者把已保存视图嵌入订单详情页,或把一块仪表盘锁定在这位客户上嵌进客户页,不带工作台 | | 运维为新业务对象配基础视图 | 系统视图 | 在定义中声明"全部""待处理""本周新增"三个视图,用户打开即有可用视角,再按需另存 | | 客户在租户内自定义报表 | 全部 | 客户保存并共享自己的视图,供应商无需为此发版 |
不是什么
不是数据库或计算后端,不是权限系统,不是通用低代码页面搭建器,也不是 BI 建模工具。数据、聚合能力与授权由业务服务提供,引擎只负责让观察方式可靠地运行。
安装
pnpm add @ahoo-wang/fetcher @ahoo-wang/fetcher-decorator \
@ahoo-wang/fetcher-eventstream @ahoo-wang/wow-client @ahoo-wang/wow-view-engine
# /react 与 /ui 入口
pnpm add react react-dom版本号跟随 Wow,次版本可能带有破坏性改动:用 save-prefix=~ 或 --save-exact 让 Wow 包停在同一个次版本上,见版本范围。
Peer 依赖:@ahoo-wang/wow-client(以及它需要的 fetcher 包);react 与 react-dom 只用于 /react 和 /ui 入口,react-router 只用于 /react-router,mingo 只用于 /testing。根入口可在 Node 中运行。要把保存的视图放在 Wow 服务端,再加 @ahoo-wang/wow-view-store。只提供 ES 模块;Node >=22.12.0 或当前的浏览器;TypeScript 6 及以上。
设计原则
| 事实 | 推论 |
| ---------------------- | ---------------------------------------------------------------------------------------- |
| 定义是代码。 | 没有定义服务与定义版本。定义变更就是一次发版;保存的视图在打开时按当前定义校验。 |
| 配置是数据。 | 持久化对象只有 ViewInstance 与个人偏好。一致性策略是乐观 revision 加幂等 requestId。 |
| 运行状态是临时的。 | 草稿、结果、分页与选择只活在一个打开的 ViewRuntime 中,不持久化。 |
快速开始
1. 声明定义
import type { ViewDefinition } from '@ahoo-wang/wow-view-engine';
export const orders: ViewDefinition = {
id: 'orders',
title: 'Orders',
kind: 'data',
source: 'orders',
fields: [
{ name: 'id', label: 'Order', kind: 'string', sortable: true },
{
name: 'status',
label: 'Status',
kind: 'enum',
options: [
{ value: 'PENDING', label: '待出库' },
{ value: 'SHIPPED', label: '已出库' },
],
},
{ name: 'warehouse', label: 'Warehouse', kind: 'string' },
{
name: 'amount',
label: 'Amount',
kind: 'number',
summary: ['SUM', 'AVG'],
},
{ name: 'createdAt', label: 'Created', kind: 'datetime', sortable: true },
],
// 行键必须可排序:每条记录查询的排序都以它收尾,排序值相同的行翻页时才不重不漏
record: { rowKey: 'id', paging: 'paged', layouts: ['table', 'card'] },
// 系统视图:开发或运维配置的基础视图,随定义部署,所有用户可见,只读,可另存
views: [
{
id: 'pending',
title: 'Pending',
config: {
kind: 'record',
filter: {
op: 'and',
// enum 是封闭集合,因此提供 IN 与 NOT_IN 而不是 EQ:
// 一个条件即表达“取其中之一”,两者互换时保留已选项。
children: [{ field: 'status', operator: 'IN', value: ['PENDING'] }],
},
filterMode: 'simple',
refresh: { interval: null },
sort: [{ field: 'createdAt', direction: 'DESC' }],
pageSize: 20,
summaries: [{ field: 'amount', fn: 'SUM' }],
layout: 'table',
table: {
columns: [
{ field: 'id' },
{ field: 'warehouse' },
{ field: 'amount' },
],
},
card: { title: 'id', fields: ['status', 'warehouse', 'amount'] },
},
},
],
};从描述符定义视图
手写的定义要把服务的查询描述符已经说过的再说一遍:有哪些路径、各存什么、能按什么排序、筛选与聚合。defineView(descriptor, spec) 从随定义一起提交的描述符快照里取这些事实,spec 只写选择:读者看到哪些字段、什么次序、叫什么,类别的措辞与语气色,单元格,以及收窄什么。结果仍是一份普通的 DataViewDefinition,引擎的其余部分不知道它是怎么写的。
import { defineView, text } from '@ahoo-wang/wow-view-engine';
export const orders = defineView(ordersDescriptor, {
id: 'orders',
source: 'orders',
title: text('orders.title'),
// 看板的时间筛选经它接到面板上。
timeField: 'createdAt',
fields: {
id: { label: text('orders.id'), cell: 'copyable', analysis: false },
status: {
label: text('orders.status'),
cell: 'status',
options: {
PENDING: { label: text('orders.pending'), tone: 'warning' },
SHIPPED: { label: text('orders.shipped'), tone: 'success' },
},
},
amount: { label: text('orders.amount'), summary: ['SUM'] },
createdAt: text('orders.createdAt'),
},
record: { layouts: ['table', 'card'] },
});- 事实取快照,能力取数据源。 没列出的字段不出现。种类、枚举值、敏感级别与数组的条目是模型的事实,取自快照;快照没有的路径或值是准入错误(
validateDefinition、onIssue)——定义照样加载,并说出错在哪。路径能怎么排序、筛选、聚合是存储的能力:你没收窄的,定义取它所运行的数据源给的一切(Elasticsearch 上有数组条目里的搜索,MongoDB 上没有);你收窄的——operators、sortable: false、summary、analysis或analysis: false——取你的子集与之相交。收窄超出快照是警告,因为换一个存储可能就有。已弃用的路径在写明理由前一直警告;敏感字段退出分析,机密字段不做任何比较;时刻按日历分组,只有最早与最晚。 - 快照随代码提交。 定义在模块加载时就建好,测试里一样。运行时给出描述符的数据源,用它填上你留开的能力、收窄其余,与任何定义一样;不给描述符的数据源按快照的能力跑。
- 措辞写键。
text(key)占着标签的位置,一份定义服务所有语言。引擎存着的、交出去的都留着键:定义、每个视图的状态与快照、list()、编辑器拿到的与交回的,以及存储——从系统视图另存的视图连键一起存,所以换到哪种语言都仍是未修改、还原也对。键只在显示或离开引擎的地方说成话——单元格、表头、图表的 option、导出、无障碍名称、标题——按最近的ViewHost的messages,缺的退回ViewEngineOptions.text;所以换语言只重画已打开的视图,不重开、不重查、不变脏。自己写的组件用/ui的useSay()说,React 之外的代码用say(label, words);cellText、displayValue经上下文的say说选项标签,renderCell拿到的是原值,键也在内。自己写的编辑器显示成话、交回键:原样留着的话交回原来的键——提交草稿时用keptKey(typed, original, say, shown)(shown是打开时显示的话,打开期间换了语言,没动过的标签也不会变成话),每敲一键就写回的输入框用useSaidText(value)。起始措辞只传到ViewHost、工作台和嵌入画的东西里;自己用ViewSurface包部件、上面又没有ViewHost的,给它engine={engine}。界面自己起的名字——「…的副本」、新标签页、新分析的默认标题——是起名时所用语言的话,此后不变。措辞缺的键按键本身显示;写了这个引擎的最外层ViewHost每种语言报一次(definition.text.unknown,起始措辞补上了的报definition.text.fallback)。字面字符串仍可当标签。 - 时间。
timeField——或系统视图自己的,null表示整体读——是看板唯一的日期筛选接到没有接线的面板上所经的字段;手写的接线优先,面板上的ignoresTime: true让时间范围不作用于它。 /testing的admit在宿主的测试里把这些一并核对(测试宿主)。
2. 创建引擎
import { MemoryViewStore, ViewEngine } from '@ahoo-wang/wow-view-engine';
const engine = new ViewEngine({
store: new MemoryViewStore(),
// 来自 @ahoo-wang/wow-client 的 Pick<QueryApi, 'paged' | 'cursor' | 'aggregate'>
resources: [{ definition: orders, source: queryClients.orders }],
});每项资源把一份定义与它的数据从哪来配成一对。看板的定义没有数据源——它自己不查数据。数据源按数据定义写的键(definition.source)找,所以同一查询模型上的两份定义共用一个;哪里都没有登记数据源的数据定义在注册时就被拒绝(definition.source.unregistered):它的工作台说明原因,看板上只有用它的面板熄掉。资源在应用启动时注册一次:一个应用一个引擎,各页面共享它的查询、偏好与描述。查询队列按看板规模自己留位——打开的看板在 maxQueuedQueries 之外为它的面板留出位置——所以大看板不用调高上限也能整块打开。
没有抛出物的发现——定义的准入、描述收窄去掉了什么——交给 onIssue。不传时,开发构建(NODE_ENV 为 development)按资源分组、折叠打印到控制台,每条带改法;生产与测试运行不出声。
服务端收什么:describe。 定义是代码,写的时候不知道部署在哪种存储上:在 Elasticsearch 上能用的短语检索,在没有文本索引的 MongoDB 上会被拒绝;调高了查询守卫的服务端,收的也比引擎的缺省多。给数据源一个 describe——wow-client 的 describeSnapshot(或 describeEventStream)可以直接充当——引擎就在这个源上的第一个视图运行之前读服务端的能力描述,把每份定义收窄到描述允许的范围(描述没有列出的算子、排序、检索、分组或指标不再提供:隐藏,不置灰),并从它读源预算(maxPageSize、maxPageWindow、maxAnalysisRows、maxQueryFilterNodes、maxFilterValues);超出后两项的查询——按服务端守卫的口径在编译后的查询上数——在发出之前就被拒绝。刷新时、页面切回来时,引擎带着持有的版本重新验证描述,至多每五分钟一次。收窄去掉了什么,按描述版本经 onIssue 报一次;描述与定义冲突时——源没有这种分页方式、行键不可排序、时间按另一种单位保存——这份定义像准入不过一样被拒绝。不给 describe,视图照旧按定义与缺省上限运行。
import { MemoryViewStore, ViewEngine } from '@ahoo-wang/wow-view-engine';
// factory.createSnapshotQueryClient() 与 factory.createQueryDescriptorClient():
// schema 路由没有租户、所有者段,所以是两个客户端。
const engine = new ViewEngine({
store: new MemoryViewStore(),
resources: [
{
definition: orders,
source: {
paged: snapshots.paged,
cursor: snapshots.cursor,
aggregate: snapshots.aggregate,
describe: descriptors.describeSnapshot,
},
},
],
});limits 叠在 DEFAULT_RUNTIME_LIMITS 之上:只传你要改的。传了的源预算只会压低描述给的值;把 DEFAULT_RUNTIME_LIMITS 整个展开进去,就又把它们钉回了缺省。
给 fetcher 设超时。 @ahoo-wang/fetcher 缺省不设,引擎也不给查询设:服务端接了连接却不应答时,视图一直停在打开中;几条这样的查询就占满引擎队列的全部并发(maxConcurrentQueries),看板上其余面板永远发不出去。在源与存储用的 fetcher 上设一个——fetcher.timeout = 60_000,或 new Fetcher({ timeout })——超时的查询与别的失败一样报出、可以重试。引擎自己只限一处等待:首次读一个源的能力描述,过了十秒就照声明的定义打开(与读取失败时一样),描述到了再收窄。
导出的文件缺省中和公式。 CSV 会离开页面,在表格软件里被打开,打开的人常常不是导出的人;所以每一处导出——记录视图的行、分析的「导出数据…」——都把文本以 =、+、-、@、制表符或回车开头的格子写成前面带一个 '(OWASP CSV Injection),表头也算。值是数的格子、以及文本就是一个纯数的格子(如 -12.5)照写:表格软件把它读成数,从不求值。文件不进表格软件时可以关掉:limits: { exportNeutralizeFormulas: false };自己调用 serializeCsv 时传 { neutralizeFormulas: false }。
失败交给你的监控。 查询、存储调用、导出、渲染、图表或声明式操作的命令失败时,界面在出事的地方照旧说明;要记日志或送监控,就给环境一个 onError。每次失败它被告知一次,带着抛出来的原物和出事的位置;它抛什么都会被吞掉,不给它就什么也不记。
import { MemoryViewStore, ViewEngine } from '@ahoo-wang/wow-view-engine';
import { browserRuntimeEnvironment } from '@ahoo-wang/wow-view-engine/react';
const engine = new ViewEngine({
store: new MemoryViewStore(),
resources: [{ definition: orders, source: queryClients.orders }],
// 不用 React 时写 `defaultRuntimeEnvironment({ onError })`。
environment: browserRuntimeEnvironment({
onError: ({ kind, error, context }) =>
sendToMonitoring({ kind, error, ...context }),
}),
});| kind | 何时告知 | context.operation |
| -------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| query | 视图的查询失败,或它旁边的查询:汇总行、分析的合计、条件的取值 | query、summaries、totals、split、record、candidates |
| store | 一次 ViewStore 调用被拒:列表、打开、写入(每次重试各一次,同一个 requestId) | 端口的方法名:list、get、create、save…… |
| export | 导出的行拉不下来,或文件做不出来、交不出去 | fetch、deliver、image |
| render | 工作台或嵌入里的某一块画的时候抛错——常常是你的动作槽位 | render |
| chart | 图表库没加载到,或绘制时抛错 | load、draw |
| action | 声明式操作的 run(或插槽的命令)在某条记录上抛错或超时——动作自己的拒绝不算 | 动作的 id;context.recordKey 是那条记录 |
context 在知道时还写明是哪个视图——definitionId、instanceId、runtimeId——render 与 chart 另有 boundary、panelId 与 React 的 componentStack。被叫停的请求(被下一个顶掉、被取消)不算失败,不告知。工作台、网格与嵌入上的 onRenderFailure 照旧:它是那一块界面自己的回调,拿到的是同一个 error;onError 是整个引擎的。引擎的 onIssue 只管没有抛出物的发现,比如定义准入。
3. 把引擎放在页面之上:ViewHost
ViewHost 是宿主在页面外面要写的唯一一样东西。它由几个端口组成,每个都只是一座桥,通到宿主已经有的东西——数据(引擎,连同它的 resources 与 store)、路由、语言、主题,以及每项资源上的命令(bind)——路由库、i18n 与主题系统仍是宿主自己的:
import '@ahoo-wang/wow-view-engine/styles.css';
import '@ahoo-wang/wow-view-engine/themes/porcelain.css';
import { useReactRouter } from '@ahoo-wang/wow-view-engine/react-router';
import { bind, ViewHost } from '@ahoo-wang/wow-view-engine/ui';
const bindings = [
bind('orders', {
route: view => (view ? `/orders?view=${view}` : '/orders'),
reading: { title: row => `订单 ${String(row.key)}` },
actions: orderActions,
}),
bind('overview', {
route: board => (board ? `/boards?view=${board}` : '/boards'),
}),
];
export function Host({ children }: { children: ReactNode }) {
return (
<ViewHost
engine={engine}
router={useReactRouter()}
locale={locale}
messages={ordersWords}
bindings={bindings}
preset="porcelain"
rememberColorMode="my-app.color-mode"
>
{children}
</ViewHost>
);
}| 属性 | 端口 | 做什么 |
| -------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| engine | 数据 | 应用的引擎,建一次。写了它的那层用自己的 messages 说出引擎定义里的键 |
| router | 路由 | 宿主的路由:/react-router 的 useReactRouter()(React Router 是可选 peer,只有这个入口加载它),或在别的路由库上写 ViewRouter 的两个成员 |
| locale、messages | 语言 | 值按哪种语言显示,以及合并在已生效措辞之上的措辞——引擎自己的(zhCN、宿主的改写)与定义的键一样放在这里;一换,所有打开的视图就地重画,还是那些运行时,不重发查询 |
| bindings | 命令 | bind(definitionId, { route, reading, actions, slots }):route(instanceId, target?) 是打开它的页面路径(没人保存的视图 instanceId 为 null;引擎要一条链接而不是一条去处时——useViewNavigation——不给 target);reading 是记录详情的选项(render、title、sections);actions 是宿主对它的记录的命令,写成声明(接入宿主),slots 是旁边宿主自己的标记——它的工作台、记录详情与所有看板上它的记录面板都挂 |
| theme、preset、brand | 主题 | 两条路选其一 |
| colorMode、rememberColorMode | 主题 | <html> 上的明暗:缺省 system,跟随系统;light/dark 从钉住开始;宿主自己画明暗时写 host。useColorMode() 给宿主的开关 { mode, setMode },读者的选择按给的 localStorage 键记住 |
| navigate | 路由 | 每一条去处由宿主自己接,不走路由端口:按目标的 route 解析好交来——{ kind: 'route', path, state }——网址与没有 route 的资源原样交来 |
有了路由端口,地址由引擎管。 离开看板或视图的每一条路——「在工作台中打开」、一组上的追问、面板的去处、回到看板——都去目标资源的 route,页面要打开的东西作为这条历史的 state(ViewRouteState:交接来的视图、看板的 filters 与 tab);宿主自己的路径也经路由,别的站点另开。没传 instanceId 的 DataWorkbench、DashboardWorkbench 打开地址的 ?view=——只认自己定义的视图,一页上两个工作台互不干扰——读者换视图就写回去,每换一个一条历史;没传 handOver 的,打开这条历史里交接的视图;没传筛选那一对(initialFilters、onFiltersChange)或标签页那一对的看板——工作台的,或 EmbeddedDashboard——从这条历史读,读者一改就记回去,每块板各记各的,一页上几块板各自找回自己的。绑定了的资源,记录详情跟着 ?id=,除非它的 reading 自己握着 open。宿主自己的 props 仍然优先,每一对各算各的。别的路由库只要两个成员:
const router: ViewRouter = {
location: { pathname, search, state },
go: (path, options) => push(path, options?.state, options?.replace ?? false),
};地址一变就给一个新的路由对象,不变就给同一个——像 useReactRouter 那样按地址的几个部分 useMemo:读地址的一切只在路由对象换了时重读。
导航是数据。 引擎画视图、页面归宿主,所以没有整页外壳;useViewNavigation() 给宿主的外壳它的去处——每一项绑了 route 的资源,按注册的次序:{ id, kind, title, path, current, views },views 是它的系统视图(看板定义的就是系统看板),各带 { id, title, path, current }。标题按生效的措辞说,current 读路由端口的地址。用宿主自己的组件画——shadcn 的 Sidebar、顶栏都行——名字也可以是宿主自己起的:
function AppSidebar() {
const places = useViewNavigation();
return (
<Sidebar>
<SidebarMenu>
{places.map(place => (
<SidebarMenuItem key={place.id}>
<SidebarMenuButton
isActive={place.current}
render={<Link to={place.path} />}
>
{place.title}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</Sidebar>
);
}它下面的外壳只写这一处与别处不同的:<DataWorkbench definitionId="orders" />、<EmbeddedDashboard instanceId="…" />。外壳自己的 engine、messages、locale、onNavigate、record 仍然优先;ViewHost 可以嵌套,内层的绑定按 id 覆盖外层——一页两个引擎照样写得出。引擎的定义说的是写了这个引擎的最外层 ViewHost 的 messages——一个引擎同时只说一种语言——所以内层换一种语言的 ViewHost——无论隔几层再写一次这个引擎,还是不写——只改写它之下引擎自己的措辞,不改定义的键;两个并列的 ViewHost 写同一个引擎时,须给它同样的措辞。EmbeddedView 的 detail 按绑定的读法读记录(render、title、sections),但开着哪一条是它自己的:同一定义的两个嵌入不会打开同一条,也不写绑定的 open。
接入宿主:声明式操作
记录上的命令写成声明,不画出来。宿主说做什么——记录接哪些命令、什么时候能做、不能做时怎么说、要不要先确认、要什么输入;引擎负责放在哪、怎样做:
| 宿主声明 | 引擎做 |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| 有哪些命令,每条的 run 调生成的命令客户端 | 放在哪:主操作是行内(与卡片上)的按钮,其余在这条记录的「⋯」菜单里,全部都在多选条与详情里 |
| 何时可用(available 返回 true 或理由),何时自己翻转(changesAt) | 不可用时置灰并写明理由(提示、按钮的描述、菜单顶端);到点重算,宿主不开定时器 |
| 口径、语气(tone)、要不要先确认(confirm)、要什么输入(form) | 确认框与表单(条件编辑器的值控件),焦点留在框内,键盘可达,开始与结局都播报 |
| 命令何时算完成:run 在读模型反映之后才 resolve | 多选时:「5 条里 3 条能做」,列出被拒的记录与理由,一键只选能做的;几条一起跑、进度、停止、结局、跑完刷新 |
import { actions, text } from '@ahoo-wang/wow-view-engine';
export const orderActions = actions([
{
id: 'ship',
label: text('orders.ship'),
primary: true,
// `true`,或者为什么不行——一个键,按读者的语言说。
available: (row, { now }) =>
holdOf(row) > now ? text('orders.onHold') : true,
// 什么时候自己翻转;到点引擎再问一次。
changesAt: (row, { now }) => (holdOf(row) > now ? holdOf(row) + 1 : null),
// 例行、但发出去收不回:一张也先问,不用危险色。
confirm: { title: text('orders.shipTitle') },
run: row => commands.ship(String(row.key)),
},
{
id: 'cancel',
label: text('orders.cancel'),
tone: 'danger',
// `{count}` 是多少条;语言把「一条」说得不同时另写 `-one` 键。
confirm: {
title: text('orders.cancelTitle'),
body: text('orders.cancelBody'),
},
run: row => commands.cancel(String(row.key)),
},
{
id: 'priority',
label: text('orders.priority'),
// 只有一个带选项的字段就是选择:选项直接是菜单项。
form: {
priority: {
label: text('orders.priorityField'),
options: [
{ value: 'HIGH', label: text('orders.high') },
{ value: 'NORMAL', label: text('orders.normal') },
],
},
},
// 带着选项问:订单已经是的那一项不再提供。
available: (row, { input }) =>
input?.priority === row.data.priority
? text('orders.samePriority')
: true,
run: (row, { priority }) =>
commands.prioritize(String(row.key), String(priority)),
},
]);run要等读模型反映了命令再 resolve。 引擎紧接着重读视图,跑在命令前头的刷新读到的是旧状态。Wow 命令等CommandStage.SNAPSHOT(请求头写waitStrategy({ stage: CommandStage.SNAPSHOT })),或宿主投影需要的阶段。它抛出的错误按服务端自己的原因读。run只写一条记录;多选由引擎几条一起跑,停止在两条之间生效。命令有批量版本之前不加 runMany。- 每条命令都要幂等——带上这一行显示的聚合版本(
commandHeaders({ aggregateVersion }),第一次已经生效时第二次按版本冲突被拒,而不是再做一遍),以及服务端据以去重同一次发送的重试的请求 id(requestId)。发出之后超时、被中止或断网的run,结局谁也不知道:引擎报「结果未知,先刷新核对」,并取消这些行的选择,不留着诱导一次盲目的重跑;但读者核对后再按一次,也不能退两次款。动作上的timeout: ms给每条记录一个截止时间(过了就是结果未知);没有它时,「停止」再按一次就不再等待。轮到时被动作拒绝的记录报「未执行」而不是「失败」,并留在选中里。 on: ['row', 'bulk', 'detail']收窄出现的位置(缺省处处都有);hidden: row => …让读者不能做的记录干脆不出现这项,available则是出现但置灰并说明。- 多个字段(或一个不带选项的字段)的
form在确认框里给出表单:文字、数字或是否,每个字段缺省必填(required: false例外),initial是打开时的值;run按字段名拿到输入。 - 每个字都是键或文字,在显示处说出(
text(key),措辞与语言)。 - 插槽是逃生口,画在声明的操作之后:
slots: { row, bulk, global }渲染宿主自己的标记——一条链出去的链接、声明说不了的控件。插槽里仍要发命令的,调上下文里的run,结局就报在这个面的那一行上:row: ({ row, run, busy }) => …,run({ title, each: key => … })。
宿主的测试按引擎的读法读这些声明,不画界面——/testing 的 actionHarness(测试宿主)。
3a. 渲染默认工作台
import '@ahoo-wang/wow-view-engine/styles.css';
import { DataWorkbench } from '@ahoo-wang/wow-view-engine/ui';
export function OrdersPage() {
return <DataWorkbench engine={engine} definitionId="orders" />;
}主题跟随宿主:祖先上带 .dark class 即为暗色;给 ViewSurface(或工作台、嵌入组件)传 theme="light" 或 theme="dark" 可以把某一处视图钉住,传 theme="system" 则跟随读者系统的 prefers-color-scheme 并随它实时切换,适合自己没有明暗开关的页面。弹层 portal 到 <body> 时带着面从级联里解析出的模式,.dark 不必放在 <html> 上。预设也是这样选的,见预设。
导入次序无关,宿主的类在面里照样生效。 styles.css 放在宿主自己的 Tailwind 样式之前或之后都行。它的工具类带前缀 fve:(fve:flex、fve:md:w-64),与宿主的不同名:我们的 fve:md:w-64 与宿主全局的 .w-full 不管次序都是两条规则;宿主写在面里、自己标记上的断点类(sm:grid-cols-3)只与宿主自己的规则比。样式表其余部分——preflight、base 与 token——仍收在两个边界里,不加权重(scripts/verify-package.mjs 核对每个被样式化的类都带前缀、其余每条规则都在作用域里)。fve: 工具类是引擎自己的,不给宿主的标记用:样式表只在引擎某个组件写着它时才有这一条。公开的是 token——见什么是公开的。
页面自己已有 main
工作台的主列是页面的 main 地标,以开着的视图命名:工作台通常就是页面的主体。宿主页面自己已有 <main>、工作台放在它里面时,两个 main 就多了一个(axe landmark-no-duplicate-main、landmark-main-is-top-level),这时给 DataWorkbench 或 DashboardWorkbench 传 landmark="region":
| 属性 | 缺省 | 画成 |
| ---------- | -------- | --------------------------------------------------------------------------------------------- |
| landmark | 'main' | 'main':以开着的视图命名的 <main>。'region':同名的 <section>,没开视图时以定义名命名 |
两种画法布局一样:样式表按主列自己的一个属性找它,从不按标签。这个属性和所有 data-slot 一样是引擎自己的,不是给宿主样式表用的选择器。嵌入本来就不画 main,没有这个属性。
export function OrdersPage() {
return (
<main>
<h1>订单</h1>
<DataWorkbench engine={engine} definitionId="orders" landmark="region" />
</main>
);
}开着哪个视图,与宿主的路由
一个数据定义同时装着它的记录视图与分析视图,DataWorkbench 把它们列在一张列表里:用户在一张订单表与一张订单图之间切换,就像在任意两个视图之间切换一样,「新建视图」会先问要建哪一种。宿主要一页只有一种,就收窄——viewKinds={['record']}——另一种在这一页既不列出也打不开。
一个人打开的视图,就是他可以发出去的一条链接。两个工作台因此都收 instanceId 与 onInstanceChange——进出你的路由的两个方向,DataWorkbench 与 DashboardWorkbench 契约完全一致。ViewHost 带了 router 时这两个都不用写:工作台自己把开着的视图记在地址的 ?view= 里。它们留给把视图记在别处的宿主,或者要决定的不止是视图的页面(控制台的失败执行按链接的 ?cluster= 收窄视图)。
export function OrdersPage() {
// 你的路由给什么都行:path 参数、query、hash。
const [view, setView] = useSearchParam('view');
return (
<DataWorkbench
engine={engine}
definitionId="orders"
instanceId={view}
onInstanceChange={setView}
/>
);
}instanceId 是受控的,语义照 input 的 value:
- 不传——非受控形态:开着哪个由工作台自己拿着,从用户的有效默认开始;
- 传了——一个视图 id,或
null表示那个有效默认:由你说开哪个,此后每一次变化都打开它所指的视图。null是一个值,不是"没有值"; onInstanceChange(id)报的是此刻开着的那个,用同一套说法——null在这里同样指有效默认——所以出来的值可以原样传回去。
它收敛而不是渲染。视图里装着未保存的草稿,所以推进来的值与侧栏上的一次点击走同一道离开守卫:后动的那一方说话,另一方跟上。若守卫问过而用户选择留下,工作台会把留下的那个报回来,你的路由不会停在一个没开着的视图上。完整参照见 examples/PlainRecordWorkbench.tsx,连浏览器后退一并覆盖。
搭仪表盘,以及打开面板背后的视图
仪表盘平时是读的:能保存它的人按「编辑」之前,板上什么都不动。编辑中顶上一条编辑条,有「撤销」「重做」、「添加」(数据:已保存的视图,或只属于这块板的新分析;内容:标题、文字、图片、链接)、「添加筛选」(加一个仪表盘筛选,再接到面板上)、「取消」与「保存」,其下的标签栏可以加、改名、排序、删除标签页——面板随改随跑,只有「保存」才存下,走的就是标题栏那次保存。系统仪表盘只读,只有「另存为」。
离开这块板的每一条路都经过你给的一个路由 onNavigate(to)——包本身从不碰地址。不给,就一条都没有:
- 面板「⋯」里的「在工作台中打开」:
{ kind: 'view', definitionId, instanceId, scopeFilter, filter, from },面板显示的已保存视图,已经换成那个视图自己的字段名。不归读者的——板子的固定范围,以及页面持有的锁定或隐藏的筛选——是scopeFilter:视图在它之下跑,是作用域,到了工作台谁也拿不掉。读者在板上设的是filter:成为视图自己的条件,于是视图开着就是「已修改」,每一条都能拿掉——全拿掉就回到保存时的样子,不再「已修改」——「还原」一次全拿掉。板内自建的分析交的是{ kind: 'unsaved', … },读者的值在它的条件里,页面持有的是它的scopeFilter。 - 点一组(柱、扇区、表格的一行)打开分析视图的追问菜单(查看这些记录、按其他维度细分、只显示这一组)。每一项都是一个没保存的视图:
{ kind: 'unsaved', definitionId, title, config, scopeFilter, named, from },读者的值与这一组已经在它的条件里,页面持有的是作用域;named是标题里那一组,读者把它拿掉后工作台的名字就不再带它。 - 每一条路交法相同,
from是回去的路:把目标交给DataWorkbench的handOver属性(每个新对象打开一次),再给同一个onNavigate,工作台就在标题栏下画「返回〈仪表盘〉」,按下交出{ kind: 'dashboard', definitionId, instanceId, filters, tab }——离开时的那块板;只有读者在板子交来的之外又改过,才先问一句。宿主不必自己画返回键。 - 「点击时…」(编辑中):面板也可以改为用点中的一组设置仪表盘筛选(交叉筛选——不需要路由;其余接线的面板跟着筛,被点的面板只标出这一组,再点一次撤销),或者去另一个已保存的视图(
{ kind: 'view' },交法同上,这一组在它自己的条件里)、另一块仪表盘,或你的一个页面({ kind: 'url', url },{{字段}}换成点中的值并编码)。 - 另一块仪表盘:作者逐个列出目的板的筛选,每个映射到这块面板的一个维度、这块板上一个同类型的筛选,或者不带——不按名字猜。点一组时交给你
{ kind: 'dashboard', definitionId, instanceId, filters }:filters就是那块板的DashboardFilters,映射了的筛选是这一组的值或这块板那个筛选点的那一刻的值,其余是它们的默认值。把它交给DashboardWorkbench的initialFilters(或ViewEngine.open的filters)——它是读者的,不写进任何一块板的配置。映射失效(筛选或维度被删、那块板被删)时面板上挂 warning,点一组改为打开追问菜单。
<DashboardWorkbench
engine={engine}
definitionId="overview"
onNavigate={to => router.push(routeFor(to))}
/>
<DataWorkbench
engine={engine}
definitionId={fromRoute.definitionId}
handOver={fromRoute}
onNavigate={to => router.push(routeFor(to))}
/>DashboardWorkbench、EmbeddedDashboard 与 EmbeddedView 收同一个属性。
嵌入一个视图或一块仪表盘
业务页面要摆出别人已经定好的观察,就嵌入它:只有结果——没有视图列表、没有条件编辑器、没有保存。嵌入一律不写:不存视图、不存板子、也不写偏好,读者在上面做的只影响这一次观看。定义视图、搭板子、保存都在工作台里;要让读者在页面上搭板子,就嵌 DashboardWorkbench。入口按资源分两个,与工作台的拆法一样:记录或分析视图用 EmbeddedView,仪表盘用 EmbeddedDashboard。各自只画自己那一种,给错了会直说。
import {
EmbeddedDashboard,
EmbeddedView,
} from '@ahoo-wang/wow-view-engine/ui';
// 订单页:这位客户最近的运单,照存下的样子读。
<EmbeddedView
engine={engine}
instanceId="orders-pending"
scopeFilter={{
op: 'and',
children: [{ field: 'customer', operator: 'IN', value: [customerId] }],
}}
/>
// 客户页:客户的那块板,锁定在这位客户上;时间归读者。
<EmbeddedDashboard
engine={engine}
instanceId="customer-board"
interaction="interactive"
filterModes={{ customer: 'locked' }}
pageValues={{
values: { customer: { items: [{ id: customerId, label: customerName }] } },
}}
initialFilters={readFromAddress()}
onFiltersChange={writeToAddress}
onNavigate={to => router.push(routeFor(to))}
/>读者能走多远是明确的一档:interaction,缺省 static。两档都不存任何东西:
| 档 | EmbeddedView(记录、分析) | EmbeddedDashboard |
| ------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| static | 结果与它是在什么条件下取来的;表头不能排序、不能拖宽、没有分页、哪儿也去不了 | 面板照画、什么都不回应:没有追问菜单、没有联动、没有「⋯」;每个筛选读作它的值,没有控件 |
| interactive | 表头排序、拖宽、翻页;分析的表格|图表切换、按一组追问;「在工作台中打开」;开了 expandable 时「铺满屏幕」 | 改筛选(搜索类的筛选也是)、追问菜单、交叉筛选、自定义目的地、「在工作台中打开」;开了 expandable 时「铺满屏幕」 |
离开嵌入的每一条路都经宿主的一个路由 onNavigate(to)——与仪表盘工作台交出的同一个 ViewNavigation;不给就一条也没有。
开关——关掉就是不存在,不是置灰。档位是上限,开关在档位之内:搜索、导出、记录详情、铺满屏幕是读者的控件,static 一档里开了也不起作用:
| 属性 | 缺省 | 做什么 |
| --------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| withTitle | 关 | 画出视图或仪表盘的标题 |
| headingLevel | 2 | 嵌入所标的标题级别:自己的标题在这一级,仪表盘的面板在它下一级(没有标题时就在这一级)。h1 归宿主页面 |
| withPanelTitles(仪表盘) | 开 | 关掉时面板标题只留给读屏 |
| withSearch(记录) | 关 | 视图的搜索框,定义声明了搜索字段才有;只在 interactive 一档 |
| withExport(记录) | 关 | 导出按钮与窗口,行可以勾选;只在 interactive 一档 |
| detail(记录) | 关 | 按一行打开这条记录的详情,读全、只读——没有行命令、什么也不写;true(有定义的绑定时按它的 reading 读),或与工作台 record.detail 同一份选项(open/onOpenChange、宿主的 sections)。在抽屉里打开时是叠在上面的第二层,Esc 只关它,焦点回到那一行。只在 interactive 一档 |
| withExport(仪表盘) | 关 | 面板「⋯」里的「导出数据…」,同一个导出窗口;只在 interactive 一档 |
| autoRefresh | 开 | 按作者存的间隔自己刷新;关掉就从不自己刷新 |
| withRefresh(仪表盘) | 关 | 首行的「更新于 10:32」——屏幕上的面板何时读的,取最早的那块——interactive 一档旁边还有刷新按钮(不带间隔菜单);刷新什么也不写 |
| caption(仪表盘) | 无 | 板子的口径说明(ReactNode,如「指标卡读 9 月 21 日(昨日),较前一日」):常态下不画——宿主在自己的标题旁画——铺满屏幕时画在标题下;铺满时 withTitle 关着也会用板子自己的标题补一行(D70) |
| openInWorkbench | 开 | interactive 一档里给不给「在工作台中打开」 |
| expandable | 关 | interactive 一档首行最后的「铺满屏幕」:与工作台一样就地铺开,Esc 收起 |
| size | content | content 按内容定高、有上限(记录表格与分析表格在 --fve-record-table-max-h 里滚);fill 填满容器——整页嵌入、大屏 |
仪表盘的筛选逐个三态(filterModes 按筛选名,时间粒度用 groupingMode):adjustable——在筛选条上、归读者,这一次看时可调,与工作台一样,也是缺省;locked——在筛选条上读作它的值,带一把锁、没有控件;hidden——不在筛选条上,照样收窄接上的面板。锁定与隐藏由 runtime 持有,读者做什么——改值、「清空」、点一组交叉筛选——都改不了它们。它们的值是页面自己的 pageValues(没写就是默认值):从第一次查询起就在,并跟着这个属性变——客户页换到下一位客户,板子跟着换。读者的筛选是宿主地址里的那一份:initialFilters 与 onFiltersChange,读法、报法与 DashboardWorkbench 相同。锁定与隐藏的值从不走地址:initialFilters 里写到它们的条目不算,onFiltersChange 只报读者能设的筛选——否则读者改一下地址就换了客户,与「锁定」正相反。板子不收条件树(EmbeddedDashboard 没有 scopeFilter):要收窄它,在板上声明那个筛选,再锁定或隐藏它。
记录面板上的宿主命令来自宿主给面板视图所在定义的绑定(ViewHost 上的 bind(definitionId, { actions, slots }),EmbeddedDashboard 与 DashboardWorkbench 都一样):记录工作台同一套声明的 actions——一条记录的两档都画,选中的只在能勾选的一档(interactive)——面板在行上方说执行的进度与结局;命令之后整块板重读。它们是宿主对自己服务的命令,嵌入照旧什么也不写。记录面板也说一共多少条,interactive 一档能翻页。
锁定不是安全边界。 页面锁定的条件是在浏览器里拼进查询的,只保证读者在界面上改不了、在这里看不到别的。改一下页面脚本、直接调接口,就能问到别的客户。租户、归属与权限必须由 Wow 后端强制——对外的页面尤其如此。本包是宿主进程里的库,不照搬 Metabase 的 iframe、签名令牌或 SSO:身份与权限属于宿主与后端。
铺满屏幕:开了 expandable,可交互的嵌入自己画这颗开关。想把它放在宿主自己的 chrome 里,或让一块 static 的大屏铺满,就传一个 ref,用 useViewExpansion 指向它。
主题:先选一条路
宿主已经在切明暗(next-themes、自己的
.dark开关)?在ViewHost上写colorMode="host"。不写时引擎也会写<html>的.dark,两边会互相覆盖。
引入 styles.css,再在 ViewHost 上做一个选择:
| 路 | 适合 | 写法 | 引擎做 |
| ---------- | ------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| 引擎跟宿主 | 已有 shadcn 主题(Tailwind v4)的宿主 | theme="host",并引入 shadcn-bridge.css | 把宿主的 shadcn 变量读进自己的;input、ring、状态色与图表色仍用本包的,为了对比度 |
| 宿主跟引擎 | 没有主题、或愿意穿引擎主题的宿主 | preset="porcelain"(引入 themes/porcelain.css),可加 brand="#1d4ed8" | 在 <html> 上点名预设与品牌色;宿主自己的外壳挂 fve-tokens,拿到同一套 shadcn 名 |
import '@ahoo-wang/wow-view-engine/styles.css';
// 引擎跟宿主:
import '@ahoo-wang/wow-view-engine/shadcn-bridge.css';
// ……或宿主跟引擎:
// import '@ahoo-wang/wow-view-engine/themes/porcelain.css';明暗归 ViewHost。 缺省的 colorMode="system" 在第一次绘制前写 <html> 的 .dark 与 color-scheme,跟随系统;light/dark 从钉住开始;读者经 useColorMode() 选的,按 rememberColorMode 记在本机;host 如上,跟宿主的 .dark。面上的 theme="light"、"dark"、"system" 仍可钉住某一个视图。宿主自己的暗色值写 --fve-dark-*,不写 .dark 下的 --fve-*:钉在浅色的视图会继承到它。
宿主自己的外壳挂 className="fve-tokens" 穿上主题——挂在用到它的外壳上,不挂 <body>:边界里有 preflight——宿主自己的每个弹层离开外壳到了 <body>,它的 portal 也挂上(<Menu.Portal className="fve-tokens">)。边界里,你自己的类按 shadcn 的名字读主题的 token(bg-card 映射到 var(--card));引擎的 fve: 工具类不公开(什么是公开的)。
其余的——逐个 --fve-* 覆盖与完整的 token 表、只给一块面的 tokens、逐套预设、品牌色的边界、角色与链接、密度、涨跌色、桥接的细则、覆盖变量要守的线、给 CI 用的 theme-check——都在文档站的视图引擎的主题。
工作台确实交出来的东西:一个值的读法
/ui 不把自己的 chrome 组件交出去,但它把读一个值的那套东西交出去了——改一个单元格,因此不必赔上整个工作台。renderCell 只盖住你在意的那一列,其余的交给 cellValue,照默认那样画:枚举取定义里的 option 标签、时间走这块面的时区与语言、数字按字段的 numberFormat:
import {
DataWorkbench,
cellValue,
useSurfaceDisplay,
useViewMessages,
} from '@ahoo-wang/wow-view-engine/ui';
import type { RecordCell } from '@ahoo-wang/wow-view-engine/ui';
function OrderCell({ cell }: { cell: RecordCell }) {
// 从这个单元格所在的那块面上读:正在生效的措辞,以及值所用的语言与时区。
const messages = useViewMessages();
const display = useSurfaceDisplay();
if (cell.column.field !== 'status') {
return cellValue(cell.value, cell.column, messages, display, 'table');
}
return <OrderStatusLamp status={String(cell.value)} />;
}
<DataWorkbench
engine={engine}
definitionId="orders"
// 关于记录视图的话——单元格怎么读、能不能勾选行、空结果说什么——是一个
// 对象,因为它们对分析视图都没有意义。
record={{
renderCell: cell => <OrderCell cell={cell} />,
selectable: false,
emptyTitle: '没有待发货的订单',
}}
/>;cellText 是同一套读法的单行文本版——写 CSV、复制选区、title 属性都用它;displayValue 只给字段种类自己的那一层,种类没话可说时返回 undefined,剩下的交给你自己的渲染。
记录详情:你的节,和地址里的那一条
按一行(或在行上按 Enter)会在侧边抽屉里把这条记录读全,按定义的字段分组排。record.detail 让你往里加东西、并由你来握住开着哪一条:
sections(context)按开着的记录返回你自己的节——每节一个id、一个title、一个render()——与引擎的节并排。placement放在'start'、'end'(缺省)或{ after: '<字段分组 id>' }。render只在这条记录上屏时才调用,所以节里的EmbeddedView在读者打开记录时才去读数据;每节是一个有名字的区域,各自包在一道渲染边界里('detail')。上下文带row(整条读到之前是页上的那一行,读到之后complete为真)、runtime与refresh,与行动作的上下文一样。节若按自己的读法画定义里的某个字段,就在fields里点名(fields: ['state.error.stackTrace']):引擎的分组不再列它,记录里不会读两遍,被拿空的分组不画。render(context)换成你自己的读法:它返回的就是整个正文,替掉定义的分组与你的sections——适合一条记录只回答一个问题(为什么失败、再试有没有用),按自己的版式说比按字段排清楚的时候。面板仍是引擎的:从行上立即打开、读全这条、说它不在/被拒/读不到、头部的行命令、焦点与返回。上下文与渲染边界同sections。title(row)在头部用你的名字代替键作标题,键移到名字上方。open/onOpenChange握住开着的是哪一条,与instanceId/onInstanceChange握住开着的视图同一个做法——ViewHost带了router时,绑定了的资源的详情已经记在地址的?id=里,这一对留给记在别处的宿主:不传open由工作台自己管;传一个键(或null),每个值都打开它说的那一条——不在当前页上的也行,单独读(runtime.fetchRecord,只叠注入的作用域),有自己的「正在读」「已不在」「没有权限」(HTTP 401/403)与「读不到、可重试」几种状态。按一行、关掉抽屉都只是经onOpenChange请求,两种模式下它都会被告知。
<DataWorkbench
engine={engine}
definitionId="failures"
record={{
detail: {
// `?id=` 打开那一条,打开或关掉一条都写回地址。
open: params.get('id'),
onOpenChange: key => setId(key === null ? null : String(key)),
sections: ({ row }) => [
{
id: 'retry',
title: '执行上下文',
placement: 'start',
render: () => <RetryForm id={String(row.key)} />,
},
{
id: 'history',
title: '执行历史',
render: () => (
<EmbeddedView
engine={engine}
instanceId="execution-history"
interaction="interactive"
scopeFilter={{
op: 'and',
children: [
{ field: 'aggregateId', operator: 'EQ', value: row.key },
],
}}
/>
),
},
],
},
}}
/>面不嵌套。 根套根不受支持:CSS 没有「最近祖先」选择器,内层面把 token 重新声明在自己身上,dark: 工具类认的却仍是外层那个根——钉成相反模式时就是浅色 token 配深色 utility,屏幕上画不出来。需要第二层的时候,戴 fve-tokens,不要再套一块面。
3b. 或者自行组合 UI
import { isRecordRuntime } from '@ahoo-wang/wow-view-engine';
import type { RecordViewRuntime } from '@ahoo-wang/wow-view-engine';
import {
useOpenView,
useViewRuntime,
useFilterEditor,
useRecordTable,
} from '@ahoo-wang/wow-view-engine/react';
export function OrdersPage({ instanceId }: { instanceId: string }) {
const { runtime, loading } = useOpenView(engine, instanceId);
if (!runtime) return loading ? <Spinner /> : <NotFound />;
// 这个 id 也可能是分析视图或仪表盘;本页只画记录。
if (runtime.kind !== 'record' || !isRecordRuntime(runtime))
return <NotFound />;
return <OrdersView runtime={runtime} />;
}
function OrdersView({ runtime }: { runtime: RecordViewRuntime }) {
const state = useViewRuntime(runtime); // draft、applied、result、issues、dirty
const filter = useFilterEditor(runtime); // 节点增删改、提交
const table = useRecordTable(runtime); // 列、排序、选择、分页
// 用这些控制器渲染任意布局,无需触及引擎内部。
return <OrdersLayout state={state} filter={filter} table={table} />;
}只用内核,不用 React
import {
builtinFieldKinds,
compileRecord,
projectRecord,
validateRecord,
} from '@ahoo-wang/wow-view-engine';
// 内核只接受数据定义;`orders` 就是一个。
if (orders.kind !== 'data') throw new Error('orders is a data definition');
const issues = validateRecord(orders, config, builtinFieldKinds);
if (issues.some(i => i.severity === 'error')) throw new Error('配置无效');
const query = compileRecord(
orders,
config,
builtinFieldKinds,
{ now: new Date(), timeZone: 'Asia/Shanghai' },
{ index: 1 }, // Wow 页码从 1 开始
);
const page = await source.paged(query);
const view = projectRecord(orders, config, page);测试宿主:内存数据源
宿主自己的测试——打开一个视图或一块板的页面、没有后端的演示——需要一个按引擎实际发出的查询作答的数据源。@ahoo-wang/wow-view-engine/testing 提供了它:memorySource(documents, options?) 是一个 ViewSource,对内存里的 JSON 文档像 MongoDB 上的 Wow 服务那样筛选、排序、分页、投影与聚合。它的答案由本包的测试对照服务端自己的语义——wow-tck 的 FilterSemantics 语义矩阵与查询 TCK 的聚合用例——逐条守着,所以测试看到的是引擎的查询按生产环境的方式得到的回答,而不是一份把被筛掉的行也显示出来的罐头结果。
import { FilterOperator } from '@ahoo-wang/wow-client';
import { MemoryViewStore, ViewEngine } from '@ahoo-wang/wow-view-engine';
import { matches, memorySource } from '@ahoo-wang/wow-view-engine/testing';
// 服务返回的快照:信封字段、`state`、以纪元毫秒存的时间。
const documents = [
{
aggregateId: 'O-1',
eventTime: 1_790_000_000_000,
state: { status: 'PAID', total: 120 },
},
{
aggregateId: 'O-2',
eventTime: 1_790_000_060_000,
state: { status: 'CANCELLED', total: 80 },
},
];
const source = memorySource(documents, {
// BEFORE_NOW / AFTER_NOW 比较的钟:与页面的钟钉在同一时刻。
now: () => Date.parse('2026-09-27T00:00:00Z'),
});
const engine = new ViewEngine({
resources: [{ definition: orders, source }],
store: new MemoryViewStore(),
});
// 测试自己的条件,按同样的读法问一条文档。
const paid = matches(documents[0], {
op: FilterOperator.EQ,
field: 'state.status',
value: 'PAID',
});它承诺的:引擎会编出的每个筛选算子,缺失字段、显式 null、空字符串或空数组、数组元素与大小写都按 MongoDB 的读法;DELETION 读 deleted 标记(deleted: true 的文档除非筛选问到,否则不答);分页、游标(一个偏移量)与排序;投影;聚合——elements(路径与门槛条件都相对于元素)、TERMS、HISTOGRAM、按时区的 DATE_HISTOGRAM 与 DATE_PART(缺省 UTC)、dense、带自身条件的每种指标、DERIVED、having、Wow 给分组的次序(先按排序,再按每个分组别名升序)与缺省 limit 100。PERCENTILE 是精确值,服务端是落在同样两个秩之间的估计值。没有读法的——ID、TENANT_ID、SPACE_ID、引擎从不发出的日历筛选——直接报错,测试开始发出的新查询会失败,而不是得到一个看似合理的错误答案。timeField 让大数据集按一个纪元毫秒列排好、对它的范围二分切片;remember 让同一个聚合从记忆里作答,只用于从不改变的文档。这个入口是无头的——没有 React、DOM 与样式表。它用 mingo(MongoDB 查询语言的 JavaScript 实现)求值筛选,mingo 是可选的对等依赖:安装本包不会带上它,导入 /testing 的宿主要自己把它加进开发依赖——pnpm add -D mingo(或 npm install -D mingo)。别的入口都不加载它。
admit(definitions, descriptors, { text }) 按引擎的方式准入宿主声明的一切——说出每份定义的键、它自己的规则、它的看板对照其余定义、每份数据定义按提交的描述符(按 source)收窄——返回每条发现连同它所属的定义,全部成立时是 []。它收定义,也收与注册时写法一样的资源——完整的写法是 { definition, source }。列表里有资源带着 source 时,source 键下没有哪份资源登记数据源的数据资源也会被报出(definition.source.unregistered),与引擎启动时一样;只有 { definition } 的列表不查数据源:
import { admit } from '@ahoo-wang/wow-view-engine/testing';
expect(
admit([orders, overview], { orders: ordersDescriptor }, { text: chinese }),
).toEqual([]);
// The resources the application registers, sources and all.
expect(
admit(
[{ definition: orders, source: ordersSource }, { definition: overview }],
{ orders: ordersDescriptor },
{ text: chinese },
),
).toEqual([]);actionHarness(actions, rows, { now }) 按引擎自己的规则读宿主声明的操作,不渲染:每项出现在哪(at)、某条记录能不能做、为什么不能(state)、一组选中怎么分(bulk)、按下会问什么(asks)、它的表单或选择(form、choice、missing)、记录什么时候自己翻转(changesAt),以及照引擎那样发出的 run——记录不接时带着理由拒绝(ActionRefusedError):
import { actionHarness } from '@ahoo-wang/wow-view-engine/testing';
const orders = actionHarness(orderActions, rows, {
now: Date.parse('2026-09-28'),
});
expect(orders.state('ship', 'O-2').reason).toBe(text('orders.onHold'));
expect(orders.bulk('ship').able).toEqual(['O-1']);
expect(orders.asks('cancel', 'row').asks).toBe(true);概念
| 类型 | 职责 | 所在 |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| ViewDefinition | 字段、类型、操作符、记录与分析能力。由代码声明或生成,运行时不可编辑。 | 代码 |
| ViewConfig | RecordViewConfig / AnalysisViewConfig / DashboardViewConfig 判别联合,共享的 FilterTree 描述数据范围。它是意图模型:保存"最近 7 天"这样的语义而不是编译结果,也不含任何 UI 组件名。Analysis 覆盖 Wow 聚合协议全部能力;Dashboard 面板分数据面板(引用已保存的视图,或板子自己的分析)与标题、Markdown 文字、图片、链接四种内容面板。 | 数据 |
| ViewInstance | 一份保存的 ViewConfig,加 id、标题、范围与不透明 revision。范围为系统、共享或个人。 | 存储 |
| ViewRuntime | 一个打开的视图:草稿、已应用配置、结果、状态、选择。提供 subscribe / getSnapshot。 | 内存 |
| ViewEngine | 定义、存储与已打开运行时的注册表;打开、保存、列表等命令的入口。 | 内存 |
| ViewStore | 八个方法(外加可选的 changeAudience)的持久化端口。Wow 服务端用 @ahoo-wang/wow-view-store 的 WowViewStore;别的后端自己实现。 | 应用 |
| FieldKind | 一种字段类型的操作符、校验、编译与编辑器描述。 | 注册表 |
有三个名字各有两种读法,这里说清哪个是哪个:
*Spec要么是宿主提供的,要么是视图存下的。DefineViewSpec、FieldSpec、FieldAnalysisSpec与AnalysisSpec是宿主交给defineView的输入——它提供的字段、操作符与分析;ChartSpec、RecordTableSpec、AnalysisTableSpec与各图型的*Spec是存下的ViewConfig的部件。RecordProjection/AnalysisProjection是projectRecord/projectAnalysis从结果投影出来的东西:界面画的列、行与系列。「视图」指保存的ViewInstance;/ui的RecordViewProps是工作台的record属性,不是投影的。kinds在ViewEngineOptions与ViewEngine上是字段类型注册表;DataWorkbench的viewKinds是它列出哪几种视图('record'、'analysis')。
视图管理
- 生命周期。 新建、保存、另存、改名、删除全部是
ViewEngine命令,默认 UI 与自定义组合走同一路径。保存的只有配置,不含选择、页码与结果。 - 三种范围。
system由开发或运维配置,是定义的基础视图与常用视图,所有用户可见、只读、可另存,可在definition.views中用代码声明,也可由服务端返回;shared由有许可的业务用户创建并对同定义用户可见;personal仅本人可见。 - 两个问题,一个值。 三种范围是"给谁看"与"是不是用户配置的"两件事的合法组合:系统视图一定是共享视图,"个人的系统视图"写都写不出来。
audienceOf(scope)回答前者,isSystemScope(scope)回答后者,从 Dashboard 的引用约束到侧栏分组都经由它们提问;用户创建时给的是ViewAudience而不是范围。 - 侧栏。 视图按受众分组——个人在前、共享在后,系统视图落在共享组里并带
system标签——每项以种类图标作前缀,因为一个 data 定义同时承载记录与分析视图;标题取自定义本身。ViewInstanceSummary为此带上kind:它是配置判别标签的投影,store 回答list时从自己存的配置里读出来。 - 许可。
store.permissions()同步提供许可,只决定按钮可用性,服务端才是权威。无权修改的共享视图与系统视图都可另存到个人范围。 - 列表与偏好。 列表、偏好、许可独立加载,互不阻塞;个人排序与默认视图存于
ViewPreferences,删除实例不改写偏好。 - 冲突与未知结果。 版本冲突时二选一:重新加载或覆盖,另加"另存";请求已发出但结果未知时可用同一
requestId重试,草稿始终保留。 - 离开保护。 有未保存草稿或未知写入的视图关闭前确认;导航不取消在途写入。
细节见 docs/design/management.md。
入口
| 入口 | 导出 |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| @ahoo-wang/wow-view-engine | 模型类型与常量;四个纯内核(validate* / compile* / project* 及其旁边的读法);运行时只导出宿主要握的,不导出它由什么搭成——ViewEngine、validateDefinition、运行时合同 ViewRuntime、RecordViewRuntime、DashboardRuntime、AnyViewRuntime 连同它们签名里出现的每一个类型、hasResult、hasAsked、isRecordRuntime、actions 连同声明式操作的类型、写入错误 ViewWriteError 与 ViewCommandError、ExportCancelledError、RuntimeEnvironment、defaultRuntimeEnvironment、ViewSource、OptionSource;ViewStore 端口、MemoryViewStore 与 localStorageSnapshot |
| /react | 钩子与无样式控制器,连同它们交出的类型:useViewEngine、useOpenView、useViewRuntime、useViewList、useViewManager、useWorkbench、useLeaveGuard、useFilterEditor、useRecordTable、useAnalysisEditor、useAnalysisResult、useDashboard、useSaveCommands、useRecordActions(一个记录面上的声明式操作:每条记录与选中的能做什么、等读者回答的确认或表单、唯一的执行器)、RecordActionSlots,以及保存命令报告的两个写入结局类型(SettledWrite、RecoveredWrite) |
| /ui | 默认组件、视图与工作台,连同它们的 props:DataWorkbench、DashboardWorkbench、DashboardEditExtensions、useDashboardExtensions、EmbeddedView、EmbeddedDashboard、ViewHost、bind、useEngine、useViewNavigation、useColorMode、ViewHeader、SaveActions、ViewManager、LeaveDialog、EditorBand、FilterPanel、StatusStrip、AppliedBar、ResultToolbar、RowActions、RecordTable、RecordCards、RecordPagination、AnalysisTable、AnalysisChart、DashboardGrid、HeadingPanel、MarkdownPanel、ImagePanel、LinksPanel、MessagesProvider;措辞目录 en 与 zhCN;一个值的读法 cellValue、cellText、displayValue |
| /testing | memorySource 与 matches:带 Wow 查询语义的内存 ViewSource;resolveNavigation:引擎自己按宿主绑定解析离开的路,供宿主测路由;admit:按提交的描述符准入宿主的声明;actionHarness:按引擎的规则读宿主声明的操作,不画界面——供宿主测试使用(测试宿主) |
| /react-router | useReactRouter:把 React Router 接成视图宿主的路由端口。React Router 是可选 peer,只有这个入口加载它 |
| /styles.css | 主题。显式导入;任何 JS 入口都不会引入 CSS,产物也不会在 .fve-root/.fve-tokens 两个样式边界之外绘制任何东西(preflight 与 base 在构建时收进边界内、不加权重,工具类一律带 fve: 前缀、与宿主的不同名,所以宿主的导入次序无关)——预设的复位规则除外,它只在挂了预设的元素上清空 --fvp-* 层——scripts/verify-package.mjs 在每次构建时逐条核对。 |
| /themes.css | 预设,可选:只有按 data-fve-preset 选中的 --fvp-* 赋值(预设),由同一个脚本核对。 |
| /themes/<名>.css | 单独一套预设,给只用一套的宿主:就是 themes.css 里它那一块(预设)。 |
| /shadcn-bridge.css | 可选:把宿主的 shadcn token 读进 --fvp-* 变量,input、ring、状态色、图表色与阴影除外,且只在没挂预设时生效(桥接),由同一个脚本核对。 |
这就是公开面,而且逐个名字守着。每个入口把它导出的名字逐个写出,按声明它的文件分组,不整模块转出(test/architecture.test.ts),所以文件为包内邻居写的 export 不会意外变成公开的。每个代码入口的完整清单——每一个名字,以及它是类型还是值——在 test/surface/(root.txt、react.txt、ui.txt、testing.txt、react-router.txt):入口多导出了清单上没有的名字、或不再导出清单上有的名字,test/publicSurface.test.ts 就失败;scripts/verify-package.mjs 再拿同一份清单核对构建出的每个 JS 入口。往清单里加一个名字或拿掉一个,就是改公开面,按改公开面来审。命令也是公开面:test/surface/bin.txt 列出 bin(wow-view-engine)与它认的每个子命令(theme-check)。
运行时自己的部件不导出:请求调度器、两种运行时共用的那个 store、刷新计时器、监听者集合、运行时的类与它们的构造函数。运行时经 ViewEngine 打开或新建、按合同持有,从不手搭;/react 与 /ui 在包内直接取这些部件,不经入口。
持久化
后端只需满足 ViewStore 这一个端口:
interface ViewStore {
list(
definitionId: string,
signal?: AbortSignal,
): Promise<ViewInstanceSummary[]>;
get(id: string, signal?: AbortSignal): Promise<ViewInstance>;
create(
input: Omit<ViewInstance, 'id' | 'revision'>,
ctx: WriteContext,
): Promise<ViewInstance>;
save(
id: string,
config: ViewConfig,
revision: string,
ctx: WriteContext,
): Promise<ViewInstance>;
rename(
id: string,
title: string,
revision: string,
ctx: WriteContext,
): Promise<ViewInstance>;
delete(id: string, revision: string, ctx: WriteContext): Promise<void>;
// 可选:没有它,视图管理里就没有「设为共享/设为个人」。
changeAudience?(
id: string,
audience: ViewAudience,
revision: string,
ctx: WriteContext,
): Promise<ViewInstance>;
getPreferences(
definitionId: string,
signal?: AbortSignal,
): Promise<ViewPreferences>;
setPreferences(
definitionId: string,
prefs: ViewPreferences,
ctx: WriteContext,
): Promise<ViewPreferences>;
permissions?(definitionId: string): ViewPermissions;
}两条规则保证一致性:
- 乐观 revision。 写入携带期望
revision,不匹配时抛出 code 为CONFLICT的ViewStoreError,UI 提供"重新加载后覆盖"或"另存"。 - 幂等
requestId。 每个逻辑写入在WriteContext中携带一次requestId,超时后的重试复用它,服务端去重。
changeAudience 把已保存的视图就地在个人与共享之间移动——id 不变,显示它的仪表盘照常显示。它守同样两条规则;要求改成视图已有的受众时原样答回、不花 revision;共享仪表盘还在显示它时拒绝改成个人(INVALID,boards 带上那些仪表盘的标题,由引擎用自己的话说出来)。没有这个方法的 store,视图管理里就没有这颗按钮;有的,每一行按 store 的 permissions 给「设为共享」或「设为个人」——instance(id).changeAudience(缺省读作允许)加上去往那个受众的创建许可。
实现 store 时可以跑端口一致性测试 test/conformance/viewStoreConformance.ts(在本包的仓库里,不随包发布):describeViewStoreConformance({ name, capabilities, connect }) 登记每个 store 都要通过的用例——列表、可见性、各种写入、过期 revision、系统视图、重放、偏好——声明不具备的能力对应的用例跳过。
本包提供 MemoryViewStore,用于测试、示例与只查询不持久化的场景。Wow 应用把视图存在 Wow 的视图存储服务端上,用 @ahoo-wang/wow-view-store 的 WowViewStore;不要为 Wow 后端自己写 store。别的后端用自己的 fetcher 针对自己的 API 实现 ViewStore,HTTP 状态码到 ViewStoreError.code 的映射在那里完成。授权、可见性过滤与去重是服务端职责,permissions 只决定按钮可用性。
本地存储:在后端接手之前
开发与单用户宿主可以用 localStorageSnapshot(key) 把 MemoryViewStore 存进浏览器的 localStorage,整份作为一个 JSON 文档放在 key 下。它只是这一个浏览器的视图,不是共享的;大家共享的视图存在服务端、ViewStore 背后——Wow 服务端上就是 WowViewStore。
import {
localStorageSnapshot,
MemoryViewStore,
} from '@ahoo-wang/wow-view-engine';
const store = new MemoryViewStore({
snapshot: localStorageSnapshot('my-app:views'),
});- 存储拒收的写入就是失败。 配额满或存储被禁用时,写入在内存中撤回,并以 code 为
UNAVAILABLE的ViewStoreError拒绝:环境的onError收到一次store失败,界面显示这次保存没有落地并可重试。 - 标签页之间不互相覆盖。 每次写入前 store 重读文档,按实例、按每个定义的偏好核对这次写入的
revision:写入合并进另一个标签页存下的内容——一个标签页保存的看板,不会被另一个标签页的排序抹掉;另一个标签页已经越过的写入是CONFLICT,与任何过期写入一样。另一个标签页的改动也会经storage事件让 store 重新载入。 - 文档缺失或读不懂时从空开始,页面不会因此出错,下一次写入会替换它。
措辞与语言
模型只带 code 与 params,措辞归 /ui。en(英文目录)给每个 issue 一句英文,ViewSurface 与每个工作台的 messages 按 key 合并在已生效的措辞之上——改写与本地化是同一个入口;在应用外层放一个 ViewHost(或单独的 MessagesProvider),就能对其中所有视图一次设定,连同值显示的语言(locale)。包里另带一份逐键对应的简体中文 zhCN:整份交给 messages 即可,要改其中几句就铺开再覆盖({ ...zhCN, 'label.filter.apply': '确定' })。
值按字段显示:枚举显示选项的标签,datetime/date 经 Intl.DateTimeFormat 格式化,日期直方图的键显示为它起始的年、季度、月或日。表格单元格里的 datetime 写得短——到分,今年之内不写年(09-17 21:19,英文 Sep 17, 9:19 PM),整份时刻在单元格的 title 与记录详情里;秒要紧的字段(事件流的时间)声明 `timePrecision: 'second'
