// // 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 locateButton = UIButton(type: .system) 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.currentScenicId }, scenicNameProvider: @escaping () -> String = { AppStore.shared.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() configureLocateButton() 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) } locateButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(12) make.size.equalTo(44) } 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 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 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 } locateButton.isEnabled = hasCurrentLocation locateButton.alpha = hasCurrentLocation ? 1 : 0.46 } 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 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 if isReportClue(clue) { let title = WildReportUI.label("线索 ID:\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2) textStack.addArrangedSubview(title) 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: statusColor(for: clue)) row.addArrangedSubview(iconWrap) row.addArrangedSubview(textStack) row.addArrangedSubview(badge) textStack.setContentHuggingPriority(.defaultLow, for: .horizontal) badge.setContentHuggingPriority(.required, for: .horizontal) 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)) } 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 } let item = MKMapItem(placemark: MKPlacemark(coordinate: target.coordinate)) item.name = target.title item.openInMaps(launchOptions: [ MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving ]) } @objc private func shareTapped() { viewModel.shareSelectedClue() } @objc private func locateButtonTapped() { viewModel.focusCurrentLocation() } } 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 { 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 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?() } }