Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,604 @@
//
// 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)
}
}