// // TravelAlbumDetailViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// 相册详情管理页,对齐 Android `TravelAlbumDetailScreen`。 final class TravelAlbumDetailViewController: BaseViewController { private let viewModel: TravelAlbumDetailViewModel private let api: any TravelAlbumServing private let scrollContainer = UIView() private let infoCard = TravelAlbumInfoCard() private let manageCard = UIView() private let tabStack = UIStackView() 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 var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private let bottomBar = UIView() private let bottomActionStack = UIStackView() private let deleteSelectedButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system) private var isDeleteSelectedButtonVisible = false init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) { viewModel = TravelAlbumDetailViewModel(albumId: albumId) self.api = api super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "相册管理" navigationItem.rightBarButtonItem = UIBarButtonItem( image: UIImage(systemName: "ellipsis"), menu: UIMenu(children: [ UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in self?.confirmDeleteAlbum() }, ]) ) } override func setupUI() { view.backgroundColor = AppColor.pageBackground manageCard.backgroundColor = .white manageCard.layer.cornerRadius = 12 manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor manageCard.layer.shadowOpacity = 1 manageCard.layer.shadowRadius = 6 manageCard.layer.shadowOffset = CGSize(width: 0, height: 2) tabStack.axis = .horizontal tabStack.spacing = 8 configurePillButton(allTabButton) configurePillButton(purchasedTabButton) configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill")) configureIconButton(selectButton, image: UIImage(systemName: "circle")) collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = .white collectionView.delegate = self 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 = AppColor.pageBackground bottomActionStack.axis = .vertical bottomActionStack.spacing = 8 configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935)) configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary) deleteSelectedButton.isHidden = true deleteSelectedButton.alpha = 0 view.addSubview(scrollContainer) scrollContainer.addSubview(infoCard) scrollContainer.addSubview(manageCard) manageCard.addSubview(tabStack) tabStack.addArrangedSubview(allTabButton) tabStack.addArrangedSubview(purchasedTabButton) manageCard.addSubview(sortButton) manageCard.addSubview(selectButton) manageCard.addSubview(collectionView) view.addSubview(bottomBar) bottomBar.addSubview(bottomActionStack) bottomActionStack.addArrangedSubview(deleteSelectedButton) bottomActionStack.addArrangedSubview(uploadButton) } override func setupConstraints() { bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } bottomActionStack.snp.makeConstraints { make in make.top.equalToSuperview().offset(12) make.leading.trailing.equalToSuperview().inset(20) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } deleteSelectedButton.snp.makeConstraints { make in make.height.equalTo(48).priority(.high) } uploadButton.snp.makeConstraints { make in make.height.equalTo(48).priority(.high) } scrollContainer.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(bottomBar.snp.top).offset(-8) } infoCard.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.greaterThanOrEqualTo(76) } manageCard.snp.makeConstraints { make in make.top.equalTo(infoCard.snp.bottom).offset(12) make.leading.trailing.bottom.equalToSuperview() } tabStack.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(12) make.height.equalTo(32) } sortButton.snp.makeConstraints { make in make.centerY.equalTo(tabStack) make.trailing.equalTo(selectButton.snp.leading).offset(-8) make.size.equalTo(32) } selectButton.snp.makeConstraints { make in make.centerY.equalTo(tabStack) make.trailing.equalToSuperview().offset(-12) make.size.equalTo(32) } collectionView.snp.makeConstraints { make in make.top.equalTo(tabStack.snp.bottom).offset(12) make.leading.trailing.bottom.equalToSuperview().inset(12) } } 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) sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside) selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside) deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside) uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) 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) } } @MainActor private func applyViewModel() { if let album = viewModel.album { infoCard.apply(album: album) } configureTabButton(allTabButton, title: "全部照片\(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all) configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased) selectButton.isHidden = viewModel.selectedTab != .all selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal) selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal) setDeleteSelectedButtonVisible(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(viewModel.materials) if !snapshot.itemIdentifiers.isEmpty { snapshot.reconfigureItems(snapshot.itemIdentifiers) } dataSource.apply(snapshot, animatingDifferences: true) if viewModel.isLoading && viewModel.album == nil { showLoading() } else { hideLoading() } } private func configurePillButton(_ button: UIButton) { button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) button.layer.cornerRadius = 16 button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14) } private func configureTabButton(_ button: UIButton, title: String, selected: Bool) { button.setTitle(title, for: .normal) button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF) button.setTitleColor(selected ? .white : AppColor.primary, for: .normal) } private func configureIconButton(_ button: UIButton, image: UIImage?) { button.setImage(image, for: .normal) button.backgroundColor = UIColor(hex: 0xEAF4FF) button.tintColor = AppColor.primary button.layer.cornerRadius = 16 } private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) { button.setTitle(title, for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) button.backgroundColor = color button.layer.cornerRadius = 10 } private func setDeleteSelectedButtonVisible(_ visible: Bool) { guard visible != isDeleteSelectedButtonVisible else { return } isDeleteSelectedButtonVisible = visible let changes = { self.deleteSelectedButton.isHidden = !visible self.deleteSelectedButton.alpha = visible ? 1 : 0 self.view.layoutIfNeeded() } guard view.window != nil else { changes() return } view.layoutIfNeeded() UIView.animate( withDuration: 0.25, delay: 0, options: [.curveEaseInOut, .beginFromCurrentState], animations: changes ) } 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 + 38)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 38)) 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 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 sortTapped() { let alert = UIAlertController(title: "排序", message: nil, preferredStyle: .actionSheet) TravelAlbumDetailViewModel.SortOption.allCases.forEach { option in alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in guard let self else { return } Task { await self.viewModel.setSortOption(option, api: self.api) } }) } alert.addAction(UIAlertAction(title: "取消", style: .cancel)) present(alert, animated: true) } @objc private func selectTapped() { viewModel.toggleSelectionMode() } @objc private func deleteSelectedTapped() { let count = viewModel.selectedMaterialIds.count let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", 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() { let album = viewModel.album let controller = WiredCameraTransferViewController( viewModel: WiredCameraTransferViewModel( albumId: album?.id ?? viewModel.albumId, albumTitle: album?.name ?? "", headerPhone: album?.displayPhone ?? "" ) ) navigationController?.pushViewController(controller, animated: true) } } extension TravelAlbumDetailViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let material = dataSource.itemIdentifier(for: indexPath) else { return } viewModel.toggleMaterialSelection(material) } func scrollViewDidScroll(_ scrollView: UIScrollView) { let visible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? 0 Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: visible, api: api) } } } /// 旅拍相册详情信息卡。 private final class TravelAlbumInfoCard: UIView { var onCall: ((String) -> Void)? private var phone = "" private let iconView = UIImageView(image: UIImage(systemName: "checklist")) private let phoneLabel = UILabel() private let timeLabel = UILabel() private let callButton = UIButton(type: .system) override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white layer.cornerRadius = 12 layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor layer.shadowOpacity = 1 layer.shadowRadius = 6 layer.shadowOffset = CGSize(width: 0, height: 2) let iconBox = UIView() iconBox.backgroundColor = AppColor.primary iconBox.layer.cornerRadius = 10 iconView.tintColor = .white iconView.contentMode = .scaleAspectFit phoneLabel.font = .systemFont(ofSize: 14, weight: .medium) phoneLabel.textColor = AppColor.textPrimary timeLabel.font = .systemFont(ofSize: 12) timeLabel.textColor = AppColor.textTertiary callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) callButton.tintColor = AppColor.primary callButton.backgroundColor = UIColor(hex: 0xEAF4FF) callButton.layer.cornerRadius = 20 addSubview(iconBox) iconBox.addSubview(iconView) addSubview(phoneLabel) addSubview(timeLabel) addSubview(callButton) iconBox.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(14) make.size.equalTo(48) } iconView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(28) } phoneLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.equalTo(iconBox.snp.trailing).offset(12) make.trailing.equalTo(callButton.snp.leading).offset(-12) } timeLabel.snp.makeConstraints { make in make.top.equalTo(phoneLabel.snp.bottom).offset(6) make.leading.trailing.equalTo(phoneLabel) make.bottom.equalToSuperview().offset(-16) } callButton.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-14) make.centerY.equalToSuperview() make.size.equalTo(40) } 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) { phone = album.displayPhone phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))" timeLabel.text = "创建时间 \(album.createdAt)" } @objc private func callTapped() { onCall?(phone) } } /// 旅拍相册素材网格 cell。 private final class TravelAlbumMaterialCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumMaterialCell" private let imageView = UIImageView() private let statusLabel = UILabel() private let checkImageView = UIImageView() 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 = 8 statusLabel.text = "已上传" statusLabel.font = .systemFont(ofSize: 10) statusLabel.textColor = .white statusLabel.backgroundColor = UIColor(hex: 0x34C759) statusLabel.layer.cornerRadius = 4 statusLabel.clipsToBounds = true statusLabel.textAlignment = .center checkImageView.tintColor = .white checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35) checkImageView.layer.cornerRadius = 10 nameLabel.font = .systemFont(ofSize: 11) nameLabel.textColor = AppColor.textPrimary nameLabel.lineBreakMode = .byTruncatingMiddle sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.textColor = AppColor.textTertiary contentView.addSubview(imageView) imageView.addSubview(statusLabel) 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) } statusLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(4) make.height.equalTo(18) make.width.equalTo(48) } checkImageView.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(4) make.size.equalTo(20) } nameLabel.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(4) 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 if let url = URL(string: urlString), !urlString.isEmpty { imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) } else { imageView.image = UIImage(systemName: "photo") imageView.tintColor = AppColor.primary imageView.backgroundColor = UIColor(hex: 0xEAF2FF) } checkImageView.isHidden = !selectionMode checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") checkImageView.tintColor = selected ? AppColor.primary : .white nameLabel.text = material.fileName sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize) } }