310 lines
12 KiB
Swift
310 lines
12 KiB
Swift
//
|
||
// WriteOffListView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/29.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 核销管理独立页,对齐 Android WriteOffListScreen。
|
||
struct WriteOffListView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@EnvironmentObject private var appRouter: AppRouter
|
||
@Environment(\.ordersAPI) private var ordersAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
@Environment(\.openURL) private var openURL
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@StateObject private var viewModel = WriteOffListViewModel()
|
||
|
||
@State private var manualOrderNumber = ""
|
||
@State private var showScanner = false
|
||
@State private var showVerifyConfirmation = false
|
||
@State private var pendingVerifyOrderNumber: String?
|
||
@State private var matchedScannedOrderNumber: String?
|
||
@State private var showStartDateSheet = false
|
||
@State private var showEndDateSheet = false
|
||
@State private var appliedSearchQuery = ""
|
||
|
||
private var currentScenicId: Int? { accountContext.currentScenic?.id }
|
||
private var currentStoreId: Int? { accountContext.currentStore?.id }
|
||
|
||
var body: some View {
|
||
Group {
|
||
if currentScenicId == nil {
|
||
AppContentUnavailableView("缺少经营上下文", systemImage: "mountain.2")
|
||
} else {
|
||
listContent
|
||
}
|
||
}
|
||
.background(AppDesign.orderPageBackground.ignoresSafeArea())
|
||
.navigationTitle("核销管理")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.searchable(text: $viewModel.searchPhone, prompt: "搜索手机号")
|
||
.onSubmit(of: .search) {
|
||
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
|
||
Task { await reload() }
|
||
}
|
||
.onChange(of: viewModel.searchPhone) { newValue in
|
||
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
|
||
appliedSearchQuery = ""
|
||
Task { await reload() }
|
||
}
|
||
.task {
|
||
await reload()
|
||
await consumePendingScanCodeIfNeeded()
|
||
}
|
||
.onChange(of: appRouter.pendingOrderScanCode) { _ in
|
||
Task { await consumePendingScanCodeIfNeeded() }
|
||
}
|
||
.fullScreenCover(isPresented: $showScanner) {
|
||
OrderScannerPage(
|
||
onClose: { showScanner = false },
|
||
onSuccess: { rawCode in
|
||
showScanner = false
|
||
handleScanResult(rawCode)
|
||
},
|
||
onFailure: { error in
|
||
showScanner = false
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
)
|
||
}
|
||
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||
Button("确认核销") {
|
||
guard let orderNumber = pendingVerifyOrderNumber else { return }
|
||
Task { await verify(orderNumber: orderNumber) }
|
||
}
|
||
Button("取消", role: .cancel) {
|
||
pendingVerifyOrderNumber = nil
|
||
}
|
||
}
|
||
.sheet(isPresented: $showStartDateSheet) {
|
||
singleDateSheet(title: "开始时间", date: viewModel.filterStartDate) { date in
|
||
viewModel.filterStartDate = date
|
||
Task { await reload() }
|
||
}
|
||
}
|
||
.sheet(isPresented: $showEndDateSheet) {
|
||
singleDateSheet(title: "结束时间", date: viewModel.filterEndDate) { date in
|
||
viewModel.filterEndDate = date
|
||
Task { await reload() }
|
||
}
|
||
}
|
||
}
|
||
|
||
private var listContent: some View {
|
||
ScrollViewReader { scrollProxy in
|
||
ScrollView {
|
||
VStack(spacing: 12) {
|
||
WriteOffStatusTabBar(
|
||
filters: OrderFilters.writeOffStatusFilters,
|
||
selectedStatus: viewModel.selectedStatus,
|
||
onSelect: { status in
|
||
viewModel.selectedStatus = status
|
||
Task { await reload() }
|
||
}
|
||
)
|
||
dateFilterSection
|
||
|
||
if viewModel.loading && viewModel.orders.isEmpty {
|
||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
|
||
} else if viewModel.orders.isEmpty {
|
||
AppContentUnavailableView("暂无核销订单", systemImage: "tray")
|
||
.frame(minHeight: 220)
|
||
} else {
|
||
ForEach(viewModel.orders) { item in
|
||
writeOffCard(item)
|
||
.id(item.orderNumber)
|
||
.onAppear {
|
||
guard item.id == viewModel.orders.last?.id else { return }
|
||
Task { await loadMore() }
|
||
}
|
||
}
|
||
if viewModel.loadingMore {
|
||
ProgressView().frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
}
|
||
.padding(.bottom, 16)
|
||
}
|
||
.onChange(of: matchedScannedOrderNumber) { orderNumber in
|
||
guard let orderNumber else { return }
|
||
withAnimation(.easeInOut(duration: 0.25)) {
|
||
scrollProxy.scrollTo(orderNumber, anchor: .center)
|
||
}
|
||
}
|
||
}
|
||
.refreshable { await reload(showLoading: false) }
|
||
}
|
||
|
||
private var dateFilterSection: some View {
|
||
HStack(spacing: 8) {
|
||
dateFilterButton(title: "开始时间", date: viewModel.filterStartDate) {
|
||
showStartDateSheet = true
|
||
}
|
||
dateFilterButton(title: "结束时间", date: viewModel.filterEndDate) {
|
||
showEndDateSheet = true
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
}
|
||
|
||
private func dateFilterButton(title: String, date: Date?, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
HStack {
|
||
Image(systemName: "calendar")
|
||
Text(date.map { OrderDateRangeFormatter.displayText(start: $0, end: nil, placeholder: title) } ?? title)
|
||
.font(.system(size: 13))
|
||
.lineLimit(1)
|
||
}
|
||
.foregroundStyle(AppDesign.orderLabelColor)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 35)
|
||
.background(AppDesign.orderInputBackground, in: RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func writeOffCard(_ item: WriteOffOrderItem) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Text("订单号:\(item.orderNumber)")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
Spacer()
|
||
OrderStatusChip(
|
||
text: viewModel.displayVerificationStatus(for: item),
|
||
status: viewModel.canVerify(item) ? 18 : 30
|
||
)
|
||
}
|
||
OrderInfoRow(title: "项目名称", value: item.projectName.isEmpty ? "--" : item.projectName)
|
||
OrderInfoRow(title: "手机号", value: item.userPhone.isEmpty ? "--" : item.userPhone) {
|
||
HStack(spacing: 7) {
|
||
Text(item.userPhone)
|
||
.font(.system(size: 14, weight: .medium))
|
||
OrderPhoneCallButton {
|
||
dialPhone(item.userPhone)
|
||
}
|
||
}
|
||
}
|
||
OrderInfoRow(title: "订单状态", value: item.orderStatusName.isEmpty ? "--" : item.orderStatusName)
|
||
HStack {
|
||
Text("¥\(item.orderAmount)")
|
||
.font(.system(size: 18, weight: .bold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
Spacer()
|
||
Button(viewModel.canVerify(item) ? "核销" : "已处理") {
|
||
pendingVerifyOrderNumber = item.orderNumber
|
||
showVerifyConfirmation = true
|
||
}
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 12)
|
||
.frame(height: 32)
|
||
.background(
|
||
viewModel.canVerify(item) ? AppDesign.primary : Color(hex: 0x9CA3AF),
|
||
in: RoundedRectangle(cornerRadius: 8)
|
||
)
|
||
.disabled(!viewModel.canVerify(item) || viewModel.isVerifying)
|
||
}
|
||
}
|
||
.padding(15)
|
||
.background(
|
||
matchedScannedOrderNumber == item.orderNumber ? Color(hex: 0xEEF6FF) : .white,
|
||
in: RoundedRectangle(cornerRadius: 12)
|
||
)
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.stroke(matchedScannedOrderNumber == item.orderNumber ? AppDesign.primary : .clear, lineWidth: 2)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
}
|
||
|
||
private func singleDateSheet(title: String, date: Date?, onConfirm: @escaping (Date?) -> Void) -> some View {
|
||
NavigationStack {
|
||
Form {
|
||
DatePicker(title, selection: Binding(
|
||
get: { date ?? Date() },
|
||
set: { onConfirm($0) }
|
||
), displayedComponents: .date)
|
||
}
|
||
.navigationTitle(title)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("清空") { onConfirm(nil) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func reload(showLoading: Bool = true) async {
|
||
do {
|
||
try await globalLoading.withOptionalLoading(showLoading && viewModel.orders.isEmpty, message: "加载中...") {
|
||
try await viewModel.reload(
|
||
api: ordersAPI,
|
||
scenicId: currentScenicId,
|
||
storeId: currentStoreId,
|
||
showLoading: showLoading
|
||
)
|
||
}
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func loadMore() async {
|
||
do {
|
||
try await viewModel.loadMore(api: ordersAPI, scenicId: currentScenicId, storeId: currentStoreId)
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
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 = ""
|
||
pendingVerifyOrderNumber = nil
|
||
matchedScannedOrderNumber = nil
|
||
toastCenter.show("核销成功")
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func consumePendingScanCodeIfNeeded() async {
|
||
guard let rawCode = appRouter.consumePendingOrderScanCode() else { return }
|
||
handleScanResult(rawCode)
|
||
}
|
||
|
||
private func handleScanResult(_ rawCode: String) {
|
||
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||
toastCenter.show("扫码成功,但未识别到有效订单号。")
|
||
return
|
||
}
|
||
manualOrderNumber = result.orderNumber
|
||
pendingVerifyOrderNumber = result.orderNumber
|
||
if let matched = result.matched {
|
||
matchedScannedOrderNumber = matched.orderNumber
|
||
}
|
||
showVerifyConfirmation = true
|
||
}
|
||
|
||
private func dialPhone(_ phone: String) {
|
||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
|
||
openURL(url)
|
||
}
|
||
}
|