Compare commits
2
Commits
43c75c8e36
...
aca19a53fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca19a53fb | ||
|
|
e64dcec094 |
@@ -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
|
||||||
|
|
||||||
@@ -38,8 +41,8 @@ protocol TravelAlbumServing {
|
|||||||
/// 删除旅拍相册。
|
/// 删除旅拍相册。
|
||||||
func deleteAlbum(id: Int) async throws
|
func deleteAlbum(id: Int) async throws
|
||||||
|
|
||||||
/// 删除旅拍相册素材。
|
/// 批量删除旅拍相册素材。
|
||||||
func deleteMaterial(id: Int) async throws
|
func batchDeleteMaterials(ids: [Int]) async throws
|
||||||
|
|
||||||
/// 拉取相册小程序码。
|
/// 拉取相册小程序码。
|
||||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
||||||
@@ -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))
|
||||||
@@ -147,10 +164,14 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除旅拍相册素材。
|
/// 批量删除旅拍相册素材。
|
||||||
func deleteMaterial(id: Int) async throws {
|
func batchDeleteMaterials(ids: [Int]) async throws {
|
||||||
let _: EmptyPayload = try await client.send(
|
let _: EmptyPayload = try await client.send(
|
||||||
APIRequest(method: .post, path: "\(basePath)/delete-material", body: TravelAlbumIDRequest(id: id))
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "\(basePath)/batch-delete-material",
|
||||||
|
body: TravelAlbumBatchDeleteMaterialRequest(ids: ids)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,3 +216,8 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
||||||
let id: Int
|
let id: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册素材批量删除请求体。
|
||||||
|
private struct TravelAlbumBatchDeleteMaterialRequest: Encodable, Sendable {
|
||||||
|
let ids: [Int]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切换选择模式。
|
/// 切换选择模式。
|
||||||
@@ -290,15 +267,13 @@ final class TravelAlbumDetailViewModel {
|
|||||||
|
|
||||||
/// 删除已选素材。
|
/// 删除已选素材。
|
||||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||||
let ids = Array(selectedMaterialIds)
|
let ids = selectedMaterialIds.sorted()
|
||||||
guard !ids.isEmpty else {
|
guard !ids.isEmpty else {
|
||||||
onShowMessage?("请选择要删除的素材")
|
onShowMessage?("请选择要删除的素材")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
for id in ids {
|
try await api.batchDeleteMaterials(ids: ids)
|
||||||
try await api.deleteMaterial(id: id)
|
|
||||||
}
|
|
||||||
onShowMessage?("删除成功")
|
onShowMessage?("删除成功")
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
selectedMaterialIds = []
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
|||||||
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
||||||
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
do {
|
do {
|
||||||
try await api.deleteMaterial(id: originalMaterialId)
|
try await api.batchDeleteMaterials(ids: [originalMaterialId])
|
||||||
return .success("删除成功")
|
return .success("删除成功")
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return .failure("删除已取消")
|
return .failure("删除已取消")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,42 @@ 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 testBatchDeleteMaterialsBuildsPathAndBody() async throws {
|
||||||
|
let session = MockURLSession(responses: [envelopeJSON("{}")])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
try await api.batchDeleteMaterials(ids: [2031, 2032, 2033])
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "POST")
|
||||||
|
XCTAssertEqual(
|
||||||
|
request.url?.path,
|
||||||
|
"/api/yf-handset-app/photog/travel-album/batch-delete-material"
|
||||||
|
)
|
||||||
|
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||||
|
XCTAssertEqual(body?["ids"] as? [Int], [2031, 2032, 2033])
|
||||||
|
}
|
||||||
|
|
||||||
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 +162,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 +172,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")
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
|
|
||||||
let success = await handler.deleteProject(originalMaterialId: 8)
|
let success = await handler.deleteProject(originalMaterialId: 8)
|
||||||
|
|
||||||
XCTAssertEqual(api.deletedMaterialIds, [8])
|
XCTAssertEqual(api.deletedMaterialIDBatches, [[8]])
|
||||||
XCTAssertEqual(success, .success("删除成功"))
|
XCTAssertEqual(success, .success("删除成功"))
|
||||||
|
|
||||||
api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用")
|
api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用")
|
||||||
let failure = await handler.deleteProject(originalMaterialId: 9)
|
let failure = await handler.deleteProject(originalMaterialId: 9)
|
||||||
|
|
||||||
XCTAssertEqual(api.deletedMaterialIds, [8, 9])
|
XCTAssertEqual(api.deletedMaterialIDBatches, [[8], [9]])
|
||||||
XCTAssertEqual(failure, .failure("服务暂不可用"))
|
XCTAssertEqual(failure, .failure("服务暂不可用"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
controller.view.allAccessibilityLabels().contains("第 1 张,共 2 张")
|
controller.view.allAccessibilityLabels().contains("第 1 张,共 2 张")
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(api.deletedMaterialIds, [1])
|
XCTAssertEqual(api.deletedMaterialIDBatches, [[1]])
|
||||||
XCTAssertEqual(deletedIds, [1])
|
XCTAssertEqual(deletedIds, [1])
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
||||||
@@ -120,7 +120,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
controller.view.allAccessibilityLabels().contains("第 2 张,共 2 张")
|
controller.view.allAccessibilityLabels().contains("第 2 张,共 2 张")
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(api.deletedMaterialIds, [3])
|
XCTAssertEqual(api.deletedMaterialIDBatches, [[3]])
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
||||||
)
|
)
|
||||||
@@ -146,7 +146,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
controller.deleteCurrentProjectAfterConfirmation()
|
controller.deleteCurrentProjectAfterConfirmation()
|
||||||
await waitUntil { host.presentedViewController == nil }
|
await waitUntil { host.presentedViewController == nil }
|
||||||
|
|
||||||
XCTAssertEqual(api.deletedMaterialIds, [1])
|
XCTAssertEqual(api.deletedMaterialIDBatches, [[1]])
|
||||||
XCTAssertNil(host.presentedViewController)
|
XCTAssertNil(host.presentedViewController)
|
||||||
window.isHidden = true
|
window.isHidden = true
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -252,6 +242,26 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(api.deletedAlbumIds, [5])
|
XCTAssertEqual(api.deletedAlbumIds, [5])
|
||||||
XCTAssertEqual(deletedId, 5)
|
XCTAssertEqual(deletedId, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDeleteSelectedMaterialsUsesSingleBatchRequest() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||||
|
viewModel.toggleSelectionMode()
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2033, status: 1))
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2031, status: 1))
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2032, status: 1))
|
||||||
|
|
||||||
|
await viewModel.deleteSelectedMaterials(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedMaterialIDBatches, [[2031, 2032, 2033]])
|
||||||
|
XCTAssertFalse(viewModel.isSelectionMode)
|
||||||
|
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 有线传输 ViewModel 测试。
|
/// 有线传输 ViewModel 测试。
|
||||||
@@ -1074,12 +1084,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,10 +1113,11 @@ 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] = []
|
||||||
private(set) var deletedMaterialIds: [Int] = []
|
private(set) var deletedMaterialIDBatches: [[Int]] = []
|
||||||
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
||||||
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
||||||
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
|
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
|
||||||
@@ -1147,6 +1165,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
|
||||||
@@ -1161,8 +1187,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
deletedAlbumIds.append(id)
|
deletedAlbumIds.append(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteMaterial(id: Int) async throws {
|
func batchDeleteMaterials(ids: [Int]) async throws {
|
||||||
deletedMaterialIds.append(id)
|
deletedMaterialIDBatches.append(ids)
|
||||||
if deleteMaterialDelayNanoseconds > 0 {
|
if deleteMaterialDelayNanoseconds > 0 {
|
||||||
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
|
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user