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

引入真实收款与钱包页面替换首页占位入口,通过 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,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 ?? "")
}
}