Files
suixinkan_ios_new/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift
汉秋 26f4d0e671 Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.
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>
2026-06-26 10:16:35 +08:00

391 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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