Sync order detail and scanner verification

This commit is contained in:
2026-06-22 17:03:27 +08:00
parent 0b83a73509
commit c2652b83c4
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()
}
}