Skip to content
bitzorcas
中EN

Recipe

实战:新增一个后台管理页面(Dashboard Page)

掌握在 BitzOrcas.Modern React 19 + TanStack Query 现代前端单体工程中新增管理页面的标准全流程:声明路由与菜单元数据、消费 OpenAPI 自动生成的强类型 TypeScript SDK、实现页级元数据自动充水表格与绑定 RBAC 细粒度权限看门狗。

Last updated

在传统前后端分离架构中,新增一个后台数据管理页面往往充满陷阱:前端开发者需要手动对照 Swagger 文档手写 TypeScript 接口与 fetch 请求函数;一旦后端字段类型重构,前端难以在编译期察觉而导致线上白屏;更为严重的是,开发者经常将用户的角色权限保存在 localStorage 中并在前端自行判断,既违反了“后端是唯一安全事实来源”的铁律,又留下了巨大的权限越权漏洞。

BitzOrcas.Modern 前端工程(位于根目录 frontend/apps/app)确立了严密的“契约驱动与设计受控”研发体系:

  1. OpenAPI 零手写 SDK(@bitz/platform-sdk):后端编译期导出的 OpenAPI 3.0 元数据通过脚本一键同步生成强类型客户端与 TypeScript DTO,严禁手写重复接口与裸 fetch;
  2. 安全凭据不可篡改:Web 平台 Refresh Token 由后端设置在 HttpOnly Cookie 中(通过后端 WebRefreshCookieService),Access Token 仅存在于前端运行时内存中,杜绝 XSS 凭据窃取与 localStorage 越权;
  3. 页级元数据自动充水(Metadata Hydration):前端表格通过后端伴随的 __meta 投影字典和关联主实体显示文本,无需前端维护重复的字典枚举映射表;
  4. 冻结设计系统(Frozen Design System v1):页面一律消费 @bitz/components 与 @bitz/widgets 标准组件,严禁业务页面私造主题变量与非标样式。

本指南以法律科技核心场景——**“民商事案件立案工作台(MatterIntakeDashboardPage)”**为例,带你完整走通从路由声明、SDK 接入、元数据充水到细粒度按钮权限守卫的标准交付流程。

前端页面开发标准时序

".NET 10 API Host (/api/legal/matters)""@bitz/widgets (ServerDataTable)""MatterIntakeDashboardPage.tsx""routes.tsx (AppRouteConfig)""@bitz/platform-sdk""sync-openapi-sdk.sh"".NET 10 API Host (/api/legal/matters)""@bitz/widgets (ServerDataTable)""MatterIntakeDashboardPage.tsx""routes.tsx (AppRouteConfig)""@bitz/platform-sdk""sync-openapi-sdk.sh""前端开发者"1. 执行 scripts/build/sync-openapi-sdk.sh12. 导出 OpenAPI 3.0 元数据规范23. 自动生成编译期强类型 api.listMatters 与 MatterDto34. 声明路由节点、RBAC 动作码与导航归属45. 组合 usePlatform 与 @tanstack/react-query56. 发起分页请求(携带内存 Access Token 与租户凭证)67. 返回强类型数据载荷及 __meta 字典映射78. 渲染带权限守卫与元数据充水的标准工作台8"前端开发者"

第一步:在路由树中声明页面元数据与权限边界

在 frontend/apps/app/src/routes.tsx 中注册新页面。BitzOrcas 采用声明式路由配置,所有业务页面必须包裹在受认证工作区壳层(authenticated-workspace-shell)内部,并明确指定归属的导航模块与 RBAC 动作权限。

frontend/apps/app/src/routes.tsx (片段)
import { Navigate } from 'react-router-dom';
import type { RouteObject } from 'react-router-dom';
import App from './App';
import { RouteError } from './components/route-error';
import { RouteGuard } from './components/route-guard';
import { RouteLoading } from './components/route-loading';
export type AppPermission = string;
export type AppSurface = 'tenant' | 'host' | 'shared';
export type AppRouteMeta = {
/** 归属的服务端一级菜单编码,用于保持 AppShell 壳层高亮与上下文一致 */
navigationCode?: string;
/** Guidance 导览使用的稳定页面键;避免业务主键污染路由 */
guidanceKey?: string;
/** 访问该页面所需的 RBAC 细粒度权限动作码集合 */
permission?: AppPermission[];
/** 权限判定模式:'all' 要求满足全部权限,'any' 满足任一即可 */
permissionMode?: 'all' | 'any';
/** 是否必须具备已认证会话 */
requiresAuth?: boolean;
/** 页面布局类型:'identity' 用于独立认证页,'workspace' 用于工作台 */
layout?: 'identity' | 'workspace';
/** 运行业务面:'tenant' 租户端、'host' SaaS 运营商端、'shared' 共享端 */
surface?: AppSurface;
/** 页面标题(用于浏览器 Title 与面包屑) */
title?: string;
};
export type AppRouteConfig = Omit<RouteObject, 'children' | 'lazy'> & {
lazy?: RouteObject['lazy'] | string;
meta?: AppRouteMeta;
hide?: boolean;
children?: AppRouteConfig[];
};
export const routes: AppRouteConfig[] = [
{
path: '/',
element: <App />,
errorElement: <RouteError />,
HydrateFallback: RouteLoading,
children: [
{
element: <RouteGuard />,
meta: { requiresAuth: true },
children: [
{
// AppShell 布局层:统一承载侧边栏导航、顶栏租户切换与当前用户信息
lazy: './components/authenticated-workspace-shell',
children: [
// --- 法律事务管理模块路由 ---
{
path: 'legal/matters',
lazy: async () => {
const { MatterIntakeDashboardPage } = await import(
'./pages/legal/matters/matter-intake-dashboard-page'
);
return { Component: MatterIntakeDashboardPage };
},
meta: {
title: '案件立案工作台',
requiresAuth: true,
surface: 'tenant',
navigationCode: 'workspace.legal-matters',
// 声明进入该路由必须具备的动作权限(后端仍是唯一安全事实来源)
permission: ['legal.matters.view'],
guidanceKey: 'legal-matters-intake-dashboard',
},
},
],
},
],
},
],
},
];

第二步:同步 OpenAPI SDK 契约

当后端新增了 api/legal/matters 垂直切片端点后,在终端执行 SDK 同步脚本:

Terminal window
# 执行一键导出 OpenAPI 规范并重新生成 @bitz/platform-sdk 强类型客户端
./scripts/build/sync-openapi-sdk.sh

该命令会读取后端 Minimal API 导出的 artifacts/openapi/openapi-v1.json,在 frontend/packages/platform-sdk 自动生成强类型的 API 客户端与 DTO 定义:

  • api.listLegalMatters(params)
  • interface LegalMatterSummaryDto
  • interface LegalMatterFilterRequest

[!CAUTION] 红线禁止项:

  1. 严禁手工修改 packages/platform-sdk 下由脚本生成的任何代码;
  2. 严禁在页面代码中手写后端返回的 DTO interface;
  3. 严禁在页面组件中直接使用 fetch() 或 axios 发起网络请求,所有请求必须通过 usePlatform().api 统一管线派发。

第三步:实现完整生产级工作台页面

在 frontend/apps/app/src/pages/legal/matters/matter-intake-dashboard-page.tsx 中编写页面。 本实现严格遵循 Frozen Design System v1 与 @bitz/widgets 业务组件规范:

frontend/apps/app/src/pages/legal/matters/matter-intake-dashboard-page.tsx
import { useEffect, useMemo, useState, type JSX } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { usePlatform, type LegalMatterSummaryDto } from '@bitz/platform-sdk';
import { useT } from '@bitz/i18n';
import {
DataTableObjectCell,
InlineNotice,
OperationField,
PermissionGate,
QueryToolbar,
ServerDataTable,
StatusBadge,
WorkspacePage,
type DataTableColumn,
type StatusTone,
} from '@bitz/widgets';
import { Button, Input, NativeSelect, NativeSelectOption } from '@bitz/components';
import {
MetadataListSearchPanel,
readMetadataString,
useMetadataListSearchPanel,
} from '../../../components/metadata-list-search-panel';
import { WorkspaceContextHeader } from '../../../components/workspace-context-header';
import { useListQueryState } from '../../../utils/list-query-state';
/** 案件列表查询 React Query Key 常量,用于状态缓存与局部失效刷新 */
const MATTERS_QUERY_KEY = ['legal', 'matters', 'dashboard'] as const;
/** 允许筛选的立案状态集合 */
const MATTER_STATUS_OPTIONS = ['Draft', 'PendingReview', 'Active', 'Closed'] as const;
/** 搜索面板参数映射配置 */
const MATTER_SEARCH_PANEL_OPTIONS = {
paramNames: {
SearchText: 'q',
Status: 'status',
},
defaultPageSize: 20,
} as const;
/**
* 民商事案件立案工作台页面组件
*/
export function MatterIntakeDashboardPage(): JSX.Element {
const { api, currentUser } = usePlatform();
const t = useT();
const queryClient = useQueryClient();
// 1. 列表分页与 URL 查询状态同步
const { state: listState, update: updateListState } = useListQueryState({
allowedStatuses: MATTER_STATUS_OPTIONS,
});
// 2. 元数据驱动的高级搜索面板 Hook
const metadataSearch = useMetadataListSearchPanel(
'Legal.MatterSummaryList',
MATTER_SEARCH_PANEL_OPTIONS,
);
const activeKeyword = metadataSearch.panelEnabled
? readMetadataString(metadataSearch.values, 'SearchText')
: listState.keyword;
const activeStatus = metadataSearch.panelEnabled
? readMetadataString(metadataSearch.values, 'Status')
: listState.status;
const pageIndex = metadataSearch.panelEnabled ? metadataSearch.pageIndex : listState.pageIndex;
const pageSize = metadataSearch.panelEnabled ? metadataSearch.pageSize : listState.pageSize;
const [keywordDraft, setKeywordDraft] = useState(activeKeyword);
const [statusDraft, setStatusDraft] = useState(activeStatus);
const [selectedMatterId, setSelectedMatterId] = useState<string | null>(null);
// 3. 用户前端权限集解析(后端安全事实来源的只读映射)
const permissions = useMemo(() => new Set(currentUser?.permissions ?? []), [currentUser]);
const canCreate = permissions.has('legal.matters.create');
const canAdvanceStage = permissions.has('legal.matters.advance-stage');
useEffect(() => {
setKeywordDraft(activeKeyword);
setStatusDraft(activeStatus);
}, [activeKeyword, activeStatus]);
// 4. 消费 TanStack Query 发起标准化分页数据拉取
const mattersQuery = useQuery({
queryKey: [...MATTERS_QUERY_KEY, pageIndex, pageSize, activeKeyword, activeStatus],
queryFn: () =>
api.listLegalMatters({
pageIndex,
pageSize,
searchText: activeKeyword.trim() || undefined,
status: activeStatus || undefined,
}),
staleTime: 30_000, // 30 秒客户端缓存
});
// 5. 状态徽标色调映射
const resolveStatusTone = (status: string): StatusTone => {
switch (status) {
case 'Active':
return 'success';
case 'PendingReview':
return 'warning';
case 'Closed':
return 'neutral';
default:
return 'neutral';
}
};
// 6. 强类型表格列定义(含元数据充水与格式化)
const columns: DataTableColumn<LegalMatterSummaryDto>[] = [
{
id: 'matterCode',
header: '案件流水号',
cell: (row) => (
<span className="font-mono text-sm font-semibold tracking-tight text-neutral-900 dark:text-neutral-100">
{row.matterCode}
</span>
),
width: '180px',
},
{
id: 'title',
header: '案件标题',
cell: (row) => (
<div className="flex flex-col gap-0.5">
<span className="font-medium text-neutral-900 dark:text-neutral-100">{row.title}</span>
<span className="text-xs text-neutral-500">{row.caseNumber || '未分配正式法院案号'}</span>
</div>
),
},
{
id: 'client',
header: '委托人 / 客户',
cell: (row) => (
// 元数据自动充水:从行内 __meta 快速读取关联客户全称,无需前端额外发起 N+1 请求
<DataTableObjectCell
primaryText={row.__meta?.clientName ?? row.clientId}
secondaryText={row.__meta?.clientUnifiedSocialCreditCode}
/>
),
width: '220px',
},
{
id: 'claimAmount',
header: '标的额 (元)',
cell: (row) => (
<span className="font-mono text-sm text-right tabular-nums">
¥ {row.claimAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</span>
),
align: 'right',
width: '150px',
},
{
id: 'status',
header: '案件状态',
cell: (row) => (
<StatusBadge
tone={resolveStatusTone(row.status)}
label={row.__meta?.statusDisplayText ?? row.status}
/>
),
width: '130px',
},
{
id: 'actions',
header: '操作',
cell: (row) => (
<OperationField>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedMatterId(row.id)}
>
查看卷宗
</Button>
{/* 细粒度按钮级 RBAC 权限看门狗 */}
<PermissionGate permission="legal.matters.advance-stage">
<Button
variant="outline"
size="sm"
disabled={row.status !== 'Active'}
onClick={() => {
// 打开阶段流转弹窗
}}
>
阶段流转
</Button>
</PermissionGate>
</OperationField>
),
width: '160px',
},
];
return (
<WorkspacePage>
{/* 顶部业务上下文标头 */}
<WorkspaceContextHeader
title="民商事案件立案工作台"
description="检索跨业务主体与部门的诉讼案件,执行立案审查、利益冲突核验与审判流程跟进。"
actions={
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => queryClient.invalidateQueries({ queryKey: MATTERS_QUERY_KEY })}
>
刷新数据
</Button>
{/* 声明式创建按钮权限控制 */}
<PermissionGate permission="legal.matters.create">
<Button
variant="primary"
onClick={() => {
// 打开新建立案抽屉
}}
>
新建立案申请
</Button>
</PermissionGate>
</div>
}
/>
{/* 检索过滤工具栏 */}
<QueryToolbar
search={
<Input
placeholder="搜索案件标题、业务案号或委托人..."
value={keywordDraft}
onChange={(e) => setKeywordDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateListState({ keyword: keywordDraft, pageIndex: 1 });
}
}}
/>
}
filters={
<NativeSelect
value={statusDraft}
onChange={(e) => {
const val = e.target.value;
setStatusDraft(val);
updateListState({ status: val, pageIndex: 1 });
}}
>
<NativeSelectOption value="">全部状态</NativeSelectOption>
<NativeSelectOption value="Draft">草稿阶段</NativeSelectOption>
<NativeSelectOption value="PendingReview">审查审批中</NativeSelectOption>
<NativeSelectOption value="Active">正式在审</NativeSelectOption>
<NativeSelectOption value="Closed">审结归档</NativeSelectOption>
</NativeSelect>
}
/>
{/* 错误提示条 */}
{mattersQuery.isError && (
<InlineNotice
tone="critical"
title="数据加载失败"
message={mattersQuery.error instanceof Error ? mattersQuery.error.message : '网络通讯异常,请重试。'}
/>
)}
{/* 服务端分页强类型表格 */}
<ServerDataTable
rowKey="id"
columns={columns}
data={mattersQuery.data?.items ?? []}
isLoading={mattersQuery.isLoading}
pagination={{
pageIndex,
pageSize,
totalCount: mattersQuery.data?.totalCount ?? 0,
onPageChange: (newPageIndex) => updateListState({ pageIndex: newPageIndex }),
onPageSizeChange: (newPageSize) => updateListState({ pageSize: newPageSize, pageIndex: 1 }),
}}
/>
</WorkspacePage>
);
}

第四步:CI 与代码规范自动化门禁验收

前端代码提交前,必须运行全套机器断言命令确保通过工程门禁:

Terminal window
# 1. 验证 Yarn 锁文件严格一致(杜绝未提交的包飘逸)
yarn install --immutable
# 2. 检查 peer 依赖兼容性
yarn peer:check
# 3. 校验 Prettier 格式基线
yarn format:check
# 4. 执行全工作区 ESLint 静态扫描
yarn lint
# 5. 执行全工作区 TypeScript 严格类型检查 (0 错误)
yarn typecheck
# 6. 执行 Web 管理端打包,验证 Bundle Budget 预算门禁
yarn workspace @bitz/app build

总结

遵循 BitzOrcas.Modern 前端开发规范构建后台管理页面,确保了整套企业级软件的质量上限:

  • 编译期类型安全:借助 sync-openapi-sdk.sh,后端 DTO 变更直接体现为前端 tsc 编译期报错,彻底杜绝字段错拼与静默故障;
  • 真实安全防御:拒绝 localStorage 存放权限的伪防护,PermissionGate 仅负责优化操作体验,后端管线 100% 承担最终授权事实;
  • 元数据自动水合:通过 ServerDataTable 消费 __meta 投影,实现零额外 HTTP 请求完成多语言字典与关联客户全称渲染;
  • 统一设计秩序:纯粹消费 @bitz/widgets 与 @bitz/components,杜绝任何页面私造 CSS 变量与非标布局。

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%