dotnet-starter-kit 租户端 Dashboard 前端工程规范详解:权限拉取、SSE/Realtime 双通道与手写表单
【免费下载链接】dotnet-starter-kitProduction Grade Cloud-Ready .NET 10 Starter Kit (Web API + React Client) with Multitenancy Support, and Clean/Modular Architecture that saves roughly 200+ Development Hours! All Batteries Included.项目地址: https://gitcode.com/GitHub_Trending/do/dotnet-starter-kit
本文是 dotnet-starter-kit 仓库中.agents/rules/frontend/dashboard.md开发规范的完整解读与源码级展开。该规范面向clients/dashboard这一租户端(Tenant-facing)应用,是开发者在该目录下做任何 React 改动前必须阅读的"分歧清单"——它假定你已经读懂了适用于 admin/dashboard 两个前端应用的公共规范 shared.md,因此只记录 dashboard 独有的约定。读完本文,你将掌握:dashboard 的端口/环境变量/HTTPS 代理设计意图、权限为什么"拉取而非内嵌 JWT"以及如何防闪烁、SSE 两步令牌与双 Context 拆分的实现原理、跨应用模拟登录(Impersonation)的令牌换手机制、chroma 0 中性色主题体系,以及新增页面的完整差异步骤。
一、定位:dashboard 是什么,与 admin 的分工
dashboard 是仓库clients/dashboard目录下的 React 19 + Vite 7 + TypeScript 应用,服务于租户(tenant)——即实际使用产品的企业客户;而clients/admin是运营方(operator)的后台。两者的技术栈底座相同(TanStack Query v5、React Router 7、Radix UI、Tailwind v4、@microsoft/signalr),但设计语言与权限模型刻意不同(详见下文"主题"与"权限"两节)。
两者差异的权威对照表见 shared.md 的"Design language"小节:admin 使用冷色调中性色(hue 240 带少量彩度)与单一 chartreuse 强调色,而 dashboard 使用chroma 0 无色调中性色与可切换的 rose 品牌色——"中性色必须保持 chroma 0"是 dashboard 专属规则,修改时须按所在文件判断归属。
二、端口、环境变量与 HTTPS 开发代理
2.1 端口 5174 与代理目标
- 端口:
5174(clients/dashboard/vite.config.ts中server.port且strictPort: true,与 admin 的端口区分开,允许两个应用并存开发)。 - 开发代理目标:
https://localhost:7030(HTTPS),且对/api配置了ws: true——这是 SignalR Hub 的 WebSocket 升级所必需的,否则/api/v1/realtime/hub的 negotiate 虽然能成功,但 WS 升级会落入 Vite 自己的 dev server,导致聊天状态永远停在 "CONNECTING"。 - localStorage 前缀:
fsh.dashboard.*(admin 为fsh.admin.*),这是两者能并排运行的前提。 - 登录请求头:
X-FSH-App: dashboard,服务端据此拒绝 root 租户在租户端登录。
2.2 为什么开发代理刻意用 HTTPS
规范明确指出:开发代理使用 HTTPS 是有意为之——如果走 HTTP→HTTPS 的 307 重定向,Authorization承载令牌会在重定向过程中被剥离。因此 dev proxy 直接changeOrigin: true, secure: false指向https://localhost:7030,保证 bearer token 直达 API。
2.3 运行时环境变量(src/env.ts)
dashboard 的运行时配置只有三个核心字段:{ apiBase, defaultTenant, demoMode }。阅读 env.ts 可以看到,它比 shared 规范中描述的多继承了inactivityIdleMs(默认 20 分钟)与inactivityWarningMs(默认 60 秒)两个非活动超时参数,并通过positiveOr()做防御性取值(要求正有限数,否则回退默认值)。
关键的架构决策是:环境变量是运行时加载而非构建时注入。loadRuntimeConfig()在main.tsx挂载 React 前await一次/config.json,env是 getter,过早读取会抛错。这意味着同一个构建产物可以跨环境直接晋升——运维只需改写config.json,不需要重新构建镜像。dashboard 的vite.config.ts中还内置了一个名为fsh-dev-direct-api-config的开发插件:dev 模式下直接改写/config.json的响应,把apiBase指向https://localhost:7030,让 REST 请求与长连接的 SSE/SignalR 流绕过 Vite 代理直连 API——否则这些长连接占用 localhost:5174 的 HTTP/1.1 每主机约 6 条连接上限,会间歇性饿死懒加载的路由 chunk(注释里直接写了 "page won't load" 的教训)。
生产环境的clients/dashboard/public/config.json保持apiBase: ""(同源默认)。
三、表单:拒绝 RHF/zod,手写受控组件
dashboard 的硬性约定:不依赖 react-hook-form 或 zod,不要为了与 admin 对齐而引入这两个依赖。表单一律使用受控输入(controlled inputs)+ 组件本地 state 手写。
这是与 admin 的明确分歧:admin 允许/使用 RHF 风格的表单方案,dashboard 刻意保持轻量。在新增页面时(见第八节),表单部分必须遵循此约定。
四、权限体系:从接口拉取而非内嵌 JWT
这是 dashboard 与 admin 在权限模型上的核心差异,也是规范着墨最多的部分。
4.1 JWT 只携带角色名
dashboard 的 JWT只包含角色名(role names),不包含权限列表。权限的权威来源是服务端按角色解析后的接口:
GET /api/v1/identity/permissions,封装为src/api/identity.ts中的getMyPermissions()(见 identity.ts 中getMyPermissions的实现,注释明确写着 "The JWT carries only role names; permissions are resolved server-side per role here")。
4.2 拉取、缓存与防闪烁
auth-context.tsx(见 auth-context.tsx)中的AuthProvider在签名主体(subject)变化时——包括冷启动、登录、模拟登录切换——都会触发权限拉取:
- 调用
getMyPermissions(); - 结果写入
tokenStore.setPermissions(perms),缓存于 localStorage 的fsh.dashboard.permissions键(见 token-store.ts 中getPermissions/setPermissions,带 JSON 解析防御); - 置位
permissionsHydrated标志。
permissionsHydrated的存在意义是:避免受权限门控的 UI 在请求进行中闪现。初始 state 会从缓存权限列表种子化(tokenStore.getPermissions().length > 0即视为已水合),因此热刷新不会闪未门控的 UI。拉取失败不会登出用户——门控 UI 保持隐藏直到下次成功水合。login()时还会先清空权限缓存再发令牌,防止上一个用户的权限泄漏进新会话。
此外还有refreshPermissions()暴露给 AuthContext,用于角色变更后主动刷新权限集。
4.3 导航门控:perm/anyPerm
导航项在 nav-data.ts 中通过NavSpec的perm(单一权限,AND 语义)与anyPerm(任选其一即可)字段门控:
// perm:必须持有 Permissions.Chat.Channels.View 才显示 { to: "/chat", label: "Chat", icon: MessageCircle, perm: "Permissions.Chat.Channels.View" }, // anyPerm:Trash 有五个页签、各门控在不同资源的 restore/view-trash 权限上, // 只要用户能进任何一个页签就显示入口 { to: "/system/trash", label: "Trash", icon: Trash2, anyPerm: ALL_TRASH_PERMISSIONS },isNavItemVisible()实现"perm AND anyPerm"的判定,visibleSections()/visibleItems()负责过滤并丢弃空 section。一个值得注意的细节:/identity/users|roles|groups门控在*.Update而非*.View——因为 View 属于 IsBasic 权限、每个成员都持有(聊天/用户选择器依赖 Users.View),只有管理员级别的用户才应看到管理页面。
4.4 路由守卫仍是"仅认证"
与 admin 不同,dashboard 的ProtectedRoute只做认证检查,不做逐路由权限门控——规范明确要求不要在此引入 admin 那种RouteGuard风格的门控。导航门控(隐藏入口)+ 服务端 403(API 层拦截)构成了双层防线,前端不再画蛇添足。
五、路由与实时通道:SignalR + 专属 SSE
5.1 路由与懒加载
- 每个路由元素都用
withSuspense(node)包裹(逐路由骨架屏 fallback),不设逐路由权限守卫。 - Provider 挂载顺序:
RealtimeProvider和SseProvider都挂在AppShell内部(仅认证路由),外层再套CommandPaletteProvider(cmdk 命令面板)。 - 页面全部为命名导出,通过
lazyNamed(importer, name)适配为React.lazy,见 shared 规范。
5.2 SignalR(src/realtime/realtime-context.tsx)
- 单一共享的
HubConnection连接到/api/v1/realtime/hub,预接线约 11 个聊天/通知事件。 @microsoft/signalr是动态导入的(约 37KB gzip,全 shell 中最重的单依赖),只在已认证会话打开 hub 时才拉取——settings/files/health/auth 等无实时消费者的页面永远不会下载它(见 realtime-context.tsx 中loadSignalR()的单例 Promise 设计)。- 认证走
accessTokenFactory;tokenEpoch由tokenStore.subscribe递增,任何登录/刷新/模拟登录切换都会强制重建连接,避免旧令牌的僵尸连接。 - 重连采用递归退避
[2s, 5s, 10s, 30s],连续失败上限 60s 并带 ±15% 抖动;传输方式不固定(WebSockets → SSE → long-polling 自动降级),因为企业代理后方的仪表盘常常需要回退。 - 消费方式:
useRealtimeEvent("EventName", handler, deps),handler 存在 ref 中避免闭包过期。
5.3 SSE(src/sse/,dashboard 独有)
SSE 是 dashboard 区别于 admin 的专属能力,采用两步令牌流程:
POST /api/v1/sse/token换取一次性短寿命令牌(见 sse-api.ts 的issueSseToken());GET /api/v1/sse/stream?token=<guid>通过fetch 流式读取消费(parseSseStream异步生成器,手写解析event:/id:/data:字段与\n\n分隔符)。
为什么不用 EventSource?因为 EventSource 无法发送Authorization请求头,而 SSE 流需要认证。令牌只在校验握手那一刻被检查,流一旦建立,其生命周期由传输层(网络/服务端)决定;断线重连时会重新调用issueSseToken(),而它背后的用户 JWT 会通过 api-client 的 401 单飞刷新自动续期——长会话的令牌刷新被隐式纳入重连路径,无需专用定时器。连接失败采用 1s→30s 的指数退避(INITIAL_BACKOFF_MS = 1000,MAX_BACKOFF_MS = 30_000),事件列表上限 200 条。
双 Context 拆分是性能关键设计(见 sse-context.tsx 中注释记录的历史教训):旧版单 Context 的 value 因events每次都是新数组而每次事件都变化,导致整个 overview 树级联重渲染。现在拆成:
useSseStatus()——稳定({ status, eventCount }),只适合顶部状态点、铃铛角标这类只关心连接状态的消费者;useSseEvents()——每次事件都变化({ events }),只在真正渲染事件列表的组件中挂载(overview 实时动态、activity 页);useSse()——向后兼容的组合钩子,新代码应改用上面两个按需切片。
六、模拟登录(Impersonation):跨应用单程移交
6.1 令牌藏匿(stash)机制
token-store.ts提供了三个关键方法(见 token-store.ts):
beginImpersonation(accessToken, impersonatedTenant):把操作者(operator)的原始 access/refresh/tenant 令牌藏到fsh.dashboard.impersonation.*键下,然后把活动令牌换成模拟登录令牌,并移除 refresh 槽——因为服务端不签发模拟登录的 refresh 令牌,api-client 检测到无 refresh 令牌就会静默跳过自动刷新(模拟登录会话刻意设计为短命);endImpersonationWithFreshTokens(access, refresh):End 成功路径,用服务端为原始操作者新铸的令牌对替换活动令牌并清空藏匿;restoreStashedActor():End 失败时的兜底,本地恢复藏匿令牌(原 access 可能已过期,此时藏匿的 refresh 会触发自动刷新)。
6.2 AuthProvider 暴露的接口与防御性设计
AuthProvider暴露beginImpersonation/stopImpersonation,并从act_sub/act_tenant/act_name声明推导ImpersonationInfo(见 auth-context.tsx 的claimsToImpersonation)。
stopImpersonation分两种情况,逻辑非常讲究:
- 无藏匿(跨应用移交):说明操作者是 root SuperAdmin 从 admin 端发起的移交,dashboard 端没有可回归的会话,且把 root 账户恢复到租户端正是
login()明令禁止的——所以立即登出,服务端endImpersonation只做 best-effort 调用(用于吊销授权 + 审计,30s 超时不容阻塞 UI); - 有藏匿(应用内模拟):await 服务端 End 换取操作者新令牌,若新令牌 tenant 仍是 root(防御纵深),同样登出而非恢复。
另外,beginImpersonation/stopImpersonation都会queryClient.clear(),避免 actor 会话的用户/角色/权限缓存泄漏给被模拟者。移交方向是单向的:admin 通过其dashboardUrl触发移交,dashboard 不反向移交(这也是env.ts注释说明 dashboard 不需要dashboardUrl配置的原因)。
6.3 会话恢复的边界
AuthProvider还处理了两个容易踩坑的会话恢复场景:冷启动时 access 过期但 refresh 存在,会先做一次静默刷新(isInitializing期间渲染 loader,而不是闪现注定 401 的仪表盘);以及跨标签页storage事件与visibilitychange监听,确保 DevTools 手动清令牌或另一标签登出后,本标签不会继续用已丢失的令牌发请求。
七、性能与主题
7.1 性能约定
@tanstack/react-virtual:任何大集合(聊天历史、大表格)必须用它做虚拟滚动。cmdk:驱动命令面板(CommandPaletteProvider)。
7.2 主题:chroma 0 中性色 + 可换强调色
设计语言定义在 globals.css,遵循"原始值 → 语义变量 →@theme inline工具类"的三层 token 架构:
- 中性色全部 chroma 0(
--neutral-*: oklch(L 0 0),无色调)。规范明确指出"暖纸色(warm-paper)被刻意移除"——旧版的暖纸底盘会让每个表面都泛黄,在深色模式下表现为整体黄色滤镜,且与非 rose 的强调色打架。现在中性色完全无色调,让所选强调色成为房间里唯一的颜色。 - 默认品牌色 rose(600 停靠点
#f91942附近的 oklch),通过:root上的.accent-{rose,indigo,violet,sky,emerald,amber}类覆盖全部--brand-*oklch 停靠点实现整套品牌色一键切换。 - saffron 次强调色(
--saffron-*,暖调第二通道,用于渐变端点、主视觉数字、信任标记)。 - 字体 Figtree(区别于 admin 的 Geist / Geist Mono)。
新增 token 的流程是:在globals.css中按"原始值 → 语义值 →@theme inline"补全三层,然后使用工具类;禁止在组件里硬编码颜色。
八、新增页面:在共享步骤之上的 delta
shared 规范给出了四步通用流程(扩展src/api/{feature}.ts手写类型与apiFetch调用 → 建src/pages/{area}/{name}.tsx命名导出页 → 在AppShell下注册lazyNamed路由 → 写tests/{area}/{name}.spec.ts测试)。dashboard 在此基础上叠加五条差异:
- 手写表单(不用 RHF/zod),受控输入 + 本地 state;
- 路由元素包
withSuspense(<X/>),不设权限守卫(导航门控 + 服务端 403 负责权限); - 若页面消费推送:SignalR 用
useRealtimeEvent("EventName", handler)(新事件名须先在 realtime-context.tsx 的预接线事件列表中注册),SSE 用useSseEvents(); - 长列表用
react-virtual; - 保持中性色 chroma 0。
九、测试与验证依据
dashboard 的 Playwright 测试(route-mocked,无真实后端)落在clients/dashboard/tests/{area}/{name}.spec.ts,覆盖 auth、billing、catalog、chat、files、identity、impersonation、overview、settings、system、tickets 各域。测试采用 shared 规范描述的 JWT 种子化(seedAuthedSession构造假 JWT 写入fsh.dashboard.*localStorage)+ shell mock(installShellMocks会abort SSE/SignalR)策略,beforeEach统一执行。对于实现细节的验证,最直接的路径是:
- 权限拉取链:identity.ts 的
getMyPermissions→ auth-context.tsx 的水合 effect → token-store.ts 的fsh.dashboard.permissions缓存; - SSE 两步令牌:sse-api.ts → sse-context.tsx 的连接循环;
- 代理与端口:vite.config.ts。
十、总结
clients/dashboard的设计哲学可以概括为几条清晰的取舍:权限走服务端权威(JWT 只带角色、权限单独拉取并缓存)、实时走双通道(SignalR 管高吞吐事件、SSE 管可认证的流式推送)、表单保持原生(不引入重型表单库)、主题保持纯净(chroma 0 中性色 + 可换强调色)。理解这些约定与其背后的历史教训(307 重定向剥 header、HTTP/1.1 连接数饿死懒加载、SSE 单 Context 级联重渲染、跨应用移交的 root 账户防御),是在该租户端应用上高效、合规地新增功能的前提。修改任何 dashboard 代码前,请先通读 shared.md 与本文件,再对照上述源码路径核实行为。
【免费下载链接】dotnet-starter-kitProduction Grade Cloud-Ready .NET 10 Starter Kit (Web API + React Client) with Multitenancy Support, and Clean/Modular Architecture that saves roughly 200+ Development Hours! All Batteries Included.项目地址: https://gitcode.com/GitHub_Trending/do/dotnet-starter-kit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考