feat: connect wild report risk map
This commit is contained in:
@ -14,6 +14,10 @@ protocol WildPhotographerReportServing {
|
|||||||
func submitReport(_ request: WildReportSubmitRequest) async throws
|
func submitReport(_ request: WildReportSubmitRequest) async throws
|
||||||
/// 为已有举报补充证据。
|
/// 为已有举报补充证据。
|
||||||
func supplementReport(_ request: WildReportSupplementRequest) 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
|
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 {
|
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
|
||||||
try await client.send(
|
try await client.send(
|
||||||
@ -89,6 +115,36 @@ struct WildReportSupplementRequest: Encodable, Equatable {
|
|||||||
let evidences: [WildReportEvidenceRequest]?
|
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 {
|
struct WildReportSubmitRequest: Encodable, Equatable {
|
||||||
let scenicId: Int
|
let scenicId: Int
|
||||||
|
|||||||
@ -868,6 +868,415 @@ enum WildReportRadarMarkerType: String, Hashable {
|
|||||||
case blue
|
case blue
|
||||||
case green
|
case green
|
||||||
case red
|
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
|
return .selfLocation
|
||||||
case .green:
|
case .green:
|
||||||
return .peerPhotographer
|
return .peerPhotographer
|
||||||
|
case .yellow:
|
||||||
|
return .processingClue
|
||||||
case .red:
|
case .red:
|
||||||
if clue.processingStarted && !clue.completed {
|
if clue.processingStarted && !clue.completed {
|
||||||
return .processingClue
|
return .processingClue
|
||||||
|
|||||||
@ -1141,20 +1141,31 @@ final class WildReportSupplementEvidenceViewModel {
|
|||||||
|
|
||||||
/// 附近风险地图 ViewModel。
|
/// 附近风险地图 ViewModel。
|
||||||
final class WildReportRiskMapViewModel {
|
final class WildReportRiskMapViewModel {
|
||||||
|
private let fallbackMarkers: [WildReportMapMarker]
|
||||||
|
private var loadedDetailMarkerIDs: Set<String> = []
|
||||||
|
|
||||||
private(set) var region: MKCoordinateRegion
|
private(set) var region: MKCoordinateRegion
|
||||||
private(set) var markers: [WildReportMapMarker]
|
private(set) var markers: [WildReportMapMarker]
|
||||||
let scenicAreaName: String
|
private(set) var scenicAreaName: String
|
||||||
private(set) var selectedMarkerID: String?
|
private(set) var selectedMarkerID: String?
|
||||||
private(set) var toastMessage: 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 onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
|
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
|
||||||
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
|
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
|
||||||
|
self.fallbackMarkers = markers
|
||||||
self.markers = markers
|
self.markers = markers
|
||||||
self.region = WildPhotographerReportMockStore.region(for: 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
|
self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1169,40 +1180,86 @@ final class WildReportRiskMapViewModel {
|
|||||||
return clue
|
return clue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 首次加载附近风险地图数据。
|
||||||
|
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 selectMarker(_ marker: WildReportMapMarker) {
|
func selectMarker(
|
||||||
|
_ marker: WildReportMapMarker,
|
||||||
|
api: (any WildPhotographerReportServing)? = nil,
|
||||||
|
scenicId: Int? = nil
|
||||||
|
) async {
|
||||||
selectedMarkerID = marker.id
|
selectedMarkerID = marker.id
|
||||||
notifyStateChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 导航到选中标记或刷新蓝点定位。
|
|
||||||
func navigateToSelectedMarker() {
|
|
||||||
guard let marker = selectedMarker,
|
|
||||||
case .activeClue(let clue) = marker.detail else { return }
|
|
||||||
|
|
||||||
if clue.type == .blue {
|
|
||||||
refreshLocation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast("已切换到该点位")
|
|
||||||
region = MKCoordinateRegion(
|
region = MKCoordinateRegion(
|
||||||
center: marker.coordinate,
|
center: marker.coordinate,
|
||||||
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
||||||
)
|
)
|
||||||
notifyStateChange()
|
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() {
|
func refreshLocation(
|
||||||
guard let blueMarker = markers.first(where: { $0.kind == .selfLocation }) else { return }
|
api: any WildPhotographerReportServing,
|
||||||
selectedMarkerID = blueMarker.id
|
locationProvider: any LocationProviding,
|
||||||
region = MKCoordinateRegion(
|
scenicId: Int? = nil,
|
||||||
center: blueMarker.coordinate,
|
scenicName: String? = nil
|
||||||
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
) async {
|
||||||
|
await loadRiskMap(
|
||||||
|
api: api,
|
||||||
|
locationProvider: locationProvider,
|
||||||
|
scenicId: scenicId,
|
||||||
|
scenicName: scenicName,
|
||||||
|
selectCurrentLocation: true
|
||||||
)
|
)
|
||||||
showToast("已定位到当前位置")
|
|
||||||
notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成线索分享提示。
|
/// 生成线索分享提示。
|
||||||
@ -1224,7 +1281,301 @@ final class WildReportRiskMapViewModel {
|
|||||||
notifyStateChange()
|
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() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nonEmpty: String? {
|
||||||
|
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return text.isEmpty ? nil : text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
import MapKit
|
import MapKit
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
@ -10,12 +11,36 @@ import UIKit
|
|||||||
/// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。
|
/// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。
|
||||||
final class WildReportRiskMapViewController: BaseViewController {
|
final class WildReportRiskMapViewController: BaseViewController {
|
||||||
private let viewModel: WildReportRiskMapViewModel
|
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 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] = [:]
|
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.viewModel = WildReportRiskMapViewModel(record: record, riskPoints: riskPoints)
|
||||||
|
self.api = api
|
||||||
|
self.locationProvider = locationProvider
|
||||||
|
self.scenicIdProvider = scenicIdProvider
|
||||||
|
self.scenicNameProvider = scenicNameProvider
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,39 +49,67 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
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() {
|
override func setupNavigationBar() {
|
||||||
title = "附近风险地图"
|
title = "附近风险地图"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
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.delegate = self
|
||||||
mapView.showsCompass = false
|
mapView.showsCompass = false
|
||||||
mapView.showsScale = true
|
mapView.showsScale = true
|
||||||
view.addSubview(mapView)
|
mapView.pointOfInterestFilter = .excludingAll
|
||||||
view.addSubview(panel)
|
mapContainerView.addSubview(mapView)
|
||||||
addLegend()
|
|
||||||
applyMap()
|
configureScenicCapsule()
|
||||||
rebuildPanel()
|
configureLegendBar()
|
||||||
|
configureDetailArea()
|
||||||
|
refreshContent(animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
mapView.snp.makeConstraints { make in
|
rootStack.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
mapContainerView.snp.makeConstraints { make in
|
||||||
make.height.equalTo(340)
|
make.height.equalTo(340)
|
||||||
}
|
}
|
||||||
panel.snp.makeConstraints { make in
|
mapView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(mapView.snp.bottom).offset(AppSpacing.sm)
|
make.edges.equalToSuperview()
|
||||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
}
|
||||||
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm)
|
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() {
|
override func bindActions() {
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.mapView.setRegion(self?.viewModel.region ?? MKCoordinateRegion(), animated: true)
|
self?.refreshContent(animated: true)
|
||||||
self?.rebuildPanel()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModel.onShowMessage = { [weak self] message in
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
@ -64,127 +117,377 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyMap() {
|
private func configureScenicCapsule() {
|
||||||
mapView.setRegion(viewModel.region, animated: false)
|
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()
|
annotationMap.removeAll()
|
||||||
let annotations = viewModel.markers.map { marker in
|
let annotations = viewModel.markers.map { marker in
|
||||||
annotationMap[marker.id] = marker
|
annotationMap[marker.id] = marker
|
||||||
let annotation = WildReportMapAnnotation(marker: marker)
|
return WildReportMapAnnotation(marker: marker)
|
||||||
return annotation
|
|
||||||
}
|
}
|
||||||
mapView.addAnnotations(annotations)
|
mapView.addAnnotations(annotations)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addLegend() {
|
private func updateAnnotationSelection() {
|
||||||
let legend = UIStackView()
|
for annotation in mapView.annotations {
|
||||||
legend.axis = .horizontal
|
guard let annotation = annotation as? WildReportMapAnnotation,
|
||||||
legend.distribution = .fillEqually
|
let view = mapView.view(for: annotation) as? WildReportPulseMarkerAnnotationView
|
||||||
legend.backgroundColor = UIColor.white.withAlphaComponent(0.94)
|
else { continue }
|
||||||
legend.layer.cornerRadius = AppRadius.md
|
view.apply(marker: annotation.marker, selected: annotation.marker.id == viewModel.selectedMarkerID)
|
||||||
legend.clipsToBounds = true
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildLegend() {
|
||||||
|
legendBar.arrangedSubviews.forEach { view in
|
||||||
|
legendBar.removeArrangedSubview(view)
|
||||||
|
view.removeFromSuperview()
|
||||||
|
}
|
||||||
[
|
[
|
||||||
WildReportMapMarkerKind.selfLocation,
|
WildReportMapMarkerKind.selfLocation,
|
||||||
.wildPhotographer,
|
.wildPhotographer,
|
||||||
.processingClue,
|
.processingClue,
|
||||||
.peerPhotographer
|
.peerPhotographer
|
||||||
].forEach { kind in
|
].forEach { kind in
|
||||||
let row = UIStackView()
|
legendBar.addArrangedSubview(WildReportRiskLegendView(kind: kind))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func rebuildPanel() {
|
private func rebuildPanel() {
|
||||||
panel.stack.arrangedSubviews.forEach { view in
|
contentStack.arrangedSubviews.forEach { view in
|
||||||
panel.stack.removeArrangedSubview(view)
|
contentStack.removeArrangedSubview(view)
|
||||||
view.removeFromSuperview()
|
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,
|
guard let marker = viewModel.selectedMarker,
|
||||||
case .activeClue(let clue) = marker.detail else {
|
case .activeClue(let clue) = marker.detail
|
||||||
panel.stack.addArrangedSubview(WildReportUI.label("点击地图标记查看详情", font: .app(.body), color: AppColor.textSecondary))
|
else {
|
||||||
|
contentStack.addArrangedSubview(makeStateCard(icon: "hand.tap.fill", text: "点击地图标记查看详情"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let header = UIStackView()
|
let card = WildReportCardView(spacing: 16, inset: 16)
|
||||||
header.axis = .horizontal
|
card.layer.cornerRadius = 16
|
||||||
header.alignment = .center
|
card.stack.addArrangedSubview(makeHeader(marker: marker, clue: clue))
|
||||||
let title = WildReportUI.label(clue.displayTitle, font: .app(.title), color: AppColor.textPrimary, lines: 0)
|
if viewModel.isLoadingDetail {
|
||||||
let badge = UILabel()
|
card.stack.addArrangedSubview(makeInlineLoading(text: "正在加载标记详情..."))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
header.addArrangedSubview(title)
|
|
||||||
header.addArrangedSubview(UIView())
|
|
||||||
header.addArrangedSubview(badge)
|
|
||||||
panel.stack.addArrangedSubview(header)
|
|
||||||
|
|
||||||
if let nearest = clue.nearestAbnormalText {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
if let reporter = clue.reporterName {
|
||||||
panel.stack.addArrangedSubview(WildReportUI.label(summary, font: .app(.body), color: AppColor.textSecondary, lines: 0))
|
stack.addArrangedSubview(WildReportInfoRowView(title: "举报人", value: reporter))
|
||||||
}
|
}
|
||||||
clue.reportContents.prefix(2).forEach { content in
|
if let phone = clue.maskedReporterPhone ?? clue.reporterPhone {
|
||||||
panel.stack.addArrangedSubview(WildReportUI.label("\(content.time) \(content.content)", font: .app(.caption), color: AppColor.textSecondary, lines: 0))
|
stack.addArrangedSubview(WildReportInfoRowView(title: "联系电话", value: phone))
|
||||||
}
|
}
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let buttonRow = UIStackView()
|
private func addDescriptionBlocks(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||||
buttonRow.axis = .horizontal
|
if let summary = clue.summary {
|
||||||
buttonRow.spacing = AppSpacing.sm
|
stack.addArrangedSubview(makeBodyLabel(summary))
|
||||||
buttonRow.distribution = .fillEqually
|
}
|
||||||
let navButton = WildReportUI.iconButton(title: clue.actionText, imageName: "location.fill", style: .secondary)
|
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)
|
navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside)
|
||||||
let shareButton = WildReportUI.iconButton(title: "分享", imageName: "square.and.arrow.up", style: .secondary)
|
let shareButton = WildReportUI.iconButton(title: "分享", imageName: "square.and.arrow.up", style: .secondary)
|
||||||
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
||||||
[navButton, shareButton].forEach { button in
|
[navButton, shareButton].forEach { button in
|
||||||
button.snp.makeConstraints { make in make.height.equalTo(42) }
|
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||||
buttonRow.addArrangedSubview(button)
|
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() {
|
@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() {
|
@objc private func shareTapped() {
|
||||||
@ -195,31 +498,22 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
extension WildReportRiskMapViewController: MKMapViewDelegate {
|
extension WildReportRiskMapViewController: MKMapViewDelegate {
|
||||||
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
|
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
|
||||||
guard let annotation = annotation as? WildReportMapAnnotation else { return nil }
|
guard let annotation = annotation as? WildReportMapAnnotation else { return nil }
|
||||||
let identifier = "WildReportMapAnnotationView"
|
let identifier = "WildReportPulseMarkerAnnotationView"
|
||||||
let view = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
|
let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? WildReportPulseMarkerAnnotationView)
|
||||||
?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
|
?? WildReportPulseMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
|
||||||
view.annotation = annotation
|
view.annotation = annotation
|
||||||
if let markerView = view as? MKMarkerAnnotationView {
|
view.apply(marker: annotation.marker, selected: annotation.marker.id == viewModel.selectedMarkerID)
|
||||||
markerView.markerTintColor = annotation.marker.kind.color
|
|
||||||
markerView.glyphImage = UIImage(systemName: glyphName(for: annotation.marker.kind))
|
|
||||||
markerView.glyphTintColor = .white
|
|
||||||
markerView.canShowCallout = false
|
|
||||||
markerView.animatesWhenAdded = true
|
|
||||||
}
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
|
func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
|
||||||
guard let annotation = annotation as? WildReportMapAnnotation else { return }
|
guard let annotation = annotation as? WildReportMapAnnotation else { return }
|
||||||
viewModel.selectMarker(annotation.marker)
|
Task {
|
||||||
}
|
await viewModel.selectMarker(
|
||||||
|
annotation.marker,
|
||||||
private func glyphName(for kind: WildReportMapMarkerKind) -> String {
|
api: api,
|
||||||
switch kind {
|
scenicId: scenicIdProvider()
|
||||||
case .selfLocation: return "location.fill"
|
)
|
||||||
case .wildPhotographer: return "exclamationmark"
|
|
||||||
case .processingClue: return "clock.fill"
|
|
||||||
case .peerPhotographer: return "camera.fill"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -235,3 +529,228 @@ final class WildReportMapAnnotation: NSObject, MKAnnotation {
|
|||||||
super.init()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
// suixinkanTests
|
// suixinkanTests
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
@ -238,6 +239,121 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg")
|
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() {
|
func testReportListItemMapsToRecord() {
|
||||||
let item = makeReportListItem(
|
let item = makeReportListItem(
|
||||||
complaintNo: "JB20260708917",
|
complaintNo: "JB20260708917",
|
||||||
@ -640,6 +756,139 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.validationMessage, URLError(.badServerResponse).localizedDescription)
|
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 {
|
func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async {
|
||||||
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
|
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
|
||||||
let viewModel = WildPhotographerReportDetailViewModel(record: record)
|
let viewModel = WildPhotographerReportDetailViewModel(record: record)
|
||||||
@ -707,6 +956,12 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
|
|||||||
var reportDetailError: Error?
|
var reportDetailError: Error?
|
||||||
var supplementRequests: [WildReportSupplementRequest] = []
|
var supplementRequests: [WildReportSupplementRequest] = []
|
||||||
var supplementError: Error?
|
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 {
|
func reportTypes() async throws -> WildReportTypeListResponse {
|
||||||
if let error { throw error }
|
if let error { throw error }
|
||||||
@ -723,6 +978,24 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
|
|||||||
supplementRequests.append(request)
|
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 {
|
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
|
||||||
if let reportListError { throw reportListError }
|
if let reportListError { throw reportListError }
|
||||||
reportListRequests.append(request)
|
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
|
@MainActor
|
||||||
private final class MockWildReportAttachmentUploader: WildReportAttachmentUploading {
|
private final class MockWildReportAttachmentUploader: WildReportAttachmentUploading {
|
||||||
var uploadedFileNames: [String] = []
|
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(
|
private func makeReportListItem(
|
||||||
id: Int = 32,
|
id: Int = 32,
|
||||||
complaintNo: String,
|
complaintNo: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user