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,243 @@
//
// DepositOrderListViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `DepositOrderListViewModel`
final class DepositOrderListViewModel {
private(set) var searchText = ""
private(set) var filterLabel = "全部"
private(set) var tabIndex = 0
private(set) var startTime: Date?
private(set) var endTime: Date?
private(set) var orderList: [StoreOrderItem] = []
private(set) var isRefreshing = false
private(set) var canLoadMore = false
private(set) var isLoadingMore = false
private(set) var writeOffResponse: WriteOffOrderResponse?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private var currentPage = 1
private var totalCount = 0
private var searchDebounceTask: Task<Void, Never>?
private let apiDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
func refreshOrderList(api: OrderAPI) async {
currentPage = 1
isRefreshing = true
isLoadingMore = false
notifyStateChange()
await loadOrderList(refresh: true, api: api)
}
func loadMore(api: OrderAPI) async {
guard !isLoadingMore, !isRefreshing, orderList.count < totalCount else { return }
isLoadingMore = true
notifyStateChange()
await loadOrderList(refresh: false, api: api)
}
func shouldLoadMore(at index: Int) -> Bool {
guard canLoadMore, !isRefreshing, !isLoadingMore else { return false }
return index >= max(0, orderList.count - 3)
}
func onSearchTextChange(_ text: String, api: OrderAPI) {
searchText = text
notifyStateChange()
searchDebounceTask?.cancel()
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
Task { await refreshOrderList(api: api) }
return
}
if Self.isValidPhone(trimmed) {
searchDebounceTask = Task {
try? await Task.sleep(nanoseconds: 400_000_000)
guard !Task.isCancelled else { return }
await refreshOrderList(api: api)
}
}
}
func onFilterChange(tab: Int) {
tabIndex = tab
filterLabel = StoreOrderFilter.from(tabIndex: tab).label
notifyStateChange()
}
func onDateRangeSelected(start: Date?, end: Date?) {
startTime = start
endTime = end
notifyStateChange()
}
func dismissWriteOffDialog() {
writeOffResponse = nil
notifyStateChange()
}
func processScanResult(_ scanResult: String, api: OrderAPI) async {
guard let parsed = QRCodeScanResult.parse(scanResult) else {
onShowMessage?("请扫描正确的二维码")
return
}
guard parsed.method == "order_verify" else {
onShowMessage?("请扫描订单核销码")
return
}
guard parsed.userId > 0, !parsed.orderNumber.isEmpty, !parsed.time.isEmpty else {
onShowMessage?("核销码内容不完整")
return
}
let storeId = AppStore.shared.currentStoreId
guard storeId > 0 else {
onShowMessage?("店铺信息异常,请先选择店铺")
return
}
await executeStoreOrderVerify(
StoreOrderVerifyRequest(
userId: parsed.userId,
orderNumber: parsed.orderNumber,
time: parsed.time,
storeId: storeId
),
api: api
)
}
func requestRefund(
orderNumber: String,
refundType: Int,
refundAmount: String,
refundReason: String,
api: OrderAPI
) async {
let storeId = AppStore.shared.currentStoreId
guard storeId > 0 else {
onShowMessage?("店铺信息异常")
return
}
do {
try await api.storeOrderRefund(
StoreOrderRefundRequest(
storeId: storeId,
orderNumber: orderNumber,
refundType: refundType,
refundAmount: refundAmount,
refundReason: refundReason
)
)
onShowMessage?("退款成功")
await refreshOrderList(api: api)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func canOpenDetail(for item: StoreOrderItem) -> Bool {
item.orderType == StoreOrderType.multiTravelShoot
}
static func isValidPhone(_ text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count == 11, trimmed.first == "1" else { return false }
guard let second = trimmed.dropFirst().first, ("3" ... "9").contains(second) else { return false }
return trimmed.allSatisfy(\.isNumber)
}
static func apiOrderStatus(forTabIndex tabIndex: Int) -> Int? {
OrderListQueryBuilder.storeOrderStatusQuery(tabIndex: tabIndex)
}
private func loadOrderList(refresh: Bool, api: OrderAPI) async {
let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let isPhoneSearch = !phone.isEmpty && Self.isValidPhone(phone)
let storeId = AppStore.shared.currentStoreId
if storeId <= 0 {
finishLoading()
orderList = []
canLoadMore = false
onShowMessage?("店铺信息异常,请先选择店铺")
return
}
let orderStatus: Int?
let start: String?
let end: String?
let userPhone: String?
if isPhoneSearch {
orderStatus = 0
start = nil
end = nil
userPhone = phone
} else {
orderStatus = Self.apiOrderStatus(forTabIndex: tabIndex)
start = startTime.map { apiDateFormatter.string(from: $0) }
end = endTime.map { apiDateFormatter.string(from: $0) }
userPhone = nil
}
do {
let response = try await api.storeOrderList(
storeId: storeId,
page: currentPage,
userPhone: userPhone,
orderStatus: orderStatus,
startTime: start,
endTime: end
)
totalCount = response.total
if refresh || currentPage == 1 {
orderList = response.list
currentPage = 2
} else {
orderList.append(contentsOf: response.list)
currentPage += 1
}
canLoadMore = orderList.count < totalCount
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
finishLoading()
}
private func executeStoreOrderVerify(_ request: StoreOrderVerifyRequest, api: OrderAPI) async {
do {
let response = try await api.storeOrderVerify(request)
writeOffResponse = response
if response.orderVerify {
await refreshOrderList(api: api)
}
notifyStateChange()
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func finishLoading() {
isRefreshing = false
isLoadingMore = false
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}