统一收款详情布局、设置金额弹窗和收款记录样式,接入 type 6/1 推送驱动与轮询 fallback,使 iOS 收款反馈与 Android 一致。 Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
9.3 KiB
Swift
259 lines
9.3 KiB
Swift
//
|
||
// 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<Void, Never>?
|
||
|
||
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)
|
||
}
|
||
}
|