在传统前后端分离架构中,新增一个后台数据管理页面往往充满陷阱:前端开发者需要手动对照 Swagger 文档手写 TypeScript 接口与 fetch 请求函数;一旦后端字段类型重构,前端难以在编译期察觉而导致线上白屏;更为严重的是,开发者经常将用户的角色权限保存在 localStorage 中并在前端自行判断,既违反了“后端是唯一安全事实来源”的铁律,又留下了巨大的权限越权漏洞。
BitzOrcas.Modern 前端工程(位于根目录 frontend/apps/app)确立了严密的“契约驱动与设计受控”研发体系:
- OpenAPI 零手写 SDK(
@bitz/platform-sdk):后端编译期导出的 OpenAPI 3.0 元数据通过脚本一键同步生成强类型客户端与 TypeScript DTO,严禁手写重复接口与裸fetch; - 安全凭据不可篡改:Web 平台 Refresh Token 由后端设置在
HttpOnlyCookie 中(通过后端WebRefreshCookieService),Access Token 仅存在于前端运行时内存中,杜绝 XSS 凭据窃取与localStorage越权; - 页级元数据自动充水(
Metadata Hydration):前端表格通过后端伴随的__meta投影字典和关联主实体显示文本,无需前端维护重复的字典枚举映射表; - 冻结设计系统(Frozen Design System v1):页面一律消费
@bitz/components与@bitz/widgets标准组件,严禁业务页面私造主题变量与非标样式。
本指南以法律科技核心场景——**“民商事案件立案工作台(MatterIntakeDashboardPage)”**为例,带你完整走通从路由声明、SDK 接入、元数据充水到细粒度按钮权限守卫的标准交付流程。
前端页面开发标准时序
第一步:在路由树中声明页面元数据与权限边界
在 frontend/apps/app/src/routes.tsx 中注册新页面。BitzOrcas 采用声明式路由配置,所有业务页面必须包裹在受认证工作区壳层(authenticated-workspace-shell)内部,并明确指定归属的导航模块与 RBAC 动作权限。
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 同步脚本:
# 执行一键导出 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 LegalMatterSummaryDtointerface LegalMatterFilterRequest
[!CAUTION] 红线禁止项:
- 严禁手工修改
packages/platform-sdk下由脚本生成的任何代码;- 严禁在页面代码中手写后端返回的 DTO
interface;- 严禁在页面组件中直接使用
fetch()或axios发起网络请求,所有请求必须通过usePlatform().api统一管线派发。
第三步:实现完整生产级工作台页面
在 frontend/apps/app/src/pages/legal/matters/matter-intake-dashboard-page.tsx 中编写页面。
本实现严格遵循 Frozen Design System v1 与 @bitz/widgets 业务组件规范:
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 与代码规范自动化门禁验收
前端代码提交前,必须运行全套机器断言命令确保通过工程门禁:
# 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 变量与非标布局。