// // WiredCameraTransferViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// 有线相机传输页,对齐 Android OTG 页面并接入真实 ImageCaptureCore 传输。 final class WiredCameraTransferViewController: BaseViewController { private let viewModel: WiredCameraTransferViewModel private let headerView = UIView() private let backButton = UIButton(type: .system) private let titleLabel = UILabel() private let phoneLabel = UILabel() private let statusCard = UIView() private let storagePanel = UIView() private let storageTitleLabel = UILabel() private let storageValueLabel = UILabel() private let statusLabel = UILabel() private let refreshButton = UIButton(type: .system) private let helpLabel = UILabel() private let chipsStack = UIStackView() private let retouchButton = UIButton(type: .system) private let formatButton = UIButton(type: .system) private let modeButton = UIButton(type: .system) private let statsCard = UIStackView() private let emptyLabel = UILabel() private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private let bottomBar = UIView() private let batchButton = UIButton(type: .system) private let historyImportButton = UIButton(type: .system) private let specifyButton = UIButton(type: .system) init(viewModel: WiredCameraTransferViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { navigationController?.setNavigationBarHidden(true, animated: false) } override func viewDidLoad() { super.viewDidLoad() applyViewModel() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) viewModel.start() navigationController?.setNavigationBarHidden(true, animated: animated) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) viewModel.stop() navigationController?.setNavigationBarHidden(false, animated: animated) } override func setupUI() { view.backgroundColor = AppColor.pageBackground headerView.backgroundColor = AppColor.primary backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) backButton.tintColor = .white titleLabel.text = viewModel.albumTitle titleLabel.font = .systemFont(ofSize: 20, weight: .bold) titleLabel.textColor = .white phoneLabel.text = viewModel.headerPhone phoneLabel.font = .systemFont(ofSize: 13) phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92) statusCard.backgroundColor = .white statusCard.layer.cornerRadius = 12 statusCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.10).cgColor statusCard.layer.shadowOpacity = 1 statusCard.layer.shadowRadius = 8 statusCard.layer.shadowOffset = CGSize(width: 0, height: 3) storagePanel.layer.cornerRadius = 12 storagePanel.layer.borderColor = AppColor.border.cgColor storagePanel.layer.borderWidth = 3 storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium) storageTitleLabel.textColor = AppColor.textPrimary storageTitleLabel.textAlignment = .center storageValueLabel.font = .systemFont(ofSize: 10) storageValueLabel.textColor = AppColor.primary storageValueLabel.textAlignment = .center statusLabel.font = .systemFont(ofSize: 11, weight: .medium) statusLabel.layer.cornerRadius = 4 statusLabel.clipsToBounds = true statusLabel.textAlignment = .center refreshButton.setTitleColor(.white, for: .normal) refreshButton.titleLabel?.font = .systemFont(ofSize: 12) refreshButton.backgroundColor = AppColor.primary refreshButton.layer.cornerRadius = 8 helpLabel.font = .systemFont(ofSize: 10) helpLabel.numberOfLines = 3 helpLabel.isUserInteractionEnabled = true chipsStack.axis = .horizontal chipsStack.spacing = 6 [retouchButton, formatButton, modeButton].forEach { configureChipButton($0) chipsStack.addArrangedSubview($0) } statsCard.axis = .horizontal statsCard.distribution = .fillEqually collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = AppColor.pageBackground collectionView.delegate = self collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, item in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier, for: indexPath ) as! WiredTransferPhotoCell let selected = self?.viewModel.selectedPhotoIds.contains(item.id) == true cell.apply(item: item, selectionMode: self?.viewModel.selectUploadMode == true, selected: selected) cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) } cell.onDelete = { [weak self] in self?.confirmDeletePhoto(item.id) } return cell } emptyLabel.text = "暂无照片" emptyLabel.font = .systemFont(ofSize: 14) emptyLabel.textColor = AppColor.textTertiary emptyLabel.textAlignment = .center bottomBar.backgroundColor = .white configureBottomButton(batchButton, title: "批量上传", filled: true) configureBottomButton(historyImportButton, title: "历史导入", filled: false) configureBottomButton(specifyButton, title: "指定上传", filled: false) view.addSubview(headerView) headerView.addSubview(backButton) headerView.addSubview(titleLabel) headerView.addSubview(phoneLabel) view.addSubview(statusCard) statusCard.addSubview(storagePanel) storagePanel.addSubview(storageTitleLabel) storagePanel.addSubview(storageValueLabel) statusCard.addSubview(statusLabel) statusCard.addSubview(refreshButton) statusCard.addSubview(helpLabel) statusCard.addSubview(chipsStack) statusCard.addSubview(statsCard) view.addSubview(collectionView) view.addSubview(emptyLabel) view.addSubview(bottomBar) bottomBar.addSubview(batchButton) bottomBar.addSubview(historyImportButton) bottomBar.addSubview(specifyButton) } override func setupConstraints() { headerView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(118) } backButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(8) make.bottom.equalToSuperview().offset(-28) make.size.equalTo(44) } titleLabel.snp.makeConstraints { make in make.leading.equalTo(backButton.snp.trailing).offset(4) make.trailing.equalToSuperview().offset(-16) make.top.equalTo(backButton).offset(4) } phoneLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(2) make.leading.trailing.equalTo(titleLabel) } statusCard.snp.makeConstraints { make in make.top.equalTo(headerView.snp.bottom).offset(-10) make.leading.trailing.equalToSuperview().inset(16) } storagePanel.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(14) make.width.greaterThanOrEqualTo(76) make.height.equalTo(76) } storageTitleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(10) make.leading.trailing.equalToSuperview().inset(8) } storageValueLabel.snp.makeConstraints { make in make.top.equalTo(storageTitleLabel.snp.bottom).offset(6) make.leading.trailing.equalTo(storageTitleLabel) } statusLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.equalTo(storagePanel.snp.trailing).offset(10) make.height.equalTo(22) make.width.greaterThanOrEqualTo(84) } refreshButton.snp.makeConstraints { make in make.centerY.equalTo(statusLabel) make.trailing.equalToSuperview().offset(-14) make.height.equalTo(34) make.width.greaterThanOrEqualTo(72) } helpLabel.snp.makeConstraints { make in make.top.equalTo(statusLabel.snp.bottom).offset(4) make.leading.equalTo(statusLabel) make.trailing.equalTo(refreshButton) } chipsStack.snp.makeConstraints { make in make.top.equalTo(storagePanel.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(14) make.height.equalTo(34) } statsCard.snp.makeConstraints { make in make.top.equalTo(chipsStack.snp.bottom).offset(14) make.leading.trailing.bottom.equalToSuperview() make.height.equalTo(68) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } batchButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(12) make.leading.equalToSuperview().offset(16) make.height.equalTo(44) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } historyImportButton.snp.makeConstraints { make in make.top.height.width.equalTo(batchButton) make.leading.equalTo(batchButton.snp.trailing).offset(8) } specifyButton.snp.makeConstraints { make in make.top.height.width.equalTo(batchButton) make.leading.equalTo(historyImportButton.snp.trailing).offset(8) make.trailing.equalToSuperview().offset(-16) } collectionView.snp.makeConstraints { make in make.top.equalTo(statusCard.snp.bottom).offset(14) make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(bottomBar.snp.top).offset(-10) } emptyLabel.snp.makeConstraints { make in make.top.equalTo(statusCard.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } viewModel.onLiveShotSaved = { [weak self] filename in Task { @MainActor in self?.showToast("已保存 \(filename)") } } backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) specifyButton.addTarget(self, action: #selector(specifyTapped), for: .touchUpInside) formatButton.menu = UIMenu(children: TravelAlbumOTGPhotoFormatOption.allCases.map { option in UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) } }) formatButton.showsMenuAsPrimaryAction = true modeButton.menu = UIMenu(children: ["边拍边传", "拍后传输"].map { option in UIAction(title: option) { [weak self] _ in self?.viewModel.selectTransferMode(option) } }) modeButton.showsMenuAsPrimaryAction = true helpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(helpTapped))) } @MainActor private func applyViewModel() { storageTitleLabel.text = viewModel.deviceModelName storageValueLabel.text = viewModel.availableStorageText statusLabel.text = viewModel.cameraStatusText let isFailed = viewModel.cameraStatusText.contains("失败") || viewModel.cameraStatusText.contains("未连接") statusLabel.textColor = isFailed ? AppColor.danger : AppColor.primary statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10) refreshButton.setTitle(viewModel.actionButtonText, for: .normal) retouchButton.setTitle(viewModel.retouchOption, for: .normal) formatButton.setTitle(viewModel.photoFormatOption.rawValue, for: .normal) modeButton.setTitle(viewModel.transferModeOption, for: .normal) helpLabel.attributedText = helpText(viewModel.sonyMTPHint) rebuildStats() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(viewModel.filteredPhotos()) dataSource.apply(snapshot, animatingDifferences: true) emptyLabel.isHidden = !viewModel.filteredPhotos().isEmpty collectionView.isHidden = viewModel.filteredPhotos().isEmpty if viewModel.selectUploadMode { let count = viewModel.selectedPhotoIds.count batchButton.setTitle(count > 0 ? "上传选中(\(count))" : "取消", for: .normal) } else { batchButton.setTitle("批量上传", for: .normal) } historyImportButton.setTitle("历史导入", for: .normal) } private func rebuildStats() { statsCard.arrangedSubviews.forEach { view in statsCard.removeArrangedSubview(view) view.removeFromSuperview() } for tab in TravelAlbumOTGTransferTab.allCases { let selected = viewModel.selectedTab == tab let button = makeStatButton( title: tab.title, count: viewModel.tabCounts[tab.rawValue], selected: selected, danger: tab == .failed ) button.tag = tab.rawValue button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) statsCard.addArrangedSubview(button) } } private func configureChipButton(_ button: UIButton) { button.titleLabel?.font = .systemFont(ofSize: 11) button.setTitleColor(AppColor.textTertiary, for: .normal) button.backgroundColor = AppColor.pageBackground button.layer.cornerRadius = 8 button.layer.borderWidth = 1 button.layer.borderColor = AppColor.border.cgColor button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) } private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton { var config = UIButton.Configuration.plain() config.title = "\(count)\n\(title)" config.titleAlignment = .center config.baseForegroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary) config.background.backgroundColor = .white let button = UIButton(configuration: config) button.titleLabel?.numberOfLines = 2 button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .semibold : .regular) return button } private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) { button.setTitle(title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 15) button.layer.cornerRadius = 10 if filled { button.backgroundColor = AppColor.primary button.setTitleColor(.white, for: .normal) } else { button.backgroundColor = .white button.setTitleColor(AppColor.primary, for: .normal) button.layer.borderWidth = 1 button.layer.borderColor = AppColor.primary.cgColor } } private func makeLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { _, environment in let spacing: CGFloat = 8 let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3 let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 58)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 58)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3) group.interItemSpacing = .fixed(spacing) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = 12 return section } } private func helpText(_ sonyHint: String?) -> NSAttributedString { let text = NSMutableAttributedString( string: sonyHint ?? "未连接,查看", attributes: [.foregroundColor: AppColor.textSecondary] ) if sonyHint == nil { text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary])) } return text } private func confirmDeletePhoto(_ photoId: String) { let alert = UIAlertController(title: "删除照片", message: "仅删除本地 OTG 缓存,不会删除服务端已上传素材。", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in self?.viewModel.deletePhoto(photoId: photoId) }) present(alert, animated: true) } @objc private func backTapped() { navigationController?.popViewController(animated: true) } @objc private func refreshTapped() { viewModel.refreshCameraFiles() } @objc private func batchTapped() { viewModel.onBatchUploadButtonClick() } @objc private func historyImportTapped() { guard let importViewModel = viewModel.makeCameraImportViewModel() else { return } viewModel.pauseLiveTransferForHistoricalImport() let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel) importController.onImportFinished = { [weak self] importedCount, failedCount in guard let self else { return } self.viewModel.reloadLocalPhotos() self.viewModel.start() if failedCount > 0 { self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败") } else { self.showToast("已导入 \(importedCount) 张照片") } } importController.onImportCancelled = { [weak self] in self?.viewModel.start() } let navigationController = UINavigationController(rootViewController: importController) navigationController.modalPresentationStyle = .pageSheet present(navigationController, animated: true) } @objc private func specifyTapped() { viewModel.onSpecifyUploadButtonClick() } @objc private func tabTapped(_ sender: UIButton) { guard let tab = TravelAlbumOTGTransferTab(rawValue: sender.tag) else { return } viewModel.selectTab(tab) } @objc private func helpTapped() { guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return } UIApplication.shared.open(url) } } extension WiredCameraTransferViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } if viewModel.selectUploadMode { viewModel.toggleTransferPhotoSelection(photoId: item.id) } else if item.status == .failed { viewModel.retryPhoto(photoId: item.id) } } } /// 有线传输照片宫格 Cell。 private final class WiredTransferPhotoCell: UICollectionViewCell { static let reuseIdentifier = "WiredTransferPhotoCell" private let imageView = UIImageView() private let statusLabel = UILabel() private let titleLabel = UILabel() private let sizeLabel = UILabel() private let progressView = UIProgressView(progressViewStyle: .default) private let retryButton = UIButton(type: .system) private let deleteButton = UIButton(type: .system) private let selectionOverlay = UIImageView() var onRetry: (() -> Void)? var onDelete: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() imageView.kf.cancelDownloadTask() imageView.image = nil onRetry = nil onDelete = nil } func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) { if let url = item.thumbnailURL { imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) } else { imageView.image = UIImage(systemName: "photo") imageView.tintColor = AppColor.textTertiary } titleLabel.text = item.fileName sizeLabel.text = item.fileSizeText statusLabel.text = statusText(item.status, progress: item.progress) statusLabel.backgroundColor = statusColor(item.status).withAlphaComponent(0.90) progressView.isHidden = item.status != .uploading && item.status != .transferring progressView.progress = Float(item.progress) / 100.0 retryButton.isHidden = item.status != .failed deleteButton.isHidden = selectionMode selectionOverlay.isHidden = !selectionMode selectionOverlay.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") selectionOverlay.tintColor = selected ? AppColor.primary : .white } private func setupUI() { contentView.backgroundColor = .white contentView.layer.cornerRadius = 8 contentView.clipsToBounds = true imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true statusLabel.font = .systemFont(ofSize: 10, weight: .medium) statusLabel.textColor = .white statusLabel.textAlignment = .center statusLabel.layer.cornerRadius = 4 statusLabel.clipsToBounds = true titleLabel.font = .systemFont(ofSize: 11, weight: .medium) titleLabel.textColor = AppColor.textPrimary titleLabel.lineBreakMode = .byTruncatingMiddle sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.textColor = AppColor.textTertiary retryButton.setImage(UIImage(systemName: "arrow.clockwise"), for: .normal) retryButton.tintColor = AppColor.danger deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) deleteButton.tintColor = AppColor.textTertiary selectionOverlay.contentMode = .scaleAspectFit contentView.addSubview(imageView) imageView.addSubview(statusLabel) imageView.addSubview(selectionOverlay) contentView.addSubview(titleLabel) contentView.addSubview(sizeLabel) contentView.addSubview(progressView) contentView.addSubview(retryButton) contentView.addSubview(deleteButton) imageView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(contentView.snp.width) } statusLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(5) make.height.equalTo(18) make.width.greaterThanOrEqualTo(44) } selectionOverlay.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(6) make.size.equalTo(22) } titleLabel.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(5) make.leading.equalToSuperview().offset(6) make.trailing.equalTo(deleteButton.snp.leading).offset(-4) } sizeLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(2) make.leading.trailing.equalTo(titleLabel) } progressView.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview().inset(6) } retryButton.snp.makeConstraints { make in make.centerY.equalTo(titleLabel) make.trailing.equalToSuperview().offset(-2) make.size.equalTo(26) } deleteButton.snp.makeConstraints { make in make.centerY.equalTo(titleLabel) make.trailing.equalToSuperview().offset(-2) make.size.equalTo(26) } retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } private func statusText(_ status: TravelAlbumOTGUploadStatus, progress: Int) -> String { switch status { case .pending: return "待上传" case .transferring: return "传输\(progress)%" case .uploading: return "上传\(progress)%" case .uploaded: return "已上传" case .failed: return "失败" } } private func statusColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { switch status { case .pending: return AppColor.textTertiary case .transferring, .uploading: return AppColor.primary case .uploaded: return UIColor(hex: 0x16A34A) case .failed: return AppColor.danger } } @objc private func retryTapped() { onRetry?() } @objc private func deleteTapped() { onDelete?() } }