feat: update wallet punch point and report success

This commit is contained in:
2026-07-09 14:27:18 +08:00
parent 2970f1514b
commit 43e6133c21
34 changed files with 6671 additions and 71 deletions

View File

@ -21,7 +21,7 @@ enum HomeRouteHandler {
return
}
if uri != "pilot_controller", AppStore.shared.currentScenicId <= 0 {
if uri != "pilot_controller", uri != "wallet", AppStore.shared.currentScenicId <= 0 {
showToast("请先选择景区", from: viewController)
return
}
@ -68,12 +68,14 @@ enum HomeRouteHandler {
viewController.navigationController?.pushViewController(SampleListViewController(), animated: true)
case "asset_management":
viewController.navigationController?.pushViewController(MaterialListViewController(), animated: true)
case "checkin_points":
viewController.navigationController?.pushViewController(PunchPointListViewController(), animated: true)
case "live_stream_management":
viewController.navigationController?.pushViewController(LiveManageViewController(), animated: true)
case "live_album":
viewController.navigationController?.pushViewController(LiveAlbumListViewController(), animated: true)
case "wallet":
showDeveloping(from: viewController)
viewController.navigationController?.pushViewController(WalletViewController(), animated: true)
default:
showDeveloping(from: viewController)
}

View File

@ -8,6 +8,9 @@ import Foundation
/// ViewModel Android `HomeViewModel`
final class HomeViewModel {
///
static let locationReportPromptText = "您已进入打卡范围"
private(set) var currentScenicName = ""
private(set) var currentAppRole: AppRoleCode?
private(set) var commonMenus: [HomeMenuItem] = []
@ -18,7 +21,7 @@ final class HomeViewModel {
private(set) var showOnlineStatusDialog = false
private(set) var showLocationReportSuccessDialog = false
private(set) var isMinimalTopRole = false
private(set) var locationInfoText = "定位中..."
private(set) var locationInfoText = HomeViewModel.locationReportPromptText
private(set) var reportTimeText = ""
private(set) var isLocationReporting = false
private(set) var needsPermissionReload = false

View File

@ -0,0 +1,84 @@
//
// PunchPointAPI.swift
// suixinkan
//
import Foundation
/// API 便 ViewModel
@MainActor
protocol PunchPointAPIProtocol {
///
func list(scenicAreaId: String, status: Int, page: Int, pageSize: Int) async throws -> PunchPointListResponse
///
func info(id: Int64) async throws -> PunchPointDetail
///
func add(_ request: PunchPointSaveRequest) async throws
///
func edit(_ request: PunchPointSaveRequest) async throws
///
func delete(id: Int64) async throws
}
/// API Android `NetworkApi` `photog/scenic-spot`
@MainActor
final class PunchPointAPI: PunchPointAPIProtocol {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func list(scenicAreaId: String, status: Int, page: Int, pageSize: Int) async throws -> PunchPointListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic-spot/list",
queryItems: [
URLQueryItem(name: "scenic_area_id", value: scenicAreaId),
URLQueryItem(name: "status", value: String(status)),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
)
)
}
///
func info(id: Int64) async throws -> PunchPointDetail {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic-spot/info",
queryItems: [URLQueryItem(name: "id", value: String(id))]
)
)
}
///
func add(_ request: PunchPointSaveRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/add", body: request)
)
}
///
func edit(_ request: PunchPointSaveRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/edit", body: request)
)
}
///
func delete(id: Int64) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/scenic-spot/delete",
body: PunchPointIDRequest(id: id)
)
)
}
}

View File

@ -0,0 +1,441 @@
//
// PunchPointModels.swift
// suixinkan
//
import CoreLocation
import Foundation
/// Android `PunchPointFilterType`
enum PunchPointFilterType: Int, CaseIterable, Sendable, Equatable, Hashable {
case all = 0
case inOperation = 1
case paused = 2
case pendingReview = 3
case rejected = 4
///
var title: String {
switch self {
case .all: "全部"
case .inOperation: "运营中"
case .paused: "已暂停"
case .pendingReview: "未审核"
case .rejected: "审核未通过"
}
}
/// `status`
var apiStatus: Int { rawValue }
}
///
struct PunchPointListResponse: Decodable, Sendable, Equatable {
let total: Int
let list: [PunchPointItem]
init(total: Int = 0, list: [PunchPointItem] = []) {
self.total = total
self.list = list
}
}
///
struct PunchPointRegion: Codable, Sendable, Equatable, Hashable {
let lat: Double
let lot: Double
let address: String
let scenicSpotString: String?
enum CodingKeys: String, CodingKey {
case lat
case lot
case address
case scenicSpotString = "scenic_spot_str"
}
init(lat: Double = 0, lot: Double = 0, address: String = "", scenicSpotString: String? = nil) {
self.lat = lat
self.lot = lot
self.address = address
self.scenicSpotString = scenicSpotString
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
lat = try container.decodeIfPresent(Double.self, forKey: .lat) ?? 0
lot = try container.decodeIfPresent(Double.self, forKey: .lot) ?? 0
address = try container.decodeIfPresent(String.self, forKey: .address) ?? ""
scenicSpotString = try container.decodeIfPresent(String.self, forKey: .scenicSpotString)
}
}
/// Android `ScenicSpotItem`
struct PunchPointItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int64
let name: String
let status: Int
let statusLabel: String
let region: PunchPointRegion?
let scenicSpotString: String
let guideImages: [String]
let mpQrcode: String?
let createdAt: String
let creatorId: Int64?
let creator: String?
let creatorPhone: String?
let auditor: String?
let auditTime: String?
let auditRemark: String?
enum CodingKeys: String, CodingKey {
case id
case name
case status
case statusLabel = "status_label"
case region
case scenicSpotString = "scenic_spot_str"
case guideImages = "guide_imgs"
case mpQrcode = "mp_qrcode"
case createdAt = "created_at"
case creatorId = "creator_id"
case creator
case creatorPhone = "creator_phone"
case auditor
case auditTime = "audit_time"
case auditRemark = "audit_remark"
}
init(
id: Int64 = 0,
name: String = "",
status: Int = 0,
statusLabel: String = "",
region: PunchPointRegion? = nil,
scenicSpotString: String = "",
guideImages: [String] = [],
mpQrcode: String? = nil,
createdAt: String = "",
creatorId: Int64? = nil,
creator: String? = nil,
creatorPhone: String? = nil,
auditor: String? = nil,
auditTime: String? = nil,
auditRemark: String? = nil
) {
self.id = id
self.name = name
self.status = status
self.statusLabel = statusLabel
self.region = region
self.scenicSpotString = scenicSpotString
self.guideImages = guideImages
self.mpQrcode = mpQrcode
self.createdAt = createdAt
self.creatorId = creatorId
self.creator = creator
self.creatorPhone = creatorPhone
self.auditor = auditor
self.auditTime = auditTime
self.auditRemark = auditRemark
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeFlexibleInt64(forKey: .id) ?? 0
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
statusLabel = try container.decodeIfPresent(String.self, forKey: .statusLabel) ?? ""
region = try container.decodeIfPresent(PunchPointRegion.self, forKey: .region)
scenicSpotString = try container.decodeIfPresent(String.self, forKey: .scenicSpotString) ?? ""
guideImages = try container.decodeIfPresent([String].self, forKey: .guideImages) ?? []
mpQrcode = try container.decodeIfPresent(String.self, forKey: .mpQrcode)
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
creatorId = try container.decodeFlexibleInt64(forKey: .creatorId)
creator = try container.decodeIfPresent(String.self, forKey: .creator)
creatorPhone = try container.decodeIfPresent(String.self, forKey: .creatorPhone)
auditor = try container.decodeIfPresent(String.self, forKey: .auditor)
auditTime = try container.decodeIfPresent(String.self, forKey: .auditTime)
auditRemark = try container.decodeIfPresent(String.self, forKey: .auditRemark)
}
///
var displayAddress: String {
let address = region?.address.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !address.isEmpty { return address }
return scenicSpotString.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
/// Android `ScenicSpotDetailResponse`
struct PunchPointDetail: Decodable, Sendable, Equatable {
let id: Int64
let name: String
let status: Int
let statusLabel: String
let region: PunchPointRegion?
let scenicSpotString: String
let description: String?
let guideImages: [String]
let mpQrcode: String?
let createdAt: String
let creatorId: Int64?
let creator: String?
let creatorPhone: String?
let auditor: String?
let auditTime: String?
let auditRemark: String?
enum CodingKeys: String, CodingKey {
case id
case name
case status
case statusLabel = "status_label"
case region
case scenicSpotString = "scenic_spot_str"
case description
case guideImages = "guide_imgs"
case mpQrcode = "mp_qrcode"
case createdAt = "created_at"
case creatorId = "creator_id"
case creator
case creatorPhone = "creator_phone"
case auditor
case auditTime = "audit_time"
case auditRemark = "audit_remark"
}
init(
id: Int64 = 0,
name: String = "",
status: Int = 0,
statusLabel: String = "",
region: PunchPointRegion? = nil,
scenicSpotString: String = "",
description: String? = nil,
guideImages: [String] = [],
mpQrcode: String? = nil,
createdAt: String = "",
creatorId: Int64? = nil,
creator: String? = nil,
creatorPhone: String? = nil,
auditor: String? = nil,
auditTime: String? = nil,
auditRemark: String? = nil
) {
self.id = id
self.name = name
self.status = status
self.statusLabel = statusLabel
self.region = region
self.scenicSpotString = scenicSpotString
self.description = description
self.guideImages = guideImages
self.mpQrcode = mpQrcode
self.createdAt = createdAt
self.creatorId = creatorId
self.creator = creator
self.creatorPhone = creatorPhone
self.auditor = auditor
self.auditTime = auditTime
self.auditRemark = auditRemark
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeFlexibleInt64(forKey: .id) ?? 0
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
statusLabel = try container.decodeIfPresent(String.self, forKey: .statusLabel) ?? ""
region = try container.decodeIfPresent(PunchPointRegion.self, forKey: .region)
scenicSpotString = try container.decodeIfPresent(String.self, forKey: .scenicSpotString) ?? ""
description = try container.decodeIfPresent(String.self, forKey: .description)
guideImages = try container.decodeIfPresent([String].self, forKey: .guideImages) ?? []
mpQrcode = try container.decodeIfPresent(String.self, forKey: .mpQrcode)
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
creatorId = try container.decodeFlexibleInt64(forKey: .creatorId)
creator = try container.decodeIfPresent(String.self, forKey: .creator)
creatorPhone = try container.decodeIfPresent(String.self, forKey: .creatorPhone)
auditor = try container.decodeIfPresent(String.self, forKey: .auditor)
auditTime = try container.decodeIfPresent(String.self, forKey: .auditTime)
auditRemark = try container.decodeIfPresent(String.self, forKey: .auditRemark)
}
///
var displayAddress: String {
let address = region?.address.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !address.isEmpty { return address }
return scenicSpotString.trimmingCharacters(in: .whitespacesAndNewlines)
}
///
var coordinate: CLLocationCoordinate2D? {
guard let region else { return nil }
return CLLocationCoordinate2D(latitude: region.lat, longitude: region.lot)
}
}
///
struct PunchPointSaveRequest: Encodable, Sendable, Equatable {
let id: Int64?
let scenicAreaId: String
let name: String
let description: String?
let region: PunchPointSaveRegion
let scenicSpotString: String
let guideImages: [String]
enum CodingKeys: String, CodingKey {
case id
case scenicAreaId = "scenic_area_id"
case name
case description
case region
case scenicSpotString = "scenic_spot_str"
case guideImages = "guide_imgs"
}
init(
id: Int64? = nil,
scenicAreaId: String,
name: String,
description: String?,
region: PunchPointSaveRegion,
scenicSpotString: String,
guideImages: [String]
) {
self.id = id
self.scenicAreaId = scenicAreaId
self.name = name
self.description = description
self.region = region
self.scenicSpotString = scenicSpotString
self.guideImages = guideImages
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(id, forKey: .id)
try container.encode(scenicAreaId, forKey: .scenicAreaId)
try container.encode(name, forKey: .name)
try container.encodeIfPresent(description, forKey: .description)
try container.encode(region, forKey: .region)
try container.encode(scenicSpotString, forKey: .scenicSpotString)
try container.encode(guideImages, forKey: .guideImages)
}
}
///
struct PunchPointSaveRegion: Encodable, Sendable, Equatable {
let lot: Double
let lat: Double
let address: String
}
///
struct PunchPointIDRequest: Encodable, Sendable, Equatable {
let id: Int64
}
///
struct PunchPointImageState: Sendable, Equatable, Hashable, Identifiable {
let id: UUID
var data: Data
var previewURL: String?
var fileName: String
var uploadedURL: String
var isUploading: Bool
var uploadProgress: Int
var errorMessage: String?
init(
id: UUID = UUID(),
data: Data,
previewURL: String? = nil,
fileName: String,
uploadedURL: String = "",
isUploading: Bool = false,
uploadProgress: Int = 0,
errorMessage: String? = nil
) {
self.id = id
self.data = data
self.previewURL = previewURL
self.fileName = fileName
self.uploadedURL = uploadedURL
self.isUploading = isUploading
self.uploadProgress = uploadProgress
self.errorMessage = errorMessage
}
///
static func remote(url: String) -> PunchPointImageState {
PunchPointImageState(
data: Data(),
previewURL: url,
fileName: URL(string: url)?.lastPathComponent.nonEmptyTrimmed ?? "punch_point.jpg",
uploadedURL: url,
isUploading: false,
uploadProgress: 100
)
}
///
var isUploaded: Bool { !uploadedURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
}
///
struct PunchPointUploadDialogState: Sendable, Equatable {
let title: String
let progress: Int
}
/// UI
enum PunchPointDisplayFormatter {
///
static func maskedPhone(_ phone: String?) -> String {
guard let phone = phone?.trimmingCharacters(in: .whitespacesAndNewlines), !phone.isEmpty else { return "--" }
guard phone.count >= 11 else { return phone }
let prefix = phone.prefix(3)
let suffix = phone.suffix(phone.count - 7)
return "\(prefix)****\(suffix)"
}
/// /
static func statusColors(_ status: Int) -> (background: UInt, text: UInt) {
switch status {
case 1:
return (0xF0FDF4, 0x09BE4F)
case 2:
return (0xFFF0E2, 0xFF7B00)
case 3:
return (0xEFF6FF, 0x0073FF)
case 4:
return (0xFFE7E7, 0xEF4444)
default:
return (0xF4F4F4, 0x666666)
}
}
}
private extension KeyedDecodingContainer {
func decodeFlexibleInt64(forKey key: Key) throws -> Int64? {
if let value = try decodeIfPresent(Int64.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(Int.self, forKey: key) {
return Int64(value)
}
if let value = try decodeIfPresent(String.self, forKey: key) {
return Int64(value)
}
return nil
}
}
private extension String {
var nonEmptyTrimmed: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

View File

@ -0,0 +1,481 @@
//
// PunchPointViewModels.swift
// suixinkan
//
import CoreLocation
import Foundation
/// 便 ViewModel
@MainActor
protocol PunchPointImageUploading {
/// OSS URL
func uploadPunchPointImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String
}
/// ViewModel
final class PunchPointListViewModel {
private(set) var items: [PunchPointItem] = []
private(set) var filterType: PunchPointFilterType = .all
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var canLoadMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onShowQRCode: ((String) -> Void)?
private var currentPage = 1
private var totalCount = 0
private let pageSize = 10
private let currentScenicIdProvider: () -> Int
/// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) {
self.currentScenicIdProvider = currentScenicIdProvider
}
///
func loadInitial(api: any PunchPointAPIProtocol) async {
await load(reset: true, showLoading: true, api: api)
}
///
func refresh(api: any PunchPointAPIProtocol) async {
isRefreshing = true
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func selectFilter(_ type: PunchPointFilterType, api: any PunchPointAPIProtocol) async {
guard filterType != type else { return }
filterType = type
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any PunchPointAPIProtocol) async {
guard lastVisibleIndex >= items.count - 3 else { return }
await load(reset: false, showLoading: false, api: api)
}
///
func showQRCode(for item: PunchPointItem) {
guard item.status == 1 || item.status == 2 else {
onShowMessage?("未审核通过不可查看二维码")
return
}
guard let url = item.mpQrcode?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty else {
onShowMessage?("暂无二维码")
return
}
onShowQRCode?(url)
}
///
func delete(item: PunchPointItem, api: any PunchPointAPIProtocol) async {
do {
try await api.delete(id: item.id)
items.removeAll { $0.id == item.id }
totalCount = max(0, totalCount - 1)
canLoadMore = items.count < totalCount
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : "删除失败:\(error.localizedDescription)")
}
}
private func load(reset: Bool, showLoading: Bool, api: any PunchPointAPIProtocol) async {
if reset {
currentPage = 1
canLoadMore = false
} else {
guard canLoadMore, !isLoading, !isRefreshing else { return }
currentPage += 1
}
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
if reset {
items = []
canLoadMore = false
totalCount = 0
}
notifyStateChange()
return
}
isLoading = showLoading
notifyStateChange()
defer {
isLoading = false
isRefreshing = false
notifyStateChange()
}
do {
let response = try await api.list(
scenicAreaId: String(scenicId),
status: filterType.apiStatus,
page: currentPage,
pageSize: pageSize
)
totalCount = response.total
items = reset ? response.list : items + response.list
canLoadMore = response.list.count >= pageSize && items.count < totalCount
} catch is CancellationError {
return
} catch {
if !reset, currentPage > 1 {
currentPage -= 1
}
if reset {
items = []
totalCount = 0
canLoadMore = false
}
onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点列表失败" : "获取打卡点列表失败:\(error.localizedDescription)")
}
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class PunchPointDetailViewModel {
private(set) var detail: PunchPointDetail?
private(set) var isLoading = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
let punchPointId: Int64
/// ViewModel
init(punchPointId: Int64) {
self.punchPointId = punchPointId
}
///
func load(api: any PunchPointAPIProtocol) async {
guard punchPointId > 0 else {
onShowMessage?("打卡点信息异常")
return
}
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
detail = try await api.info(id: punchPointId)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点详情失败" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class PunchPointFormViewModel {
///
enum Mode: Equatable {
case create
case edit(id: Int64)
}
private(set) var name = ""
private(set) var address = ""
private(set) var description = ""
private(set) var coordinate: CLLocationCoordinate2D?
private(set) var images: [PunchPointImageState] = []
private(set) var uploadDialogState: PunchPointUploadDialogState?
private(set) var isSubmitting = false
private(set) var isLocating = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitSuccess: (() -> Void)?
var onCoordinateChange: ((CLLocationCoordinate2D) -> Void)?
let mode: Mode
private let currentScenicIdProvider: () -> Int
private let locationProvider: any LocationProviding
/// ViewModel
init(
mode: Mode = .create,
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId },
locationProvider: any LocationProviding = LocationProvider.shared
) {
self.mode = mode
self.currentScenicIdProvider = currentScenicIdProvider
self.locationProvider = locationProvider
}
///
var coordinatesText: String {
guard let coordinate else { return "" }
return "\(coordinate.latitude),\(coordinate.longitude)"
}
/// 使
func initialize(with detail: PunchPointDetail) {
name = detail.name
address = detail.displayAddress
description = detail.description ?? ""
coordinate = detail.coordinate
images = detail.guideImages.map(PunchPointImageState.remote(url:))
if let coordinate {
onCoordinateChange?(coordinate)
}
notifyStateChange()
}
///
func updateName(_ text: String) {
name = text
notifyStateChange()
}
///
func updateAddress(_ text: String) {
address = text
notifyStateChange()
}
/// 50
func updateDescription(_ text: String) {
description = String(text.prefix(50))
notifyStateChange()
}
///
func autoLocation() async {
await locate(updateMap: true, missingPermissionMessage: "定位权限未授予,无法自动定位")
}
///
func locateToCurrent() async {
await locate(updateMap: true, missingPermissionMessage: "定位权限未授予,请在设置中开启")
}
///
func selectCoordinate(_ coordinate: CLLocationCoordinate2D) async {
self.coordinate = coordinate
notifyStateChange()
onCoordinateChange?(coordinate)
let resolved = await locationProvider.reverseGeocode(
latitude: coordinate.latitude,
longitude: coordinate.longitude
)
if !resolved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
address = resolved
notifyStateChange()
}
}
///
func addLocalImages(_ newImages: [PunchPointImageState], uploader: any PunchPointImageUploading) async {
let remaining = 9 - images.count
guard remaining > 0 else {
onShowMessage?("最多只能选择9张图片")
return
}
let accepted = Array(newImages.prefix(remaining))
guard !accepted.isEmpty else { return }
let start = images.count
images.append(contentsOf: accepted)
notifyStateChange()
for offset in accepted.indices {
await uploadImage(at: start + offset, title: "正在上传(\(offset + 1)/\(accepted.count))", uploader: uploader)
}
uploadDialogState = nil
notifyStateChange()
}
///
func deleteImage(at index: Int) {
guard images.indices.contains(index), !images[index].isUploading else { return }
images.remove(at: index)
notifyStateChange()
}
///
func retryUpload(at index: Int, uploader: any PunchPointImageUploading) async {
guard images.indices.contains(index) else { return }
images[index].errorMessage = nil
notifyStateChange()
await uploadImage(at: index, title: "正在上传(1/1)", uploader: uploader)
uploadDialogState = nil
notifyStateChange()
}
///
func submit(api: any PunchPointAPIProtocol) async {
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
onShowMessage?("请输入打卡点名称")
return
}
guard let coordinate else {
onShowMessage?("请选择打卡点坐标")
return
}
guard !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
onShowMessage?("请输入打卡点地址")
return
}
guard !images.isEmpty else {
onShowMessage?("请上传至少一张图片")
return
}
guard !images.contains(where: { $0.isUploading }) else {
onShowMessage?("图片正在上传,请稍后提交")
return
}
guard !images.contains(where: { !$0.isUploaded }) else {
onShowMessage?("请先完成图片上传")
return
}
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
let request = PunchPointSaveRequest(
id: editId,
scenicAreaId: String(scenicId),
name: name,
description: description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : description,
region: PunchPointSaveRegion(
lot: coordinate.longitude,
lat: coordinate.latitude,
address: address
),
scenicSpotString: address,
guideImages: images.map(\.uploadedURL)
)
isSubmitting = true
notifyStateChange()
defer {
isSubmitting = false
notifyStateChange()
}
do {
switch mode {
case .create:
try await api.add(request)
case .edit:
try await api.edit(request)
}
onShowMessage?("提交成功")
onSubmitSuccess?()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "提交失败" : "提交失败\(error.localizedDescription)")
}
}
private var editId: Int64? {
if case .edit(let id) = mode { return id }
return nil
}
private func locate(updateMap: Bool, missingPermissionMessage: String) async {
isLocating = true
notifyStateChange()
defer {
isLocating = false
notifyStateChange()
}
do {
let snapshot = try await locationProvider.requestSnapshot()
let coordinate = CLLocationCoordinate2D(latitude: snapshot.latitude, longitude: snapshot.longitude)
self.coordinate = coordinate
if !snapshot.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
address = snapshot.address
}
if updateMap {
onCoordinateChange?(coordinate)
}
} catch LocationProviderError.permissionDenied {
onShowMessage?(missingPermissionMessage)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "定位失败" : error.localizedDescription)
}
}
private func uploadImage(at index: Int, title: String, uploader: any PunchPointImageUploading) async {
guard images.indices.contains(index) else { return }
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
guard !images[index].isUploaded else { return }
images[index].isUploading = true
images[index].uploadProgress = 0
images[index].errorMessage = nil
uploadDialogState = PunchPointUploadDialogState(title: title, progress: 0)
notifyStateChange()
do {
let item = images[index]
let url = try await uploader.uploadPunchPointImage(
data: item.data,
fileName: item.fileName,
scenicId: scenicId,
onProgress: { [weak self] progress in
guard let self else { return }
Task { @MainActor in
guard self.images.indices.contains(index) else { return }
self.images[index].uploadProgress = progress
self.images[index].isUploading = progress < 100
self.uploadDialogState = PunchPointUploadDialogState(title: title, progress: progress)
self.notifyStateChange()
}
}
)
guard images.indices.contains(index) else { return }
images[index].uploadedURL = url
images[index].previewURL = url
images[index].isUploading = false
images[index].uploadProgress = 100
} catch is CancellationError {
return
} catch {
guard images.indices.contains(index) else { return }
images[index].isUploading = false
images[index].uploadProgress = 0
images[index].errorMessage = error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription
onShowMessage?(error.localizedDescription.isEmpty ? "图片上传失败,请重试" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -22,8 +22,41 @@ protocol WalletServing {
}
@MainActor
/// API
final class WalletAPI: WalletServing {
///
protocol WalletPageServing: WalletServing {
///
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse
///
func earningDetail(
startDate: String,
endDate: String,
page: Int,
pageSize: Int
) async throws -> EarningDetailResponse
///
func withdrawInfo() async throws -> WithdrawInfoResponse
///
func withdrawSendSMS() async throws
///
func withdrawApply(amount: String, smsCode: String) 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
/// API
final class WalletAPI: WalletPageServing {
private let client: APIClient
/// API
@ -64,4 +97,105 @@ final class WalletAPI: WalletServing {
)
)
}
///
func walletWithdrawList(page: Int = 1, pageSize: Int = 10) 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(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
)
)
}
///
func earningDetail(
startDate: String,
endDate: String,
page: Int = 1,
pageSize: Int = 10
) async throws -> EarningDetailResponse {
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(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
)
)
}
///
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 request = WithdrawApplyRequest(amount: amount, smsCode: smsCode)
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/wallet/withdraw-apply",
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 request = PointWithdrawApplyRequest(points: points, remark: remark)
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/withdraw/apply",
body: request
)
)
}
///
func pointWithdrawList(status: Int? = nil, page: Int = 1, pageSize: Int = 10) async throws -> PointWithdrawListResponse {
let request = PointWithdrawListRequest(status: status, page: max(page, 1), pageSize: max(pageSize, 1))
return try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/withdraw/list",
body: request
)
)
}
}

View File

@ -45,6 +45,90 @@ struct WalletTransactionListResponse: Decodable, Equatable {
}
}
/// Tab
struct WalletWithdrawListResponse: Decodable, Equatable {
let total: Int
let item: [WalletWithdrawItem]
///
init(total: Int = 0, item: [WalletWithdrawItem] = []) {
self.total = total
self.item = item
}
}
///
struct WalletWithdrawItem: Decodable, Hashable {
let id: Int
let amount: String
let createdAt: String
let settlementStatus: Int
let withdrawStatus: Int
let expectedAt: String
let auditTime: String
let auditRemark: String
let completedAt: String
let errorReason: String
let statusLabel: String
enum CodingKeys: String, CodingKey {
case id
case amount
case createdAt = "created_at"
case settlementStatus = "settlement_status"
case withdrawStatus = "withdraw_status"
case expectedAt = "expected_at"
case auditTime = "audit_time"
case auditRemark = "audit_remark"
case completedAt = "completed_at"
case errorReason = "error_reason"
case statusLabel = "status_label"
}
///
init(
id: Int = 0,
amount: String = "",
createdAt: String = "",
settlementStatus: Int = 0,
withdrawStatus: Int = 0,
expectedAt: String = "",
auditTime: String = "",
auditRemark: String = "",
completedAt: String = "",
errorReason: String = "",
statusLabel: String = ""
) {
self.id = id
self.amount = amount
self.createdAt = createdAt
self.settlementStatus = settlementStatus
self.withdrawStatus = withdrawStatus
self.expectedAt = expectedAt
self.auditTime = auditTime
self.auditRemark = auditRemark
self.completedAt = completedAt
self.errorReason = errorReason
self.statusLabel = statusLabel
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeWalletLossyInt(forKey: .id) ?? 0
amount = try container.decodeWalletLossyString(forKey: .amount)
createdAt = try container.decodeWalletLossyString(forKey: .createdAt)
settlementStatus = try container.decodeWalletLossyInt(forKey: .settlementStatus) ?? 0
withdrawStatus = try container.decodeWalletLossyInt(forKey: .withdrawStatus) ?? 0
expectedAt = try container.decodeWalletLossyString(forKey: .expectedAt)
auditTime = try container.decodeWalletLossyString(forKey: .auditTime)
auditRemark = try container.decodeWalletLossyString(forKey: .auditRemark)
completedAt = try container.decodeWalletLossyString(forKey: .completedAt)
errorReason = try container.decodeWalletLossyString(forKey: .errorReason)
statusLabel = try container.decodeWalletLossyString(forKey: .statusLabel)
}
}
///
struct WalletTransactionItem: Decodable, Hashable {
let id: Int
@ -97,6 +181,392 @@ struct WalletTransactionItem: Decodable, Hashable {
}
}
///
struct EarningDetailResponse: Decodable, Equatable {
let totalAmount: String
let totalPoints: Int
let total: Int
let list: [EarningDetailGroup]
enum CodingKeys: String, CodingKey {
case totalAmount = "total_amount"
case totalPoints = "total_points"
case total
case list
}
///
init(totalAmount: String = "", totalPoints: Int = 0, total: Int = 0, list: [EarningDetailGroup] = []) {
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.decodeWalletLossyString(forKey: .totalAmount)
totalPoints = try container.decodeWalletLossyInt(forKey: .totalPoints) ?? 0
total = try container.decodeWalletLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([EarningDetailGroup].self, forKey: .list) ?? []
}
}
///
struct EarningDetailGroup: Decodable, Hashable {
let date: String
let dayAmount: String
let dayPoints: Int
let items: [EarningDetailItem]
enum CodingKeys: String, CodingKey {
case date
case dayAmount = "day_amount"
case dayPoints = "day_points"
case items
}
///
init(date: String = "", dayAmount: String = "", dayPoints: Int = 0, items: [EarningDetailItem] = []) {
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.decodeWalletLossyString(forKey: .date)
dayAmount = try container.decodeWalletLossyString(forKey: .dayAmount)
dayPoints = try container.decodeWalletLossyInt(forKey: .dayPoints) ?? 0
items = try container.decodeIfPresent([EarningDetailItem].self, forKey: .items) ?? []
}
}
///
struct EarningDetailItem: Decodable, Hashable {
let id: Int
let amount: String
let points: Int
let type: String
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(
id: Int = 0,
amount: String = "",
points: Int = 0,
type: String = "",
typeLabel: String = "",
orderNumberSuffix: String = "",
createdAt: String = "",
withdrawLabel: String = "",
source: String = ""
) {
self.id = id
self.amount = amount
self.points = points
self.type = type
self.typeLabel = typeLabel
self.orderNumberSuffix = orderNumberSuffix
self.createdAt = createdAt
self.withdrawLabel = withdrawLabel
self.source = source
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeWalletLossyInt(forKey: .id) ?? 0
amount = try container.decodeWalletLossyString(forKey: .amount)
points = try container.decodeWalletLossyInt(forKey: .points) ?? 0
type = try container.decodeWalletLossyString(forKey: .type)
typeLabel = try container.decodeWalletLossyString(forKey: .typeLabel)
orderNumberSuffix = try container.decodeWalletLossyString(forKey: .orderNumberSuffix)
createdAt = try container.decodeWalletLossyString(forKey: .createdAt)
withdrawLabel = try container.decodeWalletLossyString(forKey: .withdrawLabel)
source = try container.decodeWalletLossyString(forKey: .source)
}
}
///
struct WithdrawInfoResponse: Decodable, Equatable {
let amountWithdrawable: String
let minWithdrawAmount: String
let maxSingleWithdrawAmount: String
let maxDailyWithdrawAmount: String
let userPhone: String
let bankCard: WithdrawBankCardInfo
let settlement: String
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 settlement
case withdrawInfo = "withdraw_info"
}
///
init(
amountWithdrawable: String = "",
minWithdrawAmount: String = "",
maxSingleWithdrawAmount: String = "",
maxDailyWithdrawAmount: String = "",
userPhone: String = "",
bankCard: WithdrawBankCardInfo = WithdrawBankCardInfo(),
settlement: String = "",
withdrawInfo: [String] = []
) {
self.amountWithdrawable = amountWithdrawable
self.minWithdrawAmount = minWithdrawAmount
self.maxSingleWithdrawAmount = maxSingleWithdrawAmount
self.maxDailyWithdrawAmount = maxDailyWithdrawAmount
self.userPhone = userPhone
self.bankCard = bankCard
self.settlement = settlement
self.withdrawInfo = withdrawInfo
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
amountWithdrawable = try container.decodeWalletLossyString(forKey: .amountWithdrawable)
minWithdrawAmount = try container.decodeWalletLossyString(forKey: .minWithdrawAmount)
maxSingleWithdrawAmount = try container.decodeWalletLossyString(forKey: .maxSingleWithdrawAmount)
maxDailyWithdrawAmount = try container.decodeWalletLossyString(forKey: .maxDailyWithdrawAmount)
userPhone = try container.decodeWalletLossyString(forKey: .userPhone)
bankCard = try container.decodeIfPresent(WithdrawBankCardInfo.self, forKey: .bankCard) ?? WithdrawBankCardInfo()
settlement = try container.decodeWalletLossyString(forKey: .settlement)
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.decodeWalletLossyString(forKey: .realName)
bankName = try container.decodeWalletLossyString(forKey: .bankName)
cardNumber = try container.decodeWalletLossyString(forKey: .cardNumber)
}
}
///
struct WithdrawApplyRequest: Encodable, Equatable {
let amount: String
let smsCode: String
enum CodingKeys: String, CodingKey {
case amount
case smsCode = "sms_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.decodeWalletLossyInt(forKey: .totalPoints) ?? 0
availablePoints = try container.decodeWalletLossyInt(forKey: .availablePoints) ?? 0
withdrawnPoints = try container.decodeWalletLossyInt(forKey: .withdrawnPoints) ?? 0
pendingPoints = try container.decodeWalletLossyInt(forKey: .pendingPoints) ?? 0
time = try container.decodeWalletLossyString(forKey: .time)
}
}
///
struct PointWithdrawItem: Decodable, Hashable {
let id: Int
let applyNo: String
let merchantType: Int
let merchantId: Int
let merchantName: String
let points: Int
let amount: Double?
let status: Int
let rejectReason: String
let paymentVoucher: String
let paymentTime: String
let estimatedReceiptTime: String
let reviewedAt: String
let reviewedBy: String
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id
case applyNo = "apply_no"
case merchantType = "merchant_type"
case merchantId = "merchant_id"
case merchantName = "merchant_name"
case points
case amount
case status
case rejectReason = "reject_reason"
case paymentVoucher = "payment_voucher"
case paymentTime = "payment_time"
case estimatedReceiptTime = "estimated_receipt_time"
case reviewedAt = "reviewed_at"
case reviewedBy = "reviewed_by"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
///
init(
id: Int = 0,
applyNo: String = "",
merchantType: Int = 0,
merchantId: Int = 0,
merchantName: String = "",
points: Int = 0,
amount: Double? = nil,
status: Int = 0,
rejectReason: String = "",
paymentVoucher: String = "",
paymentTime: String = "",
estimatedReceiptTime: String = "",
reviewedAt: String = "",
reviewedBy: String = "",
createdAt: String = "",
updatedAt: String = ""
) {
self.id = id
self.applyNo = applyNo
self.merchantType = merchantType
self.merchantId = merchantId
self.merchantName = merchantName
self.points = points
self.amount = amount
self.status = status
self.rejectReason = rejectReason
self.paymentVoucher = paymentVoucher
self.paymentTime = paymentTime
self.estimatedReceiptTime = estimatedReceiptTime
self.reviewedAt = reviewedAt
self.reviewedBy = reviewedBy
self.createdAt = createdAt
self.updatedAt = updatedAt
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeWalletLossyInt(forKey: .id) ?? 0
applyNo = try container.decodeWalletLossyString(forKey: .applyNo)
merchantType = try container.decodeWalletLossyInt(forKey: .merchantType) ?? 0
merchantId = try container.decodeWalletLossyInt(forKey: .merchantId) ?? 0
merchantName = try container.decodeWalletLossyString(forKey: .merchantName)
points = try container.decodeWalletLossyInt(forKey: .points) ?? 0
amount = try container.decodeWalletLossyDouble(forKey: .amount)
status = try container.decodeWalletLossyInt(forKey: .status) ?? 0
rejectReason = try container.decodeWalletLossyString(forKey: .rejectReason)
paymentVoucher = try container.decodeWalletLossyString(forKey: .paymentVoucher)
paymentTime = try container.decodeWalletLossyString(forKey: .paymentTime)
estimatedReceiptTime = try container.decodeWalletLossyString(forKey: .estimatedReceiptTime)
reviewedAt = try container.decodeWalletLossyString(forKey: .reviewedAt)
reviewedBy = try container.decodeWalletLossyString(forKey: .reviewedBy)
createdAt = try container.decodeWalletLossyString(forKey: .createdAt)
updatedAt = try container.decodeWalletLossyString(forKey: .updatedAt)
}
}
///
struct PointWithdrawListResponse: Decodable, Equatable {
let total: Int
let list: [PointWithdrawItem]
///
init(total: Int = 0, list: [PointWithdrawItem] = []) {
self.total = total
self.list = list
}
}
///
struct PointWithdrawApplyRequest: Encodable, Equatable {
let points: Int
let remark: String
}
///
struct PointWithdrawListRequest: Encodable, Equatable {
let status: Int?
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case status
case page
case pageSize = "page_size"
}
}
private extension KeyedDecodingContainer {
func decodeWalletLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
@ -124,4 +594,18 @@ private extension KeyedDecodingContainer {
}
return nil
}
func decodeWalletLossyDouble(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) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Double(text)
}
return nil
}
}

View File

@ -0,0 +1,598 @@
//
// WalletViewModels.swift
// suixinkan
//
import Foundation
@MainActor
///
protocol WalletProfileServing {
///
func realNameInfo() async throws -> RealNameInfoResponse
///
func bankCardInfo() async throws -> BankCardInfoResponse
}
extension ProfileAPI: WalletProfileServing {}
/// Tab
enum WalletTab: Int, CaseIterable {
case withdraw
case transaction
///
var title: String {
switch self {
case .withdraw: "提现记录"
case .transaction: "收益明细"
}
}
}
///
enum WalletFilter: Int, CaseIterable {
case last7Days
case last30Days
case last180Days
///
var title: String {
switch self {
case .last7Days: "最近7天"
case .last30Days: "最近30天"
case .last180Days: "最近180天"
}
}
///
var dayOffset: Int {
switch self {
case .last7Days: 7
case .last30Days: 30
case .last180Days: 180
}
}
}
///
enum WalletWithdrawDestination: Equatable {
case realNameAuth
case realNameAudit(RealNameInfo)
case realNamePending
case withdrawalSettings
case withdrawalAudit(BankCardInfo)
case bankCardPending
case withdraw
}
/// ViewModel
final class WalletViewModel {
private(set) var summary: WalletSummaryResponse?
private(set) var selectedTab: WalletTab = .withdraw
private(set) var filter: WalletFilter = .last7Days
private(set) var withdrawRecords: [WalletWithdrawItem] = []
private(set) var transactionGroups: [EarningDetailGroup] = []
private(set) var totalIncomeLabel = "¥ 0.00"
private(set) var totalPointsLabel = 0
private(set) var availablePoints = 0
private(set) var summaryLoading = false
private(set) var withdrawLoading = false
private(set) var withdrawRefreshing = false
private(set) var transactionLoading = false
private(set) var transactionRefreshing = false
private(set) var pointsLoading = false
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
private let staffIdProvider: () -> Int?
private let calendar: Calendar
private var withdrawPage = 1
private var withdrawCanLoadMore = true
private var transactionPage = 1
private var transactionCanLoadMore = true
private let pageSize = 10
/// ViewModel
init(
staffIdProvider: @escaping () -> Int? = {
Int(AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines))
},
calendar: Calendar = Calendar(identifier: .gregorian)
) {
self.staffIdProvider = staffIdProvider
self.calendar = calendar
}
///
func loadInitial(api: any WalletPageServing) async {
await refreshSummary(api: api)
await refreshWithdraw(api: api)
await refreshTransaction(api: api)
await refreshPoints(api: api)
}
///
func refreshSummary(api: any WalletPageServing) async {
summaryLoading = true
errorMessage = nil
notifyStateChange()
do {
summary = try await api.walletSummary(type: 0)
} catch {
errorMessage = error.localizedDescription
}
summaryLoading = false
notifyStateChange()
}
///
func refreshPoints(api: any WalletPageServing) async {
guard let staffId = staffIdProvider(), staffId > 0 else { return }
pointsLoading = true
notifyStateChange()
do {
let overview = try await api.pointOverview(staffId: staffId)
availablePoints = overview.availablePoints
} catch {
errorMessage = error.localizedDescription
}
pointsLoading = false
notifyStateChange()
}
/// Tab
func selectTab(_ tab: WalletTab, api: any WalletPageServing) async {
guard tab != selectedTab else { return }
selectedTab = tab
notifyStateChange()
switch tab {
case .withdraw:
if withdrawRecords.isEmpty { await refreshWithdraw(api: api) }
case .transaction:
if transactionGroups.isEmpty { await refreshTransaction(api: api) }
}
}
///
func selectFilter(_ nextFilter: WalletFilter, api: any WalletPageServing) async {
guard nextFilter != filter else { return }
filter = nextFilter
notifyStateChange()
await refreshTransaction(api: api)
}
///
func refreshWithdraw(api: any WalletPageServing) async {
await loadWithdraw(api: api, refresh: true)
}
///
func loadMoreWithdrawIfNeeded(currentIndex: Int, api: any WalletPageServing) async {
guard currentIndex >= withdrawRecords.count - 2, withdrawCanLoadMore, !withdrawLoading else { return }
await loadWithdraw(api: api, refresh: false)
}
///
func refreshTransaction(api: any WalletPageServing) async {
await loadTransaction(api: api, refresh: true)
}
///
func loadMoreTransactionIfNeeded(currentIndex: Int, api: any WalletPageServing) async {
let count = transactionEntries.count
guard currentIndex >= count - 2, transactionCanLoadMore, !transactionLoading else { return }
await loadTransaction(api: api, refresh: false)
}
///
var transactionEntries: [WalletTransactionEntry] {
transactionGroups.flatMap { group -> [WalletTransactionEntry] in
[.header(group)] + group.items.map(WalletTransactionEntry.item)
}
}
///
func resolveWithdrawDestination(profileAPI: any WalletProfileServing) async throws -> WalletWithdrawDestination {
let realName = try await profileAPI.realNameInfo().realNameInfo
guard let realName else { return .realNameAuth }
switch realName.auditStatus {
case 2:
break
case 3:
return .realNameAudit(realName)
default:
return .realNamePending
}
let bankCard = try await profileAPI.bankCardInfo().bankCard
guard let bankCard else { return .withdrawalSettings }
switch bankCard.auditStatus {
case 2:
return .withdraw
case 3:
return .withdrawalAudit(bankCard)
default:
return .bankCardPending
}
}
private func loadWithdraw(api: any WalletPageServing, refresh: Bool) async {
if withdrawLoading && !refresh { return }
let targetPage = refresh ? 1 : withdrawPage
withdrawLoading = true
withdrawRefreshing = refresh
if refresh {
withdrawCanLoadMore = true
withdrawPage = 1
}
notifyStateChange()
do {
let response = try await api.walletWithdrawList(page: targetPage, pageSize: pageSize)
withdrawRecords = refresh ? response.item : withdrawRecords + response.item
withdrawCanLoadMore = withdrawRecords.count < response.total
withdrawPage = response.item.isEmpty ? targetPage : targetPage + 1
} catch {
if refresh {
withdrawRecords = []
withdrawCanLoadMore = false
}
errorMessage = error.localizedDescription
}
withdrawLoading = false
withdrawRefreshing = false
notifyStateChange()
}
private func loadTransaction(api: any WalletPageServing, refresh: Bool) async {
if transactionLoading && !refresh { return }
let targetPage = refresh ? 1 : transactionPage
let range = dateRange(for: filter)
transactionLoading = true
transactionRefreshing = refresh
if refresh {
transactionCanLoadMore = true
transactionPage = 1
}
notifyStateChange()
do {
let response = try await api.earningDetail(
startDate: range.start,
endDate: range.end,
page: targetPage,
pageSize: pageSize
)
transactionGroups = refresh ? response.list : transactionGroups + response.list
totalIncomeLabel = Self.formatAmount(response.totalAmount)
totalPointsLabel = response.totalPoints
let itemCount = transactionGroups.reduce(0) { $0 + $1.items.count }
transactionCanLoadMore = itemCount < response.total
transactionPage = response.list.isEmpty ? targetPage : targetPage + 1
} catch {
if refresh {
transactionGroups = []
transactionCanLoadMore = false
}
errorMessage = error.localizedDescription
}
transactionLoading = false
transactionRefreshing = false
notifyStateChange()
}
private func dateRange(for filter: WalletFilter, now: Date = Date()) -> (start: String, end: String) {
let end = calendar.startOfDay(for: now)
let start = calendar.date(byAdding: .day, value: -filter.dayOffset, to: end) ?? end
return (Self.dayFormatter.string(from: start), Self.dayFormatter.string(from: end))
}
private func notifyStateChange() {
onStateChange?()
}
///
static func formatAmount(_ value: String?) -> String {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return "¥ 0.00"
}
guard let decimal = Decimal(string: value) else { return "¥ \(value)" }
let number = NSDecimalNumber(decimal: decimal)
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.roundingMode = .halfUp
return "¥ \(formatter.string(from: number) ?? value)"
}
private static let dayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
}
///
enum WalletTransactionEntry: Hashable {
case header(EarningDetailGroup)
case item(EarningDetailItem)
}
/// ViewModel
final class WithdrawViewModel {
private(set) var withdrawInfo: WithdrawInfoResponse?
private(set) var amount = ""
private(set) var verificationCode = ""
private(set) var countdown = 0
private(set) var loading = false
private(set) var submitting = false
private(set) var statusMessage: String?
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
///
func load(api: any WalletPageServing) async {
loading = true
errorMessage = nil
notifyStateChange()
do {
withdrawInfo = try await api.withdrawInfo()
} catch {
errorMessage = "获取提现信息失败:\(error.localizedDescription)"
}
loading = false
notifyStateChange()
}
///
func updateAmount(_ value: String) {
amount = Self.normalizedMoneyInput(value, previous: amount)
notifyStateChange()
}
///
func withdrawAll() {
amount = withdrawInfo?.amountWithdrawable ?? ""
notifyStateChange()
}
///
func updateVerificationCode(_ value: String) {
verificationCode = value.filter(\.isNumber)
notifyStateChange()
}
///
func requestVerificationCode(api: any WalletPageServing) async {
guard countdown == 0 else { return }
do {
try await api.withdrawSendSMS()
statusMessage = "验证码发送成功"
countdown = 60
} catch {
errorMessage = "验证码发送失败:\(error.localizedDescription)"
}
notifyStateChange()
}
///
func decrementCountdown() {
guard countdown > 0 else { return }
countdown -= 1
notifyStateChange()
}
///
func submit(api: any WalletPageServing) async -> Bool {
if let validationMessage {
errorMessage = validationMessage
notifyStateChange()
return false
}
submitting = true
notifyStateChange()
do {
try await api.withdrawApply(amount: amount, smsCode: verificationCode)
statusMessage = "已提交申请"
submitting = false
notifyStateChange()
return true
} catch {
errorMessage = error.localizedDescription
submitting = false
notifyStateChange()
return false
}
}
///
var canSubmit: Bool {
validationMessage == nil && !submitting
}
///
var validationMessage: String? {
guard let withdrawInfo else { return "提现信息加载中" }
guard !amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入提现金额" }
guard let amountDecimal = Decimal(string: amount), amountDecimal > 0 else { return "请输入有效的金额" }
let withdrawable = Decimal(string: withdrawInfo.amountWithdrawable) ?? 0
if amountDecimal > withdrawable { return "金额不能大于可提现金额" }
guard !verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入验证码" }
return nil
}
///
static func normalizedMoneyInput(_ value: String, previous: String) -> String {
var result = ""
var hasDot = false
var decimalCount = 0
for character in value {
if character.isNumber {
if hasDot {
guard decimalCount < 2 else { continue }
decimalCount += 1
}
result.append(character)
} else if character == ".", !hasDot {
hasDot = true
if result.isEmpty { result = "0" }
result.append(character)
}
}
while result.count > 1, result.first == "0", result.dropFirst().first?.isNumber == true {
result.removeFirst()
}
return result
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class PointsRedemptionViewModel {
private(set) var overview: PointOverviewResponse?
private(set) var withdrawnPoints = 0
private(set) var pointsInput = ""
private(set) var withdrawRecords: [PointWithdrawItem] = []
private(set) var overviewLoading = false
private(set) var withdrawLoading = false
private(set) var withdrawRefreshing = false
private(set) var statusMessage: String?
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
private let staffIdProvider: () -> Int?
private var withdrawPage = 1
private var withdrawCanLoadMore = true
private let pageSize = 10
/// ViewModel
init(staffIdProvider: @escaping () -> Int? = {
Int(AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines))
}) {
self.staffIdProvider = staffIdProvider
}
///
func loadInitial(api: any WalletPageServing) async {
await refreshOverview(api: api)
await refreshWithdrawList(api: api)
}
///
func refreshOverview(api: any WalletPageServing) async {
guard let staffId = staffIdProvider(), staffId > 0 else { return }
overviewLoading = true
notifyStateChange()
do {
let response = try await api.pointOverview(staffId: staffId)
overview = response
withdrawnPoints = response.withdrawnPoints
} catch {
errorMessage = error.localizedDescription
}
overviewLoading = false
notifyStateChange()
}
///
func refreshWithdrawList(api: any WalletPageServing) async {
await loadWithdrawList(api: api, refresh: true)
}
///
func loadMoreWithdrawListIfNeeded(currentIndex: Int, api: any WalletPageServing) async {
guard currentIndex >= withdrawRecords.count - 2, withdrawCanLoadMore, !withdrawLoading else { return }
await loadWithdrawList(api: api, refresh: false)
}
///
func updatePointsInput(_ value: String) {
let digits = value.filter(\.isNumber)
guard !digits.isEmpty else {
pointsInput = ""
notifyStateChange()
return
}
let points = min(Int(digits) ?? 0, withdrawnPoints)
pointsInput = points > 0 ? String(points) : ""
notifyStateChange()
}
///
func withdrawAll() {
pointsInput = String(withdrawnPoints)
notifyStateChange()
}
///
func applyWithdraw(api: any WalletPageServing) async {
let points = Int(pointsInput) ?? 0
guard points > 0 else {
errorMessage = "请输入提现积分"
notifyStateChange()
return
}
guard points <= withdrawnPoints else {
errorMessage = "提现积分不能大于可提现积分"
notifyStateChange()
return
}
do {
try await api.pointWithdrawApply(points: points, remark: "积分提现申请")
statusMessage = "提现申请成功"
pointsInput = ""
notifyStateChange()
await refreshOverview(api: api)
await refreshWithdrawList(api: api)
} catch {
errorMessage = error.localizedDescription.isEmpty ? "提现申请失败" : error.localizedDescription
notifyStateChange()
}
}
private func loadWithdrawList(api: any WalletPageServing, refresh: Bool) async {
if withdrawLoading && !refresh { return }
let targetPage = refresh ? 1 : withdrawPage
withdrawLoading = true
withdrawRefreshing = refresh
if refresh {
withdrawCanLoadMore = true
withdrawPage = 1
}
notifyStateChange()
do {
let response = try await api.pointWithdrawList(status: nil, page: targetPage, pageSize: pageSize)
withdrawRecords = refresh ? response.list : withdrawRecords + response.list
withdrawCanLoadMore = withdrawRecords.count < response.total
withdrawPage = response.list.isEmpty ? targetPage : targetPage + 1
} catch {
if refresh {
withdrawRecords = []
withdrawCanLoadMore = false
}
errorMessage = error.localizedDescription
}
withdrawLoading = false
withdrawRefreshing = false
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -13,7 +13,7 @@ protocol WildPhotographerReportServing {
///
func reportCopy() async throws -> WildReportCopyResponse
///
func submitReport(_ request: WildReportSubmitRequest) async throws
func submitReport(_ request: WildReportSubmitRequest) async throws -> WildReportSubmitResponse
///
func supplementReport(_ request: WildReportSupplementRequest) async throws
///
@ -51,8 +51,8 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
}
///
func submitReport(_ request: WildReportSubmitRequest) async throws {
let _: EmptyPayload = try await client.send(
func submitReport(_ request: WildReportSubmitRequest) async throws -> WildReportSubmitResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/report/submit",
@ -209,6 +209,28 @@ struct WildReportSubmitRequest: Encodable, Equatable {
}
}
/// ID
struct WildReportSubmitResponse: Decodable, Equatable {
let id: Int
let complaintNo: String
enum CodingKeys: String, CodingKey {
case id
case complaintNo = "complaint_no"
}
init(id: Int = 0, complaintNo: String = "") {
self.id = id
self.complaintNo = complaintNo
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
complaintNo = try container.decodeIfPresent(String.self, forKey: .complaintNo) ?? ""
}
}
///
struct WildReportEvidenceRequest: Encodable, Equatable {
let fileURL: String

View File

@ -317,14 +317,14 @@ final class WildPhotographerReportSubmitViewModel {
submitProgressText = "正在提交举报"
notifyStateChange()
try await api.submitReport(buildSubmitRequest(
let submitResponse = try await api.submitReport(buildSubmitRequest(
scenicId: scenicId,
scenicName: scenicName,
evidences: evidenceRequests,
paymentScreenshots: paymentRequests
))
let record = buildSubmittedRecord()
let record = buildSubmittedRecord(response: submitResponse)
let result = WildReportSubmitResult(
record: record,
imageCount: images.count,
@ -503,11 +503,11 @@ final class WildPhotographerReportSubmitViewModel {
)
}
private func buildSubmittedRecord() -> WildReportRecord {
private func buildSubmittedRecord(response: WildReportSubmitResponse) -> WildReportRecord {
let parsedContact = WildReportContactParser.parse(contact)
return WildReportRecord(
serverID: nil,
id: "",
serverID: response.id > 0 ? response.id : nil,
id: response.complaintNo,
title: "摄影师举报",
reportType: selectedReportType ?? WildReportType(value: 0, label: ""),
status: .pending,