// // ScenicQueueSettingsViews.swift // suixinkan // // Created by Codex on 2026/6/25. // import SwiftUI /// 排队设置页面。 struct ScenicQueueSettingsView: 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 = 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 { AppContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath") .frame(maxWidth: .infinity, minHeight: 180) } else { ForEach(viewModel.logs) { log in VStack(alignment: .leading, spacing: 6) { Text(log.displayText.isEmpty ? log.summary : log.displayText) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) .foregroundStyle(AppDesign.textPrimary) Text("\(log.scenicSpotName) \(log.operatorName) \(log.createdAt)") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } .frame(maxWidth: .infinity, alignment: .leading) Divider() } } } .cardStyle() } private func field(_ title: String, text: Binding, keyboard: UIKeyboardType, unit: String) -> some View { HStack(spacing: AppMetrics.Spacing.medium) { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textPrimary) .frame(width: 104, alignment: .leading) TextField("", text: text) .keyboardType(keyboard) .textFieldStyle(.plain) .multilineTextAlignment(.trailing) Text(unit) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) } private func sectionTitle(_ title: String) -> some View { Text(title) .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) } private func fetchQRCode() async { do { qrcodeURL = try await viewModel.fetchQRCode(api: queueAPI, scenicId: currentScenicId) } catch { toastCenter.show(error.localizedDescription) } } private func saveQRCode(url: String) async { do { try await viewModel.saveQRCodeToPhotoLibrary(qrcodeURL: url) toastCenter.show("二维码已保存") } catch { toastCenter.show(error.localizedDescription) } } private func save() async { saving = true defer { saving = false } do { try await viewModel.save(api: queueAPI, scenicId: currentScenicId, userId: currentUserId) toastCenter.show("排队设置已保存") await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId) } catch { toastCenter.show(error.localizedDescription) } } } private enum ScenicQueueSettingsTab: CaseIterable, Identifiable { case basic case notice case voice case logs var id: Self { self } var title: String { switch self { case .basic: return "基础" case .notice: return "通知" case .voice: return "语音" case .logs: return "日志" } } } private struct QueueQRCodeSheetItem: Identifiable { let url: String var id: String { url } } private struct QueueQRCodeView: View { let url: String let onSave: () async -> Void @Environment(\.dismiss) private var dismiss @State private var saving = false var body: some View { NavigationStack { VStack(spacing: AppMetrics.Spacing.large) { RemoteImage(urlString: url, contentMode: .fit) { Image(systemName: "qrcode") .font(.system(size: 80)) .foregroundStyle(AppDesign.placeholder) } .frame(maxWidth: .infinity) .frame(height: 280) .padding(AppMetrics.Spacing.large) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) Button { Task { saving = true await onSave() saving = false } } label: { Label(saving ? "保存中" : "保存到相册", systemImage: "square.and.arrow.down") .frame(maxWidth: .infinity) .frame(height: AppMetrics.ControlSize.primaryButtonHeight) } .buttonStyle(.borderedProminent) .disabled(saving) } .padding(AppMetrics.Spacing.pageHorizontal) .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) .navigationTitle("取号二维码") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } } } } } private extension View { func cardStyle() -> some View { padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, alignment: .leading) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } }