Files
suixinkan_uikit/suixinkan/UI/Orders/OrdersViewController.swift

615 lines
26 KiB
Swift

//
// OrdersViewController.swift
// suixinkan
//
import CoreLocation
import SnapKit
import UIKit
/// 订单 Tab 根页面,按角色分流摄影师/景区管理员/店铺管理员。
final class OrdersViewController: BaseViewController, UITableViewDelegate {
private let appStore: AppStore
private let orderAPI: OrderAPI
private let orderListViewModel: OrderListViewModel
private let depositViewModel: DepositOrderListViewModel
/// 表格快照持有完整行数据,避免刷新清空 ViewModel 后仍按旧行号取值。
private struct OrderRow: Hashable {
/// 同一快照内按位置区分行,兼容接口返回重复订单号。
let position: Int
let content: Content
/// 摄影师和店铺订单的不可变展示数据。
enum Content: Equatable {
case photographer(OrderEntity, source: OrderSourceSelection?, readOnly: Bool, showSource: Bool)
case deposit(StoreOrderItem)
}
func hash(into hasher: inout Hasher) {
hasher.combine(position)
switch content {
case .photographer(let order, _, _, _):
hasher.combine(0)
hasher.combine(order.orderNumber)
case .deposit(let order):
hasher.combine(1)
hasher.combine(order.orderNumber)
}
}
}
private var dataSource: UITableViewDiffableDataSource<Int, OrderRow>?
/// 注入订单数据依赖;默认使用应用共享 API 和独立的页面 ViewModel。
init(
orderAPI: OrderAPI? = nil,
orderListViewModel: OrderListViewModel? = nil,
depositViewModel: DepositOrderListViewModel? = nil,
appStore: AppStore = .shared
) {
self.appStore = appStore
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
self.orderListViewModel = orderListViewModel ?? OrderListViewModel(appStore: appStore)
self.depositViewModel = depositViewModel ?? DepositOrderListViewModel(appStore: appStore)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private let filterBar = OrderFilterBarView()
private let listView = OrderListView()
private var hasInitialized = false
private var isStoreMode: Bool { appStore.session.currentAppRole == .storeAdmin }
private var pendingOrderSourceNumber: String?
private weak var orderSourcePicker: OrderSourcePickerViewController?
private var pendingGiftOrderNumber: String?
private var isFetchingMultiVerifySpots = false
private let locationProvider: any LocationProviding = LocationProvider.shared
override func setupNavigationBar() {
title = "订单"
}
override func setupUI() {
view.addSubview(filterBar)
view.addSubview(listView)
listView.tableView.delegate = self
listView.tableView.register(OrderCardCell.self, forCellReuseIdentifier: OrderCardCell.reuseIdentifier)
listView.tableView.register(DepositOrderCardCell.self, forCellReuseIdentifier: DepositOrderCardCell.reuseIdentifier)
configureDataSource()
}
override func setupConstraints() {
filterBar.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
}
listView.snp.makeConstraints { make in
make.top.equalTo(filterBar.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
orderListViewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.applyViewModel()
self?.orderSourcePicker?.reloadFromViewModel()
}
}
orderListViewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
orderListViewModel.onNavigateBindAcquirer = { [weak self] saleUserId in
Task { @MainActor in self?.presentBindAcquirer(saleUserId: saleUserId) }
}
depositViewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
depositViewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
filterBar.onSearchChanged = { [weak self] text in
guard let self else { return }
if self.isStoreMode {
self.depositViewModel.onSearchTextChange(text, api: self.orderAPI)
} else {
self.orderListViewModel.onSearchTextChange(text)
}
}
filterBar.onSearchSubmitted = { [weak self] in
guard let self else { return }
Task {
if self.isStoreMode {
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
} else {
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
}
}
}
filterBar.onFilterSelected = { [weak self] tab, rTab in
guard let self else { return }
Task {
if self.isStoreMode {
self.depositViewModel.onFilterChange(tab: tab)
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
} else {
self.orderListViewModel.onFilterChange(orderStatus: tab, isRefined: rTab)
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
}
}
}
filterBar.onDateRangeTap = { [weak self] in
self?.presentDateRangeSheet()
}
listView.onRefresh = { [weak self] in
guard let self else { return }
Task {
if self.isStoreMode {
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
} else {
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
}
}
}
listView.onLoadMore = { [weak self] index in
guard let self else { return }
Task {
if self.isStoreMode {
guard self.depositViewModel.shouldLoadMore(at: index) else { return }
await self.depositViewModel.loadMore(api: self.orderAPI)
} else {
guard self.orderListViewModel.shouldLoadMore(at: index) else { return }
await self.orderListViewModel.loadMore(api: self.orderAPI)
}
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !hasInitialized else { return }
hasInitialized = true
Task { await initializeList() }
}
/// 从订单通知进入时恢复默认查询,并刷新已有或首次打开的列表。
func openFromOrderNotification() {
loadViewIfNeeded()
hasInitialized = true
if isStoreMode {
depositViewModel.resetFiltersForOrderNotification()
} else {
orderListViewModel.resetFiltersForOrderNotification()
}
applyViewModel()
Task { @MainActor [weak self] in
await self?.initializeList()
}
}
private func initializeList() async {
if isStoreMode {
await depositViewModel.refreshOrderList(api: orderAPI)
} else {
await orderListViewModel.refreshOrderList(api: orderAPI)
}
}
private func applyViewModel() {
applyOrderSnapshot()
if isStoreMode {
filterBar.configure(
isStoreMode: true,
filterLabel: depositViewModel.filterLabel,
dateRangeText: dateRangeText(start: depositViewModel.startTime, end: depositViewModel.endTime),
searchText: depositViewModel.searchText
)
listView.apply(
isRefreshing: depositViewModel.isRefreshing,
canLoadMore: depositViewModel.canLoadMore,
isEmpty: depositViewModel.orderList.isEmpty,
isLoading: depositViewModel.isRefreshing && depositViewModel.orderList.isEmpty,
isLoadingMore: depositViewModel.isLoadingMore
)
if let response = depositViewModel.writeOffResponse {
depositViewModel.dismissWriteOffDialog()
WriteOffResultAlertController.present(from: self, response: response)
}
} else {
filterBar.configure(
isStoreMode: false,
filterLabel: orderListViewModel.filterLabel,
dateRangeText: dateRangeText(start: orderListViewModel.startTime, end: orderListViewModel.endTime),
searchText: orderListViewModel.searchText
)
listView.apply(
isRefreshing: orderListViewModel.isRefreshing,
canLoadMore: orderListViewModel.canLoadMore,
isEmpty: orderListViewModel.orderList.isEmpty,
isLoading: orderListViewModel.isRefreshing && orderListViewModel.orderList.isEmpty,
isLoadingMore: orderListViewModel.isLoadingMore
)
if let response = orderListViewModel.signInResponse {
orderListViewModel.clearSignInResponse()
OrderSignInAlertController.present(from: self, response: response)
}
if let response = orderListViewModel.writeOffResponse {
orderListViewModel.clearWriteOffResponse()
WriteOffResultAlertController.present(from: self, response: response)
}
if orderListViewModel.multiVerifyPendingData != nil,
orderListViewModel.multiVerifyScenicSpotList.isEmpty,
!isFetchingMultiVerifySpots {
Task { await self.fetchMultiVerifySpotsIfNeeded() }
}
if !orderListViewModel.multiVerifyScenicSpotList.isEmpty {
presentMultiVerifySheet()
}
}
}
private func dateRangeText(start: Date?, end: Date?) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
if let start, let end {
return "\(formatter.string(from: start))-\(formatter.string(from: end))"
}
return "时间筛选"
}
private func presentDateRangeSheet() {
let start = isStoreMode ? depositViewModel.startTime : orderListViewModel.startTime
let end = isStoreMode ? depositViewModel.endTime : orderListViewModel.endTime
let controller = DateRangePickerViewController(rangeStart: start, rangeEnd: end)
controller.onShortcutSelected = { [weak self, weak controller] shortcut in
guard let self else { return }
let range = self.resolveShortcutRange(shortcut)
Task {
if self.isStoreMode {
self.depositViewModel.onDateRangeSelected(start: range.start, end: range.end)
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
} else {
self.orderListViewModel.onDateRangeSelected(start: range.start, end: range.end)
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
}
controller?.dismiss(animated: true)
}
}
controller.onRangeSelected = { [weak self, weak controller] start, end in
guard let self else { return }
Task {
if self.isStoreMode {
self.depositViewModel.onDateRangeSelected(start: start, end: end)
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
} else {
self.orderListViewModel.onDateRangeSelected(start: start, end: end)
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
}
controller?.dismiss(animated: true)
}
}
if let sheet = controller.sheetPresentationController {
sheet.detents = [.medium(), .large()]
}
present(controller, animated: true)
}
private func resolveShortcutRange(_ shortcut: String) -> (start: Date?, end: Date?) {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
switch shortcut {
case StatisticsRangeShortcut.today.rawValue:
return (today, today)
case StatisticsRangeShortcut.yesterday.rawValue:
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
return (yesterday, yesterday)
case StatisticsRangeShortcut.last7Days.rawValue:
let start = calendar.date(byAdding: .day, value: -6, to: today)!
return (start, today)
default:
return (nil, nil)
}
}
private func presentScanner(onResult: @escaping (String) -> Void) {
let scanner = QRCodeScannerViewController()
scanner.onScanResult = onResult
let nav = UINavigationController(rootViewController: scanner)
nav.modalPresentationStyle = .fullScreen
present(nav, animated: true)
}
private func presentBindAcquirer(saleUserId: Int) {
dismiss(animated: true) { [weak self] in
let controller = BindAcquirerViewController(saleUserId: saleUserId)
self?.navigationController?.pushViewController(controller, animated: true)
}
}
private func fetchMultiVerifySpotsIfNeeded() async {
isFetchingMultiVerifySpots = true
defer { isFetchingMultiVerifySpots = false }
do {
let coordinate = try await locationProvider.requestCoordinate()
await orderListViewModel.fetchScenicSpotsAndShowSheet(
lat: coordinate.latitude,
lng: coordinate.longitude,
api: orderAPI
)
applyViewModel()
} catch {
showToast(error.localizedDescription)
orderListViewModel.clearMultiVerifyPending()
}
}
private func presentMultiVerifySheet() {
let spots = orderListViewModel.multiVerifyScenicSpotList
guard !spots.isEmpty else { return }
let controller = MultiVerifyScenicSpotViewController(
spots: spots,
selected: orderListViewModel.selectedMultiVerifySpot
)
controller.onConfirm = { [weak self] spot in
guard let self else { return }
self.orderListViewModel.onMultiVerifySpotSelected(spot)
Task { await self.orderListViewModel.onMultiVerifyConfirm(api: self.orderAPI) }
}
controller.onCancel = { [weak self] in
self?.orderListViewModel.clearMultiVerifySheet()
}
let nav = UINavigationController(rootViewController: controller)
if let sheet = nav.sheetPresentationController {
sheet.detents = [.medium(), .large()]
}
present(nav, animated: true)
orderListViewModel.clearMultiVerifySheet()
}
// MARK: - UITableView
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Int, OrderRow>(tableView: listView.tableView) { [weak self] tableView, _, row in
guard let self else { return nil }
switch row.content {
case .deposit(let item):
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCardCell.reuseIdentifier) as! DepositOrderCardCell
cell.configure(item: item, actions: self.makeDepositActions(for: item))
return cell
case .photographer(let order, let source, let readOnly, let showSource):
let cell = tableView.dequeueReusableCell(withIdentifier: OrderCardCell.reuseIdentifier) as! OrderCardCell
cell.configure(
order: order,
hideWriteActions: readOnly,
actions: self.makeOrderActions(for: order),
showOrderSource: showSource,
sourceSelection: source
)
return cell
}
}
}
private func applyOrderSnapshot() {
let rows: [OrderRow]
if isStoreMode {
rows = depositViewModel.orderList.enumerated().map {
OrderRow(position: $0.offset, content: .deposit($0.element))
}
} else {
rows = orderListViewModel.orderList.enumerated().map { index, order in
OrderRow(position: index, content: .photographer(
order,
source: orderListViewModel.orderSourceSelection(for: order),
readOnly: orderListViewModel.isScenicAdminReadOnly,
showSource: orderListViewModel.shouldShowOrderSource(for: order)
))
}
}
var snapshot = NSDiffableDataSourceSnapshot<Int, OrderRow>()
snapshot.appendSections([0])
snapshot.appendItems(rows)
dataSource?.apply(snapshot, animatingDifferences: true)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let row = dataSource?.itemIdentifier(for: indexPath) else { return }
let shouldLoad: Bool
switch row.content {
case .deposit:
shouldLoad = isStoreMode && depositViewModel.shouldLoadMore(at: row.position)
case .photographer:
shouldLoad = !isStoreMode && orderListViewModel.shouldLoadMore(at: row.position)
}
listView.notifyLoadMoreIfNeeded(at: row.position, shouldLoad: shouldLoad)
}
private func makeOrderActions(for order: OrderEntity) -> OrderCardActions {
var actions = OrderCardActions()
actions.onRefund = { [weak self] in
guard let self else { return }
OrderRefundAlertController.present(from: self, order: order) { request in
Task { await self.orderListViewModel.refundOrder(request, api: self.orderAPI) }
}
}
actions.onVerify = { [weak self] in
self?.presentScanner { result in
Task { await self?.orderListViewModel.processScanResult(result, api: self?.orderAPI ?? NetworkServices.shared.orderAPI) }
}
}
actions.onUploadTask = { [weak self] in
self?.showToast("上传任务待接入")
}
actions.onCancel = { [weak self] in
guard let self else { return }
Task { await self.orderListViewModel.cancelOrder(orderNumber: order.orderNumber, api: self.orderAPI) }
}
actions.onCallPhone = {
guard let url = URL(string: "tel://\(order.phone)") else { return }
UIApplication.shared.open(url)
}
actions.onRefinedChanged = { [weak self] value in
guard let self else { return }
Task { await self.orderListViewModel.updateRefined(orderNumber: order.orderNumber, isRefined: value, api: self.orderAPI) }
}
actions.onHistoricalPhoto = { [weak self] in
let controller = HistoricalShootingInfoViewController(orderNumber: order.orderNumber)
self?.navigationController?.pushViewController(controller, animated: true)
}
actions.onOrderSourceTap = { [weak self] in
self?.presentOrderSourcePicker(for: order)
}
actions.onOrderSourceImageTap = { [weak self] images, index in
self?.presentImagePreview(imageURLs: images, startIndex: index)
}
if let travel = order.photoTravel, travel.canGiftRetouch {
actions.onSendGift = { [weak self] in
guard let self else { return }
self.pendingGiftOrderNumber = order.orderNumber
SendGiftAlertController.present(
from: self,
photoNum: travel.retouchGiftPhotoNum,
videoNum: travel.retouchGiftVideoNum
) { photo, video in
Task {
await self.orderListViewModel.sendGift(
orderNumber: order.orderNumber,
photoNum: photo,
videoNum: video,
api: self.orderAPI
)
}
}
}
}
return actions
}
private func makeDepositActions(for item: StoreOrderItem) -> DepositOrderCardActions {
var actions = DepositOrderCardActions()
actions.onRefund = { [weak self] in
guard let self else { return }
DepositOrderRefundAlertController.present(
from: self,
orderNumber: item.orderNumber,
amount: item.actualPayAmount,
refundType: 1
) { refundType, amount, reason in
Task {
await self.depositViewModel.requestRefund(
orderNumber: item.orderNumber,
refundType: refundType,
refundAmount: amount,
refundReason: reason,
api: self.orderAPI
)
}
}
}
actions.onPartialRefund = { [weak self] in
guard let self else { return }
DepositOrderRefundAlertController.present(
from: self,
orderNumber: item.orderNumber,
amount: item.actualPayAmount,
refundType: 3
) { refundType, amount, reason in
Task {
await self.depositViewModel.requestRefund(
orderNumber: item.orderNumber,
refundType: refundType,
refundAmount: amount,
refundReason: reason,
api: self.orderAPI
)
}
}
}
actions.onVerify = { [weak self] in
self?.presentScanner { result in
Task { await self?.depositViewModel.processScanResult(result, api: self?.orderAPI ?? NetworkServices.shared.orderAPI) }
}
}
actions.onCallPhone = {
guard let url = URL(string: "tel://\(item.phone)") else { return }
UIApplication.shared.open(url)
}
actions.onTap = { [weak self] in
guard let self else { return }
guard self.depositViewModel.canOpenDetail(for: item) else {
self.showToast("该订单类型暂无详情")
return
}
let controller = DepositOrderDetailViewController(orderNumber: item.orderNumber)
self.navigationController?.pushViewController(controller, animated: true)
}
return actions
}
private func presentOrderSourcePicker(for order: OrderEntity) {
pendingOrderSourceNumber = order.orderNumber
Task {
await orderListViewModel.onOrderSourceSheetOpen(api: orderAPI)
await MainActor.run { [weak self] in
guard let self else { return }
let controller = OrderSourcePickerViewController(
orderNumber: order.orderNumber,
viewModel: self.orderListViewModel,
referralOrder: order.referralOrder,
loadLeads: { [weak self] person in
guard let self else { return }
await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI)
},
onLeadBound: { [weak self] selection in
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }
self.dismiss(animated: true) {
self.orderSourcePicker = nil
}
Task {
await self.orderListViewModel.bindOrderSource(
orderNumber: orderNumber,
selection: selection,
api: self.orderAPI
)
}
},
onCallPhone: { [weak self] phone in
self?.callPhone(phone)
}
)
self.orderSourcePicker = controller
let nav = UINavigationController(rootViewController: controller)
if let sheet = nav.sheetPresentationController {
sheet.prefersGrabberVisible = true
if #available(iOS 16.0, *) {
sheet.detents = [
.custom(identifier: .init("orderSource")) { context in
context.maximumDetentValue * 0.82
},
.large(),
]
sheet.selectedDetentIdentifier = .init("orderSource")
} else {
sheet.detents = [.large()]
}
}
self.present(nav, animated: true)
}
}
}
private func callPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel://\(trimmed)") else { return }
UIApplication.shared.open(url)
}
}