Files
suixinkan_ios_uikit/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift
2026-06-26 14:33:31 +08:00

605 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}
}