feat: 接通图片预览 AI 修图流程

This commit is contained in:
2026-08-11 09:52:15 +08:00
parent 5704531f3a
commit 43c75c8e36
14 changed files with 814 additions and 169 deletions
@@ -10,13 +10,20 @@ import UIKit
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
/// 相册项目刷新回调,失败时由预览页保留当前内容并展示错误。
typealias TravelAlbumPreviewReload = () async throws -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
final class TravelAlbumPhotoPreviewViewController: UIViewController {
private var projects: [TravelAlbumPreviewProject]
private var totalCount: Int
private let configuration: TravelAlbumPreviewConfiguration
private let actionHandler: any TravelAlbumPreviewActionHandling
private let albumId: Int
private let scenicIdProvider: () -> Int
private let aiRetouchAPI: (any TravelAlbumServing)?
private let loadMore: TravelAlbumPreviewLoadMore?
private let reload: TravelAlbumPreviewReload?
private let onProjectDeleted: ((Int) -> Void)?
private var nodes: [TravelAlbumPreviewNode] = []
private var currentNodeIndex = 0
@@ -24,6 +31,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private var selectedKind: TravelAlbumPreviewAssetKind = .original
private var chromeVisible = true
private var isLoadingMore = false
private var isRefreshingProject = false
private var isDeletingProject = false
private var didApplyInitialPosition = false
private var lastCollectionSize: CGSize = .zero
@@ -39,6 +47,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private let tabStack = UIStackView()
private let actionStack = UIStackView()
private let deleteButton = UIButton(type: .system)
private let refreshButton = UIButton(type: .system)
private var tabHeightConstraint: Constraint?
/// 创建全屏预览页。
@@ -48,14 +57,22 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
startProjectIndex: Int,
configuration: TravelAlbumPreviewConfiguration = .init(),
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
albumId: Int = 0,
scenicIdProvider: @escaping () -> Int = { 0 },
aiRetouchAPI: (any TravelAlbumServing)? = nil,
loadMore: TravelAlbumPreviewLoadMore? = nil,
reload: TravelAlbumPreviewReload? = nil,
onProjectDeleted: ((Int) -> Void)? = nil
) {
self.projects = Self.deduplicated(projects)
self.totalCount = max(totalCount, projects.count)
self.configuration = configuration
self.actionHandler = actionHandler
self.albumId = albumId
self.scenicIdProvider = scenicIdProvider
self.aiRetouchAPI = aiRetouchAPI
self.loadMore = loadMore
self.reload = reload
self.onProjectDeleted = onProjectDeleted
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
@@ -203,7 +220,14 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
deleteButton.accessibilityLabel = "删除"
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
let refreshButton = makeActionButton(title: "刷新", systemName: "arrow.clockwise", color: .white)
refreshButton.configuration = makeActionButton(
title: "刷新",
systemName: "arrow.clockwise",
color: .white
).configuration
refreshButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
refreshButton.accessibilityLabel = "刷新"
refreshButton.accessibilityIdentifier = "travelAlbum.previewRefreshButton"
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
@@ -403,19 +427,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
)
}
private func performAction(_ operation: @escaping () async -> TravelAlbumPreviewActionResult) {
Task { [weak self] in
guard let self else { return }
let result = await operation()
switch result {
case .success(let message):
if let message, !message.isEmpty { self.showPreviewToast(message) }
case .failure(let message), .unavailable(let message):
self.showPreviewToast(message)
}
}
}
private func showPreviewToast(_ message: String) {
let label = TravelAlbumPreviewToastLabel()
label.text = message
@@ -444,8 +455,36 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
}
@objc private func aiTapped() {
guard let id = currentProject?.originalMaterialId else { return }
performAction { [actionHandler] in await actionHandler.requestAIRetouch(originalMaterialId: id) }
guard presentedViewController == nil else { return }
guard let project = currentProject else { return }
guard let aiRetouchAPI else {
showPreviewToast("AI修图功能暂不可用")
return
}
let scenicId = scenicIdProvider()
guard scenicId > 0 else {
showPreviewToast("请先选择景区")
return
}
let selectedKind = currentAsset?.kind ?? .original
guard let workflow = project.aiRetouchWorkflow(albumId: albumId, selectedKind: selectedKind) else {
showPreviewToast("当前图片不支持AI修图")
return
}
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: TravelAlbumAIRetouchTemplateViewModel(
scenicId: scenicId,
workflow: workflow
),
api: aiRetouchAPI,
onSubmitted: { [weak self] in
guard let self else { return }
self.showPreviewToast("AI修图任务已提交")
self.reloadProjects(showSuccessToast: false, forceRefreshImage: false)
}
)
present(controller, animated: true)
}
@objc private func deleteTapped() {
@@ -539,8 +578,98 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
}
@objc private func refreshTapped() {
guard let id = currentProject?.originalMaterialId else { return }
performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) }
reloadProjects(showSuccessToast: true, forceRefreshImage: true)
}
private func reloadProjects(showSuccessToast: Bool, forceRefreshImage: Bool) {
guard !isRefreshingProject else { return }
guard let reload else {
if showSuccessToast { showPreviewToast("关联图片刷新接口待接入") }
return
}
isRefreshingProject = true
updateRefreshButton()
let currentProjectId = currentProject?.id
let currentProjectIndex = currentNode?.projectIndex ?? 0
let kind = currentAsset?.kind ?? .original
Task { [weak self] in
guard let self else { return }
do {
let result = try await reload()
self.applySuccessfulReload(
projects: result.projects,
totalCount: result.totalCount,
currentProjectId: currentProjectId,
fallbackProjectIndex: currentProjectIndex,
kind: kind,
showSuccessToast: showSuccessToast,
forceRefreshImage: forceRefreshImage
)
} catch is CancellationError {
self.isRefreshingProject = false
self.updateRefreshButton()
} 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?,
fallbackProjectIndex: Int,
kind: TravelAlbumPreviewAssetKind,
showSuccessToast: Bool,
forceRefreshImage: Bool
) {
let incoming = Self.deduplicated(refreshedProjects)
guard !incoming.isEmpty else {
isRefreshingProject = false
updateRefreshButton()
if showSuccessToast { 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
selectedKind = resolvedKind
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind)
collectionView.reloadData()
collectionView.layoutIfNeeded()
setPage(currentNodeIndex, animated: false)
updateForCurrentNode()
if forceRefreshImage { forceRefreshCurrentImage() }
isRefreshingProject = false
updateRefreshButton()
if showSuccessToast { showPreviewToast("刷新成功") }
}
private func forceRefreshCurrentImage() {
let indexPath = IndexPath(item: currentNodeIndex, section: 0)
guard let cell = collectionView.cellForItem(at: indexPath) as? TravelAlbumPreviewImageCell else { return }
cell.apply(asset: currentAsset, forceRefresh: true)
}
private func updateRefreshButton() {
refreshButton.isEnabled = !isRefreshingProject
refreshButton.alpha = isRefreshingProject ? 0.55 : 1
refreshButton.accessibilityValue = isRefreshingProject ? "刷新中" : nil
var configuration = refreshButton.configuration
configuration?.showsActivityIndicator = isRefreshingProject
configuration?.image = isRefreshingProject ? nil : UIImage(systemName: "arrow.clockwise")
configuration?.title = isRefreshingProject ? "刷新中" : "刷新"
refreshButton.configuration = configuration
}
}
@@ -639,10 +768,10 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
resetZoom()
}
func apply(asset newAsset: TravelAlbumPreviewAsset?) {
func apply(asset newAsset: TravelAlbumPreviewAsset?, forceRefresh: Bool = false) {
asset = newAsset
resetZoom()
loadImage()
loadImage(forceRefresh: forceRefresh)
accessibilityLabel = newAsset.map {
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
}
@@ -710,7 +839,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
scrollView.addGestureRecognizer(doubleTap)
}
private func loadImage() {
private func loadImage(forceRefresh: Bool = false) {
retryButton.isHidden = true
let text = asset?.displayURL
guard let text, let url = URL(string: text), !text.isEmpty else {
@@ -718,7 +847,8 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
retryButton.isHidden = false
return
}
imageView.kf.setImage(with: url) { [weak self] result in
let options: KingfisherOptionsInfo? = forceRefresh ? [.forceRefresh] : nil
imageView.kf.setImage(with: url, options: options) { [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
@@ -748,7 +878,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
}
@objc private func retryTapped() {
loadImage()
loadImage(forceRefresh: true)
}
}