feat: 完善相册 AI 修图模板选择流程

This commit is contained in:
2026-08-11 15:22:08 +08:00
parent 43c75c8e36
commit e64dcec094
11 changed files with 261 additions and 202 deletions
@@ -12,13 +12,11 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
/// 页面使用的 collection section。
private enum Section: Hashable {
case templates(TravelAlbumAIRetouchTemplateCategory)
case mode
}
/// 页面使用的 diffable item。
private enum Item: Hashable {
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
case mode
}
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
@@ -35,6 +33,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
private let bottomBar = UIView()
private let bottomDivider = UIView()
private let footerStack = UIStackView()
private let quotaLabel = UILabel()
private let validationLabel = UILabel()
private let actionStack = UIStackView()
private let cancelButton = UIButton(type: .system)
@@ -78,10 +77,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
TravelAlbumAIRetouchTemplateCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchModeCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchSectionHeader.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
@@ -109,6 +104,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
footerStack.axis = .vertical
footerStack.spacing = 8
footerStack.alignment = .fill
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
quotaLabel.textColor = AIRetouchTemplateStyle.primary
quotaLabel.textAlignment = .center
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
actionStack.axis = .horizontal
actionStack.spacing = 12
actionStack.distribution = .fillEqually
@@ -117,6 +116,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
configureConfirmButton()
actionStack.addArrangedSubview(cancelButton)
actionStack.addArrangedSubview(confirmButton)
footerStack.addArrangedSubview(quotaLabel)
footerStack.addArrangedSubview(validationLabel)
footerStack.addArrangedSubview(actionStack)
@@ -255,13 +255,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
selected: self.viewModel.selectedTemplateId(for: category) == template.id
)
return cell
case .mode:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchModeCell
cell.apply()
return cell
}
}
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
@@ -288,40 +281,27 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
return nil
}
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
case .templates:
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(118),
heightDimension: .absolute(154)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.interGroupSpacing = 12
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18)
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(40)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(118),
heightDimension: .absolute(154)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.interGroupSpacing = 12
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18)
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(40)
),
]
return section
case .mode:
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(72)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 18, bottom: 22, trailing: 18)
return section
}
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
),
]
return section
}
}
@@ -346,6 +326,17 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
validationLabel.text = viewModel.validationMessage
validationLabel.isHidden = viewModel.validationMessage == nil
if let remainingQuota = viewModel.remainingQuota {
quotaLabel.text = "剩余 \(remainingQuota) 次"
quotaLabel.accessibilityLabel = "剩余\(remainingQuota)次修图额度"
quotaLabel.textColor = viewModel.requiredQuota > remainingQuota
? AIRetouchTemplateStyle.danger
: AIRetouchTemplateStyle.primary
} else {
quotaLabel.text = "剩余 -- 次"
quotaLabel.accessibilityLabel = "剩余修图额度暂不可用"
quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary
}
cancelButton.isEnabled = !viewModel.isSubmitting
confirmButton.isEnabled = viewModel.canSubmit
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
@@ -362,8 +353,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
for category in viewModel.visibleCategories {
appendTemplates(category, to: &snapshot)
}
snapshot.appendSections([.mode])
snapshot.appendItems([.mode], toSection: .mode)
snapshot.reconfigureItems(snapshot.itemIdentifiers)
dataSource.apply(snapshot, animatingDifferences: true)
}
@@ -539,53 +528,6 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
}
}
/// AI 修图模式卡片,展示计费方式与剩余张数占位。
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
private let titleLabel = UILabel()
private let remainingLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 14
contentView.layer.borderWidth = 1
contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
remainingLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
remainingLabel.textColor = AIRetouchTemplateStyle.primary
remainingLabel.textAlignment = .right
contentView.addSubview(titleLabel)
contentView.addSubview(remainingLabel)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
}
remainingLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 应用当前固定修图模式与额度占位文案。
func apply() {
titleLabel.text = "AI精修 按张收费"
remainingLabel.text = "剩余--张"
accessibilityIdentifier = "travelAlbum.aiRetouchModeCell"
accessibilityLabel = "AI精修,按张收费,剩余张数暂不可用"
}
}
/// AI 修图模板页视觉常量。
private enum AIRetouchTemplateStyle {
static let primary = UIColor(hex: 0x1677FF)
@@ -499,12 +499,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
previewViewModel.currentPhotoCount
)
},
reload: {
let response = try await previewViewModel.reloadLoadedMaterials(api: previewAPI)
return (
response.list.map(TravelAlbumPreviewProject.init(material:)),
response.total
)
reload: { materialId in
let material = try await previewViewModel.refreshMaterial(id: materialId, api: previewAPI)
return TravelAlbumPreviewProject(material: material)
},
onProjectDeleted: { materialId in
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
@@ -10,8 +10,8 @@ import UIKit
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
/// 相册项目刷新回调,失败时由预览页保留当前内容并展示错误。
typealias TravelAlbumPreviewReload = () async throws -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
/// 相册项目定向刷新回调,根据原素材 ID 返回该项目的最新数据。
typealias TravelAlbumPreviewReload = (_ materialId: Int) async throws -> TravelAlbumPreviewProject
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
final class TravelAlbumPhotoPreviewViewController: UIViewController {
@@ -481,7 +481,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
onSubmitted: { [weak self] in
guard let self else { return }
self.showPreviewToast("AI修图任务已提交")
self.reloadProjects(showSuccessToast: false, forceRefreshImage: false)
self.reloadProjects(showGlobalLoading: false, forceRefreshImage: false)
}
)
present(controller, animated: true)
@@ -578,32 +578,36 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
}
@objc private func refreshTapped() {
reloadProjects(showSuccessToast: true, forceRefreshImage: true)
reloadProjects(showGlobalLoading: true, forceRefreshImage: true)
}
private func reloadProjects(showSuccessToast: Bool, forceRefreshImage: Bool) {
private func reloadProjects(showGlobalLoading: Bool, forceRefreshImage: Bool) {
guard !isRefreshingProject else { return }
guard let reload else {
if showSuccessToast { showPreviewToast("关联图片刷新接口待接入") }
return
}
guard let reload else { return }
isRefreshingProject = true
updateRefreshButton()
if showGlobalLoading { GlobalLoadingManager.shared.show() }
let currentProjectId = currentProject?.id
let currentProjectIndex = currentNode?.projectIndex ?? 0
let kind = currentAsset?.kind ?? .original
Task { [weak self] in
defer {
if showGlobalLoading { GlobalLoadingManager.shared.hide() }
}
guard let self else { return }
do {
let result = try await reload()
guard let currentProjectId else {
self.isRefreshingProject = false
self.updateRefreshButton()
return
}
let project = try await reload(currentProjectId)
self.applySuccessfulReload(
projects: result.projects,
totalCount: result.totalCount,
project: project,
currentProjectId: currentProjectId,
fallbackProjectIndex: currentProjectIndex,
kind: kind,
showSuccessToast: showSuccessToast,
forceRefreshImage: forceRefreshImage
)
} catch is CancellationError {
@@ -612,37 +616,28 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
} catch {
self.isRefreshingProject = false
self.updateRefreshButton()
if showSuccessToast {
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
self.showPreviewToast(message.isEmpty ? "刷新失败" : message)
}
}
}
}
private func applySuccessfulReload(
projects refreshedProjects: [TravelAlbumPreviewProject],
totalCount: Int,
currentProjectId: Int?,
project refreshedProject: TravelAlbumPreviewProject,
currentProjectId: Int,
fallbackProjectIndex: Int,
kind: TravelAlbumPreviewAssetKind,
showSuccessToast: Bool,
forceRefreshImage: Bool
) {
let incoming = Self.deduplicated(refreshedProjects)
guard !incoming.isEmpty else {
guard !projects.isEmpty else {
isRefreshingProject = false
updateRefreshButton()
if showSuccessToast { dismiss(animated: true) }
dismiss(animated: true)
return
}
projects = incoming
self.totalCount = max(totalCount, incoming.count)
let targetProjectIndex = currentProjectId.flatMap { id in
incoming.firstIndex { $0.id == id }
} ?? min(max(0, fallbackProjectIndex), incoming.count - 1)
let resolvedKind = incoming[targetProjectIndex].asset(for: kind) == nil ? .original : kind
let targetProjectIndex = projects.firstIndex { $0.id == currentProjectId }
?? min(max(0, fallbackProjectIndex), projects.count - 1)
projects[targetProjectIndex] = refreshedProject
let resolvedKind = refreshedProject.asset(for: kind) == nil ? .original : kind
selectedKind = resolvedKind
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind)
collectionView.reloadData()
@@ -652,7 +647,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
if forceRefreshImage { forceRefreshCurrentImage() }
isRefreshingProject = false
updateRefreshButton()
if showSuccessToast { showPreviewToast("刷新成功") }
}
private func forceRefreshCurrentImage() {