// // 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! /// 初始化实例。 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( 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 { var snapshot = NSDiffableDataSourceSnapshot() 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 } }