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