908 lines
37 KiB
Swift
908 lines
37 KiB
Swift
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(templateLabel.snp.bottom).offset(12)
|
||
make.leading.equalTo(titleLabel)
|
||
make.trailing.equalToSuperview().inset(4)
|
||
make.bottom.equalToSuperview().inset(12)
|
||
make.height.greaterThanOrEqualTo(38)
|
||
}
|
||
errorIconView.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(10)
|
||
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
|
||
if showsError {
|
||
make.top.equalTo(templateLabel.snp.bottom).offset(12)
|
||
} else {
|
||
make.top.equalTo(thumbnailView.snp.bottom)
|
||
}
|
||
make.leading.equalTo(titleLabel)
|
||
make.trailing.equalToSuperview().inset(4)
|
||
make.bottom.equalToSuperview().inset(12)
|
||
if showsError {
|
||
make.height.greaterThanOrEqualTo(38)
|
||
} else {
|
||
make.height.equalTo(0)
|
||
}
|
||
}
|
||
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"
|
||
}
|
||
}
|
||
}
|