// // QueueManagementViews.swift // suixinkan // // Created by Codex on 2026/6/25. // import SwiftUI /// 排队管理首页,展示当前打卡点队列和操作入口。 struct QueueManagementView: View { @EnvironmentObject private var accountContext: AccountContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext @Environment(\.scenicQueueAPI) private var queueAPI @EnvironmentObject private var queueRuntime: ScenicQueueRuntime @EnvironmentObject private var toastCenter: ToastCenter @StateObject 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 { AppContentUnavailableView("请先选择景区", 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 { AppContentUnavailableView(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) 重新插入当前排队队列吗?" } } }