import Kingfisher import SnapKit import UIKit /// 当前账号的 AI 修图任务中心,提供筛选、游标分页、刷新与状态轮询。 final class TravelAlbumAIJobListViewController: BaseViewController { private enum Section { case main } private let viewModel = TravelAlbumAIJobListViewModel() private let api: any TravelAlbumServing private let filterContainer = UIView() private let filterStack = UIStackView() private var filterButtons: [TravelAlbumAIJobFilter: UIButton] = [:] private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) private let refreshControl = UIRefreshControl() private let emptyView = AIJobEmptyView() private var dataSource: UICollectionViewDiffableDataSource! private var pollingTask: Task? private var isVisible = false private var isPresentingLoading = false /// 创建任务中心,可注入 API 以支持测试。 init(api: (any TravelAlbumServing)? = nil) { 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 = "AI修图任务" titleLabel.textColor = UIColor(hex: 0x0F1F3D) titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) navigationItem.titleView = titleLabel navigationItem.backButtonDisplayMode = .minimal 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 = AppColor.pageBackgroundSoft configureFilters() collectionView.backgroundColor = .clear collectionView.alwaysBounceVertical = true collectionView.delegate = self collectionView.refreshControl = refreshControl collectionView.register(AIJobSummaryCell.self, forCellWithReuseIdentifier: AIJobSummaryCell.reuseIdentifier) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: AIJobSummaryCell.reuseIdentifier, for: indexPath ) as! AIJobSummaryCell cell.apply(item) return cell } emptyView.onRetry = { [weak self] in self?.reload() } view.addSubview(filterContainer) filterContainer.addSubview(filterStack) view.addSubview(collectionView) view.addSubview(emptyView) } override func setupConstraints() { filterContainer.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.leading.trailing.equalToSuperview().inset(20) make.height.equalTo(46) } filterStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(2) } collectionView.snp.makeConstraints { make in make.top.equalTo(filterContainer.snp.bottom).offset(14) make.leading.trailing.bottom.equalToSuperview() } emptyView.snp.makeConstraints { make in make.top.equalTo(filterContainer.snp.bottom) make.leading.trailing.bottom.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) } } 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.loadFirstPage(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) } private func configureFilters() { filterContainer.accessibilityIdentifier = "aiRetouchJob.filterContainer" filterContainer.backgroundColor = .white filterContainer.layer.cornerRadius = 12 filterContainer.layer.borderWidth = 1 filterContainer.layer.borderColor = UIColor(hex: 0xE8EEF7).cgColor filterContainer.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.12).cgColor filterContainer.layer.shadowOpacity = 1 filterContainer.layer.shadowRadius = 7 filterContainer.layer.shadowOffset = CGSize(width: 0, height: 3) filterStack.axis = .horizontal filterStack.spacing = 0 filterStack.distribution = .fillEqually TravelAlbumAIJobFilter.allCases.forEach { filter in var configuration = UIButton.Configuration.plain() configuration.title = filter.title configuration.contentInsets = .zero let button = UIButton(configuration: configuration) button.tag = TravelAlbumAIJobFilter.allCases.firstIndex(of: filter) ?? 0 button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) button.layer.cornerRadius = 10 button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside) button.accessibilityLabel = "筛选:\(filter.title)" filterButtons[filter] = button filterStack.addArrangedSubview(button) } } private func makeLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { _, environment in let item = NSCollectionLayoutItem(layoutSize: .init( widthDimension: .fractionalWidth(1), heightDimension: .estimated(250) )) let group = NSCollectionLayoutGroup.vertical( layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(250)), subitems: [item] ) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = 14 section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 24, trailing: 20) return section } } @MainActor private func applyState() { filterButtons.forEach { filter, button in let selected = filter == viewModel.selectedFilter button.configuration?.baseForegroundColor = selected ? .white : AppColor.textSecondary button.configuration?.background.backgroundColor = .clear button.backgroundColor = selected ? AppColor.primary : .clear button.accessibilityTraits = selected ? [.button, .selected] : .button } refreshControl.endRefreshing() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(viewModel.items) dataSource.apply(snapshot, animatingDifferences: true) let empty = viewModel.items.isEmpty && !viewModel.isLoading emptyView.isHidden = !empty emptyView.apply( title: viewModel.errorMessage == nil ? "暂无AI修图任务" : "任务加载失败", message: viewModel.errorMessage ?? "提交AI修图后,可以在这里查看进度和结果。", showsRetry: viewModel.errorMessage != nil ) updateLoadingPresentation() updatePolling() } @MainActor private func updateLoadingPresentation() { setLoadingPresented(isVisible && viewModel.isLoading && viewModel.items.isEmpty) } @MainActor private func setLoadingPresented(_ presented: Bool) { guard isPresentingLoading != presented else { return } isPresentingLoading = presented if presented { showLoading() } else { hideLoading() } } private func reload() { Task { await viewModel.loadFirstPage(api: api) } } @objc private func refreshTriggered() { Task { await viewModel.loadFirstPage(api: api, refreshing: true) } } @objc private func filterTapped(_ sender: UIButton) { guard TravelAlbumAIJobFilter.allCases.indices.contains(sender.tag) else { return } Task { await viewModel.selectFilter(TravelAlbumAIJobFilter.allCases[sender.tag], api: api) } } @objc private func applicationBecameActive() { updatePolling() } @objc private func applicationEnteredBackground() { stopPolling() } private func updatePolling() { guard isVisible, UIApplication.shared.applicationState == .active, viewModel.containsInProgressJobs, pollingTask == nil else { if !viewModel.containsInProgressJobs { stopPolling() } return } pollingTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(15)) guard !Task.isCancelled, let self else { break } await self.viewModel.loadFirstPage(api: self.api, silent: true) } } } private func stopPolling() { pollingTask?.cancel() pollingTask = nil } } extension TravelAlbumAIJobListViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } navigationController?.pushViewController( TravelAlbumAIJobDetailViewController(batchId: item.aiRetouchBatchId, api: api), animated: true ) } func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView.contentSize.height > 0, scrollView.contentOffset.y + scrollView.bounds.height > scrollView.contentSize.height - 240 else { return } Task { await viewModel.loadMore(api: api) } } } /// AI 修图任务列表卡片。 private final class AIJobSummaryCell: UICollectionViewCell { static let reuseIdentifier = "AIJobSummaryCell" private let topInfoView = UIView() private let albumCoverImageView = UIImageView() private let albumLabel = UILabel() private let phoneLabel = UILabel() private let taskLabel = UILabel() private let timeIconView = UIImageView(image: UIImage(systemName: "clock")) private let timeLabel = UILabel() private let timeStack = UIStackView() private let statusBadge = AIJobStatusBadgeView() private let previewStack = UIStackView() private var previewImageViews: [UIImageView] = [] private let progressRow = UIStackView() private let completedLabel = UILabel() private let outputLabel = UILabel() private let progressView = UIProgressView(progressViewStyle: .default) private let percentLabel = UILabel() private let etaLabel = UILabel() private let bottomRow = UIView() private let outputPill = UIView() private let outputIconView = UIImageView(image: UIImage(systemName: "wand.and.stars")) private let detailsLabel = UILabel() private let chevronView = UIImageView(image: UIImage(systemName: "chevron.right")) private let contentStack = UIStackView() override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white contentView.layer.cornerRadius = 16 contentView.layer.borderWidth = 1 contentView.layer.borderColor = UIColor(hex: 0xE6EDF8).cgColor contentView.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.1).cgColor contentView.layer.shadowOpacity = 1 contentView.layer.shadowRadius = 9 contentView.layer.shadowOffset = CGSize(width: 0, height: 4) configureTopInfo() configurePreviewGrid() completedLabel.font = .systemFont(ofSize: 14, weight: .medium) completedLabel.textColor = UIColor(hex: 0x263754) progressView.tintColor = AppColor.primary progressView.trackTintColor = UIColor(hex: 0xE5EAF2) progressView.layer.cornerRadius = 3 progressView.clipsToBounds = true percentLabel.font = .systemFont(ofSize: 14, weight: .medium) percentLabel.textColor = UIColor(hex: 0x263754) percentLabel.textAlignment = .right progressRow.axis = .horizontal progressRow.alignment = .center progressRow.spacing = 10 progressRow.addArrangedSubview(completedLabel) progressRow.addArrangedSubview(progressView) progressRow.addArrangedSubview(percentLabel) progressView.snp.makeConstraints { $0.height.equalTo(6) } completedLabel.setContentHuggingPriority(.required, for: .horizontal) percentLabel.setContentHuggingPriority(.required, for: .horizontal) etaLabel.font = .systemFont(ofSize: 13) etaLabel.textColor = UIColor(hex: 0x7F8CA3) configureBottomRow() contentStack.axis = .vertical contentStack.spacing = 12 [topInfoView, previewStack, progressRow, etaLabel, bottomRow].forEach { contentStack.addArrangedSubview($0) } contentView.addSubview(contentStack) contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(14) } topInfoView.snp.makeConstraints { $0.height.equalTo(72) } previewStack.snp.makeConstraints { $0.height.equalTo(104) } bottomRow.snp.makeConstraints { $0.height.equalTo(34) } accessibilityIdentifier = "aiRetouchJob.summaryCell" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() albumCoverImageView.kf.cancelDownloadTask() albumCoverImageView.image = nil previewImageViews.forEach { $0.kf.cancelDownloadTask() $0.image = nil $0.isHidden = true } } func apply(_ item: TravelAlbumAIJobSummary) { albumLabel.text = item.album.name.isEmpty ? "未命名相册" : item.album.name let phone = TravelAlbumDisplayFormatter.maskPhone(item.album.userPhone) phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone taskLabel.text = "任务 #\(item.aiRetouchBatchId)" timeLabel.text = relativeTime(item.createdAt) statusBadge.apply(item.status) albumCoverImageView.kf.setImage( with: URL(string: item.album.coverURL), placeholder: UIImage(systemName: "photo") ) previewImageViews.enumerated().forEach { index, imageView in guard item.previewImages.indices.contains(index) else { imageView.isHidden = true return } imageView.isHidden = false imageView.kf.setImage( with: URL(string: item.previewImages[index].thumbnailURL), placeholder: UIImage(systemName: "photo") ) } previewStack.isHidden = item.previewImages.isEmpty progressView.progress = Float(item.progress.fraction) if item.status.isInProgress { completedLabel.text = "已完成 \(item.progress.completed) / \(item.progress.total)" percentLabel.text = "\(Int((item.progress.fraction * 100).rounded()))%" etaLabel.text = item.estimatedFinishAt.map { "预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成" } ?? "完成后将通过消息通知" progressRow.isHidden = false etaLabel.isHidden = false outputIconView.image = UIImage(systemName: "wand.and.stars") outputIconView.tintColor = AppColor.primary outputPill.backgroundColor = AppColor.primaryLight outputLabel.textColor = AppColor.primary outputLabel.text = item.outputs.map { "\(shortTitle($0.type)) \($0.count)张" }.joined(separator: " · ") } else if item.status == .partiallySucceeded { progressRow.isHidden = true etaLabel.isHidden = true outputIconView.image = UIImage(systemName: "exclamationmark.circle.fill") outputIconView.tintColor = AppColor.warning outputPill.backgroundColor = .clear outputLabel.textColor = UIColor(hex: 0xB56A00) outputLabel.text = "\(item.progress.succeeded)张成功 · \(item.progress.failed)张失败" } else { progressRow.isHidden = true etaLabel.isHidden = true let succeeded = item.status == .succeeded outputIconView.image = UIImage(systemName: succeeded ? "checkmark.circle.fill" : "xmark.circle.fill") outputIconView.tintColor = succeeded ? AppColor.success : AppColor.danger outputPill.backgroundColor = .clear outputLabel.textColor = succeeded ? UIColor(hex: 0x178A55) : AppColor.danger outputLabel.text = succeeded ? "\(item.progress.succeeded)张结果已生成" : item.status.title } accessibilityLabel = [albumLabel.text, statusBadge.accessibilityLabel, timeLabel.text, phoneLabel.text, taskLabel.text, outputLabel.text, completedLabel.text, etaLabel.text] .compactMap { $0 }.joined(separator: ",") accessibilityTraits = .button } private func configureTopInfo() { albumCoverImageView.accessibilityIdentifier = "aiRetouchJob.albumCover" albumCoverImageView.contentMode = .scaleAspectFill albumCoverImageView.clipsToBounds = true albumCoverImageView.layer.cornerRadius = 10 albumCoverImageView.backgroundColor = AppColor.pageBackground albumCoverImageView.tintColor = AppColor.textTertiary albumLabel.font = .systemFont(ofSize: 17, weight: .semibold) albumLabel.textColor = UIColor(hex: 0x0F1F3D) albumLabel.lineBreakMode = .byTruncatingTail phoneLabel.font = .systemFont(ofSize: 14) phoneLabel.textColor = UIColor(hex: 0x72809A) taskLabel.font = .systemFont(ofSize: 14) taskLabel.textColor = UIColor(hex: 0x72809A) timeIconView.tintColor = UIColor(hex: 0x7F8CA3) timeIconView.contentMode = .scaleAspectFit timeLabel.font = .systemFont(ofSize: 13) timeLabel.textColor = UIColor(hex: 0x7F8CA3) timeStack.axis = .horizontal timeStack.spacing = 5 timeStack.alignment = .center timeStack.addArrangedSubview(timeIconView) timeStack.addArrangedSubview(timeLabel) [albumCoverImageView, albumLabel, phoneLabel, taskLabel, timeStack, statusBadge].forEach { topInfoView.addSubview($0) } albumCoverImageView.snp.makeConstraints { make in make.leading.top.equalToSuperview() make.size.equalTo(68) } timeIconView.snp.makeConstraints { $0.size.equalTo(15) } timeStack.snp.makeConstraints { make in make.top.trailing.equalToSuperview() } statusBadge.snp.makeConstraints { make in make.trailing.bottom.equalToSuperview() make.height.equalTo(30) } albumLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(1) make.leading.equalTo(albumCoverImageView.snp.trailing).offset(12) make.trailing.lessThanOrEqualTo(timeStack.snp.leading).offset(-8) } phoneLabel.snp.makeConstraints { make in make.leading.equalTo(albumLabel) make.top.equalTo(albumLabel.snp.bottom).offset(6) make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8) } taskLabel.snp.makeConstraints { make in make.leading.equalTo(albumLabel) make.top.equalTo(phoneLabel.snp.bottom).offset(5) make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8) } } private func configurePreviewGrid() { previewStack.axis = .horizontal previewStack.spacing = 8 previewStack.distribution = .fillEqually for index in 0..<3 { let slot = UIView() let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.cornerRadius = 10 imageView.backgroundColor = AppColor.pageBackground imageView.tintColor = AppColor.textTertiary imageView.accessibilityIdentifier = "aiRetouchJob.preview.\(index)" slot.addSubview(imageView) imageView.snp.makeConstraints { $0.edges.equalToSuperview() } previewStack.addArrangedSubview(slot) previewImageViews.append(imageView) } } private func configureBottomRow() { outputPill.layer.cornerRadius = 9 outputPill.clipsToBounds = true outputIconView.contentMode = .scaleAspectFit outputLabel.font = .systemFont(ofSize: 13, weight: .medium) outputLabel.numberOfLines = 1 outputLabel.adjustsFontSizeToFitWidth = true outputLabel.minimumScaleFactor = 0.8 outputPill.addSubview(outputIconView) outputPill.addSubview(outputLabel) outputIconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) make.centerY.equalToSuperview() make.size.equalTo(17) } outputLabel.snp.makeConstraints { make in make.leading.equalTo(outputIconView.snp.trailing).offset(6) make.trailing.equalToSuperview().inset(10) make.centerY.equalToSuperview() } detailsLabel.text = "查看详情" detailsLabel.font = .systemFont(ofSize: 13) detailsLabel.textColor = UIColor(hex: 0x7F8CA3) chevronView.tintColor = UIColor(hex: 0x7F8CA3) chevronView.contentMode = .scaleAspectFit [outputPill, detailsLabel, chevronView].forEach { bottomRow.addSubview($0) } outputPill.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview() make.trailing.lessThanOrEqualTo(detailsLabel.snp.leading).offset(-8) } chevronView.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() make.size.equalTo(14) } detailsLabel.snp.makeConstraints { make in make.trailing.equalTo(chevronView.snp.leading).offset(-6) make.centerY.equalToSuperview() } } private func shortTitle(_ type: TravelAlbumAIJobOutputType) -> String { switch type { case .refined: "精修" case .atmosphere: "氛围感" case .cover: "封面" case .unknown: "其他" } } private func relativeTime(_ value: String) -> String { guard let date = TravelAlbumAIJobDateFormatter.date(value) else { return "--" } let calendar = Calendar.current let prefix: String if calendar.isDateInToday(date) { prefix = "今天" } else if calendar.isDateInYesterday(date) { prefix = "昨天" } else { prefix = date.formatted(.dateTime.month().day()) } return "\(prefix) \(date.formatted(.dateTime.hour().minute()))" } } /// 带图标与底色的任务状态徽标,保证状态不只依赖颜色表达。 private final class AIJobStatusBadgeView: UIView { private let iconView = UIImageView() private let titleLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 9 iconView.contentMode = .scaleAspectFit titleLabel.font = .systemFont(ofSize: 14, weight: .medium) addSubview(iconView) addSubview(titleLabel) iconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(9) make.centerY.equalToSuperview() make.size.equalTo(17) } titleLabel.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(5) make.trailing.equalToSuperview().inset(10) make.centerY.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(_ status: TravelAlbumAIJobStatus) { titleLabel.text = status.title let color: UIColor let background: UIColor let symbol: String switch status { case .queued: color = AppColor.primary background = AppColor.infoBackground symbol = "clock.arrow.circlepath" case .processing: color = AppColor.primary background = AppColor.infoBackground symbol = "arrow.triangle.2.circlepath" case .succeeded: color = AppColor.success background = AppColor.successBackground symbol = "checkmark.circle.fill" case .partiallySucceeded: color = AppColor.warning background = AppColor.warningBackground symbol = "exclamationmark.circle.fill" case .failed: color = AppColor.danger background = AppColor.dangerBackground symbol = "xmark.circle.fill" case .canceled, .unknown: color = AppColor.textSecondary background = AppColor.pageBackground symbol = "minus.circle.fill" } titleLabel.textColor = color iconView.tintColor = color iconView.image = UIImage(systemName: symbol) backgroundColor = background accessibilityLabel = status.title } } /// 任务列表空态和错误态。 private final class AIJobEmptyView: UIView { private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled")) private let titleLabel = UILabel() private let messageLabel = UILabel() private let retryButton = UIButton(type: .system) var onRetry: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) imageView.tintColor = AppColor.primary imageView.contentMode = .scaleAspectFit 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 retryButton.setTitle("重新加载", for: .normal) retryButton.addTarget(self, action: #selector(retry), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, messageLabel, retryButton]) stack.axis = .vertical stack.alignment = .center stack.spacing = 12 addSubview(stack) stack.snp.makeConstraints { make in make.centerY.equalToSuperview().offset(-40) make.leading.trailing.equalToSuperview().inset(40) } imageView.snp.makeConstraints { $0.size.equalTo(52) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(title: String, message: String, showsRetry: Bool) { titleLabel.text = title messageLabel.text = message retryButton.isHidden = !showsRetry } @objc private func retry() { onRetry?() } }