diff --git a/suixinkan/Assets.xcassets/travel_album_before_after.imageset/Contents.json b/suixinkan/Assets.xcassets/travel_album_before_after.imageset/Contents.json
new file mode 100644
index 0000000..7dca1fd
--- /dev/null
+++ b/suixinkan/Assets.xcassets/travel_album_before_after.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "travel_album_before_after.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/travel_album_before_after.imageset/travel_album_before_after.svg b/suixinkan/Assets.xcassets/travel_album_before_after.imageset/travel_album_before_after.svg
new file mode 100644
index 0000000..bccac8a
--- /dev/null
+++ b/suixinkan/Assets.xcassets/travel_album_before_after.imageset/travel_album_before_after.svg
@@ -0,0 +1,5 @@
+
diff --git a/suixinkan/Features/TravelAlbum/AI修图接口文档.md b/suixinkan/Features/TravelAlbum/AI修图接口文档.md
index f80fb8f..4b19ffb 100644
--- a/suixinkan/Features/TravelAlbum/AI修图接口文档.md
+++ b/suixinkan/Features/TravelAlbum/AI修图接口文档.md
@@ -117,6 +117,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
"id": "tpl_refined_12",
"name": "清透精修",
"preview_url": "https://cdn.example.com/templates/refined_12.jpg",
+ "before_url": "https://cdn.example.com/templates/refined_12_before.jpg",
+ "after_url": "https://cdn.example.com/templates/refined_12_after.jpg",
"enabled": true
}
]
@@ -135,6 +137,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
"id": "tpl_atmosphere_06",
"name": "暖阳",
"preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg",
+ "before_url": "https://cdn.example.com/templates/atmosphere_06_before.jpg",
+ "after_url": "https://cdn.example.com/templates/atmosphere_06_after.jpg",
"enabled": true
}
]
@@ -153,6 +157,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
"id": "tpl_cover_03",
"name": "旅行画册",
"preview_url": "https://cdn.example.com/templates/cover_03.jpg",
+ "before_url": "https://cdn.example.com/templates/cover_03_before.jpg",
+ "after_url": "https://cdn.example.com/templates/cover_03_after.jpg",
"enabled": true
}
]
@@ -178,6 +184,7 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
+- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
index 576d488..ff9638f 100644
--- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
+++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
@@ -403,23 +403,45 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
}
}
-/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
+/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let name: String
let previewURL: String
+ let beforeURL: String
+ let afterURL: String
enum CodingKeys: String, CodingKey {
case id
case name
case previewURL = "preview_url"
+ case beforeURL = "before_url"
+ case afterURL = "after_url"
}
/// 创建 AI 修图模板。
- init(id: Int, name: String, previewURL: String) {
+ init(
+ id: Int,
+ name: String,
+ previewURL: String,
+ beforeURL: String = "",
+ afterURL: String = ""
+ ) {
self.id = id
self.name = name
self.previewURL = previewURL
+ self.beforeURL = beforeURL
+ self.afterURL = afterURL
+ }
+
+ /// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(Int.self, forKey: .id)
+ name = try container.decode(String.self, forKey: .name)
+ previewURL = try container.decode(String.self, forKey: .previewURL)
+ beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? ""
+ afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? ""
}
}
diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift
index 1e43ee8..32d5024 100644
--- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift
+++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift
@@ -5,6 +5,32 @@
import Foundation
+/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。
+struct BeforeAfterComparisonContent: Sendable, Equatable {
+ let beforeURL: String
+ let afterURL: String
+ let beforeLabel: String
+ let afterLabel: String
+
+ /// 创建前后对比内容,默认使用 AI 修图模块的中文角标。
+ init(
+ beforeURL: String,
+ afterURL: String,
+ beforeLabel: String = "原图",
+ afterLabel: String = "效果图"
+ ) {
+ self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.beforeLabel = beforeLabel
+ self.afterLabel = afterLabel
+ }
+
+ /// 两张图片地址均存在时才允许进入对比页。
+ var isValid: Bool {
+ !beforeURL.isEmpty && !afterURL.isEmpty
+ }
+}
+
/// 相册预览页横向滑动策略,由调用方通过配置注入。
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
/// 横滑只切换原图项目,关联图通过 Tab 切换。
@@ -85,6 +111,19 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
assets.first { $0.kind == kind }
}
+ /// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。
+ func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? {
+ guard kind == .retouched || kind == .atmosphere,
+ let original = asset(for: .original),
+ let result = asset(for: kind)
+ else { return nil }
+ let content = BeforeAfterComparisonContent(
+ beforeURL: original.displayURL,
+ afterURL: result.displayURL
+ )
+ return content.isValid ? content : nil
+ }
+
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
init(material: TravelAlbumMaterial) {
originalMaterialId = material.id
@@ -157,6 +196,14 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
}
}
+extension TravelAlbumAIRetouchTemplate {
+ /// 使用接口返回的模板示例前后图构建对比页内容。
+ var comparisonContent: BeforeAfterComparisonContent? {
+ let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
+ return content.isValid ? content : nil
+ }
+}
+
/// 预览分页节点,记录当前图片所属项目及类型。
struct TravelAlbumPreviewNode: Sendable, Hashable {
let projectIndex: Int
diff --git a/suixinkan/Features/TravelAlbum/ViewModels/BeforeAfterComparisonViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/BeforeAfterComparisonViewModel.swift
new file mode 100644
index 0000000..c598048
--- /dev/null
+++ b/suixinkan/Features/TravelAlbum/ViewModels/BeforeAfterComparisonViewModel.swift
@@ -0,0 +1,79 @@
+//
+// BeforeAfterComparisonViewModel.swift
+// suixinkan
+//
+
+import Foundation
+
+/// 前后对比分隔线的共享运动参数与计算规则。
+enum BeforeAfterComparisonMotion {
+ static let automaticCenter: CGFloat = 0.5
+ static let automaticAmplitude: CGFloat = 0.3
+ static let automaticSpeed: Double = 0.8
+ static let manualRange: ClosedRange = 0.05 ... 0.95
+
+ /// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。
+ static func automaticFraction(elapsed: TimeInterval) -> CGFloat {
+ automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude
+ }
+
+ /// 将手动拖动位置限制在两侧 5% 的安全边界内。
+ static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat {
+ min(manualRange.upperBound, max(manualRange.lowerBound, fraction))
+ }
+
+ /// 计算从当前位置恢复正向自动运动所需的正弦相位时间。
+ static func phaseTime(for fraction: CGFloat) -> TimeInterval {
+ let normalized = Double(
+ min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude))
+ )
+ return asin(normalized) / automaticSpeed
+ }
+}
+
+/// 前后对比图片在可用区域内完整展示时的等比布局计算。
+enum BeforeAfterComparisonLayout {
+ static let fallbackAspectRatio: CGFloat = 3.0 / 4.0
+
+ /// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。
+ static func aspectRatio(for imageSize: CGSize) -> CGFloat {
+ guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio }
+ let ratio = imageSize.width / imageSize.height
+ return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio
+ }
+
+ /// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。
+ static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize {
+ guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero }
+ let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio
+ if maximumSize.width / maximumSize.height > ratio {
+ return CGSize(width: maximumSize.height * ratio, height: maximumSize.height)
+ }
+ return CGSize(width: maximumSize.width, height: maximumSize.width / ratio)
+ }
+}
+
+/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。
+final class BeforeAfterComparisonViewModel {
+ let content: BeforeAfterComparisonContent
+
+ /// 创建指定内容的前后对比页状态。
+ init(content: BeforeAfterComparisonContent) {
+ self.content = content
+ }
+
+ /// 原图远程地址。
+ var beforeImageURL: URL? {
+ URL(string: content.beforeURL)
+ }
+
+ /// 效果图远程地址。
+ var afterImageURL: URL? {
+ URL(string: content.afterURL)
+ }
+
+ /// 两张图片地址都可供页面加载时返回 true。
+ var isValid: Bool {
+ content.isValid && beforeImageURL != nil && afterImageURL != nil
+ }
+}
diff --git a/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift b/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift
new file mode 100644
index 0000000..321f770
--- /dev/null
+++ b/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift
@@ -0,0 +1,520 @@
+//
+// BeforeAfterComparisonViewController.swift
+// suixinkan
+//
+
+import Kingfisher
+import SnapKit
+import UIKit
+
+/// 暗色全屏前后图片对比页,通过可拖拽分隔线查看原图与效果图。
+final class BeforeAfterComparisonViewController: UIViewController {
+ private let viewModel: BeforeAfterComparisonViewModel
+ private let backButton = UIButton(type: .system)
+ private let comparisonContainer = UIView()
+ private let afterImageView = UIImageView()
+ private let beforeImageView = UIImageView()
+ private let beforeRevealMask = CAShapeLayer()
+ private let dividerLine = UIView()
+ private let dividerHandle = BeforeAfterComparisonHandleView()
+ private let beforeLabel = BeforeAfterComparisonLabel()
+ private let afterLabel = BeforeAfterComparisonLabel()
+ private let statusContainer = UIView()
+ private let activityIndicator = UIActivityIndicatorView(style: .large)
+ private let statusLabel = UILabel()
+ private let retryButton = UIButton(type: .system)
+
+ private var dividerFraction: CGFloat = 0.5
+ private var loadGeneration = 0
+ private var beforeLoaded = false
+ private var afterLoaded = false
+ private var isReady = false
+ private var displayLink: CADisplayLink?
+ private var animationStartTime: CFTimeInterval?
+ private var isDraggingDivider = false
+ private var imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
+ private var beforeImageSize: CGSize?
+ private var afterImageSize: CGSize?
+
+ /// 创建指定前后图片内容的全屏对比页。
+ init(viewModel: BeforeAfterComparisonViewModel) {
+ self.viewModel = viewModel
+ super.init(nibName: nil, bundle: nil)
+ modalPresentationStyle = .fullScreen
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ setupUI()
+ setupConstraints()
+ bindActions()
+ loadImages()
+ }
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+ layoutComparisonContainer()
+ layoutDivider()
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ startAutomaticMotion(from: dividerFraction)
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ stopAutomaticMotion()
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ displayLink?.invalidate()
+ displayLink = nil
+ }
+
+ deinit {
+ displayLink?.invalidate()
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ private func setupUI() {
+ view.backgroundColor = UIColor(hex: 0x05070D)
+ view.accessibilityIdentifier = "travelAlbum.beforeAfterComparison"
+
+ var backConfiguration = UIButton.Configuration.filled()
+ backConfiguration.image = UIImage(systemName: "chevron.left")
+ backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
+ pointSize: 19,
+ weight: .semibold
+ )
+ backConfiguration.baseForegroundColor = .white
+ backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
+ backConfiguration.background.cornerRadius = 24
+ backConfiguration.contentInsets = .zero
+ backButton.configuration = backConfiguration
+ backButton.accessibilityLabel = "返回"
+ backButton.accessibilityIdentifier = "travelAlbum.beforeAfterBackButton"
+
+ comparisonContainer.backgroundColor = UIColor(hex: 0x111827)
+ comparisonContainer.layer.cornerRadius = 0
+ comparisonContainer.layer.borderColor = UIColor(hex: 0x263244).cgColor
+ comparisonContainer.layer.borderWidth = 1
+ comparisonContainer.clipsToBounds = true
+ comparisonContainer.accessibilityIdentifier = "travelAlbum.beforeAfterImageContainer"
+
+ [afterImageView, beforeImageView].forEach { imageView in
+ imageView.contentMode = .scaleAspectFit
+ imageView.clipsToBounds = true
+ imageView.backgroundColor = UIColor(hex: 0x111827)
+ imageView.isHidden = true
+ }
+ afterImageView.accessibilityLabel = viewModel.content.afterLabel
+ beforeImageView.accessibilityLabel = viewModel.content.beforeLabel
+ beforeImageView.layer.mask = beforeRevealMask
+
+ dividerLine.backgroundColor = UIColor.white.withAlphaComponent(0.72)
+ dividerLine.layer.shadowColor = UIColor.black.cgColor
+ dividerLine.layer.shadowOpacity = 0.35
+ dividerLine.layer.shadowRadius = 2
+ dividerLine.layer.shadowOffset = .zero
+ dividerLine.isHidden = true
+ dividerLine.isUserInteractionEnabled = false
+ dividerHandle.isHidden = true
+ dividerHandle.accessibilityIdentifier = "travelAlbum.beforeAfterDivider"
+
+ beforeLabel.text = viewModel.content.beforeLabel
+ beforeLabel.accessibilityIdentifier = "travelAlbum.beforeLabel"
+ afterLabel.text = viewModel.content.afterLabel
+ afterLabel.accessibilityIdentifier = "travelAlbum.afterLabel"
+ beforeLabel.isHidden = true
+ afterLabel.isHidden = true
+
+ statusContainer.backgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.86)
+ activityIndicator.color = .white
+ activityIndicator.hidesWhenStopped = true
+ statusLabel.textColor = UIColor.white.withAlphaComponent(0.78)
+ statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
+ statusLabel.textAlignment = .center
+ statusLabel.numberOfLines = 0
+ statusLabel.text = "正在加载对比图片"
+ statusLabel.accessibilityIdentifier = "travelAlbum.beforeAfterStatusLabel"
+ var retryConfiguration = UIButton.Configuration.filled()
+ retryConfiguration.title = "重试"
+ retryConfiguration.baseForegroundColor = .white
+ retryConfiguration.baseBackgroundColor = UIColor(hex: 0x1677FF)
+ retryConfiguration.background.cornerRadius = 12
+ retryButton.configuration = retryConfiguration
+ retryButton.accessibilityIdentifier = "travelAlbum.beforeAfterRetryButton"
+ retryButton.isHidden = true
+
+ view.addSubview(backButton)
+ view.addSubview(comparisonContainer)
+ comparisonContainer.addSubview(afterImageView)
+ comparisonContainer.addSubview(beforeImageView)
+ comparisonContainer.addSubview(dividerLine)
+ comparisonContainer.addSubview(dividerHandle)
+ comparisonContainer.addSubview(beforeLabel)
+ comparisonContainer.addSubview(afterLabel)
+ comparisonContainer.addSubview(statusContainer)
+ statusContainer.addSubview(activityIndicator)
+ statusContainer.addSubview(statusLabel)
+ statusContainer.addSubview(retryButton)
+ }
+
+ private func setupConstraints() {
+ backButton.snp.makeConstraints { make in
+ make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
+ make.leading.equalToSuperview().offset(18)
+ make.size.equalTo(48)
+ }
+ afterImageView.snp.makeConstraints { make in
+ make.edges.equalToSuperview()
+ }
+ beforeImageView.snp.makeConstraints { make in
+ make.edges.equalToSuperview()
+ }
+ beforeLabel.snp.makeConstraints { make in
+ make.leading.bottom.equalToSuperview().inset(10)
+ make.height.equalTo(26)
+ }
+ afterLabel.snp.makeConstraints { make in
+ make.trailing.bottom.equalToSuperview().inset(10)
+ make.height.equalTo(26)
+ }
+ statusContainer.snp.makeConstraints { make in
+ make.edges.equalToSuperview()
+ }
+ activityIndicator.snp.makeConstraints { make in
+ make.centerX.equalToSuperview()
+ make.centerY.equalToSuperview().offset(-28)
+ }
+ statusLabel.snp.makeConstraints { make in
+ make.top.equalTo(activityIndicator.snp.bottom).offset(14)
+ make.leading.trailing.equalToSuperview().inset(32)
+ }
+ retryButton.snp.makeConstraints { make in
+ make.top.equalTo(statusLabel.snp.bottom).offset(14)
+ make.centerX.equalToSuperview()
+ make.width.equalTo(96)
+ make.height.equalTo(44)
+ }
+ }
+
+ private func bindActions() {
+ backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
+ retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
+ let scrubberGesture = UILongPressGestureRecognizer(target: self, action: #selector(dividerDragged(_:)))
+ scrubberGesture.minimumPressDuration = 0
+ scrubberGesture.allowableMovement = .greatestFiniteMagnitude
+ comparisonContainer.addGestureRecognizer(scrubberGesture)
+ dividerHandle.onIncrement = { [weak self] in self?.adjustDivider(by: 0.1) }
+ dividerHandle.onDecrement = { [weak self] in self?.adjustDivider(by: -0.1) }
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(reduceMotionStatusChanged),
+ name: UIAccessibility.reduceMotionStatusDidChangeNotification,
+ object: nil
+ )
+ }
+
+ private func loadImages() {
+ loadGeneration += 1
+ let generation = loadGeneration
+ beforeImageView.kf.cancelDownloadTask()
+ afterImageView.kf.cancelDownloadTask()
+ beforeImageView.image = nil
+ afterImageView.image = nil
+ beforeLoaded = false
+ afterLoaded = false
+ beforeImageSize = nil
+ afterImageSize = nil
+ imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
+ isReady = false
+ dividerFraction = BeforeAfterComparisonMotion.automaticCenter
+ stopAutomaticMotion()
+ setComparisonVisible(false)
+ statusContainer.isHidden = false
+ statusLabel.text = "正在加载对比图片"
+ retryButton.isHidden = true
+ activityIndicator.startAnimating()
+
+ guard viewModel.isValid,
+ let beforeURL = viewModel.beforeImageURL,
+ let afterURL = viewModel.afterImageURL
+ else {
+ showLoadFailure()
+ return
+ }
+
+ beforeImageView.kf.setImage(with: beforeURL) { [weak self] result in
+ self?.handleImageResult(result, isBefore: true, generation: generation)
+ }
+ afterImageView.kf.setImage(with: afterURL) { [weak self] result in
+ self?.handleImageResult(result, isBefore: false, generation: generation)
+ }
+ }
+
+ private func handleImageResult(
+ _ result: Result,
+ isBefore: Bool,
+ generation: Int
+ ) {
+ guard generation == loadGeneration else { return }
+ switch result {
+ case let .success(value):
+ if isBefore {
+ beforeLoaded = true
+ beforeImageSize = value.image.size
+ } else {
+ afterLoaded = true
+ afterImageSize = value.image.size
+ }
+ guard beforeLoaded && afterLoaded else { return }
+ let resolvedSize = afterImageSize ?? beforeImageSize ?? .zero
+ imageAspectRatio = BeforeAfterComparisonLayout.aspectRatio(for: resolvedSize)
+ view.setNeedsLayout()
+ view.layoutIfNeeded()
+ isReady = true
+ activityIndicator.stopAnimating()
+ statusContainer.isHidden = true
+ setComparisonVisible(true)
+ layoutDivider()
+ startAutomaticMotion(from: dividerFraction)
+ UIAccessibility.post(notification: .announcement, argument: "对比图片已加载")
+ case .failure:
+ showLoadFailure()
+ }
+ }
+
+ private func showLoadFailure() {
+ isReady = false
+ activityIndicator.stopAnimating()
+ statusContainer.isHidden = false
+ statusLabel.text = "图片加载失败,请重试"
+ retryButton.isHidden = false
+ setComparisonVisible(false)
+ UIAccessibility.post(notification: .announcement, argument: statusLabel.text)
+ }
+
+ private func setComparisonVisible(_ visible: Bool) {
+ afterImageView.isHidden = !visible
+ beforeImageView.isHidden = !visible
+ dividerLine.isHidden = !visible
+ dividerHandle.isHidden = !visible
+ beforeLabel.isHidden = !visible
+ afterLabel.isHidden = !visible
+ }
+
+ private func layoutComparisonContainer() {
+ let safeFrame = view.bounds.inset(by: view.safeAreaInsets)
+ let topBoundary = max(safeFrame.minY, backButton.frame.maxY + 24)
+ let bottomBoundary = safeFrame.maxY - 24
+ let center = CGPoint(x: view.bounds.midX, y: safeFrame.midY)
+ let verticalRadius = max(
+ 1,
+ min(center.y - topBoundary, bottomBoundary - center.y)
+ )
+ let maximumSize = CGSize(
+ width: max(1, view.bounds.width),
+ height: verticalRadius * 2
+ )
+ let fittedSize = BeforeAfterComparisonLayout.fittedSize(
+ aspectRatio: imageAspectRatio,
+ maximumSize: maximumSize
+ )
+ comparisonContainer.frame = CGRect(
+ x: center.x - fittedSize.width / 2,
+ y: center.y - fittedSize.height / 2,
+ width: fittedSize.width,
+ height: fittedSize.height
+ )
+ comparisonContainer.layoutIfNeeded()
+ }
+
+ private func layoutDivider() {
+ guard comparisonContainer.bounds.width > 0 else { return }
+ let bounds = comparisonContainer.bounds
+ let x = bounds.width * dividerFraction
+ beforeRevealMask.frame = beforeImageView.bounds
+ beforeRevealMask.path = UIBezierPath(
+ rect: CGRect(x: 0, y: 0, width: x, height: bounds.height)
+ ).cgPath
+ dividerLine.frame = CGRect(x: x - 1.5, y: 0, width: 3, height: bounds.height)
+ dividerHandle.frame = CGRect(x: x - 24, y: bounds.midY - 24, width: 48, height: 48)
+ dividerHandle.accessibilityValue = "原图占比 (Int((dividerFraction * 100).rounded()))%"
+ }
+
+ private func updateDivider(locationX: CGFloat) {
+ guard isReady, comparisonContainer.bounds.width > 0 else { return }
+ dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(
+ locationX / comparisonContainer.bounds.width
+ )
+ layoutDivider()
+ }
+
+ private func adjustDivider(by delta: CGFloat) {
+ stopAutomaticMotion()
+ dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(dividerFraction + delta)
+ layoutDivider()
+ }
+
+ @objc private func dividerDragged(_ gesture: UILongPressGestureRecognizer) {
+ switch gesture.state {
+ case .began:
+ isDraggingDivider = true
+ stopAutomaticMotion()
+ dividerHandle.setActive(true, animated: !UIAccessibility.isReduceMotionEnabled)
+ updateDivider(locationX: gesture.location(in: comparisonContainer).x)
+ case .changed:
+ updateDivider(locationX: gesture.location(in: comparisonContainer).x)
+ case .ended, .cancelled, .failed:
+ updateDivider(locationX: gesture.location(in: comparisonContainer).x)
+ isDraggingDivider = false
+ dividerHandle.setActive(false, animated: !UIAccessibility.isReduceMotionEnabled)
+ startAutomaticMotion(from: dividerFraction)
+ default:
+ break
+ }
+ }
+
+ @objc private func automaticMotionTick(_ link: CADisplayLink) {
+ guard isReady, !isDraggingDivider, let animationStartTime else { return }
+ dividerFraction = BeforeAfterComparisonMotion.automaticFraction(
+ elapsed: link.timestamp - animationStartTime
+ )
+ layoutDivider()
+ }
+
+ private func startAutomaticMotion(from fraction: CGFloat) {
+ guard isReady,
+ viewIfLoaded?.window != nil,
+ !isDraggingDivider,
+ !UIAccessibility.isReduceMotionEnabled
+ else { return }
+ animationStartTime = CACurrentMediaTime() - BeforeAfterComparisonMotion.phaseTime(for: fraction)
+ if displayLink == nil {
+ let link = CADisplayLink(target: self, selector: #selector(automaticMotionTick(_:)))
+ link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
+ link.add(to: .main, forMode: .common)
+ displayLink = link
+ }
+ displayLink?.isPaused = false
+ }
+
+ private func stopAutomaticMotion() {
+ displayLink?.isPaused = true
+ animationStartTime = nil
+ }
+
+ @objc private func reduceMotionStatusChanged() {
+ if UIAccessibility.isReduceMotionEnabled {
+ stopAutomaticMotion()
+ } else {
+ startAutomaticMotion(from: dividerFraction)
+ }
+ }
+
+ @objc private func backTapped() {
+ dismiss(animated: true)
+ }
+
+ @objc private func retryTapped() {
+ loadImages()
+ }
+}
+
+/// 前后对比分隔线的可调节圆形手柄。
+private final class BeforeAfterComparisonHandleView: UIView {
+ var onIncrement: (() -> Void)?
+ var onDecrement: (() -> Void)?
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ backgroundColor = UIColor.black.withAlphaComponent(0.38)
+ layer.cornerRadius = 24
+ layer.borderColor = UIColor.white.withAlphaComponent(0.82).cgColor
+ layer.borderWidth = 2
+ layer.shadowColor = UIColor.black.cgColor
+ layer.shadowOpacity = 0.32
+ layer.shadowRadius = 7
+ layer.shadowOffset = CGSize(width: 0, height: 3)
+ isAccessibilityElement = true
+ accessibilityLabel = "前后对比分隔线"
+ accessibilityTraits = [.adjustable]
+
+ let imageView = UIImageView(image: UIImage(systemName: "arrow.left.and.right"))
+ imageView.tintColor = .white
+ imageView.contentMode = .scaleAspectFit
+ addSubview(imageView)
+ imageView.snp.makeConstraints { make in
+ make.center.equalToSuperview()
+ make.size.equalTo(21)
+ }
+ }
+
+ /// 更新手柄按压态,为拖动接管提供轻微但不改变布局的视觉反馈。
+ func setActive(_ active: Bool, animated: Bool) {
+ let changes = {
+ self.transform = active ? CGAffineTransform(scaleX: 1.08, y: 1.08) : .identity
+ self.backgroundColor = UIColor.black.withAlphaComponent(active ? 0.52 : 0.38)
+ }
+ guard animated else {
+ changes()
+ return
+ }
+ UIView.animate(
+ withDuration: 0.18,
+ delay: 0,
+ options: [.curveEaseOut, .beginFromCurrentState],
+ animations: changes
+ )
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func accessibilityIncrement() {
+ onIncrement?()
+ }
+
+ override func accessibilityDecrement() {
+ onDecrement?()
+ }
+}
+
+/// 前后对比图片底部的高对比度胶囊角标。
+private final class BeforeAfterComparisonLabel: UILabel {
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ textColor = .white
+ font = .systemFont(ofSize: 12, weight: .medium)
+ textAlignment = .center
+ backgroundColor = UIColor.black.withAlphaComponent(0.5)
+ layer.cornerRadius = 13
+ layer.borderColor = UIColor.white.withAlphaComponent(0.2).cgColor
+ layer.borderWidth = 0.5
+ clipsToBounds = true
+ setContentHuggingPriority(.required, for: .horizontal)
+ layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override var intrinsicContentSize: CGSize {
+ let size = super.intrinsicContentSize
+ return CGSize(width: size.width + 20, height: 26)
+ }
+}
diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift
index 1eb6fea..4196bef 100644
--- a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift
+++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift
@@ -434,8 +434,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
}
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
- let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
- controller.modalPresentationStyle = .fullScreen
+ guard let content = template.comparisonContent else {
+ showToast("暂无对比预览")
+ return
+ }
+ let controller = BeforeAfterComparisonViewController(
+ viewModel: BeforeAfterComparisonViewModel(content: content)
+ )
present(controller, animated: true)
}
}
@@ -908,128 +913,6 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
}
}
-/// 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() {
- let placeholder = UIImage(named: "ai_retouch_template_placeholder")
- guard let url = URL(string: template.previewURL), !template.previewURL.isEmpty else {
- imageView.image = placeholder
- return
- }
- imageView.kf.setImage(
- with: url,
- placeholder: placeholder
- )
- }
-
- @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)
diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift
index 27956ed..51658fb 100644
--- a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift
+++ b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift
@@ -48,6 +48,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private let variantControls = UIView()
private let variantSegmentedControl = UISegmentedControl()
private let highResolutionButton = UIButton(type: .system)
+ private let comparisonButton = UIButton(type: .system)
private let actionStack = UIStackView()
private let deleteButton = UIButton(type: .system)
private let refreshButton = UIButton(type: .system)
@@ -193,6 +194,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
)
actionStack.accessibilityIdentifier = "travelAlbum.previewActionStack"
configureHighResolutionButton()
+ configureComparisonButton()
configureActions()
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
@@ -212,6 +214,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
variantControls.addSubview(variantSegmentedControl)
variantControls.addSubview(highResolutionButton)
bottomChrome.addSubview(actionStack)
+ view.addSubview(comparisonButton)
topChrome.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
@@ -274,6 +277,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
make.height.equalTo(70)
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
+ comparisonButton.snp.makeConstraints { make in
+ make.trailing.equalToSuperview().inset(16)
+ make.bottom.equalTo(bottomChrome.snp.top).offset(-12)
+ make.size.equalTo(48)
+ }
}
private func configureHighResolutionButton() {
@@ -293,6 +301,27 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside)
}
+ private func configureComparisonButton() {
+ var configuration = UIButton.Configuration.filled()
+ configuration.image = UIImage(named: "travel_album_before_after")?.withRenderingMode(.alwaysTemplate)
+ configuration.contentInsets = .zero
+ configuration.baseForegroundColor = .white
+ configuration.baseBackgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.94)
+ configuration.background.cornerRadius = 24
+ configuration.background.strokeColor = UIColor(hex: 0x263244)
+ configuration.background.strokeWidth = 1
+ comparisonButton.configuration = configuration
+ comparisonButton.accessibilityLabel = "前后对比"
+ comparisonButton.accessibilityHint = "对比当前效果图与原图"
+ comparisonButton.accessibilityIdentifier = "travelAlbum.previewComparisonButton"
+ comparisonButton.layer.shadowColor = UIColor.black.cgColor
+ comparisonButton.layer.shadowOpacity = 0.28
+ comparisonButton.layer.shadowRadius = 8
+ comparisonButton.layer.shadowOffset = CGSize(width: 0, height: 3)
+ comparisonButton.isHidden = true
+ comparisonButton.addTarget(self, action: #selector(comparisonTapped), for: .touchUpInside)
+ }
+
private func configureActions() {
let aiButton = makeActionButton(
title: "AI修图",
@@ -392,9 +421,21 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
highResolutionButton.isEnabled = asset != nil
highResolutionButton.alpha = asset == nil ? 0.45 : 1
rebuildTabs()
+ updateComparisonButton()
loadMoreIfNeeded(projectIndex: node.projectIndex)
}
+ private func updateComparisonButton() {
+ let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
+ let shouldShow = chromeVisible && hasComparison
+ comparisonButton.isHidden = !hasComparison
+ comparisonButton.isUserInteractionEnabled = shouldShow
+ comparisonButton.alpha = shouldShow ? 1 : 0
+ comparisonButton.transform = shouldShow
+ ? .identity
+ : CGAffineTransform(translationX: 0, y: 10)
+ }
+
private func rebuildTabs() {
while variantSegmentedControl.numberOfSegments > 0 {
variantSegmentedControl.removeSegment(at: 0, animated: false)
@@ -581,6 +622,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private func toggleChrome() {
chromeVisible.toggle()
+ let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
+ if chromeVisible && hasComparison {
+ comparisonButton.isHidden = false
+ }
+ comparisonButton.isUserInteractionEnabled = chromeVisible && hasComparison
let reduceMotion = UIAccessibility.isReduceMotionEnabled
let animations = {
self.topChrome.alpha = self.chromeVisible ? 1 : 0
@@ -591,12 +637,20 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
self.bottomChrome.transform = self.chromeVisible
? .identity
: CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height)
+ self.comparisonButton.alpha = self.chromeVisible && hasComparison ? 1 : 0
+ self.comparisonButton.transform = self.chromeVisible && hasComparison
+ ? .identity
+ : CGAffineTransform(translationX: 0, y: 10)
}
UIView.animate(
withDuration: reduceMotion ? 0.12 : 0.22,
delay: 0,
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
- animations: animations
+ animations: animations,
+ completion: { [weak self] _ in
+ guard let self else { return }
+ self.comparisonButton.isHidden = !self.chromeVisible || !hasComparison
+ }
)
}
@@ -632,6 +686,17 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
toggleChrome()
}
+ @objc private func comparisonTapped() {
+ guard presentedViewController == nil,
+ let project = currentProject,
+ let content = project.comparisonContent(for: currentAsset?.kind ?? .original)
+ else { return }
+ let controller = BeforeAfterComparisonViewController(
+ viewModel: BeforeAfterComparisonViewModel(content: content)
+ )
+ present(controller, animated: true)
+ }
+
@objc private func aiTapped() {
guard presentedViewController == nil else { return }
guard let project = currentProject else { return }
diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift
index 482784e..c6a85c4 100644
--- a/suixinkanTests/TravelAlbumAPITests.swift
+++ b/suixinkanTests/TravelAlbumAPITests.swift
@@ -162,7 +162,7 @@ final class TravelAlbumAPITests: XCTestCase {
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
let data = envelopeJSON(
- #"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg"}],"remaining_quota":12}"#
+ #"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg","before_url":"https://cdn/refined-before.jpg","after_url":"https://cdn/refined-after.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg","before_url":"https://cdn/atmosphere-before.jpg","after_url":"https://cdn/atmosphere-after.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg","before_url":"https://cdn/cover-before.jpg","after_url":"https://cdn/cover-after.jpg"}],"remaining_quota":12}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
@@ -170,6 +170,8 @@ final class TravelAlbumAPITests: XCTestCase {
let response = try await api.aiRetouchTemplates(scenicId: 18)
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
+ XCTAssertEqual(response.refinedTemplates.first?.beforeURL, "https://cdn/refined-before.jpg")
+ XCTAssertEqual(response.refinedTemplates.first?.afterURL, "https://cdn/refined-after.jpg")
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
XCTAssertEqual(response.remainingQuota, 12)
diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift
index 82ee432..d7f9b1f 100644
--- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift
+++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift
@@ -637,35 +637,96 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(cell.accessibilityValue, "未选择")
}
- func testAIRetouchTemplatePreviewUsesImmersiveZoomableLayout() throws {
- let template = TravelAlbumAIRetouchTemplate(
- id: 2,
- name: "简约留白",
- previewURL: ""
+ func testBeforeAfterComparisonUsesImmersiveAccessibleLayout() throws {
+ let controller = BeforeAfterComparisonViewController(
+ viewModel: BeforeAfterComparisonViewModel(
+ content: BeforeAfterComparisonContent(
+ beforeURL: "https://cdn.example.com/before.jpg",
+ afterURL: "https://cdn.example.com/after.jpg"
+ )
+ )
)
- 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(
+ let imageContainer = try XCTUnwrap(
controller.view.findSubview {
- $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewScrollView"
- } as? UIScrollView
+ $0.accessibilityIdentifier == "travelAlbum.beforeAfterImageContainer"
+ }
)
let backButton = try XCTUnwrap(
controller.view.findSubview {
- $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewBackButton"
+ $0.accessibilityIdentifier == "travelAlbum.beforeAfterBackButton"
+ } as? UIButton
+ )
+ let divider = try XCTUnwrap(
+ controller.view.findSubview {
+ $0.accessibilityIdentifier == "travelAlbum.beforeAfterDivider"
+ }
+ )
+
+ XCTAssertEqual(controller.view.backgroundColor, UIColor(hex: 0x05070D))
+ XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图" })
+ XCTAssertTrue(controller.view.allLabels().contains { $0.text == "效果图" })
+ XCTAssertEqual(imageContainer.layer.cornerRadius, 0)
+ XCTAssertTrue(divider.accessibilityTraits.contains(.adjustable))
+ XCTAssertEqual(divider.accessibilityValue, "原图占比 50%")
+ XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44)
+ XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44)
+ }
+
+ func testPreviewComparisonButtonOnlyShowsForSelectedAIResult() throws {
+ UIView.setAnimationsEnabled(false)
+ defer { UIView.setAnimationsEnabled(true) }
+ let project = TravelAlbumPreviewProject(
+ originalMaterialId: 7,
+ assets: [
+ TravelAlbumPreviewAsset(
+ id: "original-7",
+ kind: .original,
+ fileURL: "https://cdn.example.com/original.jpg",
+ coverURL: "",
+ fileName: "原图.jpg",
+ fileSize: 10
+ ),
+ TravelAlbumPreviewAsset(
+ id: "retouched-7",
+ kind: .retouched,
+ fileURL: "https://cdn.example.com/retouched.jpg",
+ coverURL: "",
+ fileName: "精修.jpg",
+ fileSize: 10
+ ),
+ ]
+ )
+ let controller = TravelAlbumPhotoPreviewViewController(
+ projects: [project],
+ totalCount: 1,
+ startProjectIndex: 0
+ )
+ controller.loadViewIfNeeded()
+ controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
+ controller.view.layoutIfNeeded()
+ let segmentedControl = try XCTUnwrap(
+ controller.view.findSubview {
+ $0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl"
+ } as? UISegmentedControl
+ )
+ let comparisonButton = try XCTUnwrap(
+ controller.view.findSubview {
+ $0.accessibilityIdentifier == "travelAlbum.previewComparisonButton"
} 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)
+ XCTAssertTrue(comparisonButton.isHidden)
+ segmentedControl.selectedSegmentIndex = 1
+ segmentedControl.sendActions(for: .valueChanged)
+
+ XCTAssertFalse(comparisonButton.isHidden)
+ XCTAssertNil(comparisonButton.configuration?.title)
+ XCTAssertNotNil(comparisonButton.configuration?.image)
+ XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48))
}
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
diff --git a/suixinkanTests/TravelAlbumModelsTests.swift b/suixinkanTests/TravelAlbumModelsTests.swift
index 2b6f485..aea046b 100644
--- a/suixinkanTests/TravelAlbumModelsTests.swift
+++ b/suixinkanTests/TravelAlbumModelsTests.swift
@@ -8,6 +8,40 @@ import XCTest
/// 旅拍相册模型与展示工具测试。
final class TravelAlbumModelsTests: XCTestCase {
+ func testBeforeAfterComparisonMotionMatchesShipSwiftInteractionRange() {
+ XCTAssertEqual(BeforeAfterComparisonMotion.automaticFraction(elapsed: 0), 0.5, accuracy: 0.001)
+ XCTAssertEqual(
+ BeforeAfterComparisonMotion.automaticFraction(
+ elapsed: .pi / (2 * BeforeAfterComparisonMotion.automaticSpeed)
+ ),
+ 0.8,
+ accuracy: 0.001
+ )
+ XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0), 0.05)
+ XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(1), 0.95)
+ XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0.42), 0.42)
+ }
+
+ func testBeforeAfterComparisonLayoutFitsVariableRatiosWithoutCropping() {
+ let landscape = BeforeAfterComparisonLayout.fittedSize(
+ aspectRatio: 16.0 / 9.0,
+ maximumSize: CGSize(width: 358, height: 580)
+ )
+ let portrait = BeforeAfterComparisonLayout.fittedSize(
+ aspectRatio: 2.0 / 3.0,
+ maximumSize: CGSize(width: 358, height: 580)
+ )
+
+ XCTAssertEqual(landscape.width, 358, accuracy: 0.001)
+ XCTAssertEqual(landscape.height, 201.375, accuracy: 0.001)
+ XCTAssertEqual(portrait.width, 358, accuracy: 0.001)
+ XCTAssertEqual(portrait.height, 537, accuracy: 0.001)
+ XCTAssertEqual(
+ BeforeAfterComparisonLayout.aspectRatio(for: CGSize(width: 4000, height: 3000)),
+ 4.0 / 3.0
+ )
+ }
+
func testPreviewProjectMapsMaterialToOriginalAsset() {
let material = TravelAlbumMaterial(
id: 8,
@@ -68,6 +102,77 @@ final class TravelAlbumModelsTests: XCTestCase {
)
}
+ func testPreviewProjectBuildsComparisonOnlyForValidAIResults() {
+ let project = makePreviewProject(id: 8, kinds: [.original, .retouched, .atmosphere, .cover])
+
+ XCTAssertEqual(
+ project.comparisonContent(for: .retouched),
+ BeforeAfterComparisonContent(
+ beforeURL: "https://cdn.example.com/8-0.jpg",
+ afterURL: "https://cdn.example.com/8-1.jpg"
+ )
+ )
+ XCTAssertNotNil(project.comparisonContent(for: .atmosphere))
+ XCTAssertNil(project.comparisonContent(for: .original))
+ XCTAssertNil(project.comparisonContent(for: .cover))
+
+ let missingResultURL = TravelAlbumPreviewProject(
+ originalMaterialId: 9,
+ assets: [
+ TravelAlbumPreviewAsset(
+ id: "original-9",
+ kind: .original,
+ fileURL: "https://cdn.example.com/original.jpg",
+ coverURL: "",
+ fileName: "原图.jpg",
+ fileSize: 1
+ ),
+ TravelAlbumPreviewAsset(
+ id: "retouched-9",
+ kind: .retouched,
+ fileURL: " ",
+ coverURL: "",
+ fileName: "精修.jpg",
+ fileSize: 1
+ ),
+ ]
+ )
+ XCTAssertNil(missingResultURL.comparisonContent(for: .retouched))
+ }
+
+ func testTemplateComparisonContentRequiresBothURLs() {
+ let valid = TravelAlbumAIRetouchTemplate(
+ id: 1,
+ name: "清透",
+ previewURL: "https://cdn.example.com/preview.jpg",
+ beforeURL: " https://cdn.example.com/before.jpg ",
+ afterURL: "https://cdn.example.com/after.jpg"
+ )
+ let missingAfter = TravelAlbumAIRetouchTemplate(
+ id: 2,
+ name: "暖阳",
+ previewURL: "https://cdn.example.com/preview.jpg",
+ beforeURL: "https://cdn.example.com/before.jpg"
+ )
+
+ XCTAssertEqual(valid.comparisonContent?.beforeURL, "https://cdn.example.com/before.jpg")
+ XCTAssertEqual(valid.comparisonContent?.afterLabel, "效果图")
+ XCTAssertNil(missingAfter.comparisonContent)
+ }
+
+ func testTemplateDecodingDefaultsMissingComparisonURLsToEmptyStrings() throws {
+ let data = Data(
+ #"{"id":3,"name":"旧模板","preview_url":"https://cdn.example.com/preview.jpg"}"#.utf8
+ )
+
+ let template = try JSONDecoder().decode(TravelAlbumAIRetouchTemplate.self, from: data)
+
+ XCTAssertEqual(template.previewURL, "https://cdn.example.com/preview.jpg")
+ XCTAssertEqual(template.beforeURL, "")
+ XCTAssertEqual(template.afterURL, "")
+ XCTAssertNil(template.comparisonContent)
+ }
+
func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() {
let missingOriginal = TravelAlbumPreviewAsset(
id: "cover-only",