新增订单长尾流程,并迁移排队、消息、结算与审核模块
将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -45,6 +45,16 @@ extension OrdersRoute {
|
|||||||
StoreOrderDetailView(item: item)
|
StoreOrderDetailView(item: item)
|
||||||
case .writeOffDetail(let item):
|
case .writeOffDetail(let item):
|
||||||
WriteOffOrderDetailView(item: 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
|
|
||||||
/// App 根视图,负责创建全局依赖并根据登录状态切换登录页和主界面。
|
/// App 根视图,负责创建全局依赖并根据登录状态切换登录页和主界面。
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var appSession = AppSession()
|
@State private var appSession = AppSession()
|
||||||
@State private var accountContext = AccountContext()
|
@State private var accountContext = AccountContext()
|
||||||
@State private var permissionContext = PermissionContext()
|
@State private var permissionContext = PermissionContext()
|
||||||
@ -28,6 +29,10 @@ struct RootView: View {
|
|||||||
@State private var paymentAPI: PaymentAPI
|
@State private var paymentAPI: PaymentAPI
|
||||||
@State private var walletAPI: WalletAPI
|
@State private var walletAPI: WalletAPI
|
||||||
@State private var scenicPermissionAPI: ScenicPermissionAPI
|
@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 taskAPI: TaskAPI
|
||||||
@State private var projectAPI: ProjectAPI
|
@State private var projectAPI: ProjectAPI
|
||||||
@State private var scheduleAPI: ScheduleAPI
|
@State private var scheduleAPI: ScheduleAPI
|
||||||
@ -56,6 +61,9 @@ struct RootView: View {
|
|||||||
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
|
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
|
||||||
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
||||||
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(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))
|
_taskAPI = State(initialValue: TaskAPI(client: apiClient))
|
||||||
_projectAPI = State(initialValue: ProjectAPI(client: apiClient))
|
_projectAPI = State(initialValue: ProjectAPI(client: apiClient))
|
||||||
_scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient))
|
_scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient))
|
||||||
@ -101,6 +109,10 @@ struct RootView: View {
|
|||||||
.environment(paymentAPI)
|
.environment(paymentAPI)
|
||||||
.environment(walletAPI)
|
.environment(walletAPI)
|
||||||
.environment(scenicPermissionAPI)
|
.environment(scenicPermissionAPI)
|
||||||
|
.environment(scenicSettlementAPI)
|
||||||
|
.environment(messageCenterAPI)
|
||||||
|
.environment(scenicQueueAPI)
|
||||||
|
.environment(scenicQueueRuntime)
|
||||||
.environment(taskAPI)
|
.environment(taskAPI)
|
||||||
.environment(projectAPI)
|
.environment(projectAPI)
|
||||||
.environment(scheduleAPI)
|
.environment(scheduleAPI)
|
||||||
@ -132,6 +144,28 @@ struct RootView: View {
|
|||||||
api: accountContextAPI
|
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
|
.onChange(of: appSession.phase) { _, phase in
|
||||||
guard phase == .loggedOut else { return }
|
guard phase == .loggedOut else { return }
|
||||||
accountContext.reset()
|
accountContext.reset()
|
||||||
|
|||||||
73
suixinkan/Core/Queue/ScenicQueueAnnouncementState.swift
Normal file
73
suixinkan/Core/Queue/ScenicQueueAnnouncementState.swift
Normal file
@ -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<Int64>()
|
||||||
|
private var calledTicketIds = Set<Int64>()
|
||||||
|
|
||||||
|
/// 根据最新统计和列表计算下一条需要播报的内容。
|
||||||
|
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: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
289
suixinkan/Core/Queue/ScenicQueueRuntime.swift
Normal file
289
suixinkan/Core/Queue/ScenicQueueRuntime.swift
Normal file
@ -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<Void, Never>?
|
||||||
|
@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<Void, Never>] = []
|
||||||
|
|
||||||
|
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<Void, Never>?, 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
166
suixinkan/Core/Queue/ScenicQueueSocketClient.swift
Normal file
166
suixinkan/Core/Queue/ScenicQueueSocketClient.swift
Normal file
@ -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<Void, Never>?
|
||||||
|
|
||||||
|
/// 连接 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
|
||||||
|
}
|
||||||
|
}
|
||||||
62
suixinkan/Core/Validation/AppFormValidator.swift
Normal file
62
suixinkan/Core/Validation/AppFormValidator.swift
Normal file
@ -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<Int>) 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 的映射,并同步补充单元测试。
|
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||||
|
|||||||
@ -54,6 +54,12 @@ enum HomeRoute: Hashable {
|
|||||||
case punchPointQR(id: Int, title: String, qrURL: String)
|
case punchPointQR(id: Int, title: String, qrURL: String)
|
||||||
case locationReport
|
case locationReport
|
||||||
case locationReportHistory
|
case locationReportHistory
|
||||||
|
case depositOrders
|
||||||
|
case withdrawalAudit
|
||||||
|
case scenicSettlement
|
||||||
|
case scenicSettlementReview
|
||||||
|
case messageCenter
|
||||||
|
case queueManagement
|
||||||
case modulePlaceholder(uri: String, title: String)
|
case modulePlaceholder(uri: String, title: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -127,6 +127,18 @@ enum HomeMenuRouter {
|
|||||||
return .destination(.locationReport)
|
return .destination(.locationReport)
|
||||||
case "location_report_history":
|
case "location_report_history":
|
||||||
return .destination(.locationReportHistory)
|
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":
|
case "store", "more_functions":
|
||||||
return .destination(.moreFunctions)
|
return .destination(.moreFunctions)
|
||||||
case "fly", "pilot_controller":
|
case "fly", "pilot_controller":
|
||||||
@ -135,17 +147,8 @@ enum HomeMenuRouter {
|
|||||||
title: title.isEmpty ? self.title(for: uri) : title,
|
title: title.isEmpty ? self.title(for: uri) : title,
|
||||||
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
|
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
|
||||||
)
|
)
|
||||||
case "message_center",
|
case "live_stream_management",
|
||||||
"/scenic-queue",
|
|
||||||
"queue_management",
|
|
||||||
"deposit_order_detail",
|
|
||||||
"deposit_order",
|
|
||||||
"deposit_order_shooting_info",
|
|
||||||
"withdrawal_audit",
|
|
||||||
"live_stream_management",
|
|
||||||
"live_album",
|
"live_album",
|
||||||
"scenic_settlement",
|
|
||||||
"scenic_settlement_review",
|
|
||||||
"operating-area",
|
"operating-area",
|
||||||
"pilot_cert":
|
"pilot_cert":
|
||||||
let resolvedTitle = title.isEmpty ? self.title(for: uri) : title
|
let resolvedTitle = title.isEmpty ? self.title(for: uri) : title
|
||||||
|
|||||||
@ -84,6 +84,18 @@ extension HomeRoute {
|
|||||||
LocationReportView()
|
LocationReportView()
|
||||||
case .locationReportHistory:
|
case .locationReportHistory:
|
||||||
LocationReportHistoryView()
|
LocationReportHistoryView()
|
||||||
|
case .depositOrders:
|
||||||
|
DepositOrderEntryView()
|
||||||
|
case .withdrawalAudit:
|
||||||
|
WithdrawalAuditView()
|
||||||
|
case .scenicSettlement:
|
||||||
|
ScenicSettlementView()
|
||||||
|
case .scenicSettlementReview:
|
||||||
|
ScenicSettlementReviewView()
|
||||||
|
case .messageCenter:
|
||||||
|
MessageCenterView()
|
||||||
|
case .queueManagement:
|
||||||
|
QueueManagementView()
|
||||||
case let .modulePlaceholder(uri, title):
|
case let .modulePlaceholder(uri, title):
|
||||||
HomeMigrationModuleView(title: title, uri: uri)
|
HomeMigrationModuleView(title: title, uri: uri)
|
||||||
}
|
}
|
||||||
|
|||||||
74
suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift
Normal file
74
suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift
Normal file
@ -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 {}
|
||||||
18
suixinkan/Features/MessageCenter/MessageCenter.md
Normal file
18
suixinkan/Features/MessageCenter/MessageCenter.md
Normal file
@ -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 能力,逐条调用已读接口;任一失败时保留原状态并透出错误。
|
||||||
|
- 首屏刷新失败会清空旧数据和分页状态,加载更多失败保留当前列表。
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
302
suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift
Normal file
302
suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift
Normal file
@ -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("删除后不可恢复,是否继续?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,30 @@ protocol OrderServing {
|
|||||||
|
|
||||||
/// 获取门店订单详情。
|
/// 获取门店订单详情。
|
||||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse
|
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse
|
||||||
|
|
||||||
|
/// 获取押金订单列表。
|
||||||
|
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem>
|
||||||
|
|
||||||
|
/// 核销押金订单。
|
||||||
|
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
|
@MainActor
|
||||||
@ -131,4 +155,106 @@ final class OrdersAPI: OrderServing {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取押金订单列表。
|
||||||
|
func depositOrderList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<DepositOrderListItem> {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 的二级页面。
|
/// 订单详情路由实体,表示订单模块内部可 push 的二级页面。
|
||||||
enum OrdersRoute: Hashable {
|
enum OrdersRoute: Hashable {
|
||||||
case storeDetail(OrderEntity)
|
case storeDetail(OrderEntity)
|
||||||
case writeOffDetail(WriteOffOrderItem)
|
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 {
|
struct StoreOrderDetailDisplay: Equatable {
|
||||||
let orderNumber: String
|
let orderNumber: String
|
||||||
|
|||||||
@ -2,15 +2,21 @@
|
|||||||
|
|
||||||
## 模块职责
|
## 模块职责
|
||||||
|
|
||||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理和核销订单两个入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销和手动核销闭环。
|
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传和尾片入口闭环。
|
||||||
|
|
||||||
当前仍不迁移退款、历史拍摄、任务上传、视频预告、完整拍摄详情等长尾流程;这些入口统一进入安全占位页,后续按独立模块逐个迁移。
|
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。
|
||||||
|
|
||||||
## 核心对象
|
## 核心对象
|
||||||
|
|
||||||
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
||||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
||||||
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
|
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
|
||||||
|
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
|
||||||
|
- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。
|
||||||
|
- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。
|
||||||
|
- `OrderRefundViewModel`:管理普通订单退款入口判断、全额/部分退款金额校验和提交保护。
|
||||||
|
- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。
|
||||||
|
- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。
|
||||||
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
||||||
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
||||||
- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。
|
- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。
|
||||||
@ -26,8 +32,18 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理和核销
|
|||||||
|
|
||||||
核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
核销订单支持扫码和手动输入订单号。扫码使用 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。
|
||||||
|
|
||||||
退款、历史拍摄、任务上传、视频预告等尚未迁移的能力仍进入占位页,避免入口崩溃。
|
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。
|
||||||
|
|||||||
@ -43,8 +43,13 @@ final class OrderDetailViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 加载门店订单详情,缺少门店 ID 时只保留列表摘要展示。
|
/// 加载门店订单详情,缺少门店 ID 时只保留列表摘要展示。
|
||||||
func load(api: OrderServing, fallbackStoreId: Int?) async {
|
func load(api: OrderServing, fallbackStoreId: Int?, forceReload: Bool = false) async {
|
||||||
guard detail == nil, !loading else { return }
|
guard !loading else { return }
|
||||||
|
if forceReload {
|
||||||
|
detail = nil
|
||||||
|
} else {
|
||||||
|
guard detail == nil else { return }
|
||||||
|
}
|
||||||
|
|
||||||
guard let storeId = item.storeId ?? fallbackStoreId, storeId > 0 else {
|
guard let storeId = item.storeId ?? fallbackStoreId, storeId > 0 else {
|
||||||
contextMessage = "缺少门店上下文,当前展示订单摘要。"
|
contextMessage = "缺少门店上下文,当前展示订单摘要。"
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
596
suixinkan/Features/Orders/Views/DepositOrderViews.swift
Normal file
596
suixinkan/Features/Orders/Views/DepositOrderViews.swift
Normal file
@ -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<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { pendingWriteOff != nil },
|
||||||
|
set: { if !$0 { pendingWriteOff = nil } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refundBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { pendingRefund != nil },
|
||||||
|
set: { if !$0 { pendingRefund = nil; refundReason = "" } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var errorBinding: Binding<Bool> {
|
||||||
|
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<Bool> {
|
||||||
|
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<Bool> {
|
||||||
|
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<Bool> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,8 @@ struct StoreOrderDetailView: View {
|
|||||||
|
|
||||||
let item: OrderEntity
|
let item: OrderEntity
|
||||||
@State private var viewModel: OrderDetailViewModel
|
@State private var viewModel: OrderDetailViewModel
|
||||||
|
@State private var refundViewModel = OrderRefundViewModel()
|
||||||
|
@State private var showRefundSheet = false
|
||||||
|
|
||||||
/// 使用列表订单初始化详情页,并创建详情 ViewModel。
|
/// 使用列表订单初始化详情页,并创建详情 ViewModel。
|
||||||
init(item: OrderEntity) {
|
init(item: OrderEntity) {
|
||||||
@ -96,10 +98,36 @@ struct StoreOrderDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("后续功能") {
|
Section("后续功能") {
|
||||||
placeholderButton("历史拍摄", title: "历史拍摄")
|
Button {
|
||||||
placeholderButton("任务上传", title: "任务上传")
|
router.navigate(to: .orders(.historicalShooting(orderNumber: viewModel.display.orderNumber)))
|
||||||
placeholderButton("视频预告", title: "视频预告")
|
} label: {
|
||||||
placeholderButton("退款", title: "订单退款")
|
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)
|
.listStyle(.insetGrouped)
|
||||||
@ -117,6 +145,17 @@ struct StoreOrderDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(viewModel.errorMessage ?? "")
|
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 {
|
private var displayPayTime: String {
|
||||||
@ -136,11 +175,8 @@ struct StoreOrderDetailView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建尚未迁移业务入口的占位按钮。
|
/// 构建可跳转业务入口行。
|
||||||
private func placeholderButton(_ label: String, title: String) -> some View {
|
private func navigationRow(_ label: String) -> some View {
|
||||||
Button {
|
|
||||||
router.navigate(to: .placeholder(title: title))
|
|
||||||
} label: {
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(label)
|
Text(label)
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -149,7 +185,14 @@ struct StoreOrderDetailView: View {
|
|||||||
.foregroundStyle(AppDesign.textSecondary)
|
.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,避免详情页出现孤立货币符号。
|
/// 将空金额转换为 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 {
|
struct WriteOffOrderDetailView: View {
|
||||||
@Environment(AccountContext.self) private var accountContext
|
@Environment(AccountContext.self) private var accountContext
|
||||||
|
|||||||
357
suixinkan/Features/Orders/Views/OrderTailUploadViews.swift
Normal file
357
suixinkan/Features/Orders/Views/OrderTailUploadViews.swift
Normal file
@ -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<Bool> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
201
suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift
Normal file
201
suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift
Normal file
@ -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 {}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
suixinkan/Features/QueueManagement/QueueManagement.md
Normal file
20
suixinkan/Features/QueueManagement/QueueManagement.md
Normal file
@ -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` 按本地开关进行短轮询。
|
||||||
|
- 设置保存前校验拍摄时长、通知阈值、播报间隔、读秒阈值、排队范围、排队次数、过号顺延和自定义播报长度。
|
||||||
@ -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<Int64>()
|
||||||
|
@ObservationIgnored private var handledRemoteCallEventIds = Set<String>()
|
||||||
|
|
||||||
|
/// 待处理排队数量。
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) 重新插入当前排队队列吗?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String>, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,4 +26,4 @@
|
|||||||
|
|
||||||
当前景区、当前门店、角色 ID 和景区作用域属于账号快照,可保存到 UserDefaults。定位结果、景区申请图片临时数据、OSS STS token、上传进度和表单输入不落盘。
|
当前景区、当前门店、角色 ID 和景区作用域属于账号快照,可保存到 UserDefaults。定位结果、景区申请图片临时数据、OSS STS token、上传进度和表单输入不落盘。
|
||||||
|
|
||||||
`scenic_settlement` 和 `scenic_settlement_review` 是首页独立结算业务入口,不属于本模块迁移范围,目前仍保持占位。
|
`scenic_settlement` 和 `scenic_settlement_review` 是首页独立结算业务入口,已由 `Features/ScenicSettlement` 接管。ScenicPermission 模块只提供景区列表、景区申请记录和权限申请记录等可复用接口。
|
||||||
|
|||||||
@ -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 {}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
suixinkan/Features/ScenicSettlement/ScenicSettlement.md
Normal file
23
suixinkan/Features/ScenicSettlement/ScenicSettlement.md
Normal file
@ -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 已接入的真实结算提交接口和审核记录聚合方式为迁移依据,不新增后端未体现的详情审批操作。
|
||||||
@ -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<Int>()
|
||||||
|
|
||||||
|
/// 已选择景区数量。
|
||||||
|
var selectedCount: Int {
|
||||||
|
selectedIds.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选择的景区 ID,供测试和提交使用。
|
||||||
|
var selectedScenicIds: Set<Int> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Content: View>(@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 ?? "--"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
suixinkan/Features/WithdrawalAudit/WithdrawalAudit.md
Normal file
16
suixinkan/Features/WithdrawalAudit/WithdrawalAudit.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# 提现审核模块
|
||||||
|
|
||||||
|
## 模块职责
|
||||||
|
|
||||||
|
`Features/WithdrawalAudit` 承接首页 `withdrawal_audit` 权限入口,以审核视角展示钱包提现记录、状态筛选和单笔处理进度。
|
||||||
|
|
||||||
|
## 业务流程
|
||||||
|
|
||||||
|
- `WithdrawalAuditView` 进入后调用 `WalletAPI.walletWithdrawList` 加载第一页提现记录。
|
||||||
|
- `WithdrawalAuditViewModel` 管理分页、加载更多、筛选状态和失败清理。
|
||||||
|
- 筛选只在本地执行:处理中匹配状态文案中的“中/待”,已完成匹配“完成/到账/通过”。
|
||||||
|
- 刷新失败会清空旧记录和分页状态,避免账号或权限切换后残留上一组财务数据。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
本模块不做管理员提现审批操作。旧 Android 工程未找到提现审批列表或操作接口,当前按旧 iOS 的提现记录审核视角迁移。
|
||||||
27
suixinkanTests/Fixtures/deposit_order_list_success.json
Normal file
27
suixinkanTests/Fixtures/deposit_order_list_success.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
30
suixinkanTests/Fixtures/message_list_page2_success.json
Normal file
30
suixinkanTests/Fixtures/message_list_page2_success.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
30
suixinkanTests/Fixtures/message_list_success.json
Normal file
30
suixinkanTests/Fixtures/message_list_success.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 901,
|
||||||
|
"name": "湖心亭"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "902",
|
||||||
|
"name": "观景台"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
63
suixinkanTests/Fixtures/scenic_queue_home_success.json
Normal file
63
suixinkanTests/Fixtures/scenic_queue_home_success.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
suixinkanTests/Fixtures/scenic_queue_qrcode_success.json
Normal file
7
suixinkanTests/Fixtures/scenic_queue_qrcode_success.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"qrcode_url": "https://cdn.example.com/queue/spot-9.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
suixinkanTests/Fixtures/scenic_queue_setting_success.json
Normal file
40
suixinkanTests/Fixtures/scenic_queue_setting_success.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
suixinkanTests/Fixtures/scenic_queue_stats_success.json
Normal file
9
suixinkanTests/Fixtures/scenic_queue_stats_success.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"queue_count": "5",
|
||||||
|
"avg_wait_min": "12.5",
|
||||||
|
"time": "2026-05-23 03:10"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
suixinkanTests/Fixtures/scenic_spot_list_all_success.json
Normal file
21
suixinkanTests/Fixtures/scenic_spot_list_all_success.json
Normal file
@ -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": "停用"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,21 +53,22 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_invite", 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: "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() {
|
func testKnownUnmigratedRoutesResolveToPlaceholders() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""),
|
HomeMenuRouter.resolve(uri: "live_stream_management", title: ""),
|
||||||
.destination(.modulePlaceholder(uri: "scenic_settlement", title: "景区结算"))
|
.destination(.modulePlaceholder(uri: "live_stream_management", 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: "消息中心"))
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +115,8 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "payment_code"), "payment_collection")
|
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "payment_code"), "payment_collection")
|
||||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "deposit_order_shooting_info"), "deposit_order")
|
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "deposit_order_shooting_info"), "deposit_order")
|
||||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-order-manage"), "photographer_orders")
|
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")
|
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("schedule_management"))
|
||||||
XCTAssertTrue(uris.contains("registration_invitation"))
|
XCTAssertTrue(uris.contains("registration_invitation"))
|
||||||
XCTAssertTrue(uris.contains("invite_record"))
|
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: "album_list", title: ""), .destination(.albumList))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
|
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: "schedule_management", title: ""), .destination(.scheduleManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord))
|
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
|
#endif
|
||||||
|
|
||||||
|
|||||||
@ -103,4 +103,41 @@ private final class MockMainTabOrderService: OrderServing {
|
|||||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||||
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 角标测试不会请求押金订单列表。
|
||||||
|
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||||
|
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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
167
suixinkanTests/MessageCenter/MessageCenterViewModelTests.swift
Normal file
167
suixinkanTests/MessageCenter/MessageCenterViewModelTests.swift
Normal file
@ -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 {}
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@ final class NavigationRouterTests: XCTestCase {
|
|||||||
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
|
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
|
||||||
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
||||||
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).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 时会保存订单内部子入口。
|
/// 测试切换订单 Tab 时会保存订单内部子入口。
|
||||||
@ -70,10 +72,14 @@ final class NavigationRouterTests: XCTestCase {
|
|||||||
|
|
||||||
router.navigate(to: .orders(.storeDetail(order)))
|
router.navigate(to: .orders(.storeDetail(order)))
|
||||||
router.navigate(to: .orders(.writeOffDetail(writeOff)))
|
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[0], .orders(.storeDetail(order)))
|
||||||
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
|
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
|
||||||
|
XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
|
||||||
|
XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回测试用订单管理列表项。
|
/// 返回测试用订单管理列表项。
|
||||||
|
|||||||
@ -117,4 +117,53 @@ private final class DetailMockOrderService: OrderServing {
|
|||||||
}
|
}
|
||||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
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<DepositOrderListItem> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
596
suixinkanTests/OrderLongTailTests.swift
Normal file
596
suixinkanTests/OrderLongTailTests.swift
Normal file
@ -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<DepositOrderListItem> {
|
||||||
|
try TestFixture.payload(ListPayload<DepositOrderListItem>.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<DepositOrderListItem>] = []
|
||||||
|
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<OrderEntity> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核销列表接口在长尾测试中不使用。
|
||||||
|
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||||
|
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<DepositOrderListItem> {
|
||||||
|
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])
|
||||||
|
}
|
||||||
@ -309,6 +309,55 @@ private final class MockOrderService: OrderServing {
|
|||||||
detailCalls.append((storeId: storeId, orderNumber: orderNumber))
|
detailCalls.append((storeId: storeId, orderNumber: orderNumber))
|
||||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
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<DepositOrderListItem> {
|
||||||
|
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 测试替身,记录请求并返回固定响应。
|
/// URLSession 测试替身,记录请求并返回固定响应。
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<T>(
|
||||||
|
_ 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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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? {
|
||||||
|
"测试错误"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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? {
|
||||||
|
"测试错误"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,9 +53,12 @@
|
|||||||
| 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 无。 |
|
| 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 无。 |
|
||||||
| 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 无。 |
|
| 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 无。 |
|
||||||
| 完成 | 扫码核销 | AVFoundation 扫码、订单号解析、命中高亮、二次确认后核销。 | 后续可继续补扫码 UI 细节。 |
|
| 完成 | 扫码核销 | AVFoundation 扫码、订单号解析、命中高亮、二次确认后核销。 | 后续可继续补扫码 UI 细节。 |
|
||||||
| 完成 | 订单详情 | 订单管理详情、核销订单详情、详情接口和列表摘要兜底。 | 退款、历史拍摄、任务上传、视频预告等长尾流程仍未迁移。 |
|
| 完成 | 订单详情 | 订单管理详情、核销订单详情、详情接口和列表摘要兜底。 | 无。 |
|
||||||
| 未开始 | 退款流程 | 暂未迁移。 | 需要同步旧工程退款 UI、接口和状态流。 |
|
| 完成 | 押金订单 | 押金订单列表、手输订单号查详情、押金详情、押金核销、押金退款、押金拍摄信息。 | 后续可继续补押金订单 UI 细节和异常空态。 |
|
||||||
| 未开始 | 历史拍摄 | 暂未迁移。 | 需要同步旧工程相关页面和接口。 |
|
| 完成 | 退款流程 | 普通订单退款入口规则、全额/部分退款金额校验、退款提交和详情刷新。 | 复杂退款审核进度如后端需要可单独补。 |
|
||||||
|
| 完成 | 历史拍摄 | 多点位历史拍摄项目、拍摄点和媒体展示。 | 仅展示已有媒体,不做上传、删除、下载或编辑。 |
|
||||||
|
| 完成 | 任务上传 | 多点旅拍订单选择已核销打卡点、云盘素材、本地图片/视频 OSS 上传并提交素材。 | 仅 `orderType == 19` 展示。 |
|
||||||
|
| 完成 | 视频预告/尾片上传 | 进入订单尾片引导页,并复用相册预览上传。 | 没有独立订单尾片接口,暂不做尾片审核进度。 |
|
||||||
|
|
||||||
## 数据 Tab
|
## 数据 Tab
|
||||||
|
|
||||||
@ -83,9 +86,9 @@
|
|||||||
| 状态 | 模块 | 当前情况 | 后续事项 |
|
| 状态 | 模块 | 当前情况 | 后续事项 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 |
|
| 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 |
|
||||||
| 部分完成 | 提现审核 | 首页入口已识别,进入占位页。 | 迁移审核列表、详情、操作接口。 |
|
| 完成 | 提现审核 | 首页入口已接真实页面,复用提现记录接口展示审核列表、状态筛选、分页和详情时间线。 | 不做管理员审批操作,旧 Android 未体现对应操作接口。 |
|
||||||
| 部分完成 | 消息中心 | 首页入口已识别,进入占位页。 | 迁移消息列表、已读状态和详情。 |
|
| 完成 | 消息中心 | 消息列表、未读筛选、游标分页、已读/全部已读、详情和删除已接入。 | 本轮不新增后端未体现的批量接口。 |
|
||||||
| 部分完成 | 排队管理 | `/scenic-queue` / `queue_management` 已识别,进入占位页。 | 迁移队列列表、叫号、设置等流程。 |
|
| 完成 | 排队管理 | `/scenic-queue` / `queue_management` 已接真实页面;支持队列列表、叫号、过号、完成、重新排队、用户标记、设置、二维码、日志、实时监听和页面离开后的短轮询。 | 后续可继续按真实设备验证语音播报和后台活跃策略。 |
|
||||||
| 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 |
|
| 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 |
|
||||||
| 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 |
|
| 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 |
|
||||||
| 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 |
|
| 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 |
|
||||||
@ -102,7 +105,7 @@
|
|||||||
| 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 |
|
| 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 |
|
||||||
| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 |
|
| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 |
|
||||||
| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 |
|
| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 |
|
||||||
| 部分完成 | 景区结算 | 入口已识别,进入占位页。 | 迁移结算列表、审核、详情。 |
|
| 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 |
|
||||||
| 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 |
|
| 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 |
|
||||||
| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 |
|
| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 |
|
||||||
| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 |
|
| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 |
|
||||||
@ -116,7 +119,7 @@
|
|||||||
| 完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘、素材管理、样片管理和上传样片。 |
|
| 完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘、素材管理、样片管理和上传样片。 |
|
||||||
| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 |
|
| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 |
|
||||||
| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 |
|
| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 |
|
||||||
| 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 |
|
| 完成 | Core/Queue | 已迁移排队 WebSocket、远端叫号去重、语音播报和短轮询运行时。 |
|
||||||
| 未开始 | Core/Validation | 旧工程独立校验能力尚未系统迁移;当前只迁移了实名身份证校验等必要逻辑。 |
|
| 未开始 | Core/Validation | 旧工程独立校验能力尚未系统迁移;当前只迁移了实名身份证校验等必要逻辑。 |
|
||||||
| 未开始 | UIKit 旧代码 | 旧工程 UIKit 目录下的兼容/旧版页面尚未迁移,除非后续明确仍需保留。 |
|
| 未开始 | UIKit 旧代码 | 旧工程 UIKit 目录下的兼容/旧版页面尚未迁移,除非后续明确仍需保留。 |
|
||||||
|
|
||||||
@ -131,7 +134,6 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination
|
|||||||
|
|
||||||
## 下一批建议迁移顺序
|
## 下一批建议迁移顺序
|
||||||
|
|
||||||
1. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。
|
1. 直播管理/直播相册:入口已识别但业务独立,建议单独拆模块迁移。
|
||||||
2. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。
|
2. 运营区域/飞手认证:仍为首页占位入口,可按旧工程业务优先级拆分。
|
||||||
3. 排队管理/消息中心:属于常用运营辅助入口,可按列表、详情、操作闭环逐步迁移。
|
3. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。
|
||||||
4. 直播管理/直播相册:入口已识别但业务独立,建议单独拆模块迁移。
|
|
||||||
|
|||||||
Reference in New Issue
Block a user