feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
@@ -40,7 +40,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
private let refreshButton = UIButton(type: .system)
|
||||
private let helpLabel = UILabel()
|
||||
private let chipsStack = UIStackView()
|
||||
private let retouchButton = WiredTransferSettingChipButton()
|
||||
private let retouchButton = WiredTransferSettingChipButton(showsChevron: true)
|
||||
private let formatButton = WiredTransferSettingChipButton()
|
||||
private let modeButton = WiredTransferSettingChipButton(showsChevron: true)
|
||||
private let settingsStatsDivider = UIView()
|
||||
@@ -71,6 +71,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
private let specifyButton = UIButton(type: .system)
|
||||
private var isHistoryImportButtonVisible = false
|
||||
private var previousNavigationBarStyle: (tintColor: UIColor?, barStyle: UIBarStyle, isTranslucent: Bool)?
|
||||
private var previewLoadingTask: Task<Void, Never>?
|
||||
|
||||
init(viewModel: WiredCameraTransferViewModel) {
|
||||
self.viewModel = viewModel
|
||||
@@ -91,13 +92,64 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
titleStack.alignment = .center
|
||||
titleStack.spacing = 1
|
||||
navigationItem.titleView = titleStack
|
||||
|
||||
var taskConfiguration = UIButton.Configuration.plain()
|
||||
taskConfiguration.title = "修图任务"
|
||||
taskConfiguration.image = UIImage(systemName: "list.bullet")?.withTintColor(.white, renderingMode: .alwaysOriginal)
|
||||
taskConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
|
||||
taskConfiguration.imagePadding = 4
|
||||
taskConfiguration.baseForegroundColor = .white
|
||||
taskConfiguration.imageColorTransformer = UIConfigurationColorTransformer { _ in .white }
|
||||
taskConfiguration.background.backgroundColor = .clear
|
||||
taskConfiguration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 4)
|
||||
taskConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
outgoing.foregroundColor = .white
|
||||
return outgoing
|
||||
}
|
||||
let taskButton = UIButton(type: .custom)
|
||||
taskButton.configuration = taskConfiguration
|
||||
taskButton.tintColor = .white
|
||||
taskButton.configurationUpdateHandler = { button in
|
||||
button.alpha = button.isHighlighted ? 0.6 : 1
|
||||
}
|
||||
taskButton.accessibilityLabel = "查看AI修图任务"
|
||||
taskButton.accessibilityIdentifier = "wiredTransfer.aiRetouchTasksButton"
|
||||
taskButton.addTarget(self, action: #selector(openAIJobList), for: .touchUpInside)
|
||||
taskButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
make.width.greaterThanOrEqualTo(44)
|
||||
}
|
||||
let taskItem = UIBarButtonItem(customView: taskButton)
|
||||
taskItem.tintColor = .white
|
||||
taskItem.accessibilityLabel = "查看AI修图任务"
|
||||
if #available(iOS 26.0, *) {
|
||||
// 蓝色导航栏使用轻量入口,避免系统共享玻璃背景形成深色胶囊。
|
||||
taskItem.hidesSharedBackground = true
|
||||
}
|
||||
navigationItem.rightBarButtonItem = taskItem
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
applyViewModel()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationEnteredBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationBecameActive),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(self) }
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
applyNavigationBarStyle()
|
||||
@@ -107,6 +159,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
previewLoadingTask?.cancel()
|
||||
viewModel.stop()
|
||||
restoreNavigationBarStyle()
|
||||
}
|
||||
@@ -161,7 +214,6 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
[retouchButton, formatButton, modeButton].forEach {
|
||||
chipsStack.addArrangedSubview($0)
|
||||
}
|
||||
retouchButton.isUserInteractionEnabled = false
|
||||
formatButton.isUserInteractionEnabled = false
|
||||
settingsStatsDivider.backgroundColor = AppColor.border
|
||||
|
||||
@@ -189,6 +241,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
|
||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
collectionView.backgroundColor = .white
|
||||
collectionView.accessibilityIdentifier = "wiredTransfer.photoCollectionView"
|
||||
collectionView.delegate = self
|
||||
collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0)
|
||||
collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier)
|
||||
@@ -387,6 +440,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
||||
retouchButton.addTarget(self, action: #selector(retouchTapped), for: .touchUpInside)
|
||||
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
|
||||
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
|
||||
albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside)
|
||||
@@ -415,7 +469,8 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10)
|
||||
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
|
||||
|
||||
retouchButton.apply(title: viewModel.retouchOption)
|
||||
retouchButton.apply(title: viewModel.isUpdatingAutoRetouchConfiguration ? "保存中" : viewModel.retouchOption)
|
||||
retouchButton.isEnabled = !viewModel.isUpdatingAutoRetouchConfiguration
|
||||
formatButton.apply(title: "JPG")
|
||||
modeButton.apply(title: viewModel.transferModeOption)
|
||||
helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
|
||||
@@ -567,6 +622,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
let selected = viewModel.selectedPhotoIds.contains(item.id)
|
||||
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
|
||||
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
|
||||
cell.onRetryRetouch = { [weak self] in self?.viewModel.retryAutoRetouch(photoId: item.id) }
|
||||
cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) }
|
||||
}
|
||||
|
||||
@@ -711,6 +767,39 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
viewModel.refreshCameraFiles()
|
||||
}
|
||||
|
||||
@objc private func openAIJobList() {
|
||||
guard let navigationController,
|
||||
navigationController.topViewController === self,
|
||||
presentedViewController == nil,
|
||||
previewLoadingTask == nil else { return }
|
||||
navigationController.pushViewController(
|
||||
TravelAlbumAIJobListViewController(api: viewModel.autoRetouchAPI),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func applicationEnteredBackground() { viewModel.applicationDidEnterBackground() }
|
||||
|
||||
@objc private func applicationBecameActive() { viewModel.applicationDidBecomeActive() }
|
||||
|
||||
@objc private func retouchTapped() {
|
||||
guard presentedViewController == nil else { return }
|
||||
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||
scenicId: AppStore.shared.session.currentScenicId,
|
||||
configuration: viewModel.autoRetouchConfiguration,
|
||||
startsWithModeSelection: true
|
||||
)
|
||||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
||||
viewModel: settingViewModel,
|
||||
api: viewModel.autoRetouchAPI
|
||||
)
|
||||
controller.onConfirm = { [weak self] configuration in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.updateAutoRetouchConfiguration(configuration) }
|
||||
}
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
@objc private func batchTapped() {
|
||||
viewModel.onBatchUploadButtonClick()
|
||||
}
|
||||
@@ -788,6 +877,11 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
|
||||
guard previewLoadingTask == nil, presentedViewController == nil else { return }
|
||||
if item.autoRetouchState == .completed {
|
||||
presentAutoRetouchPreview(photoId: item.id)
|
||||
return
|
||||
}
|
||||
guard let url = item.thumbnailURL else {
|
||||
showToast("暂无可预览图片")
|
||||
return
|
||||
@@ -797,6 +891,39 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
: MediaPreviewItem(source: .remoteImage(url))
|
||||
MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0)
|
||||
}
|
||||
|
||||
private func presentAutoRetouchPreview(photoId: String) {
|
||||
previewLoadingTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
showLoading()
|
||||
defer {
|
||||
hideLoading()
|
||||
previewLoadingTask = nil
|
||||
}
|
||||
do {
|
||||
let project = try await viewModel.loadAutoRetouchPreviewProject(photoId: photoId)
|
||||
try Task.checkCancellation()
|
||||
guard viewIfLoaded?.window != nil,
|
||||
presentedViewController == nil,
|
||||
!viewModel.selectUploadMode else { return }
|
||||
present(
|
||||
TravelAlbumPhotoPreviewViewController(
|
||||
projects: [project],
|
||||
totalCount: 1,
|
||||
startProjectIndex: 0,
|
||||
startKind: .retouched,
|
||||
allowsActions: false
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
} catch is CancellationError {
|
||||
// 离开传输页后不再弹出预览或错误提示。
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
showToast(error.localizedDescription.isEmpty ? "修图结果加载失败,请重试" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WiredCameraTransferViewController: PHPickerViewControllerDelegate {
|
||||
@@ -1111,12 +1238,13 @@ private final class WiredTransferSectionHeaderView: UICollectionReusableView {
|
||||
}
|
||||
|
||||
/// 有线传输照片列表 Cell。
|
||||
private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "WiredTransferPhotoCell"
|
||||
private static let previewImageSize = CGSize(width: 96, height: 96)
|
||||
|
||||
private let selectionIconView = UIImageView()
|
||||
private let imageView = UIImageView()
|
||||
private let retouchBadgeLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let titleLabel = UILabel()
|
||||
private let sizeLabel = UILabel()
|
||||
@@ -1125,6 +1253,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
private let separatorView = UIView()
|
||||
|
||||
var onRetry: (() -> Void)?
|
||||
var onRetryRetouch: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -1142,10 +1271,12 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
imageView.kf.cancelDownloadTask()
|
||||
imageView.image = nil
|
||||
onRetry = nil
|
||||
onRetryRetouch = nil
|
||||
onDelete = nil
|
||||
menuButton.menu = nil
|
||||
}
|
||||
|
||||
/// 分别渲染原图上传进度与自动修图角标,修图状态不参与进度计算。
|
||||
func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) {
|
||||
let canSelect = item.canSelectForUpload
|
||||
let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1
|
||||
@@ -1172,22 +1303,64 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
statusLabel.text = statusText(item.status)
|
||||
statusLabel.textColor = statusTextColor(item.status)
|
||||
statusLabel.backgroundColor = statusBackgroundColor(item.status)
|
||||
applyRetouchBadge(state: item.autoRetouchState)
|
||||
progressView.isHidden = item.status != .uploading && item.status != .transferring
|
||||
progressView.progress = Float(item.progress) / 100.0
|
||||
selectionIconView.isHidden = !selectionMode
|
||||
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
|
||||
menuButton.isHidden = selectionMode
|
||||
menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending)
|
||||
menuButton.menu = makeMenu(
|
||||
canRetryUpload: item.status == .failed || item.status == .pending,
|
||||
canRetryRetouch: item.autoRetouchState == .failed
|
||||
)
|
||||
accessibilityLabel = "\(item.fileName),\(statusText(item.status))"
|
||||
if !retouchBadgeLabel.isHidden, let retouchStatus = retouchBadgeLabel.accessibilityLabel {
|
||||
accessibilityLabel?.append(",修图状态:\(retouchStatus)")
|
||||
}
|
||||
if let error = item.autoRetouchErrorMessage, !error.isEmpty {
|
||||
accessibilityHint = "失败原因:\(error)"
|
||||
} else {
|
||||
accessibilityHint = nil
|
||||
}
|
||||
updateImageConstraints(selectionMode: selectionMode)
|
||||
}
|
||||
|
||||
private func applyRetouchBadge(state: TravelAlbumAutoRetouchState) {
|
||||
retouchBadgeLabel.isHidden = state == .none
|
||||
switch state {
|
||||
case .none:
|
||||
retouchBadgeLabel.backgroundColor = .clear
|
||||
retouchBadgeLabel.accessibilityLabel = nil
|
||||
case .pendingSubmission, .submitting, .processing:
|
||||
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x7C3AED)
|
||||
retouchBadgeLabel.accessibilityLabel = "修图中"
|
||||
case .completed:
|
||||
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x047857)
|
||||
retouchBadgeLabel.accessibilityLabel = "修图成功"
|
||||
case .failed:
|
||||
retouchBadgeLabel.backgroundColor = AppColor.danger
|
||||
retouchBadgeLabel.accessibilityLabel = "修图失败"
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
contentView.backgroundColor = .white
|
||||
selectionIconView.contentMode = .scaleAspectFit
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
imageView.layer.cornerRadius = 6
|
||||
imageView.accessibilityIdentifier = "wiredTransfer.thumbnail"
|
||||
retouchBadgeLabel.text = "修"
|
||||
retouchBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
|
||||
retouchBadgeLabel.textColor = .white
|
||||
retouchBadgeLabel.textAlignment = .center
|
||||
retouchBadgeLabel.backgroundColor = .clear
|
||||
retouchBadgeLabel.layer.cornerRadius = 8
|
||||
retouchBadgeLabel.clipsToBounds = true
|
||||
retouchBadgeLabel.isHidden = true
|
||||
retouchBadgeLabel.accessibilityIdentifier = "wiredTransfer.retouchBadge"
|
||||
statusLabel.accessibilityIdentifier = "wiredTransfer.uploadStatus"
|
||||
statusLabel.font = .systemFont(ofSize: 9)
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.layer.cornerRadius = 3
|
||||
@@ -1203,6 +1376,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
sizeLabel.textColor = AppColor.textTertiary
|
||||
progressView.progressTintColor = AppColor.primary
|
||||
progressView.trackTintColor = AppColor.border
|
||||
progressView.accessibilityIdentifier = "wiredTransfer.uploadProgress"
|
||||
menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal)
|
||||
menuButton.tintColor = AppColor.textSecondary
|
||||
menuButton.showsMenuAsPrimaryAction = true
|
||||
@@ -1210,6 +1384,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
|
||||
contentView.addSubview(selectionIconView)
|
||||
contentView.addSubview(imageView)
|
||||
contentView.addSubview(retouchBadgeLabel)
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(statusLabel)
|
||||
contentView.addSubview(sizeLabel)
|
||||
@@ -1223,6 +1398,11 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
updateImageConstraints(selectionMode: true)
|
||||
retouchBadgeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(imageView).offset(2)
|
||||
make.trailing.equalTo(imageView).offset(-2)
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(imageView).offset(1)
|
||||
make.leading.equalTo(imageView.snp.trailing).offset(8)
|
||||
@@ -1257,15 +1437,19 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMenu(canRetry: Bool) -> UIMenu {
|
||||
private func makeMenu(canRetryUpload: Bool, canRetryRetouch: Bool) -> UIMenu {
|
||||
let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
|
||||
self?.onRetry?()
|
||||
}
|
||||
retry.attributes = canRetry ? [] : [.disabled]
|
||||
retry.attributes = canRetryUpload ? [] : [.disabled]
|
||||
let retryRetouch = UIAction(title: "重新修图", image: UIImage(systemName: "wand.and.stars")) { [weak self] _ in
|
||||
self?.onRetryRetouch?()
|
||||
}
|
||||
retryRetouch.attributes = canRetryRetouch ? [] : [.disabled]
|
||||
let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
||||
self?.onDelete?()
|
||||
}
|
||||
return UIMenu(children: [retry, delete])
|
||||
return UIMenu(children: [retry, retryRetouch, delete])
|
||||
}
|
||||
|
||||
private func updateImageConstraints(selectionMode: Bool) {
|
||||
|
||||
Reference in New Issue
Block a user