新增门店订单与核销管理列表,对齐 Android 订单能力并完善账号级缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
309
suixinkan/Features/Orders/Views/WriteOffListView.swift
Normal file
309
suixinkan/Features/Orders/Views/WriteOffListView.swift
Normal file
@ -0,0 +1,309 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user