跳到主要内容

应用壳层

根应用、布局与状态页壳层是 @vef-framework-react/starter内部实现。AppBaseLayoutLayoutAccessDeniedErrorNotFoundRouterProviderNProgress 均未从该包导出;它们由 createApp()createRouter() 以及启动与路由中记录的路由选项辅助函数组装而成。登录相关能力(LoginSsoLogin、各自的 hook 以及挑战渲染器)和 ThemeConfigProvider 面向自定义应用壳层导出。

页面级容器(PageFlexCard)、表单弹窗(FormModalFormDrawer)、表格抽象(ProTable)以及 CRUD 体系(CrudPagecreateCrudKit 等)位于 @vef-framework-react/components 中——参见 Components / CRUD

根应用

createApp().render(props) 挂载内部的根组件,其组合顺序为:

AppContextProviderApiClientProviderMotionProviderThemeConfigProvider → (NProgress + RouterProvider)

render(props) 接受:

Prop类型作用
apiClientApiClient提供给 ApiClientProvider
appContextAppContext提供给 AppContextProvider(权限、码集、文件基础 URL)
routerRouter来自 createRouter() 的实例,提供给内部的路由 provider
appVersionNotificationAppVersionNotificationOptions可选;用于接入 setupAppVersionNotification
componentsComponentDefaults可选;白名单组件 props 的应用级默认值,转发给 components 包的 ConfigProvider
defaultThemeDefaultTheme可选;应用的初始配色方案和语义化颜色

DefaultTheme 接受 colorScheme?: ColorSchemecolors?: Partial<ThemeColors>。框架默认从 system 和内置语义化颜色开始;应用颜色会合并到这些默认值之上,未指定的语义化颜色保留内置值。用户在主题面板中的选择优先,且只有该选择会被持久化——修改应用默认值可以触达尚未自行选择的用户。

主题 Provider

ThemeConfigProvider 面向不使用 createApp().render()、而是自行组装壳层的应用导出。

Prop类型作用
defaultThemeDefaultTheme应用起始主题
componentsComponentDefaults应用级组件默认值
childrenReactNode应用内容

该 provider 还导出:

  • useEffectiveColorScheme() —— 用户选择的配色方案,未选择时返回应用默认值。
  • useEffectiveThemeColors() —— 将用户选择的语义化颜色合并到应用默认值之上。
  • DefaultThemeThemeConfigProviderProps 类型。

布局

createLayoutRouteOptions() 组装,在每个已认证路由中渲染。除了 fetchUserInfo / beforeLoad / loader(参见启动与路由)之外,它还会将以下展示选项原样转发给内部的布局壳层:

选项类型作用
titleReactNode壳层标题
logoReactNode壳层 logo
headerActionsReactNode头部区域的额外内容
userMenuItemsUserMenuItem[]附加到用户下拉菜单、位于内置退出登录项之前的项
onUserMenuClick(key: string) => void点击除内置 logout 键之外的任意用户菜单项时调用
onLogout() => Awaitable<void>退出登录回调
appsAppItem[]应用切换器中展示的子应用;省略则隐藏切换器
currentAppIdstring当前激活应用的 id,会在切换器中高亮显示
onAppChange(appId: string) => Awaitable<void>用户切换到另一个应用时调用

AppItem 字段:

字段类型默认值作用
idstring必填稳定标识符,会回传给 onAppChange
namestring必填显示名称
descriptionstring一句话描述(以 tooltip 形式展示)
iconReactNode可直接渲染的节点,在切换器磁贴中按原样渲染(一个 <img>、一个 DynamicIcon 等)——不是图标名称;见 v2.5.0 版本说明

内部的布局壳层是两层结构组合而成:Layout(菜单、用户区域、标签页系统、应用切换器)包裹着更底层的 BaseLayout(头部/标签页/侧边栏/页脚区域)。BaseLayout 没有独立的公开配置面——只能通过 Layout 间接触达。

登录

createLoginRouteOptions(options) 组装,它会用 LoginProps 渲染已导出的 Login 组件,并处理带重定向感知的登录流程(?redirect= 查询参数、已认证时跳过登录页)。

Prop类型作用
logoReactNode角落处的品牌标识
titlestring页面标题
descriptionstring副标题
publicKeystring存在该值时启用凭据的 RSA 加密(并可经由 encrypt 加密敏感的挑战应答)
onLogin(params: LoginParams) => Promise<LoginResult>登录回调
onResolveChallenge(params: ResolveChallengeParams) => Promise<LoginResult>面向会发出多步登录挑战的后端(例如 TOTP、强制修改密码),须与 challengeRenderers 一起提供
challengeRenderersLoginChallengeRenderers每种挑战类型对应一个渲染器

onResolveChallengechallengeRenderers 要么同时提供、要么都不提供:从不发出挑战的后端可以两者都省略。LoginProps 在类型层面强制了这一点。

LoginParams 是一个可辨识联合。框架内置 "password"PasswordLoginParams)与 "trust_code"TrustCodeLoginParams);项目通过 Register['loginParams'] 增加登录机制。

useLoginFlow

导出的登录状态机是 LoginSsoLogin 的共同基础。它负责凭据提交、challenge 循环、RSA 加密、错误归一化、认证后的 store 写入以及后续导航;自定义页面可以复用全部逻辑,只替换展示层。

选项类型作用
onLogin(params: LoginParams) => Promise<LoginResult>提交凭据或 trust-code handoff
onResolveChallenge?(params: ResolveChallengeParams) => Promise<LoginResult>应答 challenge;后端可能发出挑战时必须接入
publicKey?string启用返回的 encrypt 辅助函数
redirectTo?string认证后的目标;默认先取路由的 redirect 查询参数,再取首页路由
onAuthenticated?(result: LoginResult) => void | Promise<void>替换默认导航与欢迎通知;认证 store 写入始终执行
autoResolve?LoginChallengeAutoResolvers用页面已有数据静默应答选定的 challenge 类型;每种类型只尝试一次,被拒绝后回退到 renderer
onError?(error: unknown) => void接收原始 rejection,便于上报

返回的 LoginFlow 暴露 loginchallengeresolvecancelpendingerrorclearError,以及可选的 encrypt

单点登录

SsoLogin 是 trust-login 网关重定向后的默认落地页。createSsoRouteOptions(options) 会把它挂载到 /ssoSSO_ROUTE_IDSSO_ROUTE_PATHSSO_APP_ID_PARAMapp_id)和 SSO_CODE_PARAMcode)均已导出。

useSsoLogin(options) 只读取一次 handoff,在消费一次性 code 前先把它从地址栏移除,再把得到的 LoginParams 交给共享登录流程。它的 search 属性是挂载时的快照,因此自定义落地页和 challenge 自动应答器在 URL 被清理后仍能读取网关参数。即使当前已有会话,交换也会执行,因为 handoff 可能指向另一个用户。

SsoLoginProps 接受与登录流程相同的 onLogin、challenge、redirect、自动应答、错误和 publicKey 接线。readTrustCodeHandoff(search) 构造默认 trust_code 载荷;也可以覆盖 readHandoff 以适配不同的网关契约。

登录挑战

挑战流水线完全位于已导出的 Login 组件之内:

  1. onLogin 返回一个 LoginResult。当它携带的是 challenge + challengeToken 而不是 tokens 时,凭据表单会被替换为注册在 challengeRenderers[challenge.type] 下的渲染器。
  2. 渲染器调用 resolve(response);Login 组件将其转发为 onResolveChallenge({ challengeToken, type, response })。返回的 LoginResult 要么完成认证(tokens——更新 store 并执行 ?redirect= 导航),要么串联下一个挑战。
  3. resolve 被拒绝时,挑战会留在屏幕上,错误信息通过渲染器的 error prop 暴露(BusinessError 的消息原样展示;其它错误回退为通用提示)。错误会在下一次 resolve 时以及取消时自动清除。
  4. cancel() 丢弃持有的挑战令牌并返回凭据表单——用户必须重新登录。
  5. 未注册渲染器的挑战类型会显示内置的"不支持的挑战"提示,并附带返回登录的按钮。

每个渲染器都是一个 LoginChallengeRenderer——一个接收 LoginChallengeRendererProps 的普通渲染函数(由 K 参数化,K 是该渲染器所绑定的挑战类型):

Prop类型作用
challengeExtract<LoginChallenge, { type: K }>待处理的挑战:type、特定于挑战的 data,以及 required
resolve(response: ResolvedChallenges[K]["response"]) => Promise<void>提交用户的应答;在得到的 LoginResult 应用完毕后才会 resolve
cancel() => void放弃挑战并返回凭据表单
pendingboolean是否有 resolve 调用正在进行(用于禁用提交按钮)
errorstring | null | undefined最近一次失败的 resolve 的错误消息,用于内联展示
encrypt((plaintext: string) => string) | undefined仅在配置了 publicKey 时存在;以与凭据相同的方案加密敏感应答。可能抛出异常——请在提交处理器中捕获

当项目扩充了 Register['challenges'](参见 Store 与类型)后,challenge.dataresolve 的参数会按挑战类型收窄,并且 LoginChallengeRenderers 会强制穷尽性——每个已声明的挑战类型都必须有对应的渲染器。

内置渲染器:PasswordChangeChallenge

starter 附带一个内置渲染器,用于后端的强制修改密码挑战。导出如下:

导出种类作用
PasswordChangeChallenge组件渲染器本体:收集并确认新密码,本地校验(两个字段均非空且一致),存在 encrypt 时用其加密,并把 resolve 失败(例如违反密码策略)与本地校验错误内联展示
PASSWORD_CHANGE_CHALLENGE_TYPE"password_change" 常量挑战类型标识符,与后端的 security.ChallengeTypePasswordChange 对齐
PasswordChangeChallengeData类型挑战的 data 负载:{ reason: PasswordChangeReason; meta?: Record<string, unknown> }
PasswordChangeReasonLiteralUnion<"first_login" | "expired", string>强制修改密码的原因;两个字面量是后端预定义的原因,会驱动与原因对应的副标题,其它任意字符串回退为通用副标题
PasswordChangeChallengeSpec类型Register['challenges'] 条目:{ data: PasswordChangeChallengeData; response: string }——应答是新密码,配置了 publicKey 时为密文,否则为明文
PasswordChangeChallengeProps类型该组件的 props(见下)

PasswordChangeChallengeProps——其形状确保无论是否存在 Register['challenges'] 扩充,都能赋值给 LoginChallengeRenderers 的条目(challenge.data 保持宽类型;resolve 保持窄类型):

Prop类型默认值作用
challenge{ data?: unknown }必填服务端提供的 PasswordChangeChallengeData;被视为不可信的线上数据并做防御性读取
resolve(response: string) => Promise<void>必填提交(可能已加密的)新密码
cancel() => void必填返回凭据表单;pending 期间禁用
pendingboolean必填resolve 调用的进行中状态
errorstring | nullundefined内联展示的 resolve 失败信息(与本地校验错误并列)
encrypt(plaintext: string) => stringundefined配置了 publicKey 时由 Login 组件提供;新密码在提交前会被加密

接线是宿主的职责——渲染器只是被导出,并不会自动注册:

src/pages/_common/login.tsx
import { createFileRoute } from "@tanstack/react-router";
import {
createLoginRouteOptions,
LOGIN_ROUTE_ID,
PASSWORD_CHANGE_CHALLENGE_TYPE,
PasswordChangeChallenge,
type LoginChallengeRenderers
} from "@vef-framework-react/starter";

const challengeRenderers: LoginChallengeRenderers = {
[PASSWORD_CHANGE_CHALLENGE_TYPE]: PasswordChangeChallenge
};

export const Route = createFileRoute(LOGIN_ROUTE_ID)(
createLoginRouteOptions({
onLogin: handleLogin,
onResolveChallenge: handleResolveChallenge,
challengeRenderers
})
);
src/types/augmentation.ts
import type { PASSWORD_CHANGE_CHALLENGE_TYPE, PasswordChangeChallengeSpec } from "@vef-framework-react/starter";

declare module "@vef-framework-react/starter" {
interface Register {
challenges: {
[PASSWORD_CHANGE_CHALLENGE_TYPE]: PasswordChangeChallengeSpec;
};
}
}

要扩展这套挑战,在各自的挑战类型下添加项目自定义的渲染器组件(每个组件都接收 LoginChallengeRendererProps);要替换内置 UI,则在 PASSWORD_CHANGE_CHALLENGE_TYPE 下注册一个不同的渲染器。

路由状态页

内部组件由谁接入
AccessDeniedcreateAccessDeniedRouteOptions()
ErrorcreateRouter()defaultErrorComponent)与 createLayoutRouteOptions()errorComponent
NotFoundcreateRouter()defaultNotFoundComponent)与 createLayoutRouteOptions()notFoundComponent

这三者均不接受公开配置选项。

路由加载指示器

NProgress(页面顶部的进度条)由根应用自动挂载,并由 createRouter()onBeforeLoad / onLoad 路由事件驱动。它没有公开配置面。