Add OperatingArea and PilotCertification modules with live push readiness.
Migrate operating area and pilot certification from home placeholders, extend Live with playback and push readiness flows, and add pilot certificate OSS upload. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
390
suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift
Normal file
390
suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift
Normal file
@ -0,0 +1,390 @@
|
||||
//
|
||||
// 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
|
||||
Reference in New Issue
Block a user