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,204 @@
//
// WalletAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
///
@MainActor
protocol WalletServing {
///
func walletSummary(type: Int) async throws -> WalletSummaryResponse
///
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse
///
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse
///
func withdrawInfo() async throws -> WithdrawInfoResponse
///
func withdrawSendSms() async throws
///
func withdrawApply(amount: String, smsCode: String) async throws
///
func bankCardInfo() async throws -> BankCardInfoResponse
///
func bankList() async throws -> BankListResponse
///
func areas() async throws -> [AreaNode]
///
func bankCardVerifyCode() async throws
///
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws
///
func pointOverview(staffId: Int) async throws -> PointOverviewResponse
///
func pointWithdrawApply(points: Int, remark: String) async throws
///
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse
}
@MainActor
@Observable
/// API
final class WalletAPI {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func walletSummary(type: Int = 0) async throws -> WalletSummaryResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/summary",
queryItems: [URLQueryItem(name: "type", value: String(type))]
)
)
}
///
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/earning-detail",
queryItems: [
URLQueryItem(name: "start_date", value: startDate),
URLQueryItem(name: "end_date", value: endDate),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "page_size", value: String(pageSize))
]
)
)
}
///
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/wallet/withdraw-list",
queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "page_size", value: String(pageSize))
]
)
)
}
///
func withdrawInfo() async throws -> WithdrawInfoResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/withdraw-info")
)
}
///
func withdrawSendSms() async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms", body: EmptyPayload())
)
}
///
func withdrawApply(amount: String, smsCode: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/wallet/withdraw-apply",
body: WithdrawApplyRequest(amount: amount, smsCode: smsCode)
)
)
}
///
func bankCardInfo() async throws -> BankCardInfoResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-card/info")
)
}
///
func bankList() async throws -> BankListResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-list")
)
}
///
func areas() async throws -> [AreaNode] {
try await client.send(
APIRequest(method: .get, path: "/api/app/config/areas")
)
}
///
func bankCardVerifyCode() async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/bank-card/sms-verify-code", body: EmptyPayload())
)
}
///
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/bank-card/update", body: request)
)
}
///
func pointOverview(staffId: Int) async throws -> PointOverviewResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/overview",
queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))],
body: EmptyPayload()
)
)
}
///
func pointWithdrawApply(points: Int, remark: String = "积分提现申请") async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/withdraw/apply",
body: PointWithdrawApplyRequest(points: points, remark: remark)
)
)
}
///
func pointWithdrawList(status: Int? = nil, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/withdraw/list",
body: PointWithdrawListRequest(status: status, page: page, pageSize: pageSize)
)
)
}
}
extension WalletAPI: WalletServing {}

View File

@ -0,0 +1,604 @@
//
// WalletModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
///
struct WalletSummaryResponse: Decodable, Equatable {
let amountTotal: String
let amountCurrentBalance: String
let amountWithdrawable: String
enum CodingKeys: String, CodingKey {
case amountTotal = "amount_total"
case amountCurrentBalance = "amount_current_balance"
case amountWithdrawable = "amount_withdrawable"
}
///
init(amountTotal: String = "0.00", amountCurrentBalance: String = "0.00", amountWithdrawable: String = "0.00") {
self.amountTotal = amountTotal
self.amountCurrentBalance = amountCurrentBalance
self.amountWithdrawable = amountWithdrawable
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
amountTotal = try container.decodeLossyString(forKey: .amountTotal)
amountCurrentBalance = try container.decodeLossyString(forKey: .amountCurrentBalance)
amountWithdrawable = try container.decodeLossyString(forKey: .amountWithdrawable)
}
}
///
struct WalletEarningDetailResponse: Decodable, Equatable {
let totalAmount: String
let totalPoints: Int
let total: Int
let list: [WalletEarningDetailGroup]
enum CodingKeys: String, CodingKey {
case totalAmount = "total_amount"
case totalPoints = "total_points"
case total
case list
}
///
init(totalAmount: String = "0.00", totalPoints: Int = 0, total: Int = 0, list: [WalletEarningDetailGroup] = []) {
self.totalAmount = totalAmount
self.totalPoints = totalPoints
self.total = total
self.list = list
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
totalAmount = try container.decodeLossyString(forKey: .totalAmount)
totalPoints = try container.decodeLossyInt(forKey: .totalPoints) ?? 0
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([WalletEarningDetailGroup].self, forKey: .list) ?? []
}
}
///
struct WalletEarningDetailGroup: Decodable, Equatable, Identifiable {
let date: String
let dayAmount: String
let dayPoints: Int
let items: [WalletEarningDetailItem]
var id: String { date }
enum CodingKeys: String, CodingKey {
case date
case dayAmount = "day_amount"
case dayPoints = "day_points"
case items
}
///
init(date: String, dayAmount: String = "0.00", dayPoints: Int = 0, items: [WalletEarningDetailItem] = []) {
self.date = date
self.dayAmount = dayAmount
self.dayPoints = dayPoints
self.items = items
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
date = try container.decodeLossyString(forKey: .date)
dayAmount = try container.decodeLossyString(forKey: .dayAmount)
dayPoints = try container.decodeLossyInt(forKey: .dayPoints) ?? 0
items = try container.decodeIfPresent([WalletEarningDetailItem].self, forKey: .items) ?? []
}
}
///
struct WalletEarningDetailItem: Decodable, Equatable, Identifiable {
let id: Int64
let amount: String
let points: Int
let type: Int
let typeLabel: String
let orderNumberSuffix: String
let createdAt: String
let withdrawLabel: String?
let source: String?
enum CodingKeys: String, CodingKey {
case id
case amount
case points
case type
case typeLabel = "type_label"
case orderNumberSuffix = "order_number_suffix"
case createdAt = "created_at"
case withdrawLabel = "withdraw_label"
case source
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
amount = try container.decodeLossyString(forKey: .amount)
points = try container.decodeLossyInt(forKey: .points) ?? 0
type = try container.decodeLossyInt(forKey: .type) ?? 0
typeLabel = try container.decodeLossyString(forKey: .typeLabel)
orderNumberSuffix = try container.decodeLossyString(forKey: .orderNumberSuffix)
createdAt = try container.decodeLossyString(forKey: .createdAt)
withdrawLabel = try container.decodeIfPresent(String.self, forKey: .withdrawLabel)
source = try container.decodeIfPresent(String.self, forKey: .source)
}
}
///
struct WalletWithdrawListResponse: Decodable, Equatable {
let total: Int
let item: [WalletWithdrawRecord]
enum CodingKeys: String, CodingKey {
case total
case item
}
///
init(total: Int = 0, item: [WalletWithdrawRecord] = []) {
self.total = total
self.item = item
}
/// total
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
item = try container.decodeIfPresent([WalletWithdrawRecord].self, forKey: .item) ?? []
}
}
///
struct WalletWithdrawRecord: Decodable, Equatable, Identifiable {
let id: Int64
let amount: String
let createdAt: String
let statusLabel: String
let expectedAt: String?
let auditTime: String?
let completedAt: String?
enum CodingKeys: String, CodingKey {
case id
case amount
case createdAt = "created_at"
case statusLabel = "status_label"
case expectedAt = "expected_at"
case auditTime = "audit_time"
case completedAt = "completed_at"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
amount = try container.decodeLossyString(forKey: .amount)
createdAt = try container.decodeLossyString(forKey: .createdAt)
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
expectedAt = try container.decodeIfPresent(String.self, forKey: .expectedAt)
auditTime = try container.decodeIfPresent(String.self, forKey: .auditTime)
completedAt = try container.decodeIfPresent(String.self, forKey: .completedAt)
}
}
///
struct BankCardInfoResponse: Decodable, Equatable {
let bankCard: WalletBankCardInfo?
enum CodingKeys: String, CodingKey {
case bankCard = "bank_card"
}
}
///
struct WalletBankCardInfo: Decodable, Equatable, Identifiable {
var id: String { cardNumber }
let realName: String
let bankName: String
let branchName: String
let cardNumber: String
let frontUrl: String?
let backUrl: String?
let provinceCode: String?
let cityCode: String?
let rejectReason: String?
let auditStatus: Int
let auditStatusLabel: String
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case bankName = "bank_name"
case branchName = "branch_name"
case cardNumber = "card_number"
case frontUrl = "front_url"
case backUrl = "back_url"
case provinceCode = "province_code"
case cityCode = "city_code"
case rejectReason = "reject_reason"
case auditStatus = "audit_status"
case auditStatusLabel = "audit_status_label"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
realName = try container.decodeLossyString(forKey: .realName)
bankName = try container.decodeLossyString(forKey: .bankName)
branchName = try container.decodeLossyString(forKey: .branchName)
cardNumber = try container.decodeLossyString(forKey: .cardNumber)
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
provinceCode = try container.decodeIfPresent(String.self, forKey: .provinceCode)
cityCode = try container.decodeIfPresent(String.self, forKey: .cityCode)
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
auditStatusLabel = try container.decodeLossyString(forKey: .auditStatusLabel)
}
}
///
struct BankListResponse: Decodable, Equatable {
let banks: [String]
enum CodingKeys: String, CodingKey {
case banks
}
///
init(banks: [String] = []) {
self.banks = banks
}
}
///
struct AreaNode: Decodable, Equatable, Identifiable {
let id: String
let code: String
let name: String
let children: [AreaNode]
enum CodingKeys: String, CodingKey {
case id
case code
case name
case children
}
/// id/code
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeLossyString(forKey: .code)
let rawId = try container.decodeLossyString(forKey: .id).trimmingCharacters(in: .whitespacesAndNewlines)
id = rawId.isEmpty ? code : rawId
name = try container.decodeLossyString(forKey: .name)
children = try container.decodeIfPresent([AreaNode].self, forKey: .children) ?? []
}
}
///
struct WithdrawInfoResponse: Decodable, Equatable {
let amountWithdrawable: String
let minWithdrawAmount: String
let maxSingleWithdrawAmount: String
let maxDailyWithdrawAmount: String
let userPhone: String
let bankCard: WithdrawBankCardInfo
let withdrawInfo: [String]
enum CodingKeys: String, CodingKey {
case amountWithdrawable = "amount_withdrawable"
case minWithdrawAmount = "min_withdraw_amount"
case maxSingleWithdrawAmount = "max_single_withdraw_amount"
case maxDailyWithdrawAmount = "max_daily_withdraw_amount"
case userPhone = "user_phone"
case bankCard = "bank_card"
case withdrawInfo = "withdraw_info"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
amountWithdrawable = try container.decodeLossyString(forKey: .amountWithdrawable)
minWithdrawAmount = try container.decodeLossyString(forKey: .minWithdrawAmount)
maxSingleWithdrawAmount = try container.decodeLossyString(forKey: .maxSingleWithdrawAmount)
maxDailyWithdrawAmount = try container.decodeLossyString(forKey: .maxDailyWithdrawAmount)
userPhone = try container.decodeLossyString(forKey: .userPhone)
bankCard = try container.decodeIfPresent(WithdrawBankCardInfo.self, forKey: .bankCard) ?? WithdrawBankCardInfo()
withdrawInfo = try container.decodeIfPresent([String].self, forKey: .withdrawInfo) ?? []
}
}
///
struct WithdrawBankCardInfo: Decodable, Equatable {
let realName: String
let bankName: String
let cardNumber: String
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case bankName = "bank_name"
case cardNumber = "card_number"
}
///
init(realName: String = "", bankName: String = "", cardNumber: String = "") {
self.realName = realName
self.bankName = bankName
self.cardNumber = cardNumber
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
realName = try container.decodeLossyString(forKey: .realName)
bankName = try container.decodeLossyString(forKey: .bankName)
cardNumber = try container.decodeLossyString(forKey: .cardNumber)
}
}
///
struct WithdrawApplyRequest: Encodable {
let amount: String
let smsCode: String
enum CodingKeys: String, CodingKey {
case amount
case smsCode = "sms_code"
}
}
/// URL
struct UpdateBankInfoRequest: Encodable, Equatable {
let realName: String
let cardNumber: String
let bankName: String
let branchName: String
let frontUrl: String
let backUrl: String
let provinceCode: String
let cityCode: String
let smsVerifyCode: String
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case cardNumber = "card_number"
case bankName = "bank_name"
case branchName = "branch_name"
case frontUrl = "front_url"
case backUrl = "back_url"
case provinceCode = "province_code"
case cityCode = "city_code"
case smsVerifyCode = "sms_verify_code"
}
}
///
struct PointOverviewResponse: Decodable, Equatable {
let totalPoints: Int
let availablePoints: Int
let withdrawnPoints: Int
let pendingPoints: Int
let time: String
enum CodingKeys: String, CodingKey {
case totalPoints = "total_points"
case availablePoints = "available_points"
case withdrawnPoints = "withdrawn_points"
case pendingPoints = "pending_points"
case time
}
///
init(totalPoints: Int = 0, availablePoints: Int = 0, withdrawnPoints: Int = 0, pendingPoints: Int = 0, time: String = "") {
self.totalPoints = totalPoints
self.availablePoints = availablePoints
self.withdrawnPoints = withdrawnPoints
self.pendingPoints = pendingPoints
self.time = time
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
totalPoints = try container.decodeLossyInt(forKey: .totalPoints) ?? 0
availablePoints = try container.decodeLossyInt(forKey: .availablePoints) ?? 0
withdrawnPoints = try container.decodeLossyInt(forKey: .withdrawnPoints) ?? 0
pendingPoints = try container.decodeLossyInt(forKey: .pendingPoints) ?? 0
time = try container.decodeLossyString(forKey: .time)
}
}
///
struct PointWithdrawApplyRequest: Encodable {
let points: Int
let remark: String
}
///
struct PointWithdrawListRequest: Encodable {
let status: Int?
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case status
case page
case pageSize = "page_size"
}
}
///
struct PointWithdrawListResponse: Decodable, Equatable {
let total: Int
let list: [PointWithdrawItem]
enum CodingKeys: String, CodingKey {
case total
case list
}
///
init(total: Int = 0, list: [PointWithdrawItem] = []) {
self.total = total
self.list = list
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([PointWithdrawItem].self, forKey: .list) ?? []
}
}
///
struct PointWithdrawItem: Decodable, Equatable, Identifiable {
let id: Int
let points: Int
let amount: Double
let status: Int
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case points
case amount
case status
case createdAt = "created_at"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
points = try container.decodeLossyInt(forKey: .points) ?? 0
amount = try container.decodeLossyDouble(forKey: .amount) ?? 0
status = try container.decodeLossyInt(forKey: .status) ?? 0
createdAt = try container.decodeLossyString(forKey: .createdAt)
}
}
///
enum WalletRoute: Hashable, Identifiable {
case withdrawApply
case bankCardSettings
case pointsRedemption
case realNameAuth
var id: String {
switch self {
case .withdrawApply: "withdrawApply"
case .bankCardSettings: "bankCardSettings"
case .pointsRedemption: "pointsRedemption"
case .realNameAuth: "realNameAuth"
}
}
}
///
enum WalletWithdrawDecision: Equatable {
case route(WalletRoute)
case message(String)
}
///
enum WalletDateFilter: String, CaseIterable, Identifiable {
case last7
case last30
case last180
var id: String { rawValue }
///
var title: String {
switch self {
case .last7: "近7日"
case .last30: "近30日"
case .last180: "近180日"
}
}
///
func range(today: Date = Date(), calendar: Calendar = .current) -> (start: String, end: String) {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.dateFormat = "yyyy-MM-dd"
let days: Int
switch self {
case .last7:
days = 6
case .last30:
days = 29
case .last180:
days = 179
}
let startDate = calendar.date(byAdding: .day, value: -days, to: today) ?? today
return (formatter.string(from: startDate), formatter.string(from: today))
}
}
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
}
///
func decodeLossyDouble(forKey key: Key) throws -> Double? {
if let value = try decodeIfPresent(Double.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(Int.self, forKey: key) {
return Double(value)
}
if let value = try decodeIfPresent(String.self, forKey: key) {
return Double(value.trimmingCharacters(in: .whitespacesAndNewlines))
}
return nil
}
}

View File

@ -0,0 +1,538 @@
//
// WalletViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
@MainActor
/// 便
protocol WalletRealNameServing {
///
func realNameInfo() async throws -> RealNameInfoResponse
}
extension ProfileAPI: WalletRealNameServing {}
/// Tab
enum WalletLedgerTab: String, CaseIterable, Identifiable {
case earnings
case withdraws
var id: String { rawValue }
/// Tab
var title: String {
switch self {
case .earnings: "收益明细"
case .withdraws: "提现记录"
}
}
}
@MainActor
@Observable
/// ViewModel
final class WalletViewModel {
var summary: WalletSummaryResponse?
var pointsOverview: PointOverviewResponse?
var selectedTab: WalletLedgerTab = .earnings
var selectedFilter: WalletDateFilter = .last30
var earningsGroups: [WalletEarningDetailGroup] = []
var withdrawRecords: [WalletWithdrawRecord] = []
var isLoadingSummary = false
var isLoadingList = false
var isCheckingWithdraw = false
var errorMessage: String?
private let pageSize = 20
private var earningsPage = 1
private var earningsTotal = 0
private var withdrawPage = 1
private var withdrawTotal = 0
///
var withdrawableText: String {
"¥ \(Self.moneyText(summary?.amountWithdrawable))"
}
///
var totalAmountText: String {
"¥ \(Self.moneyText(summary?.amountTotal))"
}
///
var currentBalanceText: String {
"¥ \(Self.moneyText(summary?.amountCurrentBalance))"
}
///
var canLoadMoreEarnings: Bool {
earningsGroups.flatMap(\.items).count < earningsTotal
}
///
var canLoadMoreWithdraws: Bool {
withdrawRecords.count < withdrawTotal
}
///
func loadInitial(api: WalletServing, staffId: Int?) async {
isLoadingSummary = true
errorMessage = nil
async let summaryResult = api.walletSummary(type: 0)
async let pointsResult: PointOverviewResponse? = {
guard let staffId else { return nil }
return try? await api.pointOverview(staffId: staffId)
}()
do {
summary = try await summaryResult
pointsOverview = await pointsResult
} catch {
errorMessage = error.localizedDescription
}
isLoadingSummary = false
await reloadList(api: api)
}
/// Tab
func reloadList(api: WalletServing) async {
switch selectedTab {
case .earnings:
earningsPage = 1
await loadEarnings(api: api, reset: true)
case .withdraws:
withdrawPage = 1
await loadWithdraws(api: api, reset: true)
}
}
/// Tab
func selectTab(_ tab: WalletLedgerTab, api: WalletServing) async {
guard selectedTab != tab else { return }
selectedTab = tab
await reloadList(api: api)
}
///
func selectFilter(_ filter: WalletDateFilter, api: WalletServing) async {
guard selectedFilter != filter else { return }
selectedFilter = filter
if selectedTab == .earnings {
await reloadList(api: api)
}
}
///
func loadMore(api: WalletServing) async {
switch selectedTab {
case .earnings where canLoadMoreEarnings:
earningsPage += 1
await loadEarnings(api: api, reset: false)
case .withdraws where canLoadMoreWithdraws:
withdrawPage += 1
await loadWithdraws(api: api, reset: false)
default:
break
}
}
///
func resolveWithdrawDecision(profileAPI: WalletRealNameServing, walletAPI: WalletServing) async -> WalletWithdrawDecision {
isCheckingWithdraw = true
defer { isCheckingWithdraw = false }
do {
let realName = try await profileAPI.realNameInfo().realNameInfo
guard let realName else {
return .route(.realNameAuth)
}
switch realName.auditStatus {
case 2:
let bankCard = try await walletAPI.bankCardInfo().bankCard
guard let bankCard else {
return .route(.bankCardSettings)
}
if bankCard.auditStatus == 2 {
return .route(.withdrawApply)
}
if bankCard.auditStatus == 3 {
return .route(.bankCardSettings)
}
return .message("银行卡审核中,请审核通过后再试")
case 3:
return .route(.realNameAuth)
default:
return .message("实名认证审核中,请审核通过后再试")
}
} catch {
return .message(error.localizedDescription)
}
}
///
private func loadEarnings(api: WalletServing, reset: Bool) async {
isLoadingList = true
errorMessage = nil
defer { isLoadingList = false }
let range = selectedFilter.range()
do {
let response = try await api.walletEarningDetail(
startDate: range.start,
endDate: range.end,
page: earningsPage,
pageSize: pageSize
)
earningsTotal = response.total
earningsGroups = reset ? response.list : earningsGroups + response.list
} catch {
if !reset { earningsPage = max(1, earningsPage - 1) }
if reset { earningsGroups = [] }
errorMessage = error.localizedDescription
}
}
///
private func loadWithdraws(api: WalletServing, reset: Bool) async {
isLoadingList = true
errorMessage = nil
defer { isLoadingList = false }
do {
let response = try await api.walletWithdrawList(page: withdrawPage, pageSize: pageSize)
withdrawTotal = response.total
withdrawRecords = reset ? response.item : withdrawRecords + response.item
} catch {
if !reset { withdrawPage = max(1, withdrawPage - 1) }
if reset { withdrawRecords = [] }
errorMessage = error.localizedDescription
}
}
/// 0.00
static func moneyText(_ rawValue: String?) -> String {
let text = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let decimal = Decimal(string: text), decimal > 0 else {
return "0.00"
}
return NSDecimalNumber(decimal: decimal).stringValue
}
}
@MainActor
@Observable
/// ViewModel
final class WithdrawApplyViewModel {
var info: WithdrawInfoResponse?
var amountText = ""
var smsCode = ""
var isLoading = false
var isSubmitting = false
var smsCountdown = 0
var errorMessage: String?
///
func load(api: WalletServing) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
info = try await api.withdrawInfo()
} catch {
errorMessage = error.localizedDescription
}
}
///
func sendSms(api: WalletServing) async {
guard smsCountdown == 0 else { return }
do {
try await api.withdrawSendSms()
smsCountdown = 60
} catch {
errorMessage = error.localizedDescription
}
}
///
func submit(api: WalletServing) async -> Bool {
guard !isSubmitting else { return false }
guard let amount = PaymentCollectionViewModel.normalizedMoney(amountText) else {
errorMessage = "请输入有效提现金额"
return false
}
guard let info else {
errorMessage = "提现信息未加载"
return false
}
guard (Decimal(string: amount) ?? 0) <= (Decimal(string: info.amountWithdrawable) ?? 0) else {
errorMessage = "提现金额不能超过可提现金额"
return false
}
guard !smsCode.walletTrimmed.isEmpty else {
errorMessage = "请输入短信验证码"
return false
}
isSubmitting = true
defer { isSubmitting = false }
do {
try await api.withdrawApply(amount: amount, smsCode: smsCode.walletTrimmed)
amountText = ""
smsCode = ""
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
}
@MainActor
@Observable
/// ViewModel
final class WithdrawalSettingsViewModel {
var bankCard: WalletBankCardInfo?
var banks: [String] = []
var areas: [AreaNode] = []
var realName = ""
var cardNumber = ""
var bankName = ""
var branchName = ""
var provinceCode = ""
var cityCode = ""
var smsCode = ""
var frontImageData: Data?
var backImageData: Data?
var frontImageURL = ""
var backImageURL = ""
var uploadProgress = 0
var isLoading = false
var isSubmitting = false
var smsCountdown = 0
var errorMessage: String?
///
func load(api: WalletServing) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
async let bankCardResult = api.bankCardInfo()
async let bankListResult = api.bankList()
async let areaResult = api.areas()
do {
let (cardResponse, bankResponse, areaResponse) = try await (bankCardResult, bankListResult, areaResult)
bankCard = cardResponse.bankCard
banks = bankResponse.banks
areas = areaResponse
apply(cardResponse.bankCard)
} catch {
errorMessage = error.localizedDescription
}
}
///
func sendSms(api: WalletServing) async {
guard smsCountdown == 0 else { return }
do {
try await api.bankCardVerifyCode()
smsCountdown = 60
} catch {
errorMessage = error.localizedDescription
}
}
///
func submit(api: WalletServing, uploader: OSSUploadServing, scenicId: Int?) async -> Bool {
guard !isSubmitting else { return false }
guard let scenicId else {
errorMessage = "请先选择景区"
return false
}
guard validateForm() else { return false }
isSubmitting = true
uploadProgress = 0
defer { isSubmitting = false }
do {
let frontURL = try await resolvedImageURL(
currentURL: frontImageURL,
data: frontImageData,
fileName: "bank_card_front.jpg",
uploader: uploader,
scenicId: scenicId
)
let backURL = try await resolvedImageURL(
currentURL: backImageURL,
data: backImageData,
fileName: "bank_card_back.jpg",
uploader: uploader,
scenicId: scenicId
)
let request = UpdateBankInfoRequest(
realName: realName.walletTrimmed,
cardNumber: cardNumber.walletTrimmed,
bankName: bankName.walletTrimmed,
branchName: branchName.walletTrimmed,
frontUrl: frontURL,
backUrl: backURL,
provinceCode: provinceCode.walletTrimmed,
cityCode: cityCode.walletTrimmed,
smsVerifyCode: smsCode.walletTrimmed
)
try await api.updateBankInfo(request)
frontImageURL = frontURL
backImageURL = backURL
frontImageData = nil
backImageData = nil
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
private func apply(_ info: WalletBankCardInfo?) {
guard let info else { return }
realName = info.realName
cardNumber = info.cardNumber
bankName = info.bankName
branchName = info.branchName
provinceCode = info.provinceCode ?? ""
cityCode = info.cityCode ?? ""
frontImageURL = info.frontUrl ?? ""
backImageURL = info.backUrl ?? ""
}
///
private func validateForm() -> Bool {
if realName.walletTrimmed.isEmpty { errorMessage = "请输入持卡人姓名"; return false }
if cardNumber.walletTrimmed.isEmpty { errorMessage = "请输入银行卡号"; return false }
if bankName.walletTrimmed.isEmpty { errorMessage = "请选择开户银行"; return false }
if branchName.walletTrimmed.isEmpty { errorMessage = "请输入开户支行"; return false }
if provinceCode.walletTrimmed.isEmpty || cityCode.walletTrimmed.isEmpty { errorMessage = "请选择开户地区"; return false }
if frontImageURL.walletTrimmed.isEmpty && frontImageData == nil { errorMessage = "请选择银行卡正面照片"; return false }
if backImageURL.walletTrimmed.isEmpty && backImageData == nil { errorMessage = "请选择银行卡反面照片"; return false }
if smsCode.walletTrimmed.isEmpty { errorMessage = "请输入短信验证码"; return false }
return true
}
/// URL OSS
private func resolvedImageURL(
currentURL: String,
data: Data?,
fileName: String,
uploader: OSSUploadServing,
scenicId: Int
) async throws -> String {
guard let data else { return currentURL }
return try await uploader.uploadBankCardImage(data: data, fileName: fileName, scenicId: scenicId) { [weak self] progress in
self?.uploadProgress = progress
}
}
}
@MainActor
@Observable
/// ViewModel
final class PointsRedemptionViewModel {
var overview = PointOverviewResponse()
var records: [PointWithdrawItem] = []
var pointsText = ""
var isLoading = false
var isSubmitting = false
var errorMessage: String?
private let pageSize = 20
private var page = 1
private var total = 0
///
var canLoadMore: Bool {
records.count < total
}
///
func load(api: WalletServing, staffId: Int?) async {
guard let staffId else {
errorMessage = "缺少账号信息"
return
}
isLoading = true
errorMessage = nil
page = 1
defer { isLoading = false }
do {
async let overviewResult = api.pointOverview(staffId: staffId)
async let listResult = api.pointWithdrawList(status: nil, page: page, pageSize: pageSize)
overview = try await overviewResult
let list = try await listResult
total = list.total
records = list.list
} catch {
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: WalletServing) async {
guard canLoadMore else { return }
page += 1
do {
let response = try await api.pointWithdrawList(status: nil, page: page, pageSize: pageSize)
total = response.total
records += response.list
} catch {
page = max(1, page - 1)
errorMessage = error.localizedDescription
}
}
///
func fillAllPoints() {
pointsText = String(overview.withdrawnPoints)
}
///
func submit(api: WalletServing, staffId: Int?) async -> Bool {
guard !isSubmitting else { return false }
guard let points = Int(pointsText.walletTrimmed), points > 0 else {
errorMessage = "请输入有效兑换积分"
return false
}
guard points <= overview.withdrawnPoints else {
errorMessage = "兑换积分不能超过可兑换积分"
return false
}
guard let staffId else {
errorMessage = "缺少账号信息"
return false
}
isSubmitting = true
defer { isSubmitting = false }
do {
try await api.pointWithdrawApply(points: points, remark: "积分提现申请")
pointsText = ""
await load(api: api, staffId: staffId)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
}
private extension String {
/// 使 fileprivate
var walletTrimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}

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 ?? "")
}
}

View File

@ -0,0 +1,31 @@
# 钱包模块
## 模块职责
`Features/Wallet` 承接首页 `wallet` 权限入口,负责个人钱包首页、收益明细、提现记录、提现申请、银行卡设置和积分兑换。
## 代码结构
- `WalletAPI`:封装钱包、提现、银行卡、地区、积分相关接口。
- `WalletViewModel`:管理钱包汇总、收益明细分页、提现记录分页和提现资格判断。
- `WithdrawApplyViewModel`:管理提现信息、金额校验、短信验证码和提现提交。
- `WithdrawalSettingsViewModel`:管理银行卡资料、图片上传和银行卡更新提交。
- `PointsRedemptionViewModel`:管理积分概览、兑换记录和兑换申请。
- `WalletView`:钱包首页和钱包内部二级路由入口。
## 提现资格流
点击提现后先查询实名认证:
1. 未提交或驳回:进入已迁移的 `RealNameAuthView`
2. 审核中:提示等待实名认证审核通过。
3. 实名认证通过:查询银行卡资料。
4. 无银行卡或银行卡驳回:进入 `WithdrawalSettingsView`
5. 银行卡审核中:提示等待审核。
6. 银行卡通过:进入 `WithdrawApplyView`
## 上传与缓存边界
银行卡正反面照片使用 `PhotosPicker` 选择后,通过 `OSSUploadService.uploadBankCardImage` 上传到 `bank_card/yyyyMMdd/scenicId/...` 路径,再把最终 URL 提交给服务端。
钱包数据只保存在各 ViewModel 内存中。短信验证码、OSS STS、提现表单、银行卡图片 Data 和积分兑换表单均不落盘。