271 lines
10 KiB
Swift
271 lines
10 KiB
Swift
//
|
|
// TravelAlbumPreviewModels.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。
|
|
struct BeforeAfterComparisonContent: Sendable, Equatable {
|
|
let beforeURL: String
|
|
let afterURL: String
|
|
let beforeLabel: String
|
|
let afterLabel: String
|
|
|
|
/// 创建前后对比内容,默认使用 AI 修图模块的中文角标。
|
|
init(
|
|
beforeURL: String,
|
|
afterURL: String,
|
|
beforeLabel: String = "原图",
|
|
afterLabel: String = "效果图"
|
|
) {
|
|
self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.beforeLabel = beforeLabel
|
|
self.afterLabel = afterLabel
|
|
}
|
|
|
|
/// 两张图片地址均存在时才允许进入对比页。
|
|
var isValid: Bool {
|
|
!beforeURL.isEmpty && !afterURL.isEmpty
|
|
}
|
|
}
|
|
|
|
/// 相册预览页横向滑动策略,由调用方通过配置注入。
|
|
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
|
|
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
|
case projectsOnly
|
|
/// 横滑依次切换当前项目的关联图,并在边界进入相邻项目原图。
|
|
case includeVariants
|
|
}
|
|
|
|
/// 相册预览页配置。
|
|
struct TravelAlbumPreviewConfiguration: Sendable {
|
|
let swipeMode: TravelAlbumPreviewSwipeMode
|
|
|
|
/// 创建预览配置,默认只按原图项目分页。
|
|
init(swipeMode: TravelAlbumPreviewSwipeMode = .projectsOnly) {
|
|
self.swipeMode = swipeMode
|
|
}
|
|
}
|
|
|
|
/// 预览图片类型,顺序同时决定 Tab 与关联图浏览顺序。
|
|
enum TravelAlbumPreviewAssetKind: Int, CaseIterable, Sendable, Hashable {
|
|
case original
|
|
case retouched
|
|
case atmosphere
|
|
case cover
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .original: "原图"
|
|
case .retouched: "精修后"
|
|
case .atmosphere: "氛围感"
|
|
case .cover: "封面"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 预览页使用的单张图片信息,与后端关联图字段解耦。
|
|
struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable {
|
|
let id: String
|
|
let kind: TravelAlbumPreviewAssetKind
|
|
let fileURL: String
|
|
let coverURL: String
|
|
let fileName: String
|
|
let fileSize: Int
|
|
|
|
/// 实际用于展示的地址,优先使用原图地址。
|
|
var displayURL: String {
|
|
let trimmedFileURL = fileURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmedFileURL.isEmpty ? coverURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedFileURL
|
|
}
|
|
|
|
/// 横滑预览地址,优先使用体积更小的封面图以降低解码开销。
|
|
var previewURL: String {
|
|
let trimmedCoverURL = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmedCoverURL.isEmpty ? fileURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedCoverURL
|
|
}
|
|
}
|
|
|
|
/// 一张原图及其所有关联图片组成的预览项目。
|
|
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
|
let originalMaterialId: Int
|
|
let aiRetouchBatchId: Int
|
|
let assets: [TravelAlbumPreviewAsset]
|
|
|
|
var id: Int { originalMaterialId }
|
|
|
|
/// 按产品约定顺序返回实际存在的图片。
|
|
var orderedAssets: [TravelAlbumPreviewAsset] {
|
|
TravelAlbumPreviewAssetKind.allCases.compactMap(asset(for:))
|
|
}
|
|
|
|
/// 是否存在原图之外的关联图片。
|
|
var hasVariants: Bool {
|
|
orderedAssets.contains { $0.kind != .original }
|
|
}
|
|
|
|
/// 返回指定类型的图片。
|
|
func asset(for kind: TravelAlbumPreviewAssetKind) -> TravelAlbumPreviewAsset? {
|
|
assets.first { $0.kind == kind }
|
|
}
|
|
|
|
/// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。
|
|
func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? {
|
|
guard kind == .retouched || kind == .atmosphere,
|
|
let original = asset(for: .original),
|
|
let result = asset(for: kind)
|
|
else { return nil }
|
|
let content = BeforeAfterComparisonContent(
|
|
beforeURL: original.displayURL,
|
|
afterURL: result.displayURL
|
|
)
|
|
return content.isValid ? content : nil
|
|
}
|
|
|
|
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
|
init(material: TravelAlbumMaterial) {
|
|
originalMaterialId = material.id
|
|
aiRetouchBatchId = material.aiRetouchBatchId
|
|
var mappedAssets = [
|
|
TravelAlbumPreviewAsset(
|
|
id: "original-\(material.id)",
|
|
kind: .original,
|
|
fileURL: material.fileUrl,
|
|
coverURL: material.coverUrl,
|
|
fileName: material.fileName,
|
|
fileSize: material.fileSize
|
|
),
|
|
]
|
|
let refinedURL = material.aiRefinedURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !refinedURL.isEmpty {
|
|
mappedAssets.append(
|
|
TravelAlbumPreviewAsset(
|
|
id: "retouched-\(material.id)",
|
|
kind: .retouched,
|
|
fileURL: refinedURL,
|
|
coverURL: refinedURL,
|
|
fileName: material.fileName,
|
|
fileSize: material.fileSize
|
|
)
|
|
)
|
|
}
|
|
let atmosphereURL = material.aiAtmosphereURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !atmosphereURL.isEmpty {
|
|
mappedAssets.append(
|
|
TravelAlbumPreviewAsset(
|
|
id: "atmosphere-\(material.id)",
|
|
kind: .atmosphere,
|
|
fileURL: atmosphereURL,
|
|
coverURL: atmosphereURL,
|
|
fileName: material.fileName,
|
|
fileSize: material.fileSize
|
|
)
|
|
)
|
|
}
|
|
assets = mappedAssets
|
|
}
|
|
|
|
/// 创建包含关联图片的项目,主要供适配器和测试使用。
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TravelAlbumAIRetouchTemplate {
|
|
/// 使用接口返回的模板示例前后图构建对比页内容。
|
|
var comparisonContent: BeforeAfterComparisonContent? {
|
|
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
|
|
return content.isValid ? content : nil
|
|
}
|
|
}
|
|
|
|
/// 预览分页节点,记录当前图片所属项目及类型。
|
|
struct TravelAlbumPreviewNode: Sendable, Hashable {
|
|
let projectIndex: Int
|
|
let kind: TravelAlbumPreviewAssetKind
|
|
}
|
|
|
|
/// 预览页纯状态工具,负责构建节点和处理分组滑动边界。
|
|
enum TravelAlbumPreviewNavigator {
|
|
/// 根据滑动模式生成页面节点。
|
|
static func nodes(
|
|
projects: [TravelAlbumPreviewProject],
|
|
mode: TravelAlbumPreviewSwipeMode
|
|
) -> [TravelAlbumPreviewNode] {
|
|
projects.enumerated().flatMap { index, project in
|
|
switch mode {
|
|
case .projectsOnly:
|
|
[TravelAlbumPreviewNode(projectIndex: index, kind: .original)]
|
|
case .includeVariants:
|
|
project.orderedAssets.map { TravelAlbumPreviewNode(projectIndex: index, kind: $0.kind) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 处理反向滑动边界:从项目原图向右滑时直接进入上一项目原图。
|
|
static func backwardTargetIndex(
|
|
nodes: [TravelAlbumPreviewNode],
|
|
currentIndex: Int
|
|
) -> Int {
|
|
guard nodes.indices.contains(currentIndex), currentIndex > 0 else { return max(0, currentIndex) }
|
|
let current = nodes[currentIndex]
|
|
guard current.kind == .original, current.projectIndex > 0 else { return currentIndex - 1 }
|
|
return nodes.firstIndex {
|
|
$0.projectIndex == current.projectIndex - 1 && $0.kind == .original
|
|
} ?? currentIndex - 1
|
|
}
|
|
|
|
/// 返回删除当前项目后应展示的项目索引;优先保持原索引以显示下一张,末项则回退上一张。
|
|
static func projectIndexAfterDeletion(
|
|
deletedProjectIndex: Int,
|
|
remainingProjectCount: Int
|
|
) -> Int? {
|
|
guard remainingProjectCount > 0 else { return nil }
|
|
return min(max(0, deletedProjectIndex), remainingProjectCount - 1)
|
|
}
|
|
}
|
|
|
|
/// 预览删除操作执行结果,统一表达成功、失败与暂不可用状态。
|
|
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
|
|
case success(String?)
|
|
case failure(String)
|
|
case unavailable(String)
|
|
}
|
|
|
|
/// 预览页删除操作协议,以原素材项目 ID 为目标。
|
|
protocol TravelAlbumPreviewActionHandling: AnyObject {
|
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
|
}
|
|
|
|
/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。
|
|
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
|
.unavailable("项目删除接口待接入")
|
|
}
|
|
}
|