feat: 接通图片预览 AI 修图流程
This commit is contained in:
+88
-60
@@ -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?()
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
guard selectedTab == .all else { return }
|
||||
|
||||
@@ -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("关联图片刷新接口待接入")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user