同步订单详情与扫码核销流程

This commit is contained in:
2026-06-22 17:03:27 +08:00
parent 726f19c124
commit d02fd2c069
15 changed files with 1456 additions and 54 deletions

View File

@ -0,0 +1,330 @@
//
// OrderCodeScannerView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import AVFoundation
import SwiftUI
///
enum OrderScannerError: LocalizedError {
case cameraUnavailable
case permissionDenied
case invalidMetadata
var errorDescription: String? {
switch self {
case .cameraUnavailable:
return "当前设备不可用相机,无法扫码。"
case .permissionDenied:
return "未开启相机权限,请在系统设置中允许后重试。"
case .invalidMetadata:
return "未识别到有效条码,请重试。"
}
}
}
/// SwiftUI
struct OrderScannerPage: View {
let onClose: () -> Void
let onSuccess: (String) -> Void
let onFailure: (Error) -> Void
var body: some View {
NavigationStack {
ZStack(alignment: .bottom) {
OrderCodeScannerView { result in
switch result {
case .success(let raw):
onSuccess(raw)
case .failure(let error):
onFailure(error)
}
}
.ignoresSafeArea()
Label("请将二维码/条码放入取景框", systemImage: "qrcode.viewfinder")
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, AppMetrics.Spacing.medium)
.padding(.vertical, AppMetrics.Spacing.xSmall)
.background(.black.opacity(0.55), in: Capsule())
.padding(.bottom, AppMetrics.Spacing.xxLarge)
}
.navigationTitle("扫码核销")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") {
onClose()
}
.accessibilityIdentifier("scanner.close")
}
}
}
}
}
/// AVFoundation SwiftUI
struct OrderCodeScannerView: UIViewControllerRepresentable {
let onScanResult: (Result<String, Error>) -> Void
///
func makeUIViewController(context: Context) -> OrderScannerViewController {
let controller = OrderScannerViewController()
controller.onScanResult = onScanResult
return controller
}
/// SwiftUI
func updateUIViewController(_ uiViewController: OrderScannerViewController, context: Context) {}
}
/// AVFoundation
final class OrderScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onScanResult: ((Result<String, Error>) -> Void)?
private let session = AVCaptureSession()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var videoDevice: AVCaptureDevice?
private var hasFinishedScan = false
private let scanFrameView: UIView = {
let view = UIView()
view.layer.borderColor = UIColor.systemBlue.cgColor
view.layer.borderWidth = 2
view.layer.cornerRadius = 12
view.backgroundColor = .clear
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private let guideLabel: UILabel = {
let label = UILabel()
label.text = "将二维码/条形码放入框内自动识别"
label.textColor = .white
label.font = .systemFont(ofSize: 13, weight: .medium)
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var torchButton: UIButton = {
let button = Self.makeScannerButton(title: "手电筒", backgroundColor: UIColor.black.withAlphaComponent(0.42))
button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside)
return button
}()
private lazy var retryButton: UIButton = {
let button = Self.makeScannerButton(title: "重新扫描", backgroundColor: UIColor.systemBlue.withAlphaComponent(0.85))
button.addTarget(self, action: #selector(restartScan), for: .touchUpInside)
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupControls()
checkPermissionAndStart()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.layer.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
updateTorch(enabled: false)
stopSession()
}
///
private func checkPermissionAndStart() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
startSession()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
if granted {
self.startSession()
} else {
self.onScanResult?(.failure(OrderScannerError.permissionDenied))
}
}
}
case .denied, .restricted:
onScanResult?(.failure(OrderScannerError.permissionDenied))
@unknown default:
onScanResult?(.failure(OrderScannerError.permissionDenied))
}
}
///
private func startSession() {
hasFinishedScan = false
guard !session.isRunning else { return }
do {
try configureSession()
} catch {
onScanResult?(.failure(error))
return
}
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning()
}
}
///
private func stopSession() {
guard session.isRunning else { return }
session.stopRunning()
}
///
private static func makeScannerButton(title: String, backgroundColor: UIColor) -> UIButton {
var attributedTitle = AttributedString(title)
attributedTitle.font = .systemFont(ofSize: 13, weight: .semibold)
var configuration = UIButton.Configuration.filled()
configuration.attributedTitle = attributedTitle
configuration.baseForegroundColor = .white
configuration.baseBackgroundColor = backgroundColor
configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)
configuration.cornerStyle = .capsule
let button = UIButton(type: .system)
button.configuration = configuration
button.translatesAutoresizingMaskIntoConstraints = false
return button
}
///
private func configureSession() throws {
if previewLayer != nil {
return
}
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
throw OrderScannerError.cameraUnavailable
}
self.videoDevice = videoDevice
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
guard session.canAddInput(videoInput) else {
throw OrderScannerError.cameraUnavailable
}
session.addInput(videoInput)
let metadataOutput = AVCaptureMetadataOutput()
guard session.canAddOutput(metadataOutput) else {
throw OrderScannerError.invalidMetadata
}
session.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = [
.qr,
.ean8,
.ean13,
.pdf417,
.code39,
.code93,
.code128,
.dataMatrix,
.aztec,
.itf14,
.upce
]
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill
preview.frame = view.layer.bounds
view.layer.insertSublayer(preview, at: 0)
previewLayer = preview
updateTorchButtonState()
}
///
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard !hasFinishedScan else { return }
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let code = metadataObject.stringValue,
!code.isEmpty else {
return
}
hasFinishedScan = true
stopSession()
UINotificationFeedbackGenerator().notificationOccurred(.success)
onScanResult?(.success(code))
}
///
private func setupControls() {
view.addSubview(scanFrameView)
view.addSubview(guideLabel)
view.addSubview(torchButton)
view.addSubview(retryButton)
NSLayoutConstraint.activate([
scanFrameView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
scanFrameView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
scanFrameView.widthAnchor.constraint(equalToConstant: 240),
scanFrameView.heightAnchor.constraint(equalToConstant: 240),
guideLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
guideLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
guideLabel.topAnchor.constraint(equalTo: scanFrameView.bottomAnchor, constant: 14),
torchButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
torchButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
retryButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
retryButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18)
])
}
///
private func updateTorchButtonState() {
let hasTorch = videoDevice?.hasTorch == true
torchButton.isEnabled = hasTorch
torchButton.alpha = hasTorch ? 1 : 0.45
}
///
@objc
private func toggleTorch() {
let next = !(videoDevice?.isTorchActive ?? false)
updateTorch(enabled: next)
}
///
private func updateTorch(enabled: Bool) {
guard let device = videoDevice, device.hasTorch else { return }
do {
try device.lockForConfiguration()
if enabled {
try device.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel)
} else {
device.torchMode = .off
}
device.unlockForConfiguration()
torchButton.setTitle(enabled ? "关闭手电" : "手电筒", for: .normal)
} catch {
torchButton.setTitle("手电筒不可用", for: .normal)
}
}
///
@objc
private func restartScan() {
hasFinishedScan = false
updateTorch(enabled: false)
startSession()
}
}

View File

@ -0,0 +1,314 @@
//
// OrderDetailViews.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import SwiftUI
///
struct StoreOrderDetailView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(OrdersAPI.self) private var ordersAPI
@Environment(RouterPath.self) private var router
@Environment(ToastCenter.self) private var toastCenter
let item: OrderEntity
@State private var viewModel: OrderDetailViewModel
/// 使 ViewModel
init(item: OrderEntity) {
self.item = item
_viewModel = State(initialValue: OrderDetailViewModel(item: item))
}
var body: some View {
List {
if viewModel.loading {
Section {
HStack {
Spacer()
ProgressView("加载详情中...")
Spacer()
}
}
}
if let contextMessage = viewModel.contextMessage {
Section {
Label(contextMessage, systemImage: "info.circle")
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
}
}
Section("订单信息") {
OrderDetailRow(title: "订单号", value: viewModel.display.orderNumber)
Button("复制订单号") {
UIPasteboard.general.string = viewModel.display.orderNumber
toastCenter.show("订单号已复制")
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.foregroundStyle(AppDesign.primary)
OrderDetailRow(title: "订单状态", value: viewModel.display.orderStatusName)
OrderDetailRow(title: "订单类型", value: viewModel.display.orderTypeLabel)
OrderDetailRow(title: "创建时间", value: viewModel.display.createdAt)
OrderDetailRow(title: "付款时间", value: displayPayTime)
OrderDetailRow(title: "完成时间", value: viewModel.display.completeTime)
}
Section("支付信息") {
OrderDetailRow(title: "付款金额", value: "¥\(emptyToZero(viewModel.display.actualPayAmount))")
OrderDetailRow(title: "退款金额", value: "¥\(emptyToZero(viewModel.display.actualRefundAmount))")
OrderDetailRow(title: "付款方式", value: viewModel.display.payTypeName)
OrderDetailRow(title: "用户 UID", value: "\(viewModel.display.userId)")
}
Section("客户与项目") {
OrderDetailRow(title: "手机号", value: viewModel.display.phone)
OrderDetailRow(title: "关联项目", value: viewModel.display.projectName)
OrderDetailRow(title: "项目 ID", value: "\(viewModel.display.projectId)")
}
if let projectInfo = viewModel.projectInfo {
Section("项目配置") {
OrderDetailRow(title: "打卡点数", value: "\(projectInfo.settleSpotNum)")
OrderDetailRow(title: "单点底片", value: "\(projectInfo.singleSpotMaterialNum)")
OrderDetailRow(title: "单点精修", value: "\(projectInfo.singleSpotPhotoNum)")
OrderDetailRow(title: "单点视频", value: "\(projectInfo.singleSpotVideoNum)")
}
}
if let photoTravel = item.photoTravel {
Section("旅拍信息") {
OrderDetailRow(title: "核销时间", value: photoTravel.checkInTime)
OrderDetailRow(title: "修图张数", value: "\(photoTravel.orderPhotoNum + photoTravel.retouchGiftPhotoNum)")
OrderDetailRow(title: "视频剪辑", value: "\(photoTravel.orderVideoNum + photoTravel.retouchGiftVideoNum)")
}
}
if !viewModel.shootingList.isEmpty {
Section("拍摄点") {
ForEach(viewModel.shootingList) { shooting in
ShootingPointRow(item: shooting)
}
}
}
if !viewModel.display.remark.isEmpty {
Section("备注") {
Text(viewModel.display.remark)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
}
}
Section("后续功能") {
placeholderButton("历史拍摄", title: "历史拍摄")
placeholderButton("任务上传", title: "任务上传")
placeholderButton("视频预告", title: "视频预告")
placeholderButton("退款", title: "订单退款")
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("订单详情")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id)
}
.alert("提示", isPresented: errorBinding) {
Button("知道了", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
private var displayPayTime: String {
let payTime = viewModel.display.payTime
if payTime.isEmpty || payTime == "0" || payTime.hasPrefix("1970") {
return item.depositPayTime
}
return payTime
}
private var errorBinding: Binding<Bool> {
Binding(
get: { viewModel.errorMessage != nil },
set: { visible in
if !visible { viewModel.clearError() }
}
)
}
///
private func placeholderButton(_ label: String, title: String) -> some View {
Button {
router.navigate(to: .placeholder(title: title))
} label: {
HStack {
Text(label)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.textSecondary)
}
}
.foregroundStyle(AppDesign.textPrimary)
}
/// 0
private func emptyToZero(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
}
}
///
struct WriteOffOrderDetailView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(OrdersAPI.self) private var ordersAPI
@Environment(ToastCenter.self) private var toastCenter
let item: WriteOffOrderItem
@State private var verifying = false
@State private var showVerifyConfirmation = false
var body: some View {
List {
Section("核销订单") {
OrderDetailRow(title: "订单号", value: item.orderNumber)
Button("复制订单号") {
UIPasteboard.general.string = item.orderNumber
toastCenter.show("订单号已复制")
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.foregroundStyle(AppDesign.primary)
OrderDetailRow(title: "项目名称", value: item.projectName)
OrderDetailRow(title: "核销状态", value: displayVerificationStatus)
OrderDetailRow(title: "订单状态", value: item.orderStatusName)
OrderDetailRow(title: "订单金额", value: "¥\(item.orderAmount)")
OrderDetailRow(title: "手机号", value: item.userPhone)
OrderDetailRow(title: "支付时间", value: item.payTime)
OrderDetailRow(title: "核销时间", value: item.orderVerificationTime)
}
Section {
Button(verifying ? "核销中..." : "确认核销") {
showVerifyConfirmation = true
}
.frame(maxWidth: .infinity, alignment: .center)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(.white)
.listRowBackground(canVerify ? AppDesign.primary : Color(hex: 0x9CA3AF))
.disabled(!canVerify || verifying)
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("核销订单详情")
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
Button("确认核销") {
Task { await verify() }
}
Button("取消", role: .cancel) {}
} message: {
Text("""
订单号:\(item.orderNumber)
项目:\(item.projectName.isEmpty ? "--" : item.projectName)
金额:¥\(item.orderAmount)
""")
}
}
private var displayVerificationStatus: String {
let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? "待核销" : text
}
private var canVerify: Bool {
!displayVerificationStatus.contains("已核销") &&
!displayVerificationStatus.contains("已退款") &&
!displayVerificationStatus.contains("已完成")
}
///
private func verify() async {
guard !verifying else { return }
guard accountContext.currentScenic?.id != nil else {
toastCenter.show("缺少景区上下文")
return
}
verifying = true
defer { verifying = false }
do {
try await ordersAPI.writeOff(orderNumber: item.orderNumber)
toastCenter.show("核销成功")
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
///
private struct OrderDetailRow: View {
let title: String
let value: String
var body: some View {
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.frame(width: 76, alignment: .leading)
Spacer(minLength: AppMetrics.Spacing.small)
Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "--" : value)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
.multilineTextAlignment(.trailing)
}
}
}
///
private struct ShootingPointRow: View {
let item: StoreOrderShootingListItem
var body: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
Text(statusText)
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(item.status == 2 ? Color(hex: 0x22C55E) : AppDesign.primary)
}
OrderDetailRow(title: "摄影师", value: item.photogName)
OrderDetailRow(title: "评分", value: String(format: "%.1f", max(item.startAvg, item.start)))
}
.padding(.vertical, AppMetrics.Spacing.xxSmall)
}
private var title: String {
let value = item.scenicSpotName.isEmpty ? item.staffName : item.scenicSpotName
return value.isEmpty ? "未命名打卡点" : value
}
private var statusText: String {
switch item.status {
case 1:
return "待拍摄"
case 2:
return "已拍摄"
default:
return "未知"
}
}
}

View File

@ -20,13 +20,21 @@ struct OrdersView: View {
@State private var viewModel = OrdersViewModel()
@State private var manualOrderNumber = ""
@State private var showDateFilterSheet = false
@State private var showScanner = false
@State private var showVerifyConfirmation = false
@State private var pendingVerifyOrder: WriteOffOrderItem?
@State private var pendingVerifyOrderNumber: String?
@State private var matchedScannedOrderNumber: String?
@State private var scanHintMessage: String?
private var currentScenicId: Int? {
accountContext.currentScenic?.id
}
private var currentStoreId: Int? {
accountContext.currentStore?.id
}
private var currentRoleId: Int? {
permissionContext.currentRole?.id
}
@ -47,28 +55,36 @@ struct OrdersView: View {
}
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.small) {
entrySection
ScrollViewReader { scrollProxy in
ScrollView {
VStack(spacing: AppMetrics.Spacing.small) {
entrySection
if currentScenicId == nil {
ContentUnavailableView(
"缺少经营上下文",
systemImage: "mountain.2",
description: Text("请先在首页选择景区后查看订单。")
)
.frame(minHeight: 360)
} else if viewModel.selectedEntry == .storeOrders {
storeOrdersSection
} else {
writeOffSection
if currentScenicId == nil {
ContentUnavailableView(
"缺少经营上下文",
systemImage: "mountain.2",
description: Text("请先在首页选择景区后查看订单。")
)
.frame(minHeight: 360)
} else if viewModel.selectedEntry == .storeOrders {
storeOrdersSection
} else {
writeOffSection
}
}
.frame(maxWidth: contentMaxWidth)
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.top, AppMetrics.Spacing.medium)
.padding(.bottom, AppMetrics.Spacing.pageVertical)
.frame(maxWidth: .infinity)
}
.onChange(of: matchedScannedOrderNumber) { _, orderNumber in
guard let orderNumber else { return }
withAnimation(.easeInOut(duration: 0.25)) {
scrollProxy.scrollTo(orderNumber, anchor: .center)
}
}
.frame(maxWidth: contentMaxWidth)
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.top, AppMetrics.Spacing.medium)
.padding(.bottom, AppMetrics.Spacing.pageVertical)
.frame(maxWidth: .infinity)
}
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("订单")
@ -92,10 +108,24 @@ struct OrdersView: View {
Task { await verify(orderNumber: orderNumber) }
}
Button("取消", role: .cancel) {
pendingVerifyOrder = nil
pendingVerifyOrderNumber = nil
}
} message: {
Text(pendingVerifyOrderNumber.map { "订单号:\($0)" } ?? "")
Text(verifyDialogMessage)
}
.fullScreenCover(isPresented: $showScanner) {
OrderScannerPage(
onClose: { showScanner = false },
onSuccess: { rawCode in
showScanner = false
handleScanResult(rawCode)
},
onFailure: { error in
showScanner = false
toastCenter.show(error.localizedDescription)
}
)
}
.sheet(isPresented: $showDateFilterSheet) {
OrdersDateFilterSheet(
@ -220,7 +250,7 @@ struct OrdersView: View {
private var writeOffActionSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Button {
router.navigate(to: .placeholder(title: "扫码核销"))
showScanner = true
} label: {
Label("扫码核销", systemImage: "qrcode.viewfinder")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
@ -232,6 +262,13 @@ struct OrdersView: View {
.buttonStyle(.plain)
.accessibilityIdentifier("orders.scan")
if let scanHintMessage {
Label(scanHintMessage, systemImage: "info.circle")
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: AppMetrics.Spacing.xSmall) {
TextField("手动输入订单号", text: $manualOrderNumber)
.textInputAutocapitalization(.never)
@ -377,7 +414,7 @@ struct OrdersView: View {
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.contentShape(Rectangle())
.onTapGesture {
router.navigate(to: .placeholder(title: "订单详情"))
router.navigate(to: .orders(.storeDetail(item)))
}
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
}
@ -406,7 +443,7 @@ struct OrdersView: View {
.foregroundStyle(AppDesign.primary)
Spacer()
Button("详情") {
router.navigate(to: .placeholder(title: "核销订单详情"))
router.navigate(to: .orders(.writeOffDetail(item)))
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.primary)
@ -417,6 +454,7 @@ struct OrdersView: View {
.accessibilityIdentifier("orders.writeoff.detail.\(item.orderNumber)")
Button(verifyButtonTitle(for: item)) {
pendingVerifyOrder = item
pendingVerifyOrderNumber = item.orderNumber
showVerifyConfirmation = true
}
@ -432,7 +470,12 @@ struct OrdersView: View {
}
.padding(AppMetrics.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.background(cardBackgroundColor(for: item), in: RoundedRectangle(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(matchedScannedOrderNumber == item.orderNumber ? AppDesign.primary : Color.clear, lineWidth: 2)
}
.id(item.orderNumber)
}
private func orderInfoRow(_ title: String, _ value: String, valueColor: Color = .black) -> some View {
@ -472,6 +515,7 @@ struct OrdersView: View {
try await viewModel.reload(
api: ordersAPI,
scenicId: currentScenicId,
storeId: currentStoreId,
roleId: currentRoleId,
showLoading: showLoading
)
@ -490,7 +534,7 @@ struct OrdersView: View {
private func loadMoreWriteOffOrders() async {
do {
try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId)
try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId, storeId: currentStoreId)
} catch {
toastCenter.show(error.localizedDescription)
}
@ -502,27 +546,73 @@ struct OrdersView: View {
toastCenter.show("请输入订单号")
return
}
pendingVerifyOrderNumber = orderNumber
if let matched = viewModel.writeOffOrder(matching: orderNumber) {
pendingVerifyOrder = matched
pendingVerifyOrderNumber = matched.orderNumber
matchedScannedOrderNumber = matched.orderNumber
scanHintMessage = "已定位到列表订单:\(matched.orderNumber)"
} else {
pendingVerifyOrder = nil
pendingVerifyOrderNumber = orderNumber
matchedScannedOrderNumber = nil
scanHintMessage = "未在当前列表定位到订单,将按输入订单号发起核销。"
}
showVerifyConfirmation = true
}
private func verify(orderNumber: String) async {
guard let scenicId = currentScenicId else { return }
do {
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, orderNumber: orderNumber)
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, storeId: currentStoreId, orderNumber: orderNumber)
manualOrderNumber = ""
pendingVerifyOrder = nil
pendingVerifyOrderNumber = nil
matchedScannedOrderNumber = nil
scanHintMessage = nil
toastCenter.show("核销成功")
} catch {
toastCenter.show(error.localizedDescription)
}
}
///
private func handleScanResult(_ rawCode: String) {
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {
toastCenter.show("扫码成功,但未识别到有效订单号,请手动输入。")
return
}
manualOrderNumber = result.orderNumber
pendingVerifyOrder = result.matched
pendingVerifyOrderNumber = result.orderNumber
if let matched = result.matched {
matchedScannedOrderNumber = matched.orderNumber
scanHintMessage = "已定位到列表订单:\(matched.orderNumber)"
} else {
matchedScannedOrderNumber = nil
scanHintMessage = "未在当前列表定位到订单,已填入订单号,可继续核销。"
}
showVerifyConfirmation = true
}
private func canVerify(_ item: WriteOffOrderItem) -> Bool {
let status = displayVerificationStatus(for: item)
return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成")
}
private var verifyDialogMessage: String {
let orderNumber = pendingVerifyOrderNumber ?? "-"
if let order = pendingVerifyOrder ?? viewModel.writeOffOrder(matching: orderNumber) {
return """
订单号:\(order.orderNumber)
项目:\(order.projectName.isEmpty ? "--" : order.projectName)
金额:¥\(order.orderAmount)
"""
}
return "订单号:\(orderNumber)"
}
private func displayVerificationStatus(for item: WriteOffOrderItem) -> String {
let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? "待核销" : text
@ -536,6 +626,10 @@ struct OrdersView: View {
canVerify(item) ? AppDesign.primary : Color(hex: 0x22C55E)
}
private func cardBackgroundColor(for item: WriteOffOrderItem) -> Color {
matchedScannedOrderNumber == item.orderNumber ? Color(hex: 0xEEF6FF) : .white
}
private func statusColor(for status: Int) -> Color {
switch status {
case 18: