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)) - } - } }