// // WildPhotographerReportViewModels.swift // suixinkan // import Foundation import MapKit import UIKit /// 举报摄影师附件上传协议,便于提交页注入 OSS 服务与单元测试替身。 @MainActor protocol WildReportAttachmentUploading { /// 上传举报附件并返回 OSS 文件 URL。 func uploadWildReportAttachment( data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String } extension OSSUploadService: WildReportAttachmentUploading {} /// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。 final class WildPhotographerReportHomeViewModel { private(set) var pageState: WildReportPageState = .normal private(set) var reports: [WildReportRecord] = [] private(set) var riskPoints: [WildReportRiskPoint] = [] private(set) var supplementaryEvidenceMap: [String: [WildReportSupplementaryEvidence]] = [:] var onStateChange: (() -> Void)? private var nextSequence = 0 private var nextSupplementSequence = 0 init() { resetDemoData(notify: false) } var emptyTitle: String { "暂无举报服务" } var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" } /// 恢复演示数据并回到正常状态。 func restoreDemoData() { resetDemoData(notify: false) pageState = .normal notifyStateChange() } /// 模拟错误后重新加载。 func retryLoadAfterError() { pageState = .loading notifyStateChange() Task { try? await Task.sleep(nanoseconds: 450_000_000) pageState = .normal notifyStateChange() } } /// 切换页面演示状态。 func setPageState(_ state: WildReportPageState) { pageState = state notifyStateChange() } /// 提交举报并写入 Mock 列表,返回新记录。 @discardableResult func submitReport( reportType: WildReportType, description: String, location: String, contact: String?, imageCount: Int, videoCount: Int ) -> WildReportRecord { let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) let parsedContact = WildPhotographerReportMockStore.parseContact(contact) let record = WildReportRecord( id: nextReportID(), title: "摄影师举报", reportType: reportType, status: .pending, location: location, submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()), description: trimmedDescription, wechatID: parsedContact.wechat, phoneNumber: parsedContact.phone, imageCount: imageCount, videoCount: videoCount, handlerInfo: nil ) reports.insert(record, at: 0) notifyStateChange() return record } /// 读取指定举报的补充证据列表。 func supplementaryEvidences(for reportID: String) -> [WildReportSupplementaryEvidence] { supplementaryEvidenceMap[reportID] ?? [] } /// 追加一条补充证据记录。 func appendSupplementaryEvidence( reportID: String, text: String, imageCount: Int, videoCount: Int ) { let item = WildReportSupplementaryEvidence( id: nextSupplementID(), submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()), text: text.trimmingCharacters(in: .whitespacesAndNewlines), imageCount: imageCount, videoCount: videoCount ) var items = supplementaryEvidenceMap[reportID] ?? [] items.insert(item, at: 0) supplementaryEvidenceMap[reportID] = items notifyStateChange() } /// 重置为种子 Mock 数据。 func resetDemoData(notify: Bool = true) { reports = WildPhotographerReportMockStore.seedReports riskPoints = WildPhotographerReportMockStore.seedRiskPoints supplementaryEvidenceMap = WildPhotographerReportMockStore.seedSupplementaryEvidences nextSequence = WildPhotographerReportMockStore.maxSequence(from: WildPhotographerReportMockStore.seedReports) + 1 nextSupplementSequence = 100 if notify { notifyStateChange() } } private func nextSupplementID() -> String { defer { nextSupplementSequence += 1 } return String(format: "SE%03d", nextSupplementSequence) } private func nextReportID() -> String { let datePart = WildPhotographerReportMockStore.reportDateFormatter.string(from: Date()) defer { nextSequence += 1 } return String(format: "JB\(datePart)%03d", nextSequence) } private func notifyStateChange() { onStateChange?() } } /// 提交举报页 ViewModel,负责表单校验和前台定位。 final class WildPhotographerReportSubmitViewModel { private(set) var pageState: WildReportPageState = .normal private(set) var images: [WildReportAttachment] private(set) var videos: [WildReportAttachment] private(set) var paymentImages: [WildReportAttachment] = [] private(set) var reportTypes: [WildReportType] = WildReportType.fallbackOptions private(set) var selectedReportType: WildReportType = .defaultSelected private(set) var description = "" private(set) var contact = "" private(set) var reportLocation = "" private(set) var isLoadingReportTypes = false private(set) var isUpdatingLocation = false private(set) var isSubmitting = false private(set) var submitProgressText = "" private(set) var locationConfirmed = false private(set) var submitResult: WildReportSubmitResult? private(set) var validationMessage: String? var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onSubmitSuccess: ((WildReportSubmitResult) -> Void)? private let homeViewModel: WildPhotographerReportHomeViewModel private let locationProvider: any LocationProviding private var hasRequestedInitialLocation = false private var locationSnapshot: HomeLocationSnapshot? var locationTitleText: String { if reportLocation.isEmpty || isUpdatingLocation { return "正在获取当前位置..." } return "当前定位:\(reportLocation)" } var locationSubtitleText: String { if isUpdatingLocation { return "正在更新定位,请稍候" } if locationConfirmed { return "已获取最新定位,如不准确请点击更新定位" } return "如位置不准确,请点击更新定位" } init( homeViewModel: WildPhotographerReportHomeViewModel, locationProvider: any LocationProviding = LocationProvider.shared ) { self.homeViewModel = homeViewModel self.locationProvider = locationProvider self.images = [] self.videos = [] } /// 首次进入页面时自动请求定位。 func loadInitialLocationIfNeeded() { guard !hasRequestedInitialLocation else { return } hasRequestedInitialLocation = true refreshCurrentLocation() } /// 从服务端加载举报类型,失败时回退到本地兜底选项。 func loadReportTypes(api: any WildPhotographerReportServing) async { guard !isLoadingReportTypes else { return } isLoadingReportTypes = true notifyStateChange() defer { isLoadingReportTypes = false notifyStateChange() } do { let response = try await api.reportTypes() let loadedTypes = uniqueReportTypes(response.list) reportTypes = loadedTypes.isEmpty ? WildReportType.fallbackOptions : loadedTypes if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) { selectedReportType = updatedType } else { selectedReportType = reportTypes.first ?? .defaultSelected } } catch is CancellationError { return } catch { reportTypes = WildReportType.fallbackOptions if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) { selectedReportType = updatedType } else { selectedReportType = .defaultSelected } onShowMessage?("举报类型获取失败,已使用默认类型") } } /// 选择举报类型。 func selectReportType(_ type: WildReportType) { selectedReportType = type notifyStateChange() } /// 限制举报说明最多 500 字。 func updateDescription(_ newValue: String) { description = String(newValue.prefix(500)) notifyStateChange() } /// 更新联系方式。 func updateContact(_ value: String) { contact = String(value.prefix(80)) notifyStateChange() } /// 添加本地现场图片。 func addImages(_ attachments: [WildReportAttachment]) { let remaining = max(0, 9 - evidenceCount) guard remaining > 0 else { onShowMessage?("现场证据最多上传9项") return } images.append(contentsOf: attachments.prefix(remaining)) notifyStateChange() } /// 添加演示用现场图片。 func addImage() { 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() { addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")]) } /// 添加本地支付截图。 func addPaymentImages(_ attachments: [WildReportAttachment]) { guard paymentImages.count < 3 else { onShowMessage?("支付截图最多上传3张") return } paymentImages.append(contentsOf: attachments.prefix(3 - paymentImages.count)) notifyStateChange() } /// 添加演示用支付截图。 func addPaymentImage() { addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")]) } /// 删除指定附件。 func removeAttachment(_ attachment: WildReportAttachment) { images.removeAll { $0.id == attachment.id } videos.removeAll { $0.id == attachment.id } paymentImages.removeAll { $0.id == attachment.id } notifyStateChange() } /// 使用前台定位 Provider 刷新当前位置。 func refreshCurrentLocation() { guard !isUpdatingLocation else { return } isUpdatingLocation = true locationConfirmed = false reportLocation = "正在获取当前位置..." notifyStateChange() Task { do { let snapshot = try await locationProvider.requestSnapshot() applyResolvedLocation(snapshot) } catch { applyResolvedLocation(HomeLocationSnapshot( latitude: WildPhotographerReportMockStore.nalatiCenter.latitude, longitude: WildPhotographerReportMockStore.nalatiCenter.longitude, address: WildPhotographerReportMockStore.fallbackLocation )) } } } /// 清除提交结果,用于导航返回。 func clearSubmitResult() { submitResult = nil notifyStateChange() } /// 校验表单并提交举报到后端。 func submitReport( api: any WildPhotographerReportServing, uploader: any WildReportAttachmentUploading, scenicId: Int, scenicName: String ) async { validationMessage = nil guard validateBeforeSubmit(scenicId: scenicId) else { return } guard !isSubmitting else { return } isSubmitting = true submitProgressText = "正在上传证据" notifyStateChange() defer { isSubmitting = false submitProgressText = "" notifyStateChange() } do { let evidenceRequests = try await uploadAttachments( images + videos, uploader: uploader, scenicId: scenicId, progressPrefix: "正在上传现场证据" ) let paymentRequests = try await uploadAttachments( paymentImages, uploader: uploader, scenicId: scenicId, progressPrefix: "正在上传支付截图", completedOffset: evidenceRequests.count, totalOverride: evidenceRequests.count + paymentImages.count ) submitProgressText = "正在提交举报" notifyStateChange() try await api.submitReport(buildSubmitRequest( scenicId: scenicId, scenicName: scenicName, evidences: evidenceRequests, paymentScreenshots: paymentRequests )) let record = homeViewModel.submitReport( reportType: selectedReportType, description: description, location: reportLocation, contact: contact.isEmpty ? nil : contact, imageCount: images.count, videoCount: videos.count ) let result = WildReportSubmitResult( record: record, imageCount: images.count, videoCount: videos.count ) submitResult = result notifyStateChange() onSubmitSuccess?(result) } catch is CancellationError { return } catch { setValidationMessage(error.localizedDescription) } } /// 校验表单并写入本地 Mock 记录。 func submitReport() { validationMessage = nil guard validateBeforeSubmit(scenicId: 1) else { return } let record = homeViewModel.submitReport( reportType: selectedReportType, description: description, location: reportLocation, contact: contact.isEmpty ? nil : contact, imageCount: images.count, videoCount: videos.count ) let result = WildReportSubmitResult( record: record, imageCount: images.count, videoCount: videos.count ) submitResult = result notifyStateChange() onSubmitSuccess?(result) } /// 重置表单到初始状态。 func resetForm() { images = [] videos = [] paymentImages = [] reportTypes = WildReportType.fallbackOptions selectedReportType = .defaultSelected description = "" contact = "" reportLocation = "" locationConfirmed = false isUpdatingLocation = false isSubmitting = false submitProgressText = "" locationSnapshot = nil hasRequestedInitialLocation = false submitResult = nil validationMessage = nil notifyStateChange() } func applyResolvedLocation(_ location: String) { applyResolvedLocation(HomeLocationSnapshot( latitude: WildPhotographerReportMockStore.nalatiCenter.latitude, longitude: WildPhotographerReportMockStore.nalatiCenter.longitude, address: location )) } func applyResolvedLocation(_ snapshot: HomeLocationSnapshot) { let address = snapshot.address.isEmpty ? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude) : snapshot.address locationSnapshot = HomeLocationSnapshot( latitude: snapshot.latitude, longitude: snapshot.longitude, address: address ) reportLocation = address locationConfirmed = true isUpdatingLocation = false notifyStateChange() } private func setValidationMessage(_ message: String) { validationMessage = message notifyStateChange() onShowMessage?(message) } private func notifyStateChange() { onStateChange?() } private func uniqueReportTypes(_ types: [WildReportType]) -> [WildReportType] { var seen: Set = [] return types.filter { seen.insert($0.value).inserted } } private var evidenceCount: Int { images.count + videos.count } private func validateBeforeSubmit(scenicId: Int) -> Bool { guard scenicId > 0 else { setValidationMessage("请先选择景区后再提交举报。") return false } guard !images.isEmpty || !videos.isEmpty else { setValidationMessage("请至少上传一项现场证据。") return false } guard evidenceCount <= 9 else { setValidationMessage("现场证据最多上传9项。") return false } guard paymentImages.count <= 3 else { setValidationMessage("支付截图最多上传3张。") return false } guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { setValidationMessage("请填写举报说明。") return false } guard !isUpdatingLocation else { setValidationMessage("正在获取定位,请稍后再提交。") return false } guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else { setValidationMessage("请先更新举报位置后再提交举报。") return false } return true } private func uploadAttachments( _ attachments: [WildReportAttachment], uploader: any WildReportAttachmentUploading, scenicId: Int, progressPrefix: String, completedOffset: Int = 0, totalOverride: Int? = nil ) async throws -> [WildReportEvidenceRequest] { var requests: [WildReportEvidenceRequest] = [] let total = totalOverride ?? attachments.count for (index, attachment) in attachments.enumerated() { let current = completedOffset + index + 1 submitProgressText = "\(progressPrefix) \(current)/\(max(total, current))" notifyStateChange() let data = try attachment.uploadData() let url = try await uploader.uploadWildReportAttachment( data: data, fileName: attachment.fileName, fileType: attachment.fileType, scenicId: scenicId ) { [weak self] progress in self?.submitProgressText = "\(progressPrefix) \(current)/\(max(total, current)) · \(progress)%" self?.notifyStateChange() } requests.append(WildReportEvidenceRequest( fileURL: url, fileType: attachment.fileType, fileName: attachment.fileName )) } return requests } private func buildSubmitRequest( scenicId: Int, scenicName: String, evidences: [WildReportEvidenceRequest], paymentScreenshots: [WildReportEvidenceRequest] ) -> WildReportSubmitRequest { let locationParts = WildReportLocationParser.split(reportLocation) let trimmedScenicName = scenicName.trimmingCharacters(in: .whitespacesAndNewlines) let locationName = !trimmedScenicName.isEmpty ? trimmedScenicName : locationParts.scenic let snapshot = locationSnapshot ?? HomeLocationSnapshot( latitude: WildPhotographerReportMockStore.nalatiCenter.latitude, longitude: WildPhotographerReportMockStore.nalatiCenter.longitude, address: reportLocation ) let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines) return WildReportSubmitRequest( scenicId: scenicId, reportType: selectedReportType.value, desc: description.trimmingCharacters(in: .whitespacesAndNewlines), storeUserId: nil, contactPhone: trimmedContact.isEmpty ? nil : trimmedContact, locationName: locationName.isEmpty ? nil : locationName, locationAddress: locationParts.detail.isEmpty ? reportLocation : locationParts.detail, lat: snapshot.latitude, lng: snapshot.longitude, evidences: evidences, paymentScreenshots: paymentScreenshots ) } } /// 举报成功页 ViewModel。 final class WildPhotographerReportSuccessViewModel { let result: WildReportSubmitResult let progressSteps = ["已提交", "现场核查中", "处理结果反馈"] init(result: WildReportSubmitResult) { self.result = result } var uploadSummary: String { var parts: [String] = [] if result.imageCount > 0 { parts.append("\(result.imageCount)张图片") } if result.videoCount > 0 { parts.append("\(result.videoCount)段视频") } return parts.isEmpty ? "暂无" : parts.joined(separator: " ") } /// 复制举报编号到剪贴板。 @MainActor func copyReportID() -> String { UIPasteboard.general.string = result.record.id return result.record.id } } /// 举报详情页 ViewModel。 final class WildPhotographerReportDetailViewModel { 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.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 { let detail = WildReportLocationParser.split(record.location).detail return detail.contains("附近") ? detail : "\(detail)附近" } 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] { if let detail { return detail.supplementRecords } return homeViewModel?.supplementaryEvidences(for: record.id) ?? [] } var reporterMediaItems: [WildReportReporterMediaItem] { if let detail { return detail.evidences.enumerated().map { index, evidence in evidence.isVideo ? .video(index: index, url: evidence.fileURL, fileName: evidence.fileName, fileTypeText: evidence.fileTypeText) : .image(index: index, url: evidence.fileURL, fileName: evidence.fileName, fileTypeText: evidence.fileTypeText) } } 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),可在提交页查看上传内容。") } /// 展示位置预览提示。 func showLocationPreview() { showActionMessage("举报位置:\(record.location)") } /// 生成分享提示文案。 func shareReport() { showActionMessage("已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。") } private func showActionMessage(_ message: String) { actionMessage = message onShowMessage?(message) notifyStateChange() } private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] { let baseDate = submitDate(from: record.submitTime) ?? Date() let expectedFeedback = formatDate(baseDate.addingTimeInterval(8 * 3_600)) switch record.status { case .pending, .processing: 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: .current), makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback) 前") ] 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: 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, date: Date?, state: WildReportProgressStepState, pendingText: String? = nil ) -> WildReportProgressStep { WildReportProgressStep( id: id, title: title, timeText: pendingText ?? (date.map(formatDate) ?? ""), state: state ) } private static func submitDate(from text: String) -> Date? { WildPhotographerReportMockStore.submitTimeFormatter.date(from: text) } private static func formatDate(_ date: Date) -> String { WildPhotographerReportMockStore.submitTimeFormatter.string(from: date) } private func notifyStateChange() { onStateChange?() } } /// 我的举报列表 ViewModel。 final class WildPhotographerReportListViewModel { private static let pageSize = 10 private(set) var selectedFilter: WildReportListFilter = .all private(set) var shareMessage: String? private(set) var reports: [WildReportRecord] = [] private(set) var isLoading = false private(set) var isLoadingMore = false private(set) var hasMore = true private(set) var page = 1 private(set) var total = 0 private(set) var errorMessage: String? let homeViewModel: WildPhotographerReportHomeViewModel var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? init(homeViewModel: WildPhotographerReportHomeViewModel) { self.homeViewModel = homeViewModel } var filteredReports: [WildReportRecord] { reports } var footerTip: String { "温馨提示:为保障处理效率,请如实举报并提供有效证据" } var footerDisplayText: String { if isLoadingMore { return "正在加载更多..." } if isLoading && reports.isEmpty { return "正在加载举报记录..." } if !hasMore && !reports.isEmpty { return footerTip } return footerTip } /// 首次加载我的举报列表。 func loadInitial(api: any WildPhotographerReportServing, scenicId: Int?) async { await reload(api: api, scenicId: scenicId) } /// 切换列表筛选标签并重新拉取第一页。 func selectFilter(_ filter: WildReportListFilter, api: any WildPhotographerReportServing, scenicId: Int?) async { guard selectedFilter != filter else { return } selectedFilter = filter await reload(api: api, scenicId: scenicId) } /// 保留本地筛选切换能力,供旧单元测试和 Mock 场景使用。 func selectFilter(_ filter: WildReportListFilter) { selectedFilter = filter reports = filterLocalReports(homeViewModel.reports) notifyStateChange() } /// 滚动到底部时加载下一页。 func loadMoreIfNeeded(api: any WildPhotographerReportServing, scenicId: Int?) async { guard hasMore, !isLoading, !isLoadingMore else { return } let nextPage = page + 1 isLoadingMore = true errorMessage = nil notifyStateChange() defer { isLoadingMore = false notifyStateChange() } do { let response = try await api.reportList(makeRequest(page: nextPage, scenicId: scenicId)) let newRecords = response.list.map { $0.toRecord() } reports.append(contentsOf: newRecords) page = response.page total = response.total hasMore = reports.count < response.total } catch is CancellationError { return } catch { errorMessage = error.localizedDescription onShowMessage?(error.localizedDescription) } } /// 生成分享提示文案。 func shareReport(_ record: WildReportRecord) { shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。" onShowMessage?(shareMessage ?? "") notifyStateChange() } private func reload(api: any WildPhotographerReportServing, scenicId: Int?) async { let hadReportsBeforeReload = !reports.isEmpty isLoading = true isLoadingMore = false errorMessage = nil page = 1 hasMore = true notifyStateChange() defer { isLoading = false notifyStateChange() } do { let response = try await api.reportList(makeRequest(page: 1, scenicId: scenicId)) reports = response.list.map { $0.toRecord() } page = response.page total = response.total hasMore = reports.count < response.total } catch is CancellationError { return } catch { errorMessage = error.localizedDescription hasMore = false if !hadReportsBeforeReload { reports = [] total = 0 } onShowMessage?(error.localizedDescription) } } private func makeRequest(page: Int, scenicId: Int?) -> WildReportListRequest { WildReportListRequest( scenicId: scenicId, handleStatus: selectedFilter.handleStatusValue, page: page, pageSize: Self.pageSize ) } private func filterLocalReports(_ localReports: [WildReportRecord]) -> [WildReportRecord] { switch selectedFilter { case .all: return localReports case .pending: return localReports.filter { $0.status == .pending } case .processing: return localReports.filter { $0.status == .processing } case .processed: return localReports.filter { $0.status == .processed } } } private func notifyStateChange() { onStateChange?() } } /// 补充证据页 ViewModel。 final class WildReportSupplementEvidenceViewModel { private(set) var text = "" private(set) var images: [WildReportAttachment] = [] 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(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) { self.reportID = record.id self.serverID = record.serverID self.homeViewModel = homeViewModel } /// 限制补充说明最多 500 字。 func updateText(_ newValue: String) { text = String(newValue.prefix(500)) notifyStateChange() } /// 添加本地补充图片。 func addImages(_ attachments: [WildReportAttachment]) { let remaining = max(0, 9 - evidenceCount) guard remaining > 0 else { onShowMessage?("补充证据最多上传9项") return } images.append(contentsOf: attachments.prefix(remaining)) notifyStateChange() } /// 添加演示用补充图片。 func addImage() { 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() { addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")]) } /// 删除指定附件。 func removeAttachment(_ attachment: WildReportAttachment) { images.removeAll { $0.id == attachment.id } videos.removeAll { $0.id == attachment.id } notifyStateChange() } /// 校验并提交补充证据。 func submit( api: any WildPhotographerReportServing, uploader: any WildReportAttachmentUploading, scenicId: Int ) async { validationMessage = nil let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) guard validateBeforeSubmit(trimmedText: trimmedText, scenicId: scenicId) else { return } guard !isSubmitting else { return } isSubmitting = true submitProgressText = evidenceCount > 0 ? "正在上传补充证据" : "正在提交补充证据" notifyStateChange() defer { isSubmitting = false submitProgressText = "" notifyStateChange() } 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() 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() { onStateChange?() } } /// 附近风险地图 ViewModel。 final class WildReportRiskMapViewModel { private let fallbackMarkers: [WildReportMapMarker] private var loadedDetailMarkerIDs: Set = [] private(set) var region: MKCoordinateRegion private(set) var markers: [WildReportMapMarker] private(set) var scenicAreaName: String private(set) var selectedMarkerID: String? private(set) var toastMessage: String? private(set) var isLoading = false private(set) var isLoadingDetail = false private(set) var errorMessage: String? private(set) var greenMarkerTip = "" private(set) var legendItems: [WildReportRiskLegendItem] = [] private(set) var currentCoordinate: CLLocationCoordinate2D? var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) { let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints) self.fallbackMarkers = markers self.markers = markers self.region = WildPhotographerReportMockStore.region(for: markers) let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) self.scenicAreaName = scenicName.isEmpty ? "那拉提景区" : scenicName self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id } var selectedMarker: WildReportMapMarker? { guard let selectedMarkerID else { return nil } return markers.first { $0.id == selectedMarkerID } } var selectedClue: WildReportRadarActiveClue? { guard let marker = selectedMarker, case .activeClue(let clue) = marker.detail else { return nil } return clue } /// 首次加载附近风险地图数据。 func loadInitial( api: any WildPhotographerReportServing, locationProvider: any LocationProviding, scenicId: Int? = nil, scenicName: String? = nil ) async { await loadRiskMap( api: api, locationProvider: locationProvider, scenicId: scenicId, scenicName: scenicName, selectCurrentLocation: selectedMarkerID == nil || selectedMarker?.kind == .selfLocation ) } /// 选中地图标记。 func selectMarker( _ marker: WildReportMapMarker, api: (any WildPhotographerReportServing)? = nil, scenicId: Int? = nil ) async { selectedMarkerID = marker.id region = MKCoordinateRegion( center: marker.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018) ) notifyStateChange() guard marker.kind != .selfLocation, !loadedDetailMarkerIDs.contains(marker.id), let api, let request = markerDetailRequest(for: marker, scenicId: scenicId) else { return } isLoadingDetail = true notifyStateChange() do { let detail = try await api.markerDetail(request) mergeDetail(detail, intoMarkerID: marker.id) loadedDetailMarkerIDs.insert(marker.id) } catch { showToast(error.localizedDescription) } isLoadingDetail = false notifyStateChange() } /// 导航到选中标记或刷新蓝点定位。 @discardableResult func navigateToSelectedMarker() -> WildReportMapMarker? { guard let marker = selectedMarker, case .activeClue(let clue) = marker.detail else { return nil } if clue.type == .blue { return nil } region = MKCoordinateRegion( center: marker.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018) ) notifyStateChange() return marker } /// 将地图居中到当前位置标记。 func refreshLocation( api: any WildPhotographerReportServing, locationProvider: any LocationProviding, scenicId: Int? = nil, scenicName: String? = nil ) async { await loadRiskMap( api: api, locationProvider: locationProvider, scenicId: scenicId, scenicName: scenicName, selectCurrentLocation: true ) } /// 生成线索分享提示。 func shareSelectedClue() { guard let clue = selectedClue else { return } let title = clue.type == .red ? "线索 ID:\(clue.id)" : clue.displayTitle showToast("已生成\(title)的分享信息,可发送给他人查看。") } /// 生成图片预览提示。 func showImagePreview(_ url: String) { _ = url showToast("演示环境暂不支持预览图片") } private func showToast(_ message: String) { toastMessage = message onShowMessage?(message) notifyStateChange() } private func loadRiskMap( api: any WildPhotographerReportServing, locationProvider: any LocationProviding, scenicId: Int?, scenicName: String?, selectCurrentLocation: Bool ) async { let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId guard resolvedScenicId >= 1 else { errorMessage = "请先选择景区后再查看风险地图" showToast(errorMessage ?? "") return } isLoading = true errorMessage = nil notifyStateChange() let coordinate = try? await locationProvider.requestCoordinate() currentCoordinate = coordinate do { let response = try await api.riskMap(WildReportRiskMapRequest( scenicId: resolvedScenicId, latitude: coordinate?.latitude, longitude: coordinate?.longitude )) scenicAreaName = response.scenicName.nonEmpty ?? scenicName?.nonEmpty ?? AppStore.shared.currentScenicName.nonEmpty ?? scenicAreaName greenMarkerTip = response.greenMarkerTip legendItems = response.legend markers = buildMarkers(response: response, currentCoordinate: coordinate) if markers.isEmpty { markers = fallbackMarkers } region = WildPhotographerReportMockStore.region(for: markers) if selectCurrentLocation, let current = markers.first(where: { $0.kind == .selfLocation }) { selectedMarkerID = current.id } else if selectedMarker == nil { selectedMarkerID = markers.first?.id } if coordinate != nil { showToast("已更新附近风险点位") } } catch { errorMessage = error.localizedDescription if markers.isEmpty { markers = fallbackMarkers region = WildPhotographerReportMockStore.region(for: markers) selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id ?? markers.first?.id } showToast(error.localizedDescription) } isLoading = false notifyStateChange() } private func buildMarkers( response: WildReportRiskMapResponse, currentCoordinate: CLLocationCoordinate2D? ) -> [WildReportMapMarker] { let remoteMarkers = response.markers.compactMap { marker(from: $0, greenMarkerTip: response.greenMarkerTip) } var result: [WildReportMapMarker] = [] if let currentCoordinate { result.append(currentLocationMarker( coordinate: currentCoordinate, nearestAbnormalText: nearestAbnormalText(for: remoteMarkers) )) } result.append(contentsOf: remoteMarkers) return result } private func currentLocationMarker( coordinate: CLLocationCoordinate2D, nearestAbnormalText: String ) -> WildReportMapMarker { WildReportMapMarker( id: "blue-current", title: "我的位置", coordinate: coordinate, kind: .selfLocation, detail: .activeClue(WildReportRadarActiveClue( id: "blue-current", type: .blue, displayTitle: "当前位置", statusText: "当前位置", actionText: "刷新定位", nearestAbnormalText: nearestAbnormalText, distance: "0米", direction: "当前位置", updatedAt: "实时定位", name: scenicAreaName, storeName: nil, avatar: nil, summary: "蓝色标记为当前位置,可对比周边摄影师风险点位距离。", detail: "点击刷新定位可重新计算周边风险点位距离。", distanceText: "0米", reporterName: nil, reporterPhone: nil, maskedReporterPhone: nil, reportContents: [], images: [], processingStarted: false, completed: false, record: nil, processingTimeline: [], completionPhoto: nil )) ) } private func marker( from dto: WildReportRiskMapMarkerDTO, greenMarkerTip: String ) -> WildReportMapMarker? { guard let latitude = Double(dto.latitude), let longitude = Double(dto.longitude), dto.id > 0 else { return nil } let markerType = WildReportRiskMarkerType(rawValue: dto.markerType) let statusText = dto.handleStatusText?.nonEmpty ?? dto.handleStatus?.displayText ?? defaultStatusText(for: markerType, dto: dto) let title = dto.title.nonEmpty ?? dto.locationName.nonEmpty ?? defaultTitle(for: markerType, id: dto.id) let clue = WildReportRadarActiveClue( id: String(dto.id), type: markerType.radarType, displayTitle: title, statusText: statusText, actionText: markerType == .blue ? "刷新定位" : "导航到此", nearestAbnormalText: nil, distance: formattedDistance(dto.distanceM), direction: nil, updatedAt: dto.createdAt.nonEmpty, name: dto.name?.nonEmpty ?? dto.locationName.nonEmpty, storeName: dto.storeName?.nonEmpty, avatar: dto.avatar?.nonEmpty, summary: markerType == .green ? (dto.greenMarkerTip?.nonEmpty ?? greenMarkerTip.nonEmpty) : dto.reportTypeText.nonEmpty, detail: dto.activityText?.nonEmpty, distanceText: formattedDistance(dto.distanceM), reporterName: nil, reporterPhone: nil, maskedReporterPhone: nil, reportContents: dto.reportTypeText.nonEmpty.map { [WildReportRadarReportContent(time: dto.createdAt.nonEmpty ?? "", content: $0)] } ?? [], images: [], processingStarted: markerType == .yellow || statusText == WildReportStatus.processing.rawValue, completed: statusText == WildReportStatus.processed.rawValue, record: nil, processingTimeline: [], completionPhoto: nil ) return WildReportMapMarker( id: "\(markerType.rawValue)-\(dto.id)", title: title, coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), kind: markerType.markerKind, detail: .activeClue(clue) ) } private func markerDetailRequest( for marker: WildReportMapMarker, scenicId: Int? ) -> WildReportMarkerDetailRequest? { let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId guard resolvedScenicId >= 1 else { return nil } let parts = marker.id.split(separator: "-", maxSplits: 1).map(String.init) guard parts.count == 2, let id = Int(parts[1]), id >= 1 else { return nil } return WildReportMarkerDetailRequest( scenicId: resolvedScenicId, markerType: parts[0], id: id, latitude: currentCoordinate?.latitude, longitude: currentCoordinate?.longitude ) } private func mergeDetail(_ detail: WildReportMarkerDetailResponse, intoMarkerID markerID: String) { guard let index = markers.firstIndex(where: { $0.id == markerID }), case .activeClue(let oldClue) = markers[index].detail else { return } let markerType = WildReportRiskMarkerType(rawValue: detail.markerType.nonEmpty ?? oldClue.type.rawValue) let statusText = detail.handleStatusText.nonEmpty ?? detail.handleStatus?.displayText ?? oldClue.statusText let reportContents = !detail.reportContents.isEmpty ? detail.reportContents.map { WildReportRadarReportContent(time: $0.time, content: $0.desc) } : (detail.desc.nonEmpty.map { [WildReportRadarReportContent(time: detail.createdAt, content: $0)] } ?? oldClue.reportContents) let imageURLs = detail.evidences.filter(\.isImage).map(\.fileURL) let clue = WildReportRadarActiveClue( id: String(detail.id > 0 ? detail.id : Int(oldClue.id) ?? 0), type: markerType.radarType, displayTitle: detail.title.nonEmpty ?? oldClue.displayTitle, statusText: statusText, actionText: oldClue.actionText, nearestAbnormalText: oldClue.nearestAbnormalText, distance: formattedDistance(detail.distanceM), direction: oldClue.direction, updatedAt: detail.createdAt.nonEmpty ?? oldClue.updatedAt, name: detail.name.nonEmpty ?? oldClue.name, storeName: detail.storeName.nonEmpty ?? oldClue.storeName, avatar: detail.avatar.nonEmpty ?? oldClue.avatar, summary: detail.greenMarkerTip.nonEmpty ?? detail.reportTypeText.nonEmpty ?? oldClue.summary, detail: detail.activityText.nonEmpty ?? detail.desc.nonEmpty ?? oldClue.detail, distanceText: formattedDistance(detail.distanceM) ?? oldClue.distanceText, reporterName: detail.reporterName.nonEmpty ?? oldClue.reporterName, reporterPhone: detail.reporterPhone.nonEmpty ?? oldClue.reporterPhone, maskedReporterPhone: detail.reporterPhone.nonEmpty.map(Self.maskPhone) ?? oldClue.maskedReporterPhone, reportContents: reportContents, images: imageURLs.isEmpty ? oldClue.images : imageURLs, processingStarted: markerType == .yellow || statusText == WildReportStatus.processing.rawValue, completed: statusText == WildReportStatus.processed.rawValue, record: oldClue.record, processingTimeline: oldClue.processingTimeline, completionPhoto: oldClue.completionPhoto ) markers[index] = WildReportMapMarker( id: markers[index].id, title: detail.title.nonEmpty ?? markers[index].title, coordinate: markers[index].coordinate, kind: markerType.markerKind, detail: .activeClue(clue) ) } private func nearestAbnormalText(for markers: [WildReportMapMarker]) -> String { let distances = markers.compactMap { marker -> Double? in guard case .activeClue(let clue) = marker.detail, clue.type == .red || clue.type == .yellow, let distance = clue.distanceText ?? clue.distance else { return nil } if distance.hasSuffix("公里") { return Double(distance.replacingOccurrences(of: "公里", with: "")).map { $0 * 1000 } } return Double(distance.replacingOccurrences(of: "米", with: "")) } guard let minDistance = distances.min() else { return "周边暂无异常点位" } return "距离最近的异常点位\(formattedDistance(minDistance) ?? "0米")" } private func defaultStatusText(for markerType: WildReportRiskMarkerType, dto: WildReportRiskMapMarkerDTO) -> String { switch markerType { case .green: return dto.online == false ? "离线" : "在线" case .yellow: return "处理中线索" case .red, .unknown: return dto.reportTypeText.nonEmpty ?? "疑似打野" case .blue: return "当前位置" } } private func defaultTitle(for markerType: WildReportRiskMarkerType, id: Int) -> String { switch markerType { case .green: return "景区内摄影师" case .yellow, .red, .unknown: return "线索 ID:\(id)" case .blue: return "我的位置" } } private func formattedDistance(_ meters: Double) -> String? { guard meters > 0 else { return nil } if meters < 1000 { return "\(Int(meters.rounded()))米" } return String(format: "%.1f公里", meters / 1000) } private static func maskPhone(_ phone: String) -> String { guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone } return "\(phone.prefix(3))****\(phone.suffix(4))" } private func notifyStateChange() { onStateChange?() } } private extension String { var nonEmpty: String? { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? nil : text } }