feat: add AI retouch task center
This commit is contained in:
@@ -349,7 +349,7 @@ final class BeforeAfterComparisonViewController: UIViewController {
|
||||
).cgPath
|
||||
dividerLine.frame = CGRect(x: x - 1.5, y: 0, width: 3, height: bounds.height)
|
||||
dividerHandle.frame = CGRect(x: x - 24, y: bounds.midY - 24, width: 48, height: 48)
|
||||
dividerHandle.accessibilityValue = "原图占比 (Int((dividerFraction * 100).rounded()))%"
|
||||
dividerHandle.accessibilityValue = "原图占比 \(Int((dividerFraction * 100).rounded()))%"
|
||||
}
|
||||
|
||||
private func updateDivider(locationX: CGFloat) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
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?() }
|
||||
}
|
||||
@@ -25,7 +25,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
|
||||
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
||||
private let api: any TravelAlbumServing
|
||||
private let onSubmitted: () -> Void
|
||||
private let onSubmitted: (TravelAlbumAIJobSubmission) -> Void
|
||||
|
||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
@@ -45,7 +45,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
init(
|
||||
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
||||
api: any TravelAlbumServing,
|
||||
onSubmitted: @escaping () -> Void
|
||||
onSubmitted: @escaping (TravelAlbumAIJobSubmission) -> Void
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
@@ -182,10 +182,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onSubmitted = { [weak self] in
|
||||
viewModel.onSubmitted = { [weak self] submission in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.dismiss(animated: true, completion: self.onSubmitted)
|
||||
self.dismiss(animated: true) { self.onSubmitted(submission) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
navigationItem.scrollEdgeAppearance = appearance
|
||||
navigationItem.compactAppearance = appearance
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
let moreItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "ellipsis"),
|
||||
menu: UIMenu(children: [
|
||||
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
||||
@@ -73,7 +73,19 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
},
|
||||
])
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "更多操作"
|
||||
moreItem.accessibilityLabel = "更多操作"
|
||||
let taskItem = UIBarButtonItem(
|
||||
title: "修图任务",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(openAIJobList)
|
||||
)
|
||||
taskItem.accessibilityLabel = "查看AI修图任务"
|
||||
navigationItem.rightBarButtonItems = [moreItem, taskItem]
|
||||
}
|
||||
|
||||
@objc private func openAIJobList() {
|
||||
navigationController?.pushViewController(TravelAlbumAIJobListViewController(api: api), animated: true)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
@@ -446,15 +458,31 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
materialIds: materialIds
|
||||
),
|
||||
api: api,
|
||||
onSubmitted: { [weak self] in
|
||||
onSubmitted: { [weak self] submission in
|
||||
guard let self else { return }
|
||||
self.viewModel.completeAIRetouchSubmission()
|
||||
self.showToast("AI修图任务已提交")
|
||||
self.presentAIRetouchSubmitted(submission)
|
||||
}
|
||||
)
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func presentAIRetouchSubmitted(_ submission: TravelAlbumAIJobSubmission) {
|
||||
let alert = UIAlertController(
|
||||
title: "AI修图任务已提交",
|
||||
message: "完成后将通过消息通知,你也可以随时查看处理进度。",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "稍后查看", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "查看任务", style: .default) { [weak self] _ in
|
||||
self?.navigationController?.pushViewController(
|
||||
TravelAlbumAIJobDetailViewController(batchId: submission.aiRetouchBatchId),
|
||||
animated: true
|
||||
)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func deleteSelectedTapped() {
|
||||
let count = viewModel.selectedMaterialIds.count
|
||||
guard count > 0 else { return }
|
||||
|
||||
@@ -25,6 +25,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
private let loadMore: TravelAlbumPreviewLoadMore?
|
||||
private let reload: TravelAlbumPreviewReload?
|
||||
private let onProjectDeleted: ((Int) -> Void)?
|
||||
private let allowsActions: Bool
|
||||
private var nodes: [TravelAlbumPreviewNode] = []
|
||||
private var currentNodeIndex = 0
|
||||
private var dragStartIndex = 0
|
||||
@@ -63,6 +64,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
projects: [TravelAlbumPreviewProject],
|
||||
totalCount: Int,
|
||||
startProjectIndex: Int,
|
||||
startKind: TravelAlbumPreviewAssetKind = .original,
|
||||
allowsActions: Bool = true,
|
||||
configuration: TravelAlbumPreviewConfiguration = .init(),
|
||||
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
||||
albumId: Int = 0,
|
||||
@@ -82,9 +85,10 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
self.loadMore = loadMore
|
||||
self.reload = reload
|
||||
self.onProjectDeleted = onProjectDeleted
|
||||
self.allowsActions = allowsActions
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original)
|
||||
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: startKind)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -196,6 +200,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
configureHighResolutionButton()
|
||||
configureComparisonButton()
|
||||
configureActions()
|
||||
actionStack.isHidden = !allowsActions
|
||||
|
||||
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
|
||||
titleStack.axis = .vertical
|
||||
@@ -721,15 +726,34 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
workflow: workflow
|
||||
),
|
||||
api: aiRetouchAPI,
|
||||
onSubmitted: { [weak self] in
|
||||
onSubmitted: { [weak self] submission in
|
||||
guard let self else { return }
|
||||
self.showPreviewToast("AI修图任务已提交")
|
||||
self.reloadProjects(showGlobalLoading: false, forceRefreshImage: false)
|
||||
self.presentAIRetouchSubmitted(submission)
|
||||
}
|
||||
)
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func presentAIRetouchSubmitted(_ submission: TravelAlbumAIJobSubmission) {
|
||||
let alert = UIAlertController(
|
||||
title: "AI修图任务已提交",
|
||||
message: "完成后将通过消息通知。",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "知道了", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "查看任务", style: .default) { [weak self] _ in
|
||||
guard let self, let navigationController = presentingViewController?.navigationController else { return }
|
||||
dismiss(animated: true) {
|
||||
navigationController.pushViewController(
|
||||
TravelAlbumAIJobDetailViewController(batchId: submission.aiRetouchBatchId),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
guard currentProject != nil, !isDeletingProject else { return }
|
||||
let alert = UIAlertController(
|
||||
|
||||
Reference in New Issue
Block a user