Implement orders tab aligned with Android, including scan verify flows.

Add role-based order/deposit lists, AVFoundation QR scanning, verify dialogs, and ViewModel tests so photographers, scenic admins, and store admins match Android behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 16:59:53 +08:00
parent 6492f80ef0
commit 31d2b6094b
27 changed files with 4226 additions and 17 deletions

View File

@ -0,0 +1,203 @@
//
// MainTabScanHandler.swift
// suixinkan
//
import CoreLocation
import UIKit
/// Tab Android `MainTabViewModel.signIn`
@MainActor
final class MainTabScanHandler: NSObject, CLLocationManagerDelegate {
var onShowMessage: ((String) -> Void)?
var onShowSignIn: ((OrderSignInResponse) -> Void)?
var onShowWriteOff: ((WriteOffOrderResponse) -> Void)?
var onMultiVerifyPending: ((MultiVerifyParseData) -> Void)?
var onShowMultiVerifySheet: (([MultiTravelScenicSpotEntity], MultiTravelScenicSpotEntity?) -> Void)?
private let api: OrderAPI
private let locationManager = CLLocationManager()
private var locationContinuation: ((Result<CLLocationCoordinate2D, Error>) -> Void)?
private(set) var pendingMultiVerify: MultiVerifyParseData?
init(api: OrderAPI) {
self.api = api
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
}
func handleScanResult(_ scanResult: String) async {
guard !scanResult.isEmpty else {
onShowMessage?("请扫描正确的二维码")
return
}
guard let parsed = QRCodeScanResult.parse(scanResult) else {
onShowMessage?("请扫描正确的二维码")
return
}
switch parsed.method {
case "check_in":
await performCheckIn(scanResult: scanResult)
case "order_verify":
if AppStore.shared.currentAppRole == .storeAdmin {
await performStoreVerify(parsed: parsed)
} else {
await performPhotographerVerify(jsonBody: parsed.rawJSON)
}
case "multi_verify":
guard !parsed.orderNumber.isEmpty, parsed.userId > 0, parsed.timeLong > 0, !parsed.checkSum.isEmpty else {
onShowMessage?("请扫描正确的多点位核销码")
return
}
let pending = MultiVerifyParseData(
orderNumber: parsed.orderNumber,
userId: parsed.userId,
time: parsed.timeLong,
checkSum: parsed.checkSum
)
pendingMultiVerify = pending
onMultiVerifyPending?(pending)
await fetchSpotsForMultiVerify(pending: pending)
case SaleUserQrParser.methodSaleUserBind:
if let id = SaleUserQrParser.parseSaleUserId(scanResult), id > 0 {
onShowMessage?("获客员绑定功能待接入")
} else {
onShowMessage?("请扫描正确的获客员二维码")
}
default:
onShowMessage?("未知二维码,请检查后重试")
}
}
func confirmMultiVerify(pending: MultiVerifyParseData, spot: MultiTravelScenicSpotEntity) async {
do {
let response = try await api.multiTravelOrderVerify(
MultiTravelOrderVerifyRequest(
orderNumber: pending.orderNumber,
userId: pending.userId,
time: pending.time,
checkSum: pending.checkSum,
scenicSpotId: spot.scenicSpotId
)
)
onShowSignIn?(response)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func performCheckIn(scanResult: String) async {
guard var json = parseJSONObject(scanResult) else {
onShowMessage?("请扫描正确的二维码")
return
}
json["scenic_id"] = String(AppStore.shared.currentScenicId)
guard let body = serializeJSON(json) else { return }
do {
let response = try await api.orderCheckIn(jsonBody: body)
onShowSignIn?(response)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func performPhotographerVerify(jsonBody: String) async {
do {
let response = try await api.orderVerify(jsonBody: jsonBody)
onShowWriteOff?(response)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func performStoreVerify(parsed: QRCodeScanResult) async {
guard parsed.userId > 0, !parsed.orderNumber.isEmpty, !parsed.time.isEmpty else {
onShowMessage?("请扫描正确的核销码")
return
}
let storeId = AppStore.shared.currentStoreId
guard storeId > 0 else {
onShowMessage?("店铺信息异常,请先选择店铺")
return
}
do {
let response = try await api.storeOrderVerify(
StoreOrderVerifyRequest(
userId: parsed.userId,
orderNumber: parsed.orderNumber,
time: parsed.time,
storeId: storeId
)
)
onShowWriteOff?(response)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func fetchSpotsForMultiVerify(pending: MultiVerifyParseData) async {
let status = locationManager.authorizationStatus
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
locationManager.requestWhenInUseAuthorization()
onShowMessage?("缺少定位权限,功能无法进行")
return
}
do {
let coordinate = try await requestLocation()
let response = try await api.multiTravelScenicSpotList(
orderNumber: pending.orderNumber,
lat: coordinate.latitude,
lng: coordinate.longitude
)
let spots = response.entityList
if spots.isEmpty {
onShowMessage?("暂无可核销的打卡点")
} else {
onShowMultiVerifySheet?(spots, spots.first)
}
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func requestLocation() async throws -> CLLocationCoordinate2D {
try await withCheckedThrowingContinuation { continuation in
locationContinuation = { result in
switch result {
case .success(let coordinate):
continuation.resume(returning: coordinate)
case .failure(let error):
continuation.resume(throwing: error)
}
}
locationManager.requestLocation()
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
locationContinuation?(.success(location.coordinate))
locationContinuation = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
locationContinuation?(.failure(error))
locationContinuation = nil
}
}
private extension MainTabScanHandler {
func parseJSONObject(_ text: String) -> [String: Any]? {
guard let data = text.data(using: .utf8) else { return nil }
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}
func serializeJSON(_ json: [String: Any]) -> String? {
guard let data = try? JSONSerialization.data(withJSONObject: json) else { return nil }
return String(data: data, encoding: .utf8)
}
}

View File

@ -0,0 +1,55 @@
//
// OrderLocationProvider.swift
// suixinkan
//
import CoreLocation
///
final class OrderLocationProvider: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: ((Result<CLLocationCoordinate2D, Error>) -> Void)?
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
}
func requestCoordinate() async throws -> CLLocationCoordinate2D {
let status = manager.authorizationStatus
if status == .notDetermined {
manager.requestWhenInUseAuthorization()
}
guard status == .authorizedWhenInUse || status == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse else {
throw OrderLocationError.permissionDenied
}
return try await withCheckedThrowingContinuation { continuation in
self.continuation = { result in
continuation.resume(with: result)
}
manager.requestLocation()
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
continuation?(.success(location.coordinate))
continuation = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
continuation?(.failure(error))
continuation = nil
}
}
enum OrderLocationError: LocalizedError {
case permissionDenied
var errorDescription: String? {
switch self {
case .permissionDenied: "缺少定位权限,功能无法进行"
}
}
}

View File

@ -0,0 +1,55 @@
//
// SaleUserQrParser.swift
// suixinkan
//
import Foundation
/// Android `SaleUserQrParser`
enum SaleUserQrParser {
static let methodSaleUserBind = "sale_user_bind"
static func parseSaleUserId(_ content: String) -> Int? {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let fromMethod = parseFromJSON(json) { return fromMethod }
}
if let intValue = Int(trimmed), intValue > 0 { return intValue }
if trimmed.contains("://") || trimmed.contains("?"),
let components = URLComponents(string: trimmed) {
for key in ["sale_user_id", "saleUserId", "user_id", "id"] {
if let value = components.queryItems?.first(where: { $0.name == key })?.value,
let intValue = Int(value), intValue > 0 {
return intValue
}
}
}
return nil
}
private static func parseFromJSON(_ json: [String: Any]) -> Int? {
if let method = json["method"] as? String, method == methodSaleUserBind,
let id = readInt(json["sale_user_id"]), id > 0 {
return id
}
for key in ["sale_user_id", "saleUserId", "user_id", "id"] {
if let id = readInt(json[key]), id > 0 { return id }
}
return nil
}
private static func readInt(_ value: Any?) -> Int? {
if let intValue = value as? Int { return intValue }
if let stringValue = value as? String, let intValue = Int(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) {
return intValue
}
if let doubleValue = value as? Double { return Int(doubleValue) }
return nil
}
}