新增门店订单与核销管理列表,对齐 Android 订单能力并完善账号级缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 14:46:10 +08:00
parent c4de3d17d0
commit 08492df6ce
51 changed files with 3668 additions and 102 deletions

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