新增订单长尾流程,并迁移排队、消息、结算与审核模块
将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user