Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library. Co-authored-by: Cursor <cursoragent@cursor.com>
326 lines
13 KiB
Swift
326 lines
13 KiB
Swift
//
|
||
// 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<Void, Never>?
|
||
@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)
|
||
}
|
||
}
|