同步已迁移的 iOS 模块

This commit is contained in:
2026-06-22 15:35:12 +08:00
parent 0a0d4fbd79
commit 08465fffde
48 changed files with 4455 additions and 15 deletions

View File

@ -0,0 +1,260 @@
//
// 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(\.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: {
HStack(spacing: 8) {
if viewModel.switching {
ProgressView()
.tint(.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))
.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 {
if let url = URL(string: account.avatar), !account.avatar.isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
avatarFallback(account)
}
}
.frame(width: 50, height: 50)
.clipShape(Circle())
} else {
avatarFallback(account)
.frame(width: 50, height: 50)
}
}
///
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 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 {
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
}
}