@bitz/app(apps/app)是 Web 管理端单页应用。它是一个 Vite + React 19 应用,唯一的数据通道是 @bitz/platform-sdk,唯一的复合 UI 来源是 @bitz/widgets,唯一的样式系统是 Tailwind CSS v4。页面组合 widget 并调 usePlatform().api.<method>(...);它们不 fetch、不抄 DTO、不手搓 loading 状态。
1. 构建与开发拓扑
| 模式 | 行为 |
|---|---|
yarn dev(默认) | VITE_API_BASE_URL 为空 → Vite dev server 在 6800 把 /api/* 与 /health proxy 到本地 Api Host(6881)。浏览器保持同源 → 开发期无 CORS。 |
设置 VITE_API_BASE_URL | 前端直连后端;后端必须放行该 origin(CORS 在 Host 上配置)。此模式下 proxy 块不存在。 |
yarn build | tsc --noEmit && vite build。类型错误先于打包失败。 |
dev proxy 是本地开发零 CORS 摩擦的原因:浏览器只看到 http://localhost:6800,Vite 把 API 路径转发给 .NET Host。proxy 本身是有条件的——它只在 command === 'serve' && VITE_API_BASE_URL === '' 时存在,这样同一份配置同时服务 dev-proxy 与直连模式。
// 空 API 基址表示浏览器应保持同源。const apiBaseUrl = process.env.VITE_API_BASE_URL ?? '';// 只有开发服务器且未配置直连地址时才创建代理。const useDevProxy = command === 'serve' && apiBaseUrl === '';
server: { port: 6800, strictPort: true, ...(useDevProxy ? { proxy: { '/api': { target: process.env.VITE_PROXY_TARGET ?? 'http://localhost:6881', changeOrigin: false }, '/health': { target: process.env.VITE_PROXY_TARGET ?? 'http://localhost:6881', changeOrigin: false }, }, } : {}),}2. 环境变量
| 变量 | 默认 | 效果 |
|---|---|---|
VITE_API_BASE_URL | '' | 空 → dev-proxy 模式(同源)。设值 → 直连模式,后端必须放行 origin。也喂给 PlatformSdkConfig.baseUrl。 |
VITE_PROXY_TARGET | http://localhost:6881 | /api、/health、/hubs 的 dev-proxy 目标;代码默认 6881,但仓库 .env.example 预填 5192,拷贝后需按需修改。 |
VITE_CLIENT_PLATFORM | Web | 作为 X-Client-Platform 头发送。 |
VITE_API_TIMEOUT_MS | 30000 | 单请求超时(ms),喂给 PlatformSdkConfig.defaultTimeoutMs。 |
VITE_LOGIN_TEMPLATE | legal-mis | 登录屏模板:mini-program、website-admin、business 或 legal-mis。 |
VITE_BRAND_THEME | legal-navy | 冻结 Brand Theme;非法值回退默认。 |
VITE_COLOR_MODE | system | light、dark 或 system。 |
VITE_DENSITY | dense | dense、compact、comfortable 或 spacious。 |
VITE_SHELL_PRESET | side | 登录后管理端使用 side 或 top;非法值回退 side,页面不得自行覆盖。 |
3. 运行时组合
应用组装一个固定的小集合:
| 关注点 | 库 | 在应用中的角色 |
|---|---|---|
| 路由 | react-router-dom 7 | import.meta.glob 懒加载;RouteGuard 包住需登录路由 |
| 客户端状态 | Jotai | UI 局部状态的 atoms |
| URL 状态 | nuqs | 排序、过滤等查询参数状态放 URL |
| 服务端状态 | @tanstack/react-query 5 | 唯一被允许的数据获取通道;缓存键派生自 API 调用 |
| 认证/HTTP | @bitz/platform-sdk | 作为 PlatformProvider 挂一次;页面读 usePlatform() |
| UI 原语 | @bitz/components | shadcn 原语 barrel(数十个原子)+ cn |
| 复合 UI | @bitz/widgets | AppShell、ServerDataTable、FormField、ConfirmAction、各种 state |
| 样式 | Tailwind v4 | @tailwindcss/vite |
| PWA | vite-plugin-pwa | registerType: prompt;manifest 名 FD WORK |
| DX | unplugin-auto-import、unplugin-icons、unplugin-svgr、Locator(仅 dev) | 自动导入 React/RR hooks、Lucide 图标、SVG-as-component、点击跳源 |
4. 入口、provider 与路由
main.tsx 按固定顺序接 provider 树:StrictMode > PlatformProvider(config) > WorkspacePreferencesProvider(注入首屏 brand/colorMode/density/shellPreset 安全默认值) > QueryClientProvider > PrincipalQueryCacheBoundary > RouterProvider + AppToaster。挂载点是 #root。没有根级 I18nProvider——i18n 由页面按需引入。
路由在 src/routes.tsx。路由扩展 React Router 的 RouteObject,带 meta(requiresAuth?、permission?、title?)与字符串 lazy,后者经 normalizeRoutes 转成 lazyRoute() glob 导入:
| 路由 | meta | 渲染什么 |
|---|---|---|
/login | 无需登录 | Design System 1.2 登录屏;凭证、验证码、MFA、外部登录 |
/forgot-password、/reset-password | 无需登录 | 密码找回与重置 |
/accept-invitation、/activate | 无需登录 | 邀请接受与账户激活 |
/verify-email、/verify-phone | 无需登录 | 联系方式确认 |
/external-login/complete、/session-expired | 无需登录 | 外部登录完成与会话恢复 |
/ | requiresAuth | <RouteGuard> → <AppShell> |
/(index) | title: 工作区总览, requiresAuth | home——权限驱动的工作区目录,渲染 api.getNavigation() |
/users | permission: ['identity.user.search'] | 用户管理列表 |
/change-password | requiresAuth | 修改密码 / 强制改密 |
/settings/security | requiresAuth | MFA、验证与会话安全 |
/identity/access | requiresAuth | 邀请与准入管理 |
/identity/organization | identity.organization-unit.view | 大型组织目录、批量移动与同名子组织创建 |
/identity/applications | platform.oauth.manage 等 | OAuth、API Key 与 HMAC 应用凭据 |
/host/tenants | permission: ['operations.tenants.view'],surface: host | 租户治理(Host 面) |
* | title: 页面不存在,requiresAuth | 懒加载 pages/not-found 兜底页 |
RouteGuard 是路由级 gate(与按钮级 PermissionGate widget 互补)。它读 meta.requiresAuth 与 meta.permission,未登录或未授权时跳 /login。RouteLoading HydrateFallback 覆盖懒加载分片拉取。
5. widget 层
页面从 @bitz/widgets 组合,它隐藏了每个数据屏原本都要重写一遍的 glue:
| Widget | 隐藏 |
|---|---|
AppShell + 导航 + HeaderBar | Side/Top 壳层;服务端授权菜单;滚动收缩 Page Context;真实通知中心;账号浮层与退出 |
WorkspacePage + WorkspaceLocalTabs | 页面安全区、模块导航去重、Page Header 收缩和页面内 Tab 磁吸 |
ServerDataTable | 服务端分页、排序、列模型与 PlatformResult 映射 |
FormField | 文本输入的 label/error/help 接线 |
ConfirmAction | 破坏性操作的二次确认流程 |
QueryState / EmptyState / ErrorState | 任意查询的 loading/empty/error 分支(ErrorState 把 AppErrorType → i18n key) |
PermissionGate | 声明式权限拦截(包装 SDK gate 的 widgets 版) |
ServerDataTable<T> props
| Prop | 类型 | 说明 |
|---|---|---|
query | { isLoading, isError, error, data?: PlatformResult<Page<T>>, refetch? } | 一个 TanStack Query 结果形状 |
columns | readonly DataTableColumn<T>[] | { key, header, cell, sortable?, className? } |
getRowId | (row: T) => string | 行 key |
pageIndex | number | 从 1 开始 |
pageSize | number | |
onPageChange | (pageIndex: number) => void | |
sortField | string | null | null = 未排序 |
sortOrder | 'asc' | 'desc' | |
onSortChange | (sortField, sortOrder) => void | 点击循环:asc → desc → 清空 |
emptyTitle?、ariaLabel? | string |
widget 自身不碰 URLSearchParams——页面拥有 URL 状态(见 users-list.tsx,参数键为 page/size/q/status/sort/order)。状态机:isLoading → QueryState;isError 或 data.ok === false → ErrorState(优先用 PlatformFailure.error,否则把抛出的错误映射进去);空 items → EmptyState;否则表格带分页页脚(共 N 条 · 第 X / Y 页)。
壳层与通知
AppShell 在部署级读取 VITE_SHELL_PRESET:
side:64px 模块轨道 + 176px 当前模块面板,桌面可有界调宽;top:一级模块栏与上下文栏高度都由密度令牌--cmp-shell-header-height驱动(dense 48px / compact 56px / comfortable+ 64px),不是写死的像素常量;超出容量的一级入口进入“更多”,窄屏进入完整模块菜单;- 两种布局复用同一
api.getNavigation()查询、URL、权限和页面 Recipe。
Identity 模块路由由 Shell 显示一次。页面只为真正的内容分区使用 WorkspaceLocalTabs;滚动时 Local Tabs 吸附在 Shell 固定栏下,标题与关键操作进入既有 Global bar。
HeaderBar 的通知入口调用 getNotificationInbox、getNotificationUnreadCount、markNotificationRead 和 markAllNotificationsRead。这些方法桥接生成的 OpenAPI 类型;通知 URL 只接受规范化的站内绝对路径。搜索、帮助或主题按钮在没有真实 owner 前不会渲染成空入口。
FormField props
| Prop | 类型 | 默认 |
|---|---|---|
name、label、value、onChange | 必填 | |
error? | string | 驱动 aria-invalid + 错误文案 |
type? | 'text' | 'email' | 'password' | 'search' | 'text' |
placeholder?、required?、disabled? |
用 useId() 连 htmlFor/id。无内置校验引擎——错误由外部传入。
ConfirmAction props
| Prop | 类型 | 默认 |
|---|---|---|
trigger、title、onConfirm | 必填(onConfirm: () => Promise<void>) | |
description? | string | |
confirmText? / cancelText? | string | '确认' / '取消' |
destructive? | boolean | true |
onConfirm 在飞时按钮禁用;promise resolve 后才关弹窗。
6. 权限感知 UI(两层)
权限拦截发生在两层,都只算 UX:
import { PermissionGate } from "@bitz/widgets";
<PermissionGate require="identity.user.search" showFallback> {/* 整个 users 页 */}</PermissionGate>;- 路由级:
RouteGuard读routes.tsx的meta.permission,把未授权用户跳走。 - 按钮级:
PermissionGatewidget 按currentUser?.permissions隐藏/启用 UI。
两层读同一份权限集,它来自 /api/auth/me——绝不来自 localStorage。后端必须在授权管道里强制同样的能力。
7. 种子屏
| 文件 | 演示什么 |
|---|---|
pages/login/login.tsx | 完整登录状态机;按 LoginOutcome 进入验证码、MFA、强制改密或成功分支 |
pages/identity/* | 密码、邀请、激活、验证、外部登录、安全设置、会话与准入管理;见 Identity 前端 |
pages/users/users-list.tsx | 参照数据屏:ServerDataTable + usePlatform().api.searchUsers + 启用/禁用的 ConfirmAction,URL 同步参数 |
pages/home.tsx | 权限驱动的工作区总览:服务端授权菜单 → 入口目录 |
这些存在是为了让 platform-sdk 契约与 widget 组合有可运行样例;扩展管理端就是按同样的组合模式加路由。
8. PWA 配置
config/pwa-options.ts 配置 vite-plugin-pwa:registerType: 'prompt'、injectRegister: false、manifest name/short_name: 'FD WORK'、display: 'standalone'、start_url: '/'、两个 SVG 图标(any + maskable)。Workbox 预缓存 **/*.{js,mjs,css,html,svg,png,ico,jpg,webp}(不含 mp4——登录视频等大媒体不进预缓存),上限 10 MB,开 cleanupOutdatedCaches 与 clientsClaim、skipWaiting: false(由 prompt 注册流程控制激活)。/web/viewer.html* 在 navigateFallbackDenyList 里。
9. 路径别名
apps/app/tsconfig.json 继承 tsconfig.base.json,映射 @/* → ./src/*,以及 @bitz/components、@bitz/i18n、@bitz/platform-sdk 的 package 根。其余 @bitz/* 解析(widgets、editor、hooks、utils、materials、scripts)来自 tsconfig.base.json,后者映射了每个 workspace package。
10. 审一次 Web 应用改动
- 确认任何新增 HTTP 调用走
usePlatform().api——不是页面级fetch。 - 确认任何新增后端类型从
@bitz/platform-sdk导入,并最终桥接自 generated schema——不是手写。 - 若新增数据屏,优先组合
@bitz/widgets(ServerDataTable、QueryState),而不是重写各种 state。 - 若新增路由,给它
meta.requiresAuth/meta.permission,让RouteGuard强制。 - 提交前跑
yarn workspace @bitz/app build——tsc --noEmit先跑,类型错误会让构建失败;产物还需通过verify-bundle-budget.mjs的体积预算门。
11. 源核对
# 确认页面经 context 消费 SDK、经 barrel 消费 widgets。rg -n "usePlatform|from '@bitz/widgets'|from '@bitz/platform-sdk'" \ frontend/apps/app/src/pages
# 确认 provider 树顺序与有条件的 dev proxy。sed -n '1,40p' frontend/apps/app/src/main.tsxsed -n '1,70p' frontend/apps/app/vite.config.ts
# 确认路由 meta 与 RouteGuard 接线。rg -n "requiresAuth|permission|RouteGuard" frontend/apps/app/src/routes.tsx