Skip to content

本地 Viewer 示例

维护期(已弃用)

@ahoo-wang/fetcher-viewer 已进入维护期(弃用),仅维护现有功能,不再新增功能。数据视图能力的后续演进由 @ahoo-wang/fetcher-view-engine 承担,新项目请使用 View Engine。本页保留供存量项目维护参考;两者模型与 API 不同,迁移需要适配。

这个浏览器示例使用四个用户,无需后端。应用先对完整数据集过滤、排序,再截取请求页。Viewer 接收计算后的 { list, total };它不会替你转换传入的行数据。

在自己的应用中运行

使用 Vite 脚手架支持的当前 Node 版本(Node 22.12+ 可作为基线)。Fetcher 库声明 Node >=18.20.8,这不代表当前 Vite 工具能在 Node 18 上运行。本示例使用 React 19 和 Ant Design 6。

bash
pnpm create vite local-viewer --template react-ts
cd local-viewer
pnpm install
pnpm add @ahoo-wang/fetcher@5.0.0 @ahoo-wang/fetcher-viewer@5.0.0 \
  @ahoo-wang/fetcher-react@5.0.0 @ahoo-wang/fetcher-wow@5.0.0 \
  @ahoo-wang/fetcher-decorator@5.0.0 @ahoo-wang/fetcher-eventstream@5.0.0 \
  @ahoo-wang/fetcher-eventbus@5.0.0 @ahoo-wang/fetcher-storage@5.0.0 \
  @ahoo-wang/fetcher-openapi@5.0.0 @ahoo-wang/fetcher-cosec@5.0.0 \
  react@^19.2.8 react-dom@^19.2.8 antd@^6.6.3 \
  @ant-design/icons@^6.3.4 dayjs@^1.11.23

上述命令显式包含 Viewer 声明的完整 peer 依赖图,包括 fetcher-react 间接要求的 CoSec。安装这些包不意味着本地示例需要 Wow 或 CoSec 服务。immerdequalreflect-metadata 等直接依赖由包管理器传递安装。不要将仓库中的 workspace:catalog: 写法复制到消费者项目。

创建 src/LocalViewer.tsx,复制下列完整文件。数据、定义、已保存视图和应用组件都在其中,无需 Storybook fixture。

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 { useCallback, useState } from 'react';
import { App } from 'antd';
import { FullscreenProvider } from '@ahoo-wang/fetcher-react';
import { Viewer } from '@ahoo-wang/fetcher-viewer';
import type { ViewDefinition, ViewState } from '@ahoo-wang/fetcher-viewer';
import { all, Operator, SortDirection } from '@ahoo-wang/fetcher-wow';
import type { Condition, FieldSort, PagedList } from '@ahoo-wang/fetcher-wow';

const users = [
  { id: 'u-ada', name: 'Ada', active: true },
  { id: 'u-lin', name: 'Lin', active: false },
  { id: 'u-grace', name: 'Grace', active: true },
  { id: 'u-zoe', name: 'Zoe', active: false },
];
type User = (typeof users)[number];

const definition: ViewDefinition = {
  id: 'local-users',
  name: 'Local users',
  fields: [
    { name: 'id', label: 'ID', type: 'text', primaryKey: true },
    {
      name: 'name',
      label: 'Name',
      type: 'text',
      primaryKey: false,
      sorter: true,
    },
    {
      name: 'active',
      label: 'Active',
      type: 'text',
      primaryKey: false,
      render: value => (value ? 'Yes' : 'No'),
    },
  ],
  availableFilters: [
    {
      label: 'User',
      filters: [
        {
          key: 'active',
          field: { name: 'active', label: 'Active' },
          component: 'bool',
        },
      ],
    },
  ],
  // Required metadata; this local application does not fetch these URLs.
  dataUrl: '/local-users/paged',
  countUrl: '/local-users/count',
};
const initialView: ViewState = {
  id: 'all-users',
  name: 'All users',
  definitionId: definition.id,
  type: 'PERSONAL',
  source: 'SYSTEM',
  isDefault: true,
  filters: [
    { key: 'active', type: 'bool', field: { name: 'active', label: 'Active' } },
  ],
  columns: definition.fields.map(field => ({
    key: field.name,
    name: field.name,
    fixed: field.primaryKey,
    hidden: false,
  })),
  tableSize: 'middle',
  pageSize: 2,
  condition: all(),
  sorter: [],
};

function filterUsers(condition: Condition): User[] {
  if (condition.operator === Operator.ALL) return [...users];
  if (
    condition.field === 'active' &&
    (condition.operator === Operator.TRUE ||
      condition.operator === Operator.FALSE)
  ) {
    return users.filter(
      user => user.active === (condition.operator === Operator.TRUE),
    );
  }
  throw new Error('This example supports only the Active boolean filter.');
}

function queryUsers(
  condition: Condition,
  page: number,
  size: number,
  sorter: FieldSort[] = [],
): PagedList<User> {
  const rows = filterUsers(condition);
  if (
    sorter.length > 1 ||
    sorter.some(
      sort =>
        sort.field !== 'name' ||
        ![SortDirection.ASC, SortDirection.DESC].includes(sort.direction),
    )
  ) {
    throw new Error(
      'This example supports only ascending/descending Name sorting.',
    );
  }
  if (sorter.length) {
    const direction = sorter[0].direction === SortDirection.ASC ? 1 : -1;
    rows.sort(
      (left, right) => left.name.localeCompare(right.name, 'en') * direction,
    );
  }
  return {
    list: rows.slice((page - 1) * size, page * size),
    total: rows.length,
  };
}

export function LocalViewer() {
  const [savedViews, setSavedViews] = useState<ViewState[]>([initialView]);
  const [data, setData] = useState(() => queryUsers(all(), 1, 2));
  const [savedName, setSavedName] = useState('');
  const [error, setError] = useState('');
  const load = useCallback(
    (
      condition: Condition,
      page: number,
      size: number,
      sorter?: FieldSort[],
    ) => {
      try {
        setData(queryUsers(condition, page, size, sorter));
        setError('');
      } catch (failure) {
        setData({ list: [], total: 0 });
        setError(failure instanceof Error ? failure.message : String(failure));
      }
    },
    [],
  );
  return (
    <App>
      <FullscreenProvider>
        <Viewer<User>
          definition={definition}
          defaultViews={savedViews}
          defaultView={initialView}
          dataSource={data}
          enableRowSelection={false}
          pagination={{ showSizeChanger: false }}
          onLoadData={load}
          onSwitchView={view =>
            load(view.condition, 1, view.pageSize, view.sorter)
          }
          onGetRecordCount={async (_url, condition) =>
            filterUsers(condition).length
          }
          onCreateView={(view, onSuccess) => {
            const saved = { ...view, id: crypto.randomUUID() };
            setSavedViews(current => [...current, saved]);
            setSavedName(saved.name);
            onSuccess?.(saved);
          }}
          onUpdateView={(view, onSuccess) => {
            setSavedViews(current =>
              current.map(saved => (saved.id === view.id ? view : saved)),
            );
            setSavedName(view.name);
            onSuccess?.(view);
          }}
          onDeleteView={(view, onSuccess) => {
            setSavedViews(current =>
              current.filter(saved => saved.id !== view.id),
            );
            onSuccess?.(view);
          }}
        />
        {error && <p role="alert">{error}</p>}
        <output aria-live="polite">{savedName && `Saved: ${savedName}`}</output>
      </FullscreenProvider>
    </App>
  );
}

src/main.tsx 替换为以下完整入口。脚手架的 index.html 已包含 <div id="root"></div>;这里不导入默认演示 CSS。

tsx
import { createRoot } from 'react-dom/client';
import 'antd/dist/reset.css';
import { LocalViewer } from './LocalViewer';

createRoot(document.getElementById('root')!).render(<LocalViewer />);
bash
pnpm dev

打开 Vite 输出的本地地址。LocalViewer 自身提供 Ant Design 的 AppFullscreenProvider

观察结果

  1. 第一页包含 Ada、Lin;点击第 2 页,显示 Grace、Zoe
  2. 回到第 1 页。点击一次 Name 表头:显示 Ada、Grace;再点击一次:显示 Zoe、Lin(降序)。
  3. Active 中选择 ,再点击 搜索。只剩 Grace、Ada,并保持降序。选择 可筛选不活跃用户;选择 未设置 并再次搜索可移除此条件。
  4. 点击 另存为,将视图命名为 Active descending 并确认。应用显示 Saved: Active descending
  5. 在左侧选择 All users:恢复 Ada、Lin。再选择 Active descending:恢复 Grace、Ada、真值条件和降序表头。切换视图回到第 1 页;保存的是每页条数,不是页码。

本示例只实现 Active 布尔过滤和 Name 升降序排序。遇到其他条件或排序字段时,显示错误并清空表格。新增界面能力时再扩展对应的本地计算;这不是通用 Wow 查询解释器。

状态与后端边界

应用在 React 内存中持有 savedViews,接受修改后调用相应操作的成功回调,Viewer 随后更新内部视图集合。刷新或卸载应用会丢失已保存视图。若要持久保存,应先等待存储/API 操作完成,再调用成功回调;失败时显示错误,不报告成功。

dataUrlcountUrl 是定义要求的元数据。此处的普通数据加载和视图计数回调使用本地函数。工具栏还提供面向服务端的数据监控:本例应保持铃铛监控关闭,它的计数轮询需要真实兼容接口。本例不提供本地监控服务或后端。

需要远端行数据时,将应用的计算函数替换为请求,再把返回的 PagedList 传给 dataSource。身份认证、授权、错误处理和持久存储仍由应用与服务端负责。根据实际协议选择 View、Viewer 或 FetcherViewer

在本仓库运行与验证

仓库开发要求 Node >=20.20.2、pnpm 10.34.5。在仓库根目录执行:

bash
pnpm install
pnpm storybook

打开 Docs → Local Viewer → Local Data,手动执行上述操作;重新加载可恢复初始状态。自动交互验证在独立回归故事中执行。

无头运行相同的浏览器验证:

bash
pnpm exec vitest run --project=storybook stories/docs/LocalViewer.test.stories.tsx

验证会检查表格行集合与顺序、过滤结果和保存设置的恢复。回调文字只是额外的保存确认,不能代替对实际显示行的验证。

基于 Apache License 2.0 发布。