import Kingfisher import SnapKit import UIKit /// AI 修图任务详情页,按设计稿展示状态总览、相册信息、任务内容与逐输出处理明细。 final class TravelAlbumAIJobDetailViewController: BaseViewController { private let viewModel: TravelAlbumAIJobDetailViewModel private let api: any TravelAlbumServing private let scrollView = UIScrollView() private let contentStack = UIStackView() private let refreshControl = UIRefreshControl() private let emptyView = AIJobDetailEmptyView() private var pollingTask: Task? private var isVisible = false private var isPresentingLoading = false /// 创建指定批次的任务详情。 init(batchId: Int, api: (any TravelAlbumServing)? = nil) { viewModel = TravelAlbumAIJobDetailViewModel(batchId: batchId) self.api = api ?? NetworkServices.shared.travelAlbumAPI super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { let titleLabel = UILabel() titleLabel.text = "任务详情" titleLabel.textColor = AIJobDetailStyle.textPrimary titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) navigationItem.titleView = titleLabel navigationItem.backButtonDisplayMode = .minimal navigationItem.rightBarButtonItem = UIBarButtonItem( image: UIImage(systemName: "arrow.clockwise"), style: .plain, target: self, action: #selector(manualRefresh) ) navigationItem.rightBarButtonItem?.tintColor = AIJobDetailStyle.textPrimary navigationItem.rightBarButtonItem?.accessibilityLabel = "刷新任务详情" let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() appearance.backgroundColor = .white appearance.shadowColor = .clear navigationItem.standardAppearance = appearance navigationItem.scrollEdgeAppearance = appearance navigationItem.compactAppearance = appearance navigationController?.setNavigationBarHidden(false, animated: false) } override func setupUI() { view.backgroundColor = AIJobDetailStyle.pageBackground scrollView.backgroundColor = .clear scrollView.alwaysBounceVertical = true scrollView.refreshControl = refreshControl contentStack.axis = .vertical contentStack.spacing = 14 emptyView.onRetry = { [weak self] in self?.reload() } view.addSubview(scrollView) scrollView.addSubview(contentStack) view.addSubview(emptyView) } override func setupConstraints() { scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } contentStack.snp.makeConstraints { make in make.top.equalTo(scrollView.contentLayoutGuide).offset(12) make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(18) make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24) } emptyView.snp.makeConstraints { $0.edges.equalToSuperview() } } override func bindActions() { refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } viewModel.onNotFound = { [weak self] in Task { @MainActor in self?.presentNotFound() } } NotificationCenter.default.addObserver( self, selector: #selector(applicationBecameActive), name: UIApplication.didBecomeActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(applicationEnteredBackground), name: UIApplication.didEnterBackgroundNotification, object: nil ) } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.load(api: api) } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) isVisible = true updateLoadingPresentation() updatePolling() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) isVisible = false setLoadingPresented(false) stopPolling() } deinit { pollingTask?.cancel() NotificationCenter.default.removeObserver(self) } @MainActor private func applyState() { refreshControl.endRefreshing() let unavailable = viewModel.detail == nil && !viewModel.isLoading emptyView.isHidden = !unavailable emptyView.apply( title: "任务详情加载失败", message: viewModel.errorMessage ?? "暂时无法获取任务信息,请稍后重试。" ) scrollView.isHidden = viewModel.detail == nil if let detail = viewModel.detail { rebuildContent(detail) } updateLoadingPresentation() updatePolling() } @MainActor private func rebuildContent(_ detail: TravelAlbumAIJobDetail) { contentStack.arrangedSubviews.forEach { contentStack.removeArrangedSubview($0) $0.removeFromSuperview() } let statusCard = AIJobDetailStatusCard() statusCard.apply(detail) contentStack.addArrangedSubview(statusCard) let albumCard = AIJobDetailAlbumCard() albumCard.apply(detail) contentStack.addArrangedSubview(albumCard) let contentCard = AIJobDetailContentCard() contentCard.apply(detail) contentStack.addArrangedSubview(contentCard) let processingCard = AIJobDetailProcessingCard() processingCard.apply( detail.targets, onViewResult: { [weak self] target in self?.openResult(target) } ) contentStack.addArrangedSubview(processingCard) let albumButton = AIJobDetailAlbumButton() albumButton.addTarget(self, action: #selector(openAlbumTapped), for: .touchUpInside) contentStack.addArrangedSubview(albumButton) albumButton.snp.makeConstraints { $0.height.equalTo(56) } } @MainActor private func updateLoadingPresentation() { setLoadingPresented(isVisible && viewModel.isLoading && viewModel.detail == nil) } @MainActor private func setLoadingPresented(_ presented: Bool) { guard isPresentingLoading != presented else { return } isPresentingLoading = presented presented ? showLoading() : hideLoading() } private func reload() { Task { await viewModel.load(api: api) } } @objc private func refreshTriggered() { Task { await viewModel.load(api: api, refreshing: true) } } @objc private func manualRefresh() { Task { await viewModel.load(api: api, refreshing: true) } } @objc private func applicationBecameActive() { updatePolling() } @objc private func applicationEnteredBackground() { stopPolling() } @objc private func openAlbumTapped() { openAlbum() } private func updatePolling() { guard isVisible, UIApplication.shared.applicationState == .active, viewModel.shouldPoll, pollingTask == nil else { if !viewModel.shouldPoll { stopPolling() } return } pollingTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(8)) guard !Task.isCancelled, let self else { break } await self.viewModel.load(api: self.api, silent: true) } } } private func stopPolling() { pollingTask?.cancel() pollingTask = nil } @MainActor private func presentNotFound() { guard presentedViewController == nil else { return } let alert = UIAlertController(title: nil, message: "任务不存在或已失效", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "知道了", style: .default) { [weak self] _ in self?.replaceWithTaskList() }) present(alert, animated: true) } private func replaceWithTaskList() { guard let navigationController else { return } if let existing = navigationController.viewControllers.first(where: { $0 is TravelAlbumAIJobListViewController }) { navigationController.popToViewController(existing, animated: true) return } var stack = navigationController.viewControllers.filter { $0 !== self } stack.append(TravelAlbumAIJobListViewController(api: api)) navigationController.setViewControllers(stack, animated: true) } private func openAlbum() { guard let id = viewModel.detail?.userEquityTravelId, id > 0 else { return } navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: id, api: api), animated: true) } private func openResult(_ target: TravelAlbumAIJobTarget) { guard let kind = target.outputType.previewKind, let asset = target.resultAsset, !asset.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { showToast("结果资源已失效") return } Task { var project: TravelAlbumPreviewProject if kind == .cover { project = TravelAlbumPreviewProject( originalMaterialId: asset.materialId, aiRetouchBatchId: viewModel.batchId, assets: [TravelAlbumPreviewAsset( id: "cover-\(asset.id)", kind: .cover, fileURL: asset.url, coverURL: asset.thumbnailURL, fileName: "AI封面", fileSize: 0 )] ) } else if let materialId = target.sourceMaterial?.id { do { let material = try await api.materialInfo( userEquityTravelId: viewModel.detail?.userEquityTravelId ?? 0, materialId: materialId ) let current = TravelAlbumPreviewProject(material: material) let result = TravelAlbumPreviewAsset( id: "job-result-\(asset.id)", kind: kind, fileURL: asset.url, coverURL: asset.thumbnailURL, fileName: material.fileName, fileSize: material.fileSize ) project = TravelAlbumPreviewProject( originalMaterialId: material.id, aiRetouchBatchId: viewModel.batchId, assets: current.assets + [result] ) } catch { await MainActor.run { self.showToast("结果资源加载失败,请前往相册查看") } return } } else { await MainActor.run { self.showToast("结果资源已失效") } return } await MainActor.run { self.present( TravelAlbumPhotoPreviewViewController( projects: [project], totalCount: 1, startProjectIndex: 0, startKind: kind, allowsActions: false ), animated: true ) } } } } /// AI 修图详情页专用视觉常量。 private enum AIJobDetailStyle { static let pageBackground = UIColor(hex: 0xF5F7FB) static let textPrimary = UIColor(hex: 0x111827) static let textSecondary = UIColor(hex: 0x64748B) static let border = UIColor(hex: 0xE4EAF3) static func styleCard(_ view: UIView) { view.backgroundColor = .white view.layer.cornerRadius = 14 view.layer.borderWidth = 1 view.layer.borderColor = border.cgColor view.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.08).cgColor view.layer.shadowOpacity = 1 view.layer.shadowRadius = 8 view.layer.shadowOffset = CGSize(width: 0, height: 3) } } /// 任务状态总览卡,包含环形状态、完成进度、ETA 和通知说明。 private final class AIJobDetailStatusCard: UIView { private let ringView = AIJobCircularProgressView() private let statusLabel = UILabel() private let completedLabel = UILabel() private let progressView = UIProgressView(progressViewStyle: .default) private let percentLabel = UILabel() private let timeIcon = UIImageView(image: UIImage(systemName: "clock")) private let timeLabel = UILabel() private let notificationIcon = UIImageView(image: UIImage(systemName: "bell")) private let notificationLabel = UILabel() private let timeRow = UIStackView() private let notificationRow = UIStackView() override init(frame: CGRect) { super.init(frame: frame) AIJobDetailStyle.styleCard(self) accessibilityIdentifier = "aiRetouchJob.detail.statusCard" statusLabel.font = .systemFont(ofSize: 23, weight: .semibold) completedLabel.font = .systemFont(ofSize: 15) completedLabel.textColor = AIJobDetailStyle.textSecondary progressView.trackTintColor = UIColor(hex: 0xE5EAF2) progressView.layer.cornerRadius = 3 progressView.clipsToBounds = true percentLabel.font = .systemFont(ofSize: 14, weight: .medium) percentLabel.textColor = AIJobDetailStyle.textSecondary percentLabel.textAlignment = .right [timeIcon, notificationIcon].forEach { $0.tintColor = AppColor.primary $0.contentMode = .scaleAspectFit $0.snp.makeConstraints { $0.size.equalTo(17) } } timeLabel.font = .systemFont(ofSize: 14) timeLabel.textColor = AIJobDetailStyle.textSecondary notificationLabel.font = .systemFont(ofSize: 14) notificationLabel.textColor = AIJobDetailStyle.textSecondary timeRow.axis = .horizontal timeRow.spacing = 8 timeRow.alignment = .center timeRow.addArrangedSubview(timeIcon) timeRow.addArrangedSubview(timeLabel) notificationRow.axis = .horizontal notificationRow.spacing = 8 notificationRow.alignment = .center notificationRow.addArrangedSubview(notificationIcon) notificationRow.addArrangedSubview(notificationLabel) let progressRow = UIStackView(arrangedSubviews: [progressView, percentLabel]) progressRow.axis = .horizontal progressRow.spacing = 10 progressRow.alignment = .center progressView.snp.makeConstraints { $0.height.equalTo(7) } percentLabel.snp.makeConstraints { $0.width.greaterThanOrEqualTo(38) } let rightStack = UIStackView(arrangedSubviews: [statusLabel, completedLabel, progressRow, timeRow, notificationRow]) rightStack.axis = .vertical rightStack.spacing = 7 addSubview(ringView) addSubview(rightStack) ringView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() make.size.equalTo(92) } rightStack.snp.makeConstraints { make in make.top.bottom.equalToSuperview().inset(16) make.leading.equalTo(ringView.snp.trailing).offset(20) make.trailing.equalToSuperview().inset(18) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(_ detail: TravelAlbumAIJobDetail) { let color = detail.status.semanticColor statusLabel.text = detail.status.title statusLabel.textColor = color completedLabel.text = "已完成 \(detail.progress.completed) / \(detail.progress.total)" progressView.progressTintColor = color progressView.progress = Float(detail.progress.fraction) percentLabel.text = "\(Int((detail.progress.fraction * 100).rounded()))%" ringView.apply(progress: detail.progress.fraction, color: color, status: detail.status) if detail.status.isInProgress { timeLabel.text = detail.estimatedFinishAt.map { "预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成" } ?? "完成时间暂无法预估" notificationLabel.text = "完成后将通过消息通知你" notificationRow.isHidden = false } else { timeLabel.text = "完成时间 \(TravelAlbumAIJobDateFormatter.display(detail.finishedAt))" notificationRow.isHidden = true } accessibilityLabel = [statusLabel.text, completedLabel.text, percentLabel.text, timeLabel.text, notificationLabel.text] .compactMap { $0 }.joined(separator: ",") } } /// 环形进度组件,使用图形、文字与颜色共同表达任务状态。 private final class AIJobCircularProgressView: UIView { private let trackLayer = CAShapeLayer() private let progressLayer = CAShapeLayer() private let iconView = UIImageView(image: UIImage(systemName: "sparkles")) override init(frame: CGRect) { super.init(frame: frame) layer.addSublayer(trackLayer) layer.addSublayer(progressLayer) [trackLayer, progressLayer].forEach { $0.fillColor = UIColor.clear.cgColor $0.lineWidth = 8 $0.lineCap = .round } trackLayer.strokeColor = UIColor(hex: 0xE7F0FF).cgColor iconView.contentMode = .scaleAspectFit addSubview(iconView) iconView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(37) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() let center = CGPoint(x: bounds.midX, y: bounds.midY) let radius = max(0, min(bounds.width, bounds.height) / 2 - 6) let path = UIBezierPath( arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: .pi * 1.5, clockwise: true ).cgPath trackLayer.path = path progressLayer.path = path } func apply(progress: Double, color: UIColor, status: TravelAlbumAIJobStatus) { progressLayer.strokeColor = color.cgColor progressLayer.strokeEnd = max(0.04, min(1, progress)) iconView.tintColor = color iconView.image = UIImage(systemName: status.isInProgress ? "sparkles" : status == .succeeded ? "checkmark" : "exclamationmark") } } /// 相册与任务信息卡,匹配设计稿中的封面、账号与提交信息结构。 private final class AIJobDetailAlbumCard: UIView { private let coverView = UIImageView() private let albumLabel = UILabel() private let phoneLabel = UILabel() private let taskLabel = UILabel() private let submitLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) AIJobDetailStyle.styleCard(self) accessibilityIdentifier = "aiRetouchJob.detail.albumCard" coverView.contentMode = .scaleAspectFill coverView.clipsToBounds = true coverView.layer.cornerRadius = 12 coverView.backgroundColor = AppColor.pageBackground coverView.tintColor = AppColor.textTertiary albumLabel.font = .systemFont(ofSize: 19, weight: .semibold) albumLabel.textColor = AIJobDetailStyle.textPrimary phoneLabel.font = .systemFont(ofSize: 14) phoneLabel.textColor = AIJobDetailStyle.textSecondary taskLabel.font = .systemFont(ofSize: 14) taskLabel.textColor = AIJobDetailStyle.textSecondary submitLabel.font = .systemFont(ofSize: 14) submitLabel.textColor = AIJobDetailStyle.textSecondary addSubview(coverView) addSubview(albumLabel) addSubview(phoneLabel) addSubview(taskLabel) addSubview(submitLabel) coverView.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(16) make.size.equalTo(84) } albumLabel.snp.makeConstraints { make in make.leading.equalTo(coverView.snp.trailing).offset(14) make.top.equalToSuperview().offset(19) make.trailing.equalToSuperview().inset(16) } phoneLabel.snp.makeConstraints { make in make.leading.equalTo(albumLabel) make.top.equalTo(albumLabel.snp.bottom).offset(7) } taskLabel.snp.makeConstraints { make in make.leading.equalTo(albumLabel) make.bottom.equalToSuperview().inset(19) } submitLabel.snp.makeConstraints { make in make.leading.greaterThanOrEqualTo(taskLabel.snp.trailing).offset(12) make.trailing.equalToSuperview().inset(16) make.centerY.equalTo(taskLabel) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(_ detail: TravelAlbumAIJobDetail) { coverView.kf.setImage(with: URL(string: detail.album.coverURL), placeholder: UIImage(systemName: "photo")) albumLabel.text = detail.album.name.isEmpty ? "未命名相册" : detail.album.name let phone = TravelAlbumDisplayFormatter.maskPhone(detail.album.userPhone) phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone taskLabel.text = "任务 #\(detail.aiRetouchBatchId)" submitLabel.text = "\(TravelAlbumAIJobDateFormatter.display(detail.createdAt)) 提交" accessibilityLabel = [albumLabel.text, phoneLabel.text, taskLabel.text, submitLabel.text] .compactMap { $0 }.joined(separator: ",") } } /// 任务内容卡,以浅蓝标签展示各输出目标数量并补充额度结算。 private final class AIJobDetailContentCard: UIView { private let outputStack = UIStackView() private let quotaLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) AIJobDetailStyle.styleCard(self) accessibilityIdentifier = "aiRetouchJob.detail.contentCard" let titleLabel = UILabel() titleLabel.text = "任务内容" titleLabel.font = .systemFont(ofSize: 18, weight: .semibold) titleLabel.textColor = AIJobDetailStyle.textPrimary outputStack.axis = .horizontal outputStack.spacing = 10 outputStack.distribution = .fillEqually quotaLabel.font = .systemFont(ofSize: 12) quotaLabel.textColor = AIJobDetailStyle.textSecondary quotaLabel.numberOfLines = 0 let stack = UIStackView(arrangedSubviews: [titleLabel, outputStack, quotaLabel]) stack.axis = .vertical stack.spacing = 14 addSubview(stack) stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(_ detail: TravelAlbumAIJobDetail) { outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() } detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) } outputStack.isHidden = detail.outputs.isEmpty let quota = detail.quotaSettlement quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)" accessibilityLabel = ["任务内容", detail.outputs.map { "\($0.type.title) \($0.count)张" }.joined(separator: ","), quotaLabel.text] .compactMap { $0 }.joined(separator: ",") } 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) 张" 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.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(6) make.trailing.equalToSuperview().inset(8) make.centerY.equalToSuperview() } container.snp.makeConstraints { $0.height.equalTo(44) } return container } } /// 处理明细卡,将逐照片、逐输出状态及失败原因放在同一卡片中展示。 private final class AIJobDetailProcessingCard: UIView { private let rowsStack = UIStackView() override init(frame: CGRect) { super.init(frame: frame) AIJobDetailStyle.styleCard(self) accessibilityIdentifier = "aiRetouchJob.detail.processingCard" let titleLabel = UILabel() titleLabel.text = "处理明细" titleLabel.font = .systemFont(ofSize: 18, weight: .semibold) titleLabel.textColor = AIJobDetailStyle.textPrimary rowsStack.axis = .vertical rowsStack.spacing = 0 let stack = UIStackView(arrangedSubviews: [titleLabel, rowsStack]) stack.axis = .vertical stack.spacing = 12 addSubview(stack) stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply( _ targets: [TravelAlbumAIJobTarget], onViewResult: @escaping (TravelAlbumAIJobTarget) -> Void ) { rowsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } if targets.isEmpty { let label = UILabel() label.text = "暂无处理明细" label.font = .systemFont(ofSize: 14) label.textColor = AIJobDetailStyle.textSecondary label.textAlignment = .center label.snp.makeConstraints { $0.height.equalTo(52) } rowsStack.addArrangedSubview(label) return } for (index, target) in targets.enumerated() { let row = AIJobDetailTargetRow() row.apply(target) row.onViewResult = { onViewResult(target) } rowsStack.addArrangedSubview(row) if index < targets.count - 1 { let separator = UIView() separator.backgroundColor = AIJobDetailStyle.border separator.snp.makeConstraints { $0.height.equalTo(1) } rowsStack.addArrangedSubview(separator) } } } } /// 单个输出明细行,内联展示结果操作或后端返回的失败原因。 private final class AIJobDetailTargetRow: UIView { private let thumbnailView = UIImageView() private let titleLabel = UILabel() private let templateLabel = UILabel() private let statusIconView = UIImageView() private let statusLabel = UILabel() private let resultButton = UIButton(type: .system) private let errorContainer = UIView() private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill")) private let errorLabel = UILabel() var onViewResult: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) thumbnailView.contentMode = .scaleAspectFill thumbnailView.clipsToBounds = true thumbnailView.layer.cornerRadius = 10 thumbnailView.backgroundColor = AppColor.pageBackground thumbnailView.tintColor = AppColor.textTertiary titleLabel.font = .systemFont(ofSize: 15, weight: .medium) titleLabel.textColor = AIJobDetailStyle.textPrimary titleLabel.lineBreakMode = .byTruncatingMiddle templateLabel.font = .systemFont(ofSize: 13) templateLabel.textColor = AIJobDetailStyle.textSecondary statusIconView.contentMode = .scaleAspectFit statusLabel.font = .systemFont(ofSize: 14, weight: .medium) resultButton.setTitle("查看结果", for: .normal) resultButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) resultButton.addTarget(self, action: #selector(viewResultTapped), for: .touchUpInside) errorContainer.backgroundColor = AppColor.dangerBackground errorContainer.layer.cornerRadius = 8 errorIconView.tintColor = AppColor.danger errorLabel.font = .systemFont(ofSize: 13) errorLabel.textColor = AppColor.danger errorLabel.numberOfLines = 0 let statusStack = UIStackView(arrangedSubviews: [statusIconView, statusLabel]) statusStack.axis = .horizontal statusStack.spacing = 6 statusStack.alignment = .center statusIconView.snp.makeConstraints { $0.size.equalTo(18) } addSubview(thumbnailView) addSubview(titleLabel) addSubview(templateLabel) addSubview(statusStack) addSubview(resultButton) addSubview(errorContainer) errorContainer.addSubview(errorIconView) errorContainer.addSubview(errorLabel) thumbnailView.snp.makeConstraints { make in make.leading.top.equalToSuperview().offset(4) make.size.equalTo(80) } titleLabel.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(thumbnailView.snp.bottom).offset(10) 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) make.top.equalToSuperview().offset(11) make.size.equalTo(16) } errorLabel.snp.makeConstraints { make in make.leading.equalTo(errorIconView.snp.trailing).offset(7) make.top.bottom.equalToSuperview().inset(9) make.trailing.equalToSuperview().inset(10) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(_ target: TravelAlbumAIJobTarget) { let thumbnail = target.sourceMaterial?.thumbnailURL ?? target.resultAsset?.thumbnailURL ?? "" thumbnailView.kf.setImage(with: URL(string: thumbnail), placeholder: UIImage(systemName: "photo")) titleLabel.text = target.outputType == .cover ? "相册封面" : (target.sourceMaterial?.fileName.isEmpty == false ? target.sourceMaterial?.fileName : "照片 \(target.sourceMaterial?.id ?? 0)") templateLabel.text = target.template?.name ?? target.outputType.title let color = target.status.semanticColor statusIconView.tintColor = color statusLabel.textColor = color statusIconView.image = UIImage(systemName: target.status.symbolName) switch target.status { case .succeeded: statusLabel.text = "\(target.outputType.shortTitle)完成" case .processing: statusLabel.text = "正在生成" case .queued: statusLabel.text = "等待处理" case .failed: statusLabel.text = "生成失败" default: statusLabel.text = target.status.title } let hasResult = target.status == .succeeded && target.resultAsset?.url.isEmpty == false resultButton.isHidden = !hasResult errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" } let showsError = target.displayFailureMessage != nil errorContainer.isHidden = !showsError errorContainer.snp.remakeConstraints { make in make.top.equalTo(thumbnailView.snp.bottom).offset(showsError ? 10 : 0) 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) } } accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text] .compactMap { $0 }.joined(separator: ",") } @objc private func viewResultTapped() { onViewResult?() } } /// 页面底部的查看相册主操作按钮。 private final class AIJobDetailAlbumButton: UIButton { override init(frame: CGRect) { super.init(frame: frame) var configuration = UIButton.Configuration.plain() configuration.title = "查看相册" configuration.image = UIImage(systemName: "photo") configuration.imagePadding = 10 configuration.baseForegroundColor = AppColor.primary configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in var outgoing = incoming outgoing.font = .systemFont(ofSize: 18, weight: .medium) return outgoing } self.configuration = configuration layer.cornerRadius = 10 layer.borderWidth = 1.5 layer.borderColor = AppColor.primary.cgColor backgroundColor = .white accessibilityIdentifier = "aiRetouchJob.detail.albumButton" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 任务详情加载失败时的空态。 private final class AIJobDetailEmptyView: UIView { private let titleLabel = UILabel() private let messageLabel = UILabel() private let button = UIButton(type: .system) var onRetry: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) titleLabel.textAlignment = .center messageLabel.font = .systemFont(ofSize: 14) messageLabel.textColor = AppColor.textSecondary messageLabel.textAlignment = .center messageLabel.numberOfLines = 0 button.setTitle("重新加载", for: .normal) button.addTarget(self, action: #selector(retry), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, button]) stack.axis = .vertical stack.spacing = 12 stack.alignment = .center addSubview(stack) stack.snp.makeConstraints { make in make.center.equalToSuperview() make.leading.trailing.equalToSuperview().inset(40) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(title: String, message: String) { titleLabel.text = title; messageLabel.text = message } @objc private func retry() { onRetry?() } } private extension TravelAlbumAIJobStatus { var semanticColor: UIColor { switch self { case .queued, .processing: AppColor.primary case .succeeded: AppColor.success case .partiallySucceeded: AppColor.warning case .failed: AppColor.danger case .canceled, .unknown: AppColor.textSecondary } } var symbolName: String { switch self { case .queued: "clock" case .processing: "arrow.triangle.2.circlepath" case .succeeded: "checkmark.circle.fill" case .partiallySucceeded: "exclamationmark.circle.fill" case .failed: "exclamationmark.circle" case .canceled, .unknown: "minus.circle" } } } private extension TravelAlbumAIJobOutputType { var shortTitle: String { switch self { case .refined: "精修" case .atmosphere: "氛围感" case .cover: "封面" case .unknown: "其他" } } var symbolName: String { switch self { case .refined: "wand.and.stars" case .atmosphere: "sun.max" case .cover: "bookmark" case .unknown: "sparkles" } } }