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>
250 lines
9.5 KiB
Swift
250 lines
9.5 KiB
Swift
//
|
||
// AccountSwitchView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||
struct AccountSwitchView: View {
|
||
@Environment(AppSession.self) private var appSession
|
||
@Environment(AccountContext.self) private var accountContext
|
||
@Environment(PermissionContext.self) private var permissionContext
|
||
@Environment(AppRouter.self) private var appRouter
|
||
@Environment(ProfileAPI.self) private var profileAPI
|
||
@Environment(AuthAPI.self) private var authAPI
|
||
@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()
|
||
|
||
private var selectedAccount: AccountSwitchAccount? {
|
||
viewModel.selectedAccount
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
accountList
|
||
bottomBar
|
||
}
|
||
.background(Color(hex: 0xF5F7FB).ignoresSafeArea())
|
||
.navigationTitle("账号切换")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task {
|
||
await loadAccounts()
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var accountList: some View {
|
||
if viewModel.accounts.isEmpty && !viewModel.loading {
|
||
ContentUnavailableView(
|
||
"暂无可切换账号",
|
||
systemImage: "person.crop.circle.badge.exclamationmark",
|
||
description: Text("当前登录账号下没有其他可切换账号。")
|
||
)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.padding(24)
|
||
} else {
|
||
ScrollView {
|
||
LazyVStack(spacing: 12) {
|
||
ForEach(viewModel.accounts) { account in
|
||
accountCard(account)
|
||
}
|
||
}
|
||
.padding(16)
|
||
.padding(.bottom, 92)
|
||
}
|
||
.refreshable {
|
||
await loadAccounts(force: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var bottomBar: some View {
|
||
VStack(spacing: 0) {
|
||
Divider()
|
||
Button {
|
||
Task { await confirmSelection() }
|
||
} label: {
|
||
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))
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 14)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!canConfirm)
|
||
}
|
||
.background(.white)
|
||
}
|
||
|
||
private var canConfirm: Bool {
|
||
selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
||
}
|
||
|
||
/// 构造单个账号卡片。
|
||
private func accountCard(_ account: AccountSwitchAccount) -> some View {
|
||
let selected = viewModel.selectedAccountId == account.id
|
||
return Button {
|
||
viewModel.select(account)
|
||
} label: {
|
||
HStack(spacing: 13) {
|
||
accountAvatar(account)
|
||
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
HStack(spacing: 8) {
|
||
Text(nonEmpty(account.title) ?? account.accountTypeLabel)
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.lineLimit(1)
|
||
if account.isCurrent || isCurrentAccount(account) {
|
||
tag("当前", foreground: AppDesign.primary, background: AppDesign.primarySoft)
|
||
}
|
||
}
|
||
|
||
if !account.subtitle.isEmpty {
|
||
Text(account.subtitle)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
.lineLimit(1)
|
||
}
|
||
|
||
if !account.phone.isEmpty {
|
||
Text(account.phone)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(Color(hex: 0x9AA1AA))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
VStack(alignment: .trailing, spacing: 8) {
|
||
tag(
|
||
account.isStoreUser ? "门店" : "景区",
|
||
foreground: account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED),
|
||
background: account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF)
|
||
)
|
||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||
.font(.system(size: 22, weight: .semibold))
|
||
.foregroundStyle(selected ? AppDesign.primary : Color(hex: 0xB6BECA))
|
||
}
|
||
}
|
||
.padding(14)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.stroke(selected ? AppDesign.primary : Color(hex: 0xE6ECF4), lineWidth: selected ? 1.4 : 1)
|
||
}
|
||
.shadow(color: Color(hex: 0xC8D7EA, alpha: selected ? 0.24 : 0.12), radius: selected ? 10 : 6, x: 0, y: 5)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
@ViewBuilder
|
||
/// 构造账号头像,网络图加载失败时显示账号类型首字。
|
||
private func accountAvatar(_ account: AccountSwitchAccount) -> some View {
|
||
RemoteImage(urlString: account.avatar) {
|
||
avatarFallback(account)
|
||
}
|
||
.frame(width: 50, height: 50)
|
||
.clipShape(Circle())
|
||
}
|
||
|
||
/// 构造账号头像占位。
|
||
private func avatarFallback(_ account: AccountSwitchAccount) -> some View {
|
||
ZStack {
|
||
Circle()
|
||
.fill(account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF))
|
||
Text(account.isStoreUser ? "店" : "景")
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED))
|
||
}
|
||
}
|
||
|
||
/// 构造账号标签。
|
||
private func tag(_ text: String, foreground: Color, background: Color) -> some View {
|
||
Text(text)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(foreground)
|
||
.padding(.horizontal, 7)
|
||
.frame(height: 22)
|
||
.background(background, in: RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
|
||
/// 拉取账号列表。
|
||
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 {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 确认账号切换,并刷新全局登录上下文。
|
||
private func confirmSelection() async {
|
||
guard let account = selectedAccount else { return }
|
||
if isCurrentAccount(account) {
|
||
dismiss()
|
||
return
|
||
}
|
||
|
||
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(
|
||
with: response,
|
||
username: username,
|
||
privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
|
||
appSession: appSession,
|
||
accountContext: accountContext,
|
||
permissionContext: permissionContext,
|
||
profileAPI: profileAPI,
|
||
accountContextAPI: accountContextAPI
|
||
)
|
||
}
|
||
appRouter.reset()
|
||
toastCenter.show("账号已切换")
|
||
dismiss()
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private var currentAccountId: String? {
|
||
guard let current = accountContext.profile else { return nil }
|
||
if let store = accountContext.currentStore {
|
||
return "\(V9StoreUser.accountTypeValue)_\(store.id)"
|
||
}
|
||
if let scenic = accountContext.currentScenic {
|
||
return "\(V9ScenicUser.accountTypeValue)_\(scenic.id)"
|
||
}
|
||
return current.userId.isEmpty ? nil : current.userId
|
||
}
|
||
|
||
/// 判断账号是否为当前已选账号。
|
||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||
account.isCurrent || viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
||
}
|
||
|
||
/// 返回去空白后的非空文本。
|
||
private func nonEmpty(_ value: String?) -> String? {
|
||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return text.isEmpty ? nil : text
|
||
}
|
||
}
|