Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift
T

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("项目删除接口待接入")
}
}