Files
suixinkan_ios_new/suixinkan/Features/QueueManagement/Views/QueueManagementViews.swift
汉秋 c39c3d3c75 新增订单长尾流程,并迁移排队、消息、结算与审核模块
将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 13:39:02 +08:00

465 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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) 重新插入当前排队队列吗?"
}
}
}