新增运营区域与飞手认证模块,并完善直播推流就绪流程
将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 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