Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,50 @@
//
// OperatingAreaAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
@MainActor
protocol OperatingAreaServing {
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
}
@MainActor
/// API
final class OperatingAreaAPI {
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 {}

View File

@ -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
}
}

View 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模拟器或未启用高德时展示坐标摘要兜底。
## 边界
本模块只展示已有区域和围栏,不提供新建、编辑、删除或绘制保存能力。缺少景区/门店、角色不支持、空列表和不可解析围栏都会进入明确空态或阻断提示。

View File

@ -0,0 +1,38 @@
//
// OperatingAreaViewController.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
///
final class OperatingAreaViewController: ModuleTableViewController {
private let viewModel = OperatingAreaViewModel()
override func viewDidLoad() {
title = "运营区域"
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func tableRowCount() -> Int {
viewModel.items.count
}
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
cell.configure(title: item.name, subtitle: "\(item.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count)")
}
override func reloadContent() async {
await viewModel.reload(
api: services.operatingAreaAPI,
accountContext: services.accountContext,
permissionContext: services.permissionContext
)
}
}
extension OperatingAreaViewModel: ViewModelBindable {}

View File

@ -0,0 +1,153 @@
//
// OperatingAreaViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
@MainActor
/// ViewModel
final class OperatingAreaViewModel {
var onChange: (() -> Void)?
private(set) var mode: OperatingAreaEntryMode? { didSet { onChange?() } }
private(set) var loading = false { didSet { onChange?() } }
private(set) var items: [OperatingAreaItem] = [] { didSet { onChange?() } }
private(set) var fenceRings: [OperatingFenceRing] = [] { didSet { onChange?() } }
private(set) var blockReason: OperatingAreaBlockReason? { didSet { onChange?() } }
private(set) var errorMessage: String? { didSet { onChange?() } }
///
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 = []
}
}