feat: add iOS travel album photo preview
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
//
|
||||
// TravelAlbumPreviewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 相册预览页横向滑动策略,由调用方通过配置注入。
|
||||
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 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 }
|
||||
}
|
||||
|
||||
/// 将当前素材映射为仅含原图的预览项目;新接口接入后替换此适配层即可。
|
||||
init(material: TravelAlbumMaterial) {
|
||||
originalMaterialId = material.id
|
||||
assets = [
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "original-\(material.id)",
|
||||
kind: .original,
|
||||
fileURL: material.fileUrl,
|
||||
coverURL: material.coverUrl,
|
||||
fileName: material.fileName,
|
||||
fileSize: material.fileSize
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// 创建包含关联图片的项目,主要供适配器和测试使用。
|
||||
init(originalMaterialId: Int, assets: [TravelAlbumPreviewAsset]) {
|
||||
self.originalMaterialId = originalMaterialId
|
||||
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
|
||||
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览分页节点,记录当前图片所属项目及类型。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览操作执行结果,便于后续替换真实业务接口。
|
||||
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
|
||||
case success(String?)
|
||||
case failure(String)
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
/// 预览页底部操作协议,所有操作均以原素材项目 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("关联图片刷新接口待接入")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user