// // OrdersViewController.swift // suixinkan // import SnapKit import UIKit /// 订单 Tab 根页面,展示订单管理和核销订单两个子入口及对应列表。 final class OrdersViewController: UIViewController { private let viewModel = OrdersViewModel() private var manualOrderNumber = "" private var scanHintMessage: String? private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .grouped) table.backgroundColor = AppDesignUIKit.pageBackground table.separatorStyle = .none table.dataSource = self table.delegate = self table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID) table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID) table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID) table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID) table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID) return table }() private lazy var refreshControl = UIRefreshControl() override func viewDidLoad() { super.viewDidLoad() title = "订单" view.backgroundColor = AppDesignUIKit.pageBackground view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) tableView.refreshControl = refreshControl viewModel.onChange = { [weak self] in self?.tableView.reloadData() } appServices.appRouter.onChange = { [weak self] in guard let self else { return } if self.viewModel.selectedEntry != self.appServices.appRouter.selectedOrdersEntry { self.viewModel.selectedEntry = self.appServices.appRouter.selectedOrdersEntry Task { await self.reload(showLoading: true) } } Task { await self.consumePendingScanCodeIfNeeded() } } appServices.accountContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } viewModel.selectedEntry = appServices.appRouter.selectedOrdersEntry Task { await reload(showLoading: true) await consumePendingScanCodeIfNeeded() } } @objc private func refreshPulled() { Task { await reload(showLoading: false) refreshControl.endRefreshing() } } private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id } private var currentStoreId: Int? { appServices.accountContext.currentStore?.id } private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } private func reload(showLoading: Bool) async { do { try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") { try await self.viewModel.reload( api: self.appServices.ordersAPI, scenicId: self.currentScenicId, storeId: self.currentStoreId, roleId: self.currentRoleId, showLoading: false ) } } catch { showToast(error.localizedDescription) } } private func loadMore() async { do { if viewModel.selectedEntry == .storeOrders { try await viewModel.loadMoreStoreOrders( api: appServices.ordersAPI, scenicId: currentScenicId, roleId: currentRoleId ) } else { try await viewModel.loadMoreWriteOffOrders( api: appServices.ordersAPI, scenicId: currentScenicId, storeId: currentStoreId ) } } catch { showToast(error.localizedDescription) } } private func switchEntry(_ entry: OrdersEntry) { guard viewModel.selectedEntry != entry else { return } viewModel.selectedEntry = entry appServices.appRouter.selectedOrdersEntry = entry Task { await reload(showLoading: true) } } private func verify(orderNumber: String) async { guard let scenicId = currentScenicId else { return } do { try await appServices.globalLoading.withLoading(message: "核销中...") { try await self.viewModel.verify( api: self.appServices.ordersAPI, scenicId: scenicId, storeId: self.currentStoreId, orderNumber: orderNumber ) } showToast("核销成功") } catch { showToast(error.localizedDescription) } } private func consumePendingScanCodeIfNeeded() async { guard viewModel.selectedEntry == .verificationOrders, let code = appServices.appRouter.consumePendingOrderScanCode() else { return } handleScanResult(code) } private func handleScanResult(_ rawCode: String) { guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else { showToast("未识别到有效订单号") return } if parsed.matched != nil { confirmVerify(orderNumber: parsed.orderNumber) } else { scanHintMessage = "扫码成功,当前列表未找到该订单" tableView.reloadData() } } private func confirmVerify(orderNumber: String) { let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "确认核销", style: .default) { [weak self] _ in Task { await self?.verify(orderNumber: orderNumber) } }) present(alert, animated: true) } private func presentScanner() { let scanner = OrderCodeScannerViewController() scanner.onScanResult = { [weak self, weak scanner] result in scanner?.dismiss(animated: true) guard let self else { return } switch result { case .success(let code): self.handleScanResult(code) case .failure(let error): self.showToast(error.localizedDescription) } } let nav = UINavigationController(rootViewController: scanner) nav.modalPresentationStyle = .fullScreen present(nav, animated: true) } private func presentDateFilter() { let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in self?.viewModel.filterStartDate = nil self?.viewModel.filterEndDate = nil Task { await self?.reload(showLoading: true) } }) alert.addAction(UIAlertAction(title: "近7天", style: .default) { [weak self] _ in let end = Date() let start = Calendar.current.date(byAdding: .day, value: -6, to: end) ?? end self?.viewModel.filterStartDate = start self?.viewModel.filterEndDate = end Task { await self?.reload(showLoading: true) } }) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) present(alert, animated: true) } private func presentStatusFilter() { let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet) for filter in OrderFilters.statusFilters { sheet.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in self?.viewModel.selectedStatus = filter.id Task { await self?.reload(showLoading: true) } }) } sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) present(sheet, animated: true) } } extension OrdersViewController: UITableViewDataSource, UITableViewDelegate { func numberOfSections(in tableView: UITableView) -> Int { guard currentScenicId != nil else { return 1 } return viewModel.selectedEntry == .storeOrders ? 3 : 3 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == 0 { return 1 } if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 } if currentScenicId == nil { return 1 } if viewModel.selectedEntry == .storeOrders { return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1) } return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1) } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { if indexPath.section == 0 { let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell let services = appServices cell.configure( selectedEntry: viewModel.selectedEntry, scenicName: services.accountContext.currentScenic?.name ?? "--", storeTotal: viewModel.storeTotal, writeOffTotal: viewModel.writeOffTotal, onSelectEntry: { [weak self] entry in self?.switchEntry(entry) } ) return cell } if currentScenicId == nil { let cell = UITableViewCell() cell.selectionStyle = .none cell.backgroundColor = .clear cell.contentView.subviews.forEach { $0.removeFromSuperview() } let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看订单。", systemImage: "mountain.2") cell.contentView.addSubview(empty) empty.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalTo(360) } return cell } if indexPath.section == 1 { if viewModel.selectedEntry == .storeOrders { let cell = tableView.dequeueReusableCell(withIdentifier: OrdersFilterCell.reuseID, for: indexPath) as! OrdersFilterCell let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部" cell.configure( statusTitle: statusTitle, phone: viewModel.searchPhone, onStatus: { [weak self] in self?.presentStatusFilter() }, onDate: { [weak self] in self?.presentDateFilter() }, onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text }, onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } } ) return cell } let cell = tableView.dequeueReusableCell(withIdentifier: OrdersWriteOffActionCell.reuseID, for: indexPath) as! OrdersWriteOffActionCell cell.configure( manualOrderNumber: manualOrderNumber, isVerifying: viewModel.isVerifying, hint: scanHintMessage, onScan: { [weak self] in self?.presentScanner() }, onManualChange: { [weak self] text in self?.manualOrderNumber = text }, onVerify: { [weak self] in guard let self, !self.manualOrderNumber.isEmpty else { return } self.confirmVerify(orderNumber: self.manualOrderNumber) } ) return cell } if viewModel.selectedEntry == .storeOrders { if viewModel.storeOrders.isEmpty { let cell = UITableViewCell() cell.selectionStyle = .none cell.backgroundColor = .clear cell.contentView.subviews.forEach { $0.removeFromSuperview() } let empty = makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray") cell.contentView.addSubview(empty) empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) } return cell } let item = viewModel.storeOrders[indexPath.row] let cell = tableView.dequeueReusableCell(withIdentifier: OrderEntityCell.reuseID, for: indexPath) as! OrderEntityCell cell.configure(item: item) return cell } if viewModel.writeOffOrders.isEmpty { let cell = UITableViewCell() cell.selectionStyle = .none cell.backgroundColor = .clear cell.contentView.subviews.forEach { $0.removeFromSuperview() } let empty = makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray") cell.contentView.addSubview(empty) empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) } return cell } let item = viewModel.writeOffOrders[indexPath.row] let cell = tableView.dequeueReusableCell(withIdentifier: WriteOffOrderCell.reuseID, for: indexPath) as! WriteOffOrderCell cell.configure(item: item, isVerifying: viewModel.currentVerifyingOrderNumber == item.orderNumber) return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard indexPath.section == 2 else { return } if viewModel.selectedEntry == .storeOrders, indexPath.row < viewModel.storeOrders.count { HomeMenuRouting.pushOrders(.storeDetail(viewModel.storeOrders[indexPath.row]), from: self) } else if viewModel.selectedEntry == .verificationOrders, indexPath.row < viewModel.writeOffOrders.count { HomeMenuRouting.pushOrders(.writeOffDetail(viewModel.writeOffOrders[indexPath.row]), from: self) } } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard indexPath.section == 2 else { return } let isLast: Bool if viewModel.selectedEntry == .storeOrders { isLast = indexPath.row == viewModel.storeOrders.count - 1 } else { isLast = indexPath.row == viewModel.writeOffOrders.count - 1 } if isLast { Task { await loadMore() } } } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { if indexPath.section == 0 { return 120 } if indexPath.section == 1 { return viewModel.selectedEntry == .storeOrders ? 130 : 150 } return UITableView.automaticDimension } } // MARK: - Cells private final class OrdersHeaderCell: UITableViewCell { static let reuseID = "OrdersHeaderCell" private var onSelectEntry: ((OrdersEntry) -> Void)? private let storeButton = UIButton(type: .system) private let verifyButton = UIButton(type: .system) private let leftPill = UILabel() private let rightPill = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } let segmentBackground = UIView() segmentBackground.backgroundColor = UIColor(hex: 0xF3F4F6) segmentBackground.layer.cornerRadius = 8 card.addSubview(segmentBackground) storeButton.addTarget(self, action: #selector(storeTapped), for: .touchUpInside) verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside) segmentBackground.addSubview(storeButton) segmentBackground.addSubview(verifyButton) [leftPill, rightPill].forEach { $0.font = .systemFont(ofSize: AppMetrics.FontSize.caption) $0.numberOfLines = 2 card.addSubview($0) } segmentBackground.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(12) make.height.equalTo(40) } storeButton.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(2) make.width.equalToSuperview().multipliedBy(0.5) } verifyButton.snp.makeConstraints { make in make.trailing.top.bottom.equalToSuperview().inset(2) make.width.equalToSuperview().multipliedBy(0.5) } leftPill.snp.makeConstraints { make in make.leading.bottom.equalToSuperview().inset(12) make.top.equalTo(segmentBackground.snp.bottom).offset(10) make.width.equalToSuperview().multipliedBy(0.45) } rightPill.snp.makeConstraints { make in make.trailing.bottom.equalToSuperview().inset(12) make.top.equalTo(leftPill) make.width.equalToSuperview().multipliedBy(0.45) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) { self.onSelectEntry = onSelectEntry updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders) updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders) if selectedEntry == .storeOrders { leftPill.text = "当前景区\n\(scenicName)" rightPill.text = "订单总数\n\(storeTotal)" } else { leftPill.text = "核销订单\n\(writeOffTotal)" rightPill.text = "当前景区\n\(scenicName)" } } private func updateSegment(_ button: UIButton, title: String, selected: Bool) { button.setTitle(title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium) button.setTitleColor(selected ? AppDesignUIKit.primary : AppDesignUIKit.textSecondary, for: .normal) button.backgroundColor = selected ? .white : .clear button.layer.cornerRadius = 6 } @objc private func storeTapped() { onSelectEntry?(.storeOrders) } @objc private func verifyTapped() { onSelectEntry?(.verificationOrders) } } private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate { static let reuseID = "OrdersFilterCell" private let phoneField = UITextField() private var onPhoneChange: ((String) -> Void)? private var onSearch: (() -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } phoneField.delegate = self phoneField.keyboardType = .phonePad phoneField.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) phoneField.backgroundColor = UIColor(hex: 0xF4F4F4) phoneField.layer.cornerRadius = 6 phoneField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) phoneField.leftViewMode = .always card.addSubview(phoneField) phoneField.snp.makeConstraints { make in make.leading.bottom.equalToSuperview().inset(12) make.height.equalTo(40) make.trailing.equalToSuperview().inset(100) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } func configure(statusTitle: String, phone: String, onStatus: @escaping () -> Void, onDate: @escaping () -> Void, onPhoneChange: @escaping (String) -> Void, onSearch: @escaping () -> Void) { phoneField.text = phone self.onPhoneChange = onPhoneChange self.onSearch = onSearch } func textFieldDidChangeSelection(_ textField: UITextField) { onPhoneChange?(textField.text ?? "") } } private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate { static let reuseID = "OrdersWriteOffActionCell" private let manualField = UITextField() private var onManualChange: ((String) -> Void)? private var onVerify: (() -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } manualField.delegate = self manualField.placeholder = "手动输入订单号" manualField.autocorrectionType = .no manualField.autocapitalizationType = .none manualField.backgroundColor = UIColor(hex: 0xF4F4F4) manualField.layer.cornerRadius = 8 manualField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) manualField.leftViewMode = .always card.addSubview(manualField) manualField.snp.makeConstraints { make in make.leading.bottom.equalToSuperview().inset(12) make.height.equalTo(42) make.trailing.equalToSuperview().inset(90) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } func configure(manualOrderNumber: String, isVerifying: Bool, hint: String?, onScan: @escaping () -> Void, onManualChange: @escaping (String) -> Void, onVerify: @escaping () -> Void) { manualField.text = manualOrderNumber self.onManualChange = onManualChange self.onVerify = onVerify } func textFieldDidChangeSelection(_ textField: UITextField) { onManualChange?(textField.text ?? "") } } private final class OrderEntityCell: UITableViewCell { static let reuseID = "OrderEntityCell" private let titleLabel = UILabel() private let statusLabel = UILabel() private let amountLabel = UILabel() private let phoneLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .default backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) amountLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold) amountLabel.textColor = AppDesignUIKit.primary phoneLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) phoneLabel.textColor = AppDesignUIKit.textSecondary let stack = UIStackView(arrangedSubviews: [titleLabel, phoneLabel, amountLabel]) stack.axis = .vertical stack.spacing = 4 card.addSubview(stack) card.addSubview(statusLabel) stack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(12) } statusLabel.snp.makeConstraints { make in make.trailing.top.equalToSuperview().inset(12) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } func configure(item: OrderEntity) { titleLabel.text = item.orderNumber statusLabel.text = item.orderStatusName amountLabel.text = "¥\(item.actualPayAmount.isEmpty ? item.orderAmount : item.actualPayAmount)" phoneLabel.text = item.phone } } private final class WriteOffOrderCell: UITableViewCell { static let reuseID = "WriteOffOrderCell" private let titleLabel = UILabel() private let subtitleLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) subtitleLabel.textColor = AppDesignUIKit.textSecondary let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) stack.axis = .vertical stack.spacing = 4 card.addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } func configure(item: WriteOffOrderItem, isVerifying: Bool) { titleLabel.text = item.orderNumber subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName) } }