完整 View Engine 示例
运行与验证
仓库开发需要 Node >=20.20.2 和 pnpm 10.34.5:
pnpm install
pnpm --filter @ahoo-wang/fetcher-view-engine... build
pnpm storybook打开 View Engine → 入门与业务流程 → 最小接入。展示页面保持初始状态,由你手动操作;断言放在独立回归故事中:
pnpm exec vitest run --project=storybook stories/view-engine/QuickStart.test.stories.tsx先用 pnpm exec playwright install chromium 安装 Chromium,或设置 VIEW_ENGINE_BROWSER_CHANNEL=chrome 使用已安装的 Chrome。完整业务与深色窄容器回归位于 stories/view-engine/orders。
| 操作 | 预期结果 |
|---|---|
| 打开页面 | 显示 SO-202609-1001、SO-202609-1002,共 3 条记录 |
| 下一页 | 显示 SO-202609-1003 |
| 金额改为 10000,暂不查询 | 当前结果保持不变 |
| 按 Enter | 只显示 SO-202609-1001 |
| 清空已应用金额条件值 | 恢复全部 3 条记录的查询范围,筛选控件保留 |
| 金额升序 | 第一页显示 SO-202609-1002、SO-202609-1003 |
销售订单全链路
从完整订单工作台开始。创建两台显示器、合计 2,400 元的订单,由销售提交、主管审核、财务收款,再放行、备货、发货和签收;登记开票、核对结算后关闭。订单详情会指出下一步动作和责任岗位,可直接交接并保留当前订单。售后订单直到关闭才离开队列;刷新失败时可在详情内重试,不重复执行业务写入。
章节分别覆盖预付与账期放行、分批发货、拒收重发、退货、退款和开票冲减,共用 18 笔一致的订单样本,每个故事独立初始化。查询和视图设置使用公开引擎;业务表单、校验和写入放在 packages/view-engine/examples/react/sales-order/。重置恢复业务记录;视图持久化单独演示,不保存业务订单。
VIEW_ENGINE_BROWSER_CHANNEL=chrome pnpm exec vitest run --project=storybook stories/view-engine/orders完整共享组件
以下源码就是 Storybook Minimal 故事使用的组件,仅导入公开包入口。本地数据源只支持定义中声明的金额下限、AND 和普通分页;更多操作符应由业务 QueryApi 实现。示例返回完整记录,没有提供视图保存服务,因此保存不可用。后续接入见保存视图指南。
/*
* Copyright [2021-present] [ahoo wang <ahoowang@qq.com> (https://github.com/Ahoo-Wang)].
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ExampleViewPage } from '../../packages/view-engine/examples/react/ExampleViewPage.js';
import { IndexedDBViewHost } from '@ahoo-wang/fetcher-view-engine/react';
import {
createFilterConfiguration,
resolveRecordPresentation,
newFilterNode,
type RecordData,
type RecordQuerySource,
type RecordViewDefinition,
type ViewHost,
type ViewInstanceList,
} from '@ahoo-wang/fetcher-view-engine';
import { useState } from 'react';
import type { RecordCardRenderContext } from '@ahoo-wang/fetcher-view-engine/react';
import type { ReactNode } from 'react';
import {
FilterOperator,
SortDirection,
type FilterExpression,
type PagedList,
type PagedQueryRequest,
} from '@ahoo-wang/fetcher-wow';
import '@ahoo-wang/fetcher-view-engine/styles.css';
const orders = [
{ id: 'SO-202609-1001', amount: 12000, status: 'confirmed' },
{ id: 'SO-202609-1002', amount: 6000, status: 'confirmed' },
{ id: 'SO-202609-1003', amount: 8000, status: 'confirmed' },
];
const definition: RecordViewDefinition = {
id: 'first-orders',
sourceId: 'orders',
title: '第一个数据视图',
record: {
allowedLayouts: ['table', 'card'],
rowKey: 'id',
defaultPresentation: {
card: {
title: { id: 'title', field: 'id' },
fields: [
{ id: 'amount', field: 'amount' },
{ id: 'status', field: 'status' },
],
},
},
},
allowedOperators: [
FilterOperator.MATCH_ALL,
FilterOperator.AND,
FilterOperator.GTE,
],
fields: [
{
field: 'id',
label: '订单编号',
type: 'string',
sortable: true,
operators: [],
cellRenderer: { name: 'text', options: { copyable: true } },
},
{
field: 'amount',
label: '金额',
type: 'number',
sortable: true,
operators: [FilterOperator.GTE],
summaryFunctions: [],
numberFormat: { style: 'currency', currency: 'CNY' },
cellRenderer: { name: 'number' },
},
{
field: 'status',
label: '状态',
type: 'string',
operators: [],
options: [
{ value: 'draft', label: '草稿' },
{ value: 'confirmed', label: '已确认' },
],
cellRenderer: {
name: 'status',
options: { tones: [{ value: 'confirmed', tone: 'success' }] },
},
},
],
};
const instances: ViewInstanceList = {
defaultInstanceId: 'my-orders',
instances: [
{
id: 'my-orders',
definitionId: definition.id,
title: '我的订单',
kind: 'record',
scope: { type: 'personal' },
revision: '1',
config: {
filters: createFilterConfiguration({
...newFilterNode(FilterOperator.GTE, 'amount'),
props: { value: 0 },
}),
sort: [{ field: 'id', direction: SortDirection.ASC }],
pagination: { mode: 'paged', size: 2 },
presentation: {
layout: 'table',
table: {
columns: [
{ id: 'id', kind: 'field', field: 'id', width: 200 },
{ id: 'amount', kind: 'field', field: 'amount', width: 160 },
{ id: 'status', kind: 'field', field: 'status', width: 140 },
],
},
},
},
},
],
};
// ponytail: local amount/AND demo only; use a business QueryApi for other predicates.
function matches(amount: number, expression: FilterExpression): boolean {
if (expression.op === FilterOperator.MATCH_ALL) return true;
if (expression.op === FilterOperator.AND)
return expression.operands.every(item => matches(amount, item));
if (
expression.op === FilterOperator.GTE &&
expression.field === 'amount' &&
typeof expression.value === 'number'
)
return amount >= expression.value;
throw new Error('示例只支持金额下限和 AND 条件');
}
const source: RecordQuerySource = {
async paged<T extends Partial<RecordData> = RecordData>(
query: PagedQueryRequest,
_attributes?: Record<string, unknown>,
controller?: AbortController,
): Promise<PagedList<T>> {
controller?.signal.throwIfAborted();
if (!('filter' in query)) throw new Error('示例使用 FilterExpression 查询');
const rows = orders.filter(order => matches(order.amount, query.filter));
for (const sort of [...(query.sort ?? [])].reverse()) {
if (sort.field !== 'id' && sort.field !== 'amount')
throw new Error('示例只支持按编号或金额排序');
const direction = sort.direction === SortDirection.ASC ? 1 : -1;
rows.sort(
(left, right) =>
direction *
(sort.field === 'amount'
? left.amount - right.amount
: left.id.localeCompare(right.id)),
);
}
const { index = 1, size = 2 } = query.pagination ?? {};
// This source always returns whole rows; QueryApi also permits projected rows.
return {
list: rows.slice((index - 1) * size, index * size) as unknown as T[],
total: rows.length,
};
},
};
const host: ViewHost = {
resolveSource(id) {
if (id !== definition.sourceId) throw new Error('未知数据源');
return source;
},
};
type RecordViewExampleProps = {
appearance?: 'light' | 'dark';
layout?: 'table' | 'card';
renderCard?(context: RecordCardRenderContext): ReactNode;
persistViews?: boolean;
};
export function RecordViewExample(props: RecordViewExampleProps) {
return (
<RecordViewWorkspace key={String(props.persistViews ?? false)} {...props} />
);
}
function RecordViewWorkspace({
appearance = 'light',
layout = 'table',
renderCard,
persistViews = false,
}: RecordViewExampleProps) {
const [viewHost] = useState(() =>
persistViews
? new IndexedDBViewHost({
scopeKey: 'card-example-user',
serviceKey: 'card-example',
definition,
instances: {
...instances,
instances: instances.instances.map(instance => ({
...instance,
config: {
...instance.config,
presentation: resolveRecordPresentation(
definition,
layout,
instance.config.presentation,
),
},
})),
},
resolveSource: host.resolveSource,
})
: host,
);
return (
<div
className="fve-root"
data-theme={appearance}
style={{ padding: 16, minWidth: 0 }}
>
<ExampleViewPage
scopeKey="docs:orders"
definitionId={definition.id}
definition={definition}
instances={
persistViews
? undefined
: layout === 'table'
? instances
: {
...instances,
instances: instances.instances.map(instance => ({
...instance,
config: {
...instance.config,
presentation: resolveRecordPresentation(
definition,
layout,
instance.config.presentation,
),
},
})),
}
}
host={viewHost}
record={{ selectable: true, renderCard }}
initialSidebarCollapsed
/>
</div>
);
}接入独立 React 应用
在包发布之前,先构建并验证本地归档:
pnpm --filter @ahoo-wang/fetcher-view-engine... build
node packages/view-engine/scripts/verify-package.mjs
pnpm --filter @ahoo-wang/fetcher-view-engine pack --pack-destination /tmp/view-engine-pack打包命令会输出归档文件名。在 React 19 TypeScript 应用中,使用 pnpm add 安装该 .tgz 的绝对路径,满足包声明的 peer 依赖,复制共享组件并渲染 <RecordViewExample />。如果环境中的内部依赖版本尚未发布,也需要打包相应工作区依赖;包验证脚本会联合验证工作区构建产物,不会执行发布。
这验证的是浏览器界面和本地记录数据源。鉴权、视图持久化和后端查询行为仍由应用接入负责,参阅 ViewHost 契约。
