实现订单 Tab 并接入扫码核销流程

This commit is contained in:
2026-07-06 16:59:53 +08:00
parent 9464e32a0a
commit c0ffe4c1d8
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?()
}
}

View File

@ -0,0 +1,473 @@
//
// OrderListViewModel.swift
// suixinkan
//
import Foundation
struct MultiVerifyParseData: Equatable {
let orderNumber: String
let userId: Int
let time: Int64
let checkSum: String
}
struct OrderSourcePerson: Equatable {
let key: String
let name: String
let phone: String
}
struct OrderSourceLead: Equatable {
let key: String
let displayText: String
let userMobile: String
let createdAt: String
}
struct OrderSourceSelection: Equatable {
let person: OrderSourcePerson
let lead: OrderSourceLead
}
/// / ViewModel Android `OrderListViewModel`
final class OrderListViewModel {
private(set) var orderList: [OrderEntity] = []
private(set) var searchText = ""
private(set) var filterLabel = "全部"
private(set) var tabIndex = 0
private(set) var rTabIndex = 0
private(set) var startTime: Date?
private(set) var endTime: Date?
private(set) var isRefreshing = false
private(set) var canLoadMore = false
private(set) var isLoadingMore = false
private(set) var multiVerifyPendingData: MultiVerifyParseData?
private(set) var multiVerifyScenicSpotList: [MultiTravelScenicSpotEntity] = []
private(set) var selectedMultiVerifySpot: MultiTravelScenicSpotEntity?
private(set) var signInResponse: OrderSignInResponse?
private(set) var writeOffResponse: WriteOffOrderResponse?
private(set) var orderSourcePeople: [OrderSourcePerson] = []
private(set) var orderSourceLeads: [String: [OrderSourceLead]] = [:]
private(set) var orderSourceSelections: [String: OrderSourceSelection] = [:]
private(set) var loadingOrderSourceLeadsFor: String?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private var currentPage = 1
private var totalCount = 0
private var loadingOrderSourcePeople = false
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
}()
var isScenicAdminReadOnly: Bool {
AppStore.shared.currentAppRole == .scenicAdmin
}
var listAPIPath: String {
isScenicAdminReadOnly
? "/api/app/scenic-admin/order/list"
: "/api/yf-handset-app/photog/order/listv2"
}
func refreshOrderList(api: OrderAPI) async {
currentPage = 1
isRefreshing = true
isLoadingMore = false
orderList = []
notifyStateChange()
await loadOrderList(api: api, append: false)
}
func loadMore(api: OrderAPI) async {
guard !isLoadingMore, !isRefreshing else { return }
guard orderList.count < totalCount else { return }
isLoadingMore = true
currentPage += 1
notifyStateChange()
await loadOrderList(api: api, append: true)
}
func shouldLoadMore(at index: Int) -> Bool {
guard canLoadMore, !isRefreshing, !isLoadingMore else { return false }
return index >= max(0, orderList.count - 3)
}
func onSearchTextChange(_ text: String) {
searchText = text
notifyStateChange()
}
func onFilterChange(orderStatus: Int, isRefined: Int) {
tabIndex = orderStatus
rTabIndex = isRefined
filterLabel = PhotographerOrderFilter.from(tabIndex: orderStatus, rTabIndex: isRefined).label
notifyStateChange()
}
func onDateRangeSelected(start: Date?, end: Date?) {
startTime = start
endTime = end
notifyStateChange()
}
func cancelOrder(orderNumber: String, api: OrderAPI) async {
guard !abortIfScenicAdminReadOnlyMutation() else { return }
do {
try await api.cancelOrder(CancelRequest(orderNumber: orderNumber))
onShowMessage?("取消成功")
await refreshOrderList(api: api)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func refundOrder(_ request: OrderRefundRequest, api: OrderAPI) async {
guard !abortIfScenicAdminReadOnlyMutation() else { return }
do {
try await api.refundOrder(request)
onShowMessage?("退款成功")
await refreshOrderList(api: api)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int, api: OrderAPI) async {
guard !abortIfScenicAdminReadOnlyMutation() else { return }
do {
try await api.giftRetouch(SendGiftRequest(orderNumber: orderNumber, photoNum: photoNum, videoNum: videoNum))
onShowMessage?("赠送成功")
await refreshOrderList(api: api)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func updateRefined(orderNumber: String, isRefined: Int, api: OrderAPI) async {
guard !abortIfScenicAdminReadOnlyMutation() else { return }
do {
try await api.updateRefined(orderNumber: orderNumber, isRefined: isRefined)
await refreshOrderList(api: api)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func processScanResult(_ scanResult: String, api: OrderAPI) async {
if scanResult.isEmpty {
onShowMessage?("请扫描正确的二维码")
return
}
guard !abortIfScenicAdminReadOnlyMutation() else { return }
if let parsed = QRCodeScanResult.parse(scanResult) {
switch parsed.method {
case "multi_verify":
guard !parsed.orderNumber.isEmpty, parsed.userId > 0, parsed.timeLong > 0, !parsed.checkSum.isEmpty else {
onShowMessage?("请扫描正确的多点位核销码")
return
}
multiVerifyPendingData = MultiVerifyParseData(
orderNumber: parsed.orderNumber,
userId: parsed.userId,
time: parsed.timeLong,
checkSum: parsed.checkSum
)
notifyStateChange()
return
case SaleUserQrParser.methodSaleUserBind:
if let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 {
onShowMessage?("获客员绑定功能待接入")
} else {
onShowMessage?("请扫描正确的获客员二维码")
}
return
case "order_verify":
await performOrderVerify(jsonBody: parsed.rawJSON, api: api)
return
case "check_in", "":
await performCheckIn(scanResult: scanResult, api: api)
return
default:
break
}
}
await performCheckIn(scanResult: scanResult, api: api)
}
func fetchScenicSpotsAndShowSheet(lat: Double, lng: Double, api: OrderAPI) async {
guard let data = multiVerifyPendingData else { return }
do {
let response = try await api.multiTravelScenicSpotList(
orderNumber: data.orderNumber,
lat: lat,
lng: lng
)
multiVerifyScenicSpotList = response.entityList
selectedMultiVerifySpot = multiVerifyScenicSpotList.first
notifyStateChange()
if multiVerifyScenicSpotList.isEmpty {
onShowMessage?("暂无可核销的打卡点")
clearMultiVerifyPending()
}
} catch {
onShowMessage?(error.localizedDescription)
clearMultiVerifyPending()
}
}
func onMultiVerifySpotSelected(_ spot: MultiTravelScenicSpotEntity) {
selectedMultiVerifySpot = spot
notifyStateChange()
}
func onMultiVerifyConfirm(api: OrderAPI) async {
guard !abortIfScenicAdminReadOnlyMutation() else { return }
guard let data = multiVerifyPendingData, let spot = selectedMultiVerifySpot else {
onShowMessage?("请选择一个打卡点")
return
}
do {
let response = try await api.multiTravelOrderVerify(
MultiTravelOrderVerifyRequest(
orderNumber: data.orderNumber,
userId: data.userId,
time: data.time,
checkSum: data.checkSum,
scenicSpotId: spot.scenicSpotId
)
)
signInResponse = response
clearMultiVerifySheet()
notifyStateChange()
} catch {
onShowMessage?(error.localizedDescription)
clearMultiVerifyPending()
}
}
func clearMultiVerifySheet() {
multiVerifyScenicSpotList = []
selectedMultiVerifySpot = nil
multiVerifyPendingData = nil
notifyStateChange()
}
func clearMultiVerifyPending() {
multiVerifyPendingData = nil
notifyStateChange()
}
func clearSignInResponse() {
signInResponse = nil
notifyStateChange()
}
func clearWriteOffResponse() {
writeOffResponse = nil
notifyStateChange()
}
func shouldShowOrderSource(for order: OrderEntity) -> Bool {
guard !isScenicAdminReadOnly else { return false }
return order.orderType == OrderTypeCode.photographerFollow
|| order.orderType == OrderTypeCode.photographerFollowOfflineScan
}
func orderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
if let selection = orderSourceSelections[order.orderNumber] { return selection }
guard let referral = order.referralOrder else { return nil }
let person = OrderSourcePerson(
key: String(referral.saleUserId),
name: referral.displaySalerName,
phone: referral.salerPhone
)
let lead = OrderSourceLead(
key: referral.orderNo,
displayText: referral.displayText.isEmpty ? referral.orderNo : referral.displayText,
userMobile: referral.userMobile,
createdAt: referral.createdAt
)
return OrderSourceSelection(person: person, lead: lead)
}
func onOrderSourceSheetOpen(api: OrderAPI) async {
orderSourceLeads = [:]
guard !loadingOrderSourcePeople else { return }
loadingOrderSourcePeople = true
defer { loadingOrderSourcePeople = false }
let storeId = AppStore.shared.currentStoreId
let storeIdParam = storeId > 0 ? String(storeId) : nil
do {
let response = try await api.cooperativeSalers(storeId: storeIdParam)
orderSourcePeople = response.list.map {
OrderSourcePerson(key: String($0.saleUserId), name: $0.displayName, phone: $0.displayPhone)
}
notifyStateChange()
} catch {
onShowMessage?(error.localizedDescription)
}
}
func loadOrderSourceLeads(person: OrderSourcePerson, api: OrderAPI) async {
guard loadingOrderSourceLeadsFor == nil else { return }
loadingOrderSourceLeadsFor = person.key
notifyStateChange()
defer {
loadingOrderSourceLeadsFor = nil
notifyStateChange()
}
do {
let response = try await api.referralOrders(saleUserId: person.key)
let leads = response.list.map {
OrderSourceLead(
key: $0.displayReferralOrderNo,
displayText: $0.displayReferralOrderNo,
userMobile: $0.userMobile,
createdAt: $0.createdAt
)
}
orderSourceLeads[person.key] = leads
notifyStateChange()
} catch {
onShowMessage?(error.localizedDescription)
}
}
func bindOrderSource(orderNumber: String, selection: OrderSourceSelection, api: OrderAPI) async {
guard !abortIfScenicAdminReadOnlyMutation() else { return }
do {
try await api.bindReferralOrder(orderNumber: orderNumber, referralOrderNo: selection.lead.key)
orderSourceSelections[orderNumber] = selection
onShowMessage?("绑定成功")
await refreshOrderList(api: api)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func loadOrderList(api: OrderAPI, append: Bool) async {
let scenicId = AppStore.shared.currentScenicId
guard scenicId > 0 else {
finishLoading()
onShowMessage?("当前景区信息缺失")
return
}
let start = startTime.map { apiDateFormatter.string(from: $0) }
let end = endTime.map { apiDateFormatter.string(from: $0) }
let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let response: OrderListResponse<OrderEntity>
if isScenicAdminReadOnly {
response = try await api.scenicAdminOrderList(
scenicId: scenicId,
page: currentPage,
orderStatus: tabIndex,
startTime: start,
endTime: end,
userPhone: phone.isEmpty ? nil : phone,
isRefined: rTabIndex
)
} else {
response = try await api.photographerOrderList(
scenicId: scenicId,
page: currentPage,
orderStatus: tabIndex,
startTime: start,
endTime: end,
userPhone: phone.isEmpty ? nil : phone,
isRefined: rTabIndex
)
}
totalCount = response.total
if append {
orderList.append(contentsOf: response.list)
} else {
orderList = response.list
}
syncOrderSourceSelections()
canLoadMore = orderList.count < totalCount
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
finishLoading()
}
private func performCheckIn(scanResult: String, api: OrderAPI) async {
guard let jsonBody = appendScenicId(to: scanResult) else {
onShowMessage?("请扫描正确的二维码")
return
}
do {
signInResponse = try await api.orderCheckIn(jsonBody: jsonBody)
notifyStateChange()
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func performOrderVerify(jsonBody: String, api: OrderAPI) async {
do {
writeOffResponse = try await api.orderVerify(jsonBody: jsonBody)
notifyStateChange()
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func appendScenicId(to scanResult: String) -> String? {
guard let data = scanResult.data(using: .utf8),
var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
json["scenic_id"] = String(AppStore.shared.currentScenicId)
guard let updated = try? JSONSerialization.data(withJSONObject: json),
let string = String(data: updated, encoding: .utf8) else {
return nil
}
return string
}
private func syncOrderSourceSelections() {
var updated = orderSourceSelections
for order in orderList {
if let selection = orderSourceSelection(for: order) {
updated[order.orderNumber] = selection
}
}
orderSourceSelections = updated
}
@discardableResult
private func abortIfScenicAdminReadOnlyMutation() -> Bool {
guard isScenicAdminReadOnly else { return false }
onShowMessage?("景区管理员仅可查看")
return true
}
private func finishLoading() {
isRefreshing = false
isLoadingMore = false
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}