// // PaymentCollectionView.swift // suixinkan // // Created by Codex on 2026/6/22. // import AVFoundation import Photos import SwiftUI /// 收款页,展示静态收款码、动态金额收款和收款记录入口。 struct PaymentCollectionView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.paymentAPI) private var paymentAPI @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @StateObject private var viewModel = PaymentCollectionViewModel() @State private var showingAmountDialog = false @State private var pollingTask: Task? private var scenicName: String { accountContext.currentScenic?.name ?? "暂无景区" } private var staffID: String { accountContext.profile?.userId ?? "" } var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { PaymentQRSection( scenicName: scenicName, qrImage: viewModel.qrImage, paymentSuccessData: viewModel.paymentSuccessData, onSetAmount: openAmountDialog, onSaveQRCode: saveQRCode, onRefresh: refreshPayCode ) PaymentStatusSection( scanSuccessTime: viewModel.scanSuccessTime, paymentSuccessData: viewModel.paymentSuccessData ) infoCard NavigationLink { PaymentCollectionRecordView() } label: { PaymentNavigationRow(title: "收款记录") } .buttonStyle(.plain) voiceToggleRow if case .failed(let message) = viewModel.status { PaymentWhiteCard { Text(message) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.warning) .frame(maxWidth: .infinity, alignment: .leading) } } } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } .background(Self.pageBackground) .navigationTitle("收款详情") .navigationBarTitleDisplayMode(.inline) .overlay { if showingAmountDialog { PaymentSetAmountDialog( amountText: $viewModel.amountText, remarkText: $viewModel.remarkText, canConfirm: viewModel.canConfirmAmount, onConfirm: confirmAmount, onDismiss: { showingAmountDialog = false } ) } } .task(id: accountContext.currentScenic?.id) { await globalLoading.withOptionalLoading(viewModel.qrImage == nil, message: "加载中...") { await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } } .onDisappear { pollingTask?.cancel() } .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } } } /// 景区/收款方/员工信息卡。 private var infoCard: some View { PaymentWhiteCard { VStack(spacing: 6) { PaymentInfoRow(label: "景区名称", value: scenicName) PaymentInfoRow(label: "收款方", value: PaymentMerchantInfo.payeeCompany) PaymentInfoRow(label: "员工ID", value: staffID) } } } /// 收款到账语音提醒开关行。 private var voiceToggleRow: some View { HStack { Text("收款到账语音提醒") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.orderLabelColor) Spacer() Toggle("", isOn: Binding( get: { viewModel.isVoiceBroadcast }, set: { _ in viewModel.toggleVoiceBroadcast() } )) .labelsHidden() } .frame(height: 52) .padding(.horizontal, AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 12)) } /// 打开设置金额弹窗。 private func openAmountDialog() { if viewModel.prepareAmountDialog() { showingAmountDialog = true } } /// 确认设置金额并启动轮询 fallback。 private func confirmAmount() { if viewModel.confirmAmount() { showingAmountDialog = false toastCenter.show("金额设置成功,二维码已更新") startPolling() } } /// 刷新收款码。 private func refreshPayCode() { Task { await globalLoading.withLoading(message: "刷新中...") { await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } } } /// 启动本次动态收款轮询。 private func startPolling() { pollingTask?.cancel() pollingTask = Task { await viewModel.primePaymentRecords(api: paymentAPI, scenicId: accountContext.currentScenic?.id) await viewModel.pollUntilPaymentDetected(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } } /// 保存当前二维码到系统相册。 private func saveQRCode() { guard viewModel.hasStaticPayCode, let image = viewModel.qrImage else { toastCenter.show("请先获取收款码") return } PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in guard status == .authorized || status == .limited else { Task { @MainActor in toastCenter.show("请允许添加照片权限") } return } PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.creationRequestForAsset(from: image) } completionHandler: { success, error in Task { @MainActor in toastCenter.show(success ? "保存成功" : (error?.localizedDescription ?? "保存失败")) } } } } } /// 收款记录页,按日期展示扫码收款明细。 struct PaymentCollectionRecordView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.paymentAPI) private var paymentAPI @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @StateObject private var viewModel = PaymentCollectionRecordViewModel() var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { if viewModel.groups.isEmpty { PaymentRecordEmptyView() } else { PaymentWhiteCard { VStack(spacing: AppMetrics.Spacing.medium) { ForEach(viewModel.groups) { group in VStack(spacing: AppMetrics.Spacing.xSmall) { PaymentRecordDayHeader(group: group) ForEach(group.items) { item in PaymentRecordItemCard(item: item) } } } } } Text("仅支持查看7天的数据") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(Color(hex: 0xB6BECA)) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) .padding(.bottom, AppMetrics.Spacing.medium) } } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.top, AppMetrics.Spacing.medium) } .background(PaymentCollectionView.pageBackground) .navigationTitle("收款记录") .navigationBarTitleDisplayMode(.inline) .refreshable { 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) } } } } /// 收款语音播报服务,使用系统 TTS 播报到账提示。 @MainActor final class PaymentVoiceSpeaker { static let shared = PaymentVoiceSpeaker() private let synthesizer = AVSpeechSynthesizer() private init() {} /// 播报指定中文文案。 func speak(_ text: String) { let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") utterance.rate = 0.5 synthesizer.speak(utterance) } }