// // 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? /// 注入订单数据依赖;默认使用应用共享 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(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() 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) } }