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

@ -25,6 +25,7 @@ final class NetworkServices {
let settingAPI: SettingAPI
let sampleManagementAPI: SampleManagementAPI
let materialManagementAPI: MaterialManagementAPI
let punchPointAPI: PunchPointAPI
let liveAPI: LiveAPI
let wildPhotographerReportAPI: WildPhotographerReportAPI
let uploadAPI: UploadAPI
@ -47,6 +48,7 @@ final class NetworkServices {
settingAPI = SettingAPI(client: client)
sampleManagementAPI = SampleManagementAPI(client: client)
materialManagementAPI = MaterialManagementAPI(client: client)
punchPointAPI = PunchPointAPI(client: client)
liveAPI = LiveAPI(client: client)
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
uploadAPI = UploadAPI(client: client)
@ -74,6 +76,7 @@ final class NetworkServices {
settingAPI = SettingAPI(client: apiClient)
sampleManagementAPI = SampleManagementAPI(client: apiClient)
materialManagementAPI = MaterialManagementAPI(client: apiClient)
punchPointAPI = PunchPointAPI(client: apiClient)
liveAPI = LiveAPI(client: apiClient)
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient)

View File

@ -127,6 +127,23 @@ final class OSSUploadService {
)
}
/// Android `moduleType = punch_point`
func uploadPunchPointImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
try await uploadFile(
data: data,
fileName: fileName,
fileType: 2,
scenicId: scenicId,
moduleType: "punch_point",
onProgress: onProgress
)
}
/// Android `moduleType = live_covers`
func uploadLiveCover(
data: Data,
@ -237,6 +254,8 @@ extension OSSUploadService: SampleOSSUploading {}
extension OSSUploadService: MaterialOSSUploading {}
extension OSSUploadService: PunchPointImageUploading {}
enum OSSUploadPolicy {
static let maxFileSize = 2_048 * 1_024 * 1_024
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
@ -278,6 +297,10 @@ enum OSSUploadPolicy {
return "sample/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "material":
return "material/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "punch_point":
let userId = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let ownerId = userId.isEmpty ? String(scenicId) : userId
return "punch_point/\(currentDate)/\(ownerId)/\(uploadId)_\(safeName)"
case "scenic_apply":
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "live_covers":

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,

View File

@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>随心瞰商家版</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -23,14 +21,6 @@
<string>weixinULAPI</string>
<string>weixinURLParamsAPI</string>
</array>
<key>NSCameraUsageDescription</key>
<string>需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以选择头像、身份证和银行卡照片</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要保存收款二维码和排队打卡点小程序码到相册</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@ -38,9 +38,9 @@ enum HomeCollectionLayoutBuilder {
let section = sectionIndex < sections.count ? sections[sectionIndex] : .commonApps
switch section {
case .workStatus:
return fullWidthSection(height: AppSpacing.formRowHeight)
return fullWidthSection(height: 84)
case .locationReport:
return fullWidthSection(height: 100)
return fullWidthSection(height: 132)
case .quickActions:
return fullWidthSection(height: 72)
case .store:

View File

@ -186,7 +186,7 @@ final class HomeViewController: BaseViewController {
Task { await self.viewModel.manualReportLocation(api: self.homeAPI) }
}
cell.apply(
address: self.viewModel.locationInfoText,
address: HomeViewModel.locationReportPromptText,
isReporting: self.viewModel.isLocationReporting
)
return cell
@ -266,8 +266,6 @@ final class HomeViewController: BaseViewController {
applyViewModel()
await evaluateDialogsWithDelay()
await viewModel.refreshLocationInfo()
applyViewModel()
}
private func evaluateDialogsWithDelay() async {

View File

@ -11,47 +11,80 @@ final class HomeLocationReportCardView: UIView {
var onReportTap: (() -> Void)?
private let titleStackView = UIStackView()
private let titleIconView = UIImageView(image: UIImage(systemName: "arrow.up.square.fill"))
private let titleLabel = UILabel()
private let addressLabel = UILabel()
private let reportButtonContainer = UIView()
private let reportButton = UIButton(type: .system)
private let reportGradientLayer = CAGradientLayer()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.cardBackground
layer.cornerRadius = AppRadius.md
clipsToBounds = true
titleLabel.text = "位置上报"
titleLabel.font = .app(.title)
titleStackView.axis = .horizontal
titleStackView.alignment = .center
titleStackView.spacing = AppSpacing.xs
titleIconView.tintColor = AppColor.primary
titleIconView.contentMode = .scaleAspectFit
titleLabel.text = "立即上报"
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
addressLabel.font = .app(.body)
addressLabel.textColor = AppColor.textSecondary
addressLabel.font = .systemFont(ofSize: 15, weight: .regular)
addressLabel.textColor = AppColor.textTertiary
addressLabel.numberOfLines = 2
reportButton.setTitle("立即上报", for: .normal)
reportButton.setTitleColor(.white, for: .normal)
reportButton.backgroundColor = AppColor.primary
reportButton.layer.cornerRadius = AppRadius.sm
reportButton.titleLabel?.font = .app(.bodyMedium)
reportButton.contentEdgeInsets = UIEdgeInsets(top: AppSpacing.xs, left: 14, bottom: AppSpacing.xs, right: 14)
reportGradientLayer.colors = [AppColor.primary.cgColor, UIColor(hex: 0x5CA8FF).cgColor]
reportGradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
reportGradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
reportButtonContainer.layer.insertSublayer(reportGradientLayer, at: 0)
reportButtonContainer.layer.cornerRadius = 46
reportButtonContainer.clipsToBounds = true
reportButton.backgroundColor = .clear
reportButton.tintColor = .white
reportButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal)
reportButton.setPreferredSymbolConfiguration(
UIImage.SymbolConfiguration(pointSize: 38, weight: .semibold),
forImageIn: .normal
)
reportButton.imageView?.contentMode = .scaleAspectFit
reportButton.addTarget(self, action: #selector(reportTapped), for: .touchUpInside)
reportButton.accessibilityLabel = "上报位置"
addSubview(titleLabel)
titleStackView.addArrangedSubview(titleIconView)
titleStackView.addArrangedSubview(titleLabel)
addSubview(titleStackView)
addSubview(addressLabel)
addSubview(reportButton)
addSubview(reportButtonContainer)
reportButtonContainer.addSubview(reportButton)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(AppSpacing.screenHorizontalInset)
titleIconView.snp.makeConstraints { make in
make.width.height.equalTo(25)
}
titleStackView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(15)
make.top.equalToSuperview().offset(15)
make.trailing.lessThanOrEqualTo(reportButtonContainer.snp.leading).offset(-AppSpacing.sm)
}
addressLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.equalToSuperview().offset(AppSpacing.screenHorizontalInset)
make.bottom.equalToSuperview().offset(-AppSpacing.screenHorizontalInset)
make.trailing.lessThanOrEqualTo(reportButton.snp.leading).offset(-AppSpacing.sm)
make.top.equalTo(titleStackView.snp.bottom).offset(AppSpacing.xxs)
make.leading.equalTo(titleStackView)
make.trailing.lessThanOrEqualTo(reportButtonContainer.snp.leading).offset(-AppSpacing.sm)
}
reportButtonContainer.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-15)
make.centerY.equalToSuperview()
make.width.height.equalTo(92)
}
reportButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-AppSpacing.screenHorizontalInset)
make.centerY.equalToSuperview()
make.edges.equalToSuperview()
}
}
@ -60,10 +93,19 @@ final class HomeLocationReportCardView: UIView {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
reportGradientLayer.frame = reportButtonContainer.bounds
reportGradientLayer.cornerRadius = reportButtonContainer.bounds.width / 2
}
func apply(address: String, isReporting: Bool) {
addressLabel.text = address
reportButton.isEnabled = !isReporting
reportButton.alpha = isReporting ? 0.6 : 1
reportButton.alpha = 1
reportGradientLayer.colors = isReporting
? [UIColor(hex: 0xC9CED6).cgColor, UIColor(hex: 0xD9DDE4).cgColor]
: [AppColor.primary.cgColor, UIColor(hex: 0x5CA8FF).cgColor]
}
@objc private func reportTapped() { onReportTap?() }

View File

@ -12,7 +12,10 @@ final class HomeWorkStatusCardView: UIView {
var onOnlineTap: (() -> Void)?
var onReminderTap: (() -> Void)?
private let contentStackView = UIStackView()
private let onlineButton = UIButton(type: .system)
private let countdownStackView = UIStackView()
private let countdownIconView = UIImageView(image: UIImage(systemName: "clock"))
private let countdownLabel = UILabel()
private let reminderButton = UIButton(type: .system)
@ -20,36 +23,57 @@ final class HomeWorkStatusCardView: UIView {
super.init(frame: frame)
backgroundColor = AppColor.cardBackground
layer.cornerRadius = AppRadius.md
clipsToBounds = true
contentStackView.axis = .horizontal
contentStackView.alignment = .center
contentStackView.distribution = .equalSpacing
contentStackView.spacing = AppSpacing.sm
onlineButton.titleLabel?.font = .app(.captionMedium)
onlineButton.layer.cornerRadius = AppRadius.xs
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
countdownStackView.axis = .horizontal
countdownStackView.alignment = .center
countdownStackView.spacing = 6
countdownIconView.tintColor = AppColor.primary
countdownIconView.contentMode = .scaleAspectFit
countdownIconView.setContentCompressionResistancePriority(.required, for: .horizontal)
countdownLabel.font = .app(.subtitle)
countdownLabel.textColor = AppColor.primary
countdownLabel.adjustsFontSizeToFitWidth = true
countdownLabel.minimumScaleFactor = 0.85
countdownLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
reminderButton.titleLabel?.font = .app(.subtitle)
reminderButton.setTitleColor(AppColor.primary, for: .normal)
reminderButton.tintColor = AppColor.primary
reminderButton.setImage(UIImage(systemName: "bell.fill"), for: .normal)
reminderButton.semanticContentAttribute = .forceLeftToRight
reminderButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 6)
reminderButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: -6)
reminderButton.titleLabel?.adjustsFontSizeToFitWidth = true
reminderButton.titleLabel?.minimumScaleFactor = 0.8
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
addSubview(onlineButton)
addSubview(countdownLabel)
addSubview(reminderButton)
countdownStackView.addArrangedSubview(countdownIconView)
countdownStackView.addArrangedSubview(countdownLabel)
contentStackView.addArrangedSubview(onlineButton)
contentStackView.addArrangedSubview(countdownStackView)
contentStackView.addArrangedSubview(reminderButton)
addSubview(contentStackView)
onlineButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.screenHorizontalInset)
make.centerY.equalToSuperview()
contentStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15)
}
countdownLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
reminderButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-AppSpacing.screenHorizontalInset)
make.centerY.equalToSuperview()
countdownIconView.snp.makeConstraints { make in
make.width.height.equalTo(15)
}
snp.makeConstraints { make in
make.height.equalTo(AppSpacing.formRowHeight)
make.height.equalTo(84)
}
}

View File

@ -0,0 +1,573 @@
//
// PunchPointDetailViewController.swift
// suixinkan
//
import Kingfisher
import Photos
import SnapKit
import UIKit
/// Android `PunchPointDetailScreen`
final class PunchPointDetailViewController: BaseViewController {
private let viewModel: PunchPointDetailViewModel
private let api: any PunchPointAPIProtocol
private let mapView = PunchPointMapView()
private let zoomStack = UIStackView()
private let zoomInButton = PunchPointMapControlButton(symbol: "plus")
private let zoomOutButton = PunchPointMapControlButton(symbol: "minus")
private let locateButton = PunchPointMapControlButton(symbol: "location.fill")
private let cardView = UIView()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let bottomBar = UIView()
private let editButton = UIButton(type: .system)
private let emptyContainer = UIStackView()
private let emptyLabel = UILabel()
private let retryButton = UIButton(type: .system)
var onChanged: (() -> Void)?
///
init(
punchPointId: Int64,
viewModel: PunchPointDetailViewModel? = nil,
api: (any PunchPointAPIProtocol)? = nil
) {
self.viewModel = viewModel ?? PunchPointDetailViewModel(punchPointId: punchPointId)
self.api = api ?? NetworkServices.shared.punchPointAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "打卡点详情"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
zoomStack.axis = .vertical
zoomStack.spacing = 0
zoomStack.addArrangedSubview(zoomInButton)
zoomStack.addArrangedSubview(zoomOutButton)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
scrollView.showsVerticalScrollIndicator = false
contentStack.axis = .vertical
contentStack.spacing = 12
bottomBar.backgroundColor = .white
editButton.setTitle("去编辑", for: .normal)
editButton.setTitleColor(.white, for: .normal)
editButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
editButton.backgroundColor = AppColor.primary
editButton.layer.cornerRadius = 12
emptyContainer.axis = .vertical
emptyContainer.alignment = .center
emptyContainer.spacing = 12
emptyLabel.text = "暂未获取到打卡点信息"
emptyLabel.textColor = AppColor.textSecondary
emptyLabel.font = .systemFont(ofSize: 15)
retryButton.setTitle("重新加载", for: .normal)
retryButton.setTitleColor(.white, for: .normal)
retryButton.backgroundColor = AppColor.primary
retryButton.layer.cornerRadius = 8
emptyContainer.addArrangedSubview(emptyLabel)
emptyContainer.addArrangedSubview(retryButton)
emptyContainer.isHidden = true
view.addSubview(mapView)
view.addSubview(zoomStack)
view.addSubview(locateButton)
view.addSubview(cardView)
cardView.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(bottomBar)
bottomBar.addSubview(editButton)
view.addSubview(emptyContainer)
}
override func setupConstraints() {
mapView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalToSuperview().multipliedBy(0.5)
}
zoomStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(cardView.snp.top).offset(-16)
}
locateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(zoomStack.snp.top).offset(-12)
make.size.equalTo(40)
}
zoomInButton.snp.makeConstraints { make in
make.size.equalTo(40)
}
zoomOutButton.snp.makeConstraints { make in
make.size.equalTo(40)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
editButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20)
}
cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top)
make.height.lessThanOrEqualToSuperview().multipliedBy(0.45)
make.top.greaterThanOrEqualTo(mapView.snp.top).offset(60)
}
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 12, right: 12))
make.width.equalTo(scrollView.snp.width).offset(-24)
}
emptyContainer.snp.makeConstraints { make in
make.center.equalToSuperview()
}
retryButton.snp.makeConstraints { make in
make.width.equalTo(118)
make.height.equalTo(40)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
zoomInButton.addTarget(self, action: #selector(zoomInTapped), for: .touchUpInside)
zoomOutButton.addTarget(self, action: #selector(zoomOutTapped), for: .touchUpInside)
locateButton.addTarget(self, action: #selector(locateTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.load(api: api) }
}
@MainActor
private func applyViewModel() {
viewModel.isLoading && viewModel.detail == nil ? showLoading() : hideLoading()
emptyContainer.isHidden = viewModel.detail != nil || viewModel.isLoading
guard let detail = viewModel.detail else {
cardView.isHidden = true
bottomBar.isHidden = true
return
}
cardView.isHidden = false
bottomBar.isHidden = false
if let region = detail.region {
let coordinate = CLLocationCoordinate2D(latitude: region.lat, longitude: region.lot)
mapView.updateMarker(coordinate: coordinate)
mapView.setCenter(coordinate, zoomLevel: 16)
}
rebuildContent(detail)
}
private func rebuildContent(_ detail: PunchPointDetail) {
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点名称", value: detail.name))
let coordinateText = detail.region.map { "\($0.lat),\($0.lot)" } ?? "--"
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点坐标", value: coordinateText))
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点地址", value: detail.displayAddress.nonEmptyTrimmed ?? "--"))
if let description = detail.description?.nonEmptyTrimmed {
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "打卡点描述", value: description, multiline: true))
}
contentStack.addArrangedSubview(makeImageSection(detail.guideImages))
contentStack.addArrangedSubview(PunchPointRowField(title: "负责人", value: detail.creator?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "负责人手机号", value: PunchPointDisplayFormatter.maskedPhone(detail.creatorPhone)))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(makeStatusRow(detail))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "审核人", value: detail.auditor?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "审核时间", value: detail.auditTime?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "审核备注", value: detail.auditRemark?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(makeQRSection(detail.mpQrcode))
}
private func makeImageSection(_ urls: [String]) -> UIView {
let section = UIStackView()
section.axis = .vertical
section.spacing = 8
let title = UILabel()
title.attributedText = requiredTitle("*上传图片 (最多9张)")
section.addArrangedSubview(title)
guard !urls.isEmpty else {
let empty = UILabel()
empty.text = "暂无图片"
empty.font = .systemFont(ofSize: 12)
empty.textColor = AppColor.textSecondary
section.addArrangedSubview(empty)
return section
}
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
let row = UIStackView()
row.axis = .horizontal
row.spacing = 12
scroll.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(90)
}
urls.enumerated().forEach { index, urlText in
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 10
imageView.isUserInteractionEnabled = true
imageView.tag = index
if let url = URL(string: urlText) {
imageView.kf.setImage(with: url)
}
imageView.snp.makeConstraints { make in
make.width.equalTo(120)
}
imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:))))
row.addArrangedSubview(imageView)
}
scroll.snp.makeConstraints { make in
make.height.equalTo(90)
}
section.addArrangedSubview(scroll)
return section
}
private func makeStatusRow(_ detail: PunchPointDetail) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.distribution = .equalSpacing
let label = UILabel()
label.text = "审核状态"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x2E3746)
let chip = PunchPointStatusChip()
chip.apply(text: detail.statusLabel.nonEmptyTrimmed ?? "--", status: detail.status)
row.addArrangedSubview(label)
row.addArrangedSubview(chip)
row.isLayoutMarginsRelativeArrangement = true
row.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
return row
}
private func makeQRSection(_ qrCode: String?) -> UIView {
let section = UIStackView()
section.axis = .vertical
section.alignment = .center
section.spacing = 12
let title = UILabel()
title.text = "打卡点二维码"
title.font = .systemFont(ofSize: 14)
title.textColor = UIColor(hex: 0x2E3746)
title.textAlignment = .left
title.snp.makeConstraints { make in
make.width.equalTo(contentStack.snp.width)
}
section.addArrangedSubview(title)
guard let qrCode = qrCode?.nonEmptyTrimmed else {
let empty = UILabel()
empty.text = "暂无二维码"
empty.font = .systemFont(ofSize: 12)
empty.textColor = AppColor.textSecondary
section.addArrangedSubview(empty)
return section
}
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 16
if let url = URL(string: qrCode) {
imageView.kf.setImage(with: url)
}
imageView.snp.makeConstraints { make in
make.size.equalTo(180)
}
let download = UIButton(type: .system)
download.setTitle("下载二维码", for: .normal)
download.setTitleColor(AppColor.primary, for: .normal)
download.titleLabel?.font = .systemFont(ofSize: 14)
download.backgroundColor = AppColor.primaryLight
download.layer.cornerRadius = 8
download.snp.makeConstraints { make in
make.height.equalTo(38)
make.width.equalTo(128)
}
download.addAction(UIAction { [weak self] _ in
self?.downloadQRCode(qrCode)
}, for: .touchUpInside)
section.addArrangedSubview(imageView)
section.addArrangedSubview(download)
return section
}
private func downloadQRCode(_ urlText: String) {
guard let url = URL(string: urlText) else {
showToast("二维码地址无效")
return
}
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { throw APIError.decodeFailed("二维码图片无效") }
try await PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: image)
}
await MainActor.run { self.showToast("已保存到相册") }
} catch {
await MainActor.run { self.showToast(error.localizedDescription.isEmpty ? "保存失败" : error.localizedDescription) }
}
}
}
private func requiredTitle(_ text: String) -> NSAttributedString {
let mutable = NSMutableAttributedString(string: text, attributes: [
.font: UIFont.systemFont(ofSize: 14, weight: .medium),
.foregroundColor: UIColor(hex: 0x2E3746),
])
if text.hasPrefix("*") {
mutable.addAttribute(.foregroundColor, value: AppColor.danger, range: NSRange(location: 0, length: 1))
}
return mutable
}
@objc private func imageTapped(_ gesture: UITapGestureRecognizer) {
guard let imageView = gesture.view, let urls = viewModel.detail?.guideImages else { return }
presentImagePreview(imageURLs: urls, startIndex: imageView.tag)
}
@objc private func zoomInTapped() {
mapView.zoomIn()
}
@objc private func zoomOutTapped() {
mapView.zoomOut()
}
@objc private func locateTapped() {
mapView.centerOnUserLocation()
}
@objc private func retryTapped() {
Task { await viewModel.load(api: api) }
}
@objc private func editTapped() {
guard let detail = viewModel.detail else { return }
let controller = PunchPointFormViewController(mode: .edit(id: detail.id), initialDetail: detail)
controller.onSubmitSuccess = { [weak self] in
guard let self else { return }
self.onChanged?()
Task { await self.viewModel.load(api: self.api) }
}
navigationController?.pushViewController(controller, animated: true)
}
}
///
/// marker
final class PunchPointMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
private let mapView: MAMapView
private var markerAnnotation: MAPointAnnotation?
override init(frame: CGRect) {
_ = AMapBootstrap.configureIfNeeded()
mapView = MAMapView(frame: .zero)
super.init(frame: frame)
mapView.delegate = self
mapView.showsUserLocation = true
mapView.showsCompass = false
mapView.showsScale = false
mapView.zoomLevel = 15
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func updateMarker(coordinate: CLLocationCoordinate2D?) {
if let markerAnnotation {
mapView.removeAnnotation(markerAnnotation)
self.markerAnnotation = nil
}
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MAPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "打卡点位置"
markerAnnotation = annotation
mapView.addAnnotation(annotation)
}
///
func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double = 15, animated: Bool = true) {
mapView.setCenter(coordinate, animated: animated)
mapView.setZoomLevel(zoomLevel, animated: animated)
}
///
func zoomIn() {
mapView.setZoomLevel(mapView.zoomLevel + 1, animated: true)
}
///
func zoomOut() {
mapView.setZoomLevel(mapView.zoomLevel - 1, animated: true)
}
///
func centerOnUserLocation() {
guard let coordinate = mapView.userLocation.location?.coordinate,
CLLocationCoordinate2DIsValid(coordinate),
coordinate.latitude != 0 || coordinate.longitude != 0 else { return }
setCenter(coordinate)
}
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
onMapTap?(coordinate)
}
}
///
///
final class PunchPointMapControlButton: UIButton {
init(symbol: String) {
super.init(frame: .zero)
setImage(UIImage(systemName: symbol), for: .normal)
tintColor = AppColor.textPrimary
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
///
final class PunchPointReadOnlyField: UIView {
init(title: String, value: String, multiline: Bool = false) {
super.init(frame: .zero)
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
let titleLabel = UILabel()
titleLabel.attributedText = Self.requiredTitle(title)
let valueLabel = UILabel()
valueLabel.text = value.nonEmptyTrimmed ?? "--"
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.textColor = AppColor.textPrimary
valueLabel.numberOfLines = multiline ? 0 : 2
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(valueLabel)
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private static func requiredTitle(_ text: String) -> NSAttributedString {
let mutable = NSMutableAttributedString(string: text, attributes: [
.font: UIFont.systemFont(ofSize: 14, weight: .medium),
.foregroundColor: UIColor(hex: 0x2E3746),
])
if text.hasPrefix("*") {
mutable.addAttribute(.foregroundColor, value: AppColor.danger, range: NSRange(location: 0, length: 1))
}
return mutable
}
}
///
///
final class PunchPointRowField: UIView {
init(title: String, value: String) {
super.init(frame: .zero)
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x2E3746)
let valueLabel = UILabel()
valueLabel.text = value.nonEmptyTrimmed ?? "--"
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.textColor = .black
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 2
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0))
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 线
/// 线
final class PunchPointDivider: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hex: 0xF0F0F0)
snp.makeConstraints { make in
make.height.equalTo(1)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension String {
var nonEmptyTrimmed: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

View File

@ -0,0 +1,668 @@
//
// PunchPointFormViewController.swift
// suixinkan
//
import Kingfisher
import PhotosUI
import SnapKit
import UIKit
/// Android `AddPunchPointScreen` / `EditPunchPointScreen`
final class PunchPointFormViewController: BaseViewController {
private let viewModel: PunchPointFormViewModel
private let api: any PunchPointAPIProtocol
private let uploader: any PunchPointImageUploading
private let initialDetail: PunchPointDetail?
private let mapView = PunchPointMapView()
private let zoomStack = UIStackView()
private let zoomInButton = PunchPointMapControlButton(symbol: "plus")
private let zoomOutButton = PunchPointMapControlButton(symbol: "minus")
private let locateButton = PunchPointMapControlButton(symbol: "location.fill")
private let cardView = UIView()
private let scrollView = UIScrollView()
private let formStack = UIStackView()
private let nameField = PunchPointTextFieldView(title: "*打卡点名称", placeholder: "请输入打卡点名称")
private let coordinateField = PunchPointCoordinateFieldView(title: "*打卡点坐标", placeholder: "点击右侧按钮,定位打卡点坐标")
private let addressField = PunchPointTextFieldView(title: "*打卡点地址", placeholder: "请输入打卡点地址")
private let descriptionField = PunchPointTextViewFieldView(title: "打卡点描述", placeholder: "请输入内容 (50字以内)")
private let imageGrid = PunchPointImageGridView()
private let bottomBar = UIView()
private let submitButton = UIButton(type: .system)
private var progressAlert: UIAlertController?
private var progressView: UIProgressView?
private var progressLabel: UILabel?
var onSubmitSuccess: (() -> Void)?
///
init(
mode: PunchPointFormViewModel.Mode,
initialDetail: PunchPointDetail? = nil,
viewModel: PunchPointFormViewModel? = nil,
api: (any PunchPointAPIProtocol)? = nil,
uploader: (any PunchPointImageUploading)? = nil
) {
self.viewModel = viewModel ?? PunchPointFormViewModel(mode: mode)
self.api = api ?? NetworkServices.shared.punchPointAPI
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
self.initialDetail = initialDetail
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
switch viewModel.mode {
case .create:
title = "新建打卡点"
case .edit:
title = "编辑打卡点"
}
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
zoomStack.axis = .vertical
zoomStack.spacing = 0
zoomStack.addArrangedSubview(zoomInButton)
zoomStack.addArrangedSubview(zoomOutButton)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
scrollView.keyboardDismissMode = .interactive
scrollView.showsVerticalScrollIndicator = false
formStack.axis = .vertical
formStack.spacing = 16
imageGrid.title = "*上传图片 (最多9张)"
bottomBar.backgroundColor = .white
submitButton.setTitle("提交审核", for: .normal)
submitButton.setTitleColor(.white, for: .normal)
submitButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
submitButton.backgroundColor = AppColor.primary
submitButton.layer.cornerRadius = 8
view.addSubview(mapView)
view.addSubview(zoomStack)
view.addSubview(locateButton)
view.addSubview(cardView)
cardView.addSubview(scrollView)
scrollView.addSubview(formStack)
[nameField, coordinateField, addressField, descriptionField, imageGrid].forEach(formStack.addArrangedSubview)
view.addSubview(bottomBar)
bottomBar.addSubview(submitButton)
}
override func setupConstraints() {
mapView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalToSuperview().multipliedBy(0.4)
}
zoomStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(cardView.snp.top).offset(-16)
}
locateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(zoomStack.snp.top).offset(-12)
make.size.equalTo(40)
}
zoomInButton.snp.makeConstraints { make in
make.size.equalTo(40)
}
zoomOutButton.snp.makeConstraints { make in
make.size.equalTo(40)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.greaterThanOrEqualTo(84)
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(52)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-16)
make.height.lessThanOrEqualToSuperview().multipliedBy(0.5)
make.top.greaterThanOrEqualTo(mapView.snp.top).offset(40)
}
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
formStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 12, right: 12))
make.width.equalTo(scrollView.snp.width).offset(-24)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onCoordinateChange = { [weak self] coordinate in
Task { @MainActor in
self?.mapView.updateMarker(coordinate: coordinate)
self?.mapView.setCenter(coordinate, zoomLevel: 17)
}
}
viewModel.onSubmitSuccess = { [weak self] in
Task { @MainActor in
self?.onSubmitSuccess?()
self?.navigationController?.popViewController(animated: true)
}
}
nameField.onTextChange = { [weak self] text in self?.viewModel.updateName(text) }
addressField.onTextChange = { [weak self] text in self?.viewModel.updateAddress(text) }
descriptionField.onTextChange = { [weak self] text in self?.viewModel.updateDescription(text) }
coordinateField.onLocate = { [weak self] in
guard let self else { return }
Task { await self.viewModel.locateToCurrent() }
}
imageGrid.onAdd = { [weak self] in self?.presentImagePicker() }
imageGrid.onDelete = { [weak self] index in self?.viewModel.deleteImage(at: index) }
imageGrid.onRetry = { [weak self] index in
guard let self else { return }
Task { await self.viewModel.retryUpload(at: index, uploader: self.uploader) }
}
mapView.onMapTap = { [weak self] coordinate in
guard let self else { return }
Task { await self.viewModel.selectCoordinate(coordinate) }
}
zoomInButton.addTarget(self, action: #selector(zoomInTapped), for: .touchUpInside)
zoomOutButton.addTarget(self, action: #selector(zoomOutTapped), for: .touchUpInside)
locateButton.addTarget(self, action: #selector(locateTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
if let initialDetail {
viewModel.initialize(with: initialDetail)
} else {
Task { await viewModel.autoLocation() }
}
}
@MainActor
private func applyViewModel() {
if !nameField.isEditing {
nameField.text = viewModel.name
}
if !addressField.isEditing {
addressField.text = viewModel.address
}
if !descriptionField.isEditing {
descriptionField.text = viewModel.description
}
coordinateField.text = viewModel.coordinatesText
imageGrid.apply(images: viewModel.images)
submitButton.isEnabled = !viewModel.isSubmitting
submitButton.alpha = submitButton.isEnabled ? 1 : 0.65
submitButton.setTitle(viewModel.isSubmitting ? "提交中..." : "提交审核", for: .normal)
updateUploadDialog(viewModel.uploadDialogState)
}
private func presentImagePicker() {
let remaining = max(0, 9 - viewModel.images.count)
guard remaining > 0 else {
showToast("最多只能选择9张图片")
return
}
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = remaining
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
private func makeImageState(image: UIImage, fileName: String) -> PunchPointImageState? {
guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
return PunchPointImageState(data: data, fileName: fileName)
}
private func updateUploadDialog(_ state: PunchPointUploadDialogState?) {
guard let state else {
progressAlert?.dismiss(animated: true)
progressAlert = nil
progressView = nil
progressLabel = nil
return
}
if progressAlert == nil {
let alert = UIAlertController(title: state.title, message: "\n\n", preferredStyle: .alert)
let progress = UIProgressView(progressViewStyle: .default)
progress.progressTintColor = AppColor.primary
let label = UILabel()
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.textSecondary
label.textAlignment = .center
alert.view.addSubview(progress)
alert.view.addSubview(label)
progress.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(28)
make.top.equalToSuperview().offset(88)
}
label.snp.makeConstraints { make in
make.top.equalTo(progress.snp.bottom).offset(12)
make.centerX.equalToSuperview()
}
progressAlert = alert
progressView = progress
progressLabel = label
present(alert, animated: true)
}
progressAlert?.title = state.title
progressView?.progress = Float(state.progress) / 100.0
progressLabel?.text = "\(state.progress)%"
}
@objc private func zoomInTapped() {
mapView.zoomIn()
}
@objc private func zoomOutTapped() {
mapView.zoomOut()
}
@objc private func locateTapped() {
Task { await viewModel.locateToCurrent() }
}
@objc private func submitTapped() {
Task { await viewModel.submit(api: api) }
}
}
extension PunchPointFormViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
var loadedItems = [PunchPointImageState?](repeating: nil, count: results.count)
let group = DispatchGroup()
for (index, result) in results.enumerated() {
group.enter()
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
defer { group.leave() }
guard let self, let image = object as? UIImage else { return }
let fileName = "punch_point_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
loadedItems[index] = self.makeImageState(image: image, fileName: fileName)
}
}
group.notify(queue: .main) { [weak self] in
guard let self else { return }
Task { await self.viewModel.addLocalImages(loadedItems.compactMap { $0 }, uploader: self.uploader) }
}
}
}
///
///
private final class PunchPointTextFieldView: UIView {
private let titleLabel = UILabel()
private let textField = UITextField()
var onTextChange: ((String) -> Void)?
var isEditing: Bool { textField.isFirstResponder }
var text: String {
get { textField.text ?? "" }
set {
if textField.text != newValue {
textField.text = newValue
}
}
}
init(title: String, placeholder: String) {
super.init(frame: .zero)
titleLabel.attributedText = Self.requiredTitle(title)
textField.placeholder = placeholder
textField.font = .systemFont(ofSize: 14)
textField.textColor = AppColor.textPrimary
textField.backgroundColor = .white
textField.layer.cornerRadius = 8
textField.layer.borderColor = AppColor.border.cgColor
textField.layer.borderWidth = 1
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
textField.leftViewMode = .always
addSubview(titleLabel)
addSubview(textField)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
textField.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(44)
}
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate static func requiredTitle(_ text: String) -> NSAttributedString {
let mutable = NSMutableAttributedString(string: text, attributes: [
.font: UIFont.systemFont(ofSize: 14, weight: .medium),
.foregroundColor: UIColor(hex: 0x2E3746),
])
if text.hasPrefix("*") {
mutable.addAttribute(.foregroundColor, value: AppColor.danger, range: NSRange(location: 0, length: 1))
}
return mutable
}
@objc private func textChanged() {
onTextChange?(textField.text ?? "")
}
}
///
///
private final class PunchPointCoordinateFieldView: UIView {
private let titleLabel = UILabel()
private let container = UIView()
private let valueLabel = UILabel()
private let locateButton = UIButton(type: .system)
private let placeholder: String
var onLocate: (() -> Void)?
var text: String = "" {
didSet {
valueLabel.text = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? placeholder : text
valueLabel.textColor = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? UIColor(hex: 0x999999) : AppColor.textPrimary
}
}
init(title: String, placeholder: String) {
self.placeholder = placeholder
super.init(frame: .zero)
titleLabel.attributedText = PunchPointTextFieldView.requiredTitle(title)
container.layer.cornerRadius = 8
container.layer.borderWidth = 1
container.layer.borderColor = AppColor.border.cgColor
valueLabel.text = placeholder
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = UIColor(hex: 0x999999)
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
locateButton.tintColor = AppColor.textPrimary
addSubview(titleLabel)
addSubview(container)
container.addSubview(valueLabel)
container.addSubview(locateButton)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
container.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(44)
}
valueLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.trailing.equalTo(locateButton.snp.leading).offset(-8)
}
locateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
make.size.equalTo(26)
}
locateButton.addTarget(self, action: #selector(locateTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func locateTapped() {
onLocate?()
}
}
///
///
private final class PunchPointTextViewFieldView: UIView, UITextViewDelegate {
private let titleLabel = UILabel()
private let textView = UITextView()
private let placeholderLabel = UILabel()
var onTextChange: ((String) -> Void)?
var isEditing: Bool { textView.isFirstResponder }
var text: String {
get { textView.text ?? "" }
set {
if textView.text != newValue {
textView.text = newValue
placeholderLabel.isHidden = !newValue.isEmpty
}
}
}
init(title: String, placeholder: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = UIColor(hex: 0x333333)
textView.font = .systemFont(ofSize: 14)
textView.textColor = AppColor.textPrimary
textView.layer.cornerRadius = 8
textView.layer.borderWidth = 1
textView.layer.borderColor = AppColor.border.cgColor
textView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
textView.delegate = self
placeholderLabel.text = placeholder
placeholderLabel.font = .systemFont(ofSize: 14)
placeholderLabel.textColor = UIColor(hex: 0x999999)
addSubview(titleLabel)
addSubview(textView)
textView.addSubview(placeholderLabel)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
textView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(92)
}
placeholderLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.equalToSuperview().offset(12)
make.trailing.equalToSuperview().inset(12)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 50 {
textView.text = String(textView.text.prefix(50))
}
placeholderLabel.isHidden = !textView.text.isEmpty
onTextChange?(textView.text)
}
}
///
///
private final class PunchPointImageGridView: UIView {
private let titleLabel = UILabel()
private let stack = UIStackView()
var onAdd: (() -> Void)?
var onDelete: ((Int) -> Void)?
var onRetry: ((Int) -> Void)?
var title: String = "" {
didSet { titleLabel.attributedText = PunchPointTextFieldView.requiredTitle(title) }
}
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
stack.axis = .vertical
stack.spacing = 12
addSubview(titleLabel)
addSubview(stack)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
stack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(images: [PunchPointImageState]) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let cells = images.map(Optional.some) + (images.count < 9 ? [nil] : [])
let rows = Int(ceil(Double(cells.count) / 3.0))
guard rows > 0 else { return }
for row in 0 ..< rows {
let rowStack = UIStackView()
rowStack.axis = .horizontal
rowStack.spacing = 12
rowStack.distribution = .fillEqually
stack.addArrangedSubview(rowStack)
for column in 0 ..< 3 {
let index = row * 3 + column
if index < cells.count {
rowStack.addArrangedSubview(makeCell(item: cells[index], index: index))
} else {
rowStack.addArrangedSubview(UIView())
}
}
}
}
private func makeCell(item: PunchPointImageState?, index: Int) -> UIView {
let cell = PunchPointImageThumbView()
cell.snp.makeConstraints { make in
make.height.equalTo(cell.snp.width).dividedBy(1.7)
}
if let item {
cell.apply(item: item)
cell.onDelete = { [weak self] in self?.onDelete?(index) }
cell.onRetry = { [weak self] in self?.onRetry?(index) }
} else {
cell.applyAdd()
cell.onAdd = { [weak self] in self?.onAdd?() }
}
return cell
}
}
///
///
private final class PunchPointImageThumbView: UIControl {
private let imageView = UIImageView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
private let deleteButton = UIButton(type: .system)
private let overlayLabel = UILabel()
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
var onRetry: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 8
clipsToBounds = true
backgroundColor = .white
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
addIcon.tintColor = AppColor.primary
deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
deleteButton.tintColor = .white
overlayLabel.font = .systemFont(ofSize: 14, weight: .medium)
overlayLabel.textColor = .white
overlayLabel.textAlignment = .center
overlayLabel.backgroundColor = UIColor.black.withAlphaComponent(0.45)
addSubview(imageView)
addSubview(addIcon)
addSubview(deleteButton)
addSubview(overlayLabel)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
}
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(22)
}
overlayLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addTarget(self, action: #selector(tapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func applyAdd() {
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
imageView.image = nil
imageView.kf.cancelDownloadTask()
addIcon.isHidden = false
deleteButton.isHidden = true
overlayLabel.isHidden = true
}
func apply(item: PunchPointImageState) {
layer.borderWidth = 0
addIcon.isHidden = true
deleteButton.isHidden = item.isUploading
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(data: item.data)
}
if item.isUploading {
overlayLabel.isHidden = false
overlayLabel.text = "\(item.uploadProgress)%"
} else if item.errorMessage != nil {
overlayLabel.isHidden = false
overlayLabel.text = "重新上传"
overlayLabel.backgroundColor = AppColor.danger.withAlphaComponent(0.65)
} else {
overlayLabel.isHidden = true
}
}
@objc private func tapped() {
if !addIcon.isHidden {
onAdd?()
} else if !overlayLabel.isHidden, overlayLabel.text == "重新上传" {
onRetry?()
}
}
@objc private func deleteTapped() {
onDelete?()
}
}

View File

@ -0,0 +1,506 @@
//
// PunchPointListViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `PunchPointListScreen`
final class PunchPointListViewController: BaseViewController {
private let viewModel: PunchPointListViewModel
private let api: any PunchPointAPIProtocol
private let filterContainer = UIView()
private let filterButton = UIButton(type: .system)
private let filterTitleLabel = UILabel()
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
private let filterDropdownView = UIView()
private let filterDropdownStack = UIStackView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let bottomBar = UIView()
private let addButton = UIButton(type: .system)
private let emptyView = PunchPointEmptyView()
private var dataSource: UITableViewDiffableDataSource<Int, PunchPointItem>!
private var filterButtons: [(PunchPointFilterType, UIButton)] = []
private var needsRefreshOnAppear = false
///
init(
viewModel: PunchPointListViewModel = PunchPointListViewModel(),
api: (any PunchPointAPIProtocol)? = nil
) {
self.viewModel = viewModel
self.api = api ?? NetworkServices.shared.punchPointAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "打卡点列表"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
filterContainer.backgroundColor = .white
filterButton.backgroundColor = UIColor(hex: 0xF5F5F5)
filterButton.layer.cornerRadius = 8
filterTitleLabel.text = PunchPointFilterType.all.title
filterTitleLabel.font = .systemFont(ofSize: 14)
filterTitleLabel.textColor = AppColor.textPrimary
filterChevronView.tintColor = AppColor.textSecondary
filterChevronView.contentMode = .scaleAspectFit
filterDropdownView.backgroundColor = .white
filterDropdownView.layer.cornerRadius = 8
filterDropdownView.layer.shadowColor = UIColor.black.cgColor
filterDropdownView.layer.shadowOpacity = 0.12
filterDropdownView.layer.shadowRadius = 10
filterDropdownView.layer.shadowOffset = CGSize(width: 0, height: 4)
filterDropdownView.isHidden = true
filterDropdownStack.axis = .vertical
tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 210
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
tableView.refreshControl = UIRefreshControl()
tableView.register(PunchPointListCell.self, forCellReuseIdentifier: PunchPointListCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Int, PunchPointItem>(tableView: tableView) { [weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: PunchPointListCell.reuseIdentifier,
for: indexPath
) as! PunchPointListCell
cell.apply(item: item)
cell.onQRCode = { [weak self] in self?.viewModel.showQRCode(for: item) }
cell.onDelete = { [weak self] in self?.confirmDelete(item) }
return cell
}
bottomBar.backgroundColor = .white
addButton.setTitle("添加", for: .normal)
addButton.setTitleColor(.white, for: .normal)
addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8
emptyView.isHidden = true
emptyView.onAction = { [weak self] in self?.addTapped() }
view.addSubview(filterContainer)
filterContainer.addSubview(filterButton)
filterButton.addSubview(filterTitleLabel)
filterButton.addSubview(filterChevronView)
view.addSubview(tableView)
view.addSubview(emptyView)
view.addSubview(bottomBar)
bottomBar.addSubview(addButton)
view.addSubview(filterDropdownView)
filterDropdownView.addSubview(filterDropdownStack)
configureFilterDropdown()
}
override func setupConstraints() {
filterContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalTo(64)
}
filterButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(15)
make.centerY.equalToSuperview()
make.height.equalTo(48)
}
filterTitleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
}
filterChevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.size.equalTo(22)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
addButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
emptyView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalTo(tableView)
make.leading.trailing.equalToSuperview().inset(24)
}
filterDropdownView.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom)
make.leading.trailing.equalTo(filterButton)
}
filterDropdownStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onShowQRCode = { [weak self] url in
Task { @MainActor in self?.showQRCodeDialog(url: url) }
}
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadInitial(api: api) }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if needsRefreshOnAppear {
needsRefreshOnAppear = false
Task { await viewModel.refresh(api: api) }
}
}
@MainActor
private func applyViewModel() {
filterTitleLabel.text = viewModel.filterType.title
filterButtons.forEach { type, button in
let selected = type == viewModel.filterType
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .medium : .regular)
}
var snapshot = NSDiffableDataSourceSnapshot<Int, PunchPointItem>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.items)
dataSource.apply(snapshot, animatingDifferences: true)
tableView.refreshControl?.endRefreshing()
emptyView.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing
viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading()
}
private func configureFilterDropdown() {
filterDropdownStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
filterButtons = PunchPointFilterType.allCases.map { type in
let button = UIButton(type: .system)
button.contentHorizontalAlignment = .left
button.setTitle(type.title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14)
var configuration = UIButton.Configuration.plain()
configuration.contentInsets = NSDirectionalEdgeInsets(top: 13, leading: 12, bottom: 13, trailing: 12)
button.configuration = configuration
button.addAction(UIAction { [weak self] _ in
guard let self else { return }
self.filterDropdownView.isHidden = true
Task { await self.viewModel.selectFilter(type, api: self.api) }
}, for: .touchUpInside)
filterDropdownStack.addArrangedSubview(button)
return (type, button)
}
}
private func confirmDelete(_ item: PunchPointItem) {
let alert = UIAlertController(
title: "删除打卡点",
message: "确定删除\(item.name)打卡点吗?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.delete(item: item, api: self.api) }
})
present(alert, animated: true)
}
private func showQRCodeDialog(url: String) {
let controller = UIAlertController(title: nil, message: "\n\n\n\n\n\n\n\n", preferredStyle: .alert)
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 12
if let imageURL = URL(string: url) {
imageView.kf.setImage(with: imageURL)
}
controller.view.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalToSuperview().offset(52)
make.size.equalTo(180)
}
controller.addAction(UIAlertAction(title: "确定", style: .default))
present(controller, animated: true)
}
@objc private func filterTapped() {
filterDropdownView.isHidden.toggle()
}
@objc private func refreshPulled() {
Task { await viewModel.refresh(api: api) }
}
@objc private func addTapped() {
let controller = PunchPointFormViewController(mode: .create)
controller.onSubmitSuccess = { [weak self] in self?.needsRefreshOnAppear = true }
navigationController?.pushViewController(controller, animated: true)
}
}
extension PunchPointListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
let controller = PunchPointDetailViewController(punchPointId: item.id)
controller.onChanged = { [weak self] in self?.needsRefreshOnAppear = true }
navigationController?.pushViewController(controller, animated: true)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row, api: api) }
}
}
///
/// Android
private final class PunchPointListCell: UITableViewCell {
static let reuseIdentifier = "PunchPointListCell"
private let cardView = UIView()
private let coverImageView = UIImageView()
private let nameLabel = UILabel()
private let operatingTag = PunchPointStatusChip()
private let creatorLabel = UILabel()
private let createdAtLabel = UILabel()
private let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
private let addressLabel = UILabel()
private let reviewTag = PunchPointStatusChip()
private let auditTimeLabel = UILabel()
private let qrButton = UIButton(type: .system)
private let deleteButton = UIButton(type: .system)
var onQRCode: (() -> Void)?
var onDelete: (() -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
contentView.backgroundColor = .clear
selectionStyle = .none
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.clipsToBounds = true
coverImageView.contentMode = .scaleAspectFill
coverImageView.clipsToBounds = true
coverImageView.layer.cornerRadius = 12
coverImageView.backgroundColor = UIColor(hex: 0xF5F5F5)
nameLabel.font = .systemFont(ofSize: 16, weight: .semibold)
nameLabel.textColor = .black
nameLabel.lineBreakMode = .byTruncatingTail
[creatorLabel, createdAtLabel, auditTimeLabel].forEach {
$0.font = .systemFont(ofSize: 12)
$0.textColor = AppColor.textSecondary
}
locationIcon.tintColor = UIColor(hex: 0x4B5563)
addressLabel.font = .systemFont(ofSize: 12)
addressLabel.textColor = UIColor(hex: 0x4B5563)
addressLabel.numberOfLines = 2
qrButton.setImage(UIImage(systemName: "qrcode"), for: .normal)
qrButton.tintColor = AppColor.textPrimary
deleteButton.setImage(UIImage(systemName: "trash"), for: .normal)
deleteButton.tintColor = AppColor.textPrimary
contentView.addSubview(cardView)
[coverImageView, nameLabel, operatingTag, creatorLabel, createdAtLabel, locationIcon, addressLabel, reviewTag, auditTimeLabel, qrButton, deleteButton].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(6)
make.leading.trailing.equalToSuperview().inset(15)
}
coverImageView.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
make.size.equalTo(128)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(coverImageView)
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
make.trailing.lessThanOrEqualTo(operatingTag.snp.leading).offset(-8)
}
operatingTag.snp.makeConstraints { make in
make.centerY.equalTo(nameLabel)
make.trailing.equalToSuperview().inset(16)
}
creatorLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(8)
make.leading.equalTo(nameLabel)
make.trailing.equalToSuperview().inset(16)
}
createdAtLabel.snp.makeConstraints { make in
make.top.equalTo(creatorLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(creatorLabel)
}
locationIcon.snp.makeConstraints { make in
make.top.equalTo(createdAtLabel.snp.bottom).offset(8)
make.leading.equalTo(nameLabel)
make.size.equalTo(14)
}
addressLabel.snp.makeConstraints { make in
make.top.equalTo(locationIcon).offset(-1)
make.leading.equalTo(locationIcon.snp.trailing).offset(4)
make.trailing.equalToSuperview().inset(16)
}
reviewTag.snp.makeConstraints { make in
make.top.equalTo(coverImageView.snp.bottom).offset(14)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(16)
}
auditTimeLabel.snp.makeConstraints { make in
make.centerY.equalTo(reviewTag)
make.leading.equalTo(reviewTag.snp.trailing).offset(8)
}
deleteButton.snp.makeConstraints { make in
make.centerY.equalTo(reviewTag)
make.trailing.equalToSuperview().inset(16)
make.size.equalTo(32)
}
qrButton.snp.makeConstraints { make in
make.centerY.equalTo(reviewTag)
make.trailing.equalTo(deleteButton.snp.leading).offset(-4)
make.size.equalTo(32)
}
qrButton.addTarget(self, action: #selector(qrTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: PunchPointItem) {
nameLabel.text = item.name
creatorLabel.text = "负责人:\(item.creator?.nonEmptyTrimmed ?? "--")"
createdAtLabel.text = "创建时间:\(item.createdAt)"
addressLabel.text = item.displayAddress.nonEmptyTrimmed ?? "--"
auditTimeLabel.text = "审核时间:\(item.auditTime?.nonEmptyTrimmed ?? "--")"
operatingTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status)
reviewTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status)
if let url = item.guideImages.first.flatMap(URL.init(string:)) {
coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
} else {
coverImageView.image = UIImage(systemName: "photo")
}
}
@objc private func qrTapped() {
onQRCode?()
}
@objc private func deleteTapped() {
onDelete?()
}
}
///
///
final class PunchPointStatusChip: UIView {
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 10
clipsToBounds = true
label.font = .systemFont(ofSize: 12, weight: .medium)
label.textAlignment = .center
addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(text: String, status: Int) {
label.text = text
let colors = PunchPointDisplayFormatter.statusColors(status)
backgroundColor = UIColor(hex: colors.background)
label.textColor = UIColor(hex: colors.text)
}
}
///
///
private final class PunchPointEmptyView: UIView {
private let titleLabel = UILabel()
private let button = UIButton(type: .system)
var onAction: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.text = "暂无打卡点"
titleLabel.font = .systemFont(ofSize: 15)
titleLabel.textColor = AppColor.textSecondary
titleLabel.textAlignment = .center
button.setTitle("添加打卡点", for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
button.backgroundColor = AppColor.primary
button.layer.cornerRadius = 8
addSubview(titleLabel)
addSubview(button)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
button.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.centerX.bottom.equalToSuperview()
make.width.equalTo(128)
make.height.equalTo(40)
}
button.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func actionTapped() {
onAction?()
}
}
private extension String {
var nonEmptyTrimmed: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

View File

@ -0,0 +1,468 @@
//
// PointsRedemptionViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `PointsRedemptionScreen`
final class PointsRedemptionViewController: BaseViewController, UITableViewDelegate {
private enum Section {
case main
}
private let viewModel: PointsRedemptionViewModel
private let walletAPI: any WalletPageServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let pointsCard = UIView()
private let pointsValueLabel = UILabel()
private let inputCard = UIView()
private let pointsInputField = UITextField()
private let withdrawablePointsLabel = UILabel()
private let recordCard = UIView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let bottomBar = UIView()
private let submitButton = AppButton(title: "立即兑换")
private var dataSource: UITableViewDiffableDataSource<Section, PointWithdrawItem>!
private var lastShownMessage: String?
///
init(
viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
) {
self.viewModel = viewModel
self.walletAPI = walletAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "积分兑现"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
contentStack.axis = .vertical
contentStack.spacing = 16
bottomBar.backgroundColor = .white
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(bottomBar)
bottomBar.addSubview(submitButton)
contentStack.addArrangedSubview(makeSummaryCard())
contentStack.addArrangedSubview(makeInputCard())
contentStack.addArrangedSubview(makeRecordCard())
configureTable()
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
tableView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(260)
make.height.lessThanOrEqualTo(420)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyState() }
}
pointsInputField.addTarget(self, action: #selector(pointsInputChanged), for: .editingChanged)
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
Task { await viewModel.loadInitial(api: walletAPI) }
}
private func configureTable() {
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
tableView.register(PointWithdrawRecordCell.self, forCellReuseIdentifier: PointWithdrawRecordCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Section, PointWithdrawItem>(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: PointWithdrawRecordCell.reuseIdentifier,
for: indexPath
) as? PointWithdrawRecordCell
cell?.apply(item)
return cell ?? UITableViewCell()
}
}
private func applyState() {
pointsValueLabel.text = "\(viewModel.withdrawnPoints)"
withdrawablePointsLabel.text = "\(viewModel.withdrawnPoints)"
pointsInputField.text = viewModel.pointsInput
submitButton.isEnabled = (Int(viewModel.pointsInput) ?? 0) > 0
refreshControl.endRefreshing()
var snapshot = NSDiffableDataSourceSnapshot<Section, PointWithdrawItem>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.withdrawRecords, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState()
showMessageIfNeeded(viewModel.statusMessage)
showMessageIfNeeded(viewModel.errorMessage)
}
private func updateEmptyState() {
guard viewModel.withdrawRecords.isEmpty, !viewModel.withdrawLoading else {
tableView.backgroundView = nil
return
}
let label = UILabel()
label.text = "暂无提现记录"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0xB3B8C2)
label.textAlignment = .center
tableView.backgroundView = label
}
private func makeSummaryCard() -> UIView {
pointsCard.backgroundColor = UIColor(hex: 0x1677FF)
pointsCard.layer.cornerRadius = 16
pointsCard.clipsToBounds = true
let title = UILabel()
title.text = "可提现积分"
title.font = .systemFont(ofSize: 14)
title.textColor = UIColor.white.withAlphaComponent(0.8)
title.textAlignment = .center
pointsValueLabel.font = .systemFont(ofSize: 36, weight: .bold)
pointsValueLabel.textColor = .white
pointsValueLabel.textAlignment = .center
pointsCard.addSubview(title)
pointsCard.addSubview(pointsValueLabel)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(24)
}
pointsValueLabel.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(24)
make.bottom.equalToSuperview().inset(16)
}
return pointsCard
}
private func makeInputCard() -> UIView {
inputCard.backgroundColor = .white
inputCard.layer.cornerRadius = 12
inputCard.clipsToBounds = true
let title = makeTitle("积分提现", size: 18)
let row = UIStackView()
row.axis = .horizontal
row.spacing = 16
let label = makeBody("可提现积分", color: .black, weight: .medium)
withdrawablePointsLabel.font = .systemFont(ofSize: 14, weight: .medium)
withdrawablePointsLabel.textColor = UIColor(hex: 0xFF7B00)
row.addArrangedSubview(label)
row.addArrangedSubview(withdrawablePointsLabel)
let inputContainer = UIView()
inputContainer.layer.cornerRadius = 4
inputContainer.layer.borderWidth = 1
inputContainer.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
pointsInputField.placeholder = "请输入积分"
pointsInputField.keyboardType = .numberPad
pointsInputField.font = .systemFont(ofSize: 14)
let allButton = UIButton(type: .system)
allButton.setTitle("全部提现", for: .normal)
allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
allButton.titleLabel?.font = .systemFont(ofSize: 14)
allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside)
inputContainer.addSubview(pointsInputField)
inputContainer.addSubview(allButton)
pointsInputField.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.leading.equalToSuperview().offset(12)
make.trailing.equalTo(allButton.snp.leading).offset(-8)
}
allButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
}
inputContainer.snp.makeConstraints { make in make.height.equalTo(46) }
let ratio = makeBody("积分金额兑换比例12积分=1元人民币", color: UIColor(hex: 0xEF4444))
ratio.font = .systemFont(ofSize: 12)
let stack = UIStackView(arrangedSubviews: [title, row, inputContainer, ratio])
stack.axis = .vertical
stack.spacing = 12
inputCard.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return inputCard
}
private func makeRecordCard() -> UIView {
recordCard.backgroundColor = .white
recordCard.layer.cornerRadius = 12
recordCard.clipsToBounds = true
let title = makeTitle("积分提现记录", size: 14)
title.textColor = UIColor(hex: 0x0073FF)
title.textAlignment = .center
recordCard.addSubview(title)
recordCard.addSubview(tableView)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(16)
}
return recordCard
}
private func makeTitle(_ text: String, size: CGFloat) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: size, weight: .medium)
label.textColor = .black
return label
}
private func makeBody(_ text: String, color: UIColor, weight: UIFont.Weight = .regular) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 14, weight: weight)
label.textColor = color
return label
}
private func showMessageIfNeeded(_ message: String?) {
guard let message, !message.isEmpty, message != lastShownMessage else { return }
lastShownMessage = message
showToast(message)
}
@objc private func pointsInputChanged() {
viewModel.updatePointsInput(pointsInputField.text ?? "")
}
@objc private func withdrawAllTapped() {
viewModel.withdrawAll()
}
@objc private func refreshPulled() {
Task { await viewModel.loadInitial(api: walletAPI) }
}
@objc private func submitTapped() {
Task {
showLoading()
await viewModel.applyWithdraw(api: walletAPI)
hideLoading()
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task { await viewModel.loadMoreWithdrawListIfNeeded(currentIndex: indexPath.row, api: walletAPI) }
}
}
/// Cell
private final class PointWithdrawRecordCell: UITableViewCell {
static let reuseIdentifier = "PointWithdrawRecordCell"
private let cardView = UIView()
private let pointsLabel = UILabel()
private let amountLabel = UILabel()
private let timeLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .bar)
private let stepsStack = UIStackView()
private let infoLabel = UILabel()
private let statusLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ record: PointWithdrawItem) {
pointsLabel.text = "-\(record.points)积分"
amountLabel.text = record.amount != nil && record.status == 2 ? "+\(formatAmount(record.amount))" : ""
amountLabel.isHidden = amountLabel.text?.isEmpty ?? true
timeLabel.text = record.createdAt.isEmpty ? "----" : record.createdAt
let style = statusStyle(for: record.status)
statusLabel.text = statusText(for: record.status)
statusLabel.textColor = style.text
statusLabel.backgroundColor = style.background
let progress = progress(for: record.status)
progressView.progress = Float(progress) / 100.0
progressView.progressTintColor = style.progress
progressView.isHidden = progress == 0
stepsStack.isHidden = progress == 0
configureSteps(progress: progress, tint: style.progress)
infoLabel.text = infoText(for: record)
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.clipsToBounds = true
pointsLabel.font = .systemFont(ofSize: 18, weight: .medium)
pointsLabel.textColor = .black
amountLabel.font = .systemFont(ofSize: 18, weight: .medium)
amountLabel.textColor = UIColor(hex: 0xEF4444)
amountLabel.textAlignment = .right
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x7B8EAA)
progressView.trackTintColor = UIColor(hex: 0xF2F4F8)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
stepsStack.axis = .horizontal
stepsStack.distribution = .equalSpacing
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
infoLabel.font = .systemFont(ofSize: 14)
infoLabel.textColor = UIColor(hex: 0x4B5563)
infoLabel.numberOfLines = 0
contentView.addSubview(cardView)
[pointsLabel, amountLabel, timeLabel, progressView, stepsStack, infoLabel, statusLabel].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0))
}
pointsLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
}
amountLabel.snp.makeConstraints { make in
make.centerY.equalTo(pointsLabel)
make.leading.greaterThanOrEqualTo(pointsLabel.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(pointsLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
progressView.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(6)
}
stepsStack.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
infoLabel.snp.makeConstraints { make in
make.top.equalTo(stepsStack.snp.bottom).offset(10)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
make.bottom.equalToSuperview().inset(14)
}
statusLabel.snp.makeConstraints { make in
make.centerY.equalTo(infoLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(70)
}
}
private func configureSteps(progress: Int, tint: UIColor) {
stepsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 11)
label.textColor = progress >= Int((Float(index) / 3.0) * 100) ? tint : UIColor(hex: 0x7B8EAA)
stepsStack.addArrangedSubview(label)
}
let percent = UILabel()
percent.text = "\(progress)%"
percent.font = .systemFont(ofSize: 12, weight: .medium)
percent.textColor = UIColor(hex: 0x9CA3AF)
stepsStack.addArrangedSubview(percent)
}
private func statusText(for status: Int) -> String {
switch status {
case 2: "提现成功"
case 1: "提现中"
case 3: "提现失败"
default: "未知"
}
}
private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) {
switch status {
case 2:
(UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0x1677FF))
case 1:
(UIColor(hex: 0xFFF0E2), UIColor(hex: 0xFF7B00), UIColor(hex: 0x1677FF))
case 3:
(UIColor(hex: 0xFFE7E7), UIColor(hex: 0xEF4444), UIColor(hex: 0xFF4D4F))
default:
(UIColor(hex: 0xE6F1FF), UIColor(hex: 0x1677FF), UIColor(hex: 0x1677FF))
}
}
private func progress(for status: Int) -> Int {
switch status {
case 1: 40
case 3: 100
default: 0
}
}
private func infoText(for record: PointWithdrawItem) -> String {
switch record.status {
case 3:
let remark = record.rejectReason.isEmpty ? "" : "\n备注信息:\(record.rejectReason)"
return "审核时间:\(record.reviewedAt.isEmpty ? "暂无" : record.reviewedAt)\(remark)"
case 2:
return "到账时间:\(record.paymentTime.isEmpty ? "暂无" : record.paymentTime)"
default:
return "预计到账时间:\(record.estimatedReceiptTime.isEmpty ? "暂无" : record.estimatedReceiptTime)"
}
}
private func formatAmount(_ value: Double?) -> String {
guard let value else { return "¥0.00" }
return String(format: "¥%.2f", value)
}
}

View File

@ -0,0 +1,348 @@
//
// WalletCells.swift
// suixinkan
//
import SnapKit
import UIKit
/// Cell
final class WalletWithdrawRecordCell: UITableViewCell {
static let reuseIdentifier = "WalletWithdrawRecordCell"
private let cardView = UIView()
private let amountLabel = UILabel()
private let statusLabel = UILabel()
private let timeLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .bar)
private let stepsStack = UIStackView()
private let infoLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ record: WalletWithdrawItem) {
amountLabel.text = WalletViewModel.formatAmount(record.amount)
statusLabel.text = record.statusLabel.isEmpty ? "--" : record.statusLabel
timeLabel.text = record.createdAt.isEmpty ? "--" : record.createdAt
let style = statusStyle(for: record.settlementStatus)
statusLabel.textColor = style.text
statusLabel.backgroundColor = style.background
progressView.progressTintColor = style.progress
progressView.progress = progress(for: record.settlementStatus)
configureSteps(progress: progress(for: record.settlementStatus), tint: style.progress)
progressView.isHidden = record.settlementStatus == 50
stepsStack.isHidden = record.settlementStatus == 50
infoLabel.text = infoText(for: record)
infoLabel.isHidden = infoLabel.text?.isEmpty ?? true
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
contentView.backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.clipsToBounds = true
amountLabel.font = .systemFont(ofSize: 18, weight: .bold)
amountLabel.textColor = .black
statusLabel.font = .systemFont(ofSize: 12, weight: .medium)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x7B8EAA)
progressView.trackTintColor = UIColor(hex: 0xF2F4F8)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
stepsStack.axis = .horizontal
stepsStack.distribution = .equalSpacing
stepsStack.alignment = .center
infoLabel.font = .systemFont(ofSize: 14)
infoLabel.textColor = UIColor(hex: 0x4B5563)
infoLabel.numberOfLines = 0
contentView.addSubview(cardView)
[amountLabel, statusLabel, timeLabel, progressView, stepsStack, infoLabel].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
}
amountLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-12)
}
statusLabel.snp.makeConstraints { make in
make.centerY.equalTo(amountLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(24)
make.width.greaterThanOrEqualTo(62)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(amountLabel.snp.bottom).offset(6)
make.leading.trailing.equalToSuperview().inset(16)
}
progressView.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(6)
}
stepsStack.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview().inset(16)
}
infoLabel.snp.makeConstraints { make in
make.top.equalTo(stepsStack.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().inset(14)
}
}
private func configureSteps(progress: Float, tint: UIColor) {
stepsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 11)
let threshold = Float(index) / 3.0
label.textColor = progress >= threshold ? tint : UIColor(hex: 0xB3B8C2)
stepsStack.addArrangedSubview(label)
}
let percent = UILabel()
percent.text = "\(Int(progress * 100))%"
percent.font = .systemFont(ofSize: 12, weight: .medium)
percent.textColor = tint
stepsStack.addArrangedSubview(percent)
}
private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) {
switch status {
case 50, 20:
(UIColor(hex: 0xEBFFF5), UIColor(hex: 0x16C26D), UIColor(hex: 0x1677FF))
case 60, 30:
(UIColor(hex: 0xFFF1F0), UIColor(hex: 0xFF4D4F), UIColor(hex: 0xFF4D4F))
case 40:
(UIColor(hex: 0xFFF3E8), UIColor(hex: 0xFF8A00), UIColor(hex: 0x1677FF))
default:
(UIColor(hex: 0xE6F1FF), UIColor(hex: 0x1677FF), UIColor(hex: 0x1677FF))
}
}
private func progress(for status: Int) -> Float {
switch status {
case 10: 0.1
case 40: 0.4
case 20: 0.8
case 30: 1.0
case 60: 0.0
default: 1.0
}
}
private func infoText(for record: WalletWithdrawItem) -> String {
if (record.settlementStatus == 10 || record.settlementStatus == 40), !record.expectedAt.isEmpty {
return "预计到账时间:\(record.expectedAt)"
}
if (record.settlementStatus == 60 || record.settlementStatus == 30), !record.auditTime.isEmpty {
let remark = record.auditRemark.isEmpty ? "" : "\n备注信息:\(record.auditRemark)"
return "审核时间:\(record.auditTime)\(remark)"
}
if record.settlementStatus == 50, !record.expectedAt.isEmpty {
return "到账时间:\(record.expectedAt)"
}
return ""
}
}
/// Cell
final class WalletTransactionHeaderCell: UITableViewCell {
static let reuseIdentifier = "WalletTransactionHeaderCell"
private let dateLabel = UILabel()
private let incomeLabel = UILabel()
private let pointsLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ group: EarningDetailGroup) {
dateLabel.text = group.date
incomeLabel.text = "当日收益:\(WalletViewModel.formatAmount(group.dayAmount))"
pointsLabel.text = group.dayPoints > 0 ? "积分+\(group.dayPoints)" : ""
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
dateLabel.font = .systemFont(ofSize: 14)
dateLabel.textColor = UIColor(hex: 0x9CA3AF)
incomeLabel.font = .systemFont(ofSize: 14)
incomeLabel.textColor = UIColor(hex: 0x4B5563)
incomeLabel.textAlignment = .right
pointsLabel.font = .systemFont(ofSize: 14)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.textAlignment = .right
contentView.addSubview(dateLabel)
contentView.addSubview(incomeLabel)
contentView.addSubview(pointsLabel)
dateLabel.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(8)
make.leading.equalToSuperview().offset(16)
}
pointsLabel.snp.makeConstraints { make in
make.centerY.equalTo(dateLabel)
make.trailing.equalToSuperview().inset(16)
}
incomeLabel.snp.makeConstraints { make in
make.centerY.equalTo(dateLabel)
make.leading.greaterThanOrEqualTo(dateLabel.snp.trailing).offset(8)
make.trailing.equalTo(pointsLabel.snp.leading).offset(-12)
}
}
}
/// Cell
final class WalletTransactionRecordCell: UITableViewCell {
static let reuseIdentifier = "WalletTransactionRecordCell"
private let cardView = UIView()
private let amountLabel = UILabel()
private let pointsLabel = UILabel()
private let typeLabel = UILabel()
private let orderLabel = UILabel()
private let timeLabel = UILabel()
private let withdrawLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ item: EarningDetailItem) {
amountLabel.text = item.amount.isEmpty ? "" : WalletViewModel.formatAmount(item.amount)
pointsLabel.text = item.points > 0 ? "积分+\(item.points)" : ""
amountLabel.isHidden = item.amount.isEmpty
pointsLabel.isHidden = item.points <= 0
if item.amount.isEmpty, item.points <= 0 {
amountLabel.text = "--"
amountLabel.isHidden = false
}
typeLabel.text = item.typeLabel
orderLabel.text = item.orderNumberSuffix
orderLabel.isHidden = item.orderNumberSuffix.isEmpty
timeLabel.text = item.createdAt
withdrawLabel.text = item.withdrawLabel
withdrawLabel.isHidden = item.withdrawLabel.isEmpty
let style = labelStyle(for: item.withdrawLabel)
withdrawLabel.textColor = style.text
withdrawLabel.backgroundColor = style.background
orderLabel.textColor = style.orderText
orderLabel.backgroundColor = style.orderBackground
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.clipsToBounds = true
amountLabel.font = .systemFont(ofSize: 18, weight: .medium)
amountLabel.textColor = .black
pointsLabel.font = .systemFont(ofSize: 18, weight: .medium)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.textAlignment = .right
typeLabel.font = .systemFont(ofSize: 14)
typeLabel.textColor = UIColor(hex: 0x9CA3AF)
orderLabel.font = .systemFont(ofSize: 12)
orderLabel.textAlignment = .center
orderLabel.layer.cornerRadius = 4
orderLabel.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x4B5563)
withdrawLabel.font = .systemFont(ofSize: 12)
withdrawLabel.textAlignment = .center
withdrawLabel.layer.cornerRadius = 4
withdrawLabel.clipsToBounds = true
contentView.addSubview(cardView)
[amountLabel, pointsLabel, typeLabel, orderLabel, timeLabel, withdrawLabel].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
}
amountLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
}
pointsLabel.snp.makeConstraints { make in
make.centerY.equalTo(amountLabel)
make.leading.greaterThanOrEqualTo(amountLabel.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
}
typeLabel.snp.makeConstraints { make in
make.top.equalTo(amountLabel.snp.bottom).offset(10)
make.leading.equalToSuperview().offset(16)
}
orderLabel.snp.makeConstraints { make in
make.centerY.equalTo(typeLabel)
make.leading.equalTo(typeLabel.snp.trailing).offset(8)
make.height.equalTo(20)
make.width.greaterThanOrEqualTo(36)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(typeLabel.snp.bottom).offset(12)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(16)
}
withdrawLabel.snp.makeConstraints { make in
make.centerY.equalTo(timeLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(20)
make.width.greaterThanOrEqualTo(48)
}
}
private func labelStyle(for label: String) -> (background: UIColor, text: UIColor, orderBackground: UIColor, orderText: UIColor) {
switch label {
case "可提现":
return (UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0xEFF6FF), UIColor(hex: 0x0073FF))
default:
return (UIColor(hex: 0xF4F4F4), UIColor(hex: 0x7B8EAA), UIColor(hex: 0xF4F4F4), UIColor(hex: 0x7B8EAA))
}
}
}

View File

@ -0,0 +1,546 @@
//
// WalletViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android
final class WalletViewController: BaseViewController, UITableViewDelegate {
private enum Section {
case main
}
private enum Item: Hashable {
case withdraw(WalletWithdrawItem)
case transactionHeader(EarningDetailGroup)
case transaction(EarningDetailItem)
}
private let viewModel: WalletViewModel
private let walletAPI: any WalletPageServing
private let profileAPI: any WalletProfileServing
private let summaryCard = WalletSummaryCardView()
private let contentCard = UIView()
private let tabStack = UIStackView()
private let withdrawTabButton = UIButton(type: .system)
private let transactionTabButton = UIButton(type: .system)
private let filterPanel = WalletFilterPanelView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let bottomBar = UIView()
private let withdrawButton = AppButton(title: "提现到银行卡")
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var lastShownMessage: String?
///
init(
viewModel: WalletViewModel = WalletViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI,
profileAPI: any WalletProfileServing = NetworkServices.shared.profileAPI
) {
self.viewModel = viewModel
self.walletAPI = walletAPI
self.profileAPI = profileAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "我的钱包"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
contentCard.backgroundColor = .white
contentCard.layer.cornerRadius = 16
contentCard.clipsToBounds = true
tabStack.axis = .horizontal
tabStack.distribution = .fillEqually
[withdrawTabButton, transactionTabButton].forEach {
$0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
$0.setTitleColor(UIColor(hex: 0x666666), for: .normal)
tabStack.addArrangedSubview($0)
}
withdrawTabButton.setTitle("提现记录", for: .normal)
transactionTabButton.setTitle("收益明细", for: .normal)
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
tableView.register(WalletWithdrawRecordCell.self, forCellReuseIdentifier: WalletWithdrawRecordCell.reuseIdentifier)
tableView.register(WalletTransactionHeaderCell.self, forCellReuseIdentifier: WalletTransactionHeaderCell.reuseIdentifier)
tableView.register(WalletTransactionRecordCell.self, forCellReuseIdentifier: WalletTransactionRecordCell.reuseIdentifier)
bottomBar.backgroundColor = .white
view.addSubview(summaryCard)
view.addSubview(contentCard)
contentCard.addSubview(tabStack)
contentCard.addSubview(filterPanel)
contentCard.addSubview(tableView)
view.addSubview(bottomBar)
bottomBar.addSubview(withdrawButton)
configureDataSource()
}
override func setupConstraints() {
summaryCard.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
withdrawButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
contentCard.snp.makeConstraints { make in
make.top.equalTo(summaryCard.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-16)
}
tabStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(50)
}
filterPanel.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom)
make.leading.trailing.equalToSuperview().inset(16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterPanel.snp.bottom).offset(4)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.applyState()
}
}
summaryCard.onPointsTap = { [weak self] in
self?.openPointsRedemption()
}
withdrawTabButton.addTarget(self, action: #selector(withdrawTabTapped), for: .touchUpInside)
transactionTabButton.addTarget(self, action: #selector(transactionTabTapped), for: .touchUpInside)
filterPanel.onFilterTap = { [weak self] in
self?.presentFilterMenu()
}
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
withdrawButton.addTarget(self, action: #selector(withdrawTapped), for: .touchUpInside)
Task { await viewModel.loadInitial(api: walletAPI) }
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { tableView, indexPath, item in
switch item {
case .withdraw(let record):
let cell = tableView.dequeueReusableCell(
withIdentifier: WalletWithdrawRecordCell.reuseIdentifier,
for: indexPath
) as? WalletWithdrawRecordCell
cell?.apply(record)
return cell ?? UITableViewCell()
case .transactionHeader(let group):
let cell = tableView.dequeueReusableCell(
withIdentifier: WalletTransactionHeaderCell.reuseIdentifier,
for: indexPath
) as? WalletTransactionHeaderCell
cell?.apply(group)
return cell ?? UITableViewCell()
case .transaction(let record):
let cell = tableView.dequeueReusableCell(
withIdentifier: WalletTransactionRecordCell.reuseIdentifier,
for: indexPath
) as? WalletTransactionRecordCell
cell?.apply(record)
return cell ?? UITableViewCell()
}
}
}
private func applyState() {
summaryCard.apply(
withdrawable: viewModel.summary?.amountWithdrawable,
total: viewModel.summary?.amountTotal,
balance: viewModel.summary?.amountCurrentBalance,
points: viewModel.availablePoints
)
applyTabAppearance()
filterPanel.isHidden = viewModel.selectedTab != .transaction
filterPanel.apply(filter: viewModel.filter, totalIncome: viewModel.totalIncomeLabel, totalPoints: viewModel.totalPointsLabel)
refreshControl.endRefreshing()
applySnapshot()
if let message = viewModel.errorMessage, message != lastShownMessage {
lastShownMessage = message
showToast(message)
}
}
private func applyTabAppearance() {
let selectedColor = UIColor(hex: 0x1677FF)
let normalColor = UIColor(hex: 0x666666)
withdrawTabButton.setTitleColor(viewModel.selectedTab == .withdraw ? selectedColor : normalColor, for: .normal)
transactionTabButton.setTitleColor(viewModel.selectedTab == .transaction ? selectedColor : normalColor, for: .normal)
withdrawTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .withdraw ? .semibold : .regular)
transactionTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .transaction ? .semibold : .regular)
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
if viewModel.selectedTab == .withdraw {
snapshot.appendItems(viewModel.withdrawRecords.map(Item.withdraw), toSection: .main)
} else {
let items = viewModel.transactionEntries.map { entry -> Item in
switch entry {
case .header(let group): .transactionHeader(group)
case .item(let item): .transaction(item)
}
}
snapshot.appendItems(items, toSection: .main)
}
dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState()
}
private func updateEmptyState() {
let emptyText: String?
if viewModel.selectedTab == .withdraw, viewModel.withdrawRecords.isEmpty, !viewModel.withdrawLoading {
emptyText = "暂无提现记录"
} else if viewModel.selectedTab == .transaction, viewModel.transactionEntries.isEmpty, !viewModel.transactionLoading {
emptyText = "暂无收益明细"
} else {
emptyText = nil
}
guard let emptyText else {
tableView.backgroundView = nil
return
}
let label = UILabel()
label.text = emptyText
label.textColor = UIColor(hex: 0xB3B8C2)
label.font = .systemFont(ofSize: 14)
label.textAlignment = .center
tableView.backgroundView = label
}
private func presentFilterMenu() {
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
WalletFilter.allCases.forEach { filter in
alert.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func openPointsRedemption() {
navigationController?.pushViewController(PointsRedemptionViewController(), animated: true)
}
@objc private func withdrawTabTapped() {
Task { await viewModel.selectTab(.withdraw, api: walletAPI) }
}
@objc private func transactionTabTapped() {
Task { await viewModel.selectTab(.transaction, api: walletAPI) }
}
@objc private func refreshPulled() {
Task {
switch viewModel.selectedTab {
case .withdraw:
await viewModel.refreshSummary(api: walletAPI)
await viewModel.refreshWithdraw(api: walletAPI)
await viewModel.refreshPoints(api: walletAPI)
case .transaction:
await viewModel.refreshTransaction(api: walletAPI)
}
}
}
@objc private func withdrawTapped() {
Task {
showLoading()
do {
let destination = try await viewModel.resolveWithdrawDestination(profileAPI: profileAPI)
hideLoading()
route(to: destination)
} catch {
hideLoading()
showToast(error.localizedDescription)
}
}
}
private func route(to destination: WalletWithdrawDestination) {
switch destination {
case .realNameAuth:
navigationController?.pushViewController(RealNameAuthViewController(), animated: true)
case .realNameAudit(let info):
showToast("实名认证不通过,请重新提交")
navigationController?.pushViewController(RealNameAuthAuditViewController(info: info), animated: true)
case .realNamePending:
showToast("实名认证审核中,请审核通过后再试")
case .withdrawalSettings:
navigationController?.pushViewController(WithdrawalSettingsViewController(), animated: true)
case .withdrawalAudit(let bankCard):
showToast("银行卡审核不通过,请重新提交")
navigationController?.pushViewController(WithdrawalSettingsAuditViewController(bankCard: bankCard), animated: true)
case .bankCardPending:
showToast("银行卡审核中,请审核通过后再试")
case .withdraw:
let controller = WithdrawViewController()
controller.onSubmitSuccess = { [weak self] in
guard let self else { return }
Task {
await self.viewModel.refreshSummary(api: self.walletAPI)
await self.viewModel.refreshWithdraw(api: self.walletAPI)
}
}
navigationController?.pushViewController(controller, animated: true)
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task {
switch viewModel.selectedTab {
case .withdraw:
await viewModel.loadMoreWithdrawIfNeeded(currentIndex: indexPath.row, api: walletAPI)
case .transaction:
await viewModel.loadMoreTransactionIfNeeded(currentIndex: indexPath.row, api: walletAPI)
}
}
}
}
///
private final class WalletSummaryCardView: UIView {
var onPointsTap: (() -> Void)?
private let withdrawableTitle = UILabel()
private let withdrawableLabel = UILabel()
private let totalView = WalletSummaryMetricView()
private let balanceView = WalletSummaryMetricView()
private let pointsView = WalletSummaryMetricView(showsArrow: true)
private let metricsStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(withdrawable: String?, total: String?, balance: String?, points: Int) {
withdrawableLabel.text = WalletViewModel.formatAmount(withdrawable)
totalView.apply(title: "累计金额", value: WalletViewModel.formatAmount(total))
balanceView.apply(title: "当前余额", value: WalletViewModel.formatAmount(balance))
pointsView.apply(title: "当前积分", value: "\(points)")
}
private func setupUI() {
backgroundColor = UIColor(hex: 0x1677FF)
layer.cornerRadius = 24
clipsToBounds = true
withdrawableTitle.text = "可提现金额"
withdrawableTitle.textColor = UIColor.white.withAlphaComponent(0.8)
withdrawableTitle.font = .systemFont(ofSize: 14)
withdrawableTitle.textAlignment = .center
withdrawableLabel.textColor = .white
withdrawableLabel.font = .systemFont(ofSize: 36, weight: .bold)
withdrawableLabel.textAlignment = .center
withdrawableLabel.adjustsFontSizeToFitWidth = true
withdrawableLabel.minimumScaleFactor = 0.7
metricsStack.axis = .horizontal
metricsStack.distribution = .fillEqually
[totalView, balanceView, pointsView].forEach(metricsStack.addArrangedSubview)
pointsView.addTarget(self, action: #selector(pointsTapped), for: .touchUpInside)
addSubview(withdrawableTitle)
addSubview(withdrawableLabel)
addSubview(metricsStack)
withdrawableTitle.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(24)
}
withdrawableLabel.snp.makeConstraints { make in
make.top.equalTo(withdrawableTitle.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(24)
}
metricsStack.snp.makeConstraints { make in
make.top.equalTo(withdrawableLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(12)
make.bottom.equalToSuperview().inset(16)
}
}
@objc private func pointsTapped() {
onPointsTap?()
}
}
///
private final class WalletSummaryMetricView: UIControl {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let arrowLabel = UILabel()
private let showsArrow: Bool
init(showsArrow: Bool = false) {
self.showsArrow = showsArrow
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String, value: String) {
titleLabel.text = title
valueLabel.text = value
}
private func setupUI() {
titleLabel.textColor = UIColor.white.withAlphaComponent(0.8)
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textAlignment = .center
valueLabel.textColor = .white
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.textAlignment = .center
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.75
arrowLabel.text = showsArrow ? "" : ""
arrowLabel.textColor = .white
arrowLabel.font = .systemFont(ofSize: 20, weight: .medium)
let valueStack = UIStackView(arrangedSubviews: [valueLabel, arrowLabel])
valueStack.axis = .horizontal
valueStack.alignment = .center
valueStack.spacing = 2
valueStack.distribution = .fill
addSubview(titleLabel)
addSubview(valueStack)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
valueStack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(6)
make.centerX.equalToSuperview()
make.leading.greaterThanOrEqualToSuperview()
make.trailing.lessThanOrEqualToSuperview()
make.bottom.equalToSuperview()
}
}
}
///
private final class WalletFilterPanelView: UIView {
var onFilterTap: (() -> Void)?
private let filterButton = UIButton(type: .system)
private let totalTitleLabel = UILabel()
private let totalLabel = UILabel()
private let pointsLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(filter: WalletFilter, totalIncome: String, totalPoints: Int) {
filterButton.setTitle("\(filter.title)", for: .normal)
totalLabel.text = totalIncome.isEmpty ? "¥ 0.00" : totalIncome
pointsLabel.text = "积分+\(totalPoints)"
}
private func setupUI() {
backgroundColor = UIColor(hex: 0xF6F6F6)
layer.cornerRadius = 16
clipsToBounds = true
filterButton.setTitleColor(UIColor(hex: 0x111827), for: .normal)
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
filterButton.contentHorizontalAlignment = .left
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
totalTitleLabel.text = "总收益:"
totalTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalTitleLabel.textColor = .black
totalLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalLabel.textColor = UIColor(hex: 0x0073FF)
pointsLabel.font = .systemFont(ofSize: 14, weight: .medium)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.textAlignment = .right
addSubview(filterButton)
addSubview(totalTitleLabel)
addSubview(totalLabel)
addSubview(pointsLabel)
filterButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.equalToSuperview().offset(16)
make.width.lessThanOrEqualTo(150)
make.height.equalTo(32)
}
totalTitleLabel.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom).offset(4)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(12)
}
totalLabel.snp.makeConstraints { make in
make.centerY.equalTo(totalTitleLabel)
make.leading.equalTo(totalTitleLabel.snp.trailing).offset(8)
}
pointsLabel.snp.makeConstraints { make in
make.centerY.equalTo(totalTitleLabel)
make.leading.greaterThanOrEqualTo(totalLabel.snp.trailing).offset(8)
make.trailing.equalToSuperview().inset(16)
}
}
@objc private func filterTapped() {
onFilterTap?()
}
}

View File

@ -0,0 +1,352 @@
//
// WithdrawViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `WithdrawScreen`
final class WithdrawViewController: BaseViewController {
var onSubmitSuccess: (() -> Void)?
private let viewModel: WithdrawViewModel
private let walletAPI: any WalletPageServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let bottomBar = UIView()
private let submitButton = AppButton(title: "立即提现")
private let amountField = UITextField()
private let codeField = UITextField()
private let codeButton = UIButton(type: .system)
private let withdrawableLabel = UILabel()
private let limitLabel = UILabel()
private let phoneLabel = UILabel()
private let bankCardLabel = UILabel()
private let bankHolderLabel = UILabel()
private let settlementLabel = UILabel()
private let infoStack = UIStackView()
private var countdownTimer: Timer?
private var lastShownMessage: String?
///
init(
viewModel: WithdrawViewModel = WithdrawViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
) {
self.viewModel = viewModel
self.walletAPI = walletAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
countdownTimer?.invalidate()
}
override func setupNavigationBar() {
title = "提现设置"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
contentStack.axis = .vertical
contentStack.spacing = 16
bottomBar.backgroundColor = .white
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(bottomBar)
bottomBar.addSubview(submitButton)
contentStack.addArrangedSubview(makeAmountCard())
contentStack.addArrangedSubview(makeBankCard())
contentStack.addArrangedSubview(makeSettlementCard())
contentStack.addArrangedSubview(makeInfoCard())
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyState() }
}
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged)
codeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
Task { await viewModel.load(api: walletAPI) }
}
private func applyState() {
amountField.text = viewModel.amount
codeField.text = viewModel.verificationCode
if let info = viewModel.withdrawInfo {
withdrawableLabel.text = WalletViewModel.formatAmount(info.amountWithdrawable)
limitLabel.text = "单笔最高提现 \(WalletViewModel.formatAmount(info.maxSingleWithdrawAmount)), 单日最高提现 \(WalletViewModel.formatAmount(info.maxDailyWithdrawAmount))"
phoneLabel.text = "验证码将发送至: \(maskPhone(info.userPhone))"
bankCardLabel.text = "\(info.bankCard.bankName) (尾号\(lastFour(info.bankCard.cardNumber)))"
bankHolderLabel.text = info.bankCard.realName
settlementLabel.text = info.settlement
applyInfoLines(info.withdrawInfo)
}
codeButton.setTitle(viewModel.countdown > 0 ? "\(viewModel.countdown)s" : "获取验证码", for: .normal)
codeButton.isEnabled = viewModel.countdown == 0
submitButton.isEnabled = viewModel.canSubmit
if viewModel.countdown > 0 { startCountdownTimerIfNeeded() }
showMessageIfNeeded(viewModel.statusMessage)
showMessageIfNeeded(viewModel.errorMessage)
}
private func makeAmountCard() -> UIView {
let card = makeCard()
let title = makeTitle("提现金额")
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 16
let withdrawableTitle = makeBodyLabel("可提现金额", color: .black, weight: .medium)
withdrawableLabel.font = .systemFont(ofSize: 14, weight: .medium)
withdrawableLabel.textColor = UIColor(hex: 0x0073FF)
row.addArrangedSubview(withdrawableTitle)
row.addArrangedSubview(withdrawableLabel)
let amountContainer = borderedInputContainer()
amountField.placeholder = "请输入金额"
amountField.keyboardType = .decimalPad
amountField.font = .systemFont(ofSize: 14)
let allButton = UIButton(type: .system)
allButton.setTitle("全部提现", for: .normal)
allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
allButton.titleLabel?.font = .systemFont(ofSize: 14)
allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside)
amountContainer.addSubview(amountField)
amountContainer.addSubview(allButton)
amountField.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.top.bottom.equalToSuperview()
make.trailing.equalTo(allButton.snp.leading).offset(-8)
}
allButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
}
limitLabel.font = .systemFont(ofSize: 12)
limitLabel.textColor = UIColor(hex: 0x9CA3AF)
phoneLabel.font = .systemFont(ofSize: 12)
phoneLabel.textColor = UIColor(hex: 0x4B5563)
let codeRow = UIStackView()
codeRow.axis = .horizontal
codeRow.spacing = 12
let codeContainer = borderedInputContainer()
codeField.placeholder = "请输入验证码"
codeField.keyboardType = .numberPad
codeField.font = .systemFont(ofSize: 14)
codeContainer.addSubview(codeField)
codeField.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)) }
codeButton.setTitle("获取验证码", for: .normal)
codeButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
codeButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
codeButton.layer.borderWidth = 1
codeButton.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
codeButton.layer.cornerRadius = 4
codeRow.addArrangedSubview(codeContainer)
codeRow.addArrangedSubview(codeButton)
codeContainer.snp.makeConstraints { make in make.height.equalTo(46) }
codeButton.snp.makeConstraints { make in make.width.equalTo(100) }
let stack = UIStackView(arrangedSubviews: [title, row, amountContainer, limitLabel, phoneLabel, codeRow])
stack.axis = .vertical
stack.spacing = 12
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
amountContainer.snp.makeConstraints { make in make.height.equalTo(46) }
return card
}
private func makeBankCard() -> UIView {
let card = makeCard()
let title = makeTitle("银行卡信息")
let inner = makeInnerPanel()
bankCardLabel.font = .systemFont(ofSize: 14, weight: .medium)
bankCardLabel.textColor = UIColor(hex: 0x333333)
bankHolderLabel.font = .systemFont(ofSize: 14)
bankHolderLabel.textColor = UIColor(hex: 0x4B5563)
let innerStack = UIStackView(arrangedSubviews: [bankCardLabel, bankHolderLabel])
innerStack.axis = .vertical
innerStack.spacing = 12
inner.addSubview(innerStack)
innerStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = 8
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card
}
private func makeSettlementCard() -> UIView {
let card = makeCard()
let title = makeTitle("预计到账时间")
let inner = makeInnerPanel()
settlementLabel.font = .systemFont(ofSize: 14)
settlementLabel.textColor = UIColor(hex: 0x4B5563)
settlementLabel.numberOfLines = 0
inner.addSubview(settlementLabel)
settlementLabel.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = 8
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card
}
private func makeInfoCard() -> UIView {
let card = makeCard()
let title = makeTitle("提现说明")
let inner = makeInnerPanel()
infoStack.axis = .vertical
infoStack.spacing = 4
inner.addSubview(infoStack)
infoStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = 8
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card
}
private func makeCard() -> UIView {
let view = UIView()
view.backgroundColor = .white
view.layer.cornerRadius = 12
view.clipsToBounds = true
return view
}
private func makeInnerPanel() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0xF4F4F4)
view.layer.cornerRadius = 8
view.layer.borderWidth = 1
view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
return view
}
private func borderedInputContainer() -> UIView {
let view = UIView()
view.layer.cornerRadius = 4
view.layer.borderWidth = 1
view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
return view
}
private func makeTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 18, weight: .medium)
label.textColor = .black
return label
}
private func makeBodyLabel(_ text: String, color: UIColor, weight: UIFont.Weight = .regular) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 14, weight: weight)
label.textColor = color
return label
}
private func applyInfoLines(_ lines: [String]) {
infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
lines.forEach { text in
let label = makeBodyLabel(text, color: UIColor(hex: 0x4B5563))
label.numberOfLines = 0
infoStack.addArrangedSubview(label)
}
}
private func showMessageIfNeeded(_ message: String?) {
guard let message, !message.isEmpty, message != lastShownMessage else { return }
lastShownMessage = message
showToast(message)
}
private func startCountdownTimerIfNeeded() {
guard countdownTimer == nil else { return }
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
guard let self else { return }
self.viewModel.decrementCountdown()
if self.viewModel.countdown == 0 {
timer.invalidate()
self.countdownTimer = nil
}
}
}
private func maskPhone(_ phone: String) -> String {
guard phone.count >= 7 else { return phone }
let prefix = phone.prefix(3)
let suffix = phone.suffix(4)
return "\(prefix)****\(suffix)"
}
private func lastFour(_ cardNumber: String) -> String {
String(cardNumber.suffix(4))
}
@objc private func amountChanged() {
viewModel.updateAmount(amountField.text ?? "")
}
@objc private func codeChanged() {
viewModel.updateVerificationCode(codeField.text ?? "")
}
@objc private func withdrawAllTapped() {
viewModel.withdrawAll()
}
@objc private func sendCodeTapped() {
Task { await viewModel.requestVerificationCode(api: walletAPI) }
}
@objc private func submitTapped() {
Task {
showLoading()
let success = await viewModel.submit(api: walletAPI)
hideLoading()
if success {
onSubmitSuccess?()
navigationController?.popViewController(animated: true)
}
}
}
}

View File

@ -976,8 +976,10 @@ private final class WildReportSuccessCheckmarkView: UIView {
///
private final class WildReportSuccessProgressView: UIView {
private let contentView = UIView()
private let stack = UIStackView()
private let steps: [String]
private var itemViews: [WildReportSuccessProgressItemView] = []
init(steps: [String]) {
self.steps = steps
@ -999,27 +1001,38 @@ private final class WildReportSuccessProgressView: UIView {
stack.axis = .horizontal
stack.alignment = .top
stack.distribution = .fillEqually
stack.spacing = 0
addSubview(stack)
addSubview(contentView)
contentView.addSubview(stack)
for (index, step) in steps.enumerated() {
let item = WildReportSuccessProgressItemView(title: step, isCompleted: index == 0)
itemViews.append(item)
stack.addArrangedSubview(item)
item.snp.makeConstraints { make in
make.width.equalTo(74)
}
}
for index in steps.indices.dropLast() {
if index < steps.count - 1 {
let connector = WildReportSuccessProgressConnectorView(isCompleted: index == 0)
stack.addArrangedSubview(connector)
contentView.insertSubview(connector, belowSubview: stack)
connector.snp.makeConstraints { make in
make.leading.equalTo(itemViews[index].connectorAnchorView.snp.centerX).offset(12)
make.trailing.equalTo(itemViews[index + 1].connectorAnchorView.snp.centerX).offset(-12)
make.centerY.equalTo(itemViews[index].connectorAnchorView)
make.height.equalTo(24)
}
}
}
}
private func setupConstraints() {
stack.snp.makeConstraints { make in
contentView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12))
}
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}
@ -1028,6 +1041,8 @@ private final class WildReportSuccessProgressItemView: UIView {
private let nodeView: WildReportSuccessProgressNodeView
private let titleLabel = UILabel()
var connectorAnchorView: UIView { nodeView }
init(title: String, isCompleted: Bool) {
self.nodeView = WildReportSuccessProgressNodeView(isCompleted: isCompleted)
super.init(frame: .zero)

View File

@ -0,0 +1,6 @@
/* Class = "UILabel"; text = "商家版"; ObjectID = "Lbl-St-001"; */
"Lbl-St-001.text" = "商家版";
/* Class = "UILabel"; text = "随心瞰"; ObjectID = "Lbl-Tt-001"; */
"Lbl-Tt-001.text" = "随心瞰";