对齐 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

@ -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)