// // WildReportRiskMapViewController.swift // suixinkan // import Kingfisher import MapKit import SnapKit import UIKit /// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。 final class WildReportRiskMapViewController: BaseViewController { private let viewModel: WildReportRiskMapViewModel private let api: any WildPhotographerReportServing private let locationProvider: any LocationProviding private let scenicIdProvider: () -> Int private let scenicNameProvider: () -> String private let rootStack = UIStackView() private let mapContainerView = UIView() private let mapView = MKMapView() private let scenicCapsule = UIView() private let scenicNameLabel = UILabel() private let legendBar = UIStackView() private let detailContainerView = UIView() private let scrollView = UIScrollView() private let contentStack = UIStackView() private let loadingIndicator = UIActivityIndicatorView(style: .medium) private var annotationMap: [String: WildReportMapMarker] = [:] init( record: WildReportRecord, riskPoints: [WildReportRiskPoint], api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, locationProvider: any LocationProviding = LocationProvider.shared, scenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }, scenicNameProvider: @escaping () -> String = { AppStore.shared.currentScenicName } ) { self.viewModel = WildReportRiskMapViewModel(record: record, riskPoints: riskPoints) self.api = api self.locationProvider = locationProvider self.scenicIdProvider = scenicIdProvider self.scenicNameProvider = scenicNameProvider super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.loadInitial( api: api, locationProvider: locationProvider, scenicId: scenicIdProvider(), scenicName: scenicNameProvider() ) } } override func setupNavigationBar() { title = "附近风险地图" } override func setupUI() { view.backgroundColor = UIColor(hex: 0xF8FAFF) rootStack.axis = .vertical rootStack.spacing = 0 view.addSubview(rootStack) mapContainerView.backgroundColor = UIColor(hex: 0xE8F3FF) mapContainerView.clipsToBounds = true rootStack.addArrangedSubview(mapContainerView) mapView.delegate = self mapView.showsCompass = false mapView.showsScale = true mapView.pointOfInterestFilter = .excludingAll mapContainerView.addSubview(mapView) configureScenicCapsule() configureLegendBar() configureDetailArea() refreshContent(animated: false) } override func setupConstraints() { rootStack.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) } mapContainerView.snp.makeConstraints { make in make.height.equalTo(340) } mapView.snp.makeConstraints { make in make.edges.equalToSuperview() } scenicCapsule.snp.makeConstraints { make in make.top.leading.equalToSuperview().inset(12) make.height.equalTo(34) } detailContainerView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(180) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.refreshContent(animated: true) } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } } private func configureScenicCapsule() { scenicCapsule.backgroundColor = UIColor.white.withAlphaComponent(0.94) scenicCapsule.layer.cornerRadius = 17 scenicCapsule.layer.shadowColor = UIColor.black.cgColor scenicCapsule.layer.shadowOpacity = 0.08 scenicCapsule.layer.shadowRadius = 8 scenicCapsule.layer.shadowOffset = CGSize(width: 0, height: 4) let icon = UIImageView(image: UIImage(systemName: "mountain.2.fill")) icon.tintColor = AppColor.primary icon.contentMode = .scaleAspectFit scenicNameLabel.font = .systemFont(ofSize: 13, weight: .semibold) scenicNameLabel.textColor = UIColor(hex: 0x195591) mapContainerView.addSubview(scenicCapsule) scenicCapsule.addSubview(icon) scenicCapsule.addSubview(scenicNameLabel) icon.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.size.equalTo(16) } scenicNameLabel.snp.makeConstraints { make in make.leading.equalTo(icon.snp.trailing).offset(6) make.trailing.equalToSuperview().inset(12) make.centerY.equalToSuperview() } } private func configureLegendBar() { legendBar.axis = .horizontal legendBar.alignment = .center legendBar.distribution = .fillEqually legendBar.spacing = 6 legendBar.backgroundColor = .white legendBar.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) legendBar.isLayoutMarginsRelativeArrangement = true rootStack.addArrangedSubview(legendBar) rebuildLegend() let divider = UIView() divider.backgroundColor = AppColor.cardOutline rootStack.addArrangedSubview(divider) divider.snp.makeConstraints { make in make.height.equalTo(1 / UIScreen.main.scale) } } private func configureDetailArea() { detailContainerView.backgroundColor = UIColor(hex: 0xF8FAFF) rootStack.addArrangedSubview(detailContainerView) detailContainerView.addSubview(scrollView) scrollView.alwaysBounceVertical = true scrollView.addSubview(contentStack) contentStack.axis = .vertical contentStack.spacing = 14 scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() } contentStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 18, bottom: 18, right: 18)) make.width.equalTo(scrollView.snp.width).offset(-36) } } @MainActor private func refreshContent(animated: Bool) { scenicNameLabel.text = viewModel.scenicAreaName syncMapAnnotations() mapView.setRegion(viewModel.region, animated: animated) updateAnnotationSelection() rebuildLegend() rebuildPanel() } private func syncMapAnnotations() { let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation } mapView.removeAnnotations(oldAnnotations) annotationMap.removeAll() let annotations = viewModel.markers.map { marker in annotationMap[marker.id] = marker return WildReportMapAnnotation(marker: marker) } mapView.addAnnotations(annotations) } private func updateAnnotationSelection() { for annotation in mapView.annotations { guard let annotation = annotation as? WildReportMapAnnotation, let view = mapView.view(for: annotation) as? WildReportPulseMarkerAnnotationView else { continue } view.apply(marker: annotation.marker, selected: annotation.marker.id == viewModel.selectedMarkerID) } } private func rebuildLegend() { legendBar.arrangedSubviews.forEach { view in legendBar.removeArrangedSubview(view) view.removeFromSuperview() } [ WildReportMapMarkerKind.selfLocation, .wildPhotographer, .processingClue, .peerPhotographer ].forEach { kind in legendBar.addArrangedSubview(WildReportRiskLegendView(kind: kind)) } } @MainActor private func rebuildPanel() { contentStack.arrangedSubviews.forEach { view in contentStack.removeArrangedSubview(view) view.removeFromSuperview() } if viewModel.isLoading { contentStack.addArrangedSubview(makeLoadingView(text: "正在加载风险点位...")) } if let message = viewModel.errorMessage, !viewModel.isLoading { contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.triangle.fill", text: message)) } guard let marker = viewModel.selectedMarker, case .activeClue(let clue) = marker.detail else { contentStack.addArrangedSubview(makeStateCard(icon: "hand.tap.fill", text: "点击地图标记查看详情")) 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)) 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 { stack.addArrangedSubview(WildReportInfoRowView(title: clue.type == .green ? "摄影师" : "点位", value: name)) } if let store = clue.storeName { stack.addArrangedSubview(WildReportInfoRowView(title: "门店", value: store)) } if let distance = clue.distanceText ?? clue.distance { stack.addArrangedSubview(WildReportInfoRowView(title: "距离", value: distance)) } if let updatedAt = clue.updatedAt { stack.addArrangedSubview(WildReportInfoRowView(title: "时间", value: updatedAt)) } if let reporter = clue.reporterName { stack.addArrangedSubview(WildReportInfoRowView(title: "举报人", value: reporter)) } if let phone = clue.maskedReporterPhone ?? clue.reporterPhone { stack.addArrangedSubview(WildReportInfoRowView(title: "联系电话", value: phone)) } } private func addDescriptionBlocks(for clue: WildReportRadarActiveClue, to stack: UIStackView) { if let summary = clue.summary { stack.addArrangedSubview(makeBodyLabel(summary)) } if let detail = clue.detail, detail != clue.summary { stack.addArrangedSubview(makeBodyLabel(detail)) } for content in clue.reportContents.prefix(3) { let time = content.time.isEmpty ? "" : "\(content.time) " stack.addArrangedSubview(makeBodyLabel("\(time)\(content.content)")) } } private func addMediaRow(for clue: WildReportRadarActiveClue, to stack: UIStackView) { guard !clue.images.isEmpty else { return } let scroll = UIScrollView() scroll.showsHorizontalScrollIndicator = false let row = UIStackView() row.axis = .horizontal row.spacing = 10 scroll.addSubview(row) row.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalTo(72) } for url in clue.images.prefix(9) { let tile = WildReportRiskImageTile(url: url) tile.onTap = { [weak self] in self?.presentImagePreview(url: url) } row.addArrangedSubview(tile) } stack.addArrangedSubview(scroll) scroll.snp.makeConstraints { make in make.height.equalTo(72) } } private func addProcessingBlock(for clue: WildReportRadarActiveClue, to stack: UIStackView) { guard let record = clue.record else { return } let box = UIView() box.backgroundColor = UIColor(hex: 0xF8FAFF) box.layer.cornerRadius = 12 box.layer.borderWidth = 1 box.layer.borderColor = AppColor.cardOutline.cgColor let inner = UIStackView() inner.axis = .vertical inner.spacing = 8 box.addSubview(inner) inner.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) } inner.addArrangedSubview(WildReportInfoRowView(title: "处理人", value: record.handlerName)) inner.addArrangedSubview(makeBodyLabel(record.processResult)) stack.addArrangedSubview(box) } private func makeButtonRow(for clue: WildReportRadarActiveClue) -> UIView { let row = UIStackView() row.axis = .horizontal row.spacing = 10 row.distribution = .fillEqually let navButton = WildReportUI.iconButton( title: clue.type == .blue ? "刷新定位" : "导航到此", imageName: clue.type == .blue ? "location.fill" : "map.fill", style: .primary ) navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside) let shareButton = WildReportUI.iconButton(title: "分享", imageName: "square.and.arrow.up", style: .secondary) shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside) [navButton, shareButton].forEach { button in button.snp.makeConstraints { make in make.height.equalTo(44) } row.addArrangedSubview(button) } 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() { 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() { viewModel.shareSelectedClue() } } extension WildReportRiskMapViewController: MKMapViewDelegate { func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { guard let annotation = annotation as? WildReportMapAnnotation else { return nil } let identifier = "WildReportPulseMarkerAnnotationView" let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? WildReportPulseMarkerAnnotationView) ?? WildReportPulseMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) view.annotation = annotation view.apply(marker: annotation.marker, selected: annotation.marker.id == viewModel.selectedMarkerID) return view } func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) { guard let annotation = annotation as? WildReportMapAnnotation else { return } Task { await viewModel.selectMarker( annotation.marker, api: api, scenicId: scenicIdProvider() ) } } } /// 风险地图 MKAnnotation 包装。 final class WildReportMapAnnotation: NSObject, MKAnnotation { let marker: WildReportMapMarker var coordinate: CLLocationCoordinate2D { marker.coordinate } var title: String? { marker.title } init(marker: WildReportMapMarker) { self.marker = marker 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) } }