From e64dcec0942e9c8e9cdd78746aea77aeb80f662f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 11 Aug 2026 15:22:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=9B=B8=E5=86=8C=20?= =?UTF-8?q?AI=20=E4=BF=AE=E5=9B=BE=E6=A8=A1=E6=9D=BF=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TravelAlbum/API/TravelAlbumAPI.swift | 17 +++ .../Models/TravelAlbumModels.swift | 15 +- ...ravelAlbumAIRetouchTemplateViewModel.swift | 26 ++++ .../TravelAlbumDetailViewModel.swift | 45 ++---- ...AlbumAIRetouchTemplateViewController.swift | 132 +++++------------- .../TravelAlbumDetailViewController.swift | 9 +- ...ravelAlbumPhotoPreviewViewController.swift | 56 ++++---- ...AlbumAIRetouchTemplateViewModelTests.swift | 25 +++- suixinkanTests/TravelAlbumAPITests.swift | 23 ++- ...TravelAlbumDetailViewControllerTests.swift | 61 ++++++-- .../TravelAlbumViewModelTests.swift | 54 +++---- 11 files changed, 261 insertions(+), 202 deletions(-) diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift index 938cc84..9933892 100644 --- a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -29,6 +29,9 @@ protocol TravelAlbumServing { isPurchased: Int? ) async throws -> TravelAlbumListResponse + /// 拉取单个相册素材及其最新关联图片信息。 + func materialInfo(userEquityTravelId: Int, materialId: Int) 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 { try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request)) diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index 59dd3d8..d6c26df 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -428,22 +428,35 @@ struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable { let refinedTemplates: [TravelAlbumAIRetouchTemplate] let atmosphereTemplates: [TravelAlbumAIRetouchTemplate] let coverTemplates: [TravelAlbumAIRetouchTemplate] + let remainingQuota: Int enum CodingKeys: String, CodingKey { case refinedTemplates = "refined_templates" case atmosphereTemplates = "atmosphere_templates" case coverTemplates = "cover_templates" + case remainingQuota = "remaining_quota" } /// 创建分组模板响应,默认各组为空。 init( refinedTemplates: [TravelAlbumAIRetouchTemplate] = [], atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [], - coverTemplates: [TravelAlbumAIRetouchTemplate] = [] + coverTemplates: [TravelAlbumAIRetouchTemplate] = [], + remainingQuota: Int = 0 ) { self.refinedTemplates = refinedTemplates self.atmosphereTemplates = atmosphereTemplates 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) } } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift index fd290db..3faa662 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift @@ -13,6 +13,7 @@ final class TravelAlbumAIRetouchTemplateViewModel { private(set) var selectedRefinedTemplateId: Int? private(set) var selectedAtmosphereTemplateId: Int? private(set) var selectedCoverTemplateId: Int? + private(set) var remainingQuota: Int? private(set) var isLoading = false private(set) var isSubmitting = false private(set) var loadErrorMessage: String? @@ -56,6 +57,22 @@ final class TravelAlbumAIRetouchTemplateViewModel { 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? { guard !isLoading, loadErrorMessage == nil else { return nil } @@ -70,6 +87,12 @@ final class TravelAlbumAIRetouchTemplateViewModel { return unavailableMessage(for: category) } } + guard let remainingQuota else { + return "剩余修图次数获取失败,请刷新后重试" + } + if requiredQuota > remainingQuota { + return "剩余修图次数不足,需要\(requiredQuota)次,当前剩余\(remainingQuota)次" + } return nil } @@ -92,6 +115,7 @@ final class TravelAlbumAIRetouchTemplateViewModel { guard !isLoading else { return } isLoading = true loadErrorMessage = nil + remainingQuota = nil notifyStateChange() do { @@ -99,6 +123,7 @@ final class TravelAlbumAIRetouchTemplateViewModel { refinedTemplates = response.refinedTemplates atmosphereTemplates = response.atmosphereTemplates coverTemplates = response.coverTemplates + remainingQuota = response.remainingQuota selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere) ? atmosphereTemplates.first?.id @@ -116,6 +141,7 @@ final class TravelAlbumAIRetouchTemplateViewModel { selectedRefinedTemplateId = nil selectedAtmosphereTemplateId = nil selectedCoverTemplateId = nil + remainingQuota = nil isLoading = false loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription notifyStateChange() diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift index 888b8b8..816b8c5 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift @@ -207,41 +207,18 @@ final class TravelAlbumDetailViewModel { } } - /// 重新拉取当前筛选、排序下已经加载的分页范围,供全屏预览刷新关联图片。 - func reloadLoadedMaterials( - api: any TravelAlbumServing - ) async throws -> TravelAlbumListResponse { - let requestedPageCount = max(currentPage, 1) - var refreshed: [TravelAlbumMaterial] = [] - var refreshedTotal = 0 - var loadedPageCount = 0 - - for page in 1 ... requestedPageCount { - let response = try await api.materialList( - userEquityTravelId: albumId, - page: page, - pageSize: pageSize, - orderBy: sortOption.rawValue, - isPurchased: selectedTab == .purchased ? 1 : nil - ) - if page == 1 { refreshedTotal = response.total } - refreshed.append(contentsOf: response.list) - loadedPageCount = page - if refreshed.count >= refreshedTotal || response.list.isEmpty { break } + /// 定向刷新单个素材,并同步替换网格列表中的对应缓存。 + func refreshMaterial(id: Int, api: any TravelAlbumServing) async throws -> TravelAlbumMaterial { + let material = try await api.materialInfo( + userEquityTravelId: albumId, + materialId: id + ) + guard material.id == id else { throw APIError.invalidResponse } + if let index = materials.firstIndex(where: { $0.id == id }) { + materials[index] = material + notifyStateChange() } - - var seen = Set() - materials = refreshed.filter { seen.insert($0.id).inserted } - currentPage = max(loadedPageCount, 1) - canLoadMore = materials.count < refreshedTotal - selectedMaterialIds.formIntersection(materials.map(\.id)) - if selectedTab == .all { - allPhotoCount = refreshedTotal - } else { - purchasedPhotoCount = refreshedTotal - } - notifyStateChange() - return TravelAlbumListResponse(total: refreshedTotal, list: materials) + return material } /// 切换选择模式。 diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift index 966145a..5f91819 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift @@ -12,13 +12,11 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { /// 页面使用的 collection section。 private enum Section: Hashable { case templates(TravelAlbumAIRetouchTemplateCategory) - case mode } /// 页面使用的 diffable item。 private enum Item: Hashable { case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate) - case mode } private let viewModel: TravelAlbumAIRetouchTemplateViewModel @@ -35,6 +33,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { private let bottomBar = UIView() private let bottomDivider = UIView() private let footerStack = UIStackView() + private let quotaLabel = UILabel() private let validationLabel = UILabel() private let actionStack = UIStackView() private let cancelButton = UIButton(type: .system) @@ -78,10 +77,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { TravelAlbumAIRetouchTemplateCell.self, forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier ) - collectionView.register( - TravelAlbumAIRetouchModeCell.self, - forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier - ) collectionView.register( TravelAlbumAIRetouchSectionHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, @@ -109,6 +104,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { footerStack.axis = .vertical footerStack.spacing = 8 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.spacing = 12 actionStack.distribution = .fillEqually @@ -117,6 +116,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { configureConfirmButton() actionStack.addArrangedSubview(cancelButton) actionStack.addArrangedSubview(confirmButton) + footerStack.addArrangedSubview(quotaLabel) footerStack.addArrangedSubview(validationLabel) footerStack.addArrangedSubview(actionStack) @@ -255,13 +255,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { selected: self.viewModel.selectedTemplateId(for: category) == template.id ) 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 @@ -288,40 +281,27 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else { return nil } - switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] { - case .templates: - let itemSize = NSCollectionLayoutSize( - widthDimension: .absolute(118), - heightDimension: .absolute(154) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) - let section = NSCollectionLayoutSection(group: group) - section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary - section.interGroupSpacing = 12 - section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18) - section.boundarySupplementaryItems = [ - NSCollectionLayoutBoundarySupplementaryItem( - layoutSize: NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .absolute(40) - ), - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .top + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(118), + heightDimension: .absolute(154) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary + section.interGroupSpacing = 12 + section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18) + section.boundarySupplementaryItems = [ + NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(40) ), - ] - 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 - } + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ), + ] + return section } } @@ -346,6 +326,17 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { validationLabel.text = viewModel.validationMessage 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 confirmButton.isEnabled = viewModel.canSubmit confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45 @@ -362,8 +353,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { for category in viewModel.visibleCategories { appendTemplates(category, to: &snapshot) } - snapshot.appendSections([.mode]) - snapshot.appendItems([.mode], toSection: .mode) snapshot.reconfigureItems(snapshot.itemIdentifiers) 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 修图模板页视觉常量。 private enum AIRetouchTemplateStyle { static let primary = UIColor(hex: 0x1677FF) diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index 18bf33c..d22570c 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -499,12 +499,9 @@ final class TravelAlbumDetailViewController: BaseViewController { previewViewModel.currentPhotoCount ) }, - reload: { - let response = try await previewViewModel.reloadLoadedMaterials(api: previewAPI) - return ( - response.list.map(TravelAlbumPreviewProject.init(material:)), - response.total - ) + reload: { materialId in + let material = try await previewViewModel.refreshMaterial(id: materialId, api: previewAPI) + return TravelAlbumPreviewProject(material: material) }, onProjectDeleted: { materialId in previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId) diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift index 03b3a5b..73fce96 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift @@ -10,8 +10,8 @@ import UIKit /// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。 typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int) -/// 相册项目刷新回调,失败时由预览页保留当前内容并展示错误。 -typealias TravelAlbumPreviewReload = () async throws -> (projects: [TravelAlbumPreviewProject], totalCount: Int) +/// 相册项目定向刷新回调,根据原素材 ID 返回该项目的最新数据。 +typealias TravelAlbumPreviewReload = (_ materialId: Int) async throws -> TravelAlbumPreviewProject /// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。 final class TravelAlbumPhotoPreviewViewController: UIViewController { @@ -481,7 +481,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { onSubmitted: { [weak self] in guard let self else { return } self.showPreviewToast("AI修图任务已提交") - self.reloadProjects(showSuccessToast: false, forceRefreshImage: false) + self.reloadProjects(showGlobalLoading: false, forceRefreshImage: false) } ) present(controller, animated: true) @@ -578,32 +578,36 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { } @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 let reload else { - if showSuccessToast { showPreviewToast("关联图片刷新接口待接入") } - return - } + guard let reload else { return } isRefreshingProject = true updateRefreshButton() + if showGlobalLoading { GlobalLoadingManager.shared.show() } let currentProjectId = currentProject?.id let currentProjectIndex = currentNode?.projectIndex ?? 0 let kind = currentAsset?.kind ?? .original Task { [weak self] in + defer { + if showGlobalLoading { GlobalLoadingManager.shared.hide() } + } guard let self else { return } do { - let result = try await reload() + guard let currentProjectId else { + self.isRefreshingProject = false + self.updateRefreshButton() + return + } + let project = try await reload(currentProjectId) self.applySuccessfulReload( - projects: result.projects, - totalCount: result.totalCount, + project: project, currentProjectId: currentProjectId, fallbackProjectIndex: currentProjectIndex, kind: kind, - showSuccessToast: showSuccessToast, forceRefreshImage: forceRefreshImage ) } catch is CancellationError { @@ -612,37 +616,28 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { } catch { self.isRefreshingProject = false self.updateRefreshButton() - if showSuccessToast { - let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - self.showPreviewToast(message.isEmpty ? "刷新失败" : message) - } } } } private func applySuccessfulReload( - projects refreshedProjects: [TravelAlbumPreviewProject], - totalCount: Int, - currentProjectId: Int?, + project refreshedProject: TravelAlbumPreviewProject, + currentProjectId: Int, fallbackProjectIndex: Int, kind: TravelAlbumPreviewAssetKind, - showSuccessToast: Bool, forceRefreshImage: Bool ) { - let incoming = Self.deduplicated(refreshedProjects) - guard !incoming.isEmpty else { + guard !projects.isEmpty else { isRefreshingProject = false updateRefreshButton() - if showSuccessToast { dismiss(animated: true) } + dismiss(animated: true) return } - projects = incoming - self.totalCount = max(totalCount, incoming.count) - let targetProjectIndex = currentProjectId.flatMap { id in - incoming.firstIndex { $0.id == id } - } ?? min(max(0, fallbackProjectIndex), incoming.count - 1) - let resolvedKind = incoming[targetProjectIndex].asset(for: kind) == nil ? .original : kind + let targetProjectIndex = projects.firstIndex { $0.id == currentProjectId } + ?? min(max(0, fallbackProjectIndex), projects.count - 1) + projects[targetProjectIndex] = refreshedProject + let resolvedKind = refreshedProject.asset(for: kind) == nil ? .original : kind selectedKind = resolvedKind rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind) collectionView.reloadData() @@ -652,7 +647,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { if forceRefreshImage { forceRefreshCurrentImage() } isRefreshingProject = false updateRefreshButton() - if showSuccessToast { showPreviewToast("刷新成功") } } private func forceRefreshCurrentImage() { diff --git a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift index ee3cc0c..65d6a21 100644 --- a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift +++ b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift @@ -240,12 +240,33 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { 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() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [template(11, "清透"), template(12, "自然")], atmosphereTemplates: [template(21, "暖阳")], - coverTemplates: [template(31, "杂志")] + coverTemplates: [template(31, "杂志")], + remainingQuota: remainingQuota ) return api } diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index 0178932..d9f96a3 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -84,6 +84,26 @@ final class TravelAlbumAPITests: XCTestCase { 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 { 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]) @@ -126,7 +146,7 @@ final class TravelAlbumAPITests: XCTestCase { func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws { 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 api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) @@ -136,6 +156,7 @@ final class TravelAlbumAPITests: XCTestCase { XCTAssertEqual(response.refinedTemplates.first?.name, "清透") XCTAssertEqual(response.atmosphereTemplates.first?.id, 2) XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg") + XCTAssertEqual(response.remainingQuota, 12) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates") diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index a683cb4..b25e804 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -250,7 +250,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, 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( albumId: 9, @@ -308,7 +309,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, 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( albumId: 9, @@ -330,9 +332,13 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) XCTAssertTrue(accessibleLabels.contains("封面风格模板")) - let modeCell = TravelAlbumAIRetouchModeCell(frame: .zero) - modeCell.apply() - XCTAssertEqual(modeCell.accessibilityLabel, "AI精修,按张收费,剩余张数暂不可用") + let quotaLabel = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchRemainingQuotaLabel" + } as? UILabel + ) + XCTAssertEqual(quotaLabel.text, "剩余 8 次") + XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8次修图额度") } func testAIRetouchTemplateCellExposesSelectedState() { @@ -358,7 +364,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { let api = TravelAlbumMockAPI() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "")], - atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")] + atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")], + remainingQuota: 8 ) let project = TravelAlbumPreviewProject( originalMaterialId: 7, @@ -451,10 +458,11 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { projects: [TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original, retouched])], totalCount: 1, startProjectIndex: 0, - reload: { + reload: { materialId in + XCTAssertEqual(materialId, 7) reloadCount += 1 let assets = reloadCount == 1 ? [original, retouched] : [original] - return ([TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets)], 1) + return TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets) } ) controller.loadViewIfNeeded() @@ -487,6 +495,43 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { 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 { let cases: [(TravelAlbumMaterial, String, UInt)] = [ (TravelAlbumMaterial(isPurchased: true), "已购", 0x475569), diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index e9d9989..7cdd912 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -206,39 +206,29 @@ final class TravelAlbumDetailViewModelTests: XCTestCase { XCTAssertEqual(viewModel.purchasedPhotoCount, 0) } - func testPreviewReloadRefetchesAllLoadedPagesAndReplacesMaterialsById() async throws { + func testRefreshMaterialUpdatesOnlyMatchingCachedMaterial() async throws { let api = TravelAlbumMockAPI() api.materialListResponses = [ TravelAlbumListResponse( - total: 35, - list: (1 ... 30).map { TravelAlbumMaterial(id: $0) } - ), - 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) } + total: 2, + list: [TravelAlbumMaterial(id: 7), TravelAlbumMaterial(id: 8)] ), ] + api.materialInfoResponse = TravelAlbumMaterial( + id: 7, + aiRetouchBatchId: 700, + aiRefinedURL: "https://cdn/refined.jpg" + ) let viewModel = TravelAlbumDetailViewModel(albumId: 2) 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(response.list.count, 35) - XCTAssertEqual(response.list.first { $0.id == 7 }?.aiRetouchBatchId, 700) - XCTAssertEqual(api.materialRequests.suffix(2).map(\.page), [1, 2]) - XCTAssertEqual(api.materialRequests.suffix(2).map(\.pageSize), [30, 30]) + XCTAssertEqual(material.aiRetouchBatchId, 700) + XCTAssertEqual(viewModel.materials.first { $0.id == 7 }?.aiRefinedURL, "https://cdn/refined.jpg") + XCTAssertEqual(viewModel.materials.first { $0.id == 8 }?.aiRetouchBatchId, 0) + XCTAssertEqual(api.materialInfoRequests, [.init(userEquityTravelId: 2, materialId: 7)]) + XCTAssertEqual(api.materialRequests.count, 1) } func testDeleteAlbumCallsCallback() async { @@ -1074,12 +1064,19 @@ final class TravelAlbumMockAPI: TravelAlbumServing { let isPurchased: Int? } + struct MaterialInfoRequest: Equatable { + let userEquityTravelId: Int + let materialId: Int + } + var availableOrdersResponse: [TravelAlbumAvailableOrder] = [] var listResponse = TravelAlbumListResponse() var createResponse = TravelAlbumCreateResponse(id: 0) var infoResponse = TravelAlbum() var materialListResponses: [TravelAlbumListResponse] = [] var materialListFailingCallIndexes: Set = [] + var materialInfoResponse = TravelAlbumMaterial() + var materialInfoError: Error? var uploadMaterialResponse = TravelAlbumMaterial() var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: []) var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") @@ -1096,6 +1093,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing { private(set) var availableOrdersCallCount = 0 private(set) var createRequests: [TravelAlbumCreateRequest] = [] private(set) var materialRequests: [MaterialRequest] = [] + private(set) var materialInfoRequests: [MaterialInfoRequest] = [] private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = [] private(set) var materialClientPhotoIdsCallCount = 0 private(set) var deletedAlbumIds: [Int] = [] @@ -1147,6 +1145,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing { 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 { uploadMaterialRequests.append(request) return uploadMaterialResponse