266 lines
9.8 KiB
Swift
266 lines
9.8 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: (() -> 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 {
|
|
"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 {
|
|
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
|
|
} 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?()
|
|
}
|
|
}
|