Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
2026-07-31 16:48:28 +08:00

801 lines
34 KiB
Swift

//
// 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 aiEditResultStore = TravelAlbumAIEditResultStore.shared
private var pendingAIEditMaterialIDs: Set<Int> = []
private var failedAIEditCount = 0
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<Int, TravelAlbumMaterial>!
private let bottomBar = UIView()
private let bottomActionStack = UIStackView()
private let aiEditSelectedButton = UIButton(type: .system)
private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system)
init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "相册管理"
navigationItem.backButtonDisplayMode = .minimal
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<Int, TravelAlbumMaterial>(collectionView: collectionView) {
[weak self] collectionView, indexPath, material in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumMaterialCell
let aiEditState = self?.aiEditResultStore.state(for: material.id)
?? TravelAlbumAIEditResultState()
let resultImage = aiEditState.isCover
? (self?.aiEditResultStore.coverImage(for: material.id)
?? self?.aiEditResultStore.editedImage(for: material.id))
: self?.aiEditResultStore.editedImage(for: material.id)
cell.apply(
material: material,
selectionMode: self?.viewModel.isSelectionMode == true,
selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true,
aiEditState: aiEditState,
editedImage: resultImage
)
return cell
}
bottomBar.backgroundColor = AppColor.pageBackground
bottomActionStack.axis = .horizontal
bottomActionStack.spacing = 12
bottomActionStack.distribution = .fillEqually
configureSelectionActionButton(
aiEditSelectedButton,
title: "AI修图",
image: UIImage(named: "travel_album_preview_ai_edit")?.withRenderingMode(.alwaysTemplate)
)
configureSelectionActionButton(
deleteSelectedButton,
title: "删除",
image: UIImage(named: "travel_album_preview_delete")?.withRenderingMode(.alwaysTemplate)
)
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
aiEditSelectedButton.isHidden = true
deleteSelectedButton.isHidden = true
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(aiEditSelectedButton)
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)
}
[aiEditSelectedButton, deleteSelectedButton, uploadButton].forEach { button in
button.snp.makeConstraints { make in
make.height.equalTo(58).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)
aiEditSelectedButton.addTarget(self, action: #selector(aiEditSelectedTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
NotificationCenter.default.addObserver(
self,
selector: #selector(aiEditResultDidChange(_:)),
name: TravelAlbumAIEditResultStore.didChangeNotification,
object: aiEditResultStore
)
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
updateBottomToolbar()
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
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 updateBottomToolbar() {
let toolbarState = TravelAlbumSelectionToolbarState(
isSelectionMode: viewModel.isSelectionMode,
selectedCount: viewModel.selectedMaterialIds.count
)
uploadButton.isHidden = !toolbarState.showsUpload
aiEditSelectedButton.isHidden = !toolbarState.showsSelectionActions
deleteSelectedButton.isHidden = !toolbarState.showsSelectionActions
let selectedContainsProcessingPhoto = viewModel.selectedMaterialIds.contains { materialID in
aiEditResultStore.state(for: materialID).isProcessing
}
let canStartAIEditing = toolbarState.selectionActionsEnabled
&& pendingAIEditMaterialIDs.isEmpty
&& !selectedContainsProcessingPhoto
aiEditSelectedButton.isEnabled = canStartAIEditing
deleteSelectedButton.isEnabled = toolbarState.selectionActionsEnabled
aiEditSelectedButton.alpha = canStartAIEditing ? 1 : 0.4
deleteSelectedButton.alpha = toolbarState.selectionActionsEnabled ? 1 : 0.4
bottomBar.backgroundColor = toolbarState.showsSelectionActions ? .white : AppColor.pageBackground
}
private func configurePillButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 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
}
/// 配置多选模式底部的图标操作按钮,图标复用照片预览页的 Iconfont 资源。
private func configureSelectionActionButton(_ button: UIButton, title: String, image: UIImage?) {
var configuration = UIButton.Configuration.plain()
configuration.title = title
configuration.image = image
configuration.imagePlacement = .top
configuration.imagePadding = 5
configuration.baseForegroundColor = AppColor.textSecondary
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 13, weight: .medium)
return attributes
}
button.configuration = configuration
}
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 aiEditSelectedTapped() {
let selectedMaterials = viewModel.materials.filter { viewModel.selectedMaterialIds.contains($0.id) }
guard let firstMaterial = selectedMaterials.first else {
showToast("请选择要修图的照片")
return
}
let imageURLString = firstMaterial.coverUrl.isEmpty ? firstMaterial.fileUrl : firstMaterial.coverUrl
guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
presentAIEditSheet(sourceImage: nil, selectedMaterials: selectedMaterials)
return
}
KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
guard let self else { return }
Task { @MainActor in
let sourceImage: UIImage?
if case let .success(value) = result {
sourceImage = value.image
} else {
sourceImage = nil
}
self.presentAIEditSheet(sourceImage: sourceImage, selectedMaterials: selectedMaterials)
}
}
}
/// 复用单张照片预览页的 AI 修图预设弹窗完成批量选择演示。
private func presentAIEditSheet(
sourceImage: UIImage?,
selectedMaterials: [TravelAlbumMaterial]
) {
let controller = TravelAlbumAIEditSheetViewController(
sourceImage: sourceImage,
selectedPhotoCount: selectedMaterials.count
)
controller.onConfirm = { [weak self] selection in
self?.startBatchAIEditing(
materials: selectedMaterials,
selection: selection
)
}
present(controller, animated: true)
}
/// 启动批量本地演示修图,并在全部照片完成前禁用 AI 修图入口。
private func startBatchAIEditing(
materials: [TravelAlbumMaterial],
selection: TravelAlbumAIEditSelection
) {
guard pendingAIEditMaterialIDs.isEmpty, !materials.isEmpty else { return }
let materialIDs = materials.map(\.id)
let coverMaterialID = selection.coverTemplate == nil ? nil : materials.first?.id
pendingAIEditMaterialIDs = Set(materialIDs)
failedAIEditCount = 0
aiEditResultStore.startProcessing(materialIDs: materialIDs)
applyViewModel()
materials.forEach { material in
let imageURLString = material.fileUrl.isEmpty ? material.coverUrl : material.fileUrl
guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
finishAIEditing(
materialID: material.id,
selection: selection,
appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
sourceImage: nil
)
return
}
KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
guard let self else { return }
Task { @MainActor in
let sourceImage: UIImage?
if case let .success(value) = result {
sourceImage = value.image
} else {
sourceImage = nil
}
self.finishAIEditing(
materialID: material.id,
selection: selection,
appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
sourceImage: sourceImage
)
}
}
}
}
/// 完成单张批量任务;最后一张结束后恢复入口并反馈整体结果。
private func finishAIEditing(
materialID: Int,
selection: TravelAlbumAIEditSelection,
appliedCoverTemplate: TravelAlbumCoverTemplate?,
sourceImage: UIImage?
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
guard let self else { return }
if let sourceImage {
let refinedImage = TravelAlbumAIEditImageProcessor.render(
effect: selection.refinedPreset.effect,
source: sourceImage
)
let atmosphereImage = selection.atmosphereOption.map { option in
TravelAlbumAIEditImageProcessor.render(effect: option.effect, source: sourceImage)
}
let coverImage = appliedCoverTemplate.map { template in
TravelAlbumCoverTemplateImageProcessor.render(
template: template,
source: refinedImage
)
}
if selection.refinedPreset.effect == .original,
selection.atmosphereOption == nil,
appliedCoverTemplate == nil {
self.aiEditResultStore.restoreOriginal(materialID: materialID)
} else {
self.aiEditResultStore.complete(
materialID: materialID,
presetID: selection.refinedPreset.id,
atmosphereOptionID: selection.atmosphereOption?.id,
coverTemplateID: appliedCoverTemplate?.id,
editedImage: refinedImage,
atmosphereImage: atmosphereImage,
coverImage: coverImage
)
}
} else {
self.aiEditResultStore.failProcessing(materialID: materialID)
self.failedAIEditCount += 1
}
self.pendingAIEditMaterialIDs.remove(materialID)
self.applyViewModel()
if self.pendingAIEditMaterialIDs.isEmpty {
let successMessage: String
if let coverTemplate = selection.coverTemplate,
selection.atmosphereOption != nil {
successMessage = "AI修图完成,每张生成 2 个结果,并生成「\(coverTemplate.title)」封面"
} else if let coverTemplate = selection.coverTemplate {
successMessage = "AI修图完成,已生成「\(coverTemplate.title)」封面"
} else if selection.atmosphereOption != nil {
successMessage = "AI修图完成,每张已生成 2 个结果"
} else {
successMessage = "AI修图完成"
}
let message = self.failedAIEditCount == 0
? successMessage
: "有 \(self.failedAIEditCount) 张照片修图失败,请重试"
self.showToast(message)
}
}
}
@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 controller = TravelAlbumTransferModeSheetViewController()
controller.onModeSelected = { [weak self] mode in
self?.openWiredTransfer(mode: mode)
}
present(controller, animated: true)
}
/// 根据本次选择进入对应传输模式,避免被历史缓存模式覆盖。
private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) {
let album = viewModel.album
let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel(
albumId: album?.id ?? viewModel.albumId,
albumTitle: album?.name ?? "",
headerPhone: album?.displayPhone ?? "",
initialTransferMode: mode
)
)
navigationController?.pushViewController(controller, animated: true)
}
/// 仅刷新状态发生变化的缩略图,及时呈现修图结果和任务角标。
@objc private func aiEditResultDidChange(_ notification: Notification) {
guard let materialID = notification.userInfo?[TravelAlbumAIEditResultStore.materialIDUserInfoKey] as? Int,
let material = dataSource.snapshot().itemIdentifiers.first(where: { $0.id == materialID }) else {
return
}
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([material])
dataSource.apply(snapshot, animatingDifferences: true)
updateBottomToolbar()
}
}
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 {
// 非多选状态进入预览,避免影响原有批量删除流程。
let initialIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) ?? 0
let controller = TravelAlbumPhotoPreviewViewController(
materials: viewModel.materials,
initialIndex: initialIndex,
api: api
)
controller.onDeleted = { [weak self] in
guard let self else { return }
Task { await self.viewModel.refreshAll(api: self.api) }
}
// 照片与修图样式、封面模板统一走全屏预览,避免系统导航栏重复出现。
present(controller, animated: true)
}
}
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")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.kf.cancelDownloadTask()
imageView.image = nil
}
func apply(
material: TravelAlbumMaterial,
selectionMode: Bool,
selected: Bool,
aiEditState: TravelAlbumAIEditResultState,
editedImage: UIImage?
) {
imageView.backgroundColor = .clear
imageView.tintColor = nil
if aiEditState.hasEditedResult, let editedImage {
imageView.kf.cancelDownloadTask()
imageView.image = editedImage
} else if let assetName = material.bundledImageAssetName,
let bundledImage = UIImage(named: assetName) {
imageView.kf.cancelDownloadTask()
imageView.image = bundledImage
} else {
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)
}
}
applyAIEditStatus(aiEditState.thumbnailStatus, isPurchased: material.isPurchased)
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)
}
/// 复用左上角状态位,让用户无需进入预览即可识别照片是否完成 AI 修图。
private func applyAIEditStatus(
_ status: TravelAlbumAIEditResultState.ThumbnailStatus,
isPurchased: Bool
) {
let appearance: (title: String, color: UIColor, width: CGFloat)
switch status {
case .none:
appearance = isPurchased
? ("已购", AppColor.primary, 48)
: ("已上传", UIColor(hex: 0x34C759), 48)
case .processing:
appearance = ("修图中", AppColor.primary, 48)
case .edited:
appearance = ("AI已修", UIColor(hex: 0x6750A4), 48)
case .cover:
appearance = ("AI封面", UIColor(hex: 0xF59E0B), 56)
case .failed:
appearance = ("修图失败", UIColor(hex: 0xE53935), 56)
}
statusLabel.text = appearance.title
statusLabel.backgroundColor = appearance.color
statusLabel.snp.updateConstraints { make in
make.width.equalTo(appearance.width)
}
}
}