Advance UIKit rewrite with AMap integration and core UI modules.

Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 15:16:12 +08:00
parent 9edf993432
commit d99a5b1bf8
124 changed files with 5195 additions and 1536 deletions

View File

@ -62,6 +62,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
let isNeedEdit: Bool
let isRefined: Int
///
enum CodingKeys: String, CodingKey {
case photogUid = "photog_uid"
case orderNumber = "order_number"
@ -131,6 +132,7 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
let retouchGiftPhotoNum: Int
let retouchGiftVideoNum: Int
///
enum CodingKeys: String, CodingKey {
case orderPhotoNum = "order_photo_num"
case orderVideoNum = "order_video_num"
@ -163,6 +165,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
let orderStatusName: String
let orderVerificationTime: String
///
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case orderVerificationStatus = "order_verification_status"
@ -192,6 +195,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
struct WriteOffRequest: Encodable {
let orderNumber: String
///
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
}
@ -207,6 +211,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
let statusName: String
let createdAt: String
///
enum CodingKeys: String, CodingKey {
case id
case orderNumber = "order_number"
@ -234,6 +239,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
struct DepositOrderWriteOffRequest: Encodable, Equatable {
let orderNumber: String
///
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
}
@ -244,6 +250,7 @@ struct DepositOrderRefundRequest: Encodable, Equatable {
let orderNumber: String
let refundReason: String
///
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case refundReason = "refund_reason"
@ -275,6 +282,7 @@ struct OrderRefundRequest: Encodable, Equatable {
let refundAmount: String
let refundReason: String
///
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case refundType = "refund_type"
@ -314,6 +322,7 @@ struct StoreOrderDetailResponse: Decodable, Equatable, Hashable {
let remark: String
let multiTravel: StoreOrderMultiTravelInfo?
///
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case orderType = "order_type"
@ -362,6 +371,7 @@ struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable {
let projectInfo: StoreOrderProjectInfo?
let shootingList: [StoreOrderShootingListItem]
///
enum CodingKeys: String, CodingKey {
case projectInfo = "project_info"
case shootingList = "shooting_list"
@ -382,6 +392,7 @@ struct StoreOrderProjectInfo: Decodable, Equatable, Hashable {
let singleSpotPhotoNum: Int
let singleSpotVideoNum: Int
///
enum CodingKeys: String, CodingKey {
case settleSpotNum = "settle_spot_num"
case singleSpotMaterialNum = "single_spot_material_num"
@ -412,6 +423,7 @@ struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable
let startAvg: Double
let start: Double
///
enum CodingKeys: String, CodingKey {
case scenicSpotId = "scenic_spot_id"
case photogUid = "photog_uid"
@ -447,6 +459,7 @@ struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable {
let materialList: [OrderMediaFile]
let completeList: [OrderMediaFile]
///
enum CodingKeys: String, CodingKey {
case scenicSpotId = "scenic_spot_id"
case scenicSpotName = "scenic_spot_name"
@ -480,6 +493,7 @@ struct StoreOrderComment: Decodable, Equatable, Hashable {
let content: String
let createdAt: String
///
enum CodingKeys: String, CodingKey {
case starShooting = "star_shooting"
case starRetouching = "star_retouching"
@ -522,6 +536,7 @@ struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable {
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
}
///
enum CodingKeys: String, CodingKey {
case fileName = "file_name"
case fileUrl = "file_url"
@ -550,6 +565,7 @@ struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable {
let projectTypeName: String
let photogSpotList: [PhotogSpotItem]
///
enum CodingKeys: String, CodingKey {
case projectName = "project_name"
case projectType = "project_type"
@ -583,6 +599,7 @@ struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable {
return nickname.isEmpty ? photogName : nickname
}
///
enum CodingKeys: String, CodingKey {
case scenicSpotId = "scenic_spot_id"
case photogUid = "photog_uid"
@ -609,6 +626,7 @@ struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Ha
let id: Int
let name: String
///
enum CodingKeys: String, CodingKey {
case id
case name
@ -629,6 +647,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
let cloudFile: [MultiTravelCloudFileItem]
let uploadFile: [MultiTravelUploadFileItem]
///
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case scenicSpotId = "scenic_spot_id"
@ -641,6 +660,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
struct MultiTravelCloudFileItem: Encodable, Equatable {
let fileId: Int
///
enum CodingKeys: String, CodingKey {
case fileId = "file_id"
}
@ -651,6 +671,7 @@ struct MultiTravelUploadFileItem: Encodable, Equatable {
let fileName: String
let fileUrl: String
///
enum CodingKeys: String, CodingKey {
case fileName = "file_name"
case fileUrl = "file_url"

View File

@ -6,32 +6,127 @@
import SnapKit
import UIKit
// MARK: - Deposit List Diffable
private typealias DepositListSection = Int
private typealias DepositListItem = String
/// /
private enum DepositListItemID {
static let placeholder = "depositList:placeholder"
}
// MARK: - Deposit Detail Diffable
private typealias DepositDetailSection = Int
private typealias DepositDetailRow = String
///
private enum DepositDetailRowID {
static let placeholder = "depositDetail:placeholder"
static func field(_ index: Int) -> String { "depositDetail:field:\(index)" }
}
// MARK: - Deposit Shooting Diffable
private typealias DepositShootingSection = Int
private typealias DepositShootingRow = String
/// section
private enum DepositShootingSectionID {
static let summary = 0
static let material = 1
static let complete = 2
}
///
private enum DepositShootingRowID {
static let placeholder = "depositShooting:placeholder"
static let scenicSpot = "depositShooting:scenicSpot"
static let rating = "depositShooting:rating"
static func material(_ index: Int) -> String { "depositShooting:material:\(index)" }
static func complete(_ index: Int) -> String { "depositShooting:complete:\(index)" }
}
/// 退
final class DepositOrderListViewController: UIViewController {
final class DepositOrderListViewController: UIViewController, UITableViewDelegate {
private let viewModel = DepositOrderListViewModel()
private var orderNumberField = UITextField()
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.delegate = self
table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID)
return table
}()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<DepositListSection, DepositListItem>!
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = "押金订单"
view.backgroundColor = AppDesignUIKit.pageBackground
configureTableDataSource()
view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
setupHeader()
Task { await reload(showLoading: true) }
}
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<DepositListSection, DepositListItem>(
tableView: tableView
) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: DepositListItem) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
if item == DepositListItemID.placeholder {
let cell = UITableViewCell()
cell.textLabel?.text = self.viewModel.loading ? "加载中..." : "暂无押金订单"
cell.selectionStyle = .none
return cell
}
guard let orderItem = self.viewModel.orders.first(where: { $0.orderNumber == item }) else {
return UITableViewCell()
}
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
cell.configure(item: orderItem, isOperating: self.viewModel.operatingOrderNumber == orderItem.orderNumber)
cell.onDetail = { [weak self] in
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: orderItem.orderNumber), from: $0) }
}
cell.onWriteOff = { [weak self] in self?.writeOff(orderItem) }
cell.onRefund = { [weak self] in self?.refund(orderItem) }
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<DepositListSection, DepositListItem> {
var snapshot = NSDiffableDataSourceSnapshot<DepositListSection, DepositListItem>()
snapshot.appendSections([0])
if viewModel.orders.isEmpty {
snapshot.appendItems([DepositListItemID.placeholder], toSection: 0)
} else {
snapshot.appendItems(viewModel.orders.map(\.orderNumber), toSection: 0)
}
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// Header UI
private func setupHeader() {
let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120))
orderNumberField.placeholder = "请输入押金订单号"
@ -55,12 +150,14 @@ final class DepositOrderListViewController: UIViewController {
tableView.tableHeaderView = header
}
/// open
@objc private func openDetail() {
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !text.isEmpty else { return }
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
}
///
private func reload(showLoading: Bool) async {
if showLoading {
await appServices.globalLoading.withLoading {
@ -72,6 +169,7 @@ final class DepositOrderListViewController: UIViewController {
if let message = viewModel.errorMessage { showToast(message) }
}
/// writeOff
private func writeOff(_ item: DepositOrderListItem) {
let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@ -86,6 +184,7 @@ final class DepositOrderListViewController: UIViewController {
present(alert, animated: true)
}
/// refund
private func refund(_ item: DepositOrderListItem) {
let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert)
alert.addTextField { $0.placeholder = "退款原因" }
@ -101,43 +200,25 @@ final class DepositOrderListViewController: UIViewController {
})
present(alert, animated: true)
}
}
extension DepositOrderListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
max(viewModel.orders.count, 1)
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
"押金订单列表 \(viewModel.orders.count)/\(viewModel.total)"
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard !viewModel.orders.isEmpty else {
let cell = UITableViewCell()
cell.textLabel?.text = viewModel.loading ? "加载中..." : "暂无押金订单"
cell.selectionStyle = .none
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
let item = viewModel.orders[indexPath.row]
cell.configure(item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber)
cell.onDetail = { [weak self] in
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: item.orderNumber), from: $0) }
}
cell.onWriteOff = { [weak self] in self?.writeOff(item) }
cell.onRefund = { [weak self] in self?.refund(item) }
return cell
}
/// UITableView
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.row == viewModel.orders.count - 1, viewModel.hasMore else { return }
guard let item = tableDataSource.itemIdentifier(for: indexPath),
item != DepositListItemID.placeholder,
item == viewModel.orders.last?.orderNumber,
viewModel.hasMore else { return }
Task {
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false)
}
}
}
/// DepositOrder Cell
private final class DepositOrderCell: UITableViewCell {
static let reuseID = "DepositOrderCell"
var onDetail: (() -> Void)?
@ -148,6 +229,7 @@ private final class DepositOrderCell: UITableViewCell {
private let statusLabel = UILabel()
private let amountLabel = UILabel()
///
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
@ -182,14 +264,18 @@ private final class DepositOrderCell: UITableViewCell {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
///
func configure(item: DepositOrderListItem, isOperating: Bool) {
titleLabel.text = item.orderNumber
statusLabel.text = isOperating ? "处理中" : item.statusName
amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)"
}
/// detail
@objc private func detailTapped() { onDetail?() }
/// writeOff
@objc private func writeOffTapped() { onWriteOff?() }
/// refund
@objc private func refundTapped() { onRefund?() }
}
@ -201,11 +287,14 @@ final class DepositOrderDetailViewController: UIViewController {
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.backgroundColor = AppDesignUIKit.pageBackground
return table
}()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<DepositDetailSection, DepositDetailRow>!
///
init(orderNumber: String) {
self.orderNumber = orderNumber
super.init(nibName: nil, bundle: nil)
@ -214,14 +303,66 @@ final class DepositOrderDetailViewController: UIViewController {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = "押金订单详情"
configureTableDataSource()
view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
Task { await loadDetail() }
}
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<DepositDetailSection, DepositDetailRow>(
tableView: tableView
) { [weak self] (_: UITableView, _: IndexPath, row: DepositDetailRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none
guard let detail = self.viewModel.detail else {
cell.textLabel?.text = self.viewModel.errorMessage ?? (self.viewModel.loading ? "加载中..." : "暂无详情")
return cell
}
let rows: [(String, String)] = [
("订单号", detail.orderNumber),
("状态", detail.orderStatusName),
("类型", detail.orderTypeLabel),
("付款金额", detail.actualPayAmount),
("手机号", detail.phone),
("项目", detail.projectName),
("创建时间", detail.createdAt),
("备注", detail.remark)
]
let index = Int(row.split(separator: ":").last ?? "") ?? 0
if index < rows.count {
cell.textLabel?.text = rows[index].0
cell.detailTextLabel?.text = rows[index].1
}
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<DepositDetailSection, DepositDetailRow> {
var snapshot = NSDiffableDataSourceSnapshot<DepositDetailSection, DepositDetailRow>()
snapshot.appendSections([0])
if viewModel.detail == nil {
snapshot.appendItems([DepositDetailRowID.placeholder], toSection: 0)
} else {
snapshot.appendItems((0..<8).map { DepositDetailRowID.field($0) }, toSection: 0)
}
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true) {
tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated)
}
/// Detail
private func loadDetail() async {
await appServices.globalLoading.withLoading {
await viewModel.load(
@ -230,41 +371,13 @@ final class DepositOrderDetailViewController: UIViewController {
orderNumber: orderNumber
)
}
tableView.reloadData()
applyTableSnapshot()
if let message = viewModel.errorMessage { showToast(message) }
}
}
extension DepositOrderDetailViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.detail == nil ? 1 : 8
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none
guard let detail = viewModel.detail else {
cell.textLabel?.text = viewModel.errorMessage ?? (viewModel.loading ? "加载中..." : "暂无详情")
return cell
}
let rows: [(String, String)] = [
("订单号", detail.orderNumber),
("状态", detail.orderStatusName),
("类型", detail.orderTypeLabel),
("付款金额", detail.actualPayAmount),
("手机号", detail.phone),
("项目", detail.projectName),
("创建时间", detail.createdAt),
("备注", detail.remark)
]
cell.textLabel?.text = rows[indexPath.row].0
cell.detailTextLabel?.text = rows[indexPath.row].1
return cell
}
}
///
final class DepositOrderShootingInfoViewController: UIViewController {
final class DepositOrderShootingInfoViewController: UIViewController, UITableViewDelegate {
private let orderNumber: String
private let scenicSpotId: Int
@ -273,10 +386,14 @@ final class DepositOrderShootingInfoViewController: UIViewController {
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.delegate = self
return table
}()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<DepositShootingSection, DepositShootingRow>!
///
init(orderNumber: String, scenicSpotId: Int, photogUid: Int) {
self.orderNumber = orderNumber
self.scenicSpotId = scenicSpotId
@ -287,14 +404,75 @@ final class DepositOrderShootingInfoViewController: UIViewController {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = "押金拍摄信息"
configureTableDataSource()
view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
Task { await loadDetail() }
}
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<DepositShootingSection, DepositShootingRow>(
tableView: tableView
) { [weak self] (_: UITableView, indexPath: IndexPath, row: DepositShootingRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
guard let detail = self.viewModel.detail else {
let cell = UITableViewCell()
cell.textLabel?.text = self.viewModel.errorMessage ?? "加载中..."
return cell
}
if row == DepositShootingRowID.scenicSpot || row == DepositShootingRowID.rating {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
if row == DepositShootingRowID.scenicSpot {
cell.textLabel?.text = "打卡点"
cell.detailTextLabel?.text = detail.scenicSpotName
} else {
cell.textLabel?.text = "拍摄评分"
cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting)" } ?? "--"
}
return cell
}
let files: [OrderMediaFile]
if row.hasPrefix("depositShooting:material:") {
files = detail.materialList
} else {
files = detail.completeList
}
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let media = files[index]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
cell.textLabel?.text = media.fileName
cell.detailTextLabel?.text = media.fileUrl
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<DepositShootingSection, DepositShootingRow> {
var snapshot = NSDiffableDataSourceSnapshot<DepositShootingSection, DepositShootingRow>()
guard let detail = viewModel.detail else {
snapshot.appendSections([0])
snapshot.appendItems([DepositShootingRowID.placeholder], toSection: 0)
return snapshot
}
snapshot.appendSections([DepositShootingSectionID.summary, DepositShootingSectionID.material, DepositShootingSectionID.complete])
snapshot.appendItems([DepositShootingRowID.scenicSpot, DepositShootingRowID.rating], toSection: DepositShootingSectionID.summary)
snapshot.appendItems(detail.materialList.indices.map { DepositShootingRowID.material($0) }, toSection: DepositShootingSectionID.material)
snapshot.appendItems(detail.completeList.indices.map { DepositShootingRowID.complete($0) }, toSection: DepositShootingSectionID.complete)
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true) {
tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated)
}
/// Detail
private func loadDetail() async {
await appServices.globalLoading.withLoading {
await viewModel.load(
@ -305,56 +483,25 @@ final class DepositOrderShootingInfoViewController: UIViewController {
photogUid: photogUid
)
}
tableView.reloadData()
applyTableSnapshot()
if let message = viewModel.errorMessage { showToast(message) }
}
}
extension DepositOrderShootingInfoViewController: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
viewModel.detail == nil ? 1 : 3
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let detail = viewModel.detail else { return 1 }
switch section {
case 0: return 2
case 1: return detail.materialList.count
default: return detail.completeList.count
}
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
guard viewModel.detail != nil else { return nil }
switch section {
case 1: return "素材"
case 2: return "成片"
guard viewModel.detail != nil,
let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
switch sectionID {
case DepositShootingSectionID.material: return "素材"
case DepositShootingSectionID.complete: return "成片"
default: return nil
}
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let detail = viewModel.detail else {
let cell = UITableViewCell()
cell.textLabel?.text = viewModel.errorMessage ?? "加载中..."
return cell
}
if indexPath.section == 0 {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
if indexPath.row == 0 {
cell.textLabel?.text = "打卡点"
cell.detailTextLabel?.text = detail.scenicSpotName
} else {
cell.textLabel?.text = "拍摄评分"
cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting)" } ?? "--"
}
return cell
}
let files = indexPath.section == 1 ? detail.materialList : detail.completeList
let media = files[indexPath.row]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
cell.textLabel?.text = media.fileName
cell.detailTextLabel?.text = media.fileUrl
return cell
/// section
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@ -32,6 +32,7 @@ final class OrderCodeScannerViewController: UIViewController {
private let scannerController = OrderScannerCaptureViewController()
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
@ -49,6 +50,7 @@ final class OrderCodeScannerViewController: UIViewController {
#endif
}
/// embed
private func embedScanner() {
scannerController.onScanResult = { [weak self] result in
self?.onScanResult?(result)
@ -77,6 +79,7 @@ final class OrderCodeScannerViewController: UIViewController {
}
}
/// UnavailablePlaceholder
private func showUnavailablePlaceholder() {
let stack = UIStackView()
stack.axis = .vertical
@ -109,6 +112,7 @@ final class OrderCodeScannerViewController: UIViewController {
}
}
/// close
@objc private func closeTapped() {
dismiss(animated: true)
}
@ -145,6 +149,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
return button
}()
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
@ -152,17 +157,20 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
checkPermissionAndStart()
}
/// frame UI
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.layer.bounds
}
///
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
updateTorch(enabled: false)
stopSession()
}
/// check
private func checkPermissionAndStart() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
@ -185,6 +193,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
}
}
/// Session
private func startSession() {
hasFinishedScan = false
guard !session.isRunning else { return }
@ -199,11 +208,13 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
}
}
/// Session
private func stopSession() {
guard session.isRunning else { return }
session.stopRunning()
}
/// ScannerButton
private static func makeScannerButton(title: String) -> UIButton {
var config = UIButton.Configuration.filled()
config.title = title
@ -213,6 +224,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
return UIButton(configuration: config)
}
/// Session
private func configureSession() throws {
if previewLayer != nil { return }
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
@ -242,6 +254,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
torchButton.isEnabled = videoDevice.hasTorch
}
/// metadataOutput
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard !hasFinishedScan else { return }
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
@ -253,6 +266,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
onScanResult?(.success(code))
}
/// Controls UI
private func setupControls() {
view.addSubview(scanFrameView)
view.addSubview(torchButton)
@ -273,10 +287,12 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
}
}
/// toggleTorch
@objc private func toggleTorch() {
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
}
/// Torch
private func updateTorch(enabled: Bool) {
guard let device = videoDevice, device.hasTorch else { return }
do {
@ -293,6 +309,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
}
}
/// restart
@objc private func restartScan() {
hasFinishedScan = false
updateTorch(enabled: false)

View File

@ -6,8 +6,45 @@
import SnapKit
import UIKit
// MARK: - Store Order Diffable
private typealias StoreOrderDetailSection = Int
private typealias StoreOrderDetailRow = String
/// section
private enum StoreOrderDetailSectionID {
static let context = 0
static let orderInfo = 1
static let payment = 2
static let customer = 3
static let shooting = 4
static let actions = 5
}
///
private enum StoreOrderDetailRowID {
static let context = "storeDetail:context"
static func orderInfo(_ index: Int) -> String { "storeDetail:orderInfo:\(index)" }
static func payment(_ index: Int) -> String { "storeDetail:payment:\(index)" }
static func customer(_ index: Int) -> String { "storeDetail:customer:\(index)" }
static func shooting(_ id: String) -> String { "storeDetail:shooting:\(id)" }
static func action(_ index: Int) -> String { "storeDetail:action:\(index)" }
}
// MARK: - Write-Off Diffable
///
private enum WriteOffDetailRowID {
static let orderNumber = "writeOff:orderNumber"
static let project = "writeOff:project"
static let phone = "writeOff:phone"
static let amount = "writeOff:amount"
static let status = "writeOff:status"
static let verificationTime = "writeOff:verificationTime"
}
///
final class StoreOrderDetailViewController: UIViewController {
final class StoreOrderDetailViewController: UIViewController, UITableViewDelegate {
private let item: OrderEntity
private let viewModel: OrderDetailViewModel
@ -15,12 +52,15 @@ final class StoreOrderDetailViewController: UIViewController {
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.delegate = self
table.backgroundColor = AppDesignUIKit.pageBackground
return table
}()
/// Diffable section
private var tableDataSource: UITableViewDiffableDataSource<StoreOrderDetailSection, StoreOrderDetailRow>!
///
init(item: OrderEntity) {
self.item = item
self.viewModel = OrderDetailViewModel(item: item)
@ -30,31 +70,176 @@ final class StoreOrderDetailViewController: UIViewController {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = "订单详情"
view.backgroundColor = AppDesignUIKit.pageBackground
configureTableDataSource()
view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
Task { await loadDetail() }
}
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<StoreOrderDetailSection, StoreOrderDetailRow>(
tableView: tableView
) { [weak self] (_: UITableView, indexPath: IndexPath, row: StoreOrderDetailRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
return self.configureDetailCell(row: row)
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<StoreOrderDetailSection, StoreOrderDetailRow> {
var snapshot = NSDiffableDataSourceSnapshot<StoreOrderDetailSection, StoreOrderDetailRow>()
if viewModel.contextMessage != nil {
snapshot.appendSections([StoreOrderDetailSectionID.context])
snapshot.appendItems([StoreOrderDetailRowID.context], toSection: StoreOrderDetailSectionID.context)
}
snapshot.appendSections([StoreOrderDetailSectionID.orderInfo])
snapshot.appendItems((0..<7).map { StoreOrderDetailRowID.orderInfo($0) }, toSection: StoreOrderDetailSectionID.orderInfo)
snapshot.appendSections([StoreOrderDetailSectionID.payment])
snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.payment($0) }, toSection: StoreOrderDetailSectionID.payment)
snapshot.appendSections([StoreOrderDetailSectionID.customer])
snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.customer($0) }, toSection: StoreOrderDetailSectionID.customer)
if !viewModel.shootingList.isEmpty {
snapshot.appendSections([StoreOrderDetailSectionID.shooting])
let shootingRows = viewModel.shootingList.enumerated().map { index, shooting in
StoreOrderDetailRowID.shooting("\(shooting.id)-\(index)")
}
snapshot.appendItems(shootingRows, toSection: StoreOrderDetailSectionID.shooting)
}
snapshot.appendSections([StoreOrderDetailSectionID.actions])
snapshot.appendItems((0..<5).map { StoreOrderDetailRowID.action($0) }, toSection: StoreOrderDetailSectionID.actions)
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// Cell
private func configureDetailCell(row: StoreOrderDetailRow) -> UITableViewCell {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none
cell.textLabel?.numberOfLines = 1
cell.textLabel?.textColor = nil
cell.accessoryType = .none
cell.isUserInteractionEnabled = true
let display = viewModel.display
if row == StoreOrderDetailRowID.context {
cell.textLabel?.text = viewModel.contextMessage
cell.textLabel?.numberOfLines = 0
return cell
}
if row.hasPrefix("storeDetail:orderInfo:") {
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"]
cell.textLabel?.text = rows[index]
switch index {
case 0: cell.detailTextLabel?.text = display.orderNumber
case 1: cell.detailTextLabel?.text = display.orderStatusName
case 2: cell.detailTextLabel?.text = display.orderTypeLabel
case 3: cell.detailTextLabel?.text = display.createdAt
case 4: cell.detailTextLabel?.text = display.payTime
case 5: cell.detailTextLabel?.text = display.completeTime
default:
cell.textLabel?.textColor = AppDesignUIKit.primary
cell.selectionStyle = .default
}
return cell
}
if row.hasPrefix("storeDetail:payment:") {
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"]
cell.textLabel?.text = rows[index]
switch index {
case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))"
case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))"
case 2: cell.detailTextLabel?.text = display.payTypeName
default: cell.detailTextLabel?.text = "\(display.userId)"
}
return cell
}
if row.hasPrefix("storeDetail:customer:") {
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let rows = ["手机号", "关联项目", "项目 ID", "备注"]
cell.textLabel?.text = rows[index]
switch index {
case 0: cell.detailTextLabel?.text = display.phone
case 1: cell.detailTextLabel?.text = display.projectName
case 2: cell.detailTextLabel?.text = "\(display.projectId)"
default: cell.detailTextLabel?.text = display.remark
}
return cell
}
if row.hasPrefix("storeDetail:shooting:") {
let suffix = row.replacingOccurrences(of: "storeDetail:shooting:", with: "")
let indexPart = suffix.split(separator: "-").last.flatMap { Int($0) } ?? 0
if indexPart < viewModel.shootingList.count {
let shooting = viewModel.shootingList[indexPart]
cell.textLabel?.text = shooting.scenicSpotName
cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName
}
return cell
}
if row.hasPrefix("storeDetail:action:") {
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"]
cell.textLabel?.text = actions[index]
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
if index == 4, !refundViewModel.canRefund(item) {
cell.isUserInteractionEnabled = false
cell.textLabel?.textColor = AppDesignUIKit.textSecondary
}
return cell
}
return cell
}
/// Detail
private func loadDetail() async {
await appServices.globalLoading.withLoading(message: "加载详情中...") {
await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id)
}
applyTableSnapshot(reconfigure: true)
if let error = viewModel.errorMessage {
showToast(error)
}
}
/// Number
private func copyOrderNumber() {
UIPasteboard.general.string = viewModel.display.orderNumber
showToast("订单号已复制")
}
/// Refund
private func presentRefund() {
refundViewModel.begin(item: item)
let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert)
@ -75,99 +260,34 @@ final class StoreOrderDetailViewController: UIViewController {
})
present(alert, animated: true)
}
}
extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int { 6 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: return viewModel.contextMessage == nil ? 0 : 1
case 1: return 7
case 2: return 4
case 3: return 4
case 4: return viewModel.shootingList.count
case 5: return 5
default: return 0
}
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch section {
case 1: "订单信息"
case 2: "支付信息"
case 3: "客户与项目"
case 4 where !viewModel.shootingList.isEmpty: "拍摄点"
case 5: "后续功能"
default: nil
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
switch sectionID {
case StoreOrderDetailSectionID.orderInfo: return "订单信息"
case StoreOrderDetailSectionID.payment: return "支付信息"
case StoreOrderDetailSectionID.customer: return "客户与项目"
case StoreOrderDetailSectionID.shooting: return "拍摄点"
case StoreOrderDetailSectionID.actions: return "后续功能"
default: return nil
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none
let display = viewModel.display
switch indexPath.section {
case 0:
cell.textLabel?.text = viewModel.contextMessage
cell.textLabel?.numberOfLines = 0
case 1:
let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"]
cell.textLabel?.text = rows[indexPath.row]
switch indexPath.row {
case 0: cell.detailTextLabel?.text = display.orderNumber
case 1: cell.detailTextLabel?.text = display.orderStatusName
case 2: cell.detailTextLabel?.text = display.orderTypeLabel
case 3: cell.detailTextLabel?.text = display.createdAt
case 4: cell.detailTextLabel?.text = displayPayTime
case 5: cell.detailTextLabel?.text = display.completeTime
default:
cell.textLabel?.textColor = AppDesignUIKit.primary
cell.selectionStyle = .default
}
case 2:
let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"]
cell.textLabel?.text = rows[indexPath.row]
switch indexPath.row {
case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))"
case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))"
case 2: cell.detailTextLabel?.text = display.payTypeName
default: cell.detailTextLabel?.text = "\(display.userId)"
}
case 3:
let rows = ["手机号", "关联项目", "项目 ID", "备注"]
cell.textLabel?.text = rows[indexPath.row]
switch indexPath.row {
case 0: cell.detailTextLabel?.text = display.phone
case 1: cell.detailTextLabel?.text = display.projectName
case 2: cell.detailTextLabel?.text = "\(display.projectId)"
default: cell.detailTextLabel?.text = display.remark
}
case 4:
let shooting = viewModel.shootingList[indexPath.row]
cell.textLabel?.text = shooting.scenicSpotName
cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName
case 5:
let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"]
cell.textLabel?.text = actions[indexPath.row]
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
if indexPath.row == 4, !refundViewModel.canRefund(item) {
cell.isUserInteractionEnabled = false
cell.textLabel?.textColor = AppDesignUIKit.textSecondary
}
default: break
}
return cell
}
/// UITableView
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return }
guard indexPath.section == 5 else { return }
guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
if row == StoreOrderDetailRowID.orderInfo(6) {
copyOrderNumber()
return
}
guard row.hasPrefix("storeDetail:action:") else { return }
let index = Int(row.split(separator: ":").last ?? "") ?? -1
let orderNumber = viewModel.display.orderNumber
switch indexPath.row {
switch index {
case 0:
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
case 1 where item.orderType == 19:
@ -178,7 +298,8 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
case 4:
presentRefund()
default: break
default:
break
}
}
@ -188,65 +309,79 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
return payTime
}
/// emptyZero
private func emptyToZero(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
}
}
/// section
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
///
final class WriteOffOrderDetailViewController: UIViewController {
final class WriteOffOrderDetailViewController: SimpleTableDiffableViewController {
private let item: WriteOffOrderItem
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.backgroundColor = AppDesignUIKit.pageBackground
return table
}()
///
init(item: WriteOffOrderItem) {
self.item = item
super.init(nibName: nil, bundle: nil)
super.init(style: .insetGrouped)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = "核销详情"
view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
tableView.backgroundColor = AppDesignUIKit.pageBackground
}
}
extension WriteOffOrderDetailViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 }
/// Diffable snapshot
override func buildSnapshot() -> NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow> {
var snapshot = NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow>()
snapshot.appendSections([0])
snapshot.appendItems([
WriteOffDetailRowID.orderNumber,
WriteOffDetailRowID.project,
WriteOffDetailRowID.phone,
WriteOffDetailRowID.amount,
WriteOffDetailRowID.status,
WriteOffDetailRowID.verificationTime
], toSection: 0)
return snapshot
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
/// Cell
override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {
cell.selectionStyle = .none
switch indexPath.row {
case 0:
switch row {
case WriteOffDetailRowID.orderNumber:
cell.textLabel?.text = "订单号"
cell.detailTextLabel?.text = item.orderNumber
case 1:
case WriteOffDetailRowID.project:
cell.textLabel?.text = "项目"
cell.detailTextLabel?.text = item.projectName
case 2:
case WriteOffDetailRowID.phone:
cell.textLabel?.text = "手机号"
cell.detailTextLabel?.text = item.userPhone
case 3:
case WriteOffDetailRowID.amount:
cell.textLabel?.text = "金额"
cell.detailTextLabel?.text = "¥\(item.orderAmount)"
case 4:
case WriteOffDetailRowID.status:
cell.textLabel?.text = "状态"
cell.detailTextLabel?.text = item.orderStatusName
default:
case WriteOffDetailRowID.verificationTime:
cell.textLabel?.text = "核销时间"
cell.detailTextLabel?.text = item.orderVerificationTime
default:
break
}
return cell
}
}

View File

@ -19,6 +19,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
private let localListLabel = UILabel()
private let submitButton = UIButton(type: .system)
///
init(initialOrderNumber: String) {
self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)
super.init(nibName: nil, bundle: nil)
@ -27,6 +28,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = "任务上传"
@ -36,12 +38,14 @@ final class MultiTravelTaskUploadViewController: UIViewController {
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
}
/// ViewModel
private func bindViewModel() {
viewModel.onChange = { [weak self] in
self?.applyViewModel()
}
}
/// Form UI
private func setupForm() {
orderField.borderStyle = .roundedRect
orderField.placeholder = "关联订单号"
@ -90,6 +94,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
applyViewModel()
}
/// labeled
private func labeledRow(_ title: String, _ content: UIView) -> UIStackView {
let label = UILabel()
label.text = title
@ -100,6 +105,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
return row
}
/// apply
private func applyViewModel() {
orderField.text = viewModel.orderNumber
spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal)
@ -123,14 +129,17 @@ final class MultiTravelTaskUploadViewController: UIViewController {
submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材"
}
/// order
@objc private func orderChanged() {
viewModel.orderNumber = orderField.text ?? ""
}
/// Spots
@objc private func refreshSpots() {
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
}
/// select
@objc private func selectSpot() {
guard !viewModel.spots.isEmpty else { return }
let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
@ -144,6 +153,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
present(sheet, animated: true)
}
/// pickFiles
@objc private func pickLocalFiles() {
var config = PHPickerConfiguration()
config.selectionLimit = 9
@ -153,6 +163,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
present(picker, animated: true)
}
/// Tapped
@objc private func submitTapped() {
Task {
let success = await viewModel.submit(
@ -172,6 +183,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
}
extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
/// picker
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
@ -199,19 +211,41 @@ extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
}
}
// MARK: - Historical Shooting Diffable
private typealias HistoricalShootingSection = Int
private typealias HistoricalShootingRow = String
/// section
private enum HistoricalShootingSectionID {
static let summary = 0
static let spots = 1
}
///
private enum HistoricalShootingRowID {
static let project = "historicalShooting:project"
static let projectType = "historicalShooting:projectType"
static let empty = "historicalShooting:empty"
static func spot(_ id: String) -> String { "historicalShooting:spot:\(id)" }
}
///
final class HistoricalShootingInfoViewController: UIViewController {
final class HistoricalShootingInfoViewController: UIViewController, UITableViewDelegate {
private let orderNumber: String
private let viewModel = HistoricalShootingInfoViewModel()
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.delegate = self
return table
}()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>!
///
init(orderNumber: String) {
self.orderNumber = orderNumber
super.init(nibName: nil, bundle: nil)
@ -220,49 +254,89 @@ final class HistoricalShootingInfoViewController: UIViewController {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = "历史拍摄"
configureTableDataSource()
view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
Task { await loadData() }
}
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>(
tableView: tableView
) { [weak self] (_: UITableView, _: IndexPath, row: HistoricalShootingRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none
switch row {
case HistoricalShootingRowID.project:
cell.textLabel?.text = "项目"
cell.detailTextLabel?.text = self.viewModel.projectName
case HistoricalShootingRowID.projectType:
cell.textLabel?.text = "项目类型"
cell.detailTextLabel?.text = self.viewModel.projectTypeName
case HistoricalShootingRowID.empty:
cell.textLabel?.text = self.viewModel.errorMessage ?? "暂无历史拍摄"
default:
if row.hasPrefix("historicalShooting:spot:") {
let id = row.replacingOccurrences(of: "historicalShooting:spot:", with: "")
if let spot = self.viewModel.spots.first(where: { $0.id == id }) {
cell.textLabel?.text = spot.scenicSpotName
cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)"
}
}
}
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<HistoricalShootingSection, HistoricalShootingRow> {
var snapshot = NSDiffableDataSourceSnapshot<HistoricalShootingSection, HistoricalShootingRow>()
snapshot.appendSections([HistoricalShootingSectionID.summary, HistoricalShootingSectionID.spots])
snapshot.appendItems([HistoricalShootingRowID.project, HistoricalShootingRowID.projectType], toSection: HistoricalShootingSectionID.summary)
if viewModel.spots.isEmpty {
snapshot.appendItems([HistoricalShootingRowID.empty], toSection: HistoricalShootingSectionID.spots)
} else {
snapshot.appendItems(viewModel.spots.map { HistoricalShootingRowID.spot($0.id) }, toSection: HistoricalShootingSectionID.spots)
}
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// Data
private func loadData() async {
await appServices.globalLoading.withLoading {
await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber)
}
applyTableSnapshot()
if let message = viewModel.errorMessage { showToast(message) }
}
}
extension HistoricalShootingInfoViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int { 2 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 2 : max(viewModel.spots.count, 1)
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 1 ? "拍摄点位" : nil
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none
if indexPath.section == 0 {
cell.textLabel?.text = indexPath.row == 0 ? "项目" : "项目类型"
cell.detailTextLabel?.text = indexPath.row == 0 ? viewModel.projectName : viewModel.projectTypeName
return cell
}
guard !viewModel.spots.isEmpty else {
cell.textLabel?.text = viewModel.errorMessage ?? "暂无历史拍摄"
return cell
}
let spot = viewModel.spots[indexPath.row]
cell.textLabel?.text = spot.scenicSpotName
cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)"
return cell
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
return sectionID == HistoricalShootingSectionID.spots ? "拍摄点位" : nil
}
}
/// section
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@ -6,6 +6,31 @@
import SnapKit
import UIKit
// MARK: - Diffable Int / String MainActor Hashable
/// section
private enum OrdersSectionID {
static let header = 0
static let toolbar = 1
static let content = 2
}
private typealias OrdersSection = Int
/// item
private enum OrdersItemID {
static let header = "orders:header"
static let filter = "orders:filter"
static let writeOffAction = "orders:writeOffAction"
static let missingContext = "orders:missingContext"
static let emptyStore = "orders:empty:store"
static let emptyWriteOff = "orders:empty:writeOff"
static func storeOrder(_ orderNumber: String) -> String { "orders:store:\(orderNumber)" }
static func writeOffOrder(_ orderNumber: String) -> String { "orders:writeoff:\(orderNumber)" }
}
private typealias OrdersItem = String
/// Tab
final class OrdersViewController: UIViewController {
@ -13,36 +38,43 @@ final class OrdersViewController: UIViewController {
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 collectionView: UICollectionView = {
let layout = makeCollectionLayout()
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
collection.backgroundColor = AppDesignUIKit.pageBackground
collection.delegate = self
collection.register(OrdersHeaderCell.self, forCellWithReuseIdentifier: OrdersHeaderCell.reuseID)
collection.register(OrdersFilterCell.self, forCellWithReuseIdentifier: OrdersFilterCell.reuseID)
collection.register(OrdersWriteOffActionCell.self, forCellWithReuseIdentifier: OrdersWriteOffActionCell.reuseID)
collection.register(OrderEntityCell.self, forCellWithReuseIdentifier: OrderEntityCell.reuseID)
collection.register(WriteOffOrderCell.self, forCellWithReuseIdentifier: WriteOffOrderCell.reuseID)
collection.register(OrdersEmptyStateCell.self, forCellWithReuseIdentifier: OrdersEmptyStateCell.reuseID)
return collection
}()
private lazy var dataSource: UICollectionViewDiffableDataSource<OrdersSection, OrdersItem> = {
UICollectionViewDiffableDataSource<OrdersSection, OrdersItem>(collectionView: collectionView) { [weak self] collectionView, indexPath, item in
self?.cell(for: collectionView, at: indexPath, item: item) ?? UICollectionViewCell()
}
}()
private lazy var refreshControl = UIRefreshControl()
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = "订单"
view.backgroundColor = AppDesignUIKit.pageBackground
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
tableView.refreshControl = refreshControl
collectionView.refreshControl = refreshControl
viewModel.onChange = { [weak self] in
self?.tableView.reloadData()
self?.applySnapshot()
}
appServices.appRouter.onChange = { [weak self] in
@ -64,6 +96,202 @@ final class OrdersViewController: UIViewController {
}
}
/// Compositional Layout section
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
guard let self else {
return CollectionDiffableLayout.fullWidthSection()
}
let section = self.dataSource.snapshot().sectionIdentifiers[sectionIndex]
switch section {
case OrdersSectionID.header:
return CollectionDiffableLayout.fullWidthSection(height: 120)
case OrdersSectionID.toolbar:
let height: CGFloat = self.viewModel.selectedEntry == .storeOrders ? 130 : 150
return CollectionDiffableLayout.fullWidthSection(height: height)
case OrdersSectionID.content:
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 88)
default:
return CollectionDiffableLayout.fullWidthSection()
}
}
}
/// ViewModel Diffable
private func applySnapshot(animatingDifferences: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<OrdersSection, OrdersItem>()
snapshot.appendSections([OrdersSectionID.header])
snapshot.appendItems([OrdersItemID.header], toSection: OrdersSectionID.header)
guard currentScenicId != nil else {
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
return
}
snapshot.appendSections([OrdersSectionID.toolbar, OrdersSectionID.content])
if viewModel.selectedEntry == .storeOrders {
snapshot.appendItems([OrdersItemID.filter], toSection: OrdersSectionID.toolbar)
appendStoreOrderItems(to: &snapshot)
} else {
snapshot.appendItems([OrdersItemID.writeOffAction], toSection: OrdersSectionID.toolbar)
appendWriteOffOrderItems(to: &snapshot)
}
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
/// item
private func appendStoreOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot<OrdersSection, OrdersItem>) {
if viewModel.storeOrders.isEmpty {
guard !(viewModel.loading && viewModel.storeOrders.isEmpty) else { return }
snapshot.appendItems([OrdersItemID.emptyStore], toSection: OrdersSectionID.content)
return
}
snapshot.appendItems(
viewModel.storeOrders.map { OrdersItemID.storeOrder($0.orderNumber) },
toSection: OrdersSectionID.content
)
}
/// item
private func appendWriteOffOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot<OrdersSection, OrdersItem>) {
if viewModel.writeOffOrders.isEmpty {
guard !(viewModel.loading && viewModel.writeOffOrders.isEmpty) else { return }
snapshot.appendItems([OrdersItemID.emptyWriteOff], toSection: OrdersSectionID.content)
return
}
snapshot.appendItems(
viewModel.writeOffOrders.map { OrdersItemID.writeOffOrder($0.orderNumber) },
toSection: OrdersSectionID.content
)
}
/// item dequeue Cell
private func cell(
for collectionView: UICollectionView,
at indexPath: IndexPath,
item: OrdersItem
) -> UICollectionViewCell {
switch item {
case OrdersItemID.header:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersHeaderCell.reuseID,
for: indexPath
) as! OrdersHeaderCell
cell.configure(
selectedEntry: viewModel.selectedEntry,
scenicName: appServices.accountContext.currentScenic?.name ?? "--",
storeTotal: viewModel.storeTotal,
writeOffTotal: viewModel.writeOffTotal,
onSelectEntry: { [weak self] entry in self?.switchEntry(entry) }
)
return cell
case OrdersItemID.filter:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: 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
case OrdersItemID.writeOffAction:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: 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
case OrdersItemID.missingContext:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersEmptyStateCell.reuseID,
for: indexPath
) as! OrdersEmptyStateCell
cell.embed(
makeEmptyStateView(
title: "缺少经营上下文",
message: "请先在首页选择景区后查看订单。",
systemImage: "mountain.2"
),
preferredHeight: 360
)
return cell
case OrdersItemID.emptyStore:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersEmptyStateCell.reuseID,
for: indexPath
) as! OrdersEmptyStateCell
cell.embed(
makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray"),
preferredHeight: 260
)
return cell
case OrdersItemID.emptyWriteOff:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersEmptyStateCell.reuseID,
for: indexPath
) as! OrdersEmptyStateCell
cell.embed(
makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray"),
preferredHeight: 260
)
return cell
default:
if item.hasPrefix("orders:store:") {
let orderNumber = String(item.dropFirst("orders:store:".count))
guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else {
return UICollectionViewCell()
}
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrderEntityCell.reuseID,
for: indexPath
) as! OrderEntityCell
cell.configure(item: order)
return cell
}
if item.hasPrefix("orders:writeoff:") {
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else {
return UICollectionViewCell()
}
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: WriteOffOrderCell.reuseID,
for: indexPath
) as! WriteOffOrderCell
cell.configure(
item: order,
isVerifying: viewModel.currentVerifyingOrderNumber == order.orderNumber
)
return cell
}
return UICollectionViewCell()
}
}
///
@objc private func refreshPulled() {
Task {
await reload(showLoading: false)
@ -75,6 +303,7 @@ final class OrdersViewController: UIViewController {
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: "加载订单...") {
@ -91,6 +320,7 @@ final class OrdersViewController: UIViewController {
}
}
///
private func loadMore() async {
do {
if viewModel.selectedEntry == .storeOrders {
@ -111,6 +341,7 @@ final class OrdersViewController: UIViewController {
}
}
/// /
private func switchEntry(_ entry: OrdersEntry) {
guard viewModel.selectedEntry != entry else { return }
viewModel.selectedEntry = entry
@ -118,6 +349,7 @@ final class OrdersViewController: UIViewController {
Task { await reload(showLoading: true) }
}
///
private func verify(orderNumber: String) async {
guard let scenicId = currentScenicId else { return }
do {
@ -135,12 +367,14 @@ final class OrdersViewController: UIViewController {
}
}
///
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("未识别到有效订单号")
@ -150,10 +384,11 @@ final class OrdersViewController: UIViewController {
confirmVerify(orderNumber: parsed.orderNumber)
} else {
scanHintMessage = "扫码成功,当前列表未找到该订单"
tableView.reloadData()
applySnapshot()
}
}
///
private func confirmVerify(orderNumber: String) {
let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@ -163,6 +398,7 @@ final class OrdersViewController: UIViewController {
present(alert, animated: true)
}
///
private func presentScanner() {
let scanner = OrderCodeScannerViewController()
scanner.onScanResult = { [weak self, weak scanner] result in
@ -180,6 +416,7 @@ final class OrdersViewController: UIViewController {
present(nav, animated: true)
}
///
private func presentDateFilter() {
let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in
@ -198,6 +435,7 @@ final class OrdersViewController: UIViewController {
present(alert, animated: true)
}
/// ActionSheet
private func presentStatusFilter() {
let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet)
for filter in OrderFilters.statusFilters {
@ -211,143 +449,51 @@ final class OrdersViewController: UIViewController {
}
}
extension OrdersViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
guard currentScenicId != nil else { return 1 }
return viewModel.selectedEntry == .storeOrders ? 3 : 3
}
// MARK: - UICollectionViewDelegate
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)
extension OrdersViewController: UICollectionViewDelegate {
///
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
if item.hasPrefix("orders:store:") {
let orderNumber = String(item.dropFirst("orders:store:".count))
guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
HomeMenuRouting.pushOrders(.storeDetail(order), from: self)
} else if item.hasPrefix("orders:writeoff:") {
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
HomeMenuRouting.pushOrders(.writeOffDetail(order), from: self)
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.section == 2 else { return }
///
func collectionView(
_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath
) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
let isLast: Bool
if viewModel.selectedEntry == .storeOrders {
isLast = indexPath.row == viewModel.storeOrders.count - 1
if item.hasPrefix("orders:store:") {
let orderNumber = String(item.dropFirst("orders:store:".count))
isLast = orderNumber == viewModel.storeOrders.last?.orderNumber
} else if item.hasPrefix("orders:writeoff:") {
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
isLast = orderNumber == viewModel.writeOffOrders.last?.orderNumber
} else {
isLast = indexPath.row == viewModel.writeOffOrders.count - 1
isLast = false
}
if isLast {
Task { await loadMore() }
}
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 {
/// Cell
private final class OrdersHeaderCell: UICollectionViewCell {
static let reuseID = "OrdersHeaderCell"
private var onSelectEntry: ((OrdersEntry) -> Void)?
@ -356,16 +502,16 @@ private final class OrdersHeaderCell: UITableViewCell {
private let leftPill = UILabel()
private let rightPill = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
/// Cell
override init(frame: CGRect) {
super.init(frame: frame)
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))
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
let segmentBackground = UIView()
@ -410,7 +556,14 @@ private final class OrdersHeaderCell: UITableViewCell {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) {
///
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)
@ -423,6 +576,7 @@ private final class OrdersHeaderCell: UITableViewCell {
}
}
///
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)
@ -431,26 +585,29 @@ private final class OrdersHeaderCell: UITableViewCell {
button.layer.cornerRadius = 6
}
///
@objc private func storeTapped() { onSelectEntry?(.storeOrders) }
///
@objc private func verifyTapped() { onSelectEntry?(.verificationOrders) }
}
private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
/// Cell /
private final class OrdersFilterCell: UICollectionViewCell, 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
/// Cell
override init(frame: CGRect) {
super.init(frame: frame)
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))
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
phoneField.delegate = self
phoneField.keyboardType = .phonePad
@ -470,32 +627,44 @@ private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
@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) {
///
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
}
/// ViewModel
func textFieldDidChangeSelection(_ textField: UITextField) {
onPhoneChange?(textField.text ?? "")
}
}
private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate {
/// Cell
private final class OrdersWriteOffActionCell: UICollectionViewCell, 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
/// Cell
override init(frame: CGRect) {
super.init(frame: frame)
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)) }
card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
manualField.delegate = self
manualField.placeholder = "手动输入订单号"
@ -516,33 +685,45 @@ private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelega
@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) {
///
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 {
/// Cell
private final class OrderEntityCell: UICollectionViewCell {
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
/// Cell
override init(frame: CGRect) {
super.init(frame: frame)
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)) }
card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
@ -563,6 +744,7 @@ private final class OrderEntityCell: UITableViewCell {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
///
func configure(item: OrderEntity) {
titleLabel.text = item.orderNumber
statusLabel.text = item.orderStatusName
@ -571,19 +753,23 @@ private final class OrderEntityCell: UITableViewCell {
}
}
private final class WriteOffOrderCell: UITableViewCell {
/// Cell
private final class WriteOffOrderCell: UICollectionViewCell {
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)
/// Cell
override init(frame: CGRect) {
super.init(frame: frame)
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)) }
card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
subtitleLabel.textColor = AppDesignUIKit.textSecondary
@ -597,8 +783,34 @@ private final class WriteOffOrderCell: UITableViewCell {
@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)
}
}
/// Cell
private final class OrdersEmptyStateCell: UICollectionViewCell {
static let reuseID = "OrdersEmptyStateCell"
private var heightConstraint: Constraint?
/// Cell
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
///
func embed(_ emptyView: UIView, preferredHeight: CGFloat) {
contentView.subviews.forEach { $0.removeFromSuperview() }
contentView.addSubview(emptyView)
emptyView.snp.makeConstraints { make in
make.edges.equalToSuperview()
heightConstraint = make.height.equalTo(preferredHeight).constraint
}
}
}

View File

@ -120,6 +120,7 @@ final class DepositOrderListViewModel {
errorMessage = nil
}
/// State
private func resetState() {
orders = []
total = 0
@ -170,6 +171,7 @@ final class DepositOrderDetailViewModel {
errorMessage = nil
}
///
private func reset(message: String) {
detail = nil
loading = false
@ -220,6 +222,7 @@ final class DepositOrderShootingInfoViewModel {
errorMessage = nil
}
///
private func reset(message: String) {
detail = nil
loading = false
@ -324,10 +327,12 @@ final class OrderRefundViewModel {
return moneyText(value)
}
/// decimal
private static func decimalValue(_ text: String) -> Decimal {
Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0
}
/// money
private static func moneyText(_ value: Decimal) -> String {
let number = NSDecimalNumber(decimal: value)
let handler = NSDecimalNumberHandler(
@ -379,6 +384,7 @@ final class HistoricalShootingInfoViewModel {
errorMessage = nil
}
///
private func reset(message: String) {
projectName = ""
projectTypeName = ""
@ -520,6 +526,7 @@ final class MultiTravelTaskUploadViewModel {
errorMessage = nil
}
/// BeforeSubmit
private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? {
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
guard let scenicId, scenicId > 0 else {
@ -549,6 +556,7 @@ final class MultiTravelTaskUploadViewModel {
return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId)
}
/// uploadFiles
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] {
var uploadFiles: [MultiTravelUploadFileItem] = []
for index in selectedLocalFiles.indices {
@ -583,11 +591,13 @@ final class MultiTravelTaskUploadViewModel {
return uploadFiles
}
/// LocalFileProgress
private func updateLocalFileProgress(id: UUID, progress: Int) {
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
selectedLocalFiles[index].progress = progress
}
/// SpotSelection
private func resetSpotSelection() {
spots = []
selectedSpotId = nil
@ -595,11 +605,13 @@ final class MultiTravelTaskUploadViewModel {
isLoadingSpots = false
}
/// fileType
private static func fileType(for fileName: String) -> Int {
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
}
/// Submit
private struct SubmitContext {
let scenicId: Int
let orderNumber: String