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:
52
suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift
Normal file
52
suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift
Normal file
@ -0,0 +1,52 @@
|
||||
//
|
||||
// OperatingAreaAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。
|
||||
@MainActor
|
||||
protocol OperatingAreaServing {
|
||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 运营区域 API,封装运营区域只读围栏接口。
|
||||
final class OperatingAreaAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
|
||||
/// 初始化运营区域 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取当前店铺的运营区域围栏。
|
||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store/business-area",
|
||||
queryItems: [URLQueryItem(name: "store_id", value: String(storeId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取景区管理员视角下的运营区域围栏。
|
||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/scenic-admin/business-area",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension OperatingAreaAPI: OperatingAreaServing {}
|
||||
@ -0,0 +1,374 @@
|
||||
//
|
||||
// OperatingAreaModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 运营区域入口模式,区分店铺管理员与景区管理员两条接口链路。
|
||||
enum OperatingAreaEntryMode: Equatable {
|
||||
case storeAdmin(storeId: Int)
|
||||
case scenicAdmin(scenicId: Int)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .storeAdmin:
|
||||
"店铺运营区域"
|
||||
case .scenicAdmin:
|
||||
"景区运营区域"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域接口单项,表示一个门店或区域围栏。
|
||||
struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let businessMapArea: OperatingMapArea?
|
||||
let statusText: String
|
||||
let typeText: String
|
||||
let auditStatusText: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case businessMapArea = "business_map_area"
|
||||
case statusText = "status_text"
|
||||
case typeText = "type_text"
|
||||
case auditStatusText = "audit_status_text"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
businessMapArea: OperatingMapArea? = nil,
|
||||
statusText: String = "",
|
||||
typeText: String = "",
|
||||
auditStatusText: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.businessMapArea = businessMapArea
|
||||
self.statusText = statusText
|
||||
self.typeText = typeText
|
||||
self.auditStatusText = auditStatusText
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.operatingDecodeLossyString(forKey: .name)
|
||||
businessMapArea = try container.decodeIfPresent(OperatingMapArea.self, forKey: .businessMapArea)
|
||||
statusText = try container.operatingDecodeLossyString(forKey: .statusText)
|
||||
typeText = try container.operatingDecodeLossyString(forKey: .typeText)
|
||||
auditStatusText = try container.operatingDecodeLossyString(forKey: .auditStatusText)
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域原始 JSON 值,用于保留并解析后端不稳定的 `business_map_area`。
|
||||
enum OperatingMapArea: Decodable, Equatable {
|
||||
case null
|
||||
case bool(Bool)
|
||||
case number(Double)
|
||||
case string(String)
|
||||
case array([OperatingMapArea])
|
||||
case object([String: OperatingMapArea])
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([OperatingMapArea].self) {
|
||||
self = .array(value)
|
||||
} else {
|
||||
let keyed = try decoder.container(keyedBy: OperatingDynamicCodingKey.self)
|
||||
var object: [String: OperatingMapArea] = [:]
|
||||
for key in keyed.allKeys {
|
||||
object[key.stringValue] = try keyed.decode(OperatingMapArea.self, forKey: key)
|
||||
}
|
||||
self = .object(object)
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 JSON 字符串解析为运营区域 JSON 值。
|
||||
static func parseString(_ text: String) -> OperatingMapArea? {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let raw = try? JSONSerialization.jsonObject(with: data)
|
||||
else { return nil }
|
||||
return fromJSONObject(raw)
|
||||
}
|
||||
|
||||
private static func fromJSONObject(_ value: Any) -> OperatingMapArea {
|
||||
switch value {
|
||||
case is NSNull:
|
||||
return .null
|
||||
case let value as Bool:
|
||||
return .bool(value)
|
||||
case let value as NSNumber:
|
||||
return .number(value.doubleValue)
|
||||
case let value as String:
|
||||
return .string(value)
|
||||
case let value as [Any]:
|
||||
return .array(value.map(fromJSONObject))
|
||||
case let value as [String: Any]:
|
||||
return .object(value.mapValues(fromJSONObject))
|
||||
default:
|
||||
return .null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域地理坐标点。
|
||||
struct OperatingGeoPoint: Equatable, Hashable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
}
|
||||
|
||||
/// 运营区域围栏环,用于地图绘制和列表展示。
|
||||
struct OperatingFenceRing: Identifiable, Equatable {
|
||||
let id: String
|
||||
let itemId: Int
|
||||
let regionName: String
|
||||
let points: [OperatingGeoPoint]
|
||||
let isCurrentStore: Bool
|
||||
|
||||
init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) {
|
||||
self.id = "\(itemId)-\(ringIndex)"
|
||||
self.itemId = itemId
|
||||
self.regionName = regionName
|
||||
self.points = points
|
||||
self.isCurrentStore = isCurrentStore
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域阻断原因,用于缺少上下文或数据不可展示时给出稳定提示。
|
||||
enum OperatingAreaBlockReason: Equatable {
|
||||
case missingStore
|
||||
case missingScenic
|
||||
case unsupportedRole
|
||||
case emptyAreaList
|
||||
case noParsableFenceData
|
||||
case backendMessage(String)
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .missingStore:
|
||||
"请先选择店铺"
|
||||
case .missingScenic:
|
||||
"请先选择景区"
|
||||
case .unsupportedRole:
|
||||
"当前账号暂不支持运营区域"
|
||||
case .emptyAreaList:
|
||||
"当前景区无区域数据"
|
||||
case .noParsableFenceData:
|
||||
"有区域记录,但未解析出有效围栏,请检查坐标数据"
|
||||
case .backendMessage(let message):
|
||||
message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "运营区域加载失败" : message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运营区域围栏解析器,兼容 GeoJSON、数组、字符串 JSON 和常见路径字段。
|
||||
enum OperatingAreaParser {
|
||||
/// 将后端 `business_map_area` 解析为若干闭合多边形环。
|
||||
static func parseToRings(_ area: OperatingMapArea?) -> [[OperatingGeoPoint]] {
|
||||
guard let area else { return [] }
|
||||
return parseElement(area)
|
||||
}
|
||||
|
||||
private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] {
|
||||
switch value {
|
||||
case .null, .bool, .number:
|
||||
return []
|
||||
case .string(let text):
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let parsed = OperatingMapArea.parseString(trimmed) else { return [] }
|
||||
return parseElement(parsed)
|
||||
case .array(let array):
|
||||
return parseArrayRoot(array)
|
||||
case .object(let object):
|
||||
return parseObject(object)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
let type: String?
|
||||
if case let .string(rawType)? = object["type"] {
|
||||
type = rawType.lowercased()
|
||||
} else {
|
||||
type = nil
|
||||
}
|
||||
|
||||
switch type {
|
||||
case "polygon":
|
||||
guard case let .array(coordinates)? = object["coordinates"],
|
||||
case let .array(outer)? = coordinates.first
|
||||
else { return [] }
|
||||
return parseRing(outer).map { [$0] } ?? []
|
||||
case "multipolygon":
|
||||
guard case let .array(polygons)? = object["coordinates"] else { return [] }
|
||||
return polygons.compactMap { polygon -> [OperatingGeoPoint]? in
|
||||
guard case let .array(rings) = polygon,
|
||||
case let .array(firstRing)? = rings.first
|
||||
else { return nil }
|
||||
return parseRing(firstRing)
|
||||
}
|
||||
default:
|
||||
for key in ["path", "paths", "points", "coordinates"] {
|
||||
if let nested = object[key] {
|
||||
return parseElement(nested)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
guard let first = array.first else { return [] }
|
||||
switch first {
|
||||
case .object:
|
||||
return parseObjectRing(array).map { [$0] } ?? []
|
||||
case .array(let nested):
|
||||
guard let sample = nested.first else { return [] }
|
||||
switch sample {
|
||||
case .array(let pointCandidate):
|
||||
if pointCandidate.count >= 2, pointCandidate[0].numberValue != nil, pointCandidate[1].numberValue != nil {
|
||||
return parseRing(nested).map { [$0] } ?? []
|
||||
}
|
||||
return array.compactMap { element -> [OperatingGeoPoint]? in
|
||||
guard case let .array(segment) = element else { return nil }
|
||||
return parseRing(segment)
|
||||
}
|
||||
case .object:
|
||||
return parseObjectRing(nested).map { [$0] } ?? []
|
||||
default:
|
||||
return parseRing(nested).map { [$0] } ?? []
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
guard case let .object(object) = element else { return nil }
|
||||
return pointFromObject(object)
|
||||
}
|
||||
return points.count >= 3 ? points : nil
|
||||
}
|
||||
|
||||
private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
switch element {
|
||||
case .array(let pair):
|
||||
guard pair.count >= 2,
|
||||
let a = pair[0].numberValue,
|
||||
let b = pair[1].numberValue
|
||||
else { return nil }
|
||||
return normalizePair(a, b)
|
||||
case .object(let object):
|
||||
return pointFromObject(object)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return points.count >= 3 ? points : nil
|
||||
}
|
||||
|
||||
private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? {
|
||||
let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue
|
||||
let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue
|
||||
guard let lat, let lng else { return nil }
|
||||
return OperatingGeoPoint(latitude: lat, longitude: lng)
|
||||
}
|
||||
|
||||
private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint {
|
||||
if looksLikeLngLatPair(lng: a, lat: b) {
|
||||
return OperatingGeoPoint(latitude: b, longitude: a)
|
||||
}
|
||||
if looksLikeLatLngPair(lat: a, lng: b) {
|
||||
return OperatingGeoPoint(latitude: a, longitude: b)
|
||||
}
|
||||
if abs(a) > 90 {
|
||||
return OperatingGeoPoint(latitude: b, longitude: a)
|
||||
}
|
||||
return OperatingGeoPoint(latitude: a, longitude: b)
|
||||
}
|
||||
|
||||
private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool {
|
||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat)
|
||||
}
|
||||
|
||||
private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool {
|
||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OperatingDynamicCodingKey: CodingKey {
|
||||
let stringValue: String
|
||||
let intValue: Int?
|
||||
|
||||
init?(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
intValue = nil
|
||||
}
|
||||
|
||||
init?(intValue: Int) {
|
||||
stringValue = String(intValue)
|
||||
self.intValue = intValue
|
||||
}
|
||||
}
|
||||
|
||||
private extension OperatingMapArea {
|
||||
var numberValue: Double? {
|
||||
switch self {
|
||||
case .number(let value):
|
||||
return value
|
||||
case .string(let text):
|
||||
return Double(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func operatingDecodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func operatingDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) { return intValue }
|
||||
if let doubleValue = Double(text) { return Int(doubleValue) }
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
17
suixinkan/Features/OperatingArea/OperatingArea.md
Normal file
17
suixinkan/Features/OperatingArea/OperatingArea.md
Normal file
@ -0,0 +1,17 @@
|
||||
# OperatingArea 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
OperatingArea 负责首页 `operating-area` 入口的运营区域展示。模块按当前角色和业务上下文进入店铺管理员或景区管理员模式,读取后端围栏数据并只读展示。
|
||||
|
||||
## 核心流程
|
||||
|
||||
- `OperatingAreaView` 从环境读取 `AccountContext`、`PermissionContext` 和 `OperatingAreaAPI`。
|
||||
- `OperatingAreaViewModel` 根据角色名称和当前景区/门店解析入口模式。
|
||||
- 店铺管理员调用 `/api/app/store/business-area`,景区管理员调用 `/api/app/scenic-admin/business-area`。
|
||||
- `OperatingAreaParser` 解析 `business_map_area`,兼容 GeoJSON、坐标数组和字符串 JSON。
|
||||
- 真机构建启用高德地图时绘制 polygon;模拟器或未启用高德时展示坐标摘要兜底。
|
||||
|
||||
## 边界
|
||||
|
||||
本模块只展示已有区域和围栏,不提供新建、编辑、删除或绘制保存能力。缺少景区/门店、角色不支持、空列表和不可解析围栏都会进入明确空态或阻断提示。
|
||||
@ -0,0 +1,154 @@
|
||||
//
|
||||
// OperatingAreaViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 运营区域 ViewModel,负责角色分流、围栏加载、解析和错误状态管理。
|
||||
final class OperatingAreaViewModel {
|
||||
private(set) var mode: OperatingAreaEntryMode?
|
||||
private(set) var loading = false
|
||||
private(set) var items: [OperatingAreaItem] = []
|
||||
private(set) var fenceRings: [OperatingFenceRing] = []
|
||||
private(set) var blockReason: OperatingAreaBlockReason?
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
/// 按当前账号和角色刷新运营区域。
|
||||
func reload(
|
||||
api: any OperatingAreaServing,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext
|
||||
) async {
|
||||
guard !loading else { return }
|
||||
guard let nextMode = resolveMode(accountContext: accountContext, permissionContext: permissionContext) else {
|
||||
return
|
||||
}
|
||||
|
||||
mode = nextMode
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
blockReason = nil
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
let response: ListPayload<OperatingAreaItem>
|
||||
switch nextMode {
|
||||
case .storeAdmin(let storeId):
|
||||
response = try await api.storeBusinessArea(storeId: storeId)
|
||||
case .scenicAdmin(let scenicId):
|
||||
response = try await api.scenicAdminBusinessArea(scenicId: scenicId)
|
||||
}
|
||||
apply(items: response.list, mode: nextMode)
|
||||
} catch {
|
||||
resetLoadedData()
|
||||
let message = error.localizedDescription
|
||||
errorMessage = message
|
||||
blockReason = .backendMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前上下文解析运营区域入口模式。
|
||||
@discardableResult
|
||||
func resolveMode(accountContext: AccountContext, permissionContext: PermissionContext) -> OperatingAreaEntryMode? {
|
||||
let roleName = permissionContext.currentRole?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lowercaseRoleName = roleName.lowercased()
|
||||
let isStoreRole = roleName.contains("店铺") || roleName.contains("门店") || lowercaseRoleName.contains("store")
|
||||
let isScenicRole = roleName.contains("景区") || lowercaseRoleName.contains("scenic")
|
||||
|
||||
if isStoreRole {
|
||||
guard let storeId = accountContext.currentStore?.id, storeId > 0 else {
|
||||
setBlocked(.missingStore)
|
||||
return nil
|
||||
}
|
||||
mode = .storeAdmin(storeId: storeId)
|
||||
blockReason = nil
|
||||
return mode
|
||||
}
|
||||
|
||||
if isScenicRole {
|
||||
guard let scenicId = accountContext.currentScenic?.id, scenicId > 0 else {
|
||||
setBlocked(.missingScenic)
|
||||
return nil
|
||||
}
|
||||
mode = .scenicAdmin(scenicId: scenicId)
|
||||
blockReason = nil
|
||||
return mode
|
||||
}
|
||||
|
||||
if let storeId = accountContext.currentStore?.id, storeId > 0 {
|
||||
mode = .storeAdmin(storeId: storeId)
|
||||
blockReason = nil
|
||||
return mode
|
||||
}
|
||||
|
||||
if let scenicId = accountContext.currentScenic?.id, scenicId > 0 {
|
||||
mode = .scenicAdmin(scenicId: scenicId)
|
||||
blockReason = nil
|
||||
return mode
|
||||
}
|
||||
|
||||
setBlocked(.unsupportedRole)
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 根据状态生成统计展示。
|
||||
var summary: (areaCount: Int, fenceCount: Int, currentStoreFenceCount: Int) {
|
||||
(
|
||||
areaCount: items.count,
|
||||
fenceCount: fenceRings.count,
|
||||
currentStoreFenceCount: fenceRings.filter(\.isCurrentStore).count
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前页面标题。
|
||||
var title: String {
|
||||
mode?.title ?? "运营区域"
|
||||
}
|
||||
|
||||
private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) {
|
||||
self.items = items
|
||||
if items.isEmpty {
|
||||
fenceRings = []
|
||||
blockReason = .emptyAreaList
|
||||
return
|
||||
}
|
||||
|
||||
let currentStoreId: Int? = {
|
||||
guard case .storeAdmin(let storeId) = mode else { return nil }
|
||||
return storeId
|
||||
}()
|
||||
|
||||
let parsed = items.flatMap { item -> [OperatingFenceRing] in
|
||||
OperatingAreaParser.parseToRings(item.businessMapArea).enumerated().compactMap { index, points in
|
||||
guard points.count >= 3 else { return nil }
|
||||
return OperatingFenceRing(
|
||||
itemId: item.id,
|
||||
regionName: item.name,
|
||||
points: points,
|
||||
isCurrentStore: currentStoreId == item.id,
|
||||
ringIndex: index
|
||||
)
|
||||
}
|
||||
}
|
||||
fenceRings = parsed
|
||||
blockReason = parsed.isEmpty ? .noParsableFenceData : nil
|
||||
}
|
||||
|
||||
private func setBlocked(_ reason: OperatingAreaBlockReason) {
|
||||
resetLoadedData()
|
||||
blockReason = reason
|
||||
errorMessage = nil
|
||||
loading = false
|
||||
}
|
||||
|
||||
private func resetLoadedData() {
|
||||
items = []
|
||||
fenceRings = []
|
||||
}
|
||||
}
|
||||
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