新增ai修图

This commit is contained in:
XHYourName
2026-07-31 16:48:28 +08:00
parent 7ccbc5568d
commit 6129a1632f
42 changed files with 3696 additions and 54 deletions
@@ -116,6 +116,8 @@ struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
private static let bundledImageURLPrefix = "asset://"
let id: Int
let userEquityTravelId: Int
let status: Int
@@ -175,6 +177,18 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// 将工程内图片资源转换为素材可识别的本地地址,仅供无接口数据的演示素材使用。
static func bundledImageURL(assetName: String) -> String {
bundledImageURLPrefix + assetName
}
/// 返回本地演示素材对应的图片资源名;真实网络素材返回 `nil`。
var bundledImageAssetName: String? {
guard fileUrl.hasPrefix(Self.bundledImageURLPrefix) else { return nil }
let assetName = String(fileUrl.dropFirst(Self.bundledImageURLPrefix.count))
return assetName.isEmpty ? nil : assetName
}
}
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
@@ -0,0 +1,316 @@
//
// TravelAlbumPhotoPreviewModels.swift
// suixinkan
//
import Foundation
/// 单张照片的 AI 修图状态,控制处理中禁用、结果对比以及再次修图覆盖。
struct TravelAlbumAIEditResultState: Equatable, Sendable {
/// 相册缩略图需要展示的 AI 修图状态。
enum ThumbnailStatus: Equatable, Sendable {
case none
case processing
case edited
case cover
case failed
}
/// 当前预览展示的版本。
enum DisplayMode: Equatable, Sendable {
case original
case edited
case atmosphere
case cover
}
private(set) var isProcessing = false
private(set) var didFailLastAttempt = false
private(set) var appliedPresetID: String?
private(set) var appliedAtmosphereOptionID: String?
private(set) var appliedCoverTemplateID: String?
private(set) var displayMode: DisplayMode = .original
/// 已经存在可查看的修图结果。
var hasEditedResult: Bool {
appliedPresetID != nil
}
/// 处理期间禁止再次发起 AI 修图。
var canStartEditing: Bool {
!isProcessing
}
/// 当前结果是否为封面模板生成的封面图。
var isCover: Bool {
appliedCoverTemplateID != nil
}
/// 当前是否已经生成可独立查看的氛围感修图结果。
var hasAtmosphereResult: Bool {
appliedAtmosphereOptionID != nil
}
/// 根据修图任务状态生成缩略图角标,处理中和失败优先于历史成功结果。
var thumbnailStatus: ThumbnailStatus {
if isProcessing {
return .processing
}
if didFailLastAttempt {
return .failed
}
if isCover {
return .cover
}
return hasEditedResult ? .edited : .none
}
/// 开始修图;已有结果保留到新结果完成,避免处理中画面回退。
mutating func startProcessing() {
guard !isProcessing else { return }
isProcessing = true
didFailLastAttempt = false
}
/// 完成修图并覆盖旧预设结果,默认展示新的修图后版本。
mutating func complete(
presetID: String,
atmosphereOptionID: String? = nil,
coverTemplateID: String? = nil
) {
isProcessing = false
didFailLastAttempt = false
appliedPresetID = presetID
appliedAtmosphereOptionID = atmosphereOptionID
appliedCoverTemplateID = coverTemplateID
displayMode = coverTemplateID == nil ? .edited : .cover
}
/// 处理失败时恢复入口;已有成功结果继续保留。
mutating func failProcessing() {
isProcessing = false
didFailLastAttempt = true
}
/// 选择还原原图后清除历史修图结果和失败状态。
mutating func restoreOriginal() {
isProcessing = false
didFailLastAttempt = false
appliedPresetID = nil
appliedAtmosphereOptionID = nil
appliedCoverTemplateID = nil
displayMode = .original
}
/// 在已有结果时切换原图或修图后预览。
mutating func selectDisplayMode(_ mode: DisplayMode) {
guard hasEditedResult else {
displayMode = .original
return
}
if mode == .atmosphere, !hasAtmosphereResult {
displayMode = .edited
return
}
if mode == .cover, !isCover {
displayMode = .edited
return
}
displayMode = mode
}
}
/// 相册管理底部操作栏状态,用于在上传与多选操作之间切换。
struct TravelAlbumSelectionToolbarState: Equatable, Sendable {
let isSelectionMode: Bool
let selectedCount: Int
/// 普通状态显示上传照片。
var showsUpload: Bool {
!isSelectionMode
}
/// 多选状态显示 AI 修图和删除。
var showsSelectionActions: Bool {
isSelectionMode
}
/// 至少选中一张照片后才允许执行批量操作。
var selectionActionsEnabled: Bool {
isSelectionMode && selectedCount > 0
}
}
/// 相册照片预览状态,维护当前照片位置并处理左右切换。
struct TravelAlbumPhotoPreviewState: Equatable, Sendable {
private(set) var materials: [TravelAlbumMaterial]
private(set) var currentIndex: Int
init(materials: [TravelAlbumMaterial], initialIndex: Int) {
self.materials = materials
currentIndex = min(max(initialIndex, 0), max(materials.count - 1, 0))
}
/// 当前正在预览的照片。
var currentMaterial: TravelAlbumMaterial? {
materials.indices.contains(currentIndex) ? materials[currentIndex] : nil
}
/// 切换至下一张;已经是最后一张时保持不变。
@discardableResult
mutating func moveNext() -> Bool {
guard currentIndex + 1 < materials.count else { return false }
currentIndex += 1
return true
}
/// 切换至上一张;已经是第一张时保持不变。
@discardableResult
mutating func movePrevious() -> Bool {
guard currentIndex > 0 else { return false }
currentIndex -= 1
return true
}
/// 删除当前照片,并自动定位到相邻照片。
@discardableResult
mutating func removeCurrent() -> Bool {
guard materials.indices.contains(currentIndex) else { return false }
materials.remove(at: currentIndex)
if currentIndex >= materials.count {
currentIndex = max(materials.count - 1, 0)
}
return !materials.isEmpty
}
}
/// 相册照片 AI 修图预设,当前为本地演示数据,后续可替换为服务端下发的预设列表。
struct TravelAlbumEditPreset: Hashable, Sendable, Identifiable {
/// 图像效果类型,用于在预览页生成对应效果图。
enum Effect: String, Hashable, Sendable {
case original
case portrait
case vintage
case brocade
case distantMountain
case mist
case summer
case rich
}
let id: String
let title: String
let effect: Effect
/// AI 修图底部弹窗展示的默认预设,暂无接口时用于完整演示选择流程。
static let defaultOptions: [TravelAlbumEditPreset] = [
TravelAlbumEditPreset(id: "original", title: "还原为原图", effect: .original),
TravelAlbumEditPreset(id: "portrait", title: "写真-简约肖像", effect: .portrait),
TravelAlbumEditPreset(id: "vintage", title: "写真-清冷古风", effect: .vintage),
TravelAlbumEditPreset(id: "brocade", title: "旅拍-锦绣", effect: .brocade),
TravelAlbumEditPreset(id: "distant_mountain", title: "旅拍-远山", effect: .distantMountain),
TravelAlbumEditPreset(id: "mist", title: "旅拍-薄雾", effect: .mist),
TravelAlbumEditPreset(id: "summer", title: "油画-夏日", effect: .summer),
TravelAlbumEditPreset(id: "rich", title: "油画-浓郁", effect: .rich),
]
}
/// 可选的氛围感修图样式;选中后在原图精修之外额外生成一张独立结果。
struct TravelAlbumAtmosphereEditOption: Hashable, Sendable, Identifiable {
let id: String
let title: String
let subtitle: String
let effect: TravelAlbumEditPreset.Effect
/// 暂无接口时用于演示横向滚动单选的氛围感样式。
static let defaultOptions: [TravelAlbumAtmosphereEditOption] = [
TravelAlbumAtmosphereEditOption(
id: "warm_sun",
title: "日落暖阳",
subtitle: "温暖柔和",
effect: .summer
),
TravelAlbumAtmosphereEditOption(
id: "retro_film",
title: "复古胶片",
subtitle: "怀旧颗粒感",
effect: .vintage
),
TravelAlbumAtmosphereEditOption(
id: "clear_blue",
title: "清透蓝调",
subtitle: "通透轻盈",
effect: .mist
),
TravelAlbumAtmosphereEditOption(
id: "forest_mist",
title: "森系薄雾",
subtitle: "低饱和自然感",
effect: .distantMountain
),
TravelAlbumAtmosphereEditOption(
id: "vivid_story",
title: "浓郁故事",
subtitle: "高饱和电影感",
effect: .rich
),
]
/// 兼容现有结果测试与默认演示数据的首个氛围感样式。
static let defaultOption = defaultOptions[0]
}
/// 多图 AI 修图使用的封面风格模板,当前为本地演示数据,后续可替换为服务端配置。
struct TravelAlbumCoverTemplate: Hashable, Sendable, Identifiable {
/// 封面预览的排版样式,用于生成可辨识的本地效果图。
enum PreviewStyle: String, Hashable, Sendable {
case travelMemoir
case minimal
case scenicStory
case film
}
/// 触发展示封面模板选择的最少照片数量。
static let minimumPhotoCount = 4
let id: String
let title: String
let previewStyle: PreviewStyle
/// 当前消耗的精修额度;免费阶段为 0,后续收费可由服务端改为正数。
let quotaCost: Int
/// 当前模板是否为赠送且不占用精修张数。
var isFreeGift: Bool {
quotaCost == 0
}
/// 判断本次多选是否需要展示封面模板模块。
static func shouldShow(for selectedPhotoCount: Int) -> Bool {
selectedPhotoCount >= minimumPhotoCount
}
/// 暂无接口时用于完整演示单选流程的封面模板。
static let defaultOptions: [TravelAlbumCoverTemplate] = [
TravelAlbumCoverTemplate(id: "travel_memoir", title: "旅行纪念册", previewStyle: .travelMemoir, quotaCost: 0),
TravelAlbumCoverTemplate(id: "minimal", title: "简约留白", previewStyle: .minimal, quotaCost: 0),
TravelAlbumCoverTemplate(id: "scenic_story", title: "景区故事", previewStyle: .scenicStory, quotaCost: 0),
TravelAlbumCoverTemplate(id: "film", title: "电影胶片", previewStyle: .film, quotaCost: 0),
]
}
/// AI 修图弹窗最终提交的样式组合,统一承载原图精修、氛围感和封面模板。
struct TravelAlbumAIEditSelection: Equatable, Sendable {
let refinedPreset: TravelAlbumEditPreset
let atmosphereOption: TravelAlbumAtmosphereEditOption?
let coverTemplate: TravelAlbumCoverTemplate?
/// 每张普通照片需要生成的修图结果数量,不包含原图。
var resultCountPerPhoto: Int {
atmosphereOption == nil ? 1 : 2
}
/// 弹窗当前展示并提交的样式类别数量。
var selectedStyleCount: Int {
1 + (atmosphereOption == nil ? 0 : 1) + (coverTemplate == nil ? 0 : 1)
}
}
@@ -52,6 +52,12 @@ struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
let photos: [TravelAlbumOTGPhotoItem]
}
/// 有线传输照片预览上下文,保存可预览照片顺序和点击照片位置。
struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
let photos: [TravelAlbumOTGPhotoItem]
let initialIndex: Int
}
/// OTG 传输页 Tab。
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
case all
@@ -80,6 +86,16 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
}
}
/// 模式选择弹窗中的业务说明。
var detailText: String {
switch self {
case .liveUpload:
return "相机拍摄后,照片自动传输并上传到当前相册"
case .postTransfer:
return "拍摄完成后,再选择照片批量传输"
}
}
/// 是否在新照片导入 App 后自动上传。
var shouldAutoUploadNewImports: Bool {
self == .liveUpload
@@ -113,6 +129,18 @@ extension TravelAlbumOTGPhotoItem {
}
extension Array where Element == TravelAlbumOTGPhotoItem {
/// 按当前列表顺序生成预览上下文,不可预览照片不会占用左右滑动页码。
func previewContext(startingWith photoID: String) -> TravelAlbumOTGPhotoPreviewContext? {
let previewablePhotos = filter { $0.thumbnailURL != nil }
guard let initialIndex = previewablePhotos.firstIndex(where: { $0.id == photoID }) else {
return nil
}
return TravelAlbumOTGPhotoPreviewContext(
photos: previewablePhotos,
initialIndex: initialIndex
)
}
/// 按 Android OTG 页规则构造半小时照片分段。
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
@@ -0,0 +1,308 @@
//
// TravelAlbumAIEditResultStore.swift
// suixinkan
//
import CoreImage
import UIKit
/// AI 修图结果的内存数据源,在相册管理与照片预览之间共享本地演示结果。
@MainActor
final class TravelAlbumAIEditResultStore {
/// 单张照片的修图记录。
struct Record {
var state: TravelAlbumAIEditResultState
var editedImage: UIImage?
var atmosphereImage: UIImage?
var coverImage: UIImage?
}
static let shared = TravelAlbumAIEditResultStore()
static let didChangeNotification = Notification.Name("TravelAlbumAIEditResultStore.didChange")
static let materialIDUserInfoKey = "materialID"
private var recordsByMaterialID: [Int: Record] = [:]
private init() {}
/// 获取指定照片的修图状态。
func state(for materialID: Int) -> TravelAlbumAIEditResultState {
recordsByMaterialID[materialID]?.state ?? TravelAlbumAIEditResultState()
}
/// 获取指定照片最新的修图结果。
func editedImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.editedImage
}
/// 获取指定照片最新的氛围感修图结果。
func atmosphereImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.atmosphereImage
}
/// 获取指定照片最新的封面模板结果。
func coverImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.coverImage
}
/// 将照片标记为修图中,已有结果继续保留到新结果完成。
func startProcessing(materialIDs: [Int]) {
materialIDs.forEach { materialID in
var record = recordsByMaterialID[materialID]
?? Record(
state: TravelAlbumAIEditResultState(),
editedImage: nil,
atmosphereImage: nil,
coverImage: nil
)
record.state.startProcessing()
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
}
/// 保存最新结果并覆盖同一照片之前的修图结果。
func complete(
materialID: Int,
presetID: String,
atmosphereOptionID: String? = nil,
coverTemplateID: String? = nil,
editedImage: UIImage,
atmosphereImage: UIImage? = nil,
coverImage: UIImage? = nil
) {
var record = recordsByMaterialID[materialID]
?? Record(
state: TravelAlbumAIEditResultState(),
editedImage: nil,
atmosphereImage: nil,
coverImage: nil
)
record.state.complete(
presetID: presetID,
atmosphereOptionID: atmosphereOptionID,
coverTemplateID: coverTemplateID
)
record.editedImage = editedImage
record.atmosphereImage = atmosphereImage
record.coverImage = coverImage
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 修图失败时恢复按钮可用状态,并保留之前成功的结果。
func failProcessing(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.failProcessing()
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 还原为原图时清除该照片的本地修图结果。
func restoreOriginal(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.restoreOriginal()
record.editedImage = nil
record.atmosphereImage = nil
record.coverImage = nil
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 切换指定照片当前展示的原图或修图后版本。
func selectDisplayMode(_ mode: TravelAlbumAIEditResultState.DisplayMode, materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.selectDisplayMode(mode)
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 删除照片时同步清理其本地修图结果。
func remove(materialID: Int) {
recordsByMaterialID.removeValue(forKey: materialID)
notifyChange(materialID: materialID)
}
private func notifyChange(materialID: Int) {
NotificationCenter.default.post(
name: Self.didChangeNotification,
object: self,
userInfo: [Self.materialIDUserInfoKey: materialID]
)
}
}
/// 本地 AI 修图效果生成器;后续接入服务端时可替换为结果图片下载。
@MainActor
enum TravelAlbumAIEditImageProcessor {
private static let context = CIContext()
/// 根据预设生成演示用修图图片。
static func render(
effect: TravelAlbumEditPreset.Effect,
source: UIImage
) -> UIImage {
guard effect != .original, let ciImage = CIImage(image: source) else { return source }
let output: CIImage
switch effect {
case .original:
output = ciImage
case .portrait:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.86,
kCIInputBrightnessKey: 0.07,
kCIInputContrastKey: 1.08,
])
case .vintage:
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.45])
case .brocade:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 1.22,
kCIInputBrightnessKey: 0.04,
kCIInputContrastKey: 1.1,
])
case .distantMountain:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.72,
kCIInputBrightnessKey: 0.1,
kCIInputContrastKey: 0.9,
])
case .mist:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.7,
kCIInputBrightnessKey: 0.14,
kCIInputContrastKey: 0.82,
])
case .summer:
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.24])
case .rich:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 1.38,
kCIInputBrightnessKey: -0.02,
kCIInputContrastKey: 1.2,
])
}
guard let cgImage = context.createCGImage(output, from: output.extent) else { return source }
return UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation)
}
}
/// 本地封面模板效果生成器,让封面结果在缩略图和预览页中与普通照片明显区分。
@MainActor
enum TravelAlbumCoverTemplateImageProcessor {
/// 将照片渲染为指定封面排版;无原图时使用稳定的演示背景。
static func render(
template: TravelAlbumCoverTemplate,
source: UIImage?,
targetSize: CGSize = CGSize(width: 1200, height: 900)
) -> UIImage {
UIGraphicsImageRenderer(size: targetSize).image { context in
let bounds = CGRect(origin: .zero, size: targetSize)
UIColor(hex: 0xCAD8E8).setFill()
context.cgContext.fill(bounds)
if let source {
source.draw(in: aspectFillRect(imageSize: source.size, bounds: bounds))
}
let unit = min(targetSize.width / 232, targetSize.height / 208)
switch template.previewStyle {
case .travelMemoir:
UIColor.black.withAlphaComponent(0.24).setFill()
context.cgContext.fill(bounds)
UIColor.white.setStroke()
let oval = UIBezierPath(ovalIn: CGRect(
x: bounds.midX - 50 * unit,
y: 28 * unit,
width: 100 * unit,
height: 100 * unit
))
oval.lineWidth = 9 * unit
oval.stroke()
drawCaption(
"TRAVEL\nMEMOIR",
in: CGRect(x: 30 * unit, y: bounds.height - 62 * unit, width: bounds.width - 60 * unit, height: 46 * unit),
alignment: .center,
fontSize: 17 * unit
)
case .minimal:
UIColor.white.withAlphaComponent(0.9).setFill()
context.cgContext.fill(CGRect(x: bounds.width * 0.55, y: 0, width: bounds.width * 0.45, height: bounds.height))
drawCaption(
"LESS\nIS MORE",
in: CGRect(x: bounds.width * 0.62, y: bounds.midY - 27 * unit, width: bounds.width * 0.3, height: 54 * unit),
alignment: .left,
color: .black,
fontSize: 17 * unit
)
case .scenicStory:
UIColor.black.withAlphaComponent(0.2).setFill()
context.cgContext.fill(bounds)
UIColor.white.setStroke()
let inset = 20 * unit
let frame = UIBezierPath(rect: bounds.insetBy(dx: inset, dy: inset))
frame.lineWidth = 4 * unit
frame.stroke()
drawCaption(
"SCENIC STORY",
in: CGRect(x: 34 * unit, y: bounds.height - 58 * unit, width: bounds.width - 68 * unit, height: 24 * unit),
alignment: .center,
fontSize: 17 * unit
)
case .film:
let stripHeight = 24 * unit
UIColor.black.withAlphaComponent(0.72).setFill()
context.cgContext.fill(CGRect(x: 0, y: 0, width: bounds.width, height: stripHeight))
context.cgContext.fill(CGRect(x: 0, y: bounds.height - stripHeight, width: bounds.width, height: stripHeight))
UIColor.white.withAlphaComponent(0.9).setFill()
var x = 10 * unit
while x <= bounds.width - 16 * unit {
context.cgContext.fill(CGRect(x: x, y: 6 * unit, width: 14 * unit, height: 10 * unit))
context.cgContext.fill(CGRect(
x: x,
y: bounds.height - 16 * unit,
width: 14 * unit,
height: 10 * unit
))
x += 30 * unit
}
drawCaption(
"THE JOURNEY",
in: CGRect(x: 28 * unit, y: bounds.height - 60 * unit, width: bounds.width - 56 * unit, height: 24 * unit),
alignment: .center,
fontSize: 17 * unit
)
}
}
}
private static func aspectFillRect(imageSize: CGSize, bounds: CGRect) -> CGRect {
guard imageSize.width > 0, imageSize.height > 0 else { return bounds }
let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height)
let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
return CGRect(
x: bounds.midX - drawSize.width / 2,
y: bounds.midY - drawSize.height / 2,
width: drawSize.width,
height: drawSize.height
)
}
private static func drawCaption(
_ text: String,
in rect: CGRect,
alignment: NSTextAlignment,
color: UIColor = .white,
fontSize: CGFloat
) {
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = alignment
text.draw(
in: rect,
withAttributes: [
.font: UIFont.systemFont(ofSize: fontSize, weight: .bold),
.foregroundColor: color,
.paragraphStyle: paragraph,
]
)
}
}
@@ -36,6 +36,7 @@ final class TravelAlbumDetailViewModel {
private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true
private(set) var isRefreshing = false
private(set) var isUsingPurchasedDemoData = false
private(set) var isSelectionMode = false
private(set) var selectedMaterialIds: Set<Int> = []
@@ -126,6 +127,7 @@ final class TravelAlbumDetailViewModel {
if reset {
currentPage = 1
canLoadMore = false
isUsingPurchasedDemoData = false
} else {
guard canLoadMore, !isLoadingMore else { return }
isLoadingMore = true
@@ -140,8 +142,15 @@ final class TravelAlbumDetailViewModel {
orderBy: sortOption.rawValue,
isPurchased: selectedTab == .purchased ? 1 : nil
)
materials = reset ? response.list : materials + response.list
canLoadMore = materials.count < response.total
if reset,
selectedTab == .purchased,
response.total == 0,
response.list.isEmpty {
applyPurchasedDemoMaterials()
} else {
materials = reset ? response.list : materials + response.list
canLoadMore = materials.count < response.total
}
if selectedTab == .all {
allPhotoCount = response.total
}
@@ -154,6 +163,12 @@ final class TravelAlbumDetailViewModel {
currentPage -= 1
}
isLoadingMore = false
if reset, selectedTab == .purchased {
// 已购接口暂不可用时继续提供稳定的本地演示数据,保证产品流程可演示。
applyPurchasedDemoMaterials()
notifyStateChange()
return
}
onShowMessage?(error.localizedDescription)
}
}
@@ -225,6 +240,44 @@ final class TravelAlbumDetailViewModel {
}
}
/// 已购照片接口无数据时生成稳定的本地素材;真实接口一旦返回数据即不再使用。
private func applyPurchasedDemoMaterials() {
let demoItems: [(fileName: String, fileSize: Int, assetName: String, createdAt: String)] = [
("IMG_0724_0001.jpeg", 2_684_928, "purchased_alpine_lake", "2026-07-24 11:25:18"),
("IMG_0724_0002.jpeg", 3_215_360, "purchased_forest_waterfall", "2026-07-24 11:27:46"),
("IMG_0724_0003.jpeg", 2_936_832, "purchased_grassland_path", "2026-07-24 11:31:09"),
("IMG_0724_0004.jpeg", 3_481_600, "purchased_lakeside_flowers", "2026-07-24 11:34:22"),
]
var materials = demoItems.enumerated().map { index, item in
TravelAlbumMaterial(
id: -((albumId * 10) + index + 1),
userEquityTravelId: albumId,
status: 2,
fileName: item.fileName,
fileType: 1,
fileUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
fileSize: item.fileSize,
coverUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
isPurchased: true,
createdAt: item.createdAt,
updatedAt: item.createdAt
)
}
switch sortOption {
case .createdAsc:
materials.sort { $0.createdAt < $1.createdAt }
case .createdDesc:
materials.sort { $0.createdAt > $1.createdAt }
case .fileNameAsc:
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedAscending }
case .fileNameDesc:
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedDescending }
}
self.materials = materials
canLoadMore = false
isUsingPurchasedDemoData = true
}
private func notifyStateChange() {
onStateChange?()
}
@@ -94,6 +94,7 @@ final class WiredCameraTransferViewModel {
albumTitle: String,
headerPhone: String,
scenicSpotLabel: String? = nil,
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil,
@@ -112,7 +113,11 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore
self.userDefaults = userDefaults
self.transferMode = Self.persistedTransferMode(in: userDefaults)
// 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
if let initialTransferMode {
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
}
}
/// 相机状态文案。