// // WiredCameraTransferViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit private enum WiredTransferMetrics { static let sidebarWidth: CGFloat = 76 static let rowHeight: CGFloat = 66 static let sectionHeaderHeight: CGFloat = 28 static let selectAllBarHeight: CGFloat = 44 } private struct WiredTransferListSection: Hashable { let id: String let title: String init(_ section: TravelAlbumOTGPhotoSection) { id = section.slotId title = section.headerTitle } } /// 有线相机传输页,对齐 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 photoPanelView = UIView() private let selectAllBar = UIControl() private let selectAllIconView = UIImageView() private let selectAllTitleLabel = UILabel() private let selectAllCountLabel = UILabel() private let photoContentView = UIView() private let sidebarView = WiredTransferTimeSidebarView() private let sidebarDivider = UIView() private let listContainerView = UIView() private let expandSidebarButton = UIButton(type: .system) private let emptyLabel = UILabel() private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var currentSections: [WiredTransferListSection] = [] private var pendingScrollSlotId: String? private var selectAllBarHeightConstraint: Constraint? 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 photoPanelView.backgroundColor = .white selectAllBar.backgroundColor = .white selectAllIconView.contentMode = .scaleAspectFit selectAllTitleLabel.text = "全选" selectAllTitleLabel.font = .systemFont(ofSize: 13) selectAllTitleLabel.textColor = AppColor.textPrimary selectAllCountLabel.font = .systemFont(ofSize: 12) selectAllCountLabel.textColor = AppColor.textSecondary selectAllCountLabel.textAlignment = .right sidebarDivider.backgroundColor = AppColor.border listContainerView.backgroundColor = .white expandSidebarButton.setImage(UIImage(systemName: "chevron.right"), for: .normal) expandSidebarButton.tintColor = AppColor.textSecondary expandSidebarButton.backgroundColor = UIColor(hex: 0xF5F6F8) expandSidebarButton.layer.cornerRadius = 8 expandSidebarButton.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = .white collectionView.delegate = self collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier) collectionView.register( WiredTransferSectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: WiredTransferSectionHeaderView.reuseIdentifier ) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, item in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier, for: indexPath ) as! WiredTransferPhotoCell self?.configurePhotoCell(cell, item: item) return cell } dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in guard kind == UICollectionView.elementKindSectionHeader else { return nil } let header = collectionView.dequeueReusableSupplementaryView( ofKind: kind, withReuseIdentifier: WiredTransferSectionHeaderView.reuseIdentifier, for: indexPath ) as! WiredTransferSectionHeaderView header.apply(title: self?.currentSections[safe: indexPath.section]?.title ?? "") return header } 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(photoPanelView) photoPanelView.addSubview(selectAllBar) selectAllBar.addSubview(selectAllIconView) selectAllBar.addSubview(selectAllTitleLabel) selectAllBar.addSubview(selectAllCountLabel) photoPanelView.addSubview(photoContentView) photoContentView.addSubview(sidebarView) photoContentView.addSubview(sidebarDivider) photoContentView.addSubview(listContainerView) listContainerView.addSubview(collectionView) listContainerView.addSubview(expandSidebarButton) 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) } photoPanelView.snp.makeConstraints { make in make.top.equalTo(statusCard.snp.bottom).offset(14) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } selectAllBar.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() selectAllBarHeightConstraint = make.height.equalTo(WiredTransferMetrics.selectAllBarHeight).constraint } selectAllIconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) make.centerY.equalToSuperview() make.size.equalTo(20) } selectAllTitleLabel.snp.makeConstraints { make in make.leading.equalTo(selectAllIconView.snp.trailing).offset(8) make.centerY.equalToSuperview() } selectAllCountLabel.snp.makeConstraints { make in make.leading.greaterThanOrEqualTo(selectAllTitleLabel.snp.trailing).offset(8) make.trailing.equalToSuperview().offset(-10) make.centerY.equalToSuperview() } photoContentView.snp.makeConstraints { make in make.top.equalTo(selectAllBar.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } expandSidebarButton.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() make.width.equalTo(24) make.height.equalTo(48) } emptyLabel.snp.makeConstraints { make in make.top.equalTo(statusCard.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } updateSidebarLayout() } 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) selectAllBar.addTarget(self, action: #selector(selectAllTapped), for: .touchUpInside) expandSidebarButton.addTarget(self, action: #selector(toggleSidebarTapped), for: .touchUpInside) sidebarView.onToggleSidebar = { [weak self] in self?.viewModel.toggleSidebar() } sidebarView.onTimeSlotSelected = { [weak self] slotId in self?.pendingScrollSlotId = slotId self?.viewModel.selectTimeSlot(id: slotId) } 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() let sections = viewModel.photoSections currentSections = sections.map(WiredTransferListSection.init) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections(currentSections) for (index, section) in sections.enumerated() { snapshot.appendItems(section.photos, toSection: currentSections[index]) } dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in self?.reconfigureVisiblePhotoCells() self?.scrollToPendingTimeSlotIfNeeded() } sidebarView.apply( groups: viewModel.sidebarGroups, selectedSlotId: viewModel.selectedTimeSlotId ) updateSelectAllBar() updateSidebarLayout() emptyLabel.isHidden = !sections.isEmpty photoPanelView.isHidden = sections.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 updateSelectAllBar() { let showSelectAll = viewModel.selectUploadMode && !viewModel.photoSections.isEmpty selectAllBar.isHidden = !showSelectAll selectAllBarHeightConstraint?.update(offset: showSelectAll ? WiredTransferMetrics.selectAllBarHeight : 0) let allSelected = viewModel.allVisibleSelectablePhotosSelected selectAllIconView.image = UIImage(systemName: allSelected ? "checkmark.circle.fill" : "circle") selectAllIconView.tintColor = allSelected ? AppColor.primary : AppColor.textTertiary selectAllCountLabel.text = "已选 \(viewModel.selectedVisibleUploadCount) / \(viewModel.selectableVisiblePhotoIds.count)" selectAllBar.isEnabled = !viewModel.selectableVisiblePhotoIds.isEmpty selectAllBar.alpha = selectAllBar.isEnabled ? 1 : 0.55 } private func updateSidebarLayout() { let showSidebar = viewModel.sidebarExpanded && !viewModel.sidebarGroups.isEmpty sidebarView.isHidden = !showSidebar sidebarDivider.isHidden = !showSidebar expandSidebarButton.isHidden = viewModel.sidebarExpanded || viewModel.sidebarGroups.isEmpty sidebarView.snp.remakeConstraints { make in make.top.leading.bottom.equalToSuperview() make.width.equalTo(showSidebar ? WiredTransferMetrics.sidebarWidth : 0) } sidebarDivider.snp.remakeConstraints { make in make.top.bottom.equalToSuperview() make.leading.equalTo(sidebarView.snp.trailing) make.width.equalTo(showSidebar ? 1 : 0) } listContainerView.snp.remakeConstraints { make in make.top.bottom.trailing.equalToSuperview() make.leading.equalTo(sidebarDivider.snp.trailing) } } private func scrollToPendingTimeSlotIfNeeded() { guard let slotId = pendingScrollSlotId, let sectionIndex = currentSections.firstIndex(where: { $0.id == slotId }), collectionView.numberOfItems(inSection: sectionIndex) > 0 else { pendingScrollSlotId = nil return } collectionView.scrollToItem( at: IndexPath(item: 0, section: sectionIndex), at: .top, animated: true ) pendingScrollSlotId = nil } private func configurePhotoCell(_ cell: WiredTransferPhotoCell, item: TravelAlbumOTGPhotoItem) { let selected = viewModel.selectedPhotoIds.contains(item.id) cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected) cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) } cell.onDelete = { [weak self] in self?.confirmDeletePhoto(item.id) } } private func reconfigureVisiblePhotoCells() { for indexPath in collectionView.indexPathsForVisibleItems { guard let cell = collectionView.cellForItem(at: indexPath) as? WiredTransferPhotoCell, let item = dataSource.itemIdentifier(for: indexPath) else { continue } configurePhotoCell(cell, item: item) } } 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 { _, _ in let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .estimated(WiredTransferMetrics.rowHeight) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .estimated(WiredTransferMetrics.rowHeight) ) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) let headerSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .absolute(WiredTransferMetrics.sectionHeaderHeight) ) let header = NSCollectionLayoutBoundarySupplementaryItem( layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top ) header.pinToVisibleBounds = true section.boundarySupplementaryItems = [header] 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 selectAllTapped() { viewModel.toggleVisibleSelectablePhotos() } @objc private func toggleSidebarTapped() { viewModel.toggleSidebar() } @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) } } } /// 有线传输照片列表时间段 Header。 private final class WiredTransferSectionHeaderView: UICollectionReusableView { static let reuseIdentifier = "WiredTransferSectionHeaderView" private let titleLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = AppColor.pageBackground titleLabel.font = .systemFont(ofSize: 11) titleLabel.textColor = AppColor.textSecondary addSubview(titleLabel) titleLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(10) make.centerY.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(title: String) { titleLabel.text = title } } /// 有线传输照片列表 Cell。 private final class WiredTransferPhotoCell: UICollectionViewCell { static let reuseIdentifier = "WiredTransferPhotoCell" private static let previewImageSize = CGSize(width: 96, height: 96) private let selectionIconView = UIImageView() private let imageView = UIImageView() private let statusLabel = UILabel() private let titleLabel = UILabel() private let sizeLabel = UILabel() private let progressView = UIProgressView(progressViewStyle: .default) private let menuButton = UIButton(type: .system) private let separatorView = UIView() 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 menuButton.menu = nil } func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) { let canSelect = item.canSelectForUpload let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1 if let url = item.thumbnailURL { let processor = DownsamplingImageProcessor(size: Self.previewImageSize) imageView.kf.setImage( with: url, placeholder: UIImage(systemName: "photo"), options: [ .processor(processor), .scaleFactor(UIScreen.main.scale), .backgroundDecode ] ) } else { imageView.image = UIImage(systemName: "photo") imageView.tintColor = AppColor.textTertiary } imageView.alpha = rowAlpha titleLabel.text = item.fileName titleLabel.alpha = rowAlpha sizeLabel.text = item.fileSizeText sizeLabel.alpha = rowAlpha statusLabel.text = statusText(item.status) statusLabel.textColor = statusTextColor(item.status) statusLabel.backgroundColor = statusBackgroundColor(item.status) progressView.isHidden = item.status != .uploading && item.status != .transferring progressView.progress = Float(item.progress) / 100.0 selectionIconView.isHidden = !selectionMode selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5) menuButton.isHidden = selectionMode menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending) updateImageConstraints(selectionMode: selectionMode) } private func setupUI() { contentView.backgroundColor = .white selectionIconView.contentMode = .scaleAspectFit imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.cornerRadius = 6 statusLabel.font = .systemFont(ofSize: 9) statusLabel.textAlignment = .center statusLabel.layer.cornerRadius = 3 statusLabel.clipsToBounds = true titleLabel.font = .systemFont(ofSize: 12, weight: .medium) titleLabel.textColor = AppColor.textPrimary titleLabel.lineBreakMode = .byTruncatingMiddle sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.textColor = AppColor.textTertiary progressView.progressTintColor = AppColor.primary progressView.trackTintColor = AppColor.border menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal) menuButton.tintColor = AppColor.textSecondary menuButton.showsMenuAsPrimaryAction = true separatorView.backgroundColor = AppColor.border contentView.addSubview(selectionIconView) contentView.addSubview(imageView) contentView.addSubview(titleLabel) contentView.addSubview(statusLabel) contentView.addSubview(sizeLabel) contentView.addSubview(progressView) contentView.addSubview(menuButton) contentView.addSubview(separatorView) selectionIconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) make.centerY.equalToSuperview() make.size.equalTo(20) } updateImageConstraints(selectionMode: true) titleLabel.snp.makeConstraints { make in make.top.equalTo(imageView).offset(1) make.leading.equalTo(imageView.snp.trailing).offset(8) make.trailing.equalTo(statusLabel.snp.leading).offset(-4) } statusLabel.snp.makeConstraints { make in make.centerY.equalTo(titleLabel) make.trailing.equalTo(menuButton.snp.leading).offset(-4) make.height.equalTo(17) make.width.greaterThanOrEqualTo(44) } sizeLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(5) make.leading.equalTo(titleLabel) make.trailing.equalToSuperview().offset(-10) } progressView.snp.makeConstraints { make in make.leading.equalTo(titleLabel) make.trailing.equalToSuperview().offset(-10) make.top.equalTo(sizeLabel.snp.bottom).offset(7) make.height.equalTo(2) } menuButton.snp.makeConstraints { make in make.centerY.equalTo(titleLabel) make.trailing.equalToSuperview().offset(-4) make.size.equalTo(32) } separatorView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(10) make.bottom.equalToSuperview() make.height.equalTo(1 / UIScreen.main.scale) } } private func makeMenu(canRetry: Bool) -> UIMenu { let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in self?.onRetry?() } retry.attributes = canRetry ? [] : [.disabled] let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in self?.onDelete?() } return UIMenu(children: [retry, delete]) } private func updateImageConstraints(selectionMode: Bool) { imageView.snp.remakeConstraints { make in if selectionMode { make.leading.equalTo(selectionIconView.snp.trailing).offset(8) } else { make.leading.equalToSuperview().offset(10) } make.top.equalToSuperview().offset(8) make.size.equalTo(48) make.bottom.lessThanOrEqualToSuperview().offset(-8) } } private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String { switch status { case .pending: return "待上传" case .transferring: return "传输中" case .uploading: return "上传中" case .uploaded: return "已上传" case .failed: return "上传失败" } } private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { switch status { case .pending: return AppColor.textSecondary case .transferring, .uploading: return AppColor.primary case .uploaded: return UIColor(hex: 0x16A34A) case .failed: return AppColor.danger } } private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { switch status { case .pending: return AppColor.pageBackground case .transferring, .uploading: return AppColor.primary.withAlphaComponent(0.12) case .uploaded: return UIColor(hex: 0x16A34A).withAlphaComponent(0.12) case .failed: return AppColor.danger.withAlphaComponent(0.12) } } } /// 有线传输页左侧时间快速导航。 private final class WiredTransferTimeSidebarView: UIView { var onToggleSidebar: (() -> Void)? var onTimeSlotSelected: ((String) -> Void)? private let headerButton = UIButton(type: .system) private let scrollView = UIScrollView() private let stackView = UIStackView() override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(groups: [TravelAlbumOTGDateGroup], selectedSlotId: String?) { stackView.arrangedSubviews.forEach { view in stackView.removeArrangedSubview(view) view.removeFromSuperview() } for group in groups { let dateContainer = UIView() let dateLabel = UILabel() dateLabel.text = group.dateLabel dateLabel.font = .systemFont(ofSize: 12) dateLabel.textColor = AppColor.textTertiary dateContainer.addSubview(dateLabel) stackView.addArrangedSubview(dateContainer) dateContainer.snp.makeConstraints { make in make.height.equalTo(24) } dateLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) make.trailing.equalToSuperview() make.bottom.equalToSuperview().offset(-4) } for slot in group.slots { let row = TimeSlotRowView() row.apply(slot: slot, selected: slot.id == selectedSlotId) row.onTap = { [weak self] in self?.onTimeSlotSelected?(slot.id) } stackView.addArrangedSubview(row) row.snp.makeConstraints { make in make.height.equalTo(52) } } } } private func setupUI() { backgroundColor = UIColor(hex: 0xF5F6F8) headerButton.setTitle("收起", for: .normal) headerButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) headerButton.tintColor = AppColor.textSecondary headerButton.setTitleColor(AppColor.textSecondary, for: .normal) headerButton.titleLabel?.font = .systemFont(ofSize: 11) headerButton.semanticContentAttribute = .forceLeftToRight headerButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside) scrollView.showsVerticalScrollIndicator = false stackView.axis = .vertical addSubview(headerButton) addSubview(scrollView) scrollView.addSubview(stackView) headerButton.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(36) } scrollView.snp.makeConstraints { make in make.top.equalTo(headerButton.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } stackView.snp.makeConstraints { make in make.edges.equalToSuperview() make.width.equalToSuperview() } } @objc private func toggleTapped() { onToggleSidebar?() } } private final class TimeSlotRowView: UIControl { private let timeLabel = UILabel() private let countLabel = UILabel() private let arrowView = UIImageView(image: UIImage(systemName: "play.fill")) var onTap: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(slot: TravelAlbumOTGTimeSlot, selected: Bool) { backgroundColor = selected ? .white : .clear timeLabel.text = slot.slotStartLabel timeLabel.font = .systemFont(ofSize: 13, weight: selected ? .semibold : .regular) timeLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary arrowView.tintColor = selected ? AppColor.primary : AppColor.textPrimary countLabel.text = "\(slot.photoCount)张" } private func setupUI() { arrowView.contentMode = .scaleAspectFit timeLabel.textAlignment = .center countLabel.font = .systemFont(ofSize: 10) countLabel.textColor = AppColor.textTertiary countLabel.textAlignment = .center addTarget(self, action: #selector(rowTapped), for: .touchUpInside) addSubview(arrowView) addSubview(timeLabel) addSubview(countLabel) arrowView.snp.makeConstraints { make in make.centerY.equalTo(timeLabel) make.trailing.equalTo(timeLabel.snp.leading).offset(-2) make.size.equalTo(10) } timeLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(8) make.centerX.equalToSuperview().offset(6) } countLabel.snp.makeConstraints { make in make.top.equalTo(timeLabel.snp.bottom).offset(2) make.centerX.equalToSuperview() } } @objc private func rowTapped() { onTap?() } } private extension Array { subscript(safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } }