From fd5dc2bbe6dd8dbbd516988303134555f837c412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 8 Jul 2026 17:23:28 +0800 Subject: [PATCH] Implement report supplement evidence flow --- .../API/WildPhotographerReportAPI.swift | 33 ++ .../Models/WildPhotographerReportModels.swift | 366 +++++++++++++- .../WildPhotographerReportViewModels.swift | 255 ++++++++-- .../WildPhotographerReport/举报摄影师接口.md | 96 +++- .../Views/WildReportCommonViews.swift | 319 ++++++++++++ ...PhotographerReportListViewController.swift | 107 +++- ...otographerReportSubmitViewController.swift | 296 ------------ ...portSupplementEvidenceViewController.swift | 457 +++++++++++++++--- .../WildPhotographerReportTests.swift | 398 ++++++++++++++- 9 files changed, 1906 insertions(+), 421 deletions(-) diff --git a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift index 30c478f..6e465da 100644 --- a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift +++ b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift @@ -12,8 +12,12 @@ protocol WildPhotographerReportServing { func reportTypes() async throws -> WildReportTypeListResponse /// 提交摄影师举报。 func submitReport(_ request: WildReportSubmitRequest) async throws + /// 为已有举报补充证据。 + func supplementReport(_ request: WildReportSupplementRequest) async throws /// 拉取我的举报列表。 func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse + /// 拉取单条举报详情。 + func reportDetail(id: Int) async throws -> WildReportDetailData } /// 举报摄影师 API,负责和 `photog/report` 相关接口通讯。 @@ -44,6 +48,17 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing { ) } + /// 为已有举报补充证据。 + func supplementReport(_ request: WildReportSupplementRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/report/supplement", + body: request + ) + ) + } + /// 拉取我的举报列表。 func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse { try await client.send( @@ -54,6 +69,24 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing { ) ) } + + /// 拉取单条举报详情。 + func reportDetail(id: Int) async throws -> WildReportDetailData { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/report/detail", + queryItems: [URLQueryItem(name: "id", value: String(id))] + ) + ) + } +} + +/// 补充证据接口请求体。 +struct WildReportSupplementRequest: Encodable, Equatable { + let id: Int + let desc: String? + let evidences: [WildReportEvidenceRequest]? } /// 提交举报接口请求体。 diff --git a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift index 4e3ee11..84239a0 100644 --- a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift +++ b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift @@ -115,12 +115,14 @@ enum WildReportStatus: String, Hashable, CaseIterable { case pending = "待处理" case processing = "处理中" case processed = "已处理" + case rejected = "驳回" var displayColor: UIColor { switch self { case .pending: return AppColor.warning case .processing: return AppColor.primary case .processed: return AppColor.success + case .rejected: return AppColor.danger } } @@ -129,12 +131,13 @@ enum WildReportStatus: String, Hashable, CaseIterable { case .pending: return AppColor.warningBackground case .processing: return AppColor.infoBackground case .processed: return AppColor.successBackground + case .rejected: return AppColor.dangerBackground } } } extension WildReportStatus { - /// 后端举报处理状态码:0 待处理、1 处理中、2 已处理。 + /// 后端举报处理状态码:0 待处理、1 处理中、2 已处理、3 驳回。 init(handleStatus: Int, fallbackText: String = "") { switch handleStatus { case 0: @@ -143,12 +146,16 @@ extension WildReportStatus { self = .processing case 2: self = .processed + case 3: + self = .rejected default: switch fallbackText { case WildReportStatus.processing.rawValue: self = .processing case WildReportStatus.processed.rawValue: self = .processed + case WildReportStatus.rejected.rawValue: + self = .rejected default: self = .pending } @@ -329,6 +336,7 @@ struct WildReportListItem: Decodable, Equatable, Hashable { } return WildReportRecord( + serverID: id > 0 ? id : nil, id: complaintNo, title: title.isEmpty ? "\(reportTypeText)举报" : title, reportType: WildReportType(value: reportType, label: reportTypeText.isEmpty ? "其他" : reportTypeText), @@ -345,6 +353,325 @@ struct WildReportListItem: Decodable, Equatable, Hashable { } } +/// 举报详情接口返回的证据统计。 +struct WildReportEvidenceSummary: Decodable, Equatable, Hashable { + let imageCount: Int + let videoCount: Int + + enum CodingKeys: String, CodingKey { + case imageCount = "image_count" + case videoCount = "video_count" + } + + init(imageCount: Int = 0, videoCount: Int = 0) { + self.imageCount = imageCount + self.videoCount = videoCount + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + imageCount = try container.decodeIfPresent(Int.self, forKey: .imageCount) ?? 0 + videoCount = try container.decodeIfPresent(Int.self, forKey: .videoCount) ?? 0 + } +} + +/// 举报详情接口返回的单个附件。 +struct WildReportDetailEvidence: Decodable, Equatable, Hashable { + let fileType: Int + let fileTypeText: String + let fileURL: String + let fileName: String + + enum CodingKeys: String, CodingKey { + case fileType = "file_type" + case fileTypeText = "file_type_text" + case fileURL = "file_url" + case fileName = "file_name" + } + + init(fileType: Int = 0, fileTypeText: String = "", fileURL: String = "", fileName: String = "") { + self.fileType = fileType + self.fileTypeText = fileTypeText + self.fileURL = fileURL + self.fileName = fileName + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + fileType = try container.decodeIfPresent(Int.self, forKey: .fileType) ?? 0 + fileTypeText = try container.decodeIfPresent(String.self, forKey: .fileTypeText) ?? "" + fileURL = try container.decodeIfPresent(String.self, forKey: .fileURL) ?? "" + fileName = try container.decodeIfPresent(String.self, forKey: .fileName) ?? "" + } + + var isVideo: Bool { fileType == 2 } + var isImage: Bool { fileType == 1 || fileType == 5 } +} + +/// 举报详情接口返回的补充内容或举报内容时间轴条目。 +struct WildReportDetailContent: Decodable, Equatable, Hashable { + let type: String + let id: Int + let desc: String + let time: String + let timeLabel: String + let evidences: [WildReportDetailEvidence] + + enum CodingKeys: String, CodingKey { + case type + case id + case desc + case time + case timeLabel = "time_label" + case evidences + } + + init( + type: String = "", + id: Int = 0, + desc: String = "", + time: String = "", + timeLabel: String = "", + evidences: [WildReportDetailEvidence] = [] + ) { + self.type = type + self.id = id + self.desc = desc + self.time = time + self.timeLabel = timeLabel + self.evidences = evidences + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decodeIfPresent(String.self, forKey: .type) ?? "" + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + desc = try container.decodeIfPresent(String.self, forKey: .desc) ?? "" + time = try container.decodeIfPresent(String.self, forKey: .time) ?? "" + timeLabel = try container.decodeIfPresent(String.self, forKey: .timeLabel) ?? "" + evidences = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .evidences) ?? [] + } + + var imageCount: Int { evidences.filter(\.isImage).count } + var videoCount: Int { evidences.filter(\.isVideo).count } +} + +/// 举报详情接口返回的处理日志时间轴。 +struct WildReportDetailTimelineItem: Decodable, Equatable, Hashable { + let time: String + let content: String + + enum CodingKeys: String, CodingKey { + case time + case content + } + + init(time: String = "", content: String = "") { + self.time = time + self.content = content + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + time = try container.decodeIfPresent(String.self, forKey: .time) ?? "" + content = try container.decodeIfPresent(String.self, forKey: .content) ?? "" + } +} + +/// 举报详情接口业务数据。 +struct WildReportDetailData: Decodable, Equatable, Hashable { + let id: Int + let complaintNo: String + let title: String + let reportType: Int + let reportTypeText: String + let desc: String + let supplements: [WildReportDetailContent] + let reportContents: [WildReportDetailContent] + let scenicId: Int + let scenicName: String + let storeUserId: Int + let contactPhone: String + let locationName: String + let locationAddress: String + let latitude: String + let longitude: String + let handleStatus: Int + let handleStatusText: String + let handleRemark: String + let evidenceSummary: WildReportEvidenceSummary? + let evidences: [WildReportDetailEvidence] + let paymentScreenshots: [WildReportDetailEvidence] + let timeline: [WildReportDetailTimelineItem] + 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 supplements + case reportContents = "report_contents" + case scenicId = "scenic_id" + case scenicName = "scenic_name" + case storeUserId = "store_user_id" + case contactPhone = "contact_phone" + case locationName = "location_name" + case locationAddress = "location_address" + case latitude + case longitude + case handleStatus = "handle_status" + case handleStatusText = "handle_status_text" + case handleRemark = "handle_remark" + case evidenceSummary = "evidence_summary" + case evidences + case paymentScreenshots = "payment_screenshots" + case timeline + case createdAt = "created_at" + } + + init( + id: Int = 0, + complaintNo: String = "", + title: String = "", + reportType: Int = 0, + reportTypeText: String = "", + desc: String = "", + supplements: [WildReportDetailContent] = [], + reportContents: [WildReportDetailContent] = [], + scenicId: Int = 0, + scenicName: String = "", + storeUserId: Int = 0, + contactPhone: String = "", + locationName: String = "", + locationAddress: String = "", + latitude: String = "", + longitude: String = "", + handleStatus: Int = 0, + handleStatusText: String = "", + handleRemark: String = "", + evidenceSummary: WildReportEvidenceSummary? = nil, + evidences: [WildReportDetailEvidence] = [], + paymentScreenshots: [WildReportDetailEvidence] = [], + timeline: [WildReportDetailTimelineItem] = [], + createdAt: String = "" + ) { + self.id = id + self.complaintNo = complaintNo + self.title = title + self.reportType = reportType + self.reportTypeText = reportTypeText + self.desc = desc + self.supplements = supplements + self.reportContents = reportContents + self.scenicId = scenicId + self.scenicName = scenicName + self.storeUserId = storeUserId + self.contactPhone = contactPhone + self.locationName = locationName + self.locationAddress = locationAddress + self.latitude = latitude + self.longitude = longitude + self.handleStatus = handleStatus + self.handleStatusText = handleStatusText + self.handleRemark = handleRemark + self.evidenceSummary = evidenceSummary + self.evidences = evidences + self.paymentScreenshots = paymentScreenshots + self.timeline = timeline + 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) ?? "" + supplements = try container.decodeIfPresent([WildReportDetailContent].self, forKey: .supplements) ?? [] + reportContents = try container.decodeIfPresent([WildReportDetailContent].self, forKey: .reportContents) ?? [] + scenicId = try container.decodeIfPresent(Int.self, forKey: .scenicId) ?? 0 + scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName) ?? "" + storeUserId = try container.decodeIfPresent(Int.self, forKey: .storeUserId) ?? 0 + contactPhone = try container.decodeIfPresent(String.self, forKey: .contactPhone) ?? "" + locationName = try container.decodeIfPresent(String.self, forKey: .locationName) ?? "" + locationAddress = try container.decodeIfPresent(String.self, forKey: .locationAddress) ?? "" + 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) ?? "" + handleRemark = try container.decodeIfPresent(String.self, forKey: .handleRemark) ?? "" + evidenceSummary = try container.decodeIfPresent(WildReportEvidenceSummary.self, forKey: .evidenceSummary) + evidences = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .evidences) ?? [] + paymentScreenshots = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .paymentScreenshots) ?? [] + timeline = try container.decodeIfPresent([WildReportDetailTimelineItem].self, forKey: .timeline) ?? [] + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + } + + var displayLocation: String { + let scenic = scenicName.trimmingCharacters(in: .whitespacesAndNewlines) + let detail = (locationAddress.nonEmpty ?? locationName).trimmingCharacters(in: .whitespacesAndNewlines) + if !scenic.isEmpty, !detail.isEmpty, scenic != detail { + return "\(scenic) · \(detail)" + } + return detail.nonEmpty ?? scenic + } + + var displayStatusText: String { + handleStatusText.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? WildReportStatus(handleStatus: handleStatus).rawValue + } + + var imageCount: Int { + evidenceSummary?.imageCount ?? evidences.filter(\.isImage).count + } + + var videoCount: Int { + evidenceSummary?.videoCount ?? evidences.filter(\.isVideo).count + } + + var supplementRecords: [WildReportSupplementaryEvidence] { + let contents = supplements.isEmpty + ? reportContents.filter { $0.type == "supplement" } + : supplements + return contents.map { content in + WildReportSupplementaryEvidence( + id: content.id > 0 ? String(content.id) : content.time, + submitTime: content.time, + text: content.desc, + imageCount: content.imageCount, + videoCount: content.videoCount + ) + } + } + + /// 转换为当前 UIKit 列表和详情页复用的举报记录实体。 + func toRecord(fallback: WildReportRecord? = nil) -> WildReportRecord { + WildReportRecord( + serverID: id > 0 ? id : fallback?.serverID, + id: complaintNo.nonEmpty ?? fallback?.id ?? "", + title: title.nonEmpty ?? fallback?.title ?? "摄影师举报", + reportType: WildReportType( + value: reportType > 0 ? reportType : fallback?.reportType.value ?? 4, + label: reportTypeText.nonEmpty ?? fallback?.reportType.label ?? "其他" + ), + status: WildReportStatus(handleStatus: handleStatus, fallbackText: handleStatusText), + location: displayLocation.nonEmpty ?? fallback?.location ?? "", + submitTime: createdAt.nonEmpty ?? fallback?.submitTime ?? "", + description: desc.nonEmpty ?? fallback?.description ?? "", + wechatID: fallback?.wechatID ?? "", + phoneNumber: contactPhone.nonEmpty ?? fallback?.phoneNumber ?? "", + imageCount: imageCount, + videoCount: videoCount, + handlerInfo: fallback?.handlerInfo + ) + } +} + /// 补充证据记录实体。 struct WildReportSupplementaryEvidence: Hashable { let id: String @@ -391,6 +718,7 @@ struct WildReportRiskPoint: Hashable { /// 举报记录实体。 struct WildReportRecord: Hashable { + var serverID: Int? = nil let id: String let title: String let reportType: WildReportType @@ -470,13 +798,34 @@ struct WildReportProgressStep: Hashable { /// 举报人材料预览项。 enum WildReportReporterMediaItem: Hashable { - case image(index: Int) - case video(index: Int) + case image(index: Int, url: String?, fileName: String?, fileTypeText: String?) + case video(index: Int, url: String?, fileName: String?, fileTypeText: String?) var id: String { switch self { - case .image(let index): return "reporter-image-\(index)" - case .video(let index): return "reporter-video-\(index)" + case .image(let index, let url, _, _): return "reporter-image-\(index)-\(url ?? "")" + case .video(let index, let url, _, _): return "reporter-video-\(index)-\(url ?? "")" + } + } + + var fileURL: String? { + switch self { + case .image(_, let url, _, _), .video(_, let url, _, _): + return url + } + } + + var fileName: String? { + switch self { + case .image(_, _, let fileName, _), .video(_, _, let fileName, _): + return fileName + } + } + + var fileTypeText: String? { + switch self { + case .image(_, _, _, let fileTypeText), .video(_, _, _, let fileTypeText): + return fileTypeText } } } @@ -601,3 +950,10 @@ extension CLLocationCoordinate2D: @retroactive Equatable { lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude } } + +private extension String { + var nonEmpty: String? { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index d1bbfa9..57b14db 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -620,18 +620,22 @@ final class WildPhotographerReportSuccessViewModel { /// 举报详情页 ViewModel。 final class WildPhotographerReportDetailViewModel { - let record: WildReportRecord + private let initialRecord: WildReportRecord private let homeViewModel: WildPhotographerReportHomeViewModel? + private(set) var detail: WildReportDetailData? + private(set) var isLoadingDetail = false + private(set) var detailErrorMessage: String? private(set) var actionMessage: String? var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel? = nil) { - self.record = record + self.initialRecord = record self.homeViewModel = homeViewModel } + var record: WildReportRecord { detail?.toRecord(fallback: initialRecord) ?? initialRecord } var heroTitle: String { record.title.isEmpty ? "摄影师举报" : record.title } var scenicAreaName: String { WildReportLocationParser.split(record.location).scenic } var detailAddress: String { @@ -639,19 +643,42 @@ final class WildPhotographerReportDetailViewModel { return detail.contains("附近") ? detail : "\(detail)附近" } - var displayStatusText: String { record.status.rawValue } + var displayStatusText: String { detail?.displayStatusText ?? record.status.rawValue } var descriptionText: String { record.description } var contactText: String { record.contactDisplayText } + var handleRemarkText: String { detail?.handleRemark.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } var supplementaryEvidences: [WildReportSupplementaryEvidence] { - homeViewModel?.supplementaryEvidences(for: record.id) ?? [] + if let detail { + return detail.supplementRecords + } + return homeViewModel?.supplementaryEvidences(for: record.id) ?? [] } var reporterMediaItems: [WildReportReporterMediaItem] { - let images = (0.. 0, (force || detail == nil), !isLoadingDetail else { return } + isLoadingDetail = true + detailErrorMessage = nil + notifyStateChange() + defer { + isLoadingDetail = false + notifyStateChange() + } + do { + detail = try await api.reportDetail(id: serverID) + } catch is CancellationError { + return + } catch { + detailErrorMessage = error.localizedDescription + onShowMessage?(error.localizedDescription) + } + } + /// 展示媒体预览提示。 func showMediaPreview(kind: String) { showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。") @@ -697,16 +749,37 @@ final class WildPhotographerReportDetailViewModel { makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current), makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback) 前") ] - case .processed: + case .processed, .rejected: return [ makeStep(id: "1", title: "已提交", date: baseDate, state: .completed), makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed), makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .completed), - makeStep(id: "4", title: "已反馈核查结果", date: baseDate.addingTimeInterval(3 * 3_600), state: .completed) + makeStep( + id: "4", + title: record.status == .rejected ? "已驳回举报" : "已反馈核查结果", + date: baseDate.addingTimeInterval(3 * 3_600), + state: .completed + ) ] } } + private static func buildProgressSteps( + from timeline: [WildReportDetailTimelineItem], + status: WildReportStatus + ) -> [WildReportProgressStep] { + timeline.enumerated().map { index, item in + let isLast = index == timeline.count - 1 + let lastIsOpen = isLast && (status == .pending || status == .processing) + return WildReportProgressStep( + id: "\(index)-\(item.time)", + title: item.content.isEmpty ? "处理进度" : item.content, + timeText: item.time, + state: lastIsOpen ? .current : .completed + ) + } + } + private static func makeStep( id: String, title: String, @@ -883,16 +956,20 @@ final class WildReportSupplementEvidenceViewModel { private(set) var videos: [WildReportAttachment] = [] private(set) var validationMessage: String? private(set) var didSubmit = false + private(set) var isSubmitting = false + private(set) var submitProgressText = "" let reportID: String + let serverID: Int? var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onSubmitSuccess: (() -> Void)? private let homeViewModel: WildPhotographerReportHomeViewModel - init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) { - self.reportID = reportID + init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) { + self.reportID = record.id + self.serverID = record.serverID self.homeViewModel = homeViewModel } @@ -902,24 +979,38 @@ final class WildReportSupplementEvidenceViewModel { notifyStateChange() } - /// 添加演示用补充图片。 - func addImage() { - guard images.count < 9 else { - onShowMessage?("补充图片最多上传9张") + /// 添加本地补充图片。 + func addImages(_ attachments: [WildReportAttachment]) { + let remaining = max(0, 9 - evidenceCount) + guard remaining > 0 else { + onShowMessage?("补充证据最多上传9项") return } - images.append(WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")) + images.append(contentsOf: attachments.prefix(remaining)) + notifyStateChange() + } + + /// 添加演示用补充图片。 + func addImage() { + 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() { - guard videos.count < 3 else { - onShowMessage?("补充视频最多上传3段") - return - } - videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")) - notifyStateChange() + addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")]) } /// 删除指定附件。 @@ -930,25 +1021,117 @@ final class WildReportSupplementEvidenceViewModel { } /// 校验并提交补充证据。 - func submit() { + func submit( + api: any WildPhotographerReportServing, + uploader: any WildReportAttachmentUploading, + scenicId: Int + ) async { validationMessage = nil let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else { - validationMessage = "请至少填写补充说明或上传一项证据。" - onShowMessage?(validationMessage ?? "") + guard validateBeforeSubmit(trimmedText: trimmedText, scenicId: scenicId) else { return } + guard !isSubmitting else { return } + + isSubmitting = true + submitProgressText = evidenceCount > 0 ? "正在上传补充证据" : "正在提交补充证据" + notifyStateChange() + defer { + isSubmitting = false + submitProgressText = "" notifyStateChange() - return } - homeViewModel.appendSupplementaryEvidence( - reportID: reportID, - text: trimmedText, - imageCount: images.count, - videoCount: videos.count - ) - didSubmit = true + do { + let evidenceRequests = try await uploadAttachments( + images + videos, + uploader: uploader, + scenicId: scenicId + ) + submitProgressText = "正在提交补充证据" + notifyStateChange() + + try await api.supplementReport(WildReportSupplementRequest( + id: serverID ?? 0, + desc: trimmedText.isEmpty ? nil : trimmedText, + evidences: evidenceRequests.isEmpty ? nil : evidenceRequests + )) + + homeViewModel.appendSupplementaryEvidence( + reportID: reportID, + text: trimmedText, + imageCount: images.count, + videoCount: videos.count + ) + didSubmit = true + notifyStateChange() + onSubmitSuccess?() + } catch is CancellationError { + return + } catch { + setValidationMessage(error.localizedDescription) + } + } + + private var evidenceCount: Int { + images.count + videos.count + } + + private func validateBeforeSubmit(trimmedText: String, scenicId: Int) -> Bool { + guard let serverID, serverID >= 1 else { + setValidationMessage("当前举报暂未同步到服务器,无法补充证据") + return false + } + guard !trimmedText.isEmpty || evidenceCount > 0 else { + setValidationMessage("请至少填写补充说明或上传一项证据。") + return false + } + guard evidenceCount <= 9 else { + setValidationMessage("补充证据最多上传9项。") + return false + } + guard videos.count <= 3 else { + setValidationMessage("补充视频最多上传3段。") + return false + } + if evidenceCount > 0, scenicId <= 0 { + setValidationMessage("请先选择景区后再提交补充证据。") + return false + } + return true + } + + private func setValidationMessage(_ message: String) { + validationMessage = message notifyStateChange() - onSubmitSuccess?() + onShowMessage?(message) + } + + private func uploadAttachments( + _ attachments: [WildReportAttachment], + uploader: any WildReportAttachmentUploading, + scenicId: Int + ) async throws -> [WildReportEvidenceRequest] { + var requests: [WildReportEvidenceRequest] = [] + for (index, attachment) in attachments.enumerated() { + let current = index + 1 + submitProgressText = "正在上传补充证据 \(current)/\(attachments.count)" + 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 = "正在上传补充证据 \(current)/\(attachments.count) · \(progress)%" + self?.notifyStateChange() + } + requests.append(WildReportEvidenceRequest( + fileURL: url, + fileType: attachment.fileType, + fileName: attachment.fileName + )) + } + return requests } private func notifyStateChange() { diff --git a/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md b/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md index ac30a90..91a2705 100644 --- a/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md +++ b/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md @@ -9,7 +9,7 @@ - 网络入口:`NetworkServices.shared.wildPhotographerReportAPI` - 后端成功码:`100000` - 响应包裹:工程统一 `APIEnvelope` -- 当前状态:“举报类型”、“提交举报”和“我的举报列表”已接接口;详情、补充证据、风险地图仍使用 Mock 数据闭环。 +- 当前状态:“举报类型”、“提交举报”、“我的举报列表”、“举报详情”和“补充证据提交”已接接口;风险地图仍使用 Mock 数据闭环。 ## 1. 获取举报类型 @@ -269,7 +269,7 @@ struct WildReportListResponse: Decodable, Equatable { - 切换筛选时重置为第一页,待处理传 `handle_status=0`,处理中传 `handle_status=1`,已处理传 `handle_status=2`。 - 滚动到底部时,如果仍有更多数据,则请求下一页并追加。 - 首屏失败显示 toast 和空态;加载更多失败只 toast,并保留已有列表。 -- 列表接口暂不返回媒体数量,`WildReportRecord.imageCount/videoCount` 暂按 0 处理,后续详情接口补齐。 +- 列表接口暂不返回媒体数量,`WildReportRecord.imageCount/videoCount` 暂按 0 处理,进入详情页后由详情接口补齐。 ### 当前调用位置 @@ -278,12 +278,100 @@ struct WildReportListResponse: Decodable, Equatable { - `WildPhotographerReportListViewModel.loadMoreIfNeeded(api:scenicId:)` - `WildPhotographerReportAPI.reportList(_:)` +## 4. 举报详情 + +### 用途 + +“举报详情”页面进入时调用。列表页传入 `WildReportRecord.serverID`,详情接口返回完整举报信息、现场证据、支付截图、补充内容和处理时间线。 + +### 请求 + +```http +GET /api/yf-handset-app/photog/report/detail?id=32 +``` + +### 请求参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `id` | integer | 必填,举报记录数值 ID,来自列表接口返回的 `id` | + +### 关键响应字段 + +- `complaint_no`:页面展示的举报编号。 +- `report_type` / `report_type_text`:举报类型。 +- `desc`:初始举报说明。 +- `evidences`:初始现场证据,详情页“举报人材料”区域展示。 +- `payment_screenshots`:支付截图,详情页单独展示。 +- `supplements` / `report_contents`:补充内容记录,优先使用 `supplements`,为空时从 `report_contents` 过滤 `type=supplement`。 +- `location_name` / `location_address`:详情页位置展示。 +- `handle_status` / `handle_status_text`:处理状态;iOS 支持 0 待处理、1 处理中、2 已处理、3 驳回。 +- `handle_remark`:处理备注,有值时在详情信息卡展示。 +- `timeline`:处理进度时间线;为空时使用本地状态推导兜底。 + +### iOS 模型 + +```swift +struct WildReportDetailData: Decodable, Equatable, Hashable +struct WildReportDetailEvidence: Decodable, Equatable, Hashable +struct WildReportDetailContent: Decodable, Equatable, Hashable +struct WildReportDetailTimelineItem: Decodable, Equatable, Hashable +``` + +### UI 行为 + +- 详情页先展示列表带入的基础记录,再异步请求详情接口刷新完整数据。 +- 如果传入记录没有 `serverID`,说明来自本地提交成功页等临时记录,不请求详情接口。 +- 详情接口失败时保留现有基础信息,并 toast 错误。 +- 图片证据使用 Kingfisher 加载网络图;视频证据展示视频样式缩略块和文件信息。 + +### 当前调用位置 + +- `WildPhotographerReportDetailViewController.viewDidLoad()` +- `WildPhotographerReportDetailViewModel.loadDetailIfNeeded(api:)` +- `WildPhotographerReportAPI.reportDetail(id:)` + +## 5. 补充证据提交 + +### 请求 + +```http +POST /api/yf-handset-app/photog/report/supplement +``` + +### 请求体 + +```json +{ + "id": 32, + "desc": "补充说明", + "evidences": [ + { + "file_url": "https://example.com/supplement.jpg", + "file_type": 1, + "file_name": "supplement.jpg" + } + ] +} +``` + +### 字段说明 + +- `id`:服务端举报 ID,必填,需大于等于 1;本地临时记录没有 `serverID` 时不允许补充提交。 +- `desc`:补充说明,选填,最多 500 字;与 `evidences` 至少填写一项。 +- `evidences`:补充附件,选填,最多 9 项;附件先通过 OSS `photog_report` 模块上传后再提交 URL。 +- `file_type`:沿用举报提交接口约定,1 图片,2 视频。 + +### 当前调用位置 + +- `WildReportSupplementEvidenceViewModel.submit(api:uploader:scenicId:)` +- `WildPhotographerReportAPI.supplementReport(_:)` +- `WildReportSupplementEvidenceViewController.submitTapped()` + ## Mock 阶段业务 以下业务当前仍由 `WildPhotographerReportMockStore` 和 ViewModel 本地逻辑完成,暂未接真实接口: -- 举报详情 -- 补充证据 - 附近风险地图 - 分享、媒体预览、地图导航演示提示 diff --git a/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift b/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift index 000d8a1..4c08f88 100644 --- a/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift +++ b/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift @@ -3,8 +3,11 @@ // suixinkan // +import AVFoundation +import PhotosUI import SnapKit import UIKit +import UniformTypeIdentifiers /// 举报摄影师模块 UI 工具,统一卡片、标签和按钮样式。 enum WildReportUI { @@ -182,6 +185,322 @@ final class WildReportAttachmentChipView: UIView { } } +/// 举报模块本地媒体选择目标。 +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 结果加载器,将图片/视频保存为可预览和待上传的本地文件。 +enum WildReportLocalMediaLoader { + static func load( + results: [PHPickerResult], + target: WildReportPickerTarget, + titlePrefix: String? = nil + ) async throws -> [WildReportAttachment] { + var attachments: [WildReportAttachment] = [] + for result in results { + let attachment = target == .video + ? try await loadVideo(from: result.itemProvider, target: target, titlePrefix: titlePrefix) + : try await loadImage(from: result.itemProvider, target: target, titlePrefix: titlePrefix) + attachments.append(attachment) + } + return attachments + } + + private static func loadImage( + from provider: NSItemProvider, + target: WildReportPickerTarget, + titlePrefix: String? + ) 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: titlePrefix ?? target.titlePrefix, + localFileURL: url, + fileName: fileName + )) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func loadVideo( + from provider: NSItemProvider, + target: WildReportPickerTarget, + titlePrefix: String? + ) 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: titlePrefix ?? 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) + } +} + +/// 举报模块附件缩略 tile,优先展示本地图片或视频封面。 +final class WildReportAttachmentTileView: UIControl { + private let gradientLayer = CAGradientLayer() + private let previewAction: () -> Void + private let removeAction: () -> Void + private let tileSize: CGFloat + + init( + attachment: WildReportAttachment, + tileSize: CGFloat = 58, + onPreview: @escaping () -> Void, + onRemove: @escaping () -> Void + ) { + self.previewAction = onPreview + self.removeAction = onRemove + self.tileSize = tileSize + super.init(frame: .zero) + layer.cornerRadius = 10 + clipsToBounds = false + + gradientLayer.cornerRadius = 10 + let colors: [UIColor] = attachment.kind == .video + ? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)] + : [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)] + gradientLayer.colors = colors.map(\.cgColor) + gradientLayer.startPoint = CGPoint(x: 0, y: 0) + 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() + make.size.equalTo(attachment.kind == .video ? 30 : 26) + } + + 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 = 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(-2) + make.size.equalTo(34) + } + + addTarget(self, action: #selector(previewTapped), for: .touchUpInside) + snp.makeConstraints { make in + make.size.equalTo(tileSize) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } + + @objc private func previewTapped() { + previewAction() + } + + @objc private func removeTapped() { + removeAction() + } +} + +/// 举报模块附件封面生成器,负责从本地图片或视频文件提取缩略图。 +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,白底虚线加号。 +final class WildReportAddAttachmentTileView: UIControl { + private let tapAction: () -> Void + private let borderLayer = CAShapeLayer() + private let tileSize: CGFloat + + init( + tileSize: CGFloat = 58, + borderColor: UIColor = UIColor(hex: 0xCDD5E1), + iconColor: UIColor = UIColor(hex: 0x8D93A1), + action: @escaping () -> Void + ) { + self.tapAction = action + self.tileSize = tileSize + super.init(frame: .zero) + backgroundColor = .white + layer.cornerRadius = 10 + + borderLayer.strokeColor = borderColor.cgColor + borderLayer.fillColor = UIColor.clear.cgColor + borderLayer.lineWidth = 1 + borderLayer.lineDashPattern = [5, 4] + layer.addSublayer(borderLayer) + + let icon = UIImageView(image: UIImage(systemName: "plus")) + icon.tintColor = iconColor + icon.contentMode = .scaleAspectFit + addSubview(icon) + icon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(30) + } + + addTarget(self, action: #selector(tapped), for: .touchUpInside) + snp.makeConstraints { make in + make.size.equalTo(tileSize) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath + } + + @objc private func tapped() { + tapAction() + } +} + /// 举报模块空状态视图。 final class WildReportEmptyView: UIView { init(text: String) { diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index e00a42f..5213d84 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -3,6 +3,7 @@ // suixinkan // +import Kingfisher import SnapKit import UIKit @@ -136,7 +137,7 @@ final class WildPhotographerReportListViewController: BaseViewController { private func openDetail(_ record: WildReportRecord) { navigationController?.pushViewController( - WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel), + WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel, api: api), animated: true ) } @@ -514,14 +515,20 @@ private final class WildReportListLoadingView: UIView { final class WildPhotographerReportDetailViewController: BaseViewController { private let viewModel: WildPhotographerReportDetailViewModel private let homeViewModel: WildPhotographerReportHomeViewModel + private let api: any WildPhotographerReportServing private let scrollView = UIScrollView() private let stack = UIStackView() private let bottomBar = UIView() private let bottomButtonRow = UIStackView() - init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) { + init( + record: WildReportRecord, + homeViewModel: WildPhotographerReportHomeViewModel, + api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI + ) { self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel) self.homeViewModel = homeViewModel + self.api = api super.init(nibName: nil, bundle: nil) } @@ -593,7 +600,17 @@ final class WildPhotographerReportDetailViewController: BaseViewController { } } + override func viewDidLoad() { + super.viewDidLoad() + Task { + await viewModel.loadDetailIfNeeded(api: api) + } + } + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.rebuildContent() } + } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } @@ -705,6 +722,10 @@ final class WildPhotographerReportDetailViewController: BaseViewController { card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报说明", value: viewModel.descriptionText, multiline: true)) card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "微信号/手机号", value: viewModel.contactText)) + if !viewModel.handleRemarkText.isEmpty { + card.stack.addArrangedSubview(makeInsetDivider()) + card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理备注", value: viewModel.handleRemarkText, multiline: true)) + } return card } @@ -715,6 +736,10 @@ final class WildPhotographerReportDetailViewController: BaseViewController { card.stack.addArrangedSubview(makeReporterMaterialsSection()) card.stack.addArrangedSubview(makeDivider()) } + if !viewModel.paymentScreenshotItems.isEmpty { + card.stack.addArrangedSubview(makePaymentScreenshotSection()) + card.stack.addArrangedSubview(makeDivider()) + } card.stack.addArrangedSubview(makeLocationSection()) return card } @@ -819,6 +844,43 @@ final class WildPhotographerReportDetailViewController: BaseViewController { return section } + private func makePaymentScreenshotSection() -> UIView { + let section = UIStackView() + section.axis = .vertical + section.spacing = 12 + section.addArrangedSubview(makeSectionHeader( + iconName: "creditcard.fill", + title: "支付截图", + subtitle: viewModel.paymentScreenshotSummary, + chevron: true, + action: #selector(paymentPreviewTapped) + )) + + let mediaScroll = UIScrollView() + mediaScroll.showsHorizontalScrollIndicator = false + let mediaStack = UIStackView() + mediaStack.axis = .horizontal + mediaStack.spacing = 10 + mediaScroll.addSubview(mediaStack) + mediaStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalTo(mediaScroll.frameLayoutGuide) + } + viewModel.paymentScreenshotItems.forEach { item in + let tile = WildReportDetailMediaThumbnailView(reporterItem: item) + tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reporterMediaTapped(_:)))) + tile.snp.makeConstraints { make in + make.size.equalTo(108) + } + mediaStack.addArrangedSubview(tile) + } + mediaScroll.snp.makeConstraints { make in + make.height.equalTo(108) + } + section.addArrangedSubview(mediaScroll) + return section + } + private func makeCompletionMediaSection(info: WildReportHandlerInfo) -> UIView { let section = UIStackView() section.axis = .vertical @@ -996,6 +1058,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController { @objc private func shareTapped() { viewModel.shareReport() } @objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") } + @objc private func paymentPreviewTapped() { viewModel.showMediaPreview(kind: "支付截图") } @objc private func locationTapped() { viewModel.showLocationPreview() } @objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) { @@ -1010,7 +1073,17 @@ final class WildPhotographerReportDetailViewController: BaseViewController { @objc private func supplementTapped() { navigationController?.pushViewController( - WildReportSupplementEvidenceViewController(reportID: viewModel.record.id, homeViewModel: homeViewModel), + WildReportSupplementEvidenceViewController( + record: viewModel.record, + homeViewModel: homeViewModel, + api: api, + onSupplementSuccess: { [weak self] in + guard let self else { return } + Task { + await self.viewModel.loadDetailIfNeeded(api: self.api, force: true) + } + } + ), animated: true ) } @@ -1107,24 +1180,25 @@ private final class WildReportDetailInfoRowView: UIView { } } -/// 举报详情页材料缩略图,用渐变与系统图标模拟图片、视频和处理凭证。 +/// 举报详情页材料缩略图,展示远程图片、视频和处理凭证。 private final class WildReportDetailMediaThumbnailView: UIView { let previewKindText: String private let gradient = CAGradientLayer() + private let remoteImageView = UIImageView() private let iconView = UIImageView() private let footerLabel = PaddingLabel(insets: UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8)) private let playView = UIImageView(image: UIImage(systemName: "play.circle.fill")) init(reporterItem: WildReportReporterMediaItem) { switch reporterItem { - case .image(let index): - previewKindText = "第\(index + 1)张举报图片" + case .image(let index, let url, let fileName, let fileTypeText): + previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)张举报图片" super.init(frame: .zero) - configure(kind: .image(index: index), footerText: "图片 \(index + 1)") - case .video(let index): - previewKindText = "第\(index + 1)段举报视频" + configure(kind: .image(index: index), footerText: fileTypeText ?? "图片 \(index + 1)", imageURL: url) + case .video(let index, _, let fileName, let fileTypeText): + previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)段举报视频" super.init(frame: .zero) - configure(kind: .video(index: index, duration: "00:32"), footerText: "视频 \(index + 1)") + configure(kind: .video(index: index, duration: fileTypeText ?? "视频"), footerText: fileTypeText ?? "视频 \(index + 1)") } } @@ -1151,7 +1225,7 @@ private final class WildReportDetailMediaThumbnailView: UIView { gradient.frame = bounds } - private func configure(kind: Kind, footerText: String) { + private func configure(kind: Kind, footerText: String, imageURL: String? = nil) { clipsToBounds = true layer.cornerRadius = 14 gradient.colors = kind.gradientColors @@ -1159,9 +1233,20 @@ private final class WildReportDetailMediaThumbnailView: UIView { gradient.endPoint = CGPoint(x: 1, y: 1) layer.insertSublayer(gradient, at: 0) + if let imageURL, let url = URL(string: imageURL) { + remoteImageView.contentMode = .scaleAspectFill + remoteImageView.clipsToBounds = true + remoteImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) + addSubview(remoteImageView) + remoteImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + iconView.image = UIImage(systemName: kind.iconName) iconView.tintColor = UIColor.white.withAlphaComponent(0.92) iconView.contentMode = .scaleAspectFit + iconView.isHidden = imageURL != nil addSubview(iconView) footerLabel.text = footerText diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift index bab59b4..e3760e7 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -3,12 +3,10 @@ // suixinkan // -import AVFoundation import PhotosUI import QuickLook import SnapKit import UIKit -import UniformTypeIdentifiers /// 提交举报页面,提供类型、证据、说明、定位和联系方式表单。 final class WildPhotographerReportSubmitViewController: BaseViewController { @@ -686,137 +684,6 @@ extension WildPhotographerReportSubmitViewController: UIGestureRecognizerDelegat } } -/// 提交页本地媒体选择目标。 -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) - } -} - /// 提交页步骤标题,复刻参考页左侧蓝色竖条与选填标识。 private final class WildReportSectionTitleView: UIView { init(index: Int, title: String, optional: Bool) { @@ -899,169 +766,6 @@ private final class WildReportTypeOptionButton: UIControl { } } -/// 提交页附件缩略 tile,优先展示本地图片或视频封面。 -private final class WildReportAttachmentTileView: UIControl { - private let gradientLayer = CAGradientLayer() - private let previewAction: () -> Void - private let removeAction: () -> Void - - init( - attachment: WildReportAttachment, - onPreview: @escaping () -> Void, - onRemove: @escaping () -> Void - ) { - self.previewAction = onPreview - self.removeAction = onRemove - super.init(frame: .zero) - layer.cornerRadius = 10 - clipsToBounds = false - - gradientLayer.cornerRadius = 10 - let colors: [UIColor] = attachment.kind == .video - ? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)] - : [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)] - gradientLayer.colors = colors.map(\.cgColor) - gradientLayer.startPoint = CGPoint(x: 0, y: 0) - 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() - make.size.equalTo(attachment.kind == .video ? 30 : 26) - } - - 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 = 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(-2) - make.size.equalTo(34) - } - - addTarget(self, action: #selector(previewTapped), for: .touchUpInside) - snp.makeConstraints { make in - make.size.equalTo(58) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - 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 - private let borderLayer = CAShapeLayer() - - init(action: @escaping () -> Void) { - self.tapAction = action - super.init(frame: .zero) - backgroundColor = .white - layer.cornerRadius = 10 - - borderLayer.strokeColor = UIColor(hex: 0xCDD5E1).cgColor - borderLayer.fillColor = UIColor.clear.cgColor - borderLayer.lineWidth = 1 - borderLayer.lineDashPattern = [5, 4] - layer.addSublayer(borderLayer) - - let icon = UIImageView(image: UIImage(systemName: "plus")) - icon.tintColor = UIColor(hex: 0x8D93A1) - icon.contentMode = .scaleAspectFit - addSubview(icon) - icon.snp.makeConstraints { make in - make.center.equalToSuperview() - make.size.equalTo(30) - } - - addTarget(self, action: #selector(tapped), for: .touchUpInside) - snp.makeConstraints { make in - make.size.equalTo(58) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath - } - - @objc private func tapped() { - tapAction() - } -} - /// 举报成功页面,展示编号、上传摘要和处理进度。 final class WildPhotographerReportSuccessViewController: BaseViewController { private let viewModel: WildPhotographerReportSuccessViewModel diff --git a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift index 5d6ed80..675d4af 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift @@ -3,19 +3,40 @@ // suixinkan // +import PhotosUI +import QuickLook import SnapKit import UIKit -/// 补充证据页面,允许为已有举报追加说明和 Mock 附件。 +/// 补充证据页面,允许为已有举报追加说明、图片和视频证据。 final class WildReportSupplementEvidenceViewController: BaseViewController { private let viewModel: WildReportSupplementEvidenceViewModel - private let scrollView = UIScrollView() - private let stack = UIStackView() - private let textView = UITextView() - private let submitButton = AppButton(title: "提交补充证据") + private let api: any WildPhotographerReportServing + private let uploader: any WildReportAttachmentUploading + private let onSupplementSuccess: (() -> Void)? - init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) { - self.viewModel = WildReportSupplementEvidenceViewModel(reportID: reportID, homeViewModel: homeViewModel) + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let textView = UITextView() + private let placeholderLabel = UILabel() + private let countLabel = UILabel() + private let submitButton = UIButton(type: .system) + + private var pickerTarget: WildReportPickerTarget = .image + private var previewURL: URL? + private weak var activeInputView: UIView? + + init( + record: WildReportRecord, + homeViewModel: WildPhotographerReportHomeViewModel, + api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, + uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService, + onSupplementSuccess: (() -> Void)? = nil + ) { + self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel) + self.api = api + self.uploader = uploader + self.onSupplementSuccess = onSupplementSuccess super.init(nibName: nil, bundle: nil) } @@ -24,105 +45,421 @@ final class WildReportSupplementEvidenceViewController: BaseViewController { fatalError("init(coder:) has not been implemented") } + deinit { + NotificationCenter.default.removeObserver(self) + } + override func setupNavigationBar() { title = "补充证据" } override func setupUI() { - view.backgroundColor = AppColor.pageBackgroundSoft + view.backgroundColor = UIColor(hex: 0xF8FAFF) scrollView.keyboardDismissMode = .interactive - stack.axis = .vertical - stack.spacing = AppSpacing.sm - textView.font = .app(.body) + scrollView.showsVerticalScrollIndicator = false + contentStack.axis = .vertical + contentStack.spacing = 14 + + textView.font = .systemFont(ofSize: 15) textView.textColor = AppColor.textPrimary - textView.backgroundColor = AppColor.inputBackground - textView.layer.cornerRadius = AppRadius.md - textView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10) + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8) textView.delegate = self + + placeholderLabel.text = "请补充新的线索说明..." + placeholderLabel.font = .systemFont(ofSize: 15) + placeholderLabel.textColor = UIColor(hex: 0x9CA3AF) + + countLabel.font = .systemFont(ofSize: 14) + countLabel.textColor = UIColor(hex: 0x6B7280) + countLabel.textAlignment = .right + + submitButton.setTitle("提交补充证据", for: .normal) + submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + submitButton.setTitleColor(.white, for: .normal) + submitButton.backgroundColor = AppColor.primary + submitButton.layer.cornerRadius = 14 submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) view.addSubview(scrollView) - scrollView.addSubview(stack) + scrollView.addSubview(contentStack) view.addSubview(submitButton) + + let dismissTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + dismissTap.cancelsTouchesInView = false + dismissTap.delegate = self + scrollView.addGestureRecognizer(dismissTap) + registerKeyboardNotifications() rebuildContent() } override func setupConstraints() { submitButton.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm) + make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(18) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(12) + make.height.equalTo(52) } scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) - make.bottom.equalTo(submitButton.snp.top).offset(-AppSpacing.sm) + make.bottom.equalTo(submitButton.snp.top).offset(-12) } - stack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.md, bottom: AppSpacing.lg, right: AppSpacing.md)) - make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2) + contentStack.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 18, bottom: 28, right: 18)) + make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) } } override func bindActions() { viewModel.onStateChange = { [weak self] in - Task { @MainActor in self?.rebuildContent() } + Task { @MainActor in + guard let self else { return } + if self.textView.isFirstResponder { + self.updateTextInputState() + self.updateSubmitButton() + } else { + self.rebuildContent() + } + } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } viewModel.onSubmitSuccess = { [weak self] in Task { @MainActor in - self?.showToast("补充证据已提交") - self?.navigationController?.popViewController(animated: true) + guard let self else { return } + self.onSupplementSuccess?() + self.showToast("补充证据已提交") + self.navigationController?.popViewController(animated: true) } } } @MainActor private func rebuildContent() { - stack.arrangedSubviews.forEach { view in - stack.removeArrangedSubview(view) + contentStack.arrangedSubviews.forEach { view in + contentStack.removeArrangedSubview(view) view.removeFromSuperview() } - let textCard = WildReportCardView(spacing: AppSpacing.sm) - textCard.stack.addArrangedSubview(WildReportUI.label("补充说明", font: .app(.title), color: AppColor.textPrimary)) - textView.text = viewModel.text - textCard.stack.addArrangedSubview(textView) - textView.snp.makeConstraints { make in make.height.equalTo(150) } - let count = WildReportUI.label("\(viewModel.text.count)/500", font: .app(.caption), color: AppColor.textTertiary) - count.textAlignment = .right - textCard.stack.addArrangedSubview(count) - stack.addArrangedSubview(textCard) - - let attachmentCard = WildReportCardView(spacing: AppSpacing.sm) - attachmentCard.stack.addArrangedSubview(WildReportUI.label("补充材料", font: .app(.title), color: AppColor.textPrimary)) - let row = UIStackView() - row.axis = .horizontal - row.spacing = AppSpacing.sm - row.distribution = .fillEqually - let imageButton = WildReportUI.iconButton(title: "添加图片", imageName: "photo", style: .secondary) - imageButton.addTarget(self, action: #selector(addImageTapped), for: .touchUpInside) - let videoButton = WildReportUI.iconButton(title: "添加视频", imageName: "video", style: .secondary) - videoButton.addTarget(self, action: #selector(addVideoTapped), for: .touchUpInside) - [imageButton, videoButton].forEach { button in - button.snp.makeConstraints { make in make.height.equalTo(44) } - row.addArrangedSubview(button) - } - attachmentCard.stack.addArrangedSubview(row) - (viewModel.images + viewModel.videos).forEach { attachment in - let chip = WildReportAttachmentChipView(attachment: attachment) - chip.onRemove = { [weak self] in self?.viewModel.removeAttachment(attachment) } - attachmentCard.stack.addArrangedSubview(chip) - } - stack.addArrangedSubview(attachmentCard) + contentStack.addArrangedSubview(makeTextCard()) + contentStack.addArrangedSubview(makeUploadCard( + icon: "photo.fill", + title: "上传图片", + subtitle: "支持 JPG、PNG 格式,最多 9 张", + attachments: viewModel.images, + maxCount: 9, + kind: .image + )) + contentStack.addArrangedSubview(makeUploadCard( + icon: "video.fill", + title: "上传视频", + subtitle: "支持 MP4 格式,最多 3 段", + attachments: viewModel.videos, + maxCount: 3, + kind: .video + )) + updateSubmitButton() } - @objc private func addImageTapped() { viewModel.addImage() } - @objc private func addVideoTapped() { viewModel.addVideo() } - @objc private func submitTapped() { viewModel.submit() } + private func makeTextCard() -> UIView { + let card = cardView(spacing: 10) + card.stack.addArrangedSubview(WildReportUI.label("补充说明", font: .systemFont(ofSize: 17, weight: .bold), color: AppColor.textPrimary)) + textView.text = viewModel.text + + let inputContainer = UIView() + inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE) + inputContainer.layer.cornerRadius = 12 + inputContainer.layer.borderWidth = 1 + inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor + inputContainer.addSubview(textView) + inputContainer.addSubview(placeholderLabel) + inputContainer.addSubview(countLabel) + + textView.snp.remakeConstraints { make in + make.edges.equalToSuperview() + make.height.greaterThanOrEqualTo(132) + } + placeholderLabel.snp.remakeConstraints { make in + make.top.equalToSuperview().offset(18) + make.leading.equalToSuperview().offset(14) + make.trailing.lessThanOrEqualToSuperview().inset(14) + } + countLabel.snp.remakeConstraints { make in + make.trailing.bottom.equalToSuperview().inset(12) + } + card.stack.addArrangedSubview(inputContainer) + updateTextInputState() + return card + } + + private func makeUploadCard( + icon: String, + title: String, + subtitle: String, + attachments: [WildReportAttachment], + maxCount: Int, + kind: WildReportAttachmentKind + ) -> UIView { + let card = cardView(spacing: 12) + let header = UIStackView() + header.axis = .horizontal + header.alignment = .center + header.spacing = 10 + + let iconView = UIImageView(image: UIImage(systemName: icon)) + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + iconView.snp.makeConstraints { make in + make.size.equalTo(26) + } + + let textStack = UIStackView() + textStack.axis = .vertical + textStack.spacing = 4 + textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary)) + textStack.addArrangedSubview(WildReportUI.label(subtitle, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0)) + + header.addArrangedSubview(iconView) + header.addArrangedSubview(textStack) + header.addArrangedSubview(UIView()) + card.stack.addArrangedSubview(header) + + let scroll = UIScrollView() + scroll.showsHorizontalScrollIndicator = false + let tiles = UIStackView() + tiles.axis = .horizontal + tiles.spacing = 10 + scroll.addSubview(tiles) + tiles.snp.makeConstraints { make in + make.edges.equalTo(scroll.contentLayoutGuide).inset(UIEdgeInsets(top: 2, left: 0, bottom: 2, right: 0)) + make.height.equalTo(scroll.frameLayoutGuide).offset(-4) + } + + attachments.forEach { attachment in + tiles.addArrangedSubview(WildReportAttachmentTileView( + attachment: attachment, + tileSize: 72, + onPreview: { [weak self] in self?.previewAttachment(attachment) }, + onRemove: { [weak self] in self?.viewModel.removeAttachment(attachment) } + )) + } + + if canAddAttachment(kind: kind, currentCount: attachments.count, maxCount: maxCount) { + tiles.addArrangedSubview(WildReportAddAttachmentTileView( + tileSize: 72, + borderColor: AppColor.primary, + iconColor: AppColor.primary + ) { [weak self] in + self?.presentMediaPicker(for: WildReportPickerTarget(kind: kind)) + }) + } + + scroll.snp.makeConstraints { make in + make.height.equalTo(76) + } + card.stack.addArrangedSubview(scroll) + return card + } + + private func cardView(spacing: CGFloat) -> WildReportCardView { + let card = WildReportCardView(spacing: spacing, inset: 16) + card.layer.cornerRadius = 16 + card.layer.borderColor = AppColor.cardOutline.cgColor + return card + } + + private func updateTextInputState() { + placeholderLabel.isHidden = !viewModel.text.isEmpty + countLabel.text = "\(viewModel.text.count)/500" + } + + private func updateSubmitButton() { + let title = viewModel.isSubmitting && !viewModel.submitProgressText.isEmpty + ? viewModel.submitProgressText + : "提交补充证据" + submitButton.setTitle(title, for: .normal) + submitButton.isEnabled = !viewModel.isSubmitting + submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1 + } + + private func canAddAttachment(kind: WildReportAttachmentKind, currentCount: Int, maxCount: Int) -> Bool { + let ownRemaining = max(0, maxCount - currentCount) + let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count) + switch kind { + case .image: + return min(ownRemaining, evidenceRemaining) > 0 + case .video: + return min(ownRemaining, evidenceRemaining) > 0 + case .payment: + return false + } + } + + private func presentMediaPicker(for target: WildReportPickerTarget) { + dismissKeyboard() + let remaining = remainingSelectionCount(for: target) + guard remaining > 0 else { + showToast(target == .video ? "补充视频最多上传3段" : "补充证据最多上传9项") + 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 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 submitTapped() { + Task { + await viewModel.submit( + api: api, + uploader: uploader, + scenicId: AppStore.shared.currentScenicId + ) + } + } + + @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 + 12, 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 WildReportSupplementEvidenceViewController: 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, + titlePrefix: target == .video ? "补充视频" : "补充图片" + ) + await MainActor.run { + switch target { + case .image: + viewModel.addImages(attachments) + case .video: + viewModel.addVideos(attachments) + case .payment: + break + } + } + } catch { + await MainActor.run { showToast(error.localizedDescription) } + } + } + } +} + +extension WildReportSupplementEvidenceViewController: QLPreviewControllerDataSource { + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + previewURL == nil ? 0 : 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + previewURL! as NSURL + } } extension WildReportSupplementEvidenceViewController: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + activeInputView = textView + DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) } + } + func textViewDidChange(_ textView: UITextView) { viewModel.updateText(textView.text) + if textView.text != viewModel.text { + textView.text = viewModel.text + } + updateTextInputState() + } + + func textViewDidEndEditing(_ textView: UITextView) { + if activeInputView === textView { + activeInputView = nil + } + } +} + +extension WildReportSupplementEvidenceViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + var touchedView = touch.view + while let currentView = touchedView { + if currentView is UIControl || currentView is UITextView { + return false + } + touchedView = currentView.superview + } + return true } } diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index b11b387..1a6f40f 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -115,6 +115,129 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(response.list.first?.complaintNo, "JB20260708917") } + func testReportDetailAPILoadsRemoteDetail() async throws { + let json = Data(""" + { + "code": 100000, + "msg": "success", + "data": { + "id": 32, + "complaint_no": "JB20260708917", + "title": "私下收款举报", + "report_type": 1, + "report_type_text": "私下收款", + "desc": "我在观景台附近看到私下收款", + "supplements": [], + "report_contents": [ + { + "type": "initial", + "id": 0, + "desc": "我在观景台附近看到私下收款", + "time": "2026-07-08 16:06:59", + "time_label": "16:06:59", + "evidences": [ + { + "file_type": 1, + "file_type_text": "图片", + "file_url": "https://example.com/report.jpg", + "file_name": "report.jpg" + } + ] + } + ], + "scenic_id": 128, + "scenic_name": "伊犁那拉提景区-5A", + "store_user_id": 0, + "contact_phone": "13800138000", + "location_name": "伊犁那拉提景区-5A", + "location_address": "江苏省扬州市邗江区瘦西湖路", + "latitude": "32.4299428", + "longitude": "119.4403263", + "handle_status": 0, + "handle_status_text": "待处理", + "handle_remark": "", + "evidence_summary": { "image_count": 1, "video_count": 1 }, + "evidences": [ + { + "file_type": 1, + "file_type_text": "图片", + "file_url": "https://example.com/report.jpg", + "file_name": "report.jpg" + }, + { + "file_type": 2, + "file_type_text": "视频", + "file_url": "https://example.com/report.mov", + "file_name": "report.mov" + } + ], + "payment_screenshots": [ + { + "file_type": 5, + "file_type_text": "支付截图", + "file_url": "https://example.com/payment.jpg", + "file_name": "payment.jpg" + } + ], + "created_at": "2026-07-08 16:06:59", + "timeline": [ + { "time": "2026-07-08 16:06:59", "content": "摄影师提交举报" } + ] + }, + "time": "2026-07-08 16:31:50" + } + """.utf8) + let session = MockURLSession(responses: [json]) + let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.reportDetail(id: 32) + + let components = URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false) + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/detail") + XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "id", value: "32")) == true) + XCTAssertEqual(response.complaintNo, "JB20260708917") + XCTAssertEqual(response.evidences.count, 2) + XCTAssertEqual(response.paymentScreenshots.first?.fileName, "payment.jpg") + XCTAssertEqual(response.timeline.first?.content, "摄影师提交举报") + } + + func testSupplementReportAPISendsRequestBody() async throws { + let json = Data(""" + { + "code": 100000, + "msg": "success", + "data": null, + "time": "2026-07-08 17:31:50" + } + """.utf8) + let session = MockURLSession(responses: [json]) + let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) + + try await api.supplementReport(WildReportSupplementRequest( + id: 32, + desc: "补充说明", + evidences: [ + WildReportEvidenceRequest( + fileURL: "https://example.com/supplement.jpg", + fileType: 1, + fileName: "supplement.jpg" + ) + ] + )) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/supplement") + let bodyData = try XCTUnwrap(request.httpBody) + let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any]) + XCTAssertEqual(body["id"] as? Int, 32) + XCTAssertEqual(body["desc"] as? String, "补充说明") + let evidences = try XCTUnwrap(body["evidences"] as? [[String: Any]]) + XCTAssertEqual(evidences.first?["file_url"] as? String, "https://example.com/supplement.jpg") + XCTAssertEqual(evidences.first?["file_type"] as? Int, 1) + XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg") + } + func testReportListItemMapsToRecord() { let item = makeReportListItem( complaintNo: "JB20260708917", @@ -136,6 +259,31 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(record.submitTime, "2026-07-08 16:06:59") XCTAssertEqual(record.imageCount, 0) XCTAssertEqual(record.videoCount, 0) + XCTAssertEqual(record.serverID, 32) + } + + func testReportDetailMapsToRecordAndSupplementData() { + let detail = makeReportDetailData(handleStatus: 3, handleStatusText: "驳回") + + let record = detail.toRecord() + + XCTAssertEqual(record.serverID, 32) + XCTAssertEqual(record.id, "JB20260708917") + XCTAssertEqual(record.reportType, WildReportType(value: 1, label: "私下收款")) + XCTAssertEqual(record.status, .rejected) + XCTAssertEqual(record.location, "伊犁那拉提景区-5A · 江苏省扬州市邗江区瘦西湖路") + XCTAssertEqual(record.description, "正式详情说明") + XCTAssertEqual(record.phoneNumber, "13800138000") + XCTAssertEqual(record.imageCount, 1) + XCTAssertEqual(record.videoCount, 1) + XCTAssertEqual(detail.supplementRecords.first?.summaryText, "补充说明 · 1张图片") + } + + func testReportStatusMapsRejected() { + XCTAssertEqual(WildReportStatus(handleStatus: 0), .pending) + XCTAssertEqual(WildReportStatus(handleStatus: 1), .processing) + XCTAssertEqual(WildReportStatus(handleStatus: 2), .processed) + XCTAssertEqual(WildReportStatus(handleStatus: 3), .rejected) } func testReportListAllFilterOmitsHandleStatus() { @@ -384,21 +532,165 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription) } - func testSupplementEvidenceValidationAndSuccess() { + func testSupplementEvidenceValidationAndSuccess() async throws { let homeViewModel = WildPhotographerReportHomeViewModel() - let reportID = homeViewModel.reports[0].id - let viewModel = WildReportSupplementEvidenceViewModel(reportID: reportID, homeViewModel: homeViewModel) + let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() + let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + let uploader = MockWildReportAttachmentUploader() - viewModel.submit() + await viewModel.submit(api: api, uploader: uploader, scenicId: 128) XCTAssertEqual(viewModel.validationMessage, "请至少填写补充说明或上传一项证据。") viewModel.updateText("补充说明") - viewModel.addImage() - viewModel.submit() + viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")]) + viewModel.addVideos([try makeAttachment(kind: .video, fileName: "supplement.mov")]) + await viewModel.submit(api: api, uploader: uploader, scenicId: 128) XCTAssertTrue(viewModel.didSubmit) - XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.text, "补充说明") - XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.imageCount, 1) + XCTAssertEqual(uploader.uploadedFileNames, ["supplement.jpg", "supplement.mov"]) + XCTAssertEqual(api.supplementRequests.first?.id, 32) + XCTAssertEqual(api.supplementRequests.first?.desc, "补充说明") + XCTAssertEqual(api.supplementRequests.first?.evidences?.map(\.fileType), [1, 2]) + XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.text, "补充说明") + XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.imageCount, 1) + XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.videoCount, 1) + } + + func testSupplementEvidenceFailsWithoutServerID() async { + let homeViewModel = WildPhotographerReportHomeViewModel() + let record = WildReportRecord( + serverID: nil, + id: "JBLOCAL", + title: "摄影师举报", + reportType: WildReportType(value: 2, label: "疑似打野"), + status: .pending, + location: "测试景区", + submitTime: "2026-07-08 16:06:59", + description: "本地记录", + wechatID: "", + phoneNumber: "", + imageCount: 1, + videoCount: 0, + handlerInfo: nil + ) + let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel) + viewModel.updateText("补充说明") + + await viewModel.submit( + api: MockWildPhotographerReportAPI(), + uploader: MockWildReportAttachmentUploader(), + scenicId: 128 + ) + + XCTAssertEqual(viewModel.validationMessage, "当前举报暂未同步到服务器,无法补充证据") + XCTAssertFalse(viewModel.didSubmit) + } + + func testSupplementEvidenceTextAndAttachmentLimits() throws { + let homeViewModel = WildPhotographerReportHomeViewModel() + let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() + let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel) + + viewModel.updateText(String(repeating: "测", count: 520)) + XCTAssertEqual(viewModel.text.count, 500) + + let images = try (0..<10).map { try makeAttachment(kind: .image, fileName: "image\($0).jpg") } + viewModel.addImages(images) + XCTAssertEqual(viewModel.images.count, 9) + + viewModel.removeAttachment(viewModel.images[0]) + let videos = try (0..<4).map { try makeAttachment(kind: .video, fileName: "video\($0).mov") } + viewModel.addVideos(videos) + XCTAssertEqual(viewModel.videos.count, 1) + } + + func testSupplementEvidenceUploadFailureDoesNotSubmit() async throws { + let homeViewModel = WildPhotographerReportHomeViewModel() + let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() + let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + let uploader = MockWildReportAttachmentUploader() + uploader.error = URLError(.cannotWriteToFile) + viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")]) + + await viewModel.submit(api: api, uploader: uploader, scenicId: 128) + + XCTAssertFalse(viewModel.didSubmit) + XCTAssertTrue(api.supplementRequests.isEmpty) + XCTAssertEqual(viewModel.validationMessage, URLError(.cannotWriteToFile).localizedDescription) + } + + func testSupplementEvidenceAPIErrorDoesNotSucceed() async throws { + let homeViewModel = WildPhotographerReportHomeViewModel() + let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() + let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel) + let api = MockWildPhotographerReportAPI() + api.supplementError = URLError(.badServerResponse) + viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")]) + + await viewModel.submit( + api: api, + uploader: MockWildReportAttachmentUploader(), + scenicId: 128 + ) + + XCTAssertFalse(viewModel.didSubmit) + XCTAssertEqual(api.supplementRequests.count, 0) + XCTAssertEqual(viewModel.validationMessage, URLError(.badServerResponse).localizedDescription) + } + + func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async { + let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() + let viewModel = WildPhotographerReportDetailViewModel(record: record) + let api = MockWildPhotographerReportAPI() + api.reportDetailResponses = [makeReportDetailData()] + + await viewModel.loadDetailIfNeeded(api: api) + + XCTAssertEqual(api.reportDetailRequests, [32]) + XCTAssertEqual(viewModel.record.description, "正式详情说明") + XCTAssertEqual(viewModel.reporterMediaItems.count, 2) + XCTAssertEqual(viewModel.paymentScreenshotItems.count, 1) + XCTAssertEqual(viewModel.progressSteps.first?.title, "摄影师提交举报") + } + + func testDetailViewModelFailureKeepsInitialRecord() async { + let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() + let viewModel = WildPhotographerReportDetailViewModel(record: record) + let api = MockWildPhotographerReportAPI() + api.reportDetailError = URLError(.badServerResponse) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.loadDetailIfNeeded(api: api) + + XCTAssertEqual(viewModel.record.description, "测试说明") + XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription) + } + + func testDetailViewModelWithoutServerIDSkipsRequest() async { + let record = WildReportRecord( + id: "JBLOCAL", + title: "摄影师举报", + reportType: WildReportType(value: 2, label: "疑似打野"), + status: .pending, + location: "测试景区", + submitTime: "2026-07-08 16:06:59", + description: "本地提交记录", + wechatID: "", + phoneNumber: "", + imageCount: 1, + videoCount: 0, + handlerInfo: nil + ) + let viewModel = WildPhotographerReportDetailViewModel(record: record) + let api = MockWildPhotographerReportAPI() + + await viewModel.loadDetailIfNeeded(api: api) + + XCTAssertTrue(api.reportDetailRequests.isEmpty) + XCTAssertEqual(viewModel.record.description, "本地提交记录") } } @@ -410,6 +702,11 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing var reportListResponses: [WildReportListResponse] = [] var reportListRequests: [WildReportListRequest] = [] var reportListError: Error? + var reportDetailResponses: [WildReportDetailData] = [] + var reportDetailRequests: [Int] = [] + var reportDetailError: Error? + var supplementRequests: [WildReportSupplementRequest] = [] + var supplementError: Error? func reportTypes() async throws -> WildReportTypeListResponse { if let error { throw error } @@ -421,6 +718,11 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing submitRequests.append(request) } + func supplementReport(_ request: WildReportSupplementRequest) async throws { + if let supplementError { throw supplementError } + supplementRequests.append(request) + } + func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse { if let reportListError { throw reportListError } reportListRequests.append(request) @@ -429,6 +731,13 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing } return reportListResponses.removeFirst() } + + func reportDetail(id: Int) async throws -> WildReportDetailData { + if let reportDetailError { throw reportDetailError } + reportDetailRequests.append(id) + guard !reportDetailResponses.isEmpty else { return WildReportDetailData(id: id) } + return reportDetailResponses.removeFirst() + } } @MainActor @@ -462,6 +771,7 @@ private func makeAttachment(kind: WildReportAttachmentKind, fileName: String) th } private func makeReportListItem( + id: Int = 32, complaintNo: String, title: String = "疑似打野摄影师举报", reportType: Int = 2, @@ -470,7 +780,7 @@ private func makeReportListItem( handleStatusText: String = "待处理" ) -> WildReportListItem { WildReportListItem( - id: Int(complaintNo.filter(\.isNumber)) ?? 1, + id: id, complaintNo: complaintNo, title: title, reportType: reportType, @@ -486,3 +796,73 @@ private func makeReportListItem( createdAt: "2026-07-08 16:06:59" ) } + +private func makeReportDetailData( + handleStatus: Int = 0, + handleStatusText: String = "待处理" +) -> WildReportDetailData { + WildReportDetailData( + id: 32, + complaintNo: "JB20260708917", + title: "私下收款举报", + reportType: 1, + reportTypeText: "私下收款", + desc: "正式详情说明", + supplements: [ + WildReportDetailContent( + type: "supplement", + id: 7, + desc: "补充说明", + time: "2026-07-08 17:06:59", + timeLabel: "17:06:59", + evidences: [ + WildReportDetailEvidence( + fileType: 1, + fileTypeText: "图片", + fileURL: "https://example.com/supplement.jpg", + fileName: "supplement.jpg" + ) + ] + ) + ], + reportContents: [], + scenicId: 128, + scenicName: "伊犁那拉提景区-5A", + storeUserId: 0, + contactPhone: "13800138000", + locationName: "伊犁那拉提景区-5A", + locationAddress: "江苏省扬州市邗江区瘦西湖路", + latitude: "32.4299428", + longitude: "119.4403263", + handleStatus: handleStatus, + handleStatusText: handleStatusText, + handleRemark: "处理备注", + evidenceSummary: WildReportEvidenceSummary(imageCount: 1, videoCount: 1), + evidences: [ + WildReportDetailEvidence( + fileType: 1, + fileTypeText: "图片", + fileURL: "https://example.com/report.jpg", + fileName: "report.jpg" + ), + WildReportDetailEvidence( + fileType: 2, + fileTypeText: "视频", + fileURL: "https://example.com/report.mov", + fileName: "report.mov" + ) + ], + paymentScreenshots: [ + WildReportDetailEvidence( + fileType: 5, + fileTypeText: "支付截图", + fileURL: "https://example.com/payment.jpg", + fileName: "payment.jpg" + ) + ], + timeline: [ + WildReportDetailTimelineItem(time: "2026-07-08 16:06:59", content: "摄影师提交举报") + ], + createdAt: "2026-07-08 16:06:59" + ) +}