From 939295d74a9340608a3641f4af8cda13e5107da4 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:52:29 +0800 Subject: [PATCH] feat: connect wild report risk map --- .../API/WildPhotographerReportAPI.swift | 56 ++ .../Models/WildPhotographerReportModels.swift | 409 ++++++++++ .../WildPhotographerReportMockStore.swift | 2 + .../WildPhotographerReportViewModels.swift | 401 +++++++++- .../WildReportRiskMapViewController.swift | 739 +++++++++++++++--- .../WildPhotographerReportTests.swift | 357 +++++++++ 6 files changed, 1829 insertions(+), 135 deletions(-) diff --git a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift index 6e465da..5f6e821 100644 --- a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift +++ b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift @@ -14,6 +14,10 @@ protocol WildPhotographerReportServing { func submitReport(_ request: WildReportSubmitRequest) async throws /// 为已有举报补充证据。 func supplementReport(_ request: WildReportSupplementRequest) async throws + /// 拉取附近风险地图点位。 + func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse + /// 拉取风险地图单个标记详情。 + func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse /// 拉取我的举报列表。 func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse /// 拉取单条举报详情。 @@ -59,6 +63,28 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing { ) } + /// 拉取附近风险地图点位。 + func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/report/risk-map", + body: request + ) + ) + } + + /// 拉取风险地图单个标记详情。 + func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/report/marker-detail", + body: request + ) + ) + } + /// 拉取我的举报列表。 func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse { try await client.send( @@ -89,6 +115,36 @@ struct WildReportSupplementRequest: Encodable, Equatable { let evidences: [WildReportEvidenceRequest]? } +/// 附近风险地图接口请求体。 +struct WildReportRiskMapRequest: Encodable, Equatable { + let scenicId: Int + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case latitude + case longitude + } +} + +/// 风险地图标记详情接口请求体。 +struct WildReportMarkerDetailRequest: Encodable, Equatable { + let scenicId: Int + let markerType: String + let id: Int + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case markerType = "marker_type" + case id + case latitude + case longitude + } +} + /// 提交举报接口请求体。 struct WildReportSubmitRequest: Encodable, Equatable { let scenicId: Int diff --git a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift index 84239a0..517f509 100644 --- a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift +++ b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift @@ -868,6 +868,415 @@ enum WildReportRadarMarkerType: String, Hashable { case blue case green case red + case yellow +} + +/// 风险地图接口标记类型,兼容后端新增枚举。 +enum WildReportRiskMarkerType: Hashable { + case blue + case green + case red + case yellow + case unknown(String) + + init(rawValue: String) { + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "blue", "self", "self_location": + self = .blue + case "green": + self = .green + case "red": + self = .red + case "yellow": + self = .yellow + default: + self = .unknown(rawValue) + } + } + + var rawValue: String { + switch self { + case .blue: return "blue" + case .green: return "green" + case .red: return "red" + case .yellow: return "yellow" + case .unknown(let value): return value + } + } + + var markerKind: WildReportMapMarkerKind { + switch self { + case .blue: + return .selfLocation + case .green: + return .peerPhotographer + case .yellow: + return .processingClue + case .red, .unknown: + return .wildPhotographer + } + } + + var radarType: WildReportRadarMarkerType { + switch self { + case .blue: + return .blue + case .green: + return .green + case .yellow: + return .yellow + case .red, .unknown: + return .red + } + } +} + +/// 风险地图后端状态字段,兼容数字和字符串两种形态。 +struct WildReportDecodedHandleStatus: Decodable, Equatable, Hashable { + let intValue: Int? + let textValue: String? + + init(intValue: Int? = nil, textValue: String? = nil) { + self.intValue = intValue + self.textValue = textValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Int.self) { + intValue = value + textValue = nil + } else if let value = try? container.decode(String.self) { + intValue = Int(value) + textValue = value + } else { + intValue = nil + textValue = nil + } + } + + var displayText: String { + if let text = textValue?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { + switch text { + case "0": return WildReportStatus.pending.rawValue + case "1": return WildReportStatus.processing.rawValue + case "2": return WildReportStatus.processed.rawValue + case "3": return WildReportStatus.rejected.rawValue + default: return text + } + } + return WildReportStatus(handleStatus: intValue ?? 0).rawValue + } +} + +/// 附近风险地图接口返回体。 +struct WildReportRiskMapResponse: Decodable, Equatable { + let scenicId: Int + let scenicName: String + let legend: [WildReportRiskLegendItem] + let greenMarkerTip: String + let markers: [WildReportRiskMapMarkerDTO] + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case scenicName = "scenic_name" + case legend + case greenMarkerTip = "green_marker_tip" + case markers + } + + init( + scenicId: Int, + scenicName: String, + legend: [WildReportRiskLegendItem] = [], + greenMarkerTip: String = "", + markers: [WildReportRiskMapMarkerDTO] = [] + ) { + self.scenicId = scenicId + self.scenicName = scenicName + self.legend = legend + self.greenMarkerTip = greenMarkerTip + self.markers = markers + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + scenicId = try container.decodeIfPresent(Int.self, forKey: .scenicId) ?? 0 + scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName) ?? "" + legend = try container.decodeIfPresent([WildReportRiskLegendItem].self, forKey: .legend) ?? [] + greenMarkerTip = try container.decodeIfPresent(String.self, forKey: .greenMarkerTip) ?? "" + markers = try container.decodeIfPresent([WildReportRiskMapMarkerDTO].self, forKey: .markers) ?? [] + } +} + +/// 风险地图图例项,兼容后端不同字段命名。 +struct WildReportRiskLegendItem: Decodable, Equatable, Hashable { + let markerType: String? + let title: String? + let color: String? + + enum CodingKeys: String, CodingKey { + case markerType = "marker_type" + case type + case title + case text + case name + case color + } + + init(markerType: String? = nil, title: String? = nil, color: String? = nil) { + self.markerType = markerType + self.title = title + self.color = color + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + markerType = try container.decodeIfPresent(String.self, forKey: .markerType) + ?? (try container.decodeIfPresent(String.self, forKey: .type)) + title = try container.decodeIfPresent(String.self, forKey: .title) + ?? (try container.decodeIfPresent(String.self, forKey: .text)) + ?? (try container.decodeIfPresent(String.self, forKey: .name)) + color = try container.decodeIfPresent(String.self, forKey: .color) + } +} + +/// 风险地图列表接口的标记摘要。 +struct WildReportRiskMapMarkerDTO: Decodable, Equatable, Hashable { + let markerType: String + let id: Int + let latitude: String + let longitude: String + let locationName: String + let title: String + let reportTypeText: String + let handleStatus: WildReportDecodedHandleStatus? + let handleStatusText: String? + let distanceM: Double + let createdAt: String + let tag: String? + let name: String? + let avatar: String? + let storeName: String? + let online: Bool? + let greenMarkerTip: String? + let activityText: String? + + enum CodingKeys: String, CodingKey { + case markerType = "marker_type" + case id + case latitude + case longitude + case locationName = "location_name" + case title + case reportTypeText = "report_type_text" + case handleStatus = "handle_status" + case handleStatusText = "handle_status_text" + case distanceM = "distance_m" + case createdAt = "created_at" + case tag + case name + case avatar + case storeName = "store_name" + case online + case greenMarkerTip = "green_marker_tip" + case activityText = "activity_text" + } + + init( + markerType: String, + id: Int, + latitude: String, + longitude: String, + locationName: String, + title: String, + reportTypeText: String = "", + handleStatus: WildReportDecodedHandleStatus? = nil, + handleStatusText: String? = nil, + distanceM: Double = 0, + createdAt: String = "", + tag: String? = nil, + name: String? = nil, + avatar: String? = nil, + storeName: String? = nil, + online: Bool? = nil, + greenMarkerTip: String? = nil, + activityText: String? = nil + ) { + self.markerType = markerType + self.id = id + self.latitude = latitude + self.longitude = longitude + self.locationName = locationName + self.title = title + self.reportTypeText = reportTypeText + self.handleStatus = handleStatus + self.handleStatusText = handleStatusText + self.distanceM = distanceM + self.createdAt = createdAt + self.tag = tag + self.name = name + self.avatar = avatar + self.storeName = storeName + self.online = online + self.greenMarkerTip = greenMarkerTip + self.activityText = activityText + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + markerType = try container.decodeIfPresent(String.self, forKey: .markerType) ?? "" + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + latitude = try container.decodeIfPresent(String.self, forKey: .latitude) ?? "" + longitude = try container.decodeIfPresent(String.self, forKey: .longitude) ?? "" + locationName = try container.decodeIfPresent(String.self, forKey: .locationName) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + reportTypeText = try container.decodeIfPresent(String.self, forKey: .reportTypeText) ?? "" + handleStatus = try container.decodeIfPresent(WildReportDecodedHandleStatus.self, forKey: .handleStatus) + handleStatusText = try container.decodeIfPresent(String.self, forKey: .handleStatusText) + distanceM = try container.decodeIfPresent(Double.self, forKey: .distanceM) ?? 0 + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + tag = try container.decodeIfPresent(String.self, forKey: .tag) + name = try container.decodeIfPresent(String.self, forKey: .name) + avatar = try container.decodeIfPresent(String.self, forKey: .avatar) + storeName = try container.decodeIfPresent(String.self, forKey: .storeName) + online = try container.decodeIfPresent(Bool.self, forKey: .online) + greenMarkerTip = try container.decodeIfPresent(String.self, forKey: .greenMarkerTip) + activityText = try container.decodeIfPresent(String.self, forKey: .activityText) + } +} + +/// 风险地图标记详情接口返回体。 +struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable { + let markerType: String + let id: Int + let title: String + let tag: String + let name: String + let avatar: String + let storeName: String + let online: Bool + let locationName: String + let latitude: String + let longitude: String + let distanceM: Double + let greenMarkerTip: String + let activityText: String + let reportTypeText: String + let handleStatus: WildReportDecodedHandleStatus? + let handleStatusText: String + let desc: String + let reporterName: String + let reporterPhone: String + let createdAt: String + let reportContents: [WildReportDetailContent] + let evidences: [WildReportDetailEvidence] + + enum CodingKeys: String, CodingKey { + case markerType = "marker_type" + case id + case title + case tag + case name + case avatar + case storeName = "store_name" + case online + case locationName = "location_name" + case latitude + case longitude + case distanceM = "distance_m" + case greenMarkerTip = "green_marker_tip" + case activityText = "activity_text" + case reportTypeText = "report_type_text" + case handleStatus = "handle_status" + case handleStatusText = "handle_status_text" + case desc + case reporterName = "reporter_name" + case reporterPhone = "reporter_phone" + case contactPhone = "contact_phone" + case createdAt = "created_at" + case reportContents = "report_contents" + case evidences + } + + init( + markerType: String, + id: Int, + title: String = "", + tag: String = "", + name: String = "", + avatar: String = "", + storeName: String = "", + online: Bool = false, + locationName: String = "", + latitude: String = "", + longitude: String = "", + distanceM: Double = 0, + greenMarkerTip: String = "", + activityText: String = "", + reportTypeText: String = "", + handleStatus: WildReportDecodedHandleStatus? = nil, + handleStatusText: String = "", + desc: String = "", + reporterName: String = "", + reporterPhone: String = "", + createdAt: String = "", + reportContents: [WildReportDetailContent] = [], + evidences: [WildReportDetailEvidence] = [] + ) { + self.markerType = markerType + self.id = id + self.title = title + self.tag = tag + self.name = name + self.avatar = avatar + self.storeName = storeName + self.online = online + self.locationName = locationName + self.latitude = latitude + self.longitude = longitude + self.distanceM = distanceM + self.greenMarkerTip = greenMarkerTip + self.activityText = activityText + self.reportTypeText = reportTypeText + self.handleStatus = handleStatus + self.handleStatusText = handleStatusText + self.desc = desc + self.reporterName = reporterName + self.reporterPhone = reporterPhone + self.createdAt = createdAt + self.reportContents = reportContents + self.evidences = evidences + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + markerType = try container.decodeIfPresent(String.self, forKey: .markerType) ?? "" + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + tag = try container.decodeIfPresent(String.self, forKey: .tag) ?? "" + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + avatar = try container.decodeIfPresent(String.self, forKey: .avatar) ?? "" + storeName = try container.decodeIfPresent(String.self, forKey: .storeName) ?? "" + online = try container.decodeIfPresent(Bool.self, forKey: .online) ?? false + locationName = try container.decodeIfPresent(String.self, forKey: .locationName) ?? "" + latitude = try container.decodeIfPresent(String.self, forKey: .latitude) ?? "" + longitude = try container.decodeIfPresent(String.self, forKey: .longitude) ?? "" + distanceM = try container.decodeIfPresent(Double.self, forKey: .distanceM) ?? 0 + greenMarkerTip = try container.decodeIfPresent(String.self, forKey: .greenMarkerTip) ?? "" + activityText = try container.decodeIfPresent(String.self, forKey: .activityText) ?? "" + reportTypeText = try container.decodeIfPresent(String.self, forKey: .reportTypeText) ?? "" + handleStatus = try container.decodeIfPresent(WildReportDecodedHandleStatus.self, forKey: .handleStatus) + handleStatusText = try container.decodeIfPresent(String.self, forKey: .handleStatusText) ?? "" + desc = try container.decodeIfPresent(String.self, forKey: .desc) ?? "" + reporterName = try container.decodeIfPresent(String.self, forKey: .reporterName) ?? "" + reporterPhone = try container.decodeIfPresent(String.self, forKey: .reporterPhone) + ?? (try container.decodeIfPresent(String.self, forKey: .contactPhone)) + ?? "" + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + reportContents = try container.decodeIfPresent([WildReportDetailContent].self, forKey: .reportContents) ?? [] + evidences = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .evidences) ?? [] + } } /// 雷达线索举报内容条目。 diff --git a/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift b/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift index 55ca26b..fd356cf 100644 --- a/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift +++ b/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift @@ -231,6 +231,8 @@ enum WildPhotographerReportMockStore { return .selfLocation case .green: return .peerPhotographer + case .yellow: + return .processingClue case .red: if clue.processingStarted && !clue.completed { return .processingClue diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index 57b14db..ed6bf07 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -1141,20 +1141,31 @@ final class WildReportSupplementEvidenceViewModel { /// 附近风险地图 ViewModel。 final class WildReportRiskMapViewModel { + private let fallbackMarkers: [WildReportMapMarker] + private var loadedDetailMarkerIDs: Set = [] + private(set) var region: MKCoordinateRegion private(set) var markers: [WildReportMapMarker] - let scenicAreaName: String + 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) - self.scenicAreaName = "那拉提景区" + let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) + self.scenicAreaName = scenicName.isEmpty ? "那拉提景区" : scenicName self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id } @@ -1169,40 +1180,86 @@ final class WildReportRiskMapViewModel { return clue } - /// 选中地图标记。 - func selectMarker(_ marker: WildReportMapMarker) { - selectedMarkerID = marker.id - notifyStateChange() + /// 首次加载附近风险地图数据。 + 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 navigateToSelectedMarker() { - guard let marker = selectedMarker, - case .activeClue(let clue) = marker.detail else { return } - - if clue.type == .blue { - refreshLocation() - return - } - - showToast("已切换到该点位") + /// 选中地图标记。 + 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() { - 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) + 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 ) - showToast("已定位到当前位置") - notifyStateChange() } /// 生成线索分享提示。 @@ -1224,7 +1281,301 @@ final class WildReportRiskMapViewModel { 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 + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift index e7f70d5..cfd28f1 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift @@ -3,6 +3,7 @@ // suixinkan // +import Kingfisher import MapKit import SnapKit import UIKit @@ -10,12 +11,36 @@ import UIKit /// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。 final class WildReportRiskMapViewController: BaseViewController { private let viewModel: WildReportRiskMapViewModel + private let api: any WildPhotographerReportServing + private let locationProvider: any LocationProviding + private let scenicIdProvider: () -> Int + private let scenicNameProvider: () -> String + + private let rootStack = UIStackView() + private let mapContainerView = UIView() private let mapView = MKMapView() - private let panel = WildReportCardView(spacing: AppSpacing.sm) + private let scenicCapsule = UIView() + private let scenicNameLabel = UILabel() + private let legendBar = UIStackView() + private let detailContainerView = UIView() + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) private var annotationMap: [String: WildReportMapMarker] = [:] - init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) { + init( + record: WildReportRecord, + riskPoints: [WildReportRiskPoint], + api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, + locationProvider: any LocationProviding = LocationProvider.shared, + scenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }, + scenicNameProvider: @escaping () -> String = { AppStore.shared.currentScenicName } + ) { self.viewModel = WildReportRiskMapViewModel(record: record, riskPoints: riskPoints) + self.api = api + self.locationProvider = locationProvider + self.scenicIdProvider = scenicIdProvider + self.scenicNameProvider = scenicNameProvider super.init(nibName: nil, bundle: nil) } @@ -24,39 +49,67 @@ final class WildReportRiskMapViewController: BaseViewController { fatalError("init(coder:) has not been implemented") } + override func viewDidLoad() { + super.viewDidLoad() + Task { + await viewModel.loadInitial( + api: api, + locationProvider: locationProvider, + scenicId: scenicIdProvider(), + scenicName: scenicNameProvider() + ) + } + } + override func setupNavigationBar() { title = "附近风险地图" } override func setupUI() { - view.backgroundColor = AppColor.pageBackgroundSoft + view.backgroundColor = UIColor(hex: 0xF8FAFF) + rootStack.axis = .vertical + rootStack.spacing = 0 + view.addSubview(rootStack) + + mapContainerView.backgroundColor = UIColor(hex: 0xE8F3FF) + mapContainerView.clipsToBounds = true + rootStack.addArrangedSubview(mapContainerView) + mapView.delegate = self mapView.showsCompass = false mapView.showsScale = true - view.addSubview(mapView) - view.addSubview(panel) - addLegend() - applyMap() - rebuildPanel() + mapView.pointOfInterestFilter = .excludingAll + mapContainerView.addSubview(mapView) + + configureScenicCapsule() + configureLegendBar() + configureDetailArea() + refreshContent(animated: false) } override func setupConstraints() { - mapView.snp.makeConstraints { make in - make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + rootStack.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + mapContainerView.snp.makeConstraints { make in make.height.equalTo(340) } - panel.snp.makeConstraints { make in - make.top.equalTo(mapView.snp.bottom).offset(AppSpacing.sm) - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm) + mapView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + scenicCapsule.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(12) + make.height.equalTo(34) + } + detailContainerView.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(180) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in - self?.mapView.setRegion(self?.viewModel.region ?? MKCoordinateRegion(), animated: true) - self?.rebuildPanel() + self?.refreshContent(animated: true) } } viewModel.onShowMessage = { [weak self] message in @@ -64,127 +117,377 @@ final class WildReportRiskMapViewController: BaseViewController { } } - private func applyMap() { - mapView.setRegion(viewModel.region, animated: false) + private func configureScenicCapsule() { + scenicCapsule.backgroundColor = UIColor.white.withAlphaComponent(0.94) + scenicCapsule.layer.cornerRadius = 17 + scenicCapsule.layer.shadowColor = UIColor.black.cgColor + scenicCapsule.layer.shadowOpacity = 0.08 + scenicCapsule.layer.shadowRadius = 8 + scenicCapsule.layer.shadowOffset = CGSize(width: 0, height: 4) + + let icon = UIImageView(image: UIImage(systemName: "mountain.2.fill")) + icon.tintColor = AppColor.primary + icon.contentMode = .scaleAspectFit + scenicNameLabel.font = .systemFont(ofSize: 13, weight: .semibold) + scenicNameLabel.textColor = UIColor(hex: 0x195591) + + mapContainerView.addSubview(scenicCapsule) + scenicCapsule.addSubview(icon) + scenicCapsule.addSubview(scenicNameLabel) + icon.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.size.equalTo(16) + } + scenicNameLabel.snp.makeConstraints { make in + make.leading.equalTo(icon.snp.trailing).offset(6) + make.trailing.equalToSuperview().inset(12) + make.centerY.equalToSuperview() + } + } + + private func configureLegendBar() { + legendBar.axis = .horizontal + legendBar.alignment = .center + legendBar.distribution = .fillEqually + legendBar.spacing = 6 + legendBar.backgroundColor = .white + legendBar.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + legendBar.isLayoutMarginsRelativeArrangement = true + rootStack.addArrangedSubview(legendBar) + rebuildLegend() + + let divider = UIView() + divider.backgroundColor = AppColor.cardOutline + rootStack.addArrangedSubview(divider) + divider.snp.makeConstraints { make in + make.height.equalTo(1 / UIScreen.main.scale) + } + } + + private func configureDetailArea() { + detailContainerView.backgroundColor = UIColor(hex: 0xF8FAFF) + rootStack.addArrangedSubview(detailContainerView) + detailContainerView.addSubview(scrollView) + scrollView.alwaysBounceVertical = true + scrollView.addSubview(contentStack) + contentStack.axis = .vertical + contentStack.spacing = 14 + scrollView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 18, bottom: 18, right: 18)) + make.width.equalTo(scrollView.snp.width).offset(-36) + } + } + + @MainActor + private func refreshContent(animated: Bool) { + scenicNameLabel.text = viewModel.scenicAreaName + syncMapAnnotations() + mapView.setRegion(viewModel.region, animated: animated) + updateAnnotationSelection() + rebuildLegend() + rebuildPanel() + } + + private func syncMapAnnotations() { + let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation } + mapView.removeAnnotations(oldAnnotations) annotationMap.removeAll() let annotations = viewModel.markers.map { marker in annotationMap[marker.id] = marker - let annotation = WildReportMapAnnotation(marker: marker) - return annotation + return WildReportMapAnnotation(marker: marker) } mapView.addAnnotations(annotations) } - private func addLegend() { - let legend = UIStackView() - legend.axis = .horizontal - legend.distribution = .fillEqually - legend.backgroundColor = UIColor.white.withAlphaComponent(0.94) - legend.layer.cornerRadius = AppRadius.md - legend.clipsToBounds = true + private func updateAnnotationSelection() { + for annotation in mapView.annotations { + guard let annotation = annotation as? WildReportMapAnnotation, + let view = mapView.view(for: annotation) as? WildReportPulseMarkerAnnotationView + else { continue } + view.apply(marker: annotation.marker, selected: annotation.marker.id == viewModel.selectedMarkerID) + } + } + + private func rebuildLegend() { + legendBar.arrangedSubviews.forEach { view in + legendBar.removeArrangedSubview(view) + view.removeFromSuperview() + } [ WildReportMapMarkerKind.selfLocation, .wildPhotographer, .processingClue, .peerPhotographer ].forEach { kind in - let row = UIStackView() - row.axis = .horizontal - row.alignment = .center - row.spacing = 4 - let dot = UIView() - dot.backgroundColor = kind.color - dot.layer.cornerRadius = 4 - dot.snp.makeConstraints { make in make.size.equalTo(8) } - let label = WildReportUI.label(kind.title, font: .app(.caption), color: AppColor.textSecondary) - row.addArrangedSubview(dot) - row.addArrangedSubview(label) - legend.addArrangedSubview(row) - } - view.addSubview(legend) - legend.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) - make.height.equalTo(36) + legendBar.addArrangedSubview(WildReportRiskLegendView(kind: kind)) } } @MainActor private func rebuildPanel() { - panel.stack.arrangedSubviews.forEach { view in - panel.stack.removeArrangedSubview(view) + contentStack.arrangedSubviews.forEach { view in + contentStack.removeArrangedSubview(view) view.removeFromSuperview() } + + if viewModel.isLoading { + contentStack.addArrangedSubview(makeLoadingView(text: "正在加载风险点位...")) + } + if let message = viewModel.errorMessage, !viewModel.isLoading { + contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.triangle.fill", text: message)) + } + guard let marker = viewModel.selectedMarker, - case .activeClue(let clue) = marker.detail else { - panel.stack.addArrangedSubview(WildReportUI.label("点击地图标记查看详情", font: .app(.body), color: AppColor.textSecondary)) + case .activeClue(let clue) = marker.detail + else { + contentStack.addArrangedSubview(makeStateCard(icon: "hand.tap.fill", text: "点击地图标记查看详情")) return } - let header = UIStackView() - header.axis = .horizontal - header.alignment = .center - let title = WildReportUI.label(clue.displayTitle, font: .app(.title), color: AppColor.textPrimary, lines: 0) - let badge = UILabel() - badge.text = clue.statusText - badge.font = .app(.captionMedium) - badge.textColor = marker.kind.color - badge.textAlignment = .center - badge.backgroundColor = marker.kind.color.withAlphaComponent(0.12) - badge.layer.cornerRadius = 12 - badge.clipsToBounds = true - badge.snp.makeConstraints { make in - make.width.greaterThanOrEqualTo(74) - make.height.equalTo(24) + let card = WildReportCardView(spacing: 16, inset: 16) + card.layer.cornerRadius = 16 + card.stack.addArrangedSubview(makeHeader(marker: marker, clue: clue)) + if viewModel.isLoadingDetail { + card.stack.addArrangedSubview(makeInlineLoading(text: "正在加载标记详情...")) } - header.addArrangedSubview(title) - header.addArrangedSubview(UIView()) - header.addArrangedSubview(badge) - panel.stack.addArrangedSubview(header) - if let nearest = clue.nearestAbnormalText { - panel.stack.addArrangedSubview(WildReportUI.label(nearest, font: .app(.bodyMedium), color: AppColor.danger, lines: 0)) + card.stack.addArrangedSubview(makeHighlight(text: nearest, color: AppColor.danger)) } + addInfoRows(for: clue, to: card.stack) + addDescriptionBlocks(for: clue, to: card.stack) + addMediaRow(for: clue, to: card.stack) + addProcessingBlock(for: clue, to: card.stack) + card.stack.addArrangedSubview(makeButtonRow(for: clue)) + contentStack.addArrangedSubview(card) + } + + private func makeHeader(marker: WildReportMapMarker, clue: WildReportRadarActiveClue) -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 12 + + let iconWrap = UIView() + iconWrap.backgroundColor = marker.kind.color.withAlphaComponent(0.14) + iconWrap.layer.cornerRadius = 22 + let icon = UIImageView(image: UIImage(systemName: iconName(for: marker.kind))) + icon.tintColor = marker.kind.color + icon.contentMode = .scaleAspectFit + iconWrap.addSubview(icon) + icon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(22) + } + iconWrap.snp.makeConstraints { make in + make.size.equalTo(44) + } + + let textStack = UIStackView() + textStack.axis = .vertical + textStack.spacing = 4 + let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 17, weight: .bold), color: AppColor.textPrimary, lines: 2) + let metaText = [clue.distanceText ?? clue.distance, clue.updatedAt].compactMap { $0 }.joined(separator: " · ") + let meta = WildReportUI.label(metaText.isEmpty ? marker.title : metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 1) + textStack.addArrangedSubview(title) + textStack.addArrangedSubview(meta) + + let badge = WildReportRiskStatusBadge(text: clue.statusText, color: marker.kind.color) + row.addArrangedSubview(iconWrap) + row.addArrangedSubview(textStack) + row.addArrangedSubview(badge) + textStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + badge.setContentHuggingPriority(.required, for: .horizontal) + return row + } + + private func addInfoRows(for clue: WildReportRadarActiveClue, to stack: UIStackView) { if let name = clue.name { - panel.stack.addArrangedSubview(WildReportInfoRowView(title: "点位", value: name)) + stack.addArrangedSubview(WildReportInfoRowView(title: clue.type == .green ? "摄影师" : "点位", value: name)) } if let store = clue.storeName { - panel.stack.addArrangedSubview(WildReportInfoRowView(title: "门店", value: store)) + stack.addArrangedSubview(WildReportInfoRowView(title: "门店", value: store)) } if let distance = clue.distanceText ?? clue.distance { - panel.stack.addArrangedSubview(WildReportInfoRowView(title: "距离", value: distance)) + stack.addArrangedSubview(WildReportInfoRowView(title: "距离", value: distance)) } if let updatedAt = clue.updatedAt { - panel.stack.addArrangedSubview(WildReportInfoRowView(title: "时间", value: updatedAt)) + stack.addArrangedSubview(WildReportInfoRowView(title: "时间", value: updatedAt)) } - if let summary = clue.summary ?? clue.detail { - panel.stack.addArrangedSubview(WildReportUI.label(summary, font: .app(.body), color: AppColor.textSecondary, lines: 0)) + if let reporter = clue.reporterName { + stack.addArrangedSubview(WildReportInfoRowView(title: "举报人", value: reporter)) } - clue.reportContents.prefix(2).forEach { content in - panel.stack.addArrangedSubview(WildReportUI.label("\(content.time) \(content.content)", font: .app(.caption), color: AppColor.textSecondary, lines: 0)) - } - if let record = clue.record { - panel.stack.addArrangedSubview(WildReportInfoRowView(title: "处理人", value: record.handlerName)) - panel.stack.addArrangedSubview(WildReportUI.label(record.processResult, font: .app(.body), color: AppColor.textSecondary, lines: 0)) + if let phone = clue.maskedReporterPhone ?? clue.reporterPhone { + stack.addArrangedSubview(WildReportInfoRowView(title: "联系电话", value: phone)) } + } - let buttonRow = UIStackView() - buttonRow.axis = .horizontal - buttonRow.spacing = AppSpacing.sm - buttonRow.distribution = .fillEqually - let navButton = WildReportUI.iconButton(title: clue.actionText, imageName: "location.fill", style: .secondary) + private func addDescriptionBlocks(for clue: WildReportRadarActiveClue, to stack: UIStackView) { + if let summary = clue.summary { + stack.addArrangedSubview(makeBodyLabel(summary)) + } + if let detail = clue.detail, detail != clue.summary { + stack.addArrangedSubview(makeBodyLabel(detail)) + } + for content in clue.reportContents.prefix(3) { + let time = content.time.isEmpty ? "" : "\(content.time) " + stack.addArrangedSubview(makeBodyLabel("\(time)\(content.content)")) + } + } + + private func addMediaRow(for clue: WildReportRadarActiveClue, to stack: UIStackView) { + guard !clue.images.isEmpty else { return } + let scroll = UIScrollView() + scroll.showsHorizontalScrollIndicator = false + let row = UIStackView() + row.axis = .horizontal + row.spacing = 10 + scroll.addSubview(row) + row.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalTo(72) + } + for url in clue.images.prefix(9) { + let tile = WildReportRiskImageTile(url: url) + tile.onTap = { [weak self] in self?.presentImagePreview(url: url) } + row.addArrangedSubview(tile) + } + stack.addArrangedSubview(scroll) + scroll.snp.makeConstraints { make in + make.height.equalTo(72) + } + } + + private func addProcessingBlock(for clue: WildReportRadarActiveClue, to stack: UIStackView) { + guard let record = clue.record else { return } + let box = UIView() + box.backgroundColor = UIColor(hex: 0xF8FAFF) + box.layer.cornerRadius = 12 + box.layer.borderWidth = 1 + box.layer.borderColor = AppColor.cardOutline.cgColor + let inner = UIStackView() + inner.axis = .vertical + inner.spacing = 8 + box.addSubview(inner) + inner.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(12) + } + inner.addArrangedSubview(WildReportInfoRowView(title: "处理人", value: record.handlerName)) + inner.addArrangedSubview(makeBodyLabel(record.processResult)) + stack.addArrangedSubview(box) + } + + private func makeButtonRow(for clue: WildReportRadarActiveClue) -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.spacing = 10 + row.distribution = .fillEqually + + let navButton = WildReportUI.iconButton( + title: clue.type == .blue ? "刷新定位" : "导航到此", + imageName: clue.type == .blue ? "location.fill" : "map.fill", + style: .primary + ) navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside) let shareButton = WildReportUI.iconButton(title: "分享", imageName: "square.and.arrow.up", style: .secondary) shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside) [navButton, shareButton].forEach { button in - button.snp.makeConstraints { make in make.height.equalTo(42) } - buttonRow.addArrangedSubview(button) + button.snp.makeConstraints { make in make.height.equalTo(44) } + row.addArrangedSubview(button) } - panel.stack.addArrangedSubview(buttonRow) + return row + } + + private func makeLoadingView(text: String) -> UIView { + let card = WildReportCardView(spacing: 12, inset: 16) + card.stack.alignment = .center + loadingIndicator.startAnimating() + card.stack.addArrangedSubview(loadingIndicator) + card.stack.addArrangedSubview(WildReportUI.label(text, font: .systemFont(ofSize: 14), color: AppColor.textSecondary)) + return card + } + + private func makeInlineLoading(text: String) -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 8 + let indicator = UIActivityIndicatorView(style: .medium) + indicator.startAnimating() + row.addArrangedSubview(indicator) + row.addArrangedSubview(WildReportUI.label(text, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)) + return row + } + + private func makeStateCard(icon: String, text: String) -> UIView { + let card = WildReportCardView(spacing: 12, inset: 18) + card.stack.alignment = .center + let image = UIImageView(image: UIImage(systemName: icon)) + image.tintColor = AppColor.primary + image.contentMode = .scaleAspectFit + image.snp.makeConstraints { make in + make.size.equalTo(34) + } + let label = WildReportUI.label(text, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary, lines: 0) + label.textAlignment = .center + card.stack.addArrangedSubview(image) + card.stack.addArrangedSubview(label) + return card + } + + private func makeHighlight(text: String, color: UIColor) -> UIView { + let label = WildReportUI.label(text, font: .systemFont(ofSize: 14, weight: .semibold), color: color, lines: 0) + label.backgroundColor = color.withAlphaComponent(0.08) + label.layer.cornerRadius = 10 + label.clipsToBounds = true + label.textAlignment = .center + label.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(34) + } + return label + } + + private func makeBodyLabel(_ text: String) -> UILabel { + WildReportUI.label(text, font: .systemFont(ofSize: 14), color: AppColor.textSecondary, lines: 0) + } + + private func iconName(for kind: WildReportMapMarkerKind) -> String { + switch kind { + case .selfLocation: return "location.fill" + case .wildPhotographer: return "exclamationmark" + case .processingClue: return "clock.fill" + case .peerPhotographer: return "camera.fill" + } + } + + private func presentImagePreview(url: String) { + present(WildReportRiskImagePreviewController(url: url), animated: true) } @objc private func navigateTapped() { - viewModel.navigateToSelectedMarker() + guard let marker = viewModel.selectedMarker, + case .activeClue(let clue) = marker.detail + else { return } + if clue.type == .blue { + Task { + await viewModel.refreshLocation( + api: api, + locationProvider: locationProvider, + scenicId: scenicIdProvider(), + scenicName: scenicNameProvider() + ) + } + return + } + guard let target = viewModel.navigateToSelectedMarker() else { return } + let item = MKMapItem(placemark: MKPlacemark(coordinate: target.coordinate)) + item.name = target.title + item.openInMaps(launchOptions: [ + MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving + ]) } @objc private func shareTapped() { @@ -195,31 +498,22 @@ final class WildReportRiskMapViewController: BaseViewController { extension WildReportRiskMapViewController: MKMapViewDelegate { func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { guard let annotation = annotation as? WildReportMapAnnotation else { return nil } - let identifier = "WildReportMapAnnotationView" - let view = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) - ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) + let identifier = "WildReportPulseMarkerAnnotationView" + let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? WildReportPulseMarkerAnnotationView) + ?? WildReportPulseMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) view.annotation = annotation - if let markerView = view as? MKMarkerAnnotationView { - markerView.markerTintColor = annotation.marker.kind.color - markerView.glyphImage = UIImage(systemName: glyphName(for: annotation.marker.kind)) - markerView.glyphTintColor = .white - markerView.canShowCallout = false - markerView.animatesWhenAdded = true - } + view.apply(marker: annotation.marker, selected: annotation.marker.id == viewModel.selectedMarkerID) return view } func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) { guard let annotation = annotation as? WildReportMapAnnotation else { return } - viewModel.selectMarker(annotation.marker) - } - - private func glyphName(for kind: WildReportMapMarkerKind) -> String { - switch kind { - case .selfLocation: return "location.fill" - case .wildPhotographer: return "exclamationmark" - case .processingClue: return "clock.fill" - case .peerPhotographer: return "camera.fill" + Task { + await viewModel.selectMarker( + annotation.marker, + api: api, + scenicId: scenicIdProvider() + ) } } } @@ -235,3 +529,228 @@ final class WildReportMapAnnotation: NSObject, MKAnnotation { super.init() } } + +/// 风险地图自定义脉冲圆点标记。 +private final class WildReportPulseMarkerAnnotationView: MKAnnotationView { + private let pulseOuter = UIView() + private let pulseInner = UIView() + private let selectedRing = UIView() + private let dot = UIView() + private let label = UILabel() + + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + frame = CGRect(x: 0, y: 0, width: 56, height: 56) + centerOffset = CGPoint(x: 0, y: -12) + canShowCallout = false + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(marker: WildReportMapMarker, selected: Bool) { + let color = marker.kind.color + pulseOuter.layer.borderColor = color.withAlphaComponent(0.18).cgColor + pulseInner.layer.borderColor = color.withAlphaComponent(0.28).cgColor + selectedRing.layer.borderColor = color.cgColor + selectedRing.isHidden = !selected + dot.backgroundColor = color + label.isHidden = marker.kind != .selfLocation + label.text = "我" + } + + private func setupUI() { + backgroundColor = .clear + [pulseOuter, pulseInner, selectedRing, dot, label].forEach(addSubview) + [pulseOuter, pulseInner, selectedRing].forEach { view in + view.backgroundColor = .clear + view.layer.borderWidth = 2 + view.isUserInteractionEnabled = false + } + pulseOuter.layer.cornerRadius = 20 + pulseInner.layer.cornerRadius = 14 + selectedRing.layer.cornerRadius = 14 + dot.layer.cornerRadius = 7 + dot.layer.borderWidth = 2 + dot.layer.borderColor = UIColor.white.cgColor + + label.font = .systemFont(ofSize: 10, weight: .bold) + label.textColor = .white + label.textAlignment = .center + + pulseOuter.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(40) + } + pulseInner.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(28) + } + selectedRing.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(28) + } + dot.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(14) + } + label.snp.makeConstraints { make in + make.center.equalTo(dot) + make.size.equalTo(14) + } + startPulse() + } + + private func startPulse() { + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = 0.85 + animation.toValue = 0.2 + animation.duration = 1.2 + animation.autoreverses = true + animation.repeatCount = .infinity + pulseOuter.layer.add(animation, forKey: "opacityPulse") + } +} + +/// 风险地图图例项视图。 +private final class WildReportRiskLegendView: UIView { + init(kind: WildReportMapMarkerKind) { + super.init(frame: .zero) + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 5 + let dot = UIView() + dot.backgroundColor = kind.color + dot.layer.cornerRadius = 4 + dot.snp.makeConstraints { make in + make.size.equalTo(8) + } + let label = WildReportUI.label(kind.title, font: .systemFont(ofSize: 11, weight: .medium), color: AppColor.textSecondary, lines: 1) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.82 + row.addArrangedSubview(dot) + row.addArrangedSubview(label) + addSubview(row) + row.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview() + make.trailing.lessThanOrEqualToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 风险地图状态胶囊。 +private final class WildReportRiskStatusBadge: UILabel { + init(text: String, color: UIColor) { + super.init(frame: .zero) + self.text = text + textColor = color + font = .systemFont(ofSize: 12, weight: .semibold) + textAlignment = .center + backgroundColor = color.withAlphaComponent(0.12) + layer.cornerRadius = 12 + clipsToBounds = true + numberOfLines = 1 + adjustsFontSizeToFitWidth = true + minimumScaleFactor = 0.8 + snp.makeConstraints { make in + make.height.equalTo(24) + make.width.greaterThanOrEqualTo(64) + make.width.lessThanOrEqualTo(96) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 风险地图举报图片缩略图。 +private final class WildReportRiskImageTile: UIView { + private let imageView = UIImageView() + var onTap: (() -> Void)? + + init(url: String) { + super.init(frame: .zero) + backgroundColor = AppColor.pageBackgroundSoft + layer.cornerRadius = 10 + layer.masksToBounds = true + imageView.contentMode = .scaleAspectFill + addSubview(imageView) + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + if let imageURL = URL(string: url) { + imageView.kf.setImage(with: imageURL, placeholder: UIImage(systemName: "photo.fill")) + } else { + imageView.image = UIImage(systemName: "photo.fill") + imageView.tintColor = AppColor.textTertiary + } + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped))) + snp.makeConstraints { make in + make.size.equalTo(72) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapped() { + onTap?() + } +} + +/// 风险地图图片预览页面。 +private final class WildReportRiskImagePreviewController: UIViewController { + private let url: String + private let imageView = UIImageView() + + init(url: String) { + self.url = url + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + imageView.contentMode = .scaleAspectFit + view.addSubview(imageView) + imageView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + if let imageURL = URL(string: url) { + imageView.kf.setImage(with: imageURL) + } + let closeButton = UIButton(type: .system) + closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + closeButton.tintColor = .white + closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) + view.addSubview(closeButton) + closeButton.snp.makeConstraints { make in + make.top.trailing.equalTo(view.safeAreaLayoutGuide).inset(18) + make.size.equalTo(36) + } + } + + @objc private func closeTapped() { + dismiss(animated: true) + } +} diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index 1a6f40f..e4e1004 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -3,6 +3,7 @@ // suixinkanTests // +import CoreLocation import XCTest @testable import suixinkan @@ -238,6 +239,121 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg") } + func testRiskMapAPISendsRequestBodyAndDecodesMarkers() async throws { + let json = Data(""" + { + "code": 100000, + "msg": "success", + "data": { + "scenic_id": 128, + "scenic_name": "伊犁那拉提景区-5A", + "legend": [ + { "marker_type": "red", "title": "疑似打野" } + ], + "green_marker_tip": "绿色为景区内摄影师", + "markers": [ + { + "marker_type": "red", + "id": 32, + "latitude": "43.4068", + "longitude": "84.0582", + "location_name": "空中草原观景道", + "title": "线索 ID:32", + "report_type_text": "疑似打野", + "handle_status": 0, + "distance_m": 320, + "created_at": "2026-07-08 16:06:59" + }, + { + "marker_type": "yellow", + "id": 33, + "latitude": "43.3652", + "longitude": "84.0385", + "location_name": "塔吾萨尼步道", + "title": "线索 ID:33", + "report_type_text": "处理中", + "handle_status": "1", + "distance_m": 1280, + "created_at": "2026-07-08 16:08:59" + } + ] + }, + "time": "2026-07-08 18:31:50" + } + """.utf8) + let session = MockURLSession(responses: [json]) + let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.riskMap(WildReportRiskMapRequest( + scenicId: 128, + latitude: 43.3779, + longitude: 84.0217 + )) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/risk-map") + let bodyData = try XCTUnwrap(request.httpBody) + let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any]) + XCTAssertEqual(body["scenic_id"] as? Int, 128) + XCTAssertEqual(body["latitude"] as? Double, 43.3779) + XCTAssertEqual(body["longitude"] as? Double, 84.0217) + XCTAssertEqual(response.scenicName, "伊犁那拉提景区-5A") + XCTAssertEqual(response.greenMarkerTip, "绿色为景区内摄影师") + XCTAssertEqual(response.markers.map(\.markerType), ["red", "yellow"]) + XCTAssertEqual(response.markers.last?.handleStatus?.displayText, WildReportStatus.processing.rawValue) + } + + func testMarkerDetailAPISendsRequestBodyAndDecodesGreenDetail() async throws { + let json = Data(""" + { + "code": 100000, + "msg": "success", + "data": { + "marker_type": "green", + "id": 901, + "title": "景区内摄影师", + "tag": "认证", + "name": "阿依达娜", + "avatar": "https://example.com/avatar.jpg", + "store_name": "空中草原旅拍店", + "online": true, + "location_name": "空中草原", + "latitude": "43.4042", + "longitude": "84.0186", + "distance_m": 430, + "green_marker_tip": "绿色为景区内摄影师", + "activity_text": "当前在空中草原区域服务游客拍摄。" + }, + "time": "2026-07-08 18:31:50" + } + """.utf8) + let session = MockURLSession(responses: [json]) + let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.markerDetail(WildReportMarkerDetailRequest( + scenicId: 128, + markerType: "green", + id: 901, + latitude: 43.3779, + longitude: 84.0217 + )) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/marker-detail") + let bodyData = try XCTUnwrap(request.httpBody) + let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any]) + XCTAssertEqual(body["scenic_id"] as? Int, 128) + XCTAssertEqual(body["marker_type"] as? String, "green") + XCTAssertEqual(body["id"] as? Int, 901) + XCTAssertEqual(body["latitude"] as? Double, 43.3779) + XCTAssertEqual(body["longitude"] as? Double, 84.0217) + XCTAssertEqual(response.name, "阿依达娜") + XCTAssertEqual(response.storeName, "空中草原旅拍店") + XCTAssertTrue(response.online) + } + func testReportListItemMapsToRecord() { let item = makeReportListItem( complaintNo: "JB20260708917", @@ -640,6 +756,139 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(viewModel.validationMessage, URLError(.badServerResponse).localizedDescription) } + func testRiskMapViewModelRejectsInvalidScenicID() async { + let viewModel = WildReportRiskMapViewModel( + record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(), + riskPoints: [] + ) + let api = MockWildPhotographerReportAPI() + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.loadInitial( + api: api, + locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)), + scenicId: 0 + ) + + XCTAssertTrue(api.riskMapRequests.isEmpty) + XCTAssertEqual(messages.last, "请先选择景区后再查看风险地图") + } + + func testRiskMapViewModelLoadsWithLocationAndMapsMarkerTypes() async { + let viewModel = WildReportRiskMapViewModel( + record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(), + riskPoints: [] + ) + let api = MockWildPhotographerReportAPI() + api.riskMapResponses = [makeRiskMapResponse()] + + await viewModel.loadInitial( + api: api, + locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)), + scenicId: 128, + scenicName: "测试景区" + ) + + XCTAssertEqual(api.riskMapRequests.first?.scenicId, 128) + XCTAssertEqual(api.riskMapRequests.first?.latitude, 43.3779) + XCTAssertEqual(api.riskMapRequests.first?.longitude, 84.0217) + XCTAssertEqual(viewModel.scenicAreaName, "伊犁那拉提景区-5A") + XCTAssertTrue(viewModel.markers.contains { $0.kind == .selfLocation }) + XCTAssertTrue(viewModel.markers.contains { $0.kind == .wildPhotographer }) + XCTAssertTrue(viewModel.markers.contains { $0.kind == .processingClue }) + XCTAssertTrue(viewModel.markers.contains { $0.kind == .peerPhotographer }) + XCTAssertEqual(viewModel.selectedMarker?.kind, .selfLocation) + let yellow = viewModel.markers.first { $0.kind == .processingClue } + XCTAssertEqual(yellow.flatMap { marker in + if case .activeClue(let clue) = marker.detail { return clue.distanceText } + return nil + }, "1.3公里") + } + + func testRiskMapViewModelLoadsWithoutLocationWhenLocationFails() async { + let viewModel = WildReportRiskMapViewModel( + record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(), + riskPoints: [] + ) + let api = MockWildPhotographerReportAPI() + api.riskMapResponses = [makeRiskMapResponse()] + + await viewModel.loadInitial( + api: api, + locationProvider: MockRiskMapLocationProvider(.failure), + scenicId: 128 + ) + + XCTAssertEqual(api.riskMapRequests.first?.scenicId, 128) + XCTAssertNil(api.riskMapRequests.first?.latitude) + XCTAssertNil(api.riskMapRequests.first?.longitude) + XCTAssertFalse(viewModel.markers.contains { $0.kind == .selfLocation }) + } + + func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async { + let viewModel = WildReportRiskMapViewModel( + record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(), + riskPoints: [] + ) + let api = MockWildPhotographerReportAPI() + api.riskMapResponses = [makeRiskMapResponse()] + api.markerDetailResponses = [ + WildReportMarkerDetailResponse( + markerType: "red", + id: 32, + title: "线索 ID:32", + locationName: "空中草原观景道", + latitude: "43.4068", + longitude: "84.0582", + distanceM: 320, + reportTypeText: "疑似打野", + handleStatus: WildReportDecodedHandleStatus(intValue: 0), + desc: "多人结伴揽客", + reporterName: "张三", + reporterPhone: "13333333333", + createdAt: "2026-07-08 16:06:59" + ) + ] + await viewModel.loadInitial( + api: api, + locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)), + scenicId: 128 + ) + let redMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .wildPhotographer }) + + await viewModel.selectMarker(redMarker, api: api, scenicId: 128) + + XCTAssertEqual(api.markerDetailRequests.first?.markerType, "red") + XCTAssertEqual(api.markerDetailRequests.first?.id, 32) + XCTAssertEqual(viewModel.selectedClue?.reporterName, "张三") + XCTAssertEqual(viewModel.selectedClue?.maskedReporterPhone, "133****3333") + XCTAssertEqual(viewModel.selectedClue?.detail, "多人结伴揽客") + } + + func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async { + let viewModel = WildReportRiskMapViewModel( + record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(), + riskPoints: [] + ) + let api = MockWildPhotographerReportAPI() + api.riskMapResponses = [makeRiskMapResponse()] + api.markerDetailError = URLError(.badServerResponse) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + await viewModel.loadInitial( + api: api, + locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)), + scenicId: 128 + ) + let redMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .wildPhotographer }) + + await viewModel.selectMarker(redMarker, api: api, scenicId: 128) + + XCTAssertEqual(viewModel.selectedClue?.summary, "疑似打野") + XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription) + } + func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async { let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() let viewModel = WildPhotographerReportDetailViewModel(record: record) @@ -707,6 +956,12 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing var reportDetailError: Error? var supplementRequests: [WildReportSupplementRequest] = [] var supplementError: Error? + var riskMapResponses: [WildReportRiskMapResponse] = [] + var riskMapRequests: [WildReportRiskMapRequest] = [] + var riskMapError: Error? + var markerDetailResponses: [WildReportMarkerDetailResponse] = [] + var markerDetailRequests: [WildReportMarkerDetailRequest] = [] + var markerDetailError: Error? func reportTypes() async throws -> WildReportTypeListResponse { if let error { throw error } @@ -723,6 +978,24 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing supplementRequests.append(request) } + func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse { + if let riskMapError { throw riskMapError } + riskMapRequests.append(request) + guard !riskMapResponses.isEmpty else { + return WildReportRiskMapResponse(scenicId: request.scenicId, scenicName: "测试景区") + } + return riskMapResponses.removeFirst() + } + + func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse { + if let markerDetailError { throw markerDetailError } + markerDetailRequests.append(request) + guard !markerDetailResponses.isEmpty else { + return WildReportMarkerDetailResponse(markerType: request.markerType, id: request.id) + } + return markerDetailResponses.removeFirst() + } + func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse { if let reportListError { throw reportListError } reportListRequests.append(request) @@ -740,6 +1013,41 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing } } +private enum MockRiskMapLocationResult: Sendable { + case success(latitude: Double, longitude: Double) + case failure +} + +private struct MockRiskMapLocationProvider: LocationProviding { + private let result: MockRiskMapLocationResult + + init(_ result: MockRiskMapLocationResult) { + self.result = result + } + + func requestSnapshot() async throws -> HomeLocationSnapshot { + switch result { + case .success(let latitude, let longitude): + return HomeLocationSnapshot(latitude: latitude, longitude: longitude, address: "测试地址") + case .failure: + throw LocationProviderError.locationFailed + } + } + + func requestCoordinate() async throws -> CLLocationCoordinate2D { + switch result { + case .success(let latitude, let longitude): + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + case .failure: + throw LocationProviderError.locationFailed + } + } + + func reverseGeocode(latitude: Double, longitude: Double) async -> String { + "测试地址" + } +} + @MainActor private final class MockWildReportAttachmentUploader: WildReportAttachmentUploading { var uploadedFileNames: [String] = [] @@ -770,6 +1078,55 @@ private func makeAttachment(kind: WildReportAttachmentKind, fileName: String) th ) } +private func makeRiskMapResponse() -> WildReportRiskMapResponse { + WildReportRiskMapResponse( + scenicId: 128, + scenicName: "伊犁那拉提景区-5A", + legend: [WildReportRiskLegendItem(markerType: "red", title: "疑似打野")], + greenMarkerTip: "绿色为景区内摄影师", + markers: [ + WildReportRiskMapMarkerDTO( + markerType: "red", + id: 32, + latitude: "43.4068", + longitude: "84.0582", + locationName: "空中草原观景道", + title: "线索 ID:32", + reportTypeText: "疑似打野", + handleStatus: WildReportDecodedHandleStatus(intValue: 0), + distanceM: 320, + createdAt: "2026-07-08 16:06:59" + ), + WildReportRiskMapMarkerDTO( + markerType: "yellow", + id: 33, + latitude: "43.3652", + longitude: "84.0385", + locationName: "塔吾萨尼步道", + title: "线索 ID:33", + reportTypeText: "处理中", + handleStatus: WildReportDecodedHandleStatus(intValue: 1), + distanceM: 1280, + createdAt: "2026-07-08 16:08:59" + ), + WildReportRiskMapMarkerDTO( + markerType: "green", + id: 901, + latitude: "43.4042", + longitude: "84.0186", + locationName: "空中草原", + title: "景区内摄影师", + distanceM: 430, + createdAt: "2026-07-08 16:09:59", + name: "阿依达娜", + storeName: "空中草原旅拍店", + online: true, + activityText: "当前在空中草原区域服务游客拍摄。" + ) + ] + ) +} + private func makeReportListItem( id: Int = 32, complaintNo: String,