// // WildPhotographerReportViewModels.swift // suixinkan // import Foundation import MapKit import UIKit /// 举报摄影师首页 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 selectedReportType: WildReportType = .suspectedWild private(set) var description = "" private(set) var contact = "" private(set) var reportLocation = "" private(set) var isUpdatingLocation = false 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 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 = WildPhotographerReportMockStore.initialImages self.videos = WildPhotographerReportMockStore.initialVideos } /// 首次进入页面时自动请求定位。 func loadInitialLocationIfNeeded() { guard !hasRequestedInitialLocation else { return } hasRequestedInitialLocation = true refreshCurrentLocation() } /// 选择举报类型。 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 addImage() { images.append(WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")) notifyStateChange() } /// 添加演示用现场视频。 func addVideo() { videos.append(WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")) notifyStateChange() } /// 添加演示用支付截图。 func addPaymentImage() { guard paymentImages.count < 3 else { onShowMessage?("支付截图最多上传3张") return } paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")) notifyStateChange() } /// 删除指定附件。 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() let address = snapshot.address.isEmpty ? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude) : snapshot.address applyResolvedLocation(address) } catch { applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation) } } } /// 清除提交结果,用于导航返回。 func clearSubmitResult() { submitResult = nil notifyStateChange() } /// 校验表单并提交举报。 func submitReport() { validationMessage = nil guard !images.isEmpty || !videos.isEmpty else { setValidationMessage("请至少上传一项现场证据。") return } guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { setValidationMessage("请填写举报说明。") return } guard !isUpdatingLocation else { setValidationMessage("正在获取定位,请稍后再提交。") return } guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else { setValidationMessage("请先更新举报位置后再提交举报。") return } 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 = WildPhotographerReportMockStore.initialImages videos = WildPhotographerReportMockStore.initialVideos paymentImages = [] selectedReportType = .suspectedWild description = "" contact = "" reportLocation = "" locationConfirmed = false isUpdatingLocation = false hasRequestedInitialLocation = false submitResult = nil validationMessage = nil notifyStateChange() } func applyResolvedLocation(_ location: String) { reportLocation = location locationConfirmed = true isUpdatingLocation = false notifyStateChange() } private func setValidationMessage(_ message: String) { validationMessage = message notifyStateChange() onShowMessage?(message) } private func notifyStateChange() { onStateChange?() } } /// 举报成功页 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 { let record: WildReportRecord private let homeViewModel: WildPhotographerReportHomeViewModel? private(set) var actionMessage: String? var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel? = nil) { self.record = record self.homeViewModel = homeViewModel } 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 { record.status.rawValue } var descriptionText: String { record.description } var contactText: String { record.contactDisplayText } var supplementaryEvidences: [WildReportSupplementaryEvidence] { homeViewModel?.supplementaryEvidences(for: record.id) ?? [] } var reporterMediaItems: [WildReportReporterMediaItem] { let images = (0.. [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: 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) ] } } 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(set) var selectedFilter: WildReportListFilter = .all private(set) var shareMessage: String? let homeViewModel: WildPhotographerReportHomeViewModel var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? init(homeViewModel: WildPhotographerReportHomeViewModel) { self.homeViewModel = homeViewModel } var filteredReports: [WildReportRecord] { let reports = homeViewModel.reports switch selectedFilter { case .all: return reports case .pending: return reports.filter { $0.status == .pending } case .processing: return reports.filter { $0.status == .processing } case .processed: return reports.filter { $0.status == .processed } } } var footerTip: String { "温馨提示:为保障处理效率,请如实举报并提供有效证据" } /// 切换列表筛选标签。 func selectFilter(_ filter: WildReportListFilter) { selectedFilter = filter notifyStateChange() } /// 生成分享提示文案。 func shareReport(_ record: WildReportRecord) { shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。" onShowMessage?(shareMessage ?? "") notifyStateChange() } 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 let reportID: String var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onSubmitSuccess: (() -> Void)? private let homeViewModel: WildPhotographerReportHomeViewModel init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) { self.reportID = reportID self.homeViewModel = homeViewModel } /// 限制补充说明最多 500 字。 func updateText(_ newValue: String) { text = String(newValue.prefix(500)) notifyStateChange() } /// 添加演示用补充图片。 func addImage() { guard images.count < 9 else { onShowMessage?("补充图片最多上传9张") return } images.append(WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")) notifyStateChange() } /// 添加演示用补充视频。 func addVideo() { guard videos.count < 3 else { onShowMessage?("补充视频最多上传3段") return } videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")) notifyStateChange() } /// 删除指定附件。 func removeAttachment(_ attachment: WildReportAttachment) { images.removeAll { $0.id == attachment.id } videos.removeAll { $0.id == attachment.id } notifyStateChange() } /// 校验并提交补充证据。 func submit() { validationMessage = nil let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else { validationMessage = "请至少填写补充说明或上传一项证据。" onShowMessage?(validationMessage ?? "") notifyStateChange() return } homeViewModel.appendSupplementaryEvidence( reportID: reportID, text: trimmedText, imageCount: images.count, videoCount: videos.count ) didSubmit = true notifyStateChange() onSubmitSuccess?() } private func notifyStateChange() { onStateChange?() } } /// 附近风险地图 ViewModel。 final class WildReportRiskMapViewModel { private(set) var region: MKCoordinateRegion private(set) var markers: [WildReportMapMarker] let scenicAreaName: String private(set) var selectedMarkerID: String? private(set) var toastMessage: String? var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) { let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints) self.markers = markers self.region = WildPhotographerReportMockStore.region(for: markers) self.scenicAreaName = "那拉提景区" 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 selectMarker(_ marker: WildReportMapMarker) { selectedMarkerID = marker.id notifyStateChange() } /// 导航到选中标记或刷新蓝点定位。 func navigateToSelectedMarker() { guard let marker = selectedMarker, case .activeClue(let clue) = marker.detail else { return } if clue.type == .blue { refreshLocation() return } showToast("已切换到该点位") region = MKCoordinateRegion( center: marker.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018) ) notifyStateChange() } /// 将地图居中到当前位置标记。 func refreshLocation() { guard let blueMarker = markers.first(where: { $0.kind == .selfLocation }) else { return } selectedMarkerID = blueMarker.id region = MKCoordinateRegion( center: blueMarker.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018) ) showToast("已定位到当前位置") notifyStateChange() } /// 生成线索分享提示。 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 notifyStateChange() { onStateChange?() } }