// // WildReportRiskMapViewController.swift // suixinkan // import Kingfisher import MapKit import SnapKit import UIKit /// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。 final class WildReportRiskMapViewController: BaseViewController { private static let cachedNavigationAppKey = "wildReportRiskMap.cachedNavigationApp" 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 mapControls = LocationReportMapControlStack() private let legendBar = UIStackView() private let detailContainerView = UIView() private let scrollView = UIScrollView() private let contentStack = UIStackView() private var annotationMap: [String: WildReportMapMarker] = [:] private var isShowingRiskMapLoading = false init( api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, locationProvider: any LocationProviding = LocationProvider.shared, scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName } ) { self.viewModel = WildReportRiskMapViewModel() 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 viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) if isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true { setRiskMapLoadingVisible(false) } } 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() configureMapControls() 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) } mapControls.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(12) } detailContainerView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(180) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.refreshContent(animated: true) } } } 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 configureMapControls() { mapControls.layer.shadowColor = UIColor.black.cgColor mapControls.layer.shadowOpacity = 0.1 mapControls.layer.shadowRadius = 8 mapControls.layer.shadowOffset = CGSize(width: 0, height: 4) mapControls.onZoomIn = { [weak self] in self?.zoomMap(by: 0.5) } mapControls.onZoomOut = { [weak self] in self?.zoomMap(by: 2) } mapControls.onRelocate = { [weak self] in self?.focusCurrentLocation() } mapContainerView.addSubview(mapControls) } 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(16) make.width.equalTo(scrollView.snp.width).offset(-32) } } @MainActor private func refreshContent(animated: Bool) { setRiskMapLoadingVisible(viewModel.isLoading) scenicNameLabel.text = viewModel.scenicAreaName updateLocateButtonState() syncMapAnnotations() mapView.setRegion(viewModel.region, animated: animated) updateAnnotationSelection() rebuildLegend() rebuildPanel() } @MainActor private func setRiskMapLoadingVisible(_ isVisible: Bool) { guard isShowingRiskMapLoading != isVisible else { return } isShowingRiskMapLoading = isVisible isVisible ? showLoading() : hideLoading() } @MainActor private func updateLocateButtonState() { let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation } mapControls.setRelocateEnabled(hasCurrentLocation) } 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 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 = makeRadarResultCard(clue: clue) contentStack.addArrangedSubview(card) } 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 container = UIStackView() container.axis = .vertical container.spacing = 6 let row = UIStackView() row.axis = .horizontal row.alignment = .top row.spacing = 12 let iconWrap = UIView() 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(20) } iconWrap.snp.makeConstraints { make in make.size.equalTo(44) } let textStack = UIStackView() textStack.axis = .vertical textStack.spacing = 6 var reportMetaText: String? if isReportClue(clue) { let title = WildReportUI.label("线索 ID:\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2) textStack.addArrangedSubview(title) var metaParts: [String] = [] if let distance = clue.distanceText ?? clue.distance { metaParts.append("距离:距离您\(distance)") } if let updatedAt = clue.updatedAt { metaParts.append("时间:\(updatedAt)") } reportMetaText = metaParts.isEmpty ? nil : metaParts.joined(separator: " ") } 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: statusColor(for: clue)) row.addArrangedSubview(iconWrap) row.addArrangedSubview(textStack) row.addArrangedSubview(badge) textStack.setContentHuggingPriority(.defaultLow, for: .horizontal) badge.setContentHuggingPriority(.required, for: .horizontal) container.addArrangedSubview(row) if let reportMetaText { let metaRow = UIStackView() metaRow.axis = .horizontal metaRow.spacing = 0 let spacer = UIView() spacer.snp.makeConstraints { make in make.width.equalTo(56) } let metaLabel = WildReportUI.label(reportMetaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary) metaLabel.textAlignment = .right metaLabel.setContentCompressionResistancePriority(.required, for: .horizontal) metaRow.addArrangedSubview(spacer) metaRow.addArrangedSubview(metaLabel) container.addArrangedSubview(metaRow) } return container } 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)) } 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 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) { presentImagePreview(imageURLs: [url], startIndex: 0) } @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 } if let cachedOption = cachedNavigationOption(for: target) { openNavigation(cachedOption, cacheSelection: false) return } presentNavigationOptions(for: target) } @objc private func shareTapped() { showLoading() Task { [weak self] in guard let self else { return } let payload = await self.viewModel.shareSelectedClue(api: self.api) await MainActor.run { self.hideLoading() guard let payload else { return } WeChatShareService.shareMiniProgram(payload, previewImage: UIImage(named: "report_share_cover")) { [weak self] result in self?.showShareResult(result) } } } } private func presentNavigationOptions(for target: WildReportMapMarker) { let options = availableNavigationOptions(for: target) let alert = UIAlertController(title: "选择导航软件", message: target.title, preferredStyle: .actionSheet) options.forEach { option in alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in self?.openNavigation(option, cacheSelection: true) }) } alert.addAction(UIAlertAction(title: "取消", style: .cancel)) if let popover = alert.popoverPresentationController { popover.sourceView = view popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 80, width: 1, height: 1) } present(alert, animated: true) } private func availableNavigationOptions(for target: WildReportMapMarker) -> [WildReportNavigationOption] { WildReportNavigationApp.allCases.compactMap { app in guard let option = navigationOption(for: app, target: target) else { return nil } guard isNavigationOptionAvailable(option) else { return nil } return option } } private func cachedNavigationOption(for target: WildReportMapMarker) -> WildReportNavigationOption? { guard let rawValue = UserDefaults.standard.string(forKey: Self.cachedNavigationAppKey), let app = WildReportNavigationApp(rawValue: rawValue), let option = navigationOption(for: app, target: target), isNavigationOptionAvailable(option) else { UserDefaults.standard.removeObject(forKey: Self.cachedNavigationAppKey) return nil } return option } private func navigationOption(for app: WildReportNavigationApp, target: WildReportMapMarker) -> WildReportNavigationOption? { WildReportNavigationOption( app: app, title: app.title, url: navigationURL(for: app, target: target), requiresAvailabilityCheck: app.requiresAvailabilityCheck ) } private func isNavigationOptionAvailable(_ option: WildReportNavigationOption) -> Bool { guard let url = option.url else { return false } return !option.requiresAvailabilityCheck || UIApplication.shared.canOpenURL(url) } private func openNavigation(_ option: WildReportNavigationOption, cacheSelection: Bool) { guard let url = option.url else { return } UIApplication.shared.open(url) { [weak self] success in guard success else { UserDefaults.standard.removeObject(forKey: Self.cachedNavigationAppKey) Task { @MainActor in self?.showToast("无法打开\(option.title)") } return } guard cacheSelection else { return } Task { @MainActor in UserDefaults.standard.set(option.app.rawValue, forKey: Self.cachedNavigationAppKey) } } } private func navigationURL(for app: WildReportNavigationApp, target: WildReportMapMarker) -> URL? { let destLat = target.coordinate.latitude let destLon = target.coordinate.longitude let name = encodedNavigationText(target.title) switch app { case .apple: return URL(string: "http://maps.apple.com/?daddr=\(destLat),\(destLon)&dirflg=d") case .amap: return URL(string: "iosamap://path?sourceApplication=suixinkan&dlat=\(destLat)&dlon=\(destLon)&dname=\(name)&dev=0&t=0") case .baidu: return URL(string: "baidumap://map/direction?destination=latlng:\(destLat),\(destLon)|name:\(name)&mode=driving&coord_type=wgs84") case .tencent: return URL(string: "qqmap://map/routeplan?type=drive&tocoord=\(destLat),\(destLon)&to=\(name)&referer=suixinkan") case .google: return URL(string: "comgooglemaps://?daddr=\(destLat),\(destLon)&directionsmode=driving") } } private func encodedNavigationText(_ text: String) -> String { var allowed = CharacterSet.urlQueryAllowed allowed.remove(charactersIn: "&=?") return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text } @MainActor private func showShareResult(_ result: WeChatShareResult) { switch result { case .success: showToast("分享成功") case .cancelled: showToast("已取消分享") case .notInstalled: showToast("请先安装微信") case .unsupported: showToast("当前微信版本不支持分享") case .invalidPayload(let message): showToast(message) case .sendFailed: showToast("微信分享发送失败") case .unknown: showToast("微信分享失败") } } private func zoomMap(by multiplier: CLLocationDegrees) { let visibleRegion = mapView.region let span = MKCoordinateSpan( latitudeDelta: min(max(visibleRegion.span.latitudeDelta * multiplier, 0.001), 180), longitudeDelta: min(max(visibleRegion.span.longitudeDelta * multiplier, 0.001), 180) ) mapView.setRegion(MKCoordinateRegion(center: visibleRegion.center, span: span), animated: true) } private func focusCurrentLocation() { viewModel.focusCurrentLocation() } } /// 风险地图导航软件选项。 private struct WildReportNavigationOption { let app: WildReportNavigationApp let title: String let url: URL? let requiresAvailabilityCheck: Bool } /// 风险地图支持打开的导航软件。 private enum WildReportNavigationApp: String, CaseIterable { case apple case amap case baidu case tencent case google var title: String { switch self { case .apple: return "Apple 地图" case .amap: return "高德地图" case .baidu: return "百度地图" case .tencent: return "腾讯地图" case .google: return "Google Maps" } } var requiresAvailabilityCheck: Bool { self != .apple } } 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 titleBadge = WildReportMapMarkerTitleBadge() private let markerContainer = UIView() private let pulseRings = (0..<3).map { _ in UIView() } private let selectedRing = UIView() private let dot = UIView() private var showsTitleBadge = false override init(annotation: MKAnnotation?, reuseIdentifier: String?) { super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) frame = CGRect(x: 0, y: 0, width: 72, height: 96) centerOffset = .zero 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 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 startPulseIfNeeded() } private func setupUI() { backgroundColor = .clear 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 } selectedRing.layer.cornerRadius = 14 selectedRing.layer.borderWidth = 2 dot.layer.cornerRadius = 7 dot.layer.borderWidth = 2 dot.layer.borderColor = UIColor.white.cgColor titleBadge.snp.makeConstraints { make in make.top.centerX.equalToSuperview() make.height.equalTo(24) } markerContainer.snp.makeConstraints { make in make.center.equalToSuperview() 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() make.size.equalTo(28) } dot.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(14) } } 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 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)) } } } /// 风险地图图例项视图。 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 { private let contentInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) 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 setContentHuggingPriority(.required, for: .horizontal) setContentCompressionResistancePriority(.required, for: .horizontal) snp.makeConstraints { make in make.height.equalTo(24) make.width.greaterThanOrEqualTo(52) } } override var intrinsicContentSize: CGSize { let size = super.intrinsicContentSize return CGSize( width: size.width + contentInsets.left + contentInsets.right, height: max(24, size.height + contentInsets.top + contentInsets.bottom) ) } override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: contentInsets)) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 风险地图底部卡片带内边距文本。 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, size: CGFloat = 72) { 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(size) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func tapped() { onTap?() } }