diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift index 6308111..e0cffc1 100644 --- a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -43,6 +43,12 @@ protocol TravelAlbumServing { /// 拉取相册小程序码。 func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse + + /// 拉取当前景区可用的 AI 修图模板。 + func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse + + /// 提交相册素材 AI 修图任务。 + func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws } @MainActor @@ -155,6 +161,24 @@ final class TravelAlbumAPI: TravelAlbumServing { ) ) } + + /// 拉取当前景区可用的 AI 修图模板。 + func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse { + try await client.send( + APIRequest( + method: .get, + path: "\(basePath)/ai-retouch-templates", + queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))] + ) + ) + } + + /// 提交相册素材 AI 修图任务;服务端 data 内容无需客户端消费。 + func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request) + ) + } } /// 旅拍相册 ID 请求体。 diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index d785fe2..ac599cb 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -127,6 +127,10 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab let fileSize: Int let coverUrl: String let isPurchased: Bool + let aiRetouchStatus: Int + let aiRetouchStatusName: String + let aiRefinedURL: String + let aiAtmosphereURL: String let createdAt: String let updatedAt: String @@ -142,10 +146,40 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab case fileSize = "file_size" case coverUrl = "cover_url" case isPurchased = "is_purchased" + case aiRetouchStatus = "ai_retouch_status" + case aiRetouchStatusName = "ai_retouch_status_name" + case aiRefinedURL = "ai_refined_url" + case aiAtmosphereURL = "ai_atmosphere_url" case createdAt = "created_at" case updatedAt = "updated_at" } + /// 从素材接口解码;AI 修图扩展字段缺失、为空或类型异常时使用安全默认值。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + userEquityTravelId = try container.decode(Int.self, forKey: .userEquityTravelId) + status = try container.decode(Int.self, forKey: .status) + orderNumber = try container.decode(String.self, forKey: .orderNumber) + userId = try container.decode(Int.self, forKey: .userId) + fileName = try container.decode(String.self, forKey: .fileName) + fileType = try container.decode(Int.self, forKey: .fileType) + fileUrl = try container.decode(String.self, forKey: .fileUrl) + fileSize = try container.decode(Int.self, forKey: .fileSize) + coverUrl = try container.decode(String.self, forKey: .coverUrl) + isPurchased = try container.decode(Bool.self, forKey: .isPurchased) + aiRetouchStatus = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchStatus)) ?? 0 + aiRetouchStatusName = ( + try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName) + ) ?? "" + aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? "" + aiAtmosphereURL = ( + try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL) + ) ?? "" + createdAt = try container.decode(String.self, forKey: .createdAt) + updatedAt = try container.decode(String.self, forKey: .updatedAt) + } + init( id: Int = 0, userEquityTravelId: Int = 0, @@ -158,6 +192,10 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab fileSize: Int = 0, coverUrl: String = "", isPurchased: Bool = false, + aiRetouchStatus: Int = 0, + aiRetouchStatusName: String = "", + aiRefinedURL: String = "", + aiAtmosphereURL: String = "", createdAt: String = "", updatedAt: String = "" ) { @@ -172,11 +210,68 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab self.fileSize = fileSize self.coverUrl = coverUrl self.isPurchased = isPurchased + self.aiRetouchStatus = aiRetouchStatus + self.aiRetouchStatusName = aiRetouchStatusName + self.aiRefinedURL = aiRefinedURL + self.aiAtmosphereURL = aiAtmosphereURL self.createdAt = createdAt self.updatedAt = updatedAt } } +/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。 +enum TravelAlbumMaterialBadgeKind: Sendable, Equatable { + case purchased + case pending + case processing + case retouched + case cover + case failed +} + +/// 相册素材网格角标展示内容。 +struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable { + let kind: TravelAlbumMaterialBadgeKind + let text: String +} + +extension TravelAlbumMaterial { + /// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。 + var badgePresentation: TravelAlbumMaterialBadgePresentation? { + if aiRetouchStatus == 0 { + return isPurchased + ? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") + : nil + } + + let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines) + switch aiRetouchStatus { + case 1: + return TravelAlbumMaterialBadgePresentation( + kind: .pending, + text: statusName.isEmpty ? "待处理" : statusName + ) + case 2: + return TravelAlbumMaterialBadgePresentation( + kind: .processing, + text: statusName.isEmpty ? "修图中" : statusName + ) + case 3: + return TravelAlbumMaterialBadgePresentation( + kind: statusName == "AI封面" ? .cover : .retouched, + text: statusName.isEmpty ? "AI已修" : statusName + ) + case 4: + return TravelAlbumMaterialBadgePresentation( + kind: .failed, + text: statusName.isEmpty ? "失败" : statusName + ) + default: + return nil + } + } +} + /// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。 struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable { let name: String @@ -247,6 +342,67 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable { } } +/// AI 修图模板,包含业务 ID、展示名称和预览图地址。 +struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable { + let id: Int + let name: String + let previewURL: String + + enum CodingKeys: String, CodingKey { + case id + case name + case previewURL = "preview_url" + } + + /// 创建 AI 修图模板。 + init(id: Int, name: String, previewURL: String) { + self.id = id + self.name = name + self.previewURL = previewURL + } +} + +/// AI 修图模板接口响应,按原图、氛围感和封面风格分组。 +struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable { + let refinedTemplates: [TravelAlbumAIRetouchTemplate] + let atmosphereTemplates: [TravelAlbumAIRetouchTemplate] + let coverTemplates: [TravelAlbumAIRetouchTemplate] + + enum CodingKeys: String, CodingKey { + case refinedTemplates = "refined_templates" + case atmosphereTemplates = "atmosphere_templates" + case coverTemplates = "cover_templates" + } + + /// 创建分组模板响应,默认各组为空。 + init( + refinedTemplates: [TravelAlbumAIRetouchTemplate] = [], + atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [], + coverTemplates: [TravelAlbumAIRetouchTemplate] = [] + ) { + self.refinedTemplates = refinedTemplates + self.atmosphereTemplates = atmosphereTemplates + self.coverTemplates = coverTemplates + } +} + +/// 提交 AI 修图任务的请求参数。 +struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable { + let userEquityTravelId: Int + let materialIds: [Int] + let refinedTemplateId: Int + let atmosphereTemplateId: Int? + let aiPreviewTabs: Int? + + enum CodingKeys: String, CodingKey { + case userEquityTravelId = "user_equity_travel_id" + case materialIds = "material_ids" + case refinedTemplateId = "refined_template_id" + case atmosphereTemplateId = "atmosphere_template_id" + case aiPreviewTabs = "ai_preview_tabs" + } +} + /// 旅拍相册展示格式化工具。 enum TravelAlbumDisplayFormatter { /// 脱敏手机号。 diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift index 50cad9e..09c5ede 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift @@ -84,10 +84,10 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable { assets.first { $0.kind == kind } } - /// 将当前素材映射为仅含原图的预览项目;新接口接入后替换此适配层即可。 + /// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。 init(material: TravelAlbumMaterial) { originalMaterialId = material.id - assets = [ + var mappedAssets = [ TravelAlbumPreviewAsset( id: "original-\(material.id)", kind: .original, @@ -97,6 +97,33 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable { fileSize: material.fileSize ), ] + let refinedURL = material.aiRefinedURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !refinedURL.isEmpty { + mappedAssets.append( + TravelAlbumPreviewAsset( + id: "retouched-\(material.id)", + kind: .retouched, + fileURL: refinedURL, + coverURL: refinedURL, + fileName: material.fileName, + fileSize: material.fileSize + ) + ) + } + let atmosphereURL = material.aiAtmosphereURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !atmosphereURL.isEmpty { + mappedAssets.append( + TravelAlbumPreviewAsset( + id: "atmosphere-\(material.id)", + kind: .atmosphere, + fileURL: atmosphereURL, + coverURL: atmosphereURL, + fileName: material.fileName, + fileSize: material.fileSize + ) + ) + } + assets = mappedAssets } /// 创建包含关联图片的项目,主要供适配器和测试使用。 @@ -142,6 +169,15 @@ enum TravelAlbumPreviewNavigator { $0.projectIndex == current.projectIndex - 1 && $0.kind == .original } ?? currentIndex - 1 } + + /// 返回删除当前项目后应展示的项目索引;优先保持原索引以显示下一张,末项则回退上一张。 + static func projectIndexAfterDeletion( + deletedProjectIndex: Int, + remainingProjectCount: Int + ) -> Int? { + guard remainingProjectCount > 0 else { return nil } + return min(max(0, deletedProjectIndex), remainingProjectCount - 1) + } } /// 预览操作执行结果,便于后续替换真实业务接口。 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift new file mode 100644 index 0000000..b923567 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift @@ -0,0 +1,192 @@ +// +// TravelAlbumAIRetouchTemplateViewModel.swift +// suixinkan +// + +import Foundation + +/// AI 修图模板类别,决定页面分组、选择规则和提交字段。 +enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable { + case refined + case atmosphere + case cover + + var title: String { + switch self { + case .refined: "原图精修" + case .atmosphere: "氛围感修图" + case .cover: "封面风格模板" + } + } +} + +/// AI 修图模板选择状态,负责实时加载、单选规则、校验和任务提交。 +final class TravelAlbumAIRetouchTemplateViewModel { + private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = [] + private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [] + private(set) var coverTemplates: [TravelAlbumAIRetouchTemplate] = [] + private(set) var selectedRefinedTemplateId: Int? + private(set) var selectedAtmosphereTemplateId: Int? + private(set) var selectedCoverTemplateId: Int? + private(set) var isLoading = false + private(set) var isSubmitting = false + private(set) var loadErrorMessage: String? + + let albumId: Int + let scenicId: Int + let materialIds: [Int] + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onSubmitted: (() -> Void)? + + /// 创建模板选择状态;素材 ID 会排序并去重,确保提交稳定。 + init(albumId: Int, scenicId: Int, materialIds: [Int]) { + self.albumId = albumId + self.scenicId = scenicId + self.materialIds = Array(Set(materialIds)).sorted() + } + + /// 选中不少于四张素材时才展示并要求选择封面模板。 + var showsCoverTemplates: Bool { + materialIds.count >= 4 + } + + /// 当前必选模板缺失时用于底部提示的文案。 + var validationMessage: String? { + guard !isLoading, loadErrorMessage == nil else { return nil } + if refinedTemplates.isEmpty || selectedRefinedTemplateId == nil { + return "暂无可用的原图精修模板" + } + if showsCoverTemplates, coverTemplates.isEmpty || selectedCoverTemplateId == nil { + return "暂无可用的封面风格模板" + } + return nil + } + + /// 是否满足提交条件。 + var canSubmit: Bool { + !isLoading + && !isSubmitting + && loadErrorMessage == nil + && !materialIds.isEmpty + && selectedRefinedTemplateId != nil + && (!showsCoverTemplates || selectedCoverTemplateId != nil) + } + + /// 实时拉取当前景区模板并按规则设置默认选择。 + func loadTemplates(api: any TravelAlbumServing) async { + guard scenicId > 0 else { + loadErrorMessage = "请先选择景区" + notifyStateChange() + return + } + guard !isLoading else { return } + isLoading = true + loadErrorMessage = nil + notifyStateChange() + + do { + let response = try await api.aiRetouchTemplates(scenicId: scenicId) + refinedTemplates = response.refinedTemplates + atmosphereTemplates = response.atmosphereTemplates + coverTemplates = response.coverTemplates + selectedRefinedTemplateId = refinedTemplates.first?.id + selectedAtmosphereTemplateId = nil + selectedCoverTemplateId = showsCoverTemplates ? coverTemplates.first?.id : nil + isLoading = false + notifyStateChange() + } catch is CancellationError { + isLoading = false + notifyStateChange() + } catch { + refinedTemplates = [] + atmosphereTemplates = [] + coverTemplates = [] + selectedRefinedTemplateId = nil + selectedAtmosphereTemplateId = nil + selectedCoverTemplateId = nil + isLoading = false + loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription + notifyStateChange() + } + } + + /// 返回指定分组的模板。 + func templates(for category: TravelAlbumAIRetouchTemplateCategory) -> [TravelAlbumAIRetouchTemplate] { + switch category { + case .refined: refinedTemplates + case .atmosphere: atmosphereTemplates + case .cover: coverTemplates + } + } + + /// 返回指定分组当前选中的模板 ID。 + func selectedTemplateId(for category: TravelAlbumAIRetouchTemplateCategory) -> Int? { + switch category { + case .refined: selectedRefinedTemplateId + case .atmosphere: selectedAtmosphereTemplateId + case .cover: selectedCoverTemplateId + } + } + + /// 选择模板;氛围感模板再次点击时取消,必选分组保持单选。 + func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) { + guard templates(for: category).contains(where: { $0.id == id }) else { return } + switch category { + case .refined: + selectedRefinedTemplateId = id + case .atmosphere: + selectedAtmosphereTemplateId = selectedAtmosphereTemplateId == id ? nil : id + case .cover: + guard showsCoverTemplates else { return } + selectedCoverTemplateId = id + } + notifyStateChange() + } + + /// 提交 AI 修图任务,成功后通知页面退出选择态。 + func submit(api: any TravelAlbumServing) async { + guard !isSubmitting else { return } + guard let refinedTemplateId = selectedRefinedTemplateId else { + onShowMessage?("请选择原图精修模板") + return + } + guard !showsCoverTemplates || selectedCoverTemplateId != nil else { + onShowMessage?("请选择封面风格模板") + return + } + guard !materialIds.isEmpty else { + onShowMessage?("请选择要修图的照片") + return + } + + isSubmitting = true + notifyStateChange() + defer { + isSubmitting = false + notifyStateChange() + } + + do { + try await api.submitAIRetouch( + TravelAlbumAIRetouchRequest( + userEquityTravelId: albumId, + materialIds: materialIds, + refinedTemplateId: refinedTemplateId, + atmosphereTemplateId: selectedAtmosphereTemplateId, + aiPreviewTabs: showsCoverTemplates ? selectedCoverTemplateId : nil + ) + ) + onSubmitted?() + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "AI修图任务提交失败" : error.localizedDescription) + } + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift index bbacf62..a8c201c 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift @@ -232,6 +232,25 @@ final class TravelAlbumDetailViewModel { notifyStateChange() } + /// AI 修图任务提交成功后退出选择模式并清空当前选择。 + func completeAIRetouchSubmission() { + isSelectionMode = false + selectedMaterialIds = [] + notifyStateChange() + } + + /// 预览页删除成功后移除对应素材,并同步全部/已购计数。 + func removeMaterialAfterPreviewDeletion(id: Int) { + guard let index = materials.firstIndex(where: { $0.id == id }) else { return } + let material = materials.remove(at: index) + selectedMaterialIds.remove(id) + allPhotoCount = max(0, allPhotoCount - 1) + if material.isPurchased { + purchasedPhotoCount = max(0, purchasedPhotoCount - 1) + } + notifyStateChange() + } + /// 删除已选素材。 func deleteSelectedMaterials(api: any TravelAlbumServing) async { let ids = Array(selectedMaterialIds) diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumPreviewActionHandler.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumPreviewActionHandler.swift new file mode 100644 index 0000000..dc683ec --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumPreviewActionHandler.swift @@ -0,0 +1,39 @@ +// +// TravelAlbumPreviewActionHandler.swift +// suixinkan +// + +import Foundation + +/// 图片预览页业务操作实现,负责将底部操作转发到旅拍相册服务。 +final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling { + private let api: any TravelAlbumServing + + /// 使用旅拍相册服务创建操作处理器。 + init(api: any TravelAlbumServing) { + self.api = api + } + + /// AI 修复仍由相册选择态的模板流程发起。 + func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult { + .unavailable("请在相册中选择照片后使用AI修图") + } + + /// 调用素材删除接口,仅在服务端确认成功后返回成功结果。 + func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult { + do { + try await api.deleteMaterial(id: originalMaterialId) + return .success("删除成功") + } catch is CancellationError { + return .failure("删除已取消") + } catch { + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return .failure(message.isEmpty ? "删除失败" : message) + } + } + + /// 刷新关联结果图接口尚未提供。 + func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult { + .unavailable("关联图片刷新接口待接入") + } +} diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift new file mode 100644 index 0000000..27ea4e6 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift @@ -0,0 +1,599 @@ +// +// TravelAlbumAIRetouchTemplateViewController.swift +// suixinkan +// + +import Kingfisher +import SnapKit +import UIKit + +/// AI 修图模板选择 Sheet,展示三类横向模板列表、修图模式与固定底部操作。 +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 + private let api: any TravelAlbumServing + private let onSubmitted: () -> Void + + private let titleLabel = UILabel() + private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) + private var dataSource: UICollectionViewDiffableDataSource! + private let statusContainer = UIView() + private let statusIndicator = UIActivityIndicatorView(style: .medium) + private let statusLabel = UILabel() + private let retryButton = UIButton(type: .system) + private let bottomBar = UIView() + private let bottomDivider = UIView() + private let footerStack = UIStackView() + private let validationLabel = UILabel() + private let actionStack = UIStackView() + private let cancelButton = UIButton(type: .system) + private let confirmButton = UIButton(type: .system) + + /// 创建 AI 修图模板选择 Sheet。 + init( + viewModel: TravelAlbumAIRetouchTemplateViewModel, + api: any TravelAlbumServing, + onSubmitted: @escaping () -> Void + ) { + self.viewModel = viewModel + self.api = api + self.onSubmitted = onSubmitted + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .pageSheet + configureSheetPresentation() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupUI() { + view.backgroundColor = AIRetouchTemplateStyle.pageBackground + view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet" + + titleLabel.text = "AI修图" + titleLabel.textColor = AIRetouchTemplateStyle.textPrimary + titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) + titleLabel.textAlignment = .center + titleLabel.accessibilityTraits = .header + + collectionView.backgroundColor = .clear + collectionView.showsVerticalScrollIndicator = false + collectionView.alwaysBounceVertical = true + collectionView.delegate = self + collectionView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateCollection" + collectionView.register( + TravelAlbumAIRetouchTemplateCell.self, + forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier + ) + collectionView.register( + TravelAlbumAIRetouchModeCell.self, + forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier + ) + collectionView.register( + TravelAlbumAIRetouchSectionHeader.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier + ) + configureDataSource() + + statusLabel.textColor = AIRetouchTemplateStyle.textSecondary + statusLabel.font = .systemFont(ofSize: 14) + statusLabel.textAlignment = .center + statusLabel.numberOfLines = 0 + retryButton.setTitle("重试", for: .normal) + retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold) + retryButton.accessibilityIdentifier = "travelAlbum.aiRetouchRetryButton" + + bottomBar.backgroundColor = .white + bottomBar.accessibilityIdentifier = "travelAlbum.aiRetouchBottomBar" + bottomDivider.backgroundColor = AIRetouchTemplateStyle.border + validationLabel.textColor = AIRetouchTemplateStyle.danger + validationLabel.font = .systemFont(ofSize: 12, weight: .medium) + validationLabel.textAlignment = .center + validationLabel.numberOfLines = 0 + validationLabel.accessibilityIdentifier = "travelAlbum.aiRetouchValidationLabel" + + footerStack.axis = .vertical + footerStack.spacing = 8 + footerStack.alignment = .fill + actionStack.axis = .horizontal + actionStack.spacing = 12 + actionStack.distribution = .fillEqually + actionStack.alignment = .fill + configureCancelButton() + configureConfirmButton() + actionStack.addArrangedSubview(cancelButton) + actionStack.addArrangedSubview(confirmButton) + footerStack.addArrangedSubview(validationLabel) + footerStack.addArrangedSubview(actionStack) + + view.addSubview(titleLabel) + view.addSubview(collectionView) + view.addSubview(statusContainer) + statusContainer.addSubview(statusIndicator) + statusContainer.addSubview(statusLabel) + statusContainer.addSubview(retryButton) + view.addSubview(bottomBar) + bottomBar.addSubview(bottomDivider) + bottomBar.addSubview(footerStack) + } + + override func setupConstraints() { + titleLabel.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(12) + make.leading.trailing.equalToSuperview().inset(18) + make.height.equalTo(30) + } + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + bottomDivider.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(0.5) + } + footerStack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(10) + make.leading.trailing.equalToSuperview().inset(18) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) + } + actionStack.snp.makeConstraints { make in + make.height.equalTo(48) + } + collectionView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + statusContainer.snp.makeConstraints { make in + make.edges.equalTo(collectionView) + } + statusIndicator.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-32) + } + statusLabel.snp.makeConstraints { make in + make.top.equalTo(statusIndicator.snp.bottom).offset(14) + make.leading.trailing.equalToSuperview().inset(40) + } + retryButton.snp.makeConstraints { make in + make.top.equalTo(statusLabel.snp.bottom).offset(12) + make.centerX.equalToSuperview() + make.height.equalTo(36) + make.bottom.lessThanOrEqualToSuperview() + } + } + + override func bindActions() { + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onSubmitted = { [weak self] in + Task { @MainActor in + guard let self else { return } + self.dismiss(animated: true, completion: self.onSubmitted) + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + applyViewModel() + Task { await viewModel.loadTemplates(api: api) } + } + + private func configureSheetPresentation() { + guard let sheet = sheetPresentationController else { return } + sheet.detents = [.large()] + sheet.selectedDetentIdentifier = .large + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + } + + private func configureCancelButton() { + var configuration = UIButton.Configuration.filled() + configuration.title = "取消" + configuration.baseBackgroundColor = .white + configuration.baseForegroundColor = AIRetouchTemplateStyle.primary + configuration.background.cornerRadius = 14 + configuration.background.strokeColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.55) + configuration.background.strokeWidth = 1 + configuration.titleTextAttributesTransformer = buttonTitleTransformer + cancelButton.configuration = configuration + cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton" + } + + private func configureConfirmButton() { + var configuration = UIButton.Configuration.filled() + configuration.title = "确定" + configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary + configuration.baseForegroundColor = .white + configuration.background.cornerRadius = 14 + configuration.titleTextAttributesTransformer = buttonTitleTransformer + confirmButton.configuration = configuration + confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton" + } + + private var buttonTitleTransformer: UIConfigurationTextAttributesTransformer { + UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = .systemFont(ofSize: 16, weight: .semibold) + return attributes + } + } + + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + [weak self] collectionView, indexPath, item in + guard let self else { return nil } + switch item { + case .template(let category, let template): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier, + for: indexPath + ) as! TravelAlbumAIRetouchTemplateCell + cell.apply( + template: template, + 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 + guard kind == UICollectionView.elementKindSectionHeader, + let self, + self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section), + case .templates(let category) = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] else { + return nil + } + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier, + for: indexPath + ) as! TravelAlbumAIRetouchSectionHeader + header.apply(title: category.title, optional: category == .atmosphere) + return header + } + } + + private func makeLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self, + self.dataSource != nil, + 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 + ), + ] + 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 + } + } + } + + @MainActor + private func applyViewModel() { + let isLoadFailure = viewModel.loadErrorMessage != nil + statusContainer.isHidden = !viewModel.isLoading && !isLoadFailure + collectionView.isHidden = viewModel.isLoading || isLoadFailure + if viewModel.isLoading { + statusIndicator.startAnimating() + statusLabel.text = "正在加载修图模板…" + retryButton.isHidden = true + } else if let message = viewModel.loadErrorMessage { + statusIndicator.stopAnimating() + statusLabel.text = message + retryButton.isHidden = false + } else { + statusIndicator.stopAnimating() + statusLabel.text = nil + retryButton.isHidden = true + } + + validationLabel.text = viewModel.validationMessage + validationLabel.isHidden = viewModel.validationMessage == nil + cancelButton.isEnabled = !viewModel.isSubmitting + confirmButton.isEnabled = viewModel.canSubmit + confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45 + var confirmConfiguration = confirmButton.configuration + confirmConfiguration?.title = viewModel.isSubmitting ? "提交中" : "确定" + confirmConfiguration?.showsActivityIndicator = viewModel.isSubmitting + confirmButton.configuration = confirmConfiguration + isModalInPresentation = viewModel.isSubmitting + applySnapshot() + } + + private func applySnapshot() { + var snapshot = NSDiffableDataSourceSnapshot() + appendTemplates(.refined, to: &snapshot) + appendTemplates(.atmosphere, to: &snapshot) + if viewModel.showsCoverTemplates { + appendTemplates(.cover, to: &snapshot) + } + snapshot.appendSections([.mode]) + snapshot.appendItems([.mode], toSection: .mode) + snapshot.reconfigureItems(snapshot.itemIdentifiers) + dataSource.apply(snapshot, animatingDifferences: true) + } + + private func appendTemplates( + _ category: TravelAlbumAIRetouchTemplateCategory, + to snapshot: inout NSDiffableDataSourceSnapshot + ) { + let section = Section.templates(category) + snapshot.appendSections([section]) + snapshot.appendItems( + viewModel.templates(for: category).map { Item.template(category, $0) }, + toSection: section + ) + } + + @objc private func cancelTapped() { + guard !viewModel.isSubmitting else { return } + dismiss(animated: true) + } + + @objc private func confirmTapped() { + guard viewModel.canSubmit else { + if let message = viewModel.validationMessage { showToast(message) } + return + } + Task { await viewModel.submit(api: api) } + } + + @objc private func retryTapped() { + Task { await viewModel.loadTemplates(api: api) } + } +} + +extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath), + case .template(let category, let template) = item else { return } + viewModel.toggleTemplate(id: template.id, category: category) + } +} + +/// AI 修图模板卡片,展示模板预览、名称和明确选中态。 +final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell { + static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell" + + private let previewImageView = UIImageView() + private let nameLabel = UILabel() + private let checkView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.backgroundColor = .white + contentView.layer.cornerRadius = 12 + contentView.layer.borderWidth = 1 + contentView.clipsToBounds = true + + previewImageView.contentMode = .scaleAspectFill + previewImageView.clipsToBounds = true + previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8) + nameLabel.font = .systemFont(ofSize: 13, weight: .medium) + nameLabel.textColor = AIRetouchTemplateStyle.textPrimary + nameLabel.textAlignment = .center + nameLabel.lineBreakMode = .byTruncatingTail + checkView.image = UIImage(systemName: "checkmark.circle.fill") + checkView.tintColor = AIRetouchTemplateStyle.primary + checkView.backgroundColor = .white + checkView.layer.cornerRadius = 10 + + contentView.addSubview(previewImageView) + contentView.addSubview(nameLabel) + contentView.addSubview(checkView) + previewImageView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(116) + } + nameLabel.snp.makeConstraints { make in + make.top.equalTo(previewImageView.snp.bottom) + make.leading.trailing.equalToSuperview().inset(6) + make.bottom.equalToSuperview() + } + checkView.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(8) + make.size.equalTo(20) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + previewImageView.kf.cancelDownloadTask() + previewImageView.image = nil + } + + /// 更新模板内容与选中状态。 + func apply(template: TravelAlbumAIRetouchTemplate, selected: Bool) { + nameLabel.text = template.name + if let url = URL(string: template.previewURL), !template.previewURL.isEmpty { + previewImageView.contentMode = .scaleAspectFill + previewImageView.kf.setImage( + with: url, + placeholder: UIImage(systemName: "photo")?.withTintColor( + AIRetouchTemplateStyle.textSecondary, + renderingMode: .alwaysOriginal + ) + ) + } else { + previewImageView.image = UIImage(systemName: "photo") + previewImageView.tintColor = AIRetouchTemplateStyle.textSecondary + previewImageView.contentMode = .scaleAspectFit + } + contentView.layer.borderColor = ( + selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border + ).cgColor + contentView.layer.borderWidth = selected ? 2 : 1 + checkView.isHidden = !selected + isSelected = selected + accessibilityLabel = template.name + accessibilityValue = selected ? "已选择" : "未选择" + accessibilityTraits = selected ? [.button, .selected] : [.button] + } +} + +/// AI 修图模板分组标题,可附带“选填”标签。 +final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView { + static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader" + + private let titleLabel = UILabel() + private let optionalLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = AIRetouchTemplateStyle.textPrimary + optionalLabel.text = "选填" + optionalLabel.font = .systemFont(ofSize: 11, weight: .medium) + optionalLabel.textColor = AIRetouchTemplateStyle.primary + optionalLabel.textAlignment = .center + optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1) + optionalLabel.layer.cornerRadius = 8 + optionalLabel.clipsToBounds = true + + addSubview(titleLabel) + addSubview(optionalLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(18) + make.centerY.equalToSuperview() + } + optionalLabel.snp.makeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).offset(8) + make.centerY.equalTo(titleLabel) + make.width.equalTo(38) + make.height.equalTo(20) + make.trailing.lessThanOrEqualToSuperview().offset(-18) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新分组标题与选填标签。 + func apply(title: String, optional: Bool) { + titleLabel.text = title + optionalLabel.isHidden = !optional + accessibilityLabel = optional ? "\(title),选填" : title + accessibilityTraits = .header + } +} + +/// 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) + static let pageBackground = UIColor(hex: 0xF7F9FC) + static let textPrimary = UIColor(hex: 0x172033) + static let textSecondary = UIColor(hex: 0x7F8A9E) + static let border = UIColor(hex: 0xDCE4EF) + static let danger = UIColor(hex: 0xE53935) +} diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index c7211ea..0f9d881 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -12,6 +12,7 @@ final class TravelAlbumDetailViewController: BaseViewController { private let viewModel: TravelAlbumDetailViewModel private let api: any TravelAlbumServing private let previewConfiguration: TravelAlbumPreviewConfiguration + private let scenicIdProvider: () -> Int private let contentView = UIView() private let infoCard = TravelAlbumInfoCard() @@ -25,17 +26,21 @@ final class TravelAlbumDetailViewController: BaseViewController { private var dataSource: UICollectionViewDiffableDataSource! private let bottomBar = UIView() private let bottomDivider = UIView() + private let selectionActionStack = UIStackView() + private let aiRetouchButton = UIButton(type: .system) private let deleteSelectedButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system) init( albumId: Int, api: (any TravelAlbumServing)? = nil, - previewConfiguration: TravelAlbumPreviewConfiguration = .init() + previewConfiguration: TravelAlbumPreviewConfiguration = .init(), + scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId } ) { viewModel = TravelAlbumDetailViewModel(albumId: albumId) self.api = api ?? NetworkServices.shared.travelAlbumAPI self.previewConfiguration = previewConfiguration + self.scenicIdProvider = scenicIdProvider super.init(nibName: nil, bundle: nil) } @@ -118,8 +123,20 @@ final class TravelAlbumDetailViewController: BaseViewController { uploadConfiguration?.imagePadding = 8 uploadButton.configuration = uploadConfiguration uploadButton.accessibilityLabel = "上传照片" - configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: TravelAlbumDetailStyle.danger) - deleteSelectedButton.isHidden = true + selectionActionStack.axis = .horizontal + selectionActionStack.alignment = .fill + selectionActionStack.distribution = .fillEqually + selectionActionStack.spacing = 12 + selectionActionStack.isHidden = true + selectionActionStack.accessibilityIdentifier = "travelAlbum.selectionActionStack" + configureBottomButton(aiRetouchButton, title: "AI修图", color: TravelAlbumDetailStyle.primary) + aiRetouchButton.accessibilityLabel = "AI修图" + aiRetouchButton.accessibilityIdentifier = "travelAlbum.aiRetouchButton" + configureBottomButton(deleteSelectedButton, title: "删除", color: TravelAlbumDetailStyle.danger) + deleteSelectedButton.accessibilityLabel = "删除" + deleteSelectedButton.accessibilityIdentifier = "travelAlbum.deleteButton" + selectionActionStack.addArrangedSubview(aiRetouchButton) + selectionActionStack.addArrangedSubview(deleteSelectedButton) view.addSubview(contentView) contentView.addSubview(infoCard) @@ -133,7 +150,7 @@ final class TravelAlbumDetailViewController: BaseViewController { view.addSubview(bottomBar) bottomBar.addSubview(bottomDivider) bottomBar.addSubview(uploadButton) - bottomBar.addSubview(deleteSelectedButton) + bottomBar.addSubview(selectionActionStack) } override func setupConstraints() { @@ -150,7 +167,7 @@ final class TravelAlbumDetailViewController: BaseViewController { make.height.equalTo(48) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } - deleteSelectedButton.snp.makeConstraints { make in + selectionActionStack.snp.makeConstraints { make in make.edges.equalTo(uploadButton) } @@ -205,6 +222,7 @@ final class TravelAlbumDetailViewController: BaseViewController { allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside) purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside) selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside) + aiRetouchButton.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside) deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside) uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) viewModel.onStateChange = { [weak self] in @@ -255,10 +273,14 @@ final class TravelAlbumDetailViewController: BaseViewController { : "已购照片不可删除" let selectedCount = viewModel.selectedMaterialIds.count - deleteSelectedButton.setTitle("删除选中(\(selectedCount))", for: .normal) + let hasSelection = selectedCount > 0 + aiRetouchButton.isEnabled = hasSelection + aiRetouchButton.alpha = hasSelection ? 1 : 0.45 + aiRetouchButton.accessibilityValue = "已选择 \(selectedCount) 张照片" deleteSelectedButton.isEnabled = selectedCount > 0 - deleteSelectedButton.alpha = selectedCount > 0 ? 1 : 0.45 - deleteSelectedButton.isHidden = !viewModel.isSelectionMode + deleteSelectedButton.alpha = hasSelection ? 1 : 0.45 + deleteSelectedButton.accessibilityValue = "已选择 \(selectedCount) 张照片" + selectionActionStack.isHidden = !viewModel.isSelectionMode uploadButton.isHidden = viewModel.isSelectionMode var snapshot = NSDiffableDataSourceSnapshot() @@ -396,6 +418,31 @@ final class TravelAlbumDetailViewController: BaseViewController { viewModel.toggleSelectionMode() } + @objc private func aiRetouchTapped() { + let materialIds = Array(viewModel.selectedMaterialIds) + guard !materialIds.isEmpty else { return } + let scenicId = scenicIdProvider() + guard scenicId > 0 else { + showToast("请先选择景区") + return + } + + let controller = TravelAlbumAIRetouchTemplateViewController( + viewModel: TravelAlbumAIRetouchTemplateViewModel( + albumId: viewModel.albumId, + scenicId: scenicId, + materialIds: materialIds + ), + api: api, + onSubmitted: { [weak self] in + guard let self else { return } + self.viewModel.completeAIRetouchSubmission() + self.showToast("AI修图任务已提交") + } + ) + present(controller, animated: true) + } + @objc private func deleteSelectedTapped() { let count = viewModel.selectedMaterialIds.count guard count > 0 else { return } @@ -429,12 +476,16 @@ final class TravelAlbumDetailViewController: BaseViewController { totalCount: viewModel.currentPhotoCount, startProjectIndex: startIndex, configuration: previewConfiguration, + actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI), loadMore: { await previewViewModel.loadMaterials(reset: false, api: previewAPI) return ( previewViewModel.materials.map(TravelAlbumPreviewProject.init(material:)), previewViewModel.currentPhotoCount ) + }, + onProjectDeleted: { materialId in + previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId) } ) present(controller, animated: true) @@ -465,6 +516,17 @@ private enum TravelAlbumDetailStyle { static let textSecondary = UIColor(hex: 0x7F8A9E) static let border = UIColor(hex: 0xDCE4EF) static let danger = UIColor(hex: 0xE53935) + + static func badgeColor(for kind: TravelAlbumMaterialBadgeKind) -> UIColor { + switch kind { + case .purchased: UIColor(hex: 0x475569) + case .pending: UIColor(hex: 0xB45309) + case .processing: UIColor(hex: 0x1D4ED8) + case .retouched: UIColor(hex: 0x047857) + case .cover: UIColor(hex: 0x6D28D9) + case .failed: UIColor(hex: 0xB91C1C) + } + } } /// 旅拍相册摘要卡,展示封面、名称、用户手机号与创建时间。 @@ -583,11 +645,13 @@ private final class TravelAlbumInfoCard: UIView { } /// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。 -private final class TravelAlbumMaterialCell: UICollectionViewCell { +final class TravelAlbumMaterialCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumMaterialCell" private let imageView = UIImageView() private let checkImageView = UIImageView() + private let badgeView = UIView() + private let badgeLabel = UILabel() private let nameLabel = UILabel() private let sizeLabel = UILabel() @@ -600,6 +664,19 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell { checkImageView.tintColor = .white checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35) checkImageView.layer.cornerRadius = 11 + checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck" + badgeView.layer.cornerRadius = 5 + badgeView.clipsToBounds = true + badgeView.isHidden = true + badgeView.isAccessibilityElement = false + badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge" + badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold) + badgeLabel.textColor = .white + badgeLabel.textAlignment = .center + badgeLabel.isAccessibilityElement = false + badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel" + badgeLabel.setContentHuggingPriority(.required, for: .horizontal) + badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) nameLabel.font = .systemFont(ofSize: 12, weight: .medium) nameLabel.textColor = TravelAlbumDetailStyle.textPrimary nameLabel.lineBreakMode = .byTruncatingMiddle @@ -607,6 +684,8 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell { sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary contentView.addSubview(imageView) + imageView.addSubview(badgeView) + badgeView.addSubview(badgeLabel) imageView.addSubview(checkImageView) contentView.addSubview(nameLabel) contentView.addSubview(sizeLabel) @@ -618,6 +697,15 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell { make.top.trailing.equalToSuperview().inset(6) make.size.equalTo(22) } + badgeView.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(6) + make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4) + } + badgeLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset( + UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6) + ) + } nameLabel.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(6) make.leading.trailing.equalToSuperview() @@ -646,9 +734,14 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell { checkImageView.isHidden = !selectionMode checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white + let badge = material.badgePresentation + badgeView.isHidden = badge == nil + badgeLabel.text = badge?.text + badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) } nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize) - accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")" + let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? "" + accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)" accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil } } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift index 563a741..8b571c3 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift @@ -17,15 +17,16 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { private let configuration: TravelAlbumPreviewConfiguration private let actionHandler: any TravelAlbumPreviewActionHandling private let loadMore: TravelAlbumPreviewLoadMore? + private let onProjectDeleted: ((Int) -> Void)? private var nodes: [TravelAlbumPreviewNode] = [] private var currentNodeIndex = 0 private var dragStartIndex = 0 private var selectedKind: TravelAlbumPreviewAssetKind = .original private var chromeVisible = true private var isLoadingMore = false + private var isDeletingProject = false private var didApplyInitialPosition = false private var lastCollectionSize: CGSize = .zero - private var previewPrefetcher: ImagePrefetcher? private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) private let topChrome = UIView() @@ -37,6 +38,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { private let divider = UIView() private let tabStack = UIStackView() private let actionStack = UIStackView() + private let deleteButton = UIButton(type: .system) private var tabHeightConstraint: Constraint? /// 创建全屏预览页。 @@ -46,13 +48,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { startProjectIndex: Int, configuration: TravelAlbumPreviewConfiguration = .init(), actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(), - loadMore: TravelAlbumPreviewLoadMore? = nil + loadMore: TravelAlbumPreviewLoadMore? = nil, + onProjectDeleted: ((Int) -> Void)? = nil ) { self.projects = Self.deduplicated(projects) self.totalCount = max(totalCount, projects.count) self.configuration = configuration self.actionHandler = actionHandler self.loadMore = loadMore + self.onProjectDeleted = onProjectDeleted super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original) @@ -70,7 +74,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { super.viewDidLoad() setupUI() updateForCurrentNode() - prefetchAdjacentImages() } override func viewDidLayoutSubviews() { @@ -81,13 +84,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { collectionView.collectionViewLayout.invalidateLayout() if didApplyInitialPosition { setPage(currentNodeIndex, animated: false) - prefetchAdjacentImages() } } guard !didApplyInitialPosition, collectionView.bounds.width > 0 else { return } didApplyInitialPosition = true setPage(currentNodeIndex, animated: false) - upgradeCurrentCellToDetailImage() } private func setupUI() { @@ -193,7 +194,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { private func configureActions() { let aiButton = makeActionButton(title: "AI修复", systemName: "wand.and.stars", color: UIColor(hex: 0x60A5FA)) - let deleteButton = makeActionButton(title: "删除", systemName: "trash", color: UIColor(hex: 0xF87171)) + let deleteConfiguration = makeActionButton( + title: "删除", + systemName: "trash", + color: UIColor(hex: 0xF87171) + ).configuration + deleteButton.configuration = deleteConfiguration + deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) + deleteButton.accessibilityLabel = "删除" + deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton" let refreshButton = makeActionButton(title: "刷新", systemName: "arrow.clockwise", color: .white) aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) @@ -305,8 +314,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { let oldProjectIndex = currentNode?.projectIndex guard oldNodeIndex != index else { updateForCurrentNode() - upgradeCurrentCellToDetailImage() - prefetchAdjacentImages() return } currentNodeIndex = index @@ -316,8 +323,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { } collectionView.visibleCells.compactMap { $0 as? TravelAlbumPreviewImageCell }.forEach { $0.resetZoom() } updateForCurrentNode() - upgradeCurrentCellToDetailImage() - prefetchAdjacentImages() } private func resetProjectCellToOriginalIfNeeded(at nodeIndex: Int) { @@ -326,38 +331,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { let cell = collectionView.cellForItem(at: IndexPath(item: nodeIndex, section: 0)) as? TravelAlbumPreviewImageCell else { return } let project = projects[nodes[nodeIndex].projectIndex] - cell.apply(asset: project.asset(for: .original), quality: .preview) - } - - private func upgradeCurrentCellToDetailImage() { - guard let cell = collectionView.cellForItem( - at: IndexPath(item: currentNodeIndex, section: 0) - ) as? TravelAlbumPreviewImageCell else { return } - cell.apply(asset: currentAsset, quality: .detail) - } - - private func prefetchAdjacentImages() { - previewPrefetcher?.stop() - let indexes = [currentNodeIndex - 2, currentNodeIndex - 1, currentNodeIndex + 1, currentNodeIndex + 2] - let urls = indexes.compactMap { index -> URL? in - guard nodes.indices.contains(index) else { return nil } - let node = nodes[index] - let project = projects[node.projectIndex] - let asset = project.asset(for: node.kind) ?? project.asset(for: .original) - guard let text = asset?.previewURL, !text.isEmpty else { return nil } - return URL(string: text) - } - guard !urls.isEmpty else { - previewPrefetcher = nil - return - } - let prefetcher = ImagePrefetcher( - urls: urls, - options: TravelAlbumPreviewImageRequest.previewOptions(viewSize: collectionView.bounds.size) - ) - prefetcher.maxConcurrentDownloads = 2 - previewPrefetcher = prefetcher - prefetcher.start() + cell.apply(asset: project.asset(for: .original)) } private func setPage(_ index: Int, animated: Bool) { @@ -400,8 +374,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { self.setPage(self.currentNodeIndex, animated: false) self.isLoadingMore = false self.updateForCurrentNode() - self.upgradeCurrentCellToDetailImage() - self.prefetchAdjacentImages() } } @@ -477,7 +449,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { } @objc private func deleteTapped() { - guard let id = currentProject?.originalMaterialId else { return } + guard currentProject != nil, !isDeletingProject else { return } let alert = UIAlertController( title: "删除整个项目", message: "将同时删除原图及其全部关联图片,是否继续?", @@ -485,14 +457,87 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { ) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in - guard let self else { return } - self.performAction { [actionHandler = self.actionHandler] in - await actionHandler.deleteProject(originalMaterialId: id) - } + self?.deleteCurrentProjectAfterConfirmation() }) present(alert, animated: true) } + /// 用户确认后删除当前项目;接口成功后才更新预览数据和位置。 + func deleteCurrentProjectAfterConfirmation() { + guard !isDeletingProject, + let project = currentProject, + let deletedProjectIndex = projects.firstIndex(where: { $0.id == project.id }) + else { return } + isDeletingProject = true + updateDeleteButton() + + Task { [weak self, actionHandler] in + let result = await actionHandler.deleteProject(originalMaterialId: project.originalMaterialId) + guard let self else { return } + switch result { + case .success(let message): + self.applySuccessfulDeletion( + project: project, + deletedProjectIndex: deletedProjectIndex, + message: message + ) + case .failure(let message), .unavailable(let message): + self.isDeletingProject = false + self.updateDeleteButton() + self.showPreviewToast(message) + } + } + } + + private func applySuccessfulDeletion( + project: TravelAlbumPreviewProject, + deletedProjectIndex: Int, + message: String? + ) { + guard projects.indices.contains(deletedProjectIndex), + projects[deletedProjectIndex].id == project.id + else { + isDeletingProject = false + updateDeleteButton() + return + } + + projects.remove(at: deletedProjectIndex) + totalCount = max(0, totalCount - 1) + onProjectDeleted?(project.originalMaterialId) + + guard let targetProjectIndex = TravelAlbumPreviewNavigator.projectIndexAfterDeletion( + deletedProjectIndex: deletedProjectIndex, + remainingProjectCount: projects.count + ) else { + dismiss(animated: true) + return + } + + selectedKind = .original + rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: .original) + collectionView.reloadData() + collectionView.layoutIfNeeded() + setPage(currentNodeIndex, animated: false) + isDeletingProject = false + updateDeleteButton() + updateForCurrentNode() + if let message, !message.isEmpty { + showPreviewToast(message) + } + } + + private func updateDeleteButton() { + deleteButton.isEnabled = !isDeletingProject + deleteButton.alpha = isDeletingProject ? 0.55 : 1 + deleteButton.accessibilityValue = isDeletingProject ? "删除中" : nil + var configuration = deleteButton.configuration + configuration?.showsActivityIndicator = isDeletingProject + configuration?.image = isDeletingProject ? nil : UIImage(systemName: "trash") + configuration?.title = isDeletingProject ? "删除中" : "删除" + deleteButton.configuration = configuration + } + @objc private func refreshTapped() { guard let id = currentProject?.originalMaterialId else { return } performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) } @@ -517,8 +562,7 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC let kind = configuration.swipeMode == .projectsOnly && indexPath.item == currentNodeIndex ? selectedKind : node.kind - let quality: TravelAlbumPreviewImageQuality = indexPath.item == currentNodeIndex ? .detail : .preview - cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original), quality: quality) + cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original)) cell.onSingleTap = { [weak self] in self?.toggleChrome() } cell.onZoomChanged = { [weak self] zoomed in self?.collectionView.isScrollEnabled = !zoomed @@ -565,38 +609,6 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC } } -/// 预览 Cell 的图片清晰度层级;滑动时使用轻量预览,停稳后升级为高清图。 -private enum TravelAlbumPreviewImageQuality: String { - case preview - case detail -} - -/// 统一生成预览页图片处理参数,确保展示请求与预取请求共用缓存键。 -private enum TravelAlbumPreviewImageRequest { - static func previewOptions(viewSize: CGSize) -> KingfisherOptionsInfo { - options(viewSize: viewSize, sizeMultiplier: 1) - } - - static func detailOptions(viewSize: CGSize) -> KingfisherOptionsInfo { - options(viewSize: viewSize, sizeMultiplier: 2) - } - - private static func options(viewSize: CGSize, sizeMultiplier: CGFloat) -> KingfisherOptionsInfo { - let fallbackSize = UIScreen.main.bounds.size - let baseSize = viewSize.width > 0 && viewSize.height > 0 ? viewSize : fallbackSize - let targetSize = CGSize( - width: baseSize.width * sizeMultiplier, - height: baseSize.height * sizeMultiplier - ) - return [ - .processor(DownsamplingImageProcessor(size: targetSize)), - .scaleFactor(UIScreen.main.scale), - .backgroundDecode, - .keepCurrentImageWhileLoading, - ] - } -} - /// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。 private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate { static let reuseIdentifier = "TravelAlbumPreviewImageCell" @@ -607,7 +619,6 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV private let imageView = UIImageView() private let retryButton = UIButton(type: .system) private var asset: TravelAlbumPreviewAsset? - private var requestKey: String? override init(frame: CGRect) { super.init(frame: frame) @@ -624,7 +635,6 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV imageView.kf.cancelDownloadTask() imageView.image = nil asset = nil - requestKey = nil retryButton.isHidden = true resetZoom() } @@ -634,15 +644,10 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV imageView.frame = scrollView.bounds } - func apply(asset newAsset: TravelAlbumPreviewAsset?, quality: TravelAlbumPreviewImageQuality) { - let urlText = quality == .preview ? newAsset?.previewURL : newAsset?.displayURL - let newRequestKey = "\(quality.rawValue):\(urlText ?? "")" - let isSameRequest = requestKey == newRequestKey && imageView.image != nil + func apply(asset newAsset: TravelAlbumPreviewAsset?) { asset = newAsset resetZoom() - if !isSameRequest { - loadImage(quality: quality, requestKey: newRequestKey) - } + loadImage() accessibilityLabel = newAsset.map { "\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)" } @@ -704,21 +709,16 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV scrollView.addGestureRecognizer(doubleTap) } - private func loadImage(quality: TravelAlbumPreviewImageQuality, requestKey: String) { + private func loadImage() { retryButton.isHidden = true - let text = quality == .preview ? asset?.previewURL : asset?.displayURL + let text = asset?.displayURL guard let text, let url = URL(string: text), !text.isEmpty else { imageView.image = nil - self.requestKey = nil retryButton.isHidden = false return } - self.requestKey = requestKey - let options = quality == .preview - ? TravelAlbumPreviewImageRequest.previewOptions(viewSize: contentView.bounds.size) - : TravelAlbumPreviewImageRequest.detailOptions(viewSize: contentView.bounds.size) - imageView.kf.setImage(with: url, options: options) { [weak self] result in - guard let self, self.requestKey == requestKey else { return } + imageView.kf.setImage(with: url) { [weak self] result in + guard let self, self.asset?.displayURL == text else { return } if case .failure = result, self.imageView.image == nil { self.retryButton.isHidden = false } @@ -747,9 +747,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV } @objc private func retryTapped() { - guard let requestKey else { return } - let quality: TravelAlbumPreviewImageQuality = requestKey.hasPrefix("preview:") ? .preview : .detail - loadImage(quality: quality, requestKey: requestKey) + loadImage() } } diff --git a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift new file mode 100644 index 0000000..03070ce --- /dev/null +++ b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift @@ -0,0 +1,191 @@ +// +// TravelAlbumAIRetouchTemplateViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// AI 修图模板选择状态测试。 +@MainActor +final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { + func testLoadDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async { + let api = makeAPI() + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 8, + scenicId: 18, + materialIds: [4, 3, 2, 1] + ) + + await viewModel.loadTemplates(api: api) + + XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18]) + XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11) + XCTAssertNil(viewModel.selectedAtmosphereTemplateId) + XCTAssertEqual(viewModel.selectedCoverTemplateId, 31) + XCTAssertTrue(viewModel.showsCoverTemplates) + XCTAssertTrue(viewModel.canSubmit) + } + + func testAtmosphereSelectionTogglesOffWhenTappedAgain() async { + let api = makeAPI() + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 8, + scenicId: 18, + materialIds: [1] + ) + await viewModel.loadTemplates(api: api) + + viewModel.toggleTemplate(id: 21, category: .atmosphere) + XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21) + + viewModel.toggleTemplate(id: 21, category: .atmosphere) + XCTAssertNil(viewModel.selectedAtmosphereTemplateId) + } + + func testThreeMaterialsHideCoverAndOmitCoverFromSubmission() async { + let api = makeAPI() + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 8, + scenicId: 18, + materialIds: [3, 1, 2] + ) + var submitted = false + viewModel.onSubmitted = { submitted = true } + await viewModel.loadTemplates(api: api) + + XCTAssertFalse(viewModel.showsCoverTemplates) + XCTAssertNil(viewModel.selectedCoverTemplateId) + + await viewModel.submit(api: api) + + XCTAssertTrue(submitted) + XCTAssertEqual(api.aiRetouchRequests.count, 1) + XCTAssertEqual(api.aiRetouchRequests.first?.materialIds, [1, 2, 3]) + XCTAssertNil(api.aiRetouchRequests.first?.aiPreviewTabs) + } + + func testFourMaterialsSubmitAllSelectedTemplateIds() async { + let api = makeAPI() + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 8, + scenicId: 18, + materialIds: [1, 2, 3, 4] + ) + await viewModel.loadTemplates(api: api) + viewModel.toggleTemplate(id: 12, category: .refined) + viewModel.toggleTemplate(id: 21, category: .atmosphere) + + await viewModel.submit(api: api) + + XCTAssertEqual( + api.aiRetouchRequests.first, + TravelAlbumAIRetouchRequest( + userEquityTravelId: 8, + materialIds: [1, 2, 3, 4], + refinedTemplateId: 12, + atmosphereTemplateId: 21, + aiPreviewTabs: 31 + ) + ) + } + + func testMissingRequiredCoverTemplateDisablesSubmission() async { + let api = makeAPI() + api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( + refinedTemplates: [template(11, "清透")], + atmosphereTemplates: [template(21, "暖阳")], + coverTemplates: [] + ) + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 8, + scenicId: 18, + materialIds: [1, 2, 3, 4] + ) + + await viewModel.loadTemplates(api: api) + + XCTAssertFalse(viewModel.canSubmit) + XCTAssertEqual(viewModel.validationMessage, "暂无可用的封面风格模板") + } + + func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async { + let api = makeAPI() + api.aiRetouchTemplatesError = APIError.serverCode(500, "模板服务繁忙") + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 8, + scenicId: 18, + materialIds: [1] + ) + + await viewModel.loadTemplates(api: api) + + XCTAssertEqual(viewModel.loadErrorMessage, "模板服务繁忙") + XCTAssertFalse(viewModel.canSubmit) + XCTAssertTrue(viewModel.refinedTemplates.isEmpty) + } + + func testSubmitFailureKeepsSelectionAndAllowsRetry() async { + let api = makeAPI() + api.submitAIRetouchError = APIError.serverCode(500, "提交服务繁忙") + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 8, + scenicId: 18, + materialIds: [1] + ) + var message: String? + var submitted = false + viewModel.onShowMessage = { message = $0 } + viewModel.onSubmitted = { submitted = true } + await viewModel.loadTemplates(api: api) + + await viewModel.submit(api: api) + + XCTAssertEqual(api.aiRetouchRequests.count, 1) + XCTAssertEqual(message, "提交服务繁忙") + XCTAssertFalse(submitted) + XCTAssertFalse(viewModel.isSubmitting) + XCTAssertTrue(viewModel.canSubmit) + XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11) + } + + func testSubmittingPreventsDuplicateRequest() async { + let api = makeAPI() + api.submitAIRetouchDelayNanoseconds = 50_000_000 + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 8, + scenicId: 18, + materialIds: [1] + ) + await viewModel.loadTemplates(api: api) + + let firstSubmission = Task { await viewModel.submit(api: api) } + await waitUntil { viewModel.isSubmitting } + await viewModel.submit(api: api) + await firstSubmission.value + + XCTAssertEqual(api.aiRetouchRequests.count, 1) + XCTAssertFalse(viewModel.isSubmitting) + } + + private func makeAPI() -> TravelAlbumMockAPI { + let api = TravelAlbumMockAPI() + api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( + refinedTemplates: [template(11, "清透"), template(12, "自然")], + atmosphereTemplates: [template(21, "暖阳")], + coverTemplates: [template(31, "杂志")] + ) + return api + } + + private func template(_ id: Int, _ name: String) -> TravelAlbumAIRetouchTemplate { + TravelAlbumAIRetouchTemplate(id: id, name: name, previewURL: "https://cdn.example.com/\(id).jpg") + } + + private func waitUntil(_ condition: @escaping () -> Bool) async { + for _ in 0 ..< 100 { + if condition() { return } + await Task.yield() + } + } +} diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index 8398fc3..f25a531 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -51,16 +51,27 @@ final class TravelAlbumAPITests: XCTestCase { } func testMaterialListAndDeleteAndMpCode() async throws { - let materialList = envelopeJSON(#"{"total":0,"list":[]}"#) + let materialList = envelopeJSON( + #"{"total":1,"list":[{"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_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}]}"# + ) let empty = envelopeJSON("{}") let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#) let session = MockURLSession(responses: [materialList, empty, code]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) - _ = try await api.materialList(userEquityTravelId: 3, page: 1, pageSize: 30, orderBy: 4, isPurchased: 1) + let materials = try await api.materialList( + userEquityTravelId: 3, + page: 1, + pageSize: 30, + orderBy: 4, + isPurchased: 1 + ) try await api.deleteAlbum(id: 3) let response = try await api.mpCode(id: 3) + XCTAssertEqual(materials.list.first?.aiRetouchStatus, 3) + XCTAssertEqual(materials.list.first?.aiRefinedURL, "https://cdn/refined.jpg") + XCTAssertEqual(materials.list.first?.aiAtmosphereURL, "https://cdn/atmosphere.jpg") XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png") XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list") let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems @@ -113,6 +124,69 @@ final class TravelAlbumAPITests: XCTestCase { XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3") } + 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"}]}"# + ) + let session = MockURLSession(responses: [data]) + let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.aiRetouchTemplates(scenicId: 18) + + XCTAssertEqual(response.refinedTemplates.first?.name, "清透") + XCTAssertEqual(response.atmosphereTemplates.first?.id, 2) + XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg") + 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") + let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "18") + } + + func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws { + let session = MockURLSession(responses: [envelopeJSON(#"{"task_id":9}"#)]) + let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) + + try await api.submitAIRetouch( + TravelAlbumAIRetouchRequest( + userEquityTravelId: 6, + materialIds: [11, 12, 13, 14], + refinedTemplateId: 21, + atmosphereTemplateId: 22, + aiPreviewTabs: 31 + ) + ) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch") + let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any] + XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 6) + XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14]) + XCTAssertEqual(body?["refined_template_id"] as? Int, 21) + XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22) + XCTAssertEqual(body?["ai_preview_tabs"] as? Int, 31) + } + + func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws { + let session = MockURLSession(responses: [envelopeJSON(#"{"accepted":true}"#)]) + let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) + + try await api.submitAIRetouch( + TravelAlbumAIRetouchRequest( + userEquityTravelId: 6, + materialIds: [11], + refinedTemplateId: 21, + atmosphereTemplateId: nil, + aiPreviewTabs: nil + ) + ) + + let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any] + XCTAssertNil(body?["atmosphere_template_id"]) + XCTAssertNil(body?["ai_preview_tabs"]) + } + private func envelopeJSON(_ dataJSON: String) -> Data { """ {"code":100000,"msg":"success","data":\(dataJSON)} diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift new file mode 100644 index 0000000..238530d --- /dev/null +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -0,0 +1,374 @@ +// +// TravelAlbumDetailViewControllerTests.swift +// suixinkanTests +// + +import UIKit +import XCTest +@testable import suixinkan + +/// 相册管理页选择态底部操作区测试。 +@MainActor +final class TravelAlbumDetailViewControllerTests: XCTestCase { + func testPreviewDeleteHandlerCallsMaterialAPIAndPreservesServerFailure() async { + let api = TravelAlbumMockAPI() + let handler = TravelAlbumPreviewActionHandler(api: api) + + let success = await handler.deleteProject(originalMaterialId: 8) + + XCTAssertEqual(api.deletedMaterialIds, [8]) + XCTAssertEqual(success, .success("删除成功")) + + api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用") + let failure = await handler.deleteProject(originalMaterialId: 9) + + XCTAssertEqual(api.deletedMaterialIds, [8, 9]) + XCTAssertEqual(failure, .failure("服务暂不可用")) + } + + func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws { + let api = TravelAlbumMockAPI() + api.deleteMaterialDelayNanoseconds = 1_000_000 + var deletedIds: [Int] = [] + let controller = TravelAlbumPhotoPreviewViewController( + projects: [ + makePreviewProject(id: 1, fileName: "第一张.jpg"), + makePreviewProject(id: 2, fileName: "第二张.jpg"), + makePreviewProject(id: 3, fileName: "第三张.jpg"), + ], + totalCount: 3, + startProjectIndex: 0, + actionHandler: TravelAlbumPreviewActionHandler(api: api), + onProjectDeleted: { deletedIds.append($0) } + ) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.view.layoutIfNeeded() + + controller.deleteCurrentProjectAfterConfirmation() + controller.deleteCurrentProjectAfterConfirmation() + await waitUntil { + controller.view.allAccessibilityLabels().contains("第 1 张,共 2 张") + } + + XCTAssertEqual(api.deletedMaterialIds, [1]) + XCTAssertEqual(deletedIds, [1]) + XCTAssertTrue( + controller.view.allLabels().contains { $0.text == "第二张.jpg" } + ) + } + + func testPreviewDeletingLastProjectShowsPreviousProject() async { + let api = TravelAlbumMockAPI() + let controller = TravelAlbumPhotoPreviewViewController( + projects: [ + makePreviewProject(id: 1, fileName: "第一张.jpg"), + makePreviewProject(id: 2, fileName: "第二张.jpg"), + makePreviewProject(id: 3, fileName: "第三张.jpg"), + ], + totalCount: 3, + startProjectIndex: 2, + actionHandler: TravelAlbumPreviewActionHandler(api: api) + ) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.view.layoutIfNeeded() + + controller.deleteCurrentProjectAfterConfirmation() + await waitUntil { + controller.view.allAccessibilityLabels().contains("第 2 张,共 2 张") + } + + XCTAssertEqual(api.deletedMaterialIds, [3]) + XCTAssertTrue( + controller.view.allLabels().contains { $0.text == "第二张.jpg" } + ) + } + + func testPreviewDeletingOnlyProjectDismissesPage() async { + UIView.setAnimationsEnabled(false) + defer { UIView.setAnimationsEnabled(true) } + let api = TravelAlbumMockAPI() + let host = UIViewController() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = host + window.makeKeyAndVisible() + let controller = TravelAlbumPhotoPreviewViewController( + projects: [makePreviewProject(id: 1, fileName: "仅有一张.jpg")], + totalCount: 1, + startProjectIndex: 0, + actionHandler: TravelAlbumPreviewActionHandler(api: api) + ) + host.present(controller, animated: false) + await waitUntil { controller.presentingViewController === host } + + controller.deleteCurrentProjectAfterConfirmation() + await waitUntil { host.presentedViewController == nil } + + XCTAssertEqual(api.deletedMaterialIds, [1]) + XCTAssertNil(host.presentedViewController) + window.isHidden = true + } + + func testSelectionModeShowsAIRetouchOnLeftAndDeleteOnRight() async throws { + let controller = TravelAlbumDetailViewController(albumId: 0) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + + let selectButton = try XCTUnwrap( + controller.view.findSubview { + ($0 as? UIButton)?.accessibilityLabel == "选择照片" + } as? UIButton + ) + selectButton.sendActions(for: .touchUpInside) + await Task.yield() + controller.view.layoutIfNeeded() + + let actionStack = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.selectionActionStack" + } as? UIStackView + ) + let aiRetouchButton = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchButton" + } as? UIButton + ) + let deleteButton = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.deleteButton" + } as? UIButton + ) + + XCTAssertFalse(actionStack.isHidden) + XCTAssertEqual(actionStack.arrangedSubviews, [aiRetouchButton, deleteButton]) + XCTAssertEqual(actionStack.distribution, .fillEqually) + XCTAssertEqual(actionStack.spacing, 12) + XCTAssertEqual(aiRetouchButton.configuration?.title, "AI修图") + XCTAssertEqual(deleteButton.configuration?.title, "删除") + XCTAssertEqual(aiRetouchButton.bounds.width, deleteButton.bounds.width, accuracy: 0.5) + XCTAssertLessThan(aiRetouchButton.frame.minX, deleteButton.frame.minX) + } + + func testAIRetouchTemplateSheetUsesLargeDetentAndFixedBottomActions() async throws { + let api = TravelAlbumMockAPI() + api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( + refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")], + atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")], + coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")] + ) + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 9, + scenicId: 18, + materialIds: [1, 2, 3, 4] + ) + let controller = TravelAlbumAIRetouchTemplateViewController( + viewModel: viewModel, + api: api, + onSubmitted: {} + ) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 } + controller.view.layoutIfNeeded() + + let sheet = try XCTUnwrap(controller.sheetPresentationController) + XCTAssertEqual(sheet.detents.count, 1) + XCTAssertEqual(sheet.selectedDetentIdentifier, .large) + XCTAssertTrue(sheet.prefersGrabberVisible) + + let collectionView = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection" + } as? UICollectionView + ) + let bottomBar = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchBottomBar" + } + ) + let cancelButton = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchCancelButton" + } as? UIButton + ) + let confirmButton = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton" + } as? UIButton + ) + await waitUntil { confirmButton.isEnabled } + controller.view.layoutIfNeeded() + + XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout) + XCTAssertFalse(bottomBar.isDescendant(of: collectionView)) + XCTAssertEqual(cancelButton.configuration?.title, "取消") + XCTAssertEqual(confirmButton.configuration?.title, "确定") + XCTAssertEqual(cancelButton.bounds.width, confirmButton.bounds.width, accuracy: 0.5) + XCTAssertTrue(confirmButton.isEnabled) + } + + func testAIRetouchSheetShowsOptionalAtmosphereCoverAndModeCopy() async throws { + let api = TravelAlbumMockAPI() + api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( + refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")], + atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")], + coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")] + ) + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + albumId: 9, + scenicId: 18, + materialIds: [1, 2, 3, 4] + ) + let controller = TravelAlbumAIRetouchTemplateViewController( + viewModel: viewModel, + api: api, + onSubmitted: {} + ) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 } + controller.view.layoutIfNeeded() + + let accessibleLabels = controller.view.allAccessibilityLabels() + XCTAssertTrue(accessibleLabels.contains("原图精修")) + XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) + XCTAssertTrue(accessibleLabels.contains("封面风格模板")) + + let modeCell = TravelAlbumAIRetouchModeCell(frame: .zero) + modeCell.apply() + XCTAssertEqual(modeCell.accessibilityLabel, "AI精修,按张收费,剩余张数暂不可用") + } + + func testAIRetouchTemplateCellExposesSelectedState() { + let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero) + let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "") + + cell.apply(template: template, selected: true) + + XCTAssertEqual(cell.contentView.layer.borderWidth, 2) + XCTAssertTrue(cell.accessibilityTraits.contains(.selected)) + XCTAssertEqual(cell.accessibilityValue, "已选择") + + cell.apply(template: template, selected: false) + + XCTAssertEqual(cell.contentView.layer.borderWidth, 1) + XCTAssertFalse(cell.accessibilityTraits.contains(.selected)) + XCTAssertEqual(cell.accessibilityValue, "未选择") + } + + func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws { + let cases: [(TravelAlbumMaterial, String, UInt)] = [ + (TravelAlbumMaterial(isPurchased: true), "已购", 0x475569), + (TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309), + (TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8), + (TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857), + ( + TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面"), + "AI封面", + 0x6D28D9 + ), + (TravelAlbumMaterial(aiRetouchStatus: 4), "失败", 0xB91C1C), + ] + + for (material, expectedText, expectedColor) in cases { + let cell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220)) + cell.apply(material: material, selectionMode: true, selected: false) + cell.layoutIfNeeded() + + let badgeView = try XCTUnwrap( + cell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.materialStatusBadge" + } + ) + let badgeLabel = try XCTUnwrap( + cell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel" + } as? UILabel + ) + let selectionCheck = try XCTUnwrap( + cell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.materialSelectionCheck" + } + ) + + XCTAssertFalse(badgeView.isHidden) + XCTAssertEqual(badgeLabel.text, expectedText) + XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor) + XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX) + XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true) + } + + let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220)) + hiddenCell.apply( + material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"), + selectionMode: false, + selected: false + ) + let hiddenBadge = try XCTUnwrap( + hiddenCell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.materialStatusBadge" + } + ) + XCTAssertTrue(hiddenBadge.isHidden) + XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true) + } + + private func waitUntil(_ condition: @escaping () -> Bool) async { + for _ in 0 ..< 200 { + if condition() { return } + try? await Task.sleep(nanoseconds: 1_000_000) + } + } + + private func makePreviewProject(id: Int, fileName: String) -> TravelAlbumPreviewProject { + TravelAlbumPreviewProject( + originalMaterialId: id, + assets: [ + TravelAlbumPreviewAsset( + id: "original-\(id)", + kind: .original, + fileURL: "", + coverURL: "", + fileName: fileName, + fileSize: 0 + ), + ] + ) + } +} + +private extension UIColor { + var travelAlbumTestHexRGB: UInt { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return UInt(round(red * 255)) << 16 + | UInt(round(green * 255)) << 8 + | UInt(round(blue * 255)) + } +} + +private extension UIView { + func findSubview(where predicate: (UIView) -> Bool) -> UIView? { + if predicate(self) { return self } + for subview in subviews { + if let match = subview.findSubview(where: predicate) { + return match + } + } + return nil + } + + func allAccessibilityLabels() -> [String] { + let current = accessibilityLabel.map { [$0] } ?? [] + return current + subviews.flatMap { $0.allAccessibilityLabels() } + } + + func allLabels() -> [UILabel] { + let current = (self as? UILabel).map { [$0] } ?? [] + return current + subviews.flatMap { $0.allLabels() } + } +} diff --git a/suixinkanTests/TravelAlbumModelsTests.swift b/suixinkanTests/TravelAlbumModelsTests.swift index f25a95a..91c843d 100644 --- a/suixinkanTests/TravelAlbumModelsTests.swift +++ b/suixinkanTests/TravelAlbumModelsTests.swift @@ -26,6 +26,46 @@ final class TravelAlbumModelsTests: XCTestCase { XCTAssertFalse(project.hasVariants) } + func testPreviewProjectMapsNonemptyAIResultURLsInCanonicalOrder() { + let material = TravelAlbumMaterial( + id: 8, + fileName: "IMG_0008.JPG", + fileUrl: "https://cdn.example.com/original.jpg", + fileSize: 4096, + coverUrl: "https://cdn.example.com/cover.jpg", + aiRefinedURL: " https://cdn.example.com/refined.jpg ", + aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg" + ) + + let project = TravelAlbumPreviewProject(material: material) + + XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched, .atmosphere]) + XCTAssertEqual( + project.asset(for: .retouched)?.displayURL, + "https://cdn.example.com/refined.jpg" + ) + XCTAssertEqual( + project.asset(for: .atmosphere)?.previewURL, + "https://cdn.example.com/atmosphere.jpg" + ) + XCTAssertNil(project.asset(for: .cover)) + XCTAssertTrue(project.hasVariants) + } + + func testPreviewProjectIgnoresBlankAIResultURLs() { + let material = TravelAlbumMaterial( + id: 8, + fileUrl: "https://cdn.example.com/original.jpg", + aiRefinedURL: " \n ", + aiAtmosphereURL: "\t" + ) + + XCTAssertEqual( + TravelAlbumPreviewProject(material: material).orderedAssets.map(\.kind), + [.original] + ) + } + func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() { let missingOriginal = TravelAlbumPreviewAsset( id: "cover-only", @@ -91,6 +131,36 @@ final class TravelAlbumModelsTests: XCTestCase { XCTAssertEqual(nodes[target], TravelAlbumPreviewNode(projectIndex: 0, kind: .original)) } + func testPreviewDeletionPrefersNextThenFallsBackToPreviousAndClosesWhenEmpty() { + XCTAssertEqual( + TravelAlbumPreviewNavigator.projectIndexAfterDeletion( + deletedProjectIndex: 0, + remainingProjectCount: 2 + ), + 0 + ) + XCTAssertEqual( + TravelAlbumPreviewNavigator.projectIndexAfterDeletion( + deletedProjectIndex: 1, + remainingProjectCount: 2 + ), + 1 + ) + XCTAssertEqual( + TravelAlbumPreviewNavigator.projectIndexAfterDeletion( + deletedProjectIndex: 2, + remainingProjectCount: 2 + ), + 1 + ) + XCTAssertNil( + TravelAlbumPreviewNavigator.projectIndexAfterDeletion( + deletedProjectIndex: 0, + remainingProjectCount: 0 + ) + ) + } + func testPlaceholderPreviewActionsReturnUnavailableWithoutMutation() async { let handler = PlaceholderTravelAlbumPreviewActionHandler() let aiResult = await handler.requestAIRetouch(originalMaterialId: 9) @@ -153,6 +223,10 @@ final class TravelAlbumModelsTests: XCTestCase { "file_size": 2048, "cover_url": "", "is_purchased": false, + "ai_retouch_status": 3, + "ai_retouch_status_name": "AI已修", + "ai_refined_url": "https://cdn/refined.jpg", + "ai_atmosphere_url": "https://cdn/atmosphere.jpg", "created_at": "", "updated_at": "" } @@ -162,6 +236,70 @@ final class TravelAlbumModelsTests: XCTestCase { XCTAssertEqual(material.userEquityTravelId, 7) XCTAssertEqual(material.fileName, "IMG_0001.JPG") XCTAssertFalse(material.isPurchased) + XCTAssertEqual(material.aiRetouchStatus, 3) + XCTAssertEqual(material.aiRetouchStatusName, "AI已修") + XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg") + XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg") + } + + func testTravelAlbumMaterialDefaultsInvalidAIFields() throws { + let json = """ + { + "id": 11, + "user_equity_travel_id": 7, + "status": 1, + "order_number": "", + "user_id": 9, + "file_name": "IMG_0001.JPG", + "file_type": 2, + "file_url": "https://cdn/a.jpg", + "file_size": 2048, + "cover_url": "", + "is_purchased": false, + "ai_retouch_status": "invalid", + "ai_retouch_status_name": null, + "ai_refined_url": 123, + "created_at": "", + "updated_at": "" + } + """.data(using: .utf8)! + + let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json) + + XCTAssertEqual(material.aiRetouchStatus, 0) + XCTAssertEqual(material.aiRetouchStatusName, "") + XCTAssertEqual(material.aiRefinedURL, "") + XCTAssertEqual(material.aiAtmosphereURL, "") + } + + func testTravelAlbumMaterialBadgePriorityAndFallbacks() { + XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation) + XCTAssertEqual( + TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传") + .badgePresentation, + TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") + ) + XCTAssertEqual( + TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation, + TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中") + ) + XCTAssertEqual( + TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation, + TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中") + ) + XCTAssertEqual( + TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation, + TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修") + ) + XCTAssertEqual( + TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation, + TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面") + ) + XCTAssertEqual( + TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation, + TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败") + ) + XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation) } func testDisplayFormatters() { diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index c31a0d7..809cf28 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -173,6 +173,39 @@ final class TravelAlbumDetailViewModelTests: XCTestCase { XCTAssertEqual(viewModel.selectedMaterialIds, [1]) } + func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() { + let viewModel = TravelAlbumDetailViewModel(albumId: 2) + viewModel.toggleSelectionMode() + viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1)) + + viewModel.completeAIRetouchSubmission() + + XCTAssertFalse(viewModel.isSelectionMode) + XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty) + } + + func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async { + let api = TravelAlbumMockAPI() + api.materialListResponses = [ + TravelAlbumListResponse( + total: 3, + list: [ + TravelAlbumMaterial(id: 1), + TravelAlbumMaterial(id: 2, isPurchased: true), + TravelAlbumMaterial(id: 3), + ] + ), + ] + let viewModel = TravelAlbumDetailViewModel(albumId: 2) + await viewModel.loadMaterials(reset: true, api: api) + + viewModel.removeMaterialAfterPreviewDeletion(id: 2) + + XCTAssertEqual(viewModel.materials.map(\.id), [1, 3]) + XCTAssertEqual(viewModel.allPhotoCount, 2) + XCTAssertEqual(viewModel.purchasedPhotoCount, 0) + } + func testDeleteAlbumCallsCallback() async { let api = TravelAlbumMockAPI() let viewModel = TravelAlbumDetailViewModel(albumId: 5) @@ -1015,7 +1048,13 @@ final class TravelAlbumMockAPI: TravelAlbumServing { var uploadMaterialResponse = TravelAlbumMaterial() var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: []) var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") + var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse() var createError: Error? + var aiRetouchTemplatesError: Error? + var submitAIRetouchError: Error? + var submitAIRetouchDelayNanoseconds: UInt64 = 0 + var deleteMaterialError: Error? + var deleteMaterialDelayNanoseconds: UInt64 = 0 private(set) var availableOrdersCallCount = 0 private(set) var createRequests: [TravelAlbumCreateRequest] = [] @@ -1024,6 +1063,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing { private(set) var materialClientPhotoIdsCallCount = 0 private(set) var deletedAlbumIds: [Int] = [] private(set) var deletedMaterialIds: [Int] = [] + private(set) var aiRetouchTemplateScenicIds: [Int] = [] + private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = [] func availableOrders() async throws -> [TravelAlbumAvailableOrder] { availableOrdersCallCount += 1 @@ -1084,9 +1125,27 @@ final class TravelAlbumMockAPI: TravelAlbumServing { func deleteMaterial(id: Int) async throws { deletedMaterialIds.append(id) + if deleteMaterialDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds) + } + if let deleteMaterialError { throw deleteMaterialError } } func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse { mpCodeResponse } + + func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse { + aiRetouchTemplateScenicIds.append(scenicId) + if let aiRetouchTemplatesError { throw aiRetouchTemplatesError } + return aiRetouchTemplatesResponse + } + + func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws { + aiRetouchRequests.append(request) + if submitAIRetouchDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds) + } + if let submitAIRetouchError { throw submitAIRetouchError } + } }