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>
532 lines
22 KiB
Swift
532 lines
22 KiB
Swift
//
|
||
// 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 ?? "")
|
||
}
|
||
}
|