diff --git a/docs/startup-analysis.md b/docs/startup-analysis.md
index 19a7e6d..db66b78 100644
--- a/docs/startup-analysis.md
+++ b/docs/startup-analysis.md
@@ -10,13 +10,13 @@
App 启动链路为:
-**`suixinkanApp` → `RootView` → `SplashView` 覆盖层 + 并行 bootstrap → 按 `AppSession.phase` 展示 `LoginView` 或 `MainTabsView`**
+**`suixinkanApp` → `RootView` → 阻塞 `rolePermissions` + 后台补充 bootstrap → 按 `AppSession.phase` 展示 `LoginView` 或 `MainTabsView`**
用户感知的「启动 Loading」并非 Lottie 全局 Loading,而是:
-1. **系统 Launch Screen**(白底 + Logo,iOS 原生)
-2. **应用内 `SplashView`**(品牌页,bootstrap 完成即结束,无固定最短展示时长)
-3. **有 token 时的 `.restoring` 阶段**(Splash 下方空白背景,等待 4 个 API 完成)
+1. **系统 Launch Screen**(品牌页,iOS 原生)
+2. **`.restoring` 短暂空白**(仅等待 `rolePermissions`,完成后立即进主界面)
+3. **后台补充刷新**(`userInfo`、`scenicListAll`、`storeAll`,不阻塞首屏)
冷启动期间 **不使用** `GlobalLoadingCenter` / Lottie 动画。
diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md
index 55369d5..7c3229e 100644
--- a/suixinkan/App/App.md
+++ b/suixinkan/App/App.md
@@ -30,27 +30,26 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
1. `suixinkanApp` 创建 `RootView`。
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
-3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`。
-4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页。
-5. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore`;Splash 以 bootstrap 完成为准,无固定最短展示时长。
-6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
-7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
-8. 无 token 时保持 `loggedOut`,Splash 结束后展示 `LoginView`。
-9. 有 token 时进入 `restoring`,先恢复本地账号快照。
-10. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
-11. 校验成功后进入 `loggedIn`,Splash 结束后展示 `MainTabsView`。
-12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
-13. 明确 token 失效时清空 token 和账号快照,回到登录页。
-14. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
-15. 冷启动 bootstrap 不使用 Lottie 全局 Loading;Splash 即启动等待层。
-16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
+3. iOS 系统 Launch Screen 展示品牌页(白底、Logo、文案)。
+4. `RootView` 挂载后在 bootstrap 完成前以 `SplashView` 覆盖根视图,避免空白或登录页闪屏。
+5. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
+6. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
+7. 无 token 时保持 `loggedOut`,展示 `LoginView`。
+8. 有 token 时进入 `restoring`,先恢复本地账号快照。
+9. 阻塞请求 `rolePermissions` 成功后立即 `markLoggedIn` 并展示 `MainTabsView`。
+10. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新,不阻塞首屏。
+11. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
+12. 明确 token 失效时清空 token 和账号快照,回到登录页。
+13. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
+14. 冷启动 bootstrap 不使用 Lottie 全局 Loading;`.restoring` 期间展示 `SplashView`。
+15. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
## 初始化分层
| 阶段 | 职责 | 主要对象 |
|------|------|----------|
| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp`、`AppUITestLaunchState` |
-| Splash | 品牌展示 + 登录态恢复 | `SplashView`、`SplashCoordinator`、`SessionBootstrapper` |
+| 冷启动恢复 | Launch Screen + Splash 覆盖 bootstrap | `SplashView`、`SessionBootstrapper`、`AccountContextLoader` |
| 首屏出现后 | 页面级远程配置 | `LoginView`、`AppConfigAPI` |
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager`、`ScenicSpotContext` |
diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift
index 6a369b3..996e19c 100644
--- a/suixinkan/App/RootView.swift
+++ b/suixinkan/App/RootView.swift
@@ -48,7 +48,7 @@ struct RootView: View {
@State private var cooperationOrderAPI: CooperationOrderAPI
@State private var authSessionCoordinator: AuthSessionCoordinator
@State private var sessionBootstrapper: SessionBootstrapper
- @StateObject private var splashCoordinator = SplashCoordinator()
+ @State private var isColdStartBootstrapPending = true
@State private var appConfigAPI: AppConfigAPI
/// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。
@@ -106,7 +106,7 @@ struct RootView: View {
ZStack {
rootContent
- if !splashCoordinator.isFinished {
+ if isColdStartBootstrapPending {
SplashView()
.transition(.opacity)
.zIndex(1)
@@ -155,15 +155,16 @@ struct RootView: View {
.task {
apiClient.bindAuthTokenProvider { appSession.token }
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
- await splashCoordinator.start {
- await sessionBootstrapper.restore(
- appSession: appSession,
- accountContext: accountContext,
- permissionContext: permissionContext,
- profileAPI: profileAPI,
- accountContextAPI: accountContextAPI,
- toastCenter: toastCenter
- )
+ await sessionBootstrapper.restore(
+ appSession: appSession,
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ profileAPI: profileAPI,
+ accountContextAPI: accountContextAPI,
+ toastCenter: toastCenter
+ )
+ withAnimation {
+ isColdStartBootstrapPending = false
}
if appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
PushNotificationManager.shared.requestAuthorizationAndRegister()
@@ -227,7 +228,7 @@ struct RootView: View {
case .loggedOut:
LoginView()
case .restoring:
- Color(.systemBackground)
+ Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .loggedIn:
MainTabsView()
diff --git a/suixinkan/App/SplashCoordinator.swift b/suixinkan/App/SplashCoordinator.swift
deleted file mode 100644
index 8e03658..0000000
--- a/suixinkan/App/SplashCoordinator.swift
+++ /dev/null
@@ -1,45 +0,0 @@
-//
-// SplashCoordinator.swift
-// suixinkan
-//
-// Created by Codex on 2026/6/29.
-//
-
-import Combine
-import Foundation
-
-@MainActor
-/// 启动页时序协调器,并行执行冷启动 bootstrap,Splash 结束时机以 bootstrap 完成为准。
-final class SplashCoordinator: ObservableObject {
- @Published private(set) var isFinished = false
-
- private let minimumDisplayDuration: TimeInterval
- private let skipsMinimumDuration: Bool
-
- /// 创建启动页协调器;默认无最短展示时长,测试可注入自定义 delay。
- init(
- minimumDisplayDuration: TimeInterval = 0,
- skipsMinimumDuration: Bool = AppUITestLaunchState.isRunningUITests
- ) {
- self.minimumDisplayDuration = minimumDisplayDuration
- self.skipsMinimumDuration = skipsMinimumDuration
- }
-
- /// 并行执行 bootstrap 与可选最短展示时长,两者都完成后结束 Splash。
- func start(bootstrap: @escaping () async -> Void) async {
- guard !isFinished else { return }
-
- async let minimumDelay: Void = waitForMinimumDisplayDuration()
- async let bootstrapWork: Void = bootstrap()
-
- _ = await (minimumDelay, bootstrapWork)
- isFinished = true
- }
-
- /// 等待最短展示时长;UI Test 模式下直接返回。
- private func waitForMinimumDisplayDuration() async {
- guard !skipsMinimumDuration else { return }
- let nanoseconds = UInt64(max(minimumDisplayDuration, 0) * 1_000_000_000)
- try? await Task.sleep(nanoseconds: nanoseconds)
- }
-}
diff --git a/suixinkan/App/State/AccountContextLoader.swift b/suixinkan/App/State/AccountContextLoader.swift
index 1733ff9..4efaffa 100644
--- a/suixinkan/App/State/AccountContextLoader.swift
+++ b/suixinkan/App/State/AccountContextLoader.swift
@@ -17,7 +17,7 @@ protocol UserProfileServing {
@MainActor
/// 账号上下文加载器,统一装配用户资料、角色权限、景区和门店上下文。
struct AccountContextLoader {
- /// 刷新账号上下文,权限失败会向外抛错,景区和门店按旧工程规则兜底。
+ /// 刷新完整账号上下文;登录成功等场景仍需要同步拉齐全部数据。
func refresh(
accountContext: AccountContext,
permissionContext: PermissionContext,
@@ -28,17 +28,66 @@ struct AccountContextLoader {
cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil
) async throws {
- async let userData = profileAPI.userInfo()
- async let roleData = accountContextAPI.rolePermissions()
- let (userInfo, rolePermissions) = try await (userData, roleData)
+ try await restorePermissions(
+ permissionContext: permissionContext,
+ accountContext: accountContext,
+ accountContextAPI: accountContextAPI,
+ cachedCurrentRoleCode: cachedCurrentRoleCode,
+ cachedLegacyRoleId: cachedLegacyRoleId,
+ cachedCurrentScenicId: cachedCurrentScenicId,
+ cachedCurrentStoreId: cachedCurrentStoreId
+ )
+ try await refreshSupplementalContext(
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ profileAPI: profileAPI,
+ accountContextAPI: accountContextAPI,
+ cachedCurrentScenicId: cachedCurrentScenicId,
+ cachedCurrentStoreId: cachedCurrentStoreId
+ )
+ }
+ /// 冷启动阻塞阶段:仅恢复角色权限,确保首页菜单和权限控制可用。
+ func restorePermissions(
+ permissionContext: PermissionContext,
+ accountContext: AccountContext,
+ accountContextAPI: AccountContextServing,
+ cachedCurrentRoleCode: String? = nil,
+ cachedLegacyRoleId: Int? = nil,
+ cachedCurrentScenicId: Int? = nil,
+ cachedCurrentStoreId: Int? = nil
+ ) async throws {
+ let rolePermissions = try await accountContextAPI.rolePermissions()
permissionContext.replaceRolePermissions(
rolePermissions,
currentRoleCode: cachedCurrentRoleCode,
cachedLegacyRoleId: cachedLegacyRoleId
)
- let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions)
+ let roleScenicScopes = permissionContext.currentRoleScenicScopes()
+ guard accountContext.scenicScopes.isEmpty, !roleScenicScopes.isEmpty else { return }
+ accountContext.replaceScopes(
+ scenic: roleScenicScopes,
+ stores: accountContext.storeScopes,
+ currentScenicId: cachedCurrentScenicId,
+ currentStoreId: cachedCurrentStoreId
+ )
+ }
+
+ /// 冷启动后台阶段:补齐用户资料、景区和门店上下文。
+ func refreshSupplementalContext(
+ accountContext: AccountContext,
+ permissionContext: PermissionContext,
+ profileAPI: UserProfileServing,
+ accountContextAPI: AccountContextServing,
+ cachedCurrentScenicId: Int? = nil,
+ cachedCurrentStoreId: Int? = nil
+ ) async throws {
+ let userInfo = try await profileAPI.userInfo()
+ let scenicResponse = await loadScenicList(
+ accountContextAPI: accountContextAPI,
+ rolePermissions: permissionContext.rolePermissions
+ )
let storesResponse = await loadStores(accountContextAPI: accountContextAPI)
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
let scenicScopes = scopedScenics(
@@ -53,8 +102,8 @@ struct AccountContextLoader {
accountContext.replaceScopes(
scenic: scenicScopes,
stores: storeScopes,
- currentScenicId: cachedCurrentScenicId,
- currentStoreId: cachedCurrentStoreId
+ currentScenicId: cachedCurrentScenicId ?? accountContext.currentScenic?.id,
+ currentStoreId: cachedCurrentStoreId ?? accountContext.currentStore?.id
)
}
diff --git a/suixinkan/App/State/SessionBootstrapper.swift b/suixinkan/App/State/SessionBootstrapper.swift
index cd65ca9..411bf94 100644
--- a/suixinkan/App/State/SessionBootstrapper.swift
+++ b/suixinkan/App/State/SessionBootstrapper.swift
@@ -32,7 +32,7 @@ final class SessionBootstrapper {
)
}
- /// 从本地缓存恢复登录态并请求服务端校验。
+ /// 从本地缓存恢复登录态;阻塞等待 rolePermissions,其余数据后台刷新。
func restore(
appSession: AppSession,
accountContext: AccountContext,
@@ -51,32 +51,38 @@ final class SessionBootstrapper {
appSession.beginRestoring(token: token)
let cachedSnapshot = restoreCachedSnapshot(to: accountContext)
+ let loader = AccountContextLoader()
do {
- try await AccountContextLoader().refresh(
- accountContext: accountContext,
+ try await loader.restorePermissions(
permissionContext: permissionContext,
- profileAPI: profileAPI,
+ accountContext: accountContext,
accountContextAPI: accountContextAPI,
cachedCurrentRoleCode: cachedSnapshot?.currentRoleCode,
cachedLegacyRoleId: cachedSnapshot?.currentRoleId,
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
)
- saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
appSession.markLoggedIn(token: token)
+ refreshSupplementalContextInBackground(
+ loader: loader,
+ appSession: appSession,
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ profileAPI: profileAPI,
+ accountContextAPI: accountContextAPI,
+ toastCenter: toastCenter,
+ cachedSnapshot: cachedSnapshot
+ )
} catch {
- if APIError.isAuthenticationExpired(error) {
- try? tokenStore.clear()
- snapshotStore.clear()
- accountContext.reset()
- permissionContext.reset()
- appSession.logout()
- toastCenter.show("登录状态已失效,请重新登录")
- } else {
- appSession.markLoggedIn(token: token)
- toastCenter.show("网络异常,已使用本地登录状态")
- }
+ handleBlockingRestoreFailure(
+ error,
+ token: token,
+ appSession: appSession,
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ toastCenter: toastCenter
+ )
}
}
@@ -93,6 +99,97 @@ final class SessionBootstrapper {
return snapshot
}
+ /// 后台补齐用户资料、景区和门店,不阻塞进入主界面。
+ private func refreshSupplementalContextInBackground(
+ loader: AccountContextLoader,
+ appSession: AppSession,
+ accountContext: AccountContext,
+ permissionContext: PermissionContext,
+ profileAPI: UserProfileServing,
+ accountContextAPI: AccountContextServing,
+ toastCenter: ToastCenter,
+ cachedSnapshot: AccountSnapshot?
+ ) {
+ Task {
+ do {
+ try await loader.refreshSupplementalContext(
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ profileAPI: profileAPI,
+ accountContextAPI: accountContextAPI,
+ cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
+ cachedCurrentStoreId: cachedSnapshot?.currentStoreId
+ )
+ saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
+ } catch {
+ handleSupplementalRefreshFailure(
+ error,
+ appSession: appSession,
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ toastCenter: toastCenter
+ )
+ }
+ }
+ }
+
+ /// 处理阻塞阶段恢复失败:鉴权失效登出,网络异常则降级为本地登录态。
+ private func handleBlockingRestoreFailure(
+ _ error: Error,
+ token: String,
+ appSession: AppSession,
+ accountContext: AccountContext,
+ permissionContext: PermissionContext,
+ toastCenter: ToastCenter
+ ) {
+ if APIError.isAuthenticationExpired(error) {
+ clearPersistedSession(
+ appSession: appSession,
+ accountContext: accountContext,
+ permissionContext: permissionContext
+ )
+ toastCenter.show("登录状态已失效,请重新登录")
+ return
+ }
+
+ appSession.markLoggedIn(token: token)
+ toastCenter.show("网络异常,已使用本地登录状态")
+ }
+
+ /// 处理后台补充刷新失败:鉴权失效登出,网络异常保留当前登录态。
+ private func handleSupplementalRefreshFailure(
+ _ error: Error,
+ appSession: AppSession,
+ accountContext: AccountContext,
+ permissionContext: PermissionContext,
+ toastCenter: ToastCenter
+ ) {
+ if APIError.isAuthenticationExpired(error) {
+ clearPersistedSession(
+ appSession: appSession,
+ accountContext: accountContext,
+ permissionContext: permissionContext
+ )
+ toastCenter.show("登录状态已失效,请重新登录")
+ return
+ }
+
+ toastCenter.show("网络异常,已使用本地登录状态")
+ }
+
+ /// 清空本地 token、快照和内存上下文。
+ private func clearPersistedSession(
+ appSession: AppSession,
+ accountContext: AccountContext,
+ permissionContext: PermissionContext
+ ) {
+ try? tokenStore.clear()
+ snapshotStore.clear()
+ accountContext.reset()
+ permissionContext.reset()
+ appSession.logout()
+ }
+
/// 保存服务端校验后的最新账号上下文。
private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) {
let existing = snapshotStore.load()
@@ -109,5 +206,4 @@ final class SessionBootstrapper {
)
)
}
-
}
diff --git a/suixinkan/Features/Splash/Splash.md b/suixinkan/Features/Splash/Splash.md
index f681943..42182cd 100644
--- a/suixinkan/Features/Splash/Splash.md
+++ b/suixinkan/Features/Splash/Splash.md
@@ -2,32 +2,36 @@
## 模块职责
-Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `SplashActivity` + `activity_splash.xml` 的视觉规范。
+Splash 模块负责冷启动品牌展示,与系统 Launch Screen 保持相同视觉,并在 bootstrap 完成前避免空白页或登录页闪屏。
-该模块不处理登录、隐私协议或远程配置,只承担启动阶段的品牌展示和时序控制。
+冷启动恢复登录态由 `SessionBootstrapper` 负责;`SplashView` 仅覆盖 bootstrap 阻塞阶段,不做固定时长品牌停留。
## 核心对象
-- `SplashView`:展示白底、180pt Logo、「随心瞰 / 商家版」文案。
-- `SplashCoordinator`:并行执行 `SessionBootstrapper.restore`,bootstrap 完成后结束 Splash(无固定最短展示时长)。
-- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen`。
+- `SplashView`:冷启动 bootstrap 期间展示的品牌页,与 Launch Screen 布局一致。
+- `SplashTypography`:启动页标题/副标题字号与 UIKit 字体,供 `SplashView` 与 `Launch Screen.storyboard` 对齐。
+- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源。
+- `SessionBootstrapper`:冷启动恢复登录态,阻塞等待 `rolePermissions`,其余数据后台刷新。
+- `AccountContextLoader.restorePermissions` / `refreshSupplementalContext`:拆分阻塞阶段与后台补充阶段。
## 展示规则
-- 背景色:白色 `#FFFFFF`
-- Logo:`SplashLogo`,180×180 pt
-- 标题:`随心瞰`,40pt,bold + italic,`AppDesign.primary`
-- 副标题:`商家版`,20pt,bold,距底 32pt
+- 系统 Launch Screen:白底、180×180 Logo、「随心瞰 / 商家版」文案。
+- 应用内 `SplashView`:bootstrap 完成前覆盖根视图,避免 `.restoring` 空白或登录页闪现。
+- bootstrap 结束后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。
+- 无固定最短展示时长。
## 启动时序
-1. iOS 系统 Launch Screen 展示白底 + Logo。
-2. `RootView` 挂载后立即显示 `SplashView`。
-3. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore` 静默恢复登录态。
-4. bootstrap 完成后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。
-5. 冷启动期间不显示 Lottie 全局 Loading。
+1. iOS 系统 Launch Screen 展示品牌页。
+2. `RootView` 挂载后立即显示 `SplashView` 覆盖层。
+3. `SessionBootstrapper.restore` 并行恢复登录态。
+4. 无 token:bootstrap 完成后进入 `LoginView`。
+5. 有 token:恢复 `AccountSnapshot` → 阻塞请求 `rolePermissions` → `markLoggedIn` → 进入 `MainTabsView`。
+6. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新,不阻塞首屏。
+7. 冷启动期间不显示 Lottie 全局 Loading。
## 与 Android 的差异
- Android 首启隐私弹窗在 Splash 上;iOS 仍在登录页勾选协议。
-- Android 系统 Launch Screen 与应用内 Splash 使用同一 XML;iOS 系统 Launch Screen 仅展示 Logo,文案由 SwiftUI Splash 补齐。
+- Android 系统 Launch Screen 与应用内 Splash 使用同一 XML;iOS 系统 Launch Screen 与应用内 `SplashView` 保持相同布局,但应用内 Splash 仅在 bootstrap 期间展示。
diff --git a/suixinkan/Features/Splash/SplashTypography.swift b/suixinkan/Features/Splash/SplashTypography.swift
new file mode 100644
index 0000000..2118be9
--- /dev/null
+++ b/suixinkan/Features/Splash/SplashTypography.swift
@@ -0,0 +1,37 @@
+//
+// SplashTypography.swift
+// suixinkan
+//
+// Created by Codex on 2026/6/30.
+//
+
+import SwiftUI
+import UIKit
+
+/// 启动页字体规范,供 `SplashView` 与 `Launch Screen.storyboard` 对齐。
+enum SplashTypography {
+ /// 标题字号(「随心瞰」)
+ static let titlePointSize: CGFloat = 40
+ /// 副标题字号(「商家版」)
+ static let subtitlePointSize: CGFloat = 20
+
+ /// 启动页标题 UIKit 字体:40pt system bold + italic。
+ static var titleUIFont: UIFont {
+ let base = UIFont.systemFont(ofSize: titlePointSize, weight: .bold)
+ guard let descriptor = base.fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic]) else {
+ return base
+ }
+ return UIFont(descriptor: descriptor, size: titlePointSize)
+ }
+
+ /// 启动页副标题 UIKit 字体:20pt system bold。
+ static var subtitleUIFont: UIFont {
+ UIFont.systemFont(ofSize: subtitlePointSize, weight: .bold)
+ }
+
+ /// 启动页标题 SwiftUI 字体。
+ static var titleFont: Font { Font(titleUIFont) }
+
+ /// 启动页副标题 SwiftUI 字体。
+ static var subtitleFont: Font { Font(subtitleUIFont) }
+}
diff --git a/suixinkan/Features/Splash/Views/SplashView.swift b/suixinkan/Features/Splash/Views/SplashView.swift
index dbf8613..f9de3fd 100644
--- a/suixinkan/Features/Splash/Views/SplashView.swift
+++ b/suixinkan/Features/Splash/Views/SplashView.swift
@@ -7,7 +7,7 @@
import SwiftUI
-/// 启动页视图,对齐 Android `activity_splash.xml` 的品牌展示布局。
+/// 启动页视图,在冷启动恢复登录态期间展示,与 Launch Screen 保持相同品牌布局。
struct SplashView: View {
var body: some View {
ZStack {
@@ -24,15 +24,14 @@ struct SplashView: View {
.accessibilityIdentifier("splash.logo")
Text("随心瞰")
- .font(.system(size: 40, weight: .bold))
- .italic()
+ .font(SplashTypography.titleFont)
.foregroundStyle(AppDesign.primary)
.accessibilityIdentifier("splash.title")
Spacer()
Text("商家版")
- .font(.system(size: 20, weight: .bold))
+ .font(SplashTypography.subtitleFont)
.foregroundStyle(AppDesign.primary)
.padding(.bottom, 32)
.accessibilityIdentifier("splash.subtitle")
diff --git a/suixinkan/Launch Screen.storyboard b/suixinkan/Launch Screen.storyboard
index 7f528a2..8f7e58a 100644
--- a/suixinkan/Launch Screen.storyboard
+++ b/suixinkan/Launch Screen.storyboard
@@ -15,8 +15,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -24,4 +66,7 @@
+
+
+
diff --git a/suixinkanTests/AccountContextLoaderTests.swift b/suixinkanTests/AccountContextLoaderTests.swift
index 2e17a5e..dc0568a 100644
--- a/suixinkanTests/AccountContextLoaderTests.swift
+++ b/suixinkanTests/AccountContextLoaderTests.swift
@@ -65,6 +65,64 @@ final class AccountContextLoaderTests: XCTestCase {
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
}
+ /// 测试仅恢复角色权限时会写入 PermissionContext,并在无快照景区时补齐作用域。
+ func testRestorePermissionsLoadsRolePermissionsOnly() async throws {
+ let accountContext = AccountContext()
+ let permissionContext = PermissionContext()
+ let api = MockAccountContextAPI()
+ api.roles = [
+ RolePermissionResponse(
+ role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]),
+ scenic: [ScenicInfo(id: 88, name: "东湖景区")]
+ )
+ ]
+
+ try await AccountContextLoader().restorePermissions(
+ permissionContext: permissionContext,
+ accountContext: accountContext,
+ accountContextAPI: api
+ )
+
+ XCTAssertEqual(permissionContext.currentRole?.id, 7)
+ XCTAssertTrue(permissionContext.canAccess("/home"))
+ XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
+ XCTAssertNil(accountContext.profile)
+ }
+
+ /// 测试补充刷新会更新用户资料、景区和门店。
+ func testRefreshSupplementalContextUpdatesProfileAndScopes() async throws {
+ let accountContext = AccountContext()
+ let permissionContext = PermissionContext()
+ let api = MockAccountContextAPI()
+ api.roles = [
+ RolePermissionResponse(
+ role: RoleInfo(id: 7, name: "运营"),
+ scenic: [ScenicInfo(id: 88, name: "东湖景区")]
+ )
+ ]
+ api.scenicResponse = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 88, name: "东湖景区")])
+ api.storeResponse = ListPayload(total: 1, list: [StoreItem(id: 66, scenicId: 88, name: "东湖门店")])
+
+ try await AccountContextLoader().restorePermissions(
+ permissionContext: permissionContext,
+ accountContext: accountContext,
+ accountContextAPI: api
+ )
+
+ try await AccountContextLoader().refreshSupplementalContext(
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ profileAPI: MockUserProfileAPI(),
+ accountContextAPI: api,
+ cachedCurrentScenicId: 88,
+ cachedCurrentStoreId: 66
+ )
+
+ XCTAssertEqual(accountContext.profile?.displayName, "测试用户")
+ XCTAssertEqual(accountContext.currentScenic?.id, 88)
+ XCTAssertEqual(accountContext.currentStore?.id, 66)
+ }
+
/// 测试门店接口失败时不阻断上下文加载。
func testRefreshAllowsStoreFailure() async throws {
let accountContext = AccountContext()
diff --git a/suixinkanTests/SessionBootstrapperTests.swift b/suixinkanTests/SessionBootstrapperTests.swift
new file mode 100644
index 0000000..8077b78
--- /dev/null
+++ b/suixinkanTests/SessionBootstrapperTests.swift
@@ -0,0 +1,144 @@
+//
+// SessionBootstrapperTests.swift
+// suixinkanTests
+//
+// Created by Codex on 2026/6/30.
+//
+
+import XCTest
+@testable import suixinkan
+
+@MainActor
+/// 冷启动恢复测试,覆盖阻塞权限恢复与后台补充刷新。
+final class SessionBootstrapperTests: XCTestCase {
+ /// 测试无 token 时直接进入未登录状态。
+ func testRestoreWithoutTokenLogsOut() async {
+ let defaults = makeDefaults()
+ let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
+ let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
+ let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
+ let appSession = AppSession()
+ let accountContext = AccountContext()
+ let permissionContext = PermissionContext()
+ let toastCenter = ToastCenter()
+
+ await bootstrapper.restore(
+ appSession: appSession,
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ profileAPI: MockUserProfileAPI(),
+ accountContextAPI: MockAccountContextAPI(),
+ toastCenter: toastCenter
+ )
+
+ XCTAssertEqual(appSession.phase, .loggedOut)
+ }
+
+ /// 测试有 token 时阻塞等待 rolePermissions 后立即进入 loggedIn。
+ func testRestoreMarksLoggedInAfterRolePermissions() async {
+ let defaults = makeDefaults()
+ let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
+ try? tokenStore.save("token-1")
+ let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
+ let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
+ let appSession = AppSession()
+ let accountContext = AccountContext()
+ let permissionContext = PermissionContext()
+ let toastCenter = ToastCenter()
+ let api = MockAccountContextAPI()
+ api.roles = [
+ RolePermissionResponse(
+ role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]),
+ scenic: [ScenicInfo(id: 88, name: "东湖景区")]
+ )
+ ]
+
+ await bootstrapper.restore(
+ appSession: appSession,
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ profileAPI: MockUserProfileAPI(),
+ accountContextAPI: api,
+ toastCenter: toastCenter
+ )
+
+ XCTAssertEqual(appSession.phase, .loggedIn)
+ XCTAssertEqual(permissionContext.currentRole?.id, 7)
+ XCTAssertTrue(permissionContext.canAccess("/home"))
+
+ let profileExpectation = expectation(description: "profile loaded in background")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ if accountContext.profile?.displayName == "测试用户" {
+ profileExpectation.fulfill()
+ }
+ }
+ await fulfillment(of: [profileExpectation], timeout: 2)
+ }
+
+ /// 测试 rolePermissions 鉴权失败时会清空本地登录态。
+ func testRestoreLogsOutWhenRolePermissionsExpired() async {
+ let defaults = makeDefaults()
+ let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
+ try? tokenStore.save("token-1")
+ let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
+ snapshotStore.save(AccountSnapshot(profile: AccountProfile(userId: "1", displayName: "本地用户")))
+ let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
+ let appSession = AppSession()
+ let accountContext = AccountContext()
+ let permissionContext = PermissionContext()
+ let toastCenter = ToastCenter()
+ let api = MockAccountContextAPI()
+ api.roleError = APIError.httpStatus(401, "登录状态已失效")
+
+ await bootstrapper.restore(
+ appSession: appSession,
+ accountContext: accountContext,
+ permissionContext: permissionContext,
+ profileAPI: MockUserProfileAPI(),
+ accountContextAPI: api,
+ toastCenter: toastCenter
+ )
+
+ XCTAssertEqual(appSession.phase, .loggedOut)
+ XCTAssertNil(tokenStore.load())
+ XCTAssertNil(snapshotStore.load())
+ }
+
+ private func makeDefaults() -> UserDefaults {
+ let suiteName = "SessionBootstrapperTests.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defaults.removePersistentDomain(forName: suiteName)
+ return defaults
+ }
+}
+
+@MainActor
+private final class MockUserProfileAPI: UserProfileServing {
+ func userInfo() async throws -> UserInfoResponse {
+ try await Task.sleep(nanoseconds: 100_000_000)
+ return UserInfoResponse(nickname: "测试用户", roleName: "运营")
+ }
+}
+
+@MainActor
+private final class MockAccountContextAPI: AccountContextServing {
+ var roles: [RolePermissionResponse] = []
+ var roleError: Error?
+
+ func rolePermissions() async throws -> [RolePermissionResponse] {
+ if let roleError { throw roleError }
+ return roles
+ }
+
+ func scenicListAll() async throws -> ScenicListAllResponse {
+ ScenicListAllResponse(total: 0, list: [])
+ }
+
+ func storeAll() async throws -> ListPayload {
+ ListPayload(total: 0, list: [])
+ }
+
+ func scenicSpotListAll(scenicId: Int) async throws -> ListPayload {
+ ListPayload(total: 0, list: [])
+ }
+}
diff --git a/suixinkanTests/SplashCoordinatorTests.swift b/suixinkanTests/SplashCoordinatorTests.swift
deleted file mode 100644
index 8acaf43..0000000
--- a/suixinkanTests/SplashCoordinatorTests.swift
+++ /dev/null
@@ -1,81 +0,0 @@
-//
-// SplashCoordinatorTests.swift
-// suixinkanTests
-//
-// Created by Codex on 2026/6/29.
-//
-
-import XCTest
-@testable import suixinkan
-
-@MainActor
-/// 启动页协调器测试,覆盖可选最短展示时长、bootstrap 并行和 UI Test 跳过策略。
-final class SplashCoordinatorTests: XCTestCase {
- /// 测试默认无最短展示时长时,bootstrap 完成即可结束 Splash。
- func testStartFinishesImmediatelyWithDefaultMinimumDuration() async {
- let coordinator = SplashCoordinator(skipsMinimumDuration: true)
- let startedAt = Date()
-
- await coordinator.start {
- try? await Task.sleep(nanoseconds: 10_000_000)
- }
-
- XCTAssertTrue(coordinator.isFinished)
- XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.15)
- }
-
- /// 测试 bootstrap 完成后仍需等待最短展示时长。
- func testStartWaitsForMinimumDisplayDuration() async {
- let coordinator = SplashCoordinator(minimumDisplayDuration: 0.2, skipsMinimumDuration: false)
- let startedAt = Date()
-
- await coordinator.start {
- try? await Task.sleep(nanoseconds: 10_000_000)
- }
-
- XCTAssertTrue(coordinator.isFinished)
- XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.19)
- }
-
- /// 测试 bootstrap 耗时超过最短展示时长时,以 bootstrap 完成时间为准。
- func testStartWaitsForSlowBootstrap() async {
- let coordinator = SplashCoordinator(minimumDisplayDuration: 0.05, skipsMinimumDuration: false)
- let startedAt = Date()
-
- await coordinator.start {
- try? await Task.sleep(nanoseconds: 150_000_000)
- }
-
- XCTAssertTrue(coordinator.isFinished)
- XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.14)
- }
-
- /// 测试 UI Test 模式会跳过最短展示时长。
- func testStartSkipsMinimumDurationWhenRequested() async {
- let coordinator = SplashCoordinator(minimumDisplayDuration: 1.0, skipsMinimumDuration: true)
- let startedAt = Date()
-
- await coordinator.start {
- try? await Task.sleep(nanoseconds: 10_000_000)
- }
-
- XCTAssertTrue(coordinator.isFinished)
- XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.2)
- }
-
- /// 测试重复调用 start 不会重置已完成状态。
- func testStartIsIdempotentAfterFinished() async {
- let coordinator = SplashCoordinator(minimumDisplayDuration: 0, skipsMinimumDuration: true)
- var bootstrapCount = 0
-
- await coordinator.start {
- bootstrapCount += 1
- }
- await coordinator.start {
- bootstrapCount += 1
- }
-
- XCTAssertTrue(coordinator.isFinished)
- XCTAssertEqual(bootstrapCount, 1)
- }
-}
diff --git a/suixinkanTests/SplashTypographyTests.swift b/suixinkanTests/SplashTypographyTests.swift
new file mode 100644
index 0000000..eda4395
--- /dev/null
+++ b/suixinkanTests/SplashTypographyTests.swift
@@ -0,0 +1,26 @@
+//
+// SplashTypographyTests.swift
+// suixinkanTests
+//
+// Created by Codex on 2026/6/30.
+//
+
+import XCTest
+@testable import suixinkan
+
+/// 启动页字体规范测试,确保 Storyboard 与 SwiftUI 使用同一套字号与字重。
+final class SplashTypographyTests: XCTestCase {
+ /// 标题应使用 40pt system bold。
+ func testTitleFontUsesExpectedPointSizeAndWeight() {
+ let font = SplashTypography.titleUIFont
+ XCTAssertEqual(font.pointSize, SplashTypography.titlePointSize, accuracy: 0.01)
+ XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold))
+ }
+
+ /// 副标题应使用 20pt system bold。
+ func testSubtitleFontUsesExpectedPointSizeAndWeight() {
+ let font = SplashTypography.subtitleUIFont
+ XCTAssertEqual(font.pointSize, SplashTypography.subtitlePointSize, accuracy: 0.01)
+ XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold))
+ }
+}
diff --git a/suixinkanUITests/Support/SuixinkanUIApplication.swift b/suixinkanUITests/Support/SuixinkanUIApplication.swift
index 750dc7a..acc323f 100644
--- a/suixinkanUITests/Support/SuixinkanUIApplication.swift
+++ b/suixinkanUITests/Support/SuixinkanUIApplication.swift
@@ -43,21 +43,26 @@ final class SuixinkanApp {
hasLaunched = true
}
dismissSystemAlertsIfNeeded()
- waitForSplashToDisappearIfNeeded()
+ waitForInitialScreen()
if waitForLoading {
waitForGlobalLoadingToDisappear()
}
return self
}
- /// 等待启动页消失后再继续后续断言。
- func waitForSplashToDisappearIfNeeded(timeout: TimeInterval = 5) {
- let splash = application.otherElements["splash.root"]
- guard splash.waitForExistence(timeout: 2) else { return }
+ /// 等待冷启动后进入登录页或主 Tab。
+ func waitForInitialScreen(timeout: TimeInterval = 45) {
+ if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
+ return
+ }
- let predicate = NSPredicate(format: "exists == false")
- let expectation = XCTNSPredicateExpectation(predicate: predicate, object: splash)
- _ = XCTWaiter.wait(for: [expectation], timeout: timeout)
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if isOnMainTabs {
+ return
+ }
+ RunLoop.current.run(until: Date().addingTimeInterval(0.2))
+ }
}
/// 等待全局 Loading 层消失。
@@ -91,19 +96,4 @@ final class SuixinkanApp {
var isOnMainTabs: Bool {
TabBarNavigator.isVisible(in: self)
}
-
- /// 等待冷启动后进入登录页或主 Tab。
- func waitForInitialScreen(timeout: TimeInterval = 45) {
- if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
- return
- }
-
- let deadline = Date().addingTimeInterval(timeout)
- while Date() < deadline {
- if isOnMainTabs {
- return
- }
- RunLoop.current.run(until: Date().addingTimeInterval(0.2))
- }
- }
}