完善风险地图定位与标记详情
This commit is contained in:
@ -87,9 +87,9 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
|
||||
func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/report/marker-detail",
|
||||
body: request
|
||||
queryItems: request.queryItems
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -148,7 +148,7 @@ struct WildReportRiskMapRequest: Encodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险地图标记详情接口请求体。
|
||||
/// 风险地图标记详情接口请求参数。
|
||||
struct WildReportMarkerDetailRequest: Encodable, Equatable {
|
||||
let scenicId: Int
|
||||
let markerType: String
|
||||
@ -156,6 +156,21 @@ struct WildReportMarkerDetailRequest: Encodable, Equatable {
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
var items = [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "marker_type", value: markerType),
|
||||
URLQueryItem(name: "id", value: String(id))
|
||||
]
|
||||
if let latitude {
|
||||
items.append(URLQueryItem(name: "latitude", value: String(latitude)))
|
||||
}
|
||||
if let longitude {
|
||||
items.append(URLQueryItem(name: "longitude", value: String(longitude)))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case markerType = "marker_type"
|
||||
|
||||
@ -1185,7 +1185,9 @@ struct WildReportRiskMapMarkerDTO: Decodable, Equatable, Hashable {
|
||||
struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
||||
let markerType: String
|
||||
let id: Int
|
||||
let clueId: String
|
||||
let title: String
|
||||
let statusTag: String
|
||||
let tag: String
|
||||
let name: String
|
||||
let avatar: String
|
||||
@ -1210,7 +1212,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case markerType = "marker_type"
|
||||
case id
|
||||
case clueId = "clue_id"
|
||||
case title
|
||||
case statusTag = "status_tag"
|
||||
case tag
|
||||
case name
|
||||
case avatar
|
||||
@ -1237,7 +1241,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
||||
init(
|
||||
markerType: String,
|
||||
id: Int,
|
||||
clueId: String = "",
|
||||
title: String = "",
|
||||
statusTag: String = "",
|
||||
tag: String = "",
|
||||
name: String = "",
|
||||
avatar: String = "",
|
||||
@ -1261,7 +1267,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
||||
) {
|
||||
self.markerType = markerType
|
||||
self.id = id
|
||||
self.clueId = clueId
|
||||
self.title = title
|
||||
self.statusTag = statusTag
|
||||
self.tag = tag
|
||||
self.name = name
|
||||
self.avatar = avatar
|
||||
@ -1288,7 +1296,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
||||
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
|
||||
clueId = try container.decodeIfPresent(String.self, forKey: .clueId) ?? ""
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
|
||||
statusTag = try container.decodeIfPresent(String.self, forKey: .statusTag) ?? ""
|
||||
tag = try container.decodeIfPresent(String.self, forKey: .tag) ?? ""
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
avatar = try container.decodeIfPresent(String.self, forKey: .avatar) ?? ""
|
||||
|
||||
@ -1018,7 +1018,6 @@ final class WildReportRiskMapViewModel {
|
||||
private(set) var markers: [WildReportMapMarker]
|
||||
private(set) var scenicAreaName: String
|
||||
private(set) var selectedMarkerID: String?
|
||||
private(set) var toastMessage: String?
|
||||
private(set) var isLoading = false
|
||||
private(set) var isLoadingDetail = false
|
||||
private(set) var errorMessage: String?
|
||||
@ -1027,7 +1026,6 @@ final class WildReportRiskMapViewModel {
|
||||
private(set) var currentCoordinate: CLLocationCoordinate2D?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
init() {
|
||||
self.markers = []
|
||||
@ -1090,7 +1088,7 @@ final class WildReportRiskMapViewModel {
|
||||
mergeDetail(detail, intoMarkerID: marker.id)
|
||||
loadedDetailMarkerIDs.insert(marker.id)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isLoadingDetail = false
|
||||
notifyStateChange()
|
||||
@ -1114,6 +1112,19 @@ final class WildReportRiskMapViewModel {
|
||||
return marker
|
||||
}
|
||||
|
||||
/// 将地图快速移动到已加载的当前位置标记。
|
||||
@discardableResult
|
||||
func focusCurrentLocation() -> Bool {
|
||||
guard let marker = markers.first(where: { $0.kind == .selfLocation }) else { return false }
|
||||
selectedMarkerID = marker.id
|
||||
region = MKCoordinateRegion(
|
||||
center: marker.coordinate,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
||||
)
|
||||
notifyStateChange()
|
||||
return true
|
||||
}
|
||||
|
||||
/// 将地图居中到当前位置标记。
|
||||
func refreshLocation(
|
||||
api: any WildPhotographerReportServing,
|
||||
@ -1132,21 +1143,12 @@ final class WildReportRiskMapViewModel {
|
||||
|
||||
/// 生成线索分享提示。
|
||||
func shareSelectedClue() {
|
||||
guard let clue = selectedClue else { return }
|
||||
let title = clue.type == .red ? "线索 ID:\(clue.id)" : clue.displayTitle
|
||||
showToast("已生成\(title)的分享信息,可发送给他人查看。")
|
||||
_ = selectedClue
|
||||
}
|
||||
|
||||
/// 生成图片预览提示。
|
||||
func showImagePreview(_ url: String) {
|
||||
_ = url
|
||||
showToast("暂不支持预览图片")
|
||||
}
|
||||
|
||||
private func showToast(_ message: String) {
|
||||
toastMessage = message
|
||||
onShowMessage?(message)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func loadRiskMap(
|
||||
@ -1159,7 +1161,7 @@ final class WildReportRiskMapViewModel {
|
||||
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId
|
||||
guard resolvedScenicId >= 1 else {
|
||||
errorMessage = "请先选择景区后再查看风险地图"
|
||||
showToast(errorMessage ?? "")
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
|
||||
@ -1170,8 +1172,7 @@ final class WildReportRiskMapViewModel {
|
||||
scenicId: resolvedScenicId,
|
||||
scenicName: scenicName,
|
||||
coordinate: coordinate,
|
||||
selectCurrentLocation: selectCurrentLocation,
|
||||
showsLocationRefreshToast: coordinate != nil
|
||||
selectCurrentLocation: selectCurrentLocation
|
||||
)
|
||||
}
|
||||
|
||||
@ -1184,8 +1185,7 @@ final class WildReportRiskMapViewModel {
|
||||
scenicId: Int,
|
||||
scenicName: String?,
|
||||
coordinate: CLLocationCoordinate2D?,
|
||||
selectCurrentLocation: Bool,
|
||||
showsLocationRefreshToast: Bool
|
||||
selectCurrentLocation: Bool
|
||||
) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
@ -1210,12 +1210,8 @@ final class WildReportRiskMapViewModel {
|
||||
} else if selectedMarker == nil {
|
||||
selectedMarkerID = markers.first?.id
|
||||
}
|
||||
if showsLocationRefreshToast {
|
||||
showToast("已更新附近风险点位")
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
@ -1354,7 +1350,8 @@ final class WildReportRiskMapViewModel {
|
||||
else { return }
|
||||
|
||||
let markerType = WildReportRiskMarkerType(rawValue: detail.markerType.nonEmpty ?? oldClue.type.rawValue)
|
||||
let statusText = detail.handleStatusText.nonEmpty
|
||||
let statusText = detail.statusTag.nonEmpty
|
||||
?? detail.handleStatusText.nonEmpty
|
||||
?? detail.handleStatus?.displayText
|
||||
?? oldClue.statusText
|
||||
let reportContents = !detail.reportContents.isEmpty
|
||||
@ -1362,7 +1359,7 @@ final class WildReportRiskMapViewModel {
|
||||
: (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),
|
||||
id: detail.clueId.nonEmpty ?? String(detail.id > 0 ? detail.id : Int(oldClue.id) ?? 0),
|
||||
type: markerType.radarType,
|
||||
displayTitle: detail.title.nonEmpty ?? oldClue.displayTitle,
|
||||
statusText: statusText,
|
||||
|
||||
@ -21,6 +21,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
private let mapView = MKMapView()
|
||||
private let scenicCapsule = UIView()
|
||||
private let scenicNameLabel = UILabel()
|
||||
private let locateButton = UIButton(type: .system)
|
||||
private let legendBar = UIStackView()
|
||||
private let detailContainerView = UIView()
|
||||
private let scrollView = UIScrollView()
|
||||
@ -87,6 +88,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
mapContainerView.addSubview(mapView)
|
||||
|
||||
configureScenicCapsule()
|
||||
configureLocateButton()
|
||||
configureLegendBar()
|
||||
configureDetailArea()
|
||||
refreshContent(animated: false)
|
||||
@ -106,6 +108,10 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
make.top.leading.equalToSuperview().inset(12)
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
locateButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(12)
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
detailContainerView.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(180)
|
||||
}
|
||||
@ -117,9 +123,6 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
self?.refreshContent(animated: true)
|
||||
}
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
private func configureScenicCapsule() {
|
||||
@ -151,6 +154,20 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func configureLocateButton() {
|
||||
locateButton.backgroundColor = UIColor.white.withAlphaComponent(0.96)
|
||||
locateButton.tintColor = AppColor.primary
|
||||
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
|
||||
locateButton.layer.cornerRadius = 22
|
||||
locateButton.layer.shadowColor = UIColor.black.cgColor
|
||||
locateButton.layer.shadowOpacity = 0.1
|
||||
locateButton.layer.shadowRadius = 8
|
||||
locateButton.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
locateButton.accessibilityLabel = "回到我的位置"
|
||||
locateButton.addTarget(self, action: #selector(locateButtonTapped), for: .touchUpInside)
|
||||
mapContainerView.addSubview(locateButton)
|
||||
}
|
||||
|
||||
private func configureLegendBar() {
|
||||
legendBar.axis = .horizontal
|
||||
legendBar.alignment = .center
|
||||
@ -182,8 +199,8 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
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)
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView.snp.width).offset(-32)
|
||||
}
|
||||
}
|
||||
|
||||
@ -191,6 +208,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
private func refreshContent(animated: Bool) {
|
||||
setRiskMapLoadingVisible(viewModel.isLoading)
|
||||
scenicNameLabel.text = viewModel.scenicAreaName
|
||||
updateLocateButtonState()
|
||||
syncMapAnnotations()
|
||||
mapView.setRegion(viewModel.region, animated: animated)
|
||||
updateAnnotationSelection()
|
||||
@ -205,6 +223,13 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
isVisible ? showLoading() : hideLoading()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateLocateButtonState() {
|
||||
let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation }
|
||||
locateButton.isEnabled = hasCurrentLocation
|
||||
locateButton.alpha = hasCurrentLocation ? 1 : 0.46
|
||||
}
|
||||
|
||||
private func syncMapAnnotations() {
|
||||
let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation }
|
||||
mapView.removeAnnotations(oldAnnotations)
|
||||
@ -258,39 +283,63 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
return
|
||||
}
|
||||
|
||||
let card = WildReportCardView(spacing: 16, inset: 16)
|
||||
card.layer.cornerRadius = 16
|
||||
card.stack.addArrangedSubview(makeHeader(marker: marker, clue: clue))
|
||||
if viewModel.isLoadingDetail {
|
||||
card.stack.addArrangedSubview(makeInlineLoading(text: "正在加载标记详情..."))
|
||||
}
|
||||
if let nearest = clue.nearestAbnormalText {
|
||||
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))
|
||||
let card = makeRadarResultCard(clue: clue)
|
||||
contentStack.addArrangedSubview(card)
|
||||
}
|
||||
|
||||
private func makeHeader(marker: WildReportMapMarker, clue: WildReportRadarActiveClue) -> UIView {
|
||||
private func makeRadarResultCard(clue: WildReportRadarActiveClue) -> UIView {
|
||||
let card = WildReportCardView(spacing: 14, inset: 16)
|
||||
card.layer.cornerRadius = 16
|
||||
card.stack.spacing = 14
|
||||
card.stack.addArrangedSubview(makeRadarHeader(clue: clue))
|
||||
|
||||
if clue.type == .blue, let nearest = clue.nearestAbnormalText {
|
||||
card.stack.addArrangedSubview(makeCompareText(nearest))
|
||||
}
|
||||
|
||||
if clue.type == .green {
|
||||
addPhotographerProfile(for: clue, to: card.stack)
|
||||
}
|
||||
|
||||
if isReportClue(clue) {
|
||||
card.stack.addArrangedSubview(makeReporterInfo(clue: clue))
|
||||
addReportContents(for: clue, to: card.stack)
|
||||
addRadarImages(for: clue, to: card.stack)
|
||||
if clue.processingStarted, let record = clue.record {
|
||||
card.stack.addArrangedSubview(makeProcessingPanel(clue: clue, record: record))
|
||||
}
|
||||
}
|
||||
|
||||
if clue.type == .green, let summary = clue.summary {
|
||||
card.stack.addArrangedSubview(makeCompareText(summary))
|
||||
}
|
||||
|
||||
if clue.type == .green, let detail = clue.detail {
|
||||
card.stack.addArrangedSubview(makeDetailSection(detail))
|
||||
}
|
||||
|
||||
if !clue.completed {
|
||||
card.stack.addArrangedSubview(makeRadarActionButtons(for: clue))
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeRadarHeader(clue: WildReportRadarActiveClue) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.alignment = .top
|
||||
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
|
||||
iconWrap.backgroundColor = headerIconColor(for: clue)
|
||||
iconWrap.layer.cornerRadius = 12
|
||||
let icon = UIImageView(image: UIImage(systemName: "location.fill"))
|
||||
icon.tintColor = .white
|
||||
icon.contentMode = .scaleAspectFit
|
||||
iconWrap.addSubview(icon)
|
||||
icon.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(22)
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
iconWrap.snp.makeConstraints { make in
|
||||
make.size.equalTo(44)
|
||||
@ -298,14 +347,34 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
|
||||
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.spacing = 6
|
||||
if isReportClue(clue) {
|
||||
let title = WildReportUI.label("线索 ID:\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
||||
textStack.addArrangedSubview(title)
|
||||
textStack.addArrangedSubview(meta)
|
||||
let metaRow = UIStackView()
|
||||
metaRow.axis = .horizontal
|
||||
metaRow.spacing = 12
|
||||
if let distance = clue.distanceText ?? clue.distance {
|
||||
metaRow.addArrangedSubview(WildReportUI.label("距离:距离您\(distance)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
}
|
||||
if let updatedAt = clue.updatedAt {
|
||||
metaRow.addArrangedSubview(WildReportUI.label("时间:\(updatedAt)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
}
|
||||
if !metaRow.arrangedSubviews.isEmpty {
|
||||
textStack.addArrangedSubview(metaRow)
|
||||
}
|
||||
} else {
|
||||
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
||||
textStack.addArrangedSubview(title)
|
||||
if clue.type == .green {
|
||||
let metaText = [clue.distance.map { "距蓝点 \($0)" }, clue.direction, clue.updatedAt].compactMap { $0 }.joined(separator: " ")
|
||||
if !metaText.isEmpty {
|
||||
textStack.addArrangedSubview(WildReportUI.label(metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: marker.kind.color)
|
||||
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: statusColor(for: clue))
|
||||
row.addArrangedSubview(iconWrap)
|
||||
row.addArrangedSubview(textStack)
|
||||
row.addArrangedSubview(badge)
|
||||
@ -314,6 +383,341 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
return row
|
||||
}
|
||||
|
||||
private func addPhotographerProfile(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 12
|
||||
|
||||
let avatarWrap = UIView()
|
||||
avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12)
|
||||
avatarWrap.layer.cornerRadius = 28
|
||||
let avatar = UIImageView(image: UIImage(systemName: clue.avatar ?? "person.crop.circle.fill"))
|
||||
avatar.tintColor = AppColor.success
|
||||
avatar.contentMode = .scaleAspectFit
|
||||
avatarWrap.addSubview(avatar)
|
||||
avatar.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(42)
|
||||
}
|
||||
avatarWrap.snp.makeConstraints { make in
|
||||
make.size.equalTo(56)
|
||||
}
|
||||
|
||||
let textStack = UIStackView()
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 4
|
||||
if let name = clue.name {
|
||||
textStack.addArrangedSubview(WildReportUI.label(name, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary))
|
||||
}
|
||||
if let storeName = clue.storeName {
|
||||
textStack.addArrangedSubview(WildReportUI.label(storeName, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.success))
|
||||
}
|
||||
|
||||
row.addArrangedSubview(avatarWrap)
|
||||
row.addArrangedSubview(textStack)
|
||||
stack.addArrangedSubview(row)
|
||||
}
|
||||
|
||||
private func makeReporterInfo(clue: WildReportRadarActiveClue) -> UIView {
|
||||
let box = UIView()
|
||||
box.backgroundColor = UIColor(hex: 0xF9FAFB)
|
||||
box.layer.cornerRadius = 10
|
||||
|
||||
let inner = UIStackView()
|
||||
inner.axis = .vertical
|
||||
inner.spacing = 10
|
||||
box.addSubview(inner)
|
||||
inner.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(12)
|
||||
}
|
||||
|
||||
inner.addArrangedSubview(makeInfoLabelRow(label: "举报人", value: clue.reporterName ?? "-"))
|
||||
inner.addArrangedSubview(makePhoneRow(
|
||||
label: "举报人手机号",
|
||||
phone: clue.reporterPhone,
|
||||
displayPhone: clue.maskedReporterPhone ?? clue.reporterPhone
|
||||
))
|
||||
return box
|
||||
}
|
||||
|
||||
private func addReportContents(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||
guard !clue.reportContents.isEmpty else { return }
|
||||
let section = UIStackView()
|
||||
section.axis = .vertical
|
||||
section.spacing = 10
|
||||
for item in clue.reportContents {
|
||||
let itemStack = UIStackView()
|
||||
itemStack.axis = .vertical
|
||||
itemStack.spacing = 4
|
||||
if !item.time.isEmpty {
|
||||
itemStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 11, weight: .semibold), color: AppColor.textSecondary))
|
||||
}
|
||||
itemStack.addArrangedSubview(makeLineSpacedLabel(item.content, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
|
||||
section.addArrangedSubview(itemStack)
|
||||
}
|
||||
stack.addArrangedSubview(section)
|
||||
}
|
||||
|
||||
private func addRadarImages(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||
guard !clue.images.isEmpty else { return }
|
||||
let section = UIStackView()
|
||||
section.axis = .vertical
|
||||
section.spacing = 10
|
||||
section.addArrangedSubview(WildReportUI.label("现场图片/视频", font: .systemFont(ofSize: 15, weight: .bold), color: AppColor.textPrimary))
|
||||
|
||||
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(108)
|
||||
}
|
||||
for url in clue.images {
|
||||
let tile = WildReportRiskImageTile(url: url, size: 108)
|
||||
tile.onTap = { [weak self] in self?.presentImagePreview(url: url) }
|
||||
row.addArrangedSubview(tile)
|
||||
}
|
||||
section.addArrangedSubview(scroll)
|
||||
scroll.snp.makeConstraints { make in
|
||||
make.height.equalTo(108)
|
||||
}
|
||||
stack.addArrangedSubview(section)
|
||||
}
|
||||
|
||||
private func makeProcessingPanel(clue: WildReportRadarActiveClue, record: WildReportRadarProcessRecord) -> UIView {
|
||||
let box = UIView()
|
||||
box.backgroundColor = UIColor(hex: 0xFFFBEB)
|
||||
box.layer.cornerRadius = 10
|
||||
|
||||
let inner = UIStackView()
|
||||
inner.axis = .vertical
|
||||
inner.spacing = 10
|
||||
box.addSubview(inner)
|
||||
inner.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(12)
|
||||
}
|
||||
|
||||
inner.addArrangedSubview(makeInfoLabelRow(label: "处理人", value: record.handlerName))
|
||||
inner.addArrangedSubview(makeInfoLabelRow(
|
||||
label: "状态",
|
||||
value: clue.completed ? "已处理" : "正在处理中",
|
||||
valueColor: clue.completed ? AppColor.success : UIColor(hex: 0xF59E0B)
|
||||
))
|
||||
inner.addArrangedSubview(makePhoneRow(label: "联系电话", phone: record.phone, displayPhone: record.maskedPhone))
|
||||
|
||||
let progressStack = UIStackView()
|
||||
progressStack.axis = .vertical
|
||||
progressStack.spacing = 10
|
||||
progressStack.addArrangedSubview(WildReportUI.label("处理进度", font: .systemFont(ofSize: 14, weight: .bold), color: AppColor.textPrimary))
|
||||
for item in clue.processingTimeline {
|
||||
progressStack.addArrangedSubview(makeTimelineRow(item))
|
||||
}
|
||||
inner.addArrangedSubview(progressStack)
|
||||
return box
|
||||
}
|
||||
|
||||
private func makeTimelineRow(_ item: WildReportRadarProcessingTimelineItem) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .top
|
||||
row.spacing = 10
|
||||
|
||||
let dot = UIView()
|
||||
dot.backgroundColor = timelineDotColor(item.status)
|
||||
dot.layer.cornerRadius = 5
|
||||
dot.snp.makeConstraints { make in
|
||||
make.size.equalTo(10)
|
||||
}
|
||||
let dotContainer = UIView()
|
||||
dotContainer.addSubview(dot)
|
||||
dot.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(4)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
dotContainer.snp.makeConstraints { make in
|
||||
make.width.equalTo(10)
|
||||
}
|
||||
|
||||
let textStack = UIStackView()
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 2
|
||||
textStack.addArrangedSubview(WildReportUI.label(item.title, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.textPrimary))
|
||||
textStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||
|
||||
row.addArrangedSubview(dotContainer)
|
||||
row.addArrangedSubview(textStack)
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeRadarActionButtons(for clue: WildReportRadarActiveClue) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.spacing = 10
|
||||
row.distribution = .fillEqually
|
||||
|
||||
let navButton = makeRadarButton(title: clue.actionText, imageName: "map.fill", color: AppColor.primary)
|
||||
navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside)
|
||||
row.addArrangedSubview(navButton)
|
||||
|
||||
if isReportClue(clue), !clue.completed {
|
||||
let shareButton = makeRadarButton(title: "分享", imageName: "square.and.arrow.up", color: AppColor.success)
|
||||
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
||||
row.addArrangedSubview(shareButton)
|
||||
}
|
||||
|
||||
[navButton].forEach { button in
|
||||
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeRadarButton(title: String, imageName: String, color: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setImage(UIImage(systemName: imageName), for: .normal)
|
||||
button.tintColor = .white
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
button.backgroundColor = color
|
||||
button.layer.cornerRadius = 12
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
return button
|
||||
}
|
||||
|
||||
private func makeCompareText(_ text: String) -> UIView {
|
||||
let label = makeLineSpacedLabel(text, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||
label.backgroundColor = UIColor(hex: 0xF3F4F6)
|
||||
label.layer.cornerRadius = 10
|
||||
label.clipsToBounds = true
|
||||
label.textInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeDetailSection(_ detail: String) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .top
|
||||
row.spacing = 8
|
||||
let icon = UIImageView(image: UIImage(systemName: "doc.text.fill"))
|
||||
icon.tintColor = AppColor.primary
|
||||
icon.contentMode = .scaleAspectFit
|
||||
icon.snp.makeConstraints { make in
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
row.addArrangedSubview(icon)
|
||||
row.addArrangedSubview(makeLineSpacedLabel(detail, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeInfoLabelRow(label: String, value: String, valueColor: UIColor = AppColor.textPrimary) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 12
|
||||
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||
let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 14, weight: .semibold), color: valueColor, lines: 0)
|
||||
valueLabel.textAlignment = .right
|
||||
row.addArrangedSubview(titleLabel)
|
||||
row.addArrangedSubview(valueLabel)
|
||||
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
return row
|
||||
}
|
||||
|
||||
private func makePhoneRow(label: String, phone: String?, displayPhone: String?) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 12
|
||||
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||
row.addArrangedSubview(titleLabel)
|
||||
row.addArrangedSubview(UIView())
|
||||
|
||||
guard let displayPhone, !displayPhone.isEmpty else {
|
||||
row.addArrangedSubview(WildReportUI.label("-", font: .systemFont(ofSize: 14, weight: .semibold), color: AppColor.textPrimary))
|
||||
return row
|
||||
}
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
button.setTitle(displayPhone, for: .normal)
|
||||
button.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||
button.tintColor = AppColor.primary
|
||||
button.setTitleColor(AppColor.primary, for: .normal)
|
||||
button.semanticContentAttribute = .forceRightToLeft
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4)
|
||||
if let phone {
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
self?.callPhone(phone)
|
||||
}, for: .touchUpInside)
|
||||
}
|
||||
row.addArrangedSubview(button)
|
||||
button.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeLineSpacedLabel(_ text: String, font: UIFont, color: UIColor) -> WildReportRiskInsetLabel {
|
||||
let label = WildReportRiskInsetLabel()
|
||||
label.font = font
|
||||
label.textColor = color
|
||||
label.numberOfLines = 0
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 4
|
||||
label.attributedText = NSAttributedString(
|
||||
string: text,
|
||||
attributes: [.paragraphStyle: paragraph, .font: font, .foregroundColor: color]
|
||||
)
|
||||
return label
|
||||
}
|
||||
|
||||
private func isReportClue(_ clue: WildReportRadarActiveClue) -> Bool {
|
||||
clue.type == .red || clue.type == .yellow
|
||||
}
|
||||
|
||||
private func headerIconColor(for clue: WildReportRadarActiveClue) -> UIColor {
|
||||
if isReportClue(clue), clue.processingStarted, !clue.completed {
|
||||
return UIColor(hex: 0xF59E0B)
|
||||
}
|
||||
switch clue.type {
|
||||
case .blue: return AppColor.primary
|
||||
case .green: return AppColor.success
|
||||
case .red: return AppColor.danger
|
||||
case .yellow: return UIColor(hex: 0xF59E0B)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(for clue: WildReportRadarActiveClue) -> UIColor {
|
||||
if clue.completed { return AppColor.success }
|
||||
if clue.processingStarted, !clue.completed { return UIColor(hex: 0xF59E0B) }
|
||||
switch clue.type {
|
||||
case .blue: return AppColor.primary
|
||||
case .green: return AppColor.success
|
||||
case .red: return AppColor.danger
|
||||
case .yellow: return UIColor(hex: 0xF59E0B)
|
||||
}
|
||||
}
|
||||
|
||||
private func timelineDotColor(_ status: String) -> UIColor {
|
||||
switch status {
|
||||
case "done": return AppColor.success
|
||||
case "current": return UIColor(hex: 0xF59E0B)
|
||||
default: return UIColor(hex: 0xD1D5DB)
|
||||
}
|
||||
}
|
||||
|
||||
private func callPhone(_ phone: String) {
|
||||
guard phone.contains("*") == false,
|
||||
let url = URL(string: "tel://\(phone)")
|
||||
else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func addInfoRows(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||
if let name = clue.name {
|
||||
stack.addArrangedSubview(WildReportInfoRowView(title: clue.type == .green ? "摄影师" : "点位", value: name))
|
||||
@ -494,6 +898,10 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
@objc private func shareTapped() {
|
||||
viewModel.shareSelectedClue()
|
||||
}
|
||||
|
||||
@objc private func locateButtonTapped() {
|
||||
viewModel.focusCurrentLocation()
|
||||
}
|
||||
}
|
||||
|
||||
extension WildReportRiskMapViewController: MKMapViewDelegate {
|
||||
@ -533,16 +941,17 @@ final class WildReportMapAnnotation: NSObject, MKAnnotation {
|
||||
|
||||
/// 风险地图自定义脉冲圆点标记。
|
||||
private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
||||
private let pulseOuter = UIView()
|
||||
private let pulseInner = UIView()
|
||||
private let titleBadge = WildReportMapMarkerTitleBadge()
|
||||
private let markerContainer = UIView()
|
||||
private let pulseRings = (0..<3).map { _ in UIView() }
|
||||
private let selectedRing = UIView()
|
||||
private let dot = UIView()
|
||||
private let label = UILabel()
|
||||
private var showsTitleBadge = false
|
||||
|
||||
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)
|
||||
frame = CGRect(x: 0, y: 0, width: 72, height: 96)
|
||||
centerOffset = .zero
|
||||
canShowCallout = false
|
||||
setupUI()
|
||||
}
|
||||
@ -554,41 +963,51 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
||||
|
||||
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
|
||||
updateTitleBadgeVisibility(marker.kind == .selfLocation)
|
||||
titleBadge.text = marker.title
|
||||
pulseRings.forEach { ring in
|
||||
ring.layer.borderColor = color.withAlphaComponent(0.32).cgColor
|
||||
}
|
||||
selectedRing.layer.borderColor = color.withAlphaComponent(0.45).cgColor
|
||||
selectedRing.isHidden = !selected
|
||||
dot.backgroundColor = color
|
||||
label.isHidden = marker.kind != .selfLocation
|
||||
label.text = "我"
|
||||
startPulseIfNeeded()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .clear
|
||||
[pulseOuter, pulseInner, selectedRing, dot, label].forEach(addSubview)
|
||||
[pulseOuter, pulseInner, selectedRing].forEach { view in
|
||||
titleBadge.isHidden = true
|
||||
addSubview(titleBadge)
|
||||
addSubview(markerContainer)
|
||||
pulseRings.forEach(markerContainer.addSubview)
|
||||
markerContainer.addSubview(selectedRing)
|
||||
markerContainer.addSubview(dot)
|
||||
|
||||
pulseRings.forEach { view in
|
||||
view.backgroundColor = .clear
|
||||
view.layer.borderWidth = 2
|
||||
view.layer.cornerRadius = 10
|
||||
view.isUserInteractionEnabled = false
|
||||
}
|
||||
pulseOuter.layer.cornerRadius = 20
|
||||
pulseInner.layer.cornerRadius = 14
|
||||
selectedRing.layer.cornerRadius = 14
|
||||
selectedRing.layer.borderWidth = 2
|
||||
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)
|
||||
titleBadge.snp.makeConstraints { make in
|
||||
make.top.centerX.equalToSuperview()
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
pulseInner.snp.makeConstraints { make in
|
||||
markerContainer.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(28)
|
||||
make.size.equalTo(56)
|
||||
}
|
||||
pulseRings.forEach { ring in
|
||||
ring.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
}
|
||||
selectedRing.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
@ -598,21 +1017,81 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
||||
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
|
||||
private func updateTitleBadgeVisibility(_ isVisible: Bool) {
|
||||
guard showsTitleBadge != isVisible else { return }
|
||||
showsTitleBadge = isVisible
|
||||
titleBadge.isHidden = !isVisible
|
||||
markerContainer.snp.remakeConstraints { make in
|
||||
if isVisible {
|
||||
make.top.equalTo(titleBadge.snp.bottom).offset(4)
|
||||
make.centerX.equalToSuperview()
|
||||
} else {
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
make.size.equalTo(56)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPulseIfNeeded() {
|
||||
guard pulseRings.first?.layer.animation(forKey: "riskMapPulseOpacity") == nil else { return }
|
||||
let endSizes: [CGFloat] = [28, 40, 52]
|
||||
for (index, ring) in pulseRings.enumerated() {
|
||||
ring.layer.opacity = 0.72
|
||||
|
||||
let scale = CABasicAnimation(keyPath: "transform.scale")
|
||||
scale.fromValue = 1
|
||||
scale.toValue = endSizes[index] / 20
|
||||
|
||||
let opacity = CABasicAnimation(keyPath: "opacity")
|
||||
opacity.fromValue = 0.72
|
||||
opacity.toValue = 0
|
||||
|
||||
let animation = CAAnimationGroup()
|
||||
animation.animations = [scale, opacity]
|
||||
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
animation.duration = 2
|
||||
animation.repeatCount = .infinity
|
||||
pulseOuter.layer.add(animation, forKey: "opacityPulse")
|
||||
animation.isRemovedOnCompletion = false
|
||||
ring.layer.add(animation, forKey: "riskMapPulseOpacity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险地图当前位置标记标题胶囊。
|
||||
private final class WildReportMapMarkerTitleBadge: UIView {
|
||||
var text: String? {
|
||||
get { label.text }
|
||||
set { label.text = newValue }
|
||||
}
|
||||
|
||||
private let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.08
|
||||
layer.shadowRadius = 4
|
||||
layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||
|
||||
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
label.textColor = AppColor.textPrimary
|
||||
addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -676,12 +1155,31 @@ private final class WildReportRiskStatusBadge: UILabel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险地图底部卡片带内边距文本。
|
||||
private final class WildReportRiskInsetLabel: UILabel {
|
||||
var textInsets = UIEdgeInsets.zero {
|
||||
didSet { invalidateIntrinsicContentSize() }
|
||||
}
|
||||
|
||||
override func drawText(in rect: CGRect) {
|
||||
super.drawText(in: rect.inset(by: textInsets))
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let size = super.intrinsicContentSize
|
||||
return CGSize(
|
||||
width: size.width + textInsets.left + textInsets.right,
|
||||
height: size.height + textInsets.top + textInsets.bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险地图举报图片缩略图。
|
||||
private final class WildReportRiskImageTile: UIView {
|
||||
private let imageView = UIImageView()
|
||||
var onTap: (() -> Void)?
|
||||
|
||||
init(url: String) {
|
||||
init(url: String, size: CGFloat = 72) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = AppColor.pageBackgroundSoft
|
||||
layer.cornerRadius = 10
|
||||
@ -699,7 +1197,7 @@ private final class WildReportRiskImageTile: UIView {
|
||||
}
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
|
||||
snp.makeConstraints { make in
|
||||
make.size.equalTo(72)
|
||||
make.size.equalTo(size)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import MapKit
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@ -345,7 +346,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
||||
XCTAssertEqual(response.markers.last?.handleStatus?.displayText, WildReportStatus.processing.rawValue)
|
||||
}
|
||||
|
||||
func testMarkerDetailAPISendsRequestBodyAndDecodesGreenDetail() async throws {
|
||||
func testMarkerDetailAPISendsGETQueryAndDecodesGreenDetail() async throws {
|
||||
let json = Data("""
|
||||
{
|
||||
"code": 100000,
|
||||
@ -381,15 +382,16 @@ final class WildPhotographerReportTests: XCTestCase {
|
||||
))
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
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)
|
||||
XCTAssertNil(request.httpBody)
|
||||
let components = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false))
|
||||
let queryItems = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) })
|
||||
XCTAssertEqual(queryItems["scenic_id"], "128")
|
||||
XCTAssertEqual(queryItems["marker_type"], "green")
|
||||
XCTAssertEqual(queryItems["id"], "901")
|
||||
XCTAssertEqual(queryItems["latitude"], "43.3779")
|
||||
XCTAssertEqual(queryItems["longitude"], "84.0217")
|
||||
XCTAssertEqual(response.name, "阿依达娜")
|
||||
XCTAssertEqual(response.storeName, "空中草原旅拍店")
|
||||
XCTAssertTrue(response.online)
|
||||
@ -801,8 +803,6 @@ final class WildPhotographerReportTests: XCTestCase {
|
||||
func testRiskMapViewModelRejectsInvalidScenicID() async {
|
||||
let viewModel = WildReportRiskMapViewModel()
|
||||
let api = MockWildPhotographerReportAPI()
|
||||
var messages: [String] = []
|
||||
viewModel.onShowMessage = { messages.append($0) }
|
||||
|
||||
await viewModel.loadInitial(
|
||||
api: api,
|
||||
@ -811,7 +811,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
||||
)
|
||||
|
||||
XCTAssertTrue(api.riskMapRequests.isEmpty)
|
||||
XCTAssertEqual(messages.last, "请先选择景区后再查看风险地图")
|
||||
XCTAssertEqual(viewModel.errorMessage, "请先选择景区后再查看风险地图")
|
||||
}
|
||||
|
||||
func testRiskMapViewModelLoadsWithLocationAndMapsMarkerTypes() async {
|
||||
@ -862,6 +862,27 @@ final class WildPhotographerReportTests: XCTestCase {
|
||||
XCTAssertFalse(viewModel.markers.contains { $0.kind == .selfLocation })
|
||||
}
|
||||
|
||||
func testRiskMapViewModelFocusCurrentLocationSelectsSelfMarker() async {
|
||||
let viewModel = WildReportRiskMapViewModel()
|
||||
let api = MockWildPhotographerReportAPI()
|
||||
api.riskMapResponses = [makeRiskMapResponse()]
|
||||
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)
|
||||
|
||||
let didFocus = viewModel.focusCurrentLocation()
|
||||
|
||||
XCTAssertTrue(didFocus)
|
||||
XCTAssertEqual(viewModel.selectedMarker?.kind, .selfLocation)
|
||||
XCTAssertEqual(viewModel.region.center.latitude, 43.3779, accuracy: 0.000001)
|
||||
XCTAssertEqual(viewModel.region.center.longitude, 84.0217, accuracy: 0.000001)
|
||||
XCTAssertTrue(api.markerDetailRequests.isEmpty)
|
||||
}
|
||||
|
||||
func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async {
|
||||
let viewModel = WildReportRiskMapViewModel()
|
||||
let api = MockWildPhotographerReportAPI()
|
||||
@ -899,13 +920,56 @@ final class WildPhotographerReportTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.selectedClue?.detail, "多人结伴揽客")
|
||||
}
|
||||
|
||||
func testRiskMapViewModelMapsProcessingMarkerDetailForBottomCard() async {
|
||||
let viewModel = WildReportRiskMapViewModel()
|
||||
let api = MockWildPhotographerReportAPI()
|
||||
api.riskMapResponses = [makeRiskMapResponse()]
|
||||
api.markerDetailResponses = [
|
||||
WildReportMarkerDetailResponse(
|
||||
markerType: "yellow",
|
||||
id: 33,
|
||||
clueId: "2026年7月9日-0039",
|
||||
title: "疑似打野摄影师举报",
|
||||
statusTag: "正在处理中",
|
||||
latitude: "32.3774100",
|
||||
longitude: "119.3979350",
|
||||
distanceM: 7068,
|
||||
reportTypeText: "疑似打野",
|
||||
desc: "色地方噶是的法规",
|
||||
reporterName: "游客",
|
||||
reporterPhone: "121****1212",
|
||||
createdAt: "2026-07-09 11:37:26",
|
||||
evidences: [
|
||||
WildReportDetailEvidence(fileType: 1, fileTypeText: "图片", fileURL: "https://example.com/one.jpg"),
|
||||
WildReportDetailEvidence(fileType: 1, fileTypeText: "图片", fileURL: "https://example.com/two.jpg")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
await viewModel.loadInitial(
|
||||
api: api,
|
||||
locationProvider: MockRiskMapLocationProvider(.success(latitude: 32.429943033854165, longitude: 119.44032552083333)),
|
||||
scenicId: 128
|
||||
)
|
||||
let yellowMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .processingClue })
|
||||
|
||||
await viewModel.selectMarker(yellowMarker, api: api, scenicId: 128)
|
||||
|
||||
XCTAssertEqual(api.markerDetailRequests.first?.markerType, "yellow")
|
||||
XCTAssertEqual(viewModel.selectedClue?.id, "2026年7月9日-0039")
|
||||
XCTAssertEqual(viewModel.selectedClue?.statusText, "正在处理中")
|
||||
XCTAssertEqual(viewModel.selectedClue?.reporterName, "游客")
|
||||
XCTAssertEqual(viewModel.selectedClue?.reporterPhone, "121****1212")
|
||||
XCTAssertEqual(viewModel.selectedClue?.reportContents.first?.content, "色地方噶是的法规")
|
||||
XCTAssertEqual(viewModel.selectedClue?.images, ["https://example.com/one.jpg", "https://example.com/two.jpg"])
|
||||
XCTAssertEqual(viewModel.selectedClue?.processingStarted, true)
|
||||
}
|
||||
|
||||
func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async {
|
||||
let viewModel = WildReportRiskMapViewModel()
|
||||
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)),
|
||||
@ -916,7 +980,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
||||
await viewModel.selectMarker(redMarker, api: api, scenicId: 128)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedClue?.summary, "疑似打野")
|
||||
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
|
||||
XCTAssertEqual(viewModel.errorMessage, URLError(.badServerResponse).localizedDescription)
|
||||
}
|
||||
|
||||
func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async {
|
||||
|
||||
Reference in New Issue
Block a user