feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
@@ -22,8 +22,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
private let freeCountField = UITextField()
|
||||
private let singlePriceField = UITextField()
|
||||
private let packagePriceField = UITextField()
|
||||
private let autoRetouchSectionView = UIView()
|
||||
private let autoRetouchTitleLabel = UILabel()
|
||||
private let autoRetouchDetailLabel = UILabel()
|
||||
private let noRetouchOption = TravelAlbumModeOptionView()
|
||||
private let aiRetouchOption = TravelAlbumModeOptionView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||
|
||||
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
|
||||
self.viewModel = viewModel
|
||||
@@ -31,8 +37,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .pageSheet
|
||||
if let sheetPresentationController {
|
||||
sheetPresentationController.detents = [.medium(), .large()]
|
||||
let formDetent = UISheetPresentationController.Detent.Identifier("createTravelAlbumForm")
|
||||
sheetPresentationController.detents = [
|
||||
.custom(identifier: formDetent) { min(650, $0.maximumDetentValue) },
|
||||
.large(),
|
||||
]
|
||||
sheetPresentationController.selectedDetentIdentifier = formDetent
|
||||
sheetPresentationController.prefersGrabberVisible = false
|
||||
sheetPresentationController.preferredCornerRadius = 22
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +74,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
|
||||
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
|
||||
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
|
||||
configureAutoRetouchSection()
|
||||
|
||||
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
|
||||
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
|
||||
@@ -112,6 +125,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
[freeCountField, singlePriceField, packagePriceField].forEach {
|
||||
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
|
||||
}
|
||||
noRetouchOption.addTarget(self, action: #selector(noRetouchTapped), for: .touchUpInside)
|
||||
aiRetouchOption.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
@@ -148,6 +163,42 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
|
||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
|
||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
|
||||
fieldsStack.addArrangedSubview(autoRetouchSectionView)
|
||||
}
|
||||
|
||||
private func configureAutoRetouchSection() {
|
||||
autoRetouchSectionView.backgroundColor = .white
|
||||
autoRetouchSectionView.layer.cornerRadius = 10
|
||||
autoRetouchSectionView.layer.borderWidth = 1
|
||||
autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor
|
||||
autoRetouchTitleLabel.text = "修图方式"
|
||||
autoRetouchTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
autoRetouchTitleLabel.textColor = AppColor.textPrimary
|
||||
autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板"
|
||||
autoRetouchDetailLabel.font = .systemFont(ofSize: 12)
|
||||
autoRetouchDetailLabel.textColor = AppColor.textSecondary
|
||||
|
||||
let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption])
|
||||
optionsStack.axis = .horizontal
|
||||
optionsStack.spacing = 10
|
||||
optionsStack.distribution = .fillEqually
|
||||
autoRetouchSectionView.addSubview(autoRetouchTitleLabel)
|
||||
autoRetouchSectionView.addSubview(autoRetouchDetailLabel)
|
||||
autoRetouchSectionView.addSubview(optionsStack)
|
||||
autoRetouchTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(14)
|
||||
}
|
||||
autoRetouchDetailLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(4)
|
||||
make.leading.trailing.equalTo(autoRetouchTitleLabel)
|
||||
}
|
||||
optionsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(12)
|
||||
make.height.equalTo(76)
|
||||
}
|
||||
updateAutoRetouchSection()
|
||||
}
|
||||
|
||||
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
|
||||
@@ -188,6 +239,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
freeCount: freeCountField.text ?? "",
|
||||
singlePrice: singlePriceField.text ?? "",
|
||||
packagePrice: packagePriceField.text ?? "",
|
||||
autoRetouchConfiguration: autoRetouchConfiguration,
|
||||
order: nil,
|
||||
api: api
|
||||
)
|
||||
@@ -199,6 +251,42 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func noRetouchTapped() {
|
||||
autoRetouchConfiguration = .disabled
|
||||
updateAutoRetouchSection()
|
||||
}
|
||||
|
||||
@objc private func aiRetouchTapped() {
|
||||
guard presentedViewController == nil else { return }
|
||||
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
|
||||
scenicId: AppStore.shared.session.currentScenicId,
|
||||
configuration: autoRetouchConfiguration,
|
||||
startsWithModeSelection: false
|
||||
)
|
||||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
||||
viewModel: settingViewModel,
|
||||
api: api
|
||||
)
|
||||
controller.onConfirm = { [weak self] configuration in
|
||||
self?.autoRetouchConfiguration = configuration
|
||||
self?.updateAutoRetouchSection()
|
||||
}
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func updateAutoRetouchSection() {
|
||||
noRetouchOption.apply(title: "不修图", desc: "保留原图", selected: !autoRetouchConfiguration.enabled)
|
||||
aiRetouchOption.apply(
|
||||
title: "AI 修图",
|
||||
desc: autoRetouchConfiguration.enabled ? "已选择真实 AI 模板" : "点击选模板",
|
||||
selected: autoRetouchConfiguration.enabled
|
||||
)
|
||||
noRetouchOption.accessibilityLabel = "不修图,保留原图"
|
||||
aiRetouchOption.accessibilityLabel = "AI 修图,点击选择模板"
|
||||
noRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "未选择" : "已选择"
|
||||
aiRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "已选择" : "未选择"
|
||||
}
|
||||
|
||||
@objc private func textFieldEditingChanged(_ field: UITextField) {
|
||||
let text = field.text ?? ""
|
||||
if field === freeCountField {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 相册自动 AI 修图设置 Sheet,使用服务端精修模板并保持单选。
|
||||
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController {
|
||||
private enum Item: Hashable {
|
||||
case mode(Bool)
|
||||
case template(TravelAlbumAIRetouchTemplate)
|
||||
}
|
||||
|
||||
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
|
||||
|
||||
private let viewModel: TravelAlbumAutoRetouchSettingViewModel
|
||||
private let api: any TravelAlbumServing
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let backButton = UIButton(type: .system)
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, Item>!
|
||||
private let statusContainer = UIView()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let statusLabel = UILabel()
|
||||
private let retryButton = UIButton(type: .system)
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
|
||||
/// 创建设置 Sheet。
|
||||
init(
|
||||
viewModel: TravelAlbumAutoRetouchSettingViewModel,
|
||||
api: any TravelAlbumServing
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .pageSheet
|
||||
if let sheetPresentationController {
|
||||
sheetPresentationController.detents = [.large()]
|
||||
sheetPresentationController.selectedDetentIdentifier = .large
|
||||
sheetPresentationController.prefersGrabberVisible = true
|
||||
sheetPresentationController.preferredCornerRadius = 22
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = .white
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||
subtitleLabel.textColor = AppColor.textSecondary
|
||||
subtitleLabel.textAlignment = .center
|
||||
subtitleLabel.numberOfLines = 0
|
||||
|
||||
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||
backButton.tintColor = AppColor.textPrimary
|
||||
backButton.accessibilityLabel = "返回修图方式"
|
||||
|
||||
tableView.backgroundColor = .white
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = 116
|
||||
tableView.delegate = self
|
||||
tableView.register(AutoRetouchOptionCell.self, forCellReuseIdentifier: AutoRetouchOptionCell.reuseIdentifier)
|
||||
configureDataSource()
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 14)
|
||||
statusLabel.textColor = AppColor.textSecondary
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.numberOfLines = 0
|
||||
retryButton.setTitle("重试", for: .normal)
|
||||
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
activityIndicator.color = AppColor.primary
|
||||
|
||||
configureAction(cancelButton, title: "取消", filled: false)
|
||||
configureAction(confirmButton, title: "确定", filled: true)
|
||||
confirmButton.accessibilityIdentifier = "travelAlbum.autoRetouchConfirmButton"
|
||||
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(subtitleLabel)
|
||||
view.addSubview(backButton)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(statusContainer)
|
||||
statusContainer.addSubview(activityIndicator)
|
||||
statusContainer.addSubview(statusLabel)
|
||||
statusContainer.addSubview(retryButton)
|
||||
view.addSubview(cancelButton)
|
||||
view.addSubview(confirmButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(56)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(28)
|
||||
}
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.size.equalTo(36)
|
||||
}
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
make.height.equalTo(52)
|
||||
make.width.equalTo(confirmButton)
|
||||
}
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.top.bottom.width.equalTo(cancelButton)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(14)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(cancelButton.snp.top).offset(-12)
|
||||
}
|
||||
statusContainer.snp.makeConstraints { $0.edges.equalTo(tableView) }
|
||||
activityIndicator.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-30)
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(activityIndicator.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(36)
|
||||
}
|
||||
retryButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusLabel.snp.bottom).offset(10)
|
||||
make.centerX.equalToSuperview()
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
applyViewModel()
|
||||
Task { await viewModel.loadTemplates(api: api) }
|
||||
}
|
||||
|
||||
private func configureDataSource() {
|
||||
dataSource = UITableViewDiffableDataSource<Int, Item>(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||
guard let self else { return nil }
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: AutoRetouchOptionCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! AutoRetouchOptionCell
|
||||
switch item {
|
||||
case .mode(let enabled):
|
||||
cell.apply(
|
||||
title: enabled ? "AI 修图" : "不修图",
|
||||
detail: enabled ? "选择后需再选一个修图模板" : "保留原图",
|
||||
imageURL: nil,
|
||||
systemImage: enabled ? "wand.and.stars" : "photo",
|
||||
selected: self.viewModel.isEnabled == enabled
|
||||
)
|
||||
case .template(let template):
|
||||
cell.apply(
|
||||
title: template.name,
|
||||
detail: "",
|
||||
imageURL: template.previewURL,
|
||||
systemImage: nil,
|
||||
selected: self.viewModel.selectedTemplateId == template.id
|
||||
)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
let isMode = viewModel.stage == .mode
|
||||
titleLabel.text = isMode ? "选择修图方式" : "选择修图模板"
|
||||
subtitleLabel.text = isMode
|
||||
? "照片上传前可选择保留原图,或使用 AI 自动修图"
|
||||
: "缩略图为模板实际效果,后续上传的照片将自动套用"
|
||||
backButton.isHidden = isMode || !viewModel.startsWithModeSelection
|
||||
confirmButton.isEnabled = viewModel.pendingConfiguration != nil && !viewModel.isLoading
|
||||
confirmButton.alpha = confirmButton.isEnabled ? 1 : 0.45
|
||||
|
||||
statusContainer.isHidden = !viewModel.isLoading && viewModel.errorMessage == nil
|
||||
if viewModel.isLoading {
|
||||
activityIndicator.startAnimating()
|
||||
statusLabel.text = "正在加载修图模板…"
|
||||
retryButton.isHidden = true
|
||||
} else {
|
||||
activityIndicator.stopAnimating()
|
||||
statusLabel.text = viewModel.errorMessage
|
||||
retryButton.isHidden = viewModel.errorMessage == nil
|
||||
}
|
||||
|
||||
let previousItems = Set(dataSource.snapshot().itemIdentifiers)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
|
||||
snapshot.appendSections([0])
|
||||
let items: [Item]
|
||||
if isMode {
|
||||
items = [.mode(false), .mode(true)]
|
||||
} else {
|
||||
items = viewModel.templates.map(Item.template)
|
||||
}
|
||||
snapshot.appendItems(items)
|
||||
snapshot.reconfigureItems(items.filter(previousItems.contains))
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
|
||||
private func configureAction(_ button: UIButton, title: String, filled: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
button.layer.cornerRadius = 12
|
||||
if filled {
|
||||
button.backgroundColor = AppColor.primary
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
} else {
|
||||
button.backgroundColor = UIColor(hex: 0xF4F5F7)
|
||||
button.setTitleColor(AppColor.textSecondary, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() { dismiss(animated: true) }
|
||||
@objc private func backTapped() { viewModel.returnToModeSelection() }
|
||||
@objc private func retryTapped() { Task { await viewModel.loadTemplates(api: api) } }
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard let configuration = viewModel.pendingConfiguration else { return }
|
||||
let completion = onConfirm
|
||||
dismiss(animated: true) { completion?(configuration) }
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAutoRetouchSettingSheetViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch item {
|
||||
case .mode(let enabled): viewModel.selectMode(enabled: enabled)
|
||||
case .template(let template): viewModel.selectTemplate(id: template.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动修图方式或模板列表单元,提供效果图、说明和明确单选状态。
|
||||
private final class AutoRetouchOptionCell: UITableViewCell {
|
||||
static let reuseIdentifier = "AutoRetouchOptionCell"
|
||||
private let card = UIView()
|
||||
private let previewImageView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let selectionImageView = UIImageView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
card.layer.cornerRadius = 12
|
||||
card.layer.borderWidth = 1
|
||||
previewImageView.contentMode = .scaleAspectFill
|
||||
previewImageView.clipsToBounds = true
|
||||
previewImageView.layer.cornerRadius = 8
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
detailLabel.font = .systemFont(ofSize: 13)
|
||||
detailLabel.textColor = AppColor.textSecondary
|
||||
detailLabel.numberOfLines = 2
|
||||
selectionImageView.contentMode = .scaleAspectFit
|
||||
|
||||
contentView.addSubview(card)
|
||||
card.addSubview(previewImageView)
|
||||
card.addSubview(titleLabel)
|
||||
card.addSubview(detailLabel)
|
||||
card.addSubview(selectionImageView)
|
||||
card.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(6)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
previewImageView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(12)
|
||||
make.top.bottom.equalToSuperview().inset(10)
|
||||
make.width.equalTo(previewImageView.snp.height)
|
||||
}
|
||||
selectionImageView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(previewImageView.snp.trailing).offset(14)
|
||||
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-12)
|
||||
make.bottom.equalTo(card.snp.centerY).offset(-2)
|
||||
}
|
||||
detailLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
make.top.equalTo(card.snp.centerY).offset(4)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
previewImageView.kf.cancelDownloadTask()
|
||||
previewImageView.image = nil
|
||||
}
|
||||
|
||||
func apply(
|
||||
title: String,
|
||||
detail: String,
|
||||
imageURL: String?,
|
||||
systemImage: String?,
|
||||
selected: Bool
|
||||
) {
|
||||
titleLabel.text = title
|
||||
let normalizedDetail = detail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
detailLabel.text = normalizedDetail
|
||||
detailLabel.isHidden = normalizedDetail.isEmpty
|
||||
titleLabel.snp.remakeConstraints { make in
|
||||
make.leading.equalTo(previewImageView.snp.trailing).offset(14)
|
||||
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-12)
|
||||
if normalizedDetail.isEmpty {
|
||||
make.centerY.equalToSuperview()
|
||||
} else {
|
||||
make.bottom.equalTo(card.snp.centerY).offset(-2)
|
||||
}
|
||||
}
|
||||
if let imageURL, let url = URL(string: imageURL), !imageURL.isEmpty {
|
||||
previewImageView.contentMode = .scaleAspectFill
|
||||
previewImageView.backgroundColor = .clear
|
||||
previewImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
|
||||
} else {
|
||||
previewImageView.image = systemImage.flatMap(UIImage.init(systemName:))
|
||||
previewImageView.contentMode = .center
|
||||
previewImageView.tintColor = AppColor.primary
|
||||
previewImageView.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
|
||||
}
|
||||
card.backgroundColor = selected ? AppColor.primary.withAlphaComponent(0.07) : .white
|
||||
card.layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
|
||||
selectionImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
selectionImageView.tintColor = selected ? AppColor.primary : AppColor.textTertiary
|
||||
accessibilityLabel = normalizedDetail.isEmpty ? title : "\(title),\(normalizedDetail)"
|
||||
accessibilityValue = selected ? "已选择" : "未选择"
|
||||
isAccessibilityElement = true
|
||||
}
|
||||
}
|
||||
@@ -498,13 +498,25 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
}
|
||||
|
||||
@objc private func uploadTapped() {
|
||||
guard presentedViewController == nil else { return }
|
||||
let selector = TravelAlbumTransferModeSheetViewController()
|
||||
selector.onModeSelected = { [weak self] mode in
|
||||
self?.openWiredTransfer(mode: mode)
|
||||
}
|
||||
present(selector, animated: true)
|
||||
}
|
||||
|
||||
private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) {
|
||||
let album = viewModel.album
|
||||
refreshState.markRefreshNeeded()
|
||||
let controller = WiredCameraTransferViewController(
|
||||
viewModel: WiredCameraTransferViewModel(
|
||||
albumId: album?.id ?? viewModel.albumId,
|
||||
albumTitle: album?.name ?? "",
|
||||
headerPhone: album?.displayPhone ?? ""
|
||||
headerPhone: album?.displayPhone ?? "",
|
||||
initialTransferMode: mode,
|
||||
initialAutoRetouchConfiguration: album?.autoRetouchConfiguration ?? .disabled,
|
||||
api: api
|
||||
)
|
||||
)
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 相册管理上传入口的传输模式选择 Sheet。
|
||||
final class TravelAlbumTransferModeSheetViewController: UIViewController {
|
||||
var onModeSelected: ((TravelAlbumOTGTransferMode) -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let optionsStack = UIStackView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||
modalPresentationStyle = .pageSheet
|
||||
if let sheetPresentationController {
|
||||
let identifier = UISheetPresentationController.Detent.Identifier("travelAlbumTransferMode")
|
||||
sheetPresentationController.detents = [
|
||||
.custom(identifier: identifier) { min(356, $0.maximumDetentValue) },
|
||||
]
|
||||
sheetPresentationController.selectedDetentIdentifier = identifier
|
||||
sheetPresentationController.prefersGrabberVisible = true
|
||||
sheetPresentationController.preferredCornerRadius = 22
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .white
|
||||
titleLabel.text = "选择传输模式"
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
subtitleLabel.text = "选择后进入对应的照片传输页面"
|
||||
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||
subtitleLabel.textColor = AppColor.textSecondary
|
||||
subtitleLabel.textAlignment = .center
|
||||
optionsStack.axis = .vertical
|
||||
optionsStack.spacing = 12
|
||||
TravelAlbumOTGTransferMode.allCases.enumerated().forEach { index, mode in
|
||||
optionsStack.addArrangedSubview(makeOption(mode: mode, index: index))
|
||||
}
|
||||
cancelButton.setTitle("取消", for: .normal)
|
||||
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
|
||||
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
cancelButton.backgroundColor = UIColor(hex: 0xF4F5F7)
|
||||
cancelButton.layer.cornerRadius = 12
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(subtitleLabel)
|
||||
view.addSubview(optionsStack)
|
||||
view.addSubview(cancelButton)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(7)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
}
|
||||
optionsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(optionsStack.snp.bottom).offset(14)
|
||||
make.leading.trailing.equalTo(optionsStack)
|
||||
make.height.equalTo(48)
|
||||
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-10)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeOption(mode: TravelAlbumOTGTransferMode, index: Int) -> UIView {
|
||||
let container = UIView()
|
||||
container.backgroundColor = UIColor(hex: 0xF7F9FC)
|
||||
container.layer.cornerRadius = 12
|
||||
container.layer.borderWidth = 1
|
||||
container.layer.borderColor = UIColor(hex: 0xE6ECF5).cgColor
|
||||
let title = UILabel()
|
||||
title.text = mode.title
|
||||
title.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
title.textColor = AppColor.textPrimary
|
||||
let detail = UILabel()
|
||||
detail.text = mode.detailText
|
||||
detail.font = .systemFont(ofSize: 12)
|
||||
detail.textColor = AppColor.textSecondary
|
||||
detail.numberOfLines = 2
|
||||
let enter = UILabel()
|
||||
enter.text = "进入"
|
||||
enter.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
enter.textColor = AppColor.primary
|
||||
let button = UIButton(type: .custom)
|
||||
button.tag = index
|
||||
button.accessibilityLabel = "\(mode.title),\(mode.detailText)"
|
||||
button.addTarget(self, action: #selector(modeTapped(_:)), for: .touchUpInside)
|
||||
container.addSubview(title)
|
||||
container.addSubview(detail)
|
||||
container.addSubview(enter)
|
||||
container.addSubview(button)
|
||||
title.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(13)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.trailing.lessThanOrEqualTo(enter.snp.leading).offset(-12)
|
||||
}
|
||||
detail.snp.makeConstraints { make in
|
||||
make.top.equalTo(title.snp.bottom).offset(5)
|
||||
make.leading.equalTo(title)
|
||||
make.trailing.lessThanOrEqualTo(enter.snp.leading).offset(-12)
|
||||
}
|
||||
enter.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
button.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
container.snp.makeConstraints { $0.height.equalTo(78) }
|
||||
return container
|
||||
}
|
||||
|
||||
@objc private func modeTapped(_ sender: UIButton) {
|
||||
guard TravelAlbumOTGTransferMode.allCases.indices.contains(sender.tag) else { return }
|
||||
let mode = TravelAlbumOTGTransferMode.allCases[sender.tag]
|
||||
let completion = onModeSelected
|
||||
dismiss(animated: true) { completion?(mode) }
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() { dismiss(animated: true) }
|
||||
}
|
||||
@@ -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