// // 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 = 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" 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 = .fillEqually 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) } 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() { var configuration = UIButton.Configuration.filled() configuration.title = "取消" configuration.baseBackgroundColor = .white configuration.baseForegroundColor = AIRetouchTemplateStyle.textPrimary configuration.background.cornerRadius = 12 configuration.background.strokeColor = UIColor(hex: 0xD1D1D6) 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 = 12 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 .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( 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 : nil) ) case .mode: header.apply(title: "修图模式", badge: nil) case .overview: return nil } 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 .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 ), ] 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 } } } @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() 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 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" 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 = .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 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) } 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) } previewButton.snp.makeConstraints { make in make.trailing.bottom.equalTo(previewImageView).inset(8) make.width.equalTo(58) make.height.equalTo(32) } } @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 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) } } /// 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" 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 .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: 0xF7F7F7) static let textPrimary = UIColor(hex: 0x172033) 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) }