Advance UIKit rewrite with AMap integration and core UI modules.
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>
This commit is contained in:
@ -10,7 +10,9 @@ import Foundation
|
||||
/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。
|
||||
@MainActor
|
||||
protocol OperatingAreaServing {
|
||||
/// store营业Area相关逻辑。
|
||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
/// scenicAdmin营业Area相关逻辑。
|
||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
let typeText: String
|
||||
let auditStatusText: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
@ -40,6 +41,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
case auditStatusText = "audit_status_text"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
@ -56,6 +58,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
self.auditStatusText = auditStatusText
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -76,6 +79,7 @@ enum OperatingMapArea: Decodable, Equatable {
|
||||
case array([OperatingMapArea])
|
||||
case object([String: OperatingMapArea])
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
@ -106,6 +110,7 @@ enum OperatingMapArea: Decodable, Equatable {
|
||||
return fromJSONObject(raw)
|
||||
}
|
||||
|
||||
/// fromJSONObject相关逻辑。
|
||||
private static func fromJSONObject(_ value: Any) -> OperatingMapArea {
|
||||
switch value {
|
||||
case is NSNull:
|
||||
@ -140,6 +145,7 @@ struct OperatingFenceRing: Identifiable, Equatable {
|
||||
let points: [OperatingGeoPoint]
|
||||
let isCurrentStore: Bool
|
||||
|
||||
/// 初始化实例。
|
||||
init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) {
|
||||
self.id = "\(itemId)-\(ringIndex)"
|
||||
self.itemId = itemId
|
||||
@ -184,6 +190,7 @@ enum OperatingAreaParser {
|
||||
return parseElement(area)
|
||||
}
|
||||
|
||||
/// 解析Element数据。
|
||||
private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] {
|
||||
switch value {
|
||||
case .null, .bool, .number:
|
||||
@ -199,6 +206,7 @@ enum OperatingAreaParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析Object数据。
|
||||
private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
let type: String?
|
||||
if case let .string(rawType)? = object["type"] {
|
||||
@ -231,6 +239,7 @@ enum OperatingAreaParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析ArrayRoot数据。
|
||||
private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
guard let first = array.first else { return [] }
|
||||
switch first {
|
||||
@ -257,6 +266,7 @@ enum OperatingAreaParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析ObjectRing数据。
|
||||
private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
guard case let .object(object) = element else { return nil }
|
||||
@ -265,6 +275,7 @@ enum OperatingAreaParser {
|
||||
return points.count >= 3 ? points : nil
|
||||
}
|
||||
|
||||
/// 解析Ring数据。
|
||||
private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
switch element {
|
||||
@ -283,6 +294,7 @@ enum OperatingAreaParser {
|
||||
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
|
||||
@ -290,6 +302,7 @@ enum OperatingAreaParser {
|
||||
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)
|
||||
@ -303,24 +316,29 @@ enum OperatingAreaParser {
|
||||
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
|
||||
@ -341,6 +359,7 @@ private extension OperatingMapArea {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// operating解码宽松字符串相关逻辑。
|
||||
func operatingDecodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
@ -357,6 +376,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// operating解码宽松整数相关逻辑。
|
||||
func operatingDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
|
||||
@ -1,37 +1,146 @@
|
||||
//
|
||||
// OperatingAreaViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
// suixinkan_ios
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 运营区域列表页。
|
||||
final class OperatingAreaViewController: ModuleTableViewController {
|
||||
/// 运营区域页:汇总信息、围栏地图与区域列表。
|
||||
final class OperatingAreaViewController: UIViewController {
|
||||
private let viewModel = OperatingAreaViewModel()
|
||||
private let mapView = OperatingAreaMapView()
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
private let summaryLabel = UILabel()
|
||||
private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash")
|
||||
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, String>!
|
||||
private var services: AppServices { AppServices.shared }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "运营区域"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
title = "运营区域"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
setupLayout()
|
||||
wireViewModel()
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.items.count
|
||||
private func setupLayout() {
|
||||
summaryLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
summaryLabel.textColor = AppDesign.textSecondary
|
||||
summaryLabel.numberOfLines = 0
|
||||
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.height.equalTo(320)
|
||||
}
|
||||
|
||||
blockReasonView.isHidden = true
|
||||
|
||||
tableView.register(TitleSubtitleTableViewCell.self, forCellReuseIdentifier: TitleSubtitleTableViewCell.reuseIdentifier)
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(onPullRefresh), for: .valueChanged)
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Int, String>(tableView: tableView) { [weak self] tableView, indexPath, itemID in
|
||||
guard let self else { return UITableViewCell() }
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TitleSubtitleTableViewCell
|
||||
guard let item = self.viewModel.items.first(where: { String($0.id) == itemID }) else {
|
||||
return cell
|
||||
}
|
||||
let ringCount = self.viewModel.fenceRings.filter { $0.itemId == item.id }.count
|
||||
cell.configure(
|
||||
title: item.name.isEmpty ? "未命名区域" : item.name,
|
||||
subtitle: [item.typeText, item.statusText].filter { !$0.isEmpty }.joined(separator: " · "),
|
||||
detail: "围栏 \(ringCount) 个"
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
let headerStack = UIStackView(arrangedSubviews: [summaryLabel, blockReasonView, mapView])
|
||||
headerStack.axis = .vertical
|
||||
headerStack.spacing = AppMetrics.Spacing.medium
|
||||
|
||||
let headerContainer = UIView()
|
||||
headerContainer.addSubview(headerStack)
|
||||
headerStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
headerContainer.layoutIfNeeded()
|
||||
let height = headerStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height
|
||||
headerContainer.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: height + 24)
|
||||
tableView.tableHeaderView = headerContainer
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
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) 组")
|
||||
private func wireViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.render()
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
private func render() {
|
||||
summaryLabel.text = "\(viewModel.title) · \(scopeText()) · 区域 \(viewModel.summary.areaCount) · 围栏 \(viewModel.summary.fenceCount)"
|
||||
|
||||
if let reason = viewModel.blockReason {
|
||||
blockReasonView.isHidden = false
|
||||
blockReasonView.update(title: reason.message, systemImage: "mappin.slash")
|
||||
mapView.isHidden = true
|
||||
} else {
|
||||
blockReasonView.isHidden = true
|
||||
mapView.isHidden = false
|
||||
mapView.rings = viewModel.fenceRings
|
||||
}
|
||||
applyTableSnapshot()
|
||||
}
|
||||
|
||||
/// 通过 Diffable snapshot 刷新区域列表。
|
||||
private func applyTableSnapshot(animated: Bool = true) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.items.map { String($0.id) }, toSection: 0)
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
private func scopeText() -> String {
|
||||
switch viewModel.mode {
|
||||
case .storeAdmin:
|
||||
return services.accountContext.currentStore?.name ?? "当前店铺"
|
||||
case .scenicAdmin:
|
||||
return services.accountContext.currentScenic?.name ?? "当前景区"
|
||||
case nil:
|
||||
return services.accountContext.currentScenic?.name
|
||||
?? services.accountContext.currentStore?.name
|
||||
?? "未选择业务范围"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func onPullRefresh() {
|
||||
Task {
|
||||
await reload(showLoading: false)
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
if showLoading { services.globalLoading.show() }
|
||||
defer { if showLoading { services.globalLoading.hide() } }
|
||||
|
||||
await viewModel.reload(
|
||||
api: services.operatingAreaAPI,
|
||||
accountContext: services.accountContext,
|
||||
permissionContext: services.permissionContext
|
||||
)
|
||||
if let message = viewModel.errorMessage {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -110,6 +110,7 @@ final class OperatingAreaViewModel {
|
||||
mode?.title ?? "运营区域"
|
||||
}
|
||||
|
||||
/// apply 业务逻辑。
|
||||
private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) {
|
||||
self.items = items
|
||||
if items.isEmpty {
|
||||
@ -139,6 +140,7 @@ final class OperatingAreaViewModel {
|
||||
blockReason = parsed.isEmpty ? .noParsableFenceData : nil
|
||||
}
|
||||
|
||||
/// setBlocked相关逻辑。
|
||||
private func setBlocked(_ reason: OperatingAreaBlockReason) {
|
||||
resetLoadedData()
|
||||
blockReason = reason
|
||||
@ -146,6 +148,7 @@ final class OperatingAreaViewModel {
|
||||
loading = false
|
||||
}
|
||||
|
||||
/// 重置LoadedData状态。
|
||||
private func resetLoadedData() {
|
||||
items = []
|
||||
fenceRings = []
|
||||
|
||||
Reference in New Issue
Block a user