From c39c3d3c75cd1ecbf5c063135ad12f72c602b3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Thu, 25 Jun 2026 13:39:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=A2=E5=8D=95=E9=95=BF?= =?UTF-8?q?=E5=B0=BE=E6=B5=81=E7=A8=8B=EF=BC=8C=E5=B9=B6=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E6=8E=92=E9=98=9F=E3=80=81=E6=B6=88=E6=81=AF=E3=80=81=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E4=B8=8E=E5=AE=A1=E6=A0=B8=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor --- .../App/Navigation/NavigationRouter.swift | 10 + suixinkan/App/RootView.swift | 34 + .../Queue/ScenicQueueAnnouncementState.swift | 73 ++ suixinkan/Core/Queue/ScenicQueueRuntime.swift | 289 ++++++ .../Core/Queue/ScenicQueueSocketClient.swift | 166 ++++ .../Core/Validation/AppFormValidator.swift | 62 ++ suixinkan/Features/Home/Home.md | 2 +- .../Features/Home/Models/HomeMenuItem.swift | 6 + .../Home/Routing/HomeMenuRouter.swift | 23 +- .../Home/Views/HomeSupportViews.swift | 12 + .../MessageCenter/API/MessageCenterAPI.swift | 74 ++ .../Features/MessageCenter/MessageCenter.md | 18 + .../Models/MessageCenterModels.swift | 226 +++++ .../ViewModels/MessageCenterViewModel.swift | 170 ++++ .../Views/MessageCenterViews.swift | 302 ++++++ suixinkan/Features/Orders/API/OrdersAPI.swift | 126 +++ .../Features/Orders/Models/OrderModels.swift | 311 ++++++ suixinkan/Features/Orders/Orders.md | 24 +- .../ViewModels/OrderDetailViewModel.swift | 9 +- .../ViewModels/OrderLongTailViewModels.swift | 610 ++++++++++++ .../Orders/Views/DepositOrderViews.swift | 596 ++++++++++++ .../Orders/Views/OrderDetailViews.swift | 150 ++- .../Orders/Views/OrderTailUploadViews.swift | 357 +++++++ .../QueueManagement/API/ScenicQueueAPI.swift | 201 ++++ .../Models/ScenicQueueModels.swift | 491 ++++++++++ .../Models/ScenicQueueSettingsModels.swift | 368 ++++++++ .../Models/ScenicQueueSettingsStore.swift | 172 ++++ .../QueueManagement/QueueManagement.md | 20 + .../QueueManagementViewModels.swift | 884 ++++++++++++++++++ .../Views/QueueManagementViews.swift | 464 +++++++++ .../Views/ScenicQueueSettingsViews.swift | 343 +++++++ .../ScenicPermission/ScenicPermission.md | 2 +- .../API/ScenicSettlementAPI.swift | 41 + .../Models/ScenicSettlementModels.swift | 28 + .../ScenicSettlement/ScenicSettlement.md | 23 + .../ScenicSettlementViewModels.swift | 264 ++++++ .../Views/ScenicSettlementViews.swift | 432 +++++++++ .../ViewModels/WithdrawalAuditViewModel.swift | 139 +++ .../Views/WithdrawalAuditView.swift | 311 ++++++ .../WithdrawalAudit/WithdrawalAudit.md | 16 + .../Fixtures/deposit_order_list_success.json | 27 + .../Fixtures/message_list_page2_success.json | 30 + .../Fixtures/message_list_success.json | 30 + .../multi_travel_shoot_history_success.json | 36 + .../multi_travel_verified_spots_success.json | 14 + .../Fixtures/scenic_queue_home_success.json | 63 ++ .../Fixtures/scenic_queue_qrcode_success.json | 7 + .../scenic_queue_setting_success.json | 40 + .../Fixtures/scenic_queue_stats_success.json | 9 + .../scenic_spot_list_all_success.json | 21 + .../store_order_shooting_detail_success.json | 44 + suixinkanTests/HomeMenuRouterTests.swift | 33 +- .../MainTabBadgeViewModelTests.swift | 37 + .../MessageCenterViewModelTests.swift | 167 ++++ suixinkanTests/NavigationRouterTests.swift | 8 +- .../OrderDetailViewModelTests.swift | 49 + suixinkanTests/OrderLongTailTests.swift | 596 ++++++++++++ suixinkanTests/OrdersViewModelTests.swift | 49 + .../QueueManagementViewModelTests.swift | 227 +++++ .../ScenicQueueSettingsViewModelTests.swift | 182 ++++ .../ScenicSettlementViewModelTests.swift | 212 +++++ .../WithdrawalAuditViewModelTests.swift | 125 +++ 功能同步Checklist.md | 26 +- 63 files changed, 9823 insertions(+), 58 deletions(-) create mode 100644 suixinkan/Core/Queue/ScenicQueueAnnouncementState.swift create mode 100644 suixinkan/Core/Queue/ScenicQueueRuntime.swift create mode 100644 suixinkan/Core/Queue/ScenicQueueSocketClient.swift create mode 100644 suixinkan/Core/Validation/AppFormValidator.swift create mode 100644 suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift create mode 100644 suixinkan/Features/MessageCenter/MessageCenter.md create mode 100644 suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift create mode 100644 suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift create mode 100644 suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift create mode 100644 suixinkan/Features/Orders/ViewModels/OrderLongTailViewModels.swift create mode 100644 suixinkan/Features/Orders/Views/DepositOrderViews.swift create mode 100644 suixinkan/Features/Orders/Views/OrderTailUploadViews.swift create mode 100644 suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift create mode 100644 suixinkan/Features/QueueManagement/Models/ScenicQueueModels.swift create mode 100644 suixinkan/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift create mode 100644 suixinkan/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift create mode 100644 suixinkan/Features/QueueManagement/QueueManagement.md create mode 100644 suixinkan/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift create mode 100644 suixinkan/Features/QueueManagement/Views/QueueManagementViews.swift create mode 100644 suixinkan/Features/QueueManagement/Views/ScenicQueueSettingsViews.swift create mode 100644 suixinkan/Features/ScenicSettlement/API/ScenicSettlementAPI.swift create mode 100644 suixinkan/Features/ScenicSettlement/Models/ScenicSettlementModels.swift create mode 100644 suixinkan/Features/ScenicSettlement/ScenicSettlement.md create mode 100644 suixinkan/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift create mode 100644 suixinkan/Features/ScenicSettlement/Views/ScenicSettlementViews.swift create mode 100644 suixinkan/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift create mode 100644 suixinkan/Features/WithdrawalAudit/Views/WithdrawalAuditView.swift create mode 100644 suixinkan/Features/WithdrawalAudit/WithdrawalAudit.md create mode 100644 suixinkanTests/Fixtures/deposit_order_list_success.json create mode 100644 suixinkanTests/Fixtures/message_list_page2_success.json create mode 100644 suixinkanTests/Fixtures/message_list_success.json create mode 100644 suixinkanTests/Fixtures/multi_travel_shoot_history_success.json create mode 100644 suixinkanTests/Fixtures/multi_travel_verified_spots_success.json create mode 100644 suixinkanTests/Fixtures/scenic_queue_home_success.json create mode 100644 suixinkanTests/Fixtures/scenic_queue_qrcode_success.json create mode 100644 suixinkanTests/Fixtures/scenic_queue_setting_success.json create mode 100644 suixinkanTests/Fixtures/scenic_queue_stats_success.json create mode 100644 suixinkanTests/Fixtures/scenic_spot_list_all_success.json create mode 100644 suixinkanTests/Fixtures/store_order_shooting_detail_success.json create mode 100644 suixinkanTests/MessageCenter/MessageCenterViewModelTests.swift create mode 100644 suixinkanTests/OrderLongTailTests.swift create mode 100644 suixinkanTests/QueueManagement/QueueManagementViewModelTests.swift create mode 100644 suixinkanTests/QueueManagement/ScenicQueueSettingsViewModelTests.swift create mode 100644 suixinkanTests/ScenicSettlement/ScenicSettlementViewModelTests.swift create mode 100644 suixinkanTests/WithdrawalAudit/WithdrawalAuditViewModelTests.swift diff --git a/suixinkan/App/Navigation/NavigationRouter.swift b/suixinkan/App/Navigation/NavigationRouter.swift index 030f604..0fa4115 100644 --- a/suixinkan/App/Navigation/NavigationRouter.swift +++ b/suixinkan/App/Navigation/NavigationRouter.swift @@ -45,6 +45,16 @@ extension OrdersRoute { StoreOrderDetailView(item: item) case .writeOffDetail(let item): WriteOffOrderDetailView(item: item) + case .depositDetail(let orderNumber): + DepositOrderDetailView(orderNumber: orderNumber) + case .depositShootingInfo(let orderNumber, let scenicSpotId, let photogUid): + DepositOrderShootingInfoView(orderNumber: orderNumber, scenicSpotId: scenicSpotId, photogUid: photogUid) + case .historicalShooting(let orderNumber): + HistoricalShootingInfoView(orderNumber: orderNumber) + case .multiTravelTaskUpload(let orderNumber): + MultiTravelTaskUploadView(initialOrderNumber: orderNumber) + case .orderTrailer(let orderNumber, let title): + OrderTrailerView(orderNumber: orderNumber, title: title) } } } diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 7bb2efb..6b2211f 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -9,6 +9,7 @@ import SwiftUI /// App 根视图,负责创建全局依赖并根据登录状态切换登录页和主界面。 struct RootView: View { + @Environment(\.scenePhase) private var scenePhase @State private var appSession = AppSession() @State private var accountContext = AccountContext() @State private var permissionContext = PermissionContext() @@ -28,6 +29,10 @@ struct RootView: View { @State private var paymentAPI: PaymentAPI @State private var walletAPI: WalletAPI @State private var scenicPermissionAPI: ScenicPermissionAPI + @State private var scenicSettlementAPI: ScenicSettlementAPI + @State private var messageCenterAPI: MessageCenterAPI + @State private var scenicQueueAPI: ScenicQueueAPI + @State private var scenicQueueRuntime = ScenicQueueRuntime() @State private var taskAPI: TaskAPI @State private var projectAPI: ProjectAPI @State private var scheduleAPI: ScheduleAPI @@ -56,6 +61,9 @@ struct RootView: View { _paymentAPI = State(initialValue: PaymentAPI(client: apiClient)) _walletAPI = State(initialValue: WalletAPI(client: apiClient)) _scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient)) + _scenicSettlementAPI = State(initialValue: ScenicSettlementAPI(client: apiClient)) + _messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient)) + _scenicQueueAPI = State(initialValue: ScenicQueueAPI(client: apiClient)) _taskAPI = State(initialValue: TaskAPI(client: apiClient)) _projectAPI = State(initialValue: ProjectAPI(client: apiClient)) _scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient)) @@ -101,6 +109,10 @@ struct RootView: View { .environment(paymentAPI) .environment(walletAPI) .environment(scenicPermissionAPI) + .environment(scenicSettlementAPI) + .environment(messageCenterAPI) + .environment(scenicQueueAPI) + .environment(scenicQueueRuntime) .environment(taskAPI) .environment(projectAPI) .environment(scheduleAPI) @@ -132,6 +144,28 @@ struct RootView: View { api: accountContextAPI ) } + .onChange(of: scenePhase) { _, newPhase in + if appSession.isLoggedIn { + scenicQueueRuntime.update( + api: scenicQueueAPI, + userId: accountContext.profile?.userId, + scenicId: accountContext.currentScenic?.id, + scenePhase: newPhase + ) + } else { + scenicQueueRuntime.stop() + } + } + .onChange(of: accountContext.currentScenic?.id) { _, _ in + if appSession.isLoggedIn { + scenicQueueRuntime.update( + api: scenicQueueAPI, + userId: accountContext.profile?.userId, + scenicId: accountContext.currentScenic?.id, + scenePhase: scenePhase + ) + } + } .onChange(of: appSession.phase) { _, phase in guard phase == .loggedOut else { return } accountContext.reset() diff --git a/suixinkan/Core/Queue/ScenicQueueAnnouncementState.swift b/suixinkan/Core/Queue/ScenicQueueAnnouncementState.swift new file mode 100644 index 0000000..769723a --- /dev/null +++ b/suixinkan/Core/Queue/ScenicQueueAnnouncementState.swift @@ -0,0 +1,73 @@ +// +// ScenicQueueAnnouncementState.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队语音播报内容。 +struct ScenicQueueAnnouncement: Equatable { + enum Kind: Equatable { + case newTickets(count: Int) + case calledTicket(id: Int64) + } + + let kind: Kind + let text: String +} + +/// 排队播报去重状态,避免同一批队列反复播报。 +struct ScenicQueueAnnouncementState { + private var knownTicketIds = Set() + private var calledTicketIds = Set() + + /// 根据最新统计和列表计算下一条需要播报的内容。 + mutating func nextAnnouncement( + stats: ScenicQueueStatsData, + tickets: [ScenicQueueTicket], + customCallText: String? + ) -> ScenicQueueAnnouncement? { + let currentIds = Set(tickets.map(\.id)) + let newTickets = tickets.filter { !knownTicketIds.contains($0.id) } + knownTicketIds = currentIds + + if let ticket = tickets.first(where: { ($0.isCalled == 1 || $0.statusText.contains("已叫号")) && !calledTicketIds.contains($0.id) }) { + calledTicketIds.insert(ticket.id) + return ScenicQueueAnnouncement(kind: .calledTicket(id: ticket.id), text: Self.callText(for: ticket, customText: customCallText)) + } + + guard !newTickets.isEmpty else { return nil } + let text = "排队提醒,当前新增\(newTickets.count)位游客,在排人数\(stats.queueCount)人。" + return ScenicQueueAnnouncement(kind: .newTickets(count: newTickets.count), text: text) + } + + /// 重置播报去重状态。 + mutating func reset() { + knownTicketIds.removeAll() + calledTicketIds.removeAll() + } + + /// 生成叫号播报文案。 + static func callText(for ticket: ScenicQueueTicket, customText: String?) -> String { + callText(forQueueCode: ticket.queueCode, customText: customText) + } + + /// 生成叫号播报文案。 + static func callText(forQueueCode queueCode: String, customText: String?) -> String { + let custom = customText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !custom.isEmpty, custom != "A0001" { + return custom.replacingOccurrences(of: "{number}", with: queueCode) + } + let code = queueCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "当前游客" : ttsLabel(for: queueCode) + return "请\(code)到拍摄点拍摄。" + } + + /// 将排队号拆成更适合语音播报的文本。 + static func ttsLabel(for queueCode: String) -> String { + let trimmed = queueCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "当前游客" } + return trimmed.map(String.init).joined(separator: " ") + } +} diff --git a/suixinkan/Core/Queue/ScenicQueueRuntime.swift b/suixinkan/Core/Queue/ScenicQueueRuntime.swift new file mode 100644 index 0000000..5da3810 --- /dev/null +++ b/suixinkan/Core/Queue/ScenicQueueRuntime.swift @@ -0,0 +1,289 @@ +// +// ScenicQueueRuntime.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import AVFoundation +import Observation +import SwiftUI +import UIKit + +@MainActor +@Observable +/// 排队运行时,页面外按开关短轮询当前打卡点并语音播报队列变化。 +final class ScenicQueueRuntime { + private(set) var isMonitoring = false + private(set) var lastPollText = "--" + private(set) var lastSpokenText = "" + private(set) var lastQueueCount = 0 + private(set) var lastError: String? + + @ObservationIgnored private let speaker = ScenicQueueSpeechService() + @ObservationIgnored private weak var api: (any ScenicQueueServing)? + @ObservationIgnored private var userId: String? + @ObservationIgnored private var scenicId: Int? + @ObservationIgnored private var scenePhase: ScenePhase = .active + @ObservationIgnored private var pollTask: Task? + @ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid + @ObservationIgnored private var announcementState = ScenicQueueAnnouncementState() + @ObservationIgnored private var lastScenicId: Int? + @ObservationIgnored private var lastSpotId: Int? + @ObservationIgnored private var suspendedByQueueScreen = false + + /// 语音播报是否开启。 + var voiceEnabled: Bool { + get { + if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.ttsEnabledKey) == nil { return true } + return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.ttsEnabledKey) + } + set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.ttsEnabledKey) } + } + + /// 后台短时轮询是否开启。 + var backgroundPollingEnabled: Bool { + get { + if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) == nil { return false } + return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) + } + set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) } + } + + /// 更新运行时上下文。 + func update( + api: any ScenicQueueServing, + userId: String?, + scenicId: Int?, + scenePhase: ScenePhase + ) { + self.api = api + self.userId = userId + self.scenicId = scenicId + self.scenePhase = scenePhase + + guard !suspendedByQueueScreen else { + stop() + return + } + + guard let scenicId else { + stop() + resetSnapshot() + return + } + + let spotId = selectedSpotId() + guard spotId > 0 else { + stop() + lastError = "排队监听未启动:请先在排队设置里选择打卡点" + return + } + + if lastScenicId != scenicId || lastSpotId != spotId { + resetSnapshot() + lastScenicId = scenicId + lastSpotId = spotId + } + + if scenePhase == .background, !backgroundPollingEnabled { + stop() + return + } + + startIfNeeded() + } + + /// 页面自身接管实时监听时暂停运行时轮询。 + func setSuspendedByQueueScreen(_ suspended: Bool) { + guard suspendedByQueueScreen != suspended else { return } + suspendedByQueueScreen = suspended + if suspended { + stop() + } else if let api { + update(api: api, userId: userId, scenicId: scenicId, scenePhase: scenePhase) + } + } + + /// 停止运行时轮询和播报。 + func stop() { + pollTask?.cancel() + pollTask = nil + isMonitoring = false + speaker.stop() + endBackgroundTask() + } + + /// 立即轮询一次。 + func pollNow() { + guard pollTask != nil else { return } + Task { await pollOnce() } + } + + private func startIfNeeded() { + guard pollTask == nil else { return } + isMonitoring = true + if scenePhase == .background { + beginBackgroundTask() + } + pollTask = Task { [weak self] in + await self?.runLoop() + } + } + + private func runLoop() async { + while !Task.isCancelled { + await pollOnce() + let seconds = pollIntervalSeconds() + try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000) + } + } + + private func pollOnce() async { + guard let api, let scenicId else { return } + let spotId = selectedSpotId() + guard spotId > 0 else { return } + + if scenePhase == .background { + beginBackgroundTask() + } + + do { + if lastScenicId != scenicId || lastSpotId != spotId { + resetSnapshot() + lastScenicId = scenicId + lastSpotId = spotId + } + async let statsData = api.scenicQueueStats(scenicId: scenicId, scenicSpotId: spotId) + async let homeData = api.scenicQueueHome( + scenicId: scenicId, + scenicSpotId: spotId, + type: QueueListType.queueing.rawValue, + page: 1, + pageSize: 20 + ) + let (stats, home) = try await (statsData, homeData) + handle(stats: stats, tickets: home.list?.list ?? []) + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + lastPollText = formatter.string(from: Date()) + lastQueueCount = stats.queueCount + + guard voiceEnabled else { return } + let customText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: lastSpotId) + if let announcement = announcementState.nextAnnouncement(stats: stats, tickets: tickets, customCallText: customText) { + lastSpokenText = announcement.text + speaker.speak(announcement.text) + } + } + + private func pollIntervalSeconds() -> UInt64 { + switch scenePhase { + case .active: + return 12 + case .inactive: + return 20 + case .background: + return 30 + @unknown default: + return 20 + } + } + + private func selectedSpotId() -> Int { + ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) + ?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) + } + + private func resetSnapshot() { + announcementState.reset() + lastQueueCount = 0 + lastPollText = "--" + lastSpokenText = "" + } + + private func beginBackgroundTask() { + guard backgroundTaskId == .invalid else { return } + backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in + Task { @MainActor in + self?.endBackgroundTask() + self?.stop() + } + } + } + + private func endBackgroundTask() { + guard backgroundTaskId != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTaskId) + backgroundTaskId = .invalid + } +} + +@MainActor +/// 排队语音播报服务。 +final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate { + private let synthesizer = AVSpeechSynthesizer() + private var pendingContinuations: [CheckedContinuation] = [] + + override init() { + super.init() + synthesizer.delegate = self + } + + /// 播放语音文本。 + func speak(_ text: String) { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + enqueue(normalized, continuation: nil, replacePending: true) + } + + /// 播放语音并等待结束,供测试和设置页试听使用。 + func speakAndWait(_ text: String) async { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + await withCheckedContinuation { continuation in + enqueue(normalized, continuation: continuation, replacePending: false) + } + } + + /// 停止语音播报。 + func stop() { + synthesizer.stopSpeaking(at: .immediate) + resumePending() + } + + private func enqueue(_ normalized: String, continuation: CheckedContinuation?, replacePending: Bool) { + if replacePending { + synthesizer.stopSpeaking(at: .immediate) + resumePending() + } + if let continuation { + pendingContinuations.append(continuation) + } + let utterance = AVSpeechUtterance(string: normalized) + utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") + utterance.rate = 0.48 + synthesizer.speak(utterance) + } + + nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + Task { @MainActor in self.resumePending() } + } + + nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + Task { @MainActor in self.resumePending() } + } + + private func resumePending() { + let continuations = pendingContinuations + pendingContinuations.removeAll() + continuations.forEach { $0.resume() } + } +} diff --git a/suixinkan/Core/Queue/ScenicQueueSocketClient.swift b/suixinkan/Core/Queue/ScenicQueueSocketClient.swift new file mode 100644 index 0000000..7d0d814 --- /dev/null +++ b/suixinkan/Core/Queue/ScenicQueueSocketClient.swift @@ -0,0 +1,166 @@ +// +// ScenicQueueSocketClient.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队 WebSocket 消息。 +struct ScenicQueueSocketMessage: Equatable { + static let queueUpdatedAction = "scenic_spot_queue_updated" + static let ticketCalledAction = "scenic_queue_ticket_called" + + let code: Int + let data: ScenicQueueSocketData? + + var isScenicQueueEvent: Bool { + let action = data?.action ?? "" + return action == Self.queueUpdatedAction || action == Self.ticketCalledAction + } +} + +/// 排队 WebSocket data 块。 +struct ScenicQueueSocketData: Equatable { + let action: String + let params: ScenicQueueSocketParams? +} + +/// 排队 WebSocket 参数。 +struct ScenicQueueSocketParams: Equatable { + let scenicSpotId: Int64? + let recordId: Int64? + let operatorUid: Int64? + let eventId: String? +} + +@MainActor +/// 排队 WebSocket 客户端,负责订阅排队更新和叫号事件。 +final class ScenicQueueSocketClient { + private var webSocketTask: URLSessionWebSocketTask? + private var receiveTask: Task? + + /// 连接 WebSocket 并订阅指定打卡点的排队事件。 + func connect( + socketToken: String, + scenicSpotId: Int, + onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void + ) { + let token = socketToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { return } + disconnect(reason: "Reconnect before scenic queue socket connect") + + let task = URLSession.shared.webSocketTask(with: APIEnvironment.current.webSocketURL) + webSocketTask = task + task.resume() + + receiveTask = Task { [weak self] in + await self?.receiveLoop(task: task, onMessage: onMessage) + } + + Task { + for payload in Self.subscriptionPayloads(scenicSpotId: scenicSpotId) { + try? await task.send(.string(payload)) + } + } + } + + /// 断开 WebSocket。 + func disconnect(reason: String = "Scenic queue page stopped") { + receiveTask?.cancel() + receiveTask = nil + webSocketTask?.cancel(with: .normalClosure, reason: reason.data(using: .utf8)) + webSocketTask = nil + } + + private func receiveLoop( + task: URLSessionWebSocketTask, + onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void + ) async { + while !Task.isCancelled { + do { + let message = try await task.receive() + let text: String? + switch message { + case .string(let value): + text = value + case .data(let data): + text = String(data: data, encoding: .utf8) + @unknown default: + text = nil + } + guard let text, let parsed = Self.parseMessage(text), parsed.isScenicQueueEvent else { continue } + onMessage(parsed) + } catch { + return + } + } + } + + /// 构造订阅 payload,type 与 Android 保持一致。 + static func subscriptionPayloads(scenicSpotId: Int) -> [String] { + [ + #"{"type":306,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#, + #"{"type":307,"params":{"scenic_spot_id":\#(scenicSpotId)}}"# + ] + } + + /// 解析排队 WebSocket 原始消息。 + static func parseMessage(_ raw: String) -> ScenicQueueSocketMessage? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8) else { return nil } + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + + let code = intValue(root["code"]) ?? 0 + var socketData: ScenicQueueSocketData? + if let dataObject = root["data"] as? [String: Any] { + let action = stringValue(dataObject["action"]) + var params: ScenicQueueSocketParams? + if let paramsObject = dataObject["params"] as? [String: Any] { + params = ScenicQueueSocketParams( + scenicSpotId: int64Value(paramsObject["scenic_spot_id"]), + recordId: int64Value(paramsObject["record_id"]), + operatorUid: int64Value(paramsObject["operator_uid"]), + eventId: stringValue(paramsObject["event_id"]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ) + } + socketData = ScenicQueueSocketData(action: action, params: params) + } + return ScenicQueueSocketMessage(code: code, data: socketData) + } + + private static func stringValue(_ value: Any?) -> String { + switch value { + case let value as String: + return value + case let value as NSNumber: + return value.stringValue + case let value?: + return "\(value)" + case nil: + return "" + } + } + + private static func intValue(_ value: Any?) -> Int? { + if let value = value as? Int { return value } + if let value = value as? NSNumber { return value.intValue } + if let value = value as? String { return Int(value) } + return nil + } + + private static func int64Value(_ value: Any?) -> Int64? { + if let value = value as? Int64 { return value } + if let value = value as? Int { return Int64(value) } + if let value = value as? NSNumber { return value.int64Value } + if let value = value as? String { return Int64(value) } + return nil + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan/Core/Validation/AppFormValidator.swift b/suixinkan/Core/Validation/AppFormValidator.swift new file mode 100644 index 0000000..a5c557f --- /dev/null +++ b/suixinkan/Core/Validation/AppFormValidator.swift @@ -0,0 +1,62 @@ +// +// AppFormValidator.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 表单校验工具,承接排队设置等页面的通用数字和金额格式校验。 +enum AppFormValidator { + /// 校验整数文本在指定范围内。 + static func rangedInteger(_ text: String, name: String, range: ClosedRange) throws -> Int { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let value = Int(trimmed) else { + throw APIError.networkFailed("\(name)格式不正确") + } + guard range.contains(value) else { + throw APIError.networkFailed("\(name)须在 \(range.lowerBound)~\(range.upperBound)") + } + return value + } + + /// 校验两次通知阈值,第二次应小于等于第一次。 + static func validateQueueNoticeThresholds(first: Int, second: Int) throws { + guard second <= first else { + throw APIError.networkFailed("第二次通知阈值不能大于第一次通知阈值") + } + } + + /// 清洗最多指定小数位的金额输入。 + static func sanitizedMoneyInput(_ input: String, maxDecimalPlaces: Int = 2) -> String { + var result = "" + var hasDot = false + var decimalCount = 0 + + for char in input { + if char.isNumber { + if hasDot { + guard decimalCount < maxDecimalPlaces else { continue } + decimalCount += 1 + } + result.append(char) + } else if char == ".", !hasDot { + hasDot = true + result.append(char) + } + } + + if result.hasPrefix(".") { + result = "0" + result + } + return result + } + + /// 规范化提交用金额,最多两位小数。 + static func normalizedMoneyForSubmit(_ input: String) -> String { + let sanitized = sanitizedMoneyInput(input, maxDecimalPlaces: 2) + guard !sanitized.isEmpty, sanitized != "." else { return "" } + return sanitized + } +} diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index 84fb201..d45be7e 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 ## 后续迁移 -目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。 +目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管。 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index 6c2cd63..61827a7 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -54,6 +54,12 @@ enum HomeRoute: Hashable { case punchPointQR(id: Int, title: String, qrURL: String) case locationReport case locationReportHistory + case depositOrders + case withdrawalAudit + case scenicSettlement + case scenicSettlementReview + case messageCenter + case queueManagement case modulePlaceholder(uri: String, title: String) } diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 7b01978..36cc697 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -127,6 +127,18 @@ enum HomeMenuRouter { return .destination(.locationReport) case "location_report_history": return .destination(.locationReportHistory) + case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info": + return .destination(.depositOrders) + case "withdrawal_audit": + return .destination(.withdrawalAudit) + case "scenic_settlement": + return .destination(.scenicSettlement) + case "scenic_settlement_review": + return .destination(.scenicSettlementReview) + case "message_center": + return .destination(.messageCenter) + case "/scenic-queue", "queue_management": + return .destination(.queueManagement) case "store", "more_functions": return .destination(.moreFunctions) case "fly", "pilot_controller": @@ -135,17 +147,8 @@ enum HomeMenuRouter { title: title.isEmpty ? self.title(for: uri) : title, reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。" ) - case "message_center", - "/scenic-queue", - "queue_management", - "deposit_order_detail", - "deposit_order", - "deposit_order_shooting_info", - "withdrawal_audit", - "live_stream_management", + case "live_stream_management", "live_album", - "scenic_settlement", - "scenic_settlement_review", "operating-area", "pilot_cert": let resolvedTitle = title.isEmpty ? self.title(for: uri) : title diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index e916576..5b3cff8 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -84,6 +84,18 @@ extension HomeRoute { LocationReportView() case .locationReportHistory: LocationReportHistoryView() + case .depositOrders: + DepositOrderEntryView() + case .withdrawalAudit: + WithdrawalAuditView() + case .scenicSettlement: + ScenicSettlementView() + case .scenicSettlementReview: + ScenicSettlementReviewView() + case .messageCenter: + MessageCenterView() + case .queueManagement: + QueueManagementView() case let .modulePlaceholder(uri, title): HomeMigrationModuleView(title: title, uri: uri) } diff --git a/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift b/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift new file mode 100644 index 0000000..9650aeb --- /dev/null +++ b/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift @@ -0,0 +1,74 @@ +// +// MessageCenterAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +/// 消息中心服务协议,定义消息列表、已读和删除能力。 +@MainActor +protocol MessageCenterServing { + /// 获取消息列表,使用 last_id 游标分页。 + func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse + + /// 标记单条消息为已读。 + func messageRead(id: Int) async throws + + /// 删除单条消息。 + func messageDelete(id: Int) async throws +} + +@MainActor +@Observable +/// 消息中心 API,封装消息相关网络请求。 +final class MessageCenterAPI { + @ObservationIgnored private let client: APIClient + + /// 初始化消息中心 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取消息列表,使用 last_id 游标分页。 + func messageList(lastId: Int = 0, limit: Int = 20, unread: Int = 0) async throws -> MessageListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/msg/list", + queryItems: [ + URLQueryItem(name: "last_id", value: String(max(lastId, 0))), + URLQueryItem(name: "limit", value: String(max(limit, 1))), + URLQueryItem(name: "unread", value: String(max(unread, 0))) + ] + ) + ) + } + + /// 标记单条消息为已读。 + func messageRead(id: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/msg/read", + queryItems: [URLQueryItem(name: "id", value: String(id))], + body: EmptyPayload() + ) + ) + } + + /// 删除单条消息。 + func messageDelete(id: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/msg/delete", + body: MessageDeleteRequest(id: id) + ) + ) + } +} + +extension MessageCenterAPI: MessageCenterServing {} diff --git a/suixinkan/Features/MessageCenter/MessageCenter.md b/suixinkan/Features/MessageCenter/MessageCenter.md new file mode 100644 index 0000000..9191397 --- /dev/null +++ b/suixinkan/Features/MessageCenter/MessageCenter.md @@ -0,0 +1,18 @@ +# 消息中心模块 + +## 模块职责 + +`Features/MessageCenter` 承接首页 `message_center` 权限入口,负责消息列表、未读筛选、标记已读、全部已读、详情展示和删除。 + +## 代码结构 + +- `MessageCenterAPI`:封装 `/api/app/msg/list`、`/api/app/msg/read`、`/api/app/msg/delete`。 +- `MessageCenterViewModel`:管理 `last_id` 游标分页、筛选、去重排序、已读和删除后的本地状态同步。 +- `MessageCenterView`:提供统计卡、全部/未读筛选、列表、失败重试、空态、详情和删除确认。 + +## 数据与边界 + +- 消息详情不请求独立详情接口,直接使用列表返回的标题、内容、类型和推送时间。 +- 点击消息时先调用已读接口,再进入详情,并将本地未读状态同步为已读。 +- 全部已读沿用旧 iOS 能力,逐条调用已读接口;任一失败时保留原状态并透出错误。 +- 首屏刷新失败会清空旧数据和分页状态,加载更多失败保留当前列表。 diff --git a/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift b/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift new file mode 100644 index 0000000..dee32ad --- /dev/null +++ b/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift @@ -0,0 +1,226 @@ +// +// MessageCenterModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import SwiftUI + +/// 消息中心列表响应,使用 last_id 游标分页。 +struct MessageListResponse: Decodable, Equatable { + let hasMore: Bool + let lastId: Int + let items: [MessageEntity] + + enum CodingKeys: String, CodingKey { + case hasMore = "has_more" + case lastId = "last_id" + case items + } + + /// 创建空消息响应,主要用于测试和失败兜底。 + init(hasMore: Bool = false, lastId: Int = 0, items: [MessageEntity] = []) { + self.hasMore = hasMore + self.lastId = lastId + self.items = items + } + + /// 宽松解码分页状态,兼容后端数字/字符串/布尔类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hasMore = try container.decodeLossyBool(forKey: .hasMore) ?? false + lastId = try container.decodeLossyInt(forKey: .lastId) ?? 0 + items = try container.decodeIfPresent([MessageEntity].self, forKey: .items) ?? [] + } +} + +/// 后端消息实体。 +struct MessageEntity: Decodable, Equatable, Identifiable { + let id: Int + let type: Int + let typeName: String + let title: String + let content: String + let pushAt: String + let createdAt: String + let isRead: Bool + + enum CodingKeys: String, CodingKey { + case id + case type + case typeName = "type_name" + case title + case content + case pushAt = "push_at" + case createdAt = "created_at" + case isRead = "is_read" + } + + /// 创建消息实体,主要用于测试。 + init( + id: Int, + type: Int, + typeName: String = "", + title: String = "", + content: String = "", + pushAt: String = "", + createdAt: String = "", + isRead: Bool = false + ) { + self.id = id + self.type = type + self.typeName = typeName + self.title = title + self.content = content + self.pushAt = pushAt + self.createdAt = createdAt + self.isRead = isRead + } + + /// 宽松解码消息字段,避免单条异常字段导致整页失败。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + type = try container.decodeLossyInt(forKey: .type) ?? 0 + typeName = try container.decodeLossyString(forKey: .typeName) + title = try container.decodeLossyString(forKey: .title) + content = try container.decodeLossyString(forKey: .content) + pushAt = try container.decodeLossyString(forKey: .pushAt) + createdAt = try container.decodeLossyString(forKey: .createdAt) + isRead = try container.decodeLossyBool(forKey: .isRead) ?? false + } +} + +/// 删除消息请求体。 +struct MessageDeleteRequest: Encodable { + let id: Int +} + +/// 页面展示用消息类型。 +enum MessageType: Equatable { + case order + case writeOff + case system + + /// 消息类型图标。 + var iconName: String { + switch self { + case .order: + return "cart.fill" + case .writeOff: + return "qrcode.viewfinder" + case .system: + return "bell.badge.fill" + } + } + + /// 消息类型标题。 + var title: String { + switch self { + case .order: + return "订单" + case .writeOff: + return "核销" + case .system: + return "系统" + } + } + + /// 消息类型颜色。 + var color: Color { + switch self { + case .order: + return AppDesign.primary + case .writeOff: + return AppDesign.success + case .system: + return AppDesign.warning + } + } +} + +/// 页面展示用消息项。 +struct MessageItem: Identifiable, Equatable { + let id: String + let msgId: Int? + let title: String + let detail: String + let time: String + var isRead: Bool + let type: MessageType +} + +/// 消息筛选项。 +enum MessageFilter: Int, CaseIterable, Identifiable { + case all = 0 + case unread = 1 + + var id: Int { rawValue } + + /// 筛选标题。 + var title: String { + switch self { + case .all: + return "全部" + case .unread: + return "未读" + } + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + 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) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "1" : "0" + } + return "" + } + + /// 将 String、数字和 Bool 宽松解码为 Int。 + 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) + } + if let value = try? decodeIfPresent(String.self, forKey: key), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return Int(text) ?? Int(Double(text) ?? 0) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? 1 : 0 + } + return nil + } + + /// 将 String、数字和 Bool 宽松解码为 Bool。 + 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 + } + if let value = try? decodeIfPresent(String.self, forKey: key), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !text.isEmpty { + if ["1", "true", "yes"].contains(text) { return true } + if ["0", "false", "no"].contains(text) { return false } + } + return nil + } +} diff --git a/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift new file mode 100644 index 0000000..fb527f1 --- /dev/null +++ b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift @@ -0,0 +1,170 @@ +// +// MessageCenterViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 消息中心 ViewModel,负责消息分页、筛选、已读和删除。 +final class MessageCenterViewModel { + var messages: [MessageItem] = [] + var selectedFilter: MessageFilter = .all + var isLoading = false + var isLoadingMore = false + var loadFailed = false + var loadFailureReason: String? + var message: String? + + private(set) var hasMoreMessages = false + private(set) var lastId = 0 + private let pageSize = 20 + + /// 未读消息数量。 + var unreadCount: Int { + messages.filter { !$0.isRead }.count + } + + /// 当前筛选说明。 + var filterDescription: String { + selectedFilter == .all ? "显示全部消息" : "仅显示未读消息" + } + + /// 切换筛选并重新加载首屏。 + func selectFilter(_ filter: MessageFilter, api: any MessageCenterServing) async { + guard selectedFilter != filter else { return } + selectedFilter = filter + await reloadFirstPage(api: api) + } + + /// 重新加载首屏消息,失败时清空旧数据。 + func reloadFirstPage(api: any MessageCenterServing) async { + isLoading = true + loadFailed = false + loadFailureReason = nil + message = nil + defer { isLoading = false } + + do { + let response = try await api.messageList(lastId: 0, limit: pageSize, unread: selectedFilter.rawValue) + messages = mapMessages(response.items) + hasMoreMessages = response.hasMore + lastId = response.lastId + } catch { + clearMessages() + loadFailed = true + loadFailureReason = error.localizedDescription + message = error.localizedDescription + } + } + + /// 加载更多消息,失败时保留当前数据和分页状态。 + func loadMore(api: any MessageCenterServing) async { + guard hasMoreMessages, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + message = nil + defer { isLoadingMore = false } + + do { + let response = try await api.messageList(lastId: lastId, limit: pageSize, unread: selectedFilter.rawValue) + messages.append(contentsOf: mapMessages(response.items)) + messages = deduplicatedAndSorted(messages) + hasMoreMessages = response.hasMore + lastId = response.lastId + } catch { + message = error.localizedDescription + } + } + + /// 标记单条消息为已读,接口成功后再更新本地状态。 + func markAsRead(api: any MessageCenterServing, item: MessageItem) async throws { + guard !item.isRead, let msgId = item.msgId else { return } + try await api.messageRead(id: msgId) + guard let index = messages.firstIndex(where: { $0.id == item.id }) else { return } + messages[index].isRead = true + } + + /// 将当前列表中的未读消息逐条标记为已读。 + func markAllAsRead(api: any MessageCenterServing) async throws { + let original = messages + do { + for item in messages where !item.isRead { + if let msgId = item.msgId { + try await api.messageRead(id: msgId) + } + } + messages = messages.map { item in + var next = item + next.isRead = true + return next + } + } catch { + messages = original + throw error + } + } + + /// 删除消息,接口成功后再移除本地列表。 + func deleteMessage(api: any MessageCenterServing, item: MessageItem) async throws { + if let msgId = item.msgId { + try await api.messageDelete(id: msgId) + } + messages.removeAll { $0.id == item.id } + } + + /// 将后端实体转换为页面展示实体。 + func mapMessages(_ source: [MessageEntity]) -> [MessageItem] { + source.map { item in + MessageItem( + id: "msg_\(item.id)", + msgId: item.id, + title: item.title.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? item.typeName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? "系统通知", + detail: item.content, + time: item.pushAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? item.createdAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? "0000-00-00 00:00", + isRead: item.isRead, + type: Self.messageType(from: item.type) + ) + } + } + + /// 将后端类型映射为页面消息类型。 + static func messageType(from value: Int) -> MessageType { + switch value { + case 1: + return .order + case 2: + return .writeOff + default: + return .system + } + } + + private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] { + var unique: [String: MessageItem] = [:] + for item in source { + unique[item.id] = item + } + return unique.values.sorted { $0.time > $1.time } + } + + private func clearMessages() { + messages = [] + hasMoreMessages = false + lastId = 0 + isLoadingMore = false + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift b/suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift new file mode 100644 index 0000000..360a185 --- /dev/null +++ b/suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift @@ -0,0 +1,302 @@ +// +// MessageCenterViews.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import SwiftUI + +/// 消息中心页面,展示消息列表、筛选、已读和删除入口。 +struct MessageCenterView: View { + @Environment(MessageCenterAPI.self) private var messageAPI + @Environment(ToastCenter.self) private var toastCenter + @State private var viewModel = MessageCenterViewModel() + @State private var selectedMessage: MessageItem? + @State private var processingMessageId: String? + + var body: some View { + VStack(spacing: 0) { + header + content + } + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle("消息中心") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("全部已读") { + Task { await markAllAsRead() } + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .disabled(viewModel.unreadCount == 0 || viewModel.messages.isEmpty) + } + } + .sheet(item: $selectedMessage) { item in + MessageDetailView(item: item) { deleteItem in + await deleteMessage(deleteItem) + } + } + .task { + if viewModel.messages.isEmpty { + await viewModel.reloadFirstPage(api: messageAPI) + } + } + .refreshable { + await viewModel.reloadFirstPage(api: messageAPI) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + HStack(spacing: AppMetrics.Spacing.medium) { + messageMetric(title: "消息总数", value: "\(viewModel.messages.count)", color: AppDesign.primary) + messageMetric(title: "未读消息", value: "\(viewModel.unreadCount)", color: AppDesign.warning) + } + + Picker("消息筛选", selection: Binding( + get: { viewModel.selectedFilter }, + set: { filter in + Task { await viewModel.selectFilter(filter, api: messageAPI) } + } + )) { + ForEach(MessageFilter.allCases) { filter in + Text(filter.title).tag(filter) + } + } + .pickerStyle(.segmented) + + Text(viewModel.filterDescription) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + .padding(AppMetrics.Spacing.pageHorizontal) + .background(.white) + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.messages.isEmpty { + ProgressView("加载中...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.loadFailed { + ContentUnavailableView { + Label("消息加载失败", systemImage: "exclamationmark.triangle") + } description: { + Text(viewModel.loadFailureReason ?? "请稍后重试") + } actions: { + Button("重新加载") { + Task { await viewModel.reloadFirstPage(api: messageAPI) } + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.messages.isEmpty { + ContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: AppMetrics.Spacing.medium) { + ForEach(viewModel.messages) { item in + Button { + Task { await openMessage(item) } + } label: { + MessageRow(item: item, processing: processingMessageId == item.id) + } + .buttonStyle(.plain) + .onAppear { + if item.id == viewModel.messages.last?.id { + Task { await viewModel.loadMore(api: messageAPI) } + } + } + } + + if viewModel.isLoadingMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, AppMetrics.Spacing.medium) + } + } + .padding(AppMetrics.Spacing.pageHorizontal) + } + } + } + + private func messageMetric(title: String, value: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text(value) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(color) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.medium) + .background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + private func openMessage(_ item: MessageItem) async { + processingMessageId = item.id + defer { processingMessageId = nil } + do { + try await viewModel.markAsRead(api: messageAPI, item: item) + } catch { + toastCenter.show(error.localizedDescription) + } + selectedMessage = viewModel.messages.first(where: { $0.id == item.id }) ?? item + } + + private func markAllAsRead() async { + do { + try await viewModel.markAllAsRead(api: messageAPI) + toastCenter.show("已全部标记为已读") + } catch { + toastCenter.show(error.localizedDescription) + } + } + + @discardableResult + private func deleteMessage(_ item: MessageItem) async -> Bool { + processingMessageId = item.id + defer { processingMessageId = nil } + do { + try await viewModel.deleteMessage(api: messageAPI, item: item) + toastCenter.show("消息已删除") + return true + } catch { + toastCenter.show(error.localizedDescription) + return false + } + } +} + +private struct MessageRow: View { + let item: MessageItem + let processing: Bool + + var body: some View { + HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(item.type.color.opacity(0.12)) + Image(systemName: item.type.iconName) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(item.type.color) + } + .frame(width: 48, height: 48) + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(item.title) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + if !item.isRead { + Circle() + .fill(Color(hex: 0xEF4444)) + .frame(width: 7, height: 7) + } + Spacer() + Text(item.type.title) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(item.type.color) + } + + Text(item.detail.isEmpty ? "暂无消息内容" : item.detail) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(2) + + Text(item.time) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(Color(hex: 0x9CA3AF)) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + .overlay { + RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card) + .stroke(Color(hex: item.isRead ? 0xECEFF3 : 0xBFD7FF), lineWidth: 1) + } + .opacity(processing ? 0.6 : 1) + } +} + +private struct MessageDetailView: View { + let item: MessageItem + let onDelete: (MessageItem) async -> Bool + @Environment(\.dismiss) private var dismiss + @State private var showDeleteConfirm = false + @State private var deleting = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: AppMetrics.Spacing.large) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(item.type.color.opacity(0.12)) + Image(systemName: item.type.iconName) + .font(.system(size: 38, weight: .bold)) + .foregroundStyle(item.type.color) + } + .frame(width: 82, height: 82) + + VStack(spacing: AppMetrics.Spacing.small) { + Text(item.title) + .font(.system(size: AppMetrics.FontSize.title2, weight: .bold)) + .foregroundStyle(AppDesign.textPrimary) + .multilineTextAlignment(.center) + Text(item.time) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } + + Text(item.detail.isEmpty ? "暂无消息内容" : item.detail) + .font(.system(size: AppMetrics.FontSize.body)) + .foregroundStyle(AppDesign.textPrimary) + .lineSpacing(6) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.large) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + .padding(AppMetrics.Spacing.pageHorizontal) + } + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle("消息详情") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + if deleting { + ProgressView() + } else { + Image(systemName: "trash") + } + } + .disabled(deleting || item.msgId == nil) + } + } + .alert("确认删除", isPresented: $showDeleteConfirm) { + Button("取消", role: .cancel) {} + Button("删除", role: .destructive) { + Task { + deleting = true + let success = await onDelete(item) + deleting = false + if success { dismiss() } + } + } + } message: { + Text("删除后不可恢复,是否继续?") + } + } + } +} diff --git a/suixinkan/Features/Orders/API/OrdersAPI.swift b/suixinkan/Features/Orders/API/OrdersAPI.swift index 2833f94..23ca3a2 100644 --- a/suixinkan/Features/Orders/API/OrdersAPI.swift +++ b/suixinkan/Features/Orders/API/OrdersAPI.swift @@ -32,6 +32,30 @@ protocol OrderServing { /// 获取门店订单详情。 func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse + + /// 获取押金订单列表。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload + + /// 核销押金订单。 + func depositOrderWriteOff(orderNumber: String) async throws + + /// 申请押金订单退款。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws + + /// 获取订单某个拍摄点的底片、成片和评价信息。 + func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse + + /// 申请普通订单退款。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws + + /// 获取订单历史拍摄信息。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse + + /// 获取多点旅拍订单已核销打卡点列表。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] + + /// 提交多点旅拍任务素材。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws } @MainActor @@ -131,4 +155,106 @@ final class OrdersAPI: OrderServing { ) ) } + + /// 获取押金订单列表。 + func depositOrderList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/order/deposit-list", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } + + /// 核销押金订单。 + func depositOrderWriteOff(orderNumber: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/deposit-writeoff", + body: DepositOrderWriteOffRequest(orderNumber: orderNumber) + ) + ) + } + + /// 申请押金订单退款。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/deposit-refund", + body: DepositOrderRefundRequest(orderNumber: orderNumber, refundReason: refundReason) + ) + ) + } + + /// 获取订单某个拍摄点的底片、成片和评价信息。 + func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/order/shooting-detail", + queryItems: [ + URLQueryItem(name: "store_id", value: "\(storeId)"), + URLQueryItem(name: "order_number", value: orderNumber), + URLQueryItem(name: "scenic_spot_id", value: "\(scenicSpotId)"), + URLQueryItem(name: "photog_uid", value: "\(photogUid)") + ] + ) + ) + } + + /// 申请普通订单退款。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/refund", + body: OrderRefundRequest( + orderNumber: orderNumber, + refundType: refundType.rawValue, + refundAmount: refundAmount, + refundReason: refundReason + ) + ) + ) + } + + /// 获取订单历史拍摄信息。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/order/multi-travel/shoot-history", + queryItems: [URLQueryItem(name: "order_number", value: orderNumber)] + ) + ) + } + + /// 获取多点旅拍订单已核销打卡点列表。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list", + queryItems: [URLQueryItem(name: "order_number", value: orderNumber)] + ) + ) + } + + /// 提交多点旅拍任务素材。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/multi-travel/upload-material", + body: request + ) + ) + } } diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift index a02658d..1d33233 100644 --- a/suixinkan/Features/Orders/Models/OrderModels.swift +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -197,10 +197,101 @@ struct WriteOffRequest: Encodable { } } +/// 押金订单列表实体,表示首页押金订单卡片的基础信息和操作状态。 +struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable { + let id: Int + let orderNumber: String + let userPhone: String + let amount: String + let status: Int + let statusName: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case orderNumber = "order_number" + case userPhone = "user_phone" + case amount + case status + case statusName = "status_name" + case createdAt = "created_at" + } + + /// 宽松解码押金订单字段,兼容后端数字和字符串混用。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + userPhone = try container.decodeLossyString(forKey: .userPhone) + amount = try container.decodeLossyString(forKey: .amount) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusName = try container.decodeLossyString(forKey: .statusName) + createdAt = try container.decodeLossyString(forKey: .createdAt) + } +} + +/// 押金订单核销请求实体。 +struct DepositOrderWriteOffRequest: Encodable, Equatable { + let orderNumber: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + } +} + +/// 押金订单退款请求实体。 +struct DepositOrderRefundRequest: Encodable, Equatable { + let orderNumber: String + let refundReason: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case refundReason = "refund_reason" + } +} + +/// 普通订单退款模式,表示全额退款或部分退款。 +enum OrderRefundMode: Int, CaseIterable, Identifiable, Hashable { + case full = 1 + case partial = 3 + + var id: Int { rawValue } + + /// 退款模式展示文案。 + var title: String { + switch self { + case .full: + return "全额退款" + case .partial: + return "部分退款" + } + } +} + +/// 普通订单退款请求实体。 +struct OrderRefundRequest: Encodable, Equatable { + let orderNumber: String + let refundType: Int + let refundAmount: String + let refundReason: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case refundType = "refund_type" + case refundAmount = "refund_amount" + case refundReason = "refund_reason" + } +} + /// 订单详情路由实体,表示订单模块内部可 push 的二级页面。 enum OrdersRoute: Hashable { case storeDetail(OrderEntity) case writeOffDetail(WriteOffOrderItem) + case depositDetail(orderNumber: String) + case depositShootingInfo(orderNumber: String, scenicSpotId: Int, photogUid: Int) + case historicalShooting(orderNumber: String) + case multiTravelTaskUpload(orderNumber: String) + case orderTrailer(orderNumber: String, title: String) } /// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。 @@ -346,6 +437,226 @@ struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable } } +/// 订单拍摄信息响应实体,表示某个打卡点的评价、底片和成片。 +struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable { + let scenicSpotId: Int + let scenicSpotName: String + let orderType: Int + let orderTypeName: String + let orderComment: StoreOrderComment? + let materialList: [OrderMediaFile] + let completeList: [OrderMediaFile] + + enum CodingKeys: String, CodingKey { + case scenicSpotId = "scenic_spot_id" + case scenicSpotName = "scenic_spot_name" + case orderType = "order_type" + case orderTypeName = "order_type_name" + case orderComment = "order_comment" + case materialList = "material_list" + case completeList = "complete_list" + } + + /// 宽松解码拍摄信息详情。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0 + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0 + orderTypeName = try container.decodeLossyString(forKey: .orderTypeName) + orderComment = try container.decodeIfPresent(StoreOrderComment.self, forKey: .orderComment) + materialList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .materialList) ?? [] + completeList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .completeList) ?? [] + } +} + +/// 订单评价实体,表示拍摄、修图、景区、服务和相机评分。 +struct StoreOrderComment: Decodable, Equatable, Hashable { + let starShooting: Int + let starRetouching: Int + let starScenery: Int + let starService: Int + let starCamera: Int + let content: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case starShooting = "star_shooting" + case starRetouching = "star_retouching" + case starScenery = "star_scenery" + case starService = "star_service" + case starCamera = "star_camera" + case content + case createdAt = "created_at" + } + + /// 宽松解码评价字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + starShooting = try container.decodeLossyInt(forKey: .starShooting) ?? 0 + starRetouching = try container.decodeLossyInt(forKey: .starRetouching) ?? 0 + starScenery = try container.decodeLossyInt(forKey: .starScenery) ?? 0 + starService = try container.decodeLossyInt(forKey: .starService) ?? 0 + starCamera = try container.decodeLossyInt(forKey: .starCamera) ?? 0 + content = try container.decodeLossyString(forKey: .content) + createdAt = try container.decodeLossyString(forKey: .createdAt) + } +} + +/// 订单媒体文件实体,表示底片、成片或历史拍摄媒体。 +struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable { + var id: String { "\(fileUrl)-\(fileName)-\(uploadTime)" } + + let fileName: String + let fileUrl: String + let fileType: Int + let fileSize: Int64 + let coverUrl: String + let uploadTime: String + + var isVideo: Bool { + fileType == 1 + } + + var previewURLString: String { + coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl + } + + enum CodingKeys: String, CodingKey { + case fileName = "file_name" + case fileUrl = "file_url" + case fileType = "file_type" + case fileSize = "file_size" + case coverUrl = "cover_url" + case uploadTime = "upload_time" + } + + /// 宽松解码媒体文件字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + fileName = try container.decodeLossyString(forKey: .fileName) + fileUrl = try container.decodeLossyString(forKey: .fileUrl) + fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0 + fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0) + coverUrl = try container.decodeLossyString(forKey: .coverUrl) + uploadTime = try container.decodeLossyString(forKey: .uploadTime) + } +} + +/// 历史拍摄响应实体,表示订单多点位历史拍摄媒体。 +struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable { + let projectName: String + let projectType: Int + let projectTypeName: String + let photogSpotList: [PhotogSpotItem] + + enum CodingKeys: String, CodingKey { + case projectName = "project_name" + case projectType = "project_type" + case projectTypeName = "project_type_name" + case photogSpotList = "photog_spot_list" + } + + /// 宽松解码历史拍摄响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + projectName = try container.decodeLossyString(forKey: .projectName) + projectType = try container.decodeLossyInt(forKey: .projectType) ?? 0 + projectTypeName = try container.decodeLossyString(forKey: .projectTypeName) + photogSpotList = try container.decodeIfPresent([PhotogSpotItem].self, forKey: .photogSpotList) ?? [] + } +} + +/// 历史拍摄打卡点实体,表示某个摄影师在打卡点上传过的媒体。 +struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable { + var id: String { "\(scenicSpotId)-\(photogUid)" } + + let scenicSpotId: Int + let photogUid: Int + let scenicSpotName: String + let photogNickname: String + let photogName: String + let files: [OrderMediaFile] + + var photographerDisplayName: String { + let nickname = photogNickname.trimmingCharacters(in: .whitespacesAndNewlines) + return nickname.isEmpty ? photogName : nickname + } + + enum CodingKeys: String, CodingKey { + case scenicSpotId = "scenic_spot_id" + case photogUid = "photog_uid" + case scenicSpotName = "scenic_spot_name" + case photogNickname = "photog_nickname" + case photogName = "photog_name" + case files + } + + /// 宽松解码历史拍摄打卡点。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0 + photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0 + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + photogNickname = try container.decodeLossyString(forKey: .photogNickname) + photogName = try container.decodeLossyString(forKey: .photogName) + files = try container.decodeIfPresent([OrderMediaFile].self, forKey: .files) ?? [] + } +} + +/// 多点旅拍已核销打卡点实体,用于任务上传时选择目标打卡点。 +struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Hashable { + let id: Int + let name: String + + enum CodingKeys: String, CodingKey { + case id + case name + } + + /// 宽松解码已核销打卡点字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + } +} + +/// 多点旅拍任务素材提交请求实体。 +struct MultiTravelUploadMaterialRequest: Encodable, Equatable { + let orderNumber: String + let scenicSpotId: Int + let cloudFile: [MultiTravelCloudFileItem] + let uploadFile: [MultiTravelUploadFileItem] + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case scenicSpotId = "scenic_spot_id" + case cloudFile = "cloud_file" + case uploadFile = "upload_file" + } +} + +/// 多点旅拍云盘素材提交项。 +struct MultiTravelCloudFileItem: Encodable, Equatable { + let fileId: Int + + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + } +} + +/// 多点旅拍本地上传素材提交项。 +struct MultiTravelUploadFileItem: Encodable, Equatable { + let fileName: String + let fileUrl: String + + enum CodingKeys: String, CodingKey { + case fileName = "file_name" + case fileUrl = "file_url" + } +} + /// 订单详情展示适配实体,统一列表摘要和详情接口响应的读取方式。 struct StoreOrderDetailDisplay: Equatable { let orderNumber: String diff --git a/suixinkan/Features/Orders/Orders.md b/suixinkan/Features/Orders/Orders.md index f464b97..63c70bf 100644 --- a/suixinkan/Features/Orders/Orders.md +++ b/suixinkan/Features/Orders/Orders.md @@ -2,15 +2,21 @@ ## 模块职责 -Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理和核销订单两个入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销和手动核销闭环。 +Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传和尾片入口闭环。 -当前仍不迁移退款、历史拍摄、任务上传、视频预告、完整拍摄详情等长尾流程;这些入口统一进入安全占位页,后续按独立模块逐个迁移。 +当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。 ## 核心对象 - `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。 - `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。 - `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。 +- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。 +- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。 +- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。 +- `OrderRefundViewModel`:管理普通订单退款入口判断、全额/部分退款金额校验和提交保护。 +- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。 +- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。 - `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。 - `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。 - `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。 @@ -26,8 +32,18 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理和核销 核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。 +押金订单入口由首页 `deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 进入。没有订单上下文时先展示押金订单页,用户可以手输订单号进入详情,也可以从列表进入详情。押金详情复用门店订单详情接口,按当前门店 ID 和订单号加载;拍摄点列表进入拍摄信息页,展示评价、底片和成片。 + +普通订单退款只在 `orderStatus == 18 || orderStatus == 30`、`orderType != 19` 且可退金额大于 0 时展示入口。全额退款使用可退金额,部分退款要求金额大于 0、最多两位小数且不能超过可退金额。押金退款要求填写退款原因,普通退款和押金退款都不保存表单数据,提交成功后刷新对应订单数据。 + +历史拍摄按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/shoot-history`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑。 + +任务上传仅对 `orderType == 19` 的多点旅拍订单展示。页面先按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list` 获取已核销打卡点,再支持选择云盘素材和本地图片/视频;本地素材先通过 `OSSUploadService.uploadTaskFile` 上传,成功后和云盘文件一起提交到 `/api/yf-handset-app/photog/order/multi-travel/upload-material`。 + +视频预告和尾片上传按旧工程现状统一进入 `OrderTrailerView`,该页只做订单尾片流程引导,并打开已迁移的 `AlbumTrailerEntryView` 完成相册预览上传,不新增独立订单尾片接口。 + ## 路由边界 -首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单。订单详情和核销订单详情已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。 +首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。 -退款、历史拍摄、任务上传、视频预告等尚未迁移的能力仍进入占位页,避免入口崩溃。 +未明确接口的订单后续能力继续进入占位页,避免入口崩溃。 diff --git a/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift index 2d2361d..6590381 100644 --- a/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift @@ -43,8 +43,13 @@ final class OrderDetailViewModel { } /// 加载门店订单详情,缺少门店 ID 时只保留列表摘要展示。 - func load(api: OrderServing, fallbackStoreId: Int?) async { - guard detail == nil, !loading else { return } + func load(api: OrderServing, fallbackStoreId: Int?, forceReload: Bool = false) async { + guard !loading else { return } + if forceReload { + detail = nil + } else { + guard detail == nil else { return } + } guard let storeId = item.storeId ?? fallbackStoreId, storeId > 0 else { contextMessage = "缺少门店上下文,当前展示订单摘要。" diff --git a/suixinkan/Features/Orders/ViewModels/OrderLongTailViewModels.swift b/suixinkan/Features/Orders/ViewModels/OrderLongTailViewModels.swift new file mode 100644 index 0000000..ffa1060 --- /dev/null +++ b/suixinkan/Features/Orders/ViewModels/OrderLongTailViewModels.swift @@ -0,0 +1,610 @@ +// +// OrderLongTailViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。 +final class DepositOrderListViewModel { + private(set) var orders: [DepositOrderListItem] = [] + private(set) var total = 0 + private(set) var page = 1 + private(set) var hasMore = false + private(set) var loading = false + private(set) var loadingMore = false + private(set) var operatingOrderNumber: String? + var errorMessage: String? + + private let pageSize = 10 + + /// 加载押金订单列表,缺少景区时清空旧数据且不请求接口。 + func reload(api: OrderServing, scenicId: Int?, reset: Bool) async { + guard let scenicId, scenicId > 0 else { + resetState() + return + } + + if reset { + page = 1 + total = 0 + orders = [] + loading = true + } else { + guard !loadingMore, hasMore else { return } + loadingMore = true + } + + defer { + loading = false + loadingMore = false + } + + do { + let result = try await api.depositOrderList(scenicId: scenicId, page: page, pageSize: pageSize) + total = result.total + if page == 1 { + orders = result.list + } else { + orders.append(contentsOf: result.list) + } + page += 1 + hasMore = orders.count < result.total && !result.list.isEmpty + errorMessage = nil + } catch { + if reset { + orders = [] + total = 0 + page = 1 + hasMore = false + } + errorMessage = error.localizedDescription + } + } + + /// 核销押金订单,成功后刷新第一页。 + func writeOff(api: OrderServing, scenicId: Int?, orderNumber: String) async -> Bool { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, operatingOrderNumber == nil else { return false } + guard let scenicId, scenicId > 0 else { + errorMessage = "缺少景区上下文" + return false + } + + operatingOrderNumber = normalized + defer { operatingOrderNumber = nil } + + do { + try await api.depositOrderWriteOff(orderNumber: normalized) + await reload(api: api, scenicId: scenicId, reset: true) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 申请押金订单退款,原因必填,成功后刷新第一页。 + func refund(api: OrderServing, scenicId: Int?, orderNumber: String, reason: String) async -> Bool { + let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedOrderNumber.isEmpty, operatingOrderNumber == nil else { return false } + guard !normalizedReason.isEmpty else { + errorMessage = "请填写退款原因" + return false + } + guard let scenicId, scenicId > 0 else { + errorMessage = "缺少景区上下文" + return false + } + + operatingOrderNumber = normalizedOrderNumber + defer { operatingOrderNumber = nil } + + do { + try await api.depositOrderRefund(orderNumber: normalizedOrderNumber, refundReason: normalizedReason) + await reload(api: api, scenicId: scenicId, reset: true) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func resetState() { + orders = [] + total = 0 + page = 1 + hasMore = false + loading = false + loadingMore = false + operatingOrderNumber = nil + errorMessage = nil + } +} + +@MainActor +@Observable +/// 押金订单详情 ViewModel,负责按门店和订单号加载详情。 +final class DepositOrderDetailViewModel { + private(set) var detail: StoreOrderDetailResponse? + private(set) var loading = false + var errorMessage: String? + + /// 加载押金订单详情,缺少门店或订单号时不请求接口。 + func load(api: OrderServing, storeId: Int?, orderNumber: String) async { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard let storeId, storeId > 0 else { + reset(message: "当前账号缺少门店信息") + return + } + guard !normalized.isEmpty else { + reset(message: "订单号不能为空") + return + } + + detail = nil + loading = true + defer { loading = false } + + do { + detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: normalized) + errorMessage = nil + } catch { + detail = nil + errorMessage = error.localizedDescription + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func reset(message: String) { + detail = nil + loading = false + errorMessage = message + } +} + +@MainActor +@Observable +/// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。 +final class DepositOrderShootingInfoViewModel { + private(set) var detail: StoreOrderShootingDetailResponse? + private(set) var loading = false + var errorMessage: String? + + /// 加载押金订单拍摄信息,缺少门店或订单号时不请求接口。 + func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard let storeId, storeId > 0 else { + reset(message: "当前账号缺少门店信息") + return + } + guard !normalized.isEmpty else { + reset(message: "订单号不能为空") + return + } + + detail = nil + loading = true + defer { loading = false } + + do { + detail = try await api.storeOrderShootingDetail( + storeId: storeId, + orderNumber: normalized, + scenicSpotId: scenicSpotId, + photogUid: photogUid + ) + errorMessage = nil + } catch { + detail = nil + errorMessage = error.localizedDescription + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func reset(message: String) { + detail = nil + loading = false + errorMessage = message + } +} + +@MainActor +@Observable +/// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。 +final class OrderRefundViewModel { + var mode: OrderRefundMode = .full + var amount = "" + var reason = "" + private(set) var submitting = false + var errorMessage: String? + + /// 判断订单是否允许展示普通退款入口。 + func canRefund(_ item: OrderEntity) -> Bool { + (item.orderStatus == 18 || item.orderStatus == 30) && + item.orderType != 19 && + availableAmount(for: item) > 0 + } + + /// 打开退款表单时初始化默认值。 + func begin(item: OrderEntity) { + mode = .full + amount = availableAmountText(for: item) + reason = "" + errorMessage = nil + } + + /// 提交普通订单退款。 + func submit(api: OrderServing, item: OrderEntity) async -> Bool { + guard !submitting else { return false } + guard canRefund(item) else { + errorMessage = "当前订单不可退款" + return false + } + let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedReason.isEmpty else { + errorMessage = "请输入退款原因" + return false + } + + let refundAmount: String + switch mode { + case .full: + refundAmount = availableAmountText(for: item) + case .partial: + guard let normalized = Self.normalizedMoney(amount) else { + errorMessage = "请输入有效的退款金额" + return false + } + guard (Decimal(string: normalized) ?? 0) <= availableAmount(for: item) else { + errorMessage = "退款金额不能大于可退金额" + return false + } + refundAmount = normalized + } + + submitting = true + errorMessage = nil + defer { submitting = false } + + do { + try await api.orderRefund( + orderNumber: item.orderNumber, + refundType: mode, + refundAmount: refundAmount, + refundReason: normalizedReason + ) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 计算普通订单可退金额。 + func availableAmount(for item: OrderEntity) -> Decimal { + let paid = Self.decimalValue(item.actualPayAmount) + if paid > 0 { return paid } + return Self.decimalValue(item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount) + } + + /// 返回可退金额展示和提交文案。 + func availableAmountText(for item: OrderEntity) -> String { + Self.moneyText(availableAmount(for: item)) + } + + /// 将输入金额规范成两位小数,空值、非法值和超过两位小数返回 nil。 + static func normalizedMoney(_ rawValue: String) -> String? { + let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "") + guard !text.isEmpty else { return nil } + let pattern = #"^\d+(\.\d{1,2})?$"# + guard text.range(of: pattern, options: .regularExpression) != nil, + let value = Decimal(string: text), + value > 0 else { + return nil + } + return moneyText(value) + } + + private static func decimalValue(_ text: String) -> Decimal { + Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0 + } + + private static func moneyText(_ value: Decimal) -> String { + let number = NSDecimalNumber(decimal: value) + let handler = NSDecimalNumberHandler( + roundingMode: .plain, + scale: 2, + raiseOnExactness: false, + raiseOnOverflow: false, + raiseOnUnderflow: false, + raiseOnDivideByZero: false + ) + return String(format: "%.2f", number.rounding(accordingToBehavior: handler).doubleValue) + } +} + +@MainActor +@Observable +/// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。 +final class HistoricalShootingInfoViewModel { + private(set) var projectName = "" + private(set) var projectTypeName = "" + private(set) var spots: [PhotogSpotItem] = [] + private(set) var loading = false + var errorMessage: String? + + /// 加载历史拍摄信息,订单号为空时清空旧数据且不请求接口。 + func load(api: OrderServing, orderNumber: String) async { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + reset(message: "订单号不能为空") + return + } + + loading = true + defer { loading = false } + + do { + let result = try await api.multiTravelShootHistory(orderNumber: normalized) + projectName = result.projectName + projectTypeName = result.projectTypeName + spots = result.photogSpotList + errorMessage = nil + } catch { + reset(message: error.localizedDescription) + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func reset(message: String) { + projectName = "" + projectTypeName = "" + spots = [] + loading = false + errorMessage = message + } +} + +@MainActor +@Observable +/// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。 +final class MultiTravelTaskUploadViewModel { + var orderNumber = "" + var selectedSpotId: Int? + private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = [] + var selectedCloudFiles: [TaskCloudSelectionItem] = [] + var selectedLocalFiles: [TaskLocalUploadItem] = [] + private(set) var isLoadingSpots = false + private(set) var isSubmitting = false + private(set) var didSubmitSuccessfully = false + var errorMessage: String? + + private var loadedSpotOrderNumber = "" + + /// 当前选中打卡点展示名。 + var selectedSpotName: String { + spots.first(where: { $0.id == selectedSpotId })?.name ?? "请选择打卡点" + } + + /// 判断提交按钮是否满足基础启用条件。 + var canSubmit: Bool { + !isSubmitting && + selectedSpotId != nil && + (!selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty) && + !selectedLocalFiles.contains(where: \.isUploading) + } + + /// 初始化任务上传表单订单号。 + init(initialOrderNumber: String = "") { + orderNumber = initialOrderNumber + } + + /// 加载当前订单已核销打卡点,订单号为空时不请求接口。 + func loadSpots(api: OrderServing) async { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + resetSpotSelection() + return + } + + isLoadingSpots = true + defer { isLoadingSpots = false } + + do { + let result = try await api.multiTravelVerifiedScenicSpotList(orderNumber: normalized) + spots = result + loadedSpotOrderNumber = normalized + if !spots.contains(where: { $0.id == selectedSpotId }) { + selectedSpotId = spots.first?.id + } + errorMessage = nil + } catch { + resetSpotSelection() + errorMessage = error.localizedDescription + } + } + + /// 切换选中的打卡点。 + func selectSpot(id: Int?) { + selectedSpotId = id + } + + /// 合并云盘附件,按文件 ID 去重。 + func mergeCloudFiles(_ files: [TaskCloudSelectionItem]) { + var next = selectedCloudFiles + for file in files where !next.contains(where: { $0.id == file.id }) { + next.append(file) + } + selectedCloudFiles = next + } + + /// 移除指定云盘附件。 + func removeCloudFile(id: Int) { + selectedCloudFiles.removeAll { $0.id == id } + } + + /// 添加本地图片或视频附件。 + func addLocalFile(data: Data, fileName: String) { + selectedLocalFiles.append( + TaskLocalUploadItem( + id: UUID(), + data: data, + fileName: fileName, + fileType: Self.fileType(for: fileName), + remark: "", + uploadedURL: nil, + progress: 0, + errorMessage: nil + ) + ) + } + + /// 移除指定本地附件。 + func removeLocalFile(id: UUID) { + selectedLocalFiles.removeAll { $0.id == id } + } + + /// 提交多点旅拍任务素材;本地附件先上传 OSS,再提交最终 URL。 + @discardableResult + func submit(api: OrderServing, uploadService: any OSSUploadServing, scenicId: Int?) async -> Bool { + guard !isSubmitting else { return false } + guard let requestContext = validateBeforeSubmit(scenicId: scenicId) else { return false } + + isSubmitting = true + didSubmitSuccessfully = false + errorMessage = nil + defer { isSubmitting = false } + + do { + let uploadFiles = try await uploadLocalFiles(uploadService: uploadService, scenicId: requestContext.scenicId) + try await api.multiTravelUploadMaterial( + MultiTravelUploadMaterialRequest( + orderNumber: requestContext.orderNumber, + scenicSpotId: requestContext.scenicSpotId, + cloudFile: selectedCloudFiles.map { MultiTravelCloudFileItem(fileId: $0.id) }, + uploadFile: uploadFiles + ) + ) + didSubmitSuccessfully = true + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? { + let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard let scenicId, scenicId > 0 else { + errorMessage = "请先选择景区" + return nil + } + guard !normalizedOrderNumber.isEmpty else { + errorMessage = "请先填写订单号" + return nil + } + guard loadedSpotOrderNumber == normalizedOrderNumber else { + errorMessage = "请先刷新打卡点" + return nil + } + guard let selectedSpotId else { + errorMessage = "请选择打卡点" + return nil + } + guard !selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty else { + errorMessage = "请至少选择一个素材文件" + return nil + } + guard !selectedLocalFiles.contains(where: \.isUploading) else { + errorMessage = "文件上传中,请稍后提交" + return nil + } + return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId) + } + + private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] { + var uploadFiles: [MultiTravelUploadFileItem] = [] + for index in selectedLocalFiles.indices { + if let uploadedURL = selectedLocalFiles[index].uploadedURL { + uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: uploadedURL)) + continue + } + + selectedLocalFiles[index].errorMessage = nil + let fileID = selectedLocalFiles[index].id + do { + let url = try await uploadService.uploadTaskFile( + data: selectedLocalFiles[index].data, + fileName: selectedLocalFiles[index].fileName, + fileType: selectedLocalFiles[index].fileType, + scenicId: scenicId, + onProgress: { [weak self] progress in + Task { @MainActor in + self?.updateLocalFileProgress(id: fileID, progress: progress) + } + } + ) + selectedLocalFiles[index].uploadedURL = url + selectedLocalFiles[index].progress = 100 + uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: url)) + } catch { + selectedLocalFiles[index].progress = 0 + selectedLocalFiles[index].errorMessage = error.localizedDescription + throw error + } + } + return uploadFiles + } + + private func updateLocalFileProgress(id: UUID, progress: Int) { + guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return } + selectedLocalFiles[index].progress = progress + } + + private func resetSpotSelection() { + spots = [] + selectedSpotId = nil + loadedSpotOrderNumber = "" + isLoadingSpots = false + } + + private static func fileType(for fileName: String) -> Int { + let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() + return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2 + } + + private struct SubmitContext { + let scenicId: Int + let orderNumber: String + let scenicSpotId: Int + } +} diff --git a/suixinkan/Features/Orders/Views/DepositOrderViews.swift b/suixinkan/Features/Orders/Views/DepositOrderViews.swift new file mode 100644 index 0000000..2eb6263 --- /dev/null +++ b/suixinkan/Features/Orders/Views/DepositOrderViews.swift @@ -0,0 +1,596 @@ +// +// DepositOrderViews.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import SwiftUI + +/// 押金订单入口页,支持订单号查询、列表分页、核销和退款。 +struct DepositOrderEntryView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(OrdersAPI.self) private var ordersAPI + @Environment(RouterPath.self) private var router + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel = DepositOrderListViewModel() + @State private var orderNumber = "" + @State private var pendingWriteOff: DepositOrderListItem? + @State private var pendingRefund: DepositOrderListItem? + @State private var refundReason = "" + + var body: some View { + List { + Section("订单查询") { + TextField("请输入押金订单号", text: $orderNumber) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + + Button("查看订单详情") { + navigateToDetail(orderNumber) + } + .disabled(orderNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + Section { + if viewModel.orders.isEmpty { + ContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass") + .frame(maxWidth: .infinity, minHeight: 180) + } else { + ForEach(viewModel.orders) { item in + DepositOrderRow( + item: item, + isOperating: viewModel.operatingOrderNumber == item.orderNumber, + onDetail: { navigateToDetail(item.orderNumber) }, + onWriteOff: { pendingWriteOff = item }, + onRefund: { pendingRefund = item } + ) + } + + if viewModel.hasMore { + Button(viewModel.loadingMore ? "加载中..." : "加载更多") { + Task { await viewModel.reload(api: ordersAPI, scenicId: accountContext.currentScenic?.id, reset: false) } + } + .frame(maxWidth: .infinity, alignment: .center) + .disabled(viewModel.loadingMore) + } + } + } header: { + HStack { + Text("押金订单列表") + Spacer() + Text("\(viewModel.orders.count)/\(viewModel.total)") + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle("押金订单") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await reload(showLoading: false) } + .task { await reload(showLoading: true) } + .confirmationDialog("确认核销该押金订单?", isPresented: writeOffBinding, titleVisibility: .visible) { + Button("确认核销") { + guard let pendingWriteOff else { return } + Task { await writeOff(pendingWriteOff) } + } + Button("取消", role: .cancel) {} + } message: { + Text(pendingWriteOff?.orderNumber ?? "") + } + .alert("申请退款", isPresented: refundBinding) { + TextField("请输入退款原因", text: $refundReason) + Button("提交") { + guard let pendingRefund else { return } + Task { await refund(pendingRefund) } + } + Button("取消", role: .cancel) { + pendingRefund = nil + refundReason = "" + } + } message: { + Text("请填写退款原因后提交。") + } + .alert("提示", isPresented: errorBinding) { + Button("知道了", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private var writeOffBinding: Binding { + Binding( + get: { pendingWriteOff != nil }, + set: { if !$0 { pendingWriteOff = nil } } + ) + } + + private var refundBinding: Binding { + Binding( + get: { pendingRefund != nil }, + set: { if !$0 { pendingRefund = nil; refundReason = "" } } + ) + } + + private var errorBinding: Binding { + Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.clearError() } } + ) + } + + /// 跳转到押金订单详情页。 + private func navigateToDetail(_ rawOrderNumber: String) { + let text = rawOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + router.navigate(to: .orders(.depositDetail(orderNumber: text))) + } + + /// 刷新押金订单列表。 + private func reload(showLoading: Bool) async { + if showLoading { + await globalLoading.withLoading { + await viewModel.reload(api: ordersAPI, scenicId: accountContext.currentScenic?.id, reset: true) + } + } else { + await viewModel.reload(api: ordersAPI, scenicId: accountContext.currentScenic?.id, reset: true) + } + } + + /// 核销押金订单并提示结果。 + private func writeOff(_ item: DepositOrderListItem) async { + pendingWriteOff = nil + let success = await viewModel.writeOff(api: ordersAPI, scenicId: accountContext.currentScenic?.id, orderNumber: item.orderNumber) + if success { + toastCenter.show("核销成功") + } + } + + /// 提交押金订单退款并提示结果。 + private func refund(_ item: DepositOrderListItem) async { + let reason = refundReason + pendingRefund = nil + refundReason = "" + let success = await viewModel.refund(api: ordersAPI, scenicId: accountContext.currentScenic?.id, orderNumber: item.orderNumber, reason: reason) + if success { + toastCenter.show("退款申请已提交") + } + } +} + +/// 押金订单列表行,展示订单状态和操作按钮。 +private struct DepositOrderRow: View { + let item: DepositOrderListItem + let isOperating: Bool + let onDetail: () -> Void + let onWriteOff: () -> Void + let onRefund: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text(item.orderNumber.isEmpty ? "--" : item.orderNumber) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + Text(item.statusName.isEmpty ? "状态\(item.status)" : item.statusName) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(statusColor) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 24) + .background(statusColor.opacity(0.12), in: Capsule()) + } + + HStack { + Text("¥\(item.amount.isEmpty ? "0.00" : item.amount)") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + Spacer() + Text(item.createdAt.isEmpty ? "--" : item.createdAt) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + + if !item.userPhone.isEmpty { + Text("手机号:\(item.userPhone)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + + HStack(spacing: AppMetrics.Spacing.small) { + rowButton("详情", tint: AppDesign.primary, action: onDetail) + rowButton(isOperating ? "处理中..." : "核销", tint: Color(hex: 0x22C55E), disabled: isOperating, action: onWriteOff) + rowButton(isOperating ? "处理中..." : "退款", tint: Color(hex: 0xF59E0B), disabled: isOperating, action: onRefund) + } + } + .padding(.vertical, AppMetrics.Spacing.xSmall) + } + + private var statusColor: Color { + switch item.status { + case 20: + return AppDesign.primary + case 30: + return Color(hex: 0x22C55E) + case 50: + return Color(hex: 0xEF4444) + default: + return Color(hex: 0xF59E0B) + } + } + + /// 构建列表行操作按钮。 + private func rowButton(_ title: String, tint: Color, disabled: Bool = false, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(disabled ? AppDesign.textSecondary : tint) + .frame(maxWidth: .infinity) + .frame(height: 34) + .background((disabled ? Color(hex: 0xEEF2F7) : tint.opacity(0.12)), in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(disabled) + } +} + +/// 押金订单详情页,展示订单基础信息和拍摄点列表。 +struct DepositOrderDetailView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(OrdersAPI.self) private var ordersAPI + @Environment(RouterPath.self) private var router + @Environment(\.globalLoading) private var globalLoading + + let orderNumber: String + @State private var viewModel = DepositOrderDetailViewModel() + + var body: some View { + List { + if let detail = viewModel.detail { + Section("订单信息") { + OrderDetailValueRow(title: "订单号", value: detail.orderNumber) + OrderDetailValueRow(title: "订单状态", value: detail.orderStatusName) + OrderDetailValueRow(title: "订单类型", value: detail.orderTypeLabel) + OrderDetailValueRow(title: "项目名称", value: detail.projectName) + OrderDetailValueRow(title: "订单金额", value: "¥\(detail.actualPayAmount.isEmpty ? "0.00" : detail.actualPayAmount)") + OrderDetailValueRow(title: "退款金额", value: "¥\(detail.actualRefundAmount.isEmpty ? "0" : detail.actualRefundAmount)") + OrderDetailValueRow(title: "手机号", value: detail.phone) + OrderDetailValueRow(title: "下单时间", value: detail.createdAt) + OrderDetailValueRow(title: "付款时间", value: detail.payTime) + OrderDetailValueRow(title: "完成时间", value: detail.completeTime) + } + + if let projectInfo = detail.multiTravel?.projectInfo { + Section("项目配置") { + OrderDetailValueRow(title: "打卡点数", value: "\(projectInfo.settleSpotNum)") + OrderDetailValueRow(title: "单点底片", value: "\(projectInfo.singleSpotMaterialNum)") + OrderDetailValueRow(title: "单点精修", value: "\(projectInfo.singleSpotPhotoNum)") + OrderDetailValueRow(title: "单点视频", value: "\(projectInfo.singleSpotVideoNum)") + } + } + + Section("拍摄详情") { + let shootingList = detail.multiTravel?.shootingList ?? [] + if shootingList.isEmpty { + ContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack") + } else { + ForEach(shootingList) { item in + Button { + router.navigate( + to: .orders( + .depositShootingInfo( + orderNumber: detail.orderNumber, + scenicSpotId: item.scenicSpotId, + photogUid: item.photogUid + ) + ) + ) + } label: { + DepositShootingPointRow(item: item) + } + } + } + } + } else { + Section { + ContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass") + .frame(maxWidth: .infinity, minHeight: 220) + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle("押金订单详情") + .navigationBarTitleDisplayMode(.inline) + .task { + await globalLoading.withLoading { + await viewModel.load(api: ordersAPI, storeId: accountContext.currentStore?.id, orderNumber: orderNumber) + } + } + .alert("提示", isPresented: errorBinding) { + Button("知道了", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private var errorBinding: Binding { + Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.clearError() } } + ) + } +} + +/// 押金订单拍摄点行。 +private struct DepositShootingPointRow: View { + let item: StoreOrderShootingListItem + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text(item.photogName.isEmpty ? "摄影师未分配" : item.photogName) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + VStack(alignment: .trailing, spacing: AppMetrics.Spacing.xSmall) { + Text(item.status == 2 ? "已拍摄" : "待拍摄") + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(item.status == 2 ? Color(hex: 0x22C55E) : AppDesign.primary) + Text(String(format: "评分 %.1f", max(item.startAvg, item.start))) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Image(systemName: "chevron.right") + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.textSecondary) + } + } + + private var title: String { + let text = item.scenicSpotName.isEmpty ? item.staffName : item.scenicSpotName + return text.isEmpty ? "未命名打卡点" : text + } +} + +/// 押金拍摄信息页,展示评价、底片和成片。 +struct DepositOrderShootingInfoView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(OrdersAPI.self) private var ordersAPI + @Environment(\.globalLoading) private var globalLoading + + let orderNumber: String + let scenicSpotId: Int + let photogUid: Int + + @State private var viewModel = DepositOrderShootingInfoViewModel() + @State private var selectedTab: DepositShootingMediaTab = .negative + + var body: some View { + List { + if let detail = viewModel.detail { + Section("拍摄点") { + OrderDetailValueRow(title: "名称", value: detail.scenicSpotName) + OrderDetailValueRow(title: "订单类型", value: detail.orderTypeName) + } + + Section("评分与评价") { + if let comment = detail.orderComment { + RatingRow(title: "拍摄满意度", score: comment.starShooting) + RatingRow(title: "修图满意度", score: comment.starRetouching) + RatingRow(title: "景区满意度", score: comment.starScenery) + RatingRow(title: "服务满意度", score: comment.starService) + RatingRow(title: "相机满意度", score: comment.starCamera) + Text(comment.content.isEmpty ? "暂无评价内容" : comment.content) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } else { + ContentUnavailableView("暂无评价信息", systemImage: "star") + } + } + + Section { + Picker("媒体类型", selection: $selectedTab) { + Text("底片").tag(DepositShootingMediaTab.negative) + Text("成片").tag(DepositShootingMediaTab.complete) + } + .pickerStyle(.segmented) + + OrderMediaGrid(files: selectedTab == .negative ? detail.materialList : detail.completeList) + } header: { + Text("媒体文件") + } + } else { + Section { + ContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack") + .frame(maxWidth: .infinity, minHeight: 220) + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle("拍摄信息") + .navigationBarTitleDisplayMode(.inline) + .task { + await globalLoading.withLoading { + await viewModel.load( + api: ordersAPI, + storeId: accountContext.currentStore?.id, + orderNumber: orderNumber, + scenicSpotId: scenicSpotId, + photogUid: photogUid + ) + } + } + .alert("提示", isPresented: errorBinding) { + Button("知道了", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private var errorBinding: Binding { + Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.clearError() } } + ) + } +} + +/// 历史拍摄信息页,展示多点位拍摄媒体。 +struct HistoricalShootingInfoView: View { + @Environment(OrdersAPI.self) private var ordersAPI + @Environment(\.globalLoading) private var globalLoading + + let orderNumber: String + @State private var viewModel = HistoricalShootingInfoViewModel() + + var body: some View { + List { + Section("订单") { + OrderDetailValueRow(title: "订单号", value: orderNumber) + OrderDetailValueRow(title: "项目名称", value: viewModel.projectName) + OrderDetailValueRow(title: "项目类型", value: viewModel.projectTypeName) + } + + Section("历史拍摄") { + if viewModel.spots.isEmpty { + ContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle") + .frame(maxWidth: .infinity, minHeight: 220) + } else { + ForEach(viewModel.spots) { spot in + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Label(spot.scenicSpotName.isEmpty ? "未命名打卡点" : spot.scenicSpotName, systemImage: "mappin.and.ellipse") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + Spacer() + Text("摄影师:\(spot.photographerDisplayName)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + OrderMediaGrid(files: spot.files) + } + .padding(.vertical, AppMetrics.Spacing.xSmall) + } + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .navigationTitle("历史拍摄") + .navigationBarTitleDisplayMode(.inline) + .task { + await globalLoading.withLoading { + await viewModel.load(api: ordersAPI, orderNumber: orderNumber) + } + } + .alert("提示", isPresented: errorBinding) { + Button("知道了", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + private var errorBinding: Binding { + Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.clearError() } } + ) + } +} + +/// 拍摄媒体类型。 +private enum DepositShootingMediaTab: Hashable { + case negative + case complete +} + +/// 通用订单详情键值行。 +private struct OrderDetailValueRow: View { + let title: String + let value: String + + var body: some View { + HStack(alignment: .top) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + Spacer(minLength: AppMetrics.Spacing.medium) + Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "--" : value) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + .multilineTextAlignment(.trailing) + } + } +} + +/// 评分展示行。 +private struct RatingRow: View { + let title: String + let score: Int + + var body: some View { + HStack { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + Spacer() + Text(String(repeating: "★", count: max(score, 0))) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(Color(hex: 0xF59E0B)) + } + } +} + +/// 订单媒体网格,统一展示图片和视频缩略图。 +private struct OrderMediaGrid: View { + let files: [OrderMediaFile] + + private let columns = [ + GridItem(.flexible(), spacing: AppMetrics.Spacing.small), + GridItem(.flexible(), spacing: AppMetrics.Spacing.small), + GridItem(.flexible(), spacing: AppMetrics.Spacing.small) + ] + + var body: some View { + if files.isEmpty { + ContentUnavailableView("暂无媒体文件", systemImage: "photo") + .frame(maxWidth: .infinity, minHeight: 140) + } else { + LazyVGrid(columns: columns, spacing: AppMetrics.Spacing.small) { + ForEach(files) { file in + Link(destination: URL(string: file.fileUrl) ?? APIEnvironment.production.baseURL) { + ZStack(alignment: .bottomTrailing) { + RemoteImage(urlString: file.previewURLString) { + RoundedRectangle(cornerRadius: 8) + .fill(AppDesign.primarySoft) + } + .frame(height: 88) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + if file.isVideo { + Image(systemName: "play.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .shadow(radius: 2) + .padding(AppMetrics.Spacing.xSmall) + } + } + } + .buttonStyle(.plain) + } + } + } + } +} diff --git a/suixinkan/Features/Orders/Views/OrderDetailViews.swift b/suixinkan/Features/Orders/Views/OrderDetailViews.swift index ae942ad..25f5f4b 100644 --- a/suixinkan/Features/Orders/Views/OrderDetailViews.swift +++ b/suixinkan/Features/Orders/Views/OrderDetailViews.swift @@ -17,6 +17,8 @@ struct StoreOrderDetailView: View { let item: OrderEntity @State private var viewModel: OrderDetailViewModel + @State private var refundViewModel = OrderRefundViewModel() + @State private var showRefundSheet = false /// 使用列表订单初始化详情页,并创建详情 ViewModel。 init(item: OrderEntity) { @@ -96,10 +98,36 @@ struct StoreOrderDetailView: View { } Section("后续功能") { - placeholderButton("历史拍摄", title: "历史拍摄") - placeholderButton("任务上传", title: "任务上传") - placeholderButton("视频预告", title: "视频预告") - placeholderButton("退款", title: "订单退款") + Button { + router.navigate(to: .orders(.historicalShooting(orderNumber: viewModel.display.orderNumber))) + } label: { + navigationRow("历史拍摄") + } + if item.orderType == 19 { + Button { + router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: viewModel.display.orderNumber))) + } label: { + navigationRow("任务上传") + } + } + Button { + router.navigate(to: .orders(.orderTrailer(orderNumber: viewModel.display.orderNumber, title: "视频预告"))) + } label: { + navigationRow("视频预告") + } + Button { + router.navigate(to: .orders(.orderTrailer(orderNumber: viewModel.display.orderNumber, title: "尾片上传"))) + } label: { + navigationRow("尾片上传") + } + if refundViewModel.canRefund(item) { + Button { + refundViewModel.begin(item: item) + showRefundSheet = true + } label: { + navigationRow("退款") + } + } } } .listStyle(.insetGrouped) @@ -117,6 +145,17 @@ struct StoreOrderDetailView: View { } message: { Text(viewModel.errorMessage ?? "") } + .sheet(isPresented: $showRefundSheet) { + OrderRefundSheet( + item: item, + viewModel: refundViewModel, + onCancel: { showRefundSheet = false }, + onSubmit: { + Task { await submitRefund() } + } + ) + .presentationDetents([.medium, .large]) + } } private var displayPayTime: String { @@ -136,20 +175,24 @@ struct StoreOrderDetailView: View { ) } - /// 构建尚未迁移业务入口的占位按钮。 - private func placeholderButton(_ label: String, title: String) -> some View { - Button { - router.navigate(to: .placeholder(title: title)) - } label: { - HStack { - Text(label) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) - .foregroundStyle(AppDesign.textSecondary) - } + /// 构建可跳转业务入口行。 + private func navigationRow(_ label: String) -> some View { + HStack { + Text(label) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.textSecondary) } - .foregroundStyle(AppDesign.textPrimary) + } + + /// 提交普通订单退款并刷新详情。 + private func submitRefund() async { + let success = await refundViewModel.submit(api: ordersAPI, item: item) + guard success else { return } + showRefundSheet = false + toastCenter.show("退款申请已提交") + await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id, forceReload: true) } /// 将空金额转换为 0,避免详情页出现孤立货币符号。 @@ -158,6 +201,79 @@ struct StoreOrderDetailView: View { } } +/// 普通订单退款表单,负责收集退款模式、金额和原因。 +private struct OrderRefundSheet: View { + let item: OrderEntity + @Bindable var viewModel: OrderRefundViewModel + let onCancel: () -> Void + let onSubmit: () -> Void + @FocusState private var focusedField: RefundInputField? + + var body: some View { + NavigationStack { + Form { + Section("订单") { + OrderDetailRow(title: "订单号", value: item.orderNumber) + OrderDetailRow(title: "可退金额", value: "¥\(viewModel.availableAmountText(for: item))") + } + + Section("退款方式") { + Picker("退款方式", selection: $viewModel.mode) { + ForEach(OrderRefundMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + if viewModel.mode == .partial { + TextField("请输入退款金额", text: $viewModel.amount) + .keyboardType(.decimalPad) + .focused($focusedField, equals: .amount) + } + } + + Section("退款原因") { + TextEditor(text: $viewModel.reason) + .frame(minHeight: 96) + .focused($focusedField, equals: .reason) + } + + if let errorMessage = viewModel.errorMessage { + Section { + Text(errorMessage) + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(Color(hex: 0xEF4444)) + } + } + } + .navigationTitle("订单退款") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消", action: onCancel) + .disabled(viewModel.submitting) + } + ToolbarItem(placement: .confirmationAction) { + Button(viewModel.submitting ? "提交中..." : "提交", action: onSubmit) + .disabled(viewModel.submitting) + } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("完成") { + focusedField = nil + } + } + } + } + } +} + +/// 退款表单当前聚焦字段。 +private enum RefundInputField: Hashable { + case amount + case reason +} + /// 核销订单详情页,展示核销列表项信息并支持再次发起核销。 struct WriteOffOrderDetailView: View { @Environment(AccountContext.self) private var accountContext diff --git a/suixinkan/Features/Orders/Views/OrderTailUploadViews.swift b/suixinkan/Features/Orders/Views/OrderTailUploadViews.swift new file mode 100644 index 0000000..7993566 --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrderTailUploadViews.swift @@ -0,0 +1,357 @@ +// +// OrderTailUploadViews.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers + +/// 多点旅拍任务上传页面,负责选择打卡点和素材后提交到订单接口。 +struct MultiTravelTaskUploadView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(OrdersAPI.self) private var ordersAPI + @Environment(OSSUploadService.self) private var uploadService + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.dismiss) private var dismiss + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel: MultiTravelTaskUploadViewModel + @State private var showSpotPicker = false + @State private var showCloudSelection = false + @State private var pickerItems: [PhotosPickerItem] = [] + + /// 使用订单号初始化任务上传页面。 + init(initialOrderNumber: String) { + _viewModel = State(initialValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)) + } + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + orderSection + cloudFileSection + localFileSection + submitButton + } + .padding(AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("任务上传") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadSpots(api: ordersAPI) + } + .sheet(isPresented: $showCloudSelection) { + NavigationStack { + TaskCloudFileSelectionView { items in + viewModel.mergeCloudFiles(items) + showCloudSelection = false + } + } + } + .confirmationDialog("选择打卡点", isPresented: $showSpotPicker, titleVisibility: .visible) { + ForEach(viewModel.spots) { spot in + Button(spot.name.isEmpty ? "打卡点 \(spot.id)" : spot.name) { + viewModel.selectSpot(id: spot.id) + } + } + Button("取消", role: .cancel) {} + } + .onChange(of: pickerItems) { _, items in + Task { await importPickerItems(items) } + } + .alert("提示", isPresented: errorBinding) { + Button("知道了", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + /// 订单号和打卡点选择区域。 + private var orderSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("基础信息") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + + TextField("关联订单号", text: $viewModel.orderNumber) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + + HStack(spacing: AppMetrics.Spacing.small) { + Button(viewModel.isLoadingSpots ? "加载中..." : "刷新打卡点") { + Task { await viewModel.loadSpots(api: ordersAPI) } + } + .disabled(viewModel.isLoadingSpots) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(width: 104, height: 40) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + + Button { + showSpotPicker = true + } label: { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Text(viewModel.selectedSpotName) + .lineLimit(1) + Spacer() + Image(systemName: "chevron.down") + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(maxWidth: .infinity) + .frame(height: 40) + .background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + } + .buttonStyle(.plain) + .disabled(viewModel.spots.isEmpty) + } + + if viewModel.spots.isEmpty && !viewModel.isLoadingSpots { + Text("暂无可选打卡点,请确认订单已完成核销。") + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textSecondary) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 云盘素材选择区域。 + private var cloudFileSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text("云盘素材") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + Button("选择云盘文件") { + showCloudSelection = true + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + } + + if viewModel.selectedCloudFiles.isEmpty { + Text("未选择云盘文件") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } else { + ForEach(viewModel.selectedCloudFiles) { file in + OrderTaskAttachmentRow( + title: file.fileName.isEmpty ? "文件 \(file.id)" : file.fileName, + subtitle: "云盘文件" + ) { + viewModel.removeCloudFile(id: file.id) + } + } + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 本地素材选择区域。 + private var localFileSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text("本地素材") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + PhotosPicker(selection: $pickerItems, maxSelectionCount: 9, matching: .any(of: [.images, .videos])) { + Text("选择图片/视频") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + } + } + + if viewModel.selectedLocalFiles.isEmpty { + Text("未选择本地文件") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } else { + ForEach(viewModel.selectedLocalFiles) { file in + OrderTaskAttachmentRow(title: file.fileName, subtitle: file.statusText) { + viewModel.removeLocalFile(id: file.id) + } + } + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 提交按钮。 + private var submitButton: some View { + Button { + Task { await submit() } + } label: { + Text(viewModel.isSubmitting ? "保存中..." : "保存任务素材") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + .background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + } + .disabled(!viewModel.canSubmit) + } + + private var errorBinding: Binding { + Binding( + get: { viewModel.errorMessage != nil }, + set: { visible in + if !visible { viewModel.clearError() } + } + ) + } + + /// 导入本地图片或视频。 + private func importPickerItems(_ items: [PhotosPickerItem]) async { + for item in items { + do { + guard let data = try await item.loadTransferable(type: Data.self) else { continue } + viewModel.addLocalFile(data: data, fileName: Self.fileName(for: item)) + } catch { + toastCenter.show(error.localizedDescription) + } + } + pickerItems = [] + } + + /// 提交任务素材。 + private func submit() async { + let success = await globalLoading.withLoading { + await viewModel.submit(api: ordersAPI, uploadService: uploadService, scenicId: accountContext.currentScenic?.id) + } + if success { + toastCenter.show("任务素材已保存") + dismiss() + } else if let message = viewModel.errorMessage { + toastCenter.show(message) + } + } + + /// 根据 PhotosPicker 类型生成本地文件名。 + private static func fileName(for item: PhotosPickerItem) -> String { + let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) || $0.conforms(to: .video) } + return "\(UUID().uuidString).\(isVideo ? "mp4" : "jpg")" + } +} + +/// 订单尾片入口页面,复用相册预览上传能力承接视频预告和尾片上传。 +struct OrderTrailerView: View { + @State private var showAlbumTrailer = false + + let orderNumber: String + let title: String + + var body: some View { + VStack(spacing: AppMetrics.Spacing.medium) { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text(title) + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text("订单号:\(orderNumber.isEmpty ? "--" : orderNumber)") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + Text("用于完成订单尾片素材的最终核对与提交,按“选择相册 -> 预览文件 -> 确认上传”流程操作。") + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("提交流程") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + trailerStep("1. 进入相册预览上传并选择目标相册") + trailerStep("2. 核对素材内容与顺序后确认上传") + trailerStep("3. 上传完成后返回订单页继续处理") + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + + Button("打开相册预览上传") { + showAlbumTrailer = true + } + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + + Spacer() + } + .padding(AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showAlbumTrailer) { + NavigationStack { + AlbumTrailerEntryView() + } + } + } + + /// 构建尾片流程步骤行。 + private func trailerStep(_ text: String) -> some View { + HStack(alignment: .top, spacing: AppMetrics.Spacing.small) { + Circle() + .fill(AppDesign.primary) + .frame(width: 6, height: 6) + .padding(.top, 6) + Text(text) + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textSecondary) + } + } +} + +/// 订单任务素材附件行。 +private struct OrderTaskAttachmentRow: View { + let title: String + let subtitle: String + let onDelete: () -> Void + + var body: some View { + HStack(spacing: AppMetrics.Spacing.small) { + Image(systemName: "paperclip") + .foregroundStyle(AppDesign.primary) + .frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea) + .background(AppDesign.primarySoft, in: Circle()) + + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + Text(subtitle) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + + Spacer() + + Button(action: onDelete) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(AppDesign.placeholder) + .frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea) + } + .accessibilityLabel("删除附件") + } + .padding(AppMetrics.Spacing.small) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + } +} diff --git a/suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift b/suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift new file mode 100644 index 0000000..995751f --- /dev/null +++ b/suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift @@ -0,0 +1,201 @@ +// +// ScenicQueueAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。 +@MainActor +protocol ScenicQueueServing: AnyObject { + func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData + func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData + func socketToken() async throws -> SocketTokenResponse + func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData + func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData + func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData + func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws + func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws + func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData + func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws + func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData + func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData +} + +@MainActor +@Observable +/// 排队管理 API,封装 `/api/app/scenic-queue` 下的排队接口。 +final class ScenicQueueAPI { + @ObservationIgnored private let client: APIClient + + /// 初始化排队管理 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取当前打卡点排队统计。 + func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData { + do { + return try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-queue/queue-stats", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)) + ] + ) + ) + } catch APIError.emptyData { + return ScenicQueueStatsData() + } + } + + /// 获取当前打卡点排队列表。 + func scenicQueueHome( + scenicId: Int, + scenicSpotId: Int, + type: Int, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> ScenicQueueHomeData { + do { + return try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-queue/home", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)), + URLQueryItem(name: "type", value: String(type)), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))) + ] + ) + ) + } catch APIError.emptyData { + return ScenicQueueHomeData() + } + } + + /// 获取 WebSocket 订阅 token。 + func socketToken() async throws -> SocketTokenResponse { + try await client.send(APIRequest(method: .get, path: "/api/app/socket-token")) + } + + /// 叫号。 + func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData { + do { + return try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/call", body: ScenicQueueActionRequest(id: id)) + ) + } catch APIError.emptyData { + return ScenicQueueCallData(id: id) + } + } + + /// 标记过号。 + func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData { + do { + return try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/pass", body: ScenicQueueActionRequest(id: id)) + ) + } catch APIError.emptyData { + return ScenicQueuePassData(id: id) + } + } + + /// 标记完成。 + func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData { + do { + return try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/finish", body: ScenicQueueActionRequest(id: id)) + ) + } catch APIError.emptyData { + return ScenicQueueFinishData(id: id) + } + } + + /// 将过号记录重新插回当前排队队列。 + func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/scenic-queue/requeue-insert-before", + body: ScenicQueueRequeueInsertBeforeRequest(recordId: recordId, operatorId: operatorId) + ) + ) + } + + /// 标记用户身份/限制排队。 + func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/user-mark", body: request) + ) + } + + /// 获取排队设置。 + func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { + var query = [URLQueryItem(name: "scenic_id", value: String(scenicId))] + if let scenicSpotId { + query.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))) + } + do { + return try await client.send( + APIRequest(method: .get, path: "/api/app/scenic-queue/setting", queryItems: query) + ) + } catch APIError.emptyData { + return ScenicQueueSettingData() + } + } + + /// 保存排队设置。 + func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/save-setting", body: request) + ) + } + + /// 获取取号二维码。 + func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-queue/shoot-queue-qrcode", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)) + ] + ) + ) + } + + /// 获取排队设置变更日志。 + func scenicQueueSettingChangeLog( + scenicId: Int, + scenicSpotId: Int?, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> ScenicQueueSettingChangeLogData { + var query = [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))) + ] + if let scenicSpotId { + query.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))) + } + do { + return try await client.send( + APIRequest(method: .get, path: "/api/app/scenic-queue/setting-change-log", queryItems: query) + ) + } catch APIError.emptyData { + return ScenicQueueSettingChangeLogData() + } + } +} + +extension ScenicQueueAPI: ScenicQueueServing {} diff --git a/suixinkan/Features/QueueManagement/Models/ScenicQueueModels.swift b/suixinkan/Features/QueueManagement/Models/ScenicQueueModels.swift new file mode 100644 index 0000000..0da1016 --- /dev/null +++ b/suixinkan/Features/QueueManagement/Models/ScenicQueueModels.swift @@ -0,0 +1,491 @@ +// +// ScenicQueueModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队统计响应。 +struct ScenicQueueStatsData: Decodable, Equatable { + let queueCount: Int + let avgWaitMin: Double + let time: String + + enum CodingKeys: String, CodingKey { + case queueCount = "queue_count" + case avgWaitMin = "avg_wait_min" + case time + } + + /// 创建排队统计,主要用于空响应兜底。 + init(queueCount: Int = 0, avgWaitMin: Double = 0, time: String = "") { + self.queueCount = queueCount + self.avgWaitMin = avgWaitMin + self.time = time + } + + /// 宽松解码统计字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + queueCount = try container.decodeLossyInt(forKey: .queueCount) ?? 0 + avgWaitMin = try container.decodeLossyDouble(forKey: .avgWaitMin) ?? 0 + time = try container.decodeLossyString(forKey: .time) + } +} + +/// 排队首页响应,兼容 list 为数组或分页对象两种形态。 +struct ScenicQueueHomeData: Decodable, Equatable { + let stats: ScenicQueueHomeStats? + let list: ScenicQueueHomeListBlock? + let time: String? + + enum CodingKeys: String, CodingKey { + case stats + case list + case time + } + + /// 创建排队首页数据,主要用于空响应兜底。 + init(stats: ScenicQueueHomeStats? = nil, list: ScenicQueueHomeListBlock? = nil, time: String? = nil) { + self.stats = stats + self.list = list + self.time = time + } + + /// 宽松解码排队首页数据。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + stats = try container.decodeIfPresent(ScenicQueueHomeStats.self, forKey: .stats) + if let block = try? container.decodeIfPresent(ScenicQueueHomeListBlock.self, forKey: .list) { + list = block + } else if let tickets = try? container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) { + list = ScenicQueueHomeListBlock(list: tickets) + } else { + list = nil + } + time = try container.decodeIfPresent(String.self, forKey: .time) + } +} + +/// 排队首页统计块。 +struct ScenicQueueHomeStats: Decodable, Equatable { + let type: Int + + enum CodingKeys: String, CodingKey { + case type + } + + /// 创建统计块。 + init(type: Int = 0) { + self.type = type + } + + /// 宽松解码统计块。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decodeLossyInt(forKey: .type) ?? 0 + } +} + +/// 排队列表分页块。 +struct ScenicQueueHomeListBlock: Decodable, Equatable { + let list: [ScenicQueueTicket] + let total: Int + let page: Int + let pageSize: Int + + enum CodingKeys: String, CodingKey { + case list + case total + case page + case pageSize = "page_size" + } + + /// 创建分页块。 + init(list: [ScenicQueueTicket] = [], total: Int? = nil, page: Int = 1, pageSize: Int = 20) { + self.list = list + self.total = total ?? list.count + self.page = page + self.pageSize = pageSize + } + + /// 宽松解码分页块。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + list = try container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) ?? [] + total = try container.decodeLossyInt(forKey: .total) ?? list.count + page = try container.decodeLossyInt(forKey: .page) ?? 1 + pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? max(list.count, 20) + } +} + +/// 单个排队取号记录。 +struct ScenicQueueTicket: Decodable, Equatable, Identifiable { + let id: Int64 + let queueCode: String + let mobile: String + let status: Int + let statusText: String + let waitMin: Int + let aheadCount: Int + let isCalled: Int + let createdAt: String + let calledAt: String + let expiredAt: String + let finishedAt: String + let queueBanLabel: String + let identityTag: String + let queueTime: String + let queueCountToday: Int + let uid: Int64 + let markAsPhotog: Int + let markAsFreelancePhotog: Int + let isMissRequeue: Int + let missRequeueText: String + + var phoneMasked: String { + let digits = mobile.filter(\.isNumber) + if mobile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "--" } + guard digits.count >= 7 else { return mobile } + return "\(digits.prefix(3))****\(digits.suffix(4))" + } + + var dialPhoneDigits: String { + let digits = mobile.filter(\.isNumber) + return digits.isEmpty ? mobile.filter { !$0.isWhitespace } : digits + } + + var queueTimeDisplay: String { + Self.formatQueueTime(queueTime.nonEmptyOrDefault(createdAt)) + } + + var skippedTimeDisplay: String { + Self.formatQueueTime(expiredAt.nonEmptyOrDefault(calledAt.nonEmptyOrDefault(createdAt))) + } + + var shouldShowIdentityTag: Bool { + let text = identityTag.trimmingCharacters(in: .whitespacesAndNewlines) + return !text.isEmpty && text != "普通用户" + } + + var isMissRequeueRecord: Bool { + isMissRequeue == 1 + } + + enum CodingKeys: String, CodingKey { + case id + case queueCode = "queue_code" + case mobile + case status + case statusText = "status_text" + case waitMin = "wait_min" + case aheadCount = "ahead_count" + case isCalled = "is_called" + case createdAt = "created_at" + case calledAt = "called_at" + case expiredAt = "expired_at" + case finishedAt = "finished_at" + case queueBanLabel = "queue_ban_label" + case identityTag = "identity_tag" + case queueTime = "queue_time" + case queueCountToday = "queue_count_today" + case uid + case markAsPhotog = "mark_as_photog" + case markAsFreelancePhotog = "mark_as_freelance_photog" + case isMissRequeue = "is_miss_requeue" + case missRequeueText = "is_miss_requeue_text" + } + + enum AlternateCodingKeys: String, CodingKey { + case queueNo = "queue_no" + case queueNumber = "queue_number" + case code + case phone + case statusName = "status_name" + } + + /// 创建取号记录,主要用于本地状态替换和测试。 + init( + id: Int64, + queueCode: String, + mobile: String, + status: Int, + statusText: String, + waitMin: Int, + aheadCount: Int, + isCalled: Int, + createdAt: String, + calledAt: String, + expiredAt: String, + finishedAt: String, + queueBanLabel: String = "", + identityTag: String = "", + queueTime: String = "", + queueCountToday: Int = 0, + uid: Int64 = 0, + markAsPhotog: Int = 0, + markAsFreelancePhotog: Int = 0, + isMissRequeue: Int = 0, + missRequeueText: String = "" + ) { + self.id = id + self.queueCode = queueCode + self.mobile = mobile + self.status = status + self.statusText = statusText + self.waitMin = waitMin + self.aheadCount = aheadCount + self.isCalled = isCalled + self.createdAt = createdAt + self.calledAt = calledAt + self.expiredAt = expiredAt + self.finishedAt = finishedAt + self.queueBanLabel = queueBanLabel + self.identityTag = identityTag + self.queueTime = queueTime + self.queueCountToday = queueCountToday + self.uid = uid + self.markAsPhotog = markAsPhotog + self.markAsFreelancePhotog = markAsFreelancePhotog + self.isMissRequeue = isMissRequeue + self.missRequeueText = missRequeueText + } + + /// 宽松解码取号记录,兼容 Android 历史字段别名。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let alternate = try decoder.container(keyedBy: AlternateCodingKeys.self) + id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) + queueCode = try container.decodeLossyString(forKey: .queueCode) + .nonEmptyOrDefault( + try alternate.decodeLossyString(forKey: .queueNo) + .nonEmptyOrDefault( + try alternate.decodeLossyString(forKey: .queueNumber) + .nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .code)) + ) + ) + mobile = try container.decodeLossyString(forKey: .mobile) + .nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .phone)) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusText = try container.decodeLossyString(forKey: .statusText) + .nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .statusName)) + waitMin = try container.decodeLossyInt(forKey: .waitMin) ?? 0 + aheadCount = try container.decodeLossyInt(forKey: .aheadCount) ?? 0 + isCalled = try container.decodeLossyInt(forKey: .isCalled) ?? 0 + createdAt = try container.decodeLossyString(forKey: .createdAt) + calledAt = try container.decodeLossyString(forKey: .calledAt) + expiredAt = try container.decodeLossyString(forKey: .expiredAt) + finishedAt = try container.decodeLossyString(forKey: .finishedAt) + queueBanLabel = try container.decodeLossyString(forKey: .queueBanLabel).trimmingCharacters(in: .whitespacesAndNewlines) + identityTag = try container.decodeLossyString(forKey: .identityTag).trimmingCharacters(in: .whitespacesAndNewlines) + queueTime = try container.decodeLossyString(forKey: .queueTime) + queueCountToday = max(try container.decodeLossyInt(forKey: .queueCountToday) ?? 0, 0) + uid = Int64(try container.decodeLossyInt(forKey: .uid) ?? 0) + markAsPhotog = try container.decodeLossyInt(forKey: .markAsPhotog) ?? 0 + markAsFreelancePhotog = try container.decodeLossyInt(forKey: .markAsFreelancePhotog) ?? 0 + isMissRequeue = try container.decodeLossyInt(forKey: .isMissRequeue) ?? 0 + 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 "--" } + if text.range(of: #"^\d{2}-\d{2}\s+\d{2}:\d{2}$"#, options: .regularExpression) != nil { + return text + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] { + formatter.dateFormat = format + if let date = formatter.date(from: text) { + formatter.dateFormat = "MM-dd HH:mm" + return formatter.string(from: date) + } + } + return text + } +} + +/// 排队动作请求体。 +struct ScenicQueueActionRequest: Encodable { + let id: Int64 +} + +/// socket token 响应。 +struct SocketTokenResponse: Decodable, Equatable { + let socketToken: String + + enum CodingKeys: String, CodingKey { + case socketToken = "socket_token" + } + + /// 创建 socket token 响应。 + init(socketToken: String = "") { + self.socketToken = socketToken + } + + /// 宽松解码 socket token。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + socketToken = try container.decodeLossyString(forKey: .socketToken) + } +} + +/// 排队动作响应。 +struct ScenicQueueActionData: Decodable, Equatable { + let id: Int64 + let status: Int + let statusText: String + let calledAt: String? + + enum CodingKeys: String, CodingKey { + case id + case status + case statusText = "status_text" + case calledAt = "called_at" + } + + /// 创建动作响应。 + init(id: Int64 = 0, status: Int = 0, statusText: String = "", calledAt: String? = nil) { + self.id = id + self.status = status + self.statusText = statusText + self.calledAt = calledAt + } + + /// 宽松解码动作响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusText = try container.decodeLossyString(forKey: .statusText) + calledAt = try container.decodeIfPresent(String.self, forKey: .calledAt) + } +} + +typealias ScenicQueueCallData = ScenicQueueActionData +typealias ScenicQueuePassData = ScenicQueueActionData +typealias ScenicQueueFinishData = ScenicQueueActionData +typealias QueueItem = ScenicQueueTicket + +/// 重新排队请求体。 +struct ScenicQueueRequeueInsertBeforeRequest: Encodable { + let recordId: Int64 + let operatorId: Int + + enum CodingKeys: String, CodingKey { + case recordId = "record_id" + case operatorId = "operator_id" + } +} + +/// 用户标记请求体。 +struct ScenicQueueUserMarkRequest: Encodable, Equatable { + let uid: Int64 + let scenicId: Int64 + let markAsFreelancePhotog: Int + let queueBanDays: Int? + let operatorId: Int? + + enum CodingKeys: String, CodingKey { + case uid + case scenicId = "scenic_id" + case markAsFreelancePhotog = "mark_as_freelance_photog" + case queueBanDays = "queue_ban_days" + case operatorId = "operator_id" + } +} + +/// 排队列表类型。 +enum QueueListType: Int, CaseIterable, Identifiable { + case queueing = 1 + case passed = 2 + + var id: Int { rawValue } + + /// 分段标题。 + var title: String { + switch self { + case .queueing: + return "当前排队" + case .passed: + return "过号列表" + } + } + + /// 空态文案。 + var emptyText: String { + switch self { + case .queueing: + return "暂无排队" + case .passed: + return "暂无过号" + } + } +} + +/// 远端叫号提示。 +struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable { + let id: Int64 + let queueCode: String +} + +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) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "1" : "0" + } + 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) + } + if let value = try? decodeIfPresent(String.self, forKey: key), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return Int(text) ?? Int(Double(text) ?? 0) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? 1 : 0 + } + return nil + } + + func decodeLossyDouble(forKey key: Key) throws -> Double? { + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return Double(text) + } + return nil + } +} + +private extension String { + func nonEmptyOrDefault(_ fallback: String) -> String { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? fallback : text + } +} diff --git a/suixinkan/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift b/suixinkan/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift new file mode 100644 index 0000000..2bfac30 --- /dev/null +++ b/suixinkan/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift @@ -0,0 +1,368 @@ +// +// ScenicQueueSettingsModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队设置响应。 +struct ScenicQueueSettingData: Decodable, Equatable { + let exists: Bool + let setting: ScenicQueueSettingItem? + let time: String? + + enum CodingKeys: String, CodingKey { + case exists + case setting + case time + } + + /// 创建排队设置响应。 + init(exists: Bool = false, setting: ScenicQueueSettingItem? = nil, time: String? = nil) { + self.exists = exists + self.setting = setting + self.time = time + } + + /// 宽松解码排队设置响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + exists = try container.decodeLossyBool(forKey: .exists) ?? false + setting = try container.decodeIfPresent(ScenicQueueSettingItem.self, forKey: .setting) + time = try container.decodeLossyString(forKey: .time) + } +} + +/// 排队设置实体。 +struct ScenicQueueSettingItem: Decodable, Equatable { + let id: Int64? + let scenicId: Int? + let scenicSpotId: Int? + let scenicSpotName: String + let photoEstimateMin: Int + let photoEstimateSec: Int + let firstNoticeThresholdPos: Int + let firstNoticeSmsEnabled: Int + let firstNoticeCallEnabled: Int + let secondNoticeThresholdPos: Int + let secondNoticeSmsEnabled: Int + let secondNoticeCallEnabled: Int + let countdownBroadcastIntervalSec: Int + let countdownReadableThresholdSec: Int + let queueDistanceMeter: Int + let queueTakeLimit: Int + let missCallRequeueOffset: Int + let showStartShootButton: Int? + let autoCallNextCount: Int? + let businessStartTime: String + let businessEndTime: String + let status: Int + let remark: String + let voiceBroadcasts: [ScenicQueueVoiceBroadcastItem] + let createdAt: String + let updatedAt: String + + enum CodingKeys: String, CodingKey { + case id + case scenicId = "scenic_id" + case scenicSpotId = "scenic_spot_id" + case scenicSpotName = "scenic_spot_name" + case photoEstimateMin = "photo_estimate_min" + case photoEstimateSec = "photo_estimate_sec" + case firstNoticeThresholdPos = "first_notice_threshold_pos" + case firstNoticeSmsEnabled = "first_notice_sms_enabled" + case firstNoticeCallEnabled = "first_notice_call_enabled" + case secondNoticeThresholdPos = "second_notice_threshold_pos" + case secondNoticeSmsEnabled = "second_notice_sms_enabled" + case secondNoticeCallEnabled = "second_notice_call_enabled" + case countdownBroadcastIntervalSec = "countdown_broadcast_interval_sec" + case countdownReadableThresholdSec = "countdown_readable_threshold_sec" + case queueDistanceMeter = "queue_distance_meter" + case queueTakeLimit = "queue_take_limit" + case missCallRequeueOffset = "miss_call_requeue_offset" + case showStartShootButton = "show_start_shoot_button" + case autoCallNextCount = "auto_call_next_count" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + case status + case remark + case voiceBroadcasts = "voice_broadcasts" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + /// 宽松解码排队设置实体。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try container.decodeLossyInt(forKey: .id)).map(Int64.init) + scenicId = try container.decodeLossyInt(forKey: .scenicId) + scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + photoEstimateMin = try container.decodeLossyInt(forKey: .photoEstimateMin) ?? 0 + photoEstimateSec = try container.decodeLossyInt(forKey: .photoEstimateSec) ?? 0 + firstNoticeThresholdPos = try container.decodeLossyInt(forKey: .firstNoticeThresholdPos) ?? 0 + firstNoticeSmsEnabled = try container.decodeLossyInt(forKey: .firstNoticeSmsEnabled) ?? 0 + firstNoticeCallEnabled = try container.decodeLossyInt(forKey: .firstNoticeCallEnabled) ?? 0 + secondNoticeThresholdPos = try container.decodeLossyInt(forKey: .secondNoticeThresholdPos) ?? 0 + secondNoticeSmsEnabled = try container.decodeLossyInt(forKey: .secondNoticeSmsEnabled) ?? 0 + secondNoticeCallEnabled = try container.decodeLossyInt(forKey: .secondNoticeCallEnabled) ?? 0 + countdownBroadcastIntervalSec = try container.decodeLossyInt(forKey: .countdownBroadcastIntervalSec) ?? 50 + countdownReadableThresholdSec = try container.decodeLossyInt(forKey: .countdownReadableThresholdSec) ?? 15 + queueDistanceMeter = try container.decodeLossyInt(forKey: .queueDistanceMeter) ?? 0 + queueTakeLimit = try container.decodeLossyInt(forKey: .queueTakeLimit) ?? 0 + missCallRequeueOffset = try container.decodeLossyInt(forKey: .missCallRequeueOffset) ?? 1 + showStartShootButton = try container.decodeLossyInt(forKey: .showStartShootButton) + autoCallNextCount = try container.decodeLossyInt(forKey: .autoCallNextCount) + businessStartTime = try container.decodeLossyString(forKey: .businessStartTime) + businessEndTime = try container.decodeLossyString(forKey: .businessEndTime) + status = try container.decodeLossyInt(forKey: .status) ?? 1 + remark = try container.decodeLossyString(forKey: .remark) + voiceBroadcasts = (try? container.decodeIfPresent([ScenicQueueVoiceBroadcastItem].self, forKey: .voiceBroadcasts)) ?? [] + createdAt = try container.decodeLossyString(forKey: .createdAt) + updatedAt = try container.decodeLossyString(forKey: .updatedAt) + } +} + +/// 排队语音播报预设。 +struct ScenicQueueVoiceBroadcastItem: Codable, Identifiable, Equatable { + var id: String { "\(sortOrder)-\(content)" } + let content: String + let sortOrder: Int + + enum CodingKeys: String, CodingKey { + case content + case sortOrder = "sort_order" + } + + /// 创建语音播报预设。 + init(content: String, sortOrder: Int) { + self.content = content + self.sortOrder = sortOrder + } + + /// 宽松解码语音播报预设。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + content = try container.decodeLossyString(forKey: .content).trimmingCharacters(in: .whitespacesAndNewlines) + sortOrder = try container.decodeLossyInt(forKey: .sortOrder) ?? 0 + } +} + +/// 保存排队设置请求体。 +struct ScenicQueueSaveSettingRequest: Encodable, Equatable { + let scenicId: Int + let scenicSpotId: Int + let photoEstimateMin: Int + let photoEstimateSec: Int + let firstNoticeThresholdPos: Int + let firstNoticeSmsEnabled: Int + let firstNoticeCallEnabled: Int + let secondNoticeThresholdPos: Int + let secondNoticeSmsEnabled: Int + let secondNoticeCallEnabled: Int + let countdownBroadcastIntervalSec: Int + let countdownReadableThresholdSec: Int + let queueDistanceMeter: Int? + let queueTakeLimit: Int? + let missCallRequeueOffset: Int? + let showStartShootButton: Int? + let autoCallNextCount: Int? + let businessStartTime: String + let businessEndTime: String + let voiceBroadcasts: [ScenicQueueVoiceBroadcastItem] + let status: Int + let remark: String? + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case scenicSpotId = "scenic_spot_id" + case photoEstimateMin = "photo_estimate_min" + case photoEstimateSec = "photo_estimate_sec" + case firstNoticeThresholdPos = "first_notice_threshold_pos" + case firstNoticeSmsEnabled = "first_notice_sms_enabled" + case firstNoticeCallEnabled = "first_notice_call_enabled" + case secondNoticeThresholdPos = "second_notice_threshold_pos" + case secondNoticeSmsEnabled = "second_notice_sms_enabled" + case secondNoticeCallEnabled = "second_notice_call_enabled" + case countdownBroadcastIntervalSec = "countdown_broadcast_interval_sec" + case countdownReadableThresholdSec = "countdown_readable_threshold_sec" + case queueDistanceMeter = "queue_distance_meter" + case queueTakeLimit = "queue_take_limit" + case missCallRequeueOffset = "miss_call_requeue_offset" + case showStartShootButton = "show_start_shoot_button" + case autoCallNextCount = "auto_call_next_count" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + case voiceBroadcasts = "voice_broadcasts" + case status + case remark + } +} + +/// 排队设置变更日志响应。 +struct ScenicQueueSettingChangeLogData: Decodable, Equatable { + let total: Int + let page: Int + let pageSize: Int + let list: [ScenicQueueSettingChangeLogItem] + + enum CodingKeys: String, CodingKey { + case total + case page + case pageSize = "page_size" + case list + } + + /// 创建变更日志响应。 + init(total: Int = 0, page: Int = 1, pageSize: Int = 20, list: [ScenicQueueSettingChangeLogItem] = []) { + self.total = total + self.page = page + self.pageSize = pageSize + self.list = list + } + + /// 宽松解码变更日志响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + page = try container.decodeLossyInt(forKey: .page) ?? 1 + pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 20 + list = (try? container.decodeIfPresent([ScenicQueueSettingChangeLogItem].self, forKey: .list)) ?? [] + } +} + +/// 排队设置变更日志项。 +struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable { + let id: Int64 + let scenicSpotName: String + let operatorName: String + let operatorPhoneTail: String + let summary: String + let displayText: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case scenicSpotName = "scenic_spot_name" + case operatorName = "operator_name" + case operatorPhoneTail = "operator_phone_tail" + case summary + case displayText = "display_text" + case createdAt = "created_at" + } + + /// 宽松解码变更日志项。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + operatorName = try container.decodeLossyString(forKey: .operatorName) + operatorPhoneTail = try container.decodeLossyString(forKey: .operatorPhoneTail) + summary = try container.decodeLossyString(forKey: .summary) + displayText = try container.decodeLossyString(forKey: .displayText) + createdAt = try container.decodeLossyString(forKey: .createdAt) + } +} + +/// 排队取号二维码响应。 +struct ScenicQueueShootQueueQRCodeData: Decodable, Equatable { + let qrcodeUrl: String + + enum CodingKeys: String, CodingKey { + case qrcodeUrl = "qrcode_url" + } + + /// 创建二维码响应。 + init(qrcodeUrl: String = "") { + self.qrcodeUrl = qrcodeUrl + } + + /// 宽松解码二维码响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + qrcodeUrl = try container.decodeLossyString(forKey: .qrcodeUrl) + } +} + +/// 本地排队设置快照,用于服务器无配置或离线时保留关键配置。 +struct ScenicQueueSettingsSnapshot: Codable, Equatable { + var shootMinute: Int = 0 + var shootSecond: Int = 30 + var firstAheadCount: Int = 0 + var firstSms: Bool = false + var firstPhone: Bool = false + var secondAheadCount: Int = 0 + var secondSms: Bool = false + var secondPhone: Bool = false + var broadcastIntervalSec: Int = 50 + var countdownThresholdSec: Int = 15 + var queueDistanceMeter: Int = 0 + var queueTakeLimit: Int = 0 + var missCallRequeueOffset: Int = 1 + var showStartShootingButton: Bool = true + var autoCallAheadCount: Int = 0 + var quickCallButtonEnabled: Bool = false + var prepareCallButtonEnabled: Bool = false + var businessOpen: Bool = true + var businessStartTime: String = "10:00" + var businessEndTime: String = "20:00" + + enum CodingKeys: String, CodingKey { + case shootMinute = "shoot_minute" + case shootSecond = "shoot_second" + case firstAheadCount = "first_ahead_count" + case firstSms = "first_sms" + case firstPhone = "first_phone" + case secondAheadCount = "second_ahead_count" + case secondSms = "second_sms" + case secondPhone = "second_phone" + case broadcastIntervalSec = "broadcast_interval_sec" + case countdownThresholdSec = "countdown_threshold_sec" + case queueDistanceMeter = "queue_distance_meter" + case queueTakeLimit = "queue_take_limit" + case missCallRequeueOffset = "miss_call_requeue_offset" + case showStartShootingButton = "show_start_shooting_button" + case autoCallAheadCount = "auto_call_ahead_count" + case quickCallButtonEnabled = "quick_call_button_enabled" + case prepareCallButtonEnabled = "prepare_call_button_enabled" + case businessOpen = "business_open" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + } +} + +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) } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "1" : "0" } + 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) } + if let value = try? decodeIfPresent(String.self, forKey: key), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return Int(text) ?? Int(Double(text) ?? 0) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? 1 : 0 } + 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 } + if let value = try? decodeIfPresent(String.self, forKey: key), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !text.isEmpty { + if ["1", "true", "yes"].contains(text) { return true } + if ["0", "false", "no"].contains(text) { return false } + } + return nil + } +} diff --git a/suixinkan/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift b/suixinkan/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift new file mode 100644 index 0000000..792e58a --- /dev/null +++ b/suixinkan/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift @@ -0,0 +1,172 @@ +// +// ScenicQueueSettingsStore.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队管理本地设置 key。 +enum ScenicQueueLocalSettings { + static let selectedSpotIdKey = "scenic_queue_selected_spot_id" + static let selectedSpotNameKey = "scenic_queue_selected_spot_name" + static let customTtsTextKey = "scenic_queue_custom_tts_text" + static let settingsSnapshotKey = "scenic_queue_settings_snapshot" + static let photoEstimateSecondsKey = "scenic_queue_photo_estimate_seconds" + static let broadcastIntervalSecondsKey = "scenic_queue_broadcast_interval_seconds" + static let countdownThresholdSecondsKey = "scenic_queue_countdown_threshold_seconds" + static let showStartShootingButtonKey = "scenic_queue_show_start_shooting_button" + static let autoCallAheadCountKey = "scenic_queue_auto_call_ahead_count" + static let quickCallButtonEnabledKey = "scenic_queue_quick_call_button_enabled" + static let prepareCallButtonEnabledKey = "scenic_queue_prepare_call_button_enabled" + static let presetVoicesKey = "scenic_queue_preset_voices" + static let ttsEnabledKey = "scenic_queue_tts_enabled" + static let backgroundPollEnabledKey = "scenic_queue_background_poll_enabled" +} + +/// 排队管理本地设置存储,按用户、景区、打卡点隔离并兼容旧 key。 +enum ScenicQueueSettingsStore { + /// 生成景区作用域 key。 + static func scopedKey(base: String, userId: String?, scenicId: Int?) -> String { + guard let scenicId else { return base } + let user = normalizedUserId(userId) + return "\(base)_user_\(user)_scenic_\(scenicId)" + } + + /// 生成打卡点作用域 key。 + static func scopedSpotKey(base: String, userId: String?, scenicId: Int?, spotId: Int?) -> String { + guard let scenicId, let spotId else { return scopedKey(base: base, userId: userId, scenicId: scenicId) } + let user = normalizedUserId(userId) + return "\(base)_user_\(user)_scenic_\(scenicId)_spot_\(spotId)" + } + + /// 读取已选打卡点 ID。 + static func selectedSpotId(userId: String?, scenicId: Int?) -> Int? { + guard let scenicId else { return legacyPositiveInt(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) } + let key = scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: userId, scenicId: scenicId) + if let value = positiveInt(forKey: key) { return value } + if let legacy = legacyPositiveInt(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) { + UserDefaults.standard.set(legacy, forKey: key) + return legacy + } + return nil + } + + /// 读取已选打卡点名称。 + static func selectedSpotName(userId: String?, scenicId: Int?) -> String { + guard let scenicId else { + return UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.selectedSpotNameKey) ?? "" + } + let key = scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: userId, scenicId: scenicId) + if let value = UserDefaults.standard.string(forKey: key), !value.isEmpty { return value } + let legacy = UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.selectedSpotNameKey) ?? "" + if !legacy.isEmpty { + UserDefaults.standard.set(legacy, forKey: key) + } + return legacy + } + + /// 保存已选打卡点。 + static func saveSelectedSpot(id: Int, name: String, userId: String?, scenicId: Int) { + let defaults = UserDefaults.standard + defaults.set(id, forKey: ScenicQueueLocalSettings.selectedSpotIdKey) + defaults.set(name, forKey: ScenicQueueLocalSettings.selectedSpotNameKey) + defaults.set(id, forKey: scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: userId, scenicId: scenicId)) + defaults.set(name, forKey: scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: userId, scenicId: scenicId)) + } + + /// 读取自定义语音文本。 + static func customTtsText(userId: String?, scenicId: Int?, spotId: Int?) -> String { + guard let scenicId, let spotId else { + return UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.customTtsTextKey) ?? "" + } + let key = scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: userId, scenicId: scenicId, spotId: spotId) + if let value = UserDefaults.standard.string(forKey: key) { return value } + let legacy = UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.customTtsTextKey) ?? "" + if !legacy.isEmpty { + UserDefaults.standard.set(legacy, forKey: key) + } + return legacy + } + + /// 保存自定义语音文本。 + static func saveCustomTtsText(_ text: String, userId: String?, scenicId: Int?, spotId: Int?) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + UserDefaults.standard.set(trimmed, forKey: ScenicQueueLocalSettings.customTtsTextKey) + if let scenicId, let spotId { + UserDefaults.standard.set( + trimmed, + forKey: scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: userId, scenicId: scenicId, spotId: spotId) + ) + } + } + + /// 读取设置快照。 + static func settingsSnapshot(userId: String?, scenicId: Int, spotId: Int) -> ScenicQueueSettingsSnapshot? { + let keys = [ + scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: userId, scenicId: scenicId, spotId: spotId), + "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.settingsSnapshotKey)", + "scenic_\(scenicId)_\(ScenicQueueLocalSettings.settingsSnapshotKey)" + ] + for key in keys { + guard let data = UserDefaults.standard.data(forKey: key), + let snapshot = try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data) + else { continue } + return snapshot + } + return nil + } + + /// 保存设置快照。 + static func saveSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot, userId: String?, scenicId: Int, spotId: Int) { + guard let data = try? JSONEncoder().encode(snapshot) else { return } + UserDefaults.standard.set( + data, + forKey: scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: userId, scenicId: scenicId, spotId: spotId) + ) + UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.settingsSnapshotKey)") + } + + /// 读取语音预设。 + static func presetVoices(userId: String?, scenicId: Int?, spotId: Int?) -> [String] { + guard let scenicId, let spotId else { return [] } + let keys = [ + scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: userId, scenicId: scenicId, spotId: spotId), + "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)" + ] + for key in keys { + guard let data = UserDefaults.standard.data(forKey: key), + let voices = try? JSONDecoder().decode([String].self, from: data) + else { continue } + return voices + } + return [] + } + + /// 保存语音预设。 + static func savePresetVoices(_ voices: [String], userId: String?, scenicId: Int, spotId: Int) { + let normalized = Array(voices.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }.prefix(5)) + guard let data = try? JSONEncoder().encode(normalized) else { return } + UserDefaults.standard.set( + data, + forKey: scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: userId, scenicId: scenicId, spotId: spotId) + ) + UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)") + } + + private static func normalizedUserId(_ userId: String?) -> String { + let text = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? "anonymous" : text + } + + 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 + } + + private static func legacyPositiveInt(forKey key: String) -> Int? { + positiveInt(forKey: key) + } +} diff --git a/suixinkan/Features/QueueManagement/QueueManagement.md b/suixinkan/Features/QueueManagement/QueueManagement.md new file mode 100644 index 0000000..b1107de --- /dev/null +++ b/suixinkan/Features/QueueManagement/QueueManagement.md @@ -0,0 +1,20 @@ +# 排队管理模块 + +## 模块职责 + +`Features/QueueManagement` 承接首页 `/scenic-queue` 和 `queue_management` 权限入口,负责景区打卡点队列列表、叫号、过号、完成、重新排队、用户标记、队列设置、排队二维码和配置日志。 + +## 代码结构 + +- `ScenicQueueAPI`:封装 `/api/app/scenic-queue/...` 和 `/api/app/socket-token`。 +- `QueueManagementViewModel`:管理已保存打卡点门禁、当前排队/过号列表分页、统计、队列动作和页面前台实时监听。 +- `ScenicQueueSettingsViewModel`:管理打卡点设置加载、本地快照兜底、表单校验、保存、二维码和配置日志。 +- `ScenicQueueSettingsStore`:按 `userId + scenicId + spotId` 作用域保存本地排队设置,并兼容旧 key。 +- `Core/Queue`:承接排队 WebSocket、远端叫号去重、语音播报和页面离开后的短轮询运行时。 + +## 业务边界 + +- 列表页从当前账号景区和打卡点上下文读取数据;没有已保存打卡点时只显示设置引导,不自动选择第一个打卡点。 +- 叫号成功后只做本地已叫号标记;过号、完成、重新排队和用户标记成功后刷新当前打卡点列表。 +- 页面可见时由 `QueueManagementViewModel` 维护 WebSocket 监听;页面离开后由 `ScenicQueueRuntime` 按本地开关进行短轮询。 +- 设置保存前校验拍摄时长、通知阈值、播报间隔、读秒阈值、排队范围、排队次数、过号顺延和自定义播报长度。 diff --git a/suixinkan/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift b/suixinkan/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift new file mode 100644 index 0000000..78155d0 --- /dev/null +++ b/suixinkan/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift @@ -0,0 +1,884 @@ +// +// QueueManagementViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation +import Photos +import SwiftUI +import UIKit + +@MainActor +@Observable +/// 排队管理列表 ViewModel,负责打卡点门禁、列表分页、动作和实时事件。 +final class QueueManagementViewModel { + var loading = false + var loadingMore = false + var items: [QueueItem] = [] + var scenicSpots: [ScenicSpotItem] = [] + var selectedSpotId: Int? + var selectedListType: QueueListType = .queueing + var queueCount = 0 + var avgWaitMin = 0.0 + var totalCount = 0 + var hasMore = false + var page = 1 + var lastSyncTimeText = "--" + var queueGatePassed = false + var selectedSpotName = "--" + var showStartShootingButton = true + var autoCallAheadCount = 0 + var quickCallButtonEnabled = false + var prepareCallButtonEnabled = false + var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement? + var errorMessage: String? + + @ObservationIgnored private let pageSize = 10 + @ObservationIgnored private let socketClient = ScenicQueueSocketClient() + @ObservationIgnored private var realtimeSpotId: Int? + @ObservationIgnored private var realtimeScenicId: Int? + @ObservationIgnored private var currentUserId: String? + @ObservationIgnored private var currentScenicId: Int? + @ObservationIgnored private var pendingRemoteCalledRecordIds = Set() + @ObservationIgnored private var handledRemoteCallEventIds = Set() + + /// 待处理排队数量。 + var pendingCount: Int { + items.filter { $0.status < 7 && !$0.statusText.contains("完成") }.count + } + + /// 平均等待时间文案。 + var avgWaitMinText: String { + avgWaitMin.rounded() == avgWaitMin ? "\(Int(avgWaitMin))" : String(format: "%.1f", avgWaitMin) + } + + /// 当前打卡点名称。 + var currentSpotName: String { + scenicSpots.first(where: { $0.id == selectedSpotId })?.name + ?? selectedSpotName.nonEmptyOrDefault(queueGatePassed ? "--" : "未设置打卡点") + } + + /// 重新加载排队数据。没有已保存打卡点时只展示设置引导,不自动选第一个。 + func reload(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async { + guard let scenicId else { + reset() + return + } + + loading = true + errorMessage = nil + defer { loading = false } + + currentUserId = userId + currentScenicId = scenicId + scenicSpots = spots + + let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) + if let savedSpotId, spots.isEmpty || spots.contains(where: { $0.id == savedSpotId }) { + selectedSpotId = savedSpotId + selectedSpotName = ScenicQueueSettingsStore.selectedSpotName(userId: userId, scenicId: scenicId) + .nonEmptyOrDefault(spots.first(where: { $0.id == savedSpotId })?.name ?? "--") + queueGatePassed = true + await syncQueueSettingFromServerIfMatchesLocal(api: api, userId: userId, scenicId: scenicId, spotId: savedSpotId) + refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: savedSpotId) + } else { + selectedSpotId = nil + selectedSpotName = "--" + queueGatePassed = false + clearQueueData() + return + } + + do { + try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1) + } catch { + clearQueueData() + errorMessage = error.localizedDescription + } + } + + /// 切换列表类型并加载第一页。 + func selectListType(_ type: QueueListType, api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard selectedListType != type else { return } + selectedListType = type + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + /// 重新加载当前打卡点列表。 + func reloadCurrentSpot(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard let scenicId else { return } + loading = true + errorMessage = nil + defer { loading = false } + do { + try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1) + } catch { + clearQueueData() + errorMessage = error.localizedDescription + } + } + + /// 加载下一页。 + func loadMore(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard let scenicId, hasMore, !loadingMore else { return } + loadingMore = true + defer { loadingMore = false } + do { + try await loadPage(api: api, scenicId: scenicId, userId: userId, page: page + 1) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 叫号,成功后仅本地标记为已叫号。 + func callQueue(api: any ScenicQueueServing, id: Int64) async throws { + let data = try await api.scenicQueueCall(id: id) + markQueueCalled(id: id, statusText: data.statusText) + } + + /// 标记过号并刷新列表。 + func passQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws { + _ = try await api.scenicQueuePass(id: id) + items.removeAll { $0.id == id } + queueCount = max(queueCount - 1, 0) + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + /// 标记完成并刷新列表。 + func finishQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws -> ScenicQueueFinishData { + let data = try await api.scenicQueueFinish(id: id) + items.removeAll { $0.id == id } + queueCount = max(queueCount - 1, 0) + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + return data + } + + /// 重新排队并切回当前排队列表。 + func requeue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64, operatorId: Int) async throws { + try await api.scenicQueueRequeueInsertBefore(recordId: id, operatorId: operatorId) + selectedListType = .queueing + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + /// 标记用户身份/限制排队。 + func userMark(api: any ScenicQueueServing, scenicId: Int?, userId: String?, request: ScenicQueueUserMarkRequest) async throws { + try await api.scenicQueueUserMark(request) + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + /// 启动页面前台实时监听。 + func startRealtime(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard queueGatePassed, let scenicId, let scenicSpotId = selectedSpotId else { + stopRealtime() + return + } + guard realtimeScenicId != scenicId || realtimeSpotId != scenicSpotId else { return } + stopRealtime() + do { + let token = try await api.socketToken().socketToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { return } + realtimeScenicId = scenicId + realtimeSpotId = scenicSpotId + socketClient.connect(socketToken: token, scenicSpotId: scenicSpotId) { [weak self] message in + Task { @MainActor in + await self?.handleSocketMessage(message, api: api, scenicId: scenicId, userId: userId) + } + } + } catch { + errorMessage = error.localizedDescription + } + } + + /// 停止页面实时监听。 + func stopRealtime() { + socketClient.disconnect() + realtimeScenicId = nil + realtimeSpotId = nil + pendingRemoteCalledRecordIds.removeAll() + handledRemoteCallEventIds.removeAll() + } + + /// 当前拍摄预计时长秒数。 + var shootingDurationSeconds: Int { + guard let scenicId = currentScenicId, let selectedSpotId, + let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId) + else { + return max(UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey), 0) + } + return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0) + } + + private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async { + guard message.isScenicQueueEvent, + let params = message.data?.params, + let changedSpotId = params.scenicSpotId, + let selectedSpotId, + changedSpotId == Int64(selectedSpotId) + else { return } + + switch message.data?.action { + case ScenicQueueSocketMessage.queueUpdatedAction: + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + case ScenicQueueSocketMessage.ticketCalledAction: + await handleRemoteTicketCalled(params: params, api: api, scenicId: scenicId, userId: userId) + default: + return + } + } + + 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, + let myUid = Int64(userId ?? ""), + operatorUid == myUid { + return + } + let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !eventId.isEmpty, !handledRemoteCallEventIds.insert(eventId).inserted { + return + } + pendingRemoteCalledRecordIds.insert(recordId) + guard let selectedSpotId else { return } + _ = try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: selectedSpotId) + let tickets = (try? await loadQueueingTickets(api: api, scenicId: scenicId, scenicSpotId: selectedSpotId)) ?? [] + if selectedListType == .queueing { + items = tickets + totalCount = max(totalCount, tickets.count) + hasMore = tickets.count < totalCount + } + consumePendingRemoteCalledTickets(from: tickets) + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] { + let home = try await api.scenicQueueHome( + scenicId: scenicId, + scenicSpotId: scenicSpotId, + type: QueueListType.queueing.rawValue, + page: 1, + pageSize: pageSize + ) + return home.list?.list ?? [] + } + + 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("已叫号")) + }) else { + return + } + pendingRemoteCalledRecordIds.remove(ticket.id) + remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode) + } + + 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( + scenicId: scenicId, + scenicSpotId: scenicSpotId, + type: selectedListType.rawValue, + page: targetPage, + pageSize: pageSize + ) + let stats = (try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: scenicSpotId)) ?? ScenicQueueStatsData() + let block = home.list + let nextList = block?.list ?? [] + + if targetPage <= 1 { + items = nextList + } else { + items.append(contentsOf: nextList) + } + page = targetPage + totalCount = block?.total ?? items.count + hasMore = page * pageSize < totalCount + queueCount = stats.queueCount + avgWaitMin = stats.avgWaitMin + let syncText = stats.time.isEmpty ? (home.time ?? "") : stats.time + lastSyncTimeText = syncText.isEmpty ? "--" : syncText + refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId) + } + + private func markQueueCalled(id: Int64, statusText: String) { + let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines) + items = items.map { item in + guard item.id == id else { return item } + return ScenicQueueTicket( + id: item.id, + queueCode: item.queueCode, + mobile: item.mobile, + status: max(item.status, 1), + statusText: trimmedStatus.nonEmptyOrDefault(item.statusText.contains("已叫号") ? item.statusText : "已叫号"), + waitMin: item.waitMin, + aheadCount: item.aheadCount, + isCalled: 1, + createdAt: item.createdAt, + calledAt: item.calledAt, + expiredAt: item.expiredAt, + finishedAt: item.finishedAt, + queueBanLabel: item.queueBanLabel, + identityTag: item.identityTag, + queueTime: item.queueTime, + queueCountToday: item.queueCountToday, + uid: item.uid, + markAsPhotog: item.markAsPhotog, + markAsFreelancePhotog: item.markAsFreelancePhotog, + isMissRequeue: item.isMissRequeue, + missRequeueText: item.missRequeueText + ) + } + } + + private func reset() { + stopRealtime() + loading = false + loadingMore = false + scenicSpots = [] + selectedSpotId = nil + selectedSpotName = "--" + queueGatePassed = false + selectedListType = .queueing + currentScenicId = nil + clearQueueData() + } + + private func clearQueueData() { + loadingMore = false + items = [] + queueCount = 0 + avgWaitMin = 0 + totalCount = 0 + hasMore = false + page = 1 + lastSyncTimeText = "--" + } + + private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) { + let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot() + showStartShootingButton = snapshot.showStartShootingButton + autoCallAheadCount = min(max(snapshot.autoCallAheadCount, 0), 5) + quickCallButtonEnabled = snapshot.quickCallButtonEnabled + prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled + } + + 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, + let setting = data.setting + else { return } + if let settingScenicId = setting.scenicId, settingScenicId != scenicId { return } + if let settingSpotId = setting.scenicSpotId, settingSpotId != spotId { return } + + let local = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot() + let snapshot = ScenicQueueSettingsSnapshot( + shootMinute: min(max(setting.photoEstimateMin, 0), 30), + shootSecond: min(max(setting.photoEstimateSec, 0), 59), + firstAheadCount: min(max(setting.firstNoticeThresholdPos, 0), 50), + firstSms: setting.firstNoticeSmsEnabled == 1, + firstPhone: setting.firstNoticeCallEnabled == 1, + secondAheadCount: min(max(setting.secondNoticeThresholdPos, 0), 50), + secondSms: setting.secondNoticeSmsEnabled == 1, + secondPhone: setting.secondNoticeCallEnabled == 1, + broadcastIntervalSec: ScenicQueueSettingsViewModel.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec), + countdownThresholdSec: ScenicQueueSettingsViewModel.coercedCountdownThreshold(setting.countdownReadableThresholdSec), + queueDistanceMeter: min(max(setting.queueDistanceMeter, 0), 9_999_990), + queueTakeLimit: min(max(setting.queueTakeLimit, 0), 999_999), + missCallRequeueOffset: min(max(setting.missCallRequeueOffset, 1), 9_999), + showStartShootingButton: (setting.showStartShootButton ?? (local.showStartShootingButton ? 1 : 0)) == 1, + autoCallAheadCount: min(max(setting.autoCallNextCount ?? local.autoCallAheadCount, 0), 5), + quickCallButtonEnabled: local.quickCallButtonEnabled, + prepareCallButtonEnabled: local.prepareCallButtonEnabled, + businessOpen: setting.status == 1, + businessStartTime: setting.businessStartTime, + businessEndTime: setting.businessEndTime + ) + ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: userId, scenicId: scenicId, spotId: spotId) + ScenicQueueSettingsStore.saveSelectedSpot( + id: spotId, + name: setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName), + userId: userId, + scenicId: scenicId + ) + if !setting.remark.isEmpty, setting.remark != "A0001" { + ScenicQueueSettingsStore.saveCustomTtsText(setting.remark, userId: userId, scenicId: scenicId, spotId: spotId) + } + let voices = setting.voiceBroadcasts + .sorted { $0.sortOrder < $1.sortOrder } + .map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if !voices.isEmpty { + ScenicQueueSettingsStore.savePresetVoices(Array(voices.prefix(5)), userId: userId, scenicId: scenicId, spotId: spotId) + } + selectedSpotName = setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName) + } +} + +@MainActor +@Observable +/// 排队设置 ViewModel,负责设置读取、校验、保存、二维码和日志。 +final class ScenicQueueSettingsViewModel { + var loading = false + var message: String? + var scenicSpots: [ScenicSpotItem] = [] + var selectedSpotId: Int? + var photoEstimateMin = "0" + var photoEstimateSec = "30" + var firstAhead = "0" + var firstSms = false + var firstPhone = false + var secondAhead = "0" + var secondSms = false + var secondPhone = false + var broadcastIntervalSec = "50" + var countdownThresholdSec = "15" + var maxQueueRangeKm = "0.00" + var localQueueLimit = "0" + var localPassDelay = "1" + var showStartShootingButton = true + var autoCallAheadCount = "0" + var queueOpen = true + var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0) + var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0) + var customTtsText = "" + var presetVoices: [String] = [] + var quickCallButtonEnabled = false + var prepareCallButtonEnabled = false + var logs: [ScenicQueueSettingChangeLogItem] = [] + var qrcodeURL = "" + + @ObservationIgnored private let speaker = ScenicQueueSpeechService() + @ObservationIgnored private var currentUserId: String? + @ObservationIgnored private var currentScenicId: Int? + + /// 加载设置。设置页允许自动选中第一个打卡点。 + func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async { + guard let scenicId else { + resetSettingsState() + message = "请先选择景区" + return + } + currentUserId = userId + currentScenicId = scenicId + scenicSpots = spots + message = nil + loading = true + defer { loading = false } + logs = [] + + let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) + selectedSpotId = savedSpotId.flatMap { id in spots.first(where: { $0.id == id })?.id ?? id } ?? selectedSpotId ?? spots.first?.id + guard selectedSpotId != nil else { return } + await loadSelectedSpotSetting(api: api, scenicId: scenicId, userId: userId) + } + + /// 加载当前选中打卡点设置。 + func loadSelectedSpotSetting(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard let scenicId, let selectedSpotId else { return } + currentUserId = userId + currentScenicId = scenicId + presetVoices = ScenicQueueSettingsStore.presetVoices(userId: userId, scenicId: scenicId, spotId: selectedSpotId) + let savedCustomText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: selectedSpotId) + if !savedCustomText.isEmpty, savedCustomText != "A0001" { + customTtsText = savedCustomText + } + if let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: selectedSpotId) { + apply(snapshot) + } + do { + let setting = try await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: selectedSpotId) + if setting.exists, let item = setting.setting { + apply(item) + ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot(), userId: userId, scenicId: scenicId, spotId: selectedSpotId) + } + } catch { + message = error.localizedDescription + } + } + + /// 加载设置变更日志。 + func loadSettingChangeLogs(api: any ScenicQueueServing, scenicId: Int?) async { + guard let scenicId else { + message = "请先选择景区" + return + } + do { + let data = try await api.scenicQueueSettingChangeLog(scenicId: scenicId, scenicSpotId: selectedSpotId, page: 1, pageSize: 20) + logs = data.list + } catch { + message = error.localizedDescription + } + } + + /// 获取排队二维码 URL。 + func fetchQRCode(api: any ScenicQueueServing, scenicId: Int?) async throws -> String { + guard let scenicId else { throw APIError.networkFailed("请先选择景区") } + guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") } + let data = try await api.scenicQueueShootQueueQRCode(scenicId: scenicId, scenicSpotId: selectedSpotId) + let url = data.qrcodeUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { throw APIError.networkFailed("服务端未返回二维码图片地址") } + qrcodeURL = url + return url + } + + /// 保存二维码图片到系统相册。 + func saveQRCodeToPhotoLibrary(qrcodeURL: String) async throws { + let trimmed = qrcodeURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw APIError.networkFailed("服务端未返回二维码图片地址") + } + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + guard status == .authorized || status == .limited else { + throw APIError.networkFailed("请在系统设置中允许保存图片到相册") + } + let image = try await loadQRCodeImage(from: trimmed) + try await PHPhotoLibrary.shared().performChanges { + PHAssetChangeRequest.creationRequestForAsset(from: image) + } + } + + /// 保存排队设置。 + func save(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async throws { + guard let scenicId else { throw APIError.networkFailed("请先选择景区") } + guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") } + currentUserId = userId + currentScenicId = scenicId + let min = try AppFormValidator.rangedInteger(photoEstimateMin, name: "拍摄时长分钟", range: 0...30) + let sec = try AppFormValidator.rangedInteger(photoEstimateSec, name: "拍摄时长秒", range: 0...59) + guard min > 0 || sec > 0 else { throw APIError.networkFailed("拍摄时长须大于 0") } + let first = try AppFormValidator.rangedInteger(firstAhead, name: "第一次通知阈值", range: 0...50) + let second = try AppFormValidator.rangedInteger(secondAhead, name: "第二次通知阈值", range: 0...50) + try AppFormValidator.validateQueueNoticeThresholds(first: first, second: second) + let interval = try AppFormValidator.rangedInteger(broadcastIntervalSec, name: "播报间隔时间", range: 40...60) + let countdown = try AppFormValidator.rangedInteger(countdownThresholdSec, name: "进入读秒倒计时阈值", range: 10...30) + let autoCallAhead = try AppFormValidator.rangedInteger(autoCallAheadCount, name: "自动叫号后续几位", range: 0...5) + maxQueueRangeKm = try normalizedMaxQueueRangeForDisplay() + let queueDistanceMeter = try queueDistanceMeterForSubmit() + let queueTakeLimit = try AppFormValidator.rangedInteger(localQueueLimit, name: "排队次数限制", range: 0...999_999) + let missCallRequeueOffset = try AppFormValidator.rangedInteger(localPassDelay, name: "过号顺延", range: 1...9_999) + guard customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).count <= 255 else { + throw APIError.networkFailed("自定义播报文本最多 255 个字") + } + + loading = true + defer { loading = false } + try await api.scenicQueueSaveSetting(ScenicQueueSaveSettingRequest( + scenicId: scenicId, + scenicSpotId: selectedSpotId, + photoEstimateMin: min, + photoEstimateSec: sec, + firstNoticeThresholdPos: first, + firstNoticeSmsEnabled: firstSms ? 1 : 0, + firstNoticeCallEnabled: firstPhone ? 1 : 0, + secondNoticeThresholdPos: second, + secondNoticeSmsEnabled: secondSms ? 1 : 0, + secondNoticeCallEnabled: secondPhone ? 1 : 0, + countdownBroadcastIntervalSec: interval, + countdownReadableThresholdSec: countdown, + queueDistanceMeter: queueDistanceMeter, + queueTakeLimit: queueTakeLimit, + missCallRequeueOffset: missCallRequeueOffset, + showStartShootButton: showStartShootingButton ? 1 : 0, + autoCallNextCount: autoCallAhead, + businessStartTime: Self.businessTimePayload(businessStartTime), + businessEndTime: Self.businessTimePayload(businessEndTime), + voiceBroadcasts: voiceBroadcastsForSavePayload(), + status: queueOpen ? 1 : 0, + remark: customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + )) + let spotName = scenicSpots.first(where: { $0.id == selectedSpotId })?.name ?? "" + ScenicQueueSettingsStore.saveSelectedSpot(id: selectedSpotId, name: spotName, userId: userId, scenicId: scenicId) + saveTimingSnapshot(min: min, sec: sec, interval: interval, countdown: countdown) + saveShootingCallSettings(autoCallAhead: autoCallAhead) + saveCustomTextLocally() + ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: userId, scenicId: scenicId, spotId: selectedSpotId) + ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot( + min: min, + sec: sec, + first: first, + second: second, + interval: interval, + countdown: countdown, + queueDistanceMeter: queueDistanceMeter, + queueTakeLimit: queueTakeLimit, + missCallRequeueOffset: missCallRequeueOffset, + autoCallAhead: autoCallAhead + ), userId: userId, scenicId: scenicId, spotId: selectedSpotId) + } + + /// 保存自定义文本为本地预设。 + func saveCustomTextAsPreset() { + let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines) + guard text.count <= 255 else { + message = "自定义播报文本最多 255 个字" + return + } + saveCustomTextLocally() + if !text.isEmpty, !presetVoices.contains(text), presetVoices.count < 5 { + presetVoices.append(text) + } + if let scenicId = currentScenicId, let selectedSpotId { + ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId) + } + } + + /// 更新最大排队范围输入。 + func updateMaxQueueRange(_ value: String) { + maxQueueRangeKm = AppFormValidator.sanitizedMoneyInput(value, maxDecimalPlaces: 2) + } + + /// 播放测试音。 + func playTestSound() { + speaker.speak("蓝牙音响测试音,请确认外放音量是否合适。") + } + + /// 播放自定义播报文本。 + func playCustomText() { + let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + message = "请输入要播报的文字" + return + } + speaker.speak(text) + } + + /// 删除预设语音。 + func deletePresetVoice(at index: Int) { + guard presetVoices.indices.contains(index) else { return } + presetVoices.remove(at: index) + } + + private func resetSettingsState() { + loading = false + scenicSpots = [] + selectedSpotId = nil + photoEstimateMin = "0" + photoEstimateSec = "30" + firstAhead = "0" + firstSms = false + firstPhone = false + secondAhead = "0" + secondSms = false + secondPhone = false + broadcastIntervalSec = "50" + countdownThresholdSec = "15" + maxQueueRangeKm = "0.00" + localQueueLimit = "0" + localPassDelay = "1" + showStartShootingButton = true + autoCallAheadCount = "0" + queueOpen = true + businessStartTime = Self.businessTime(hour: 10, minute: 0) + businessEndTime = Self.businessTime(hour: 20, minute: 0) + customTtsText = "" + logs = [] + presetVoices = [] + quickCallButtonEnabled = false + prepareCallButtonEnabled = false + qrcodeURL = "" + } + + private func apply(_ setting: ScenicQueueSettingItem?) { + guard let setting else { return } + if let spotId = setting.scenicSpotId { selectedSpotId = spotId } + photoEstimateMin = "\(setting.photoEstimateMin)" + photoEstimateSec = "\(setting.photoEstimateSec)" + firstAhead = "\(setting.firstNoticeThresholdPos)" + firstSms = setting.firstNoticeSmsEnabled == 1 + firstPhone = setting.firstNoticeCallEnabled == 1 + secondAhead = "\(setting.secondNoticeThresholdPos)" + secondSms = setting.secondNoticeSmsEnabled == 1 + secondPhone = setting.secondNoticeCallEnabled == 1 + broadcastIntervalSec = "\(Self.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec))" + countdownThresholdSec = "\(Self.coercedCountdownThreshold(setting.countdownReadableThresholdSec))" + maxQueueRangeKm = Self.queueDistanceDisplay(meters: setting.queueDistanceMeter) + localQueueLimit = "\(min(max(setting.queueTakeLimit, 0), 999_999))" + localPassDelay = "\(min(max(setting.missCallRequeueOffset, 1), 9_999))" + if let showStartShootButton = setting.showStartShootButton { showStartShootingButton = showStartShootButton == 1 } + if let autoCallNextCount = setting.autoCallNextCount { autoCallAheadCount = "\(min(max(autoCallNextCount, 0), 5))" } + queueOpen = setting.status == 1 + businessStartTime = Self.parseBusinessTime(setting.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0)) + businessEndTime = Self.parseBusinessTime(setting.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0)) + if !setting.remark.isEmpty, setting.remark != "A0001" { customTtsText = setting.remark } + let voices = setting.voiceBroadcasts + .sorted { $0.sortOrder < $1.sortOrder } + .map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) } + } + + private func apply(_ snapshot: ScenicQueueSettingsSnapshot) { + photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))" + photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))" + firstAhead = "\(min(max(snapshot.firstAheadCount, 0), 50))" + firstSms = snapshot.firstSms + firstPhone = snapshot.firstPhone + secondAhead = "\(min(max(snapshot.secondAheadCount, 0), 50))" + secondSms = snapshot.secondSms + secondPhone = snapshot.secondPhone + broadcastIntervalSec = "\(Self.coercedBroadcastInterval(snapshot.broadcastIntervalSec))" + countdownThresholdSec = "\(Self.coercedCountdownThreshold(snapshot.countdownThresholdSec))" + maxQueueRangeKm = Self.queueDistanceDisplay(meters: snapshot.queueDistanceMeter) + localQueueLimit = "\(min(max(snapshot.queueTakeLimit, 0), 999_999))" + localPassDelay = "\(min(max(snapshot.missCallRequeueOffset, 1), 9_999))" + showStartShootingButton = snapshot.showStartShootingButton + autoCallAheadCount = "\(min(max(snapshot.autoCallAheadCount, 0), 5))" + quickCallButtonEnabled = snapshot.quickCallButtonEnabled + prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled + queueOpen = snapshot.businessOpen + businessStartTime = Self.parseBusinessTime(snapshot.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0)) + businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0)) + } + + private func normalizedMaxQueueRangeForDisplay() throws -> String { + let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm) + guard !normalized.isEmpty, + normalized.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil, + let value = Decimal(string: normalized, locale: Locale(identifier: "en_US_POSIX")) + else { + throw APIError.networkFailed("允许排队的最大范围格式不正确") + } + let max = Decimal(999_999) / Decimal(100) + guard value >= 0, value <= max else { + throw APIError.networkFailed("允许排队的最大范围须在 0~9999.99") + } + return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue) + } + + 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 + } + + 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) + if let image = UIImage(data: data) { return image } + } + if let generated = Self.generateQRCode(from: text) { return generated } + throw APIError.networkFailed("二维码图片生成失败") + } + + private static func generateQRCode(from string: String) -> UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + guard let outputImage = filter.outputImage else { return nil } + let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: 8, y: 8)) + let context = CIContext() + guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil } + return UIImage(cgImage: cgImage) + } + + 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) + } + + private func saveShootingCallSettings(autoCallAhead: Int) { + UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey) + UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey) + UserDefaults.standard.set(quickCallButtonEnabled, forKey: ScenicQueueLocalSettings.quickCallButtonEnabledKey) + UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey) + } + + private func saveCustomTextLocally() { + ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId) + } + + private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] { + Array(presetVoices + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(5) + .enumerated() + .map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) }) + } + + private func makeSnapshot( + min: Int? = nil, + sec: Int? = nil, + first: Int? = nil, + second: Int? = nil, + interval: Int? = nil, + countdown: Int? = nil, + queueDistanceMeter: Int? = nil, + queueTakeLimit: Int? = nil, + missCallRequeueOffset: Int? = nil, + autoCallAhead: Int? = nil + ) -> ScenicQueueSettingsSnapshot { + ScenicQueueSettingsSnapshot( + shootMinute: min ?? (Int(photoEstimateMin) ?? 0), + shootSecond: sec ?? (Int(photoEstimateSec) ?? 30), + firstAheadCount: first ?? (Int(firstAhead) ?? 0), + firstSms: firstSms, + firstPhone: firstPhone, + secondAheadCount: second ?? (Int(secondAhead) ?? 0), + secondSms: secondSms, + secondPhone: secondPhone, + broadcastIntervalSec: interval ?? (Int(broadcastIntervalSec) ?? 50), + countdownThresholdSec: countdown ?? (Int(countdownThresholdSec) ?? 15), + queueDistanceMeter: queueDistanceMeter ?? ((Decimal(string: maxQueueRangeKm) ?? 0) as NSDecimalNumber).intValue * 1000, + queueTakeLimit: queueTakeLimit ?? (Int(localQueueLimit) ?? 0), + missCallRequeueOffset: missCallRequeueOffset ?? (Int(localPassDelay) ?? 1), + showStartShootingButton: showStartShootingButton, + autoCallAheadCount: autoCallAhead ?? (Int(autoCallAheadCount) ?? 0), + quickCallButtonEnabled: quickCallButtonEnabled, + prepareCallButtonEnabled: prepareCallButtonEnabled, + businessOpen: queueOpen, + businessStartTime: Self.businessTimePayload(businessStartTime), + businessEndTime: Self.businessTimePayload(businessEndTime) + ) + } + + static func coercedBroadcastInterval(_ raw: Int) -> Int { + (40...60).contains(raw) ? raw : 50 + } + + static func coercedCountdownThreshold(_ raw: Int) -> Int { + (10...30).contains(raw) ? raw : 15 + } + + static func businessTime(hour: Int, minute: Int) -> Date { + Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date() + } + + static func parseBusinessTime(_ raw: String, fallback: Date) -> Date { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return fallback } + for format in ["HH:mm", "HH:mm:ss"] { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = format + if let date = formatter.date(from: text) { return date } + } + return fallback + } + + static func businessTimePayload(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + private static func queueDistanceDisplay(meters: Int) -> String { + let clamped = min(max(meters, 0), 9_999_990) + return String(format: "%.2f", Double(clamped) / 1000) + } +} + +private extension String { + var nilIfEmpty: String? { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + + func nonEmptyOrDefault(_ fallback: String) -> String { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? fallback : text + } +} diff --git a/suixinkan/Features/QueueManagement/Views/QueueManagementViews.swift b/suixinkan/Features/QueueManagement/Views/QueueManagementViews.swift new file mode 100644 index 0000000..52aa7bb --- /dev/null +++ b/suixinkan/Features/QueueManagement/Views/QueueManagementViews.swift @@ -0,0 +1,464 @@ +// +// QueueManagementViews.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import SwiftUI + +/// 排队管理首页,展示当前打卡点队列和操作入口。 +struct QueueManagementView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(ScenicSpotContext.self) private var scenicSpotContext + @Environment(ScenicQueueAPI.self) private var queueAPI + @Environment(ScenicQueueRuntime.self) private var queueRuntime + @Environment(ToastCenter.self) private var toastCenter + @State private var viewModel = QueueManagementViewModel() + @State private var pendingAction: QueueActionConfirmation? + @State private var markTarget: QueueItem? + @State private var processingId: Int64? + + private var currentScenicId: Int? { accountContext.currentScenic?.id } + private var currentUserId: String? { accountContext.profile?.userId } + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + header + if currentScenicId == nil { + ContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文")) + .frame(minHeight: 260) + } else if !viewModel.queueGatePassed { + gateCard + } else { + listSection + } + } + .padding(AppMetrics.Spacing.pageHorizontal) + } + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle("排队管理") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + ScenicQueueSettingsView() + } label: { + Image(systemName: "gearshape") + } + .accessibilityLabel("排队设置") + } + } + .alert(item: $pendingAction) { action in + Alert( + title: Text(action.title), + message: Text(action.message), + primaryButton: .default(Text("确认")) { + Task { await perform(action) } + }, + secondaryButton: .cancel() + ) + } + .sheet(item: $markTarget) { item in + QueueUserMarkView(item: item) { markAsFreelance, banDays in + await userMark(item: item, markAsFreelance: markAsFreelance, banDays: banDays) + } + } + .task(id: reloadTaskID) { + await reload() + await viewModel.startRealtime(api: queueAPI, scenicId: currentScenicId, userId: currentUserId) + } + .onAppear { + queueRuntime.setSuspendedByQueueScreen(true) + } + .onDisappear { + viewModel.stopRealtime() + queueRuntime.setSuspendedByQueueScreen(false) + } + .refreshable { + await reload() + } + .onChange(of: viewModel.remoteCallAnnouncement) { _, announcement in + if let announcement { + toastCenter.show("远端已叫号 \(announcement.queueCode.isEmpty ? "当前游客" : announcement.queueCode)") + } + } + .onChange(of: viewModel.errorMessage) { _, message in + if let message { toastCenter.show(message) } + } + } + + private var reloadTaskID: String { + "\(currentScenicId ?? 0)-\(scenicSpotContext.spots.map(\.id).description)" + } + + private var header: some View { + VStack(spacing: AppMetrics.Spacing.medium) { + HStack(spacing: AppMetrics.Spacing.medium) { + metricCard(title: "在排人数", value: "\(viewModel.queueCount)", icon: "person.3.fill", color: AppDesign.primary) + metricCard(title: "平均等待", value: "\(viewModel.avgWaitMinText) 分", icon: "clock.fill", color: AppDesign.warning) + } + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(accountContext.currentScenic?.name ?? "未选择景区") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text("打卡点:\(viewModel.currentSpotName)") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + Text(viewModel.lastSyncTimeText) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + } + + private var gateCard: some View { + VStack(spacing: AppMetrics.Spacing.medium) { + Image(systemName: "mappin.and.ellipse") + .font(.system(size: 42, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + Text("请先设置排队打卡点") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text("排队列表需要固定打卡点后才能加载。") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + NavigationLink { + ScenicQueueSettingsView() + } label: { + Label("前往设置", systemImage: "gearshape") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + } + .buttonStyle(.borderedProminent) + } + .padding(AppMetrics.Spacing.large) + .frame(maxWidth: .infinity) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + private var listSection: some View { + VStack(spacing: AppMetrics.Spacing.medium) { + Picker("排队类型", selection: Binding( + get: { viewModel.selectedListType }, + set: { type in + Task { await viewModel.selectListType(type, api: queueAPI, scenicId: currentScenicId, userId: currentUserId) } + } + )) { + ForEach(QueueListType.allCases) { type in + Text(type.title).tag(type) + } + } + .pickerStyle(.segmented) + + if viewModel.loading && viewModel.items.isEmpty { + ProgressView("加载中...") + .frame(maxWidth: .infinity, minHeight: 240) + } else if viewModel.items.isEmpty { + ContentUnavailableView(viewModel.selectedListType.emptyText, systemImage: "person.crop.circle.badge.questionmark") + .frame(maxWidth: .infinity, minHeight: 240) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } else { + LazyVStack(spacing: AppMetrics.Spacing.medium) { + ForEach(Array(viewModel.items.enumerated()), id: \.element.id) { index, item in + QueueTicketCard( + item: item, + index: index + 1, + listType: viewModel.selectedListType, + processing: processingId == item.id, + onCall: { pendingAction = QueueActionConfirmation(kind: item.isCalled == 1 ? .recall : .call, item: item) }, + onPass: { pendingAction = QueueActionConfirmation(kind: .pass, item: item) }, + onFinish: { pendingAction = QueueActionConfirmation(kind: .finish, item: item) }, + onRequeue: { pendingAction = QueueActionConfirmation(kind: .requeue, item: item) }, + onMark: { markTarget = item } + ) + .onAppear { + if item.id == viewModel.items.last?.id { + Task { await viewModel.loadMore(api: queueAPI, scenicId: currentScenicId, userId: currentUserId) } + } + } + } + if viewModel.loadingMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, AppMetrics.Spacing.medium) + } + } + } + } + } + + private func metricCard(title: String, value: String, icon: String, color: Color) -> some View { + HStack(spacing: AppMetrics.Spacing.medium) { + Image(systemName: icon) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(color) + .frame(width: 38, height: 38) + .background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text(value) + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + private func reload() async { + await viewModel.reload( + api: queueAPI, + scenicId: currentScenicId, + userId: currentUserId, + spots: scenicSpotContext.spots + ) + } + + private func perform(_ action: QueueActionConfirmation) async { + processingId = action.item.id + defer { processingId = nil } + do { + switch action.kind { + case .call, .recall: + try await viewModel.callQueue(api: queueAPI, id: action.item.id) + case .pass: + try await viewModel.passQueue(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, id: action.item.id) + case .finish: + _ = try await viewModel.finishQueue(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, id: action.item.id) + case .requeue: + try await viewModel.requeue(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, id: action.item.id, operatorId: Int(currentUserId ?? "") ?? 0) + } + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func userMark(item: QueueItem, markAsFreelance: Bool, banDays: Int) async -> Bool { + guard let scenicId = currentScenicId else { return false } + processingId = item.id + defer { processingId = nil } + do { + try await viewModel.userMark( + api: queueAPI, + scenicId: scenicId, + userId: currentUserId, + request: ScenicQueueUserMarkRequest( + uid: item.uid, + scenicId: Int64(scenicId), + markAsFreelancePhotog: markAsFreelance ? 1 : 0, + queueBanDays: markAsFreelance ? banDays : nil, + operatorId: Int(currentUserId ?? "") + ) + ) + return true + } catch { + toastCenter.show(error.localizedDescription) + return false + } + } +} + +private struct QueueTicketCard: View { + let item: QueueItem + let index: Int + let listType: QueueListType + let processing: Bool + let onCall: () -> Void + let onPass: () -> Void + let onFinish: () -> Void + let onRequeue: () -> Void + let onMark: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(item.queueCode.isEmpty ? "\(index)" : item.queueCode) + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(AppDesign.textPrimary) + if item.isCalled == 1 || item.statusText.contains("已叫号") { + tag("已叫号", color: AppDesign.primary) + } + if item.shouldShowIdentityTag { + tag(item.identityTag, color: AppDesign.warning) + } + } + Text(item.phoneMasked) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 5) { + Text(item.statusText.isEmpty ? "--" : item.statusText) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + Text(item.queueTimeDisplay) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + } + + HStack(spacing: AppMetrics.Spacing.small) { + info("等待", "\(item.waitMin)分") + info("前方", "\(item.aheadCount)位") + info("今日", "\(item.queueCountToday)次") + } + + if !item.queueBanLabel.isEmpty || item.isMissRequeueRecord { + HStack(spacing: 8) { + if !item.queueBanLabel.isEmpty { + tag(item.queueBanLabel, color: AppDesign.warning) + } + if item.isMissRequeueRecord { + tag(item.missRequeueText.isEmpty ? "重排" : item.missRequeueText, color: AppDesign.warning) + } + } + } + + HStack(spacing: AppMetrics.Spacing.small) { + if listType == .queueing { + actionButton(item.isCalled == 1 ? "重叫" : "叫号", icon: "speaker.wave.2.fill", action: onCall) + actionButton("过号", icon: "forward.end.fill", action: onPass) + actionButton("完成", icon: "checkmark.circle.fill", action: onFinish) + } else { + actionButton("重排", icon: "arrow.uturn.left", action: onRequeue) + } + actionButton("标记", icon: "person.badge.key.fill", action: onMark) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + .opacity(processing ? 0.6 : 1) + } + + private func info(_ title: String, _ value: String) -> some View { + VStack(spacing: 4) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text(value) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + } + + private func tag(_ text: String, color: Color) -> some View { + Text(text) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.12), in: Capsule()) + } + + private func actionButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: icon) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.bordered) + } +} + +private struct QueueUserMarkView: View { + let item: QueueItem + let onConfirm: (Bool, Int) async -> Bool + @Environment(\.dismiss) private var dismiss + @State private var markAsFreelance = true + @State private var queueBanDays = 7 + @State private var submitting = false + + var body: some View { + NavigationStack { + Form { + Section("用户") { + Text(item.queueCode.isEmpty ? "当前游客" : item.queueCode) + Text(item.phoneMasked) + } + Section("标记") { + Toggle("标记为打野摄影师", isOn: $markAsFreelance) + Stepper("限制排队 \(queueBanDays) 天", value: $queueBanDays, in: 0...999) + } + } + .navigationTitle("用户标记") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(submitting ? "提交中" : "提交") { + Task { + submitting = true + let ok = await onConfirm(markAsFreelance, queueBanDays) + submitting = false + if ok { dismiss() } + } + } + .disabled(submitting) + } + } + } + } +} + +private struct QueueActionConfirmation: Identifiable { + enum Kind { + case call + case recall + case pass + case finish + case requeue + } + + let kind: Kind + let item: QueueItem + + var id: String { "\(kind)-\(item.id)" } + + var title: String { + switch kind { + case .call: return "确认叫号" + case .recall: return "确认重新叫号" + case .pass: return "确认过号" + case .finish: return "确认完成" + case .requeue: return "确认重新排队" + } + } + + var message: String { + let queueCode = item.queueCode.isEmpty ? "该游客" : item.queueCode + switch kind { + case .call: + return "确定要叫号 \(queueCode) 吗?" + case .recall: + return "确定要重新叫号 \(queueCode) 吗?" + case .pass: + return "确定将 \(queueCode) 标记为过号吗?" + case .finish: + return "确定 \(queueCode) 已完成拍摄吗?" + case .requeue: + return "确定将 \(queueCode) 重新插入当前排队队列吗?" + } + } +} diff --git a/suixinkan/Features/QueueManagement/Views/ScenicQueueSettingsViews.swift b/suixinkan/Features/QueueManagement/Views/ScenicQueueSettingsViews.swift new file mode 100644 index 0000000..e8457ff --- /dev/null +++ b/suixinkan/Features/QueueManagement/Views/ScenicQueueSettingsViews.swift @@ -0,0 +1,343 @@ +// +// ScenicQueueSettingsViews.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import SwiftUI + +/// 排队设置页面。 +struct ScenicQueueSettingsView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(ScenicSpotContext.self) private var scenicSpotContext + @Environment(ScenicQueueAPI.self) private var queueAPI + @Environment(ScenicQueueRuntime.self) private var queueRuntime + @Environment(ToastCenter.self) private var toastCenter + @State private var viewModel = ScenicQueueSettingsViewModel() + @State private var selectedTab: ScenicQueueSettingsTab = .basic + @State private var qrcodeURL: String? + @State private var saving = false + + private var currentScenicId: Int? { accountContext.currentScenic?.id } + private var currentUserId: String? { accountContext.profile?.userId } + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + Picker("设置分组", selection: $selectedTab) { + ForEach(ScenicQueueSettingsTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + + switch selectedTab { + case .basic: + basicSection + case .notice: + noticeSection + case .voice: + voiceSection + case .logs: + logsSection + } + } + .padding(AppMetrics.Spacing.pageHorizontal) + } + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle("排队设置") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + Task { await fetchQRCode() } + } label: { + Image(systemName: "qrcode") + } + .accessibilityLabel("取号二维码") + + Button(saving ? "保存中" : "保存") { + Task { await save() } + } + .disabled(saving || viewModel.loading) + } + } + .sheet(item: Binding( + get: { qrcodeURL.map(QueueQRCodeSheetItem.init(url:)) }, + set: { qrcodeURL = $0?.url } + )) { item in + QueueQRCodeView(url: item.url) { + await saveQRCode(url: item.url) + } + } + .task(id: taskID) { + await viewModel.load(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, spots: scenicSpotContext.spots) + await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId) + } + .onChange(of: viewModel.message) { _, message in + if let message { toastCenter.show(message) } + } + } + + private var taskID: String { + "\(currentScenicId ?? 0)-\(scenicSpotContext.spots.map(\.id).description)" + } + + private var basicSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + sectionTitle("基础设置") + Picker("打卡点", selection: Binding( + get: { viewModel.selectedSpotId ?? 0 }, + set: { id in + viewModel.selectedSpotId = id == 0 ? nil : id + Task { await viewModel.loadSelectedSpotSetting(api: queueAPI, scenicId: currentScenicId, userId: currentUserId) } + } + )) { + Text("请选择").tag(0) + ForEach(viewModel.scenicSpots) { spot in + Text(spot.name).tag(spot.id) + } + } + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle("开放排队", isOn: $viewModel.queueOpen) + DatePicker("营业开始", selection: $viewModel.businessStartTime, displayedComponents: .hourAndMinute) + DatePicker("营业结束", selection: $viewModel.businessEndTime, displayedComponents: .hourAndMinute) + field("最大排队范围", text: $viewModel.maxQueueRangeKm, keyboard: .decimalPad, unit: "公里") + .onChange(of: viewModel.maxQueueRangeKm) { _, value in viewModel.updateMaxQueueRange(value) } + field("排队次数限制", text: $viewModel.localQueueLimit, keyboard: .numberPad, unit: "次") + field("过号顺延", text: $viewModel.localPassDelay, keyboard: .numberPad, unit: "位") + field("拍摄分钟", text: $viewModel.photoEstimateMin, keyboard: .numberPad, unit: "分") + field("拍摄秒数", text: $viewModel.photoEstimateSec, keyboard: .numberPad, unit: "秒") + Toggle("展示开始拍摄按钮", isOn: $viewModel.showStartShootingButton) + field("自动叫号后续", text: $viewModel.autoCallAheadCount, keyboard: .numberPad, unit: "位") + Toggle("快捷叫号按钮", isOn: $viewModel.quickCallButtonEnabled) + Toggle("预备叫号按钮", isOn: $viewModel.prepareCallButtonEnabled) + } + .cardStyle() + } + + private var noticeSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + sectionTitle("通知规则") + field("第一次通知阈值", text: $viewModel.firstAhead, keyboard: .numberPad, unit: "位") + Toggle("第一次短信通知", isOn: $viewModel.firstSms) + Toggle("第一次电话通知", isOn: $viewModel.firstPhone) + Divider() + field("第二次通知阈值", text: $viewModel.secondAhead, keyboard: .numberPad, unit: "位") + Toggle("第二次短信通知", isOn: $viewModel.secondSms) + Toggle("第二次电话通知", isOn: $viewModel.secondPhone) + } + .cardStyle() + } + + private var voiceSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + sectionTitle("语音播报") + Toggle("本地语音合成提醒", isOn: Binding( + get: { queueRuntime.voiceEnabled }, + set: { queueRuntime.voiceEnabled = $0 } + )) + Toggle("后台短时轮询", isOn: Binding( + get: { queueRuntime.backgroundPollingEnabled }, + set: { queueRuntime.backgroundPollingEnabled = $0 } + )) + field("播报间隔", text: $viewModel.broadcastIntervalSec, keyboard: .numberPad, unit: "秒") + field("读秒阈值", text: $viewModel.countdownThresholdSec, keyboard: .numberPad, unit: "秒") + + TextEditor(text: $viewModel.customTtsText) + .frame(minHeight: 88) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 88) + HStack { + Button("试听") { viewModel.playCustomText() } + .buttonStyle(.bordered) + Button("保存为预设") { viewModel.saveCustomTextAsPreset() } + .buttonStyle(.borderedProminent) + } + Button("播放测试音") { viewModel.playTestSound() } + .buttonStyle(.bordered) + + if !viewModel.presetVoices.isEmpty { + ForEach(Array(viewModel.presetVoices.enumerated()), id: \.offset) { index, text in + HStack { + Text(text) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(2) + Spacer() + Button { + viewModel.deletePresetVoice(at: index) + } label: { + Image(systemName: "trash") + } + } + .padding(.vertical, 6) + } + } + } + .cardStyle() + } + + private var logsSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + HStack { + sectionTitle("配置日志") + Spacer() + Button { + Task { await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId) } + } label: { + Image(systemName: "arrow.clockwise") + } + } + if viewModel.logs.isEmpty { + ContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath") + .frame(maxWidth: .infinity, minHeight: 180) + } else { + ForEach(viewModel.logs) { log in + VStack(alignment: .leading, spacing: 6) { + Text(log.displayText.isEmpty ? log.summary : log.displayText) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + Text("\(log.scenicSpotName) \(log.operatorName) \(log.createdAt)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + Divider() + } + } + } + .cardStyle() + } + + private func field(_ title: String, text: Binding, keyboard: UIKeyboardType, unit: String) -> some View { + HStack(spacing: AppMetrics.Spacing.medium) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textPrimary) + .frame(width: 104, alignment: .leading) + TextField("", text: text) + .keyboardType(keyboard) + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + Text(unit) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + + private func sectionTitle(_ title: String) -> some View { + Text(title) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + } + + private func fetchQRCode() async { + do { + qrcodeURL = try await viewModel.fetchQRCode(api: queueAPI, scenicId: currentScenicId) + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func saveQRCode(url: String) async { + do { + try await viewModel.saveQRCodeToPhotoLibrary(qrcodeURL: url) + toastCenter.show("二维码已保存") + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func save() async { + saving = true + defer { saving = false } + do { + try await viewModel.save(api: queueAPI, scenicId: currentScenicId, userId: currentUserId) + toastCenter.show("排队设置已保存") + await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId) + } catch { + toastCenter.show(error.localizedDescription) + } + } +} + +private enum ScenicQueueSettingsTab: CaseIterable, Identifiable { + case basic + case notice + case voice + case logs + + var id: Self { self } + + var title: String { + switch self { + case .basic: return "基础" + case .notice: return "通知" + case .voice: return "语音" + case .logs: return "日志" + } + } +} + +private struct QueueQRCodeSheetItem: Identifiable { + let url: String + var id: String { url } +} + +private struct QueueQRCodeView: View { + let url: String + let onSave: () async -> Void + @Environment(\.dismiss) private var dismiss + @State private var saving = false + + var body: some View { + NavigationStack { + VStack(spacing: AppMetrics.Spacing.large) { + RemoteImage(urlString: url, contentMode: .fit) { + Image(systemName: "qrcode") + .font(.system(size: 80)) + .foregroundStyle(AppDesign.placeholder) + } + .frame(maxWidth: .infinity) + .frame(height: 280) + .padding(AppMetrics.Spacing.large) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + + Button { + Task { + saving = true + await onSave() + saving = false + } + } label: { + Label(saving ? "保存中" : "保存到相册", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + } + .buttonStyle(.borderedProminent) + .disabled(saving) + } + .padding(AppMetrics.Spacing.pageHorizontal) + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle("取号二维码") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭") { dismiss() } + } + } + } + } +} + +private extension View { + func cardStyle() -> some View { + padding(AppMetrics.Spacing.medium) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} diff --git a/suixinkan/Features/ScenicPermission/ScenicPermission.md b/suixinkan/Features/ScenicPermission/ScenicPermission.md index c293756..6facbe7 100644 --- a/suixinkan/Features/ScenicPermission/ScenicPermission.md +++ b/suixinkan/Features/ScenicPermission/ScenicPermission.md @@ -26,4 +26,4 @@ 当前景区、当前门店、角色 ID 和景区作用域属于账号快照,可保存到 UserDefaults。定位结果、景区申请图片临时数据、OSS STS token、上传进度和表单输入不落盘。 -`scenic_settlement` 和 `scenic_settlement_review` 是首页独立结算业务入口,不属于本模块迁移范围,目前仍保持占位。 +`scenic_settlement` 和 `scenic_settlement_review` 是首页独立结算业务入口,已由 `Features/ScenicSettlement` 接管。ScenicPermission 模块只提供景区列表、景区申请记录和权限申请记录等可复用接口。 diff --git a/suixinkan/Features/ScenicSettlement/API/ScenicSettlementAPI.swift b/suixinkan/Features/ScenicSettlement/API/ScenicSettlementAPI.swift new file mode 100644 index 0000000..319ccaf --- /dev/null +++ b/suixinkan/Features/ScenicSettlement/API/ScenicSettlementAPI.swift @@ -0,0 +1,41 @@ +// +// ScenicSettlementAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +/// 景区结算服务协议,定义结算申请提交能力。 +@MainActor +protocol ScenicSettlementServing { + /// 提交单个景区的结算申请。 + func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws +} + +@MainActor +@Observable +/// 景区结算 API,封装结算申请提交网络请求。 +final class ScenicSettlementAPI { + @ObservationIgnored private let client: APIClient + + /// 初始化景区结算 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 提交单个景区的结算申请。 + func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/scenic-settlement/submit", + body: request + ) + ) + } +} + +extension ScenicSettlementAPI: ScenicSettlementServing {} diff --git a/suixinkan/Features/ScenicSettlement/Models/ScenicSettlementModels.swift b/suixinkan/Features/ScenicSettlement/Models/ScenicSettlementModels.swift new file mode 100644 index 0000000..eb0a490 --- /dev/null +++ b/suixinkan/Features/ScenicSettlement/Models/ScenicSettlementModels.swift @@ -0,0 +1,28 @@ +// +// ScenicSettlementModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 景区结算可选项,表示一个可申请结算的景区。 +struct ScenicSettlementOption: Equatable, Identifiable { + let id: Int + let name: String + var selected: Bool +} + +/// 景区结算提交请求实体。 +struct ScenicSettlementSubmitRequest: Encodable, Equatable { + let scenicId: Int + let applyAmount: String + let applyRemark: String + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case applyAmount = "apply_amount" + case applyRemark = "apply_remark" + } +} diff --git a/suixinkan/Features/ScenicSettlement/ScenicSettlement.md b/suixinkan/Features/ScenicSettlement/ScenicSettlement.md new file mode 100644 index 0000000..8b88355 --- /dev/null +++ b/suixinkan/Features/ScenicSettlement/ScenicSettlement.md @@ -0,0 +1,23 @@ +# 景区结算模块 + +## 模块职责 + +`Features/ScenicSettlement` 承接首页 `scenic_settlement` 和 `scenic_settlement_review` 权限入口,负责景区结算申请提交和结算相关审核记录展示。 + +## 结算申请 + +`ScenicSettlementViewModel` 从账号上下文读取已有景区,并通过 `ScenicPermissionAPI.scenicListAll` 加载全部可申请景区,排除已开通景区后供用户多选。 + +提交时校验金额必填、正数且最多两位小数,再按景区 ID 升序逐条调用 `/api/yf-handset-app/photog/scenic-settlement/submit`。全部成功后清空已选景区、金额和备注;任一请求失败时保留当前表单并提示错误。 + +## 结算审核 + +`ScenicSettlementReviewViewModel` 复用景区申请记录和权限申请记录接口: +- `scenicApplicationPendingAll` +- `roleApplyAll` + +两个通道独立容错。单通道失败时展示另一通道数据并提示失败;双通道失败时展示整页失败和重新加载入口。 + +## 边界 + +旧 Android 工程中的景区结算仍是本地 mock 数据。本模块以旧 iOS 已接入的真实结算提交接口和审核记录聚合方式为迁移依据,不新增后端未体现的详情审批操作。 diff --git a/suixinkan/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift b/suixinkan/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift new file mode 100644 index 0000000..0551d48 --- /dev/null +++ b/suixinkan/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift @@ -0,0 +1,264 @@ +// +// ScenicSettlementViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation +import SwiftUI + +@MainActor +@Observable +/// 景区结算申请 ViewModel,管理可申请景区、多选、金额备注和提交。 +final class ScenicSettlementViewModel { + var existingScenics: [BusinessScope] = [] + var options: [ScenicSettlementOption] = [] + var amountText = "" + var remarkText = "" + var isLoading = false + var isSubmitting = false + var loadFailed = false + var loadFailureReason: String? + var message: String? + + private var selectedIds = Set() + + /// 已选择景区数量。 + var selectedCount: Int { + selectedIds.count + } + + /// 当前选择的景区 ID,供测试和提交使用。 + var selectedScenicIds: Set { + selectedIds + } + + /// 金额校验状态。 + var amountValidation: ScenicSettlementAmountValidation { + let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return .empty } + guard trimmed.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil else { + return .invalidFormat + } + guard let decimal = Decimal(string: trimmed), decimal > 0 else { + return .notPositive + } + return .valid + } + + /// 是否允许提交结算申请。 + var canSubmit: Bool { + !isSubmitting && !selectedIds.isEmpty && amountValidation == .valid + } + + /// 加载已开通景区和可申请结算景区。 + func load(api: any ScenicPermissionServing, existingScenics: [BusinessScope]) async { + isLoading = true + loadFailed = false + loadFailureReason = nil + self.existingScenics = existingScenics + defer { isLoading = false } + + do { + let all = try await api.scenicListAll().list + let existingIds = Set(existingScenics.map(\.id)) + options = all + .filter { !existingIds.contains($0.id) } + .map { ScenicSettlementOption(id: $0.id, name: $0.name, selected: selectedIds.contains($0.id)) } + } catch { + options = [] + selectedIds.removeAll() + loadFailed = true + loadFailureReason = error.localizedDescription + message = "可申请景区加载失败,请重试" + } + } + + /// 切换一个景区的结算申请选择状态。 + func toggleScenic(id: Int) { + guard let index = options.firstIndex(where: { $0.id == id }) else { return } + options[index].selected.toggle() + if options[index].selected { + selectedIds.insert(id) + } else { + selectedIds.remove(id) + } + } + + /// 提交结算申请,多景区按 ID 升序逐条提交。 + func submit(api: any ScenicSettlementServing) async -> Bool { + guard !isSubmitting else { return false } + guard canSubmit else { + message = validationMessage + return false + } + + isSubmitting = true + defer { isSubmitting = false } + + let amount = normalizedAmount + let remark = remarkText.trimmingCharacters(in: .whitespacesAndNewlines) + + do { + for scenicId in selectedIds.sorted() { + try await api.scenicSettlementSubmit( + ScenicSettlementSubmitRequest( + scenicId: scenicId, + applyAmount: amount, + applyRemark: remark + ) + ) + } + selectedIds.removeAll() + options = options.map { option in + ScenicSettlementOption(id: option.id, name: option.name, selected: false) + } + amountText = "" + remarkText = "" + message = "提交成功,等待审核" + return true + } catch { + message = error.localizedDescription + return false + } + } + + private var normalizedAmount: String { + let decimal = Decimal(string: amountText.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + return String(format: "%.2f", NSDecimalNumber(decimal: decimal).doubleValue) + } + + private var validationMessage: String { + if selectedIds.isEmpty { return "请先选择景区" } + switch amountValidation { + case .empty: + return "请填写结算金额" + case .invalidFormat: + return "金额格式错误,最多支持两位小数" + case .notPositive: + return "金额需大于 0" + case .valid: + return "" + } + } +} + +/// 景区结算金额校验结果。 +enum ScenicSettlementAmountValidation: Equatable { + case empty + case invalidFormat + case notPositive + case valid + + /// 对应的状态提示文案。 + var badgeText: String { + switch self { + case .empty: "待填写金额" + case .invalidFormat: "金额格式错误" + case .notPositive: "金额需大于 0" + case .valid: "金额有效" + } + } + + /// 对应的状态颜色。 + var badgeColor: Color { + switch self { + case .empty, .notPositive: + return AppDesign.warning + case .invalidFormat: + return Color(hex: 0xDC2626) + case .valid: + return AppDesign.success + } + } +} + +@MainActor +@Observable +/// 景区结算审核 ViewModel,聚合景区申请和权限申请审核记录。 +final class ScenicSettlementReviewViewModel { + var scenicApplications: [ScenicApplicationPendingResponse] = [] + var roleApplications: [RoleApplyPendingResponse] = [] + var isLoading = false + var message: String? + var loadFailedAll = false + var scenicLoadFailed = false + var roleLoadFailed = false + + /// 待审核记录数量。 + var pendingCount: Int { + scenicApplications.filter { $0.status == 1 }.count + roleApplications.filter { $0.status == 1 }.count + } + + /// 加载审核记录,单通道失败时保留另一通道数据。 + func load(api: any ScenicPermissionServing) async { + isLoading = true + loadFailedAll = false + scenicLoadFailed = false + roleLoadFailed = false + message = nil + defer { isLoading = false } + + var errors: [String] = [] + var scenicLoaded = false + var roleLoaded = false + + do { + scenicApplications = try await api.scenicApplicationPendingAll().items + scenicLoaded = true + } catch { + scenicApplications = [] + scenicLoadFailed = true + errors.append("景区申请记录加载失败") + } + + do { + roleApplications = try await api.roleApplyAll() + roleLoaded = true + } catch { + roleApplications = [] + roleLoadFailed = true + errors.append("权限申请记录加载失败") + } + + loadFailedAll = !scenicLoaded && !roleLoaded + if !errors.isEmpty { + message = errors.joined(separator: ";") + } + } + + /// 审核状态文案。 + func statusText(_ status: Int, fallback: String = "") -> String { + let trimmed = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + switch status { + case 1: return "待审核" + case 2: return "已通过" + case 3: return "已驳回" + case 9: return "已取消" + default: return "未知" + } + } + + /// 审核状态图标。 + func statusIcon(_ status: Int) -> String { + switch status { + case 2: return "checkmark.circle.fill" + case 3: return "xmark.octagon.fill" + case 1: return "clock.badge.exclamationmark.fill" + default: return "questionmark.circle.fill" + } + } + + /// 审核状态颜色。 + func statusColor(_ status: Int) -> Color { + switch status { + case 2: return AppDesign.success + case 3: return Color(hex: 0xDC2626) + case 1: return AppDesign.warning + default: return AppDesign.textSecondary + } + } +} diff --git a/suixinkan/Features/ScenicSettlement/Views/ScenicSettlementViews.swift b/suixinkan/Features/ScenicSettlement/Views/ScenicSettlementViews.swift new file mode 100644 index 0000000..3007ad4 --- /dev/null +++ b/suixinkan/Features/ScenicSettlement/Views/ScenicSettlementViews.swift @@ -0,0 +1,432 @@ +// +// ScenicSettlementViews.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import SwiftUI + +/// 景区结算申请页,支持选择未开通结算的景区并提交金额。 +struct ScenicSettlementView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI + @Environment(ScenicSettlementAPI.self) private var scenicSettlementAPI + @Environment(ToastCenter.self) private var toastCenter + @State private var viewModel = ScenicSettlementViewModel() + @State private var showScenicApplication = false + + var body: some View { + VStack(spacing: 0) { + NewScenicBanner { + showScenicApplication = true + } + ScrollView { + VStack(spacing: AppMetrics.Spacing.small) { + existingScenicsSection + selectableScenicsSection + settlementInfoSection + } + .padding(AppMetrics.Spacing.medium) + } + submitButton + } + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle("景区结算") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.load(api: scenicPermissionAPI, existingScenics: accountContext.scenicScopes) + } + .refreshable { + await viewModel.load(api: scenicPermissionAPI, existingScenics: accountContext.scenicScopes) + } + .sheet(isPresented: $showScenicApplication) { + NavigationStack { + ScenicApplicationView() + } + } + .onChange(of: viewModel.message) { _, message in + guard let message else { return } + toastCenter.show(message) + viewModel.message = nil + } + } + + private var existingScenicsSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Text("已有景区") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + if viewModel.existingScenics.isEmpty { + Text("暂无已开通景区") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } else { + ForEach(viewModel.existingScenics) { scenic in + HStack(spacing: AppMetrics.Spacing.xSmall) { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(AppDesign.success) + Text(scenic.name) + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textPrimary) + Spacer(minLength: 0) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private var selectableScenicsSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + HStack { + Text("可申请结算景区") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + Spacer() + formBadge("已选 \(viewModel.selectedCount)", tint: viewModel.selectedCount == 0 ? AppDesign.textSecondary : AppDesign.primary) + } + + if viewModel.isLoading && viewModel.options.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 120) + } else if viewModel.loadFailed { + VStack(spacing: AppMetrics.Spacing.xSmall) { + ContentUnavailableView( + "可申请景区加载失败", + systemImage: "exclamationmark.triangle", + description: Text(viewModel.loadFailureReason ?? "请检查网络后重试") + ) + Button("重新加载") { + Task { await viewModel.load(api: scenicPermissionAPI, existingScenics: accountContext.scenicScopes) } + } + .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, minHeight: 160) + } else if viewModel.options.isEmpty { + Text("暂无可申请结算景区") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } else { + ForEach(viewModel.options) { option in + Button { + viewModel.toggleScenic(id: option.id) + } label: { + HStack { + Text(option.name) + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(option.selected ? AppDesign.primary : AppDesign.textPrimary) + Spacer() + Image(systemName: option.selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(option.selected ? AppDesign.primary : Color(hex: 0xCBD5E1)) + } + .padding(.horizontal, AppMetrics.Spacing.small) + .padding(.vertical, AppMetrics.Spacing.xSmall) + .background(option.selected ? AppDesign.primarySoft : Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private var settlementInfoSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + HStack { + Text("结算信息") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + formBadge("已选 \(viewModel.selectedCount)", tint: viewModel.selectedCount == 0 ? AppDesign.textSecondary : AppDesign.primary) + formBadge(viewModel.amountValidation.badgeText, tint: viewModel.amountValidation.badgeColor) + } + TextField("申请金额(必填)", text: Binding( + get: { viewModel.amountText }, + set: { viewModel.amountText = $0 } + )) + .keyboardType(.decimalPad) + .textFieldStyle(.plain) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(minHeight: 44) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10)) + + TextField("备注信息(可选)", text: Binding( + get: { viewModel.remarkText }, + set: { viewModel.remarkText = $0 } + ), axis: .vertical) + .lineLimit(2...4) + .textFieldStyle(.plain) + .padding(AppMetrics.Spacing.small) + .frame(minHeight: 86, alignment: .topLeading) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10)) + } + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private var submitButton: some View { + Button(viewModel.isSubmitting ? "提交中..." : "提交审核") { + Task { _ = await viewModel.submit(api: scenicSettlementAPI) } + } + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 12)) + .buttonStyle(.plain) + .disabled(!viewModel.canSubmit) + .padding(AppMetrics.Spacing.medium) + .background(.white) + } + + private func formBadge(_ text: String, tint: Color) -> some View { + Text(text) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(tint) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 22) + .background(tint.opacity(0.12), in: Capsule()) + } +} + +/// 景区结算审核记录页,展示景区申请和权限申请审核状态。 +struct ScenicSettlementReviewView: View { + @Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI + @Environment(ToastCenter.self) private var toastCenter + @State private var viewModel = ScenicSettlementReviewViewModel() + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.small) { + if viewModel.loadFailedAll { + fullFailureView + } else { + reviewSummary + scenicSection + roleSection + } + } + .padding(AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle("结算审核") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.load(api: scenicPermissionAPI) + } + .refreshable { + await viewModel.load(api: scenicPermissionAPI) + } + .onChange(of: viewModel.message) { _, message in + guard let message else { return } + toastCenter.show(message) + viewModel.message = nil + } + } + + private var fullFailureView: some View { + VStack(spacing: AppMetrics.Spacing.small) { + ContentUnavailableView( + "审核记录加载失败", + systemImage: "exclamationmark.triangle", + description: Text("景区申请与权限申请均未加载成功,请检查网络后重试") + ) + Button("重新加载") { + Task { await viewModel.load(api: scenicPermissionAPI) } + } + .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 34) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, minHeight: 260) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private var reviewSummary: some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + summaryCard("景区申请", "\(viewModel.scenicApplications.count)") + summaryCard("权限申请", "\(viewModel.roleApplications.count)") + summaryCard("待审核", "\(viewModel.pendingCount)") + } + } + + private var scenicSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + sectionHeader("景区申请记录", count: viewModel.scenicApplications.count, icon: "mountain.2.fill") + if viewModel.scenicLoadFailed { + sectionFailureRow("景区申请记录加载失败") + } else if viewModel.scenicApplications.isEmpty { + Text("暂无景区申请记录") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } else { + ForEach(viewModel.scenicApplications) { item in + reviewRow( + title: item.scenicName, + code: item.code, + subtitle: item.createdAt, + status: viewModel.statusText(item.status), + status: item.status + ) + } + } + } + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private var roleSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + sectionHeader("权限申请记录", count: viewModel.roleApplications.count, icon: "person.badge.shield.checkmark.fill") + if viewModel.roleLoadFailed { + sectionFailureRow("权限申请记录加载失败") + } else if viewModel.roleApplications.isEmpty { + Text("暂无权限申请记录") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } else { + ForEach(viewModel.roleApplications) { item in + reviewRow( + title: "\(item.roleName) · \(scenicNames(item.scenicList))", + code: item.code, + subtitle: item.createdAt, + status: viewModel.statusText(item.status, fallback: item.statusLabel), + status: item.status + ) + } + } + } + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private func sectionFailureRow(_ text: String) -> some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(AppDesign.warning) + Text(text) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Spacer(minLength: 0) + Button("重试") { + Task { await viewModel.load(api: scenicPermissionAPI) } + } + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .buttonStyle(.plain) + } + .padding(AppMetrics.Spacing.xSmall) + .background(Color(hex: 0xFFF7ED), in: RoundedRectangle(cornerRadius: 8)) + } + + private func reviewRow(title: String, code: String, subtitle: String, status statusText: String, status: Int) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Label("申请编号:\(code.settlementDash)", systemImage: "number.square.fill") + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(AppDesign.primary) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 22) + .background(AppDesign.primarySoft, in: Capsule()) + HStack { + Label(statusText, systemImage: viewModel.statusIcon(status)) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(viewModel.statusColor(status)) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 22) + .background(viewModel.statusColor(status).opacity(0.12), in: Capsule()) + Spacer(minLength: 0) + Label(subtitle.settlementDash, systemImage: "clock") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + } + .padding(AppMetrics.Spacing.xSmall) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10)) + } + + private func summaryCard(_ title: String, _ value: String) -> 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.title, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private func sectionHeader(_ title: String, count: Int, icon: String) -> some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Image(systemName: icon) + .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + Text("\(count)") + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(AppDesign.primary) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 22) + .background(AppDesign.primarySoft, in: Capsule()) + } + } + + private func scenicNames(_ items: [RoleApplyScenicItem]) -> String { + let names = items.map(\.name).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + return names.isEmpty ? "--" : names.joined(separator: "、") + } +} + +private struct NewScenicBanner: View { + let onApplyClick: () -> Void + + var body: some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Image(systemName: "building.2.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color(hex: 0xFF7B00)) + Text("没有找到心仪的景区") + .font(.system(size: AppMetrics.FontSize.footnote, weight: .medium)) + .foregroundStyle(Color(hex: 0xFF7B00)) + .lineLimit(1) + .minimumScaleFactor(0.85) + Spacer() + Button(action: onApplyClick) { + HStack(spacing: 4) { + Text("申请平台开通新景区") + .lineLimit(1) + .minimumScaleFactor(0.8) + Image(systemName: "chevron.right") + } + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(Color(hex: 0xFF7B00)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, AppMetrics.Spacing.medium) + .padding(.vertical, AppMetrics.Spacing.small) + .background(Color(hex: 0xFFF0E2)) + } +} + +private extension String { + var settlementDash: String { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "--" : trimmed + } +} diff --git a/suixinkan/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift b/suixinkan/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift new file mode 100644 index 0000000..4baaa53 --- /dev/null +++ b/suixinkan/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift @@ -0,0 +1,139 @@ +// +// WithdrawalAuditViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +/// 提现审核筛选项,按提现记录的状态文案聚合审核视角。 +enum WithdrawalAuditFilter: String, CaseIterable, Identifiable { + case all + case processing + case completed + + var id: String { rawValue } + + /// 筛选项展示标题。 + var title: String { + switch self { + case .all: "全部" + case .processing: "处理中" + case .completed: "已完成" + } + } +} + +@MainActor +@Observable +/// 提现审核 ViewModel,负责提现记录分页、状态筛选和失败状态清理。 +final class WithdrawalAuditViewModel { + var records: [WalletWithdrawRecord] = [] + var selectedFilter: WithdrawalAuditFilter = .all + var isLoading = false + var isLoadingMore = false + var loadFailed = false + var loadFailureReason: String? + var errorMessage: String? + + private let pageSize = 10 + private var page = 1 + private var total = 0 + + /// 当前筛选下的提现记录。 + var filteredRecords: [WalletWithdrawRecord] { + filteredRecords(for: selectedFilter) + } + + /// 是否还有下一页。 + var hasMore: Bool { + records.count < total + } + + /// 记录总数,优先使用服务端 total。 + var totalCount: Int { + max(total, records.count) + } + + /// 处理中数量。 + var processingCount: Int { + filteredRecords(for: .processing).count + } + + /// 已完成数量。 + var completedCount: Int { + filteredRecords(for: .completed).count + } + + /// 按指定筛选返回提现记录。 + func filteredRecords(for filter: WithdrawalAuditFilter) -> [WalletWithdrawRecord] { + switch filter { + case .all: + return records + case .processing: + return records.filter { record in + let status = record.statusLabel + return status.contains("中") || status.contains("待") + } + case .completed: + return records.filter { record in + let status = record.statusLabel + return status.contains("完成") || status.contains("到账") || status.contains("通过") + } + } + } + + /// 切换筛选项,仅影响本地展示。 + func selectFilter(_ filter: WithdrawalAuditFilter) { + selectedFilter = filter + } + + /// 重新加载第一页提现审核记录。 + func reload(api: WalletServing) async { + isLoading = true + loadFailed = false + loadFailureReason = nil + errorMessage = nil + defer { isLoading = false } + + do { + let response = try await api.walletWithdrawList(page: 1, pageSize: pageSize) + records = response.item + total = response.total + page = 1 + } catch { + clearRecords() + loadFailed = true + loadFailureReason = error.localizedDescription + errorMessage = error.localizedDescription + } + } + + /// 加载下一页提现审核记录。 + func loadMore(api: WalletServing) async { + guard hasMore, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + errorMessage = nil + defer { isLoadingMore = false } + + let nextPage = page + 1 + do { + let response = try await api.walletWithdrawList(page: nextPage, pageSize: pageSize) + records += response.item + total = response.total + page = nextPage + } catch { + errorMessage = error.localizedDescription + } + } + + /// 清空旧提现审核数据,避免失败后残留上一账号或上一状态记录。 + private func clearRecords() { + records = [] + page = 1 + total = 0 + isLoadingMore = false + } +} diff --git a/suixinkan/Features/WithdrawalAudit/Views/WithdrawalAuditView.swift b/suixinkan/Features/WithdrawalAudit/Views/WithdrawalAuditView.swift new file mode 100644 index 0000000..00115a5 --- /dev/null +++ b/suixinkan/Features/WithdrawalAudit/Views/WithdrawalAuditView.swift @@ -0,0 +1,311 @@ +// +// WithdrawalAuditView.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import SwiftUI + +/// 提现审核列表页,以审核视角展示钱包提现记录和处理进度。 +struct WithdrawalAuditView: View { + @Environment(WalletAPI.self) private var walletAPI + @Environment(ToastCenter.self) private var toastCenter + @State private var viewModel = WithdrawalAuditViewModel() + @State private var selectedRecord: WalletWithdrawRecord? + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.small) { + summarySection + filterSection + contentSection + } + .padding(AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle("提现审核") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.reload(api: walletAPI) + } + .refreshable { + await viewModel.reload(api: walletAPI) + } + .sheet(item: $selectedRecord) { record in + NavigationStack { + WithdrawalAuditDetailView(record: record) + } + } + .onChange(of: viewModel.errorMessage) { _, message in + guard let message else { return } + toastCenter.show(message) + viewModel.errorMessage = nil + } + } + + private var summarySection: some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + summaryCard("记录总数", "\(viewModel.totalCount)") + summaryCard("处理中", "\(viewModel.processingCount)") + summaryCard("已完成", "\(viewModel.completedCount)") + } + } + + private var filterSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Picker("审核筛选", selection: Binding( + get: { viewModel.selectedFilter }, + set: { viewModel.selectFilter($0) } + )) { + ForEach(WithdrawalAuditFilter.allCases) { filter in + Text(filter.title).tag(filter) + } + } + .pickerStyle(.segmented) + + HStack(spacing: AppMetrics.Spacing.xSmall) { + badge("当前 \(viewModel.selectedFilter.title)", tint: AppDesign.primary) + badge("共 \(viewModel.filteredRecords.count) 条", tint: AppDesign.textSecondary) + Spacer(minLength: 0) + } + } + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + @ViewBuilder + private var contentSection: some View { + if viewModel.isLoading && viewModel.records.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 220) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } else if viewModel.loadFailed && viewModel.records.isEmpty { + VStack(spacing: AppMetrics.Spacing.small) { + ContentUnavailableView( + "提现审核记录加载失败", + systemImage: "exclamationmark.triangle", + description: Text(viewModel.loadFailureReason ?? "请检查网络后重试") + ) + Button("重新加载") { + Task { await viewModel.reload(api: walletAPI) } + } + .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 34) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, minHeight: 220) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } else if viewModel.filteredRecords.isEmpty { + ContentUnavailableView("暂无提现审核记录", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新")) + .frame(maxWidth: .infinity, minHeight: 220) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } else { + VStack(spacing: AppMetrics.Spacing.small) { + ForEach(viewModel.filteredRecords) { record in + recordCard(record) + } + if viewModel.hasMore { + Button(viewModel.isLoadingMore ? "加载中..." : "加载更多") { + Task { await viewModel.loadMore(api: walletAPI) } + } + .font(.system(size: AppMetrics.FontSize.footnote, weight: .medium)) + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity) + .frame(height: 40) + .background(.white, in: RoundedRectangle(cornerRadius: 10)) + .buttonStyle(.plain) + .disabled(viewModel.isLoadingMore) + } + } + } + } + + private func recordCard(_ record: WalletWithdrawRecord) -> some View { + HStack(alignment: .top, spacing: AppMetrics.Spacing.small) { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Text("¥ \(moneyText(record.amount))") + .font(.system(size: AppMetrics.FontSize.title, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + Text("申请时间:\(record.createdAt.withdrawalAuditDash)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer(minLength: AppMetrics.Spacing.small) + VStack(alignment: .trailing, spacing: AppMetrics.Spacing.xSmall) { + Text(statusText(record.statusLabel)) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(statusColor(record.statusLabel)) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 24) + .background(statusColor(record.statusLabel).opacity(0.12), in: Capsule()) + Button("查看详情") { + selectedRecord = record + } + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(AppDesign.primary) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 28) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) + .buttonStyle(.plain) + } + } + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private func summaryCard(_ title: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text(value) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private func badge(_ text: String, tint: Color) -> some View { + Text(text) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(tint) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 22) + .background(tint.opacity(0.12), in: Capsule()) + } +} + +/// 提现审核详情页,展示单笔提现记录的审核时间线。 +private struct WithdrawalAuditDetailView: View { + let record: WalletWithdrawRecord + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.small) { + card { + row("提现金额", "¥ \(moneyText(record.amount))") + row("申请时间", record.createdAt.withdrawalAuditDash) + HStack { + Text("当前状态") + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textSecondary) + Spacer() + Text(statusText(record.statusLabel)) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(statusColor(record.statusLabel)) + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 24) + .background(statusColor(record.statusLabel).opacity(0.12), in: Capsule()) + } + if let expectedAt = record.expectedAt?.withdrawalAuditNonEmpty { + row("预计到账", expectedAt) + } + if let auditTime = record.auditTime?.withdrawalAuditNonEmpty { + row("审核时间", auditTime) + } + if let completedAt = record.completedAt?.withdrawalAuditNonEmpty { + row("到账时间", completedAt) + } + } + + card { + Text("处理进度") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + timelineRow("1. 提交申请", record.createdAt.withdrawalAuditDash, done: true) + timelineRow("2. 审核中", record.auditTime?.withdrawalAuditDash ?? "--", done: true) + timelineRow("3. 打款中", record.expectedAt?.withdrawalAuditDash ?? "--", done: record.completedAt?.withdrawalAuditNonEmpty == nil) + timelineRow("4. 已完成", record.completedAt?.withdrawalAuditDash ?? "--", done: record.completedAt?.withdrawalAuditNonEmpty != nil) + } + } + .padding(AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .navigationTitle("审核详情") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("关闭") { dismiss() } + } + } + } + + private func card(@ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + content() + } + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + } + + private func row(_ title: String, _ value: String) -> some View { + HStack { + Text(title) + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textSecondary) + Spacer() + Text(value) + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textPrimary) + .multilineTextAlignment(.trailing) + } + } + + private func timelineRow(_ title: String, _ time: String, done: Bool) -> some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Circle() + .fill(done ? AppDesign.success : Color(hex: 0xD1D5DB)) + .frame(width: 8, height: 8) + Text(title) + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + Text(time) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + .padding(.horizontal, AppMetrics.Spacing.small) + .frame(height: 34) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private func statusText(_ status: String) -> String { + status.withdrawalAuditNonEmpty ?? "处理中" +} + +private func statusColor(_ status: String) -> Color { + if status.contains("通过") || status.contains("完成") || status.contains("到账") { + return AppDesign.success + } + if status.contains("拒") || status.contains("失败") { + return Color(hex: 0xDC2626) + } + return AppDesign.warning +} + +private func moneyText(_ raw: String) -> String { + let value = raw.filter { "0123456789.-".contains($0) } + guard let decimal = Decimal(string: value), decimal > 0 else { + return "0.00" + } + return NSDecimalNumber(decimal: decimal).stringValue +} + +private extension String { + var withdrawalAuditNonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + var withdrawalAuditDash: String { + withdrawalAuditNonEmpty ?? "--" + } +} diff --git a/suixinkan/Features/WithdrawalAudit/WithdrawalAudit.md b/suixinkan/Features/WithdrawalAudit/WithdrawalAudit.md new file mode 100644 index 0000000..6da5588 --- /dev/null +++ b/suixinkan/Features/WithdrawalAudit/WithdrawalAudit.md @@ -0,0 +1,16 @@ +# 提现审核模块 + +## 模块职责 + +`Features/WithdrawalAudit` 承接首页 `withdrawal_audit` 权限入口,以审核视角展示钱包提现记录、状态筛选和单笔处理进度。 + +## 业务流程 + +- `WithdrawalAuditView` 进入后调用 `WalletAPI.walletWithdrawList` 加载第一页提现记录。 +- `WithdrawalAuditViewModel` 管理分页、加载更多、筛选状态和失败清理。 +- 筛选只在本地执行:处理中匹配状态文案中的“中/待”,已完成匹配“完成/到账/通过”。 +- 刷新失败会清空旧记录和分页状态,避免账号或权限切换后残留上一组财务数据。 + +## 边界 + +本模块不做管理员提现审批操作。旧 Android 工程未找到提现审批列表或操作接口,当前按旧 iOS 的提现记录审核视角迁移。 diff --git a/suixinkanTests/Fixtures/deposit_order_list_success.json b/suixinkanTests/Fixtures/deposit_order_list_success.json new file mode 100644 index 0000000..87c9f12 --- /dev/null +++ b/suixinkanTests/Fixtures/deposit_order_list_success.json @@ -0,0 +1,27 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "2", + "list": [ + { + "id": "4101", + "order_number": "DEP001", + "user_phone": "13800000000", + "amount": 99.5, + "status": "20", + "status_name": "待核销", + "created_at": "2026-05-23 09:00:00" + }, + { + "id": 4102, + "order_number": "DEP002", + "user_phone": 13900000000, + "amount": "199.00", + "status": 50, + "status_name": "已退款", + "created_at": "2026-05-22 18:30:00" + } + ] + } +} diff --git a/suixinkanTests/Fixtures/message_list_page2_success.json b/suixinkanTests/Fixtures/message_list_page2_success.json new file mode 100644 index 0000000..d50d57b --- /dev/null +++ b/suixinkanTests/Fixtures/message_list_page2_success.json @@ -0,0 +1,30 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "has_more": 0, + "last_id": 99, + "items": [ + { + "id": 100, + "type": 2, + "type_name": "核销", + "title": "", + "content": "订单已核销", + "push_at": "2026-05-23 02:50", + "created_at": "2026-05-23 02:50", + "is_read": "1" + }, + { + "id": 99, + "type": 0, + "type_name": "", + "title": "", + "content": "系统维护通知", + "push_at": "2026-05-23 02:40", + "created_at": "2026-05-23 02:40", + "is_read": false + } + ] + } +} diff --git a/suixinkanTests/Fixtures/message_list_success.json b/suixinkanTests/Fixtures/message_list_success.json new file mode 100644 index 0000000..6802677 --- /dev/null +++ b/suixinkanTests/Fixtures/message_list_success.json @@ -0,0 +1,30 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "has_more": 1, + "last_id": "101", + "items": [ + { + "id": "101", + "type": "1", + "type_name": "订单", + "title": "新订单提醒", + "content": "你有新的拍摄订单", + "push_at": "2026-05-23 02:55", + "created_at": "2026-05-23 02:55", + "is_read": 0 + }, + { + "id": 100, + "type": 2, + "type_name": "核销", + "title": "", + "content": "订单已核销", + "push_at": "2026-05-23 02:50", + "created_at": "2026-05-23 02:50", + "is_read": "1" + } + ] + } +} diff --git a/suixinkanTests/Fixtures/multi_travel_shoot_history_success.json b/suixinkanTests/Fixtures/multi_travel_shoot_history_success.json new file mode 100644 index 0000000..8a91a47 --- /dev/null +++ b/suixinkanTests/Fixtures/multi_travel_shoot_history_success.json @@ -0,0 +1,36 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "project_name": "多点旅拍", + "project_type": "19", + "project_type_name": "多点旅拍", + "photog_spot_list": [ + { + "scenic_spot_id": "901", + "photog_uid": "42", + "scenic_spot_name": "湖心亭", + "photog_nickname": "小景", + "photog_name": "测试摄影师", + "files": [ + { + "file_name": "spot-901.jpg", + "file_url": "https://cdn.example.com/multi/spot-901.jpg", + "file_type": "1", + "file_size": "204800", + "cover_url": "", + "upload_time": "2026-05-23 04:22:00" + }, + { + "file_name": "spot-901.mp4", + "file_url": "https://cdn.example.com/multi/spot-901.mp4", + "file_type": 2, + "file_size": 1048576, + "cover_url": "https://cdn.example.com/multi/spot-901-cover.jpg", + "upload_time": "2026-05-23 04:23:00" + } + ] + } + ] + } +} diff --git a/suixinkanTests/Fixtures/multi_travel_verified_spots_success.json b/suixinkanTests/Fixtures/multi_travel_verified_spots_success.json new file mode 100644 index 0000000..8e488f6 --- /dev/null +++ b/suixinkanTests/Fixtures/multi_travel_verified_spots_success.json @@ -0,0 +1,14 @@ +{ + "code": 100000, + "msg": "success", + "data": [ + { + "id": 901, + "name": "湖心亭" + }, + { + "id": "902", + "name": "观景台" + } + ] +} diff --git a/suixinkanTests/Fixtures/scenic_queue_home_success.json b/suixinkanTests/Fixtures/scenic_queue_home_success.json new file mode 100644 index 0000000..aeb17c8 --- /dev/null +++ b/suixinkanTests/Fixtures/scenic_queue_home_success.json @@ -0,0 +1,63 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "stats": { + "type": "1" + }, + "list": { + "total": "2", + "page": "1", + "page_size": "20", + "list": [ + { + "id": "1001", + "queue_code": "A001", + "mobile": "13800000000", + "status": "1", + "status_text": "等待中", + "wait_min": "8", + "ahead_count": "0", + "is_called": "1", + "queue_ban_label": "限制排队7天", + "identity_tag": "打野摄影师", + "queue_time": "2026-05-23 03:05:00", + "queue_count_today": "2", + "uid": "7001", + "mark_as_photog": "0", + "mark_as_freelance_photog": "1", + "is_miss_requeue": "1", + "is_miss_requeue_text": "重排", + "created_at": "2026-05-23 03:05", + "called_at": "2026-05-23 03:06", + "expired_at": "", + "finished_at": "" + }, + { + "id": 1002, + "queue_code": "A002", + "mobile": "13900000000", + "status": 1, + "status_text": "等待中", + "wait_min": 10, + "ahead_count": 1, + "is_called": 0, + "queue_ban_label": "", + "identity_tag": "普通用户", + "queue_time": "05-23 03:07", + "queue_count_today": 1, + "uid": 7002, + "mark_as_photog": 1, + "mark_as_freelance_photog": 0, + "is_miss_requeue": 0, + "is_miss_requeue_text": "", + "created_at": "2026-05-23 03:07", + "called_at": "", + "expired_at": "", + "finished_at": "" + } + ] + }, + "time": "2026-05-23 03:08" + } +} diff --git a/suixinkanTests/Fixtures/scenic_queue_qrcode_success.json b/suixinkanTests/Fixtures/scenic_queue_qrcode_success.json new file mode 100644 index 0000000..2524204 --- /dev/null +++ b/suixinkanTests/Fixtures/scenic_queue_qrcode_success.json @@ -0,0 +1,7 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "qrcode_url": "https://cdn.example.com/queue/spot-9.png" + } +} diff --git a/suixinkanTests/Fixtures/scenic_queue_setting_success.json b/suixinkanTests/Fixtures/scenic_queue_setting_success.json new file mode 100644 index 0000000..8aa7576 --- /dev/null +++ b/suixinkanTests/Fixtures/scenic_queue_setting_success.json @@ -0,0 +1,40 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "exists": "1", + "setting": { + "id": "11", + "scenic_id": "88", + "scenic_spot_id": "9", + "scenic_spot_name": "东门打卡点", + "photo_estimate_min": "2", + "photo_estimate_sec": "30", + "first_notice_threshold_pos": "3", + "first_notice_sms_enabled": "1", + "first_notice_call_enabled": "1", + "second_notice_threshold_pos": "1", + "second_notice_sms_enabled": "0", + "second_notice_call_enabled": "1", + "countdown_broadcast_interval_sec": "15", + "countdown_readable_threshold_sec": "60", + "business_start_time": "08:00:00", + "business_end_time": "22:00:59", + "status": "1", + "remark": "自动叫号", + "voice_broadcasts": [ + { + "content": "A001请到拍摄点", + "sort_order": "1" + }, + { + "content": "请后续游客到等候区", + "sort_order": "2" + } + ], + "created_at": "2026-05-23 03:10", + "updated_at": "2026-05-23 03:11" + }, + "time": "2026-05-23 03:12" + } +} diff --git a/suixinkanTests/Fixtures/scenic_queue_stats_success.json b/suixinkanTests/Fixtures/scenic_queue_stats_success.json new file mode 100644 index 0000000..3f863dd --- /dev/null +++ b/suixinkanTests/Fixtures/scenic_queue_stats_success.json @@ -0,0 +1,9 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "queue_count": "5", + "avg_wait_min": "12.5", + "time": "2026-05-23 03:10" + } +} diff --git a/suixinkanTests/Fixtures/scenic_spot_list_all_success.json b/suixinkanTests/Fixtures/scenic_spot_list_all_success.json new file mode 100644 index 0000000..64a5961 --- /dev/null +++ b/suixinkanTests/Fixtures/scenic_spot_list_all_success.json @@ -0,0 +1,21 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "2", + "list": [ + { + "id": "901", + "name": "湖心亭", + "status": "1", + "status_label": "启用" + }, + { + "id": 902, + "name": "观景台", + "status": 0, + "status_label": "停用" + } + ] + } +} diff --git a/suixinkanTests/Fixtures/store_order_shooting_detail_success.json b/suixinkanTests/Fixtures/store_order_shooting_detail_success.json new file mode 100644 index 0000000..f10353d --- /dev/null +++ b/suixinkanTests/Fixtures/store_order_shooting_detail_success.json @@ -0,0 +1,44 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "scenic_spot_id": "901", + "scenic_spot_name": "湖心亭", + "order_type": "4", + "order_type_name": "押金订单", + "order_comment": { + "star_shooting": "5", + "star_retouching": "4", + "star_scenery": "5", + "star_service": "5", + "star_camera": "4", + "content": "拍摄体验很好", + "created_at": "2026-05-23 12:00:00" + }, + "material_list": [ + { + "file_name": "negative-video.mp4", + "file_url": "https://cdn.example.com/deposit/negative-video.mp4", + "file_type": "1", + "file_size": "10485760", + "cover_url": "https://cdn.example.com/deposit/negative-video.jpg" + }, + { + "file_name": "negative-photo.jpg", + "file_url": "https://cdn.example.com/deposit/negative-photo.jpg", + "file_type": 2, + "file_size": 204800, + "cover_url": "" + } + ], + "complete_list": [ + { + "file_name": "retouch.jpg", + "file_url": "https://cdn.example.com/deposit/retouch.jpg", + "file_type": "2", + "file_size": "409600", + "cover_url": "https://cdn.example.com/deposit/retouch-thumb.jpg" + } + ] + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index bdc21d3..043bfb8 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -53,21 +53,22 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_invite", title: ""), .destination(.photographerInvite)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_detail", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "withdrawal_audit", title: ""), .destination(.withdrawalAudit)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.scenicSettlement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.scenicSettlementReview)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) } /// 测试已知但未迁移的首页路由会进入安全占位。 func testKnownUnmigratedRoutesResolveToPlaceholders() { XCTAssertEqual( - HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), - .destination(.modulePlaceholder(uri: "scenic_settlement", title: "景区结算")) - ) - XCTAssertEqual( - HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), - .destination(.modulePlaceholder(uri: "scenic_settlement_review", title: "结算审核")) - ) - XCTAssertEqual( - HomeMenuRouter.resolve(uri: "message_center", title: ""), - .destination(.modulePlaceholder(uri: "message_center", title: "消息中心")) + HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), + .destination(.modulePlaceholder(uri: "live_stream_management", title: "直播管理")) ) } @@ -114,6 +115,8 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "payment_code"), "payment_collection") XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "deposit_order_shooting_info"), "deposit_order") XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-order-manage"), "photographer_orders") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-queue"), "queue_management") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "queue_management"), "queue_management") XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "android_only_module"), "android_only_module") } @@ -187,6 +190,11 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertTrue(uris.contains("schedule_management")) XCTAssertTrue(uris.contains("registration_invitation")) XCTAssertTrue(uris.contains("invite_record")) + XCTAssertTrue(uris.contains("deposit_order")) + XCTAssertTrue(uris.contains("deposit_order_shooting_info")) + XCTAssertTrue(uris.contains("message_center")) + XCTAssertTrue(uris.contains("/scenic-queue")) + XCTAssertTrue(uris.contains("queue_management")) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) @@ -197,6 +205,11 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) } #endif diff --git a/suixinkanTests/MainTabBadgeViewModelTests.swift b/suixinkanTests/MainTabBadgeViewModelTests.swift index 7e1be08..9219487 100644 --- a/suixinkanTests/MainTabBadgeViewModelTests.swift +++ b/suixinkanTests/MainTabBadgeViewModelTests.swift @@ -103,4 +103,41 @@ private final class MockMainTabOrderService: OrderServing { func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") } + + /// 角标测试不会请求押金订单列表。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 角标测试不会触发押金核销。 + func depositOrderWriteOff(orderNumber: String) async throws {} + + /// 角标测试不会触发押金退款。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws {} + + /// 角标测试不会请求拍摄信息。 + func storeOrderShootingDetail( + storeId: Int, + orderNumber: String, + scenicSpotId: Int, + photogUid: Int + ) async throws -> StoreOrderShootingDetailResponse { + try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") + } + + /// 角标测试不会触发普通退款。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {} + + /// 角标测试不会请求历史拍摄。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") + } + + /// 角标测试不会请求已核销打卡点。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + [] + } + + /// 角标测试不会提交多点旅拍素材。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {} } diff --git a/suixinkanTests/MessageCenter/MessageCenterViewModelTests.swift b/suixinkanTests/MessageCenter/MessageCenterViewModelTests.swift new file mode 100644 index 0000000..22a6bd6 --- /dev/null +++ b/suixinkanTests/MessageCenter/MessageCenterViewModelTests.swift @@ -0,0 +1,167 @@ +// +// MessageCenterViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 消息中心测试,覆盖分页、筛选、已读和删除。 +final class MessageCenterViewModelTests: XCTestCase { + func testReloadFirstPageMapsMessagesAndPaginationState() async { + let api = MessageCenterMock() + api.listResponses = [ + MessageListResponse(hasMore: true, lastId: 101, items: [ + MessageEntity(id: 101, type: 1, typeName: "", title: "新订单提醒", content: "有新订单", pushAt: "2026-06-25 10:00", isRead: false), + MessageEntity(id: 100, type: 2, typeName: "核销", title: "", content: "核销完成", pushAt: "2026-06-25 09:00", isRead: true) + ]) + ] + let viewModel = MessageCenterViewModel() + viewModel.selectedFilter = .unread + + await viewModel.reloadFirstPage(api: api) + + XCTAssertEqual(api.listRequests.map(\.unread), [1]) + XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100"]) + XCTAssertEqual(viewModel.messages[0].title, "新订单提醒") + XCTAssertEqual(viewModel.messages[0].type, .order) + XCTAssertEqual(viewModel.messages[1].title, "核销") + XCTAssertEqual(viewModel.messages[1].type, .writeOff) + XCTAssertEqual(viewModel.unreadCount, 1) + XCTAssertTrue(viewModel.hasMoreMessages) + XCTAssertEqual(viewModel.lastId, 101) + } + + func testLoadMoreDeduplicatesSortsAndStopsAtTail() async { + let api = MessageCenterMock() + api.listResponses = [ + MessageListResponse(hasMore: true, lastId: 101, items: [ + MessageEntity(id: 101, type: 1, title: "A", content: "A", pushAt: "2026-06-25 10:00"), + MessageEntity(id: 100, type: 3, title: "B", content: "B", pushAt: "2026-06-25 09:00") + ]), + MessageListResponse(hasMore: false, lastId: 99, items: [ + MessageEntity(id: 100, type: 3, title: "B2", content: "B2", pushAt: "2026-06-25 09:00"), + MessageEntity(id: 99, type: 0, title: "C", content: "C", pushAt: "2026-06-24 08:00") + ]) + ] + let viewModel = MessageCenterViewModel() + + await viewModel.reloadFirstPage(api: api) + await viewModel.loadMore(api: api) + await viewModel.loadMore(api: api) + + XCTAssertEqual(api.listRequests.map(\.lastId), [0, 101]) + XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100", "msg_99"]) + XCTAssertEqual(viewModel.messages[1].title, "B2") + XCTAssertFalse(viewModel.hasMoreMessages) + XCTAssertEqual(viewModel.lastId, 99) + } + + func testReloadFailureClearsStaleMessagesAndPaging() async { + let api = MessageCenterMock() + api.listResponses = [ + MessageListResponse(hasMore: true, lastId: 101, items: [ + MessageEntity(id: 101, type: 1, title: "A") + ]) + ] + let viewModel = MessageCenterViewModel() + await viewModel.reloadFirstPage(api: api) + + api.listError = TestError.sample + await viewModel.reloadFirstPage(api: api) + + XCTAssertTrue(viewModel.messages.isEmpty) + XCTAssertFalse(viewModel.hasMoreMessages) + XCTAssertEqual(viewModel.lastId, 0) + XCTAssertTrue(viewModel.loadFailed) + } + + func testMarkReadAllReadAndDeleteUpdateOnlyAfterSuccess() async throws { + let api = MessageCenterMock() + api.listResponses = [ + MessageListResponse(items: [ + MessageEntity(id: 101, type: 1, title: "A", isRead: false), + MessageEntity(id: 100, type: 0, title: "B", isRead: false) + ]) + ] + let viewModel = MessageCenterViewModel() + await viewModel.reloadFirstPage(api: api) + + try await viewModel.markAsRead(api: api, item: try XCTUnwrap(viewModel.messages.first)) + XCTAssertEqual(api.readIds, [101]) + XCTAssertEqual(viewModel.messages.map(\.isRead), [true, false]) + + api.readIds = [] + try await viewModel.markAllAsRead(api: api) + XCTAssertEqual(api.readIds, [100]) + XCTAssertTrue(viewModel.messages.allSatisfy(\.isRead)) + + let item = try XCTUnwrap(viewModel.messages.first) + try await viewModel.deleteMessage(api: api, item: item) + XCTAssertEqual(api.deleteIds, [101]) + XCTAssertFalse(viewModel.messages.contains(where: { $0.id == item.id })) + } + + func testFailedReadAndDeleteKeepLocalState() async throws { + let api = MessageCenterMock() + api.listResponses = [MessageListResponse(items: [MessageEntity(id: 101, type: 1, title: "A", isRead: false)])] + let viewModel = MessageCenterViewModel() + await viewModel.reloadFirstPage(api: api) + let item = try XCTUnwrap(viewModel.messages.first) + + api.readError = TestError.sample + await XCTAssertThrowsErrorAsync(try await viewModel.markAsRead(api: api, item: item)) + XCTAssertFalse(try XCTUnwrap(viewModel.messages.first).isRead) + + api.readError = nil + api.deleteError = TestError.sample + await XCTAssertThrowsErrorAsync(try await viewModel.deleteMessage(api: api, item: item)) + XCTAssertEqual(viewModel.messages.map(\.id), [item.id]) + } +} + +@MainActor +private final class MessageCenterMock: MessageCenterServing { + var listResponses: [MessageListResponse] = [] + var listError: Error? + var readError: Error? + var deleteError: Error? + var readIds: [Int] = [] + var deleteIds: [Int] = [] + private(set) var listRequests: [(lastId: Int, limit: Int, unread: Int)] = [] + + func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse { + listRequests.append((lastId, limit, unread)) + if let listError { throw listError } + return listResponses.isEmpty ? MessageListResponse() : listResponses.removeFirst() + } + + func messageRead(id: Int) async throws { + if let readError { throw readError } + readIds.append(id) + } + + func messageDelete(id: Int) async throws { + if let deleteError { throw deleteError } + deleteIds.append(id) + } +} + +private enum TestError: LocalizedError { + case sample + var errorDescription: String? { "测试错误" } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + try await expression() + XCTFail("Expected error", file: file, line: line) + } catch {} +} diff --git a/suixinkanTests/NavigationRouterTests.swift b/suixinkanTests/NavigationRouterTests.swift index 25280ac..f201841 100644 --- a/suixinkanTests/NavigationRouterTests.swift +++ b/suixinkanTests/NavigationRouterTests.swift @@ -18,6 +18,8 @@ final class NavigationRouterTests: XCTestCase { XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.orders(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).hidesTabBarWhenPushed) } /// 测试切换订单 Tab 时会保存订单内部子入口。 @@ -70,10 +72,14 @@ final class NavigationRouterTests: XCTestCase { router.navigate(to: .orders(.storeDetail(order))) router.navigate(to: .orders(.writeOffDetail(writeOff))) + router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: "MT001"))) + router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告"))) - XCTAssertEqual(router.path.count, 2) + XCTAssertEqual(router.path.count, 4) XCTAssertEqual(router.path[0], .orders(.storeDetail(order))) XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff))) + XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001"))) + XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告"))) } /// 返回测试用订单管理列表项。 diff --git a/suixinkanTests/OrderDetailViewModelTests.swift b/suixinkanTests/OrderDetailViewModelTests.swift index 90ac028..ae6ef94 100644 --- a/suixinkanTests/OrderDetailViewModelTests.swift +++ b/suixinkanTests/OrderDetailViewModelTests.swift @@ -117,4 +117,53 @@ private final class DetailMockOrderService: OrderServing { } return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() } + + /// 押金订单列表接口在详情测试中不会被调用。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + XCTFail("depositOrderList should not be called") + return ListPayload(total: 0, list: []) + } + + /// 押金核销接口在详情测试中不会被调用。 + func depositOrderWriteOff(orderNumber: String) async throws { + XCTFail("depositOrderWriteOff should not be called") + } + + /// 押金退款接口在详情测试中不会被调用。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws { + XCTFail("depositOrderRefund should not be called") + } + + /// 拍摄信息接口在详情测试中不会被调用。 + func storeOrderShootingDetail( + storeId: Int, + orderNumber: String, + scenicSpotId: Int, + photogUid: Int + ) async throws -> StoreOrderShootingDetailResponse { + XCTFail("storeOrderShootingDetail should not be called") + return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") + } + + /// 普通退款接口在详情测试中不会被调用。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { + XCTFail("orderRefund should not be called") + } + + /// 历史拍摄接口在详情测试中不会被调用。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + XCTFail("multiTravelShootHistory should not be called") + return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") + } + + /// 已核销打卡点接口在详情测试中不会被调用。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + XCTFail("multiTravelVerifiedScenicSpotList should not be called") + return [] + } + + /// 多点旅拍素材提交接口在详情测试中不会被调用。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { + XCTFail("multiTravelUploadMaterial should not be called") + } } diff --git a/suixinkanTests/OrderLongTailTests.swift b/suixinkanTests/OrderLongTailTests.swift new file mode 100644 index 0000000..91fa48e --- /dev/null +++ b/suixinkanTests/OrderLongTailTests.swift @@ -0,0 +1,596 @@ +// +// OrderLongTailTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 订单长尾测试,覆盖押金订单、退款和历史拍摄的 API 与 ViewModel 规则。 +final class OrderLongTailTests: XCTestCase { + /// 测试押金订单接口使用旧工程路径、分页兜底和请求体。 + func testDepositOrderAPIRequestsAndDecodesLossyFields() async throws { + let session = LongTailRecordingURLSession(responses: [ + try TestFixture.data(named: "deposit_order_list_success"), + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = OrdersAPI(client: APIClient(session: session)) + + let list = try await api.depositOrderList(scenicId: 88, page: 0, pageSize: 0) + try await api.depositOrderWriteOff(orderNumber: "DEP001") + try await api.depositOrderRefund(orderNumber: "DEP002", refundReason: "游客取消行程") + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/order/deposit-list", + "/api/yf-handset-app/photog/order/deposit-writeoff", + "/api/yf-handset-app/photog/order/deposit-refund" + ]) + let query = longTailQueryItems(from: try XCTUnwrap(session.requests.first)) + XCTAssertEqual(query["scenic_id"], "88") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(list.total, 2) + XCTAssertEqual(list.list[1].id, 4102) + XCTAssertEqual(list.list[1].userPhone, "13900000000") + + let writeOffBody = try longTailBodyObject(from: session.requests[1]) + XCTAssertEqual(writeOffBody["order_number"] as? String, "DEP001") + let refundBody = try longTailBodyObject(from: session.requests[2]) + XCTAssertEqual(refundBody["order_number"] as? String, "DEP002") + XCTAssertEqual(refundBody["refund_reason"] as? String, "游客取消行程") + } + + /// 测试拍摄信息、普通退款和历史拍摄接口 path、query、body 与宽松解码。 + func testShootingRefundAndHistoryAPIRequestsAndDecodes() async throws { + let session = LongTailRecordingURLSession(responses: [ + try TestFixture.data(named: "store_order_shooting_detail_success"), + try TestFixture.data(named: "multi_travel_shoot_history_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = OrdersAPI(client: APIClient(session: session)) + + let shooting = try await api.storeOrderShootingDetail( + storeId: 66, + orderNumber: "DEP001", + scenicSpotId: 901, + photogUid: 42 + ) + let history = try await api.multiTravelShootHistory(orderNumber: "MT001") + try await api.orderRefund( + orderNumber: "STORE001", + refundType: .partial, + refundAmount: "10.50", + refundReason: "游客取消部分项目" + ) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/app/store/order/shooting-detail", + "/api/yf-handset-app/photog/order/multi-travel/shoot-history", + "/api/yf-handset-app/photog/order/refund" + ]) + let shootingQuery = longTailQueryItems(from: session.requests[0]) + XCTAssertEqual(shootingQuery["store_id"], "66") + XCTAssertEqual(shootingQuery["order_number"], "DEP001") + XCTAssertEqual(shootingQuery["scenic_spot_id"], "901") + XCTAssertEqual(shootingQuery["photog_uid"], "42") + XCTAssertEqual(shooting.orderComment?.starShooting, 5) + XCTAssertEqual(shooting.materialList.first?.fileSize, 10_485_760) + XCTAssertTrue(shooting.materialList.first?.isVideo == true) + + XCTAssertEqual(longTailQueryItems(from: session.requests[1])["order_number"], "MT001") + XCTAssertEqual(history.projectType, 19) + XCTAssertEqual(history.photogSpotList.first?.photographerDisplayName, "小景") + XCTAssertEqual(history.photogSpotList.first?.files.count, 2) + + let refundBody = try longTailBodyObject(from: session.requests[2]) + XCTAssertEqual(refundBody["order_number"] as? String, "STORE001") + XCTAssertEqual(refundBody["refund_type"] as? Int, OrderRefundMode.partial.rawValue) + XCTAssertEqual(refundBody["refund_amount"] as? String, "10.50") + XCTAssertNil(refundBody["store_id"]) + } + + /// 测试已核销打卡点和多点旅拍素材提交接口 path、query、body。 + func testMultiTravelTaskUploadAPIRequestsAndDecodes() async throws { + let session = LongTailRecordingURLSession(responses: [ + try TestFixture.data(named: "multi_travel_verified_spots_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = OrdersAPI(client: APIClient(session: session)) + + let spots = try await api.multiTravelVerifiedScenicSpotList(orderNumber: "MT001") + try await api.multiTravelUploadMaterial( + MultiTravelUploadMaterialRequest( + orderNumber: "MT001", + scenicSpotId: 901, + cloudFile: [MultiTravelCloudFileItem(fileId: 7001)], + uploadFile: [MultiTravelUploadFileItem(fileName: "spot.jpg", fileUrl: "https://cdn.example.com/spot.jpg")] + ) + ) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list", + "/api/yf-handset-app/photog/order/multi-travel/upload-material" + ]) + XCTAssertEqual(longTailQueryItems(from: session.requests[0])["order_number"], "MT001") + XCTAssertEqual(spots.map(\.id), [901, 902]) + XCTAssertEqual(spots.last?.name, "观景台") + let body = try longTailBodyObject(from: session.requests[1]) + XCTAssertEqual(body["order_number"] as? String, "MT001") + XCTAssertEqual(body["scenic_spot_id"] as? Int, 901) + XCTAssertEqual((body["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 7001) + XCTAssertEqual((body["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/spot.jpg") + } + + /// 测试押金订单无景区时清空旧数据且不请求接口。 + func testDepositListClearsWithoutScenicAndStopsAtLastPage() async throws { + let service = LongTailMockOrderService() + service.depositListResponses = [try Self.depositPayload()] + let viewModel = DepositOrderListViewModel() + + await viewModel.reload(api: service, scenicId: 88, reset: true) + await viewModel.reload(api: service, scenicId: 88, reset: false) + await viewModel.reload(api: service, scenicId: nil, reset: true) + + XCTAssertTrue(viewModel.orders.isEmpty) + XCTAssertEqual(viewModel.total, 0) + XCTAssertEqual(viewModel.page, 1) + XCTAssertEqual(service.depositListCalls, [ + .init(scenicId: 88, page: 1, pageSize: 10) + ]) + } + + /// 测试押金核销和押金退款成功后刷新列表,失败时保留旧列表并清理操作状态。 + func testDepositWriteOffAndRefundRefreshOrKeepListOnFailure() async throws { + let service = LongTailMockOrderService() + service.depositListResponses = [ + try Self.depositPayload(), + try Self.depositPayload(), + try Self.depositPayload() + ] + let viewModel = DepositOrderListViewModel() + await viewModel.reload(api: service, scenicId: 88, reset: true) + + let writeOffSuccess = await viewModel.writeOff(api: service, scenicId: 88, orderNumber: "DEP001") + let emptyReason = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: " ") + service.depositRefundError = APIError.serverCode(500, "退款失败") + let refundFailure = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: "游客取消") + + XCTAssertTrue(writeOffSuccess) + XCTAssertFalse(emptyReason) + XCTAssertFalse(refundFailure) + XCTAssertEqual(service.depositWriteOffNumbers, ["DEP001"]) + XCTAssertEqual(service.depositRefundCalls, [.init(orderNumber: "DEP001", reason: "游客取消")]) + XCTAssertEqual(service.depositListCalls.count, 2) + XCTAssertEqual(viewModel.orders.count, 2) + XCTAssertNil(viewModel.operatingOrderNumber) + XCTAssertEqual(viewModel.errorMessage, "退款失败") + } + + /// 测试押金详情和拍摄信息缺少门店时不请求接口。 + func testDepositDetailAndShootingSkipWithoutStore() async { + let service = LongTailMockOrderService() + let detailViewModel = DepositOrderDetailViewModel() + let shootingViewModel = DepositOrderShootingInfoViewModel() + + await detailViewModel.load(api: service, storeId: nil, orderNumber: "DEP001") + await shootingViewModel.load(api: service, storeId: nil, orderNumber: "DEP001", scenicSpotId: 901, photogUid: 42) + + XCTAssertTrue(service.storeDetailCalls.isEmpty) + XCTAssertTrue(service.storeShootingCalls.isEmpty) + XCTAssertEqual(detailViewModel.errorMessage, "当前账号缺少门店信息") + XCTAssertEqual(shootingViewModel.errorMessage, "当前账号缺少门店信息") + } + + /// 测试普通退款入口规则、金额校验、重复提交保护和成功提交参数。 + func testOrderRefundValidationAndSubmit() async throws { + let service = LongTailMockOrderService() + let viewModel = OrderRefundViewModel() + let refundable = try Self.orderEntity(orderStatus: 18, orderType: 4, actualPayAmount: "99.50") + let unavailable = try Self.orderEntity(orderStatus: 50, orderType: 4, actualPayAmount: "99.50") + + XCTAssertTrue(viewModel.canRefund(refundable)) + XCTAssertFalse(viewModel.canRefund(unavailable)) + viewModel.begin(item: refundable) + XCTAssertEqual(viewModel.amount, "99.50") + let missingReasonSuccess = await viewModel.submit(api: service, item: refundable) + XCTAssertFalse(missingReasonSuccess) + XCTAssertEqual(viewModel.errorMessage, "请输入退款原因") + + viewModel.reason = "部分退款" + viewModel.mode = .partial + viewModel.amount = "12.345" + let invalidAmountSuccess = await viewModel.submit(api: service, item: refundable) + XCTAssertFalse(invalidAmountSuccess) + XCTAssertEqual(viewModel.errorMessage, "请输入有效的退款金额") + + viewModel.amount = "120.00" + let overLimitSuccess = await viewModel.submit(api: service, item: refundable) + XCTAssertFalse(overLimitSuccess) + XCTAssertEqual(viewModel.errorMessage, "退款金额不能大于可退金额") + + viewModel.amount = "12.3" + let submitSuccess = await viewModel.submit(api: service, item: refundable) + XCTAssertTrue(submitSuccess) + XCTAssertEqual(service.orderRefundCalls, [ + .init(orderNumber: "STORE001", mode: .partial, amount: "12.30", reason: "部分退款") + ]) + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.submitting) + } + + /// 测试历史拍摄空订单不请求,成功后展示项目和媒体,失败后清空旧数据。 + func testHistoricalShootingLoadSuccessAndFailure() async throws { + let service = LongTailMockOrderService() + service.historyResponses = [try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")] + let viewModel = HistoricalShootingInfoViewModel() + + await viewModel.load(api: service, orderNumber: " ") + XCTAssertTrue(service.historyCalls.isEmpty) + XCTAssertEqual(viewModel.errorMessage, "订单号不能为空") + + await viewModel.load(api: service, orderNumber: "MT001") + XCTAssertEqual(viewModel.projectName, "多点旅拍") + XCTAssertEqual(viewModel.spots.first?.scenicSpotName, "湖心亭") + XCTAssertEqual(viewModel.spots.first?.files.count, 2) + + service.historyError = APIError.serverCode(500, "历史拍摄失败") + await viewModel.load(api: service, orderNumber: "MT001") + XCTAssertTrue(viewModel.spots.isEmpty) + XCTAssertEqual(viewModel.errorMessage, "历史拍摄失败") + } + + /// 测试任务上传空订单不请求,加载成功默认选中首个打卡点,加载失败清空状态。 + func testMultiTravelTaskUploadLoadsAndRepairsSpotSelection() async throws { + let service = LongTailMockOrderService() + service.verifiedSpotResponses = [ + try Self.verifiedSpots(), + [try Self.verifiedSpot(id: 902, name: "观景台")] + ] + let viewModel = MultiTravelTaskUploadViewModel() + + await viewModel.loadSpots(api: service) + XCTAssertTrue(service.verifiedSpotCalls.isEmpty) + + viewModel.orderNumber = "MT001" + await viewModel.loadSpots(api: service) + XCTAssertEqual(viewModel.selectedSpotId, 901) + viewModel.selectSpot(id: 901) + await viewModel.loadSpots(api: service) + XCTAssertEqual(viewModel.selectedSpotId, 902) + + service.verifiedSpotError = APIError.serverCode(500, "打卡点失败") + await viewModel.loadSpots(api: service) + XCTAssertTrue(viewModel.spots.isEmpty) + XCTAssertNil(viewModel.selectedSpotId) + XCTAssertEqual(viewModel.errorMessage, "打卡点失败") + } + + /// 测试任务上传提交校验和仅云盘文件直接提交。 + func testMultiTravelTaskUploadValidatesAndSubmitsCloudFiles() async throws { + let service = LongTailMockOrderService() + service.verifiedSpotResponses = [try Self.verifiedSpots()] + let uploader = LongTailMockUploader() + let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001") + + let missingScenicSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: nil) + XCTAssertFalse(missingScenicSuccess) + XCTAssertEqual(viewModel.errorMessage, "请先选择景区") + + await viewModel.loadSpots(api: service) + let missingFileSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88) + XCTAssertFalse(missingFileSuccess) + XCTAssertEqual(viewModel.errorMessage, "请至少选择一个素材文件") + + viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 7001, fileName: "cloud.jpg", fileType: 2, remark: "")]) + let submitSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88) + + XCTAssertTrue(submitSuccess) + XCTAssertTrue(viewModel.didSubmitSuccessfully) + XCTAssertEqual(service.uploadMaterialRequests, [ + MultiTravelUploadMaterialRequest( + orderNumber: "MT001", + scenicSpotId: 901, + cloudFile: [MultiTravelCloudFileItem(fileId: 7001)], + uploadFile: [] + ) + ]) + XCTAssertTrue(uploader.uploadTaskFiles.isEmpty) + } + + /// 测试任务上传本地文件先上传 OSS,上传失败不提交业务接口。 + func testMultiTravelTaskUploadUploadsLocalFilesBeforeSubmit() async throws { + let service = LongTailMockOrderService() + service.verifiedSpotResponses = [try Self.verifiedSpots()] + let uploader = LongTailMockUploader() + uploader.uploadTaskFileURLs = ["https://cdn.example.com/local.jpg"] + let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001") + await viewModel.loadSpots(api: service) + viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg") + + let success = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadTaskFiles.map(\.fileName), ["local.jpg"]) + XCTAssertEqual(service.uploadMaterialRequests.first?.uploadFile, [ + MultiTravelUploadFileItem(fileName: "local.jpg", fileUrl: "https://cdn.example.com/local.jpg") + ]) + + let failingService = LongTailMockOrderService() + failingService.verifiedSpotResponses = [try Self.verifiedSpots()] + let failingUploader = LongTailMockUploader() + failingUploader.uploadError = APIError.serverCode(500, "上传失败") + let failingViewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001") + await failingViewModel.loadSpots(api: failingService) + failingViewModel.addLocalFile(data: Data([1]), fileName: "bad.mp4") + + let failure = await failingViewModel.submit(api: failingService, uploadService: failingUploader, scenicId: 88) + + XCTAssertFalse(failure) + XCTAssertTrue(failingService.uploadMaterialRequests.isEmpty) + XCTAssertEqual(failingViewModel.errorMessage, "上传失败") + } + + /// 读取押金订单列表 fixture。 + private static func depositPayload() throws -> ListPayload { + try TestFixture.payload(ListPayload.self, named: "deposit_order_list_success") + } + + /// 构建最小普通订单实体,用于退款规则测试。 + private static func orderEntity(orderStatus: Int, orderType: Int, actualPayAmount: String) throws -> OrderEntity { + let json = """ + { + "order_number": "STORE001", + "order_status": "\(orderStatus)", + "order_type": "\(orderType)", + "actual_pay_amount": "\(actualPayAmount)", + "actual_refund_amount": "0", + "refund_amount": "0", + "order_type_label": "旅拍订单", + "order_status_name": "待退款" + } + """ + return try JSONDecoder().decode(OrderEntity.self, from: Data(json.utf8)) + } + + /// 读取已核销打卡点 fixture。 + private static func verifiedSpots() throws -> [MultiTravelVerifiedScenicSpotItem] { + try TestFixture.payload([MultiTravelVerifiedScenicSpotItem].self, named: "multi_travel_verified_spots_success") + } + + /// 构建已核销打卡点测试实体。 + private static func verifiedSpot(id: Int, name: String) throws -> MultiTravelVerifiedScenicSpotItem { + let json = #"{"id":"\#(id)","name":"\#(name)"}"# + return try JSONDecoder().decode(MultiTravelVerifiedScenicSpotItem.self, from: Data(json.utf8)) + } +} + +/// 长尾订单 URLSession 测试替身,按调用顺序返回响应并记录请求。 +private final class LongTailRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + init(responses: [Data]) { + self.responses = responses + } + + /// 返回队列中的下一个响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data(#"{"code":100000,"msg":"success"}"#.utf8) : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} + +@MainActor +/// 长尾订单服务测试替身,记录押金、退款和历史拍摄调用。 +private final class LongTailMockOrderService: OrderServing { + struct DepositListCall: Equatable { + let scenicId: Int + let page: Int + let pageSize: Int + } + + struct DepositRefundCall: Equatable { + let orderNumber: String + let reason: String + } + + struct OrderRefundCall: Equatable { + let orderNumber: String + let mode: OrderRefundMode + let amount: String + let reason: String + } + + var depositListResponses: [ListPayload] = [] + var detailResponses: [StoreOrderDetailResponse] = [] + var shootingResponses: [StoreOrderShootingDetailResponse] = [] + var historyResponses: [MultiTravelShootHistoryResponse] = [] + var verifiedSpotResponses: [[MultiTravelVerifiedScenicSpotItem]] = [] + var depositWriteOffError: Error? + var depositRefundError: Error? + var orderRefundError: Error? + var historyError: Error? + var verifiedSpotError: Error? + var uploadMaterialError: Error? + + private(set) var depositListCalls: [DepositListCall] = [] + private(set) var depositWriteOffNumbers: [String] = [] + private(set) var depositRefundCalls: [DepositRefundCall] = [] + private(set) var orderRefundCalls: [OrderRefundCall] = [] + private(set) var storeDetailCalls: [(storeId: Int, orderNumber: String)] = [] + private(set) var storeShootingCalls: [(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int)] = [] + private(set) var historyCalls: [String] = [] + private(set) var verifiedSpotCalls: [String] = [] + private(set) var uploadMaterialRequests: [MultiTravelUploadMaterialRequest] = [] + + /// 基础订单列表接口在长尾测试中不使用。 + func orderList( + scenicId: Int, + page: Int, + pageSize: Int, + orderStatus: Int?, + userPhone: String?, + startTime: String?, + endTime: String?, + isRefined: Int?, + isScenicAdmin: Bool + ) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 核销列表接口在长尾测试中不使用。 + func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 普通核销接口在长尾测试中不使用。 + func writeOff(orderNumber: String) async throws {} + + /// 返回门店订单详情。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { + storeDetailCalls.append((storeId: storeId, orderNumber: orderNumber)) + return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() + } + + /// 返回押金订单列表。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + depositListCalls.append(.init(scenicId: scenicId, page: page, pageSize: pageSize)) + return depositListResponses.isEmpty ? ListPayload(total: 0, list: []) : depositListResponses.removeFirst() + } + + /// 记录押金核销订单号。 + func depositOrderWriteOff(orderNumber: String) async throws { + depositWriteOffNumbers.append(orderNumber) + if let depositWriteOffError { + throw depositWriteOffError + } + } + + /// 记录押金退款参数。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws { + depositRefundCalls.append(.init(orderNumber: orderNumber, reason: refundReason)) + if let depositRefundError { + throw depositRefundError + } + } + + /// 返回拍摄信息详情。 + func storeOrderShootingDetail( + storeId: Int, + orderNumber: String, + scenicSpotId: Int, + photogUid: Int + ) async throws -> StoreOrderShootingDetailResponse { + storeShootingCalls.append((storeId: storeId, orderNumber: orderNumber, scenicSpotId: scenicSpotId, photogUid: photogUid)) + return shootingResponses.isEmpty ? try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") : shootingResponses.removeFirst() + } + + /// 记录普通退款参数。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { + orderRefundCalls.append(.init(orderNumber: orderNumber, mode: refundType, amount: refundAmount, reason: refundReason)) + if let orderRefundError { + throw orderRefundError + } + } + + /// 返回历史拍摄信息。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + historyCalls.append(orderNumber) + if let historyError { + throw historyError + } + return historyResponses.isEmpty ? try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") : historyResponses.removeFirst() + } + + /// 返回已核销打卡点列表。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + verifiedSpotCalls.append(orderNumber) + if let verifiedSpotError { + throw verifiedSpotError + } + return verifiedSpotResponses.isEmpty ? [] : verifiedSpotResponses.removeFirst() + } + + /// 记录多点旅拍素材提交请求。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { + uploadMaterialRequests.append(request) + if let uploadMaterialError { + throw uploadMaterialError + } + } +} + +@MainActor +/// 长尾订单 OSS 上传测试替身,只记录任务附件上传。 +private final class LongTailMockUploader: OSSUploadServing { + struct UploadTaskFileCall: Equatable { + let fileName: String + let fileType: Int + let scenicId: Int + let dataSize: Int + } + + var uploadTaskFileURLs: [String] = [] + var uploadError: Error? + private(set) var uploadTaskFiles: [UploadTaskFileCall] = [] + + /// 返回预设任务附件 URL。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + uploadTaskFiles.append(.init(fileName: fileName, fileType: fileType, scenicId: scenicId, dataSize: data.count)) + if let uploadError { + throw uploadError + } + onProgress(100) + return uploadTaskFileURLs.isEmpty ? "https://cdn.example.com/\(fileName)" : uploadTaskFileURLs.removeFirst() + } + + /// 其他上传入口在本测试中不使用。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +/// 提取请求 query 方便断言。 +private func longTailQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +/// 将 JSON body 解成字典方便断言。 +private func longTailBodyObject(from request: URLRequest) throws -> [String: Any] { + let data = try XCTUnwrap(request.httpBody) + let object = try JSONSerialization.jsonObject(with: data) + return try XCTUnwrap(object as? [String: Any]) +} diff --git a/suixinkanTests/OrdersViewModelTests.swift b/suixinkanTests/OrdersViewModelTests.swift index 0983e30..5003ac3 100644 --- a/suixinkanTests/OrdersViewModelTests.swift +++ b/suixinkanTests/OrdersViewModelTests.swift @@ -309,6 +309,55 @@ private final class MockOrderService: OrderServing { detailCalls.append((storeId: storeId, orderNumber: orderNumber)) return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() } + + /// 押金订单列表接口在基础订单测试中不会被调用。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + XCTFail("depositOrderList should not be called") + return ListPayload(total: 0, list: []) + } + + /// 押金核销接口在基础订单测试中不会被调用。 + func depositOrderWriteOff(orderNumber: String) async throws { + XCTFail("depositOrderWriteOff should not be called") + } + + /// 押金退款接口在基础订单测试中不会被调用。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws { + XCTFail("depositOrderRefund should not be called") + } + + /// 拍摄信息接口在基础订单测试中不会被调用。 + func storeOrderShootingDetail( + storeId: Int, + orderNumber: String, + scenicSpotId: Int, + photogUid: Int + ) async throws -> StoreOrderShootingDetailResponse { + XCTFail("storeOrderShootingDetail should not be called") + return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") + } + + /// 普通退款接口在基础订单测试中不会被调用。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { + XCTFail("orderRefund should not be called") + } + + /// 历史拍摄接口在基础订单测试中不会被调用。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + XCTFail("multiTravelShootHistory should not be called") + return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") + } + + /// 已核销打卡点接口在基础订单测试中不会被调用。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + XCTFail("multiTravelVerifiedScenicSpotList should not be called") + return [] + } + + /// 多点旅拍素材提交接口在基础订单测试中不会被调用。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { + XCTFail("multiTravelUploadMaterial should not be called") + } } /// URLSession 测试替身,记录请求并返回固定响应。 diff --git a/suixinkanTests/QueueManagement/QueueManagementViewModelTests.swift b/suixinkanTests/QueueManagement/QueueManagementViewModelTests.swift new file mode 100644 index 0000000..104ad6f --- /dev/null +++ b/suixinkanTests/QueueManagement/QueueManagementViewModelTests.swift @@ -0,0 +1,227 @@ +// +// QueueManagementViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 排队管理列表测试,覆盖点位门禁、列表分页和动作。 +final class QueueManagementViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + clearQueueDefaults() + } + + func testReloadWithoutScenicClearsState() async { + let api = ScenicQueueMock() + let viewModel = QueueManagementViewModel() + + await viewModel.reload(api: api, scenicId: nil, userId: "77", spots: sampleSpots) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertNil(viewModel.selectedSpotId) + XCTAssertFalse(viewModel.queueGatePassed) + XCTAssertEqual(viewModel.queueCount, 0) + } + + func testReloadWithoutSavedSpotShowsGateAndDoesNotRequestQueue() async { + let api = ScenicQueueMock() + let viewModel = QueueManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots) + + XCTAssertEqual(viewModel.scenicSpots.map(\.id), [901, 902]) + XCTAssertNil(viewModel.selectedSpotId) + XCTAssertFalse(viewModel.queueGatePassed) + XCTAssertTrue(api.homeRequests.isEmpty) + XCTAssertTrue(api.statsRequests.isEmpty) + } + + func testReloadWithSavedSpotLoadsHomeAndStats() async { + saveSelectedSpot() + let api = ScenicQueueMock() + api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001), ticket(id: 1002)], total: 2), time: "2026-06-25 10:01")] + api.statsResponse = ScenicQueueStatsData(queueCount: 5, avgWaitMin: 12.5, time: "2026-06-25 10:02") + let viewModel = QueueManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots) + + XCTAssertEqual(viewModel.selectedSpotId, 901) + XCTAssertTrue(viewModel.queueGatePassed) + XCTAssertEqual(viewModel.items.map(\.id), [1001, 1002]) + XCTAssertEqual(viewModel.queueCount, 5) + XCTAssertEqual(viewModel.avgWaitMin, 12.5) + XCTAssertEqual(viewModel.lastSyncTimeText, "2026-06-25 10:02") + XCTAssertEqual(api.homeRequests.first?.type, QueueListType.queueing.rawValue) + } + + func testStatsFailureKeepsQueueList() async { + saveSelectedSpot() + let api = ScenicQueueMock() + api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1), time: "home-time")] + api.statsError = QueueTestError.sample + let viewModel = QueueManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots) + + XCTAssertEqual(viewModel.items.map(\.id), [1001]) + XCTAssertEqual(viewModel.queueCount, 0) + XCTAssertEqual(viewModel.lastSyncTimeText, "home-time") + } + + func testCallPassFinishRequeueAndUserMark() async throws { + saveSelectedSpot() + let api = ScenicQueueMock() + api.homeResponses = Array(repeating: ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1)), count: 8) + let viewModel = QueueManagementViewModel() + await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots) + + try await viewModel.callQueue(api: api, id: 1001) + XCTAssertEqual(api.callIds, [1001]) + XCTAssertEqual(viewModel.items.first?.isCalled, 1) + + try await viewModel.passQueue(api: api, scenicId: 88, userId: "77", id: 1001) + XCTAssertEqual(api.passIds, [1001]) + + try await viewModel.finishQueue(api: api, scenicId: 88, userId: "77", id: 1001) + XCTAssertEqual(api.finishIds, [1001]) + + try await viewModel.requeue(api: api, scenicId: 88, userId: "77", id: 1001, operatorId: 77) + XCTAssertEqual(api.requeueRequests.map(\.recordId), [1001]) + XCTAssertEqual(api.requeueRequests.map(\.operatorId), [77]) + XCTAssertEqual(viewModel.selectedListType, .queueing) + + try await viewModel.userMark( + api: api, + scenicId: 88, + userId: "77", + request: ScenicQueueUserMarkRequest(uid: 7001, scenicId: 88, markAsFreelancePhotog: 1, queueBanDays: 14, operatorId: 77) + ) + XCTAssertEqual(api.userMarkRequests.first?.uid, 7001) + XCTAssertEqual(api.userMarkRequests.first?.queueBanDays, 14) + } + + func testSocketSubscriptionAndParse() { + XCTAssertEqual(ScenicQueueSocketClient.subscriptionPayloads(scenicSpotId: 901), [ + #"{"type":306,"params":{"scenic_spot_id":901}}"#, + #"{"type":307,"params":{"scenic_spot_id":901}}"# + ]) + + let message = ScenicQueueSocketClient.parseMessage(""" + {"code":100000,"data":{"action":"scenic_queue_ticket_called","params":{"scenic_spot_id":"901","record_id":"1002","operator_uid":"77","event_id":"evt-1"}}} + """) + + XCTAssertEqual(message?.code, 100000) + XCTAssertTrue(message?.isScenicQueueEvent == true) + XCTAssertEqual(message?.data?.params?.scenicSpotId, 901) + XCTAssertEqual(message?.data?.params?.recordId, 1002) + XCTAssertEqual(message?.data?.params?.operatorUid, 77) + XCTAssertEqual(message?.data?.params?.eventId, "evt-1") + } +} + +private let sampleSpots = [ + ScenicSpotItem(id: 901, name: "东门打卡点"), + ScenicSpotItem(id: 902, name: "西门打卡点") +] + +private func saveSelectedSpot() { + ScenicQueueSettingsStore.saveSelectedSpot(id: 901, name: "东门打卡点", userId: "77", scenicId: 88) +} + +private func clearQueueDefaults() { + let defaults = UserDefaults.standard + [ + ScenicQueueLocalSettings.selectedSpotIdKey, + ScenicQueueLocalSettings.selectedSpotNameKey, + ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88), + ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88), + ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901) + ].forEach(defaults.removeObject) +} + +private func ticket(id: Int64, isCalled: Int = 0) -> ScenicQueueTicket { + ScenicQueueTicket( + id: id, + queueCode: "A\(id)", + mobile: "13800000000", + status: 0, + statusText: "", + waitMin: 5, + aheadCount: 1, + isCalled: isCalled, + createdAt: "2026-06-25 10:00:00", + calledAt: "", + expiredAt: "", + finishedAt: "", + queueCountToday: 2, + uid: 7001 + ) +} + +@MainActor +final class ScenicQueueMock: ScenicQueueServing { + var homeResponses: [ScenicQueueHomeData] = [] + var statsResponse = ScenicQueueStatsData() + var statsError: Error? + var settingResponse = ScenicQueueSettingData() + var callIds: [Int64] = [] + var passIds: [Int64] = [] + var finishIds: [Int64] = [] + var requeueRequests: [(recordId: Int64, operatorId: Int)] = [] + var userMarkRequests: [ScenicQueueUserMarkRequest] = [] + var saveSettingRequests: [ScenicQueueSaveSettingRequest] = [] + var homeRequests: [(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int)] = [] + var statsRequests: [(scenicId: Int, scenicSpotId: Int)] = [] + var qrcodeURL = "https://example.com/qrcode.png" + var logs = ScenicQueueSettingChangeLogData() + + func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData { + statsRequests.append((scenicId, scenicSpotId)) + if let statsError { throw statsError } + return statsResponse + } + + func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData { + homeRequests.append((scenicId, scenicSpotId, type, page, pageSize)) + return homeResponses.isEmpty ? ScenicQueueHomeData() : homeResponses.removeFirst() + } + + func socketToken() async throws -> SocketTokenResponse { SocketTokenResponse(socketToken: "token") } + func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData { + callIds.append(id) + return ScenicQueueCallData(id: id, status: 1, statusText: "已叫号") + } + func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData { + passIds.append(id) + return ScenicQueuePassData(id: id) + } + func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData { + finishIds.append(id) + return ScenicQueueFinishData(id: id) + } + func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws { + requeueRequests.append((recordId, operatorId)) + } + func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws { + userMarkRequests.append(request) + } + func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { settingResponse } + func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws { + saveSettingRequests.append(request) + } + func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData { + ScenicQueueShootQueueQRCodeData(qrcodeUrl: qrcodeURL) + } + func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData { + logs + } +} + +private enum QueueTestError: Error { + case sample +} diff --git a/suixinkanTests/QueueManagement/ScenicQueueSettingsViewModelTests.swift b/suixinkanTests/QueueManagement/ScenicQueueSettingsViewModelTests.swift new file mode 100644 index 0000000..16e0964 --- /dev/null +++ b/suixinkanTests/QueueManagement/ScenicQueueSettingsViewModelTests.swift @@ -0,0 +1,182 @@ +// +// ScenicQueueSettingsViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 排队设置测试,覆盖加载、保存和校验。 +final class ScenicQueueSettingsViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + clearQueueSettingsDefaults() + } + + func testLoadSelectsFirstSpotAndAppliesLocalSnapshotWhenServerMissing() async { + let snapshot = ScenicQueueSettingsSnapshot( + shootMinute: 4, + shootSecond: 15, + firstAheadCount: 6, + firstSms: false, + firstPhone: true, + secondAheadCount: 2, + secondSms: true, + secondPhone: false, + broadcastIntervalSec: 55, + countdownThresholdSec: 25, + queueDistanceMeter: 8_880, + queueTakeLimit: 11, + missCallRequeueOffset: 5, + showStartShootingButton: false, + autoCallAheadCount: 3, + quickCallButtonEnabled: true, + prepareCallButtonEnabled: true, + businessOpen: false, + businessStartTime: "09:30", + businessEndTime: "18:45" + ) + ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: "77", scenicId: 88, spotId: 901) + let api = ScenicQueueMock() + api.settingResponse = ScenicQueueSettingData(exists: false) + let viewModel = ScenicQueueSettingsViewModel() + + await viewModel.load(api: api, scenicId: 88, userId: "77", spots: settingsSpots) + + XCTAssertEqual(viewModel.selectedSpotId, 901) + XCTAssertEqual(viewModel.photoEstimateMin, "4") + XCTAssertEqual(viewModel.photoEstimateSec, "15") + XCTAssertEqual(viewModel.firstAhead, "6") + XCTAssertTrue(viewModel.firstPhone) + XCTAssertEqual(viewModel.secondAhead, "2") + XCTAssertEqual(viewModel.broadcastIntervalSec, "55") + XCTAssertEqual(viewModel.countdownThresholdSec, "25") + XCTAssertEqual(viewModel.maxQueueRangeKm, "8.88") + XCTAssertEqual(viewModel.localQueueLimit, "11") + XCTAssertEqual(viewModel.localPassDelay, "5") + XCTAssertFalse(viewModel.showStartShootingButton) + XCTAssertEqual(viewModel.autoCallAheadCount, "3") + XCTAssertTrue(viewModel.quickCallButtonEnabled) + XCTAssertTrue(viewModel.prepareCallButtonEnabled) + XCTAssertFalse(viewModel.queueOpen) + } + + func testSaveNormalizesBodyAndPersistsSnapshot() async throws { + let api = ScenicQueueMock() + let viewModel = ScenicQueueSettingsViewModel() + viewModel.scenicSpots = settingsSpots + viewModel.selectedSpotId = 901 + viewModel.photoEstimateMin = "1" + viewModel.photoEstimateSec = "20" + viewModel.firstAhead = "5" + viewModel.secondAhead = "2" + viewModel.broadcastIntervalSec = "45" + viewModel.countdownThresholdSec = "20" + viewModel.localQueueLimit = "7" + viewModel.localPassDelay = "3" + viewModel.showStartShootingButton = false + viewModel.autoCallAheadCount = "4" + viewModel.quickCallButtonEnabled = true + viewModel.prepareCallButtonEnabled = true + viewModel.queueOpen = false + viewModel.customTtsText = " {number} 请到拍摄点 " + viewModel.presetVoices = ["预设播报", " ", "第二条"] + viewModel.businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 11, minute: 0) + viewModel.businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 21, minute: 30) + viewModel.updateMaxQueueRange("12.3") + + try await viewModel.save(api: api, scenicId: 88, userId: "77") + + let request = try XCTUnwrap(api.saveSettingRequests.first) + XCTAssertEqual(request.scenicId, 88) + XCTAssertEqual(request.scenicSpotId, 901) + XCTAssertEqual(request.photoEstimateMin, 1) + XCTAssertEqual(request.photoEstimateSec, 20) + XCTAssertEqual(request.queueDistanceMeter, 12_300) + XCTAssertEqual(request.queueTakeLimit, 7) + XCTAssertEqual(request.missCallRequeueOffset, 3) + XCTAssertEqual(request.showStartShootButton, 0) + XCTAssertEqual(request.autoCallNextCount, 4) + XCTAssertEqual(request.businessStartTime, "11:00") + XCTAssertEqual(request.businessEndTime, "21:30") + XCTAssertEqual(request.voiceBroadcasts.map(\.content), ["预设播报", "第二条"]) + XCTAssertEqual(request.status, 0) + XCTAssertEqual(request.remark, "{number} 请到拍摄点") + + XCTAssertEqual(ScenicQueueSettingsStore.selectedSpotId(userId: "77", scenicId: 88), 901) + let snapshot = try XCTUnwrap(ScenicQueueSettingsStore.settingsSnapshot(userId: "77", scenicId: 88, spotId: 901)) + XCTAssertEqual(snapshot.queueDistanceMeter, 12_300) + XCTAssertEqual(snapshot.autoCallAheadCount, 4) + XCTAssertTrue(snapshot.quickCallButtonEnabled) + } + + func testValidationRejectsInvalidValues() async { + let api = ScenicQueueMock() + let viewModel = ScenicQueueSettingsViewModel() + viewModel.selectedSpotId = 901 + viewModel.photoEstimateMin = "0" + viewModel.photoEstimateSec = "0" + + await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77")) + + viewModel.photoEstimateSec = "20" + viewModel.firstAhead = "1" + viewModel.secondAhead = "2" + await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77")) + + viewModel.firstAhead = "2" + viewModel.secondAhead = "1" + viewModel.broadcastIntervalSec = "30" + await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77")) + } + + func testFetchQRCodeRejectsEmptyURL() async { + let api = ScenicQueueMock() + api.qrcodeURL = "" + let viewModel = ScenicQueueSettingsViewModel() + viewModel.selectedSpotId = 901 + + await XCTAssertThrowsErrorAsync(try await viewModel.fetchQRCode(api: api, scenicId: 88)) + } +} + +private let settingsSpots = [ + ScenicSpotItem(id: 901, name: "东门打卡点"), + ScenicSpotItem(id: 902, name: "西门打卡点") +] + +private func clearQueueSettingsDefaults() { + let defaults = UserDefaults.standard + [ + ScenicQueueLocalSettings.selectedSpotIdKey, + ScenicQueueLocalSettings.selectedSpotNameKey, + ScenicQueueLocalSettings.customTtsTextKey, + ScenicQueueLocalSettings.photoEstimateSecondsKey, + ScenicQueueLocalSettings.broadcastIntervalSecondsKey, + ScenicQueueLocalSettings.countdownThresholdSecondsKey, + ScenicQueueLocalSettings.showStartShootingButtonKey, + ScenicQueueLocalSettings.autoCallAheadCountKey, + ScenicQueueLocalSettings.quickCallButtonEnabledKey, + ScenicQueueLocalSettings.prepareCallButtonEnabledKey, + ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88), + ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88), + ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901), + ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: "77", scenicId: 88, spotId: 901), + ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: "77", scenicId: 88, spotId: 901) + ].forEach(defaults.removeObject) +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + try await expression() + XCTFail("Expected error to be thrown", file: file, line: line) + } catch { + } +} diff --git a/suixinkanTests/ScenicSettlement/ScenicSettlementViewModelTests.swift b/suixinkanTests/ScenicSettlement/ScenicSettlementViewModelTests.swift new file mode 100644 index 0000000..0629351 --- /dev/null +++ b/suixinkanTests/ScenicSettlement/ScenicSettlementViewModelTests.swift @@ -0,0 +1,212 @@ +// +// ScenicSettlementViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 景区结算测试,覆盖结算申请和审核记录聚合。 +final class ScenicSettlementViewModelTests: XCTestCase { + /// 测试已有景区会从可申请结算景区中排除。 + func testSettlementLoadExcludesExistingScenics() async { + let permissionAPI = ScenicSettlementPermissionMock() + permissionAPI.scenicList = ScenicListAllResponse(total: 3, list: [ + ScenicListItem(id: 1, name: "已有景区"), + ScenicListItem(id: 2, name: "新增景区"), + ScenicListItem(id: 3, name: "另一个景区") + ]) + let viewModel = ScenicSettlementViewModel() + + await viewModel.load( + api: permissionAPI, + existingScenics: [BusinessScope(id: 1, name: "已有景区", kind: .scenic)] + ) + + XCTAssertEqual(viewModel.options.map(\.id), [2, 3]) + } + + /// 测试金额校验覆盖空值、0、负数、超过两位小数和有效金额。 + func testSettlementAmountValidation() { + let viewModel = ScenicSettlementViewModel() + + XCTAssertEqual(viewModel.amountValidation, .empty) + + viewModel.amountText = "0" + XCTAssertEqual(viewModel.amountValidation, .notPositive) + + viewModel.amountText = "-1" + XCTAssertEqual(viewModel.amountValidation, .invalidFormat) + + viewModel.amountText = "1.234" + XCTAssertEqual(viewModel.amountValidation, .invalidFormat) + + viewModel.amountText = "12.30" + XCTAssertEqual(viewModel.amountValidation, .valid) + } + + /// 测试多景区提交 body 正确且成功后清空表单。 + func testSettlementSubmitMultipleScenicsAndClearsForm() async { + let submitAPI = ScenicSettlementSubmitMock() + let viewModel = ScenicSettlementViewModel() + viewModel.options = [ + ScenicSettlementOption(id: 3, name: "三号", selected: false), + ScenicSettlementOption(id: 2, name: "二号", selected: false) + ] + viewModel.toggleScenic(id: 3) + viewModel.toggleScenic(id: 2) + viewModel.amountText = "99" + viewModel.remarkText = " 活动结算 " + + let success = await viewModel.submit(api: submitAPI) + + XCTAssertTrue(success) + XCTAssertEqual(submitAPI.requests, [ + ScenicSettlementSubmitRequest(scenicId: 2, applyAmount: "99.00", applyRemark: "活动结算"), + ScenicSettlementSubmitRequest(scenicId: 3, applyAmount: "99.00", applyRemark: "活动结算") + ]) + XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) + XCTAssertEqual(viewModel.amountText, "") + XCTAssertEqual(viewModel.remarkText, "") + XCTAssertEqual(viewModel.message, "提交成功,等待审核") + } + + /// 测试任一提交失败会保留当前表单并透出错误。 + func testSettlementSubmitFailureKeepsForm() async { + let submitAPI = ScenicSettlementSubmitMock() + submitAPI.errorAfterRequestCount = 1 + let viewModel = ScenicSettlementViewModel() + viewModel.options = [ + ScenicSettlementOption(id: 1, name: "一号", selected: false), + ScenicSettlementOption(id: 2, name: "二号", selected: false) + ] + viewModel.toggleScenic(id: 1) + viewModel.toggleScenic(id: 2) + viewModel.amountText = "10" + + let success = await viewModel.submit(api: submitAPI) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.selectedScenicIds, [1, 2]) + XCTAssertEqual(viewModel.amountText, "10") + XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription) + } + + /// 测试审核记录两个通道都成功时汇总正确。 + func testReviewLoadSuccessAggregatesRecords() async { + let permissionAPI = ScenicSettlementPermissionMock() + permissionAPI.scenicPendings = ScenicApplicationPendingsResponse(items: [ + ScenicApplicationPendingResponse(id: 1, scenicName: "景区", status: 1) + ]) + permissionAPI.roleApplies = [ + RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 2) + ] + let viewModel = ScenicSettlementReviewViewModel() + + await viewModel.load(api: permissionAPI) + + XCTAssertEqual(viewModel.scenicApplications.count, 1) + XCTAssertEqual(viewModel.roleApplications.count, 1) + XCTAssertEqual(viewModel.pendingCount, 1) + XCTAssertFalse(viewModel.loadFailedAll) + } + + /// 测试单通道失败时保留另一通道数据。 + func testReviewSingleChannelFailureKeepsOtherChannel() async { + let permissionAPI = ScenicSettlementPermissionMock() + permissionAPI.scenicPendingsError = TestError.sample + permissionAPI.roleApplies = [ + RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 1) + ] + let viewModel = ScenicSettlementReviewViewModel() + + await viewModel.load(api: permissionAPI) + + XCTAssertTrue(viewModel.scenicLoadFailed) + XCTAssertFalse(viewModel.roleLoadFailed) + XCTAssertFalse(viewModel.loadFailedAll) + XCTAssertEqual(viewModel.roleApplications.count, 1) + } + + /// 测试双通道失败时进入整页失败态。 + func testReviewBothChannelsFailureMarksFullFailure() async { + let permissionAPI = ScenicSettlementPermissionMock() + permissionAPI.scenicPendingsError = TestError.sample + permissionAPI.roleAppliesError = TestError.sample + let viewModel = ScenicSettlementReviewViewModel() + + await viewModel.load(api: permissionAPI) + + XCTAssertTrue(viewModel.loadFailedAll) + XCTAssertTrue(viewModel.scenicApplications.isEmpty) + XCTAssertTrue(viewModel.roleApplications.isEmpty) + } + + /// 测试审核状态文案和语义映射。 + func testReviewStatusMapping() { + let viewModel = ScenicSettlementReviewViewModel() + + XCTAssertEqual(viewModel.statusText(1), "待审核") + XCTAssertEqual(viewModel.statusText(2), "已通过") + XCTAssertEqual(viewModel.statusText(3), "已驳回") + XCTAssertEqual(viewModel.statusText(9), "已取消") + XCTAssertEqual(viewModel.statusText(99), "未知") + XCTAssertEqual(viewModel.statusIcon(2), "checkmark.circle.fill") + } +} + +@MainActor +private final class ScenicSettlementPermissionMock: ScenicPermissionServing { + var scenicList = ScenicListAllResponse(total: 0, list: []) + var scenicListError: Error? + var scenicPendings = ScenicApplicationPendingsResponse() + var scenicPendingsError: Error? + var roleApplies: [RoleApplyPendingResponse] = [] + var roleAppliesError: Error? + + func scenicListAll() async throws -> ScenicListAllResponse { + if let scenicListError { throw scenicListError } + return scenicList + } + + func areas() async throws -> [ScenicAreaNode] { [] } + + func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse { + if let scenicPendingsError { throw scenicPendingsError } + return scenicPendings + } + + func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws {} + func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws {} + + func roleApplyAll() async throws -> [RoleApplyPendingResponse] { + if let roleAppliesError { throw roleAppliesError } + return roleApplies + } + + func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws {} +} + +@MainActor +private final class ScenicSettlementSubmitMock: ScenicSettlementServing { + var requests: [ScenicSettlementSubmitRequest] = [] + var errorAfterRequestCount: Int? + + func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws { + requests.append(request) + if let errorAfterRequestCount, requests.count >= errorAfterRequestCount { + throw TestError.sample + } + } +} + +private enum TestError: LocalizedError { + case sample + + var errorDescription: String? { + "测试错误" + } +} diff --git a/suixinkanTests/WithdrawalAudit/WithdrawalAuditViewModelTests.swift b/suixinkanTests/WithdrawalAudit/WithdrawalAuditViewModelTests.swift new file mode 100644 index 0000000..da501fa --- /dev/null +++ b/suixinkanTests/WithdrawalAudit/WithdrawalAuditViewModelTests.swift @@ -0,0 +1,125 @@ +// +// WithdrawalAuditViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 提现审核测试,覆盖分页、筛选和失败清理。 +final class WithdrawalAuditViewModelTests: XCTestCase { + /// 测试首次加载、筛选统计和分页加载。 + func testLoadFilterAndLoadMore() async throws { + let api = WithdrawalAuditWalletMock() + api.withdrawResponses = [ + WalletWithdrawListResponse(total: 3, item: [ + try record(id: 1, status: "审核中"), + try record(id: 2, status: "已到账") + ]), + WalletWithdrawListResponse(total: 3, item: [ + try record(id: 3, status: "待打款") + ]) + ] + let viewModel = WithdrawalAuditViewModel() + + await viewModel.reload(api: api) + + XCTAssertEqual(api.withdrawPages, [1]) + XCTAssertEqual(viewModel.totalCount, 3) + XCTAssertEqual(viewModel.processingCount, 1) + XCTAssertEqual(viewModel.completedCount, 1) + XCTAssertTrue(viewModel.hasMore) + + viewModel.selectFilter(.processing) + XCTAssertEqual(viewModel.filteredRecords.map(\.id), [1]) + + await viewModel.loadMore(api: api) + + XCTAssertEqual(api.withdrawPages, [1, 2]) + XCTAssertEqual(viewModel.records.map(\.id), [1, 2, 3]) + XCTAssertEqual(viewModel.processingCount, 2) + XCTAssertFalse(viewModel.hasMore) + } + + /// 测试刷新失败会清空旧记录和分页状态。 + func testReloadFailureClearsStaleRecordsAndPaging() async throws { + let api = WithdrawalAuditWalletMock() + api.withdrawResponses = [ + WalletWithdrawListResponse(total: 2, item: [try record(id: 1, status: "审核中")]) + ] + let viewModel = WithdrawalAuditViewModel() + + await viewModel.reload(api: api) + XCTAssertEqual(viewModel.records.map(\.id), [1]) + XCTAssertTrue(viewModel.hasMore) + + api.withdrawError = TestError.sample + await viewModel.reload(api: api) + + XCTAssertTrue(viewModel.records.isEmpty) + XCTAssertFalse(viewModel.hasMore) + XCTAssertTrue(viewModel.loadFailed) + XCTAssertEqual(viewModel.loadFailureReason, TestError.sample.localizedDescription) + } + + /// 测试空列表和尾页边界不会继续加载。 + func testEmptyListAndLastPageDoNotLoadMore() async { + let api = WithdrawalAuditWalletMock() + api.withdrawResponses = [WalletWithdrawListResponse(total: 0, item: [])] + let viewModel = WithdrawalAuditViewModel() + + await viewModel.reload(api: api) + await viewModel.loadMore(api: api) + + XCTAssertTrue(viewModel.records.isEmpty) + XCTAssertEqual(api.withdrawPages, [1]) + XCTAssertFalse(viewModel.hasMore) + } + + private func record(id: Int64, amount: String = "10", status: String) throws -> WalletWithdrawRecord { + let data = """ + {"id":\(id),"amount":"\(amount)","created_at":"2026-06-25 10:00","status_label":"\(status)","expected_at":"2026-06-26","audit_time":"2026-06-25 11:00","completed_at":""} + """.data(using: .utf8)! + return try JSONDecoder().decode(WalletWithdrawRecord.self, from: data) + } +} + +@MainActor +private final class WithdrawalAuditWalletMock: WalletServing { + var withdrawResponses: [WalletWithdrawListResponse] = [] + var withdrawError: Error? + private(set) var withdrawPages: [Int] = [] + + func walletSummary(type: Int) async throws -> WalletSummaryResponse { WalletSummaryResponse() } + func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse { WalletEarningDetailResponse() } + func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { + withdrawPages.append(page) + if let withdrawError { throw withdrawError } + return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst() + } + func withdrawInfo() async throws -> WithdrawInfoResponse { + let data = #"{"amount_withdrawable":"0","min_withdraw_amount":"1","max_single_withdraw_amount":"100","max_daily_withdraw_amount":"100","user_phone":"13800000000","bank_card":{},"withdraw_info":[]}"#.data(using: .utf8)! + return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data) + } + func withdrawSendSms() async throws {} + func withdrawApply(amount: String, smsCode: String) async throws {} + func bankCardInfo() async throws -> BankCardInfoResponse { BankCardInfoResponse(bankCard: nil) } + func bankList() async throws -> BankListResponse { BankListResponse() } + func areas() async throws -> [AreaNode] { [] } + func bankCardVerifyCode() async throws {} + func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {} + func pointOverview(staffId: Int) async throws -> PointOverviewResponse { PointOverviewResponse() } + func pointWithdrawApply(points: Int, remark: String) async throws {} + func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { PointWithdrawListResponse() } +} + +private enum TestError: LocalizedError { + case sample + + var errorDescription: String? { + "测试错误" + } +} diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 32dafb2..c72e7ad 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -53,9 +53,12 @@ | 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 无。 | | 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 无。 | | 完成 | 扫码核销 | AVFoundation 扫码、订单号解析、命中高亮、二次确认后核销。 | 后续可继续补扫码 UI 细节。 | -| 完成 | 订单详情 | 订单管理详情、核销订单详情、详情接口和列表摘要兜底。 | 退款、历史拍摄、任务上传、视频预告等长尾流程仍未迁移。 | -| 未开始 | 退款流程 | 暂未迁移。 | 需要同步旧工程退款 UI、接口和状态流。 | -| 未开始 | 历史拍摄 | 暂未迁移。 | 需要同步旧工程相关页面和接口。 | +| 完成 | 订单详情 | 订单管理详情、核销订单详情、详情接口和列表摘要兜底。 | 无。 | +| 完成 | 押金订单 | 押金订单列表、手输订单号查详情、押金详情、押金核销、押金退款、押金拍摄信息。 | 后续可继续补押金订单 UI 细节和异常空态。 | +| 完成 | 退款流程 | 普通订单退款入口规则、全额/部分退款金额校验、退款提交和详情刷新。 | 复杂退款审核进度如后端需要可单独补。 | +| 完成 | 历史拍摄 | 多点位历史拍摄项目、拍摄点和媒体展示。 | 仅展示已有媒体,不做上传、删除、下载或编辑。 | +| 完成 | 任务上传 | 多点旅拍订单选择已核销打卡点、云盘素材、本地图片/视频 OSS 上传并提交素材。 | 仅 `orderType == 19` 展示。 | +| 完成 | 视频预告/尾片上传 | 进入订单尾片引导页,并复用相册预览上传。 | 没有独立订单尾片接口,暂不做尾片审核进度。 | ## 数据 Tab @@ -83,9 +86,9 @@ | 状态 | 模块 | 当前情况 | 后续事项 | | --- | --- | --- | --- | | 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 | -| 部分完成 | 提现审核 | 首页入口已识别,进入占位页。 | 迁移审核列表、详情、操作接口。 | -| 部分完成 | 消息中心 | 首页入口已识别,进入占位页。 | 迁移消息列表、已读状态和详情。 | -| 部分完成 | 排队管理 | `/scenic-queue` / `queue_management` 已识别,进入占位页。 | 迁移队列列表、叫号、设置等流程。 | +| 完成 | 提现审核 | 首页入口已接真实页面,复用提现记录接口展示审核列表、状态筛选、分页和详情时间线。 | 不做管理员审批操作,旧 Android 未体现对应操作接口。 | +| 完成 | 消息中心 | 消息列表、未读筛选、游标分页、已读/全部已读、详情和删除已接入。 | 本轮不新增后端未体现的批量接口。 | +| 完成 | 排队管理 | `/scenic-queue` / `queue_management` 已接真实页面;支持队列列表、叫号、过号、完成、重新排队、用户标记、设置、二维码、日志、实时监听和页面离开后的短轮询。 | 后续可继续按真实设备验证语音播报和后台活跃策略。 | | 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 | | 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 | | 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 | @@ -102,7 +105,7 @@ | 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 | | 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | | 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | -| 部分完成 | 景区结算 | 入口已识别,进入占位页。 | 迁移结算列表、审核、详情。 | +| 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 | | 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 | | 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | | 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | @@ -116,7 +119,7 @@ | 完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘、素材管理、样片管理和上传样片。 | | 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 | | 未开始 | Core/Push | 旧工程推送能力尚未迁移。 | -| 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 | +| 完成 | Core/Queue | 已迁移排队 WebSocket、远端叫号去重、语音播报和短轮询运行时。 | | 未开始 | Core/Validation | 旧工程独立校验能力尚未系统迁移;当前只迁移了实名身份证校验等必要逻辑。 | | 未开始 | UIKit 旧代码 | 旧工程 UIKit 目录下的兼容/旧版页面尚未迁移,除非后续明确仍需保留。 | @@ -131,7 +134,6 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination ## 下一批建议迁移顺序 -1. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。 -2. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。 -3. 排队管理/消息中心:属于常用运营辅助入口,可按列表、详情、操作闭环逐步迁移。 -4. 直播管理/直播相册:入口已识别但业务独立,建议单独拆模块迁移。 +1. 直播管理/直播相册:入口已识别但业务独立,建议单独拆模块迁移。 +2. 运营区域/飞手认证:仍为首页占位入口,可按旧工程业务优先级拆分。 +3. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。