// // TravelAlbumAIRetouchTemplateViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。 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 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 = AIRetouchGradientButton(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" 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( TravelAlbumAIRetouchOverviewCell.self, forCellWithReuseIdentifier: TravelAlbumAIRetouchOverviewCell.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 = .fill actionStack.alignment = .fill configureCancelButton() configureConfirmButton() actionStack.addArrangedSubview(cancelButton) actionStack.addArrangedSubview(confirmButton) footerStack.addArrangedSubview(validationLabel) footerStack.addArrangedSubview(actionStack) 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() { 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(56) } cancelButton.snp.makeConstraints { make in make.width.equalTo(confirmButton.snp.width).multipliedBy(0.78) } collectionView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) 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() { cancelButton.setTitle("取消", for: .normal) cancelButton.setTitleColor(AIRetouchTemplateStyle.primary, for: .normal) cancelButton.setTitleColor(AIRetouchTemplateStyle.primary.withAlphaComponent(0.45), for: .disabled) cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) cancelButton.backgroundColor = AIRetouchTemplateStyle.cardBackground cancelButton.layer.cornerRadius = 12 cancelButton.layer.borderColor = AIRetouchTemplateStyle.primary.cgColor cancelButton.layer.borderWidth = 1 cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton" } private func configureConfirmButton() { confirmButton.setTitle("确定", for: .normal) confirmButton.setTitleColor(.white, for: .normal) confirmButton.setTitleColor(.white, for: .disabled) confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton" } private func configureDataSource() { dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [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, for: indexPath ) as! TravelAlbumAIRetouchTemplateCell cell.apply( 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( requiredQuota: self.viewModel.requiredQuota, 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) else { return nil } let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] let header = collectionView.dequeueReusableSupplementaryView( ofKind: kind, withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier, for: indexPath ) as! TravelAlbumAIRetouchSectionHeader switch section { case .templates(let category): header.apply( title: category.title, badge: category == .cover ? .gift : (self.viewModel.isOptional(category) ? .optional : .required) ) case .mode: return nil case .overview: return nil } return header } } private func makeLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in guard let self, self.dataSource != nil, self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else { return nil } switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] { case .overview: let size = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .absolute(self.viewModel.shouldShowInitialTips ? 150 : 82) ) let item = NSCollectionLayoutItem(layoutSize: size) let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item]) return NSCollectionLayoutSection(group: group) case .templates: let availableWidth = layoutEnvironment.container.effectiveContentSize.width let cardWidth = min(118, floor((availableWidth - 56) / 3)) let itemSize = NSCollectionLayoutSize( widthDimension: .absolute(cardWidth), heightDimension: .absolute(156) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary section.interGroupSpacing = 10 section.contentInsets = NSDirectionalEdgeInsets( top: 4, leading: 18, bottom: 14, 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(78) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.contentInsets = NSDirectionalEdgeInsets( top: 8, leading: 18, bottom: 18, 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 confirmButton.setGradientEnabled(viewModel.canSubmit) confirmButton.setTitle(viewModel.isSubmitting ? "提交中" : "确定", for: .normal) confirmButton.setLoading(viewModel.isSubmitting) isModalInPresentation = viewModel.isSubmitting applySnapshot() } 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) } 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) } } 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 tipsIconContainer = UIView() private let tipsIconView = UIImageView() private let tipsLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) titleLabel.text = "AI修图" titleLabel.textColor = AIRetouchTemplateStyle.textPrimary titleLabel.font = .systemFont(ofSize: 22, weight: .bold) titleLabel.textAlignment = .center titleLabel.accessibilityTraits = .header selectionCountLabel.textColor = AIRetouchTemplateStyle.textSecondary selectionCountLabel.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont( for: .systemFont(ofSize: 15, weight: .regular) ) selectionCountLabel.adjustsFontForContentSizeCategory = true selectionCountLabel.textAlignment = .center selectionCountLabel.accessibilityIdentifier = "travelAlbum.aiRetouchSelectionCountLabel" tipsContainer.backgroundColor = AIRetouchTemplateStyle.tipsBackground tipsContainer.layer.cornerRadius = 12 tipsContainer.layer.borderWidth = 1 tipsContainer.layer.borderColor = AIRetouchTemplateStyle.tipsBorder.cgColor tipsContainer.accessibilityIdentifier = "travelAlbum.aiRetouchTipsContainer" tipsIconContainer.backgroundColor = AIRetouchTemplateStyle.primary tipsIconContainer.layer.cornerRadius = 8 tipsIconView.image = UIImage(systemName: "wand.and.stars") tipsIconView.tintColor = .white tipsIconView.contentMode = .scaleAspectFit tipsLabel.textColor = AIRetouchTemplateStyle.tipsText tipsLabel.font = UIFontMetrics(forTextStyle: .footnote).scaledFont( for: .systemFont(ofSize: 12, weight: .regular) ) tipsLabel.adjustsFontForContentSizeCategory = true tipsLabel.numberOfLines = 2 tipsLabel.accessibilityIdentifier = "travelAlbum.aiRetouchTipsLabel" stackView.axis = .vertical stackView.alignment = .fill stackView.spacing = 2 stackView.addArrangedSubview(titleLabel) stackView.addArrangedSubview(selectionCountLabel) stackView.addArrangedSubview(tipsContainer) stackView.setCustomSpacing(14, after: selectionCountLabel) contentView.addSubview(stackView) tipsContainer.addSubview(tipsIconContainer) tipsIconContainer.addSubview(tipsIconView) tipsContainer.addSubview(tipsLabel) stackView.snp.makeConstraints { make in make.top.equalToSuperview().offset(8) make.leading.trailing.equalToSuperview().inset(18) make.bottom.lessThanOrEqualToSuperview().offset(-8) } titleLabel.snp.makeConstraints { make in make.height.equalTo(32) } selectionCountLabel.snp.makeConstraints { make in make.height.equalTo(22) } tipsContainer.snp.makeConstraints { make in make.height.equalTo(52) } tipsIconContainer.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.size.equalTo(32) } tipsIconView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(7) } tipsLabel.snp.makeConstraints { make in make.leading.equalTo(tipsIconContainer.snp.trailing).offset(10) make.trailing.equalToSuperview().offset(-12) make.centerY.equalToSuperview() } } @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 tipsIconContainer.isHidden = tips == nil tipsLabel.isHidden = tips == nil } } /// AI 修图模板卡片,展示模板预览、名称和明确选中态。 final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell" 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) contentView.backgroundColor = AIRetouchTemplateStyle.cardBackground contentView.layer.cornerRadius = 12 contentView.layer.borderWidth = 1 contentView.clipsToBounds = true layer.shadowColor = UIColor(hex: 0x254A7A).cgColor layer.shadowOpacity = 0.08 layer.shadowRadius = 7 layer.shadowOffset = CGSize(width: 0, height: 3) layer.masksToBounds = false previewImageView.contentMode = .scaleAspectFill previewImageView.clipsToBounds = true previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8) nameLabel.font = .systemFont(ofSize: 13, weight: .semibold) 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 = 11 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(118) } 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(7) make.size.equalTo(22) } previewButton.snp.makeConstraints { make in make.trailing.bottom.equalTo(previewImageView).inset(7) make.width.equalTo(54) make.height.equalTo(30) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() previewImageView.kf.cancelDownloadTask() previewImageView.image = nil onPreviewTapped = 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 layer.shadowOpacity = selected ? 0.16 : 0.08 checkView.isHidden = !selected isSelected = selected accessibilityLabel = template.name accessibilityValue = selected ? "已选择" : "未选择" accessibilityTraits = selected ? [.button, .selected] : [.button] } @objc private func previewTapped() { onPreviewTapped?() } } /// 扩大模板预览按钮的触摸区域,同时保持截图中的胶囊尺寸。 private final class AIRetouchTemplatePreviewButton: UIButton { override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { bounds.insetBy(dx: -6, dy: -6).contains(point) } } /// 模板选择页主操作按钮,使用品牌蓝渐变并支持禁用态切换。 private final class AIRetouchGradientButton: UIButton { private let gradientLayer = CAGradientLayer() private let activityIndicator = UIActivityIndicatorView(style: .medium) override init(frame: CGRect) { super.init(frame: frame) gradientLayer.colors = [ AIRetouchTemplateStyle.primary.cgColor, AIRetouchTemplateStyle.primaryGradientEnd.cgColor, ] gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) gradientLayer.cornerRadius = 12 layer.insertSublayer(gradientLayer, at: 0) activityIndicator.color = .white activityIndicator.hidesWhenStopped = true activityIndicator.isUserInteractionEnabled = false addSubview(activityIndicator) clipsToBounds = true } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds activityIndicator.center = CGPoint(x: bounds.midX - 44, y: bounds.midY) } /// 根据提交条件切换品牌渐变和禁用灰色。 func setGradientEnabled(_ enabled: Bool) { gradientLayer.colors = enabled ? [ AIRetouchTemplateStyle.primary.cgColor, AIRetouchTemplateStyle.primaryGradientEnd.cgColor, ] : [ AIRetouchTemplateStyle.disabledStart.cgColor, AIRetouchTemplateStyle.disabledEnd.cgColor, ] } /// 切换提交中状态,并在标题左侧展示活动指示器。 func setLoading(_ loading: Bool) { if loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } setNeedsLayout() } } /// AI 修图计费模式卡,展示按张收费规则与剩余额度。 final class TravelAlbumAIRetouchModeCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumAIRetouchModeCell" private let iconContainer = UIView() private let iconView = UIImageView() private let modeLabel = UILabel() private let quotaStack = UIStackView() private let requiredQuotaContainer = UIView() private let requiredQuotaLabel = UILabel() private let quotaLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white contentView.layer.cornerRadius = 12 contentView.layer.borderWidth = 1 contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor contentView.layer.shadowColor = UIColor(hex: 0x254A7A).cgColor contentView.layer.shadowOpacity = 0.06 contentView.layer.shadowRadius = 8 contentView.layer.shadowOffset = CGSize(width: 0, height: 3) iconContainer.backgroundColor = AIRetouchTemplateStyle.primary iconContainer.layer.cornerRadius = 10 iconView.image = UIImage(systemName: "wand.and.stars") iconView.tintColor = .white iconView.contentMode = .scaleAspectFit modeLabel.text = "AI精修 · 按张收费" modeLabel.textColor = AIRetouchTemplateStyle.textPrimary modeLabel.font = .systemFont(ofSize: 15, weight: .semibold) quotaStack.axis = .vertical quotaStack.alignment = .trailing quotaStack.spacing = 3 requiredQuotaContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1) requiredQuotaContainer.layer.cornerRadius = 12 requiredQuotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold) requiredQuotaLabel.textColor = AIRetouchTemplateStyle.primary requiredQuotaLabel.textAlignment = .center requiredQuotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRequiredQuotaLabel" quotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary quotaLabel.textAlignment = .right quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel" contentView.addSubview(iconContainer) iconContainer.addSubview(iconView) contentView.addSubview(modeLabel) contentView.addSubview(quotaStack) quotaStack.addArrangedSubview(requiredQuotaContainer) requiredQuotaContainer.addSubview(requiredQuotaLabel) quotaStack.addArrangedSubview(quotaLabel) iconContainer.snp.makeConstraints { make in make.leading.equalToSuperview().offset(14) make.centerY.equalToSuperview() make.size.equalTo(38) } iconView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(9) } modeLabel.snp.makeConstraints { make in make.leading.equalTo(iconContainer.snp.trailing).offset(10) make.centerY.equalToSuperview() make.trailing.lessThanOrEqualTo(quotaStack.snp.leading).offset(-10) } quotaStack.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-14) make.centerY.equalToSuperview() } requiredQuotaContainer.snp.makeConstraints { make in make.height.equalTo(24) make.width.greaterThanOrEqualTo(108) } requiredQuotaLabel.snp.makeConstraints { make in make.edges.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 更新预计消耗和剩余精修张数,并在额度不足时切换为警示色。 func apply(requiredQuota: Int, remainingQuota: Int?, insufficient: Bool) { requiredQuotaLabel.text = "预计消耗 \(requiredQuota) 张" requiredQuotaLabel.accessibilityLabel = "预计消耗\(requiredQuota)张修图额度" quotaLabel.text = remainingQuota.map { "剩余 \($0) 张" } ?? "剩余 -- 张" quotaLabel.accessibilityLabel = remainingQuota.map { "剩余\($0)张修图额度" } ?? "剩余修图额度暂不可用" requiredQuotaLabel.textColor = insufficient ? AIRetouchTemplateStyle.danger : AIRetouchTemplateStyle.primary requiredQuotaContainer.backgroundColor = requiredQuotaLabel.textColor.withAlphaComponent(0.1) quotaLabel.textColor = insufficient ? AIRetouchTemplateStyle.danger : AIRetouchTemplateStyle.textSecondary } } /// 模板分组标题右侧的业务标记。 fileprivate enum AIRetouchTemplateSectionBadge { case required case optional case gift } /// 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.font = .systemFont(ofSize: 11, weight: .medium) optionalLabel.textAlignment = .center optionalLabel.layer.cornerRadius = 10 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.greaterThanOrEqualTo(38) make.height.equalTo(20) make.trailing.lessThanOrEqualToSuperview().offset(-18) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 更新分组标题与业务标记。 fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) { titleLabel.text = title optionalLabel.isHidden = badge == nil switch badge { case .required: optionalLabel.text = " 必选 " optionalLabel.textColor = AIRetouchTemplateStyle.primary optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1) 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 primaryGradientEnd = UIColor(hex: 0x4B9BFF) static let pageBackground = UIColor(hex: 0xF7FAFF) static let cardBackground = UIColor.white static let textPrimary = UIColor(hex: 0x172033) static let textSecondary = UIColor(hex: 0x7D8799) static let tipsBackground = UIColor(hex: 0xF4F8FF) static let tipsBorder = UIColor(hex: 0xD9E7FA) 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) static let disabledStart = UIColor(hex: 0xA9C8EE) static let disabledEnd = UIColor(hex: 0xC6DAF3) }