新增支付与钱包模块,并接入首页路由
引入真实收款与钱包页面替换首页占位入口,通过 RootView 接入 API,并支持银行卡 OSS 上传及二维码保存到相册。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
325
suixinkan/Features/Payment/Views/PaymentCollectionView.swift
Normal file
325
suixinkan/Features/Payment/Views/PaymentCollectionView.swift
Normal file
@ -0,0 +1,325 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user