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,17 +10,29 @@ import Foundation
/// token
@MainActor
protocol ScenicQueueServing: AnyObject {
/// scenic
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData
/// scenicHome
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData
/// socket
func socketToken() async throws -> SocketTokenResponse
/// scenicCall
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData
/// scenicPass
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData
/// scenicFinish
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData
/// scenicRequeueInsertBefore
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws
/// scenicUserMark
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws
/// scenicSetting
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData
/// scenicSetting
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
/// scenicShootQRCode
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData
/// scenicSettingChangeLog
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData
}

View File

@ -13,6 +13,7 @@ struct ScenicQueueStatsData: Decodable, Equatable {
let avgWaitMin: Double
let time: String
///
enum CodingKeys: String, CodingKey {
case queueCount = "queue_count"
case avgWaitMin = "avg_wait_min"
@ -41,6 +42,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
let list: ScenicQueueHomeListBlock?
let time: String?
///
enum CodingKeys: String, CodingKey {
case stats
case list
@ -73,6 +75,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
struct ScenicQueueHomeStats: Decodable, Equatable {
let type: Int
///
enum CodingKeys: String, CodingKey {
case type
}
@ -96,6 +99,7 @@ struct ScenicQueueHomeListBlock: Decodable, Equatable {
let page: Int
let pageSize: Int
///
enum CodingKeys: String, CodingKey {
case list
case total
@ -174,6 +178,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
isMissRequeue == 1
}
///
enum CodingKeys: String, CodingKey {
case id
case queueCode = "queue_code"
@ -198,6 +203,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
case missRequeueText = "is_miss_requeue_text"
}
///
enum AlternateCodingKeys: String, CodingKey {
case queueNo = "queue_no"
case queueNumber = "queue_number"
@ -289,6 +295,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines)
}
///
private static func formatQueueTime(_ raw: String) -> String {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return "--" }
@ -317,6 +324,7 @@ struct ScenicQueueActionRequest: Encodable {
struct SocketTokenResponse: Decodable, Equatable {
let socketToken: String
///
enum CodingKeys: String, CodingKey {
case socketToken = "socket_token"
}
@ -340,6 +348,7 @@ struct ScenicQueueActionData: Decodable, Equatable {
let statusText: String
let calledAt: String?
///
enum CodingKeys: String, CodingKey {
case id
case status
@ -375,6 +384,7 @@ struct ScenicQueueRequeueInsertBeforeRequest: Encodable {
let recordId: Int64
let operatorId: Int
///
enum CodingKeys: String, CodingKey {
case recordId = "record_id"
case operatorId = "operator_id"
@ -389,6 +399,7 @@ struct ScenicQueueUserMarkRequest: Encodable, Equatable {
let queueBanDays: Int?
let operatorId: Int?
///
enum CodingKeys: String, CodingKey {
case uid
case scenicId = "scenic_id"
@ -433,6 +444,7 @@ struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable {
}
private extension KeyedDecodingContainer {
///
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
@ -449,6 +461,7 @@ private extension KeyedDecodingContainer {
return ""
}
///
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
@ -467,6 +480,7 @@ private extension KeyedDecodingContainer {
return nil
}
/// Double
func decodeLossyDouble(forKey key: Key) throws -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return value
@ -484,6 +498,7 @@ private extension KeyedDecodingContainer {
}
private extension String {
/// non
func nonEmptyOrDefault(_ fallback: String) -> String {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? fallback : text

View File

@ -13,6 +13,7 @@ struct ScenicQueueSettingData: Decodable, Equatable {
let setting: ScenicQueueSettingItem?
let time: String?
///
enum CodingKeys: String, CodingKey {
case exists
case setting
@ -64,6 +65,7 @@ struct ScenicQueueSettingItem: Decodable, Equatable {
let createdAt: String
let updatedAt: String
///
enum CodingKeys: String, CodingKey {
case id
case scenicId = "scenic_id"
@ -131,6 +133,7 @@ struct ScenicQueueVoiceBroadcastItem: Codable, Identifiable, Equatable {
let content: String
let sortOrder: Int
///
enum CodingKeys: String, CodingKey {
case content
case sortOrder = "sort_order"
@ -175,6 +178,7 @@ struct ScenicQueueSaveSettingRequest: Encodable, Equatable {
let status: Int
let remark: String?
///
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case scenicSpotId = "scenic_spot_id"
@ -208,6 +212,7 @@ struct ScenicQueueSettingChangeLogData: Decodable, Equatable {
let pageSize: Int
let list: [ScenicQueueSettingChangeLogItem]
///
enum CodingKeys: String, CodingKey {
case total
case page
@ -243,6 +248,7 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable {
let displayText: String
let createdAt: String
///
enum CodingKeys: String, CodingKey {
case id
case scenicSpotName = "scenic_spot_name"
@ -270,6 +276,7 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable {
struct ScenicQueueShootQueueQRCodeData: Decodable, Equatable {
let qrcodeUrl: String
///
enum CodingKeys: String, CodingKey {
case qrcodeUrl = "qrcode_url"
}
@ -309,6 +316,7 @@ struct ScenicQueueSettingsSnapshot: Codable, Equatable {
var businessStartTime: String = "10:00"
var businessEndTime: String = "20:00"
///
enum CodingKeys: String, CodingKey {
case shootMinute = "shoot_minute"
case shootSecond = "shoot_second"
@ -334,6 +342,7 @@ struct ScenicQueueSettingsSnapshot: Codable, Equatable {
}
private extension KeyedDecodingContainer {
///
func decodeLossyString(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) }
@ -342,6 +351,7 @@ private extension KeyedDecodingContainer {
return ""
}
///
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) { return value }
if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) }
@ -354,6 +364,7 @@ private extension KeyedDecodingContainer {
return nil
}
///
func decodeLossyBool(forKey key: Key) throws -> Bool? {
if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value }
if let value = try? decodeIfPresent(Int.self, forKey: key) { return value != 0 }

View File

@ -155,17 +155,20 @@ enum ScenicQueueSettingsStore {
UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)")
}
/// dUserId
private static func normalizedUserId(_ userId: String?) -> String {
let text = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? "anonymous" : text
}
/// positive
private static func positiveInt(forKey key: String) -> Int? {
guard UserDefaults.standard.object(forKey: key) != nil else { return nil }
let value = UserDefaults.standard.integer(forKey: key)
return value > 0 ? value : nil
}
/// legacy
private static func legacyPositiveInt(forKey key: String) -> Int? {
positiveInt(forKey: key)
}

View File

@ -11,6 +11,7 @@ import UIKit
final class QueueManagementViewController: ModuleTableViewController {
private let viewModel = QueueManagementViewModel()
/// UI
override func viewDidLoad() {
title = "排队管理"
navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -23,13 +24,16 @@ final class QueueManagementViewController: ModuleTableViewController {
wireViewModel(viewModel) { [weak self] in self?.updateTitle() }
}
/// tableCount
override func tableRowCount() -> Int { viewModel.items.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
cell.configure(title: item.phoneMasked, subtitle: item.statusText, detail: item.queueCode)
}
/// Content
override func reloadContent() async {
await viewModel.reload(
api: services.scenicQueueAPI,
@ -40,10 +44,12 @@ final class QueueManagementViewController: ModuleTableViewController {
updateTitle()
}
/// Title
private func updateTitle() {
title = "排队 \(viewModel.queueCount)"
}
/// open
@objc private func openSettings() {
navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true)
}
@ -55,14 +61,17 @@ extension QueueManagementViewModel: ViewModelBindable {}
final class ScenicQueueSettingsViewController: ModuleTableViewController {
private let viewModel = ScenicQueueSettingsViewModel()
/// UI
override func viewDidLoad() {
title = "排队设置"
super.viewDidLoad()
wireViewModel(viewModel) { }
}
/// tableCount
override func tableRowCount() -> Int { viewModel.scenicSpots.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let spot = viewModel.scenicSpots[indexPath.row]
let selected = viewModel.selectedSpotId == spot.id
@ -70,10 +79,12 @@ final class ScenicQueueSettingsViewController: ModuleTableViewController {
cell.accessoryType = selected ? .checkmark : .none
}
/// didSelectTableRow
override func didSelectTableRow(at indexPath: IndexPath) {
viewModel.selectedSpotId = viewModel.scenicSpots[indexPath.row].id
}
/// Content
override func reloadContent() async {
await viewModel.load(
api: services.scenicQueueAPI,

View File

@ -209,6 +209,7 @@ final class QueueManagementViewModel {
return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0)
}
/// SocketMessage
private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
guard message.isScenicQueueEvent,
let params = message.data?.params,
@ -227,6 +228,7 @@ final class QueueManagementViewModel {
}
}
/// RemoteTicketCalled
private func handleRemoteTicketCalled(params: ScenicQueueSocketParams, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
guard let recordId = params.recordId, recordId > 0 else { return }
if let operatorUid = params.operatorUid,
@ -251,6 +253,7 @@ final class QueueManagementViewModel {
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
}
/// QueueingTickets
private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] {
let home = try await api.scenicQueueHome(
scenicId: scenicId,
@ -262,6 +265,7 @@ final class QueueManagementViewModel {
return home.list?.list ?? []
}
/// consumeCalledTickets
private func consumePendingRemoteCalledTickets(from tickets: [QueueItem]) {
guard let ticket = tickets.first(where: {
pendingRemoteCalledRecordIds.contains($0.id) && ($0.isCalled == 1 || $0.status == 1 || $0.statusText.contains("已叫号"))
@ -272,6 +276,7 @@ final class QueueManagementViewModel {
remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode)
}
/// Page
private func loadPage(api: any ScenicQueueServing, scenicId: Int, userId: String?, page targetPage: Int) async throws {
guard let scenicSpotId = selectedSpotId else { return }
let home = try await api.scenicQueueHome(
@ -300,6 +305,7 @@ final class QueueManagementViewModel {
refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId)
}
/// markCalled
private func markQueueCalled(id: Int64, statusText: String) {
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
items = items.map { item in
@ -330,6 +336,7 @@ final class QueueManagementViewModel {
}
}
///
private func reset() {
stopRealtime()
loading = false
@ -343,6 +350,7 @@ final class QueueManagementViewModel {
clearQueueData()
}
///
private func clearQueueData() {
loadingMore = false
items = []
@ -354,6 +362,7 @@ final class QueueManagementViewModel {
lastSyncTimeText = "--"
}
/// ShootingCallConfig
private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) {
let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot()
showStartShootingButton = snapshot.showStartShootingButton
@ -362,6 +371,7 @@ final class QueueManagementViewModel {
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
}
/// QueueSettingFromServerIfMatchesLocal
private func syncQueueSettingFromServerIfMatchesLocal(api: any ScenicQueueServing, userId: String?, scenicId: Int, spotId: Int) async {
guard let data = try? await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: spotId),
data.exists,
@ -648,6 +658,7 @@ final class ScenicQueueSettingsViewModel {
presetVoices.remove(at: index)
}
/// SettingsState
private func resetSettingsState() {
loading = false
scenicSpots = []
@ -678,6 +689,7 @@ final class ScenicQueueSettingsViewModel {
qrcodeURL = ""
}
/// apply
private func apply(_ setting: ScenicQueueSettingItem?) {
guard let setting else { return }
if let spotId = setting.scenicSpotId { selectedSpotId = spotId }
@ -707,6 +719,7 @@ final class ScenicQueueSettingsViewModel {
if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) }
}
/// apply
private func apply(_ snapshot: ScenicQueueSettingsSnapshot) {
photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))"
photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))"
@ -730,6 +743,7 @@ final class ScenicQueueSettingsViewModel {
businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0))
}
/// dMaxQueueRangeForDisplay
private func normalizedMaxQueueRangeForDisplay() throws -> String {
let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm)
guard !normalized.isEmpty,
@ -745,12 +759,14 @@ final class ScenicQueueSettingsViewModel {
return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue)
}
/// queueDistanceMeter
private func queueDistanceMeterForSubmit() throws -> Int {
_ = try normalizedMaxQueueRangeForDisplay()
let value = Decimal(string: maxQueueRangeKm, locale: Locale(identifier: "en_US_POSIX")) ?? 0
return NSDecimalNumber(decimal: value * Decimal(1000)).intValue
}
/// QRCodeImage
private func loadQRCodeImage(from text: String) async throws -> UIImage {
if let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased()) {
let (data, _) = try await URLSession.shared.data(from: url)
@ -760,6 +776,7 @@ final class ScenicQueueSettingsViewModel {
throw APIError.networkFailed("二维码图片生成失败")
}
/// generateQRCode
private static func generateQRCode(from string: String) -> UIImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
@ -770,12 +787,14 @@ final class ScenicQueueSettingsViewModel {
return UIImage(cgImage: cgImage)
}
/// saveTiming
private func saveTimingSnapshot(min: Int, sec: Int, interval: Int, countdown: Int) {
UserDefaults.standard.set(max(min * 60 + sec, 0), forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey)
UserDefaults.standard.set(interval, forKey: ScenicQueueLocalSettings.broadcastIntervalSecondsKey)
UserDefaults.standard.set(countdown, forKey: ScenicQueueLocalSettings.countdownThresholdSecondsKey)
}
/// saveShootingCall
private func saveShootingCallSettings(autoCallAhead: Int) {
UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey)
UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey)
@ -783,10 +802,12 @@ final class ScenicQueueSettingsViewModel {
UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey)
}
/// saveLocally
private func saveCustomTextLocally() {
ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId)
}
/// voiceBroadcasts
private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] {
Array(presetVoices
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
@ -796,6 +817,7 @@ final class ScenicQueueSettingsViewModel {
.map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) })
}
/// Snapshot
private func makeSnapshot(
min: Int? = nil,
sec: Int? = nil,
@ -832,18 +854,22 @@ final class ScenicQueueSettingsViewModel {
)
}
/// coerced
static func coercedBroadcastInterval(_ raw: Int) -> Int {
(40...60).contains(raw) ? raw : 50
}
/// coerced
static func coercedCountdownThreshold(_ raw: Int) -> Int {
(10...30).contains(raw) ? raw : 15
}
/// business
static func businessTime(hour: Int, minute: Int) -> Date {
Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date()
}
/// BusinessTime
static func parseBusinessTime(_ raw: String, fallback: Date) -> Date {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return fallback }
@ -856,6 +882,7 @@ final class ScenicQueueSettingsViewModel {
return fallback
}
/// business
static func businessTimePayload(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
@ -863,6 +890,7 @@ final class ScenicQueueSettingsViewModel {
return formatter.string(from: date)
}
/// queueDistanceDisplay
private static func queueDistanceDisplay(meters: Int) -> String {
let clamped = min(max(meters, 0), 9_999_990)
return String(format: "%.2f", Double(clamped) / 1000)
@ -875,6 +903,7 @@ private extension String {
return text.isEmpty ? nil : text
}
/// non
func nonEmptyOrDefault(_ fallback: String) -> String {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? fallback : text