新增线下收款记录和 ai 修图优化

This commit is contained in:
han xin
2026-08-21 15:51:52 +08:00
parent 6fe9928b49
commit 5cf4409bad
40 changed files with 5482 additions and 56 deletions
@@ -0,0 +1,122 @@
//
// TravelAlbumAutoRetouchModels.swift
// suixinkan
//
import Foundation
/// 自动修图第一级方式,页面上始终只展示“不修图”和“AI 修图”两个选项。
enum TravelAlbumRetouchMode: String, CaseIterable, Hashable, Sendable {
case disabled
case aiRetouch
/// 修图方式的用户可见名称。
var title: String {
switch self {
case .disabled:
return "不修图"
case .aiRetouch:
return "AI 修图"
}
}
}
/// 相册自动 AI 修图配置,用于在新建相册与照片上传页之间传递模板选择。
struct TravelAlbumAutoRetouchConfiguration: Codable, Equatable, Sendable {
let isEnabled: Bool
let templateID: String?
/// 默认不启用自动修图。
static let disabled = TravelAlbumAutoRetouchConfiguration(isEnabled: false, templateID: nil)
/// 根据已选模板创建启用状态配置。
static func enabled(templateID: String) -> TravelAlbumAutoRetouchConfiguration {
TravelAlbumAutoRetouchConfiguration(isEnabled: true, templateID: templateID)
}
/// 当前配置对应的修图模板。
var template: TravelAlbumEditPreset? {
guard isEnabled, let templateID else { return nil }
return TravelAlbumEditPreset.autoRetouchOptions.first { $0.id == templateID }
}
/// 是否可以作为完整的自动修图配置提交。
var isValid: Bool {
!isEnabled || template != nil
}
/// 上传页紧凑设置项的展示文案。
var uploadOptionTitle: String {
isEnabled ? TravelAlbumRetouchMode.aiRetouch.title : TravelAlbumRetouchMode.disabled.title
}
}
extension TravelAlbumEditPreset {
/// 自动修图可选模板,不包含手动修图中的“还原为原图”。
static var autoRetouchOptions: [TravelAlbumEditPreset] {
defaultOptions.filter { $0.effect != .original }
}
}
/// 单张 OTG 照片的自动 AI 修图状态,与原有上传状态分开计算。
enum TravelAlbumAutoRetouchState: String, Codable, Equatable, Sendable {
case none = "NONE"
case processing = "PROCESSING"
case completed = "COMPLETED"
case failed = "FAILED"
}
/// 相册自动修图配置读写接口,便于 ViewModel 与单元测试注入。
protocol TravelAlbumAutoRetouchConfigurationStoring: AnyObject {
/// 读取指定服务端相册的配置。
func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration
/// 保存指定服务端相册的配置。
func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int)
/// 删除指定相册的本地配置。
func remove(albumID: Int)
}
/// 使用 UserDefaults 按服务端相册 ID 持久化自动 AI 修图配置。
final class TravelAlbumAutoRetouchConfigurationStore: TravelAlbumAutoRetouchConfigurationStoring {
static let shared = TravelAlbumAutoRetouchConfigurationStore()
private let userDefaults: UserDefaults
private let keyPrefix: String
/// 创建配置存储;测试可传入独立 UserDefaults suite。
init(
userDefaults: UserDefaults = .standard,
keyPrefix: String = "travelAlbum.autoRetouch.album"
) {
self.userDefaults = userDefaults
self.keyPrefix = keyPrefix
}
func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration {
guard albumID > 0,
let data = userDefaults.data(forKey: key(albumID: albumID)),
let configuration = try? JSONDecoder().decode(TravelAlbumAutoRetouchConfiguration.self, from: data),
configuration.isValid else {
return .disabled
}
return configuration
}
func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int) {
guard albumID > 0 else { return }
let normalized = configuration.isValid ? configuration : .disabled
guard let data = try? JSONEncoder().encode(normalized) else { return }
userDefaults.set(data, forKey: key(albumID: albumID))
}
func remove(albumID: Int) {
guard albumID > 0 else { return }
userDefaults.removeObject(forKey: key(albumID: albumID))
}
private func key(albumID: Int) -> String {
"\(keyPrefix).\(albumID)"
}
}
@@ -19,6 +19,8 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
var errorMessage: String?
let localPath: String
var remoteUrl: String
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
/// 是否未上传完成。
var isNotUploaded: Bool {
@@ -346,7 +348,8 @@ enum TravelAlbumOTGPhotoFormatMatcher {
extension TravelAlbumOTGPhotoRecord {
/// 转为页面展示项。
func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem {
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in
let preferredRetouchedPath = autoRetouchState == .completed ? retouchedPath : ""
let previewPath = [preferredRetouchedPath, thumbnailPath, localPath, remoteUrl].first { path in
guard !path.isEmpty else { return false }
if path.hasPrefix("http") { return true }
return storage.fileExists(relativePath: path, albumId: albumId)
@@ -371,7 +374,9 @@ extension TravelAlbumOTGPhotoRecord {
progress: progress,
errorMessage: errorMessage,
localPath: localPath,
remoteUrl: remoteUrl
remoteUrl: remoteUrl,
autoRetouchState: autoRetouchState,
autoRetouchTemplateId: autoRetouchTemplateId
)
}
}
@@ -56,6 +56,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
let albumId: Int
let userId: String
var remoteUrl: String
var materialId: Int?
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
var retouchedPath: String
var updatedAt: Int64
/// 创建 OTG 本地照片记录。
@@ -74,6 +78,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId: Int,
userId: String,
remoteUrl: String = "",
materialId: Int? = nil,
autoRetouchState: TravelAlbumAutoRetouchState = .none,
autoRetouchTemplateId: String? = nil,
retouchedPath: String = "",
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
) {
self.id = id
@@ -90,12 +98,17 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.albumId = albumId
self.userId = userId
self.remoteUrl = remoteUrl
self.materialId = materialId
self.autoRetouchState = autoRetouchState
self.autoRetouchTemplateId = autoRetouchTemplateId
self.retouchedPath = retouchedPath
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, materialId
case autoRetouchState, autoRetouchTemplateId, retouchedPath, updatedAt
}
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
@@ -115,16 +128,30 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId = try container.decode(Int.self, forKey: .albumId)
userId = try container.decode(String.self, forKey: .userId)
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? ""
materialId = try container.decodeIfPresent(Int.self, forKey: .materialId)
autoRetouchState = try container.decodeIfPresent(
TravelAlbumAutoRetouchState.self,
forKey: .autoRetouchState
) ?? .none
autoRetouchTemplateId = try container.decodeIfPresent(String.self, forKey: .autoRetouchTemplateId)
retouchedPath = try container.decodeIfPresent(String.self, forKey: .retouchedPath) ?? ""
updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
}
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
guard status == .transferring || status == .uploading else { return self }
let interruptedUpload = status == .transferring || status == .uploading
let interruptedRetouch = autoRetouchState == .processing
guard interruptedUpload || interruptedRetouch else { return self }
var copy = self
copy.status = .pending
copy.progress = 0
copy.errorMessage = nil
if interruptedUpload {
copy.status = copy.materialId == nil ? .pending : .uploaded
copy.progress = copy.materialId == nil ? 0 : 100
copy.errorMessage = nil
}
if interruptedRetouch {
copy.autoRetouchState = copy.materialId == nil ? .none : .failed
}
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
return copy
}
@@ -242,7 +269,7 @@ final class TravelAlbumOTGPhotoStore {
?? TravelAlbumClientPhotoID.make()
return copy.normalizedAfterInterruptedTransfer()
}
if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) {
if migrated != scoped {
save(migrated, albumId: albumId)
}
return migrated
@@ -333,6 +360,22 @@ final class TravelAlbumOTGPhotoStore {
return url
}
/// 写入自动 AI 修图结果,返回当前相册预览目录中的文件 URL。
func writeRetouchedImage(_ data: Data, filename: String, albumId: Int) throws -> URL {
let directory = try previewsDirectory(albumId: albumId)
let sanitized = PTPHelper.sanitizeFilename(filename)
let stem = (sanitized as NSString).deletingPathExtension
let preferredName = "\(stem)_retouched.jpg"
var candidate = directory.appendingPathComponent(preferredName)
var counter = 1
while fileManager.fileExists(atPath: candidate.path), counter < 10_000 {
candidate = directory.appendingPathComponent("\(stem)_retouched_\(counter).jpg")
counter += 1
}
try data.write(to: candidate, options: .atomic)
return candidate
}
/// 返回写入索引用的相对路径。
func relativePath(for url: URL, albumId: Int) -> String {
let path = url.standardizedFileURL.path
@@ -382,6 +425,9 @@ final class TravelAlbumOTGPhotoStore {
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
resolved.thumbnailPath = thumbnailURL.path
}
if let retouchedURL = absoluteURL(for: record.retouchedPath, albumId: albumId) {
resolved.retouchedPath = retouchedURL.path
}
return resolved
}
@@ -433,7 +479,7 @@ final class TravelAlbumOTGPhotoStore {
}
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
[record.localPath, record.thumbnailPath].forEach { path in
[record.localPath, record.thumbnailPath, record.retouchedPath].forEach { path in
guard !path.isEmpty else { return }
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
try? fileManager.removeItem(at: url)
@@ -463,6 +509,11 @@ final class TravelAlbumOTGPhotoStore {
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
: record.thumbnailPath
}
if !record.retouchedPath.isEmpty {
normalized.retouchedPath = record.retouchedPath.hasPrefix("/")
? relativePath(for: URL(fileURLWithPath: record.retouchedPath), albumId: albumId)
: record.retouchedPath
}
return normalized
}
@@ -90,6 +90,18 @@ final class TravelAlbumAIEditResultStore {
notifyChange(materialID: materialID)
}
/// 使用已编码图片数据保存自动修图结果,供无 UIKit 依赖的 ViewModel 调用。
@discardableResult
func completeFromImageData(
materialID: Int,
presetID: String,
editedImageData: Data
) -> Bool {
guard let image = UIImage(data: editedImageData) else { return false }
complete(materialID: materialID, presetID: presetID, editedImage: image)
return true
}
/// 修图失败时恢复按钮可用状态,并保留之前成功的结果。
func failProcessing(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
@@ -0,0 +1,59 @@
//
// TravelAlbumAutoRetouchProcessor.swift
// suixinkan
//
import Foundation
import UIKit
/// 自动 AI 修图处理接口,把本地原图与已选模板转换为可持久化的结果数据。
@MainActor
protocol TravelAlbumAutoRetouchProcessing {
/// 按指定模板处理一张本地照片。
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data
}
/// 使用项目现有 Core Image 效果生成自动修图演示结果。
@MainActor
final class TravelAlbumAutoRetouchProcessor: TravelAlbumAutoRetouchProcessing {
private let processingDelayNanoseconds: UInt64
/// 创建本地修图处理器,默认保留短暂处理时间用于展示“修图中”状态。
init(processingDelayNanoseconds: UInt64 = 900_000_000) {
self.processingDelayNanoseconds = processingDelayNanoseconds
}
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data {
let sourceData = try Data(contentsOf: sourceURL)
guard let sourceImage = UIImage(data: sourceData) else {
throw TravelAlbumAutoRetouchError.invalidSourceImage
}
if processingDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: processingDelayNanoseconds)
}
try Task.checkCancellation()
let editedImage = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: sourceImage)
guard let resultData = editedImage.jpegData(compressionQuality: 0.92) else {
throw TravelAlbumAutoRetouchError.resultEncodingFailed
}
return resultData
}
}
/// 本地自动 AI 修图过程的可展示错误。
enum TravelAlbumAutoRetouchError: LocalizedError, Equatable {
case invalidSourceImage
case resultEncodingFailed
case invalidImageData
var errorDescription: String? {
switch self {
case .invalidSourceImage:
return "原图无法读取,修图失败"
case .resultEncodingFailed:
return "修图结果生成失败"
case .invalidImageData:
return "修图结果无法读取"
}
}
}
@@ -42,13 +42,16 @@ final class TravelAlbumEntryViewModel {
private let currentScenicIdProvider: () -> Int
private let dateProvider: () -> Date
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
init(
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
dateProvider: @escaping () -> Date = Date.init
dateProvider: @escaping () -> Date = Date.init,
autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared
) {
self.currentScenicIdProvider = currentScenicIdProvider
self.dateProvider = dateProvider
self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
}
/// 重新拉取相册列表。
@@ -110,6 +113,7 @@ final class TravelAlbumEntryViewModel {
freeCount: String,
singlePrice: String,
packagePrice: String,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
order: TravelAlbumAvailableOrder?,
api: any TravelAlbumServing
) async {
@@ -132,6 +136,10 @@ final class TravelAlbumEntryViewModel {
onShowMessage?("请输入有效的单张照片价格")
return
}
if !autoRetouchConfiguration.isValid {
onShowMessage?("请选择修图模板")
return
}
let albumName: String
switch mode {
@@ -174,6 +182,7 @@ final class TravelAlbumEntryViewModel {
do {
let response = try await api.create(request)
autoRetouchConfigurationStore.save(autoRetouchConfiguration, albumID: response.id)
isCreateSheetVisible = false
onShowMessage?("任务创建成功")
onCreatedAlbum?(response)
@@ -63,7 +63,7 @@ final class WiredCameraTransferViewModel {
private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false
let retouchOption = "不修图"
private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() }
@@ -80,6 +80,9 @@ final class WiredCameraTransferViewModel {
private let api: any TravelAlbumServing
private let appStore: AppStore
private let userDefaults: UserDefaults
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
private let autoRetouchProcessor: any TravelAlbumAutoRetouchProcessing
private let aiEditResultStore: TravelAlbumAIEditResultStore
private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
@@ -100,7 +103,10 @@ final class WiredCameraTransferViewModel {
uploader: (any TravelAlbumOTGUploading)? = nil,
api: (any TravelAlbumServing)? = nil,
appStore: AppStore = .shared,
userDefaults: UserDefaults = .standard
userDefaults: UserDefaults = .standard,
autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared,
autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil,
aiEditResultStore: TravelAlbumAIEditResultStore? = nil
) {
self.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
@@ -113,6 +119,10 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore
self.userDefaults = userDefaults
self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
self.autoRetouchProcessor = autoRetouchProcessor ?? TravelAlbumAutoRetouchProcessor()
self.aiEditResultStore = aiEditResultStore ?? .shared
self.autoRetouchConfiguration = autoRetouchConfigurationStore.configuration(albumID: albumId)
// 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
if let initialTransferMode {
@@ -218,6 +228,11 @@ final class WiredCameraTransferViewModel {
transferMode.title
}
/// 自动 AI 修图设置项展示文案。
var retouchOption: String {
autoRetouchConfiguration.uploadOptionTitle
}
/// 指定上传弹窗选项。
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases
@@ -306,6 +321,15 @@ final class WiredCameraTransferViewModel {
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
}
/// 更新当前相册的自动 AI 修图配置,只影响尚未开始的上传任务。
func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) {
let normalized = configuration.isValid ? configuration : .disabled
guard normalized != autoRetouchConfiguration else { return }
autoRetouchConfiguration = normalized
autoRetouchConfigurationStore.save(normalized, albumID: albumId)
notifyStateChanged()
}
/// 切换上传格式。
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return }
@@ -467,8 +491,31 @@ final class WiredCameraTransferViewModel {
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
}
/// 使用该照片上次失败时的模板重试自动修图,不重复上传原图。
func retryAutoRetouch(photoId: String) {
guard let record = persistedRecordsById[photoId],
record.autoRetouchState == .failed,
let materialId = record.materialId,
let templateID = record.autoRetouchTemplateId,
let preset = TravelAlbumEditPreset.autoRetouchOptions.first(where: { $0.id == templateID }) else {
showMessage("暂无可重试的修图任务")
return
}
Task {
await processAutoRetouch(
photoId: photoId,
sourceRecord: record,
materialId: materialId,
preset: preset
)
}
}
/// 删除单张本地照片。
func deletePhoto(photoId: String) {
if let materialId = persistedRecordsById[photoId]?.materialId {
aiEditResultStore.remove(materialID: materialId)
}
storage.remove(albumId: albumId, photoId: photoId)
persistedRecordsById.removeValue(forKey: photoId)
selectedPhotoIds.remove(photoId)
@@ -478,6 +525,9 @@ final class WiredCameraTransferViewModel {
/// 清空当前相册本地 OTG 缓存。
func clearLocalAlbumCache() {
persistedRecordsById.values.compactMap(\.materialId).forEach { materialId in
aiEditResultStore.remove(materialID: materialId)
}
storage.clearAlbum(albumId: albumId)
persistedRecordsById = [:]
photos = []
@@ -492,9 +542,27 @@ final class WiredCameraTransferViewModel {
private func loadPersistedPhotos() {
let records = storage.load(albumId: albumId)
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
restorePersistedAutoRetouchResults(records)
applyMergedPhotos()
}
private func restorePersistedAutoRetouchResults(_ records: [TravelAlbumOTGPhotoRecord]) {
records.forEach { record in
guard record.autoRetouchState == .completed,
let materialId = record.materialId,
let templateID = record.autoRetouchTemplateId,
let resultURL = storage.absoluteURL(for: record.retouchedPath, albumId: albumId),
let resultData = try? Data(contentsOf: resultURL) else {
return
}
_ = aiEditResultStore.completeFromImageData(
materialID: materialId,
presetID: templateID,
editedImageData: resultData
)
}
}
private func syncServerUploadStatuses() {
serverStatusSyncTask?.cancel()
guard albumId > 0 else { return }
@@ -593,6 +661,8 @@ final class WiredCameraTransferViewModel {
}
private func uploadPhoto(id: String) async {
// 每张照片开始上传时固定一次配置,避免修图过程中切换模板导致结果不一致。
let retouchConfiguration = autoRetouchConfiguration
do {
let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
@@ -602,13 +672,89 @@ final class WiredCameraTransferViewModel {
) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
}
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
// 上传期间用户可能删除照片,删除后不再回写本地任务或发起修图。
guard persistedRecordsById[id] != nil else { return }
updateRecord(
id: id,
status: retouchConfiguration.template == nil ? .uploaded : .uploading,
progress: 100,
error: nil,
remoteUrl: material.fileUrl,
materialId: material.id
)
guard let preset = retouchConfiguration.template else { return }
await processAutoRetouch(
photoId: id,
sourceRecord: record,
materialId: material.id,
preset: preset
)
} catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(error.localizedDescription)
}
}
/// 对已完成原图上传的照片执行本地演示修图,修图失败不回退上传成功状态。
private func processAutoRetouch(
photoId: String,
sourceRecord: TravelAlbumOTGPhotoRecord,
materialId: Int,
preset: TravelAlbumEditPreset
) async {
let resolvedRecord = storage.recordWithResolvedFilePaths(sourceRecord, albumId: albumId)
let sourceURL = URL(fileURLWithPath: resolvedRecord.localPath)
updateAutoRetouchRecord(
id: photoId,
state: .processing,
templateId: preset.id,
materialId: materialId,
status: .uploading,
error: nil
)
aiEditResultStore.startProcessing(materialIDs: [materialId])
do {
let resultData = try await autoRetouchProcessor.process(sourceURL: sourceURL, preset: preset)
guard persistedRecordsById[photoId] != nil else {
aiEditResultStore.remove(materialID: materialId)
return
}
let resultURL = try storage.writeRetouchedImage(
resultData,
filename: sourceRecord.fileName,
albumId: albumId
)
guard aiEditResultStore.completeFromImageData(
materialID: materialId,
presetID: preset.id,
editedImageData: resultData
) else {
throw TravelAlbumAutoRetouchError.invalidImageData
}
updateAutoRetouchRecord(
id: photoId,
state: .completed,
templateId: preset.id,
materialId: materialId,
status: .uploaded,
error: nil,
retouchedPath: storage.relativePath(for: resultURL, albumId: albumId)
)
} catch {
aiEditResultStore.failProcessing(materialID: materialId)
updateAutoRetouchRecord(
id: photoId,
state: .failed,
templateId: preset.id,
materialId: materialId,
status: .uploaded,
error: error.localizedDescription
)
showMessage("修图失败,可点击更多重试")
}
}
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id],
!record.localPath.isEmpty,
@@ -623,7 +769,8 @@ final class WiredCameraTransferViewModel {
status: TravelAlbumOTGUploadStatus,
progress: Int,
error: String?,
remoteUrl: String? = nil
remoteUrl: String? = nil,
materialId: Int? = nil
) {
var record = persistedRecordsById[id]
if record == nil, let item = photos.first(where: { $0.id == id }) {
@@ -642,7 +789,10 @@ final class WiredCameraTransferViewModel {
errorMessage: error,
albumId: albumId,
userId: appStore.session.userId,
remoteUrl: remoteUrl ?? item.remoteUrl
remoteUrl: remoteUrl ?? item.remoteUrl,
materialId: materialId,
autoRetouchState: item.autoRetouchState,
autoRetouchTemplateId: item.autoRetouchTemplateId
)
}
guard var record else { return }
@@ -652,6 +802,35 @@ final class WiredCameraTransferViewModel {
if let remoteUrl {
record.remoteUrl = remoteUrl
}
if let materialId {
record.materialId = materialId
}
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId)
applyMergedPhotos()
}
/// 单独更新修图阶段,保留已完成的原图上传信息。
private func updateAutoRetouchRecord(
id: String,
state: TravelAlbumAutoRetouchState,
templateId: String,
materialId: Int,
status: TravelAlbumOTGUploadStatus,
error: String?,
retouchedPath: String? = nil
) {
guard var record = persistedRecordsById[id] else { return }
record.status = status
record.progress = 100
record.errorMessage = error
record.materialId = materialId
record.autoRetouchState = state
record.autoRetouchTemplateId = templateId
if let retouchedPath {
record.retouchedPath = retouchedPath
}
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId)