Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift
T

270 lines
10 KiB
Swift

//
// 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 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)
}
}
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) ? 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 = 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?()
}
}