From 1757ba5e36782ee88a328dcb4ff58110b8cde891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 18 Aug 2026 16:41:55 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E4=B8=8E=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...TravelAlbumAIJobDetailViewController.swift | 156 ++++++++++-------- ...ravelAlbumPhotoPreviewViewController.swift | 67 +++++++- ...TravelAlbumDetailViewControllerTests.swift | 88 +++++++++- 3 files changed, 237 insertions(+), 74 deletions(-) diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIJobDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIJobDetailViewController.swift index c722f0e..55474ed 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumAIJobDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIJobDetailViewController.swift @@ -533,7 +533,7 @@ private final class AIJobDetailAlbumCard: UIView { } } -/// 任务内容卡,以浅蓝标签展示各输出目标数量并补充额度结算。 +/// 任务内容卡,以三色紧凑标签展示各输出目标数量并补充额度结算。 private final class AIJobDetailContentCard: UIView { private let outputStack = UIStackView() private let quotaLabel = UILabel() @@ -548,7 +548,8 @@ private final class AIJobDetailContentCard: UIView { titleLabel.textColor = AIJobDetailStyle.textPrimary outputStack.axis = .horizontal outputStack.spacing = 10 - outputStack.distribution = .fillEqually + outputStack.distribution = .fill + outputStack.alignment = .center quotaLabel.font = .systemFont(ofSize: 12) quotaLabel.textColor = AIJobDetailStyle.textSecondary quotaLabel.numberOfLines = 0 @@ -565,6 +566,12 @@ private final class AIJobDetailContentCard: UIView { func apply(_ detail: TravelAlbumAIJobDetail) { outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() } detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) } + if let lastChip = outputStack.arrangedSubviews.last { + let spacer = UIView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + outputStack.setCustomSpacing(0, after: lastChip) + outputStack.addArrangedSubview(spacer) + } outputStack.isHidden = detail.outputs.isEmpty let quota = detail.quotaSettlement quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)" @@ -573,32 +580,42 @@ private final class AIJobDetailContentCard: UIView { } private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView { - let container = UIView() - container.backgroundColor = AppColor.primaryLight - container.layer.cornerRadius = 9 - let iconView = UIImageView(image: UIImage(systemName: output.type.symbolName)) - iconView.tintColor = AppColor.primary - iconView.contentMode = .scaleAspectFit - let label = UILabel() - label.text = "\(output.type.shortTitle) \(output.count) 张" + AIJobDetailOutputChip( + text: "\(output.type.shortTitle) \(output.count)张", + backgroundColor: output.type.chipBackgroundColor, + accessibilityIdentifier: "aiRetouchJob.contentChip.\(output.type.chipIdentifier)" + ) + } +} + +/// 依据文字固有宽度展示的任务输出类型标签。 +private final class AIJobDetailOutputChip: UIView { + private let label = UILabel() + + init(text: String, backgroundColor: UIColor, accessibilityIdentifier: String) { + super.init(frame: .zero) + self.backgroundColor = backgroundColor + self.accessibilityIdentifier = accessibilityIdentifier + layer.cornerRadius = 9 + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + label.text = text label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = AIJobDetailStyle.textPrimary - label.adjustsFontSizeToFitWidth = true - label.minimumScaleFactor = 0.75 - container.addSubview(iconView) - container.addSubview(label) - iconView.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(10) - make.centerY.equalToSuperview() - make.size.equalTo(18) - } + label.setContentCompressionResistancePriority(.required, for: .horizontal) + addSubview(label) label.snp.makeConstraints { make in - make.leading.equalTo(iconView.snp.trailing).offset(6) - make.trailing.equalToSuperview().inset(8) + make.leading.trailing.equalToSuperview().inset(12) make.centerY.equalToSuperview() } - container.snp.makeConstraints { $0.height.equalTo(44) } - return container + snp.makeConstraints { $0.height.equalTo(44) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override var intrinsicContentSize: CGSize { + CGSize(width: ceil(label.intrinsicContentSize.width) + 24, height: 44) } } @@ -663,6 +680,9 @@ private final class AIJobDetailTargetRow: UIView { private let templateLabel = UILabel() private let statusIconView = UIImageView() private let statusLabel = UILabel() + private let statusStack = UIStackView() + private let statusRow = UIStackView() + private let contentStack = UIStackView() private let resultButton = UIButton(type: .system) private let errorContainer = UIView() private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill")) @@ -679,8 +699,11 @@ private final class AIJobDetailTargetRow: UIView { titleLabel.font = .systemFont(ofSize: 15, weight: .medium) titleLabel.textColor = AIJobDetailStyle.textPrimary titleLabel.lineBreakMode = .byTruncatingMiddle + titleLabel.numberOfLines = 1 templateLabel.font = .systemFont(ofSize: 13) templateLabel.textColor = AIJobDetailStyle.textSecondary + templateLabel.lineBreakMode = .byTruncatingTail + templateLabel.numberOfLines = 1 statusIconView.contentMode = .scaleAspectFit statusLabel.font = .systemFont(ofSize: 14, weight: .medium) resultButton.setTitle("查看结果", for: .normal) @@ -693,49 +716,45 @@ private final class AIJobDetailTargetRow: UIView { errorLabel.textColor = AppColor.danger errorLabel.numberOfLines = 0 - let statusStack = UIStackView(arrangedSubviews: [statusIconView, statusLabel]) + statusStack.addArrangedSubview(statusIconView) + statusStack.addArrangedSubview(statusLabel) statusStack.axis = .horizontal statusStack.spacing = 6 statusStack.alignment = .center + statusStack.setContentCompressionResistancePriority(.required, for: .horizontal) + statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + let statusSpacer = UIView() + statusRow.addArrangedSubview(statusStack) + statusRow.addArrangedSubview(statusSpacer) + statusRow.addArrangedSubview(resultButton) + statusRow.axis = .horizontal + statusRow.spacing = 8 + statusRow.alignment = .center + contentStack.addArrangedSubview(titleLabel) + contentStack.addArrangedSubview(templateLabel) + contentStack.addArrangedSubview(statusRow) + contentStack.addArrangedSubview(errorContainer) + contentStack.axis = .vertical + contentStack.spacing = 0 + contentStack.setCustomSpacing(7, after: titleLabel) + contentStack.setCustomSpacing(8, after: templateLabel) + contentStack.setCustomSpacing(12, after: statusRow) statusIconView.snp.makeConstraints { $0.size.equalTo(18) } addSubview(thumbnailView) - addSubview(titleLabel) - addSubview(templateLabel) - addSubview(statusStack) - addSubview(resultButton) - addSubview(errorContainer) + addSubview(contentStack) errorContainer.addSubview(errorIconView) errorContainer.addSubview(errorLabel) thumbnailView.snp.makeConstraints { make in make.leading.top.equalToSuperview().offset(4) make.size.equalTo(80) + make.bottom.lessThanOrEqualToSuperview().inset(12) } - titleLabel.snp.makeConstraints { make in + contentStack.snp.makeConstraints { make in make.leading.equalTo(thumbnailView.snp.trailing).offset(14) make.top.equalTo(thumbnailView).offset(18) - make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8) - } - templateLabel.snp.makeConstraints { make in - make.leading.equalTo(titleLabel) - make.top.equalTo(titleLabel.snp.bottom).offset(7) - make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8) - } - statusStack.snp.makeConstraints { make in - make.trailing.equalToSuperview().inset(4) - make.centerY.equalTo(thumbnailView).offset(-7) - } - resultButton.snp.makeConstraints { make in - make.trailing.equalToSuperview().inset(4) - make.top.equalTo(statusStack.snp.bottom).offset(4) - make.height.equalTo(28) - } - errorContainer.snp.makeConstraints { make in - make.top.equalTo(templateLabel.snp.bottom).offset(12) - make.leading.equalTo(titleLabel) make.trailing.equalToSuperview().inset(4) make.bottom.equalToSuperview().inset(12) - make.height.greaterThanOrEqualTo(38) } errorIconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) @@ -780,21 +799,9 @@ private final class AIJobDetailTargetRow: UIView { errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" } let showsError = target.displayFailureMessage != nil errorContainer.isHidden = !showsError - errorContainer.snp.remakeConstraints { make in - if showsError { - make.top.equalTo(templateLabel.snp.bottom).offset(12) - } else { - make.top.equalTo(thumbnailView.snp.bottom) - } - make.leading.equalTo(titleLabel) - make.trailing.equalToSuperview().inset(4) - make.bottom.equalToSuperview().inset(12) - if showsError { - make.height.greaterThanOrEqualTo(38) - } else { - make.height.equalTo(0) - } - } + titleLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).title" + templateLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).template" + statusStack.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).status" accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text] .compactMap { $0 }.joined(separator: ",") } @@ -896,12 +903,21 @@ private extension TravelAlbumAIJobOutputType { } } - var symbolName: String { + var chipBackgroundColor: UIColor { switch self { - case .refined: "wand.and.stars" - case .atmosphere: "sun.max" - case .cover: "bookmark" - case .unknown: "sparkles" + case .refined: UIColor(hex: 0xEAF3FF) + case .atmosphere: UIColor(hex: 0xFFF2DC) + case .cover: UIColor(hex: 0xF2ECFF) + case .unknown: UIColor(hex: 0xF1F5F9) + } + } + + var chipIdentifier: String { + switch self { + case .refined: "refined" + case .atmosphere: "atmosphere" + case .cover: "cover" + case .unknown: "unknown" } } } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift index 899b14d..66aee0c 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift @@ -91,7 +91,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { self.allowsActions = allowsActions super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen - rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: startKind) + let resolvedProjectIndex = max(0, min(startProjectIndex, self.projects.count - 1)) + let initialProject = self.projects.indices.contains(resolvedProjectIndex) + ? self.projects[resolvedProjectIndex] + : nil + let resolvedStartKind = initialProject?.asset(for: startKind) == nil + ? initialProject?.orderedAssets.first?.kind ?? .original + : startKind + selectedKind = resolvedStartKind + rebuildNodes(keepingProjectIndex: resolvedProjectIndex, kind: resolvedStartKind) } @available(*, unavailable) @@ -165,6 +173,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52) sizeLabel.font = .systemFont(ofSize: 13, weight: .regular) sizeLabel.textAlignment = .center + sizeLabel.accessibilityIdentifier = "travelAlbum.previewFileSizeLabel" counterLabel.textColor = UIColor.white.withAlphaComponent(0.82) counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium) @@ -423,7 +432,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController { guard let node = currentNode else { return } let asset = currentAsset titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片" - sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0) + let fileSize = asset?.fileSize ?? 0 + sizeLabel.text = fileSize > 0 ? TravelAlbumDisplayFormatter.fileSizeText(fileSize) : nil + sizeLabel.isHidden = fileSize <= 0 counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))" counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张" highResolutionButton.isEnabled = asset != nil @@ -982,6 +993,56 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC } } +/// 深色图片预览中使用的高对比度延迟加载指示器。 +@MainActor +private final class TravelAlbumPreviewLoadingIndicator: Indicator { + private let containerView = UIView() + private let activityIndicator = UIActivityIndicatorView(style: .medium) + private var revealTask: Task? + + var view: IndicatorView { containerView } + + init() { + containerView.backgroundColor = UIColor(red: 39 / 255, green: 39 / 255, blue: 42 / 255, alpha: 0.94) + containerView.layer.cornerRadius = 22 + containerView.isAccessibilityElement = true + containerView.accessibilityIdentifier = "travelAlbum.previewLoadingIndicator" + containerView.accessibilityLabel = "图片加载中" + activityIndicator.color = UIColor.white.withAlphaComponent(0.92) + activityIndicator.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) + containerView.addSubview(activityIndicator) + activityIndicator.snp.makeConstraints { $0.center.equalToSuperview() } + } + + func startAnimatingView() { + revealTask?.cancel() + containerView.alpha = 0 + containerView.isHidden = true + revealTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled, let self else { return } + activityIndicator.startAnimating() + containerView.isHidden = false + UIView.animate( + withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.12, + animations: { self.containerView.alpha = 1 } + ) + } + } + + func stopAnimatingView() { + revealTask?.cancel() + revealTask = nil + activityIndicator.stopAnimating() + containerView.alpha = 0 + containerView.isHidden = true + } + + func sizeStrategy(in imageView: KFCrossPlatformImageView) -> IndicatorSizeStrategy { + .size(CGSize(width: 44, height: 44)) + } +} + /// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。 private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate { static let reuseIdentifier = "TravelAlbumPreviewImageCell" @@ -1054,7 +1115,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV imageView.contentMode = .scaleAspectFit imageView.backgroundColor = .black - imageView.kf.indicatorType = .activity + imageView.kf.indicatorType = .custom(indicator: TravelAlbumPreviewLoadingIndicator()) imageView.accessibilityIdentifier = "travelAlbum.previewImageView" retryButton.setTitle("图片加载失败,点击重试", for: .normal) diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index b7b948e..ed1515b 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -14,7 +14,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { let api = TravelAlbumMockAPI() let source = TravelAlbumAIJobSourceMaterial( id: 691, - fileName: "IMG_8293.JPG", + fileName: "beauty_1757851944526_这个文件标题特别长用于验证布局不会挤压状态.jpg", thumbnailURL: "" ) let failedTarget = TravelAlbumAIJobTarget( @@ -104,6 +104,42 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图")) XCTAssertFalse(labels.contains("去相册处理")) XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") }) + let contentCard = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "aiRetouchJob.detail.contentCard" + }) + let refinedChip = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "aiRetouchJob.contentChip.refined" + }) + let atmosphereChip = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "aiRetouchJob.contentChip.atmosphere" + }) + let coverChip = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "aiRetouchJob.contentChip.cover" + }) + XCTAssertEqual(refinedChip.backgroundColor?.travelAlbumTestHexRGB, 0xEAF3FF) + XCTAssertEqual(atmosphereChip.backgroundColor?.travelAlbumTestHexRGB, 0xFFF2DC) + XCTAssertEqual(coverChip.backgroundColor?.travelAlbumTestHexRGB, 0xF2ECFF) + XCTAssertNil(refinedChip.findSubview { $0 is UIImageView }) + XCTAssertNil(atmosphereChip.findSubview { $0 is UIImageView }) + XCTAssertNil(coverChip.findSubview { $0 is UIImageView }) + XCTAssertGreaterThan(atmosphereChip.bounds.width, refinedChip.bounds.width) + let coverFrame = coverChip.convert(coverChip.bounds, to: contentCard) + XCTAssertLessThan(coverFrame.maxX, contentCard.bounds.maxX - 16) + let titleLabel = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "aiRetouchJob.target.3.title" + }) + let templateLabel = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "aiRetouchJob.target.3.template" + }) + let statusView = try XCTUnwrap(controller.view.findSubview { + $0.accessibilityIdentifier == "aiRetouchJob.target.3.status" + }) + let titleFrame = titleLabel.convert(titleLabel.bounds, to: controller.view) + let templateFrame = templateLabel.convert(templateLabel.bounds, to: controller.view) + let statusFrame = statusView.convert(statusView.bounds, to: controller.view) + XCTAssertLessThanOrEqual(titleFrame.maxX, controller.view.bounds.maxX - 18) + XCTAssertGreaterThan(statusFrame.minY, templateFrame.maxY) + XCTAssertLessThanOrEqual(statusFrame.maxX, controller.view.bounds.maxX - 18) let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in window.layer.render(in: context.cgContext) } @@ -315,6 +351,56 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(counterContainer.bounds.width - counterLabel.frame.maxX, 12, accuracy: 0.5) } + func testPreviewAppliesRequestedInitialKindForCoverOnlyProject() throws { + let cover = TravelAlbumPreviewAsset( + id: "cover-897", + kind: .cover, + fileURL: "https://example.com/final.png", + coverURL: "https://example.com/final.png", + fileName: "AI封面", + fileSize: 0 + ) + let controller = TravelAlbumPhotoPreviewViewController( + projects: [TravelAlbumPreviewProject(originalMaterialId: 897, assets: [cover])], + totalCount: 1, + startProjectIndex: 0, + startKind: .cover, + allowsActions: false + ) + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.view.layoutIfNeeded() + + let highResolutionButton = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton" + } as? UIButton + ) + let loadingIndicator = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.previewLoadingIndicator" + } + ) + let activityIndicator = try XCTUnwrap( + loadingIndicator.findSubview { $0 is UIActivityIndicatorView } as? UIActivityIndicatorView + ) + let fileSizeLabel = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.previewFileSizeLabel" + } as? UILabel + ) + + XCTAssertTrue(controller.view.allLabels().contains { $0.text == "AI封面" }) + XCTAssertTrue(highResolutionButton.isEnabled) + XCTAssertTrue(fileSizeLabel.isHidden) + XCTAssertNil(fileSizeLabel.text) + XCTAssertEqual(fileSizeLabel.bounds.height, 0, accuracy: 0.5) + XCTAssertEqual(loadingIndicator.bounds.size, CGSize(width: 44, height: 44)) + XCTAssertEqual(loadingIndicator.backgroundColor?.travelAlbumTestHexRGB, 0x27272A) + XCTAssertEqual(loadingIndicator.accessibilityLabel, "图片加载中") + XCTAssertEqual(activityIndicator.color.travelAlbumTestHexRGB, 0xFFFFFF) + } + func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws { let api = TravelAlbumMockAPI() api.deleteMaterialDelayNanoseconds = 1_000_000