// // OperatingAreaViews.swift // suixinkan // // Created by Codex on 2026/6/25. // import CoreLocation import SwiftUI #if AMAP_ENABLED import MAMapKit #endif /// 运营区域首页,按当前角色展示店铺或景区运营围栏。 struct OperatingAreaView: View { @Environment(AccountContext.self) private var accountContext @Environment(PermissionContext.self) private var permissionContext @Environment(OperatingAreaAPI.self) private var operatingAreaAPI @Environment(ToastCenter.self) private var toastCenter @Environment(\.globalLoading) private var globalLoading @State private var viewModel = OperatingAreaViewModel() var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { summarySection mapSection areaListSection } .padding(AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } .background(Color(hex: 0xF5F7FA)) .navigationTitle("运营区域") .navigationBarTitleDisplayMode(.inline) .refreshable { await reload(showLoading: false) } .task(id: taskID) { await reload(showLoading: viewModel.items.isEmpty) } } private var taskID: String { "\(permissionContext.currentRole?.id ?? 0)-\(accountContext.currentScenic?.id ?? 0)-\(accountContext.currentStore?.id ?? 0)" } private var summarySection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { HStack { VStack(alignment: .leading, spacing: 4) { Text(viewModel.title) .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Text(scopeText) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } Spacer() Image(systemName: "map.fill") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(width: 42, height: 42) .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) } HStack(spacing: AppMetrics.Spacing.small) { OperatingAreaSummaryCard(title: "区域记录", value: "\(viewModel.summary.areaCount)", tint: AppDesign.primary) OperatingAreaSummaryCard(title: "有效围栏", value: "\(viewModel.summary.fenceCount)", tint: AppDesign.success) OperatingAreaSummaryCard(title: "当前门店", value: "\(viewModel.summary.currentStoreFenceCount)", tint: AppDesign.warning) } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private var mapSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { HStack { Text("围栏地图") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Spacer() Text(mapLegendText) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } if let reason = viewModel.blockReason { ContentUnavailableView(reason.message, systemImage: "mappin.slash") .frame(maxWidth: .infinity, minHeight: 260) .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) .overlay(alignment: .bottom) { if shouldShowRetry(for: reason) { Button("重新加载") { Task { await reload(showLoading: true) } } .buttonStyle(.borderedProminent) .padding(.bottom, AppMetrics.Spacing.medium) } } } else { OperatingAreaMapView(rings: viewModel.fenceRings) .frame(height: 320) .clipShape(RoundedRectangle(cornerRadius: 8)) } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private var areaListSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text("区域列表") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) if viewModel.items.isEmpty { Text(viewModel.blockReason?.message ?? "暂无运营区域") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) .frame(maxWidth: .infinity, minHeight: 88) .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) } else { ForEach(viewModel.items) { item in let ringCount = viewModel.fenceRings.filter { $0.itemId == item.id }.count OperatingAreaItemRow(item: item, ringCount: ringCount, isCurrentStore: isCurrentStore(item)) } } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private var scopeText: String { switch viewModel.mode { case .storeAdmin: return accountContext.currentStore?.name ?? "当前店铺" case .scenicAdmin: return accountContext.currentScenic?.name ?? "当前景区" case nil: return accountContext.currentScenic?.name ?? accountContext.currentStore?.name ?? "未选择业务范围" } } private var mapLegendText: String { switch viewModel.mode { case .storeAdmin: "红色为当前门店" case .scenicAdmin: "景区全部围栏" case nil: "只读展示" } } private func reload(showLoading: Bool) async { await globalLoading.withOptionalLoading(showLoading) { await viewModel.reload(api: operatingAreaAPI, accountContext: accountContext, permissionContext: permissionContext) } if let message = viewModel.errorMessage { toastCenter.show(message) } } private func isCurrentStore(_ item: OperatingAreaItem) -> Bool { guard case .storeAdmin(let storeId)? = viewModel.mode else { return false } return item.id == storeId } private func shouldShowRetry(for reason: OperatingAreaBlockReason) -> Bool { if case .backendMessage = reason { return true } return false } } private struct OperatingAreaSummaryCard: View { let title: String let value: String let tint: Color var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) Text(value) .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) .foregroundStyle(tint) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.small) .background(tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) } } private struct OperatingAreaItemRow: View { let item: OperatingAreaItem let ringCount: Int let isCurrentStore: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text(item.name.isEmpty ? "未命名区域" : item.name) .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Text("有效围栏 \(ringCount) 个") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } Spacer() if isCurrentStore { Text("当前门店") .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) .foregroundStyle(Color(hex: 0xDC2626)) .padding(.horizontal, 8) .frame(height: 24) .background(Color(hex: 0xDC2626).opacity(0.12), in: Capsule()) } } HStack(spacing: 8) { if !item.statusText.isEmpty { OperatingAreaTag(text: item.statusText, color: AppDesign.success) } if !item.typeText.isEmpty { OperatingAreaTag(text: item.typeText, color: AppDesign.primary) } if !item.auditStatusText.isEmpty { OperatingAreaTag(text: item.auditStatusText, color: AppDesign.warning) } } } .padding(AppMetrics.Spacing.small) .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) } } private struct OperatingAreaTag: View { let text: String let color: Color var body: some View { Text(text) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(color) .padding(.horizontal, 8) .frame(height: 22) .background(color.opacity(0.12), in: Capsule()) } } private struct OperatingAreaMapView: View { let rings: [OperatingFenceRing] var body: some View { #if AMAP_ENABLED OperatingAreaAMapRepresentable(rings: rings) #else OperatingAreaMapFallback(rings: rings) #endif } } private struct OperatingAreaMapFallback: View { let rings: [OperatingFenceRing] var body: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Label("模拟器未启用高德地图,以下为围栏坐标摘要", systemImage: "map") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) .foregroundStyle(AppDesign.textSecondary) ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(rings) { ring in VStack(alignment: .leading, spacing: 4) { Text(ring.regionName.isEmpty ? "未命名区域" : ring.regionName) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(ring.isCurrentStore ? Color(hex: 0xDC2626) : AppDesign.textPrimary) Text(ring.points.prefix(4).map { String(format: "%.6f, %.6f", $0.latitude, $0.longitude) }.joined(separator: " ")) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) .lineLimit(2) } .frame(maxWidth: .infinity, alignment: .leading) .padding(10) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } } } } .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color(hex: 0xEEF2F7)) } } #if AMAP_ENABLED private struct OperatingAreaAMapRepresentable: UIViewRepresentable { let rings: [OperatingFenceRing] func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> MAMapView { let mapView = MAMapView(frame: .zero) mapView.delegate = context.coordinator mapView.showsCompass = false mapView.showsScale = false mapView.isRotateEnabled = false return mapView } func updateUIView(_ mapView: MAMapView, context: Context) { context.coordinator.ringsByOverlay.removeAll() mapView.removeOverlays(mapView.overlays) mapView.removeAnnotations(mapView.annotations) var allCoordinates: [CLLocationCoordinate2D] = [] for ring in rings { var coordinates = ring.points.map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } guard coordinates.count >= 3 else { continue } let polygon = MAPolygon(coordinates: &coordinates, count: UInt(coordinates.count)) context.coordinator.ringsByOverlay[ObjectIdentifier(polygon)] = ring mapView.add(polygon) allCoordinates.append(contentsOf: coordinates) if let center = ring.centerCoordinate { let annotation = MAPointAnnotation() annotation.coordinate = center annotation.title = ring.regionName mapView.addAnnotation(annotation) } } if let region = Self.region(for: allCoordinates) { mapView.setRegion(region, animated: true) } } static func dismantleUIView(_ uiView: MAMapView, coordinator: Coordinator) { uiView.delegate = nil } private static func region(for coordinates: [CLLocationCoordinate2D]) -> MACoordinateRegion? { guard !coordinates.isEmpty else { return nil } let minLat = coordinates.map(\.latitude).min() ?? 0 let maxLat = coordinates.map(\.latitude).max() ?? 0 let minLng = coordinates.map(\.longitude).min() ?? 0 let maxLng = coordinates.map(\.longitude).max() ?? 0 let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, longitude: (minLng + maxLng) / 2) let span = MACoordinateSpan( latitudeDelta: max((maxLat - minLat) * 1.4, 0.01), longitudeDelta: max((maxLng - minLng) * 1.4, 0.01) ) return MACoordinateRegion(center: center, span: span) } final class Coordinator: NSObject, MAMapViewDelegate { var ringsByOverlay: [ObjectIdentifier: OperatingFenceRing] = [:] func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! { guard let polygon = overlay as? MAPolygon else { return nil } let ring = ringsByOverlay[ObjectIdentifier(polygon)] let renderer = MAPolygonRenderer(polygon: polygon) let color = ring?.isCurrentStore == true ? UIColor.systemRed : UIColor.systemBlue renderer?.strokeColor = color renderer?.fillColor = color.withAlphaComponent(0.18) renderer?.lineWidth = 3 return renderer } } } private extension OperatingFenceRing { var centerCoordinate: CLLocationCoordinate2D? { guard !points.isEmpty else { return nil } let latitude = points.reduce(0) { $0 + $1.latitude } / Double(points.count) let longitude = points.reduce(0) { $0 + $1.longitude } / Double(points.count) return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } } #endif