diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index ccbb062..f16ac13 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -24,6 +24,7 @@ final class NetworkServices { let travelAlbumAPI: TravelAlbumAPI let sampleManagementAPI: SampleManagementAPI let liveAPI: LiveAPI + let wildPhotographerReportAPI: WildPhotographerReportAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -43,6 +44,7 @@ final class NetworkServices { travelAlbumAPI = TravelAlbumAPI(client: client) sampleManagementAPI = SampleManagementAPI(client: client) liveAPI = LiveAPI(client: client) + wildPhotographerReportAPI = WildPhotographerReportAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { @@ -67,6 +69,7 @@ final class NetworkServices { travelAlbumAPI = TravelAlbumAPI(client: apiClient) sampleManagementAPI = SampleManagementAPI(client: apiClient) liveAPI = LiveAPI(client: apiClient) + wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) } diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index ee88d01..8788200 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -144,6 +144,24 @@ final class OSSUploadService { ) } + /// 上传举报摄影师附件,对齐举报提交接口的证据与支付截图。 + func uploadWildReportAttachment( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + try await uploadFile( + data: data, + fileName: fileName, + fileType: fileType, + scenicId: scenicId, + moduleType: "photog_report", + onProgress: onProgress + ) + } + private func uploadFile( data: Data, fileName: String, @@ -244,6 +262,8 @@ enum OSSUploadPolicy { return "live_covers/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "alive_album": return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "photog_report": + return "photog_report/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" } diff --git a/suixinkan/Features/Profile/Models/ProfileModels.swift b/suixinkan/Features/Profile/Models/ProfileModels.swift index 5f11cb5..7e877ca 100644 --- a/suixinkan/Features/Profile/Models/ProfileModels.swift +++ b/suixinkan/Features/Profile/Models/ProfileModels.swift @@ -167,7 +167,7 @@ struct PhotographerProfileInfoResponse: Decodable, Equatable { holidayEndTime = try container.decodeLossyString(forKey: .holidayEndTime) businessRange = try container.decodeLossyIntArray(forKey: .businessRange) acceptOrderStatus = try container.decodeLossyInt(forKey: .acceptOrderStatus) ?? 0 - schedule = try container.decodeIfPresent([String: [PhotographerSchedule]].self, forKey: .schedule) ?? [:] + schedule = try container.decodeScheduleMap(forKey: .schedule) virtualPhone = try container.decodeLossyString(forKey: .virtualPhone) } } @@ -612,4 +612,15 @@ private extension KeyedDecodingContainer { } return [] } + + func decodeScheduleMap(forKey key: Key) throws -> [String: [PhotographerSchedule]] { + if let values = try? decodeIfPresent([String: [PhotographerSchedule]].self, forKey: key) { + return values + } + if let values = try? decodeIfPresent([PhotographerSchedule].self, forKey: key) { + return Dictionary(grouping: values) { $0.scheduleDate } + .filter { !$0.key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + return [:] + } } diff --git a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift new file mode 100644 index 0000000..30c478f --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift @@ -0,0 +1,99 @@ +// +// WildPhotographerReportAPI.swift +// suixinkan +// + +import Foundation + +/// 举报摄影师服务协议,封装举报类型等后端接口。 +@MainActor +protocol WildPhotographerReportServing { + /// 拉取提交举报可选类型。 + func reportTypes() async throws -> WildReportTypeListResponse + /// 提交摄影师举报。 + func submitReport(_ request: WildReportSubmitRequest) async throws + /// 拉取我的举报列表。 + func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse +} + +/// 举报摄影师 API,负责和 `photog/report` 相关接口通讯。 +@MainActor +final class WildPhotographerReportAPI: WildPhotographerReportServing { + private let client: APIClient + + /// 初始化举报摄影师 API。 + init(client: APIClient) { + self.client = client + } + + /// 拉取提交举报可选类型。 + func reportTypes() async throws -> WildReportTypeListResponse { + try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/report/types") + ) + } + + /// 提交摄影师举报。 + func submitReport(_ request: WildReportSubmitRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/report/submit", + body: request + ) + ) + } + + /// 拉取我的举报列表。 + func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/report/list", + queryItems: request.queryItems + ) + ) + } +} + +/// 提交举报接口请求体。 +struct WildReportSubmitRequest: Encodable, Equatable { + let scenicId: Int + let reportType: Int + let desc: String + let storeUserId: Int? + let contactPhone: String? + let locationName: String? + let locationAddress: String? + let lat: Double + let lng: Double + let evidences: [WildReportEvidenceRequest] + let paymentScreenshots: [WildReportEvidenceRequest] + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case reportType = "report_type" + case desc + case storeUserId = "store_user_id" + case contactPhone = "contact_phone" + case locationName = "location_name" + case locationAddress = "location_address" + case lat + case lng + case evidences + case paymentScreenshots = "payment_screenshots" + } +} + +/// 提交举报接口中的附件项。 +struct WildReportEvidenceRequest: Encodable, Equatable { + let fileURL: String + let fileType: Int + let fileName: String? + + enum CodingKeys: String, CodingKey { + case fileURL = "file_url" + case fileType = "file_type" + case fileName = "file_name" + } +} diff --git a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift index c32f037..4e3ee11 100644 --- a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift +++ b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift @@ -32,31 +32,81 @@ enum WildReportAttachmentKind: Hashable { case payment } -/// 举报类型,用于提交页单选并写入 Mock 举报记录。 -enum WildReportType: String, CaseIterable, Hashable { - case suspectedWild = "疑似打野" - case noWorkBadge = "未戴工牌" - case other = "其他" +/// 举报类型接口返回的单项数据,由后端动态配置。 +struct WildReportType: Decodable, Hashable { + let value: Int + let label: String - var iconName: String { - switch self { - case .suspectedWild: return "person.crop.circle.badge.exclamationmark" - case .noWorkBadge: return "lanyardcard" - case .other: return "ellipsis.circle" - } + static let defaultSelected = WildReportType(value: 2, label: "疑似打野") + + static let fallbackOptions = [ + WildReportType(value: 1, label: "私下收款"), + WildReportType(value: 2, label: "疑似打野"), + WildReportType(value: 3, label: "未戴工牌"), + WildReportType(value: 4, label: "其他") + ] +} + +/// 举报类型接口返回的列表包裹。 +struct WildReportTypeListResponse: Decodable, Hashable { + let list: [WildReportType] + + init(list: [WildReportType]) { + self.list = list } } -/// 举报附件实体,表示一条演示用的上传项。 +/// 举报附件实体,表示一条本地待上传或演示用的上传项。 struct WildReportAttachment: Hashable { let id: UUID let kind: WildReportAttachmentKind let title: String + let localFileURL: URL? + let fileName: String + let fileType: Int + let uploadedURL: String? - init(id: UUID = UUID(), kind: WildReportAttachmentKind, title: String) { + init( + id: UUID = UUID(), + kind: WildReportAttachmentKind, + title: String, + localFileURL: URL? = nil, + fileName: String? = nil, + uploadedURL: String? = nil + ) { self.id = id self.kind = kind self.title = title + self.localFileURL = localFileURL + self.fileName = fileName ?? "\(title).\(kind.defaultFileExtension)" + self.fileType = kind.reportFileType + self.uploadedURL = uploadedURL + } +} + +extension WildReportAttachment { + /// 读取待上传附件的本地文件数据。 + func uploadData() throws -> Data { + guard let localFileURL else { throw OSSUploadError.emptyFile } + return try Data(contentsOf: localFileURL) + } +} + +extension WildReportAttachmentKind { + /// 提交举报接口的文件类型:1 图片,2 视频。 + var reportFileType: Int { + switch self { + case .image, .payment: return 1 + case .video: return 2 + } + } + + /// 本地附件默认扩展名。 + var defaultFileExtension: String { + switch self { + case .image, .payment: return "jpg" + case .video: return "mp4" + } } } @@ -83,6 +133,29 @@ enum WildReportStatus: String, Hashable, CaseIterable { } } +extension WildReportStatus { + /// 后端举报处理状态码:0 待处理、1 处理中、2 已处理。 + init(handleStatus: Int, fallbackText: String = "") { + switch handleStatus { + case 0: + self = .pending + case 1: + self = .processing + case 2: + self = .processed + default: + switch fallbackText { + case WildReportStatus.processing.rawValue: + self = .processing + case WildReportStatus.processed.rawValue: + self = .processed + default: + self = .pending + } + } + } +} + /// 我的举报列表筛选条件。 enum WildReportListFilter: String, CaseIterable, Hashable { case all = "全部" @@ -91,6 +164,187 @@ enum WildReportListFilter: String, CaseIterable, Hashable { case processed = "已处理" } +extension WildReportListFilter { + /// 后端列表筛选状态码:全部不传,0 待处理、1 处理中、2 已处理。 + var handleStatusValue: Int? { + switch self { + case .all: return nil + case .pending: return 0 + case .processing: return 1 + case .processed: return 2 + } + } +} + +/// 我的举报列表接口请求参数。 +struct WildReportListRequest: Equatable { + let scenicId: Int? + let handleStatus: Int? + let page: Int + let pageSize: Int + + init( + scenicId: Int?, + handleStatus: Int?, + page: Int = 1, + pageSize: Int = 10 + ) { + self.scenicId = scenicId + self.handleStatus = handleStatus + self.page = page + self.pageSize = pageSize + } + + var queryItems: [URLQueryItem] { + var items: [URLQueryItem] = [] + if let scenicId, scenicId > 0 { + items.append(URLQueryItem(name: "scenic_id", value: String(scenicId))) + } + if let handleStatus { + items.append(URLQueryItem(name: "handle_status", value: String(handleStatus))) + } + items.append(URLQueryItem(name: "page", value: String(max(1, page)))) + items.append(URLQueryItem(name: "page_size", value: String(min(max(1, pageSize), 50)))) + return items + } +} + +/// 我的举报列表接口响应体。 +struct WildReportListResponse: Decodable, Equatable { + let list: [WildReportListItem] + let total: Int + let page: Int + let pageSize: Int + + enum CodingKeys: String, CodingKey { + case list + case total + case page + case pageSize = "page_size" + } + + init(list: [WildReportListItem], total: Int, page: Int, pageSize: Int) { + self.list = list + self.total = total + self.page = page + self.pageSize = pageSize + } +} + +/// 我的举报列表接口单条举报数据。 +struct WildReportListItem: Decodable, Equatable, Hashable { + let id: Int + let complaintNo: String + let title: String + let reportType: Int + let reportTypeText: String + let desc: String + let scenicId: Int + let scenicName: String + let locationName: String + let latitude: String + let longitude: String + let handleStatus: Int + let handleStatusText: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case complaintNo = "complaint_no" + case title + case reportType = "report_type" + case reportTypeText = "report_type_text" + case desc + case scenicId = "scenic_id" + case scenicName = "scenic_name" + case locationName = "location_name" + case latitude + case longitude + case handleStatus = "handle_status" + case handleStatusText = "handle_status_text" + case createdAt = "created_at" + } + + init( + id: Int, + complaintNo: String, + title: String, + reportType: Int, + reportTypeText: String, + desc: String, + scenicId: Int, + scenicName: String, + locationName: String, + latitude: String, + longitude: String, + handleStatus: Int, + handleStatusText: String, + createdAt: String + ) { + self.id = id + self.complaintNo = complaintNo + self.title = title + self.reportType = reportType + self.reportTypeText = reportTypeText + self.desc = desc + self.scenicId = scenicId + self.scenicName = scenicName + self.locationName = locationName + self.latitude = latitude + self.longitude = longitude + self.handleStatus = handleStatus + self.handleStatusText = handleStatusText + self.createdAt = createdAt + } + + 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) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + reportType = try container.decodeIfPresent(Int.self, forKey: .reportType) ?? 0 + reportTypeText = try container.decodeIfPresent(String.self, forKey: .reportTypeText) ?? "" + desc = try container.decodeIfPresent(String.self, forKey: .desc) ?? "" + scenicId = try container.decodeIfPresent(Int.self, forKey: .scenicId) ?? 0 + scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName) ?? "" + locationName = try container.decodeIfPresent(String.self, forKey: .locationName) ?? "" + latitude = try container.decodeIfPresent(String.self, forKey: .latitude) ?? "" + longitude = try container.decodeIfPresent(String.self, forKey: .longitude) ?? "" + handleStatus = try container.decodeIfPresent(Int.self, forKey: .handleStatus) ?? 0 + handleStatusText = try container.decodeIfPresent(String.self, forKey: .handleStatusText) ?? "" + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + } + + /// 转换为当前 UIKit 列表和详情页复用的举报记录实体。 + func toRecord() -> WildReportRecord { + let scenic = scenicName.trimmingCharacters(in: .whitespacesAndNewlines) + let location = locationName.trimmingCharacters(in: .whitespacesAndNewlines) + let displayLocation: String + if !scenic.isEmpty, !location.isEmpty, scenic != location { + displayLocation = "\(scenic) · \(location)" + } else if !location.isEmpty { + displayLocation = location + } else { + displayLocation = scenic + } + + return WildReportRecord( + id: complaintNo, + title: title.isEmpty ? "\(reportTypeText)举报" : title, + reportType: WildReportType(value: reportType, label: reportTypeText.isEmpty ? "其他" : reportTypeText), + status: WildReportStatus(handleStatus: handleStatus, fallbackText: handleStatusText), + location: displayLocation, + submitTime: createdAt, + description: desc, + wechatID: "", + phoneNumber: "", + imageCount: 0, + videoCount: 0, + handlerInfo: nil + ) + } +} + /// 补充证据记录实体。 struct WildReportSupplementaryEvidence: Hashable { let id: String diff --git a/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift b/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift index 6be599e..55ca26b 100644 --- a/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift +++ b/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift @@ -56,7 +56,7 @@ enum WildPhotographerReportMockStore { WildReportRecord( id: "JB20260618001", title: "摄影师举报", - reportType: .suspectedWild, + reportType: WildReportType(value: 2, label: "疑似打野"), status: .processing, location: "那拉提景区 · 空中草原入口附近", submitTime: "2026-06-18 10:24", @@ -70,7 +70,7 @@ enum WildPhotographerReportMockStore { WildReportRecord( id: "JB20260617008", title: "摄影师举报", - reportType: .suspectedWild, + reportType: WildReportType(value: 2, label: "疑似打野"), status: .processed, location: "那拉提景区 · 河谷草原游客中心", submitTime: "2026-06-17 16:42", @@ -91,7 +91,7 @@ enum WildPhotographerReportMockStore { WildReportRecord( id: "JB20260616003", title: "摄影师举报", - reportType: .noWorkBadge, + reportType: WildReportType(value: 3, label: "未戴工牌"), status: .pending, location: "那拉提景区 · 盘龙古道停车区", submitTime: "2026-06-16 09:15", @@ -105,7 +105,7 @@ enum WildPhotographerReportMockStore { WildReportRecord( id: "JB20260615002", title: "摄影师举报", - reportType: .other, + reportType: WildReportType(value: 4, label: "其他"), status: .processed, location: "那拉提景区 · 雪莲谷步道", submitTime: "2026-06-15 14:30", diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index f78b8db..d1bbfa9 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -7,6 +7,21 @@ import Foundation import MapKit import UIKit +/// 举报摄影师附件上传协议,便于提交页注入 OSS 服务与单元测试替身。 +@MainActor +protocol WildReportAttachmentUploading { + /// 上传举报附件并返回 OSS 文件 URL。 + func uploadWildReportAttachment( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String +} + +extension OSSUploadService: WildReportAttachmentUploading {} + /// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。 final class WildPhotographerReportHomeViewModel { private(set) var pageState: WildReportPageState = .normal @@ -138,11 +153,15 @@ final class WildPhotographerReportSubmitViewModel { private(set) var images: [WildReportAttachment] private(set) var videos: [WildReportAttachment] private(set) var paymentImages: [WildReportAttachment] = [] - private(set) var selectedReportType: WildReportType = .suspectedWild + private(set) var reportTypes: [WildReportType] = WildReportType.fallbackOptions + private(set) var selectedReportType: WildReportType = .defaultSelected private(set) var description = "" private(set) var contact = "" private(set) var reportLocation = "" + private(set) var isLoadingReportTypes = false private(set) var isUpdatingLocation = false + private(set) var isSubmitting = false + private(set) var submitProgressText = "" private(set) var locationConfirmed = false private(set) var submitResult: WildReportSubmitResult? private(set) var validationMessage: String? @@ -154,6 +173,7 @@ final class WildPhotographerReportSubmitViewModel { private let homeViewModel: WildPhotographerReportHomeViewModel private let locationProvider: any LocationProviding private var hasRequestedInitialLocation = false + private var locationSnapshot: HomeLocationSnapshot? var locationTitleText: String { if reportLocation.isEmpty || isUpdatingLocation { @@ -178,8 +198,8 @@ final class WildPhotographerReportSubmitViewModel { ) { self.homeViewModel = homeViewModel self.locationProvider = locationProvider - self.images = WildPhotographerReportMockStore.initialImages - self.videos = WildPhotographerReportMockStore.initialVideos + self.images = [] + self.videos = [] } /// 首次进入页面时自动请求定位。 @@ -189,6 +209,38 @@ final class WildPhotographerReportSubmitViewModel { refreshCurrentLocation() } + /// 从服务端加载举报类型,失败时回退到本地兜底选项。 + func loadReportTypes(api: any WildPhotographerReportServing) async { + guard !isLoadingReportTypes else { return } + isLoadingReportTypes = true + notifyStateChange() + defer { + isLoadingReportTypes = false + notifyStateChange() + } + + do { + let response = try await api.reportTypes() + let loadedTypes = uniqueReportTypes(response.list) + reportTypes = loadedTypes.isEmpty ? WildReportType.fallbackOptions : loadedTypes + if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) { + selectedReportType = updatedType + } else { + selectedReportType = reportTypes.first ?? .defaultSelected + } + } catch is CancellationError { + return + } catch { + reportTypes = WildReportType.fallbackOptions + if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) { + selectedReportType = updatedType + } else { + selectedReportType = .defaultSelected + } + onShowMessage?("举报类型获取失败,已使用默认类型") + } + } + /// 选择举报类型。 func selectReportType(_ type: WildReportType) { selectedReportType = type @@ -207,26 +259,53 @@ final class WildPhotographerReportSubmitViewModel { notifyStateChange() } + /// 添加本地现场图片。 + func addImages(_ attachments: [WildReportAttachment]) { + let remaining = max(0, 9 - evidenceCount) + guard remaining > 0 else { + onShowMessage?("现场证据最多上传9项") + return + } + images.append(contentsOf: attachments.prefix(remaining)) + notifyStateChange() + } + /// 添加演示用现场图片。 func addImage() { - images.append(WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")) + addImages([WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")]) + } + + /// 添加本地现场视频。 + func addVideos(_ attachments: [WildReportAttachment]) { + let videoRemaining = max(0, 3 - videos.count) + let evidenceRemaining = max(0, 9 - evidenceCount) + let allowedCount = min(videoRemaining, evidenceRemaining) + guard allowedCount > 0 else { + onShowMessage?(videos.count >= 3 ? "现场视频最多上传3段" : "现场证据最多上传9项") + return + } + videos.append(contentsOf: attachments.prefix(allowedCount)) notifyStateChange() } /// 添加演示用现场视频。 func addVideo() { - videos.append(WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")) + addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")]) + } + + /// 添加本地支付截图。 + func addPaymentImages(_ attachments: [WildReportAttachment]) { + guard paymentImages.count < 3 else { + onShowMessage?("支付截图最多上传3张") + return + } + paymentImages.append(contentsOf: attachments.prefix(3 - paymentImages.count)) notifyStateChange() } /// 添加演示用支付截图。 func addPaymentImage() { - guard paymentImages.count < 3 else { - onShowMessage?("支付截图最多上传3张") - return - } - paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")) - notifyStateChange() + addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")]) } /// 删除指定附件。 @@ -248,12 +327,13 @@ final class WildPhotographerReportSubmitViewModel { Task { do { let snapshot = try await locationProvider.requestSnapshot() - let address = snapshot.address.isEmpty - ? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude) - : snapshot.address - applyResolvedLocation(address) + applyResolvedLocation(snapshot) } catch { - applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation) + applyResolvedLocation(HomeLocationSnapshot( + latitude: WildPhotographerReportMockStore.nalatiCenter.latitude, + longitude: WildPhotographerReportMockStore.nalatiCenter.longitude, + address: WildPhotographerReportMockStore.fallbackLocation + )) } } } @@ -264,25 +344,78 @@ final class WildPhotographerReportSubmitViewModel { notifyStateChange() } - /// 校验表单并提交举报。 + /// 校验表单并提交举报到后端。 + func submitReport( + api: any WildPhotographerReportServing, + uploader: any WildReportAttachmentUploading, + scenicId: Int, + scenicName: String + ) async { + validationMessage = nil + guard validateBeforeSubmit(scenicId: scenicId) else { return } + guard !isSubmitting else { return } + + isSubmitting = true + submitProgressText = "正在上传证据" + notifyStateChange() + defer { + isSubmitting = false + submitProgressText = "" + notifyStateChange() + } + + do { + let evidenceRequests = try await uploadAttachments( + images + videos, + uploader: uploader, + scenicId: scenicId, + progressPrefix: "正在上传现场证据" + ) + let paymentRequests = try await uploadAttachments( + paymentImages, + uploader: uploader, + scenicId: scenicId, + progressPrefix: "正在上传支付截图", + completedOffset: evidenceRequests.count, + totalOverride: evidenceRequests.count + paymentImages.count + ) + submitProgressText = "正在提交举报" + notifyStateChange() + + try await api.submitReport(buildSubmitRequest( + scenicId: scenicId, + scenicName: scenicName, + evidences: evidenceRequests, + paymentScreenshots: paymentRequests + )) + + let record = homeViewModel.submitReport( + reportType: selectedReportType, + description: description, + location: reportLocation, + contact: contact.isEmpty ? nil : contact, + imageCount: images.count, + videoCount: videos.count + ) + let result = WildReportSubmitResult( + record: record, + imageCount: images.count, + videoCount: videos.count + ) + submitResult = result + notifyStateChange() + onSubmitSuccess?(result) + } catch is CancellationError { + return + } catch { + setValidationMessage(error.localizedDescription) + } + } + + /// 校验表单并写入本地 Mock 记录。 func submitReport() { validationMessage = nil - guard !images.isEmpty || !videos.isEmpty else { - setValidationMessage("请至少上传一项现场证据。") - return - } - guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - setValidationMessage("请填写举报说明。") - return - } - guard !isUpdatingLocation else { - setValidationMessage("正在获取定位,请稍后再提交。") - return - } - guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else { - setValidationMessage("请先更新举报位置后再提交举报。") - return - } + guard validateBeforeSubmit(scenicId: 1) else { return } let record = homeViewModel.submitReport( reportType: selectedReportType, @@ -304,15 +437,19 @@ final class WildPhotographerReportSubmitViewModel { /// 重置表单到初始状态。 func resetForm() { - images = WildPhotographerReportMockStore.initialImages - videos = WildPhotographerReportMockStore.initialVideos + images = [] + videos = [] paymentImages = [] - selectedReportType = .suspectedWild + reportTypes = WildReportType.fallbackOptions + selectedReportType = .defaultSelected description = "" contact = "" reportLocation = "" locationConfirmed = false isUpdatingLocation = false + isSubmitting = false + submitProgressText = "" + locationSnapshot = nil hasRequestedInitialLocation = false submitResult = nil validationMessage = nil @@ -320,7 +457,23 @@ final class WildPhotographerReportSubmitViewModel { } func applyResolvedLocation(_ location: String) { - reportLocation = location + applyResolvedLocation(HomeLocationSnapshot( + latitude: WildPhotographerReportMockStore.nalatiCenter.latitude, + longitude: WildPhotographerReportMockStore.nalatiCenter.longitude, + address: location + )) + } + + func applyResolvedLocation(_ snapshot: HomeLocationSnapshot) { + let address = snapshot.address.isEmpty + ? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude) + : snapshot.address + locationSnapshot = HomeLocationSnapshot( + latitude: snapshot.latitude, + longitude: snapshot.longitude, + address: address + ) + reportLocation = address locationConfirmed = true isUpdatingLocation = false notifyStateChange() @@ -335,6 +488,110 @@ final class WildPhotographerReportSubmitViewModel { private func notifyStateChange() { onStateChange?() } + + private func uniqueReportTypes(_ types: [WildReportType]) -> [WildReportType] { + var seen: Set = [] + return types.filter { seen.insert($0.value).inserted } + } + + private var evidenceCount: Int { + images.count + videos.count + } + + private func validateBeforeSubmit(scenicId: Int) -> Bool { + guard scenicId > 0 else { + setValidationMessage("请先选择景区后再提交举报。") + return false + } + guard !images.isEmpty || !videos.isEmpty else { + setValidationMessage("请至少上传一项现场证据。") + return false + } + guard evidenceCount <= 9 else { + setValidationMessage("现场证据最多上传9项。") + return false + } + guard paymentImages.count <= 3 else { + setValidationMessage("支付截图最多上传3张。") + return false + } + guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + setValidationMessage("请填写举报说明。") + return false + } + guard !isUpdatingLocation else { + setValidationMessage("正在获取定位,请稍后再提交。") + return false + } + guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else { + setValidationMessage("请先更新举报位置后再提交举报。") + return false + } + return true + } + + private func uploadAttachments( + _ attachments: [WildReportAttachment], + uploader: any WildReportAttachmentUploading, + scenicId: Int, + progressPrefix: String, + completedOffset: Int = 0, + totalOverride: Int? = nil + ) async throws -> [WildReportEvidenceRequest] { + var requests: [WildReportEvidenceRequest] = [] + let total = totalOverride ?? attachments.count + for (index, attachment) in attachments.enumerated() { + let current = completedOffset + index + 1 + submitProgressText = "\(progressPrefix) \(current)/\(max(total, current))" + notifyStateChange() + let data = try attachment.uploadData() + let url = try await uploader.uploadWildReportAttachment( + data: data, + fileName: attachment.fileName, + fileType: attachment.fileType, + scenicId: scenicId + ) { [weak self] progress in + self?.submitProgressText = "\(progressPrefix) \(current)/\(max(total, current)) · \(progress)%" + self?.notifyStateChange() + } + requests.append(WildReportEvidenceRequest( + fileURL: url, + fileType: attachment.fileType, + fileName: attachment.fileName + )) + } + return requests + } + + private func buildSubmitRequest( + scenicId: Int, + scenicName: String, + evidences: [WildReportEvidenceRequest], + paymentScreenshots: [WildReportEvidenceRequest] + ) -> WildReportSubmitRequest { + let locationParts = WildReportLocationParser.split(reportLocation) + let trimmedScenicName = scenicName.trimmingCharacters(in: .whitespacesAndNewlines) + let locationName = !trimmedScenicName.isEmpty ? trimmedScenicName : locationParts.scenic + let snapshot = locationSnapshot ?? HomeLocationSnapshot( + latitude: WildPhotographerReportMockStore.nalatiCenter.latitude, + longitude: WildPhotographerReportMockStore.nalatiCenter.longitude, + address: reportLocation + ) + let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines) + return WildReportSubmitRequest( + scenicId: scenicId, + reportType: selectedReportType.value, + desc: description.trimmingCharacters(in: .whitespacesAndNewlines), + storeUserId: nil, + contactPhone: trimmedContact.isEmpty ? nil : trimmedContact, + locationName: locationName.isEmpty ? nil : locationName, + locationAddress: locationParts.detail.isEmpty ? reportLocation : locationParts.detail, + lat: snapshot.latitude, + lng: snapshot.longitude, + evidences: evidences, + paymentScreenshots: paymentScreenshots + ) + } } /// 举报成功页 ViewModel。 @@ -480,8 +737,17 @@ final class WildPhotographerReportDetailViewModel { /// 我的举报列表 ViewModel。 final class WildPhotographerReportListViewModel { + private static let pageSize = 10 + private(set) var selectedFilter: WildReportListFilter = .all private(set) var shareMessage: String? + private(set) var reports: [WildReportRecord] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var hasMore = true + private(set) var page = 1 + private(set) var total = 0 + private(set) var errorMessage: String? let homeViewModel: WildPhotographerReportHomeViewModel var onStateChange: (() -> Void)? @@ -492,23 +758,63 @@ final class WildPhotographerReportListViewModel { } var filteredReports: [WildReportRecord] { - let reports = homeViewModel.reports - switch selectedFilter { - case .all: return reports - case .pending: return reports.filter { $0.status == .pending } - case .processing: return reports.filter { $0.status == .processing } - case .processed: return reports.filter { $0.status == .processed } - } + reports } var footerTip: String { "温馨提示:为保障处理效率,请如实举报并提供有效证据" } - /// 切换列表筛选标签。 + var footerDisplayText: String { + if isLoadingMore { return "正在加载更多..." } + if isLoading && reports.isEmpty { return "正在加载举报记录..." } + if !hasMore && !reports.isEmpty { return footerTip } + return footerTip + } + + /// 首次加载我的举报列表。 + func loadInitial(api: any WildPhotographerReportServing, scenicId: Int?) async { + await reload(api: api, scenicId: scenicId) + } + + /// 切换列表筛选标签并重新拉取第一页。 + func selectFilter(_ filter: WildReportListFilter, api: any WildPhotographerReportServing, scenicId: Int?) async { + guard selectedFilter != filter else { return } + selectedFilter = filter + await reload(api: api, scenicId: scenicId) + } + + /// 保留本地筛选切换能力,供旧单元测试和 Mock 场景使用。 func selectFilter(_ filter: WildReportListFilter) { selectedFilter = filter + reports = filterLocalReports(homeViewModel.reports) notifyStateChange() } + /// 滚动到底部时加载下一页。 + func loadMoreIfNeeded(api: any WildPhotographerReportServing, scenicId: Int?) async { + guard hasMore, !isLoading, !isLoadingMore else { return } + let nextPage = page + 1 + isLoadingMore = true + errorMessage = nil + notifyStateChange() + defer { + isLoadingMore = false + notifyStateChange() + } + do { + let response = try await api.reportList(makeRequest(page: nextPage, scenicId: scenicId)) + let newRecords = response.list.map { $0.toRecord() } + reports.append(contentsOf: newRecords) + page = response.page + total = response.total + hasMore = reports.count < response.total + } catch is CancellationError { + return + } catch { + errorMessage = error.localizedDescription + onShowMessage?(error.localizedDescription) + } + } + /// 生成分享提示文案。 func shareReport(_ record: WildReportRecord) { shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。" @@ -516,6 +822,55 @@ final class WildPhotographerReportListViewModel { notifyStateChange() } + private func reload(api: any WildPhotographerReportServing, scenicId: Int?) async { + let hadReportsBeforeReload = !reports.isEmpty + isLoading = true + isLoadingMore = false + errorMessage = nil + page = 1 + hasMore = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + let response = try await api.reportList(makeRequest(page: 1, scenicId: scenicId)) + reports = response.list.map { $0.toRecord() } + page = response.page + total = response.total + hasMore = reports.count < response.total + } catch is CancellationError { + return + } catch { + errorMessage = error.localizedDescription + hasMore = false + if !hadReportsBeforeReload { + reports = [] + total = 0 + } + onShowMessage?(error.localizedDescription) + } + } + + private func makeRequest(page: Int, scenicId: Int?) -> WildReportListRequest { + WildReportListRequest( + scenicId: scenicId, + handleStatus: selectedFilter.handleStatusValue, + page: page, + pageSize: Self.pageSize + ) + } + + private func filterLocalReports(_ localReports: [WildReportRecord]) -> [WildReportRecord] { + switch selectedFilter { + case .all: return localReports + case .pending: return localReports.filter { $0.status == .pending } + case .processing: return localReports.filter { $0.status == .processing } + case .processed: return localReports.filter { $0.status == .processed } + } + } + private func notifyStateChange() { onStateChange?() } diff --git a/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md b/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md new file mode 100644 index 0000000..ac30a90 --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md @@ -0,0 +1,290 @@ +# 举报摄影师接口文档 + +本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口,以及仍处于 Mock 阶段的业务点。 + +## 基础约定 + +- 业务域:举报摄影师 +- API 封装:`suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift` +- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI` +- 后端成功码:`100000` +- 响应包裹:工程统一 `APIEnvelope` +- 当前状态:“举报类型”、“提交举报”和“我的举报列表”已接接口;详情、补充证据、风险地图仍使用 Mock 数据闭环。 + +## 1. 获取举报类型 + +### 用途 + +提交举报页面进入时拉取可选举报类型。举报类型由后台动态维护,客户端不能用枚举写死,也不展示图标。 + +### 请求 + +```http +GET /api/yf-handset-app/photog/report/types +``` + +### 请求参数 + +无。 + +### 响应示例 + +```json +{ + "code": 100000, + "msg": "success", + "data": { + "list": [ + { + "value": 1, + "label": "私下收款" + }, + { + "value": 2, + "label": "疑似打野" + }, + { + "value": 3, + "label": "未戴工牌" + }, + { + "value": 4, + "label": "其他" + } + ] + }, + "time": "2026-07-08 14:21:21" +} +``` + +### iOS 模型 + +```swift +struct WildReportType: Decodable, Hashable { + let value: Int + let label: String +} + +struct WildReportTypeListResponse: Decodable, Hashable { + let list: [WildReportType] +} +``` + +### UI 行为 + +- 页面加载时调用 `WildPhotographerReportSubmitViewModel.loadReportTypes(api:)`。 +- 按接口返回顺序渲染举报类型。 +- 每行最多展示 3 个类型,超过 3 个自动换行。 +- 按 `label` 展示文字,不展示图标。 +- 默认选中 `value == 2` 的类型;如果接口返回中不存在该类型,则选中列表第一项。 +- 接口失败时回退本地兜底列表,并提示“举报类型获取失败,已使用默认类型”。 + +### 当前调用位置 + +- `WildPhotographerReportSubmitViewController.viewDidLoad()` +- `WildPhotographerReportSubmitViewModel.loadReportTypes(api:)` +- `WildPhotographerReportAPI.reportTypes()` + +## 2. 提交举报 + +### 用途 + +提交页点击“提交举报”时调用。客户端会先把现场证据和支付截图上传到 OSS,全部上传成功后再提交表单数据。 + +### 请求 + +```http +POST /api/yf-handset-app/photog/report/submit +``` + +### 请求参数 + +```json +{ + "scenic_id": 1, + "report_type": 2, + "desc": "疑似人员在观景台附近主动揽客,并引导游客线下转账。", + "contact_phone": "13800138000", + "location_name": "黄山风景区", + "location_address": "光明顶景区", + "lat": 30.12345, + "lng": 120.12345, + "evidences": [ + { + "file_url": "https://example.com/photog_report/20260708/1/report_image.jpg", + "file_type": 1, + "file_name": "report_image.jpg" + }, + { + "file_url": "https://example.com/photog_report/20260708/1/report_video.mp4", + "file_type": 2, + "file_name": "report_video.mp4" + } + ], + "payment_screenshots": [ + { + "file_url": "https://example.com/photog_report/20260708/1/payment.jpg", + "file_type": 1, + "file_name": "payment.jpg" + } + ] +} +``` + +### 字段说明 + +- `scenic_id`:当前景区 ID,来自 `AppStore.shared.currentScenicId`,必须大于 0。 +- `report_type`:举报类型接口返回的 `value`。 +- `desc`:举报说明,必填,最多 500 字。 +- `store_user_id`:被举报摄影师 ID;当前页面无来源,本轮暂不传。 +- `contact_phone`:当前联系方式输入值,非空才传。 +- `location_name` / `location_address`:从当前定位展示文案拆分;无法拆分时使用景区名和完整地址兜底。 +- `lat` / `lng`:当前定位快照坐标;定位失败时使用 Mock 地址对应的兜底坐标。 +- `evidences`:现场证据,必填,至少 1 项、最多 9 项。 +- `payment_screenshots`:线下微信、支付宝截图,选填,最多 3 张。 +- `file_type`:举报提交接口约定为 `1 = 图片`,`2 = 视频`。 + +### iOS 模型 + +```swift +struct WildReportSubmitRequest: Encodable, Equatable { + let scenicId: Int + let reportType: Int + let desc: String + let storeUserId: Int? + let contactPhone: String? + let locationName: String? + let locationAddress: String? + let lat: Double + let lng: Double + let evidences: [WildReportEvidenceRequest] + let paymentScreenshots: [WildReportEvidenceRequest] +} + +struct WildReportEvidenceRequest: Encodable, Equatable { + let fileURL: String + let fileType: Int + let fileName: String? +} +``` + +### UI 与上传行为 + +- 图片、视频和支付截图使用系统相册选择器。 +- 图片/视频选择后先保存到本地临时目录,点击附件 tile 可预览本地文件。 +- 提交时顺序上传附件到 OSS,按钮展示上传进度并禁用重复提交。 +- 任一 OSS 上传失败时不调用提交接口,并保留用户已填写内容。 +- 支付截图通过 `payment_screenshots` 独立提交,不替代现场证据必填校验。 + +### 当前调用位置 + +- `WildPhotographerReportSubmitViewController.submitTapped()` +- `WildPhotographerReportSubmitViewModel.submitReport(api:uploader:scenicId:scenicName:)` +- `OSSUploadService.uploadWildReportAttachment(...)` +- `WildPhotographerReportAPI.submitReport(_:)` + +## 3. 我的举报列表 + +### 用途 + +“我的举报”页面进入、切换状态筛选、滚动加载更多时调用。 + +### 请求 + +```http +GET /api/yf-handset-app/photog/report/list +``` + +### 请求参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `scenic_id` | integer | 可选,当前景区 ID,大于 0 时传 | +| `handle_status` | integer | 可选,状态筛选;“全部”不传该参数,其他筛选 iOS 固定使用该参数 | +| `status` | integer | 可选,与 `handle_status` 等价;iOS 不传 | +| `page` | integer | 可选,默认 1 | +| `page_size` | integer | 可选,默认 10,最大 50 | + +### 状态码 + +- 全部:不传 `handle_status` +- `0`:待处理 +- `1`:处理中 +- `2`:已处理 + +### 响应示例 + +```json +{ + "code": 100000, + "msg": "success", + "data": { + "list": [ + { + "id": 32, + "complaint_no": "JB20260708917", + "title": "私下收款举报", + "report_type": 1, + "report_type_text": "私下收款", + "desc": "我在", + "scenic_id": 128, + "scenic_name": "伊犁那拉提景区-5A", + "location_name": "伊犁那拉提景区-5A", + "latitude": "32.4299428", + "longitude": "119.4403263", + "handle_status": 0, + "handle_status_text": "待处理", + "created_at": "2026-07-08 16:06:59" + } + ], + "total": 1, + "page": 1, + "page_size": 10 + }, + "time": "2026-07-08 16:07:30" +} +``` + +### iOS 模型 + +```swift +struct WildReportListRequest: Equatable { + let scenicId: Int? + let handleStatus: Int? + let page: Int + let pageSize: Int +} + +struct WildReportListResponse: Decodable, Equatable { + let list: [WildReportListItem] + let total: Int + let page: Int + let pageSize: Int +} +``` + +### UI 行为 + +- 页面首次进入请求 `page=1&page_size=10`,不传 `handle_status`。 +- 切换筛选时重置为第一页,待处理传 `handle_status=0`,处理中传 `handle_status=1`,已处理传 `handle_status=2`。 +- 滚动到底部时,如果仍有更多数据,则请求下一页并追加。 +- 首屏失败显示 toast 和空态;加载更多失败只 toast,并保留已有列表。 +- 列表接口暂不返回媒体数量,`WildReportRecord.imageCount/videoCount` 暂按 0 处理,后续详情接口补齐。 + +### 当前调用位置 + +- `WildPhotographerReportListViewController.viewDidLoad()` +- `WildPhotographerReportListViewModel.loadInitial(api:scenicId:)` +- `WildPhotographerReportListViewModel.loadMoreIfNeeded(api:scenicId:)` +- `WildPhotographerReportAPI.reportList(_:)` + +## Mock 阶段业务 + +以下业务当前仍由 `WildPhotographerReportMockStore` 和 ViewModel 本地逻辑完成,暂未接真实接口: + +- 举报详情 +- 补充证据 +- 附近风险地图 +- 分享、媒体预览、地图导航演示提示 + +后续接入真实接口时,应优先在 `WildPhotographerReportServing` 中补充语义化方法,并保持 ViewModel 不依赖 UIKit 视图类型。 diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index 54cb2f5..e00a42f 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -9,12 +9,17 @@ import UIKit /// 我的举报列表页,支持按状态筛选并进入举报详情。 final class WildPhotographerReportListViewController: BaseViewController { private let viewModel: WildPhotographerReportListViewModel + private let api: any WildPhotographerReportServing private let scrollView = UIScrollView() private let stack = UIStackView() private let filterTabBar = WildReportFilterTabBarView() - init(homeViewModel: WildPhotographerReportHomeViewModel) { + init( + homeViewModel: WildPhotographerReportHomeViewModel, + api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI + ) { self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) + self.api = api super.init(nibName: nil, bundle: nil) } @@ -31,11 +36,19 @@ final class WildPhotographerReportListViewController: BaseViewController { view.backgroundColor = AppColor.pageBackgroundSoft scrollView.showsVerticalScrollIndicator = false scrollView.alwaysBounceVertical = true + scrollView.delegate = self stack.axis = .vertical stack.spacing = 14 filterTabBar.onSelect = { [weak self] filter in - self?.viewModel.selectFilter(filter) + guard let self else { return } + Task { + await self.viewModel.selectFilter( + filter, + api: self.api, + scenicId: self.currentScenicId + ) + } } view.addSubview(scrollView) @@ -56,6 +69,13 @@ final class WildPhotographerReportListViewController: BaseViewController { } } + override func viewDidLoad() { + super.viewDidLoad() + Task { + await viewModel.loadInitial(api: api, scenicId: currentScenicId) + } + } + override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.reloadContent(animated: true) } @@ -93,7 +113,7 @@ final class WildPhotographerReportListViewController: BaseViewController { let reports = viewModel.filteredReports if reports.isEmpty { - stack.addArrangedSubview(WildReportListEmptyView()) + stack.addArrangedSubview(viewModel.isLoading ? WildReportListLoadingView(text: "正在加载举报记录...") : WildReportListEmptyView()) } else { reports.forEach { record in let card = WildReportListCardView(record: record) @@ -103,7 +123,7 @@ final class WildPhotographerReportListViewController: BaseViewController { } } - let footer = WildReportUI.label(viewModel.footerTip, font: .app(.caption), color: UIColor(hex: 0x9CA3AF), lines: 0) + let footer = WildReportUI.label(viewModel.footerDisplayText, font: .app(.caption), color: UIColor(hex: 0x9CA3AF), lines: 0) footer.textAlignment = .center let footerWrap = UIView() footerWrap.addSubview(footer) @@ -120,6 +140,23 @@ final class WildPhotographerReportListViewController: BaseViewController { animated: true ) } + + private var currentScenicId: Int? { + let scenicId = AppStore.shared.currentScenicId + return scenicId > 0 ? scenicId : nil + } +} + +extension WildPhotographerReportListViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let threshold: CGFloat = 80 + let visibleBottom = scrollView.contentOffset.y + scrollView.bounds.height + let triggerY = scrollView.contentSize.height - threshold + guard visibleBottom >= triggerY, scrollView.contentSize.height > scrollView.bounds.height else { return } + Task { + await viewModel.loadMoreIfNeeded(api: api, scenicId: currentScenicId) + } + } } /// 我的举报筛选栏,使用四段等宽胶囊按钮展示状态过滤。 @@ -180,7 +217,7 @@ private final class WildReportFilterTabBarView: UIView { } /// 我的举报列表卡片,展示一条举报记录并提供详情与分享入口。 -private final class WildReportListCardView: UIControl { +private final class WildReportListCardView: UIControl, UIGestureRecognizerDelegate { var onOpen: (() -> Void)? var onShare: (() -> Void)? @@ -215,7 +252,11 @@ private final class WildReportListCardView: UIControl { layer.cornerRadius = 16 layer.borderWidth = 1 layer.borderColor = AppColor.cardOutline.cgColor - addTarget(self, action: #selector(openTapped), for: .touchUpInside) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(openTapped)) + tapRecognizer.delegate = self + tapRecognizer.cancelsTouchesInView = false + addGestureRecognizer(tapRecognizer) let contentStack = UIStackView() contentStack.axis = .vertical @@ -365,6 +406,17 @@ private final class WildReportListCardView: UIControl { @objc private func shareTapped() { onShare?() } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + var currentView: UIView? = touch.view + while let view = currentView { + if view is UIButton { + return false + } + currentView = view.superview + } + return true + } } /// 我的举报列表信息行,展示举报编号、举报时间等左右结构内容。 @@ -429,6 +481,35 @@ private final class WildReportListEmptyView: UIView { } } +/// 我的举报列表加载态卡片。 +private final class WildReportListLoadingView: UIView { + init(text: String) { + super.init(frame: .zero) + backgroundColor = .white + layer.cornerRadius = 16 + + let indicator = UIActivityIndicatorView(style: .medium) + indicator.color = AppColor.primary + indicator.startAnimating() + let label = WildReportUI.label(text, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary) + + let stack = UIStackView(arrangedSubviews: [indicator, label]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 12 + addSubview(stack) + stack.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(48) + make.leading.trailing.equalToSuperview().inset(16) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + /// 举报详情页。 final class WildPhotographerReportDetailViewController: BaseViewController { private let viewModel: WildPhotographerReportDetailViewModel @@ -619,7 +700,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController { private func makeReportInfoCard() -> UIView { let card = WildReportCardView(spacing: 0, inset: 0) card.layer.cornerRadius = 18 - card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报类型", value: viewModel.record.reportType.rawValue)) + card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报类型", value: viewModel.record.reportType.label)) card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报说明", value: viewModel.descriptionText, multiline: true)) card.stack.addArrangedSubview(makeInsetDivider()) diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift index 0c8aff2..bab59b4 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -3,24 +3,37 @@ // suixinkan // +import AVFoundation +import PhotosUI +import QuickLook import SnapKit import UIKit +import UniformTypeIdentifiers /// 提交举报页面,提供类型、证据、说明、定位和联系方式表单。 final class WildPhotographerReportSubmitViewController: BaseViewController { private let viewModel: WildPhotographerReportSubmitViewModel private let homeViewModel: WildPhotographerReportHomeViewModel + private let api: any WildPhotographerReportServing + private let uploader: any WildReportAttachmentUploading private let scrollView = UIScrollView() private let contentStack = UIStackView() private let descriptionView = UITextView() private let contactField = UITextField() private let submitButton = UIButton(type: .system) + private var pickerTarget: WildReportPickerTarget = .image + private var previewURL: URL? + private weak var activeInputView: UIView? init( homeViewModel: WildPhotographerReportHomeViewModel, - locationProvider: any LocationProviding = LocationProvider.shared + locationProvider: any LocationProviding = LocationProvider.shared, + api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, + uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService ) { self.homeViewModel = homeViewModel + self.api = api + self.uploader = uploader self.viewModel = WildPhotographerReportSubmitViewModel( homeViewModel: homeViewModel, locationProvider: locationProvider @@ -33,13 +46,17 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { fatalError("init(coder:) has not been implemented") } + deinit { + NotificationCenter.default.removeObserver(self) + } + override func setupNavigationBar() { title = "提交举报" } override func setupUI() { view.backgroundColor = AppColor.pageBackgroundSoft - scrollView.keyboardDismissMode = .interactive + scrollView.keyboardDismissMode = .onDrag scrollView.showsVerticalScrollIndicator = false contentStack.axis = .vertical contentStack.spacing = AppSpacing.sm @@ -59,6 +76,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { contactField.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor contactField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) contactField.leftViewMode = .always + contactField.delegate = self contactField.addTarget(self, action: #selector(contactChanged), for: .editingChanged) submitButton.setTitle("提交举报", for: .normal) @@ -74,12 +92,18 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { view.addSubview(scrollView) scrollView.addSubview(contentStack) + let dismissTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + dismissTap.cancelsTouchesInView = false + dismissTap.delegate = self + scrollView.addGestureRecognizer(dismissTap) + registerKeyboardNotifications() rebuildContent() } override func setupConstraints() { scrollView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalToSuperview() } contentStack.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 14, left: 18, bottom: 26, right: 18)) @@ -111,6 +135,9 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() viewModel.loadInitialLocationIfNeeded() + Task { + await viewModel.loadReportTypes(api: api) + } } @MainActor @@ -129,9 +156,15 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { contentStack.addArrangedSubview(paymentCard) contentStack.setCustomSpacing(16, after: paymentCard) contentStack.addArrangedSubview(submitButton) + submitButton.setTitle(viewModel.isSubmitting ? viewModel.submitProgressText : "提交举报", for: .normal) + submitButton.isEnabled = !viewModel.isSubmitting + submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1 submitButton.snp.remakeConstraints { make in make.height.equalTo(56) } + if activeInputView?.isFirstResponder == true { + scrollActiveInputIntoView(animated: false) + } } private func makeRealNameBanner() -> UIView { @@ -172,18 +205,38 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { private func makeTypeCard() -> UIView { let card = sectionCard(index: 1, title: "举报类型") - let row = UIStackView() - row.axis = .horizontal - row.spacing = 8 - row.distribution = .fillEqually - WildReportType.allCases.forEach { type in - let button = WildReportTypeOptionButton() - button.apply(type: type, isSelected: type == viewModel.selectedReportType) - button.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside) - button.snp.makeConstraints { make in make.height.equalTo(72) } - row.addArrangedSubview(button) + + if viewModel.isLoadingReportTypes { + let loading = WildReportUI.label("正在同步举报类型...", font: .systemFont(ofSize: 13), color: AppColor.textSecondary) + card.stack.addArrangedSubview(loading) } - card.stack.addArrangedSubview(row) + + let grid = UIStackView() + grid.axis = .vertical + grid.spacing = 8 + + let types = viewModel.reportTypes.isEmpty ? WildReportType.fallbackOptions : viewModel.reportTypes + stride(from: 0, to: types.count, by: 3).forEach { startIndex in + let rowTypes = Array(types[startIndex ..< min(startIndex + 3, types.count)]) + let row = UIStackView() + row.axis = .horizontal + row.spacing = 8 + row.distribution = .fillEqually + + rowTypes.forEach { type in + let button = WildReportTypeOptionButton() + button.apply(type: type, isSelected: type == viewModel.selectedReportType) + button.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside) + button.snp.makeConstraints { make in make.height.equalTo(72) } + row.addArrangedSubview(button) + } + for _ in rowTypes.count..<3 { + row.addArrangedSubview(UIView()) + } + grid.addArrangedSubview(row) + } + + card.stack.addArrangedSubview(grid) return card } @@ -320,61 +373,15 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { } private func makePaymentCard() -> UIView { - let card = sectionCard(index: 6, title: "线下微信/支付宝支付截图", optional: true) - let row = UIView() - row.backgroundColor = UIColor(hex: 0xFBFCFE) - row.layer.cornerRadius = 12 - row.layer.borderWidth = 1 - row.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor - - let iconWrap = UIView() - iconWrap.backgroundColor = AppColor.primaryLight - iconWrap.layer.cornerRadius = 12 - let icon = UIImageView(image: UIImage(systemName: "creditcard.fill")) - icon.tintColor = AppColor.primary - iconWrap.addSubview(icon) - icon.snp.makeConstraints { make in - make.center.equalToSuperview() - make.size.equalTo(24) - } - - let textStack = UIStackView() - textStack.axis = .vertical - textStack.spacing = 4 - textStack.addArrangedSubview(WildReportUI.label("上传线下微信/支付宝支付截图(选填)", font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textPrimary, lines: 0)) - textStack.addArrangedSubview(WildReportUI.label("支持 JPG、PNG 格式,最多 3 张", font: .systemFont(ofSize: 12), color: AppColor.textSecondary)) - - let trailingView: UIView - if let first = viewModel.paymentImages.first { - trailingView = WildReportAttachmentTileView(attachment: first) { [weak self] in - self?.viewModel.removeAttachment(first) - } - } else { - trailingView = WildReportAddAttachmentTileView { [weak self] in - self?.viewModel.addPaymentImage() - } - } - - row.addSubview(iconWrap) - row.addSubview(textStack) - row.addSubview(trailingView) - iconWrap.snp.makeConstraints { make in - make.leading.top.bottom.equalToSuperview().inset(12) - make.size.equalTo(44) - } - trailingView.snp.makeConstraints { make in - make.trailing.equalToSuperview().inset(12) - make.centerY.equalToSuperview() - make.size.equalTo(58) - } - textStack.snp.makeConstraints { make in - make.leading.equalTo(iconWrap.snp.trailing).offset(12) - make.trailing.lessThanOrEqualTo(trailingView.snp.leading).offset(-10) - make.centerY.equalToSuperview() - make.top.greaterThanOrEqualToSuperview().offset(12) - make.bottom.lessThanOrEqualToSuperview().inset(12) - } - card.stack.addArrangedSubview(row) + let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true) + card.stack.addArrangedSubview(makeEvidenceUploadBlock( + icon: "creditcard.fill", + title: "上传支付截图", + subtitle: "支持 JPG、PNG 格式,最多 3 张", + attachments: viewModel.paymentImages, + maxCount: 3, + kind: .payment + )) return card } @@ -428,6 +435,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { block.addArrangedSubview(header) let scroll = UIScrollView() + scroll.keyboardDismissMode = .onDrag scroll.showsHorizontalScrollIndicator = false let tiles = UIStackView() tiles.axis = .horizontal @@ -438,21 +446,16 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { make.height.equalTo(scroll.frameLayoutGuide).offset(-4) } attachments.forEach { attachment in - tiles.addArrangedSubview(WildReportAttachmentTileView(attachment: attachment) { [weak self] in - self?.viewModel.removeAttachment(attachment) - }) + tiles.addArrangedSubview(WildReportAttachmentTileView( + attachment: attachment, + onPreview: { [weak self] in self?.previewAttachment(attachment) }, + onRemove: { [weak self] in self?.viewModel.removeAttachment(attachment) } + )) } - let addCount = max(0, min(3, maxCount - attachments.count)) + let addCount = availableAddTileCount(kind: kind, attachments: attachments, maxCount: maxCount) for _ in 0.. 0 else { + showToast(target.limitMessage) + return + } + pickerTarget = target + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.selectionLimit = remaining + configuration.filter = target == .video ? .videos : .images + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + + private func remainingSelectionCount(for target: WildReportPickerTarget) -> Int { + switch target { + case .image: + return max(0, 9 - viewModel.images.count - viewModel.videos.count) + case .video: + return max(0, min(3 - viewModel.videos.count, 9 - viewModel.images.count - viewModel.videos.count)) + case .payment: + return max(0, 3 - viewModel.paymentImages.count) + } + } + + private func availableAddTileCount( + kind: WildReportAttachmentKind, + attachments: [WildReportAttachment], + maxCount: Int + ) -> Int { + let ownRemaining = max(0, maxCount - attachments.count) + let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count) + switch kind { + case .image: + return min(ownRemaining, evidenceRemaining) > 0 ? 1 : 0 + case .video: + return min(ownRemaining, evidenceRemaining) > 0 ? 1 : 0 + case .payment: + return ownRemaining > 0 ? 1 : 0 + } + } + + private func previewAttachment(_ attachment: WildReportAttachment) { + guard let url = attachment.localFileURL else { + showToast("当前附件暂无本地预览") + return + } + previewURL = url + let preview = QLPreviewController() + preview.dataSource = self + present(preview, animated: true) + } + @objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() } - @objc private func submitTapped() { viewModel.submitReport() } + @objc private func submitTapped() { + Task { + await viewModel.submitReport( + api: api, + uploader: uploader, + scenicId: AppStore.shared.currentScenicId, + scenicName: AppStore.shared.currentScenicName + ) + } + } @objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") } + @objc private func dismissKeyboard() { view.endEditing(true) } + + private func registerKeyboardNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + let keyboardFrameInView = view.convert(keyboardFrame, from: nil) + let overlap = max(0, view.bounds.maxY - keyboardFrameInView.minY) + updateScrollInsets(bottom: overlap + 16, notification: notification) + scrollActiveInputIntoView(animated: true) + } + + @objc private func keyboardWillHide(_ notification: Notification) { + updateScrollInsets(bottom: 0, notification: notification) + } + + private func updateScrollInsets(bottom: CGFloat, notification: Notification) { + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 + let curveRaw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? UIView.AnimationOptions.curveEaseInOut.rawValue + let options = UIView.AnimationOptions(rawValue: curveRaw << 16) + UIView.animate(withDuration: duration, delay: 0, options: options) { + self.scrollView.contentInset.bottom = bottom + self.scrollView.verticalScrollIndicatorInsets.bottom = bottom + } + } + + private func scrollActiveInputIntoView(animated: Bool) { + guard let activeInputView else { return } + view.layoutIfNeeded() + let rect = activeInputView.convert(activeInputView.bounds, to: scrollView).insetBy(dx: 0, dy: -24) + scrollView.scrollRectToVisible(rect, animated: animated) + } +} + +extension WildPhotographerReportSubmitViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard !results.isEmpty else { return } + let target = pickerTarget + Task { + do { + let attachments = try await WildReportLocalMediaLoader.load(results: results, target: target) + await MainActor.run { + switch target { + case .image: + viewModel.addImages(attachments) + case .video: + viewModel.addVideos(attachments) + case .payment: + viewModel.addPaymentImages(attachments) + } + } + } catch { + await MainActor.run { showToast(error.localizedDescription) } + } + } + } +} + +extension WildPhotographerReportSubmitViewController: QLPreviewControllerDataSource { + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + previewURL == nil ? 0 : 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + previewURL! as NSURL + } } extension WildPhotographerReportSubmitViewController: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + activeInputView = textView + DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) } + } + func textViewDidChange(_ textView: UITextView) { viewModel.updateDescription(textView.text) } + + func textViewDidEndEditing(_ textView: UITextView) { + if activeInputView === textView { + activeInputView = nil + } + } +} + +extension WildPhotographerReportSubmitViewController: UITextFieldDelegate { + func textFieldDidBeginEditing(_ textField: UITextField) { + activeInputView = textField + DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) } + } + + func textFieldDidEndEditing(_ textField: UITextField) { + if activeInputView === textField { + activeInputView = nil + } + } +} + +extension WildPhotographerReportSubmitViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + var touchedView = touch.view + while let currentView = touchedView { + if currentView is UIControl || currentView is UITextView || currentView is UITextField { + return false + } + touchedView = currentView.superview + } + return true + } +} + +/// 提交页本地媒体选择目标。 +private enum WildReportPickerTarget: Equatable { + case image + case video + case payment + + init(kind: WildReportAttachmentKind) { + switch kind { + case .image: self = .image + case .video: self = .video + case .payment: self = .payment + } + } + + var attachmentKind: WildReportAttachmentKind { + switch self { + case .image: return .image + case .video: return .video + case .payment: return .payment + } + } + + var titlePrefix: String { + switch self { + case .image: return "现场图片" + case .video: return "现场视频" + case .payment: return "支付截图" + } + } + + var limitMessage: String { + switch self { + case .image: return "现场证据最多上传9项" + case .video: return "现场视频最多上传3段" + case .payment: return "支付截图最多上传3张" + } + } +} + +/// 提交页 PHPicker 结果加载器,将图片/视频保存为可预览和待上传的本地文件。 +private enum WildReportLocalMediaLoader { + static func load(results: [PHPickerResult], target: WildReportPickerTarget) async throws -> [WildReportAttachment] { + var attachments: [WildReportAttachment] = [] + for result in results { + let attachment = target == .video + ? try await loadVideo(from: result.itemProvider, target: target) + : try await loadImage(from: result.itemProvider, target: target) + attachments.append(attachment) + } + return attachments + } + + private static func loadImage(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment { + try await withCheckedThrowingContinuation { continuation in + guard provider.canLoadObject(ofClass: UIImage.self) else { + continuation.resume(throwing: OSSUploadError.unsupportedFileType) + return + } + provider.loadObject(ofClass: UIImage.self) { object, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let image = object as? UIImage, + let data = image.jpegData(compressionQuality: 0.9) else { + continuation.resume(throwing: OSSUploadError.emptyFile) + return + } + do { + let fileName = "report_image_\(UUID().uuidString).jpg" + let url = try write(data: data, fileName: fileName) + continuation.resume(returning: WildReportAttachment( + kind: target.attachmentKind, + title: target.titlePrefix, + localFileURL: url, + fileName: fileName + )) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func loadVideo(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment { + try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let url else { + continuation.resume(throwing: OSSUploadError.emptyFile) + return + } + do { + let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension + let fileName = "report_video_\(UUID().uuidString).\(ext)" + let targetURL = try temporaryFileURL(fileName: fileName) + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + try FileManager.default.copyItem(at: url, to: targetURL) + continuation.resume(returning: WildReportAttachment( + kind: target.attachmentKind, + title: target.titlePrefix, + localFileURL: targetURL, + fileName: fileName + )) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func write(data: Data, fileName: String) throws -> URL { + let url = try temporaryFileURL(fileName: fileName) + try data.write(to: url, options: .atomic) + return url + } + + private static func temporaryFileURL(fileName: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("wild_report_media", isDirectory: true) + if !FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + return directory.appendingPathComponent(fileName) + } } /// 提交页步骤标题,复刻参考页左侧蓝色竖条与选填标识。 @@ -552,7 +864,6 @@ private final class WildReportSectionTitleView: UIView { /// 提交页举报类型三列选择按钮。 private final class WildReportTypeOptionButton: UIControl { - private let iconView = UIImageView() private let titleLabel = UILabel() override init(frame: CGRect) { @@ -560,27 +871,17 @@ private final class WildReportTypeOptionButton: UIControl { layer.cornerRadius = 12 layer.borderWidth = 1 - let stack = UIStackView() - stack.axis = .vertical - stack.alignment = .center - stack.spacing = 6 - stack.isUserInteractionEnabled = false - - iconView.contentMode = .scaleAspectFit - titleLabel.font = .systemFont(ofSize: 13, weight: .bold) + titleLabel.font = .systemFont(ofSize: 15, weight: .bold) titleLabel.textAlignment = .center titleLabel.adjustsFontSizeToFitWidth = true titleLabel.minimumScaleFactor = 0.8 - titleLabel.lineBreakMode = .byClipping + titleLabel.numberOfLines = 2 + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.isUserInteractionEnabled = false - addSubview(stack) - stack.addArrangedSubview(iconView) - stack.addArrangedSubview(titleLabel) - iconView.snp.makeConstraints { make in - make.size.equalTo(22) - } - stack.snp.makeConstraints { make in - make.center.equalToSuperview() + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() make.leading.trailing.equalToSuperview().inset(6) } } @@ -591,21 +892,25 @@ private final class WildReportTypeOptionButton: UIControl { } func apply(type: WildReportType, isSelected: Bool) { - iconView.image = UIImage(systemName: type.iconName) - titleLabel.text = type.rawValue + titleLabel.text = type.label backgroundColor = isSelected ? AppColor.primary : UIColor(hex: 0xF6FAFF) layer.borderColor = (isSelected ? AppColor.primary : UIColor(hex: 0xC8DDFF)).cgColor - iconView.tintColor = isSelected ? .white : AppColor.textPrimary titleLabel.textColor = isSelected ? .white : AppColor.textPrimary } } -/// 提交页附件缩略 tile,使用渐变和删除角标模拟本地 Mock 附件。 +/// 提交页附件缩略 tile,优先展示本地图片或视频封面。 private final class WildReportAttachmentTileView: UIControl { private let gradientLayer = CAGradientLayer() + private let previewAction: () -> Void private let removeAction: () -> Void - init(attachment: WildReportAttachment, onRemove: @escaping () -> Void) { + init( + attachment: WildReportAttachment, + onPreview: @escaping () -> Void, + onRemove: @escaping () -> Void + ) { + self.previewAction = onPreview self.removeAction = onRemove super.init(frame: .zero) layer.cornerRadius = 10 @@ -620,9 +925,22 @@ private final class WildReportAttachmentTileView: UIControl { gradientLayer.endPoint = CGPoint(x: 1, y: 1) layer.insertSublayer(gradientLayer, at: 0) + let thumbnailView = UIImageView() + thumbnailView.image = WildReportAttachmentThumbnailFactory.thumbnail(for: attachment) + thumbnailView.contentMode = .scaleAspectFill + thumbnailView.clipsToBounds = true + thumbnailView.layer.cornerRadius = 10 + thumbnailView.isUserInteractionEnabled = false + thumbnailView.isHidden = thumbnailView.image == nil + addSubview(thumbnailView) + thumbnailView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + let icon = UIImageView(image: UIImage(systemName: attachment.kind == .video ? "play.circle.fill" : "mountain.2.fill")) icon.tintColor = UIColor.white.withAlphaComponent(0.95) icon.contentMode = .scaleAspectFit + icon.isHidden = attachment.kind != .video && thumbnailView.image != nil addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() @@ -631,16 +949,22 @@ private final class WildReportAttachmentTileView: UIControl { let removeButton = UIButton(type: .system) removeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + removeButton.setPreferredSymbolConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold), forImageIn: .normal) removeButton.tintColor = UIColor(hex: 0x8D93A1) removeButton.backgroundColor = .white - removeButton.layer.cornerRadius = 9 + removeButton.layer.cornerRadius = 14 + removeButton.layer.shadowColor = UIColor.black.cgColor + removeButton.layer.shadowOpacity = 0.10 + removeButton.layer.shadowRadius = 4 + removeButton.layer.shadowOffset = CGSize(width: 0, height: 2) removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside) addSubview(removeButton) removeButton.snp.makeConstraints { make in - make.top.trailing.equalToSuperview().inset(-6) - make.size.equalTo(18) + make.top.trailing.equalToSuperview().inset(-2) + make.size.equalTo(34) } + addTarget(self, action: #selector(previewTapped), for: .touchUpInside) snp.makeConstraints { make in make.size.equalTo(58) } @@ -656,11 +980,41 @@ private final class WildReportAttachmentTileView: UIControl { gradientLayer.frame = bounds } + @objc private func previewTapped() { + previewAction() + } + @objc private func removeTapped() { removeAction() } } +/// 提交页附件封面生成器,负责从本地图片或视频文件提取缩略图。 +private enum WildReportAttachmentThumbnailFactory { + static func thumbnail(for attachment: WildReportAttachment) -> UIImage? { + guard let url = attachment.localFileURL else { return nil } + switch attachment.kind { + case .image, .payment: + return UIImage(contentsOfFile: url.path) + case .video: + return videoThumbnail(url: url) + } + } + + private static func videoThumbnail(url: URL) -> UIImage? { + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 160, height: 160) + do { + let cgImage = try generator.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil) + return UIImage(cgImage: cgImage) + } catch { + return nil + } + } +} + /// 提交页添加附件 tile,白底虚线加号。 private final class WildReportAddAttachmentTileView: UIControl { private let tapAction: () -> Void @@ -712,6 +1066,7 @@ private final class WildReportAddAttachmentTileView: UIControl { final class WildPhotographerReportSuccessViewController: BaseViewController { private let viewModel: WildPhotographerReportSuccessViewModel private let homeViewModel: WildPhotographerReportHomeViewModel + private let scrollView = UIScrollView() private let stack = UIStackView() init(result: WildReportSubmitResult, homeViewModel: WildPhotographerReportHomeViewModel) { @@ -730,49 +1085,62 @@ final class WildPhotographerReportSuccessViewController: BaseViewController { } override func setupUI() { - view.backgroundColor = AppColor.pageBackgroundSoft + view.backgroundColor = UIColor(hex: 0xF8FAFF) + scrollView.showsVerticalScrollIndicator = false stack.axis = .vertical - stack.spacing = AppSpacing.sm - view.addSubview(stack) + stack.spacing = 12 + view.addSubview(scrollView) + scrollView.addSubview(stack) - let hero = WildReportCardView(spacing: AppSpacing.md) - hero.stack.alignment = .center - let icon = UIImageView(image: UIImage(systemName: "checkmark.circle.fill")) - icon.tintColor = AppColor.success - icon.snp.makeConstraints { make in make.size.equalTo(58) } - hero.stack.addArrangedSubview(icon) - hero.stack.addArrangedSubview(WildReportUI.label("举报已提交", font: .app(.metric), color: AppColor.textPrimary)) - let desc = WildReportUI.label("景区公安已收到线索,将尽快前往现场核实处理", font: .app(.body), color: AppColor.textSecondary, lines: 0) - desc.textAlignment = .center - hero.stack.addArrangedSubview(desc) - stack.addArrangedSubview(hero) + stack.addArrangedSubview(WildReportSuccessHeroView()) + stack.addArrangedSubview(WildReportSuccessProgressView(steps: viewModel.progressSteps)) - let info = WildReportCardView(spacing: AppSpacing.sm) - info.stack.addArrangedSubview(WildReportInfoRowView(title: "举报编号", value: viewModel.result.record.id)) - info.stack.addArrangedSubview(WildReportInfoRowView(title: "上传材料", value: viewModel.uploadSummary)) - info.stack.addArrangedSubview(WildReportInfoRowView(title: "提交时间", value: viewModel.result.record.submitTime)) - stack.addArrangedSubview(info) - - let progress = WildReportCardView(spacing: AppSpacing.sm) - progress.stack.addArrangedSubview(WildReportUI.label("处理进度", font: .app(.title), color: AppColor.textPrimary)) - viewModel.progressSteps.enumerated().forEach { index, title in - progress.stack.addArrangedSubview(WildReportUI.label("\(index + 1). \(title)", font: .app(.body), color: AppColor.textSecondary)) + let detailCard = WildReportSuccessDetailCardView( + reportID: viewModel.result.record.id, + reportType: viewModel.result.record.reportType.label, + location: viewModel.result.record.location, + uploadSummary: viewModel.uploadSummary + ) + detailCard.onCopyReportID = { [weak self] in + guard let self else { return } + _ = viewModel.copyReportID() + showToast("举报编号已复制") } - stack.addArrangedSubview(progress) + stack.addArrangedSubview(detailCard) - let detailButton = AppButton(title: "查看处理进度") + let detailButton = makeActionButton(title: "查看处理进度", style: .primary) detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside) - let homeButton = AppButton(title: "返回首页", style: .secondary) + let homeButton = makeActionButton(title: "返回首页", style: .secondary) homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside) stack.addArrangedSubview(detailButton) stack.addArrangedSubview(homeButton) } override func setupConstraints() { - stack.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md) - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + scrollView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) } + stack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(14) + make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(18) + make.bottom.equalToSuperview().inset(28) + make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) + } + } + + private func makeActionButton(title: String, style: WildReportSuccessActionButtonStyle) -> UIButton { + let button = UIButton(type: .system) + button.titleLabel?.font = .systemFont(ofSize: 20, weight: .bold) + button.setTitle(title, for: .normal) + button.layer.cornerRadius = 14 + button.layer.borderWidth = style == .secondary ? 1.2 : 0 + button.layer.borderColor = AppColor.primary.cgColor + button.backgroundColor = style == .primary ? AppColor.primary : .white + button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal) + button.snp.makeConstraints { make in + make.height.equalTo(54) + } + return button } @objc private func detailTapped() { @@ -789,3 +1157,482 @@ final class WildPhotographerReportSuccessViewController: BaseViewController { ) } } + +/// 举报成功页底部操作按钮样式。 +private enum WildReportSuccessActionButtonStyle { + case primary + case secondary +} + +/// 举报成功页顶部渐变成功反馈区。 +private final class WildReportSuccessHeroView: UIView { + private let gradientLayer = CAGradientLayer() + private let stack = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + gradientLayer.cornerRadius = 18 + } + + private func setupUI() { + layer.cornerRadius = 18 + layer.borderWidth = 1 + layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor + layer.masksToBounds = true + + gradientLayer.colors = [ + UIColor.white.cgColor, + UIColor(hex: 0xEEF6FF).cgColor + ] + gradientLayer.startPoint = CGPoint(x: 0, y: 0) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + layer.insertSublayer(gradientLayer, at: 0) + + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 14 + + let iconView = WildReportSuccessCheckmarkView() + let titleLabel = WildReportUI.label("举报已提交", font: .systemFont(ofSize: 24, weight: .heavy), color: AppColor.textPrimary) + let descriptionLabel = WildReportUI.label( + "景区公安已收到线索,将尽快前往现场核实处理", + font: .systemFont(ofSize: 14), + color: AppColor.textSecondary, + lines: 0 + ) + descriptionLabel.textAlignment = .center + + addSubview(stack) + stack.addArrangedSubview(iconView) + stack.addArrangedSubview(titleLabel) + stack.addArrangedSubview(descriptionLabel) + + iconView.snp.makeConstraints { make in + make.size.equalTo(78) + } + descriptionLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + } + } + + private func setupConstraints() { + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 24, left: 16, bottom: 24, right: 16)) + } + } +} + +/// 举报成功页圆形对勾图标。 +private final class WildReportSuccessCheckmarkView: UIView { + private let outerCircle = UIView() + private let innerCircle = UIView() + private let iconView = UIImageView(image: UIImage(systemName: "checkmark")) + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + outerCircle.backgroundColor = AppColor.primary.withAlphaComponent(0.12) + outerCircle.layer.cornerRadius = 39 + innerCircle.backgroundColor = AppColor.primary + innerCircle.layer.cornerRadius = 29 + iconView.tintColor = .white + iconView.contentMode = .scaleAspectFit + iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 28, weight: .bold) + + addSubview(outerCircle) + addSubview(innerCircle) + addSubview(iconView) + } + + private func setupConstraints() { + outerCircle.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + innerCircle.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(58) + } + iconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(28) + } + } +} + +/// 举报成功页横向处理进度卡片。 +private final class WildReportSuccessProgressView: UIView { + private let stack = UIStackView() + private let steps: [String] + + init(steps: [String]) { + self.steps = steps + super.init(frame: .zero) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + backgroundColor = .white + layer.cornerRadius = 16 + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + + stack.axis = .horizontal + stack.alignment = .top + stack.spacing = 0 + + addSubview(stack) + for (index, step) in steps.enumerated() { + let item = WildReportSuccessProgressItemView(title: step, isCompleted: index == 0) + stack.addArrangedSubview(item) + item.snp.makeConstraints { make in + make.width.equalTo(74) + } + + if index < steps.count - 1 { + let connector = WildReportSuccessProgressConnectorView(isCompleted: index == 0) + stack.addArrangedSubview(connector) + } + } + } + + private func setupConstraints() { + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12)) + } + } +} + +/// 举报成功页单个进度节点。 +private final class WildReportSuccessProgressItemView: UIView { + private let nodeView: WildReportSuccessProgressNodeView + private let titleLabel = UILabel() + + init(title: String, isCompleted: Bool) { + self.nodeView = WildReportSuccessProgressNodeView(isCompleted: isCompleted) + super.init(frame: .zero) + setupUI(title: title, isCompleted: isCompleted) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI(title: String, isCompleted: Bool) { + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 11, weight: isCompleted ? .semibold : .regular) + titleLabel.textColor = isCompleted ? AppColor.primary : UIColor(hex: 0x9CA3AF) + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 2 + + addSubview(nodeView) + addSubview(titleLabel) + } + + private func setupConstraints() { + nodeView.snp.makeConstraints { make in + make.top.centerX.equalToSuperview() + make.size.equalTo(24) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(nodeView.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview() + } + } +} + +/// 举报成功页进度节点圆点。 +private final class WildReportSuccessProgressNodeView: UIView { + private let innerCircle = UIView() + private let checkmarkView = UIImageView(image: UIImage(systemName: "checkmark")) + private let isCompleted: Bool + + init(isCompleted: Bool) { + self.isCompleted = isCompleted + super.init(frame: .zero) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + layer.cornerRadius = 12 + layer.borderWidth = 2 + layer.borderColor = (isCompleted ? AppColor.primary : UIColor(hex: 0xD1D5DB)).cgColor + + innerCircle.backgroundColor = isCompleted ? AppColor.primary : .clear + innerCircle.layer.cornerRadius = 10 + checkmarkView.tintColor = .white + checkmarkView.contentMode = .scaleAspectFit + checkmarkView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 10, weight: .bold) + checkmarkView.isHidden = !isCompleted + + addSubview(innerCircle) + addSubview(checkmarkView) + } + + private func setupConstraints() { + innerCircle.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(20) + } + checkmarkView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(10) + } + } +} + +/// 举报成功页进度节点连接线。 +private final class WildReportSuccessProgressConnectorView: UIView { + private let isCompleted: Bool + private let solidLine = UIView() + private let dotsStack = UIStackView() + + init(isCompleted: Bool) { + self.isCompleted = isCompleted + super.init(frame: .zero) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + if isCompleted { + solidLine.backgroundColor = AppColor.primary + addSubview(solidLine) + } else { + dotsStack.axis = .horizontal + dotsStack.alignment = .center + dotsStack.distribution = .equalSpacing + dotsStack.spacing = 3 + addSubview(dotsStack) + for _ in 0..<5 { + let dot = UIView() + dot.backgroundColor = UIColor(hex: 0xD1D5DB) + dot.layer.cornerRadius = 1.5 + dot.snp.makeConstraints { make in + make.size.equalTo(3) + } + dotsStack.addArrangedSubview(dot) + } + } + } + + private func setupConstraints() { + snp.makeConstraints { make in + make.height.equalTo(24) + } + if isCompleted { + solidLine.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.centerY.equalToSuperview() + make.height.equalTo(2) + } + } else { + dotsStack.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.trailing.equalToSuperview() + } + } + } +} + +/// 举报成功页详情信息卡。 +private final class WildReportSuccessDetailCardView: UIView { + var onCopyReportID: (() -> Void)? + private let stack = UIStackView() + + init(reportID: String, reportType: String, location: String, uploadSummary: String) { + super.init(frame: .zero) + setupUI(reportID: reportID, reportType: reportType, location: location, uploadSummary: uploadSummary) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI(reportID: String, reportType: String, location: String, uploadSummary: String) { + backgroundColor = .white + layer.cornerRadius = 16 + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + + stack.axis = .vertical + stack.spacing = 0 + addSubview(stack) + + appendRow(icon: "doc.text.fill", title: "举报编号", value: reportID, valueColor: AppColor.primary, showsCopy: true) + appendDivider() + appendRow(icon: "tag.fill", title: "举报类型", value: reportType) + appendDivider() + appendRow(icon: "mappin.circle.fill", title: "举报位置", value: location) + appendDivider() + appendRow(icon: "photo.on.rectangle.angled", title: "已上传", value: uploadSummary) + } + + private func setupConstraints() { + stack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func appendRow(icon: String, title: String, value: String, valueColor: UIColor = AppColor.textPrimary, showsCopy: Bool = false) { + let row = WildReportSuccessDetailRowView( + icon: icon, + title: title, + value: value, + valueColor: valueColor, + showsCopy: showsCopy + ) + row.onCopy = { [weak self] in self?.onCopyReportID?() } + stack.addArrangedSubview(row) + } + + private func appendDivider() { + let container = UIView() + let divider = UIView() + divider.backgroundColor = AppColor.border + container.addSubview(divider) + divider.snp.makeConstraints { make in + make.top.bottom.trailing.equalToSuperview() + make.leading.equalToSuperview().offset(56) + make.height.equalTo(1) + } + stack.addArrangedSubview(container) + } +} + +/// 举报成功页详情信息行。 +private final class WildReportSuccessDetailRowView: UIView { + var onCopy: (() -> Void)? + private let iconContainer = UIView() + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let valueLabel = UILabel() + private let copyButton = UIButton(type: .system) + private let showsCopy: Bool + + init(icon: String, title: String, value: String, valueColor: UIColor, showsCopy: Bool) { + self.showsCopy = showsCopy + super.init(frame: .zero) + setupUI(icon: icon, title: title, value: value, valueColor: valueColor) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI(icon: String, title: String, value: String, valueColor: UIColor) { + iconContainer.backgroundColor = AppColor.primaryLight + iconContainer.layer.cornerRadius = 10 + iconView.image = UIImage(systemName: icon) + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .semibold) + + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 13) + titleLabel.textColor = AppColor.textSecondary + + valueLabel.text = value + valueLabel.font = .systemFont(ofSize: 16, weight: .semibold) + valueLabel.textColor = valueColor + valueLabel.numberOfLines = 2 + valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + addSubview(iconContainer) + iconContainer.addSubview(iconView) + addSubview(titleLabel) + addSubview(valueLabel) + + guard showsCopy else { return } + copyButton.setImage(UIImage(systemName: "doc.on.doc"), for: .normal) + copyButton.tintColor = AppColor.primary + copyButton.backgroundColor = AppColor.primaryLight + copyButton.layer.cornerRadius = 17 + copyButton.accessibilityLabel = "复制举报编号" + copyButton.addTarget(self, action: #selector(copyTapped), for: .touchUpInside) + addSubview(copyButton) + } + + private func setupConstraints() { + iconContainer.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + make.size.equalTo(42) + make.top.greaterThanOrEqualToSuperview().offset(14) + make.bottom.lessThanOrEqualToSuperview().inset(14) + } + iconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(22) + } + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(14) + make.leading.equalTo(iconContainer.snp.trailing).offset(14) + make.trailing.lessThanOrEqualToSuperview().inset(16) + } + valueLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.equalTo(titleLabel) + make.bottom.equalToSuperview().inset(14) + if showsCopy { + make.trailing.lessThanOrEqualTo(copyButton.snp.leading).offset(-8) + } else { + make.trailing.equalToSuperview().inset(16) + } + } + + guard showsCopy else { return } + copyButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.centerY.equalToSuperview() + make.size.equalTo(34) + } + } + + @objc private func copyTapped() { + onCopy?() + } +} diff --git a/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift index 1ab2bb3..7940e6d 100644 --- a/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift +++ b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift @@ -83,6 +83,48 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase { XCTAssertEqual(response.schedule["2026-07-08"]?.first?.userPhone, "13800000000") } + func testProfileInfoEnvelopeDecodeSupportsEmptyScheduleArray() throws { + let json = """ + { + "code": 100000, + "data": { + "accept_order_status": 1, + "attr_label": [], + "avatar": "https://vipsky.oss-cn-shanghai.aliyuncs.com/avatar/20260707/128/E7036CC996884E8A963A3962A9E72850_avatar_1783416288.jpg", + "business_end_time": "", + "business_start_time": "", + "camera_device": [], + "description": "", + "holiday_end_time": "", + "holiday_start_time": "", + "id": 732, + "nickname": "陆久银12", + "real_name": "陆久银", + "scenic_certification": [ + "伊犁那拉提景区-5A" + ], + "schedule": [], + "shoot_label": [] + }, + "msg": "success", + "time": "2026-07-08 16:16:18" + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: json) + let response = try XCTUnwrap(envelope.data) + + XCTAssertEqual(response.id, 732) + XCTAssertEqual(response.scenicId, 0) + XCTAssertEqual(response.photogUid, 0) + XCTAssertEqual(response.realName, "陆久银") + XCTAssertEqual(response.nickname, "陆久银12") + XCTAssertEqual(response.scenicCertification, ["伊犁那拉提景区-5A"]) + XCTAssertEqual(response.acceptOrderStatus, 1) + XCTAssertEqual(response.schedule, [:]) + XCTAssertEqual(response.virtualPhone, "") + } + func testLabelValidationCoversEmptyTooLongDuplicateAndLimit() { let viewModel = makeViewModel() var messages: [String] = [] diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index 68bd47c..b11b387 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -6,7 +6,8 @@ import XCTest @testable import suixinkan -/// 举报摄影师模块测试,覆盖 Mock 数据、提交校验、列表筛选和补充证据。 +@MainActor +/// 举报摄影师模块测试,覆盖 Mock 数据、接口类型、提交校验、列表筛选和补充证据。 final class WildPhotographerReportTests: XCTestCase { func testSeedDataLoadsReportsAndRiskPoints() { let viewModel = WildPhotographerReportHomeViewModel() @@ -21,7 +22,7 @@ final class WildPhotographerReportTests: XCTestCase { let beforeCount = viewModel.reports.count let record = viewModel.submitReport( - reportType: .suspectedWild, + reportType: WildReportType(value: 2, label: "疑似打野"), description: "测试举报说明", location: "测试景区 · 测试点位", contact: "13800138000", @@ -36,11 +37,154 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(record.phoneNumber, "13800138000") } + func testReportTypesAPILoadsRemoteOptions() async throws { + let json = Data(""" + { + "code": 100000, + "msg": "success", + "data": { + "list": [ + { "value": 1, "label": "私下收款" }, + { "value": 2, "label": "疑似打野" }, + { "value": 3, "label": "未戴工牌" }, + { "value": 4, "label": "其他" } + ] + }, + "time": "2026-07-08 14:21:21" + } + """.utf8) + let session = MockURLSession(responses: [json]) + let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.reportTypes() + + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/types") + XCTAssertEqual(response.list, [ + WildReportType(value: 1, label: "私下收款"), + WildReportType(value: 2, label: "疑似打野"), + WildReportType(value: 3, label: "未戴工牌"), + WildReportType(value: 4, label: "其他") + ]) + } + + func testReportListAPILoadsRemoteReports() async throws { + let json = Data(""" + { + "code": 100000, + "msg": "success", + "data": { + "list": [ + { + "id": 32, + "complaint_no": "JB20260708917", + "title": "私下收款举报", + "report_type": 1, + "report_type_text": "私下收款", + "desc": "我在", + "scenic_id": 128, + "scenic_name": "伊犁那拉提景区-5A", + "location_name": "伊犁那拉提景区-5A", + "latitude": "32.4299428", + "longitude": "119.4403263", + "handle_status": 0, + "handle_status_text": "待处理", + "created_at": "2026-07-08 16:06:59" + } + ], + "total": 1, + "page": 1, + "page_size": 10 + }, + "time": "2026-07-08 16:07:30" + } + """.utf8) + let session = MockURLSession(responses: [json]) + let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.reportList(WildReportListRequest(scenicId: 128, handleStatus: 0, page: 1, pageSize: 10)) + + let components = URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false) + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/list") + XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "scenic_id", value: "128")) == true) + XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "handle_status", value: "0")) == true) + XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "page", value: "1")) == true) + XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "page_size", value: "10")) == true) + XCTAssertEqual(response.total, 1) + XCTAssertEqual(response.page, 1) + XCTAssertEqual(response.pageSize, 10) + XCTAssertEqual(response.list.first?.complaintNo, "JB20260708917") + } + + func testReportListItemMapsToRecord() { + let item = makeReportListItem( + complaintNo: "JB20260708917", + title: "私下收款举报", + reportType: 1, + reportTypeText: "私下收款", + handleStatus: 0, + handleStatusText: "待处理" + ) + + let record = item.toRecord() + + XCTAssertEqual(record.id, "JB20260708917") + XCTAssertEqual(record.title, "私下收款举报") + XCTAssertEqual(record.reportType, WildReportType(value: 1, label: "私下收款")) + XCTAssertEqual(record.status, .pending) + XCTAssertEqual(record.location, "伊犁那拉提景区-5A") + XCTAssertEqual(record.description, "测试说明") + XCTAssertEqual(record.submitTime, "2026-07-08 16:06:59") + XCTAssertEqual(record.imageCount, 0) + XCTAssertEqual(record.videoCount, 0) + } + + func testReportListAllFilterOmitsHandleStatus() { + let request = WildReportListRequest(scenicId: 128, handleStatus: nil, page: 1, pageSize: 10) + + XCTAssertFalse(request.queryItems.contains { $0.name == "handle_status" }) + XCTAssertTrue(request.queryItems.contains(URLQueryItem(name: "scenic_id", value: "128"))) + XCTAssertTrue(request.queryItems.contains(URLQueryItem(name: "page", value: "1"))) + XCTAssertTrue(request.queryItems.contains(URLQueryItem(name: "page_size", value: "10"))) + } + + func testSubmitViewModelLoadsReportTypesFromAPI() async { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + api.reportTypeResponse = WildReportTypeListResponse(list: [ + WildReportType(value: 1, label: "私下收款"), + WildReportType(value: 2, label: "疑似打野"), + WildReportType(value: 4, label: "其他"), + ]) + + await viewModel.loadReportTypes(api: api) + + XCTAssertEqual(viewModel.reportTypes, [ + WildReportType(value: 1, label: "私下收款"), + WildReportType(value: 2, label: "疑似打野"), + WildReportType(value: 4, label: "其他") + ]) + XCTAssertEqual(viewModel.selectedReportType, .defaultSelected) + } + + func testSubmitViewModelFallsBackWhenReportTypesFail() async { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + api.error = URLError(.badServerResponse) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.loadReportTypes(api: api) + + XCTAssertEqual(viewModel.reportTypes, WildReportType.fallbackOptions) + XCTAssertEqual(viewModel.selectedReportType, .defaultSelected) + XCTAssertEqual(messages.last, "举报类型获取失败,已使用默认类型") + } + func testSubmitFailsWithoutEvidence() { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) - viewModel.removeAttachment(viewModel.images[0]) - viewModel.removeAttachment(viewModel.videos[0]) viewModel.updateDescription("有说明") viewModel.applyResolvedLocation("测试位置") @@ -53,6 +197,7 @@ final class WildPhotographerReportTests: XCTestCase { func testSubmitFailsWithoutDescription() { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + viewModel.addImage() viewModel.updateDescription(" ") viewModel.applyResolvedLocation("测试位置") @@ -65,6 +210,7 @@ final class WildPhotographerReportTests: XCTestCase { func testSubmitFailsWithoutConfirmedLocation() { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + viewModel.addImage() viewModel.updateDescription("测试说明") viewModel.submitReport() @@ -73,17 +219,77 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertNil(viewModel.submitResult) } - func testSubmitSuccessCreatesRecord() { + func testSubmitSuccessCreatesRecord() async throws { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + let uploader = MockWildReportAttachmentUploader() + viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")]) + viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")]) viewModel.updateDescription("测试说明") - viewModel.applyResolvedLocation("测试景区 · 测试点") + viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: "测试景区 · 测试点")) - viewModel.submitReport() + await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "测试景区") XCTAssertNil(viewModel.validationMessage) XCTAssertNotNil(viewModel.submitResult) XCTAssertEqual(homeViewModel.reports.first?.id, viewModel.submitResult?.record.id) + XCTAssertEqual(uploader.uploadedFileNames, ["scene.jpg", "payment.jpg"]) + XCTAssertEqual(api.submitRequests.first?.scenicId, 9) + XCTAssertEqual(api.submitRequests.first?.reportType, 2) + XCTAssertEqual(api.submitRequests.first?.lat, 30.12) + XCTAssertEqual(api.submitRequests.first?.lng, 120.34) + XCTAssertEqual(api.submitRequests.first?.evidences.count, 1) + XCTAssertEqual(api.submitRequests.first?.evidences.first?.fileType, 1) + XCTAssertEqual(api.submitRequests.first?.paymentScreenshots.count, 1) + XCTAssertNil(api.submitRequests.first?.storeUserId) + } + + func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")]) + viewModel.updateDescription("测试说明") + viewModel.applyResolvedLocation("测试景区 · 测试点") + + await viewModel.submitReport( + api: MockWildPhotographerReportAPI(), + uploader: MockWildReportAttachmentUploader(), + scenicId: 9, + scenicName: "测试景区" + ) + + XCTAssertEqual(viewModel.validationMessage, "请至少上传一项现场证据。") + } + + func testPaymentScreenshotsLimitedToThree() throws { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + + viewModel.addPaymentImages([ + try makeAttachment(kind: .payment, fileName: "p1.jpg"), + try makeAttachment(kind: .payment, fileName: "p2.jpg"), + try makeAttachment(kind: .payment, fileName: "p3.jpg"), + try makeAttachment(kind: .payment, fileName: "p4.jpg") + ]) + + XCTAssertEqual(viewModel.paymentImages.count, 3) + } + + func testUploadFailureSkipsSubmitAPI() async throws { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + let uploader = MockWildReportAttachmentUploader() + uploader.error = OSSUploadError.emptyFile + viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")]) + viewModel.updateDescription("测试说明") + viewModel.applyResolvedLocation("测试景区 · 测试点") + + await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "测试景区") + + XCTAssertEqual(api.submitRequests.count, 0) + XCTAssertEqual(viewModel.validationMessage, OSSUploadError.emptyFile.localizedDescription) } func testListFilterReturnsMatchingStatus() { @@ -103,6 +309,81 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(viewModel.filteredReports.count, homeViewModel.reports.count) } + func testListInitialLoadUsesAllStatusAndFirstPage() async { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + api.reportListResponses = [ + WildReportListResponse( + list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], + total: 1, + page: 1, + pageSize: 10 + ) + ] + + await viewModel.loadInitial(api: api, scenicId: 128) + + XCTAssertEqual(api.reportListRequests.first, WildReportListRequest(scenicId: 128, handleStatus: nil, page: 1, pageSize: 10)) + XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB001"]) + XCTAssertFalse(viewModel.hasMore) + } + + func testListFilterReloadsWithMappedStatus() async { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + api.reportListResponses = [ + WildReportListResponse(list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], total: 1, page: 1, pageSize: 10), + WildReportListResponse(list: [makeReportListItem(complaintNo: "JB002", handleStatus: 1)], total: 1, page: 1, pageSize: 10), + WildReportListResponse(list: [makeReportListItem(complaintNo: "JB003", handleStatus: 2)], total: 1, page: 1, pageSize: 10), + ] + + await viewModel.selectFilter(.pending, api: api, scenicId: 128) + await viewModel.selectFilter(.processing, api: api, scenicId: 128) + await viewModel.selectFilter(.processed, api: api, scenicId: 128) + + XCTAssertEqual(api.reportListRequests.map(\.handleStatus), [0, 1, 2] as [Int?]) + XCTAssertEqual(api.reportListRequests.map(\.page), [1, 1, 1]) + XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB003"]) + } + + func testListLoadMoreAppendsReportsAndUpdatesHasMore() async { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + api.reportListResponses = [ + WildReportListResponse(list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], total: 2, page: 1, pageSize: 10), + WildReportListResponse(list: [makeReportListItem(complaintNo: "JB002", handleStatus: 0)], total: 2, page: 2, pageSize: 10), + ] + + await viewModel.loadInitial(api: api, scenicId: 128) + XCTAssertTrue(viewModel.hasMore) + await viewModel.loadMoreIfNeeded(api: api, scenicId: 128) + + XCTAssertEqual(api.reportListRequests.map(\.page), [1, 2]) + XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB001", "JB002"]) + XCTAssertFalse(viewModel.hasMore) + } + + func testListLoadFailureKeepsExistingReports() async { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + api.reportListResponses = [ + WildReportListResponse(list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], total: 2, page: 1, pageSize: 10) + ] + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.loadInitial(api: api, scenicId: 128) + api.reportListError = URLError(.badServerResponse) + await viewModel.loadMoreIfNeeded(api: api, scenicId: 128) + + XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB001"]) + XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription) + } + func testSupplementEvidenceValidationAndSuccess() { let homeViewModel = WildPhotographerReportHomeViewModel() let reportID = homeViewModel.reports[0].id @@ -120,3 +401,88 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.imageCount, 1) } } + +@MainActor +private final class MockWildPhotographerReportAPI: WildPhotographerReportServing { + var reportTypeResponse = WildReportTypeListResponse(list: []) + var error: Error? + var submitRequests: [WildReportSubmitRequest] = [] + var reportListResponses: [WildReportListResponse] = [] + var reportListRequests: [WildReportListRequest] = [] + var reportListError: Error? + + func reportTypes() async throws -> WildReportTypeListResponse { + if let error { throw error } + return reportTypeResponse + } + + func submitReport(_ request: WildReportSubmitRequest) async throws { + if let error { throw error } + submitRequests.append(request) + } + + func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse { + if let reportListError { throw reportListError } + reportListRequests.append(request) + guard !reportListResponses.isEmpty else { + return WildReportListResponse(list: [], total: 0, page: request.page, pageSize: request.pageSize) + } + return reportListResponses.removeFirst() + } +} + +@MainActor +private final class MockWildReportAttachmentUploader: WildReportAttachmentUploading { + var uploadedFileNames: [String] = [] + var error: Error? + + func uploadWildReportAttachment( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + if let error { throw error } + onProgress(100) + uploadedFileNames.append(fileName) + return "https://oss.example.com/\(fileName)" + } +} + +private func makeAttachment(kind: WildReportAttachmentKind, fileName: String) throws -> WildReportAttachment { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + fileName) + try Data("mock".utf8).write(to: url) + return WildReportAttachment( + kind: kind, + title: fileName, + localFileURL: url, + fileName: fileName + ) +} + +private func makeReportListItem( + complaintNo: String, + title: String = "疑似打野摄影师举报", + reportType: Int = 2, + reportTypeText: String = "疑似打野", + handleStatus: Int, + handleStatusText: String = "待处理" +) -> WildReportListItem { + WildReportListItem( + id: Int(complaintNo.filter(\.isNumber)) ?? 1, + complaintNo: complaintNo, + title: title, + reportType: reportType, + reportTypeText: reportTypeText, + desc: "测试说明", + scenicId: 128, + scenicName: "伊犁那拉提景区-5A", + locationName: "伊犁那拉提景区-5A", + latitude: "32.4299428", + longitude: "119.4403263", + handleStatus: handleStatus, + handleStatusText: handleStatusText, + createdAt: "2026-07-08 16:06:59" + ) +}