diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift index e0cffc1..938cc84 100644 --- a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -49,6 +49,9 @@ protocol TravelAlbumServing { /// 提交相册素材 AI 修图任务。 func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws + + /// 提交单张素材重新修图任务。 + func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws } @MainActor @@ -179,6 +182,13 @@ final class TravelAlbumAPI: TravelAlbumServing { APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request) ) } + + /// 提交重新修图任务;服务端返回的批次与额度信息当前无需消费。 + func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request) + ) + } } /// 旅拍相册 ID 请求体。 diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index ac599cb..59dd3d8 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -129,6 +129,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab let isPurchased: Bool let aiRetouchStatus: Int let aiRetouchStatusName: String + let aiRetouchBatchId: Int let aiRefinedURL: String let aiAtmosphereURL: String let createdAt: String @@ -148,6 +149,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab case isPurchased = "is_purchased" case aiRetouchStatus = "ai_retouch_status" case aiRetouchStatusName = "ai_retouch_status_name" + case aiRetouchBatchId = "ai_retouch_batch_id" case aiRefinedURL = "ai_refined_url" case aiAtmosphereURL = "ai_atmosphere_url" case createdAt = "created_at" @@ -172,6 +174,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab aiRetouchStatusName = ( try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName) ) ?? "" + aiRetouchBatchId = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchBatchId)) ?? 0 aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? "" aiAtmosphereURL = ( try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL) @@ -194,6 +197,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab isPurchased: Bool = false, aiRetouchStatus: Int = 0, aiRetouchStatusName: String = "", + aiRetouchBatchId: Int = 0, aiRefinedURL: String = "", aiAtmosphereURL: String = "", createdAt: String = "", @@ -212,6 +216,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab self.isPurchased = isPurchased self.aiRetouchStatus = aiRetouchStatus self.aiRetouchStatusName = aiRetouchStatusName + self.aiRetouchBatchId = aiRetouchBatchId self.aiRefinedURL = aiRefinedURL self.aiAtmosphereURL = aiAtmosphereURL self.createdAt = createdAt @@ -342,6 +347,62 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable { } } +/// AI 修图模板类别,决定页面分组、选择规则和提交字段。 +enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable { + case refined + case atmosphere + case cover + + /// 模板分组展示标题。 + var title: String { + switch self { + case .refined: "原图精修" + case .atmosphere: "氛围感修图" + case .cover: "封面风格模板" + } + } +} + +/// AI 修图模板页工作流,明确区分首次批量修图与单张结果覆盖重修。 +enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable { + /// 对一个相册内的原始素材发起首次 AI 修图。 + case initial(albumId: Int, materialIds: [Int]) + /// 对单张素材的既有 AI 结果发起覆盖重修。 + case reretouch(materialId: Int, batchId: Int, type: TravelAlbumAIReretouchType) + + /// 当前页面需要展示的模板分组。 + var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] { + switch self { + case .initial(_, let materialIds): + var categories: [TravelAlbumAIRetouchTemplateCategory] = [.refined, .atmosphere] + if materialIds.count >= 4 { categories.append(.cover) } + return categories + case .reretouch(_, _, .refined): + return [.refined] + case .reretouch(_, _, .atmosphere): + return [.atmosphere] + case .reretouch(_, _, .all): + return [.refined, .atmosphere] + } + } + + /// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。 + func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { + if case .initial = self, category == .atmosphere { return true } + return false + } + + /// 工作流目标是否满足接口的最小参数要求。 + var isValid: Bool { + switch self { + case .initial(let albumId, let materialIds): + return albumId > 0 && !materialIds.isEmpty + case .reretouch(let materialId, let batchId, _): + return materialId > 0 && batchId > 0 + } + } +} + /// AI 修图模板,包含业务 ID、展示名称和预览图地址。 struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable { let id: Int @@ -392,14 +453,38 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable { let materialIds: [Int] let refinedTemplateId: Int let atmosphereTemplateId: Int? - let aiPreviewTabs: Int? + let coverTemplateId: Int? enum CodingKeys: String, CodingKey { case userEquityTravelId = "user_equity_travel_id" case materialIds = "material_ids" case refinedTemplateId = "refined_template_id" case atmosphereTemplateId = "atmosphere_template_id" - case aiPreviewTabs = "ai_preview_tabs" + case coverTemplateId = "cover_template_id" + } +} + +/// 重新修图类型,决定后端覆盖的 AI 结果及必需模板字段。 +enum TravelAlbumAIReretouchType: Int, Encodable, Sendable, Equatable { + case refined = 1 + case atmosphere = 2 + case all = 3 +} + +/// 提交单张素材重新修图的最小请求参数。 +struct TravelAlbumAIReretouchRequest: Encodable, Sendable, Equatable { + let id: Int + let aiRetouchBatchId: Int + let type: TravelAlbumAIReretouchType + let refinedTemplateId: Int? + let atmosphereTemplateId: Int? + + enum CodingKeys: String, CodingKey { + case id + case aiRetouchBatchId = "ai_retouch_batch_id" + case type + case refinedTemplateId = "refined_template_id" + case atmosphereTemplateId = "atmosphere_template_id" } } diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift index 53f4927..1e43ee8 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift @@ -65,6 +65,7 @@ struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable { /// 一张原图及其所有关联图片组成的预览项目。 struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable { let originalMaterialId: Int + let aiRetouchBatchId: Int let assets: [TravelAlbumPreviewAsset] var id: Int { originalMaterialId } @@ -87,6 +88,7 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable { /// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。 init(material: TravelAlbumMaterial) { originalMaterialId = material.id + aiRetouchBatchId = material.aiRetouchBatchId var mappedAssets = [ TravelAlbumPreviewAsset( id: "original-\(material.id)", @@ -127,11 +129,32 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable { } /// 创建包含关联图片的项目,主要供适配器和测试使用。 - init(originalMaterialId: Int, assets: [TravelAlbumPreviewAsset]) { + init(originalMaterialId: Int, aiRetouchBatchId: Int = 0, assets: [TravelAlbumPreviewAsset]) { self.originalMaterialId = originalMaterialId + self.aiRetouchBatchId = aiRetouchBatchId var seenKinds = Set() self.assets = assets.filter { seenKinds.insert($0.kind).inserted } } + + /// 根据当前 Tab 生成首次修图或覆盖重修工作流。 + func aiRetouchWorkflow( + albumId: Int, + selectedKind: TravelAlbumPreviewAssetKind + ) -> TravelAlbumAIRetouchWorkflow? { + guard hasVariants else { + return .initial(albumId: albumId, materialIds: [originalMaterialId]) + } + switch selectedKind { + case .original: + return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all) + case .retouched: + return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined) + case .atmosphere: + return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere) + case .cover: + return nil + } + } } /// 预览分页节点,记录当前图片所属项目及类型。 @@ -180,31 +203,21 @@ enum TravelAlbumPreviewNavigator { } } -/// 预览操作执行结果,便于后续替换真实业务接口。 +/// 预览删除操作执行结果,统一表达成功、失败与暂不可用状态。 enum TravelAlbumPreviewActionResult: Sendable, Equatable { case success(String?) case failure(String) case unavailable(String) } -/// 预览页底部操作协议,所有操作均以原素材项目 ID 为目标。 +/// 预览页删除操作协议,以原素材项目 ID 为目标。 protocol TravelAlbumPreviewActionHandling: AnyObject { - func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult - func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult } -/// 新接口接入前的占位操作实现,不修改任何业务数据。 +/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。 final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling { - func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult { - .unavailable("AI修图功能待接口接入") - } - func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult { .unavailable("项目删除接口待接入") } - - func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult { - .unavailable("关联图片刷新接口待接入") - } } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift index b923567..fd290db 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift @@ -5,22 +5,7 @@ import Foundation -/// AI 修图模板类别,决定页面分组、选择规则和提交字段。 -enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable { - case refined - case atmosphere - case cover - - var title: String { - switch self { - case .refined: "原图精修" - case .atmosphere: "氛围感修图" - case .cover: "封面风格模板" - } - } -} - -/// AI 修图模板选择状态,负责实时加载、单选规则、校验和任务提交。 +/// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。 final class TravelAlbumAIRetouchTemplateViewModel { private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = [] private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [] @@ -32,34 +17,58 @@ final class TravelAlbumAIRetouchTemplateViewModel { private(set) var isSubmitting = false private(set) var loadErrorMessage: String? - let albumId: Int let scenicId: Int - let materialIds: [Int] + let workflow: TravelAlbumAIRetouchWorkflow var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onSubmitted: (() -> Void)? - /// 创建模板选择状态;素材 ID 会排序并去重,确保提交稳定。 - init(albumId: Int, scenicId: Int, materialIds: [Int]) { - self.albumId = albumId + /// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。 + convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) { + self.init( + scenicId: scenicId, + workflow: .initial(albumId: albumId, materialIds: materialIds) + ) + } + + /// 使用明确工作流创建模板选择状态。 + init(scenicId: Int, workflow: TravelAlbumAIRetouchWorkflow) { self.scenicId = scenicId - self.materialIds = Array(Set(materialIds)).sorted() + switch workflow { + case .initial(let albumId, let materialIds): + self.workflow = .initial( + albumId: albumId, + materialIds: Array(Set(materialIds)).sorted() + ) + case .reretouch: + self.workflow = workflow + } } - /// 选中不少于四张素材时才展示并要求选择封面模板。 - var showsCoverTemplates: Bool { - materialIds.count >= 4 + /// 当前工作流需要展示的模板分组。 + var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] { + workflow.visibleCategories } - /// 当前必选模板缺失时用于底部提示的文案。 + /// 当前分组是否为选填。 + func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { + workflow.isOptional(category) + } + + /// 当前必选模板或业务参数缺失时用于底部提示的文案。 var validationMessage: String? { guard !isLoading, loadErrorMessage == nil else { return nil } - if refinedTemplates.isEmpty || selectedRefinedTemplateId == nil { - return "暂无可用的原图精修模板" + guard workflow.isValid else { + if case .reretouch = workflow { + return "当前图片缺少修图批次,请刷新后重试" + } + return "请选择要修图的照片" } - if showsCoverTemplates, coverTemplates.isEmpty || selectedCoverTemplateId == nil { - return "暂无可用的封面风格模板" + for category in visibleCategories where !isOptional(category) { + if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil { + return unavailableMessage(for: category) + } } return nil } @@ -69,12 +78,11 @@ final class TravelAlbumAIRetouchTemplateViewModel { !isLoading && !isSubmitting && loadErrorMessage == nil - && !materialIds.isEmpty - && selectedRefinedTemplateId != nil - && (!showsCoverTemplates || selectedCoverTemplateId != nil) + && workflow.isValid + && validationMessage == nil } - /// 实时拉取当前景区模板并按规则设置默认选择。 + /// 实时拉取当前景区模板并按工作流设置默认选择。 func loadTemplates(api: any TravelAlbumServing) async { guard scenicId > 0 else { loadErrorMessage = "请先选择景区" @@ -91,9 +99,11 @@ final class TravelAlbumAIRetouchTemplateViewModel { refinedTemplates = response.refinedTemplates atmosphereTemplates = response.atmosphereTemplates coverTemplates = response.coverTemplates - selectedRefinedTemplateId = refinedTemplates.first?.id - selectedAtmosphereTemplateId = nil - selectedCoverTemplateId = showsCoverTemplates ? coverTemplates.first?.id : nil + selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil + selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere) + ? atmosphereTemplates.first?.id + : nil + selectedCoverTemplateId = visibleCategories.contains(.cover) ? coverTemplates.first?.id : nil isLoading = false notifyStateChange() } catch is CancellationError { @@ -130,34 +140,27 @@ final class TravelAlbumAIRetouchTemplateViewModel { } } - /// 选择模板;氛围感模板再次点击时取消,必选分组保持单选。 + /// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。 func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) { - guard templates(for: category).contains(where: { $0.id == id }) else { return } + guard visibleCategories.contains(category), + templates(for: category).contains(where: { $0.id == id }) + else { return } switch category { case .refined: selectedRefinedTemplateId = id case .atmosphere: - selectedAtmosphereTemplateId = selectedAtmosphereTemplateId == id ? nil : id + selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id case .cover: - guard showsCoverTemplates else { return } selectedCoverTemplateId = id } notifyStateChange() } - /// 提交 AI 修图任务,成功后通知页面退出选择态。 + /// 按当前工作流提交首次修图或覆盖重修任务。 func submit(api: any TravelAlbumServing) async { guard !isSubmitting else { return } - guard let refinedTemplateId = selectedRefinedTemplateId else { - onShowMessage?("请选择原图精修模板") - return - } - guard !showsCoverTemplates || selectedCoverTemplateId != nil else { - onShowMessage?("请选择封面风格模板") - return - } - guard !materialIds.isEmpty else { - onShowMessage?("请选择要修图的照片") + guard canSubmit else { + onShowMessage?(validationMessage ?? "当前无法提交AI修图") return } @@ -169,15 +172,32 @@ final class TravelAlbumAIRetouchTemplateViewModel { } do { - try await api.submitAIRetouch( - TravelAlbumAIRetouchRequest( - userEquityTravelId: albumId, - materialIds: materialIds, - refinedTemplateId: refinedTemplateId, - atmosphereTemplateId: selectedAtmosphereTemplateId, - aiPreviewTabs: showsCoverTemplates ? selectedCoverTemplateId : nil + switch workflow { + case .initial(let albumId, let materialIds): + guard let refinedTemplateId = selectedRefinedTemplateId else { + onShowMessage?("请选择原图精修模板") + return + } + try await api.submitAIRetouch( + TravelAlbumAIRetouchRequest( + userEquityTravelId: albumId, + materialIds: materialIds, + refinedTemplateId: refinedTemplateId, + atmosphereTemplateId: selectedAtmosphereTemplateId, + coverTemplateId: selectedCoverTemplateId + ) ) - ) + case .reretouch(let materialId, let batchId, let type): + try await api.submitAIReretouch( + TravelAlbumAIReretouchRequest( + id: materialId, + aiRetouchBatchId: batchId, + type: type, + refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId, + atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId + ) + ) + } onSubmitted?() } catch is CancellationError { return @@ -186,6 +206,14 @@ final class TravelAlbumAIRetouchTemplateViewModel { } } + private func unavailableMessage(for category: TravelAlbumAIRetouchTemplateCategory) -> String { + switch category { + case .refined: "暂无可用的原图精修模板" + case .atmosphere: "暂无可用的氛围感修图模板" + case .cover: "暂无可用的封面风格模板" + } + } + private func notifyStateChange() { onStateChange?() } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift index a8c201c..888b8b8 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift @@ -207,6 +207,43 @@ final class TravelAlbumDetailViewModel { } } + /// 重新拉取当前筛选、排序下已经加载的分页范围,供全屏预览刷新关联图片。 + func reloadLoadedMaterials( + api: any TravelAlbumServing + ) async throws -> TravelAlbumListResponse { + 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() + materials = refreshed.filter { seen.insert($0.id).inserted } + currentPage = max(loadedPageCount, 1) + canLoadMore = materials.count < refreshedTotal + selectedMaterialIds.formIntersection(materials.map(\.id)) + if selectedTab == .all { + allPhotoCount = refreshedTotal + } else { + purchasedPhotoCount = refreshedTotal + } + notifyStateChange() + return TravelAlbumListResponse(total: refreshedTotal, list: materials) + } + /// 切换选择模式。 func toggleSelectionMode() { guard selectedTab == .all else { return } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumPreviewActionHandler.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumPreviewActionHandler.swift index 3cdb324..4c7d306 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumPreviewActionHandler.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumPreviewActionHandler.swift @@ -14,11 +14,6 @@ final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling { self.api = api } - /// AI 修图仍由相册选择态的模板流程发起。 - func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult { - .unavailable("请在相册中选择照片后使用AI修图") - } - /// 调用素材删除接口,仅在服务端确认成功后返回成功结果。 func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult { do { @@ -31,9 +26,4 @@ final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling { return .failure(message.isEmpty ? "删除失败" : message) } } - - /// 刷新关联结果图接口尚未提供。 - func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult { - .unavailable("关联图片刷新接口待接入") - } } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift index 27ea4e6..966145a 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift @@ -7,7 +7,7 @@ import Kingfisher import SnapKit import UIKit -/// AI 修图模板选择 Sheet,展示三类横向模板列表、修图模式与固定底部操作。 +/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。 final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { /// 页面使用的 collection section。 private enum Section: Hashable { @@ -276,7 +276,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier, for: indexPath ) as! TravelAlbumAIRetouchSectionHeader - header.apply(title: category.title, optional: category == .atmosphere) + header.apply(title: category.title, optional: self.viewModel.isOptional(category)) return header } } @@ -359,10 +359,8 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { private func applySnapshot() { var snapshot = NSDiffableDataSourceSnapshot() - appendTemplates(.refined, to: &snapshot) - appendTemplates(.atmosphere, to: &snapshot) - if viewModel.showsCoverTemplates { - appendTemplates(.cover, to: &snapshot) + for category in viewModel.visibleCategories { + appendTemplates(category, to: &snapshot) } snapshot.appendSections([.mode]) snapshot.appendItems([.mode], toSection: .mode) diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index 9c9d3ae..18bf33c 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -489,6 +489,9 @@ final class TravelAlbumDetailViewController: BaseViewController { startProjectIndex: startIndex, configuration: previewConfiguration, actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI), + albumId: viewModel.albumId, + scenicIdProvider: scenicIdProvider, + aiRetouchAPI: previewAPI, loadMore: { await previewViewModel.loadMaterials(reset: false, api: previewAPI) return ( @@ -496,6 +499,13 @@ final class TravelAlbumDetailViewController: BaseViewController { previewViewModel.currentPhotoCount ) }, + reload: { + let response = try await previewViewModel.reloadLoadedMaterials(api: previewAPI) + return ( + response.list.map(TravelAlbumPreviewProject.init(material:)), + response.total + ) + }, onProjectDeleted: { materialId in previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId) } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift index 873921b..03b3a5b 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift @@ -10,13 +10,20 @@ import UIKit /// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。 typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int) +/// 相册项目刷新回调,失败时由预览页保留当前内容并展示错误。 +typealias TravelAlbumPreviewReload = () async throws -> (projects: [TravelAlbumPreviewProject], totalCount: Int) + /// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。 final class TravelAlbumPhotoPreviewViewController: UIViewController { private var projects: [TravelAlbumPreviewProject] private var totalCount: Int private let configuration: TravelAlbumPreviewConfiguration private let actionHandler: any TravelAlbumPreviewActionHandling + private let albumId: Int + private let scenicIdProvider: () -> Int + private let aiRetouchAPI: (any TravelAlbumServing)? private let loadMore: TravelAlbumPreviewLoadMore? + private let reload: TravelAlbumPreviewReload? private let onProjectDeleted: ((Int) -> Void)? private var nodes: [TravelAlbumPreviewNode] = [] private var currentNodeIndex = 0 @@ -24,6 +31,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { private var selectedKind: TravelAlbumPreviewAssetKind = .original private var chromeVisible = true private var isLoadingMore = false + private var isRefreshingProject = false private var isDeletingProject = false private var didApplyInitialPosition = false private var lastCollectionSize: CGSize = .zero @@ -39,6 +47,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { private let tabStack = UIStackView() private let actionStack = UIStackView() private let deleteButton = UIButton(type: .system) + private let refreshButton = UIButton(type: .system) private var tabHeightConstraint: Constraint? /// 创建全屏预览页。 @@ -48,14 +57,22 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { startProjectIndex: Int, configuration: TravelAlbumPreviewConfiguration = .init(), actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(), + albumId: Int = 0, + scenicIdProvider: @escaping () -> Int = { 0 }, + aiRetouchAPI: (any TravelAlbumServing)? = nil, loadMore: TravelAlbumPreviewLoadMore? = nil, + reload: TravelAlbumPreviewReload? = nil, onProjectDeleted: ((Int) -> Void)? = nil ) { self.projects = Self.deduplicated(projects) self.totalCount = max(totalCount, projects.count) self.configuration = configuration self.actionHandler = actionHandler + self.albumId = albumId + self.scenicIdProvider = scenicIdProvider + self.aiRetouchAPI = aiRetouchAPI self.loadMore = loadMore + self.reload = reload self.onProjectDeleted = onProjectDeleted super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen @@ -203,7 +220,14 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) deleteButton.accessibilityLabel = "删除" deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton" - let refreshButton = makeActionButton(title: "刷新", systemName: "arrow.clockwise", color: .white) + refreshButton.configuration = makeActionButton( + title: "刷新", + systemName: "arrow.clockwise", + color: .white + ).configuration + refreshButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) + refreshButton.accessibilityLabel = "刷新" + refreshButton.accessibilityIdentifier = "travelAlbum.previewRefreshButton" aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) @@ -403,19 +427,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { ) } - private func performAction(_ operation: @escaping () async -> TravelAlbumPreviewActionResult) { - Task { [weak self] in - guard let self else { return } - let result = await operation() - switch result { - case .success(let message): - if let message, !message.isEmpty { self.showPreviewToast(message) } - case .failure(let message), .unavailable(let message): - self.showPreviewToast(message) - } - } - } - private func showPreviewToast(_ message: String) { let label = TravelAlbumPreviewToastLabel() label.text = message @@ -444,8 +455,36 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { } @objc private func aiTapped() { - guard let id = currentProject?.originalMaterialId else { return } - performAction { [actionHandler] in await actionHandler.requestAIRetouch(originalMaterialId: id) } + guard presentedViewController == nil else { return } + guard let project = currentProject else { return } + guard let aiRetouchAPI else { + showPreviewToast("AI修图功能暂不可用") + return + } + let scenicId = scenicIdProvider() + guard scenicId > 0 else { + showPreviewToast("请先选择景区") + return + } + let selectedKind = currentAsset?.kind ?? .original + guard let workflow = project.aiRetouchWorkflow(albumId: albumId, selectedKind: selectedKind) else { + showPreviewToast("当前图片不支持AI修图") + return + } + + let controller = TravelAlbumAIRetouchTemplateViewController( + viewModel: TravelAlbumAIRetouchTemplateViewModel( + scenicId: scenicId, + workflow: workflow + ), + api: aiRetouchAPI, + onSubmitted: { [weak self] in + guard let self else { return } + self.showPreviewToast("AI修图任务已提交") + self.reloadProjects(showSuccessToast: false, forceRefreshImage: false) + } + ) + present(controller, animated: true) } @objc private func deleteTapped() { @@ -539,8 +578,98 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { } @objc private func refreshTapped() { - guard let id = currentProject?.originalMaterialId else { return } - performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) } + reloadProjects(showSuccessToast: true, forceRefreshImage: true) + } + + private func reloadProjects(showSuccessToast: Bool, forceRefreshImage: Bool) { + guard !isRefreshingProject else { return } + guard let reload else { + if showSuccessToast { showPreviewToast("关联图片刷新接口待接入") } + return + } + isRefreshingProject = true + updateRefreshButton() + let currentProjectId = currentProject?.id + let currentProjectIndex = currentNode?.projectIndex ?? 0 + let kind = currentAsset?.kind ?? .original + + Task { [weak self] in + guard let self else { return } + do { + let result = try await reload() + self.applySuccessfulReload( + projects: result.projects, + totalCount: result.totalCount, + currentProjectId: currentProjectId, + fallbackProjectIndex: currentProjectIndex, + kind: kind, + showSuccessToast: showSuccessToast, + forceRefreshImage: forceRefreshImage + ) + } catch is CancellationError { + self.isRefreshingProject = false + self.updateRefreshButton() + } catch { + self.isRefreshingProject = false + self.updateRefreshButton() + if showSuccessToast { + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + self.showPreviewToast(message.isEmpty ? "刷新失败" : message) + } + } + } + } + + private func applySuccessfulReload( + projects refreshedProjects: [TravelAlbumPreviewProject], + totalCount: Int, + currentProjectId: Int?, + fallbackProjectIndex: Int, + kind: TravelAlbumPreviewAssetKind, + showSuccessToast: Bool, + forceRefreshImage: Bool + ) { + let incoming = Self.deduplicated(refreshedProjects) + guard !incoming.isEmpty else { + isRefreshingProject = false + updateRefreshButton() + if showSuccessToast { dismiss(animated: true) } + return + } + + projects = incoming + self.totalCount = max(totalCount, incoming.count) + let targetProjectIndex = currentProjectId.flatMap { id in + incoming.firstIndex { $0.id == id } + } ?? min(max(0, fallbackProjectIndex), incoming.count - 1) + let resolvedKind = incoming[targetProjectIndex].asset(for: kind) == nil ? .original : kind + selectedKind = resolvedKind + rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind) + collectionView.reloadData() + collectionView.layoutIfNeeded() + setPage(currentNodeIndex, animated: false) + updateForCurrentNode() + if forceRefreshImage { forceRefreshCurrentImage() } + isRefreshingProject = false + updateRefreshButton() + if showSuccessToast { showPreviewToast("刷新成功") } + } + + private func forceRefreshCurrentImage() { + let indexPath = IndexPath(item: currentNodeIndex, section: 0) + guard let cell = collectionView.cellForItem(at: indexPath) as? TravelAlbumPreviewImageCell else { return } + cell.apply(asset: currentAsset, forceRefresh: true) + } + + private func updateRefreshButton() { + refreshButton.isEnabled = !isRefreshingProject + refreshButton.alpha = isRefreshingProject ? 0.55 : 1 + refreshButton.accessibilityValue = isRefreshingProject ? "刷新中" : nil + var configuration = refreshButton.configuration + configuration?.showsActivityIndicator = isRefreshingProject + configuration?.image = isRefreshingProject ? nil : UIImage(systemName: "arrow.clockwise") + configuration?.title = isRefreshingProject ? "刷新中" : "刷新" + refreshButton.configuration = configuration } } @@ -639,10 +768,10 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV resetZoom() } - func apply(asset newAsset: TravelAlbumPreviewAsset?) { + func apply(asset newAsset: TravelAlbumPreviewAsset?, forceRefresh: Bool = false) { asset = newAsset resetZoom() - loadImage() + loadImage(forceRefresh: forceRefresh) accessibilityLabel = newAsset.map { "\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)" } @@ -710,7 +839,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV scrollView.addGestureRecognizer(doubleTap) } - private func loadImage() { + private func loadImage(forceRefresh: Bool = false) { retryButton.isHidden = true let text = asset?.displayURL guard let text, let url = URL(string: text), !text.isEmpty else { @@ -718,7 +847,8 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV retryButton.isHidden = false return } - imageView.kf.setImage(with: url) { [weak self] result in + let options: KingfisherOptionsInfo? = forceRefresh ? [.forceRefresh] : nil + imageView.kf.setImage(with: url, options: options) { [weak self] result in guard let self, self.asset?.displayURL == text else { return } if case .failure = result, self.imageView.image == nil { self.retryButton.isHidden = false @@ -748,7 +878,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV } @objc private func retryTapped() { - loadImage() + loadImage(forceRefresh: true) } } diff --git a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift index 03070ce..ee3cc0c 100644 --- a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift +++ b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift @@ -6,28 +6,30 @@ import XCTest @testable import suixinkan -/// AI 修图模板选择状态测试。 +/// AI 修图模板工作流、选择状态与请求分流测试。 @MainActor final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { - func testLoadDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async { + func testInitialWorkflowDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async { let api = makeAPI() let viewModel = TravelAlbumAIRetouchTemplateViewModel( albumId: 8, scenicId: 18, - materialIds: [4, 3, 2, 1] + materialIds: [4, 3, 2, 1, 1] ) await viewModel.loadTemplates(api: api) 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) XCTAssertNil(viewModel.selectedAtmosphereTemplateId) XCTAssertEqual(viewModel.selectedCoverTemplateId, 31) - XCTAssertTrue(viewModel.showsCoverTemplates) + XCTAssertTrue(viewModel.isOptional(.atmosphere)) XCTAssertTrue(viewModel.canSubmit) } - func testAtmosphereSelectionTogglesOffWhenTappedAgain() async { + func testInitialAtmosphereSelectionTogglesOffWhenTappedAgain() async { let api = makeAPI() let viewModel = TravelAlbumAIRetouchTemplateViewModel( albumId: 8, @@ -43,70 +45,141 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { XCTAssertNil(viewModel.selectedAtmosphereTemplateId) } - func testThreeMaterialsHideCoverAndOmitCoverFromSubmission() async { + func testInitialSubmissionUsesCoverTemplateOnlyForFourOrMoreMaterials() async { let api = makeAPI() - let viewModel = TravelAlbumAIRetouchTemplateViewModel( + let threePhotoViewModel = TravelAlbumAIRetouchTemplateViewModel( albumId: 8, scenicId: 18, materialIds: [3, 1, 2] ) - var submitted = false - viewModel.onSubmitted = { submitted = true } - await viewModel.loadTemplates(api: api) + await threePhotoViewModel.loadTemplates(api: api) + await threePhotoViewModel.submit(api: api) - XCTAssertFalse(viewModel.showsCoverTemplates) - XCTAssertNil(viewModel.selectedCoverTemplateId) + XCTAssertEqual( + api.aiRetouchRequests[0], + TravelAlbumAIRetouchRequest( + userEquityTravelId: 8, + materialIds: [1, 2, 3], + refinedTemplateId: 11, + atmosphereTemplateId: nil, + coverTemplateId: nil + ) + ) - await viewModel.submit(api: api) - - 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( + let fourPhotoViewModel = TravelAlbumAIRetouchTemplateViewModel( albumId: 8, scenicId: 18, materialIds: [1, 2, 3, 4] ) - await viewModel.loadTemplates(api: api) - viewModel.toggleTemplate(id: 12, category: .refined) - viewModel.toggleTemplate(id: 21, category: .atmosphere) - - await viewModel.submit(api: api) + await fourPhotoViewModel.loadTemplates(api: api) + fourPhotoViewModel.toggleTemplate(id: 12, category: .refined) + fourPhotoViewModel.toggleTemplate(id: 21, category: .atmosphere) + await fourPhotoViewModel.submit(api: api) XCTAssertEqual( - api.aiRetouchRequests.first, + api.aiRetouchRequests[1], TravelAlbumAIRetouchRequest( userEquityTravelId: 8, materialIds: [1, 2, 3, 4], refinedTemplateId: 12, 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() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [template(11, "清透")], - atmosphereTemplates: [template(21, "暖阳")], + atmosphereTemplates: [], coverTemplates: [] ) let viewModel = TravelAlbumAIRetouchTemplateViewModel( - albumId: 8, scenicId: 18, - materialIds: [1, 2, 3, 4] + workflow: .reretouch(materialId: 7, batchId: 70, type: .all) ) await viewModel.loadTemplates(api: api) XCTAssertFalse(viewModel.canSubmit) - XCTAssertEqual(viewModel.validationMessage, "暂无可用的封面风格模板") + XCTAssertEqual(viewModel.validationMessage, "暂无可用的氛围感修图模板") } func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async { @@ -127,11 +200,10 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { func testSubmitFailureKeepsSelectionAndAllowsRetry() async { let api = makeAPI() - api.submitAIRetouchError = APIError.serverCode(500, "提交服务繁忙") + api.submitAIReretouchError = APIError.serverCode(500, "提交服务繁忙") let viewModel = TravelAlbumAIRetouchTemplateViewModel( - albumId: 8, scenicId: 18, - materialIds: [1] + workflow: .reretouch(materialId: 7, batchId: 70, type: .refined) ) var message: String? var submitted = false @@ -141,7 +213,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { await viewModel.submit(api: api) - XCTAssertEqual(api.aiRetouchRequests.count, 1) + XCTAssertEqual(api.aiReretouchRequests.count, 1) XCTAssertEqual(message, "提交服务繁忙") XCTAssertFalse(submitted) XCTAssertFalse(viewModel.isSubmitting) diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index f25a531..0178932 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -153,7 +153,7 @@ final class TravelAlbumAPITests: XCTestCase { materialIds: [11, 12, 13, 14], refinedTemplateId: 21, 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?["refined_template_id"] as? Int, 21) 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 { @@ -178,13 +185,75 @@ final class TravelAlbumAPITests: XCTestCase { materialIds: [11], refinedTemplateId: 21, atmosphereTemplateId: nil, - aiPreviewTabs: nil + coverTemplateId: nil ) ) let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any] 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 { diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index 70c99e0..a683cb4 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -352,6 +352,141 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { 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 { let cases: [(TravelAlbumMaterial, String, UInt)] = [ (TravelAlbumMaterial(isPurchased: true), "已购", 0x475569), diff --git a/suixinkanTests/TravelAlbumModelsTests.swift b/suixinkanTests/TravelAlbumModelsTests.swift index e383fbe..2b6f485 100644 --- a/suixinkanTests/TravelAlbumModelsTests.swift +++ b/suixinkanTests/TravelAlbumModelsTests.swift @@ -33,6 +33,7 @@ final class TravelAlbumModelsTests: XCTestCase { fileUrl: "https://cdn.example.com/original.jpg", fileSize: 4096, coverUrl: "https://cdn.example.com/cover.jpg", + aiRetouchBatchId: 55, aiRefinedURL: " https://cdn.example.com/refined.jpg ", aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg" ) @@ -50,6 +51,7 @@ final class TravelAlbumModelsTests: XCTestCase { ) XCTAssertNil(project.asset(for: .cover)) XCTAssertTrue(project.hasVariants) + XCTAssertEqual(project.aiRetouchBatchId, 55) } func testPreviewProjectIgnoresBlankAIResultURLs() { @@ -161,24 +163,40 @@ final class TravelAlbumModelsTests: XCTestCase { ) } - func testPlaceholderPreviewActionsReturnUnavailableWithoutMutation() async { - let handler = PlaceholderTravelAlbumPreviewActionHandler() - let aiResult = await handler.requestAIRetouch(originalMaterialId: 9) - let deleteResult = await handler.deleteProject(originalMaterialId: 9) - let refreshResult = await handler.refreshProject(originalMaterialId: 9) + func testPreviewProjectMapsCurrentTabToMinimalAIRetouchWorkflow() { + let originalOnly = makePreviewProject(id: 9, kinds: [.original]) + let variants = TravelAlbumPreviewProject( + originalMaterialId: 10, + aiRetouchBatchId: 88, + assets: makePreviewProject(id: 10, kinds: [.original, .retouched, .atmosphere]).assets + ) XCTAssertEqual( - aiResult, - .unavailable("AI修图功能待接口接入") + originalOnly.aiRetouchWorkflow(albumId: 7, selectedKind: .original), + .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( deleteResult, .unavailable("项目删除接口待接入") ) - XCTAssertEqual( - refreshResult, - .unavailable("关联图片刷新接口待接入") - ) } func testTravelAlbumDecodesSnakeCaseFields() throws { @@ -225,6 +243,7 @@ final class TravelAlbumModelsTests: XCTestCase { "is_purchased": false, "ai_retouch_status": 3, "ai_retouch_status_name": "AI已修", + "ai_retouch_batch_id": 66, "ai_refined_url": "https://cdn/refined.jpg", "ai_atmosphere_url": "https://cdn/atmosphere.jpg", "created_at": "", @@ -238,6 +257,7 @@ final class TravelAlbumModelsTests: XCTestCase { XCTAssertFalse(material.isPurchased) XCTAssertEqual(material.aiRetouchStatus, 3) XCTAssertEqual(material.aiRetouchStatusName, "AI已修") + XCTAssertEqual(material.aiRetouchBatchId, 66) XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg") XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg") } @@ -258,6 +278,7 @@ final class TravelAlbumModelsTests: XCTestCase { "is_purchased": false, "ai_retouch_status": "invalid", "ai_retouch_status_name": null, + "ai_retouch_batch_id": "invalid", "ai_refined_url": 123, "created_at": "", "updated_at": "" @@ -268,6 +289,7 @@ final class TravelAlbumModelsTests: XCTestCase { XCTAssertEqual(material.aiRetouchStatus, 0) XCTAssertEqual(material.aiRetouchStatusName, "") + XCTAssertEqual(material.aiRetouchBatchId, 0) XCTAssertEqual(material.aiRefinedURL, "") XCTAssertEqual(material.aiAtmosphereURL, "") } diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 809cf28..e9d9989 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -206,6 +206,41 @@ final class TravelAlbumDetailViewModelTests: XCTestCase { 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 { let api = TravelAlbumMockAPI() let viewModel = TravelAlbumDetailViewModel(albumId: 5) @@ -1053,6 +1088,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing { var aiRetouchTemplatesError: Error? var submitAIRetouchError: Error? var submitAIRetouchDelayNanoseconds: UInt64 = 0 + var submitAIReretouchError: Error? + var submitAIReretouchDelayNanoseconds: UInt64 = 0 var deleteMaterialError: Error? var deleteMaterialDelayNanoseconds: UInt64 = 0 @@ -1065,6 +1102,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing { private(set) var deletedMaterialIds: [Int] = [] private(set) var aiRetouchTemplateScenicIds: [Int] = [] private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = [] + private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = [] func availableOrders() async throws -> [TravelAlbumAvailableOrder] { availableOrdersCallCount += 1 @@ -1148,4 +1186,12 @@ final class TravelAlbumMockAPI: TravelAlbumServing { } 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 } + } }