Files
suixinkan_ios_new/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift
汉秋 1970572edd 迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。
新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:33:00 +08:00

393 lines
16 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.currentAppRole?.rawValue ?? "")-\(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 }
guard let polygon = MAPolygon(coordinates: &coordinates, count: UInt(coordinates.count)) else {
continue
}
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