Files
suixinkan_ios_new/suixinkan/Features/Orders/Views/OrdersView.swift
汉秋 c4de3d17d0 移除订单 Tab 内门店与核销订单详情页及跳转入口。
列表卡片仅展示摘要,保留订单来源绑定与押金订单详情等其它子流程。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:36:27 +08:00

816 lines
34 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// OrdersView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import SwiftUI
/// Tab
struct OrdersView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var permissionContext: PermissionContext
@EnvironmentObject private var appRouter: AppRouter
@Environment(\.ordersAPI) private var ordersAPI
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.openURL) private var openURL
@StateObject 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?
@State private var bindAcquirerSaleUserId: Int?
@State private var referralBindOrderNumber: String?
private var currentScenicId: Int? {
accountContext.currentScenic?.id
}
private var currentStoreId: Int? {
accountContext.currentStore?.id
}
private var currentAppRole: AppRoleCode? {
permissionContext.currentAppRole
}
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 {
AppContentUnavailableView(
"缺少经营上下文",
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() } }
)
}
.fullScreenCover(item: bindAcquirerBinding) { item in
NavigationStack {
BindAcquirerView(saleUserId: item.saleUserId, onSuccess: {
bindAcquirerSaleUserId = nil
})
}
}
.appNavigationDestination(item: referralBindBinding) { item in
ReferralOrderSelectView(orderNumber: item.orderNumber) {
Task { await reload(showLoading: false) }
}
}
}
private var bindAcquirerBinding: Binding<BindAcquirerSheetItem?> {
Binding(
get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) },
set: { bindAcquirerSaleUserId = $0?.saleUserId }
)
}
private var referralBindBinding: Binding<OrderNumberSheetItem?> {
Binding(
get: { referralBindOrderNumber.map(OrderNumberSheetItem.init(orderNumber:)) },
set: { referralBindOrderNumber = $0?.orderNumber }
)
}
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 {
AppContentUnavailableView("暂无订单", 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 {
AppContentUnavailableView("暂无核销订单", 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)
if viewModel.shouldShowOrderSource(order: item, appRole: currentAppRole) {
OrderSourcePicker(
selection: viewModel.getOrderSourceSelection(for: item),
people: viewModel.orderSourcePeople,
leadsForPerson: { person in
viewModel.orderSourceLeads[person.key] ?? []
},
leadsLoading: viewModel.loadingOrderSourceLeadsFor != nil,
onSheetOpen: {
Task {
await viewModel.prepareOrderSourceSheet(
api: cooperationOrderAPI,
storeId: currentStoreId
)
}
},
onPersonClick: { person in
Task {
await viewModel.loadOrderSourceLeads(api: cooperationOrderAPI, person: person)
}
},
onSelectionChange: { selection in
Task {
do {
try await viewModel.bindOrderSource(
api: cooperationOrderAPI,
orderNumber: item.orderNumber,
selection: selection
)
toastCenter.show("绑定成功")
await reload(showLoading: false)
} catch {
toastCenter.show(error.localizedDescription)
}
}
},
callPhone: { phone in
dialPhone(phone)
}
)
}
}
.padding(AppMetrics.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.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(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,
appRole: currentAppRole,
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, appRole: currentAppRole)
} 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 }
if SaleUserQrParser.isSaleUserBindQR(rawCode) {
guard let saleUserId = SaleUserQrParser.parseSaleUserId(rawCode), saleUserId > 0 else {
toastCenter.show("请扫描正确的获客员二维码")
return
}
bindAcquirerSaleUserId = saleUserId
return
}
viewModel.selectedEntry = .verificationOrders
appRouter.selectOrders(entry: .verificationOrders)
await reload(showLoading: false)
handleScanResult(rawCode)
}
///
private func dialPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
openURL(url)
}
///
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()
.environmentObject(AccountContext())
.environmentObject(PermissionContext())
.environmentObject(AppRouter())
.environment(\.ordersAPI, OrdersAPI(client: APIClient()))
.environmentObject(ToastCenter())
}
}