feat: 接通图片预览 AI 修图流程
This commit is contained in:
@@ -7,7 +7,7 @@ import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// AI 修图模板选择 Sheet,展示三类横向模板列表、修图模式与固定底部操作。
|
||||
/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。
|
||||
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
/// 页面使用的 collection section。
|
||||
private enum Section: Hashable {
|
||||
@@ -276,7 +276,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumAIRetouchSectionHeader
|
||||
header.apply(title: category.title, optional: category == .atmosphere)
|
||||
header.apply(title: category.title, optional: self.viewModel.isOptional(category))
|
||||
return header
|
||||
}
|
||||
}
|
||||
@@ -359,10 +359,8 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
|
||||
private func applySnapshot() {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
appendTemplates(.refined, to: &snapshot)
|
||||
appendTemplates(.atmosphere, to: &snapshot)
|
||||
if viewModel.showsCoverTemplates {
|
||||
appendTemplates(.cover, to: &snapshot)
|
||||
for category in viewModel.visibleCategories {
|
||||
appendTemplates(category, to: &snapshot)
|
||||
}
|
||||
snapshot.appendSections([.mode])
|
||||
snapshot.appendItems([.mode], toSection: .mode)
|
||||
|
||||
@@ -489,6 +489,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
startProjectIndex: startIndex,
|
||||
configuration: previewConfiguration,
|
||||
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
|
||||
albumId: viewModel.albumId,
|
||||
scenicIdProvider: scenicIdProvider,
|
||||
aiRetouchAPI: previewAPI,
|
||||
loadMore: {
|
||||
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
|
||||
return (
|
||||
@@ -496,6 +499,13 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
previewViewModel.currentPhotoCount
|
||||
)
|
||||
},
|
||||
reload: {
|
||||
let response = try await previewViewModel.reloadLoadedMaterials(api: previewAPI)
|
||||
return (
|
||||
response.list.map(TravelAlbumPreviewProject.init(material:)),
|
||||
response.total
|
||||
)
|
||||
},
|
||||
onProjectDeleted: { materialId in
|
||||
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user