375 lines
13 KiB
Swift
375 lines
13 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|