// // PaymentCollectionView.swift // suixinkan // // Created by Codex on 2026/6/22. // import AVFoundation import Photos import SwiftUI /// 收款页,展示静态收款码、动态金额收款和收款记录入口。 struct PaymentCollectionView: View { @Environment(AccountContext.self) private var accountContext @Environment(PaymentAPI.self) private var paymentAPI @Environment(ToastCenter.self) private var toastCenter @State private var viewModel = PaymentCollectionViewModel() @State private var showingAmountSheet = false @State private var pollingTask: Task? @State private var speaker = PaymentVoiceSpeaker() var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { contextHeader qrCard actionGrid statusCard } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.pageVertical) } .background(Color(hex: 0xF5F7FA)) .navigationTitle("立即收款") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { NavigationLink { PaymentCollectionRecordView() } label: { Image(systemName: "list.bullet.rectangle") } } } .task(id: accountContext.currentScenic?.id) { await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } .sheet(isPresented: $showingAmountSheet) { amountSheet .presentationDetents([.medium]) } .onDisappear { pollingTask?.cancel() } .onChange(of: viewModel.errorMessage) { _, message in if let message { toastCenter.show(message) } } .onChange(of: viewModel.status) { _, status in if case .success(let item) = status { speaker.speak("收款到账\(item.orderAmount)元") } } } /// 景区上下文展示区。 private var contextHeader: some View { HStack(spacing: AppMetrics.Spacing.small) { Image(systemName: "mappin.and.ellipse") .foregroundStyle(AppDesign.primary) VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) { Text(accountContext.currentScenic?.name ?? "未选择景区") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Text("收款记录按当前景区查询") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } Spacer() } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } /// 二维码展示卡片。 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 { Image(uiImage: image) .interpolation(.none) .resizable() .scaledToFit() .frame(width: 220, height: 220) .background(.white) } else { VStack(spacing: AppMetrics.Spacing.small) { Image(systemName: "qrcode") .font(.system(size: 56, weight: .semibold)) .foregroundStyle(AppDesign.placeholder) Text("暂无收款码") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) } .frame(width: 220, height: 220) } Text(viewModel.currentPayUrl.isEmpty ? "请先加载收款码" : "向客户展示二维码完成收款") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(AppMetrics.Spacing.large) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } /// 收款操作按钮区。 private var actionGrid: some View { HStack(spacing: AppMetrics.Spacing.small) { paymentActionButton(title: "设置金额", icon: "yensign.circle.fill") { showingAmountSheet = true } paymentActionButton(title: "保存二维码", icon: "square.and.arrow.down") { saveQRCode() } paymentActionButton(title: "刷新", icon: "arrow.clockwise") { Task { await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } } } } /// 当前收款状态展示卡。 @ViewBuilder private var statusCard: some View { switch viewModel.status { case .idle: EmptyView() case .waiting: HStack(spacing: AppMetrics.Spacing.small) { ProgressView() Text("等待付款到账...") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) } .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) case .success(let item): VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { Label("收款成功", systemImage: "checkmark.circle.fill") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.success) Text("订单号:\(item.orderNumber)") Text("金额:¥\(item.orderAmount)") } .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) case .failed(let message): Text(message) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.warning) .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } } /// 设置金额弹窗。 private var amountSheet: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { Text("设置收款金额") .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) TextField("请输入金额", text: $viewModel.amountText) .keyboardType(.decimalPad) .appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight) TextField("备注,可不填", text: $viewModel.remarkText) .appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight) Button { if viewModel.applyDynamicAmount() { showingAmountSheet = false startPolling() } else if let message = viewModel.errorMessage { toastCenter.show(message) } } label: { Text("生成动态二维码") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight) .foregroundStyle(.white) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8)) } } .padding(AppMetrics.Spacing.large) } /// 生成操作按钮。 private func paymentActionButton(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 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 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 { @Environment(AccountContext.self) private var accountContext @Environment(PaymentAPI.self) private var paymentAPI @Environment(ToastCenter.self) private var toastCenter @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 VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { HStack { Text("¥\(item.orderAmount)") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) Spacer() Text(item.createTime) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } Text(item.orderNumber) .font(.system(size: AppMetrics.FontSize.footnote)) .foregroundStyle(AppDesign.textSecondary) Text(item.userPhone) .font(.system(size: AppMetrics.FontSize.footnote)) .foregroundStyle(AppDesign.textSecondary) } .padding(.vertical, AppMetrics.Spacing.xxSmall) } } header: { HStack { Text(group.analyse.date) Spacer() Text("\(group.analyse.orderCount)笔 ¥\(group.analyse.orderAmountSum)") } } } } .navigationTitle("收款记录") .navigationBarTitleDisplayMode(.inline) .refreshable { await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } .task(id: accountContext.currentScenic?.id) { await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } .onChange(of: viewModel.errorMessage) { _, message in if let message { toastCenter.show(message) } } } } /// 收款语音播报服务,使用系统 TTS 播报到账提示。 @MainActor @Observable final class PaymentVoiceSpeaker { @ObservationIgnored private let synthesizer = AVSpeechSynthesizer() /// 播报指定中文文案。 func speak(_ text: String) { let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") utterance.rate = 0.5 synthesizer.speak(utterance) } }