import SwiftUI import MapKit import UIKit import CoreLocation private struct SearchLocationResult: Identifiable { let id = UUID() let name: String let subtitle: String let coordinate: CLLocationCoordinate2D } private enum HomeSheet: String, Identifiable { case settings, logs var id: String { rawValue } } private enum SpoofState { case idle, verifying, active } private struct RealtimeLocationRequestContext { let intent: RealtimeLocationIntent let source: String let showFailureAlert: Bool } struct MapHomeView: View { @ObservedObject var setup: SetupCoordinator @StateObject private var favorites = FavoriteLocationStore() @StateObject private var actions = LocationActionCoordinator() @ObservedObject private var proxy = ProxyManager.shared @StateObject private var realtime = RealtimeLocationManager.shared @StateObject private var mapState: MapLocationState @ObservedObject private var net = NetworkMonitor.shared @State private var searchText = "" @State private var searchResults: [SearchLocationResult] = [] @State private var isSearching = false @State private var searchRequestID: UInt64 = 0 @State private var searchError = "" @State private var mapDidInitialize = false @State private var activeSheet: HomeSheet? @State private var showEnableTip = false @State private var showDisableTip = false @State private var activeTip: TipKind? @State private var manualHint = "" @AppStorage("activationTipCount") private var activationTipCount = 0 @AppStorage("activationTipDisabled") private var activationTipDisabled = false @State private var editingFavorite: FavoriteLocation? @State private var editName = "" @State private var reverseGeocodeTask: Task? @State private var geocodeDebounceTask: Task? @State private var showLocationAlert = false @State private var realtimeRequestTask: Task? @State private var realtimeRequestContext: RealtimeLocationRequestContext? @State private var copyConfirmed = false @State private var spoofState: SpoofState = .idle @State private var locationOperationTask: Task? @State private var locationOperationID: UInt64 = 0 // 激活时的坐标(本地存,绕过 C 桥接层精度丢失) @State private var activeSpoofLat: Double? @State private var activeSpoofLon: Double? init(setup: SetupCoordinator) { self.setup = setup let savedCoord = LastCoordinateStore.load() let initialZoom = ViewportStore.loadOrDefault() // 坐标:缓存 → 先给兜底深圳(启动后会由 initializeMap 按优先级覆盖) let initialCoord: CLLocationCoordinate2D if let coord = savedCoord?.coordinate { initialCoord = coord } else { initialCoord = CLLocationCoordinate2D(latitude: 22.544577, longitude: 113.94114) } _mapState = StateObject(wrappedValue: MapLocationState( initialCoordinate: initialCoord, initialViewportMeters: initialZoom )) } var body: some View { ZStack { MapViewRepresentable( selection: mapState.selection, cameraCommand: mapState.cameraCommand, onRealtimeLocationChanged: { location in handleNativeRealtimeLocation(location) }, onUserCenterChanged: { coordinate, distance in mapState.updateViewport(distanceMeters: distance) let previousRevision = mapState.selection.revision let revision = mapState.selectUserMapCenter(coordinate) guard revision != previousRevision else { return } LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude) favorites.select(nil) scheduleGeocode(coordinate: coordinate, revision: revision) }, onViewportChanged: { distance in mapState.updateViewport(distanceMeters: distance) }, onMapTap: { coordinate in favorites.select(nil) let revision = mapState.selectMapTap(coordinate) LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude) scheduleGeocode(coordinate: coordinate, revision: revision) }, onUserZoomChanged: { distance in ViewportStore.save(distance) } ) .ignoresSafeArea() .ignoresSafeArea(.keyboard) .overlay { Image(systemName: "mappin.and.ellipse") .font(.system(size: 38, weight: .semibold)) .symbolRenderingMode(.palette) .foregroundStyle(.white, .red) .shadow(color: .black.opacity(0.28), radius: 5, y: 3) .offset(y: -19) .allowsHitTesting(false) .accessibilityHidden(true) } HStack { zoomControls Spacer() } .padding(.leading, 16) .padding(.top, 130) .allowsHitTesting(true) .allowsHitTesting(true) VStack(spacing: 10) { topControls if !searchResults.isEmpty || !searchError.isEmpty { searchResultList } Spacer() // 右下角按钮 HStack { Spacer() VStack(spacing: 12) { Button { if let url = URL(string: "maps://app") { UIApplication.shared.open(url) } } label: { Image(systemName: "map.fill") .font(.system(size: 18, weight: .semibold)) .frame(width: 44, height: 44) .background(.regularMaterial, in: Circle()) .shadow(color: .black.opacity(0.2), radius: 6, y: 3) } Button { requestRealtimeLocation() } label: { if realtime.isRequesting { ProgressView() .frame(width: 44, height: 44) .background(.regularMaterial, in: Circle()) .shadow(color: .black.opacity(0.2), radius: 6, y: 3) } else { Image(systemName: "location.fill") .font(.system(size: 20, weight: .semibold)) .frame(width: 44, height: 44) .background(.regularMaterial, in: Circle()) .shadow(color: .black.opacity(0.2), radius: 6, y: 3) } } .disabled(realtimeRequestTask != nil || realtime.isRequesting) } } .padding(.trailing, 16) .padding(.bottom, 8) bottomControls } .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 12) } .navigationBarHidden(true) .sheet(item: $activeSheet) { sheet in NavigationView { switch sheet { case .settings: SettingsView(setup: setup, actions: actions) case .logs: RuntimeLogsView(setup: setup, actions: actions, testFavorite: testFavorite) } } } .sheet(item: $activeTip) { kind in TipSheetView(kind: kind) } .alert("无法直接跳转", isPresented: Binding( get: { !manualHint.isEmpty }, set: { if !$0 { manualHint = "" } } )) { Button("知道了", role: .cancel) {} } message: { Text(manualHint) } .onAppear(perform: initializeMap) .onChange(of: net.isAirplaneMode) { airplane in if airplane { showEnableTip = true } else { showEnableTip = false Task { await setup.refreshTrust() } } } .onChange(of: proxy.isRunning) { running in if !running && spoofState == .active { spoofState = .idle actions.clear() } } .sheet(isPresented: $showEnableTip) { enableTipSheet } .sheet(isPresented: $showDisableTip) { disableTipSheet } .alert("定位失败", isPresented: $showLocationAlert) { Button("打开设置") { openSettings(.locationServices) } Button("知道了", role: .cancel) {} } message: { Text("无法获取当前定位,请检查定位服务是否已开启") } .alert("编辑收藏名称", isPresented: Binding( get: { editingFavorite != nil }, set: { if !$0 { editingFavorite = nil } } )) { TextField("名称", text: $editName) Button("保存") { if let f = editingFavorite { let name = editName.trimmingCharacters(in: .whitespacesAndNewlines) let finalName = name.isEmpty ? f.name : name favorites.rename(f.id, to: finalName) mapState.updateExplicitName(finalName, forFavoriteID: f.id) } editingFavorite = nil } Button("取消", role: .cancel) { editingFavorite = nil } } message: { Text("修改收藏地点名称") } } private var topControls: some View { HStack(spacing: 10) { HStack(spacing: 10) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField("搜索地点", text: $searchText) .textInputAutocapitalization(.never).autocorrectionDisabled().submitLabel(.search).onSubmit(doSearch) if isSearching { ProgressView().controlSize(.small) } else if !searchText.isEmpty { Button { searchRequestID &+= 1 isSearching = false searchText = "" searchResults = [] searchError = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }.buttonStyle(.plain) } Button(action: doSearch) { Image(systemName: "arrow.right.circle.fill").font(.title3) } .disabled(searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSearching) } .padding(.horizontal, 14).frame(height: 48) .background(.regularMaterial, in: Capsule()) .shadow(color: .black.opacity(0.13), radius: 9, y: 4) Menu { Button { activeSheet = .logs } label: { Label("日志", systemImage: "list.bullet.rectangle") } Button { activeSheet = .settings } label: { Label("设置", systemImage: "gearshape") } } label: { Image(systemName: "ellipsis").font(.system(size: 20, weight: .bold)) .frame(width: 48, height: 48) .background(.regularMaterial, in: Circle()) .shadow(color: .black.opacity(0.13), radius: 9, y: 4) .contentShape(Circle()) }.accessibilityLabel("更多") } } // 搜索列表:动态高度,不写死 private var searchResultList: some View { VStack(spacing: 0) { if !searchError.isEmpty { Text(searchError).font(.footnote).foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .leading).padding(12) } ForEach(searchResults) { r in HStack(spacing: 8) { Button { selectSearchResult(r) } label: { HStack(spacing: 10) { Image(systemName: "mappin.and.ellipse") .foregroundStyle(.red) VStack(alignment: .leading, spacing: 3) { Text(r.name).font(.subheadline.weight(.semibold)).lineLimit(1) if !r.subtitle.isEmpty { Text(r.subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) } .contentShape(Rectangle()) }.buttonStyle(.plain) Button(role: .destructive) { deleteSearchResult(r) } label: { Image(systemName: "trash").frame(width: 36, height: 36).contentShape(Rectangle()) }.buttonStyle(.plain).foregroundStyle(.red) } .padding(.horizontal, 12).padding(.vertical, 10) if r.id != searchResults.last?.id { Divider().padding(.leading, 46) } } } .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 8, y: 4) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } // 底部:当前选点 + 收藏 + 主控按钮 private var bottomControls: some View { VStack(alignment: .leading, spacing: 12) { // 当前选点 HStack { VStack(alignment: .leading, spacing: 3) { Text(mapState.displayName ?? "当前选点").font(.subheadline.weight(.semibold)).lineLimit(1) Text(String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude)) .font(.caption.monospaced()) .foregroundStyle(copyConfirmed ? .green : .secondary) .onTapGesture { let text = String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude) UIPasteboard.general.string = text RuntimeLogger.info("APP", "地图", "复制坐标: \(text)") copyConfirmed = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyConfirmed = false } } .overlay(alignment: .top) { if copyConfirmed { Text("已复制") .font(.caption2.bold()) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 2) .background(.green, in: Capsule()) .offset(y: -24) } } } Spacer() // 帮助说明按钮 Button { if actions.virtualLocationEnabled { activeTip = .activation } else { showDisableTip = true } } label: { Text(actions.virtualLocationEnabled ? "无法生效?" : "无法取消?") .font(.system(size: 10, weight: .medium)) .foregroundStyle(.secondary) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.secondary.opacity(0.1), in: Capsule()) } .buttonStyle(.plain) // 收藏按钮 Button { if favorites.selectedFavoriteID != nil { favorites.select(nil) return } let snapshot = currentSelectionFavorite let favorite = favorites.save( name: snapshot.name, latitude: snapshot.latitude, longitude: snapshot.longitude, accuracy: snapshot.accuracy ) mapState.selectFavorite( CLLocationCoordinate2D(latitude: favorite.latitude, longitude: favorite.longitude), id: favorite.id, name: favorite.name ) } label: { Image(systemName: favorites.selectedFavoriteID != nil ? "star.fill" : "star") .font(.system(size: 18, weight: .semibold)) .frame(width: 38, height: 38) .background((favorites.selectedFavoriteID != nil ? Color.yellow : Color.gray).opacity(0.18), in: Circle()) } .buttonStyle(.plain) .foregroundStyle(favorites.selectedFavoriteID != nil ? .orange : .gray) .accessibilityLabel(favorites.selectedFavoriteID != nil ? "已收藏,点击取消收藏" : "收藏当前选点") } // 收藏 if favorites.favorites.isEmpty { Text("搜索或点击地图选点后,保存为收藏。").font(.footnote).foregroundStyle(.secondary) } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(favorites.favorites) { f in favoriteChip(f) } }.padding(.vertical, 2) } } // 飞行模式 if net.isAirplaneMode { HStack { Image(systemName: "airplane").foregroundStyle(.orange) Text("飞行模式已开启").font(.caption).foregroundStyle(.orange) Spacer() Button("查看说明") { activeTip = .activation }.font(.caption).buttonStyle(.bordered).tint(.orange) } } // 主控按钮(带动画) HStack(spacing: 10) { Button(action: handleMainButtonTap) { HStack(spacing: 6) { if spoofState == .verifying { ProgressView().tint(.white) } Text(spoofState == .active && needsSwitchButton ? "关闭" : buttonTitle) .font(.headline).lineLimit(1) } .frame(maxWidth: needsSwitchButton ? nil : .infinity) .frame(minWidth: needsSwitchButton ? 56 : nil) .padding(.vertical, 14) .padding(.horizontal, needsSwitchButton ? 12 : 14) } .background(buttonColor, in: RoundedRectangle(cornerRadius: 14)) .foregroundStyle(.white) .disabled(spoofState == .verifying) if needsSwitchButton { Button { beginLocationOperation() } label: { Label("切换到此处", systemImage: "arrow.triangle.swap") .font(.body.weight(.medium)).lineLimit(1) .frame(maxWidth: .infinity) .padding(.vertical, 12).padding(.horizontal, 16) } .background(.blue, in: RoundedRectangle(cornerRadius: 14)) .foregroundStyle(.white) .transition(.move(edge: .trailing).combined(with: .opacity)) } } .animation(.spring(response: 0.35, dampingFraction: 0.7), value: needsSwitchButton) .padding(.top, 4) } .padding(16) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) .shadow(color: .black.opacity(0.16), radius: 14, y: 6) } private var needsSwitchButton: Bool { guard spoofState == .active, let sLat = activeSpoofLat, let sLon = activeSpoofLon else { return false } return abs(sLat - mapState.selection.coordinate.latitude) > 0.0001 || abs(sLon - mapState.selection.coordinate.longitude) > 0.0001 } private var buttonTitle: String { switch spoofState { case .idle: return "开始虚拟定位" case .verifying: return "验证环境中…" case .active: return "停止虚拟定位" } } private var buttonColor: Color { switch spoofState { case .idle: return .blue case .verifying: return .gray case .active: return .green } } private func handleMainButtonTap() { switch spoofState { case .idle: beginLocationOperation() case .active: stopSpoofing() case .verifying: break } } private func beginLocationOperation() { guard spoofState != .verifying, locationOperationTask == nil else { return } locationOperationID &+= 1 let operationID = locationOperationID let selectionRevision = mapState.selection.revision let target = currentSelectionFavorite spoofState = .verifying locationOperationTask = Task { @MainActor in let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude) guard !Task.isCancelled, operationID == locationOperationID, selectionRevision == mapState.selection.revision else { if operationID == locationOperationID { spoofState = actions.virtualLocationEnabled ? .active : .idle locationOperationTask = nil } return } if result.isSuccess { let applied = actions.applyVerified(target) spoofState = applied ? .active : .idle if applied { activeSpoofLat = target.latitude activeSpoofLon = target.longitude } if applied && !activationTipDisabled { showEnableTip = true activationTipCount += 1 } } else { spoofState = actions.virtualLocationEnabled ? .active : .idle } locationOperationTask = nil } } private func stopSpoofing() { locationOperationTask?.cancel() locationOperationTask = nil locationOperationID &+= 1 actions.clear() spoofState = .idle activeSpoofLat = nil activeSpoofLon = nil showDisableTip = true } private func favoriteChip(_ f: FavoriteLocation) -> some View { HStack(spacing: 0) { Button { select(f) } label: { Label(f.name, systemImage: favorites.selectedFavoriteID == f.id ? "checkmark.circle.fill" : "mappin") .lineLimit(1).padding(.leading, 10).padding(.vertical, 8).padding(.trailing, 7).contentShape(Rectangle()) }.buttonStyle(.plain) Divider().frame(height: 22) Button { editingFavorite = f editName = f.name } label: { Image(systemName: "pencil").font(.caption2).frame(width: 32, height: 36).contentShape(Rectangle()) }.buttonStyle(.plain).foregroundStyle(.primary.opacity(0.55)) Divider().frame(height: 22) Button(role: .destructive) { favorites.delete(f) } label: { Image(systemName: "trash").font(.caption.weight(.semibold)).frame(width: 36, height: 36).contentShape(Rectangle()) }.buttonStyle(.plain).foregroundStyle(.red) } .background((favorites.selectedFavoriteID == f.id ? Color.red.opacity(0.14) : Color.secondary.opacity(0.12)), in: Capsule()) .overlay(Capsule().stroke(favorites.selectedFavoriteID == f.id ? Color.red.opacity(0.7) : Color.clear)) } @MainActor private func openSettings(_ destination: SystemSettingsDestination) { SystemSettingsNavigator.open(destination) { fallbackHint in if let fallbackHint { manualHint = fallbackHint } } } private var currentSelectionFavorite: FavoriteLocation { FavoriteLocation( name: mapState.displayName ?? String( format: "%.4f, %.4f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude ), latitude: mapState.selection.coordinate.latitude, longitude: mapState.selection.coordinate.longitude, accuracy: 25 ) } private var testFavorite: FavoriteLocation { currentSelectionFavorite } private var zoomControls: some View { VStack(spacing: 0) { Button { mapState.zoom(by: 0.5) } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .bold)) .frame(width: 52, height: 52) } .accessibilityLabel("放大地图") Divider().frame(width: 28) Text(MapZoomMath.viewportScaleLabel(distanceMeters: mapState.viewportMeters)) .font(.system(size: 9, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.7) .frame(width: 54, height: 28) .accessibilityLabel("当前地图范围") .accessibilityValue(MapZoomMath.viewportScaleLabel(distanceMeters: mapState.viewportMeters)) Divider().frame(width: 28) Button { mapState.zoom(by: 2) } label: { Image(systemName: "minus") .font(.system(size: 22, weight: .bold)) .frame(width: 52, height: 52) } .accessibilityLabel("缩小地图") } .buttonStyle(.plain) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 13, style: .continuous)) .shadow(color: .black.opacity(0.18), radius: 7, y: 3) } private func initializeMap() { guard !mapDidInitialize else { return } mapDidInitialize = true if let selected = favorites.selectedFavorite { mapState.selectFavorite( CLLocationCoordinate2D(latitude: selected.latitude, longitude: selected.longitude), id: selected.id, name: selected.name ) LastCoordinateStore.save(lat: selected.latitude, lon: selected.longitude) } else { mapState.focusSelection(distanceMeters: mapState.viewportMeters) scheduleGeocode( coordinate: mapState.selection.coordinate, revision: mapState.selection.revision ) } // 启动坐标获取优先级:缓存 > 实时定位 > 深圳兜底(init 时已给兜底) let savedCoord = LastCoordinateStore.load() if savedCoord == nil { requestRealtimeLocation() } } private func requestRealtimeLocation() { let intent = mapState.beginRealtimeIntent() if let nativeLocation = mapState.realtimeLocation, abs(nativeLocation.timestamp.timeIntervalSinceNow) <= 30 { acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, source: "MapKit 实时位置") return } startRealtimeLocationRequest( source: "定位按钮兜底", showFailureAlert: true, intent: intent ) } private func handleNativeRealtimeLocation(_ location: CLLocation) { mapState.updateRealtimeLocation(location) guard let context = realtimeRequestContext else { return } // MKMapView's MKUserLocation is the visible blue point. When it arrives, // fulfill the pending intent from that exact sample and cancel the slower // CLLocationManager fallback so the camera and dot cannot disagree. realtimeRequestContext = nil realtimeRequestTask?.cancel() acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "MapKit \(context.source)") } private func startRealtimeLocationRequest( source: String, showFailureAlert: Bool, intent suppliedIntent: RealtimeLocationIntent? = nil ) { let intent = suppliedIntent ?? mapState.beginRealtimeIntent() realtimeRequestContext = RealtimeLocationRequestContext( intent: intent, source: source, showFailureAlert: showFailureAlert ) // A button tap during the startup request retargets that same in-flight // Core Location request to the newer intent instead of being ignored. guard realtimeRequestTask == nil, !realtime.isRequesting else { return } realtimeRequestTask = Task { @MainActor in defer { realtimeRequestTask = nil realtimeRequestContext = nil } guard let coordinate = await realtime.requestLocation() else { guard let context = realtimeRequestContext, context.showFailureAlert, !Task.isCancelled, mapState.selection.revision == context.intent.selectionRevision else { return } RuntimeLogger.info("APP", "地图", "定位失败 status=\(realtime.authorizationStatus.rawValue)") showLocationAlert = true return } guard !Task.isCancelled, let context = realtimeRequestContext else { return } acceptRealtimeLocation(coordinate, intent: context.intent, source: context.source) } } private func acceptRealtimeLocation( _ coordinate: CLLocationCoordinate2D, intent: RealtimeLocationIntent, source: String ) { let currentViewport = mapState.viewportMeters let accepted = mapState.acceptRealtimeLocation(coordinate, intent: intent) RuntimeLogger.info("APP", "地图", "\(source)返回", details: [ "accepted": String(accepted), "lat": String(coordinate.latitude), "lon": String(coordinate.longitude) ]) guard accepted else { return } // 用点击时的缩放级别居中,不改变缩放 mapState.focusSelection(distanceMeters: currentViewport) LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude) favorites.select(nil) scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision) } private func scheduleGeocode(coordinate: CLLocationCoordinate2D, revision: UInt64) { geocodeDebounceTask?.cancel() reverseGeocodeTask?.cancel() geocodeDebounceTask = Task { @MainActor in do { try await Task.sleep(nanoseconds: 300_000_000) } catch { return } guard !Task.isCancelled, mapState.selection.revision == revision else { return } reverseGeocode(coordinate, revision: revision) } } private func reverseGeocode(_ coordinate: CLLocationCoordinate2D, revision: UInt64) { reverseGeocodeTask?.cancel() let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) reverseGeocodeTask = Task { @MainActor in let retryDelays: [UInt64] = [0, 800_000_000, 1_600_000_000] var lastError: Error? for (attempt, delay) in retryDelays.enumerated() { if delay > 0 { do { try await Task.sleep(nanoseconds: delay) } catch { return } } guard !Task.isCancelled, mapState.selection.revision == revision else { return } do { // 并⾏获取:CLGeocoder(地址结构化) + MKLocalSearch(地图显⽰名称) // MKLocalSearch 在无结果时抛错,不可与 CLGeocoder 共用 try await 导致互相影响 async let clPlacemarks = CLGeocoder().reverseGeocodeLocation(location) let mkRequest = MKLocalSearch.Request() mkRequest.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 400, longitudinalMeters: 400) let mkResponse = try? await MKLocalSearch(request: mkRequest).start() let placemarks = try await clPlacemarks guard !Task.isCancelled, mapState.selection.revision == revision, let placemark = placemarks.first else { return } let mapItemName = mkResponse?.mapItems.first?.name?.trimmingCharacters(in: .whitespacesAndNewlines) let mapItemPOI = mkResponse?.mapItems.first?.placemark.areasOfInterest?.first // 优先使⽤ MKLocalSearch 结果(与地图显⽰一致),CLGeocoder 作为 fallback let poi = { () -> String? in if let v = mapItemPOI?.trimmingCharacters(in: .whitespacesAndNewlines), !v.isEmpty { return v } if let v = mapItemName?.trimmingCharacters(in: .whitespacesAndNewlines), !v.isEmpty { return v } return placemark.areasOfInterest?.first ?? placemark.name }() let streetAddress = [placemark.thoroughfare, placemark.subThoroughfare] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } .joined(separator: " ") let descriptor = MapPlaceDescriptor( pointOfInterest: poi, streetAddress: streetAddress, road: placemark.thoroughfare, neighborhood: placemark.subLocality, district: placemark.subLocality ?? placemark.subAdministrativeArea, city: placemark.locality ?? placemark.subAdministrativeArea, province: placemark.administrativeArea, country: placemark.country ) _ = mapState.acceptPlaceDescriptor(descriptor, selectionRevision: revision) return } catch { guard !Task.isCancelled, mapState.selection.revision == revision else { return } lastError = error let nsError = error as NSError let isNetworkError = nsError.domain == kCLErrorDomain && nsError.code == CLError.network.rawValue guard isNetworkError, attempt < retryDelays.count - 1 else { break } RuntimeLogger.info("APP", "Geocode", "反向地理编码网络失败,准备重试", details: [ "attempt": String(attempt + 1), "revision": String(revision) ]) } } guard !Task.isCancelled, mapState.selection.revision == revision, let lastError else { return } RuntimeLogger.warning("APP", "Geocode", "反向地理编码失败", details: [ "error": lastError.localizedDescription, "revision": String(revision) ]) } } private func doSearch() { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty, !isSearching else { return } searchRequestID &+= 1 let requestID = searchRequestID isSearching = true searchError = "" let request = MKLocalSearch.Request() request.naturalLanguageQuery = query MKLocalSearch(request: request).start { response, error in DispatchQueue.main.async { guard requestID == searchRequestID else { return } isSearching = false if let error { searchResults = [] searchError = error.localizedDescription return } searchResults = (response?.mapItems ?? []).prefix(6).map { item in SearchLocationResult( name: item.name ?? "未命名", subtitle: [item.placemark.locality, item.placemark.subLocality, item.placemark.thoroughfare] .compactMap { $0 } .filter { !$0.isEmpty } .joined(separator: " · "), coordinate: item.placemark.coordinate ) } if searchResults.isEmpty { searchError = "没有找到相关地点" } } } } private func selectSearchResult(_ result: SearchLocationResult) { geocodeDebounceTask?.cancel() reverseGeocodeTask?.cancel() favorites.select(nil) mapState.selectSearchResult(result.coordinate, name: result.name) LastCoordinateStore.save(lat: result.coordinate.latitude, lon: result.coordinate.longitude) searchText = result.name searchResults = [] searchError = "" } private func deleteSearchResult(_ result: SearchLocationResult) { searchResults.removeAll { $0.id == result.id } } private func select(_ favorite: FavoriteLocation) { geocodeDebounceTask?.cancel() reverseGeocodeTask?.cancel() favorites.select(favorite.id) mapState.selectFavorite( CLLocationCoordinate2D(latitude: favorite.latitude, longitude: favorite.longitude), id: favorite.id, name: favorite.name ) LastCoordinateStore.save(lat: favorite.latitude, lon: favorite.longitude) } private var enableTipSheet: some View { NavigationView { ScrollView { VStack(alignment: .leading, spacing: 12) { ActivationTipContent(dismiss: {}) if activationTipCount >= 3 { Button(role: .destructive) { activationTipDisabled = true showEnableTip = false } label: { Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity) }.buttonStyle(.bordered) } }.padding(16) } .navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .bottom) { Button { showEnableTip = false } label: { Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12) }.buttonStyle(.borderedProminent).tint(.blue).padding(.horizontal, 16).padding(.bottom, 8) } } } private var disableTipSheet: some View { NavigationView { ScrollView { VStack(alignment: .leading, spacing: 12) { DeactivationTipContent(dismiss: {}) RemoveProxyTipContent(dismiss: {}) }.padding(16) } .navigationTitle("虚拟定位已关闭").navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .bottom) { Button { showDisableTip = false } label: { Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12) }.buttonStyle(.borderedProminent).tint(.blue).padding(.horizontal, 16).padding(.bottom, 8) } } } }