feat: connect wild report risk map

This commit is contained in:
2026-07-08 17:52:29 +08:00
parent fd5dc2bbe6
commit 939295d74a
6 changed files with 1829 additions and 135 deletions

View File

@ -1141,20 +1141,31 @@ final class WildReportSupplementEvidenceViewModel {
/// ViewModel
final class WildReportRiskMapViewModel {
private let fallbackMarkers: [WildReportMapMarker]
private var loadedDetailMarkerIDs: Set<String> = []
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
}
}