// // WalletView.swift // suixinkan // // Created by Codex on 2026/6/22. // import Combine import PhotosUI import SwiftUI /// 钱包首页,展示钱包汇总、收益明细、提现记录和钱包二级入口。 struct WalletView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.walletAPI) private var walletAPI @Environment(\.profileAPI) private var profileAPI @EnvironmentObject private var toastCenter: ToastCenter @StateObject private var viewModel = WalletViewModel() @State private var route: WalletRoute? var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { summaryCard walletActions ledgerControls ledgerList } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.pageVertical) } .background(Color(hex: 0xF5F7FA)) .navigationTitle("我的钱包") .navigationBarTitleDisplayMode(.inline) .appNavigationDestination(item: $route) { route in switch route { case .withdrawApply: WithdrawApplyView() case .bankCardSettings: WithdrawalSettingsView() case .pointsRedemption: PointsRedemptionView() case .realNameAuth: RealNameAuthView() } } .task { await viewModel.loadInitial(api: walletAPI, staffId: staffId) } .refreshable { await viewModel.loadInitial(api: walletAPI, staffId: staffId) } .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } } } /// 当前用户 staffId,优先使用账号快照里的 userId。 private var staffId: Int? { Int(accountContext.profile?.userId ?? "") } /// 钱包汇总卡片。 private var summaryCard: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { HStack { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { Text("可提现金额") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(.white.opacity(0.86)) Text(viewModel.withdrawableText) .font(.system(size: AppMetrics.FontSize.largeTitle, weight: .bold)) .foregroundStyle(.white) } Spacer() if viewModel.isLoadingSummary { ProgressView() .tint(.white) } } HStack(spacing: AppMetrics.Spacing.medium) { walletSummaryItem(title: "累计金额", value: viewModel.totalAmountText) walletSummaryItem(title: "当前余额", value: viewModel.currentBalanceText) walletSummaryItem(title: "可兑换积分", value: "\(viewModel.pointsOverview?.withdrawnPoints ?? 0)") } } .padding(AppMetrics.Spacing.large) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8)) } /// 钱包快捷操作。 private var walletActions: some View { HStack(spacing: AppMetrics.Spacing.small) { walletActionButton(title: "提现", icon: "banknote") { Task { let decision = await viewModel.resolveWithdrawDecision(profileAPI: profileAPI, walletAPI: walletAPI) switch decision { case .route(let nextRoute): route = nextRoute case .message(let message): toastCenter.show(message) } } } walletActionButton(title: "银行卡", icon: "creditcard") { route = .bankCardSettings } walletActionButton(title: "积分兑换", icon: "giftcard") { route = .pointsRedemption } } } /// 明细筛选控件。 private var ledgerControls: some View { VStack(spacing: AppMetrics.Spacing.small) { Picker("明细类型", selection: Binding( get: { viewModel.selectedTab }, set: { tab in Task { await viewModel.selectTab(tab, api: walletAPI) } } )) { ForEach(WalletLedgerTab.allCases) { tab in Text(tab.title).tag(tab) } } .pickerStyle(.segmented) if viewModel.selectedTab == .earnings { HStack { ForEach(WalletDateFilter.allCases) { filter in Button { Task { await viewModel.selectFilter(filter, api: walletAPI) } } label: { Text(filter.title) .font(.system(size: AppMetrics.FontSize.footnote, weight: .medium)) .foregroundStyle(viewModel.selectedFilter == filter ? .white : AppDesign.primary) .padding(.horizontal, AppMetrics.Spacing.small) .padding(.vertical, AppMetrics.Spacing.xSmall) .background(viewModel.selectedFilter == filter ? AppDesign.primary : AppDesign.primarySoft, in: Capsule()) } } Spacer() } } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } /// 钱包明细列表。 @ViewBuilder private var ledgerList: some View { if viewModel.selectedTab == .earnings { LazyVStack(spacing: AppMetrics.Spacing.small) { ForEach(viewModel.earningsGroups) { group in walletSection(title: group.date, subtitle: "¥\(group.dayAmount) / \(group.dayPoints)分") { ForEach(group.items) { item in ledgerRow(title: item.typeLabel.isEmpty ? "收益" : item.typeLabel, subtitle: item.createdAt, amount: "+¥\(item.amount)", extra: item.orderNumberSuffix) } } } loadMoreButton(enabled: viewModel.canLoadMoreEarnings) } } else { LazyVStack(spacing: AppMetrics.Spacing.small) { ForEach(viewModel.withdrawRecords) { record in ledgerRow(title: record.statusLabel, subtitle: record.createdAt, amount: "-¥\(record.amount)", extra: record.expectedAt ?? "") .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } loadMoreButton(enabled: viewModel.canLoadMoreWithdraws) } } } /// 汇总子项。 private func walletSummaryItem(title: String, value: String) -> some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) { Text(title) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(.white.opacity(0.75)) Text(value) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(.white) } .frame(maxWidth: .infinity, alignment: .leading) } /// 快捷操作按钮。 private func walletActionButton(title: String, icon: String, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(spacing: AppMetrics.Spacing.xSmall) { Image(systemName: icon) .font(.system(size: 22, weight: .semibold)) Text(title) .font(.system(size: AppMetrics.FontSize.footnote, weight: .medium)) } .foregroundStyle(AppDesign.primary) .frame(maxWidth: .infinity, minHeight: 76) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } } /// 白色分组卡片。 private func walletSection(title: String, subtitle: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { HStack { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) Spacer() Text(subtitle) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } content() } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } /// 明细行。 private func ledgerRow(title: String, subtitle: String, amount: String, extra: String) -> some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Text([subtitle, extra].filter { !$0.isEmpty }.joined(separator: " · ")) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } Spacer() Text(amount) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(amount.hasPrefix("+") ? AppDesign.success : AppDesign.textPrimary) } } /// 加载更多按钮。 @ViewBuilder private func loadMoreButton(enabled: Bool) -> some View { if enabled { Button { Task { await viewModel.loadMore(api: walletAPI) } } label: { Text(viewModel.isLoadingList ? "加载中..." : "加载更多") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) .frame(maxWidth: .infinity, minHeight: 44) } } } } /// 提现申请页,输入金额和短信验证码后提交提现。 struct WithdrawApplyView: View { @Environment(\.walletAPI) private var walletAPI @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @StateObject private var viewModel = WithdrawApplyViewModel() var body: some View { Form { if let info = viewModel.info { Section("银行卡") { Text("\(info.bankCard.bankName) \(info.bankCard.cardNumber)") Text("可提现金额:¥\(WalletViewModel.moneyText(info.amountWithdrawable))") } } Section("提现金额") { TextField("请输入提现金额", text: $viewModel.amountText) .keyboardType(.decimalPad) HStack { TextField("短信验证码", text: $viewModel.smsCode) .keyboardType(.numberPad) Button(viewModel.smsCountdown > 0 ? "\(viewModel.smsCountdown)s" : "获取验证码") { Task { await viewModel.sendSms(api: walletAPI) } } .disabled(viewModel.smsCountdown > 0) } } Button(viewModel.isSubmitting ? "提交中..." : "提交提现申请") { Task { if await viewModel.submit(api: walletAPI) { toastCenter.show("提现申请已提交") dismiss() } } } .disabled(viewModel.isSubmitting) } .navigationTitle("提现申请") .navigationBarTitleDisplayMode(.inline) .task { await viewModel.load(api: walletAPI) } .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in if viewModel.smsCountdown > 0 { viewModel.smsCountdown -= 1 } } .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } } } } /// 银行卡设置页,使用图片选择和 OSS 上传提交银行卡资料。 struct WithdrawalSettingsView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.walletAPI) private var walletAPI @Environment(\.ossUploadService) private var ossUploadService @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @StateObject private var viewModel = WithdrawalSettingsViewModel() @State private var frontItem: PhotosPickerItem? @State private var backItem: PhotosPickerItem? var body: some View { Form { if let bankCard = viewModel.bankCard { Section("审核状态") { Text(bankCard.auditStatusLabel.isEmpty ? "待提交" : bankCard.auditStatusLabel) if let reason = bankCard.rejectReason, !reason.isEmpty { Text(reason).foregroundStyle(AppDesign.warning) } } } Section("基础信息") { TextField("持卡人姓名", text: $viewModel.realName) TextField("银行卡号", text: $viewModel.cardNumber) .keyboardType(.numberPad) Picker("开户银行", selection: $viewModel.bankName) { Text("请选择").tag("") ForEach(viewModel.banks, id: \.self) { bank in Text(bank).tag(bank) } } TextField("开户支行", text: $viewModel.branchName) Picker("省份", selection: $viewModel.provinceCode) { Text("请选择").tag("") ForEach(viewModel.areas) { area in Text(area.name).tag(area.code) } } Picker("城市", selection: $viewModel.cityCode) { Text("请选择").tag("") ForEach(selectedProvinceChildren) { city in Text(city.name).tag(city.code) } } } Section("银行卡照片") { bankCardImagePicker(title: "银行卡正面", item: $frontItem, data: viewModel.frontImageData, url: viewModel.frontImageURL) bankCardImagePicker(title: "银行卡反面", item: $backItem, data: viewModel.backImageData, url: viewModel.backImageURL) if viewModel.uploadProgress > 0 && viewModel.uploadProgress < 100 { ProgressView(value: Double(viewModel.uploadProgress), total: 100) } } Section("短信验证码") { HStack { TextField("请输入短信验证码", text: $viewModel.smsCode) .keyboardType(.numberPad) Button(viewModel.smsCountdown > 0 ? "\(viewModel.smsCountdown)s" : "获取验证码") { Task { await viewModel.sendSms(api: walletAPI) } } .disabled(viewModel.smsCountdown > 0) } } Button(viewModel.isSubmitting ? "提交中..." : "提交银行卡资料") { Task { if await viewModel.submit(api: walletAPI, uploader: ossUploadService, scenicId: accountContext.currentScenic?.id) { toastCenter.show("银行卡资料已提交") dismiss() } } } .disabled(viewModel.isSubmitting) } .navigationTitle("银行卡设置") .navigationBarTitleDisplayMode(.inline) .task { await viewModel.load(api: walletAPI) } .onChange(of: frontItem) { item in Task { viewModel.frontImageData = try? await item?.loadTransferable(type: Data.self) } } .onChange(of: backItem) { item in Task { viewModel.backImageData = try? await item?.loadTransferable(type: Data.self) } } .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in if viewModel.smsCountdown > 0 { viewModel.smsCountdown -= 1 } } .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } } } /// 当前所选省份下的城市列表。 private var selectedProvinceChildren: [AreaNode] { viewModel.areas.first { $0.code == viewModel.provinceCode }?.children ?? [] } /// 银行卡图片选择行。 private func bankCardImagePicker(title: String, item: Binding, data: Data?, url: String) -> some View { PhotosPicker(selection: item, matching: .images) { HStack(spacing: AppMetrics.Spacing.small) { bankCardPreview(data: data, url: url) .frame(width: 88, height: 56) .clipShape(RoundedRectangle(cornerRadius: 8)) Text(title) Spacer() Image(systemName: "chevron.right") .foregroundStyle(AppDesign.placeholder) } } } /// 银行卡图片预览。 @ViewBuilder private func bankCardPreview(data: Data?, url: String) -> some View { if let data, let image = UIImage(data: data) { Image(uiImage: image) .resizable() .scaledToFill() } else { RemoteImage(urlString: url) { Rectangle() .fill(AppDesign.primarySoft) .overlay { Image(systemName: "creditcard") .foregroundStyle(AppDesign.primary) } } } } } /// 积分兑换页,展示积分概览、提交兑换申请和兑换记录。 struct PointsRedemptionView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.walletAPI) private var walletAPI @EnvironmentObject private var toastCenter: ToastCenter @StateObject private var viewModel = PointsRedemptionViewModel() var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text("可兑换积分") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) Text("\(viewModel.overview.withdrawnPoints)") .font(.system(size: AppMetrics.FontSize.largeTitle, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) HStack { TextField("请输入兑换积分", text: $viewModel.pointsText) .keyboardType(.numberPad) Button("全部") { viewModel.fillAllPoints() } } .appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight) Button(viewModel.isSubmitting ? "提交中..." : "提交兑换") { Task { if await viewModel.submit(api: walletAPI, staffId: staffId) { toastCenter.show("积分兑换申请已提交") } } } .frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight) .foregroundStyle(.white) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8)) } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) LazyVStack(spacing: AppMetrics.Spacing.small) { ForEach(viewModel.records) { item in HStack { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) { Text("\(item.points) 积分") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) Text(item.createdAt) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } Spacer() Text("¥\(item.amount, specifier: "%.2f")") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } if viewModel.canLoadMore { Button("加载更多") { Task { await viewModel.loadMore(api: walletAPI) } } } } } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.pageVertical) } .background(Color(hex: 0xF5F7FA)) .navigationTitle("积分兑换") .navigationBarTitleDisplayMode(.inline) .task { await viewModel.load(api: walletAPI, staffId: staffId) } .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } } } /// 当前用户 staffId。 private var staffId: Int? { Int(accountContext.profile?.userId ?? "") } }