Add Payment and Wallet modules with home routing integration.

Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library.

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

View File

@ -0,0 +1,531 @@
//
// WalletView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Combine
import PhotosUI
import SwiftUI
///
struct WalletView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(WalletAPI.self) private var walletAPI
@Environment(ProfileAPI.self) private var profileAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = WalletViewModel()
@State private var route: WalletRoute?
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
summaryCard
walletActions
ledgerControls
ledgerList
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.pageVertical)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("我的钱包")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(item: $route) { route in
switch route {
case .withdrawApply:
WithdrawApplyView()
case .bankCardSettings:
WithdrawalSettingsView()
case .pointsRedemption:
PointsRedemptionView()
case .realNameAuth:
RealNameAuthView()
}
}
.task {
await viewModel.loadInitial(api: walletAPI, staffId: staffId)
}
.refreshable {
await viewModel.loadInitial(api: walletAPI, staffId: staffId)
}
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
}
/// staffId使 userId
private var staffId: Int? {
Int(accountContext.profile?.userId ?? "")
}
///
private var summaryCard: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
HStack {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text("可提现金额")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(.white.opacity(0.86))
Text(viewModel.withdrawableText)
.font(.system(size: AppMetrics.FontSize.largeTitle, weight: .bold))
.foregroundStyle(.white)
}
Spacer()
if viewModel.isLoadingSummary {
ProgressView()
.tint(.white)
}
}
HStack(spacing: AppMetrics.Spacing.medium) {
walletSummaryItem(title: "累计金额", value: viewModel.totalAmountText)
walletSummaryItem(title: "当前余额", value: viewModel.currentBalanceText)
walletSummaryItem(title: "可兑换积分", value: "\(viewModel.pointsOverview?.withdrawnPoints ?? 0)")
}
}
.padding(AppMetrics.Spacing.large)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
}
///
private var walletActions: some View {
HStack(spacing: AppMetrics.Spacing.small) {
walletActionButton(title: "提现", icon: "banknote") {
Task {
let decision = await viewModel.resolveWithdrawDecision(profileAPI: profileAPI, walletAPI: walletAPI)
switch decision {
case .route(let nextRoute):
route = nextRoute
case .message(let message):
toastCenter.show(message)
}
}
}
walletActionButton(title: "银行卡", icon: "creditcard") {
route = .bankCardSettings
}
walletActionButton(title: "积分兑换", icon: "giftcard") {
route = .pointsRedemption
}
}
}
///
private var ledgerControls: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Picker("明细类型", selection: Binding(
get: { viewModel.selectedTab },
set: { tab in Task { await viewModel.selectTab(tab, api: walletAPI) } }
)) {
ForEach(WalletLedgerTab.allCases) { tab in
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
if viewModel.selectedTab == .earnings {
HStack {
ForEach(WalletDateFilter.allCases) { filter in
Button {
Task { await viewModel.selectFilter(filter, api: walletAPI) }
} label: {
Text(filter.title)
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
.foregroundStyle(viewModel.selectedFilter == filter ? .white : AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xSmall)
.background(viewModel.selectedFilter == filter ? AppDesign.primary : AppDesign.primarySoft, in: Capsule())
}
}
Spacer()
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
///
@ViewBuilder
private var ledgerList: some View {
if viewModel.selectedTab == .earnings {
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.earningsGroups) { group in
walletSection(title: group.date, subtitle: "¥\(group.dayAmount) / \(group.dayPoints)") {
ForEach(group.items) { item in
ledgerRow(title: item.typeLabel.isEmpty ? "收益" : item.typeLabel, subtitle: item.createdAt, amount: "\(item.amount)", extra: item.orderNumberSuffix)
}
}
}
loadMoreButton(enabled: viewModel.canLoadMoreEarnings)
}
} else {
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.withdrawRecords) { record in
ledgerRow(title: record.statusLabel, subtitle: record.createdAt, amount: "\(record.amount)", extra: record.expectedAt ?? "")
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
loadMoreButton(enabled: viewModel.canLoadMoreWithdraws)
}
}
}
///
private func walletSummaryItem(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(.white.opacity(0.75))
Text(value)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(.white)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
///
private func walletActionButton(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 walletSection<Content: View>(title: String, subtitle: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Spacer()
Text(subtitle)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
content()
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
///
private func ledgerRow(title: String, subtitle: String, amount: String, extra: String) -> some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text([subtitle, extra].filter { !$0.isEmpty }.joined(separator: " · "))
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Text(amount)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(amount.hasPrefix("+") ? AppDesign.success : AppDesign.textPrimary)
}
}
///
@ViewBuilder
private func loadMoreButton(enabled: Bool) -> some View {
if enabled {
Button {
Task { await viewModel.loadMore(api: walletAPI) }
} label: {
Text(viewModel.isLoadingList ? "加载中..." : "加载更多")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.frame(maxWidth: .infinity, minHeight: 44)
}
}
}
}
///
struct WithdrawApplyView: View {
@Environment(WalletAPI.self) private var walletAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.dismiss) private var dismiss
@State private var viewModel = WithdrawApplyViewModel()
var body: some View {
Form {
if let info = viewModel.info {
Section("银行卡") {
Text("\(info.bankCard.bankName) \(info.bankCard.cardNumber)")
Text("可提现金额:¥\(WalletViewModel.moneyText(info.amountWithdrawable))")
}
}
Section("提现金额") {
TextField("请输入提现金额", text: $viewModel.amountText)
.keyboardType(.decimalPad)
HStack {
TextField("短信验证码", text: $viewModel.smsCode)
.keyboardType(.numberPad)
Button(viewModel.smsCountdown > 0 ? "\(viewModel.smsCountdown)s" : "获取验证码") {
Task { await viewModel.sendSms(api: walletAPI) }
}
.disabled(viewModel.smsCountdown > 0)
}
}
Button(viewModel.isSubmitting ? "提交中..." : "提交提现申请") {
Task {
if await viewModel.submit(api: walletAPI) {
toastCenter.show("提现申请已提交")
dismiss()
}
}
}
.disabled(viewModel.isSubmitting)
}
.navigationTitle("提现申请")
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load(api: walletAPI) }
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
if viewModel.smsCountdown > 0 {
viewModel.smsCountdown -= 1
}
}
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
}
}
/// 使 OSS
struct WithdrawalSettingsView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(WalletAPI.self) private var walletAPI
@Environment(OSSUploadService.self) private var ossUploadService
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.dismiss) private var dismiss
@State private var viewModel = WithdrawalSettingsViewModel()
@State private var frontItem: PhotosPickerItem?
@State private var backItem: PhotosPickerItem?
var body: some View {
Form {
if let bankCard = viewModel.bankCard {
Section("审核状态") {
Text(bankCard.auditStatusLabel.isEmpty ? "待提交" : bankCard.auditStatusLabel)
if let reason = bankCard.rejectReason, !reason.isEmpty {
Text(reason).foregroundStyle(AppDesign.warning)
}
}
}
Section("基础信息") {
TextField("持卡人姓名", text: $viewModel.realName)
TextField("银行卡号", text: $viewModel.cardNumber)
.keyboardType(.numberPad)
Picker("开户银行", selection: $viewModel.bankName) {
Text("请选择").tag("")
ForEach(viewModel.banks, id: \.self) { bank in
Text(bank).tag(bank)
}
}
TextField("开户支行", text: $viewModel.branchName)
Picker("省份", selection: $viewModel.provinceCode) {
Text("请选择").tag("")
ForEach(viewModel.areas) { area in
Text(area.name).tag(area.code)
}
}
Picker("城市", selection: $viewModel.cityCode) {
Text("请选择").tag("")
ForEach(selectedProvinceChildren) { city in
Text(city.name).tag(city.code)
}
}
}
Section("银行卡照片") {
bankCardImagePicker(title: "银行卡正面", item: $frontItem, data: viewModel.frontImageData, url: viewModel.frontImageURL)
bankCardImagePicker(title: "银行卡反面", item: $backItem, data: viewModel.backImageData, url: viewModel.backImageURL)
if viewModel.uploadProgress > 0 && viewModel.uploadProgress < 100 {
ProgressView(value: Double(viewModel.uploadProgress), total: 100)
}
}
Section("短信验证码") {
HStack {
TextField("请输入短信验证码", text: $viewModel.smsCode)
.keyboardType(.numberPad)
Button(viewModel.smsCountdown > 0 ? "\(viewModel.smsCountdown)s" : "获取验证码") {
Task { await viewModel.sendSms(api: walletAPI) }
}
.disabled(viewModel.smsCountdown > 0)
}
}
Button(viewModel.isSubmitting ? "提交中..." : "提交银行卡资料") {
Task {
if await viewModel.submit(api: walletAPI, uploader: ossUploadService, scenicId: accountContext.currentScenic?.id) {
toastCenter.show("银行卡资料已提交")
dismiss()
}
}
}
.disabled(viewModel.isSubmitting)
}
.navigationTitle("银行卡设置")
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load(api: walletAPI) }
.onChange(of: frontItem) { _, item in
Task { viewModel.frontImageData = try? await item?.loadTransferable(type: Data.self) }
}
.onChange(of: backItem) { _, item in
Task { viewModel.backImageData = try? await item?.loadTransferable(type: Data.self) }
}
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
if viewModel.smsCountdown > 0 {
viewModel.smsCountdown -= 1
}
}
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
}
///
private var selectedProvinceChildren: [AreaNode] {
viewModel.areas.first { $0.code == viewModel.provinceCode }?.children ?? []
}
///
private func bankCardImagePicker(title: String, item: Binding<PhotosPickerItem?>, data: Data?, url: String) -> some View {
PhotosPicker(selection: item, matching: .images) {
HStack(spacing: AppMetrics.Spacing.small) {
bankCardPreview(data: data, url: url)
.frame(width: 88, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(title)
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(AppDesign.placeholder)
}
}
}
///
@ViewBuilder
private func bankCardPreview(data: Data?, url: String) -> some View {
if let data, let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
RemoteImage(urlString: url) {
Rectangle()
.fill(AppDesign.primarySoft)
.overlay {
Image(systemName: "creditcard")
.foregroundStyle(AppDesign.primary)
}
}
}
}
}
///
struct PointsRedemptionView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(WalletAPI.self) private var walletAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = PointsRedemptionViewModel()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("可兑换积分")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
Text("\(viewModel.overview.withdrawnPoints)")
.font(.system(size: AppMetrics.FontSize.largeTitle, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
HStack {
TextField("请输入兑换积分", text: $viewModel.pointsText)
.keyboardType(.numberPad)
Button("全部") {
viewModel.fillAllPoints()
}
}
.appInputFieldStyle(cornerRadius: 8, minHeight: AppMetrics.ControlSize.inputHeight)
Button(viewModel.isSubmitting ? "提交中..." : "提交兑换") {
Task {
if await viewModel.submit(api: walletAPI, staffId: staffId) {
toastCenter.show("积分兑换申请已提交")
}
}
}
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
.foregroundStyle(.white)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.records) { item in
HStack {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
Text("\(item.points) 积分")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Text(item.createdAt)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Text("¥\(item.amount, specifier: "%.2f")")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
if viewModel.canLoadMore {
Button("加载更多") {
Task { await viewModel.loadMore(api: walletAPI) }
}
}
}
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.pageVertical)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("积分兑换")
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load(api: walletAPI, staffId: staffId) }
.onChange(of: viewModel.errorMessage) { _, message in
if let message {
toastCenter.show(message)
}
}
}
/// staffId
private var staffId: Int? {
Int(accountContext.profile?.userId ?? "")
}
}