801 lines
34 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|