diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index f20a244..45a585d 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,11 +64,15 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanUITests; sourceTree = ""; }; @@ -324,14 +328,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md index 0ba5e32..c85a7df 100644 --- a/suixinkan/App/App.md +++ b/suixinkan/App/App.md @@ -28,20 +28,34 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 1. `suixinkanApp` 创建 `RootView`。 2. `RootView` 初始化共享依赖,并把它们注入 Environment。 -3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。 -4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。 -5. 无 token 时保持 `loggedOut`,展示 `LoginView`。 -6. 有 token 时进入 `restoring`,先恢复本地账号快照。 -7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。 -8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`。 -9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。 -10. 明确 token 失效时清空 token 和账号快照,回到登录页。 -11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 +3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`。 +4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页。 +5. `SplashCoordinator.start` 并行执行最短 1.5 秒展示和 `SessionBootstrapper.restore`;UI Test 模式跳过 1.5 秒延迟。 +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`),失败静默。 + +## 初始化分层 + +| 阶段 | 职责 | 主要对象 | +|------|------|----------| +| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp`、`AppUITestLaunchState` | +| Splash | 品牌展示 + 登录态恢复 | `SplashView`、`SplashCoordinator`、`SessionBootstrapper` | +| 首屏出现后 | 页面级远程配置 | `LoginView`、`AppConfigAPI` | +| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager`、`ScenicSpotContext` | ## UI Test 启动约定 - `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。 -- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。 +- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 UserDefaults 中的 token、账号快照和偏好。 - `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。 - `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings`、`realNameAuth`)。 - `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。 @@ -58,7 +72,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 - `AppSession` 切换为 `loggedIn`。 退出登录由 `AuthSessionCoordinator.logout` 统一处理: -- 清空 Keychain token。 +- 清空本地 token。 - 清空账号快照。 - 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。 - `AppSession` 切换为 `loggedOut`。 diff --git a/suixinkan/App/AppServiceEnvironment.swift b/suixinkan/App/AppServiceEnvironment.swift index 11fa053..712b597 100644 --- a/suixinkan/App/AppServiceEnvironment.swift +++ b/suixinkan/App/AppServiceEnvironment.swift @@ -45,6 +45,10 @@ private struct AuthAPIEnvironmentKey: EnvironmentKey { static var defaultValue: AuthAPI { AuthAPI(client: EnvironmentServiceDefaults.apiClient()) } } +private struct AppConfigAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: AppConfigAPI { AppConfigAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + private struct InviteAPIEnvironmentKey: EnvironmentKey { static var defaultValue: InviteAPI { InviteAPI(client: EnvironmentServiceDefaults.apiClient()) } } @@ -168,6 +172,11 @@ extension EnvironmentValues { set { self[AuthAPIEnvironmentKey.self] = newValue } } + var appConfigAPI: AppConfigAPI { + get { self[AppConfigAPIEnvironmentKey.self] } + set { self[AppConfigAPIEnvironmentKey.self] = newValue } + } + var inviteAPI: InviteAPI { get { self[InviteAPIEnvironmentKey.self] } set { self[InviteAPIEnvironmentKey.self] = newValue } diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 6108e0e..a9d1c0e 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -47,6 +47,8 @@ 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 appConfigAPI: AppConfigAPI /// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。 init() { @@ -56,6 +58,7 @@ struct RootView: View { _snapshotStore = State(initialValue: snapshotStore) _apiClient = State(initialValue: apiClient) _authAPI = State(initialValue: AuthAPI(client: apiClient)) + _appConfigAPI = State(initialValue: AppConfigAPI(client: apiClient)) _profileAPI = State(initialValue: ProfileAPI(client: apiClient)) let uploadAPI = UploadAPI(client: apiClient) _uploadAPI = State(initialValue: uploadAPI) @@ -97,7 +100,15 @@ struct RootView: View { } var body: some View { - rootContent + ZStack { + rootContent + + if !splashCoordinator.isFinished { + SplashView() + .transition(.opacity) + .zIndex(1) + } + } .globalToastOverlay() .globalLoadingOverlay(loadingCenter: globalLoading) .environmentObject(appSession) @@ -110,6 +121,7 @@ struct RootView: View { .environment(\.accountSnapshotStore, snapshotStore) .environment(\.apiClient, apiClient) .environment(\.authAPI, authAPI) + .environment(\.appConfigAPI, appConfigAPI) .environment(\.profileAPI, profileAPI) .environment(\.uploadAPI, uploadAPI) .environment(\.ossUploadService, ossUploadService) @@ -139,7 +151,7 @@ struct RootView: View { .task { apiClient.bindAuthTokenProvider { appSession.token } PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter) - await globalLoading.withLoading { + await splashCoordinator.start { await sessionBootstrapper.restore( appSession: appSession, accountContext: accountContext, diff --git a/suixinkan/App/SplashCoordinator.swift b/suixinkan/App/SplashCoordinator.swift new file mode 100644 index 0000000..0382c93 --- /dev/null +++ b/suixinkan/App/SplashCoordinator.swift @@ -0,0 +1,45 @@ +// +// SplashCoordinator.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Combine +import Foundation + +@MainActor +/// 启动页时序协调器,保证品牌页最短展示时长与冷启动 bootstrap 并行完成。 +final class SplashCoordinator: ObservableObject { + @Published private(set) var isFinished = false + + private let minimumDisplayDuration: TimeInterval + private let skipsMinimumDuration: Bool + + /// 创建启动页协调器,并配置最短展示时长与 UI Test 跳过策略。 + init( + minimumDisplayDuration: TimeInterval = 1.5, + 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/AuthSessionCoordinator.swift b/suixinkan/App/State/AuthSessionCoordinator.swift index 43b405b..411bae4 100644 --- a/suixinkan/App/State/AuthSessionCoordinator.swift +++ b/suixinkan/App/State/AuthSessionCoordinator.swift @@ -43,7 +43,7 @@ final class AuthSessionCoordinator { ) } - /// 完成正式登录,写入内存状态、Keychain token 和账号快照。 + /// 完成正式登录,写入内存状态、本地 token 和账号快照。 func completeLogin( with response: V9AuthResponse, username: String, diff --git a/suixinkan/Assets.xcassets/LaunchBackground.colorset/Contents.json b/suixinkan/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..97650a1 --- /dev/null +++ b/suixinkan/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/SplashLogo.imageset/Contents.json b/suixinkan/Assets.xcassets/SplashLogo.imageset/Contents.json new file mode 100644 index 0000000..644d848 --- /dev/null +++ b/suixinkan/Assets.xcassets/SplashLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon_splash.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/SplashLogo.imageset/icon_splash.png b/suixinkan/Assets.xcassets/SplashLogo.imageset/icon_splash.png new file mode 100644 index 0000000..2cfbec3 Binary files /dev/null and b/suixinkan/Assets.xcassets/SplashLogo.imageset/icon_splash.png differ diff --git a/suixinkan/Core/Core.md b/suixinkan/Core/Core.md index fe983cf..d3bb50e 100644 --- a/suixinkan/Core/Core.md +++ b/suixinkan/Core/Core.md @@ -42,12 +42,12 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存 ## Storage 本地缓存按安全级别拆分: -- `SessionTokenStore`:使用 Keychain 保存正式 token。 +- `SessionTokenStore`:使用 UserDefaults 保存正式 token。 - `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。 - `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。 缓存边界: -- 正式 token 只放 Keychain。 +- 正式 token 由 `SessionTokenStore` 写入 UserDefaults。 - 临时 token 只放内存。 - 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。 - 头像、证件照等图片缓存交给 Kingfisher,Core 不保存图片 Data。 @@ -86,7 +86,7 @@ Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部 `UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。 上传模块只保存内存状态: -- STS token 不写入 Keychain 或 UserDefaults。 +- STS token 不写入 UserDefaults。 - 用户选择的本地图片 Data 不落盘。 - 上传进度只用于当前页面展示。 diff --git a/suixinkan/Core/Storage/SessionTokenStore.swift b/suixinkan/Core/Storage/SessionTokenStore.swift index a24c823..b960170 100644 --- a/suixinkan/Core/Storage/SessionTokenStore.swift +++ b/suixinkan/Core/Storage/SessionTokenStore.swift @@ -6,33 +6,21 @@ // import Foundation -import Combine -import Security -/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。 -enum SessionTokenStoreError: LocalizedError { - case unexpectedStatus(OSStatus) - - var errorDescription: String? { - switch self { - case let .unexpectedStatus(status): - "登录凭证存储失败(\(status))" - } - } -} - -/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。 +/// 正式登录 token 存储服务,封装 UserDefaults 读写并保持业务层不接触缓存细节。 final class SessionTokenStore { - private let service: String - private let account: String + private let defaults: UserDefaults + private let key: String - /// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。 + /// 初始化 token 存储服务,默认按 App Bundle 和账号名隔离 UserDefaults 键。 init( + defaults: UserDefaults = .standard, + key: String? = nil, service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan", account: String = "session.token" ) { - self.service = service - self.account = account + self.defaults = defaults + self.key = key ?? "suixinkan.session.token.\(service).\(account)" } /// 保存正式 token,空 token 会被视为清空凭证。 @@ -43,38 +31,12 @@ final class SessionTokenStore { return } - let data = Data(trimmedToken.utf8) - let query = baseQuery() - let updateAttributes: [String: Any] = [kSecValueData as String: data] - - let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary) - if updateStatus == errSecSuccess { - return - } - guard updateStatus == errSecItemNotFound else { - throw SessionTokenStoreError.unexpectedStatus(updateStatus) - } - - var addQuery = query - updateAttributes.forEach { addQuery[$0.key] = $0.value } - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw SessionTokenStoreError.unexpectedStatus(addStatus) - } + defaults.set(trimmedToken, forKey: key) } /// 读取本地正式 token,读取失败或无值时返回 nil。 func load() -> String? { - var query = baseQuery() - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, - let data = result as? Data, - let token = String(data: data, encoding: .utf8)? + guard let token = defaults.string(forKey: key)? .trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { return nil @@ -84,18 +46,6 @@ final class SessionTokenStore { /// 清空本地正式 token。 func clear() throws { - let status = SecItemDelete(baseQuery() as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw SessionTokenStoreError.unexpectedStatus(status) - } - } - - /// 构造当前 App 使用的 Keychain 查询条件。 - private func baseQuery() -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] + defaults.removeObject(forKey: key) } } diff --git a/suixinkan/Core/Upload/Upload.md b/suixinkan/Core/Upload/Upload.md index 6f91c45..f15d545 100644 --- a/suixinkan/Core/Upload/Upload.md +++ b/suixinkan/Core/Upload/Upload.md @@ -49,4 +49,4 @@ Upload 模块负责 App 内通用文件上传能力,当前主要服务个人 - OSS STS token 不落盘,只在一次上传流程中临时使用。 - 原始图片 Data、压缩后图片 Data、上传进度不落盘。 - 图片展示缓存交给 Kingfisher,业务代码不自行保存网络图片文件。 -- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。 +- 正式登录 token 仍由 `SessionTokenStore` 使用 UserDefaults 保存,上传模块不直接读取或保存登录态。 diff --git a/suixinkan/Features/Auth/API/AppConfigAPI.swift b/suixinkan/Features/Auth/API/AppConfigAPI.swift new file mode 100644 index 0000000..87e046d --- /dev/null +++ b/suixinkan/Features/Auth/API/AppConfigAPI.swift @@ -0,0 +1,26 @@ +// +// AppConfigAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +@MainActor +/// App 远程配置 API,封装 `/api/app/config` 接口。 +final class AppConfigAPI { + private let client: APIClient + + /// 初始化 App 配置 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取 App 远程配置,例如是否开放注册入口。 + func fetchAppConfig() async throws -> AppConfigResponse { + try await client.send( + APIRequest(method: .get, path: "/api/app/config") + ) + } +} diff --git a/suixinkan/Features/Auth/Auth.md b/suixinkan/Features/Auth/Auth.md index 716d13a..8f13f0a 100644 --- a/suixinkan/Features/Auth/Auth.md +++ b/suixinkan/Features/Auth/Auth.md @@ -56,7 +56,7 @@ Auth 模块负责登录页、手机号密码登录、多账号选择和账号选 ## 缓存规则 Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责: -- 正式 token 写 Keychain。 +- 正式 token 写 UserDefaults。 - 上次手机号和协议状态写 UserDefaults。 - 账号资料、当前角色和业务作用域写入账号快照。 diff --git a/suixinkan/Features/Auth/Models/AppConfigModels.swift b/suixinkan/Features/Auth/Models/AppConfigModels.swift new file mode 100644 index 0000000..e0f5d69 --- /dev/null +++ b/suixinkan/Features/Auth/Models/AppConfigModels.swift @@ -0,0 +1,22 @@ +// +// AppConfigModels.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// App 远程配置响应,对齐 Android `AppConfigResponse`。 +struct AppConfigResponse: Decodable, Equatable { + let enableRegister: Bool + + enum CodingKeys: String, CodingKey { + case enableRegister = "enable_register" + } + + /// 创建 App 配置响应,默认关闭注册入口。 + init(enableRegister: Bool = false) { + self.enableRegister = enableRegister + } +} diff --git a/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift index 426211b..1691b6f 100644 --- a/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift +++ b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift @@ -78,6 +78,7 @@ final class LoginViewModel: ObservableObject { @Published var showsPassword = false @Published var showsAgreementSheet = false @Published var pendingAccountSelection: AccountSelectionPayload? + @Published private(set) var enableRegister = false var canSubmit: Bool { isValidPhone && !trimmedPassword.isEmpty @@ -124,6 +125,16 @@ final class LoginViewModel: ObservableObject { showsAgreementSheet = false } + /// 加载 App 远程配置;请求失败时保持默认值,与 Android 登录页行为一致。 + func loadAppConfig(appConfigAPI: AppConfigAPI) async { + do { + let config = try await appConfigAPI.fetchAppConfig() + enableRegister = config.enableRegister + } catch { + // 请求失败不做任何提示,保持默认值 false。 + } + } + /// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。 func normalizeUsernameCountryCodeIfNeeded() { let digits = username.filter(\.isNumber) diff --git a/suixinkan/Features/Auth/Views/LoginView.swift b/suixinkan/Features/Auth/Views/LoginView.swift index d0647e1..9d4cd5b 100644 --- a/suixinkan/Features/Auth/Views/LoginView.swift +++ b/suixinkan/Features/Auth/Views/LoginView.swift @@ -14,6 +14,7 @@ struct LoginView: View { @EnvironmentObject private var permissionContext: PermissionContext @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.authAPI) private var authAPI + @Environment(\.appConfigAPI) private var appConfigAPI @Environment(\.profileAPI) private var profileAPI @Environment(\.accountContextAPI) private var accountContextAPI @Environment(\.authSessionCoordinator) private var authSessionCoordinator @@ -85,6 +86,7 @@ struct LoginView: View { } .task { viewModel.applyPreferences(authSessionCoordinator.loginPreferences()) + await viewModel.loadAppConfig(appConfigAPI: appConfigAPI) } } diff --git a/suixinkan/Features/Splash/Splash.md b/suixinkan/Features/Splash/Splash.md new file mode 100644 index 0000000..0162188 --- /dev/null +++ b/suixinkan/Features/Splash/Splash.md @@ -0,0 +1,35 @@ +# Splash 模块业务逻辑 + +## 模块职责 + +Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `SplashActivity` + `activity_splash.xml` 的视觉规范。 + +该模块不处理登录、隐私协议或远程配置,只承担启动阶段的品牌展示和时序控制。 + +## 核心对象 + +- `SplashView`:展示白底、180pt Logo、「随心瞰 / 商家版」文案。 +- `SplashCoordinator`:并行执行最短 1.5 秒展示与 `SessionBootstrapper.restore`,两者都完成后结束 Splash。 +- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen`。 + +## 展示规则 + +- 背景色:白色 `#FFFFFF` +- Logo:`SplashLogo`,180×180 pt +- 标题:`随心瞰`,40pt,bold + italic,`AppDesign.primary` +- 副标题:`商家版`,20pt,bold,距底 32pt + +## 启动时序 + +1. iOS 系统 Launch Screen 展示白底 + Logo。 +2. `RootView` 挂载后立即显示 `SplashView`。 +3. `SplashCoordinator.start` 并行执行: + - 最短展示 1.5 秒(UI Test 模式跳过) + - `SessionBootstrapper.restore` 静默恢复登录态 +4. 两者都完成后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。 +5. 冷启动期间不显示 Lottie 全局 Loading。 + +## 与 Android 的差异 + +- Android 首启隐私弹窗在 Splash 上;iOS 仍在登录页勾选协议。 +- Android 系统 Launch Screen 与应用内 Splash 使用同一 XML;iOS 系统 Launch Screen 仅展示 Logo,文案由 SwiftUI Splash 补齐。 diff --git a/suixinkan/Features/Splash/Views/SplashView.swift b/suixinkan/Features/Splash/Views/SplashView.swift new file mode 100644 index 0000000..dbf8613 --- /dev/null +++ b/suixinkan/Features/Splash/Views/SplashView.swift @@ -0,0 +1,47 @@ +// +// SplashView.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import SwiftUI + +/// 启动页视图,对齐 Android `activity_splash.xml` 的品牌展示布局。 +struct SplashView: View { + var body: some View { + ZStack { + Color.white + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + Image("SplashLogo") + .resizable() + .scaledToFit() + .frame(width: 180, height: 180) + .accessibilityIdentifier("splash.logo") + + Text("随心瞰") + .font(.system(size: 40, weight: .bold)) + .italic() + .foregroundStyle(AppDesign.primary) + .accessibilityIdentifier("splash.title") + + Spacer() + + Text("商家版") + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(AppDesign.primary) + .padding(.bottom, 32) + .accessibilityIdentifier("splash.subtitle") + } + } + .accessibilityIdentifier("splash.root") + } +} + +#Preview { + SplashView() +} diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index 6edd58f..9e281b3 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -42,7 +42,14 @@ remote-notification UILaunchScreen - + + UIColorName + LaunchBackground + UIImageName + SplashLogo + UIImageRespectsSafeAreaInsets + + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/suixinkanTests/AppConfigTests.swift b/suixinkanTests/AppConfigTests.swift new file mode 100644 index 0000000..3fcb77e --- /dev/null +++ b/suixinkanTests/AppConfigTests.swift @@ -0,0 +1,87 @@ +// +// AppConfigTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// App 配置接口测试,覆盖 API 请求路径和登录页配置加载行为。 +final class AppConfigTests: XCTestCase { + /// 测试 App 配置接口使用正确路径并解析 enable_register 字段。 + func testAppConfigAPIRequestsConfigEndpoint() async throws { + let response = """ + { + "code": 100000, + "msg": "success", + "data": { + "enable_register": true + } + } + """.data(using: .utf8)! + let session = AppConfigURLSession(responses: [response]) + let api = AppConfigAPI(client: APIClient(session: session)) + + let config = try await api.fetchAppConfig() + + XCTAssertTrue(config.enableRegister) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/config") + } + + /// 测试登录页加载 App 配置成功时会更新 enableRegister。 + func testLoginViewModelLoadAppConfigUpdatesEnableRegister() async throws { + let response = """ + { + "code": 100000, + "msg": "success", + "data": { + "enable_register": true + } + } + """.data(using: .utf8)! + let session = AppConfigURLSession(responses: [response]) + let api = AppConfigAPI(client: APIClient(session: session)) + let viewModel = LoginViewModel() + + await viewModel.loadAppConfig(appConfigAPI: api) + + XCTAssertTrue(viewModel.enableRegister) + } + + /// 测试登录页加载 App 配置失败时保持默认值。 + func testLoginViewModelLoadAppConfigKeepsDefaultOnFailure() async { + let session = AppConfigURLSession(responses: [Data()]) + let api = AppConfigAPI(client: APIClient(session: session)) + let viewModel = LoginViewModel() + + await viewModel.loadAppConfig(appConfigAPI: api) + + XCTAssertFalse(viewModel.enableRegister) + } +} + +/// App 配置测试用 URLSession 替身,按顺序返回预设响应。 +private final class AppConfigURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化顺序响应。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回下一份响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} diff --git a/suixinkanTests/ProfileSecondaryPagesTests.swift b/suixinkanTests/ProfileSecondaryPagesTests.swift index 5de60af..807d398 100644 --- a/suixinkanTests/ProfileSecondaryPagesTests.swift +++ b/suixinkanTests/ProfileSecondaryPagesTests.swift @@ -191,7 +191,7 @@ final class ProfileSecondaryPagesTests: XCTestCase { /// 测试接口数据已存在时不再被本地缓存覆盖。 func testApplyCachedProfileIgnoredAfterUserInfoLoaded() { let viewModel = ProfileViewModel() - viewModel.userInfo = UserInfoResponse(nickname: "接口昵称", phone: "13900000000") + viewModel.userInfo = UserInfoResponse(phone: "13900000000", nickname: "接口昵称") viewModel.applyCachedProfile( AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000") ) diff --git a/suixinkanTests/SplashCoordinatorTests.swift b/suixinkanTests/SplashCoordinatorTests.swift new file mode 100644 index 0000000..a136add --- /dev/null +++ b/suixinkanTests/SplashCoordinatorTests.swift @@ -0,0 +1,68 @@ +// +// SplashCoordinatorTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 启动页协调器测试,覆盖最短展示时长、bootstrap 并行和 UI Test 跳过策略。 +final class SplashCoordinatorTests: XCTestCase { + /// 测试 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/StorageTests.swift b/suixinkanTests/StorageTests.swift index 0dd0b11..b356e1e 100644 --- a/suixinkanTests/StorageTests.swift +++ b/suixinkanTests/StorageTests.swift @@ -10,6 +10,29 @@ import XCTest /// 本地缓存 Store 测试,覆盖 UserDefaults 中的偏好和账号快照。 final class StorageTests: XCTestCase { + /// 测试 SessionTokenStore 使用 UserDefaults 保存、读取、修剪和清空正式 token。 + func testSessionTokenStorePersistsTokenInUserDefaults() throws { + let defaults = makeIsolatedDefaults() + let store = SessionTokenStore(defaults: defaults, key: "session.token.test") + + try store.save(" token-123 ") + XCTAssertEqual(store.load(), "token-123") + + try store.clear() + XCTAssertNil(store.load()) + } + + /// 测试保存空 token 会清空本地正式 token。 + func testSessionTokenStoreClearsWhenSavingBlankToken() throws { + let defaults = makeIsolatedDefaults() + let store = SessionTokenStore(defaults: defaults, key: "session.token.blank.test") + + try store.save("token-123") + try store.save(" \n") + + XCTAssertNil(store.load()) + } + /// 测试 AppPreferencesStore 能保存并读取上次登录手机号和协议状态。 func testAppPreferencesStorePersistsLoginPreferences() { let defaults = makeIsolatedDefaults() diff --git a/suixinkanUITests/Auth/ZLoginSmokeUITests.swift b/suixinkanUITests/Auth/ZLoginSmokeUITests.swift index a738e2a..5f70ad0 100644 --- a/suixinkanUITests/Auth/ZLoginSmokeUITests.swift +++ b/suixinkanUITests/Auth/ZLoginSmokeUITests.swift @@ -2,7 +2,7 @@ // ZLoginSmokeUITests.swift // suixinkanUITests // -// 命名以 Z 开头,确保在全套件末尾执行,避免清空 Keychain 影响其它用例。 +// 命名以 Z 开头,确保在全套件末尾执行,避免清空 UserDefaults 登录态影响其它用例。 // import XCTest diff --git a/suixinkanUITests/README.md b/suixinkanUITests/README.md index 485c958..7783f1e 100644 --- a/suixinkanUITests/README.md +++ b/suixinkanUITests/README.md @@ -57,7 +57,7 @@ xcodebuild test \ UI Test 进程会自动传入: - `-suixinkan-ui-tests`:主开关,App 侧跳过推送注册与排队 WebSocket -- `-suixinkan-ui-tests-reset-state`:冷启动清理 Keychain / UserDefaults +- `-suixinkan-ui-tests-reset-state`:冷启动清理 UserDefaults 中的 token、账号快照和偏好 - `-suixinkan-ui-tests-open-menu <菜单标题>`:登录后直达首页调试目录中的页面 - `-suixinkan-ui-tests-open-profile <路由名>`:登录后直达个人中心二级页(如 `settings`) diff --git a/suixinkanUITests/Support/SuixinkanUIApplication.swift b/suixinkanUITests/Support/SuixinkanUIApplication.swift index b915937..750dc7a 100644 --- a/suixinkanUITests/Support/SuixinkanUIApplication.swift +++ b/suixinkanUITests/Support/SuixinkanUIApplication.swift @@ -43,12 +43,23 @@ final class SuixinkanApp { hasLaunched = true } dismissSystemAlertsIfNeeded() + waitForSplashToDisappearIfNeeded() if waitForLoading { waitForGlobalLoadingToDisappear() } return self } + /// 等待启动页消失后再继续后续断言。 + func waitForSplashToDisappearIfNeeded(timeout: TimeInterval = 5) { + let splash = application.otherElements["splash.root"] + guard splash.waitForExistence(timeout: 2) else { return } + + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: splash) + _ = XCTWaiter.wait(for: [expectation], timeout: timeout) + } + /// 等待全局 Loading 层消失。 func waitForGlobalLoadingToDisappear(timeout: TimeInterval = 45) { let loading = application.staticTexts["加载中"] diff --git a/suixinkanUITests/Support/UITestSession.swift b/suixinkanUITests/Support/UITestSession.swift index 3857ffe..7284684 100644 --- a/suixinkanUITests/Support/UITestSession.swift +++ b/suixinkanUITests/Support/UITestSession.swift @@ -25,7 +25,7 @@ enum UITestSession { return launched } - /// 以指定首页菜单直达参数重启 App,并复用 Keychain 登录态。 + /// 以指定首页菜单直达参数重启 App,并复用 UserDefaults 登录态。 @discardableResult static func relaunch( openMenu menuTitle: String, @@ -44,7 +44,7 @@ enum UITestSession { return relaunched } - /// 以指定个人中心路由直达参数重启 App,并复用 Keychain 登录态。 + /// 以指定个人中心路由直达参数重启 App,并复用 UserDefaults 登录态。 @discardableResult static func relaunch( openProfile route: String, diff --git a/功能同步Checklist.md b/功能同步Checklist.md index f77b2f1..01385d6 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -16,7 +16,7 @@ | 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab;主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 | | 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tab;push 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | | 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | -| 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | +| 完成 | 登录态缓存 | token、账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | | 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | | 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 | | 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 |