Implement orders tab aligned with Android, including scan verify flows.
Add role-based order/deposit lists, AVFoundation QR scanning, verify dialogs, and ViewModel tests so photographers, scenic admins, and store admins match Android behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -1,13 +1,13 @@
|
||||
//
|
||||
// StatisticsDateRangeViewController.swift
|
||||
// DateRangePickerViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 数据统计日期范围选择 Sheet,对齐 Android `DateRangeBottomSheet`。
|
||||
final class StatisticsDateRangeViewController: UIViewController {
|
||||
/// 通用日期范围选择 Sheet,从 Statistics 抽取,对齐 Android `DateRangeBottomSheet`。
|
||||
final class DateRangePickerViewController: UIViewController {
|
||||
|
||||
var onShortcutSelected: ((String) -> Void)?
|
||||
var onRangeSelected: ((Date, Date) -> Void)?
|
||||
@ -227,7 +227,7 @@ final class StatisticsDateRangeViewController: UIViewController {
|
||||
}
|
||||
|
||||
private final class DateCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "StatisticsDateCell"
|
||||
static let reuseIdentifier = "DateRangePickerDateCell"
|
||||
|
||||
private let label = UILabel()
|
||||
|
||||
@ -264,7 +264,7 @@ final class StatisticsDateRangeViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
extension StatisticsDateRangeViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
extension DateRangePickerViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
monthDates().count
|
||||
}
|
||||
@ -301,3 +301,6 @@ extension StatisticsDateRangeViewController: UICollectionViewDataSource, UIColle
|
||||
return CGSize(width: width, height: 32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics 模块兼容别名。
|
||||
typealias StatisticsDateRangeViewController = DateRangePickerViewController
|
||||
@ -12,6 +12,8 @@ final class MainTabBarController: UITabBarController {
|
||||
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
||||
private var isSyncingTabSelection = false
|
||||
|
||||
private let scanHandler = MainTabScanHandler(api: NetworkServices.shared.orderAPI)
|
||||
|
||||
/// 测试时可注入扫码点击处理。
|
||||
var onScanTabSelected: (() -> Void)?
|
||||
|
||||
@ -47,6 +49,7 @@ final class MainTabBarController: UITabBarController {
|
||||
configureTabBarAppearance()
|
||||
configureTabs()
|
||||
bindBadgeViewModel()
|
||||
bindScanHandler()
|
||||
badgeViewModel.refreshPendingWriteOffCount()
|
||||
}
|
||||
|
||||
@ -118,10 +121,50 @@ final class MainTabBarController: UITabBarController {
|
||||
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
|
||||
}
|
||||
|
||||
private func bindScanHandler() {
|
||||
scanHandler.onShowMessage = { [weak self] message in
|
||||
self?.showScanToast(message)
|
||||
}
|
||||
scanHandler.onShowSignIn = { [weak self] response in
|
||||
guard let self else { return }
|
||||
OrderSignInAlertController.present(from: self, response: response)
|
||||
}
|
||||
scanHandler.onShowWriteOff = { [weak self] response in
|
||||
guard let self else { return }
|
||||
WriteOffResultAlertController.present(from: self, response: response)
|
||||
}
|
||||
scanHandler.onShowMultiVerifySheet = { [weak self] spots, selected in
|
||||
self?.presentMultiVerifySheet(spots: spots, selected: selected)
|
||||
}
|
||||
}
|
||||
|
||||
private func showScanToast(_ message: String) {
|
||||
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
|
||||
present(alert, animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
alert.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMultiVerifySheet(spots: [MultiTravelScenicSpotEntity], selected: MultiTravelScenicSpotEntity?) {
|
||||
let controller = MultiVerifyScenicSpotViewController(spots: spots, selected: selected)
|
||||
controller.onConfirm = { [weak self] spot in
|
||||
guard let self, let pending = self.scanHandler.pendingMultiVerify else { return }
|
||||
Task { await self.scanHandler.confirmMultiVerify(pending: pending, spot: spot) }
|
||||
}
|
||||
let nav = UINavigationController(rootViewController: controller)
|
||||
nav.modalPresentationStyle = .pageSheet
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
private func presentGlobalScanner() {
|
||||
guard presentedViewController == nil else { return }
|
||||
|
||||
let scanner = ScanPlaceholderViewController()
|
||||
let scanner = QRCodeScannerViewController()
|
||||
scanner.onScanResult = { [weak self] result in
|
||||
guard let self else { return }
|
||||
Task { await self.scanHandler.handleScanResult(result) }
|
||||
}
|
||||
let navigationController = UINavigationController(rootViewController: scanner)
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
present(navigationController, animated: true)
|
||||
|
||||
144
suixinkan/UI/Orders/DepositOrderCardCell.swift
Normal file
144
suixinkan/UI/Orders/DepositOrderCardCell.swift
Normal file
@ -0,0 +1,144 @@
|
||||
//
|
||||
// DepositOrderCardCell.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
struct DepositOrderCardActions {
|
||||
var onRefund: (() -> Void)?
|
||||
var onPartialRefund: (() -> Void)?
|
||||
var onVerify: (() -> Void)?
|
||||
var onTap: (() -> Void)?
|
||||
}
|
||||
|
||||
/// 店铺订单卡片,对齐 Android `DepositOrderListScreen`。
|
||||
final class DepositOrderCardCell: UITableViewCell {
|
||||
static let reuseIdentifier = "DepositOrderCardCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let projectLabel = UILabel()
|
||||
private let statusBadge = OrderStatusBadgeView()
|
||||
private let amountLabel = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
private let uidLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let refundButton = UIButton(type: .system)
|
||||
private let partialRefundButton = UIButton(type: .system)
|
||||
private let verifyButton = UIButton(type: .system)
|
||||
private var actions = DepositOrderCardActions()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(item: StoreOrderItem, actions: DepositOrderCardActions) {
|
||||
self.actions = actions
|
||||
projectLabel.text = item.projectName.isEmpty ? item.orderTypeLabel : item.projectName
|
||||
statusBadge.apply(status: item.orderStatus, text: item.orderStatusName)
|
||||
amountLabel.text = "¥\(item.actualPayAmount)"
|
||||
timeLabel.text = item.createdAt
|
||||
uidLabel.text = "UID:\(item.userId)"
|
||||
phoneLabel.text = OrderPhoneMask.mask(item.phone)
|
||||
|
||||
let showVerify = item.orderType == StoreOrderType.offlineProduct && item.orderStatus == 20
|
||||
verifyButton.isHidden = !showVerify
|
||||
refundButton.isHidden = item.orderStatus != 20 && item.orderStatus != 30
|
||||
partialRefundButton.isHidden = item.orderStatus != 20 && item.orderStatus != 30
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 12
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(cardTapped))
|
||||
cardView.addGestureRecognizer(tap)
|
||||
|
||||
projectLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
amountLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
amountLabel.textColor = AppColor.primary
|
||||
[timeLabel, uidLabel, phoneLabel].forEach {
|
||||
$0.font = .systemFont(ofSize: 13)
|
||||
$0.textColor = UIColor(hex: 0x6B7280)
|
||||
}
|
||||
|
||||
configureActionButton(refundButton, title: "退款", color: UIColor(hex: 0xEF4444), bg: UIColor(hex: 0xFFE7E7))
|
||||
configureActionButton(partialRefundButton, title: "部分退款", color: AppColor.primary, bg: UIColor(hex: 0xEFF6FF))
|
||||
configureActionButton(verifyButton, title: "线下核销", color: .white, bg: UIColor(hex: 0x0073FF))
|
||||
refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside)
|
||||
partialRefundButton.addTarget(self, action: #selector(partialRefundTapped), for: .touchUpInside)
|
||||
verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside)
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
[projectLabel, statusBadge, amountLabel, timeLabel, uidLabel, phoneLabel, refundButton, partialRefundButton, verifyButton].forEach {
|
||||
cardView.addSubview($0)
|
||||
}
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 12, right: 16))
|
||||
}
|
||||
projectLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
statusBadge.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(projectLabel)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
amountLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(projectLabel.snp.bottom).offset(8)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
timeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(amountLabel.snp.bottom).offset(4)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
uidLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(timeLabel.snp.bottom).offset(4)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(uidLabel.snp.bottom).offset(4)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
refundButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(phoneLabel.snp.bottom).offset(12)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
make.height.equalTo(40)
|
||||
make.width.equalTo(88)
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
partialRefundButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(refundButton)
|
||||
make.leading.equalTo(refundButton.snp.trailing).offset(8)
|
||||
make.height.equalTo(40)
|
||||
make.width.equalTo(100)
|
||||
}
|
||||
verifyButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(refundButton)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(40)
|
||||
make.width.equalTo(100)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureActionButton(_ button: UIButton, title: String, color: UIColor, bg: UIColor) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(color, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
button.backgroundColor = bg
|
||||
button.layer.cornerRadius = 8
|
||||
}
|
||||
|
||||
@objc private func refundTapped() { actions.onRefund?() }
|
||||
@objc private func partialRefundTapped() { actions.onPartialRefund?() }
|
||||
@objc private func verifyTapped() { actions.onVerify?() }
|
||||
@objc private func cardTapped() { actions.onTap?() }
|
||||
}
|
||||
88
suixinkan/UI/Orders/DepositOrderDetailViewController.swift
Normal file
88
suixinkan/UI/Orders/DepositOrderDetailViewController.swift
Normal file
@ -0,0 +1,88 @@
|
||||
//
|
||||
// DepositOrderDetailViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 店铺多点位旅拍订单详情。
|
||||
final class DepositOrderDetailViewController: BaseViewController, UITableViewDataSource {
|
||||
|
||||
private let orderNumber: String
|
||||
private let orderAPI: OrderAPI
|
||||
private var detail: StoreOrderDetailData?
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
|
||||
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
|
||||
self.orderNumber = orderNumber
|
||||
self.orderAPI = orderAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "订单详情"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
tableView.dataSource = self
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
private func loadDetail() async {
|
||||
let storeId = AppStore.shared.currentStoreId
|
||||
guard storeId > 0 else {
|
||||
showToast("店铺信息异常")
|
||||
return
|
||||
}
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
detail = try await orderAPI.storeOrderDetail(storeId: storeId, orderNumber: orderNumber)
|
||||
tableView.reloadData()
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if section == 0 { return 6 }
|
||||
return detail?.multiTravel?.shootingList.count ?? 0
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "基本信息" : "拍摄点位"
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
guard let detail else { return cell }
|
||||
if indexPath.section == 0 {
|
||||
switch indexPath.row {
|
||||
case 0: cell.textLabel?.text = "订单号"; cell.detailTextLabel?.text = detail.orderNumber
|
||||
case 1: cell.textLabel?.text = "项目"; cell.detailTextLabel?.text = detail.projectName
|
||||
case 2: cell.textLabel?.text = "状态"; cell.detailTextLabel?.text = detail.orderStatusName
|
||||
case 3: cell.textLabel?.text = "金额"; cell.detailTextLabel?.text = "¥\(detail.actualPayAmount)"
|
||||
case 4: cell.textLabel?.text = "手机号"; cell.detailTextLabel?.text = OrderPhoneMask.mask(detail.phone)
|
||||
default: cell.textLabel?.text = "创建时间"; cell.detailTextLabel?.text = detail.createdAt
|
||||
}
|
||||
} else if let item = detail.multiTravel?.shootingList[indexPath.row] {
|
||||
cell.textLabel?.text = item.scenicSpotName
|
||||
cell.detailTextLabel?.text = item.displayStaffName
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
//
|
||||
// HistoricalShootingInfoViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 历史拍摄信息详情页。
|
||||
final class HistoricalShootingInfoViewController: BaseViewController, UITableViewDataSource {
|
||||
|
||||
private let orderNumber: String
|
||||
private let orderAPI: OrderAPI
|
||||
private var response: MultiTravelShootHistoryResponse?
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
|
||||
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
|
||||
self.orderNumber = orderNumber
|
||||
self.orderAPI = orderAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "历史拍摄"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
tableView.dataSource = self
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await loadData() }
|
||||
}
|
||||
|
||||
private func loadData() async {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
response = try await orderAPI.shootHistory(orderNumber: orderNumber)
|
||||
tableView.reloadData()
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
response?.photogSpotList.count ?? 0
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
guard let spot = response?.photogSpotList[indexPath.row] else { return cell }
|
||||
cell.textLabel?.text = spot.scenicSpotName
|
||||
cell.detailTextLabel?.text = "\(spot.photographerDisplayName) · \(spot.files.count)个素材"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
//
|
||||
// MultiVerifyScenicSpotViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 多点位核销打卡点选择 Sheet。
|
||||
final class MultiVerifyScenicSpotViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||
|
||||
var onConfirm: ((MultiTravelScenicSpotEntity) -> Void)?
|
||||
var onCancel: (() -> Void)?
|
||||
|
||||
private var spots: [MultiTravelScenicSpotEntity]
|
||||
private var selectedSpot: MultiTravelScenicSpotEntity?
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
|
||||
init(spots: [MultiTravelScenicSpotEntity], selected: MultiTravelScenicSpotEntity?) {
|
||||
self.spots = spots
|
||||
self.selectedSpot = selected ?? spots.first
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .white
|
||||
title = "选择打卡点"
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "取消", style: .plain, target: self, action: #selector(cancelTapped))
|
||||
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "spot")
|
||||
|
||||
confirmButton.setTitle("确认核销", for: .normal)
|
||||
confirmButton.backgroundColor = AppColor.primary
|
||||
confirmButton.setTitleColor(.white, for: .normal)
|
||||
confirmButton.layer.cornerRadius = 8
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(confirmButton)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(confirmButton.snp.top).offset(-12)
|
||||
}
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
onCancel?()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard let selectedSpot else { return }
|
||||
onConfirm?(selectedSpot)
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
spots.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "spot", for: indexPath)
|
||||
let spot = spots[indexPath.row]
|
||||
var config = cell.defaultContentConfiguration()
|
||||
config.text = spot.name
|
||||
config.secondaryText = "\(spot.scenicName) · \(spot.distanceText)"
|
||||
cell.contentConfiguration = config
|
||||
cell.accessoryType = spot.scenicSpotId == selectedSpot?.scenicSpotId ? .checkmark : .none
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
selectedSpot = spots[indexPath.row]
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
314
suixinkan/UI/Orders/OrderCardCell.swift
Normal file
314
suixinkan/UI/Orders/OrderCardCell.swift
Normal file
@ -0,0 +1,314 @@
|
||||
//
|
||||
// OrderCardCell.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
struct OrderCardActions {
|
||||
var onRefund: (() -> Void)?
|
||||
var onVerify: (() -> Void)?
|
||||
var onUploadTask: (() -> Void)?
|
||||
var onCancel: (() -> Void)?
|
||||
var onCallPhone: (() -> Void)?
|
||||
var onGiftPhotoMinus: (() -> Void)?
|
||||
var onGiftPhotoPlus: (() -> Void)?
|
||||
var onGiftVideoMinus: (() -> Void)?
|
||||
var onGiftVideoPlus: (() -> Void)?
|
||||
var onSendGift: (() -> Void)?
|
||||
var onRefinedChanged: ((Int) -> Void)?
|
||||
var onHistoricalPhoto: (() -> Void)?
|
||||
var onOrderSourceTap: (() -> Void)?
|
||||
}
|
||||
|
||||
/// 摄影师订单卡片,对齐 Android `OrderView`。
|
||||
final class OrderCardCell: UITableViewCell {
|
||||
static let reuseIdentifier = "OrderCardCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let orderNumberLabel = UILabel()
|
||||
private let statusBadge = OrderStatusBadgeView()
|
||||
private let createdAtLabel = UILabel()
|
||||
private let typeChip = UILabel()
|
||||
private let actionStack = UIStackView()
|
||||
private let refundButton = UIButton(type: .system)
|
||||
private let verifyButton = UIButton(type: .system)
|
||||
private let uploadTaskButton = UIButton(type: .system)
|
||||
private let infoStack = UIStackView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let orderSourceButton = UIButton(type: .system)
|
||||
private let historicalButton = UIButton(type: .system)
|
||||
private let refinedStack = UIStackView()
|
||||
private let refinedNeedButton = UIButton(type: .system)
|
||||
private let refinedNoButton = UIButton(type: .system)
|
||||
|
||||
private var actions = OrderCardActions()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(order: OrderEntity, hideWriteActions: Bool, actions: OrderCardActions, showOrderSource: Bool, sourceText: String?) {
|
||||
self.actions = actions
|
||||
orderNumberLabel.text = "订单号:\(order.orderNumber)"
|
||||
statusBadge.apply(status: order.orderStatus, text: order.orderStatusName)
|
||||
createdAtLabel.text = order.createdAt.isEmpty ? "--" : order.createdAt
|
||||
typeChip.text = order.orderTypeLabel
|
||||
typeChip.isHidden = order.orderTypeLabel.isEmpty
|
||||
|
||||
let showRefund = !hideWriteActions && (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19
|
||||
let showVerify = !hideWriteActions && (
|
||||
(order.photoTravel?.needCheckIn == true && order.orderStatus != 30) || order.orderType == 19
|
||||
)
|
||||
refundButton.isHidden = !showRefund
|
||||
verifyButton.isHidden = !showVerify
|
||||
actionStack.isHidden = !showRefund && !showVerify
|
||||
uploadTaskButton.isHidden = hideWriteActions || order.orderType != 19
|
||||
|
||||
orderSourceButton.isHidden = !showOrderSource
|
||||
orderSourceButton.setTitle(sourceText ?? "选择订单来源", for: .normal)
|
||||
|
||||
let showRefined = order.orderStatus == 18 || order.orderStatus == 30
|
||||
refinedStack.isHidden = !showRefined
|
||||
updateRefinedButtons(isRefined: order.isRefined, readOnly: hideWriteActions)
|
||||
|
||||
cancelButton.isHidden = hideWriteActions || order.photoTravel?.canCancelOrder != true
|
||||
|
||||
infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
addInfoRow(title: "付款金额", value: "¥\(order.actualPayAmount)")
|
||||
if !order.payTime.isEmpty { addInfoRow(title: "付款时间", value: order.payTime) }
|
||||
if !order.completeTime.isEmpty { addInfoRow(title: "完成时间", value: order.completeTime) }
|
||||
if !order.payTypeName.isEmpty { addInfoRow(title: "付款方式", value: order.payTypeName) }
|
||||
addInfoRow(title: "UID", value: String(order.userId))
|
||||
addPhoneRow(phone: order.phone, hidden: hideWriteActions)
|
||||
if let travel = order.photoTravel, !travel.checkInTime.isEmpty {
|
||||
addInfoRow(title: "核销时间", value: travel.checkInTime)
|
||||
}
|
||||
if let travel = order.photoTravel {
|
||||
addInfoRow(title: "修图张数", value: "\(travel.orderPhotoNum)")
|
||||
addInfoRow(title: "视频", value: "\(travel.orderVideoNum)")
|
||||
}
|
||||
if let remark = order.remark, !remark.isEmpty {
|
||||
addInfoRow(title: "备注", value: remark)
|
||||
}
|
||||
|
||||
let materials = order.multiTravel?.materialList ?? []
|
||||
historicalButton.isHidden = materials.isEmpty
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 12
|
||||
|
||||
orderNumberLabel.font = .systemFont(ofSize: 14)
|
||||
orderNumberLabel.textColor = .black
|
||||
createdAtLabel.font = .systemFont(ofSize: 12)
|
||||
createdAtLabel.textColor = UIColor(hex: 0x6B7280)
|
||||
typeChip.font = .systemFont(ofSize: 12)
|
||||
typeChip.textColor = AppColor.primary
|
||||
typeChip.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
typeChip.layer.cornerRadius = 4
|
||||
typeChip.clipsToBounds = true
|
||||
typeChip.textAlignment = .center
|
||||
|
||||
configureActionButton(refundButton, title: "退款", color: UIColor(hex: 0xEF4444), bg: UIColor(hex: 0xFFE7E7))
|
||||
configureActionButton(verifyButton, title: "核销订单", color: .white, bg: UIColor(hex: 0x0073FF))
|
||||
refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside)
|
||||
verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside)
|
||||
|
||||
uploadTaskButton.setTitle("上传任务", for: .normal)
|
||||
uploadTaskButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
uploadTaskButton.titleLabel?.font = .systemFont(ofSize: 16)
|
||||
uploadTaskButton.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
uploadTaskButton.layer.cornerRadius = 12
|
||||
uploadTaskButton.addTarget(self, action: #selector(uploadTaskTapped), for: .touchUpInside)
|
||||
|
||||
actionStack.axis = .horizontal
|
||||
actionStack.spacing = 12
|
||||
actionStack.distribution = .fillEqually
|
||||
actionStack.addArrangedSubview(refundButton)
|
||||
actionStack.addArrangedSubview(verifyButton)
|
||||
|
||||
infoStack.axis = .vertical
|
||||
infoStack.spacing = 6
|
||||
|
||||
cancelButton.setTitle("取消订单", for: .normal)
|
||||
cancelButton.setTitleColor(UIColor(hex: 0xEF4444), for: .normal)
|
||||
cancelButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
|
||||
orderSourceButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
orderSourceButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
orderSourceButton.contentHorizontalAlignment = .leading
|
||||
orderSourceButton.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside)
|
||||
|
||||
historicalButton.setTitle("历史拍摄", for: .normal)
|
||||
historicalButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
historicalButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
historicalButton.addTarget(self, action: #selector(historicalTapped), for: .touchUpInside)
|
||||
|
||||
refinedStack.axis = .horizontal
|
||||
refinedStack.spacing = 8
|
||||
refinedNeedButton.setTitle("需要精修", for: .normal)
|
||||
refinedNoButton.setTitle("无需精修", for: .normal)
|
||||
[refinedNeedButton, refinedNoButton].forEach {
|
||||
$0.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
$0.layer.cornerRadius = 6
|
||||
$0.layer.borderWidth = 1
|
||||
}
|
||||
refinedNeedButton.addTarget(self, action: #selector(refinedNeedTapped), for: .touchUpInside)
|
||||
refinedNoButton.addTarget(self, action: #selector(refinedNoTapped), for: .touchUpInside)
|
||||
refinedStack.addArrangedSubview(refinedNeedButton)
|
||||
refinedStack.addArrangedSubview(refinedNoButton)
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(orderNumberLabel)
|
||||
cardView.addSubview(statusBadge)
|
||||
cardView.addSubview(createdAtLabel)
|
||||
cardView.addSubview(typeChip)
|
||||
cardView.addSubview(orderSourceButton)
|
||||
cardView.addSubview(actionStack)
|
||||
cardView.addSubview(uploadTaskButton)
|
||||
cardView.addSubview(refinedStack)
|
||||
cardView.addSubview(infoStack)
|
||||
cardView.addSubview(historicalButton)
|
||||
cardView.addSubview(cancelButton)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 12, right: 16))
|
||||
}
|
||||
orderNumberLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
statusBadge.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(orderNumberLabel)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
createdAtLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(orderNumberLabel.snp.bottom).offset(4)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
typeChip.snp.makeConstraints { make in
|
||||
make.top.equalTo(createdAtLabel.snp.bottom).offset(4)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
make.height.equalTo(20)
|
||||
make.width.greaterThanOrEqualTo(40)
|
||||
}
|
||||
orderSourceButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(typeChip.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
actionStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(orderSourceButton.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
uploadTaskButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(actionStack.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
refinedStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(uploadTaskButton.snp.bottom).offset(12)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
infoStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(refinedStack.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
historicalButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(infoStack.snp.bottom).offset(8)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(historicalButton.snp.bottom).offset(12)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureActionButton(_ button: UIButton, title: String, color: UIColor, bg: UIColor) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(color, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16)
|
||||
button.backgroundColor = bg
|
||||
button.layer.cornerRadius = 8
|
||||
}
|
||||
|
||||
private func addInfoRow(title: String, value: String) {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.distribution = .fillEqually
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .systemFont(ofSize: 13)
|
||||
titleLabel.textColor = UIColor(hex: 0x6B7280)
|
||||
let valueLabel = UILabel()
|
||||
valueLabel.text = value
|
||||
valueLabel.font = .systemFont(ofSize: 13)
|
||||
valueLabel.textColor = AppColor.text333
|
||||
valueLabel.textAlignment = .right
|
||||
row.addArrangedSubview(titleLabel)
|
||||
row.addArrangedSubview(valueLabel)
|
||||
infoStack.addArrangedSubview(row)
|
||||
}
|
||||
|
||||
private func addPhoneRow(phone: String, hidden: Bool) {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.distribution = .fillEqually
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "手机号"
|
||||
titleLabel.font = .systemFont(ofSize: 13)
|
||||
titleLabel.textColor = UIColor(hex: 0x6B7280)
|
||||
let valueButton = UIButton(type: .system)
|
||||
valueButton.setTitle(OrderPhoneMask.mask(phone), for: .normal)
|
||||
valueButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
valueButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
valueButton.contentHorizontalAlignment = .trailing
|
||||
valueButton.isHidden = hidden || phone.isEmpty
|
||||
valueButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
||||
row.addArrangedSubview(titleLabel)
|
||||
row.addArrangedSubview(valueButton)
|
||||
infoStack.addArrangedSubview(row)
|
||||
}
|
||||
|
||||
private func updateRefinedButtons(isRefined: Int, readOnly: Bool) {
|
||||
let selectedBorder = AppColor.primary.cgColor
|
||||
let normalBorder = UIColor(hex: 0xE5E7EB).cgColor
|
||||
refinedNeedButton.layer.borderColor = isRefined == 1 ? selectedBorder : normalBorder
|
||||
refinedNoButton.layer.borderColor = isRefined == 2 ? selectedBorder : normalBorder
|
||||
refinedNeedButton.isEnabled = !readOnly
|
||||
refinedNoButton.isEnabled = !readOnly
|
||||
}
|
||||
|
||||
@objc private func refundTapped() { actions.onRefund?() }
|
||||
@objc private func verifyTapped() { actions.onVerify?() }
|
||||
@objc private func uploadTaskTapped() { actions.onUploadTask?() }
|
||||
@objc private func cancelTapped() { actions.onCancel?() }
|
||||
@objc private func callTapped() { actions.onCallPhone?() }
|
||||
@objc private func orderSourceTapped() { actions.onOrderSourceTap?() }
|
||||
@objc private func historicalTapped() { actions.onHistoricalPhoto?() }
|
||||
@objc private func refinedNeedTapped() { actions.onRefinedChanged?(1) }
|
||||
@objc private func refinedNoTapped() { actions.onRefinedChanged?(2) }
|
||||
}
|
||||
|
||||
enum OrderPhoneMask {
|
||||
static func mask(_ phone: String) -> String {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 7 else { return trimmed }
|
||||
let start = trimmed.prefix(3)
|
||||
let end = trimmed.suffix(4)
|
||||
return "\(start)****\(end)"
|
||||
}
|
||||
}
|
||||
95
suixinkan/UI/Orders/OrderDialogControllers.swift
Normal file
95
suixinkan/UI/Orders/OrderDialogControllers.swift
Normal file
@ -0,0 +1,95 @@
|
||||
//
|
||||
// OrderDialogControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum OrderRefundAlertController {
|
||||
static func present(
|
||||
from presenter: UIViewController,
|
||||
order: OrderEntity,
|
||||
onConfirm: @escaping (OrderRefundRequest) -> Void
|
||||
) {
|
||||
let alert = UIAlertController(title: "订单退款", message: order.orderNumber, preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款金额"; $0.text = order.actualPayAmount }
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认退款", style: .destructive) { _ in
|
||||
let amount = alert.textFields?.first?.text ?? order.actualPayAmount
|
||||
let reason = alert.textFields?.last?.text ?? ""
|
||||
onConfirm(OrderRefundRequest(orderNumber: order.orderNumber, refundType: 1, refundAmount: amount, refundReason: reason))
|
||||
})
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
enum DepositOrderRefundAlertController {
|
||||
static func present(
|
||||
from presenter: UIViewController,
|
||||
orderNumber: String,
|
||||
amount: String,
|
||||
refundType: Int,
|
||||
onConfirm: @escaping (String, String) -> Void
|
||||
) {
|
||||
let title = refundType == 3 ? "部分退款" : "全额退款"
|
||||
let alert = UIAlertController(title: title, message: orderNumber, preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款金额"; $0.text = amount }
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认", style: .destructive) { _ in
|
||||
let refundAmount = alert.textFields?.first?.text ?? amount
|
||||
let reason = alert.textFields?.last?.text ?? ""
|
||||
onConfirm(refundAmount, reason)
|
||||
})
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
enum OrderSignInAlertController {
|
||||
static func present(from presenter: UIViewController, response: OrderSignInResponse) {
|
||||
let message = """
|
||||
项目:\(response.projectName)
|
||||
景区:\(response.scenicName)
|
||||
金额:¥\(response.orderAmount)
|
||||
手机号:\(response.userPhone)
|
||||
"""
|
||||
let alert = UIAlertController(title: response.checkIn ? "签到成功" : "核销结果", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default))
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
enum WriteOffResultAlertController {
|
||||
static func present(from presenter: UIViewController, response: WriteOffOrderResponse) {
|
||||
let message = """
|
||||
项目:\(response.projectName)
|
||||
景区:\(response.scenicName)
|
||||
金额:¥\(response.orderAmount)
|
||||
手机号:\(response.userPhone)
|
||||
"""
|
||||
let alert = UIAlertController(title: response.orderVerify ? "核销成功" : "核销结果", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default))
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
enum SendGiftAlertController {
|
||||
static func present(
|
||||
from presenter: UIViewController,
|
||||
photoNum: Int,
|
||||
videoNum: Int,
|
||||
onConfirm: @escaping (Int, Int) -> Void
|
||||
) {
|
||||
let alert = UIAlertController(title: "赠送修图", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "赠送照片数"; $0.keyboardType = .numberPad; $0.text = "\(photoNum)" }
|
||||
alert.addTextField { $0.placeholder = "赠送视频数"; $0.keyboardType = .numberPad; $0.text = "\(videoNum)" }
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in
|
||||
let photo = Int(alert.textFields?.first?.text ?? "0") ?? 0
|
||||
let video = Int(alert.textFields?.last?.text ?? "0") ?? 0
|
||||
onConfirm(photo, video)
|
||||
})
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
134
suixinkan/UI/Orders/OrderFilterBarView.swift
Normal file
134
suixinkan/UI/Orders/OrderFilterBarView.swift
Normal file
@ -0,0 +1,134 @@
|
||||
//
|
||||
// OrderFilterBarView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单筛选栏:搜索 + 状态下拉 + 日期筛选。
|
||||
final class OrderFilterBarView: UIView {
|
||||
|
||||
var onSearchChanged: ((String) -> Void)?
|
||||
var onSearchSubmitted: (() -> Void)?
|
||||
var onFilterSelected: ((Int, Int) -> Void)?
|
||||
var onDateRangeTap: (() -> Void)?
|
||||
|
||||
private let searchField = UITextField()
|
||||
private let filterButton = UIButton(type: .system)
|
||||
private let dateButton = UIButton(type: .system)
|
||||
|
||||
private var isStoreMode = false
|
||||
private var currentFilterLabel = "全部"
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(isStoreMode: Bool, filterLabel: String, dateRangeText: String, searchText: String) {
|
||||
self.isStoreMode = isStoreMode
|
||||
currentFilterLabel = filterLabel
|
||||
searchField.placeholder = isStoreMode ? "输入游客手机号" : "输入手机号搜索"
|
||||
searchField.text = searchText
|
||||
filterButton.setTitle("\(filterLabel) ▾", for: .normal)
|
||||
dateButton.setTitle(dateRangeText, for: .normal)
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
searchField.font = .systemFont(ofSize: 14)
|
||||
searchField.keyboardType = .phonePad
|
||||
searchField.returnKeyType = .search
|
||||
searchField.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
searchField.layer.cornerRadius = 4
|
||||
searchField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 12))
|
||||
searchField.leftViewMode = .always
|
||||
searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged)
|
||||
searchField.delegate = self
|
||||
|
||||
filterButton.setTitleColor(AppColor.text333, for: .normal)
|
||||
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
|
||||
|
||||
dateButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
dateButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
dateButton.contentHorizontalAlignment = .trailing
|
||||
dateButton.addTarget(self, action: #selector(dateTapped), for: .touchUpInside)
|
||||
|
||||
addSubview(searchField)
|
||||
addSubview(filterButton)
|
||||
addSubview(dateButton)
|
||||
|
||||
searchField.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
filterButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(searchField.snp.bottom).offset(12)
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
make.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
dateButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(filterButton)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func searchChanged() {
|
||||
onSearchChanged?(searchField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func filterTapped() {
|
||||
if isStoreMode {
|
||||
presentStoreFilterMenu()
|
||||
} else {
|
||||
presentPhotographerFilterMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func dateTapped() {
|
||||
onDateRangeTap?()
|
||||
}
|
||||
|
||||
private func presentPhotographerFilterMenu() {
|
||||
let actions: [(String, Int, Int)] = PhotographerOrderFilter.allCases.map {
|
||||
($0.label, $0.tabIndex, $0.rTabIndex)
|
||||
}
|
||||
presentMenu(actions: actions)
|
||||
}
|
||||
|
||||
private func presentStoreFilterMenu() {
|
||||
let actions: [(String, Int, Int)] = StoreOrderFilter.allCases.map {
|
||||
($0.label, $0.tabIndex, 0)
|
||||
}
|
||||
presentMenu(actions: actions)
|
||||
}
|
||||
|
||||
private func presentMenu(actions: [(String, Int, Int)]) {
|
||||
guard let presenter = window?.rootViewController?.presentedViewController ?? window?.rootViewController else { return }
|
||||
let alert = UIAlertController(title: "筛选订单", message: nil, preferredStyle: .actionSheet)
|
||||
actions.forEach { title, tab, rTab in
|
||||
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
self?.currentFilterLabel = title
|
||||
self?.filterButton.setTitle("\(title) ▾", for: .normal)
|
||||
self?.onFilterSelected?(tab, rTab)
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension OrderFilterBarView: UITextFieldDelegate {
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
textField.resignFirstResponder()
|
||||
onSearchSubmitted?()
|
||||
return true
|
||||
}
|
||||
}
|
||||
73
suixinkan/UI/Orders/OrderListView.swift
Normal file
73
suixinkan/UI/Orders/OrderListView.swift
Normal file
@ -0,0 +1,73 @@
|
||||
//
|
||||
// OrderListView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单列表容器,含下拉刷新与分页。
|
||||
final class OrderListView: UIView {
|
||||
|
||||
enum Mode {
|
||||
case photographer
|
||||
case store
|
||||
}
|
||||
|
||||
var onRefresh: (() -> Void)?
|
||||
var onLoadMore: ((Int) -> Void)?
|
||||
|
||||
let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let footerLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.keyboardDismissMode = .onDrag
|
||||
tableView.refreshControl = refreshControl
|
||||
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
|
||||
|
||||
footerLabel.font = .systemFont(ofSize: 12)
|
||||
footerLabel.textColor = UIColor(hex: 0x9CA3AF)
|
||||
footerLabel.textAlignment = .center
|
||||
footerLabel.text = "没有更多"
|
||||
|
||||
addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(isRefreshing: Bool, canLoadMore: Bool, isEmpty: Bool) {
|
||||
if isRefreshing {
|
||||
if !refreshControl.isRefreshing { refreshControl.beginRefreshing() }
|
||||
} else {
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
footerLabel.text = isEmpty ? "暂无订单" : (canLoadMore ? "" : "没有更多")
|
||||
tableView.tableFooterView = footerLabelFrame(isEmpty: isEmpty, canLoadMore: canLoadMore)
|
||||
}
|
||||
|
||||
private func footerLabelFrame(isEmpty: Bool, canLoadMore: Bool) -> UIView? {
|
||||
guard !canLoadMore || isEmpty else { return nil }
|
||||
footerLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 44)
|
||||
return footerLabel
|
||||
}
|
||||
|
||||
@objc private func refreshTriggered() {
|
||||
onRefresh?()
|
||||
}
|
||||
|
||||
func notifyLoadMoreIfNeeded(at index: Int, shouldLoad: Bool) {
|
||||
guard shouldLoad else { return }
|
||||
onLoadMore?(index)
|
||||
}
|
||||
}
|
||||
76
suixinkan/UI/Orders/OrderSourcePickerViewController.swift
Normal file
76
suixinkan/UI/Orders/OrderSourcePickerViewController.swift
Normal file
@ -0,0 +1,76 @@
|
||||
//
|
||||
// OrderSourcePickerViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单来源选择器。
|
||||
final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||
|
||||
var onPersonSelected: ((OrderSourcePerson) -> Void)?
|
||||
var onLeadSelected: ((OrderSourceSelection) -> Void)?
|
||||
|
||||
private let people: [OrderSourcePerson]
|
||||
private let leads: [OrderSourceLead]
|
||||
private let selectedPerson: OrderSourcePerson?
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
private var showingLeads = false
|
||||
|
||||
init(people: [OrderSourcePerson], leads: [OrderSourceLead], selectedPerson: OrderSourcePerson?) {
|
||||
self.people = people
|
||||
self.leads = leads
|
||||
self.selectedPerson = selectedPerson
|
||||
self.showingLeads = !leads.isEmpty
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = showingLeads ? "选择带客单" : "选择获客员"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped))
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
showingLeads ? leads.count : people.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
if showingLeads {
|
||||
let lead = leads[indexPath.row]
|
||||
cell.textLabel?.text = lead.displayText
|
||||
cell.detailTextLabel?.text = lead.userMobile
|
||||
} else {
|
||||
let person = people[indexPath.row]
|
||||
cell.textLabel?.text = person.name
|
||||
cell.detailTextLabel?.text = person.phone
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
if showingLeads, let person = selectedPerson {
|
||||
let lead = leads[indexPath.row]
|
||||
onLeadSelected?(OrderSourceSelection(person: person, lead: lead))
|
||||
dismiss(animated: true)
|
||||
} else {
|
||||
onPersonSelected?(people[indexPath.row])
|
||||
}
|
||||
}
|
||||
}
|
||||
47
suixinkan/UI/Orders/OrderStatusBadgeView.swift
Normal file
47
suixinkan/UI/Orders/OrderStatusBadgeView.swift
Normal file
@ -0,0 +1,47 @@
|
||||
//
|
||||
// OrderStatusBadgeView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单状态标签。
|
||||
final class OrderStatusBadgeView: UIView {
|
||||
private let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
label.textAlignment = .center
|
||||
addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4))
|
||||
}
|
||||
layer.cornerRadius = 4
|
||||
clipsToBounds = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(status: Int, text: String) {
|
||||
label.text = text
|
||||
switch status {
|
||||
case 18, 20:
|
||||
label.textColor = UIColor(hex: 0x0073FF)
|
||||
backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
case 30:
|
||||
label.textColor = UIColor(hex: 0x10B981)
|
||||
backgroundColor = UIColor(hex: 0xF0FDF4)
|
||||
case 50:
|
||||
label.textColor = UIColor(hex: 0xEF4444)
|
||||
backgroundColor = UIColor(hex: 0xFFE7E7)
|
||||
default:
|
||||
label.textColor = UIColor(hex: 0xFF7B00)
|
||||
backgroundColor = UIColor(hex: 0xFFF0E2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,26 +3,476 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单 Tab 根页面。
|
||||
final class OrdersViewController: BaseViewController {
|
||||
/// 订单 Tab 根页面,按角色分流摄影师/景区管理员/店铺管理员。
|
||||
final class OrdersViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate {
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let orderAPI = NetworkServices.shared.orderAPI
|
||||
private let orderListViewModel = OrderListViewModel()
|
||||
private let depositViewModel = DepositOrderListViewModel()
|
||||
|
||||
private let filterBar = OrderFilterBarView()
|
||||
private let listView = OrderListView()
|
||||
|
||||
private var hasInitialized = false
|
||||
private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin }
|
||||
private var pendingOrderSourceNumber: String?
|
||||
private var pendingOrderSourcePerson: OrderSourcePerson?
|
||||
private var pendingGiftOrderNumber: String?
|
||||
private var isFetchingMultiVerifySpots = false
|
||||
private let locationProvider = OrderLocationProvider()
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "订单"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
title = "订单"
|
||||
titleLabel.text = "订单"
|
||||
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.textColor = AppColor.text333
|
||||
view.addSubview(titleLabel)
|
||||
view.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
view.addSubview(filterBar)
|
||||
view.addSubview(listView)
|
||||
listView.tableView.dataSource = self
|
||||
listView.tableView.delegate = self
|
||||
listView.tableView.register(OrderCardCell.self, forCellReuseIdentifier: OrderCardCell.reuseIdentifier)
|
||||
listView.tableView.register(DepositOrderCardCell.self, forCellReuseIdentifier: DepositOrderCardCell.reuseIdentifier)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
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() }
|
||||
}
|
||||
orderListViewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
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() }
|
||||
}
|
||||
|
||||
private func initializeList() async {
|
||||
if isStoreMode {
|
||||
await depositViewModel.refreshOrderList(api: orderAPI)
|
||||
} else {
|
||||
await orderListViewModel.refreshOrderList(api: orderAPI)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
listView.tableView.reloadData()
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
isStoreMode ? depositViewModel.orderList.count : orderListViewModel.orderList.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if isStoreMode {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCardCell.reuseIdentifier, for: indexPath) as! DepositOrderCardCell
|
||||
let item = depositViewModel.orderList[indexPath.row]
|
||||
cell.configure(item: item, actions: makeDepositActions(for: item))
|
||||
listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: depositViewModel.shouldLoadMore(at: indexPath.row))
|
||||
return cell
|
||||
}
|
||||
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrderCardCell.reuseIdentifier, for: indexPath) as! OrderCardCell
|
||||
let order = orderListViewModel.orderList[indexPath.row]
|
||||
let source = orderListViewModel.orderSourceSelection(for: order)
|
||||
cell.configure(
|
||||
order: order,
|
||||
hideWriteActions: orderListViewModel.isScenicAdminReadOnly,
|
||||
actions: makeOrderActions(for: order),
|
||||
showOrderSource: orderListViewModel.shouldShowOrderSource(for: order),
|
||||
sourceText: source.map { "\($0.person.name) · \($0.lead.displayText)" }
|
||||
)
|
||||
listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: orderListViewModel.shouldLoadMore(at: indexPath.row))
|
||||
return cell
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
) { amount, reason in
|
||||
Task {
|
||||
await self.depositViewModel.requestRefund(
|
||||
orderNumber: item.orderNumber,
|
||||
refundType: 1,
|
||||
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
|
||||
) { amount, reason in
|
||||
Task {
|
||||
await self.depositViewModel.requestRefund(
|
||||
orderNumber: item.orderNumber,
|
||||
refundType: 3,
|
||||
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.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)
|
||||
let controller = OrderSourcePickerViewController(
|
||||
people: orderListViewModel.orderSourcePeople,
|
||||
leads: [],
|
||||
selectedPerson: nil
|
||||
)
|
||||
controller.onPersonSelected = { [weak self] person in
|
||||
guard let self else { return }
|
||||
self.pendingOrderSourcePerson = person
|
||||
Task {
|
||||
await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI)
|
||||
let leadsController = OrderSourcePickerViewController(
|
||||
people: self.orderListViewModel.orderSourcePeople,
|
||||
leads: self.orderListViewModel.orderSourceLeads[person.key] ?? [],
|
||||
selectedPerson: person
|
||||
)
|
||||
leadsController.onLeadSelected = { [weak self] selection in
|
||||
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }
|
||||
Task {
|
||||
await self.orderListViewModel.bindOrderSource(
|
||||
orderNumber: orderNumber,
|
||||
selection: selection,
|
||||
api: self.orderAPI
|
||||
)
|
||||
}
|
||||
}
|
||||
self.present(leadsController, animated: true)
|
||||
}
|
||||
}
|
||||
present(controller, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
suixinkan/UI/Scan/QRCodeScannerViewController.swift
Normal file
150
suixinkan/UI/Scan/QRCodeScannerViewController.swift
Normal file
@ -0,0 +1,150 @@
|
||||
//
|
||||
// QRCodeScannerViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// AVFoundation 二维码扫描页。
|
||||
final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
|
||||
var onScanResult: ((String) -> Void)?
|
||||
|
||||
private let captureSession = AVCaptureSession()
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var hasHandledResult = false
|
||||
|
||||
private let maskView = UIView()
|
||||
private let hintLabel = UILabel()
|
||||
private let closeButton = UIButton(type: .system)
|
||||
|
||||
override func setupNavigationBar() {
|
||||
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = .black
|
||||
hintLabel.text = "将二维码放入框内"
|
||||
hintLabel.textColor = .white
|
||||
hintLabel.font = .systemFont(ofSize: 14)
|
||||
hintLabel.textAlignment = .center
|
||||
|
||||
closeButton.setTitle("关闭", for: .normal)
|
||||
closeButton.setTitleColor(.white, for: .normal)
|
||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||
|
||||
view.addSubview(maskView)
|
||||
view.addSubview(hintLabel)
|
||||
view.addSubview(closeButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(48)
|
||||
}
|
||||
maskView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(260)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
checkCameraPermission()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.bounds
|
||||
updateMask()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
if captureSession.isRunning {
|
||||
captureSession.stopRunning()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkCameraPermission() {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
setupCamera()
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
granted ? self?.setupCamera() : self?.showPermissionDenied()
|
||||
}
|
||||
}
|
||||
default:
|
||||
showPermissionDenied()
|
||||
}
|
||||
}
|
||||
|
||||
private func showPermissionDenied() {
|
||||
showToast("需要相机权限才能扫码")
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
private func setupCamera() {
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device) else {
|
||||
showToast("无法打开相机")
|
||||
return
|
||||
}
|
||||
guard captureSession.canAddInput(input) else { return }
|
||||
captureSession.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard captureSession.canAddOutput(output) else { return }
|
||||
captureSession.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
let layer = AVCaptureVideoPreviewLayer(session: captureSession)
|
||||
layer.videoGravity = .resizeAspectFill
|
||||
view.layer.insertSublayer(layer, at: 0)
|
||||
previewLayer = layer
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.captureSession.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMask() {
|
||||
maskView.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
|
||||
let border = CAShapeLayer()
|
||||
border.path = UIBezierPath(roundedRect: maskView.bounds, cornerRadius: 12).cgPath
|
||||
border.strokeColor = AppColor.primary.cgColor
|
||||
border.fillColor = UIColor.clear.cgColor
|
||||
border.lineWidth = 2
|
||||
maskView.layer.addSublayer(border)
|
||||
}
|
||||
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard !hasHandledResult,
|
||||
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
object.type == .qr,
|
||||
let value = object.stringValue,
|
||||
!value.isEmpty else { return }
|
||||
hasHandledResult = true
|
||||
captureSession.stopRunning()
|
||||
onScanResult?(value)
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user