// // WildPhotographerReportViewModels.swift // suixinkan // // Created by Codex on 2026/7/1. // import Combine import CoreLocation import MapKit import SwiftUI import UIKit /// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。 @MainActor final class WildPhotographerReportHomeViewModel: ObservableObject { @Published var pageState: WildReportPageState = .normal @Published private(set) var reports: [WildReportRecord] = [] @Published private(set) var riskPoints: [WildReportRiskPoint] = [] @Published private(set) var supplementaryEvidences: [String: [WildReportSupplementaryEvidence]] = [:] private var nextSequence = 0 private var nextSupplementSequence = 0 init() { resetDemoData() } var emptyTitle: String { "暂无举报服务" } var emptyImage: String { "exclamationmark.shield" } var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" } /// 恢复演示数据并回到正常状态。 func restoreDemoData() { resetDemoData() pageState = .normal } /// 模拟错误后重新加载。 func retryLoadAfterError() { pageState = .loading Task { @MainActor in try? await Task.sleep(nanoseconds: 450_000_000) pageState = .normal } } /// 提交举报并写入 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) return record } /// 读取指定举报的补充证据列表。 func supplementaryEvidences(for reportID: String) -> [WildReportSupplementaryEvidence] { supplementaryEvidences[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 = supplementaryEvidences[reportID] ?? [] items.insert(item, at: 0) supplementaryEvidences[reportID] = items } /// 重置为种子 Mock 数据。 func resetDemoData() { reports = WildPhotographerReportMockStore.seedReports riskPoints = WildPhotographerReportMockStore.seedRiskPoints supplementaryEvidences = WildPhotographerReportMockStore.seedSupplementaryEvidences nextSequence = WildPhotographerReportMockStore.maxSequence(from: WildPhotographerReportMockStore.seedReports) + 1 nextSupplementSequence = 100 } 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) } } /// 提交举报页 ViewModel,负责表单校验和前台定位。 @MainActor final class WildPhotographerReportSubmitViewModel: ObservableObject { @Published var pageState: WildReportPageState = .normal @Published var images: [WildReportAttachment] @Published var videos: [WildReportAttachment] @Published var paymentImages: [WildReportAttachment] = [] @Published var selectedReportType: WildReportType = .suspectedWild @Published var description = "" @Published var contact = "" @Published var reportLocation = "" @Published var isUpdatingLocation = false @Published var locationConfirmed = false @Published var submitResult: WildReportSubmitResult? @Published var validationMessage: String? private let homeViewModel: WildPhotographerReportHomeViewModel private let locationProvider: ForegroundLocationProvider 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: ForegroundLocationProvider? = nil ) { self.homeViewModel = homeViewModel self.locationProvider = locationProvider ?? ForegroundLocationProvider() self.images = WildPhotographerReportMockStore.initialImages self.videos = WildPhotographerReportMockStore.initialVideos } /// 首次进入页面时自动请求定位。 func loadInitialLocationIfNeeded() { guard !hasRequestedInitialLocation else { return } hasRequestedInitialLocation = true refreshCurrentLocation() } /// 限制举报说明最多 500 字。 func updateDescription(_ newValue: String) { description = newValue.count > 500 ? String(newValue.prefix(500)) : newValue } /// 使用前台定位 Provider 刷新当前位置。 func refreshCurrentLocation() { isUpdatingLocation = true locationConfirmed = false reportLocation = "正在获取当前位置..." Task { do { let result = try await locationProvider.requestCurrentLocation() applyResolvedLocation(result.address) } catch { applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation) } } } /// 从空状态恢复演示数据。 func restoreDemoDataFromEmpty() { pageState = .normal refreshCurrentLocation() } /// 模拟加载失败后恢复。 func retryLoadAfterError() { pageState = .loading Task { @MainActor in try? await Task.sleep(nanoseconds: 450_000_000) pageState = .normal } } /// 清除提交结果,用于导航返回。 func clearSubmitResult() { submitResult = nil } /// 校验表单并提交举报。 func submitReport() { guard !images.isEmpty || !videos.isEmpty else { validationMessage = "请至少上传一项现场证据。" return } guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { validationMessage = "请填写举报说明。" return } guard !isUpdatingLocation else { validationMessage = "正在获取定位,请稍后再提交。" return } guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else { validationMessage = "请先更新举报位置后再提交举报。" return } let record = homeViewModel.submitReport( reportType: selectedReportType, description: description, location: reportLocation, contact: contact.isEmpty ? nil : contact, imageCount: images.count, videoCount: videos.count ) submitResult = WildReportSubmitResult( record: record, imageCount: images.count, videoCount: videos.count ) } /// 重置表单到初始状态。 func resetForm() { images = WildPhotographerReportMockStore.initialImages videos = WildPhotographerReportMockStore.initialVideos paymentImages = [] selectedReportType = .suspectedWild description = "" contact = "" reportLocation = "" locationConfirmed = false isUpdatingLocation = false hasRequestedInitialLocation = false submitResult = nil } private func applyResolvedLocation(_ location: String) { reportLocation = location locationConfirmed = true isUpdatingLocation = false } } /// 举报成功页 ViewModel。 @MainActor final class WildPhotographerReportSuccessViewModel: ObservableObject { 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: " ") } /// 复制举报编号到剪贴板。 func copyReportID() -> String { UIPasteboard.general.string = result.record.id return result.record.id } } /// 举报详情页 ViewModel。 @MainActor final class WildPhotographerReportDetailViewModel: ObservableObject { let record: WildReportRecord private let homeViewModel: WildPhotographerReportHomeViewModel? @Published var actionMessage: String? 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 displayStatusColor: Color { record.status.displayColor } var descriptionText: String { record.description } var contactText: String { record.contactDisplayText } var supplementaryEvidences: [WildReportSupplementaryEvidence] { homeViewModel?.supplementaryEvidences(for: record.id) ?? [] } var imageCountText: String { "\(record.imageCount)张" } var videoCountText: String { record.videoCount > 0 ? "\(record.videoCount)段" : "0段" } var reporterMediaSummary: String { switch (record.imageCount, record.videoCount) { case (0, 0): "0项" case (_, 0): "\(record.imageCount)张" case (0, _): "\(record.videoCount)段" default: "\(record.imageCount)张 \(record.videoCount)段" } } var reporterMediaItems: [WildReportReporterMediaItem] { var items: [WildReportReporterMediaItem] = [] for index in 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) } } /// 我的举报列表 ViewModel。 @MainActor final class WildPhotographerReportListViewModel: ObservableObject { @Published var selectedFilter: WildReportListFilter = .all @Published var shareMessage: String? let homeViewModel: WildPhotographerReportHomeViewModel 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) { withAnimation(.easeInOut(duration: 0.22)) { selectedFilter = filter } } /// 生成分享提示文案。 func shareReport(_ record: WildReportRecord) { shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。" } } /// 补充证据页 ViewModel。 @MainActor final class WildReportSupplementEvidenceViewModel: ObservableObject { @Published var text = "" @Published var images: [WildReportAttachment] = [] @Published var videos: [WildReportAttachment] = [] @Published var validationMessage: String? @Published private(set) var didSubmit = false let reportID: String private let homeViewModel: WildPhotographerReportHomeViewModel init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) { self.reportID = reportID self.homeViewModel = homeViewModel } /// 限制补充说明最多 500 字。 func updateText(_ newValue: String) { text = newValue.count > 500 ? String(newValue.prefix(500)) : newValue } /// 添加演示用补充图片。 func addImage() { guard images.count < 9 else { return } images.append(WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")) } /// 添加演示用补充视频。 func addVideo() { guard videos.count < 3 else { return } videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")) } /// 校验并提交补充证据。 func submit() { let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else { validationMessage = "请至少填写补充说明或上传一项证据。" return } homeViewModel.appendSupplementaryEvidence( reportID: reportID, text: trimmedText, imageCount: images.count, videoCount: videos.count ) didSubmit = true } } /// 附近风险地图 ViewModel。 @MainActor final class WildReportRiskMapViewModel: ObservableObject { @Published var region: MKCoordinateRegion @Published var markers: [WildReportMapMarker] let scenicAreaName: String @Published var selectedMarkerID: String? @Published var previewMedia: WildReportMapMediaItem? @Published var previewImageURL: WildReportPreviewImageURL? @Published var toastMessage: String? 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 } /// 展示媒体预览层。 func showMediaPreview(_ media: WildReportMapMediaItem) { previewMedia = media } /// 关闭媒体预览层。 func dismissMediaPreview() { previewMedia = nil } /// 展示网络图片预览。 func showImagePreview(_ url: String) { previewImageURL = WildReportPreviewImageURL(url: url) } /// 关闭网络图片预览。 func dismissImagePreview() { previewImageURL = nil } /// 导航到选中标记或刷新蓝点定位。 func navigateToSelectedMarker() { guard let marker = selectedMarker, case .activeClue(let clue) = marker.detail else { return } if clue.type == .blue { refreshLocation() return } let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: marker.coordinate)) mapItem.name = marker.title mapItem.openInMaps(launchOptions: [ MKLaunchOptionsMapCenterKey: NSValue(mkCoordinate: marker.coordinate), MKLaunchOptionsMapSpanKey: NSValue(mkCoordinateSpan: region.span) ]) showToast("已切换到该点位") } /// 将地图居中到当前位置标记。 func refreshLocation() { guard let blueMarker = markers.first(where: { $0.kind == .selfLocation }) else { return } selectedMarkerID = blueMarker.id withAnimation(.easeInOut(duration: 0.35)) { region = MKCoordinateRegion( center: blueMarker.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018) ) } showToast("已定位到当前位置") } /// 生成线索分享提示。 func shareSelectedClue() { guard let clue = selectedClue else { return } let title = clue.type == .red ? "线索 ID:\(clue.id)" : clue.displayTitle showToast("已生成\(title)的分享信息,可发送给他人查看。") } private func showToast(_ message: String) { toastMessage = message } /// 清除地图页 Toast。 func clearToast() { toastMessage = nil } }