// // TravelAlbumAIRetouchTemplateViewModel.swift // suixinkan // import Foundation /// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。 final class TravelAlbumAIRetouchTemplateViewModel { private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = [] private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [] private(set) var coverTemplates: [TravelAlbumAIRetouchTemplate] = [] private(set) var selectedRefinedTemplateId: Int? private(set) var selectedAtmosphereTemplateId: Int? private(set) var selectedCoverTemplateId: Int? private(set) var remainingQuota: Int? private(set) var isLoading = false private(set) var isSubmitting = false private(set) var loadErrorMessage: String? let scenicId: Int let workflow: TravelAlbumAIRetouchWorkflow var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)? /// 创建首次 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 switch workflow { case .initial(let albumId, let materialIds): self.workflow = .initial( albumId: albumId, materialIds: Array(Set(materialIds)).sorted() ) case .reretouch: self.workflow = workflow } } /// 当前工作流需要展示的模板分组。 var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] { workflow.visibleCategories } /// 当前工作流选择的原始照片数量。 var selectedPhotoCount: Int { switch workflow { case .initial(_, let materialIds): materialIds.count case .reretouch: 1 } } /// 是否展示首次修图的氛围感与封面生成说明。 var shouldShowInitialTips: Bool { if case .initial = workflow { return true } return false } /// 首次修图页按照当前选图数量展示氛围感与封面生成规则。 var initialTipsText: String { if visibleCategories.contains(.cover) { return "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。" } return "Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。" } /// 当前分组是否为选填。 func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { workflow.isOptional(category) } /// 当前选择预计消耗的修图次数;封面模板免费,不计入额度。 var requiredQuota: Int { switch workflow { case .initial(_, let materialIds): let outputCount = 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1) return materialIds.count * outputCount case .reretouch(_, _, let type): switch type { case .refined, .atmosphere: return 1 case .all: return (selectedRefinedTemplateId == nil ? 0 : 1) + (selectedAtmosphereTemplateId == nil ? 0 : 1) } } } /// 当前必选模板或业务参数缺失时用于底部提示的文案。 var validationMessage: String? { guard !isLoading, loadErrorMessage == nil else { return nil } guard workflow.isValid else { if case .reretouch = workflow { return "当前图片缺少修图批次,请刷新后重试" } return "请选择要修图的照片" } for category in visibleCategories where !isOptional(category) { if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil { return unavailableMessage(for: category) } } if case .reretouch(_, _, .all) = workflow, selectedRefinedTemplateId == nil, selectedAtmosphereTemplateId == nil { return "请至少选择一个修图模板" } guard let remainingQuota else { return "剩余修图次数获取失败,请刷新后重试" } if requiredQuota > remainingQuota { return "剩余修图次数不足,需要\(requiredQuota)次,当前剩余\(remainingQuota)次" } return nil } /// 是否满足提交条件。 var canSubmit: Bool { !isLoading && !isSubmitting && loadErrorMessage == nil && workflow.isValid && validationMessage == nil } /// 实时拉取当前景区模板并按工作流设置默认选择。 func loadTemplates(api: any TravelAlbumServing) async { guard scenicId > 0 else { loadErrorMessage = "请先选择景区" notifyStateChange() return } guard !isLoading else { return } isLoading = true loadErrorMessage = nil remainingQuota = nil notifyStateChange() do { let response = try await api.aiRetouchTemplates(scenicId: scenicId) refinedTemplates = response.refinedTemplates atmosphereTemplates = response.atmosphereTemplates coverTemplates = response.coverTemplates remainingQuota = response.remainingQuota selectedRefinedTemplateId = visibleCategories.contains(.refined) && !isOptional(.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 { isLoading = false notifyStateChange() } catch { refinedTemplates = [] atmosphereTemplates = [] coverTemplates = [] selectedRefinedTemplateId = nil selectedAtmosphereTemplateId = nil selectedCoverTemplateId = nil remainingQuota = nil isLoading = false loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription notifyStateChange() } } /// 返回指定分组的模板。 func templates(for category: TravelAlbumAIRetouchTemplateCategory) -> [TravelAlbumAIRetouchTemplate] { switch category { case .refined: refinedTemplates case .atmosphere: atmosphereTemplates case .cover: coverTemplates } } /// 返回指定分组当前选中的模板 ID。 func selectedTemplateId(for category: TravelAlbumAIRetouchTemplateCategory) -> Int? { switch category { case .refined: selectedRefinedTemplateId case .atmosphere: selectedAtmosphereTemplateId case .cover: selectedCoverTemplateId } } /// 选择模板;选填分组允许再次点击取消,必选分组保持单选。 func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) { guard visibleCategories.contains(category), templates(for: category).contains(where: { $0.id == id }) else { return } switch category { case .refined: selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id case .atmosphere: selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id case .cover: selectedCoverTemplateId = id } notifyStateChange() } /// 按当前工作流提交首次修图或覆盖重修任务。 func submit(api: any TravelAlbumServing) async { guard !isSubmitting else { return } guard canSubmit else { onShowMessage?(validationMessage ?? "当前无法提交AI修图") return } isSubmitting = true notifyStateChange() defer { isSubmitting = false notifyStateChange() } do { let submission: TravelAlbumAIJobSubmission switch workflow { case .initial(let albumId, let materialIds): guard let refinedTemplateId = selectedRefinedTemplateId else { onShowMessage?("请选择原图精修模板") return } submission = try await api.submitAIRetouch( TravelAlbumAIRetouchRequest( userEquityTravelId: albumId, materialIds: materialIds, refinedTemplateId: refinedTemplateId, atmosphereTemplateId: selectedAtmosphereTemplateId, coverTemplateId: selectedCoverTemplateId ) ) case .reretouch(let materialId, let batchId, let type): submission = try await api.submitAIReretouch( TravelAlbumAIReretouchRequest( id: materialId, aiRetouchBatchId: batchId, type: type, refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId, atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId ) ) } onSubmitted?(submission) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "AI修图任务提交失败" : error.localizedDescription) } } private func unavailableMessage(for category: TravelAlbumAIRetouchTemplateCategory) -> String { switch category { case .refined: "暂无可用的原图精修模板" case .atmosphere: "暂无可用的氛围感修图模板" case .cover: "暂无可用的封面风格模板" } } private func notifyStateChange() { onStateChange?() } }