feat: 接通图片预览 AI 修图流程
This commit is contained in:
@@ -49,6 +49,9 @@ protocol TravelAlbumServing {
|
|||||||
|
|
||||||
/// 提交相册素材 AI 修图任务。
|
/// 提交相册素材 AI 修图任务。
|
||||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
|
||||||
|
|
||||||
|
/// 提交单张素材重新修图任务。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -179,6 +182,13 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
|
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 请求体。
|
/// 旅拍相册 ID 请求体。
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
let isPurchased: Bool
|
let isPurchased: Bool
|
||||||
let aiRetouchStatus: Int
|
let aiRetouchStatus: Int
|
||||||
let aiRetouchStatusName: String
|
let aiRetouchStatusName: String
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
let aiRefinedURL: String
|
let aiRefinedURL: String
|
||||||
let aiAtmosphereURL: String
|
let aiAtmosphereURL: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
@@ -148,6 +149,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
case isPurchased = "is_purchased"
|
case isPurchased = "is_purchased"
|
||||||
case aiRetouchStatus = "ai_retouch_status"
|
case aiRetouchStatus = "ai_retouch_status"
|
||||||
case aiRetouchStatusName = "ai_retouch_status_name"
|
case aiRetouchStatusName = "ai_retouch_status_name"
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
case aiRefinedURL = "ai_refined_url"
|
case aiRefinedURL = "ai_refined_url"
|
||||||
case aiAtmosphereURL = "ai_atmosphere_url"
|
case aiAtmosphereURL = "ai_atmosphere_url"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
@@ -172,6 +174,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
aiRetouchStatusName = (
|
aiRetouchStatusName = (
|
||||||
try? container.decodeIfPresent(String.self, forKey: .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)) ?? ""
|
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
|
||||||
aiAtmosphereURL = (
|
aiAtmosphereURL = (
|
||||||
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
|
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
|
||||||
@@ -194,6 +197,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
isPurchased: Bool = false,
|
isPurchased: Bool = false,
|
||||||
aiRetouchStatus: Int = 0,
|
aiRetouchStatus: Int = 0,
|
||||||
aiRetouchStatusName: String = "",
|
aiRetouchStatusName: String = "",
|
||||||
|
aiRetouchBatchId: Int = 0,
|
||||||
aiRefinedURL: String = "",
|
aiRefinedURL: String = "",
|
||||||
aiAtmosphereURL: String = "",
|
aiAtmosphereURL: String = "",
|
||||||
createdAt: String = "",
|
createdAt: String = "",
|
||||||
@@ -212,6 +216,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
self.isPurchased = isPurchased
|
self.isPurchased = isPurchased
|
||||||
self.aiRetouchStatus = aiRetouchStatus
|
self.aiRetouchStatus = aiRetouchStatus
|
||||||
self.aiRetouchStatusName = aiRetouchStatusName
|
self.aiRetouchStatusName = aiRetouchStatusName
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
self.aiRefinedURL = aiRefinedURL
|
self.aiRefinedURL = aiRefinedURL
|
||||||
self.aiAtmosphereURL = aiAtmosphereURL
|
self.aiAtmosphereURL = aiAtmosphereURL
|
||||||
self.createdAt = createdAt
|
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、展示名称和预览图地址。
|
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
|
||||||
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
@@ -392,14 +453,38 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
|||||||
let materialIds: [Int]
|
let materialIds: [Int]
|
||||||
let refinedTemplateId: Int
|
let refinedTemplateId: Int
|
||||||
let atmosphereTemplateId: Int?
|
let atmosphereTemplateId: Int?
|
||||||
let aiPreviewTabs: Int?
|
let coverTemplateId: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case userEquityTravelId = "user_equity_travel_id"
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
case materialIds = "material_ids"
|
case materialIds = "material_ids"
|
||||||
case refinedTemplateId = "refined_template_id"
|
case refinedTemplateId = "refined_template_id"
|
||||||
case atmosphereTemplateId = "atmosphere_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 {
|
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||||
let originalMaterialId: Int
|
let originalMaterialId: Int
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
let assets: [TravelAlbumPreviewAsset]
|
let assets: [TravelAlbumPreviewAsset]
|
||||||
|
|
||||||
var id: Int { originalMaterialId }
|
var id: Int { originalMaterialId }
|
||||||
@@ -87,6 +88,7 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
|||||||
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
||||||
init(material: TravelAlbumMaterial) {
|
init(material: TravelAlbumMaterial) {
|
||||||
originalMaterialId = material.id
|
originalMaterialId = material.id
|
||||||
|
aiRetouchBatchId = material.aiRetouchBatchId
|
||||||
var mappedAssets = [
|
var mappedAssets = [
|
||||||
TravelAlbumPreviewAsset(
|
TravelAlbumPreviewAsset(
|
||||||
id: "original-\(material.id)",
|
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.originalMaterialId = originalMaterialId
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
|
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
|
||||||
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
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 {
|
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
|
||||||
case success(String?)
|
case success(String?)
|
||||||
case failure(String)
|
case failure(String)
|
||||||
case unavailable(String)
|
case unavailable(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 预览页底部操作协议,所有操作均以原素材项目 ID 为目标。
|
/// 预览页删除操作协议,以原素材项目 ID 为目标。
|
||||||
protocol TravelAlbumPreviewActionHandling: AnyObject {
|
protocol TravelAlbumPreviewActionHandling: AnyObject {
|
||||||
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
|
||||||
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
||||||
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 新接口接入前的占位操作实现,不修改任何业务数据。
|
/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。
|
||||||
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
||||||
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
|
||||||
.unavailable("AI修图功能待接口接入")
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
.unavailable("项目删除接口待接入")
|
.unavailable("项目删除接口待接入")
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
|
||||||
.unavailable("关联图片刷新接口待接入")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-60
@@ -5,22 +5,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
|
/// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。
|
||||||
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
|
|
||||||
case refined
|
|
||||||
case atmosphere
|
|
||||||
case cover
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .refined: "原图精修"
|
|
||||||
case .atmosphere: "氛围感修图"
|
|
||||||
case .cover: "封面风格模板"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AI 修图模板选择状态,负责实时加载、单选规则、校验和任务提交。
|
|
||||||
final class TravelAlbumAIRetouchTemplateViewModel {
|
final class TravelAlbumAIRetouchTemplateViewModel {
|
||||||
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
|
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
|
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
@@ -32,34 +17,58 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
private(set) var isSubmitting = false
|
private(set) var isSubmitting = false
|
||||||
private(set) var loadErrorMessage: String?
|
private(set) var loadErrorMessage: String?
|
||||||
|
|
||||||
let albumId: Int
|
|
||||||
let scenicId: Int
|
let scenicId: Int
|
||||||
let materialIds: [Int]
|
let workflow: TravelAlbumAIRetouchWorkflow
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
var onSubmitted: (() -> Void)?
|
var onSubmitted: (() -> Void)?
|
||||||
|
|
||||||
/// 创建模板选择状态;素材 ID 会排序并去重,确保提交稳定。
|
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
|
||||||
init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
||||||
self.albumId = albumId
|
self.init(
|
||||||
|
scenicId: scenicId,
|
||||||
|
workflow: .initial(albumId: albumId, materialIds: materialIds)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用明确工作流创建模板选择状态。
|
||||||
|
init(scenicId: Int, workflow: TravelAlbumAIRetouchWorkflow) {
|
||||||
self.scenicId = scenicId
|
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 {
|
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
|
||||||
materialIds.count >= 4
|
workflow.visibleCategories
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前必选模板缺失时用于底部提示的文案。
|
/// 当前分组是否为选填。
|
||||||
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
|
workflow.isOptional(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前必选模板或业务参数缺失时用于底部提示的文案。
|
||||||
var validationMessage: String? {
|
var validationMessage: String? {
|
||||||
guard !isLoading, loadErrorMessage == nil else { return nil }
|
guard !isLoading, loadErrorMessage == nil else { return nil }
|
||||||
if refinedTemplates.isEmpty || selectedRefinedTemplateId == nil {
|
guard workflow.isValid else {
|
||||||
return "暂无可用的原图精修模板"
|
if case .reretouch = workflow {
|
||||||
|
return "当前图片缺少修图批次,请刷新后重试"
|
||||||
|
}
|
||||||
|
return "请选择要修图的照片"
|
||||||
}
|
}
|
||||||
if showsCoverTemplates, coverTemplates.isEmpty || selectedCoverTemplateId == nil {
|
for category in visibleCategories where !isOptional(category) {
|
||||||
return "暂无可用的封面风格模板"
|
if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil {
|
||||||
|
return unavailableMessage(for: category)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -69,12 +78,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
!isLoading
|
!isLoading
|
||||||
&& !isSubmitting
|
&& !isSubmitting
|
||||||
&& loadErrorMessage == nil
|
&& loadErrorMessage == nil
|
||||||
&& !materialIds.isEmpty
|
&& workflow.isValid
|
||||||
&& selectedRefinedTemplateId != nil
|
&& validationMessage == nil
|
||||||
&& (!showsCoverTemplates || selectedCoverTemplateId != nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 实时拉取当前景区模板并按规则设置默认选择。
|
/// 实时拉取当前景区模板并按工作流设置默认选择。
|
||||||
func loadTemplates(api: any TravelAlbumServing) async {
|
func loadTemplates(api: any TravelAlbumServing) async {
|
||||||
guard scenicId > 0 else {
|
guard scenicId > 0 else {
|
||||||
loadErrorMessage = "请先选择景区"
|
loadErrorMessage = "请先选择景区"
|
||||||
@@ -91,9 +99,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
refinedTemplates = response.refinedTemplates
|
refinedTemplates = response.refinedTemplates
|
||||||
atmosphereTemplates = response.atmosphereTemplates
|
atmosphereTemplates = response.atmosphereTemplates
|
||||||
coverTemplates = response.coverTemplates
|
coverTemplates = response.coverTemplates
|
||||||
selectedRefinedTemplateId = refinedTemplates.first?.id
|
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
|
||||||
selectedAtmosphereTemplateId = nil
|
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
||||||
selectedCoverTemplateId = showsCoverTemplates ? coverTemplates.first?.id : nil
|
? atmosphereTemplates.first?.id
|
||||||
|
: nil
|
||||||
|
selectedCoverTemplateId = visibleCategories.contains(.cover) ? coverTemplates.first?.id : nil
|
||||||
isLoading = false
|
isLoading = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
@@ -130,34 +140,27 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 选择模板;氛围感模板再次点击时取消,必选分组保持单选。
|
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。
|
||||||
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
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 {
|
switch category {
|
||||||
case .refined:
|
case .refined:
|
||||||
selectedRefinedTemplateId = id
|
selectedRefinedTemplateId = id
|
||||||
case .atmosphere:
|
case .atmosphere:
|
||||||
selectedAtmosphereTemplateId = selectedAtmosphereTemplateId == id ? nil : id
|
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
||||||
case .cover:
|
case .cover:
|
||||||
guard showsCoverTemplates else { return }
|
|
||||||
selectedCoverTemplateId = id
|
selectedCoverTemplateId = id
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提交 AI 修图任务,成功后通知页面退出选择态。
|
/// 按当前工作流提交首次修图或覆盖重修任务。
|
||||||
func submit(api: any TravelAlbumServing) async {
|
func submit(api: any TravelAlbumServing) async {
|
||||||
guard !isSubmitting else { return }
|
guard !isSubmitting else { return }
|
||||||
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
guard canSubmit else {
|
||||||
onShowMessage?("请选择原图精修模板")
|
onShowMessage?(validationMessage ?? "当前无法提交AI修图")
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !showsCoverTemplates || selectedCoverTemplateId != nil else {
|
|
||||||
onShowMessage?("请选择封面风格模板")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !materialIds.isEmpty else {
|
|
||||||
onShowMessage?("请选择要修图的照片")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,15 +172,32 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await api.submitAIRetouch(
|
switch workflow {
|
||||||
TravelAlbumAIRetouchRequest(
|
case .initial(let albumId, let materialIds):
|
||||||
userEquityTravelId: albumId,
|
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
||||||
materialIds: materialIds,
|
onShowMessage?("请选择原图精修模板")
|
||||||
refinedTemplateId: refinedTemplateId,
|
return
|
||||||
atmosphereTemplateId: selectedAtmosphereTemplateId,
|
}
|
||||||
aiPreviewTabs: showsCoverTemplates ? selectedCoverTemplateId : nil
|
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?()
|
onSubmitted?()
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
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() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
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() {
|
func toggleSelectionMode() {
|
||||||
guard selectedTab == .all else { return }
|
guard selectedTab == .all else { return }
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
|||||||
self.api = api
|
self.api = api
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 修图仍由相册选择态的模板流程发起。
|
|
||||||
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
|
||||||
.unavailable("请在相册中选择照片后使用AI修图")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
||||||
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
do {
|
do {
|
||||||
@@ -31,9 +26,4 @@ final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
|||||||
return .failure(message.isEmpty ? "删除失败" : message)
|
return .failure(message.isEmpty ? "删除失败" : message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 刷新关联结果图接口尚未提供。
|
|
||||||
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
|
||||||
.unavailable("关联图片刷新接口待接入")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Kingfisher
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// AI 修图模板选择 Sheet,展示三类横向模板列表、修图模式与固定底部操作。
|
/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。
|
||||||
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||||
/// 页面使用的 collection section。
|
/// 页面使用的 collection section。
|
||||||
private enum Section: Hashable {
|
private enum Section: Hashable {
|
||||||
@@ -276,7 +276,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
||||||
for: indexPath
|
for: indexPath
|
||||||
) as! TravelAlbumAIRetouchSectionHeader
|
) as! TravelAlbumAIRetouchSectionHeader
|
||||||
header.apply(title: category.title, optional: category == .atmosphere)
|
header.apply(title: category.title, optional: self.viewModel.isOptional(category))
|
||||||
return header
|
return header
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,10 +359,8 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
|
|
||||||
private func applySnapshot() {
|
private func applySnapshot() {
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
appendTemplates(.refined, to: &snapshot)
|
for category in viewModel.visibleCategories {
|
||||||
appendTemplates(.atmosphere, to: &snapshot)
|
appendTemplates(category, to: &snapshot)
|
||||||
if viewModel.showsCoverTemplates {
|
|
||||||
appendTemplates(.cover, to: &snapshot)
|
|
||||||
}
|
}
|
||||||
snapshot.appendSections([.mode])
|
snapshot.appendSections([.mode])
|
||||||
snapshot.appendItems([.mode], toSection: .mode)
|
snapshot.appendItems([.mode], toSection: .mode)
|
||||||
|
|||||||
@@ -489,6 +489,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
startProjectIndex: startIndex,
|
startProjectIndex: startIndex,
|
||||||
configuration: previewConfiguration,
|
configuration: previewConfiguration,
|
||||||
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
|
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
|
||||||
|
albumId: viewModel.albumId,
|
||||||
|
scenicIdProvider: scenicIdProvider,
|
||||||
|
aiRetouchAPI: previewAPI,
|
||||||
loadMore: {
|
loadMore: {
|
||||||
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
|
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
|
||||||
return (
|
return (
|
||||||
@@ -496,6 +499,13 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
previewViewModel.currentPhotoCount
|
previewViewModel.currentPhotoCount
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
reload: {
|
||||||
|
let response = try await previewViewModel.reloadLoadedMaterials(api: previewAPI)
|
||||||
|
return (
|
||||||
|
response.list.map(TravelAlbumPreviewProject.init(material:)),
|
||||||
|
response.total
|
||||||
|
)
|
||||||
|
},
|
||||||
onProjectDeleted: { materialId in
|
onProjectDeleted: { materialId in
|
||||||
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ import UIKit
|
|||||||
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
|
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
|
||||||
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
|
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
|
||||||
|
|
||||||
|
/// 相册项目刷新回调,失败时由预览页保留当前内容并展示错误。
|
||||||
|
typealias TravelAlbumPreviewReload = () async throws -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
|
||||||
|
|
||||||
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
|
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
|
||||||
final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||||
private var projects: [TravelAlbumPreviewProject]
|
private var projects: [TravelAlbumPreviewProject]
|
||||||
private var totalCount: Int
|
private var totalCount: Int
|
||||||
private let configuration: TravelAlbumPreviewConfiguration
|
private let configuration: TravelAlbumPreviewConfiguration
|
||||||
private let actionHandler: any TravelAlbumPreviewActionHandling
|
private let actionHandler: any TravelAlbumPreviewActionHandling
|
||||||
|
private let albumId: Int
|
||||||
|
private let scenicIdProvider: () -> Int
|
||||||
|
private let aiRetouchAPI: (any TravelAlbumServing)?
|
||||||
private let loadMore: TravelAlbumPreviewLoadMore?
|
private let loadMore: TravelAlbumPreviewLoadMore?
|
||||||
|
private let reload: TravelAlbumPreviewReload?
|
||||||
private let onProjectDeleted: ((Int) -> Void)?
|
private let onProjectDeleted: ((Int) -> Void)?
|
||||||
private var nodes: [TravelAlbumPreviewNode] = []
|
private var nodes: [TravelAlbumPreviewNode] = []
|
||||||
private var currentNodeIndex = 0
|
private var currentNodeIndex = 0
|
||||||
@@ -24,6 +31,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
private var selectedKind: TravelAlbumPreviewAssetKind = .original
|
private var selectedKind: TravelAlbumPreviewAssetKind = .original
|
||||||
private var chromeVisible = true
|
private var chromeVisible = true
|
||||||
private var isLoadingMore = false
|
private var isLoadingMore = false
|
||||||
|
private var isRefreshingProject = false
|
||||||
private var isDeletingProject = false
|
private var isDeletingProject = false
|
||||||
private var didApplyInitialPosition = false
|
private var didApplyInitialPosition = false
|
||||||
private var lastCollectionSize: CGSize = .zero
|
private var lastCollectionSize: CGSize = .zero
|
||||||
@@ -39,6 +47,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
private let tabStack = UIStackView()
|
private let tabStack = UIStackView()
|
||||||
private let actionStack = UIStackView()
|
private let actionStack = UIStackView()
|
||||||
private let deleteButton = UIButton(type: .system)
|
private let deleteButton = UIButton(type: .system)
|
||||||
|
private let refreshButton = UIButton(type: .system)
|
||||||
private var tabHeightConstraint: Constraint?
|
private var tabHeightConstraint: Constraint?
|
||||||
|
|
||||||
/// 创建全屏预览页。
|
/// 创建全屏预览页。
|
||||||
@@ -48,14 +57,22 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
startProjectIndex: Int,
|
startProjectIndex: Int,
|
||||||
configuration: TravelAlbumPreviewConfiguration = .init(),
|
configuration: TravelAlbumPreviewConfiguration = .init(),
|
||||||
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
||||||
|
albumId: Int = 0,
|
||||||
|
scenicIdProvider: @escaping () -> Int = { 0 },
|
||||||
|
aiRetouchAPI: (any TravelAlbumServing)? = nil,
|
||||||
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
||||||
|
reload: TravelAlbumPreviewReload? = nil,
|
||||||
onProjectDeleted: ((Int) -> Void)? = nil
|
onProjectDeleted: ((Int) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.projects = Self.deduplicated(projects)
|
self.projects = Self.deduplicated(projects)
|
||||||
self.totalCount = max(totalCount, projects.count)
|
self.totalCount = max(totalCount, projects.count)
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
self.actionHandler = actionHandler
|
self.actionHandler = actionHandler
|
||||||
|
self.albumId = albumId
|
||||||
|
self.scenicIdProvider = scenicIdProvider
|
||||||
|
self.aiRetouchAPI = aiRetouchAPI
|
||||||
self.loadMore = loadMore
|
self.loadMore = loadMore
|
||||||
|
self.reload = reload
|
||||||
self.onProjectDeleted = onProjectDeleted
|
self.onProjectDeleted = onProjectDeleted
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
modalPresentationStyle = .fullScreen
|
modalPresentationStyle = .fullScreen
|
||||||
@@ -203,7 +220,14 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
deleteButton.accessibilityLabel = "删除"
|
deleteButton.accessibilityLabel = "删除"
|
||||||
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
|
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)
|
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
|
||||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
refreshButton.addTarget(self, action: #selector(refreshTapped), 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) {
|
private func showPreviewToast(_ message: String) {
|
||||||
let label = TravelAlbumPreviewToastLabel()
|
let label = TravelAlbumPreviewToastLabel()
|
||||||
label.text = message
|
label.text = message
|
||||||
@@ -444,8 +455,36 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func aiTapped() {
|
@objc private func aiTapped() {
|
||||||
guard let id = currentProject?.originalMaterialId else { return }
|
guard presentedViewController == nil else { return }
|
||||||
performAction { [actionHandler] in await actionHandler.requestAIRetouch(originalMaterialId: id) }
|
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() {
|
@objc private func deleteTapped() {
|
||||||
@@ -539,8 +578,98 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func refreshTapped() {
|
@objc private func refreshTapped() {
|
||||||
guard let id = currentProject?.originalMaterialId else { return }
|
reloadProjects(showSuccessToast: true, forceRefreshImage: true)
|
||||||
performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) }
|
}
|
||||||
|
|
||||||
|
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()
|
resetZoom()
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply(asset newAsset: TravelAlbumPreviewAsset?) {
|
func apply(asset newAsset: TravelAlbumPreviewAsset?, forceRefresh: Bool = false) {
|
||||||
asset = newAsset
|
asset = newAsset
|
||||||
resetZoom()
|
resetZoom()
|
||||||
loadImage()
|
loadImage(forceRefresh: forceRefresh)
|
||||||
accessibilityLabel = newAsset.map {
|
accessibilityLabel = newAsset.map {
|
||||||
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
|
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
|
||||||
}
|
}
|
||||||
@@ -710,7 +839,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
scrollView.addGestureRecognizer(doubleTap)
|
scrollView.addGestureRecognizer(doubleTap)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadImage() {
|
private func loadImage(forceRefresh: Bool = false) {
|
||||||
retryButton.isHidden = true
|
retryButton.isHidden = true
|
||||||
let text = asset?.displayURL
|
let text = asset?.displayURL
|
||||||
guard let text, let url = URL(string: text), !text.isEmpty else {
|
guard let text, let url = URL(string: text), !text.isEmpty else {
|
||||||
@@ -718,7 +847,8 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
retryButton.isHidden = false
|
retryButton.isHidden = false
|
||||||
return
|
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 }
|
guard let self, self.asset?.displayURL == text else { return }
|
||||||
if case .failure = result, self.imageView.image == nil {
|
if case .failure = result, self.imageView.image == nil {
|
||||||
self.retryButton.isHidden = false
|
self.retryButton.isHidden = false
|
||||||
@@ -748,7 +878,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func retryTapped() {
|
@objc private func retryTapped() {
|
||||||
loadImage()
|
loadImage(forceRefresh: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,28 +6,30 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
/// AI 修图模板选择状态测试。
|
/// AI 修图模板工作流、选择状态与请求分流测试。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||||
func testLoadDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async {
|
func testInitialWorkflowDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
albumId: 8,
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [4, 3, 2, 1]
|
materialIds: [4, 3, 2, 1, 1]
|
||||||
)
|
)
|
||||||
|
|
||||||
await viewModel.loadTemplates(api: api)
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
|
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
|
||||||
|
XCTAssertEqual(viewModel.workflow, .initial(albumId: 8, materialIds: [1, 2, 3, 4]))
|
||||||
|
XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover])
|
||||||
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
||||||
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
||||||
XCTAssertEqual(viewModel.selectedCoverTemplateId, 31)
|
XCTAssertEqual(viewModel.selectedCoverTemplateId, 31)
|
||||||
XCTAssertTrue(viewModel.showsCoverTemplates)
|
XCTAssertTrue(viewModel.isOptional(.atmosphere))
|
||||||
XCTAssertTrue(viewModel.canSubmit)
|
XCTAssertTrue(viewModel.canSubmit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAtmosphereSelectionTogglesOffWhenTappedAgain() async {
|
func testInitialAtmosphereSelectionTogglesOffWhenTappedAgain() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
albumId: 8,
|
||||||
@@ -43,70 +45,141 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testThreeMaterialsHideCoverAndOmitCoverFromSubmission() async {
|
func testInitialSubmissionUsesCoverTemplateOnlyForFourOrMoreMaterials() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let threePhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
albumId: 8,
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [3, 1, 2]
|
materialIds: [3, 1, 2]
|
||||||
)
|
)
|
||||||
var submitted = false
|
await threePhotoViewModel.loadTemplates(api: api)
|
||||||
viewModel.onSubmitted = { submitted = true }
|
await threePhotoViewModel.submit(api: api)
|
||||||
await viewModel.loadTemplates(api: api)
|
|
||||||
|
|
||||||
XCTAssertFalse(viewModel.showsCoverTemplates)
|
XCTAssertEqual(
|
||||||
XCTAssertNil(viewModel.selectedCoverTemplateId)
|
api.aiRetouchRequests[0],
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: 8,
|
||||||
|
materialIds: [1, 2, 3],
|
||||||
|
refinedTemplateId: 11,
|
||||||
|
atmosphereTemplateId: nil,
|
||||||
|
coverTemplateId: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await viewModel.submit(api: api)
|
let fourPhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
|
||||||
XCTAssertTrue(submitted)
|
|
||||||
XCTAssertEqual(api.aiRetouchRequests.count, 1)
|
|
||||||
XCTAssertEqual(api.aiRetouchRequests.first?.materialIds, [1, 2, 3])
|
|
||||||
XCTAssertNil(api.aiRetouchRequests.first?.aiPreviewTabs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFourMaterialsSubmitAllSelectedTemplateIds() async {
|
|
||||||
let api = makeAPI()
|
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
||||||
albumId: 8,
|
albumId: 8,
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [1, 2, 3, 4]
|
materialIds: [1, 2, 3, 4]
|
||||||
)
|
)
|
||||||
await viewModel.loadTemplates(api: api)
|
await fourPhotoViewModel.loadTemplates(api: api)
|
||||||
viewModel.toggleTemplate(id: 12, category: .refined)
|
fourPhotoViewModel.toggleTemplate(id: 12, category: .refined)
|
||||||
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
fourPhotoViewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
|
await fourPhotoViewModel.submit(api: api)
|
||||||
await viewModel.submit(api: api)
|
|
||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
api.aiRetouchRequests.first,
|
api.aiRetouchRequests[1],
|
||||||
TravelAlbumAIRetouchRequest(
|
TravelAlbumAIRetouchRequest(
|
||||||
userEquityTravelId: 8,
|
userEquityTravelId: 8,
|
||||||
materialIds: [1, 2, 3, 4],
|
materialIds: [1, 2, 3, 4],
|
||||||
refinedTemplateId: 12,
|
refinedTemplateId: 12,
|
||||||
atmosphereTemplateId: 21,
|
atmosphereTemplateId: 21,
|
||||||
aiPreviewTabs: 31
|
coverTemplateId: 31
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMissingRequiredCoverTemplateDisablesSubmission() async {
|
func testReretouchWorkflowShowsAndSubmitsOnlyRequiredCategories() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
let refined = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
||||||
|
)
|
||||||
|
await refined.loadTemplates(api: api)
|
||||||
|
XCTAssertEqual(refined.visibleCategories, [.refined])
|
||||||
|
XCTAssertEqual(refined.selectedRefinedTemplateId, 11)
|
||||||
|
XCTAssertNil(refined.selectedAtmosphereTemplateId)
|
||||||
|
await refined.submit(api: api)
|
||||||
|
|
||||||
|
let atmosphere = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 8, batchId: 80, type: .atmosphere)
|
||||||
|
)
|
||||||
|
await atmosphere.loadTemplates(api: api)
|
||||||
|
XCTAssertEqual(atmosphere.visibleCategories, [.atmosphere])
|
||||||
|
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
|
||||||
|
atmosphere.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
|
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
|
||||||
|
await atmosphere.submit(api: api)
|
||||||
|
|
||||||
|
let all = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 9, batchId: 90, type: .all)
|
||||||
|
)
|
||||||
|
await all.loadTemplates(api: api)
|
||||||
|
XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere])
|
||||||
|
XCTAssertEqual(all.selectedRefinedTemplateId, 11)
|
||||||
|
XCTAssertEqual(all.selectedAtmosphereTemplateId, 21)
|
||||||
|
await all.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.aiReretouchRequests, [
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 7,
|
||||||
|
aiRetouchBatchId: 70,
|
||||||
|
type: .refined,
|
||||||
|
refinedTemplateId: 11,
|
||||||
|
atmosphereTemplateId: nil
|
||||||
|
),
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 8,
|
||||||
|
aiRetouchBatchId: 80,
|
||||||
|
type: .atmosphere,
|
||||||
|
refinedTemplateId: nil,
|
||||||
|
atmosphereTemplateId: 21
|
||||||
|
),
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 9,
|
||||||
|
aiRetouchBatchId: 90,
|
||||||
|
type: .all,
|
||||||
|
refinedTemplateId: 11,
|
||||||
|
atmosphereTemplateId: 21
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidReretouchBatchDisablesSubmission() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 7, batchId: 0, type: .refined)
|
||||||
|
)
|
||||||
|
var message: String?
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "当前图片缺少修图批次,请刷新后重试")
|
||||||
|
XCTAssertEqual(message, "当前图片缺少修图批次,请刷新后重试")
|
||||||
|
XCTAssertTrue(api.aiReretouchRequests.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingRequiredTemplateDisablesMatchingWorkflow() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
refinedTemplates: [template(11, "清透")],
|
refinedTemplates: [template(11, "清透")],
|
||||||
atmosphereTemplates: [template(21, "暖阳")],
|
atmosphereTemplates: [],
|
||||||
coverTemplates: []
|
coverTemplates: []
|
||||||
)
|
)
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [1, 2, 3, 4]
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
|
||||||
)
|
)
|
||||||
|
|
||||||
await viewModel.loadTemplates(api: api)
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
XCTAssertFalse(viewModel.canSubmit)
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
XCTAssertEqual(viewModel.validationMessage, "暂无可用的封面风格模板")
|
XCTAssertEqual(viewModel.validationMessage, "暂无可用的氛围感修图模板")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async {
|
func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async {
|
||||||
@@ -127,11 +200,10 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
func testSubmitFailureKeepsSelectionAndAllowsRetry() async {
|
func testSubmitFailureKeepsSelectionAndAllowsRetry() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
api.submitAIRetouchError = APIError.serverCode(500, "提交服务繁忙")
|
api.submitAIReretouchError = APIError.serverCode(500, "提交服务繁忙")
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [1]
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
||||||
)
|
)
|
||||||
var message: String?
|
var message: String?
|
||||||
var submitted = false
|
var submitted = false
|
||||||
@@ -141,7 +213,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
await viewModel.submit(api: api)
|
await viewModel.submit(api: api)
|
||||||
|
|
||||||
XCTAssertEqual(api.aiRetouchRequests.count, 1)
|
XCTAssertEqual(api.aiReretouchRequests.count, 1)
|
||||||
XCTAssertEqual(message, "提交服务繁忙")
|
XCTAssertEqual(message, "提交服务繁忙")
|
||||||
XCTAssertFalse(submitted)
|
XCTAssertFalse(submitted)
|
||||||
XCTAssertFalse(viewModel.isSubmitting)
|
XCTAssertFalse(viewModel.isSubmitting)
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
materialIds: [11, 12, 13, 14],
|
materialIds: [11, 12, 13, 14],
|
||||||
refinedTemplateId: 21,
|
refinedTemplateId: 21,
|
||||||
atmosphereTemplateId: 22,
|
atmosphereTemplateId: 22,
|
||||||
aiPreviewTabs: 31
|
coverTemplateId: 31
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,7 +165,14 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14])
|
XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14])
|
||||||
XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
|
XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
|
||||||
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
|
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
|
||||||
XCTAssertEqual(body?["ai_preview_tabs"] as? Int, 31)
|
XCTAssertEqual(body?["cover_template_id"] as? Int, 31)
|
||||||
|
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
|
||||||
|
"user_equity_travel_id",
|
||||||
|
"material_ids",
|
||||||
|
"refined_template_id",
|
||||||
|
"atmosphere_template_id",
|
||||||
|
"cover_template_id",
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws {
|
func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws {
|
||||||
@@ -178,13 +185,75 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
materialIds: [11],
|
materialIds: [11],
|
||||||
refinedTemplateId: 21,
|
refinedTemplateId: 21,
|
||||||
atmosphereTemplateId: nil,
|
atmosphereTemplateId: nil,
|
||||||
aiPreviewTabs: nil
|
coverTemplateId: nil
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
|
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
|
||||||
XCTAssertNil(body?["atmosphere_template_id"])
|
XCTAssertNil(body?["atmosphere_template_id"])
|
||||||
XCTAssertNil(body?["ai_preview_tabs"])
|
XCTAssertNil(body?["cover_template_id"])
|
||||||
|
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
|
||||||
|
"user_equity_travel_id",
|
||||||
|
"material_ids",
|
||||||
|
"refined_template_id",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubmitAIReretouchEncodesOnlyFieldsRequiredByEachType() async throws {
|
||||||
|
let session = MockURLSession(responses: [
|
||||||
|
envelopeJSON(#"{"accepted":true}"#),
|
||||||
|
envelopeJSON(#"{"accepted":true}"#),
|
||||||
|
envelopeJSON(#"{"accepted":true}"#),
|
||||||
|
])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 11,
|
||||||
|
aiRetouchBatchId: 51,
|
||||||
|
type: .refined,
|
||||||
|
refinedTemplateId: 21,
|
||||||
|
atmosphereTemplateId: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 12,
|
||||||
|
aiRetouchBatchId: 52,
|
||||||
|
type: .atmosphere,
|
||||||
|
refinedTemplateId: nil,
|
||||||
|
atmosphereTemplateId: 22
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 13,
|
||||||
|
aiRetouchBatchId: 53,
|
||||||
|
type: .all,
|
||||||
|
refinedTemplateId: 21,
|
||||||
|
atmosphereTemplateId: 22
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let bodies = try session.requests.map { request in
|
||||||
|
try JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||||
|
}
|
||||||
|
XCTAssertEqual(session.requests.map { $0.url?.path }, Array(
|
||||||
|
repeating: "/api/yf-handset-app/photog/travel-album/ai-reretouch",
|
||||||
|
count: 3
|
||||||
|
))
|
||||||
|
XCTAssertEqual(Set(bodies[0]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "refined_template_id"])
|
||||||
|
XCTAssertEqual(Set(bodies[1]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "atmosphere_template_id"])
|
||||||
|
XCTAssertEqual(Set(bodies[2]?.keys.map { $0 } ?? []), [
|
||||||
|
"id",
|
||||||
|
"ai_retouch_batch_id",
|
||||||
|
"type",
|
||||||
|
"refined_template_id",
|
||||||
|
"atmosphere_template_id",
|
||||||
|
])
|
||||||
|
XCTAssertEqual(bodies[0]?["type"] as? Int, 1)
|
||||||
|
XCTAssertEqual(bodies[1]?["type"] as? Int, 2)
|
||||||
|
XCTAssertEqual(bodies[2]?["type"] as? Int, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func envelopeJSON(_ dataJSON: String) -> Data {
|
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||||
|
|||||||
@@ -352,6 +352,141 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(cell.accessibilityValue, "未选择")
|
XCTAssertEqual(cell.accessibilityValue, "未选择")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
|
||||||
|
UIView.setAnimationsEnabled(false)
|
||||||
|
defer { UIView.setAnimationsEnabled(true) }
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
|
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "")],
|
||||||
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")]
|
||||||
|
)
|
||||||
|
let project = TravelAlbumPreviewProject(
|
||||||
|
originalMaterialId: 7,
|
||||||
|
aiRetouchBatchId: 70,
|
||||||
|
assets: [
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "original-7",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "原图.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
),
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "retouched-7",
|
||||||
|
kind: .retouched,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "精修后.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [project],
|
||||||
|
totalCount: 1,
|
||||||
|
startProjectIndex: 0,
|
||||||
|
albumId: 8,
|
||||||
|
scenicIdProvider: { 18 },
|
||||||
|
aiRetouchAPI: api
|
||||||
|
)
|
||||||
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||||
|
window.rootViewController = controller
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer { window.isHidden = true }
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let retouchedButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "精修后" } as? UIButton
|
||||||
|
)
|
||||||
|
retouchedButton.sendActions(for: .touchUpInside)
|
||||||
|
let aiButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "AI修图" } as? UIButton
|
||||||
|
)
|
||||||
|
aiButton.sendActions(for: .touchUpInside)
|
||||||
|
await waitUntil { controller.presentedViewController is TravelAlbumAIRetouchTemplateViewController }
|
||||||
|
let sheet = try XCTUnwrap(controller.presentedViewController as? TravelAlbumAIRetouchTemplateViewController)
|
||||||
|
sheet.loadViewIfNeeded()
|
||||||
|
let confirmButton = try XCTUnwrap(
|
||||||
|
sheet.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
await waitUntil { api.aiRetouchTemplateScenicIds.count == 1 && confirmButton.isEnabled }
|
||||||
|
sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
sheet.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let labels = sheet.view.allAccessibilityLabels()
|
||||||
|
XCTAssertTrue(labels.contains("原图精修"))
|
||||||
|
XCTAssertFalse(labels.contains { $0.contains("氛围感修图") })
|
||||||
|
confirmButton.sendActions(for: .touchUpInside)
|
||||||
|
await waitUntil { api.aiReretouchRequests.count == 1 }
|
||||||
|
await waitUntil { controller.presentedViewController == nil }
|
||||||
|
|
||||||
|
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined)
|
||||||
|
XCTAssertNil(controller.presentedViewController)
|
||||||
|
XCTAssertTrue(window.rootViewController === controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewRefreshPreservesVariantAndFallsBackWhenVariantDisappears() async throws {
|
||||||
|
let original = TravelAlbumPreviewAsset(
|
||||||
|
id: "original-7",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "原图.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
)
|
||||||
|
let retouched = TravelAlbumPreviewAsset(
|
||||||
|
id: "retouched-7",
|
||||||
|
kind: .retouched,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "精修后.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
)
|
||||||
|
var reloadCount = 0
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original, retouched])],
|
||||||
|
totalCount: 1,
|
||||||
|
startProjectIndex: 0,
|
||||||
|
reload: {
|
||||||
|
reloadCount += 1
|
||||||
|
let assets = reloadCount == 1 ? [original, retouched] : [original]
|
||||||
|
return ([TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets)], 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
let retouchedButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "精修后" } as? UIButton
|
||||||
|
)
|
||||||
|
retouchedButton.sendActions(for: .touchUpInside)
|
||||||
|
let refreshButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.previewRefreshButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshButton.sendActions(for: .touchUpInside)
|
||||||
|
await waitUntil { reloadCount == 1 && refreshButton.isEnabled }
|
||||||
|
XCTAssertTrue(
|
||||||
|
controller.view.findSubview {
|
||||||
|
($0 as? UIButton)?.accessibilityLabel == "精修后"
|
||||||
|
&& $0.accessibilityTraits.contains(.selected)
|
||||||
|
} != nil
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshButton.sendActions(for: .touchUpInside)
|
||||||
|
await waitUntil { reloadCount == 2 && refreshButton.isEnabled }
|
||||||
|
XCTAssertNil(
|
||||||
|
controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "精修后" }
|
||||||
|
)
|
||||||
|
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图.jpg" })
|
||||||
|
}
|
||||||
|
|
||||||
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
|
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
|
||||||
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
||||||
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
|
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
fileUrl: "https://cdn.example.com/original.jpg",
|
fileUrl: "https://cdn.example.com/original.jpg",
|
||||||
fileSize: 4096,
|
fileSize: 4096,
|
||||||
coverUrl: "https://cdn.example.com/cover.jpg",
|
coverUrl: "https://cdn.example.com/cover.jpg",
|
||||||
|
aiRetouchBatchId: 55,
|
||||||
aiRefinedURL: " https://cdn.example.com/refined.jpg ",
|
aiRefinedURL: " https://cdn.example.com/refined.jpg ",
|
||||||
aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg"
|
aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg"
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTAssertNil(project.asset(for: .cover))
|
XCTAssertNil(project.asset(for: .cover))
|
||||||
XCTAssertTrue(project.hasVariants)
|
XCTAssertTrue(project.hasVariants)
|
||||||
|
XCTAssertEqual(project.aiRetouchBatchId, 55)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPreviewProjectIgnoresBlankAIResultURLs() {
|
func testPreviewProjectIgnoresBlankAIResultURLs() {
|
||||||
@@ -161,24 +163,40 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPlaceholderPreviewActionsReturnUnavailableWithoutMutation() async {
|
func testPreviewProjectMapsCurrentTabToMinimalAIRetouchWorkflow() {
|
||||||
let handler = PlaceholderTravelAlbumPreviewActionHandler()
|
let originalOnly = makePreviewProject(id: 9, kinds: [.original])
|
||||||
let aiResult = await handler.requestAIRetouch(originalMaterialId: 9)
|
let variants = TravelAlbumPreviewProject(
|
||||||
let deleteResult = await handler.deleteProject(originalMaterialId: 9)
|
originalMaterialId: 10,
|
||||||
let refreshResult = await handler.refreshProject(originalMaterialId: 9)
|
aiRetouchBatchId: 88,
|
||||||
|
assets: makePreviewProject(id: 10, kinds: [.original, .retouched, .atmosphere]).assets
|
||||||
|
)
|
||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
aiResult,
|
originalOnly.aiRetouchWorkflow(albumId: 7, selectedKind: .original),
|
||||||
.unavailable("AI修图功能待接口接入")
|
.initial(albumId: 7, materialIds: [9])
|
||||||
)
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .original),
|
||||||
|
.reretouch(materialId: 10, batchId: 88, type: .all)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched),
|
||||||
|
.reretouch(materialId: 10, batchId: 88, type: .refined)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere),
|
||||||
|
.reretouch(materialId: 10, batchId: 88, type: .atmosphere)
|
||||||
|
)
|
||||||
|
XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPlaceholderPreviewDeleteReturnsUnavailableWithoutMutation() async {
|
||||||
|
let handler = PlaceholderTravelAlbumPreviewActionHandler()
|
||||||
|
let deleteResult = await handler.deleteProject(originalMaterialId: 9)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
deleteResult,
|
deleteResult,
|
||||||
.unavailable("项目删除接口待接入")
|
.unavailable("项目删除接口待接入")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
|
||||||
refreshResult,
|
|
||||||
.unavailable("关联图片刷新接口待接入")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTravelAlbumDecodesSnakeCaseFields() throws {
|
func testTravelAlbumDecodesSnakeCaseFields() throws {
|
||||||
@@ -225,6 +243,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
"is_purchased": false,
|
"is_purchased": false,
|
||||||
"ai_retouch_status": 3,
|
"ai_retouch_status": 3,
|
||||||
"ai_retouch_status_name": "AI已修",
|
"ai_retouch_status_name": "AI已修",
|
||||||
|
"ai_retouch_batch_id": 66,
|
||||||
"ai_refined_url": "https://cdn/refined.jpg",
|
"ai_refined_url": "https://cdn/refined.jpg",
|
||||||
"ai_atmosphere_url": "https://cdn/atmosphere.jpg",
|
"ai_atmosphere_url": "https://cdn/atmosphere.jpg",
|
||||||
"created_at": "",
|
"created_at": "",
|
||||||
@@ -238,6 +257,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
XCTAssertFalse(material.isPurchased)
|
XCTAssertFalse(material.isPurchased)
|
||||||
XCTAssertEqual(material.aiRetouchStatus, 3)
|
XCTAssertEqual(material.aiRetouchStatus, 3)
|
||||||
XCTAssertEqual(material.aiRetouchStatusName, "AI已修")
|
XCTAssertEqual(material.aiRetouchStatusName, "AI已修")
|
||||||
|
XCTAssertEqual(material.aiRetouchBatchId, 66)
|
||||||
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
|
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
|
||||||
XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
|
XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
|
||||||
}
|
}
|
||||||
@@ -258,6 +278,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
"is_purchased": false,
|
"is_purchased": false,
|
||||||
"ai_retouch_status": "invalid",
|
"ai_retouch_status": "invalid",
|
||||||
"ai_retouch_status_name": null,
|
"ai_retouch_status_name": null,
|
||||||
|
"ai_retouch_batch_id": "invalid",
|
||||||
"ai_refined_url": 123,
|
"ai_refined_url": 123,
|
||||||
"created_at": "",
|
"created_at": "",
|
||||||
"updated_at": ""
|
"updated_at": ""
|
||||||
@@ -268,6 +289,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(material.aiRetouchStatus, 0)
|
XCTAssertEqual(material.aiRetouchStatus, 0)
|
||||||
XCTAssertEqual(material.aiRetouchStatusName, "")
|
XCTAssertEqual(material.aiRetouchStatusName, "")
|
||||||
|
XCTAssertEqual(material.aiRetouchBatchId, 0)
|
||||||
XCTAssertEqual(material.aiRefinedURL, "")
|
XCTAssertEqual(material.aiRefinedURL, "")
|
||||||
XCTAssertEqual(material.aiAtmosphereURL, "")
|
XCTAssertEqual(material.aiAtmosphereURL, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,41 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
|
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPreviewReloadRefetchesAllLoadedPagesAndReplacesMaterialsById() async throws {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 35,
|
||||||
|
list: (1 ... 30).map { TravelAlbumMaterial(id: $0) }
|
||||||
|
),
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 35,
|
||||||
|
list: (31 ... 35).map { TravelAlbumMaterial(id: $0) }
|
||||||
|
),
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 35,
|
||||||
|
list: (1 ... 30).map {
|
||||||
|
TravelAlbumMaterial(id: $0, aiRetouchBatchId: $0 == 7 ? 700 : 0)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 35,
|
||||||
|
list: (31 ... 35).map { TravelAlbumMaterial(id: $0) }
|
||||||
|
),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
await viewModel.loadMaterials(reset: true, api: api)
|
||||||
|
await viewModel.loadMaterials(reset: false, api: api)
|
||||||
|
|
||||||
|
let response = try await viewModel.reloadLoadedMaterials(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.total, 35)
|
||||||
|
XCTAssertEqual(response.list.count, 35)
|
||||||
|
XCTAssertEqual(response.list.first { $0.id == 7 }?.aiRetouchBatchId, 700)
|
||||||
|
XCTAssertEqual(api.materialRequests.suffix(2).map(\.page), [1, 2])
|
||||||
|
XCTAssertEqual(api.materialRequests.suffix(2).map(\.pageSize), [30, 30])
|
||||||
|
}
|
||||||
|
|
||||||
func testDeleteAlbumCallsCallback() async {
|
func testDeleteAlbumCallsCallback() async {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||||
@@ -1053,6 +1088,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
var aiRetouchTemplatesError: Error?
|
var aiRetouchTemplatesError: Error?
|
||||||
var submitAIRetouchError: Error?
|
var submitAIRetouchError: Error?
|
||||||
var submitAIRetouchDelayNanoseconds: UInt64 = 0
|
var submitAIRetouchDelayNanoseconds: UInt64 = 0
|
||||||
|
var submitAIReretouchError: Error?
|
||||||
|
var submitAIReretouchDelayNanoseconds: UInt64 = 0
|
||||||
var deleteMaterialError: Error?
|
var deleteMaterialError: Error?
|
||||||
var deleteMaterialDelayNanoseconds: UInt64 = 0
|
var deleteMaterialDelayNanoseconds: UInt64 = 0
|
||||||
|
|
||||||
@@ -1065,6 +1102,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
private(set) var deletedMaterialIds: [Int] = []
|
private(set) var deletedMaterialIds: [Int] = []
|
||||||
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
||||||
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
||||||
|
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
|
||||||
|
|
||||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
||||||
availableOrdersCallCount += 1
|
availableOrdersCallCount += 1
|
||||||
@@ -1148,4 +1186,12 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
}
|
}
|
||||||
if let submitAIRetouchError { throw submitAIRetouchError }
|
if let submitAIRetouchError { throw submitAIRetouchError }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
|
||||||
|
aiReretouchRequests.append(request)
|
||||||
|
if submitAIReretouchDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: submitAIReretouchDelayNanoseconds)
|
||||||
|
}
|
||||||
|
if let submitAIReretouchError { throw submitAIReretouchError }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user