diff --git a/AGENTS.md b/AGENTS.md index de75865..fb215d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,9 @@ Kingfisher - 用于显示网络图片 + Lottie + - 用于展示全局 Loading 动画 + --- diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 1a7eedf..f8ce19e 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 11A5FC66EE3769DAC202A397 /* Pods_suixinkanTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */; }; 93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */; }; 93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */; }; + 93DAED112FE9100000B9E2B1 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED102FE9100000B9E2B1 /* Lottie */; }; AE69C4E776CD531A1BB67C15 /* Pods_suixinkan.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 454D6E9EC1585878877CF5FD /* Pods_suixinkan.framework */; }; /* End PBXBuildFile section */ @@ -58,6 +59,7 @@ files = ( 93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */, 93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */, + 93DAED112FE9100000B9E2B1 /* Lottie in Frameworks */, AE69C4E776CD531A1BB67C15 /* Pods_suixinkan.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -137,6 +139,7 @@ packageProductDependencies = ( 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */, 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */, + 93DAED102FE9100000B9E2B1 /* Lottie */, ); productName = suixinkan; productReference = 939AC7962FE3F832004B22E4 /* suixinkan.app */; @@ -195,6 +198,7 @@ packageReferences = ( 93DAED092FE8E14D00B9E2B1 /* XCRemoteSwiftPackageReference "Kingfisher" */, 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */, + 93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */, ); preferredProjectObjectVersion = 77; productRefGroup = 939AC7972FE3F832004B22E4 /* Products */; @@ -644,6 +648,14 @@ minimumVersion = 0.2.0; }; }; + 93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/airbnb/lottie-ios.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.5.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -657,6 +669,11 @@ package = 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */; productName = AlibabaCloudOSS; }; + 93DAED102FE9100000B9E2B1 /* Lottie */ = { + isa = XCSwiftPackageProductDependency; + package = 93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */; + productName = Lottie; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 939AC78E2FE3F832004B22E4 /* Project object */; diff --git a/suixinkan.xcworkspace/xcshareddata/swiftpm/Package.resolved b/suixinkan.xcworkspace/xcshareddata/swiftpm/Package.resolved index e6e1acb..6209188 100644 --- a/suixinkan.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/suixinkan.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "dca97369973ef74cd9a7eb6d4579ab8af27675808e732def830661fbc4778f6f", + "originHash" : "6f02892b99a60872df0561ceead8949f780d607eccd0a776d8f299248fc13a72", "pins" : [ { "identity" : "alibabacloud-oss-swift-sdk-v2", @@ -19,6 +19,15 @@ "version" : "8.10.0" } }, + { + "identity" : "lottie-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/airbnb/lottie-ios.git", + "state" : { + "revision" : "f4db77d7feacba0c2360b84a40c38a6ce8ff399d", + "version" : "4.6.1" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index b782321..f6adcae 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -15,6 +15,7 @@ struct RootView: View { @State private var scenicSpotContext = ScenicSpotContext() @State private var appRouter = AppRouter() @State private var toastCenter = ToastCenter() + @State private var globalLoading = GlobalLoadingCenter() @State private var snapshotStore: AccountSnapshotStore @State private var apiClient: APIClient @State private var authAPI: AuthAPI @@ -66,12 +67,14 @@ struct RootView: View { var body: some View { rootContent .globalToastOverlay() + .globalLoadingOverlay(loadingCenter: globalLoading) .environment(appSession) .environment(accountContext) .environment(permissionContext) .environment(scenicSpotContext) .environment(appRouter) .environment(toastCenter) + .environment(\.globalLoading, globalLoading) .environment(snapshotStore) .environment(apiClient) .environment(authAPI) @@ -87,14 +90,16 @@ struct RootView: View { .environment(authSessionCoordinator) .task { apiClient.bindAuthTokenProvider { appSession.token } - await sessionBootstrapper.restore( - appSession: appSession, - accountContext: accountContext, - permissionContext: permissionContext, - profileAPI: profileAPI, - accountContextAPI: accountContextAPI, - toastCenter: toastCenter - ) + await globalLoading.withLoading { + await sessionBootstrapper.restore( + appSession: appSession, + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: profileAPI, + accountContextAPI: accountContextAPI, + toastCenter: toastCenter + ) + } } .task(id: scenicSpotTaskID) { guard appSession.isLoggedIn else { @@ -122,9 +127,8 @@ struct RootView: View { case .loggedOut: LoginView() case .restoring: - ProgressView() + Color(.systemBackground) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemBackground)) case .loggedIn: MainTabsView() } diff --git a/suixinkan/Core/Core.md b/suixinkan/Core/Core.md index fcffc1a..d8a6182 100644 --- a/suixinkan/Core/Core.md +++ b/suixinkan/Core/Core.md @@ -65,6 +65,14 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存 新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。 +### 全局 Loading + +`GlobalLoadingCenter` 是全局 Loading 的命令中心,只负责展示加载状态,不保存业务数据。业务 View 只能通过 Environment 获取它并调用 `show`、`hide`、`updateMessage`、`withLoading` 或 `withOptionalLoading`,不要在 `body` 中读取 `isVisible`、`message` 等展示状态。 + +Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay,不会让当前页面、Tab 根视图或业务子视图形成观察依赖。 + +全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。 + ## Upload `UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。 diff --git a/suixinkan/Core/Design/GlobalLoadingCenter.swift b/suixinkan/Core/Design/GlobalLoadingCenter.swift new file mode 100644 index 0000000..bafe627 --- /dev/null +++ b/suixinkan/Core/Design/GlobalLoadingCenter.swift @@ -0,0 +1,225 @@ +// +// GlobalLoadingCenter.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Combine +import Foundation +import Lottie +import SwiftUI + +@MainActor +/// 全局 Loading 命令中心,只负责展示状态控制,不承载业务数据。 +final class GlobalLoadingCenter { + fileprivate let state = GlobalLoadingState() + + /// 显示全局 Loading;多次调用会通过引用计数叠加。 + func show(message: String = "") { + if !message.isEmpty { + state.message = message + } + state.activeCount += 1 + state.isVisible = true + } + + /// 隐藏一次全局 Loading;所有调用方都结束后才真正关闭。 + func hide() { + state.activeCount = max(0, state.activeCount - 1) + guard state.activeCount == 0 else { return } + state.isVisible = false + state.message = "" + } + + /// 更新当前 Loading 文案,仅在 Loading 可见且文案非空时生效。 + func updateMessage(_ message: String) { + guard state.isVisible, !message.isEmpty else { return } + state.message = message + } + + /// 包裹一个异步操作,操作结束或抛错时自动关闭 Loading。 + func withLoading( + message: String = "", + operation: () async throws -> T + ) async rethrows -> T { + show(message: message) + defer { hide() } + return try await operation() + } + + /// 根据开关决定是否展示 Loading,适合下拉刷新等可选 loading 场景。 + func withOptionalLoading( + _ enabled: Bool, + message: String = "", + operation: () async throws -> T + ) async rethrows -> T { + if enabled { + return try await withLoading(message: message, operation: operation) + } + return try await operation() + } + + #if DEBUG + /// 测试专用快照,业务代码不应读取 Loading 展示状态。 + var snapshotForTests: GlobalLoadingSnapshot { + GlobalLoadingSnapshot( + isVisible: state.isVisible, + message: state.message, + activeCount: state.activeCount + ) + } + #endif +} + +@MainActor +/// 全局 Loading 的私有可观察状态,只允许根部 Overlay 订阅。 +fileprivate final class GlobalLoadingState: ObservableObject { + @Published fileprivate var isVisible = false + @Published fileprivate var message = "" + fileprivate var activeCount = 0 +} + +/// Lottie Loading 动画容器,负责播放主包中的 loading.json。 +struct LottieLoadingAnimationView: UIViewRepresentable { + /// 判断当前包内是否存在可用 Loading 动画资源。 + static var hasAnimationResource: Bool { + loadAnimation() != nil + } + + /// 创建承载 Lottie 动画的 UIKit 视图。 + func makeUIView(context: Context) -> UIView { + let container = UIView() + container.backgroundColor = .clear + + let animationView = LottieAnimationView() + animationView.translatesAutoresizingMaskIntoConstraints = false + animationView.contentMode = .scaleAspectFit + animationView.loopMode = .loop + animationView.backgroundBehavior = .pauseAndRestore + animationView.animation = Self.loadAnimation() + animationView.play() + + container.addSubview(animationView) + NSLayoutConstraint.activate([ + animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + animationView.topAnchor.constraint(equalTo: container.topAnchor), + animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + return container + } + + /// Loading 动画无需响应 SwiftUI 状态更新。 + func updateUIView(_ uiView: UIView, context: Context) {} + + /// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。 + private static func loadAnimation() -> LottieAnimation? { + if let animation = LottieAnimation.named("loading") { + return animation + } + if let url = Bundle.main.url(forResource: "loading", withExtension: "json", subdirectory: "Resources") { + return LottieAnimation.filepath(url.path) + } + if let url = Bundle.main.url(forResource: "loading", withExtension: "json") { + return LottieAnimation.filepath(url.path) + } + return nil + } +} + +/// 全局 Loading Overlay 宿主,只在根部订阅 Loading 状态,避免业务页面重绘。 +private struct GlobalLoadingOverlayHost: View { + @ObservedObject private var state: GlobalLoadingState + + /// 使用全局 Loading 命令中心初始化 Overlay 宿主。 + init(loadingCenter: GlobalLoadingCenter) { + _state = ObservedObject(wrappedValue: loadingCenter.state) + } + + var body: some View { + ZStack { + if state.isVisible { + ZStack { + Color.black.opacity(0.28) + .ignoresSafeArea() + + VStack(spacing: 14) { + loadingAnimation + + if !state.message.isEmpty { + Text(state.message) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Color(hex: 0x333333)) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.horizontal, 8) + } + } + .padding(.horizontal, 28) + .padding(.vertical, 24) + .background(.white, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .shadow(color: Color.black.opacity(0.12), radius: 24, x: 0, y: 10) + } + .transition(.opacity) + .zIndex(10_000) + .accessibilityElement(children: .combine) + .accessibilityLabel(state.message.isEmpty ? "加载中" : state.message) + } + } + .animation(.easeInOut(duration: 0.2), value: state.isVisible) + } + + @ViewBuilder + private var loadingAnimation: some View { + if LottieLoadingAnimationView.hasAnimationResource { + LottieLoadingAnimationView() + .frame(width: 132, height: 132) + } else { + ProgressView() + .controlSize(.large) + .tint(AppDesign.primary) + .frame(width: 132, height: 132) + } + } +} + +private struct GlobalLoadingOverlayModifier: ViewModifier { + let loadingCenter: GlobalLoadingCenter + + /// 在页面根部覆盖全局 Loading 展示层。 + func body(content: Content) -> some View { + ZStack { + content + GlobalLoadingOverlayHost(loadingCenter: loadingCenter) + } + } +} + +extension View { + /// 挂载全局 Loading 展示层,通常只应在 RootView 调用一次。 + func globalLoadingOverlay(loadingCenter: GlobalLoadingCenter) -> some View { + modifier(GlobalLoadingOverlayModifier(loadingCenter: loadingCenter)) + } +} + +#if DEBUG +/// 全局 Loading 测试快照,用于验证引用计数和展示文案。 +struct GlobalLoadingSnapshot: Equatable { + let isVisible: Bool + let message: String + let activeCount: Int +} +#endif + +private struct GlobalLoadingCenterKey: EnvironmentKey { + static let defaultValue = GlobalLoadingCenter() +} + +extension EnvironmentValues { + /// 全局 Loading 命令中心,业务页面只能通过它发出 show/hide 指令。 + var globalLoading: GlobalLoadingCenter { + get { self[GlobalLoadingCenterKey.self] } + set { self[GlobalLoadingCenterKey.self] = newValue } + } +} diff --git a/suixinkan/Features/Auth/Views/AccountSelectionView.swift b/suixinkan/Features/Auth/Views/AccountSelectionView.swift index 2c5b951..786dfe3 100644 --- a/suixinkan/Features/Auth/Views/AccountSelectionView.swift +++ b/suixinkan/Features/Auth/Views/AccountSelectionView.swift @@ -71,15 +71,9 @@ struct AccountSelectionView: View { guard let selectedAccount else { return } onConfirm(selectedAccount) } label: { - HStack(spacing: AppMetrics.Spacing.xSmall) { - if isLoading { - ProgressView() - .tint(.white) - } - Text("进入系统") - .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) - } - .foregroundStyle(.white) + Text("进入系统") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: AppMetrics.ControlSize.primaryButtonHeight) .background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) diff --git a/suixinkan/Features/Auth/Views/LoginView.swift b/suixinkan/Features/Auth/Views/LoginView.swift index 49c9cce..54d8fe9 100644 --- a/suixinkan/Features/Auth/Views/LoginView.swift +++ b/suixinkan/Features/Auth/Views/LoginView.swift @@ -17,6 +17,7 @@ struct LoginView: View { @Environment(ProfileAPI.self) private var profileAPI @Environment(AccountContextAPI.self) private var accountContextAPI @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator + @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var viewModel = LoginViewModel() @FocusState private var focusedField: LoginField? @@ -111,27 +112,13 @@ struct LoginView: View { Spacer().frame(height: AppMetrics.Spacing.mediumLarge) Button(action: loginAction) { - HStack { - if viewModel.isLoading { - ProgressView() - .tint(.white) - .frame(width: AppMetrics.ControlSize.progressWidth) - } else { - Spacer() - .frame(width: AppMetrics.ControlSize.progressWidth) - } - - Text("登录") - .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) - .foregroundStyle(.white) - - Spacer() - .frame(width: AppMetrics.ControlSize.progressWidth) - } - .frame(maxWidth: .infinity) - .frame(height: AppMetrics.ControlSize.primaryButtonHeight) - .background(viewModel.canSubmit ? AppDesign.primary : Color.gray) - .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + Text("登录") + .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + .background(viewModel.canSubmit ? AppDesign.primary : Color.gray) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) } .disabled(!viewModel.canSubmit || viewModel.isLoading) .accessibilityIdentifier("login.submit") @@ -167,12 +154,14 @@ struct LoginView: View { Task { do { - let resolution = try await viewModel.login(authAPI: authAPI) - switch resolution { - case let .completed(response): - await completeLogin(with: response) - case .needsAccountSelection: - break + try await globalLoading.withLoading(message: "登录中...") { + let resolution = try await viewModel.login(authAPI: authAPI) + switch resolution { + case let .completed(response): + await completeLogin(with: response) + case .needsAccountSelection: + break + } } } catch is CancellationError { // Keep cancellation silent; the current task was abandoned by SwiftUI. @@ -186,8 +175,10 @@ struct LoginView: View { private func selectAccount(_ account: AccountSwitchAccount) { Task { do { - let response = try await viewModel.selectAccount(account, authAPI: authAPI) - await completeLogin(with: response) + try await globalLoading.withLoading(message: "账号切换中...") { + let response = try await viewModel.selectAccount(account, authAPI: authAPI) + await completeLogin(with: response) + } } catch is CancellationError { // Keep cancellation silent; the current task was abandoned by SwiftUI. } catch { diff --git a/suixinkan/Features/Orders/Views/OrderDetailViews.swift b/suixinkan/Features/Orders/Views/OrderDetailViews.swift index a9060a8..ae942ad 100644 --- a/suixinkan/Features/Orders/Views/OrderDetailViews.swift +++ b/suixinkan/Features/Orders/Views/OrderDetailViews.swift @@ -13,6 +13,7 @@ struct StoreOrderDetailView: View { @Environment(OrdersAPI.self) private var ordersAPI @Environment(RouterPath.self) private var router @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading let item: OrderEntity @State private var viewModel: OrderDetailViewModel @@ -25,16 +26,6 @@ struct StoreOrderDetailView: View { var body: some View { List { - if viewModel.loading { - Section { - HStack { - Spacer() - ProgressView("加载详情中...") - Spacer() - } - } - } - if let contextMessage = viewModel.contextMessage { Section { Label(contextMessage, systemImage: "info.circle") @@ -117,7 +108,9 @@ struct StoreOrderDetailView: View { .navigationTitle("订单详情") .navigationBarTitleDisplayMode(.inline) .task { - await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id) + await globalLoading.withLoading(message: "加载详情中...") { + await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id) + } } .alert("提示", isPresented: errorBinding) { Button("知道了", role: .cancel) {} diff --git a/suixinkan/Features/Orders/Views/OrdersView.swift b/suixinkan/Features/Orders/Views/OrdersView.swift index d95ff3d..c41702d 100644 --- a/suixinkan/Features/Orders/Views/OrdersView.swift +++ b/suixinkan/Features/Orders/Views/OrdersView.swift @@ -15,6 +15,7 @@ struct OrdersView: View { @Environment(RouterPath.self) private var router @Environment(OrdersAPI.self) private var ordersAPI @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var viewModel = OrdersViewModel() @@ -227,10 +228,10 @@ struct OrdersView: View { private var storeList: some View { LazyVStack(spacing: AppMetrics.Spacing.small) { - if viewModel.loading { - ProgressView() + if viewModel.loading && viewModel.storeOrders.isEmpty { + Color.clear .frame(maxWidth: .infinity) - .padding(.vertical, AppMetrics.Spacing.xxLarge) + .frame(minHeight: 260) } else if viewModel.storeOrders.isEmpty { ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。")) .frame(minHeight: 260) @@ -300,10 +301,10 @@ struct OrdersView: View { private var writeOffList: some View { LazyVStack(spacing: AppMetrics.Spacing.small) { - if viewModel.loading { - ProgressView() + if viewModel.loading && viewModel.writeOffOrders.isEmpty { + Color.clear .frame(maxWidth: .infinity) - .padding(.vertical, AppMetrics.Spacing.xxLarge) + .frame(minHeight: 260) } else if viewModel.writeOffOrders.isEmpty { ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。")) .frame(minHeight: 260) @@ -516,18 +517,31 @@ struct OrdersView: View { private func reload(showLoading: Bool = true) async { do { - try await viewModel.reload( - api: ordersAPI, - scenicId: currentScenicId, - storeId: currentStoreId, - roleId: currentRoleId, - showLoading: showLoading - ) + try await globalLoading.withOptionalLoading(shouldShowGlobalLoading(showLoading: showLoading), message: "加载中...") { + try await viewModel.reload( + api: ordersAPI, + scenicId: currentScenicId, + storeId: currentStoreId, + roleId: currentRoleId, + showLoading: showLoading + ) + } } catch { toastCenter.show(error.localizedDescription) } } + /// 判断当前订单列表加载是否需要使用全局 Loading。 + private func shouldShowGlobalLoading(showLoading: Bool) -> Bool { + guard showLoading, currentScenicId != nil else { return false } + switch viewModel.selectedEntry { + case .storeOrders: + return viewModel.storeOrders.isEmpty + case .verificationOrders: + return viewModel.writeOffOrders.isEmpty + } + } + private func loadMoreStoreOrders() async { do { try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId) @@ -567,7 +581,9 @@ struct OrdersView: View { private func verify(orderNumber: String) async { guard let scenicId = currentScenicId else { return } do { - try await viewModel.verify(api: ordersAPI, scenicId: scenicId, storeId: currentStoreId, orderNumber: orderNumber) + try await globalLoading.withLoading(message: "核销中...") { + try await viewModel.verify(api: ordersAPI, scenicId: scenicId, storeId: currentStoreId, orderNumber: orderNumber) + } manualOrderNumber = "" pendingVerifyOrder = nil pendingVerifyOrderNumber = nil diff --git a/suixinkan/Features/Payment/Views/PaymentCollectionView.swift b/suixinkan/Features/Payment/Views/PaymentCollectionView.swift index 0166a27..3bcf2bc 100644 --- a/suixinkan/Features/Payment/Views/PaymentCollectionView.swift +++ b/suixinkan/Features/Payment/Views/PaymentCollectionView.swift @@ -14,6 +14,7 @@ struct PaymentCollectionView: View { @Environment(AccountContext.self) private var accountContext @Environment(PaymentAPI.self) private var paymentAPI @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading @State private var viewModel = PaymentCollectionViewModel() @State private var showingAmountSheet = false @@ -44,7 +45,9 @@ struct PaymentCollectionView: View { } } .task(id: accountContext.currentScenic?.id) { - await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) + await globalLoading.withOptionalLoading(viewModel.qrImage == nil, message: "加载中...") { + await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) + } } .sheet(isPresented: $showingAmountSheet) { amountSheet @@ -87,10 +90,7 @@ struct PaymentCollectionView: View { /// 二维码展示卡片。 private var qrCard: some View { VStack(spacing: AppMetrics.Spacing.medium) { - if viewModel.isLoading { - ProgressView() - .frame(width: 220, height: 220) - } else if let image = viewModel.qrImage { + if let image = viewModel.qrImage { Image(uiImage: image) .interpolation(.none) .resizable() @@ -129,7 +129,11 @@ struct PaymentCollectionView: View { saveQRCode() } paymentActionButton(title: "刷新", icon: "arrow.clockwise") { - Task { await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } + Task { + await globalLoading.withLoading(message: "刷新中...") { + await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) + } + } } } } @@ -255,14 +259,11 @@ struct PaymentCollectionRecordView: View { @Environment(AccountContext.self) private var accountContext @Environment(PaymentAPI.self) private var paymentAPI @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading @State private var viewModel = PaymentCollectionRecordViewModel() var body: some View { List { - if viewModel.isLoading { - ProgressView() - .frame(maxWidth: .infinity) - } ForEach(viewModel.groups) { group in Section { ForEach(group.items) { item in @@ -299,7 +300,9 @@ struct PaymentCollectionRecordView: View { await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } .task(id: accountContext.currentScenic?.id) { - await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id) + await globalLoading.withOptionalLoading(viewModel.groups.isEmpty, message: "加载中...") { + await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id) + } } .onChange(of: viewModel.errorMessage) { _, message in if let message { diff --git a/suixinkan/Features/Profile/Views/AccountSwitchView.swift b/suixinkan/Features/Profile/Views/AccountSwitchView.swift index 97a3f8f..00f88c7 100644 --- a/suixinkan/Features/Profile/Views/AccountSwitchView.swift +++ b/suixinkan/Features/Profile/Views/AccountSwitchView.swift @@ -18,6 +18,7 @@ struct AccountSwitchView: View { @Environment(AccountContextAPI.self) private var accountContextAPI @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading @Environment(\.dismiss) private var dismiss @State private var viewModel = AccountSwitchViewModel() @@ -71,15 +72,9 @@ struct AccountSwitchView: View { Button { Task { await confirmSelection() } } label: { - HStack(spacing: 8) { - if viewModel.switching { - ProgressView() - .tint(.white) - } - Text("确认切换") - .font(.system(size: 16, weight: .semibold)) - } - .foregroundStyle(.white) + Text("确认切换") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 50) .background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 12)) @@ -187,7 +182,9 @@ struct AccountSwitchView: View { /// 拉取账号列表。 private func loadAccounts(force: Bool = false) async { do { - try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId) + try await globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") { + try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId) + } } catch is CancellationError { return } catch { @@ -204,18 +201,20 @@ struct AccountSwitchView: View { } do { - let response = try await viewModel.switchAccount(account, api: authAPI) - let username = nonEmpty(accountContext.profile?.phone) ?? nonEmpty(account.phone) ?? "" - try await authSessionCoordinator.completeLogin( - with: response, - username: username, - privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted, - appSession: appSession, - accountContext: accountContext, - permissionContext: permissionContext, - profileAPI: profileAPI, - accountContextAPI: accountContextAPI - ) + try await globalLoading.withLoading(message: "账号切换中...") { + let response = try await viewModel.switchAccount(account, api: authAPI) + let username = nonEmpty(accountContext.profile?.phone) ?? nonEmpty(account.phone) ?? "" + try await authSessionCoordinator.completeLogin( + with: response, + username: username, + privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted, + appSession: appSession, + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: profileAPI, + accountContextAPI: accountContextAPI + ) + } appRouter.reset() toastCenter.show("账号已切换") dismiss() diff --git a/suixinkan/Features/Profile/Views/ProfileView.swift b/suixinkan/Features/Profile/Views/ProfileView.swift index f1e51a0..dc79b3d 100644 --- a/suixinkan/Features/Profile/Views/ProfileView.swift +++ b/suixinkan/Features/Profile/Views/ProfileView.swift @@ -21,6 +21,7 @@ struct ProfileView: View { @Environment(OSSUploadService.self) private var ossUploadService @Environment(ToastCenter.self) private var toastCenter @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator + @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var viewModel = ProfileViewModel() @@ -73,14 +74,6 @@ struct ProfileView: View { } Button("取消", role: .cancel) {} } - .overlay { - if viewModel.isLoading && viewModel.userInfo == nil { - ProgressView() - .controlSize(.large) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.white.opacity(0.35)) - } - } .onChange(of: viewModel.isEditingProfile) { _, isEditing in guard isEditing else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { @@ -512,7 +505,9 @@ struct ProfileView: View { /// 重新拉取个人资料,并同步更新全局账号资料。 private func reloadProfile(showToast: Bool) async { do { - try await viewModel.reload(api: profileAPI) + try await globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载中...") { + try await viewModel.reload(api: profileAPI) + } if let userInfo = viewModel.userInfo { authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext) } else { @@ -531,11 +526,13 @@ struct ProfileView: View { /// 保存昵称编辑内容,并在成功后刷新全局账号展示。 private func saveProfileEdits() async { do { - try await viewModel.saveProfile( - api: profileAPI, - uploader: ossUploadService, - scenicId: accountContext.currentScenic?.id ?? 0 - ) + try await globalLoading.withLoading(message: "保存中...") { + try await viewModel.saveProfile( + api: profileAPI, + uploader: ossUploadService, + scenicId: accountContext.currentScenic?.id ?? 0 + ) + } if let userInfo = viewModel.userInfo { authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext) } else { diff --git a/suixinkan/Features/Profile/Views/RealNameAuthView.swift b/suixinkan/Features/Profile/Views/RealNameAuthView.swift index ca79638..27baa0e 100644 --- a/suixinkan/Features/Profile/Views/RealNameAuthView.swift +++ b/suixinkan/Features/Profile/Views/RealNameAuthView.swift @@ -15,6 +15,7 @@ struct RealNameAuthView: View { @Environment(ProfileAPI.self) private var profileAPI @Environment(OSSUploadService.self) private var ossUploadService @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading @State private var viewModel = RealNameAuthViewModel() @State private var pickedFrontItem: PhotosPickerItem? @State private var pickedBackItem: PhotosPickerItem? @@ -37,14 +38,6 @@ struct RealNameAuthView: View { .task { await loadInfo() } - .overlay { - if viewModel.loading && viewModel.info == nil { - ProgressView() - .controlSize(.large) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.white.opacity(0.35)) - } - } .onChange(of: pickedFrontItem) { _, item in Task { await prepareIdentityImage(from: item, side: .front) } } @@ -380,7 +373,9 @@ struct RealNameAuthView: View { /// 拉取实名认证信息。 private func loadInfo() async { do { - try await viewModel.load(api: profileAPI) + try await globalLoading.withOptionalLoading(viewModel.info == nil, message: "加载中...") { + try await viewModel.load(api: profileAPI) + } } catch is CancellationError { return } catch { @@ -400,11 +395,13 @@ struct RealNameAuthView: View { /// 提交实名资料。 private func submit() async { do { - try await viewModel.submit( - api: profileAPI, - uploader: ossUploadService, - scenicId: accountContext.currentScenic?.id ?? 0 - ) + try await globalLoading.withLoading(message: "提交中...") { + try await viewModel.submit( + api: profileAPI, + uploader: ossUploadService, + scenicId: accountContext.currentScenic?.id ?? 0 + ) + } } catch { viewModel.statusMessage = error.localizedDescription toastCenter.show(error.localizedDescription) diff --git a/suixinkan/Features/Statistics/Views/StatisticsView.swift b/suixinkan/Features/Statistics/Views/StatisticsView.swift index 06238a3..008f2dd 100644 --- a/suixinkan/Features/Statistics/Views/StatisticsView.swift +++ b/suixinkan/Features/Statistics/Views/StatisticsView.swift @@ -13,6 +13,7 @@ struct StatisticsView: View { @Environment(PermissionContext.self) private var permissionContext @Environment(StatisticsAPI.self) private var statisticsAPI @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var viewModel = StatisticsViewModel() @@ -165,8 +166,8 @@ struct StatisticsView: View { Divider() - if viewModel.loading { - ProgressView() + if viewModel.loading && viewModel.dailyItems.isEmpty { + Color.clear .frame(maxWidth: .infinity) .frame(minHeight: 280) } else if viewModel.dailyItems.isEmpty { @@ -287,7 +288,9 @@ struct StatisticsView: View { private func selectPeriod(_ period: StatisticsPeriod) async { do { - try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId) + try await globalLoading.withLoading(message: "加载中...") { + try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId) + } } catch { toastCenter.show(error.localizedDescription) } @@ -295,7 +298,9 @@ struct StatisticsView: View { private func reload(showLoading: Bool = true) async { do { - try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading) + try await globalLoading.withOptionalLoading(showLoading && currentScenicId != nil && viewModel.dailyItems.isEmpty, message: "加载中...") { + try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading) + } } catch { toastCenter.show(error.localizedDescription) } diff --git a/suixinkan/Resources/loading.json b/suixinkan/Resources/loading.json new file mode 100644 index 0000000..7bd0c82 --- /dev/null +++ b/suixinkan/Resources/loading.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE 1.0.0","a":"","k":"","d":"","tc":"#FFFFFF"},"fr":25,"ip":25,"op":55,"w":800,"h":800,"nm":"Loading #18","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[20]}],"ix":11},"r":{"a":0,"k":90,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[-175,46,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-2,0]],"o":[[0,0],[2,0]],"v":[[-268,46],[-82,46]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.2745,0.3608,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[45]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[0]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[45]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[0]},{"t":60,"s":[45]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[55]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[55]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[55]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":125,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Line 5","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":25,"op":80,"st":25,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Line 4","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[470,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":22,"op":77,"st":22,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Line 3","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":19,"op":74,"st":19,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Line 2","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[330,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":16,"op":71,"st":16,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Line 1","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[260,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":13,"op":68,"st":13,"bm":0}],"markers":[]} diff --git a/suixinkanTests/GlobalLoadingCenterTests.swift b/suixinkanTests/GlobalLoadingCenterTests.swift new file mode 100644 index 0000000..10ab459 --- /dev/null +++ b/suixinkanTests/GlobalLoadingCenterTests.swift @@ -0,0 +1,113 @@ +// +// GlobalLoadingCenterTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 全局 Loading 命令中心测试,验证展示状态不会泄漏给业务页面使用。 +final class GlobalLoadingCenterTests: XCTestCase { + /// 测试多次 show/hide 会按引用计数控制最终显隐。 + func testShowHideUsesReferenceCount() { + let center = GlobalLoadingCenter() + + center.show(message: "加载账号") + center.show(message: "加载权限") + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2)) + + center.hide() + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1)) + + center.hide() + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试 hide 调用次数超过 show 时不会产生负数计数。 + func testHideNeverDropsReferenceCountBelowZero() { + let center = GlobalLoadingCenter() + + center.hide() + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试更新文案只影响当前展示内容,不改变引用计数。 + func testUpdateMessageOnlyChangesVisibleMessage() { + let center = GlobalLoadingCenter() + + center.updateMessage("不会展示") + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + + center.show(message: "加载中") + center.updateMessage("即将完成") + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1)) + } + + /// 测试 withLoading 成功完成后自动隐藏 Loading。 + func testWithLoadingHidesAfterSuccess() async throws { + let center = GlobalLoadingCenter() + + let value = try await center.withLoading(message: "提交中") { + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1)) + return 42 + } + + XCTAssertEqual(value, 42) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试 withLoading 抛错时也会自动隐藏 Loading。 + func testWithLoadingHidesAfterFailure() async { + let center = GlobalLoadingCenter() + + do { + _ = try await center.withLoading(message: "提交中") { + throw TestFailure.expected + } as Int + XCTFail("withLoading 应该向外抛出业务错误") + } catch { + XCTAssertEqual(error as? TestFailure, .expected) + } + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试 withOptionalLoading 关闭时不会改变展示状态。 + func testWithOptionalLoadingDisabledDoesNotChangeState() async throws { + let center = GlobalLoadingCenter() + + let value = try await center.withOptionalLoading(false, message: "不展示") { + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + return "done" + } + + XCTAssertEqual(value, "done") + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试使用 Loading 不需要读取可观察展示状态,业务侧只能拿到命令中心类型。 + func testBusinessCodeOnlyDependsOnCommandCenter() { + let center = GlobalLoadingCenter() + + issueLoadingCommand(center) + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1)) + center.hide() + } + + /// 模拟业务代码只接收命令中心并发出展示指令。 + private func issueLoadingCommand(_ center: GlobalLoadingCenter) { + center.show(message: "加载中") + } + + /// 测试用错误类型。 + private enum TestFailure: Error, Equatable { + case expected + } +}