对齐 Android 立即收款页面 UI,并补齐推送状态与语音开关。
统一收款详情布局、设置金额弹窗和收款记录样式,接入 type 6/1 推送驱动与轮询 fallback,使 iOS 收款反馈与 Android 一致。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user