Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit. Co-authored-by: Cursor <cursoragent@cursor.com>
344 lines
14 KiB
Swift
344 lines
14 KiB
Swift
//
|
|
// 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))
|
|
}
|
|
}
|