Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests. Co-authored-by: Cursor <cursoragent@cursor.com>
391 lines
15 KiB
Swift
391 lines
15 KiB
Swift
//
|
||
// OperatingAreaViews.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import CoreLocation
|
||
import SwiftUI
|
||
|
||
#if AMAP_ENABLED
|
||
import MAMapKit
|
||
#endif
|
||
|
||
/// 运营区域首页,按当前角色展示店铺或景区运营围栏。
|
||
struct OperatingAreaView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@EnvironmentObject private var permissionContext: PermissionContext
|
||
@Environment(\.operatingAreaAPI) private var operatingAreaAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
@StateObject 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 {
|
||
AppContentUnavailableView(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
|