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:
2026-06-26 15:16:12 +08:00
parent 9edf993432
commit d99a5b1bf8
124 changed files with 5195 additions and 1536 deletions

View File

@ -10,7 +10,9 @@ import Foundation
///
@MainActor
protocol OperatingAreaServing {
/// storeArea
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
/// scenicAdminArea
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
}

View File

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

View File

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

View File

@ -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 = []