feat: 完善相册 AI 修图模板选择流程

This commit is contained in:
2026-08-11 15:22:08 +08:00
parent 43c75c8e36
commit e64dcec094
11 changed files with 261 additions and 202 deletions
@@ -29,6 +29,9 @@ protocol TravelAlbumServing {
isPurchased: Int? isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> ) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
/// 拉取单个相册素材及其最新关联图片信息。
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial
/// 上传并登记旅拍相册素材。 /// 上传并登记旅拍相册素材。
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
@@ -122,6 +125,20 @@ final class TravelAlbumAPI: TravelAlbumServing {
) )
} }
/// 拉取单个相册素材及其最新关联图片信息。
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/material-info",
queryItems: [
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
URLQueryItem(name: "material_id", value: String(materialId)),
]
)
)
}
/// 上传并登记旅拍相册素材。 /// 上传并登记旅拍相册素材。
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request)) try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
@@ -428,22 +428,35 @@ struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable {
let refinedTemplates: [TravelAlbumAIRetouchTemplate] let refinedTemplates: [TravelAlbumAIRetouchTemplate]
let atmosphereTemplates: [TravelAlbumAIRetouchTemplate] let atmosphereTemplates: [TravelAlbumAIRetouchTemplate]
let coverTemplates: [TravelAlbumAIRetouchTemplate] let coverTemplates: [TravelAlbumAIRetouchTemplate]
let remainingQuota: Int
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case refinedTemplates = "refined_templates" case refinedTemplates = "refined_templates"
case atmosphereTemplates = "atmosphere_templates" case atmosphereTemplates = "atmosphere_templates"
case coverTemplates = "cover_templates" case coverTemplates = "cover_templates"
case remainingQuota = "remaining_quota"
} }
/// 创建分组模板响应,默认各组为空。 /// 创建分组模板响应,默认各组为空。
init( init(
refinedTemplates: [TravelAlbumAIRetouchTemplate] = [], refinedTemplates: [TravelAlbumAIRetouchTemplate] = [],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [], atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [],
coverTemplates: [TravelAlbumAIRetouchTemplate] = [] coverTemplates: [TravelAlbumAIRetouchTemplate] = [],
remainingQuota: Int = 0
) { ) {
self.refinedTemplates = refinedTemplates self.refinedTemplates = refinedTemplates
self.atmosphereTemplates = atmosphereTemplates self.atmosphereTemplates = atmosphereTemplates
self.coverTemplates = coverTemplates self.coverTemplates = coverTemplates
self.remainingQuota = max(0, remainingQuota)
}
/// 解码模板和剩余额度;旧响应缺少额度时按零处理,避免误提交付费任务。
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
refinedTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .refinedTemplates)
atmosphereTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .atmosphereTemplates)
coverTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .coverTemplates)
remainingQuota = max(0, try container.decodeIfPresent(Int.self, forKey: .remainingQuota) ?? 0)
} }
} }
@@ -13,6 +13,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
private(set) var selectedRefinedTemplateId: Int? private(set) var selectedRefinedTemplateId: Int?
private(set) var selectedAtmosphereTemplateId: Int? private(set) var selectedAtmosphereTemplateId: Int?
private(set) var selectedCoverTemplateId: Int? private(set) var selectedCoverTemplateId: Int?
private(set) var remainingQuota: Int?
private(set) var isLoading = false private(set) var isLoading = false
private(set) var isSubmitting = false private(set) var isSubmitting = false
private(set) var loadErrorMessage: String? private(set) var loadErrorMessage: String?
@@ -56,6 +57,22 @@ final class TravelAlbumAIRetouchTemplateViewModel {
workflow.isOptional(category) 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? { var validationMessage: String? {
guard !isLoading, loadErrorMessage == nil else { return nil } guard !isLoading, loadErrorMessage == nil else { return nil }
@@ -70,6 +87,12 @@ final class TravelAlbumAIRetouchTemplateViewModel {
return unavailableMessage(for: category) return unavailableMessage(for: category)
} }
} }
guard let remainingQuota else {
return "剩余修图次数获取失败,请刷新后重试"
}
if requiredQuota > remainingQuota {
return "剩余修图次数不足,需要\(requiredQuota)次,当前剩余\(remainingQuota)次"
}
return nil return nil
} }
@@ -92,6 +115,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
guard !isLoading else { return } guard !isLoading else { return }
isLoading = true isLoading = true
loadErrorMessage = nil loadErrorMessage = nil
remainingQuota = nil
notifyStateChange() notifyStateChange()
do { do {
@@ -99,6 +123,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
refinedTemplates = response.refinedTemplates refinedTemplates = response.refinedTemplates
atmosphereTemplates = response.atmosphereTemplates atmosphereTemplates = response.atmosphereTemplates
coverTemplates = response.coverTemplates coverTemplates = response.coverTemplates
remainingQuota = response.remainingQuota
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere) selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
? atmosphereTemplates.first?.id ? atmosphereTemplates.first?.id
@@ -116,6 +141,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
selectedRefinedTemplateId = nil selectedRefinedTemplateId = nil
selectedAtmosphereTemplateId = nil selectedAtmosphereTemplateId = nil
selectedCoverTemplateId = nil selectedCoverTemplateId = nil
remainingQuota = nil
isLoading = false isLoading = false
loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
notifyStateChange() notifyStateChange()
@@ -207,41 +207,18 @@ final class TravelAlbumDetailViewModel {
} }
} }
/// 重新拉取当前筛选、排序下已经加载的分页范围,供全屏预览刷新关联图片。 /// 定向刷新单个素材,并同步替换网格列表中的对应缓存。
func reloadLoadedMaterials( func refreshMaterial(id: Int, api: any TravelAlbumServing) async throws -> TravelAlbumMaterial {
api: any TravelAlbumServing let material = try await api.materialInfo(
) 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, userEquityTravelId: albumId,
page: page, materialId: id
pageSize: pageSize,
orderBy: sortOption.rawValue,
isPurchased: selectedTab == .purchased ? 1 : nil
) )
if page == 1 { refreshedTotal = response.total } guard material.id == id else { throw APIError.invalidResponse }
refreshed.append(contentsOf: response.list) if let index = materials.firstIndex(where: { $0.id == id }) {
loadedPageCount = page materials[index] = material
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() notifyStateChange()
return TravelAlbumListResponse(total: refreshedTotal, list: materials) }
return material
} }
/// 切换选择模式。 /// 切换选择模式。
@@ -12,13 +12,11 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
/// 页面使用的 collection section。 /// 页面使用的 collection section。
private enum Section: Hashable { private enum Section: Hashable {
case templates(TravelAlbumAIRetouchTemplateCategory) case templates(TravelAlbumAIRetouchTemplateCategory)
case mode
} }
/// 页面使用的 diffable item。 /// 页面使用的 diffable item。
private enum Item: Hashable { private enum Item: Hashable {
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate) case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
case mode
} }
private let viewModel: TravelAlbumAIRetouchTemplateViewModel private let viewModel: TravelAlbumAIRetouchTemplateViewModel
@@ -35,6 +33,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
private let bottomBar = UIView() private let bottomBar = UIView()
private let bottomDivider = UIView() private let bottomDivider = UIView()
private let footerStack = UIStackView() private let footerStack = UIStackView()
private let quotaLabel = UILabel()
private let validationLabel = UILabel() private let validationLabel = UILabel()
private let actionStack = UIStackView() private let actionStack = UIStackView()
private let cancelButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system)
@@ -78,10 +77,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
TravelAlbumAIRetouchTemplateCell.self, TravelAlbumAIRetouchTemplateCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
) )
collectionView.register(
TravelAlbumAIRetouchModeCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
)
collectionView.register( collectionView.register(
TravelAlbumAIRetouchSectionHeader.self, TravelAlbumAIRetouchSectionHeader.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
@@ -109,6 +104,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
footerStack.axis = .vertical footerStack.axis = .vertical
footerStack.spacing = 8 footerStack.spacing = 8
footerStack.alignment = .fill footerStack.alignment = .fill
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
quotaLabel.textColor = AIRetouchTemplateStyle.primary
quotaLabel.textAlignment = .center
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
actionStack.axis = .horizontal actionStack.axis = .horizontal
actionStack.spacing = 12 actionStack.spacing = 12
actionStack.distribution = .fillEqually actionStack.distribution = .fillEqually
@@ -117,6 +116,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
configureConfirmButton() configureConfirmButton()
actionStack.addArrangedSubview(cancelButton) actionStack.addArrangedSubview(cancelButton)
actionStack.addArrangedSubview(confirmButton) actionStack.addArrangedSubview(confirmButton)
footerStack.addArrangedSubview(quotaLabel)
footerStack.addArrangedSubview(validationLabel) footerStack.addArrangedSubview(validationLabel)
footerStack.addArrangedSubview(actionStack) footerStack.addArrangedSubview(actionStack)
@@ -255,13 +255,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
selected: self.viewModel.selectedTemplateId(for: category) == template.id selected: self.viewModel.selectedTemplateId(for: category) == template.id
) )
return cell return cell
case .mode:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchModeCell
cell.apply()
return cell
} }
} }
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
@@ -288,8 +281,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else { self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
return nil return nil
} }
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
case .templates:
let itemSize = NSCollectionLayoutSize( let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(118), widthDimension: .absolute(118),
heightDimension: .absolute(154) heightDimension: .absolute(154)
@@ -311,17 +302,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
), ),
] ]
return section return section
case .mode:
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(72)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 18, bottom: 22, trailing: 18)
return section
}
} }
} }
@@ -346,6 +326,17 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
validationLabel.text = viewModel.validationMessage validationLabel.text = viewModel.validationMessage
validationLabel.isHidden = viewModel.validationMessage == nil validationLabel.isHidden = viewModel.validationMessage == nil
if let remainingQuota = viewModel.remainingQuota {
quotaLabel.text = "剩余 \(remainingQuota) 次"
quotaLabel.accessibilityLabel = "剩余\(remainingQuota)次修图额度"
quotaLabel.textColor = viewModel.requiredQuota > remainingQuota
? AIRetouchTemplateStyle.danger
: AIRetouchTemplateStyle.primary
} else {
quotaLabel.text = "剩余 -- 次"
quotaLabel.accessibilityLabel = "剩余修图额度暂不可用"
quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary
}
cancelButton.isEnabled = !viewModel.isSubmitting cancelButton.isEnabled = !viewModel.isSubmitting
confirmButton.isEnabled = viewModel.canSubmit confirmButton.isEnabled = viewModel.canSubmit
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45 confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
@@ -362,8 +353,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
for category in viewModel.visibleCategories { for category in viewModel.visibleCategories {
appendTemplates(category, to: &snapshot) appendTemplates(category, to: &snapshot)
} }
snapshot.appendSections([.mode])
snapshot.appendItems([.mode], toSection: .mode)
snapshot.reconfigureItems(snapshot.itemIdentifiers) snapshot.reconfigureItems(snapshot.itemIdentifiers)
dataSource.apply(snapshot, animatingDifferences: true) dataSource.apply(snapshot, animatingDifferences: true)
} }
@@ -539,53 +528,6 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
} }
} }
/// AI 修图模式卡片,展示计费方式与剩余张数占位。
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
private let titleLabel = UILabel()
private let remainingLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 14
contentView.layer.borderWidth = 1
contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
remainingLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
remainingLabel.textColor = AIRetouchTemplateStyle.primary
remainingLabel.textAlignment = .right
contentView.addSubview(titleLabel)
contentView.addSubview(remainingLabel)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
}
remainingLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 应用当前固定修图模式与额度占位文案。
func apply() {
titleLabel.text = "AI精修 按张收费"
remainingLabel.text = "剩余--张"
accessibilityIdentifier = "travelAlbum.aiRetouchModeCell"
accessibilityLabel = "AI精修,按张收费,剩余张数暂不可用"
}
}
/// AI 修图模板页视觉常量。 /// AI 修图模板页视觉常量。
private enum AIRetouchTemplateStyle { private enum AIRetouchTemplateStyle {
static let primary = UIColor(hex: 0x1677FF) static let primary = UIColor(hex: 0x1677FF)
@@ -499,12 +499,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
previewViewModel.currentPhotoCount previewViewModel.currentPhotoCount
) )
}, },
reload: { reload: { materialId in
let response = try await previewViewModel.reloadLoadedMaterials(api: previewAPI) let material = try await previewViewModel.refreshMaterial(id: materialId, api: previewAPI)
return ( return TravelAlbumPreviewProject(material: material)
response.list.map(TravelAlbumPreviewProject.init(material:)),
response.total
)
}, },
onProjectDeleted: { materialId in onProjectDeleted: { materialId in
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId) previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
@@ -10,8 +10,8 @@ import UIKit
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。 /// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int) typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
/// 相册项目刷新回调,失败时由预览页保留当前内容并展示错误。 /// 相册项目定向刷新回调,根据原素材 ID 返回该项目的最新数据。
typealias TravelAlbumPreviewReload = () async throws -> (projects: [TravelAlbumPreviewProject], totalCount: Int) typealias TravelAlbumPreviewReload = (_ materialId: Int) async throws -> TravelAlbumPreviewProject
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。 /// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
final class TravelAlbumPhotoPreviewViewController: UIViewController { final class TravelAlbumPhotoPreviewViewController: UIViewController {
@@ -481,7 +481,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
onSubmitted: { [weak self] in onSubmitted: { [weak self] in
guard let self else { return } guard let self else { return }
self.showPreviewToast("AI修图任务已提交") self.showPreviewToast("AI修图任务已提交")
self.reloadProjects(showSuccessToast: false, forceRefreshImage: false) self.reloadProjects(showGlobalLoading: false, forceRefreshImage: false)
} }
) )
present(controller, animated: true) present(controller, animated: true)
@@ -578,32 +578,36 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
} }
@objc private func refreshTapped() { @objc private func refreshTapped() {
reloadProjects(showSuccessToast: true, forceRefreshImage: true) reloadProjects(showGlobalLoading: true, forceRefreshImage: true)
} }
private func reloadProjects(showSuccessToast: Bool, forceRefreshImage: Bool) { private func reloadProjects(showGlobalLoading: Bool, forceRefreshImage: Bool) {
guard !isRefreshingProject else { return } guard !isRefreshingProject else { return }
guard let reload else { guard let reload else { return }
if showSuccessToast { showPreviewToast("关联图片刷新接口待接入") }
return
}
isRefreshingProject = true isRefreshingProject = true
updateRefreshButton() updateRefreshButton()
if showGlobalLoading { GlobalLoadingManager.shared.show() }
let currentProjectId = currentProject?.id let currentProjectId = currentProject?.id
let currentProjectIndex = currentNode?.projectIndex ?? 0 let currentProjectIndex = currentNode?.projectIndex ?? 0
let kind = currentAsset?.kind ?? .original let kind = currentAsset?.kind ?? .original
Task { [weak self] in Task { [weak self] in
defer {
if showGlobalLoading { GlobalLoadingManager.shared.hide() }
}
guard let self else { return } guard let self else { return }
do { do {
let result = try await reload() guard let currentProjectId else {
self.isRefreshingProject = false
self.updateRefreshButton()
return
}
let project = try await reload(currentProjectId)
self.applySuccessfulReload( self.applySuccessfulReload(
projects: result.projects, project: project,
totalCount: result.totalCount,
currentProjectId: currentProjectId, currentProjectId: currentProjectId,
fallbackProjectIndex: currentProjectIndex, fallbackProjectIndex: currentProjectIndex,
kind: kind, kind: kind,
showSuccessToast: showSuccessToast,
forceRefreshImage: forceRefreshImage forceRefreshImage: forceRefreshImage
) )
} catch is CancellationError { } catch is CancellationError {
@@ -612,37 +616,28 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
} catch { } catch {
self.isRefreshingProject = false self.isRefreshingProject = false
self.updateRefreshButton() self.updateRefreshButton()
if showSuccessToast {
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
self.showPreviewToast(message.isEmpty ? "刷新失败" : message)
}
} }
} }
} }
private func applySuccessfulReload( private func applySuccessfulReload(
projects refreshedProjects: [TravelAlbumPreviewProject], project refreshedProject: TravelAlbumPreviewProject,
totalCount: Int, currentProjectId: Int,
currentProjectId: Int?,
fallbackProjectIndex: Int, fallbackProjectIndex: Int,
kind: TravelAlbumPreviewAssetKind, kind: TravelAlbumPreviewAssetKind,
showSuccessToast: Bool,
forceRefreshImage: Bool forceRefreshImage: Bool
) { ) {
let incoming = Self.deduplicated(refreshedProjects) guard !projects.isEmpty else {
guard !incoming.isEmpty else {
isRefreshingProject = false isRefreshingProject = false
updateRefreshButton() updateRefreshButton()
if showSuccessToast { dismiss(animated: true) } dismiss(animated: true)
return return
} }
projects = incoming let targetProjectIndex = projects.firstIndex { $0.id == currentProjectId }
self.totalCount = max(totalCount, incoming.count) ?? min(max(0, fallbackProjectIndex), projects.count - 1)
let targetProjectIndex = currentProjectId.flatMap { id in projects[targetProjectIndex] = refreshedProject
incoming.firstIndex { $0.id == id } let resolvedKind = refreshedProject.asset(for: kind) == nil ? .original : kind
} ?? min(max(0, fallbackProjectIndex), incoming.count - 1)
let resolvedKind = incoming[targetProjectIndex].asset(for: kind) == nil ? .original : kind
selectedKind = resolvedKind selectedKind = resolvedKind
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind) rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind)
collectionView.reloadData() collectionView.reloadData()
@@ -652,7 +647,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
if forceRefreshImage { forceRefreshCurrentImage() } if forceRefreshImage { forceRefreshCurrentImage() }
isRefreshingProject = false isRefreshingProject = false
updateRefreshButton() updateRefreshButton()
if showSuccessToast { showPreviewToast("刷新成功") }
} }
private func forceRefreshCurrentImage() { private func forceRefreshCurrentImage() {
@@ -240,12 +240,33 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.isSubmitting) XCTAssertFalse(viewModel.isSubmitting)
} }
private func makeAPI() -> TravelAlbumMockAPI { func testRemainingQuotaDisablesSubmissionWhenSelectedOutputsExceedBalance() async {
let api = makeAPI(remainingQuota: 2)
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1, 2]
)
await viewModel.loadTemplates(api: api)
XCTAssertEqual(viewModel.remainingQuota, 2)
XCTAssertEqual(viewModel.requiredQuota, 2)
XCTAssertTrue(viewModel.canSubmit)
viewModel.toggleTemplate(id: 21, category: .atmosphere)
XCTAssertEqual(viewModel.requiredQuota, 4)
XCTAssertFalse(viewModel.canSubmit)
XCTAssertEqual(viewModel.validationMessage, "剩余修图次数不足,需要4次,当前剩余2次")
}
private func makeAPI(remainingQuota: Int = 100) -> TravelAlbumMockAPI {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [template(11, "清透"), template(12, "自然")], refinedTemplates: [template(11, "清透"), template(12, "自然")],
atmosphereTemplates: [template(21, "暖阳")], atmosphereTemplates: [template(21, "暖阳")],
coverTemplates: [template(31, "杂志")] coverTemplates: [template(31, "杂志")],
remainingQuota: remainingQuota
) )
return api return api
} }
+22 -1
View File
@@ -84,6 +84,26 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code") XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code")
} }
func testMaterialInfoBuildsQueryAndDecodesMaterial() async throws {
let data = envelopeJSON(
#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":2,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"ai_retouch_status":3,"ai_retouch_status_name":"AI已修","ai_retouch_batch_id":60,"ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let material = try await api.materialInfo(userEquityTravelId: 3, materialId: 6)
XCTAssertEqual(material.id, 6)
XCTAssertEqual(material.aiRetouchBatchId, 60)
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/material-info")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
XCTAssertEqual(query?.first { $0.name == "material_id" }?.value, "6")
}
func testUploadMaterialBuildsPathAndBody() async throws { func testUploadMaterialBuildsPathAndBody() async throws {
let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":1,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"created_at":"2026-07-08 10:00:00","updated_at":"2026-07-08 10:00:00"}"#) let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":1,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"created_at":"2026-07-08 10:00:00","updated_at":"2026-07-08 10:00:00"}"#)
let session = MockURLSession(responses: [material]) let session = MockURLSession(responses: [material])
@@ -126,7 +146,7 @@ final class TravelAlbumAPITests: XCTestCase {
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws { func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
let data = envelopeJSON( let data = envelopeJSON(
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg"}]}"# #"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg"}],"remaining_quota":12}"#
) )
let session = MockURLSession(responses: [data]) let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
@@ -136,6 +156,7 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(response.refinedTemplates.first?.name, "清透") XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2) XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg") XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
XCTAssertEqual(response.remainingQuota, 12)
let request = try XCTUnwrap(session.requests.first) let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates") XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates")
@@ -250,7 +250,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")], refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")], atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")] coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")],
remainingQuota: 8
) )
let viewModel = TravelAlbumAIRetouchTemplateViewModel( let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 9, albumId: 9,
@@ -308,7 +309,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")], refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")], atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")] coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")],
remainingQuota: 8
) )
let viewModel = TravelAlbumAIRetouchTemplateViewModel( let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 9, albumId: 9,
@@ -330,9 +332,13 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
XCTAssertTrue(accessibleLabels.contains("封面风格模板")) XCTAssertTrue(accessibleLabels.contains("封面风格模板"))
let modeCell = TravelAlbumAIRetouchModeCell(frame: .zero) let quotaLabel = try XCTUnwrap(
modeCell.apply() controller.view.findSubview {
XCTAssertEqual(modeCell.accessibilityLabel, "AI精修,按张收费,剩余张数暂不可用") $0.accessibilityIdentifier == "travelAlbum.aiRetouchRemainingQuotaLabel"
} as? UILabel
)
XCTAssertEqual(quotaLabel.text, "剩余 8 次")
XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8次修图额度")
} }
func testAIRetouchTemplateCellExposesSelectedState() { func testAIRetouchTemplateCellExposesSelectedState() {
@@ -358,7 +364,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "")], refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "")],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")] atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")],
remainingQuota: 8
) )
let project = TravelAlbumPreviewProject( let project = TravelAlbumPreviewProject(
originalMaterialId: 7, originalMaterialId: 7,
@@ -451,10 +458,11 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
projects: [TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original, retouched])], projects: [TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original, retouched])],
totalCount: 1, totalCount: 1,
startProjectIndex: 0, startProjectIndex: 0,
reload: { reload: { materialId in
XCTAssertEqual(materialId, 7)
reloadCount += 1 reloadCount += 1
let assets = reloadCount == 1 ? [original, retouched] : [original] let assets = reloadCount == 1 ? [original, retouched] : [original]
return ([TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets)], 1) return TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets)
} }
) )
controller.loadViewIfNeeded() controller.loadViewIfNeeded()
@@ -487,6 +495,43 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图.jpg" }) XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图.jpg" })
} }
func testPreviewRefreshUsesGlobalLoadingWithoutToast() async throws {
GlobalLoadingManager.shared.hideAll()
defer { GlobalLoadingManager.shared.hideAll() }
let original = TravelAlbumPreviewAsset(
id: "original-7",
kind: .original,
fileURL: "",
coverURL: "",
fileName: "原图.jpg",
fileSize: 0
)
let project = TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original])
let controller = TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0,
reload: { _ in
try await Task.sleep(nanoseconds: 50_000_000)
return project
}
)
controller.loadViewIfNeeded()
let refreshButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewRefreshButton"
} as? UIButton
)
refreshButton.sendActions(for: .touchUpInside)
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
await waitUntil { !GlobalLoadingManager.shared.isShowing && refreshButton.isEnabled }
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新成功" })
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" })
}
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws { func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
let cases: [(TravelAlbumMaterial, String, UInt)] = [ let cases: [(TravelAlbumMaterial, String, UInt)] = [
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569), (TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
+30 -24
View File
@@ -206,39 +206,29 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.purchasedPhotoCount, 0) XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
} }
func testPreviewReloadRefetchesAllLoadedPagesAndReplacesMaterialsById() async throws { func testRefreshMaterialUpdatesOnlyMatchingCachedMaterial() async throws {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
api.materialListResponses = [ api.materialListResponses = [
TravelAlbumListResponse( TravelAlbumListResponse(
total: 35, total: 2,
list: (1 ... 30).map { TravelAlbumMaterial(id: $0) } list: [TravelAlbumMaterial(id: 7), TravelAlbumMaterial(id: 8)]
),
TravelAlbumListResponse(
total: 35,
list: (31 ... 35).map { TravelAlbumMaterial(id: $0) }
),
TravelAlbumListResponse(
total: 35,
list: (1 ... 30).map {
TravelAlbumMaterial(id: $0, aiRetouchBatchId: $0 == 7 ? 700 : 0)
}
),
TravelAlbumListResponse(
total: 35,
list: (31 ... 35).map { TravelAlbumMaterial(id: $0) }
), ),
] ]
api.materialInfoResponse = TravelAlbumMaterial(
id: 7,
aiRetouchBatchId: 700,
aiRefinedURL: "https://cdn/refined.jpg"
)
let viewModel = TravelAlbumDetailViewModel(albumId: 2) let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.loadMaterials(reset: true, api: api) await viewModel.loadMaterials(reset: true, api: api)
await viewModel.loadMaterials(reset: false, api: api)
let response = try await viewModel.reloadLoadedMaterials(api: api) let material = try await viewModel.refreshMaterial(id: 7, api: api)
XCTAssertEqual(response.total, 35) XCTAssertEqual(material.aiRetouchBatchId, 700)
XCTAssertEqual(response.list.count, 35) XCTAssertEqual(viewModel.materials.first { $0.id == 7 }?.aiRefinedURL, "https://cdn/refined.jpg")
XCTAssertEqual(response.list.first { $0.id == 7 }?.aiRetouchBatchId, 700) XCTAssertEqual(viewModel.materials.first { $0.id == 8 }?.aiRetouchBatchId, 0)
XCTAssertEqual(api.materialRequests.suffix(2).map(\.page), [1, 2]) XCTAssertEqual(api.materialInfoRequests, [.init(userEquityTravelId: 2, materialId: 7)])
XCTAssertEqual(api.materialRequests.suffix(2).map(\.pageSize), [30, 30]) XCTAssertEqual(api.materialRequests.count, 1)
} }
func testDeleteAlbumCallsCallback() async { func testDeleteAlbumCallsCallback() async {
@@ -1074,12 +1064,19 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
let isPurchased: Int? let isPurchased: Int?
} }
struct MaterialInfoRequest: Equatable {
let userEquityTravelId: Int
let materialId: Int
}
var availableOrdersResponse: [TravelAlbumAvailableOrder] = [] var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
var listResponse = TravelAlbumListResponse<TravelAlbum>() var listResponse = TravelAlbumListResponse<TravelAlbum>()
var createResponse = TravelAlbumCreateResponse(id: 0) var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum() var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = [] var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var materialListFailingCallIndexes: Set<Int> = [] var materialListFailingCallIndexes: Set<Int> = []
var materialInfoResponse = TravelAlbumMaterial()
var materialInfoError: Error?
var uploadMaterialResponse = TravelAlbumMaterial() var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: []) var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
@@ -1096,6 +1093,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
private(set) var availableOrdersCallCount = 0 private(set) var availableOrdersCallCount = 0
private(set) var createRequests: [TravelAlbumCreateRequest] = [] private(set) var createRequests: [TravelAlbumCreateRequest] = []
private(set) var materialRequests: [MaterialRequest] = [] private(set) var materialRequests: [MaterialRequest] = []
private(set) var materialInfoRequests: [MaterialInfoRequest] = []
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = [] private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
private(set) var materialClientPhotoIdsCallCount = 0 private(set) var materialClientPhotoIdsCallCount = 0
private(set) var deletedAlbumIds: [Int] = [] private(set) var deletedAlbumIds: [Int] = []
@@ -1147,6 +1145,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
return materialListResponses.removeFirst() return materialListResponses.removeFirst()
} }
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
materialInfoRequests.append(
MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId)
)
if let materialInfoError { throw materialInfoError }
return materialInfoResponse
}
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
uploadMaterialRequests.append(request) uploadMaterialRequests.append(request)
return uploadMaterialResponse return uploadMaterialResponse