feat: add AI retouch and album preview actions

This commit is contained in:
2026-08-10 16:00:32 +08:00
parent 24fa66281c
commit 439edf827c
14 changed files with 2110 additions and 118 deletions
@@ -0,0 +1,599 @@
//
// TravelAlbumAIRetouchTemplateViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// AI 修图模板选择 Sheet,展示三类横向模板列表、修图模式与固定底部操作。
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
private let api: any TravelAlbumServing
private let onSubmitted: () -> Void
private let titleLabel = UILabel()
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private let statusContainer = UIView()
private let statusIndicator = UIActivityIndicatorView(style: .medium)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let bottomBar = UIView()
private let bottomDivider = UIView()
private let footerStack = UIStackView()
private let validationLabel = UILabel()
private let actionStack = UIStackView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
/// 创建 AI 修图模板选择 Sheet。
init(
viewModel: TravelAlbumAIRetouchTemplateViewModel,
api: any TravelAlbumServing,
onSubmitted: @escaping () -> Void
) {
self.viewModel = viewModel
self.api = api
self.onSubmitted = onSubmitted
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
configureSheetPresentation()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupUI() {
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
titleLabel.text = "AI修图"
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
titleLabel.accessibilityTraits = .header
collectionView.backgroundColor = .clear
collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateCollection"
collectionView.register(
TravelAlbumAIRetouchTemplateCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchModeCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchSectionHeader.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier
)
configureDataSource()
statusLabel.textColor = AIRetouchTemplateStyle.textSecondary
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
retryButton.setTitle("重试", for: .normal)
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
retryButton.accessibilityIdentifier = "travelAlbum.aiRetouchRetryButton"
bottomBar.backgroundColor = .white
bottomBar.accessibilityIdentifier = "travelAlbum.aiRetouchBottomBar"
bottomDivider.backgroundColor = AIRetouchTemplateStyle.border
validationLabel.textColor = AIRetouchTemplateStyle.danger
validationLabel.font = .systemFont(ofSize: 12, weight: .medium)
validationLabel.textAlignment = .center
validationLabel.numberOfLines = 0
validationLabel.accessibilityIdentifier = "travelAlbum.aiRetouchValidationLabel"
footerStack.axis = .vertical
footerStack.spacing = 8
footerStack.alignment = .fill
actionStack.axis = .horizontal
actionStack.spacing = 12
actionStack.distribution = .fillEqually
actionStack.alignment = .fill
configureCancelButton()
configureConfirmButton()
actionStack.addArrangedSubview(cancelButton)
actionStack.addArrangedSubview(confirmButton)
footerStack.addArrangedSubview(validationLabel)
footerStack.addArrangedSubview(actionStack)
view.addSubview(titleLabel)
view.addSubview(collectionView)
view.addSubview(statusContainer)
statusContainer.addSubview(statusIndicator)
statusContainer.addSubview(statusLabel)
statusContainer.addSubview(retryButton)
view.addSubview(bottomBar)
bottomBar.addSubview(bottomDivider)
bottomBar.addSubview(footerStack)
}
override func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(18)
make.height.equalTo(30)
}
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)
}
footerStack.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(18)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
actionStack.snp.makeConstraints { make in
make.height.equalTo(48)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
statusContainer.snp.makeConstraints { make in
make.edges.equalTo(collectionView)
}
statusIndicator.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-32)
}
statusLabel.snp.makeConstraints { make in
make.top.equalTo(statusIndicator.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview().inset(40)
}
retryButton.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(12)
make.centerX.equalToSuperview()
make.height.equalTo(36)
make.bottom.lessThanOrEqualToSuperview()
}
}
override func bindActions() {
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), 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.onSubmitted = { [weak self] in
Task { @MainActor in
guard let self else { return }
self.dismiss(animated: true, completion: self.onSubmitted)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await viewModel.loadTemplates(api: api) }
}
private func configureSheetPresentation() {
guard let sheet = sheetPresentationController else { return }
sheet.detents = [.large()]
sheet.selectedDetentIdentifier = .large
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
}
private func configureCancelButton() {
var configuration = UIButton.Configuration.filled()
configuration.title = "取消"
configuration.baseBackgroundColor = .white
configuration.baseForegroundColor = AIRetouchTemplateStyle.primary
configuration.background.cornerRadius = 14
configuration.background.strokeColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.55)
configuration.background.strokeWidth = 1
configuration.titleTextAttributesTransformer = buttonTitleTransformer
cancelButton.configuration = configuration
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
}
private func configureConfirmButton() {
var configuration = UIButton.Configuration.filled()
configuration.title = "确定"
configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary
configuration.baseForegroundColor = .white
configuration.background.cornerRadius = 14
configuration.titleTextAttributesTransformer = buttonTitleTransformer
confirmButton.configuration = configuration
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
}
private var buttonTitleTransformer: UIConfigurationTextAttributesTransformer {
UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
return attributes
}
}
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
[weak self] collectionView, indexPath, item in
guard let self else { return nil }
switch item {
case .template(let category, let template):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchTemplateCell
cell.apply(
template: template,
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
guard kind == UICollectionView.elementKindSectionHeader,
let self,
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section),
case .templates(let category) = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] else {
return nil
}
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchSectionHeader
header.apply(title: category.title, optional: category == .atmosphere)
return header
}
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
guard let self,
self.dataSource != nil,
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
),
]
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
}
}
}
@MainActor
private func applyViewModel() {
let isLoadFailure = viewModel.loadErrorMessage != nil
statusContainer.isHidden = !viewModel.isLoading && !isLoadFailure
collectionView.isHidden = viewModel.isLoading || isLoadFailure
if viewModel.isLoading {
statusIndicator.startAnimating()
statusLabel.text = "正在加载修图模板…"
retryButton.isHidden = true
} else if let message = viewModel.loadErrorMessage {
statusIndicator.stopAnimating()
statusLabel.text = message
retryButton.isHidden = false
} else {
statusIndicator.stopAnimating()
statusLabel.text = nil
retryButton.isHidden = true
}
validationLabel.text = viewModel.validationMessage
validationLabel.isHidden = viewModel.validationMessage == nil
cancelButton.isEnabled = !viewModel.isSubmitting
confirmButton.isEnabled = viewModel.canSubmit
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
var confirmConfiguration = confirmButton.configuration
confirmConfiguration?.title = viewModel.isSubmitting ? "提交中" : "确定"
confirmConfiguration?.showsActivityIndicator = viewModel.isSubmitting
confirmButton.configuration = confirmConfiguration
isModalInPresentation = viewModel.isSubmitting
applySnapshot()
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
appendTemplates(.refined, to: &snapshot)
appendTemplates(.atmosphere, to: &snapshot)
if viewModel.showsCoverTemplates {
appendTemplates(.cover, to: &snapshot)
}
snapshot.appendSections([.mode])
snapshot.appendItems([.mode], toSection: .mode)
snapshot.reconfigureItems(snapshot.itemIdentifiers)
dataSource.apply(snapshot, animatingDifferences: true)
}
private func appendTemplates(
_ category: TravelAlbumAIRetouchTemplateCategory,
to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>
) {
let section = Section.templates(category)
snapshot.appendSections([section])
snapshot.appendItems(
viewModel.templates(for: category).map { Item.template(category, $0) },
toSection: section
)
}
@objc private func cancelTapped() {
guard !viewModel.isSubmitting else { return }
dismiss(animated: true)
}
@objc private func confirmTapped() {
guard viewModel.canSubmit else {
if let message = viewModel.validationMessage { showToast(message) }
return
}
Task { await viewModel.submit(api: api) }
}
@objc private func retryTapped() {
Task { await viewModel.loadTemplates(api: api) }
}
}
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath),
case .template(let category, let template) = item else { return }
viewModel.toggleTemplate(id: template.id, category: category)
}
}
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
private let previewImageView = UIImageView()
private let nameLabel = UILabel()
private let checkView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 12
contentView.layer.borderWidth = 1
contentView.clipsToBounds = true
previewImageView.contentMode = .scaleAspectFill
previewImageView.clipsToBounds = true
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
nameLabel.font = .systemFont(ofSize: 13, weight: .medium)
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
nameLabel.textAlignment = .center
nameLabel.lineBreakMode = .byTruncatingTail
checkView.image = UIImage(systemName: "checkmark.circle.fill")
checkView.tintColor = AIRetouchTemplateStyle.primary
checkView.backgroundColor = .white
checkView.layer.cornerRadius = 10
contentView.addSubview(previewImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(checkView)
previewImageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(116)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(previewImageView.snp.bottom)
make.leading.trailing.equalToSuperview().inset(6)
make.bottom.equalToSuperview()
}
checkView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(8)
make.size.equalTo(20)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
previewImageView.kf.cancelDownloadTask()
previewImageView.image = nil
}
/// 更新模板内容与选中状态。
func apply(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
nameLabel.text = template.name
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
previewImageView.contentMode = .scaleAspectFill
previewImageView.kf.setImage(
with: url,
placeholder: UIImage(systemName: "photo")?.withTintColor(
AIRetouchTemplateStyle.textSecondary,
renderingMode: .alwaysOriginal
)
)
} else {
previewImageView.image = UIImage(systemName: "photo")
previewImageView.tintColor = AIRetouchTemplateStyle.textSecondary
previewImageView.contentMode = .scaleAspectFit
}
contentView.layer.borderColor = (
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
).cgColor
contentView.layer.borderWidth = selected ? 2 : 1
checkView.isHidden = !selected
isSelected = selected
accessibilityLabel = template.name
accessibilityValue = selected ? "已选择" : "未选择"
accessibilityTraits = selected ? [.button, .selected] : [.button]
}
}
/// AI 修图模板分组标题,可附带“选填”标签。
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
private let titleLabel = UILabel()
private let optionalLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
optionalLabel.text = "选填"
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
optionalLabel.textColor = AIRetouchTemplateStyle.primary
optionalLabel.textAlignment = .center
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
optionalLabel.layer.cornerRadius = 8
optionalLabel.clipsToBounds = true
addSubview(titleLabel)
addSubview(optionalLabel)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalToSuperview()
}
optionalLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalTo(titleLabel)
make.width.equalTo(38)
make.height.equalTo(20)
make.trailing.lessThanOrEqualToSuperview().offset(-18)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新分组标题与选填标签。
func apply(title: String, optional: Bool) {
titleLabel.text = title
optionalLabel.isHidden = !optional
accessibilityLabel = optional ? "\(title),选填" : title
accessibilityTraits = .header
}
}
/// 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)
static let pageBackground = UIColor(hex: 0xF7F9FC)
static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x7F8A9E)
static let border = UIColor(hex: 0xDCE4EF)
static let danger = UIColor(hex: 0xE53935)
}
@@ -12,6 +12,7 @@ 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()
@@ -25,17 +26,21 @@ final class TravelAlbumDetailViewController: BaseViewController {
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
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)
init(
albumId: Int,
api: (any TravelAlbumServing)? = nil,
previewConfiguration: TravelAlbumPreviewConfiguration = .init()
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)
}
@@ -118,8 +123,20 @@ final class TravelAlbumDetailViewController: BaseViewController {
uploadConfiguration?.imagePadding = 8
uploadButton.configuration = uploadConfiguration
uploadButton.accessibilityLabel = "上传照片"
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: TravelAlbumDetailStyle.danger)
deleteSelectedButton.isHidden = true
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)
@@ -133,7 +150,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
view.addSubview(bottomBar)
bottomBar.addSubview(bottomDivider)
bottomBar.addSubview(uploadButton)
bottomBar.addSubview(deleteSelectedButton)
bottomBar.addSubview(selectionActionStack)
}
override func setupConstraints() {
@@ -150,7 +167,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
deleteSelectedButton.snp.makeConstraints { make in
selectionActionStack.snp.makeConstraints { make in
make.edges.equalTo(uploadButton)
}
@@ -205,6 +222,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
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)
viewModel.onStateChange = { [weak self] in
@@ -255,10 +273,14 @@ final class TravelAlbumDetailViewController: BaseViewController {
: "已购照片不可删除"
let selectedCount = viewModel.selectedMaterialIds.count
deleteSelectedButton.setTitle("删除选中(\(selectedCount))", for: .normal)
let hasSelection = selectedCount > 0
aiRetouchButton.isEnabled = hasSelection
aiRetouchButton.alpha = hasSelection ? 1 : 0.45
aiRetouchButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
deleteSelectedButton.isEnabled = selectedCount > 0
deleteSelectedButton.alpha = selectedCount > 0 ? 1 : 0.45
deleteSelectedButton.isHidden = !viewModel.isSelectionMode
deleteSelectedButton.alpha = hasSelection ? 1 : 0.45
deleteSelectedButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
selectionActionStack.isHidden = !viewModel.isSelectionMode
uploadButton.isHidden = viewModel.isSelectionMode
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
@@ -396,6 +418,31 @@ final class TravelAlbumDetailViewController: BaseViewController {
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
guard let self else { return }
self.viewModel.completeAIRetouchSubmission()
self.showToast("AI修图任务已提交")
}
)
present(controller, animated: true)
}
@objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count
guard count > 0 else { return }
@@ -429,12 +476,16 @@ final class TravelAlbumDetailViewController: BaseViewController {
totalCount: viewModel.currentPhotoCount,
startProjectIndex: startIndex,
configuration: previewConfiguration,
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
loadMore: {
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
return (
previewViewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
previewViewModel.currentPhotoCount
)
},
onProjectDeleted: { materialId in
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
}
)
present(controller, animated: true)
@@ -465,6 +516,17 @@ private enum TravelAlbumDetailStyle {
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)
}
}
}
/// 旅拍相册摘要卡,展示封面、名称、用户手机号与创建时间。
@@ -583,11 +645,13 @@ private final class TravelAlbumInfoCard: UIView {
}
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
private final class TravelAlbumMaterialCell: UICollectionViewCell {
final class TravelAlbumMaterialCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumMaterialCell"
private let imageView = UIImageView()
private let checkImageView = UIImageView()
private let badgeView = UIView()
private let badgeLabel = UILabel()
private let nameLabel = UILabel()
private let sizeLabel = UILabel()
@@ -600,6 +664,19 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
checkImageView.tintColor = .white
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
checkImageView.layer.cornerRadius = 11
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
badgeView.layer.cornerRadius = 5
badgeView.clipsToBounds = true
badgeView.isHidden = true
badgeView.isAccessibilityElement = false
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
badgeLabel.textColor = .white
badgeLabel.textAlignment = .center
badgeLabel.isAccessibilityElement = false
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
nameLabel.lineBreakMode = .byTruncatingMiddle
@@ -607,6 +684,8 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
contentView.addSubview(imageView)
imageView.addSubview(badgeView)
badgeView.addSubview(badgeLabel)
imageView.addSubview(checkImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(sizeLabel)
@@ -618,6 +697,15 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
make.top.trailing.equalToSuperview().inset(6)
make.size.equalTo(22)
}
badgeView.snp.makeConstraints { make in
make.top.leading.equalToSuperview().inset(6)
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
}
badgeLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(6)
make.leading.trailing.equalToSuperview()
@@ -646,9 +734,14 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
let badge = material.badgePresentation
badgeView.isHidden = badge == nil
badgeLabel.text = badge?.text
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
}
}
@@ -17,15 +17,16 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private let configuration: TravelAlbumPreviewConfiguration
private let actionHandler: any TravelAlbumPreviewActionHandling
private let loadMore: TravelAlbumPreviewLoadMore?
private let onProjectDeleted: ((Int) -> Void)?
private var nodes: [TravelAlbumPreviewNode] = []
private var currentNodeIndex = 0
private var dragStartIndex = 0
private var selectedKind: TravelAlbumPreviewAssetKind = .original
private var chromeVisible = true
private var isLoadingMore = false
private var isDeletingProject = false
private var didApplyInitialPosition = false
private var lastCollectionSize: CGSize = .zero
private var previewPrefetcher: ImagePrefetcher?
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private let topChrome = UIView()
@@ -37,6 +38,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private let divider = UIView()
private let tabStack = UIStackView()
private let actionStack = UIStackView()
private let deleteButton = UIButton(type: .system)
private var tabHeightConstraint: Constraint?
/// 创建全屏预览页。
@@ -46,13 +48,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
startProjectIndex: Int,
configuration: TravelAlbumPreviewConfiguration = .init(),
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
loadMore: TravelAlbumPreviewLoadMore? = nil
loadMore: TravelAlbumPreviewLoadMore? = nil,
onProjectDeleted: ((Int) -> Void)? = nil
) {
self.projects = Self.deduplicated(projects)
self.totalCount = max(totalCount, projects.count)
self.configuration = configuration
self.actionHandler = actionHandler
self.loadMore = loadMore
self.onProjectDeleted = onProjectDeleted
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original)
@@ -70,7 +74,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
super.viewDidLoad()
setupUI()
updateForCurrentNode()
prefetchAdjacentImages()
}
override func viewDidLayoutSubviews() {
@@ -81,13 +84,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
collectionView.collectionViewLayout.invalidateLayout()
if didApplyInitialPosition {
setPage(currentNodeIndex, animated: false)
prefetchAdjacentImages()
}
}
guard !didApplyInitialPosition, collectionView.bounds.width > 0 else { return }
didApplyInitialPosition = true
setPage(currentNodeIndex, animated: false)
upgradeCurrentCellToDetailImage()
}
private func setupUI() {
@@ -193,7 +194,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private func configureActions() {
let aiButton = makeActionButton(title: "AI修复", systemName: "wand.and.stars", color: UIColor(hex: 0x60A5FA))
let deleteButton = makeActionButton(title: "删除", systemName: "trash", color: UIColor(hex: 0xF87171))
let deleteConfiguration = makeActionButton(
title: "删除",
systemName: "trash",
color: UIColor(hex: 0xF87171)
).configuration
deleteButton.configuration = deleteConfiguration
deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
deleteButton.accessibilityLabel = "删除"
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
let refreshButton = makeActionButton(title: "刷新", systemName: "arrow.clockwise", color: .white)
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
@@ -305,8 +314,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
let oldProjectIndex = currentNode?.projectIndex
guard oldNodeIndex != index else {
updateForCurrentNode()
upgradeCurrentCellToDetailImage()
prefetchAdjacentImages()
return
}
currentNodeIndex = index
@@ -316,8 +323,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
}
collectionView.visibleCells.compactMap { $0 as? TravelAlbumPreviewImageCell }.forEach { $0.resetZoom() }
updateForCurrentNode()
upgradeCurrentCellToDetailImage()
prefetchAdjacentImages()
}
private func resetProjectCellToOriginalIfNeeded(at nodeIndex: Int) {
@@ -326,38 +331,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
let cell = collectionView.cellForItem(at: IndexPath(item: nodeIndex, section: 0)) as? TravelAlbumPreviewImageCell
else { return }
let project = projects[nodes[nodeIndex].projectIndex]
cell.apply(asset: project.asset(for: .original), quality: .preview)
}
private func upgradeCurrentCellToDetailImage() {
guard let cell = collectionView.cellForItem(
at: IndexPath(item: currentNodeIndex, section: 0)
) as? TravelAlbumPreviewImageCell else { return }
cell.apply(asset: currentAsset, quality: .detail)
}
private func prefetchAdjacentImages() {
previewPrefetcher?.stop()
let indexes = [currentNodeIndex - 2, currentNodeIndex - 1, currentNodeIndex + 1, currentNodeIndex + 2]
let urls = indexes.compactMap { index -> URL? in
guard nodes.indices.contains(index) else { return nil }
let node = nodes[index]
let project = projects[node.projectIndex]
let asset = project.asset(for: node.kind) ?? project.asset(for: .original)
guard let text = asset?.previewURL, !text.isEmpty else { return nil }
return URL(string: text)
}
guard !urls.isEmpty else {
previewPrefetcher = nil
return
}
let prefetcher = ImagePrefetcher(
urls: urls,
options: TravelAlbumPreviewImageRequest.previewOptions(viewSize: collectionView.bounds.size)
)
prefetcher.maxConcurrentDownloads = 2
previewPrefetcher = prefetcher
prefetcher.start()
cell.apply(asset: project.asset(for: .original))
}
private func setPage(_ index: Int, animated: Bool) {
@@ -400,8 +374,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
self.setPage(self.currentNodeIndex, animated: false)
self.isLoadingMore = false
self.updateForCurrentNode()
self.upgradeCurrentCellToDetailImage()
self.prefetchAdjacentImages()
}
}
@@ -477,7 +449,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
}
@objc private func deleteTapped() {
guard let id = currentProject?.originalMaterialId else { return }
guard currentProject != nil, !isDeletingProject else { return }
let alert = UIAlertController(
title: "删除整个项目",
message: "将同时删除原图及其全部关联图片,是否继续?",
@@ -485,14 +457,87 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
self.performAction { [actionHandler = self.actionHandler] in
await actionHandler.deleteProject(originalMaterialId: id)
}
self?.deleteCurrentProjectAfterConfirmation()
})
present(alert, animated: true)
}
/// 用户确认后删除当前项目;接口成功后才更新预览数据和位置。
func deleteCurrentProjectAfterConfirmation() {
guard !isDeletingProject,
let project = currentProject,
let deletedProjectIndex = projects.firstIndex(where: { $0.id == project.id })
else { return }
isDeletingProject = true
updateDeleteButton()
Task { [weak self, actionHandler] in
let result = await actionHandler.deleteProject(originalMaterialId: project.originalMaterialId)
guard let self else { return }
switch result {
case .success(let message):
self.applySuccessfulDeletion(
project: project,
deletedProjectIndex: deletedProjectIndex,
message: message
)
case .failure(let message), .unavailable(let message):
self.isDeletingProject = false
self.updateDeleteButton()
self.showPreviewToast(message)
}
}
}
private func applySuccessfulDeletion(
project: TravelAlbumPreviewProject,
deletedProjectIndex: Int,
message: String?
) {
guard projects.indices.contains(deletedProjectIndex),
projects[deletedProjectIndex].id == project.id
else {
isDeletingProject = false
updateDeleteButton()
return
}
projects.remove(at: deletedProjectIndex)
totalCount = max(0, totalCount - 1)
onProjectDeleted?(project.originalMaterialId)
guard let targetProjectIndex = TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
deletedProjectIndex: deletedProjectIndex,
remainingProjectCount: projects.count
) else {
dismiss(animated: true)
return
}
selectedKind = .original
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: .original)
collectionView.reloadData()
collectionView.layoutIfNeeded()
setPage(currentNodeIndex, animated: false)
isDeletingProject = false
updateDeleteButton()
updateForCurrentNode()
if let message, !message.isEmpty {
showPreviewToast(message)
}
}
private func updateDeleteButton() {
deleteButton.isEnabled = !isDeletingProject
deleteButton.alpha = isDeletingProject ? 0.55 : 1
deleteButton.accessibilityValue = isDeletingProject ? "删除中" : nil
var configuration = deleteButton.configuration
configuration?.showsActivityIndicator = isDeletingProject
configuration?.image = isDeletingProject ? nil : UIImage(systemName: "trash")
configuration?.title = isDeletingProject ? "删除中" : "删除"
deleteButton.configuration = configuration
}
@objc private func refreshTapped() {
guard let id = currentProject?.originalMaterialId else { return }
performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) }
@@ -517,8 +562,7 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
let kind = configuration.swipeMode == .projectsOnly && indexPath.item == currentNodeIndex
? selectedKind
: node.kind
let quality: TravelAlbumPreviewImageQuality = indexPath.item == currentNodeIndex ? .detail : .preview
cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original), quality: quality)
cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original))
cell.onSingleTap = { [weak self] in self?.toggleChrome() }
cell.onZoomChanged = { [weak self] zoomed in
self?.collectionView.isScrollEnabled = !zoomed
@@ -565,38 +609,6 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
}
}
/// 预览 Cell 的图片清晰度层级;滑动时使用轻量预览,停稳后升级为高清图。
private enum TravelAlbumPreviewImageQuality: String {
case preview
case detail
}
/// 统一生成预览页图片处理参数,确保展示请求与预取请求共用缓存键。
private enum TravelAlbumPreviewImageRequest {
static func previewOptions(viewSize: CGSize) -> KingfisherOptionsInfo {
options(viewSize: viewSize, sizeMultiplier: 1)
}
static func detailOptions(viewSize: CGSize) -> KingfisherOptionsInfo {
options(viewSize: viewSize, sizeMultiplier: 2)
}
private static func options(viewSize: CGSize, sizeMultiplier: CGFloat) -> KingfisherOptionsInfo {
let fallbackSize = UIScreen.main.bounds.size
let baseSize = viewSize.width > 0 && viewSize.height > 0 ? viewSize : fallbackSize
let targetSize = CGSize(
width: baseSize.width * sizeMultiplier,
height: baseSize.height * sizeMultiplier
)
return [
.processor(DownsamplingImageProcessor(size: targetSize)),
.scaleFactor(UIScreen.main.scale),
.backgroundDecode,
.keepCurrentImageWhileLoading,
]
}
}
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
@@ -607,7 +619,6 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
private let imageView = UIImageView()
private let retryButton = UIButton(type: .system)
private var asset: TravelAlbumPreviewAsset?
private var requestKey: String?
override init(frame: CGRect) {
super.init(frame: frame)
@@ -624,7 +635,6 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
imageView.kf.cancelDownloadTask()
imageView.image = nil
asset = nil
requestKey = nil
retryButton.isHidden = true
resetZoom()
}
@@ -634,15 +644,10 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
imageView.frame = scrollView.bounds
}
func apply(asset newAsset: TravelAlbumPreviewAsset?, quality: TravelAlbumPreviewImageQuality) {
let urlText = quality == .preview ? newAsset?.previewURL : newAsset?.displayURL
let newRequestKey = "\(quality.rawValue):\(urlText ?? "")"
let isSameRequest = requestKey == newRequestKey && imageView.image != nil
func apply(asset newAsset: TravelAlbumPreviewAsset?) {
asset = newAsset
resetZoom()
if !isSameRequest {
loadImage(quality: quality, requestKey: newRequestKey)
}
loadImage()
accessibilityLabel = newAsset.map {
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
}
@@ -704,21 +709,16 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
scrollView.addGestureRecognizer(doubleTap)
}
private func loadImage(quality: TravelAlbumPreviewImageQuality, requestKey: String) {
private func loadImage() {
retryButton.isHidden = true
let text = quality == .preview ? asset?.previewURL : asset?.displayURL
let text = asset?.displayURL
guard let text, let url = URL(string: text), !text.isEmpty else {
imageView.image = nil
self.requestKey = nil
retryButton.isHidden = false
return
}
self.requestKey = requestKey
let options = quality == .preview
? TravelAlbumPreviewImageRequest.previewOptions(viewSize: contentView.bounds.size)
: TravelAlbumPreviewImageRequest.detailOptions(viewSize: contentView.bounds.size)
imageView.kf.setImage(with: url, options: options) { [weak self] result in
guard let self, self.requestKey == requestKey else { return }
imageView.kf.setImage(with: url) { [weak self] result in
guard let self, self.asset?.displayURL == text else { return }
if case .failure = result, self.imageView.image == nil {
self.retryButton.isHidden = false
}
@@ -747,9 +747,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
}
@objc private func retryTapped() {
guard let requestKey else { return }
let quality: TravelAlbumPreviewImageQuality = requestKey.hasPrefix("preview:") ? .preview : .detail
loadImage(quality: quality, requestKey: requestKey)
loadImage()
}
}