From 010add828b1767d9df8a419cc24c3912d9525005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 11 Aug 2026 17:04:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96AI=E4=BF=AE=E5=9B=BE?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E9=80=89=E6=8B=A9=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ravelAlbumAIRetouchTemplateViewModel.swift | 22 + ...AlbumAIRetouchTemplateViewController.swift | 533 +++++++++++++++--- ...AlbumAIRetouchTemplateViewModelTests.swift | 8 + ...TravelAlbumDetailViewControllerTests.swift | 107 +++- 4 files changed, 593 insertions(+), 77 deletions(-) diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift index 3faa662..312800a 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift @@ -52,6 +52,28 @@ final class TravelAlbumAIRetouchTemplateViewModel { workflow.visibleCategories } + /// 当前工作流选择的原始照片数量。 + var selectedPhotoCount: Int { + switch workflow { + case .initial(_, let materialIds): materialIds.count + case .reretouch: 1 + } + } + + /// 是否展示首次修图的氛围感与封面生成说明。 + var shouldShowInitialTips: Bool { + if case .initial = workflow { return true } + return false + } + + /// 首次修图页按实际展示分组生成的规则说明。 + var initialTipsText: String { + if visibleCategories.contains(.cover) { + return "Tips:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。" + } + return "Tips:本次显示 2 类风格。氛围感修图为选填;选中后每张照片会生成「精修后 + 氛围感」2 个独立结果。" + } + /// 当前分组是否为选填。 func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { workflow.isOptional(category) diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift index 5f91819..7bebfbc 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift @@ -11,19 +11,22 @@ import UIKit final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { /// 页面使用的 collection section。 private enum Section: Hashable { + case overview case templates(TravelAlbumAIRetouchTemplateCategory) + case mode } /// 页面使用的 diffable item。 private enum Item: Hashable { + case overview 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() @@ -33,7 +36,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { private let bottomBar = UIView() private let bottomDivider = UIView() private let footerStack = UIStackView() - private let quotaLabel = UILabel() private let validationLabel = UILabel() private let actionStack = UIStackView() private let cancelButton = UIButton(type: .system) @@ -62,12 +64,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { 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 @@ -77,6 +73,14 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { TravelAlbumAIRetouchTemplateCell.self, forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier ) + collectionView.register( + TravelAlbumAIRetouchOverviewCell.self, + forCellWithReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier + ) + collectionView.register( + TravelAlbumAIRetouchModeCell.self, + forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier + ) collectionView.register( TravelAlbumAIRetouchSectionHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, @@ -104,10 +108,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { footerStack.axis = .vertical footerStack.spacing = 8 footerStack.alignment = .fill - quotaLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium) - quotaLabel.textColor = AIRetouchTemplateStyle.primary - quotaLabel.textAlignment = .center - quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel" actionStack.axis = .horizontal actionStack.spacing = 12 actionStack.distribution = .fillEqually @@ -116,11 +116,9 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { configureConfirmButton() actionStack.addArrangedSubview(cancelButton) actionStack.addArrangedSubview(confirmButton) - footerStack.addArrangedSubview(quotaLabel) footerStack.addArrangedSubview(validationLabel) footerStack.addArrangedSubview(actionStack) - view.addSubview(titleLabel) view.addSubview(collectionView) view.addSubview(statusContainer) statusContainer.addSubview(statusIndicator) @@ -132,11 +130,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { } 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() } @@ -150,10 +143,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } actionStack.snp.makeConstraints { make in - make.height.equalTo(48) + make.height.equalTo(56) } collectionView.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } @@ -212,9 +205,9 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { 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.baseForegroundColor = AIRetouchTemplateStyle.textPrimary + configuration.background.cornerRadius = 12 + configuration.background.strokeColor = UIColor(hex: 0xD1D1D6) configuration.background.strokeWidth = 1 configuration.titleTextAttributesTransformer = buttonTitleTransformer cancelButton.configuration = configuration @@ -226,7 +219,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { configuration.title = "确定" configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary configuration.baseForegroundColor = .white - configuration.background.cornerRadius = 14 + configuration.background.cornerRadius = 12 configuration.titleTextAttributesTransformer = buttonTitleTransformer confirmButton.configuration = configuration confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton" @@ -245,6 +238,16 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { [weak self] collectionView, indexPath, item in guard let self else { return nil } switch item { + case .overview: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier, + for: indexPath + ) as! TravelAlbumAIRetouchOverviewCell + cell.apply( + selectedPhotoCount: self.viewModel.selectedPhotoCount, + tips: self.viewModel.shouldShowInitialTips ? self.viewModel.initialTipsText : nil + ) + return cell case .template(let category, let template): let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier, @@ -254,22 +257,49 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { template: template, selected: self.viewModel.selectedTemplateId(for: category) == template.id ) + cell.onPreviewTapped = { [weak self] in + self?.showPreview(for: template) + } + return cell + case .mode: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier, + for: indexPath + ) as! TravelAlbumAIRetouchModeCell + cell.apply( + remainingQuota: self.viewModel.remainingQuota, + insufficient: self.viewModel.remainingQuota.map { + self.viewModel.requiredQuota > $0 + } ?? false + ) 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 { + self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section) else { return nil } + let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] let header = collectionView.dequeueReusableSupplementaryView( ofKind: kind, withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier, for: indexPath ) as! TravelAlbumAIRetouchSectionHeader - header.apply(title: category.title, optional: self.viewModel.isOptional(category)) + switch section { + case .templates(let category): + header.apply( + title: category.title, + badge: category == .cover + ? .gift + : (self.viewModel.isOptional(category) ? .optional : nil) + ) + case .mode: + header.apply(title: "修图模式", badge: nil) + case .overview: + return nil + } return header } } @@ -281,27 +311,68 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else { return nil } - 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) + switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] { + case .overview: + let size = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(self.viewModel.shouldShowInitialTips ? 160 : 74) + ) + let item = NSCollectionLayoutItem(layoutSize: size) + let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item]) + return NSCollectionLayoutSection(group: group) + 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 ), - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .top - ), - ] - return section + ] + return section + case .mode: + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(64) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: 4, + leading: 18, + bottom: 18, + trailing: 18 + ) + section.boundarySupplementaryItems = [ + NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(42) + ), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ), + ] + return section + } } } @@ -326,17 +397,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { validationLabel.text = viewModel.validationMessage validationLabel.isHidden = viewModel.validationMessage == nil - if let remainingQuota = viewModel.remainingQuota { - quotaLabel.text = "剩余 \(remainingQuota) 次" - quotaLabel.accessibilityLabel = "剩余\(remainingQuota)次修图额度" - quotaLabel.textColor = viewModel.requiredQuota > remainingQuota - ? AIRetouchTemplateStyle.danger - : AIRetouchTemplateStyle.primary - } else { - quotaLabel.text = "剩余 -- 次" - quotaLabel.accessibilityLabel = "剩余修图额度暂不可用" - quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary - } cancelButton.isEnabled = !viewModel.isSubmitting confirmButton.isEnabled = viewModel.canSubmit confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45 @@ -350,9 +410,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { private func applySnapshot() { var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.overview]) + snapshot.appendItems([.overview], toSection: .overview) for category in viewModel.visibleCategories { appendTemplates(category, to: &snapshot) } + snapshot.appendSections([.mode]) + snapshot.appendItems([.mode], toSection: .mode) snapshot.reconfigureItems(snapshot.itemIdentifiers) dataSource.apply(snapshot, animatingDifferences: true) } @@ -385,16 +449,104 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController { @objc private func retryTapped() { Task { await viewModel.loadTemplates(api: api) } } + + private func showPreview(for template: TravelAlbumAIRetouchTemplate) { + let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template) + controller.modalPresentationStyle = .fullScreen + present(controller, animated: true) + } } 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 } + collectionView.scrollToItem( + at: indexPath, + at: .centeredHorizontally, + animated: !UIAccessibility.isReduceMotionEnabled + ) viewModel.toggleTemplate(id: template.id, category: category) } } +/// AI 修图模板页顶部说明卡,随模板列表一起滚动。 +final class TravelAlbumAIRetouchOverviewCell: UICollectionViewCell { + static let reuseIdentifier = "TravelAlbumAIRetouchOverviewCell" + + private let stackView = UIStackView() + private let titleLabel = UILabel() + private let selectionCountLabel = UILabel() + private let tipsContainer = UIView() + private let tipsLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.text = "AI修图" + titleLabel.textColor = AIRetouchTemplateStyle.textPrimary + titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) + titleLabel.textAlignment = .center + titleLabel.accessibilityTraits = .header + + selectionCountLabel.textColor = AIRetouchTemplateStyle.textSecondary + selectionCountLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont( + for: .systemFont(ofSize: 17, weight: .regular) + ) + selectionCountLabel.adjustsFontForContentSizeCategory = true + selectionCountLabel.textAlignment = .center + selectionCountLabel.accessibilityIdentifier = "travelAlbum.aiRetouchSelectionCountLabel" + + tipsContainer.backgroundColor = AIRetouchTemplateStyle.tipsBackground + tipsContainer.layer.cornerRadius = 10 + tipsContainer.accessibilityIdentifier = "travelAlbum.aiRetouchTipsContainer" + tipsLabel.textColor = AIRetouchTemplateStyle.tipsText + tipsLabel.font = UIFontMetrics(forTextStyle: .footnote).scaledFont( + for: .systemFont(ofSize: 13, weight: .regular) + ) + tipsLabel.adjustsFontForContentSizeCategory = true + tipsLabel.numberOfLines = 0 + tipsLabel.accessibilityIdentifier = "travelAlbum.aiRetouchTipsLabel" + + stackView.axis = .vertical + stackView.alignment = .fill + stackView.spacing = 4 + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(selectionCountLabel) + stackView.addArrangedSubview(tipsContainer) + stackView.setCustomSpacing(12, after: selectionCountLabel) + + contentView.addSubview(stackView) + tipsContainer.addSubview(tipsLabel) + stackView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(10) + make.leading.trailing.equalToSuperview().inset(18) + make.bottom.lessThanOrEqualToSuperview().offset(-10) + } + titleLabel.snp.makeConstraints { make in + make.height.equalTo(30) + } + selectionCountLabel.snp.makeConstraints { make in + make.height.equalTo(24) + } + tipsLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新选中照片数量和当前工作流说明。 + func apply(selectedPhotoCount: Int, tips: String?) { + selectionCountLabel.text = "已选择 \(selectedPhotoCount) 张照片" + tipsLabel.text = tips + tipsContainer.isHidden = tips == nil + tipsLabel.isHidden = tips == nil + } +} + /// AI 修图模板卡片,展示模板预览、名称和明确选中态。 final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell" @@ -402,6 +554,9 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell { private let previewImageView = UIImageView() private let nameLabel = UILabel() private let checkView = UIImageView() + private let previewButton = AIRetouchTemplatePreviewButton(type: .system) + + var onPreviewTapped: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) @@ -421,10 +576,26 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell { checkView.tintColor = AIRetouchTemplateStyle.primary checkView.backgroundColor = .white checkView.layer.cornerRadius = 10 + var previewConfiguration = UIButton.Configuration.filled() + previewConfiguration.title = "预览" + previewConfiguration.baseForegroundColor = .white + previewConfiguration.baseBackgroundColor = UIColor.black.withAlphaComponent(0.58) + previewConfiguration.background.cornerRadius = 15 + previewConfiguration.contentInsets = .zero + previewConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + attributes in + var attributes = attributes + attributes.font = .systemFont(ofSize: 13, weight: .regular) + return attributes + } + previewButton.configuration = previewConfiguration + previewButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewButton" + previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside) contentView.addSubview(previewImageView) contentView.addSubview(nameLabel) contentView.addSubview(checkView) + contentView.addSubview(previewButton) previewImageView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(116) @@ -438,6 +609,11 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell { make.top.trailing.equalToSuperview().inset(8) make.size.equalTo(20) } + previewButton.snp.makeConstraints { make in + make.trailing.bottom.equalTo(previewImageView).inset(8) + make.width.equalTo(58) + make.height.equalTo(32) + } } @available(*, unavailable) @@ -449,6 +625,7 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell { super.prepareForReuse() previewImageView.kf.cancelDownloadTask() previewImageView.image = nil + onPreviewTapped = nil } /// 更新模板内容与选中状态。 @@ -478,9 +655,84 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell { accessibilityValue = selected ? "已选择" : "未选择" accessibilityTraits = selected ? [.button, .selected] : [.button] } + + @objc private func previewTapped() { + onPreviewTapped?() + } } -/// AI 修图模板分组标题,可附带“选填”标签。 +/// 扩大模板预览按钮的触摸区域,同时保持截图中的胶囊尺寸。 +private final class AIRetouchTemplatePreviewButton: UIButton { + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds.insetBy(dx: -6, dy: -6).contains(point) + } +} + +/// AI 修图计费模式卡,展示按张收费规则与剩余额度。 +final class TravelAlbumAIRetouchModeCell: UICollectionViewCell { + static let reuseIdentifier = "TravelAlbumAIRetouchModeCell" + + private let modeLabel = UILabel() + private let quotaContainer = UIView() + private let quotaLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.backgroundColor = .white + contentView.layer.cornerRadius = 12 + modeLabel.text = "AI精修 · 按张收费" + modeLabel.textColor = AIRetouchTemplateStyle.textPrimary + modeLabel.font = .systemFont(ofSize: 16, weight: .medium) + quotaContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1) + quotaContainer.layer.cornerRadius = 15 + quotaLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium) + quotaLabel.textColor = AIRetouchTemplateStyle.primary + quotaLabel.textAlignment = .center + quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel" + + contentView.addSubview(modeLabel) + contentView.addSubview(quotaContainer) + quotaContainer.addSubview(quotaLabel) + modeLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(quotaContainer.snp.leading).offset(-12) + } + quotaContainer.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-16) + make.centerY.equalToSuperview() + make.width.equalTo(96) + make.height.equalTo(30) + } + quotaLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新剩余可精修张数,并在额度不足时切换为警示色。 + func apply(remainingQuota: Int?, insufficient: Bool) { + quotaLabel.text = remainingQuota.map { "剩余 \($0) 张" } ?? "剩余 -- 张" + quotaLabel.accessibilityLabel = remainingQuota.map { "剩余\($0)张修图额度" } + ?? "剩余修图额度暂不可用" + quotaLabel.textColor = insufficient + ? AIRetouchTemplateStyle.danger + : (remainingQuota == nil ? AIRetouchTemplateStyle.textSecondary : AIRetouchTemplateStyle.primary) + quotaContainer.backgroundColor = quotaLabel.textColor.withAlphaComponent(0.1) + } +} + +/// 模板分组标题右侧的业务标记。 +fileprivate enum AIRetouchTemplateSectionBadge { + case optional + case gift +} + +/// AI 修图模板分组标题,可附带选填或赠送标记。 final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView { static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader" @@ -491,12 +743,9 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView { 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.layer.cornerRadius = 10 optionalLabel.clipsToBounds = true addSubview(titleLabel) @@ -508,7 +757,7 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView { optionalLabel.snp.makeConstraints { make in make.leading.equalTo(titleLabel.snp.trailing).offset(8) make.centerY.equalTo(titleLabel) - make.width.equalTo(38) + make.width.greaterThanOrEqualTo(38) make.height.equalTo(20) make.trailing.lessThanOrEqualToSuperview().offset(-18) } @@ -519,21 +768,163 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView { fatalError("init(coder:) has not been implemented") } - /// 更新分组标题与选填标签。 - func apply(title: String, optional: Bool) { + /// 更新分组标题与业务标记。 + fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) { titleLabel.text = title - optionalLabel.isHidden = !optional - accessibilityLabel = optional ? "\(title),选填" : title + optionalLabel.isHidden = badge == nil + switch badge { + case .optional: + optionalLabel.text = " 选填 " + optionalLabel.textColor = AIRetouchTemplateStyle.primary + optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1) + case .gift: + optionalLabel.text = " 赠送 · 不占精修张数 " + optionalLabel.textColor = AIRetouchTemplateStyle.gift + optionalLabel.backgroundColor = AIRetouchTemplateStyle.giftBackground + case nil: + optionalLabel.text = nil + } + let badgeText = optionalLabel.text?.trimmingCharacters(in: .whitespaces) + accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title accessibilityTraits = .header } } +/// AI 修图模板全屏预览,支持双指缩放查看模板细节。 +final class TravelAlbumAIRetouchTemplatePreviewViewController: UIViewController { + private let template: TravelAlbumAIRetouchTemplate + private let backButton = UIButton(type: .system) + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let scrollView = UIScrollView() + private let imageView = UIImageView() + + /// 创建指定模板的全屏预览页。 + init(template: TravelAlbumAIRetouchTemplate) { + self.template = template + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + loadPreview() + } + + private func setupUI() { + view.backgroundColor = .black + view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreview" + + var backConfiguration = UIButton.Configuration.filled() + backConfiguration.image = UIImage(systemName: "chevron.left") + backConfiguration.baseForegroundColor = .white + backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12) + backConfiguration.background.cornerRadius = 24 + backButton.configuration = backConfiguration + backButton.accessibilityLabel = "返回模板选择" + backButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewBackButton" + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + + titleLabel.text = template.name + titleLabel.textColor = .white + titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) + titleLabel.textAlignment = .center + titleLabel.lineBreakMode = .byTruncatingTail + + subtitleLabel.text = "双指缩放查看细节" + subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.52) + subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular) + subtitleLabel.textAlignment = .center + + scrollView.minimumZoomScale = 1 + scrollView.maximumZoomScale = 4 + scrollView.delegate = self + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewScrollView" + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.backgroundColor = UIColor(hex: 0x111111) + imageView.accessibilityLabel = "\(template.name)模板预览图" + + view.addSubview(backButton) + view.addSubview(titleLabel) + view.addSubview(subtitleLabel) + view.addSubview(scrollView) + scrollView.addSubview(imageView) + } + + private func setupConstraints() { + backButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(20) + make.leading.equalToSuperview().offset(18) + make.size.equalTo(48) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(20) + make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12) + make.centerX.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview().offset(-66) + make.height.equalTo(26) + } + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(2) + make.centerX.equalToSuperview() + } + scrollView.snp.makeConstraints { make in + make.top.equalTo(subtitleLabel.snp.bottom).offset(18) + make.leading.trailing.bottom.equalToSuperview() + } + imageView.snp.makeConstraints { make in + make.center.equalTo(scrollView.frameLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + make.height.equalTo(imageView.snp.width).multipliedBy(0.75) + } + } + + private func loadPreview() { + guard let url = URL(string: template.previewURL), !template.previewURL.isEmpty else { + imageView.image = UIImage(systemName: "photo") + imageView.tintColor = UIColor.white.withAlphaComponent(0.45) + return + } + imageView.kf.setImage( + with: url, + placeholder: UIImage(systemName: "photo")?.withTintColor( + UIColor.white.withAlphaComponent(0.45), + renderingMode: .alwaysOriginal + ) + ) + } + + @objc private func backTapped() { + dismiss(animated: true) + } +} + +extension TravelAlbumAIRetouchTemplatePreviewViewController: UIScrollViewDelegate { + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } +} + /// AI 修图模板页视觉常量。 private enum AIRetouchTemplateStyle { static let primary = UIColor(hex: 0x1677FF) - static let pageBackground = UIColor(hex: 0xF7F9FC) + static let pageBackground = UIColor(hex: 0xF7F7F7) static let textPrimary = UIColor(hex: 0x172033) - static let textSecondary = UIColor(hex: 0x7F8A9E) + static let textSecondary = UIColor(hex: 0x6E6E73) + static let tipsBackground = UIColor(hex: 0xEAF3FF) + static let tipsText = UIColor(hex: 0x385778) + static let gift = UIColor(hex: 0xFF9418) + static let giftBackground = UIColor(hex: 0xFFF1D9) static let border = UIColor(hex: 0xDCE4EF) static let danger = UIColor(hex: 0xE53935) } diff --git a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift index 65d6a21..d0daf2c 100644 --- a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift +++ b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift @@ -21,6 +21,12 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18]) XCTAssertEqual(viewModel.workflow, .initial(albumId: 8, materialIds: [1, 2, 3, 4])) + XCTAssertEqual(viewModel.selectedPhotoCount, 4) + XCTAssertTrue(viewModel.shouldShowInitialTips) + XCTAssertEqual( + viewModel.initialTipsText, + "Tips:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。" + ) XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover]) XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11) XCTAssertNil(viewModel.selectedAtmosphereTemplateId) @@ -95,6 +101,8 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { workflow: .reretouch(materialId: 7, batchId: 70, type: .refined) ) await refined.loadTemplates(api: api) + XCTAssertEqual(refined.selectedPhotoCount, 1) + XCTAssertFalse(refined.shouldShowInitialTips) XCTAssertEqual(refined.visibleCategories, [.refined]) XCTAssertEqual(refined.selectedRefinedTemplateId, 11) XCTAssertNil(refined.selectedAtmosphereTemplateId) diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index d113aab..0f968a6 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -296,7 +296,33 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { await waitUntil { confirmButton.isEnabled } controller.view.layoutIfNeeded() + let selectionCountLabel = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel" + } as? UILabel + ) + let tipsLabel = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsLabel" + } as? UILabel + ) + let tipsContainer = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsContainer" + } + ) + XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout) + XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片") + XCTAssertEqual( + tipsLabel.text, + "Tips:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。" + ) + XCTAssertFalse(tipsContainer.isHidden) + XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xEAF3FF) + XCTAssertEqual(tipsLabel.textColor.travelAlbumTestHexRGB, 0x385778) + XCTAssertEqual(tipsContainer.layer.cornerRadius, 10) + XCTAssertTrue(tipsContainer.isDescendant(of: collectionView)) XCTAssertFalse(bottomBar.isDescendant(of: collectionView)) XCTAssertEqual(cancelButton.configuration?.title, "取消") XCTAssertEqual(confirmButton.configuration?.title, "确定") @@ -327,29 +353,47 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 } controller.view.layoutIfNeeded() + let collectionView = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection" + } as? UICollectionView + ) let accessibleLabels = controller.view.allAccessibilityLabels() XCTAssertTrue(accessibleLabels.contains("原图精修")) XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) - XCTAssertTrue(accessibleLabels.contains("封面风格模板")) + XCTAssertTrue(accessibleLabels.contains("封面风格模板,赠送 · 不占精修张数")) + collectionView.scrollToItem( + at: IndexPath(item: 0, section: 4), + at: .top, + animated: false + ) + controller.view.layoutIfNeeded() let quotaLabel = try XCTUnwrap( controller.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchRemainingQuotaLabel" } as? UILabel ) - XCTAssertEqual(quotaLabel.text, "剩余 8 次") - XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8次修图额度") + XCTAssertEqual(quotaLabel.text, "剩余 8 张") + XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度") } func testAIRetouchTemplateCellExposesSelectedState() { let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero) let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "") + var previewTapped = false + cell.onPreviewTapped = { previewTapped = true } cell.apply(template: template, selected: true) + let previewButton = cell.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton" + } as? UIButton + previewButton?.sendActions(for: .touchUpInside) XCTAssertEqual(cell.contentView.layer.borderWidth, 2) XCTAssertTrue(cell.accessibilityTraits.contains(.selected)) XCTAssertEqual(cell.accessibilityValue, "已选择") + XCTAssertTrue(previewTapped) cell.apply(template: template, selected: false) @@ -358,6 +402,37 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(cell.accessibilityValue, "未选择") } + func testAIRetouchTemplatePreviewUsesImmersiveZoomableLayout() throws { + let template = TravelAlbumAIRetouchTemplate( + id: 2, + name: "简约留白", + previewURL: "" + ) + let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.view.layoutIfNeeded() + + let scrollView = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewScrollView" + } as? UIScrollView + ) + let backButton = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewBackButton" + } as? UIButton + ) + + XCTAssertEqual(controller.view.backgroundColor, .black) + XCTAssertTrue(controller.view.allLabels().contains { $0.text == "简约留白" }) + XCTAssertTrue(controller.view.allLabels().contains { $0.text == "双指缩放查看细节" }) + XCTAssertEqual(scrollView.minimumZoomScale, 1) + XCTAssertEqual(scrollView.maximumZoomScale, 4) + XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44) + XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44) + } + func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws { UIView.setAnimationsEnabled(false) defer { UIView.setAnimationsEnabled(true) } @@ -424,9 +499,29 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) sheet.view.layoutIfNeeded() - let labels = sheet.view.allAccessibilityLabels() - XCTAssertTrue(labels.contains("原图精修")) - XCTAssertFalse(labels.contains { $0.contains("氛围感修图") }) + let visibleLabelTexts = sheet.view.allLabels().compactMap { label in + label.isHidden ? nil : label.text + } + XCTAssertTrue(visibleLabelTexts.contains("原图精修")) + XCTAssertFalse(visibleLabelTexts.contains { $0.contains("氛围感修图") }) + let selectionCountLabel = try XCTUnwrap( + sheet.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel" + } as? UILabel + ) + let tipsLabel = try XCTUnwrap( + sheet.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsLabel" + } as? UILabel + ) + let tipsContainer = try XCTUnwrap( + sheet.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsContainer" + } + ) + XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片") + XCTAssertTrue(tipsContainer.isHidden) + XCTAssertTrue(tipsLabel.isHidden) confirmButton.sendActions(for: .touchUpInside) await waitUntil { api.aiReretouchRequests.count == 1 } await waitUntil { controller.presentedViewController == nil }