Files
suixinkan_ios_new/suixinkan/Features/Orders/Views/WriteOffListView.swift

310 lines
12 KiB
Swift
Raw Permalink 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.

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