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
@@ -49,6 +49,9 @@ protocol TravelAlbumServing {
/// 提交相册素材 AI 修图任务。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
/// 提交单张素材重新修图任务。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws
}
@MainActor
@@ -179,6 +182,13 @@ final class TravelAlbumAPI: TravelAlbumServing {
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
)
}
/// 提交重新修图任务;服务端返回的批次与额度信息当前无需消费。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request)
)
}
}
/// 旅拍相册 ID 请求体。
@@ -129,6 +129,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
let isPurchased: Bool
let aiRetouchStatus: Int
let aiRetouchStatusName: String
let aiRetouchBatchId: Int
let aiRefinedURL: String
let aiAtmosphereURL: String
let createdAt: String
@@ -148,6 +149,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
case isPurchased = "is_purchased"
case aiRetouchStatus = "ai_retouch_status"
case aiRetouchStatusName = "ai_retouch_status_name"
case aiRetouchBatchId = "ai_retouch_batch_id"
case aiRefinedURL = "ai_refined_url"
case aiAtmosphereURL = "ai_atmosphere_url"
case createdAt = "created_at"
@@ -172,6 +174,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
aiRetouchStatusName = (
try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName)
) ?? ""
aiRetouchBatchId = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchBatchId)) ?? 0
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
aiAtmosphereURL = (
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
@@ -194,6 +197,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
isPurchased: Bool = false,
aiRetouchStatus: Int = 0,
aiRetouchStatusName: String = "",
aiRetouchBatchId: Int = 0,
aiRefinedURL: String = "",
aiAtmosphereURL: String = "",
createdAt: String = "",
@@ -212,6 +216,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
self.isPurchased = isPurchased
self.aiRetouchStatus = aiRetouchStatus
self.aiRetouchStatusName = aiRetouchStatusName
self.aiRetouchBatchId = aiRetouchBatchId
self.aiRefinedURL = aiRefinedURL
self.aiAtmosphereURL = aiAtmosphereURL
self.createdAt = createdAt
@@ -342,6 +347,62 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
}
}
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
case refined
case atmosphere
case cover
/// 模板分组展示标题。
var title: String {
switch self {
case .refined: "原图精修"
case .atmosphere: "氛围感修图"
case .cover: "封面风格模板"
}
}
}
/// AI 修图模板页工作流,明确区分首次批量修图与单张结果覆盖重修。
enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
/// 对一个相册内的原始素材发起首次 AI 修图。
case initial(albumId: Int, materialIds: [Int])
/// 对单张素材的既有 AI 结果发起覆盖重修。
case reretouch(materialId: Int, batchId: Int, type: TravelAlbumAIReretouchType)
/// 当前页面需要展示的模板分组。
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
switch self {
case .initial(_, let materialIds):
var categories: [TravelAlbumAIRetouchTemplateCategory] = [.refined, .atmosphere]
if materialIds.count >= 4 { categories.append(.cover) }
return categories
case .reretouch(_, _, .refined):
return [.refined]
case .reretouch(_, _, .atmosphere):
return [.atmosphere]
case .reretouch(_, _, .all):
return [.refined, .atmosphere]
}
}
/// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
if case .initial = self, category == .atmosphere { return true }
return false
}
/// 工作流目标是否满足接口的最小参数要求。
var isValid: Bool {
switch self {
case .initial(let albumId, let materialIds):
return albumId > 0 && !materialIds.isEmpty
case .reretouch(let materialId, let batchId, _):
return materialId > 0 && batchId > 0
}
}
}
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
@@ -392,14 +453,38 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
let materialIds: [Int]
let refinedTemplateId: Int
let atmosphereTemplateId: Int?
let aiPreviewTabs: Int?
let coverTemplateId: Int?
enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id"
case materialIds = "material_ids"
case refinedTemplateId = "refined_template_id"
case atmosphereTemplateId = "atmosphere_template_id"
case aiPreviewTabs = "ai_preview_tabs"
case coverTemplateId = "cover_template_id"
}
}
/// 重新修图类型,决定后端覆盖的 AI 结果及必需模板字段。
enum TravelAlbumAIReretouchType: Int, Encodable, Sendable, Equatable {
case refined = 1
case atmosphere = 2
case all = 3
}
/// 提交单张素材重新修图的最小请求参数。
struct TravelAlbumAIReretouchRequest: Encodable, Sendable, Equatable {
let id: Int
let aiRetouchBatchId: Int
let type: TravelAlbumAIReretouchType
let refinedTemplateId: Int?
let atmosphereTemplateId: Int?
enum CodingKeys: String, CodingKey {
case id
case aiRetouchBatchId = "ai_retouch_batch_id"
case type
case refinedTemplateId = "refined_template_id"
case atmosphereTemplateId = "atmosphere_template_id"
}
}
@@ -65,6 +65,7 @@ struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable {
/// 一张原图及其所有关联图片组成的预览项目。
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
let originalMaterialId: Int
let aiRetouchBatchId: Int
let assets: [TravelAlbumPreviewAsset]
var id: Int { originalMaterialId }
@@ -87,6 +88,7 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
init(material: TravelAlbumMaterial) {
originalMaterialId = material.id
aiRetouchBatchId = material.aiRetouchBatchId
var mappedAssets = [
TravelAlbumPreviewAsset(
id: "original-\(material.id)",
@@ -127,11 +129,32 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
}
/// 创建包含关联图片的项目,主要供适配器和测试使用。
init(originalMaterialId: Int, assets: [TravelAlbumPreviewAsset]) {
init(originalMaterialId: Int, aiRetouchBatchId: Int = 0, assets: [TravelAlbumPreviewAsset]) {
self.originalMaterialId = originalMaterialId
self.aiRetouchBatchId = aiRetouchBatchId
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
}
/// 根据当前 Tab 生成首次修图或覆盖重修工作流。
func aiRetouchWorkflow(
albumId: Int,
selectedKind: TravelAlbumPreviewAssetKind
) -> TravelAlbumAIRetouchWorkflow? {
guard hasVariants else {
return .initial(albumId: albumId, materialIds: [originalMaterialId])
}
switch selectedKind {
case .original:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
case .retouched:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined)
case .atmosphere:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere)
case .cover:
return nil
}
}
}
/// 预览分页节点,记录当前图片所属项目及类型。
@@ -180,31 +203,21 @@ enum TravelAlbumPreviewNavigator {
}
}
/// 预览操作执行结果,便于后续替换真实业务接口。
/// 预览删除操作执行结果,统一表达成功、失败与暂不可用状态。
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
case success(String?)
case failure(String)
case unavailable(String)
}
/// 预览页底部操作协议,所有操作均以原素材项目 ID 为目标。
/// 预览页删除操作协议,以原素材项目 ID 为目标。
protocol TravelAlbumPreviewActionHandling: AnyObject {
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
}
/// 新接口接入前的占位操作实现,不修改任何业务数据。
/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("AI修图功能待接口接入")
}
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("项目删除接口待接入")
}
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("关联图片刷新接口待接入")
}
}
@@ -5,22 +5,7 @@
import Foundation
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
case refined
case atmosphere
case cover
var title: String {
switch self {
case .refined: "原图精修"
case .atmosphere: "氛围感修图"
case .cover: "封面风格模板"
}
}
}
/// AI 修图模板选择状态,负责实时加载、单选规则、校验和任务提交。
/// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。
final class TravelAlbumAIRetouchTemplateViewModel {
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
@@ -32,34 +17,58 @@ final class TravelAlbumAIRetouchTemplateViewModel {
private(set) var isSubmitting = false
private(set) var loadErrorMessage: String?
let albumId: Int
let scenicId: Int
let materialIds: [Int]
let workflow: TravelAlbumAIRetouchWorkflow
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitted: (() -> Void)?
/// 创建模板选择状态;素材 ID 会排序并去重,确保提交稳定。
init(albumId: Int, scenicId: Int, materialIds: [Int]) {
self.albumId = albumId
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
self.init(
scenicId: scenicId,
workflow: .initial(albumId: albumId, materialIds: materialIds)
)
}
/// 使用明确工作流创建模板选择状态。
init(scenicId: Int, workflow: TravelAlbumAIRetouchWorkflow) {
self.scenicId = scenicId
self.materialIds = Array(Set(materialIds)).sorted()
switch workflow {
case .initial(let albumId, let materialIds):
self.workflow = .initial(
albumId: albumId,
materialIds: Array(Set(materialIds)).sorted()
)
case .reretouch:
self.workflow = workflow
}
}
/// 选中不少于四张素材时才展示并要求选择封面模板。
var showsCoverTemplates: Bool {
materialIds.count >= 4
/// 当前工作流需要展示的模板分组。
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
workflow.visibleCategories
}
/// 当前必选模板缺失时用于底部提示的文案。
/// 当前分组是否为选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
workflow.isOptional(category)
}
/// 当前必选模板或业务参数缺失时用于底部提示的文案。
var validationMessage: String? {
guard !isLoading, loadErrorMessage == nil else { return nil }
if refinedTemplates.isEmpty || selectedRefinedTemplateId == nil {
return "暂无可用的原图精修模板"
guard workflow.isValid else {
if case .reretouch = workflow {
return "当前图片缺少修图批次,请刷新后重试"
}
return "请选择要修图的照片"
}
if showsCoverTemplates, coverTemplates.isEmpty || selectedCoverTemplateId == nil {
return "暂无可用的封面风格模板"
for category in visibleCategories where !isOptional(category) {
if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil {
return unavailableMessage(for: category)
}
}
return nil
}
@@ -69,12 +78,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
!isLoading
&& !isSubmitting
&& loadErrorMessage == nil
&& !materialIds.isEmpty
&& selectedRefinedTemplateId != nil
&& (!showsCoverTemplates || selectedCoverTemplateId != nil)
&& workflow.isValid
&& validationMessage == nil
}
/// 实时拉取当前景区模板并按规则设置默认选择。
/// 实时拉取当前景区模板并按工作流设置默认选择。
func loadTemplates(api: any TravelAlbumServing) async {
guard scenicId > 0 else {
loadErrorMessage = "请先选择景区"
@@ -91,9 +99,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
refinedTemplates = response.refinedTemplates
atmosphereTemplates = response.atmosphereTemplates
coverTemplates = response.coverTemplates
selectedRefinedTemplateId = refinedTemplates.first?.id
selectedAtmosphereTemplateId = nil
selectedCoverTemplateId = showsCoverTemplates ? coverTemplates.first?.id : nil
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
? atmosphereTemplates.first?.id
: nil
selectedCoverTemplateId = visibleCategories.contains(.cover) ? coverTemplates.first?.id : nil
isLoading = false
notifyStateChange()
} catch is CancellationError {
@@ -130,34 +140,27 @@ final class TravelAlbumAIRetouchTemplateViewModel {
}
}
/// 选择模板;氛围感模板再次点击时取消,必选分组保持单选。
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
guard templates(for: category).contains(where: { $0.id == id }) else { return }
guard visibleCategories.contains(category),
templates(for: category).contains(where: { $0.id == id })
else { return }
switch category {
case .refined:
selectedRefinedTemplateId = id
case .atmosphere:
selectedAtmosphereTemplateId = selectedAtmosphereTemplateId == id ? nil : id
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
case .cover:
guard showsCoverTemplates else { return }
selectedCoverTemplateId = id
}
notifyStateChange()
}
/// 提交 AI 修图任务,成功后通知页面退出选择态。
/// 按当前工作流提交首次修图或覆盖重修任务。
func submit(api: any TravelAlbumServing) async {
guard !isSubmitting else { return }
guard let refinedTemplateId = selectedRefinedTemplateId else {
onShowMessage?("请选择原图精修模板")
return
}
guard !showsCoverTemplates || selectedCoverTemplateId != nil else {
onShowMessage?("请选择封面风格模板")
return
}
guard !materialIds.isEmpty else {
onShowMessage?("请选择要修图的照片")
guard canSubmit else {
onShowMessage?(validationMessage ?? "当前无法提交AI修图")
return
}
@@ -169,15 +172,32 @@ final class TravelAlbumAIRetouchTemplateViewModel {
}
do {
try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: albumId,
materialIds: materialIds,
refinedTemplateId: refinedTemplateId,
atmosphereTemplateId: selectedAtmosphereTemplateId,
aiPreviewTabs: showsCoverTemplates ? selectedCoverTemplateId : nil
switch workflow {
case .initial(let albumId, let materialIds):
guard let refinedTemplateId = selectedRefinedTemplateId else {
onShowMessage?("请选择原图精修模板")
return
}
try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: albumId,
materialIds: materialIds,
refinedTemplateId: refinedTemplateId,
atmosphereTemplateId: selectedAtmosphereTemplateId,
coverTemplateId: selectedCoverTemplateId
)
)
)
case .reretouch(let materialId, let batchId, let type):
try await api.submitAIReretouch(
TravelAlbumAIReretouchRequest(
id: materialId,
aiRetouchBatchId: batchId,
type: type,
refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId,
atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId
)
)
}
onSubmitted?()
} catch is CancellationError {
return
@@ -186,6 +206,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
}
}
private func unavailableMessage(for category: TravelAlbumAIRetouchTemplateCategory) -> String {
switch category {
case .refined: "暂无可用的原图精修模板"
case .atmosphere: "暂无可用的氛围感修图模板"
case .cover: "暂无可用的封面风格模板"
}
}
private func notifyStateChange() {
onStateChange?()
}
@@ -207,6 +207,43 @@ final class TravelAlbumDetailViewModel {
}
}
/// 重新拉取当前筛选、排序下已经加载的分页范围,供全屏预览刷新关联图片。
func reloadLoadedMaterials(
api: any TravelAlbumServing
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
let requestedPageCount = max(currentPage, 1)
var refreshed: [TravelAlbumMaterial] = []
var refreshedTotal = 0
var loadedPageCount = 0
for page in 1 ... requestedPageCount {
let response = try await api.materialList(
userEquityTravelId: albumId,
page: page,
pageSize: pageSize,
orderBy: sortOption.rawValue,
isPurchased: selectedTab == .purchased ? 1 : nil
)
if page == 1 { refreshedTotal = response.total }
refreshed.append(contentsOf: response.list)
loadedPageCount = page
if refreshed.count >= refreshedTotal || response.list.isEmpty { break }
}
var seen = Set<Int>()
materials = refreshed.filter { seen.insert($0.id).inserted }
currentPage = max(loadedPageCount, 1)
canLoadMore = materials.count < refreshedTotal
selectedMaterialIds.formIntersection(materials.map(\.id))
if selectedTab == .all {
allPhotoCount = refreshedTotal
} else {
purchasedPhotoCount = refreshedTotal
}
notifyStateChange()
return TravelAlbumListResponse(total: refreshedTotal, list: materials)
}
/// 切换选择模式。
func toggleSelectionMode() {
guard selectedTab == .all else { return }
@@ -14,11 +14,6 @@ final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
self.api = api
}
/// AI 修图仍由相册选择态的模板流程发起。
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("请在相册中选择照片后使用AI修图")
}
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
do {
@@ -31,9 +26,4 @@ final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
return .failure(message.isEmpty ? "删除失败" : message)
}
}
/// 刷新关联结果图接口尚未提供。
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("关联图片刷新接口待接入")
}
}
@@ -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)
}
}