Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
395 lines
14 KiB
Swift
395 lines
14 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)
|
||
}
|
||
|
||
/// fromJSONObject相关逻辑。
|
||
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)
|
||
}
|
||
|
||
/// 解析Element数据。
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 解析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 []
|
||
}
|
||
}
|
||
|
||
/// 解析ArrayRoot数据。
|
||
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 []
|
||
}
|
||
}
|
||
|
||
/// 解析ObjectRing数据。
|
||
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
|
||
}
|
||
|
||
/// 解析Ring数据。
|
||
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
|
||
}
|
||
|
||
/// point从对象相关逻辑。
|
||
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)
|
||
}
|
||
|
||
/// 规范化Pair格式。
|
||
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)
|
||
}
|
||
|
||
/// looksLikeLngLat坐标对相关逻辑。
|
||
private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool {
|
||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat)
|
||
}
|
||
|
||
/// looksLikeLatLng坐标对相关逻辑。
|
||
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 {
|
||
/// operating解码宽松字符串相关逻辑。
|
||
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 ""
|
||
}
|
||
|
||
/// operating解码宽松整数相关逻辑。
|
||
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
|
||
}
|
||
}
|