676 lines
28 KiB
Swift
676 lines
28 KiB
Swift
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<Section, TravelAlbumAIJobSummary>!
|
||
private var pollingTask: Task<Void, Never>?
|
||
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<Section, TravelAlbumAIJobSummary>(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<Section, TravelAlbumAIJobSummary>()
|
||
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?() }
|
||
}
|