feat: connect wild report risk map
This commit is contained in:
@ -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
|
||||
|
||||
@ -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) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 雷达线索举报内容条目。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user