将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 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 {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.walletAPI) private var walletAPI
|
||
@Environment(\.profileAPI) private var profileAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
|
||
@StateObject 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)
|
||
.appNavigationDestination(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) private var walletAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.dismiss) private var dismiss
|
||
@StateObject 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 {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.walletAPI) private var walletAPI
|
||
@Environment(\.ossUploadService) private var ossUploadService
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@StateObject 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 {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.walletAPI) private var walletAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@StateObject 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 ?? "")
|
||
}
|
||
}
|