// // 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) } }