引入带引用计数的 GlobalLoadingCenter,通过 RootView 及主要登录/订单/个人中心页面接入,并添加 Loading 动画资源与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
746 lines
31 KiB
Swift
746 lines
31 KiB
Swift
//
|
||
// OrdersView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 订单 Tab 根视图,展示订单管理和核销订单两个业务入口。
|
||
struct OrdersView: View {
|
||
@Environment(AccountContext.self) private var accountContext
|
||
@Environment(PermissionContext.self) private var permissionContext
|
||
@Environment(AppRouter.self) private var appRouter
|
||
@Environment(RouterPath.self) private var router
|
||
@Environment(OrdersAPI.self) private var ordersAPI
|
||
@Environment(ToastCenter.self) private var toastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||
|
||
@State private var viewModel = OrdersViewModel()
|
||
@State private var manualOrderNumber = ""
|
||
@State private var showDateFilterSheet = false
|
||
@State private var showScanner = false
|
||
@State private var showVerifyConfirmation = false
|
||
@State private var pendingVerifyOrder: WriteOffOrderItem?
|
||
@State private var pendingVerifyOrderNumber: String?
|
||
@State private var matchedScannedOrderNumber: String?
|
||
@State private var scanHintMessage: String?
|
||
|
||
private var currentScenicId: Int? {
|
||
accountContext.currentScenic?.id
|
||
}
|
||
|
||
private var currentStoreId: Int? {
|
||
accountContext.currentStore?.id
|
||
}
|
||
|
||
private var currentRoleId: Int? {
|
||
permissionContext.currentRole?.id
|
||
}
|
||
|
||
private var contentMaxWidth: CGFloat {
|
||
horizontalSizeClass == .regular ? 900 : .infinity
|
||
}
|
||
|
||
private var currentFilterTitle: String {
|
||
OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
|
||
}
|
||
|
||
private var timeFilterTitle: String {
|
||
if let start = viewModel.filterStartDate, let end = viewModel.filterEndDate {
|
||
return "\(Self.dateFormatter.string(from: start)) 至 \(Self.dateFormatter.string(from: end))"
|
||
}
|
||
return "时间筛选"
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollViewReader { scrollProxy in
|
||
ScrollView {
|
||
VStack(spacing: AppMetrics.Spacing.small) {
|
||
entrySection
|
||
|
||
if currentScenicId == nil {
|
||
ContentUnavailableView(
|
||
"缺少经营上下文",
|
||
systemImage: "mountain.2",
|
||
description: Text("请先在首页选择景区后查看订单。")
|
||
)
|
||
.frame(minHeight: 360)
|
||
} else if viewModel.selectedEntry == .storeOrders {
|
||
storeOrdersSection
|
||
} else {
|
||
writeOffSection
|
||
}
|
||
}
|
||
.frame(maxWidth: contentMaxWidth)
|
||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||
.padding(.top, AppMetrics.Spacing.medium)
|
||
.padding(.bottom, AppMetrics.Spacing.pageVertical)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.onChange(of: matchedScannedOrderNumber) { _, orderNumber in
|
||
guard let orderNumber else { return }
|
||
withAnimation(.easeInOut(duration: 0.25)) {
|
||
scrollProxy.scrollTo(orderNumber, anchor: .center)
|
||
}
|
||
}
|
||
}
|
||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||
.navigationTitle("订单")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await reload(showLoading: false) }
|
||
.task {
|
||
viewModel.selectedEntry = appRouter.selectedOrdersEntry
|
||
await reload()
|
||
await consumePendingScanCodeIfNeeded()
|
||
}
|
||
.onChange(of: appRouter.selectedOrdersEntry) { _, entry in
|
||
guard viewModel.selectedEntry != entry else { return }
|
||
viewModel.selectedEntry = entry
|
||
Task { await reload() }
|
||
}
|
||
.onChange(of: appRouter.pendingOrderScanCode) { _, _ in
|
||
Task { await consumePendingScanCodeIfNeeded() }
|
||
}
|
||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||
Task { await reload() }
|
||
}
|
||
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||
Button("确认核销") {
|
||
guard let orderNumber = pendingVerifyOrderNumber else { return }
|
||
Task { await verify(orderNumber: orderNumber) }
|
||
}
|
||
Button("取消", role: .cancel) {
|
||
pendingVerifyOrder = nil
|
||
pendingVerifyOrderNumber = nil
|
||
}
|
||
} message: {
|
||
Text(verifyDialogMessage)
|
||
}
|
||
.fullScreenCover(isPresented: $showScanner) {
|
||
OrderScannerPage(
|
||
onClose: { showScanner = false },
|
||
onSuccess: { rawCode in
|
||
showScanner = false
|
||
handleScanResult(rawCode)
|
||
},
|
||
onFailure: { error in
|
||
showScanner = false
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
)
|
||
}
|
||
.sheet(isPresented: $showDateFilterSheet) {
|
||
OrdersDateFilterSheet(
|
||
startDate: $viewModel.filterStartDate,
|
||
endDate: $viewModel.filterEndDate,
|
||
onApply: { Task { await reload() } }
|
||
)
|
||
}
|
||
}
|
||
|
||
private var entrySection: some View {
|
||
VStack(spacing: AppMetrics.Spacing.small) {
|
||
HStack(spacing: 0) {
|
||
entryButton("订单管理", entry: .storeOrders)
|
||
entryButton("核销订单", entry: .verificationOrders)
|
||
}
|
||
.padding(AppMetrics.Spacing.xxxSmall)
|
||
.background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 8))
|
||
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
contextPill(
|
||
icon: viewModel.selectedEntry == .storeOrders ? "mountain.2" : "qrcode",
|
||
title: viewModel.selectedEntry == .storeOrders ? "当前景区" : "核销订单",
|
||
value: viewModel.selectedEntry == .storeOrders ? (accountContext.currentScenic?.name ?? "--") : "\(viewModel.writeOffTotal)"
|
||
)
|
||
contextPill(
|
||
icon: "doc.text",
|
||
title: viewModel.selectedEntry == .storeOrders ? "订单总数" : "当前景区",
|
||
value: viewModel.selectedEntry == .storeOrders ? "\(viewModel.storeTotal)" : (accountContext.currentScenic?.name ?? "--")
|
||
)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private var storeOrdersSection: some View {
|
||
VStack(spacing: AppMetrics.Spacing.small) {
|
||
storeFilterSection
|
||
storeList
|
||
}
|
||
}
|
||
|
||
private var writeOffSection: some View {
|
||
VStack(spacing: AppMetrics.Spacing.small) {
|
||
writeOffActionSection
|
||
writeOffList
|
||
}
|
||
}
|
||
|
||
private var storeFilterSection: some View {
|
||
VStack(spacing: AppMetrics.Spacing.small) {
|
||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||
Menu {
|
||
ForEach(OrderFilters.statusFilters) { filter in
|
||
Button(filter.title) {
|
||
viewModel.selectedStatus = filter.id
|
||
Task { await reload() }
|
||
}
|
||
}
|
||
} label: {
|
||
filterLabel(title: currentFilterTitle, icon: "chevron.down")
|
||
}
|
||
Button {
|
||
showDateFilterSheet = true
|
||
} label: {
|
||
filterLabel(title: timeFilterTitle, icon: "calendar")
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||
TextField("输入手机号搜索", text: $viewModel.searchPhone)
|
||
.keyboardType(.phonePad)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||
.frame(height: 40)
|
||
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6))
|
||
|
||
Button("搜索") {
|
||
Task { await reload() }
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||
.frame(height: 40)
|
||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 6))
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private var storeList: some View {
|
||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||
if viewModel.loading && viewModel.storeOrders.isEmpty {
|
||
Color.clear
|
||
.frame(maxWidth: .infinity)
|
||
.frame(minHeight: 260)
|
||
} else if viewModel.storeOrders.isEmpty {
|
||
ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||
.frame(minHeight: 260)
|
||
} else {
|
||
ForEach(viewModel.storeOrders) { item in
|
||
storeOrderCard(item)
|
||
.onAppear {
|
||
guard item.id == viewModel.storeOrders.last?.id else { return }
|
||
Task { await loadMoreStoreOrders() }
|
||
}
|
||
}
|
||
if viewModel.loadingMore {
|
||
ProgressView()
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, AppMetrics.Spacing.small)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var writeOffActionSection: some View {
|
||
VStack(spacing: AppMetrics.Spacing.small) {
|
||
Button {
|
||
showScanner = true
|
||
} label: {
|
||
Label("扫码核销", systemImage: "qrcode.viewfinder")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 42)
|
||
.background(Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityIdentifier("orders.scan")
|
||
|
||
if let scanHintMessage {
|
||
Label(scanHintMessage, systemImage: "info.circle")
|
||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||
TextField("手动输入订单号", text: $manualOrderNumber)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||
.frame(height: 42)
|
||
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 8))
|
||
.accessibilityIdentifier("orders.manualOrderNumber")
|
||
|
||
Button(viewModel.isVerifying ? "核销中" : "核销") {
|
||
triggerManualVerify()
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||
.frame(height: 42)
|
||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
|
||
.disabled(viewModel.isVerifying)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private var writeOffList: some View {
|
||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||
if viewModel.loading && viewModel.writeOffOrders.isEmpty {
|
||
Color.clear
|
||
.frame(maxWidth: .infinity)
|
||
.frame(minHeight: 260)
|
||
} else if viewModel.writeOffOrders.isEmpty {
|
||
ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
|
||
.frame(minHeight: 260)
|
||
} else {
|
||
ForEach(viewModel.writeOffOrders) { item in
|
||
writeOffOrderCard(item)
|
||
.onAppear {
|
||
guard item.id == viewModel.writeOffOrders.last?.id else { return }
|
||
Task { await loadMoreWriteOffOrders() }
|
||
}
|
||
}
|
||
if viewModel.loadingMore {
|
||
ProgressView()
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, AppMetrics.Spacing.small)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func entryButton(_ title: String, entry: OrdersEntry) -> some View {
|
||
let selected = viewModel.selectedEntry == entry
|
||
return Button {
|
||
switchEntry(entry)
|
||
} label: {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium))
|
||
.foregroundStyle(selected ? AppDesign.primary : AppDesign.textSecondary)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 36)
|
||
.background(selected ? .white : .clear, in: RoundedRectangle(cornerRadius: 6))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func contextPill(icon: String, title: String, value: String) -> some View {
|
||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
.frame(width: 28, height: 28)
|
||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
Text(value)
|
||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.75)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(AppMetrics.Spacing.xSmall)
|
||
.frame(maxWidth: .infinity)
|
||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private func filterLabel(title: String, icon: String) -> some View {
|
||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(Color(hex: 0x4B5563))
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.75)
|
||
Spacer(minLength: AppMetrics.Spacing.xSmall)
|
||
Image(systemName: icon)
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x4B5563))
|
||
}
|
||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 38)
|
||
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6))
|
||
}
|
||
|
||
private func storeOrderCard(_ item: OrderEntity) -> some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
|
||
Text("订单号:\(item.orderNumber)")
|
||
.font(.system(size: AppMetrics.FontSize.title3))
|
||
.foregroundStyle(.black)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.72)
|
||
Spacer()
|
||
statusBadge(item.orderStatusName, color: statusColor(for: item.orderStatus))
|
||
}
|
||
|
||
Text(item.createdAt.isEmpty ? "--" : item.createdAt)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
|
||
if !item.orderTypeLabel.isEmpty {
|
||
Text(item.orderTypeLabel)
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||
.foregroundStyle(AppDesign.primary)
|
||
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
|
||
orderInfoRow("付款金额", "¥\(item.actualPayAmount)", valueColor: AppDesign.primary)
|
||
orderInfoRow("付款时间", item.payTime.isEmpty ? "--" : item.payTime)
|
||
orderInfoRow("完成时间", item.completeTime.isEmpty ? "--" : item.completeTime)
|
||
orderInfoRow("付款方式", item.payTypeName.isEmpty ? "--" : item.payTypeName)
|
||
orderInfoRow("手机号", maskPhoneNumber(item.phone))
|
||
orderInfoRow("关联项目", item.projectName.isEmpty ? "--" : item.projectName)
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
router.navigate(to: .orders(.storeDetail(item)))
|
||
}
|
||
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
|
||
}
|
||
|
||
private func writeOffOrderCard(_ item: WriteOffOrderItem) -> some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
|
||
Text("订单号:\(item.orderNumber)")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(.black)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
statusBadge(displayVerificationStatus(for: item), color: writeOffStatusColor(for: item))
|
||
}
|
||
|
||
Text(item.payTime.isEmpty ? "--" : item.payTime)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
orderInfoRow("项目名称", item.projectName.isEmpty ? "--" : item.projectName)
|
||
orderInfoRow("手机号", item.userPhone.isEmpty ? "--" : item.userPhone)
|
||
orderInfoRow("订单状态", item.orderStatusName.isEmpty ? "--" : item.orderStatusName)
|
||
|
||
HStack {
|
||
Text("¥\(item.orderAmount)")
|
||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
Spacer()
|
||
Button("详情") {
|
||
router.navigate(to: .orders(.writeOffDetail(item)))
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||
.foregroundStyle(AppDesign.primary)
|
||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||
.frame(height: 32)
|
||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
|
||
.buttonStyle(.plain)
|
||
.accessibilityIdentifier("orders.writeoff.detail.\(item.orderNumber)")
|
||
|
||
Button(verifyButtonTitle(for: item)) {
|
||
pendingVerifyOrder = item
|
||
pendingVerifyOrderNumber = item.orderNumber
|
||
showVerifyConfirmation = true
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||
.frame(height: 32)
|
||
.background(canVerify(item) ? AppDesign.primary : Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8))
|
||
.buttonStyle(.plain)
|
||
.disabled(!canVerify(item) || viewModel.isVerifying)
|
||
.accessibilityIdentifier("orders.writeoff.verify.\(item.orderNumber)")
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(cardBackgroundColor(for: item), in: RoundedRectangle(cornerRadius: 8))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(matchedScannedOrderNumber == item.orderNumber ? AppDesign.primary : Color.clear, lineWidth: 2)
|
||
}
|
||
.id(item.orderNumber)
|
||
}
|
||
|
||
private func orderInfoRow(_ title: String, _ value: String, valueColor: Color = .black) -> some View {
|
||
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(Color(hex: 0x4B5563))
|
||
.frame(width: 72, alignment: .leading)
|
||
Spacer(minLength: AppMetrics.Spacing.xSmall)
|
||
Text(value)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||
.foregroundStyle(valueColor)
|
||
.multilineTextAlignment(.trailing)
|
||
.lineLimit(2)
|
||
.minimumScaleFactor(0.75)
|
||
}
|
||
}
|
||
|
||
private func statusBadge(_ text: String, color: Color) -> some View {
|
||
Text(text.isEmpty ? "--" : text)
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||
.foregroundStyle(color)
|
||
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
|
||
private func switchEntry(_ entry: OrdersEntry) {
|
||
guard viewModel.selectedEntry != entry else { return }
|
||
viewModel.selectedEntry = entry
|
||
appRouter.selectOrders(entry: entry)
|
||
Task { await reload() }
|
||
}
|
||
|
||
private func reload(showLoading: Bool = true) async {
|
||
do {
|
||
try await globalLoading.withOptionalLoading(shouldShowGlobalLoading(showLoading: showLoading), message: "加载中...") {
|
||
try await viewModel.reload(
|
||
api: ordersAPI,
|
||
scenicId: currentScenicId,
|
||
storeId: currentStoreId,
|
||
roleId: currentRoleId,
|
||
showLoading: showLoading
|
||
)
|
||
}
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 判断当前订单列表加载是否需要使用全局 Loading。
|
||
private func shouldShowGlobalLoading(showLoading: Bool) -> Bool {
|
||
guard showLoading, currentScenicId != nil else { return false }
|
||
switch viewModel.selectedEntry {
|
||
case .storeOrders:
|
||
return viewModel.storeOrders.isEmpty
|
||
case .verificationOrders:
|
||
return viewModel.writeOffOrders.isEmpty
|
||
}
|
||
}
|
||
|
||
private func loadMoreStoreOrders() async {
|
||
do {
|
||
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func loadMoreWriteOffOrders() async {
|
||
do {
|
||
try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId, storeId: currentStoreId)
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func triggerManualVerify() {
|
||
let orderNumber = manualOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !orderNumber.isEmpty else {
|
||
toastCenter.show("请输入订单号")
|
||
return
|
||
}
|
||
if let matched = viewModel.writeOffOrder(matching: orderNumber) {
|
||
pendingVerifyOrder = matched
|
||
pendingVerifyOrderNumber = matched.orderNumber
|
||
matchedScannedOrderNumber = matched.orderNumber
|
||
scanHintMessage = "已定位到列表订单:\(matched.orderNumber)"
|
||
} else {
|
||
pendingVerifyOrder = nil
|
||
pendingVerifyOrderNumber = orderNumber
|
||
matchedScannedOrderNumber = nil
|
||
scanHintMessage = "未在当前列表定位到订单,将按输入订单号发起核销。"
|
||
}
|
||
showVerifyConfirmation = true
|
||
}
|
||
|
||
private func verify(orderNumber: String) async {
|
||
guard let scenicId = currentScenicId else { return }
|
||
do {
|
||
try await globalLoading.withLoading(message: "核销中...") {
|
||
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, storeId: currentStoreId, orderNumber: orderNumber)
|
||
}
|
||
manualOrderNumber = ""
|
||
pendingVerifyOrder = nil
|
||
pendingVerifyOrderNumber = nil
|
||
matchedScannedOrderNumber = nil
|
||
scanHintMessage = nil
|
||
toastCenter.show("核销成功")
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 消费主 TabBar 全局扫码结果,并复用订单页内部扫码核销处理。
|
||
private func consumePendingScanCodeIfNeeded() async {
|
||
guard let rawCode = appRouter.consumePendingOrderScanCode() else { return }
|
||
viewModel.selectedEntry = .verificationOrders
|
||
appRouter.selectOrders(entry: .verificationOrders)
|
||
await reload(showLoading: false)
|
||
handleScanResult(rawCode)
|
||
}
|
||
|
||
/// 处理扫码返回的原始内容,并进入二次确认核销流程。
|
||
private func handleScanResult(_ rawCode: String) {
|
||
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||
toastCenter.show("扫码成功,但未识别到有效订单号,请手动输入。")
|
||
return
|
||
}
|
||
|
||
manualOrderNumber = result.orderNumber
|
||
pendingVerifyOrder = result.matched
|
||
pendingVerifyOrderNumber = result.orderNumber
|
||
|
||
if let matched = result.matched {
|
||
matchedScannedOrderNumber = matched.orderNumber
|
||
scanHintMessage = "已定位到列表订单:\(matched.orderNumber)"
|
||
} else {
|
||
matchedScannedOrderNumber = nil
|
||
scanHintMessage = "未在当前列表定位到订单,已填入订单号,可继续核销。"
|
||
}
|
||
showVerifyConfirmation = true
|
||
}
|
||
|
||
private func canVerify(_ item: WriteOffOrderItem) -> Bool {
|
||
let status = displayVerificationStatus(for: item)
|
||
return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成")
|
||
}
|
||
|
||
private var verifyDialogMessage: String {
|
||
let orderNumber = pendingVerifyOrderNumber ?? "-"
|
||
if let order = pendingVerifyOrder ?? viewModel.writeOffOrder(matching: orderNumber) {
|
||
return """
|
||
订单号:\(order.orderNumber)
|
||
项目:\(order.projectName.isEmpty ? "--" : order.projectName)
|
||
金额:¥\(order.orderAmount)
|
||
"""
|
||
}
|
||
return "订单号:\(orderNumber)"
|
||
}
|
||
|
||
private func displayVerificationStatus(for item: WriteOffOrderItem) -> String {
|
||
let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return text.isEmpty ? "待核销" : text
|
||
}
|
||
|
||
private func verifyButtonTitle(for item: WriteOffOrderItem) -> String {
|
||
canVerify(item) ? "核销" : "已处理"
|
||
}
|
||
|
||
private func writeOffStatusColor(for item: WriteOffOrderItem) -> Color {
|
||
canVerify(item) ? AppDesign.primary : Color(hex: 0x22C55E)
|
||
}
|
||
|
||
private func cardBackgroundColor(for item: WriteOffOrderItem) -> Color {
|
||
matchedScannedOrderNumber == item.orderNumber ? Color(hex: 0xEEF6FF) : .white
|
||
}
|
||
|
||
private func statusColor(for status: Int) -> Color {
|
||
switch status {
|
||
case 18:
|
||
return AppDesign.primary
|
||
case 30:
|
||
return Color(hex: 0x22C55E)
|
||
case 50:
|
||
return Color(hex: 0xEF4444)
|
||
default:
|
||
return Color(hex: 0x6B7280)
|
||
}
|
||
}
|
||
|
||
private func maskPhoneNumber(_ phone: String) -> String {
|
||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard trimmed.count >= 7 else { return trimmed.isEmpty ? "--" : trimmed }
|
||
let prefix = trimmed.prefix(3)
|
||
let suffix = trimmed.suffix(4)
|
||
return "\(prefix)****\(suffix)"
|
||
}
|
||
|
||
private static let dateFormatter: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
return formatter
|
||
}()
|
||
}
|
||
|
||
/// 订单日期筛选 Sheet,负责选择开始和结束日期。
|
||
private struct OrdersDateFilterSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Binding var startDate: Date?
|
||
@Binding var endDate: Date?
|
||
let onApply: () -> Void
|
||
|
||
@State private var draftStartDate = Date()
|
||
@State private var draftEndDate = Date()
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
DatePicker("开始日期", selection: $draftStartDate, displayedComponents: .date)
|
||
DatePicker("结束日期", selection: $draftEndDate, displayedComponents: .date)
|
||
}
|
||
.navigationTitle("时间筛选")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("清空") {
|
||
startDate = nil
|
||
endDate = nil
|
||
onApply()
|
||
dismiss()
|
||
}
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("确定") {
|
||
startDate = min(draftStartDate, draftEndDate)
|
||
endDate = max(draftStartDate, draftEndDate)
|
||
onApply()
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
draftStartDate = startDate ?? Date()
|
||
draftEndDate = endDate ?? Date()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
NavigationStack {
|
||
OrdersView()
|
||
.environment(AccountContext())
|
||
.environment(PermissionContext())
|
||
.environment(AppRouter())
|
||
.environment(RouterPath())
|
||
.environment(OrdersAPI(client: APIClient()))
|
||
.environment(ToastCenter())
|
||
}
|
||
}
|