diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index e6555a2..ebf15b7 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -214,11 +214,12 @@ }; }; buildConfigurationList = 93FF17C02FFB84950033C304 /* Build configuration list for PBXProject "suixinkan" */; - developmentRegion = en; + developmentRegion = "zh-Hans"; hasScannedForEncodings = 0; knownRegions = ( en, Base, + "zh-Hans", ); mainGroup = 93FF17BC2FFB84950033C304; minimizedProjectReferenceProxies = 1; @@ -388,6 +389,11 @@ ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = suixinkan/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版"; + INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码和排队打卡点小程序码到相册"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证和银行卡照片"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; @@ -447,6 +453,11 @@ ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = suixinkan/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版"; + INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码和排队打卡点小程序码到相册"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证和银行卡照片"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; @@ -494,6 +505,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -558,6 +570,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 2525a05..5e10ff0 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -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) diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index a9f8b01..6a4a192 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -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 = ["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": diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index f818663..c86ad1a 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -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) } diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index d0e7042..4eb6137 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -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 diff --git a/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift b/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift new file mode 100644 index 0000000..9ca4687 --- /dev/null +++ b/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift @@ -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) + ) + ) + } +} diff --git a/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift b/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift new file mode 100644 index 0000000..e74b754 --- /dev/null +++ b/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift new file mode 100644 index 0000000..2ac14a4 --- /dev/null +++ b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift @@ -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?() + } +} diff --git a/suixinkan/Features/Wallet/API/WalletAPI.swift b/suixinkan/Features/Wallet/API/WalletAPI.swift index d153bfa..660a7e7 100644 --- a/suixinkan/Features/Wallet/API/WalletAPI.swift +++ b/suixinkan/Features/Wallet/API/WalletAPI.swift @@ -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 + ) + ) + } } diff --git a/suixinkan/Features/Wallet/Models/WalletModels.swift b/suixinkan/Features/Wallet/Models/WalletModels.swift index 1f683a6..2d3f91f 100644 --- a/suixinkan/Features/Wallet/Models/WalletModels.swift +++ b/suixinkan/Features/Wallet/Models/WalletModels.swift @@ -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 + } } diff --git a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift new file mode 100644 index 0000000..c4a554d --- /dev/null +++ b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift @@ -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?() + } +} diff --git a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift index 4a4aa00..52a308e 100644 --- a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift +++ b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift @@ -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 diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index 63b5663..f846ca6 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -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, diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index 9850113..a778307 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -2,8 +2,6 @@ - CFBundleDisplayName - 随心瞰商家版 CFBundleURLTypes @@ -23,14 +21,6 @@ weixinULAPI weixinURLParamsAPI - NSCameraUsageDescription - 需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输 - NSLocationWhenInUseUsageDescription - 需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡 - NSPhotoLibraryUsageDescription - 需要访问相册以选择头像、身份证和银行卡照片 - NSPhotoLibraryAddUsageDescription - 需要保存收款二维码和排队打卡点小程序码到相册 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/suixinkan/UI/Home/HomeCollectionModels.swift b/suixinkan/UI/Home/HomeCollectionModels.swift index 1ce18d6..4ca60da 100644 --- a/suixinkan/UI/Home/HomeCollectionModels.swift +++ b/suixinkan/UI/Home/HomeCollectionModels.swift @@ -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: diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index f57f944..46abffa 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -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 { diff --git a/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift b/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift index 1c5389e..45394b2 100644 --- a/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift +++ b/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift @@ -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?() } diff --git a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift index 2724be9..e968403 100644 --- a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift +++ b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift @@ -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) } } diff --git a/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift b/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift new file mode 100644 index 0000000..efb15c3 --- /dev/null +++ b/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift @@ -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 + } +} diff --git a/suixinkan/UI/PunchPoint/PunchPointFormViewController.swift b/suixinkan/UI/PunchPoint/PunchPointFormViewController.swift new file mode 100644 index 0000000..29ea3c4 --- /dev/null +++ b/suixinkan/UI/PunchPoint/PunchPointFormViewController.swift @@ -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?() + } +} diff --git a/suixinkan/UI/PunchPoint/PunchPointListViewController.swift b/suixinkan/UI/PunchPoint/PunchPointListViewController.swift new file mode 100644 index 0000000..56a3c6e --- /dev/null +++ b/suixinkan/UI/PunchPoint/PunchPointListViewController.swift @@ -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! + 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(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() + 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 + } +} diff --git a/suixinkan/UI/Wallet/PointsRedemptionViewController.swift b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift new file mode 100644 index 0000000..f61d723 --- /dev/null +++ b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift @@ -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! + 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(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() + 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) + } +} diff --git a/suixinkan/UI/Wallet/WalletCells.swift b/suixinkan/UI/Wallet/WalletCells.swift new file mode 100644 index 0000000..2b74f37 --- /dev/null +++ b/suixinkan/UI/Wallet/WalletCells.swift @@ -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)) + } + } +} diff --git a/suixinkan/UI/Wallet/WalletViewController.swift b/suixinkan/UI/Wallet/WalletViewController.swift new file mode 100644 index 0000000..5cd325b --- /dev/null +++ b/suixinkan/UI/Wallet/WalletViewController.swift @@ -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! + 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(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() + 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?() + } +} diff --git a/suixinkan/UI/Wallet/WithdrawViewController.swift b/suixinkan/UI/Wallet/WithdrawViewController.swift new file mode 100644 index 0000000..5d426fd --- /dev/null +++ b/suixinkan/UI/Wallet/WithdrawViewController.swift @@ -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) + } + } + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift index 2ed1dfb..517d889 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -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) diff --git a/suixinkan/zh-Hans.lproj/LaunchScreen.strings b/suixinkan/zh-Hans.lproj/LaunchScreen.strings new file mode 100644 index 0000000..f06486b --- /dev/null +++ b/suixinkan/zh-Hans.lproj/LaunchScreen.strings @@ -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" = "随心瞰"; diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift index 3e840ca..9a26b42 100644 --- a/suixinkanTests/HomeViewModelTests.swift +++ b/suixinkanTests/HomeViewModelTests.swift @@ -41,6 +41,13 @@ final class HomeViewModelTests: XCTestCase { XCTAssertFalse(viewModel.isMinimalTopRole) } + func testLocationReportPromptDefaultsToFixedHomeCopy() { + let viewModel = HomeViewModel(appStore: appStore) + + XCTAssertEqual(viewModel.locationInfoText, HomeViewModel.locationReportPromptText) + XCTAssertEqual(viewModel.locationInfoText, "您已进入打卡范围") + } + func testRebuildCommonMenusFiltersUnknownURIs() { appStore.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), diff --git a/suixinkanTests/PunchPointAPITests.swift b/suixinkanTests/PunchPointAPITests.swift new file mode 100644 index 0000000..e8a3803 --- /dev/null +++ b/suixinkanTests/PunchPointAPITests.swift @@ -0,0 +1,93 @@ +// +// PunchPointAPITests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 打卡点管理 API 路径、参数与请求体测试。 +@MainActor +final class PunchPointAPITests: XCTestCase { + func testListAndInfoUseAndroidPathsAndQueries() async throws { + let session = MockURLSession(responses: [ + envelopeJSON(#"{"total":0,"list":[]}"#), + envelopeJSON(#"{"id":18,"name":"观景台"}"#), + ]) + let api = PunchPointAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.list(scenicAreaId: "9", status: 2, page: 3, pageSize: 10) + _ = try await api.info(id: 18) + + XCTAssertEqual(session.requests[0].httpMethod, "GET") + XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/scenic-spot/list") + let listQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(listQuery?.first { $0.name == "scenic_area_id" }?.value, "9") + XCTAssertEqual(listQuery?.first { $0.name == "status" }?.value, "2") + XCTAssertEqual(listQuery?.first { $0.name == "page" }?.value, "3") + XCTAssertEqual(listQuery?.first { $0.name == "page_size" }?.value, "10") + + XCTAssertEqual(session.requests[1].httpMethod, "GET") + XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/scenic-spot/info") + let infoQuery = URLComponents(url: try XCTUnwrap(session.requests[1].url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(infoQuery?.first { $0.name == "id" }?.value, "18") + } + + func testAddEditAndDeleteEncodeAndroidBodies() async throws { + let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}"), envelopeJSON("{}")]) + let api = PunchPointAPI(client: APIClient(environment: .testing, session: session)) + let addRequest = PunchPointSaveRequest( + scenicAreaId: "7", + name: "山顶打卡点", + description: "日出位置", + region: PunchPointSaveRegion(lot: 120.12, lat: 30.23, address: "山顶平台"), + scenicSpotString: "山顶平台", + guideImages: ["https://cdn/a.jpg"] + ) + let editRequest = PunchPointSaveRequest( + id: 88, + scenicAreaId: "7", + name: "山顶打卡点2", + description: nil, + region: PunchPointSaveRegion(lot: 121.12, lat: 31.23, address: "新平台"), + scenicSpotString: "新平台", + guideImages: ["https://cdn/b.jpg"] + ) + + try await api.add(addRequest) + try await api.edit(editRequest) + try await api.delete(id: 88) + + XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/scenic-spot/add") + let addBody = try bodyJSON(session.requests[0]) + XCTAssertNil(addBody["id"]) + XCTAssertEqual(addBody["scenic_area_id"] as? String, "7") + XCTAssertEqual(addBody["name"] as? String, "山顶打卡点") + XCTAssertEqual(addBody["scenic_spot_str"] as? String, "山顶平台") + XCTAssertEqual(addBody["guide_imgs"] as? [String], ["https://cdn/a.jpg"]) + let addRegion = try XCTUnwrap(addBody["region"] as? [String: Any]) + XCTAssertEqual(addRegion["lot"] as? Double, 120.12) + XCTAssertEqual(addRegion["lat"] as? Double, 30.23) + XCTAssertEqual(addRegion["address"] as? String, "山顶平台") + + XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/scenic-spot/edit") + let editBody = try bodyJSON(session.requests[1]) + XCTAssertEqual(editBody["id"] as? Int, 88) + XCTAssertEqual(editBody["name"] as? String, "山顶打卡点2") + XCTAssertNil(editBody["description"]) + + XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/scenic-spot/delete") + let deleteBody = try bodyJSON(session.requests[2]) + XCTAssertEqual(deleteBody["id"] as? Int, 88) + } + + private func envelopeJSON(_ dataJSON: String) -> Data { + """ + {"code":100000,"msg":"success","data":\(dataJSON)} + """.data(using: .utf8)! + } + + private func bodyJSON(_ request: URLRequest) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]) + } +} diff --git a/suixinkanTests/PunchPointModelsTests.swift b/suixinkanTests/PunchPointModelsTests.swift new file mode 100644 index 0000000..2fb19d3 --- /dev/null +++ b/suixinkanTests/PunchPointModelsTests.swift @@ -0,0 +1,85 @@ +// +// PunchPointModelsTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 打卡点模型解码与格式化测试。 +final class PunchPointModelsTests: XCTestCase { + func testListItemDecodesRegionImagesQRCodeAndAuditFields() throws { + let json = """ + { + "total": 1, + "list": [{ + "id": "9001", + "name": "湖边平台", + "status": 1, + "status_label": "运营中", + "region": {"lat": 30.1, "lot": 120.2, "address": "湖边", "scenic_spot_str": "景区湖边"}, + "scenic_spot_str": "备用地址", + "guide_imgs": ["https://cdn/1.jpg", "https://cdn/2.jpg"], + "mp_qrcode": "https://cdn/qr.png", + "created_at": "2026-07-08 10:00:00", + "creator_id": "77", + "creator": "张三", + "creator_phone": "13800138000", + "auditor": "李四", + "audit_time": "2026-07-08 11:00:00", + "audit_remark": "通过" + }] + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(PunchPointListResponse.self, from: json) + + XCTAssertEqual(response.total, 1) + let item = try XCTUnwrap(response.list.first) + XCTAssertEqual(item.id, 9001) + XCTAssertEqual(item.statusLabel, "运营中") + XCTAssertEqual(item.region?.lat, 30.1) + XCTAssertEqual(item.region?.lot, 120.2) + XCTAssertEqual(item.displayAddress, "湖边") + XCTAssertEqual(item.guideImages.count, 2) + XCTAssertEqual(item.mpQrcode, "https://cdn/qr.png") + XCTAssertEqual(item.creatorId, 77) + XCTAssertEqual(item.auditTime, "2026-07-08 11:00:00") + XCTAssertEqual(PunchPointDisplayFormatter.maskedPhone(item.creatorPhone), "138****8000") + } + + func testDetailDecodesDefaultsAndCoordinateFallbacks() throws { + let json = """ + { + "id": 12, + "name": "入口", + "status": 3, + "region": {"lat": 31.2, "lot": 121.3, "address": ""}, + "scenic_spot_str": "景区入口", + "description": "集合点" + } + """.data(using: .utf8)! + + let detail = try JSONDecoder().decode(PunchPointDetail.self, from: json) + + XCTAssertEqual(detail.statusLabel, "") + XCTAssertEqual(detail.guideImages, []) + XCTAssertNil(detail.mpQrcode) + XCTAssertEqual(detail.displayAddress, "景区入口") + XCTAssertEqual(detail.coordinate?.latitude, 31.2) + XCTAssertEqual(detail.coordinate?.longitude, 121.3) + XCTAssertEqual(detail.description, "集合点") + XCTAssertEqual(PunchPointFilterType.rejected.apiStatus, 4) + XCTAssertEqual(PunchPointFilterType.pendingReview.title, "未审核") + } + + func testImageRemoteStateUsesURLAndUploadedFlag() { + let image = PunchPointImageState.remote(url: "https://cdn.example.com/path/a.jpg") + + XCTAssertEqual(image.fileName, "a.jpg") + XCTAssertEqual(image.previewURL, "https://cdn.example.com/path/a.jpg") + XCTAssertEqual(image.uploadedURL, "https://cdn.example.com/path/a.jpg") + XCTAssertTrue(image.isUploaded) + XCTAssertFalse(image.isUploading) + } +} diff --git a/suixinkanTests/PunchPointViewModelTests.swift b/suixinkanTests/PunchPointViewModelTests.swift new file mode 100644 index 0000000..ae8dfda --- /dev/null +++ b/suixinkanTests/PunchPointViewModelTests.swift @@ -0,0 +1,259 @@ +// +// PunchPointViewModelTests.swift +// suixinkanTests +// + +import CoreLocation +import XCTest +@testable import suixinkan + +/// 打卡点列表与表单 ViewModel 行为测试。 +@MainActor +final class PunchPointViewModelTests: XCTestCase { + func testFilterRefreshAndPaginationUseAndroidStatusAndPageSize() async { + let api = MockPunchPointAPI() + api.listResponses = [ + PunchPointListResponse(total: 12, list: (1...10).map { item(id: Int64($0)) }), + PunchPointListResponse(total: 12, list: [item(id: 11), item(id: 12)]), + PunchPointListResponse(total: 1, list: [item(id: 21)]), + ] + let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 }) + + await viewModel.loadInitial(api: api) + await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api) + await viewModel.selectFilter(.paused, api: api) + + XCTAssertEqual(viewModel.items.map(\.id), [21]) + XCTAssertEqual(api.listCalls.map(\.status), [0, 0, 2]) + XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1]) + XCTAssertEqual(api.listCalls.map(\.pageSize), [10, 10, 10]) + XCTAssertEqual(api.listCalls.map(\.scenicAreaId), ["9", "9", "9"]) + } + + func testDeleteSuccessRemovesItemAndQRCodeValidationMatchesAndroid() async { + let api = MockPunchPointAPI() + api.listResponses = [PunchPointListResponse(total: 2, list: [ + item(id: 1, status: 1, mpQrcode: "https://cdn/qr.png"), + item(id: 2, status: 3, mpQrcode: nil), + ])] + let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 }) + var messages: [String] = [] + var qrcode: String? + viewModel.onShowMessage = { messages.append($0) } + viewModel.onShowQRCode = { qrcode = $0 } + + await viewModel.loadInitial(api: api) + viewModel.showQRCode(for: viewModel.items[1]) + viewModel.showQRCode(for: PunchPointItem(id: 3, status: 1, mpQrcode: " ")) + viewModel.showQRCode(for: viewModel.items[0]) + await viewModel.delete(item: viewModel.items[0], api: api) + + XCTAssertEqual(messages, ["未审核通过不可查看二维码", "暂无二维码"]) + XCTAssertEqual(qrcode, "https://cdn/qr.png") + XCTAssertEqual(viewModel.items.map(\.id), [2]) + XCTAssertEqual(api.deletedIds, [1]) + } + + func testFormValidationUploadAndCreateRequestBody() async { + let api = MockPunchPointAPI() + let uploader = MockPunchPointUploader(resultURL: "https://cdn/upload.jpg") + let location = MockPunchPointLocationProvider(reverseAddress: "地图地址") + let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.submit(api: api) + viewModel.updateName("湖边打卡") + await viewModel.selectCoordinate(CLLocationCoordinate2D(latitude: 30.1, longitude: 120.2)) + viewModel.updateDescription(String(repeating: "景", count: 80)) + await viewModel.addLocalImages([PunchPointImageState(data: Data([1, 2]), fileName: "a.jpg")], uploader: uploader) + await viewModel.submit(api: api) + + XCTAssertEqual(messages.first, "请输入打卡点名称") + XCTAssertEqual(viewModel.description.count, 50) + XCTAssertEqual(uploader.uploads.map(\.moduleFileName), ["a.jpg"]) + let request = api.addRequests.first + XCTAssertEqual(request?.scenicAreaId, "7") + XCTAssertEqual(request?.name, "湖边打卡") + XCTAssertEqual(request?.region.lat, 30.1) + XCTAssertEqual(request?.region.lot, 120.2) + XCTAssertEqual(request?.region.address, "地图地址") + XCTAssertEqual(request?.scenicSpotString, "地图地址") + XCTAssertEqual(request?.guideImages, ["https://cdn/upload.jpg"]) + } + + func testEditInitializationAndSubmitUsesEditAPI() async { + let api = MockPunchPointAPI() + let viewModel = PunchPointFormViewModel(mode: .edit(id: 66), currentScenicIdProvider: { 8 }, locationProvider: MockPunchPointLocationProvider()) + var changedCoordinate: CLLocationCoordinate2D? + viewModel.onCoordinateChange = { changedCoordinate = $0 } + + viewModel.initialize(with: PunchPointDetail( + id: 66, + name: "旧打卡点", + region: PunchPointRegion(lat: 31.1, lot: 121.1, address: "旧地址"), + description: "说明", + guideImages: ["https://cdn/old.jpg"] + )) + viewModel.updateName("新打卡点") + await viewModel.submit(api: api) + + XCTAssertEqual(changedCoordinate?.latitude, 31.1) + XCTAssertEqual(viewModel.images.first?.uploadedURL, "https://cdn/old.jpg") + XCTAssertEqual(api.editRequests.first?.id, 66) + XCTAssertEqual(api.editRequests.first?.name, "新打卡点") + XCTAssertEqual(api.editRequests.first?.guideImages, ["https://cdn/old.jpg"]) + } + + func testUploadingAndFailedImagesBlockSubmit() async { + let api = MockPunchPointAPI() + let slowUploader = SlowPunchPointUploader() + let location = MockPunchPointLocationProvider(reverseAddress: "地址") + let uploadingViewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location) + uploadingViewModel.updateName("上传中") + await uploadingViewModel.selectCoordinate(CLLocationCoordinate2D(latitude: 1, longitude: 2)) + let started = expectation(description: "upload started") + slowUploader.onStart = { started.fulfill() } + let task = Task { + await uploadingViewModel.addLocalImages([PunchPointImageState(data: Data([1]), fileName: "slow.jpg")], uploader: slowUploader) + } + await fulfillment(of: [started], timeout: 1) + var uploadMessages: [String] = [] + uploadingViewModel.onShowMessage = { uploadMessages.append($0) } + + await uploadingViewModel.submit(api: api) + task.cancel() + + let failedViewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location) + failedViewModel.updateName("失败图") + await failedViewModel.selectCoordinate(CLLocationCoordinate2D(latitude: 1, longitude: 2)) + await failedViewModel.addLocalImages( + [PunchPointImageState(data: Data([1]), fileName: "fail.jpg")], + uploader: MockPunchPointUploader(error: URLError(.cannotConnectToHost)) + ) + var failedMessages: [String] = [] + failedViewModel.onShowMessage = { failedMessages.append($0) } + await failedViewModel.submit(api: api) + + XCTAssertEqual(uploadMessages.last, "图片正在上传,请稍后提交") + XCTAssertEqual(failedMessages.last, "请先完成图片上传") + XCTAssertTrue(api.addRequests.isEmpty) + } + + private func item(id: Int64, status: Int = 1, mpQrcode: String? = nil) -> PunchPointItem { + PunchPointItem( + id: id, + name: "点\(id)", + status: status, + statusLabel: status == 1 ? "运营中" : "未审核", + region: PunchPointRegion(lat: 30, lot: 120, address: "地址\(id)"), + guideImages: ["https://cdn/\(id).jpg"], + mpQrcode: mpQrcode + ) + } +} + +@MainActor +private final class MockPunchPointAPI: PunchPointAPIProtocol { + var listResponses: [PunchPointListResponse] = [] + var listCalls: [(scenicAreaId: String, status: Int, page: Int, pageSize: Int)] = [] + var addRequests: [PunchPointSaveRequest] = [] + var editRequests: [PunchPointSaveRequest] = [] + var deletedIds: [Int64] = [] + + func list(scenicAreaId: String, status: Int, page: Int, pageSize: Int) async throws -> PunchPointListResponse { + listCalls.append((scenicAreaId, status, page, pageSize)) + guard !listResponses.isEmpty else { return PunchPointListResponse() } + return listResponses.removeFirst() + } + + func info(id: Int64) async throws -> PunchPointDetail { + PunchPointDetail(id: id) + } + + func add(_ request: PunchPointSaveRequest) async throws { + addRequests.append(request) + } + + func edit(_ request: PunchPointSaveRequest) async throws { + editRequests.append(request) + } + + func delete(id: Int64) async throws { + deletedIds.append(id) + } +} + +@MainActor +private final class MockPunchPointUploader: PunchPointImageUploading { + struct Upload { + let moduleFileName: String + let scenicId: Int + } + + var uploads: [Upload] = [] + let resultURL: String + let error: Error? + + init(resultURL: String = "https://cdn/default.jpg", error: Error? = nil) { + self.resultURL = resultURL + self.error = error + } + + func uploadPunchPointImage( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + uploads.append(Upload(moduleFileName: fileName, scenicId: scenicId)) + onProgress(100) + if let error { + throw error + } + return resultURL + } +} + +@MainActor +private final class SlowPunchPointUploader: PunchPointImageUploading { + var onStart: (() -> Void)? + + func uploadPunchPointImage( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + onProgress(10) + onStart?() + try await Task.sleep(nanoseconds: 1_000_000_000) + onProgress(100) + return "https://cdn/slow.jpg" + } +} + +private final class MockPunchPointLocationProvider: LocationProviding, @unchecked Sendable { + let snapshot: HomeLocationSnapshot + let reverseAddress: String + + init( + snapshot: HomeLocationSnapshot = HomeLocationSnapshot(latitude: 30.2, longitude: 120.3, address: "当前位置"), + reverseAddress: String = "反查地址" + ) { + self.snapshot = snapshot + self.reverseAddress = reverseAddress + } + + func requestSnapshot() async throws -> HomeLocationSnapshot { + snapshot + } + + func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: snapshot.latitude, longitude: snapshot.longitude) + } + + func reverseGeocode(latitude: Double, longitude: Double) async -> String { + reverseAddress + } +} diff --git a/suixinkanTests/TravelAlbumOTGCoreTests.swift b/suixinkanTests/TravelAlbumOTGCoreTests.swift index e9d71e9..8af8d8a 100644 --- a/suixinkanTests/TravelAlbumOTGCoreTests.swift +++ b/suixinkanTests/TravelAlbumOTGCoreTests.swift @@ -84,4 +84,26 @@ final class TravelAlbumOTGCoreTests: XCTestCase { XCTAssertTrue(key.hasPrefix("album/20260707/9/11111111222233334444555555555555_")) XCTAssertTrue(key.hasSuffix("A_B.JPG")) } + + func testOSSPunchPointObjectKeyUsesAndroidUserPath() { + let previousUserId = AppStore.shared.userId + AppStore.shared.userId = "u900" + defer { AppStore.shared.userId = previousUserId } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let date = calendar.date(from: DateComponents(year: 2026, month: 7, day: 9))! + let uuid = UUID(uuidString: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")! + + let key = OSSUploadPolicy.objectKey( + fileName: "guide/photo.jpg", + scenicId: 9, + moduleType: "punch_point", + date: date, + uuid: uuid, + timeZone: TimeZone(secondsFromGMT: 0)! + ) + + XCTAssertEqual(key, "punch_point/20260709/u900/aaaaaaaabbbbccccddddeeeeeeeeeeee_guide_photo.jpg") + } } diff --git a/suixinkanTests/WalletFeatureTests.swift b/suixinkanTests/WalletFeatureTests.swift new file mode 100644 index 0000000..4356791 --- /dev/null +++ b/suixinkanTests/WalletFeatureTests.swift @@ -0,0 +1,272 @@ +// +// WalletFeatureTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 钱包功能模型、API 和 ViewModel 测试。 +@MainActor +final class WalletFeatureTests: XCTestCase { + func testWalletModelsDecodeLossyFields() throws { + let withdrawJSON = Data(#"{"total":"2","item":[{"id":"10","amount":88.5,"created_at":"2026-07-01","settlement_status":"40","withdraw_status":1,"expected_at":"T+1","status_label":"打款中"}]}"#.utf8) + let withdraw = try JSONDecoder().decode(WalletWithdrawListResponse.self, from: withdrawJSON) + + XCTAssertEqual(withdraw.total, 2) + XCTAssertEqual(withdraw.item[0].id, 10) + XCTAssertEqual(withdraw.item[0].amount, "88.5") + XCTAssertEqual(withdraw.item[0].settlementStatus, 40) + + let earningJSON = Data(#"{"total_amount":12.3,"total_points":"7","total":"1","list":[{"date":"2026-07-01","day_amount":"12.30","day_points":"7","items":[{"id":"1","amount":"12.30","points":"7","type_label":"订单收益","order_number_suffix":"001","created_at":"10:00","withdraw_label":"可提现","source":"wallet"}]}]}"#.utf8) + let earning = try JSONDecoder().decode(EarningDetailResponse.self, from: earningJSON) + + XCTAssertEqual(earning.totalAmount, "12.3") + XCTAssertEqual(earning.totalPoints, 7) + XCTAssertEqual(earning.list[0].items[0].points, 7) + + let pointJSON = Data(#"{"total":"1","list":[{"id":"2","points":"24","amount":"2.0","status":"2","payment_time":"2026-07-02","created_at":"2026-07-01"}]}"#.utf8) + let points = try JSONDecoder().decode(PointWithdrawListResponse.self, from: pointJSON) + + XCTAssertEqual(points.total, 1) + XCTAssertEqual(points.list[0].points, 24) + XCTAssertEqual(points.list[0].amount, 2.0) + } + + func testWalletAPIUsesExpectedRequests() async throws { + let session = MockURLSession(responses: [ + Self.envelope(#"{"amount_total":"100.00","amount_current_balance":"80.00","amount_withdrawable":"50.00"}"#), + Self.envelope(#"{"total":0,"item":[]}"#), + Self.envelope(#"{"total_amount":"0.00","total_points":0,"total":0,"list":[]}"#), + Self.envelope(#"{"amount_withdrawable":"50.00","min_withdraw_amount":"1","max_single_withdraw_amount":"100","max_daily_withdraw_amount":"200","user_phone":"13800138000","bank_card":{"real_name":"张三","bank_name":"招商银行","card_number":"622200001234"},"settlement":"T+1","withdraw_info":["说明"]}"#), + Self.envelope(#"{}"#), + Self.envelope(#"{}"#), + Self.envelope(#"{"total_points":100,"available_points":80,"withdrawn_points":60,"pending_points":20,"time":"2026"}"#), + Self.envelope(#"{}"#), + Self.envelope(#"{"total":0,"list":[]}"#), + ]) + let api = WalletAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.walletSummary(type: 0) + _ = try await api.walletWithdrawList(page: 0, pageSize: 0) + _ = try await api.earningDetail(startDate: "2026-07-01", endDate: "2026-07-09", page: 2, pageSize: 10) + _ = try await api.withdrawInfo() + try await api.withdrawSendSMS() + try await api.withdrawApply(amount: "12.30", smsCode: "123456") + _ = try await api.pointOverview(staffId: 9) + try await api.pointWithdrawApply(points: 24, remark: "积分提现申请") + _ = try await api.pointWithdrawList(status: nil, page: 1, pageSize: 10) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/wallet/summary", + "/api/yf-handset-app/photog/wallet/withdraw-list", + "/api/yf-handset-app/photog/wallet/earning-detail", + "/api/yf-handset-app/photog/wallet/withdraw-info", + "/api/yf-handset-app/photog/wallet/withdraw-send-sms", + "/api/yf-handset-app/photog/wallet/withdraw-apply", + "/api/yf-handset-app/photog/point/overview", + "/api/yf-handset-app/photog/point/withdraw/apply", + "/api/yf-handset-app/photog/point/withdraw/list", + ]) + XCTAssertEqual(Self.queryItems(from: session.requests[1])["page"], "1") + XCTAssertEqual(Self.queryItems(from: session.requests[2])["start_date"], "2026-07-01") + XCTAssertEqual(Self.queryItems(from: session.requests[6])["staff_id"], "9") + XCTAssertEqual(Self.bodyString(from: session.requests[5]).contains(#""sms_code":"123456""#), true) + XCTAssertEqual(Self.bodyString(from: session.requests[7]).contains(#""points":24"#), true) + XCTAssertEqual(Self.bodyString(from: session.requests[8]).contains(#""page_size":10"#), true) + } + + func testWalletViewModelLoadsAndPaginates() async { + let api = FakeWalletPageService() + let viewModel = WalletViewModel(staffIdProvider: { 9 }) + + await viewModel.loadInitial(api: api) + + XCTAssertEqual(viewModel.summary?.amountWithdrawable, "50.00") + XCTAssertEqual(viewModel.availablePoints, 80) + XCTAssertEqual(viewModel.withdrawRecords.count, 1) + XCTAssertEqual(viewModel.selectedTab, .withdraw) + + await viewModel.selectTab(.transaction, api: api) + XCTAssertEqual(viewModel.transactionEntries.count, 2) + XCTAssertEqual(viewModel.totalIncomeLabel, "¥ 12.30") + + await viewModel.selectFilter(.last30Days, api: api) + XCTAssertEqual(viewModel.filter, .last30Days) + XCTAssertGreaterThanOrEqual(api.earningQueries.count, 2) + } + + func testWalletWithdrawDestinationFlow() async throws { + let viewModel = WalletViewModel() + + var profile = FakeWalletProfileService(realNameStatus: nil, bankCardStatus: nil) + var destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) + XCTAssertEqual(destination, .realNameAuth) + + profile = FakeWalletProfileService(realNameStatus: 1, bankCardStatus: nil) + destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) + XCTAssertEqual(destination, .realNamePending) + + profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: nil) + destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) + XCTAssertEqual(destination, .withdrawalSettings) + + profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 1) + destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) + XCTAssertEqual(destination, .bankCardPending) + + profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 2) + destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) + XCTAssertEqual(destination, .withdraw) + } + + func testWithdrawViewModelValidatesAndSubmits() async { + let api = FakeWalletPageService() + let viewModel = WithdrawViewModel() + + await viewModel.load(api: api) + viewModel.updateAmount("0012.345") + viewModel.updateVerificationCode("12a456") + + XCTAssertEqual(viewModel.amount, "12.34") + XCTAssertEqual(viewModel.verificationCode, "12456") + + let success = await viewModel.submit(api: api) + XCTAssertTrue(success) + XCTAssertEqual(api.withdrawApplyRequests.first?.amount, "12.34") + } + + func testPointsRedemptionViewModelClampsAndRefreshes() async { + let api = FakeWalletPageService() + let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 }) + + await viewModel.loadInitial(api: api) + viewModel.updatePointsInput("999") + XCTAssertEqual(viewModel.pointsInput, "60") + + await viewModel.applyWithdraw(api: api) + + XCTAssertEqual(api.pointApplyRequests, [60]) + XCTAssertEqual(viewModel.pointsInput, "") + XCTAssertEqual(viewModel.withdrawRecords.count, 1) + } + + private static func envelope(_ dataJSON: String) -> Data { + Data(#"{"code":100000,"msg":"success","data":\#(dataJSON)}"#.utf8) + } + + private static func queryItems(from request: URLRequest) -> [String: String] { + URLComponents(url: request.url!, resolvingAgainstBaseURL: false)? + .queryItems? + .reduce(into: [String: String]()) { result, item in + result[item.name] = item.value + } ?? [:] + } + + private static func bodyString(from request: URLRequest) -> String { + String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "" + } + +} + +@MainActor +/// 钱包页面服务测试替身。 +private final class FakeWalletPageService: WalletPageServing { + private(set) var earningQueries: [(start: String, end: String, page: Int)] = [] + private(set) var withdrawApplyRequests: [(amount: String, smsCode: String)] = [] + private(set) var pointApplyRequests: [Int] = [] + + func walletSummary(type: Int) async throws -> WalletSummaryResponse { + WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "80.00", amountWithdrawable: "50.00") + } + + func walletTransactionList( + startDate: String, + endDate: String, + page: Int, + pageSize: Int, + type: Int + ) async throws -> WalletTransactionListResponse { + WalletTransactionListResponse() + } + + func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { + WalletWithdrawListResponse( + total: 1, + item: [WalletWithdrawItem(id: page, amount: "10.00", createdAt: "2026-07-01", settlementStatus: 40, statusLabel: "打款中")] + ) + } + + func earningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> EarningDetailResponse { + earningQueries.append((startDate, endDate, page)) + return EarningDetailResponse( + totalAmount: "12.30", + totalPoints: 7, + total: 1, + list: [ + EarningDetailGroup( + date: "2026-07-01", + dayAmount: "12.30", + dayPoints: 7, + items: [EarningDetailItem(id: page, amount: "12.30", points: 7, typeLabel: "订单收益", createdAt: "10:00", withdrawLabel: "可提现")] + ), + ] + ) + } + + func withdrawInfo() async throws -> WithdrawInfoResponse { + WithdrawInfoResponse( + amountWithdrawable: "50.00", + minWithdrawAmount: "1", + maxSingleWithdrawAmount: "100", + maxDailyWithdrawAmount: "200", + userPhone: "13800138000", + bankCard: WithdrawBankCardInfo(realName: "张三", bankName: "招商银行", cardNumber: "622200001234"), + settlement: "T+1", + withdrawInfo: ["说明"] + ) + } + + func withdrawSendSMS() async throws {} + + func withdrawApply(amount: String, smsCode: String) async throws { + withdrawApplyRequests.append((amount, smsCode)) + } + + func pointOverview(staffId: Int) async throws -> PointOverviewResponse { + PointOverviewResponse(totalPoints: 100, availablePoints: 80, withdrawnPoints: 60, pendingPoints: 20) + } + + func pointWithdrawApply(points: Int, remark: String) async throws { + pointApplyRequests.append(points) + } + + func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { + PointWithdrawListResponse(total: 1, list: [PointWithdrawItem(id: page, points: 24, amount: 2, status: 1, createdAt: "2026-07-01")]) + } +} + +@MainActor +/// 钱包前置资料服务测试替身。 +private struct FakeWalletProfileService: WalletProfileServing { + let realNameStatus: Int? + let bankCardStatus: Int? + + func realNameInfo() async throws -> RealNameInfoResponse { + let data: Data = if let realNameStatus { + Data(#"{"real_name_info":{"real_name":"张三","id_card_no":"110101199001010010","audit_status":\#(realNameStatus),"is_long_valid":1}}"#.utf8) + } else { + Data(#"{"real_name_info":null}"#.utf8) + } + return try JSONDecoder().decode(RealNameInfoResponse.self, from: data) + } + + func bankCardInfo() async throws -> BankCardInfoResponse { + let data: Data = if let bankCardStatus { + Data(#"{"bank_card":{"real_name":"张三","bank_name":"招商银行","branch_name":"支行","card_number":"622200001234","audit_status":\#(bankCardStatus),"audit_status_label":"审核中"}}"#.utf8) + } else { + Data(#"{"bank_card":null}"#.utf8) + } + return try JSONDecoder().decode(BankCardInfoResponse.self, from: data) + } +} diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index 16c8072..e1643af 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -158,6 +158,40 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(response.list.first?.complaintNo, "JB20260708917") } + func testSubmitReportAPIReturnsComplaintNumber() async throws { + let json = Data(""" + { + "code": 100000, + "data": { + "complaint_no": "JB20260709886", + "id": 40 + }, + "msg": "success", + "time": "2026-07-09 14:09:41" + } + """.utf8) + let session = MockURLSession(responses: [json]) + let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.submitReport(WildReportSubmitRequest( + scenicId: 128, + reportType: 2, + desc: "测试说明", + storeUserId: nil, + contactPhone: "13800138000", + locationName: "测试景区", + locationAddress: "测试地址", + lat: 30.12, + lng: 120.34, + evidences: [WildReportEvidenceRequest(fileURL: "https://example.com/scene.jpg", fileType: 1, fileName: "scene.jpg")], + paymentScreenshots: [] + )) + + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/submit") + XCTAssertEqual(response.id, 40) + XCTAssertEqual(response.complaintNo, "JB20260709886") + } + func testReportDetailAPILoadsRemoteDetail() async throws { let json = Data(""" { @@ -548,6 +582,7 @@ final class WildPhotographerReportTests: XCTestCase { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) let api = MockWildPhotographerReportAPI() + api.submitResponse = WildReportSubmitResponse(id: 40, complaintNo: "JB20260709886") let uploader = MockWildReportAttachmentUploader() viewModel.selectReportType(WildReportType(value: 2, label: "疑似打野")) viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")]) @@ -559,7 +594,8 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertNil(viewModel.validationMessage) XCTAssertNotNil(viewModel.submitResult) - XCTAssertEqual(viewModel.submitResult?.record.id, "") + XCTAssertEqual(viewModel.submitResult?.record.serverID, 40) + XCTAssertEqual(viewModel.submitResult?.record.id, "JB20260709886") XCTAssertEqual(uploader.uploadedFileNames, ["scene.jpg", "payment.jpg"]) XCTAssertEqual(api.submitRequests.first?.scenicId, 9) XCTAssertEqual(api.submitRequests.first?.reportType, 2) @@ -1043,6 +1079,7 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing var reportCopyResponse = WildReportCopyResponse() var reportCopyError: Error? var error: Error? + var submitResponse = WildReportSubmitResponse() var submitRequests: [WildReportSubmitRequest] = [] var reportListResponses: [WildReportListResponse] = [] var reportListRequests: [WildReportListRequest] = [] @@ -1069,9 +1106,10 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing return reportCopyResponse } - func submitReport(_ request: WildReportSubmitRequest) async throws { + func submitReport(_ request: WildReportSubmitRequest) async throws -> WildReportSubmitResponse { if let error { throw error } submitRequests.append(request) + return submitResponse } func supplementReport(_ request: WildReportSupplementRequest) async throws {