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