Add Lottie-based global loading overlay across key flows.

Introduce GlobalLoadingCenter with reference counting, wire it through RootView and major auth/order/profile screens, and add the loading animation asset plus unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 11:25:56 +08:00
parent 1d1eb46ed8
commit 7fd964fe19
17 changed files with 514 additions and 139 deletions

View File

@ -51,6 +51,9 @@
Kingfisher
- 用于显示网络图片
Lottie
- 用于展示全局 Loading 动画
---

View File

@ -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 */;

View File

@ -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",

View File

@ -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,6 +90,7 @@ struct RootView: View {
.environment(authSessionCoordinator)
.task {
apiClient.bindAuthTokenProvider { appSession.token }
await globalLoading.withLoading {
await sessionBootstrapper.restore(
appSession: appSession,
accountContext: accountContext,
@ -96,6 +100,7 @@ struct RootView: View {
toastCenter: toastCenter
)
}
}
.task(id: scenicSpotTaskID) {
guard appSession.isLoggedIn else {
scenicSpotContext.reset()
@ -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()
}

View File

@ -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。

View File

@ -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<T>(
message: String = "",
operation: () async throws -> T
) async rethrows -> T {
show(message: message)
defer { hide() }
return try await operation()
}
/// Loading loading
func withOptionalLoading<T>(
_ 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 }
}
}

View File

@ -71,14 +71,8 @@ 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)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)

View File

@ -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,23 +112,9 @@ 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)
@ -167,6 +154,7 @@ struct LoginView: View {
Task {
do {
try await globalLoading.withLoading(message: "登录中...") {
let resolution = try await viewModel.login(authAPI: authAPI)
switch resolution {
case let .completed(response):
@ -174,6 +162,7 @@ struct LoginView: View {
case .needsAccountSelection:
break
}
}
} catch is CancellationError {
// Keep cancellation silent; the current task was abandoned by SwiftUI.
} catch {
@ -186,8 +175,10 @@ struct LoginView: View {
private func selectAccount(_ account: AccountSwitchAccount) {
Task {
do {
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 {

View File

@ -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,8 +108,10 @@ struct StoreOrderDetailView: View {
.navigationTitle("订单详情")
.navigationBarTitleDisplayMode(.inline)
.task {
await globalLoading.withLoading(message: "加载详情中...") {
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id)
}
}
.alert("提示", isPresented: errorBinding) {
Button("知道了", role: .cancel) {}
} message: {

View File

@ -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,6 +517,7 @@ struct OrdersView: View {
private func reload(showLoading: Bool = true) async {
do {
try await globalLoading.withOptionalLoading(shouldShowGlobalLoading(showLoading: showLoading), message: "加载中...") {
try await viewModel.reload(
api: ordersAPI,
scenicId: currentScenicId,
@ -523,11 +525,23 @@ struct OrdersView: View {
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 globalLoading.withLoading(message: "核销中...") {
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, storeId: currentStoreId, orderNumber: orderNumber)
}
manualOrderNumber = ""
pendingVerifyOrder = nil
pendingVerifyOrderNumber = nil

View File

@ -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,8 +45,10 @@ struct PaymentCollectionView: View {
}
}
.task(id: accountContext.currentScenic?.id) {
await globalLoading.withOptionalLoading(viewModel.qrImage == nil, message: "加载中...") {
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
}
.sheet(isPresented: $showingAmountSheet) {
amountSheet
.presentationDetents([.medium])
@ -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,8 +300,10 @@ struct PaymentCollectionRecordView: View {
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
.task(id: 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 {
toastCenter.show(message)

View File

@ -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,14 +72,8 @@ 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)
.frame(maxWidth: .infinity)
.frame(height: 50)
@ -187,7 +182,9 @@ struct AccountSwitchView: View {
///
private func loadAccounts(force: Bool = false) async {
do {
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,6 +201,7 @@ struct AccountSwitchView: View {
}
do {
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(
@ -216,6 +214,7 @@ struct AccountSwitchView: View {
profileAPI: profileAPI,
accountContextAPI: accountContextAPI
)
}
appRouter.reset()
toastCenter.show("账号已切换")
dismiss()

View File

@ -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 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 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 {

View File

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

View File

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

View File

@ -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":[]}

View File

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