feat: add AI retouch task center

This commit is contained in:
2026-08-14 15:06:57 +08:00
parent 0f3b26991e
commit 30bfe0313f
26 changed files with 3679 additions and 50 deletions
@@ -0,0 +1,903 @@
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<Void, Never>?
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"
}
}
}