Skip to content

完整 View Engine 示例

运行与验证

仓库开发需要 Node >=20.20.2 和 pnpm 10.34.5:

bash
pnpm install
pnpm --filter @ahoo-wang/fetcher-view-engine... build
pnpm storybook

打开 View Engine → 入门与业务流程 → 最小接入。展示页面保持初始状态,由你手动操作;断言放在独立回归故事中:

bash
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/。重置恢复业务记录;视图持久化单独演示,不保存业务订单。

bash
VIEW_ENGINE_BROWSER_CHANNEL=chrome pnpm exec vitest run --project=storybook stories/view-engine/orders

完整共享组件

以下源码就是 Storybook Minimal 故事使用的组件,仅导入公开包入口。本地数据源只支持定义中声明的金额下限、AND 和普通分页;更多操作符应由业务 QueryApi 实现。示例返回完整记录,没有提供视图保存服务,因此保存不可用。后续接入见保存视图指南

tsx
/*
 * 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 应用

在包发布之前,先构建并验证本地归档:

bash
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 契约

基于 Apache License 2.0 发布。