// // TravelAlbumDetailViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// 相册管理页,展示相册摘要、素材筛选排序、分页与批量操作。 final class TravelAlbumDetailViewController: BaseViewController { private let viewModel: TravelAlbumDetailViewModel private let api: any TravelAlbumServing private let previewConfiguration: TravelAlbumPreviewConfiguration private let scenicIdProvider: () -> Int private let contentView = UIView() private let infoCard = TravelAlbumInfoCard() private let sectionTitleLabel = UILabel() private let segmentedControl = UIView() private let allTabButton = UIButton(type: .system) private let purchasedTabButton = UIButton(type: .system) private let sortButton = UIButton(type: .system) private let selectButton = UIButton(type: .system) private let refreshControl = UIRefreshControl() private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private let bottomBar = UIView() private let bottomDivider = UIView() private let selectionActionStack = UIStackView() private let aiRetouchButton = UIButton(type: .system) private let deleteSelectedButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system) private var refreshState = TravelAlbumReturnRefreshState() init( albumId: Int, api: (any TravelAlbumServing)? = nil, previewConfiguration: TravelAlbumPreviewConfiguration = .init(), scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId } ) { viewModel = TravelAlbumDetailViewModel(albumId: albumId) self.api = api ?? NetworkServices.shared.travelAlbumAPI self.previewConfiguration = previewConfiguration self.scenicIdProvider = scenicIdProvider super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { let titleLabel = UILabel() titleLabel.text = "相册管理" titleLabel.textColor = TravelAlbumDetailStyle.textPrimary titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) navigationItem.titleView = titleLabel let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() appearance.backgroundColor = .white appearance.shadowColor = .clear navigationItem.standardAppearance = appearance navigationItem.scrollEdgeAppearance = appearance navigationItem.compactAppearance = appearance let moreItem = UIBarButtonItem( image: UIImage(systemName: "ellipsis"), menu: UIMenu(children: [ UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in self?.confirmDeleteAlbum() }, ]) ) moreItem.accessibilityLabel = "更多操作" let taskItem = UIBarButtonItem( title: "修图任务", style: .plain, target: self, action: #selector(openAIJobList) ) taskItem.accessibilityLabel = "查看AI修图任务" navigationItem.rightBarButtonItems = [moreItem, taskItem] } @objc private func openAIJobList() { navigationController?.pushViewController(TravelAlbumAIJobListViewController(api: api), animated: true) } override func setupUI() { view.backgroundColor = TravelAlbumDetailStyle.pageBackground sectionTitleLabel.text = "照片" sectionTitleLabel.textColor = TravelAlbumDetailStyle.textPrimary sectionTitleLabel.font = .systemFont(ofSize: 20, weight: .semibold) segmentedControl.backgroundColor = .white segmentedControl.layer.cornerRadius = 9 segmentedControl.layer.borderWidth = 1 segmentedControl.layer.borderColor = TravelAlbumDetailStyle.border.cgColor segmentedControl.clipsToBounds = true configureTabButtonBase(allTabButton) configureTabButtonBase(purchasedTabButton) configureSortButton() configureSelectButton() collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = .clear collectionView.alwaysBounceVertical = true collectionView.showsVerticalScrollIndicator = false collectionView.delegate = self refreshControl.tintColor = TravelAlbumDetailStyle.primary refreshControl.accessibilityIdentifier = "travelAlbum.refreshControl" collectionView.refreshControl = refreshControl collectionView.register( TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier ) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, material in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier, for: indexPath ) as! TravelAlbumMaterialCell cell.apply( material: material, selectionMode: self?.viewModel.isSelectionMode == true, selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true ) return cell } bottomBar.backgroundColor = .white bottomDivider.backgroundColor = TravelAlbumDetailStyle.border.withAlphaComponent(0.65) configureBottomButton(uploadButton, title: "上传照片", color: TravelAlbumDetailStyle.primary) var uploadConfiguration = uploadButton.configuration uploadConfiguration?.image = UIImage(systemName: "plus.circle.fill") uploadConfiguration?.imagePadding = 8 uploadButton.configuration = uploadConfiguration uploadButton.accessibilityLabel = "上传照片" selectionActionStack.axis = .horizontal selectionActionStack.alignment = .fill selectionActionStack.distribution = .fillEqually selectionActionStack.spacing = 12 selectionActionStack.isHidden = true selectionActionStack.accessibilityIdentifier = "travelAlbum.selectionActionStack" configureBottomButton(aiRetouchButton, title: "AI修图", color: TravelAlbumDetailStyle.primary) aiRetouchButton.accessibilityLabel = "AI修图" aiRetouchButton.accessibilityIdentifier = "travelAlbum.aiRetouchButton" configureBottomButton(deleteSelectedButton, title: "删除", color: TravelAlbumDetailStyle.danger) deleteSelectedButton.accessibilityLabel = "删除" deleteSelectedButton.accessibilityIdentifier = "travelAlbum.deleteButton" selectionActionStack.addArrangedSubview(aiRetouchButton) selectionActionStack.addArrangedSubview(deleteSelectedButton) view.addSubview(contentView) contentView.addSubview(infoCard) contentView.addSubview(sectionTitleLabel) contentView.addSubview(segmentedControl) segmentedControl.addSubview(allTabButton) segmentedControl.addSubview(purchasedTabButton) contentView.addSubview(sortButton) contentView.addSubview(selectButton) contentView.addSubview(collectionView) view.addSubview(bottomBar) bottomBar.addSubview(bottomDivider) bottomBar.addSubview(uploadButton) bottomBar.addSubview(selectionActionStack) } override func setupConstraints() { bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } bottomDivider.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(0.5) } uploadButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(12) make.leading.trailing.equalToSuperview().inset(18) make.height.equalTo(48) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } selectionActionStack.snp.makeConstraints { make in make.edges.equalTo(uploadButton) } contentView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.leading.trailing.equalToSuperview().inset(18) make.bottom.equalTo(bottomBar.snp.top) } infoCard.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(116) } sectionTitleLabel.snp.makeConstraints { make in make.top.equalTo(infoCard.snp.bottom).offset(20) make.leading.equalToSuperview() } segmentedControl.snp.makeConstraints { make in make.top.equalTo(sectionTitleLabel.snp.bottom).offset(14) make.leading.equalToSuperview() make.width.equalTo(182) make.height.equalTo(32) } allTabButton.snp.makeConstraints { make in make.top.bottom.leading.equalToSuperview() make.width.equalToSuperview().multipliedBy(0.5) } purchasedTabButton.snp.makeConstraints { make in make.top.bottom.trailing.equalToSuperview() make.leading.equalTo(allTabButton.snp.trailing) } selectButton.snp.makeConstraints { make in make.centerY.equalTo(segmentedControl) make.trailing.equalToSuperview() make.width.equalTo(48) make.height.equalTo(32) } sortButton.snp.makeConstraints { make in make.centerY.equalTo(segmentedControl) make.trailing.equalTo(selectButton.snp.leading).offset(-8) make.width.equalTo(74) make.height.equalTo(32) make.leading.greaterThanOrEqualTo(segmentedControl.snp.trailing).offset(8) } collectionView.snp.makeConstraints { make in make.top.equalTo(segmentedControl.snp.bottom).offset(14) make.leading.trailing.bottom.equalToSuperview() } } override func bindActions() { infoCard.onCall = { [weak self] phone in self?.call(phone) } allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside) purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside) selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside) aiRetouchButton.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside) deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside) uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } viewModel.onDeletedAlbum = { [weak self] _ in Task { @MainActor in guard let self else { return } TravelAlbumOTGPhotoStore().clearAlbum(albumId: self.viewModel.albumId) self.navigationController?.popViewController(animated: true) } } } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.refreshAll(api: api) } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) guard refreshState.beginRefreshIfNeeded() else { return } Task { await viewModel.refreshAll(api: api) await MainActor.run { self.applyViewModel() self.refreshState.finishRefresh() } } } @MainActor private func applyViewModel() { if let album = viewModel.album { infoCard.apply(album: album, coverURL: viewModel.displayCoverURL) } configureTabButton(allTabButton, title: "全部 \(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all) configureTabButton( purchasedTabButton, title: "已购 \(viewModel.purchasedPhotoCount)", selected: viewModel.selectedTab == .purchased ) var sortConfiguration = sortButton.configuration sortConfiguration?.title = viewModel.sortOption.compactTitle sortButton.configuration = sortConfiguration sortButton.accessibilityValue = viewModel.sortOption.title sortButton.menu = makeSortMenu() selectButton.isHidden = false selectButton.isEnabled = true selectButton.alpha = 1 selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal) selectButton.accessibilityValue = viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭" let selectedCount = viewModel.selectedMaterialIds.count let hasSelection = selectedCount > 0 aiRetouchButton.isEnabled = hasSelection aiRetouchButton.alpha = hasSelection ? 1 : 0.45 aiRetouchButton.accessibilityValue = "已选择 \(selectedCount) 张照片" deleteSelectedButton.isEnabled = selectedCount > 0 deleteSelectedButton.alpha = hasSelection ? 1 : 0.45 deleteSelectedButton.accessibilityValue = "已选择 \(selectedCount) 张照片" selectionActionStack.isHidden = !viewModel.isSelectionMode uploadButton.isHidden = viewModel.isSelectionMode var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(viewModel.materials) if !snapshot.itemIdentifiers.isEmpty { snapshot.reconfigureItems(snapshot.itemIdentifiers) } dataSource.apply( snapshot, animatingDifferences: !refreshState.suppressesSnapshotAnimations ) if !viewModel.isRefreshing { refreshControl.endRefreshing() } if viewModel.isLoading && viewModel.album == nil && !refreshControl.isRefreshing { showLoading() } else { hideLoading() } } @objc private func refreshPulled() { Task { await viewModel.refreshAll(api: api) } } private func configureTabButtonBase(_ button: UIButton) { button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) button.accessibilityTraits.insert(.button) } private func configureTabButton(_ button: UIButton, title: String, selected: Bool) { button.setTitle(title, for: .normal) button.backgroundColor = selected ? TravelAlbumDetailStyle.primary : .white button.setTitleColor(selected ? .white : TravelAlbumDetailStyle.textSecondary, for: .normal) button.accessibilityTraits = selected ? [.button, .selected] : [.button] } private func configureSortButton() { var configuration = UIButton.Configuration.plain() configuration.image = UIImage(named: "travel_album_sort_icon") configuration.imagePadding = 4 configuration.title = viewModel.sortOption.compactTitle configuration.baseForegroundColor = TravelAlbumDetailStyle.textPrimary configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 7, bottom: 0, trailing: 7) configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in var attributes = attributes attributes.font = .systemFont(ofSize: 11, weight: .medium) return attributes } sortButton.configuration = configuration sortButton.backgroundColor = .white sortButton.layer.cornerRadius = 9 sortButton.layer.borderWidth = 1 sortButton.layer.borderColor = TravelAlbumDetailStyle.border.cgColor sortButton.showsMenuAsPrimaryAction = true sortButton.accessibilityLabel = "排序" } private func configureSelectButton() { selectButton.setTitle("选择", for: .normal) selectButton.setTitleColor(TravelAlbumDetailStyle.primary, for: .normal) selectButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) selectButton.backgroundColor = .white selectButton.layer.cornerRadius = 9 selectButton.layer.borderWidth = 1 selectButton.layer.borderColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.45).cgColor selectButton.accessibilityLabel = "选择照片" } private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) { var configuration = UIButton.Configuration.filled() configuration.title = title configuration.baseBackgroundColor = color configuration.baseForegroundColor = .white configuration.cornerStyle = .fixed configuration.background.cornerRadius = 14 configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in var attributes = attributes attributes.font = .systemFont(ofSize: 16, weight: .semibold) return attributes } button.configuration = configuration } private func makeSortMenu() -> UIMenu { let actions = TravelAlbumDetailViewModel.SortOption.allCases.map { option in UIAction( title: option.title, state: option == viewModel.sortOption ? .on : .off ) { [weak self] _ in guard let self else { return } Task { await self.viewModel.setSortOption(option, api: self.api) } } } return UIMenu(title: "排序方式", options: .singleSelection, children: actions) } private func makeLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { _, environment in let spacing: CGFloat = 10 let width = floor((environment.container.effectiveContentSize.width - spacing * 2) / 3) let itemSize = NSCollectionLayoutSize( widthDimension: .absolute(width), heightDimension: .absolute(width + 40) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 40) ) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3) group.interItemSpacing = .fixed(spacing) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = 14 return section } } private func call(_ phone: String) { guard !phone.isEmpty, let url = URL(string: "tel:\(phone)") else { return } UIApplication.shared.open(url) } private func confirmDeleteAlbum() { let alert = UIAlertController(title: "删除相册", message: "确定删除该相册吗?已有购买素材的相册无法删除。", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in guard let self else { return } Task { await self.viewModel.deleteAlbum(api: self.api) } }) present(alert, animated: true) } @objc private func allTabTapped() { Task { await viewModel.selectTab(.all, api: api) } } @objc private func purchasedTabTapped() { Task { await viewModel.selectTab(.purchased, api: api) } } @objc private func selectTapped() { viewModel.toggleSelectionMode() } @objc private func aiRetouchTapped() { let materialIds = Array(viewModel.selectedMaterialIds) guard !materialIds.isEmpty else { return } let scenicId = scenicIdProvider() guard scenicId > 0 else { showToast("请先选择景区") return } let controller = TravelAlbumAIRetouchTemplateViewController( viewModel: TravelAlbumAIRetouchTemplateViewModel( albumId: viewModel.albumId, scenicId: scenicId, materialIds: materialIds ), api: api, onSubmitted: { [weak self] _ in self?.handleAIRetouchSubmitted() } ) present(controller, animated: true) } private func handleAIRetouchSubmitted() { showToast("AI修图任务已提交,完成后将通过消息通知") Task { await viewModel.refreshAfterAIRetouchSubmission(api: api) } } @objc private func deleteSelectedTapped() { let count = viewModel.selectedMaterialIds.count guard count > 0 else { return } let alert = UIAlertController( title: "删除素材", message: viewModel.deleteSelectedConfirmationMessage, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in guard let self else { return } Task { await self.viewModel.deleteSelectedMaterials(api: self.api) } }) present(alert, animated: true) } @objc private func uploadTapped() { guard presentedViewController == nil else { return } let selector = TravelAlbumTransferModeSheetViewController() selector.onModeSelected = { [weak self] mode in self?.openWiredTransfer(mode: mode) } present(selector, animated: true) } private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) { let album = viewModel.album refreshState.markRefreshNeeded() let controller = WiredCameraTransferViewController( viewModel: WiredCameraTransferViewModel( albumId: album?.id ?? viewModel.albumId, albumTitle: album?.name ?? "", headerPhone: album?.displayPhone ?? "", initialTransferMode: mode, initialAutoRetouchConfiguration: album?.autoRetouchConfiguration ?? .disabled, api: api ) ) navigationController?.pushViewController(controller, animated: true) } private func presentPreview(startingWith material: TravelAlbumMaterial) { guard let startIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) else { return } let previewViewModel = viewModel let previewAPI = api let controller = TravelAlbumPhotoPreviewViewController( projects: viewModel.materials.map(TravelAlbumPreviewProject.init(material:)), totalCount: viewModel.currentPhotoCount, startProjectIndex: startIndex, configuration: previewConfiguration, actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI), albumId: viewModel.albumId, scenicIdProvider: scenicIdProvider, aiRetouchAPI: previewAPI, loadMore: { await previewViewModel.loadMaterials(reset: false, api: previewAPI) return ( previewViewModel.materials.map(TravelAlbumPreviewProject.init(material:)), previewViewModel.currentPhotoCount ) }, reload: { materialId in let material = try await previewViewModel.refreshMaterial(id: materialId, api: previewAPI) return TravelAlbumPreviewProject(material: material) }, onProjectDeleted: { materialId in previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId) }, onAIRetouchSubmitted: { [weak self] in self?.handleAIRetouchSubmitted() } ) present(controller, animated: true) } } extension TravelAlbumDetailViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let material = dataSource.itemIdentifier(for: indexPath) else { return } if viewModel.isSelectionMode { viewModel.toggleMaterialSelection(material) } else { presentPreview(startingWith: material) } } func scrollViewDidScroll(_ scrollView: UIScrollView) { let visible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? 0 Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: visible, api: api) } } } /// 相册管理页专用视觉常量,避免污染全局主题。 private enum TravelAlbumDetailStyle { static let primary = UIColor(hex: 0x1677FF) static let pageBackground = UIColor(hex: 0xF8FAFD) static let textPrimary = UIColor(hex: 0x172033) static let textSecondary = UIColor(hex: 0x7F8A9E) static let border = UIColor(hex: 0xDCE4EF) static let danger = UIColor(hex: 0xE53935) static func badgeColor(for kind: TravelAlbumMaterialBadgeKind) -> UIColor { switch kind { case .purchased: UIColor(hex: 0x475569) case .pending: UIColor(hex: 0xB45309) case .processing: UIColor(hex: 0x1D4ED8) case .retouched: UIColor(hex: 0x047857) case .cover: UIColor(hex: 0x6D28D9) case .failed: UIColor(hex: 0xB91C1C) } } } /// 旅拍相册摘要卡,展示封面、名称、用户手机号与创建时间。 private final class TravelAlbumInfoCard: UIView { var onCall: ((String) -> Void)? private var phone = "" private let backgroundImageView = UIImageView(image: UIImage(named: "travel_album_header_background")) private let coverImageView = UIImageView() private let coverPhotoIconView = UIImageView(image: UIImage(named: "travel_album_cover_photo_icon")) private let nameLabel = UILabel() private let phoneLabel = UILabel() private let timeLabel = UILabel() private let callButton = UIButton(type: .system) override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = true layer.cornerRadius = 16 backgroundImageView.contentMode = .scaleAspectFill coverImageView.contentMode = .scaleAspectFill coverImageView.clipsToBounds = true coverImageView.layer.cornerRadius = 12 coverImageView.backgroundColor = UIColor(hex: 0xDCEEFF) coverPhotoIconView.contentMode = .scaleAspectFit coverPhotoIconView.layer.shadowColor = UIColor.black.withAlphaComponent(0.3).cgColor coverPhotoIconView.layer.shadowOpacity = 1 coverPhotoIconView.layer.shadowRadius = 2 coverPhotoIconView.layer.shadowOffset = .zero nameLabel.font = .systemFont(ofSize: 16, weight: .semibold) nameLabel.textColor = TravelAlbumDetailStyle.textPrimary nameLabel.lineBreakMode = .byTruncatingTail phoneLabel.font = .systemFont(ofSize: 13, weight: .medium) phoneLabel.textColor = TravelAlbumDetailStyle.textPrimary timeLabel.font = .systemFont(ofSize: 11) timeLabel.textColor = TravelAlbumDetailStyle.textSecondary timeLabel.adjustsFontSizeToFitWidth = true timeLabel.minimumScaleFactor = 0.85 callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) callButton.tintColor = TravelAlbumDetailStyle.primary callButton.backgroundColor = .white.withAlphaComponent(0.9) callButton.layer.cornerRadius = 22 callButton.layer.shadowColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.18).cgColor callButton.layer.shadowOpacity = 1 callButton.layer.shadowRadius = 6 callButton.layer.shadowOffset = CGSize(width: 0, height: 2) callButton.accessibilityLabel = "拨打相册用户电话" addSubview(backgroundImageView) addSubview(coverImageView) coverImageView.addSubview(coverPhotoIconView) addSubview(nameLabel) addSubview(phoneLabel) addSubview(timeLabel) addSubview(callButton) backgroundImageView.snp.makeConstraints { $0.edges.equalToSuperview() } coverImageView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.top.bottom.equalToSuperview().inset(20) make.width.equalTo(68) } coverPhotoIconView.snp.makeConstraints { make in make.trailing.bottom.equalToSuperview().inset(5) make.size.equalTo(22) } callButton.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-16) make.centerY.equalToSuperview() make.size.equalTo(44) } nameLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(21) make.leading.equalTo(coverImageView.snp.trailing).offset(12) make.trailing.equalTo(callButton.snp.leading).offset(-10) } phoneLabel.snp.makeConstraints { make in make.top.equalTo(nameLabel.snp.bottom).offset(8) make.leading.trailing.equalTo(nameLabel) } timeLabel.snp.makeConstraints { make in make.top.equalTo(phoneLabel.snp.bottom).offset(6) make.leading.trailing.equalTo(nameLabel) } callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(album: TravelAlbum, coverURL: String) { phone = album.displayPhone nameLabel.text = album.name.isEmpty ? "旅拍相册" : album.name phoneLabel.text = TravelAlbumDisplayFormatter.maskPhone(album.displayPhone) timeLabel.text = "创建于 \(TravelAlbumDisplayFormatter.creationTimeText(album.createdAt))" if let url = URL(string: coverURL), !coverURL.isEmpty { coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo.fill")) coverImageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.45) } else { coverImageView.image = UIImage(systemName: "photo.fill") coverImageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35) coverImageView.contentMode = .center } callButton.isEnabled = !phone.isEmpty callButton.alpha = phone.isEmpty ? 0.45 : 1 } @objc private func callTapped() { onCall?(phone) } } /// 旅拍相册素材网格单元,独立展示购买、修图和选择状态。 final class TravelAlbumMaterialCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumMaterialCell" private let imageView = UIImageView() private let checkImageView = UIImageView() private let purchaseBadgeView = UIView() private let purchaseBadgeLabel = UILabel() private let aiRetouchBadgeView = UIView() private let aiRetouchBadgeLabel = UILabel() private let nameLabel = UILabel() private let sizeLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.cornerRadius = 12 imageView.backgroundColor = UIColor(hex: 0xEAF4FF) checkImageView.tintColor = .white checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35) checkImageView.layer.cornerRadius = 11 checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck" configureBadge( purchaseBadgeView, label: purchaseBadgeLabel, viewIdentifier: "travelAlbum.materialPurchaseBadge", labelIdentifier: "travelAlbum.materialPurchaseBadgeLabel" ) configureBadge( aiRetouchBadgeView, label: aiRetouchBadgeLabel, viewIdentifier: "travelAlbum.materialAIRetouchBadge", labelIdentifier: "travelAlbum.materialAIRetouchBadgeLabel" ) nameLabel.font = .systemFont(ofSize: 12, weight: .medium) nameLabel.textColor = TravelAlbumDetailStyle.textPrimary nameLabel.lineBreakMode = .byTruncatingMiddle sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary contentView.addSubview(imageView) imageView.addSubview(purchaseBadgeView) imageView.addSubview(aiRetouchBadgeView) imageView.addSubview(checkImageView) contentView.addSubview(nameLabel) contentView.addSubview(sizeLabel) imageView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(imageView.snp.width) } checkImageView.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(6) make.size.equalTo(22) } aiRetouchBadgeView.snp.makeConstraints { make in make.top.leading.equalToSuperview().inset(6) make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4) } purchaseBadgeView.snp.makeConstraints { make in make.leading.bottom.equalToSuperview().inset(6) make.trailing.lessThanOrEqualToSuperview().inset(6) } nameLabel.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(6) make.leading.trailing.equalToSuperview() } sizeLabel.snp.makeConstraints { make in make.top.equalTo(nameLabel.snp.bottom).offset(2) make.leading.trailing.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) { let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl imageView.contentMode = .scaleAspectFill if let url = URL(string: urlString), !urlString.isEmpty { imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo.fill")) } else { imageView.image = UIImage(systemName: "photo.fill") imageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35) imageView.contentMode = .center } checkImageView.isHidden = !selectionMode checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white let purchaseBadge = material.purchaseBadgePresentation let aiRetouchBadge = material.aiRetouchBadgePresentation applyBadge(purchaseBadge, to: purchaseBadgeView, label: purchaseBadgeLabel) applyBadge(aiRetouchBadge, to: aiRetouchBadgeView, label: aiRetouchBadgeLabel) nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize) let purchaseAccessibilityText = purchaseBadge.map { ",购买状态:\($0.text)" } ?? "" let aiRetouchAccessibilityText = aiRetouchBadge.map { ",修图状态:\($0.text)" } ?? "" accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")" + purchaseAccessibilityText + aiRetouchAccessibilityText accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil } private func configureBadge( _ badgeView: UIView, label: UILabel, viewIdentifier: String, labelIdentifier: String ) { badgeView.layer.cornerRadius = 5 badgeView.clipsToBounds = true badgeView.isHidden = true badgeView.isAccessibilityElement = false badgeView.accessibilityIdentifier = viewIdentifier label.font = .systemFont(ofSize: 10, weight: .semibold) label.textColor = .white label.textAlignment = .center label.lineBreakMode = .byTruncatingTail label.isAccessibilityElement = false label.accessibilityIdentifier = labelIdentifier label.setContentHuggingPriority(.required, for: .horizontal) label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) badgeView.addSubview(label) label.snp.makeConstraints { make in make.edges.equalToSuperview().inset( UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6) ) } } private func applyBadge( _ presentation: TravelAlbumMaterialBadgePresentation?, to badgeView: UIView, label: UILabel ) { badgeView.isHidden = presentation == nil label.text = presentation?.text badgeView.backgroundColor = presentation.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) } } }