对齐 Android 立即收款页面 UI,并补齐推送状态与语音开关。

统一收款详情布局、设置金额弹窗和收款记录样式,接入 type 6/1 推送驱动与轮询 fallback,使 iOS 收款反馈与 Android 一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 15:17:09 +08:00
parent 5bdf4a7dbf
commit 3dcfb99254
11 changed files with 924 additions and 228 deletions

View File

@ -137,6 +137,19 @@ struct PaymentCollectionRecordGroup: Equatable, Identifiable {
var id: String { analyse.id }
}
/// Android PaymentSuccessData
struct PaymentSuccessDisplayData: Equatable {
let orderAmount: String
let orderNumber: String
let payTime: String
let remark: String
}
/// Android MerchantInfo
enum PaymentMerchantInfo {
static let payeeCompany = "扬州元智享网络科技有限公司"
}
///
enum PaymentCollectionStatus: Equatable {
case idle

View File

@ -2,24 +2,43 @@
## 模块职责
`Features/Payment` 承接首页 `payment_collection``payment_qr``payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账轮询
`Features/Payment` 承接首页 `payment_collection``payment_qr``payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账反馈
## 代码结构
- `PaymentAPI`:封装收款码和扫码收款记录接口。
- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成和到账轮询
- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成、推送状态和到账轮询 fallback
- `PaymentCollectionRecordViewModel`:管理收款记录加载和按日期分组。
- `PaymentCollectionView`:展示收款码、金额弹窗、保存二维码和收款状态
- `PaymentCollectionView`:展示 Android 对齐后的收款详情页 UI
- `PaymentCollectionComponents`:收款页复用 UI 组件白卡、QR 区、设置金额弹窗、记录卡片)。
- `PaymentPushEventCenter`:应用内收款推送事件总线,对齐 Android `PushBus`
- `PaymentCollectionRecordView`:展示收款记录明细。
## 页面结构(对齐 Android
主页面标题为「收款详情」,自上而下:
1. QR 卡景区名、182×182 二维码、设置金额/保存二维码文字链;失败态显示红字与点击刷新。
2. 状态卡:推送 type=6 时显示「扫码成功支付中...」;付款成功后显示金额/时间/单号/备注。
3. 信息卡:景区名称、收款方、员工 ID。
4. 收款记录入口行。
5. 收款到账语音提醒开关。
设置金额使用居中 Dialog确认后直接更新主二维码。
## 数据流
1. 页面读取 `AccountContext.currentScenic`
1. 页面读取 `AccountContext.currentScenic``AccountContext.profile.userId`
2. 有景区时调用 `/api/yf-handset-app/photog/pay-code?scenic_id=...` 获取收款码。
3. 设置金额后基于动态收款码 URL 追加 `amount` 和可选 `remark`,生成二维码。
4. 开始轮询 `/api/yf-handset-app/photog/order/photo-scan-order-list?scenic_id=...`,最多 60 次,每 2 秒一次。
5. 命中新收款记录后进入成功态,并使用系统语音播报到账金额。
4. 优先通过推送更新 UI
- `type=6`:扫码成功,展示扫码时间 + 「扫码成功支付中...」
- `type=1`付款成功QR 区大成功态 + 详情卡;若语音开关开启则全局 TTS 播报
5. 保留轮询 fallback`/api/yf-handset-app/photog/order/photo-scan-order-list?scenic_id=...`,最多 60 次,每 2 秒一次。
6. 命中新收款记录后进入成功态,并填充 `PaymentSuccessDisplayData`
## 缓存边界
收款码 URL、付款状态、轮询结果不落盘。保存二维码只写入系统相册App 不额外缓存图片文件或 Data。
- 收款码 URL、付款状态、轮询结果不落盘。
- 语音开关写入 `AppPreferencesStore``suixinkan.preferences.payment_receive_voice`)。
- 保存二维码只写入系统相册App 不额外缓存图片文件或 Data。

View File

@ -0,0 +1,35 @@
//
// PaymentPushEventCenter.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Combine
import Foundation
/// Android PushBus
struct PaymentPushEvent: Equatable, Sendable {
let type: Int
let dataJSON: String?
}
@MainActor
/// PushNotificationManager
final class PaymentPushEventCenter {
static let shared = PaymentPushEventCenter()
private let subject = PassthroughSubject<PaymentPushEvent, Never>()
private init() {}
///
var events: AnyPublisher<PaymentPushEvent, Never> {
subject.eraseToAnyPublisher()
}
///
func post(type: Int, dataJSON: String?) {
subject.send(PaymentPushEvent(type: type, dataJSON: dataJSON))
}
}

View File

@ -7,12 +7,12 @@
import CoreImage
import CoreImage.CIFilterBuiltins
import Foundation
import Combine
import Foundation
import UIKit
@MainActor
/// ViewModel
/// ViewModel
final class PaymentCollectionViewModel: ObservableObject {
@Published var staticPayUrl = ""
@Published var dynamicPayUrl = ""
@ -20,20 +20,43 @@ final class PaymentCollectionViewModel: ObservableObject {
@Published var remarkText = ""
@Published var currentPayUrl = ""
@Published var qrImage: UIImage?
private var isLoading = false
private var isPolling = false
@Published var errorMessage: String?
@Published var status: PaymentCollectionStatus = .idle
@Published var isVoiceBroadcast = false
@Published var scanSuccessTime: String?
@Published var paymentSuccessData: PaymentSuccessDisplayData?
@Published private var knownRecordIDs = Set<String>()
private var isLoading = false
private var isPolling = false
private let preferencesStore: AppPreferencesStore
private let pushEventCenter: PaymentPushEventCenter
private var pushEventsCancellable: AnyCancellable?
private let context = CIContext()
private let qrFilter = CIFilter.qrCodeGenerator()
/// ViewModel
init(
preferencesStore: AppPreferencesStore = AppPreferencesStore(),
pushEventCenter: PaymentPushEventCenter = .shared
) {
self.preferencesStore = preferencesStore
self.pushEventCenter = pushEventCenter
isVoiceBroadcast = preferencesStore.loadPaymentReceiveVoiceEnabled()
startListeningPushEvents()
}
///
var hasStaticPayCode: Bool {
!staticPayUrl.paymentTrimmed.isEmpty
}
///
var canConfirmAmount: Bool {
guard let amount = Self.normalizedMoney(amountText) else { return false }
return (Decimal(string: amount) ?? 0) > 0
}
///
func loadPayCode(api: PaymentServing, scenicId: Int?) async {
guard let scenicId else {
@ -53,38 +76,92 @@ final class PaymentCollectionViewModel: ObservableObject {
currentPayUrl = response.staticPayUrl
qrImage = makeQRCode(from: currentPayUrl)
status = .idle
scanSuccessTime = nil
paymentSuccessData = nil
} catch {
errorMessage = error.localizedDescription
}
}
/// URL
///
@discardableResult
func applyDynamicAmount() -> Bool {
guard let amount = Self.normalizedMoney(amountText), Decimal(string: amount) ?? 0 > 0 else {
func prepareAmountDialog() -> Bool {
guard hasStaticPayCode else {
errorMessage = "请先获取收款码"
return false
}
amountText = ""
remarkText = ""
return true
}
/// Android confirmAmount
@discardableResult
func confirmAmount() -> Bool {
guard hasStaticPayCode else {
errorMessage = "请先获取收款码"
return false
}
guard let amount = Self.normalizedMoney(amountText), (Decimal(string: amount) ?? 0) > 0 else {
errorMessage = "请输入有效收款金额"
return false
}
guard !dynamicPayUrl.paymentTrimmed.isEmpty else {
errorMessage = "动态收款码暂不可用"
guard amountText.matches(regex: #"^\d+(\.\d{1,2})?$"#) else {
errorMessage = "金额最多支持两位小数"
return false
}
var components = URLComponents(string: dynamicPayUrl)
var queryItems = components?.queryItems ?? []
queryItems.removeAll { $0.name == "amount" || $0.name == "remark" }
queryItems.append(URLQueryItem(name: "amount", value: amount))
if !remarkText.paymentTrimmed.isEmpty {
queryItems.append(URLQueryItem(name: "remark", value: remarkText.paymentTrimmed))
guard applyDynamicAmount(normalizedAmount: amount) else {
return false
}
components?.queryItems = queryItems
currentPayUrl = components?.url?.absoluteString ?? "\(dynamicPayUrl)&amount=\(amount)"
qrImage = makeQRCode(from: currentPayUrl)
amountText = amount
status = .waiting
scanSuccessTime = nil
paymentSuccessData = nil
return true
}
///
func toggleVoiceBroadcast() {
isVoiceBroadcast.toggle()
preferencesStore.savePaymentReceiveVoiceEnabled(isVoiceBroadcast)
}
/// Android dealWithPushEvent
func handlePaymentPush(type: Int, dataJSON: String?) {
if type == 6 {
scanSuccessTime = Self.currentTimestamp()
paymentSuccessData = nil
return
}
guard type == 1,
let dataJSON,
let data = dataJSON.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return
}
let orderAmount = Self.stringValue(json["order_amount"])
guard !orderAmount.isEmpty else { return }
paymentSuccessData = PaymentSuccessDisplayData(
orderAmount: orderAmount,
orderNumber: Self.stringValue(json["order_number"]),
payTime: Self.formattedPayTime(from: json["pay_time"]),
remark: Self.stringValue(json["remark"])
)
scanSuccessTime = nil
status = .idle
}
/// URL
@discardableResult
func applyDynamicAmount() -> Bool {
guard let amount = Self.normalizedMoney(amountText) else {
errorMessage = "请输入有效收款金额"
return false
}
return applyDynamicAmount(normalizedAmount: amount)
}
///
func primePaymentRecords(api: PaymentServing, scenicId: Int?) async {
guard let scenicId else { return }
@ -115,7 +192,7 @@ final class PaymentCollectionViewModel: ObservableObject {
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
if let item = matchedNewRecord(in: response.list) {
knownRecordIDs.insert(item.id)
status = .success(item)
applySuccessState(from: item)
return
}
} catch {
@ -137,6 +214,8 @@ final class PaymentCollectionViewModel: ObservableObject {
qrImage = nil
knownRecordIDs = []
status = .idle
scanSuccessTime = nil
paymentSuccessData = nil
}
///
@ -157,6 +236,90 @@ final class PaymentCollectionViewModel: ObservableObject {
return number.rounding(accordingToBehavior: handler).stringValue
}
/// Android
static func currentTimestamp() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter.string(from: Date())
}
///
static func formattedPayTime(from value: Any?) -> String {
if let text = value as? String, !text.isEmpty {
return text
}
if let number = value as? NSNumber {
let timestamp = number.int64Value
let epochSecond = timestamp > 1_000_000_000_000 ? timestamp / 1000 : timestamp
let date = Date(timeIntervalSince1970: TimeInterval(epochSecond))
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter.string(from: date)
}
return ""
}
/// JSON
static func stringValue(_ value: Any?) -> String {
switch value {
case let string as String:
return string
case let number as NSNumber:
return number.stringValue
case .some(let value):
return "\(value)"
case nil:
return ""
}
}
/// ViewModel
private func startListeningPushEvents() {
pushEventsCancellable = pushEventCenter.events
.sink { [weak self] event in
self?.handlePaymentPush(type: event.type, dataJSON: event.dataJSON)
}
}
/// URL
@discardableResult
private func applyDynamicAmount(normalizedAmount amount: String) -> Bool {
guard !dynamicPayUrl.paymentTrimmed.isEmpty else {
errorMessage = "动态收款码暂不可用"
return false
}
var components = URLComponents(string: dynamicPayUrl)
var queryItems = components?.queryItems ?? []
queryItems.removeAll { $0.name == "amount" || $0.name == "remark" }
queryItems.append(URLQueryItem(name: "amount", value: amount))
if !remarkText.paymentTrimmed.isEmpty {
queryItems.append(URLQueryItem(name: "remark", value: remarkText.paymentTrimmed))
}
components?.queryItems = queryItems
currentPayUrl = components?.url?.absoluteString ?? "\(dynamicPayUrl)&amount=\(amount)"
qrImage = makeQRCode(from: currentPayUrl)
amountText = amount
status = .waiting
return true
}
/// Android
private func applySuccessState(from item: PaymentCollectionRecordItem) {
status = .success(item)
paymentSuccessData = PaymentSuccessDisplayData(
orderAmount: item.orderAmount,
orderNumber: item.orderNumber,
payTime: [item.createDate, item.createTime]
.filter { !$0.isEmpty }
.joined(separator: " "),
remark: remarkText.paymentTrimmed
)
scanSuccessTime = nil
}
///
private func matchedNewRecord(in records: [PaymentCollectionRecordItem]) -> PaymentCollectionRecordItem? {
let newRecords = records.filter { !knownRecordIDs.contains($0.id) }
@ -187,6 +350,11 @@ private extension String {
var paymentTrimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
///
func matches(regex pattern: String) -> Bool {
range(of: pattern, options: .regularExpression) != nil
}
}
@MainActor

View File

@ -0,0 +1,399 @@
//
// PaymentCollectionComponents.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
private enum PaymentCollectionDesign {
static let pageBackground = Color(hex: 0xF4F4F4)
static let cardRadius: CGFloat = 12
static let qrSize: CGFloat = 182
static let qrPlaceholderRadius: CGFloat = 24
static let scanSuccess = Color(hex: 0x22C55E)
static let errorText = Color(hex: 0xEF4444)
static let mutedText = Color(hex: 0x9CA3AF)
static let recordFooter = Color(hex: 0xB6BECA)
static let recordBorder = Color(hex: 0xF3F4F6)
static let orderBadgeBackground = Color(hex: 0xF4F4F4)
static let orderBadgeText = Color(hex: 0x7B8EAA)
static let navigationRowHeight: CGFloat = 52
}
///
struct PaymentWhiteCard<Content: View>: View {
let verticalPadding: CGFloat
let horizontalPadding: CGFloat
@ViewBuilder var content: () -> Content
///
init(
verticalPadding: CGFloat = AppMetrics.Spacing.medium,
horizontalPadding: CGFloat = AppMetrics.Spacing.medium,
@ViewBuilder content: @escaping () -> Content
) {
self.verticalPadding = verticalPadding
self.horizontalPadding = horizontalPadding
self.content = content
}
var body: some View {
content()
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.background(.white, in: RoundedRectangle(cornerRadius: PaymentCollectionDesign.cardRadius))
}
}
/// Android label/value
struct PaymentInfoRow: View {
let label: String
let value: String
var body: some View {
HStack(alignment: .top) {
Text(label)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.orderLabelColor)
Spacer(minLength: AppMetrics.Spacing.small)
Text(value)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(.black)
.multilineTextAlignment(.trailing)
}
}
}
///
struct PaymentNavigationRow: View {
let title: String
var body: some View {
HStack {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.orderLabelColor)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(PaymentCollectionDesign.mutedText)
}
.frame(height: PaymentCollectionDesign.navigationRowHeight)
.padding(.horizontal, AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: PaymentCollectionDesign.cardRadius))
}
}
///
struct PaymentQRSection: View {
let scenicName: String
let qrImage: UIImage?
let paymentSuccessData: PaymentSuccessDisplayData?
let onSetAmount: () -> Void
let onSaveQRCode: () -> Void
let onRefresh: () -> Void
var body: some View {
PaymentWhiteCard(verticalPadding: 32, horizontalPadding: 0) {
VStack(spacing: AppMetrics.Spacing.xLarge) {
if let paymentSuccessData {
paymentSuccessContent(paymentSuccessData)
} else {
Text(scenicName)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
.foregroundStyle(.black)
.lineLimit(1)
.padding(.horizontal, AppMetrics.Spacing.medium)
if let qrImage {
Image(uiImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: PaymentCollectionDesign.qrSize, height: PaymentCollectionDesign.qrSize)
} else {
qrPlaceholder
}
HStack(spacing: 32) {
actionLink(title: "设置金额", action: onSetAmount)
actionLink(title: "保存二维码", action: onSaveQRCode)
}
}
}
.frame(maxWidth: .infinity)
}
}
///
private var qrPlaceholder: some View {
VStack(spacing: AppMetrics.Spacing.xSmall) {
Text("未选景区或网络问题")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(PaymentCollectionDesign.errorText)
Button(action: onRefresh) {
HStack(spacing: AppMetrics.Spacing.xxSmall) {
Image(systemName: "arrow.clockwise")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Text("点击刷新")
.font(.system(size: AppMetrics.FontSize.subheadline))
}
.foregroundStyle(AppDesign.primary)
}
}
.frame(width: PaymentCollectionDesign.qrSize, height: PaymentCollectionDesign.qrSize)
.background(PaymentCollectionDesign.pageBackground, in: RoundedRectangle(cornerRadius: PaymentCollectionDesign.qrPlaceholderRadius))
}
/// QR
private func paymentSuccessContent(_ data: PaymentSuccessDisplayData) -> some View {
VStack(spacing: AppMetrics.Spacing.medium) {
Text("付款成功")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
.foregroundStyle(.black)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 90))
.foregroundStyle(PaymentCollectionDesign.scanSuccess)
Text("¥ \(data.orderAmount)")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.black)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, AppMetrics.Spacing.medium)
}
///
private func actionLink(title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.primary)
}
}
}
///
struct PaymentStatusSection: View {
let scanSuccessTime: String?
let paymentSuccessData: PaymentSuccessDisplayData?
var body: some View {
if let scanSuccessTime {
PaymentWhiteCard {
HStack {
Text(scanSuccessTime)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.orderLabelColor)
Spacer()
Text("扫码成功支付中...")
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(PaymentCollectionDesign.scanSuccess)
}
}
} else if let paymentSuccessData {
PaymentWhiteCard {
VStack(spacing: 6) {
PaymentDetailRow(label: "付款金额", value: "¥ \(paymentSuccessData.orderAmount)", valueColor: AppDesign.primary)
PaymentDetailRow(label: "付款时间", value: paymentSuccessData.payTime)
PaymentDetailRow(label: "付款单号", value: paymentSuccessData.orderNumber)
if !paymentSuccessData.remark.isEmpty {
PaymentDetailRow(label: "备注信息", value: paymentSuccessData.remark)
}
}
}
}
}
}
///
private struct PaymentDetailRow: View {
let label: String
let value: String
var valueColor: Color = .black
var body: some View {
HStack(alignment: .top) {
Text(label)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.orderLabelColor)
Spacer(minLength: AppMetrics.Spacing.small)
Text(value)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(valueColor)
.multilineTextAlignment(.trailing)
}
}
}
/// Android SetAmountDialog
struct PaymentSetAmountDialog: View {
@Binding var amountText: String
@Binding var remarkText: String
let canConfirm: Bool
let onConfirm: () -> Void
let onDismiss: () -> Void
var body: some View {
ZStack {
Color.black.opacity(0.35)
.ignoresSafeArea()
.onTapGesture { }
VStack(alignment: .leading, spacing: 0) {
Text("收款金额")
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(.black)
.padding(.bottom, AppMetrics.Spacing.large)
HStack(spacing: AppMetrics.Spacing.xSmall) {
Text("¥")
.font(.system(size: AppMetrics.FontSize.title2, weight: .bold))
.foregroundStyle(.black)
TextField("请输入收款金额", text: $amountText)
.keyboardType(.decimalPad)
.font(.system(size: AppMetrics.FontSize.title3, weight: .medium))
.foregroundStyle(.black)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(minHeight: AppMetrics.ControlSize.inputHeight)
.background(PaymentCollectionDesign.pageBackground, in: RoundedRectangle(cornerRadius: 8))
.padding(.bottom, AppMetrics.Spacing.medium)
Text("备注 (选填)")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.orderLabelColor)
.padding(.bottom, AppMetrics.Spacing.xSmall)
TextField("请输入备注信息...", text: $remarkText, axis: .vertical)
.lineLimit(4, reservesSpace: true)
.font(.system(size: AppMetrics.FontSize.subheadline))
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(Color(hex: 0xE5E7EB), lineWidth: 1)
}
.onChange(of: remarkText) { newValue in
if newValue.count > 200 {
remarkText = String(newValue.prefix(200))
}
}
HStack {
Spacer()
Text("\(remarkText.count)/200")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(PaymentCollectionDesign.mutedText)
}
.padding(.top, AppMetrics.Spacing.xxSmall)
.padding(.bottom, AppMetrics.Spacing.medium)
HStack {
Spacer()
Button(action: onDismiss) {
Text("取消")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.primary)
.frame(width: 84, height: 38)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
}
Button(action: onConfirm) {
Text("确定")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(.white)
.frame(width: 84, height: 38)
.background(AppDesign.primary.opacity(canConfirm ? 1 : 0.5), in: RoundedRectangle(cornerRadius: 8))
}
.disabled(!canConfirm)
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.12), radius: 16, y: 8)
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
}
}
}
///
struct PaymentRecordDayHeader: View {
let group: PaymentCollectionRecordGroup
var body: some View {
HStack {
Text(group.analyse.date)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(PaymentCollectionDesign.mutedText)
Spacer()
Text("共收款\(group.analyse.orderCount)笔, 当日收益:¥\(group.analyse.orderAmountSum)")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.orderLabelColor)
}
.padding(.vertical, AppMetrics.Spacing.xSmall)
}
}
///
struct PaymentRecordItemCard: View {
let item: PaymentCollectionRecordItem
var body: some View {
VStack(spacing: 6) {
HStack(alignment: .center) {
Text("\(item.orderAmount)")
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(AppDesign.primary)
Spacer()
Text("订单号:\(item.orderNumber)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(PaymentCollectionDesign.orderBadgeText)
.padding(.horizontal, AppMetrics.Spacing.xxSmall)
.padding(.vertical, 2)
.background(PaymentCollectionDesign.orderBadgeBackground, in: RoundedRectangle(cornerRadius: 4))
}
HStack {
Text(item.userPhone)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(PaymentCollectionDesign.mutedText)
Spacer()
Text(item.createTime)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.orderLabelColor)
}
}
.padding(AppMetrics.Spacing.medium)
.overlay {
RoundedRectangle(cornerRadius: PaymentCollectionDesign.cardRadius)
.stroke(PaymentCollectionDesign.recordBorder, lineWidth: 1)
}
}
}
///
struct PaymentRecordEmptyView: View {
var body: some View {
VStack {
Spacer(minLength: 80)
Text("暂无收款记录")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(PaymentCollectionDesign.mutedText)
Spacer()
}
.frame(maxWidth: .infinity)
}
}
///
extension PaymentCollectionView {
static var pageBackground: Color {
PaymentCollectionDesign.pageBackground
}
}

View File

@ -17,31 +17,69 @@ struct PaymentCollectionView: View {
@Environment(\.globalLoading) private var globalLoading
@StateObject private var viewModel = PaymentCollectionViewModel()
@State private var showingAmountSheet = false
@State private var showingAmountDialog = false
@State private var pollingTask: Task<Void, Never>?
@State private var speaker = PaymentVoiceSpeaker()
private var scenicName: String {
accountContext.currentScenic?.name ?? "暂无景区"
}
private var staffID: String {
accountContext.profile?.userId ?? ""
}
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) {
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: {
Image(systemName: "list.bullet.rectangle")
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) {
@ -49,10 +87,6 @@ struct PaymentCollectionView: View {
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
}
.sheet(isPresented: $showingAmountSheet) {
amountSheet
.presentationDetents([.medium])
}
.onDisappear {
pollingTask?.cancel()
}
@ -61,166 +95,60 @@ struct PaymentCollectionView: View {
toastCenter.show(message)
}
}
.onChange(of: viewModel.status) { status in
if case .success(let item) = status {
speaker.speak("收款到账\(item.orderAmount)")
}
/// //
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 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)
}
///
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()
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.frame(height: 52)
.padding(.horizontal, AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
///
private var qrCard: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
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 globalLoading.withLoading(message: "刷新中...") {
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
}
}
///
private func openAmountDialog() {
if viewModel.prepareAmountDialog() {
showingAmountDialog = true
}
}
///
@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))
/// fallback
private func confirmAmount() {
if viewModel.confirmAmount() {
showingAmountDialog = false
toastCenter.show("金额设置成功,二维码已更新")
startPolling()
}
}
///
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))
///
private func refreshPayCode() {
Task {
await globalLoading.withLoading(message: "刷新中...") {
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
}
.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))
}
}
///
@ -234,8 +162,8 @@ struct PaymentCollectionView: View {
///
private func saveQRCode() {
guard let image = viewModel.qrImage else {
toastCenter.show("暂无可保存二维")
guard viewModel.hasStaticPayCode, let image = viewModel.qrImage else {
toastCenter.show("请先获取收款")
return
}
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
@ -247,7 +175,7 @@ struct PaymentCollectionView: View {
PHAssetChangeRequest.creationRequestForAsset(from: image)
} completionHandler: { success, error in
Task { @MainActor in
toastCenter.show(success ? "二维码已保存" : (error?.localizedDescription ?? "保存失败"))
toastCenter.show(success ? "保存成功" : (error?.localizedDescription ?? "保存失败"))
}
}
}
@ -263,37 +191,36 @@ struct PaymentCollectionRecordView: View {
@StateObject private var viewModel = PaymentCollectionRecordViewModel()
var body: some View {
List {
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)
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(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)")
}
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 {
@ -315,8 +242,12 @@ struct PaymentCollectionRecordView: View {
/// 使 TTS
@MainActor
final class PaymentVoiceSpeaker {
static let shared = PaymentVoiceSpeaker()
private let synthesizer = AVSpeechSynthesizer()
private init() {}
///
func speak(_ text: String) {
let utterance = AVSpeechUtterance(string: text)