新增支付与钱包模块,并接入首页路由

引入真实收款与钱包页面替换首页占位入口,通过 RootView 接入 API,并支持银行卡 OSS 上传及二维码保存到相册。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 17:46:01 +08:00
parent d02fd2c069
commit 5d4613f317
23 changed files with 3117 additions and 9 deletions

View File

@ -0,0 +1,55 @@
//
// PaymentAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
///
@MainActor
protocol PaymentServing {
/// URL
func payCode(scenicId: Int) async throws -> PayCodeResponse
///
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse
}
@MainActor
@Observable
/// API
final class PaymentAPI {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// URL
func payCode(scenicId: Int) async throws -> PayCodeResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/pay-code",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
///
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/order/photo-scan-order-list",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
}
extension PaymentAPI: PaymentServing {}

View File

@ -0,0 +1,182 @@
//
// PaymentModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// URL URL
struct PayCodeResponse: Decodable, Equatable {
let staticPayUrl: String
let dynamicPayUrl: String
/// JSON
enum CodingKeys: String, CodingKey {
case staticPayUrl = "static_pay_url"
case dynamicPayUrl = "dynamic_pay_url"
}
///
init(staticPayUrl: String = "", dynamicPayUrl: String = "") {
self.staticPayUrl = staticPayUrl
self.dynamicPayUrl = dynamicPayUrl
}
/// URL
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
staticPayUrl = try container.decodeLossyString(forKey: .staticPayUrl)
dynamicPayUrl = try container.decodeLossyString(forKey: .dynamicPayUrl)
}
}
///
struct PaymentCollectionRecordResponse: Decodable, Equatable {
let analyse: [PaymentCollectionRecordAnalyseItem]
let list: [PaymentCollectionRecordItem]
/// JSON
enum CodingKeys: String, CodingKey {
case analyse
case list
}
///
init(analyse: [PaymentCollectionRecordAnalyseItem] = [], list: [PaymentCollectionRecordItem] = []) {
self.analyse = analyse
self.list = list
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
analyse = try container.decodeIfPresent([PaymentCollectionRecordAnalyseItem].self, forKey: .analyse) ?? []
list = try container.decodeIfPresent([PaymentCollectionRecordItem].self, forKey: .list) ?? []
}
}
///
struct PaymentCollectionRecordAnalyseItem: Decodable, Equatable, Identifiable {
let date: String
let orderCount: Int
let orderAmountSum: String
var id: String { date }
/// JSON
enum CodingKeys: String, CodingKey {
case date
case orderCount = "order_count"
case orderAmountSum = "order_amount_sum"
}
///
init(date: String, orderCount: Int, orderAmountSum: String) {
self.date = date
self.orderCount = orderCount
self.orderAmountSum = orderAmountSum
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
date = try container.decodeLossyString(forKey: .date)
orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0
orderAmountSum = try container.decodeLossyString(forKey: .orderAmountSum)
}
}
///
struct PaymentCollectionRecordItem: Decodable, Equatable, Identifiable {
let orderNumber: String
let userPhone: String
let orderAmount: String
let createDate: String
let createTime: String
var id: String {
orderNumber.isEmpty ? "\(createDate)-\(createTime)-\(orderAmount)" : orderNumber
}
/// JSON
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case userPhone = "user_phone"
case orderAmount = "order_amount"
case createDate = "create_date"
case createTime = "create_time"
}
///
init(orderNumber: String, userPhone: String, orderAmount: String, createDate: String, createTime: String) {
self.orderNumber = orderNumber
self.userPhone = userPhone
self.orderAmount = orderAmount
self.createDate = createDate
self.createTime = createTime
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
userPhone = try container.decodeLossyString(forKey: .userPhone)
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
createDate = try container.decodeLossyString(forKey: .createDate)
createTime = try container.decodeLossyString(forKey: .createTime)
}
}
///
struct PaymentCollectionRecordGroup: Equatable, Identifiable {
let analyse: PaymentCollectionRecordAnalyseItem
let items: [PaymentCollectionRecordItem]
var id: String { analyse.id }
}
///
enum PaymentCollectionStatus: Equatable {
case idle
case waiting
case success(PaymentCollectionRecordItem)
case failed(String)
}
private extension KeyedDecodingContainer {
///
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try decodeIfPresent(Double.self, forKey: key) {
return value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value)
}
return ""
}
///
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
}

View File

@ -0,0 +1,25 @@
# 收款模块
## 模块职责
`Features/Payment` 承接首页 `payment_collection``payment_qr``payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账轮询。
## 代码结构
- `PaymentAPI`:封装收款码和扫码收款记录接口。
- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成和到账轮询。
- `PaymentCollectionRecordViewModel`:管理收款记录加载和按日期分组。
- `PaymentCollectionView`:展示收款码、金额弹窗、保存二维码和收款状态。
- `PaymentCollectionRecordView`:展示收款记录明细。
## 数据流
1. 页面读取 `AccountContext.currentScenic`
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. 命中新收款记录后进入成功态,并使用系统语音播报到账金额。
## 缓存边界
收款码 URL、付款状态、轮询结果不落盘。保存二维码只写入系统相册App 不额外缓存图片文件或 Data。

View File

@ -0,0 +1,246 @@
//
// PaymentViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import CoreImage
import CoreImage.CIFilterBuiltins
import Foundation
import Observation
import UIKit
@MainActor
@Observable
/// ViewModel
final class PaymentCollectionViewModel {
var staticPayUrl = ""
var dynamicPayUrl = ""
var amountText = ""
var remarkText = ""
var currentPayUrl = ""
var qrImage: UIImage?
var isLoading = false
var isPolling = false
var errorMessage: String?
var status: PaymentCollectionStatus = .idle
private var knownRecordIDs = Set<String>()
private let context = CIContext()
private let qrFilter = CIFilter.qrCodeGenerator()
///
var hasStaticPayCode: Bool {
!staticPayUrl.paymentTrimmed.isEmpty
}
///
func loadPayCode(api: PaymentServing, scenicId: Int?) async {
guard let scenicId else {
resetPayCode()
errorMessage = "请先选择景区"
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await api.payCode(scenicId: scenicId)
staticPayUrl = response.staticPayUrl
dynamicPayUrl = response.dynamicPayUrl
currentPayUrl = response.staticPayUrl
qrImage = makeQRCode(from: currentPayUrl)
status = .idle
} catch {
errorMessage = error.localizedDescription
}
}
/// URL
@discardableResult
func applyDynamicAmount() -> Bool {
guard let amount = Self.normalizedMoney(amountText), Decimal(string: amount) ?? 0 > 0 else {
errorMessage = "请输入有效收款金额"
return false
}
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
}
///
func primePaymentRecords(api: PaymentServing, scenicId: Int?) async {
guard let scenicId else { return }
if let response = try? await api.paymentCollectionRecords(scenicId: scenicId) {
knownRecordIDs = Set(response.list.map(\.id))
}
}
///
func pollUntilPaymentDetected(
api: PaymentServing,
scenicId: Int?,
maxAttempts: Int = 60,
intervalNanoseconds: UInt64 = 2_000_000_000
) async {
guard let scenicId else {
status = .failed("请先选择景区")
return
}
guard maxAttempts > 0 else { return }
isPolling = true
defer { isPolling = false }
for attempt in 0 ..< maxAttempts {
if Task.isCancelled { return }
do {
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
if let item = matchedNewRecord(in: response.list) {
knownRecordIDs.insert(item.id)
status = .success(item)
return
}
} catch {
status = .failed(error.localizedDescription)
return
}
if attempt < maxAttempts - 1 {
try? await Task.sleep(nanoseconds: intervalNanoseconds)
}
}
status = .failed("暂未查询到到账记录,请稍后刷新收款记录")
}
///
func resetPayCode() {
staticPayUrl = ""
dynamicPayUrl = ""
currentPayUrl = ""
qrImage = nil
knownRecordIDs = []
status = .idle
}
///
static func normalizedMoney(_ rawValue: String) -> String? {
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
let sanitized = text.replacingOccurrences(of: ",", with: "")
guard let decimal = Decimal(string: sanitized), decimal > 0 else { return nil }
let number = NSDecimalNumber(decimal: decimal)
let handler = NSDecimalNumberHandler(
roundingMode: .plain,
scale: 2,
raiseOnExactness: false,
raiseOnOverflow: false,
raiseOnUnderflow: false,
raiseOnDivideByZero: false
)
return number.rounding(accordingToBehavior: handler).stringValue
}
///
private func matchedNewRecord(in records: [PaymentCollectionRecordItem]) -> PaymentCollectionRecordItem? {
let newRecords = records.filter { !knownRecordIDs.contains($0.id) }
guard !newRecords.isEmpty else { return nil }
guard let amount = Self.normalizedMoney(amountText) else {
return newRecords.first
}
return newRecords.first { Self.normalizedMoney($0.orderAmount) == amount } ?? newRecords.first
}
/// 使
private func makeQRCode(from text: String) -> UIImage? {
guard !text.paymentTrimmed.isEmpty,
let data = text.data(using: .utf8) else {
return nil
}
qrFilter.setValue(data, forKey: "inputMessage")
qrFilter.setValue("M", forKey: "inputCorrectionLevel")
guard let output = qrFilter.outputImage else { return nil }
let transformed = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
private extension String {
/// 使 fileprivate
var paymentTrimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@MainActor
@Observable
/// ViewModel
final class PaymentCollectionRecordViewModel {
var groups: [PaymentCollectionRecordGroup] = []
var isLoading = false
var errorMessage: String?
///
func load(api: PaymentServing, scenicId: Int?) async {
guard let scenicId else {
groups = []
errorMessage = "请先选择景区"
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await api.paymentCollectionRecords(scenicId: scenicId)
groups = Self.makeGroups(from: response)
} catch {
errorMessage = error.localizedDescription
}
}
/// analyse list
static func makeGroups(from response: PaymentCollectionRecordResponse) -> [PaymentCollectionRecordGroup] {
if !response.analyse.isEmpty {
return response.analyse.map { analyse in
PaymentCollectionRecordGroup(
analyse: analyse,
items: response.list.filter { $0.createDate == analyse.date }
)
}
}
let grouped = Dictionary(grouping: response.list, by: \.createDate)
return grouped.keys.sorted(by: >).map { date in
let items = grouped[date] ?? []
let total = items.reduce(Decimal(0)) { partial, item in
partial + (Decimal(string: item.orderAmount) ?? 0)
}
let analyse = PaymentCollectionRecordAnalyseItem(
date: date,
orderCount: items.count,
orderAmountSum: NSDecimalNumber(decimal: total).stringValue
)
return PaymentCollectionRecordGroup(analyse: analyse, items: items)
}
}
}

View 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)
}
}