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>
343 lines
14 KiB
Swift
343 lines
14 KiB
Swift
//
|
||
// OrderTailUploadViewControllers.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import PhotosUI
|
||
import SnapKit
|
||
import UIKit
|
||
import UniformTypeIdentifiers
|
||
|
||
/// 多点旅拍任务上传页,负责选择打卡点和素材后提交到订单接口。
|
||
final class MultiTravelTaskUploadViewController: UIViewController {
|
||
|
||
private let viewModel: MultiTravelTaskUploadViewModel
|
||
|
||
private let orderField = UITextField()
|
||
private let spotButton = UIButton(type: .system)
|
||
private let cloudListLabel = UILabel()
|
||
private let localListLabel = UILabel()
|
||
private let submitButton = UIButton(type: .system)
|
||
|
||
/// 初始化实例。
|
||
init(initialOrderNumber: String) {
|
||
self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError() }
|
||
|
||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
title = "任务上传"
|
||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||
setupForm()
|
||
bindViewModel()
|
||
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 = "关联订单号"
|
||
orderField.autocorrectionType = .no
|
||
orderField.autocapitalizationType = .none
|
||
orderField.text = viewModel.orderNumber
|
||
orderField.addTarget(self, action: #selector(orderChanged), for: .editingChanged)
|
||
|
||
spotButton.setTitle("选择打卡点", for: .normal)
|
||
spotButton.addTarget(self, action: #selector(selectSpot), for: .touchUpInside)
|
||
|
||
cloudListLabel.numberOfLines = 0
|
||
cloudListLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||
localListLabel.numberOfLines = 0
|
||
localListLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||
|
||
submitButton.configuration = .filled()
|
||
submitButton.configuration?.title = "保存任务素材"
|
||
submitButton.configuration?.baseBackgroundColor = AppDesignUIKit.primary
|
||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||
|
||
let refreshButton = UIButton(type: .system)
|
||
refreshButton.setTitle("刷新打卡点", for: .normal)
|
||
refreshButton.addTarget(self, action: #selector(refreshSpots), for: .touchUpInside)
|
||
|
||
let pickLocalButton = UIButton(type: .system)
|
||
pickLocalButton.setTitle("选择图片/视频", for: .normal)
|
||
pickLocalButton.addTarget(self, action: #selector(pickLocalFiles), for: .touchUpInside)
|
||
|
||
let stack = UIStackView(arrangedSubviews: [
|
||
labeledRow("订单号", orderField),
|
||
refreshButton,
|
||
spotButton,
|
||
labeledRow("云盘素材", cloudListLabel),
|
||
pickLocalButton,
|
||
labeledRow("本地素材", localListLabel),
|
||
submitButton
|
||
])
|
||
stack.axis = .vertical
|
||
stack.spacing = 12
|
||
view.addSubview(stack)
|
||
stack.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
}
|
||
applyViewModel()
|
||
}
|
||
|
||
/// labeled行相关逻辑。
|
||
private func labeledRow(_ title: String, _ content: UIView) -> UIStackView {
|
||
let label = UILabel()
|
||
label.text = title
|
||
label.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
|
||
let row = UIStackView(arrangedSubviews: [label, content])
|
||
row.axis = .vertical
|
||
row.spacing = 8
|
||
return row
|
||
}
|
||
|
||
/// apply视图模型相关逻辑。
|
||
private func applyViewModel() {
|
||
orderField.text = viewModel.orderNumber
|
||
spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal)
|
||
spotButton.isEnabled = !viewModel.isLoadingSpots
|
||
|
||
if viewModel.selectedCloudFiles.isEmpty {
|
||
cloudListLabel.text = "未选择云盘文件"
|
||
} else {
|
||
cloudListLabel.text = viewModel.selectedCloudFiles.map(\.fileName).joined(separator: "\n")
|
||
}
|
||
|
||
if viewModel.selectedLocalFiles.isEmpty {
|
||
localListLabel.text = "未选择本地文件"
|
||
} else {
|
||
localListLabel.text = viewModel.selectedLocalFiles.map { file in
|
||
"\(file.fileName) \(file.statusText)"
|
||
}.joined(separator: "\n")
|
||
}
|
||
|
||
submitButton.isEnabled = viewModel.canSubmit
|
||
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)
|
||
for spot in viewModel.spots {
|
||
let title = spot.name.isEmpty ? "打卡点 \(spot.id)" : spot.name
|
||
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||
self?.viewModel.selectSpot(id: spot.id)
|
||
})
|
||
}
|
||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
present(sheet, animated: true)
|
||
}
|
||
|
||
/// pick本地Files相关逻辑。
|
||
@objc private func pickLocalFiles() {
|
||
var config = PHPickerConfiguration()
|
||
config.selectionLimit = 9
|
||
config.filter = .any(of: [.images, .videos])
|
||
let picker = PHPickerViewController(configuration: config)
|
||
picker.delegate = self
|
||
present(picker, animated: true)
|
||
}
|
||
|
||
/// 提交Tapped。
|
||
@objc private func submitTapped() {
|
||
Task {
|
||
let success = await viewModel.submit(
|
||
api: appServices.ordersAPI,
|
||
uploadService: appServices.ossUploadService,
|
||
scenicId: appServices.accountContext.currentScenic?.id
|
||
)
|
||
if success {
|
||
showToast("提交成功")
|
||
navigationController?.popViewController(animated: true)
|
||
} else if let message = viewModel.errorMessage {
|
||
showToast(message)
|
||
}
|
||
applyViewModel()
|
||
}
|
||
}
|
||
}
|
||
|
||
extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
|
||
/// picker 业务逻辑。
|
||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||
picker.dismiss(animated: true)
|
||
guard !results.isEmpty else { return }
|
||
for result in results {
|
||
let provider = result.itemProvider
|
||
if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in
|
||
guard let self, let url else { return }
|
||
let data = (try? Data(contentsOf: url)) ?? Data()
|
||
DispatchQueue.main.async {
|
||
self.viewModel.addLocalFile(data: data, fileName: url.lastPathComponent)
|
||
self.applyViewModel()
|
||
}
|
||
}
|
||
} else if provider.canLoadObject(ofClass: UIImage.self) {
|
||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||
DispatchQueue.main.async {
|
||
self.viewModel.addLocalFile(data: data, fileName: "upload_\(Int(Date().timeIntervalSince1970)).jpg")
|
||
self.applyViewModel()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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, UITableViewDelegate {
|
||
|
||
private let orderNumber: String
|
||
private let viewModel = HistoricalShootingInfoViewModel()
|
||
|
||
private lazy var tableView: UITableView = {
|
||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||
table.delegate = self
|
||
return table
|
||
}()
|
||
|
||
/// Diffable 数据源,驱动历史拍摄摘要与点位列表刷新。
|
||
private var tableDataSource: UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>!
|
||
|
||
/// 初始化实例。
|
||
init(orderNumber: String) {
|
||
self.orderNumber = orderNumber
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@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?.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) }
|
||
}
|
||
|
||
/// UITableView 代理:section 标题。
|
||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||
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
|
||
}
|
||
}
|