795 lines
35 KiB
Swift
795 lines
35 KiB
Swift
//
|
||
// TravelAlbumDetailViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Kingfisher
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 相册管理页,展示相册摘要、素材筛选排序、分页与批量操作。
|
||
final class TravelAlbumDetailViewController: BaseViewController {
|
||
private let viewModel: TravelAlbumDetailViewModel
|
||
private let api: any TravelAlbumServing
|
||
private let previewConfiguration: TravelAlbumPreviewConfiguration
|
||
private let scenicIdProvider: () -> Int
|
||
|
||
private let contentView = UIView()
|
||
private let infoCard = TravelAlbumInfoCard()
|
||
private let sectionTitleLabel = UILabel()
|
||
private let segmentedControl = UIView()
|
||
private let allTabButton = UIButton(type: .system)
|
||
private let purchasedTabButton = UIButton(type: .system)
|
||
private let sortButton = UIButton(type: .system)
|
||
private let selectButton = UIButton(type: .system)
|
||
private let refreshControl = UIRefreshControl()
|
||
private var collectionView: UICollectionView!
|
||
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
|
||
private let bottomBar = UIView()
|
||
private let bottomDivider = UIView()
|
||
private let selectionActionStack = UIStackView()
|
||
private let aiRetouchButton = UIButton(type: .system)
|
||
private let deleteSelectedButton = UIButton(type: .system)
|
||
private let uploadButton = UIButton(type: .system)
|
||
|
||
init(
|
||
albumId: Int,
|
||
api: (any TravelAlbumServing)? = nil,
|
||
previewConfiguration: TravelAlbumPreviewConfiguration = .init(),
|
||
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }
|
||
) {
|
||
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||
self.previewConfiguration = previewConfiguration
|
||
self.scenicIdProvider = scenicIdProvider
|
||
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 = TravelAlbumDetailStyle.textPrimary
|
||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||
navigationItem.titleView = titleLabel
|
||
|
||
let appearance = UINavigationBarAppearance()
|
||
appearance.configureWithOpaqueBackground()
|
||
appearance.backgroundColor = .white
|
||
appearance.shadowColor = .clear
|
||
navigationItem.standardAppearance = appearance
|
||
navigationItem.scrollEdgeAppearance = appearance
|
||
navigationItem.compactAppearance = appearance
|
||
|
||
let moreItem = UIBarButtonItem(
|
||
image: UIImage(systemName: "ellipsis"),
|
||
menu: UIMenu(children: [
|
||
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
||
self?.confirmDeleteAlbum()
|
||
},
|
||
])
|
||
)
|
||
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() {
|
||
view.backgroundColor = TravelAlbumDetailStyle.pageBackground
|
||
|
||
sectionTitleLabel.text = "照片"
|
||
sectionTitleLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||
sectionTitleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||
|
||
segmentedControl.backgroundColor = .white
|
||
segmentedControl.layer.cornerRadius = 9
|
||
segmentedControl.layer.borderWidth = 1
|
||
segmentedControl.layer.borderColor = TravelAlbumDetailStyle.border.cgColor
|
||
segmentedControl.clipsToBounds = true
|
||
configureTabButtonBase(allTabButton)
|
||
configureTabButtonBase(purchasedTabButton)
|
||
configureSortButton()
|
||
configureSelectButton()
|
||
|
||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||
collectionView.backgroundColor = .clear
|
||
collectionView.alwaysBounceVertical = true
|
||
collectionView.showsVerticalScrollIndicator = false
|
||
collectionView.delegate = self
|
||
refreshControl.tintColor = TravelAlbumDetailStyle.primary
|
||
refreshControl.accessibilityIdentifier = "travelAlbum.refreshControl"
|
||
collectionView.refreshControl = refreshControl
|
||
collectionView.register(
|
||
TravelAlbumMaterialCell.self,
|
||
forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier
|
||
)
|
||
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
|
||
[weak self] collectionView, indexPath, material in
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as! TravelAlbumMaterialCell
|
||
cell.apply(
|
||
material: material,
|
||
selectionMode: self?.viewModel.isSelectionMode == true,
|
||
selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true
|
||
)
|
||
return cell
|
||
}
|
||
|
||
bottomBar.backgroundColor = .white
|
||
bottomDivider.backgroundColor = TravelAlbumDetailStyle.border.withAlphaComponent(0.65)
|
||
configureBottomButton(uploadButton, title: "上传照片", color: TravelAlbumDetailStyle.primary)
|
||
var uploadConfiguration = uploadButton.configuration
|
||
uploadConfiguration?.image = UIImage(systemName: "plus.circle.fill")
|
||
uploadConfiguration?.imagePadding = 8
|
||
uploadButton.configuration = uploadConfiguration
|
||
uploadButton.accessibilityLabel = "上传照片"
|
||
selectionActionStack.axis = .horizontal
|
||
selectionActionStack.alignment = .fill
|
||
selectionActionStack.distribution = .fillEqually
|
||
selectionActionStack.spacing = 12
|
||
selectionActionStack.isHidden = true
|
||
selectionActionStack.accessibilityIdentifier = "travelAlbum.selectionActionStack"
|
||
configureBottomButton(aiRetouchButton, title: "AI修图", color: TravelAlbumDetailStyle.primary)
|
||
aiRetouchButton.accessibilityLabel = "AI修图"
|
||
aiRetouchButton.accessibilityIdentifier = "travelAlbum.aiRetouchButton"
|
||
configureBottomButton(deleteSelectedButton, title: "删除", color: TravelAlbumDetailStyle.danger)
|
||
deleteSelectedButton.accessibilityLabel = "删除"
|
||
deleteSelectedButton.accessibilityIdentifier = "travelAlbum.deleteButton"
|
||
selectionActionStack.addArrangedSubview(aiRetouchButton)
|
||
selectionActionStack.addArrangedSubview(deleteSelectedButton)
|
||
|
||
view.addSubview(contentView)
|
||
contentView.addSubview(infoCard)
|
||
contentView.addSubview(sectionTitleLabel)
|
||
contentView.addSubview(segmentedControl)
|
||
segmentedControl.addSubview(allTabButton)
|
||
segmentedControl.addSubview(purchasedTabButton)
|
||
contentView.addSubview(sortButton)
|
||
contentView.addSubview(selectButton)
|
||
contentView.addSubview(collectionView)
|
||
view.addSubview(bottomBar)
|
||
bottomBar.addSubview(bottomDivider)
|
||
bottomBar.addSubview(uploadButton)
|
||
bottomBar.addSubview(selectionActionStack)
|
||
}
|
||
|
||
override func setupConstraints() {
|
||
bottomBar.snp.makeConstraints { make in
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
bottomDivider.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(0.5)
|
||
}
|
||
uploadButton.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(12)
|
||
make.leading.trailing.equalToSuperview().inset(18)
|
||
make.height.equalTo(48)
|
||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||
}
|
||
selectionActionStack.snp.makeConstraints { make in
|
||
make.edges.equalTo(uploadButton)
|
||
}
|
||
|
||
contentView.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||
make.leading.trailing.equalToSuperview().inset(18)
|
||
make.bottom.equalTo(bottomBar.snp.top)
|
||
}
|
||
infoCard.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(116)
|
||
}
|
||
sectionTitleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(infoCard.snp.bottom).offset(20)
|
||
make.leading.equalToSuperview()
|
||
}
|
||
segmentedControl.snp.makeConstraints { make in
|
||
make.top.equalTo(sectionTitleLabel.snp.bottom).offset(14)
|
||
make.leading.equalToSuperview()
|
||
make.width.equalTo(182)
|
||
make.height.equalTo(32)
|
||
}
|
||
allTabButton.snp.makeConstraints { make in
|
||
make.top.bottom.leading.equalToSuperview()
|
||
make.width.equalToSuperview().multipliedBy(0.5)
|
||
}
|
||
purchasedTabButton.snp.makeConstraints { make in
|
||
make.top.bottom.trailing.equalToSuperview()
|
||
make.leading.equalTo(allTabButton.snp.trailing)
|
||
}
|
||
selectButton.snp.makeConstraints { make in
|
||
make.centerY.equalTo(segmentedControl)
|
||
make.trailing.equalToSuperview()
|
||
make.width.equalTo(48)
|
||
make.height.equalTo(32)
|
||
}
|
||
sortButton.snp.makeConstraints { make in
|
||
make.centerY.equalTo(segmentedControl)
|
||
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
|
||
make.width.equalTo(74)
|
||
make.height.equalTo(32)
|
||
make.leading.greaterThanOrEqualTo(segmentedControl.snp.trailing).offset(8)
|
||
}
|
||
collectionView.snp.makeConstraints { make in
|
||
make.top.equalTo(segmentedControl.snp.bottom).offset(14)
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
override func bindActions() {
|
||
infoCard.onCall = { [weak self] phone in self?.call(phone) }
|
||
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
|
||
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
|
||
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
|
||
aiRetouchButton.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
|
||
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
|
||
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
|
||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||
viewModel.onStateChange = { [weak self] in
|
||
Task { @MainActor in self?.applyViewModel() }
|
||
}
|
||
viewModel.onShowMessage = { [weak self] message in
|
||
Task { @MainActor in self?.showToast(message) }
|
||
}
|
||
viewModel.onDeletedAlbum = { [weak self] _ in
|
||
Task { @MainActor in
|
||
guard let self else { return }
|
||
TravelAlbumOTGPhotoStore().clearAlbum(albumId: self.viewModel.albumId)
|
||
self.navigationController?.popViewController(animated: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
Task { await viewModel.refreshAll(api: api) }
|
||
}
|
||
|
||
@MainActor
|
||
private func applyViewModel() {
|
||
if let album = viewModel.album {
|
||
infoCard.apply(album: album, coverURL: viewModel.displayCoverURL)
|
||
}
|
||
configureTabButton(allTabButton, title: "全部 \(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
|
||
configureTabButton(
|
||
purchasedTabButton,
|
||
title: "已购 \(viewModel.purchasedPhotoCount)",
|
||
selected: viewModel.selectedTab == .purchased
|
||
)
|
||
|
||
var sortConfiguration = sortButton.configuration
|
||
sortConfiguration?.title = viewModel.sortOption.compactTitle
|
||
sortButton.configuration = sortConfiguration
|
||
sortButton.accessibilityValue = viewModel.sortOption.title
|
||
sortButton.menu = makeSortMenu()
|
||
|
||
let canSelectMaterials = viewModel.selectedTab == .all
|
||
selectButton.isHidden = false
|
||
selectButton.isEnabled = canSelectMaterials
|
||
selectButton.alpha = canSelectMaterials ? 1 : 0.45
|
||
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
||
selectButton.accessibilityValue = canSelectMaterials
|
||
? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭")
|
||
: "已购照片不可删除"
|
||
|
||
let selectedCount = viewModel.selectedMaterialIds.count
|
||
let hasSelection = selectedCount > 0
|
||
aiRetouchButton.isEnabled = hasSelection
|
||
aiRetouchButton.alpha = hasSelection ? 1 : 0.45
|
||
aiRetouchButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
|
||
deleteSelectedButton.isEnabled = selectedCount > 0
|
||
deleteSelectedButton.alpha = hasSelection ? 1 : 0.45
|
||
deleteSelectedButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
|
||
selectionActionStack.isHidden = !viewModel.isSelectionMode
|
||
uploadButton.isHidden = viewModel.isSelectionMode
|
||
|
||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
|
||
snapshot.appendSections([0])
|
||
snapshot.appendItems(viewModel.materials)
|
||
if !snapshot.itemIdentifiers.isEmpty {
|
||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||
}
|
||
dataSource.apply(snapshot, animatingDifferences: true)
|
||
|
||
if !viewModel.isRefreshing {
|
||
refreshControl.endRefreshing()
|
||
}
|
||
if viewModel.isLoading && viewModel.album == nil && !refreshControl.isRefreshing {
|
||
showLoading()
|
||
} else {
|
||
hideLoading()
|
||
}
|
||
}
|
||
|
||
@objc private func refreshPulled() {
|
||
Task { await viewModel.refreshAll(api: api) }
|
||
}
|
||
|
||
private func configureTabButtonBase(_ button: UIButton) {
|
||
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
||
button.accessibilityTraits.insert(.button)
|
||
}
|
||
|
||
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
|
||
button.setTitle(title, for: .normal)
|
||
button.backgroundColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||
button.setTitleColor(selected ? .white : TravelAlbumDetailStyle.textSecondary, for: .normal)
|
||
button.accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||
}
|
||
|
||
private func configureSortButton() {
|
||
var configuration = UIButton.Configuration.plain()
|
||
configuration.image = UIImage(named: "travel_album_sort_icon")
|
||
configuration.imagePadding = 4
|
||
configuration.title = viewModel.sortOption.compactTitle
|
||
configuration.baseForegroundColor = TravelAlbumDetailStyle.textPrimary
|
||
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 7, bottom: 0, trailing: 7)
|
||
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||
var attributes = attributes
|
||
attributes.font = .systemFont(ofSize: 11, weight: .medium)
|
||
return attributes
|
||
}
|
||
sortButton.configuration = configuration
|
||
sortButton.backgroundColor = .white
|
||
sortButton.layer.cornerRadius = 9
|
||
sortButton.layer.borderWidth = 1
|
||
sortButton.layer.borderColor = TravelAlbumDetailStyle.border.cgColor
|
||
sortButton.showsMenuAsPrimaryAction = true
|
||
sortButton.accessibilityLabel = "排序"
|
||
}
|
||
|
||
private func configureSelectButton() {
|
||
selectButton.setTitle("选择", for: .normal)
|
||
selectButton.setTitleColor(TravelAlbumDetailStyle.primary, for: .normal)
|
||
selectButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||
selectButton.backgroundColor = .white
|
||
selectButton.layer.cornerRadius = 9
|
||
selectButton.layer.borderWidth = 1
|
||
selectButton.layer.borderColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.45).cgColor
|
||
selectButton.accessibilityLabel = "选择照片"
|
||
}
|
||
|
||
private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
|
||
var configuration = UIButton.Configuration.filled()
|
||
configuration.title = title
|
||
configuration.baseBackgroundColor = color
|
||
configuration.baseForegroundColor = .white
|
||
configuration.cornerStyle = .fixed
|
||
configuration.background.cornerRadius = 14
|
||
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||
var attributes = attributes
|
||
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
|
||
return attributes
|
||
}
|
||
button.configuration = configuration
|
||
}
|
||
|
||
private func makeSortMenu() -> UIMenu {
|
||
let actions = TravelAlbumDetailViewModel.SortOption.allCases.map { option in
|
||
UIAction(
|
||
title: option.title,
|
||
state: option == viewModel.sortOption ? .on : .off
|
||
) { [weak self] _ in
|
||
guard let self else { return }
|
||
Task { await self.viewModel.setSortOption(option, api: self.api) }
|
||
}
|
||
}
|
||
return UIMenu(title: "排序方式", options: .singleSelection, children: actions)
|
||
}
|
||
|
||
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||
UICollectionViewCompositionalLayout { _, environment in
|
||
let spacing: CGFloat = 10
|
||
let width = floor((environment.container.effectiveContentSize.width - spacing * 2) / 3)
|
||
let itemSize = NSCollectionLayoutSize(
|
||
widthDimension: .absolute(width),
|
||
heightDimension: .absolute(width + 40)
|
||
)
|
||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||
let groupSize = NSCollectionLayoutSize(
|
||
widthDimension: .fractionalWidth(1),
|
||
heightDimension: .absolute(width + 40)
|
||
)
|
||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
|
||
group.interItemSpacing = .fixed(spacing)
|
||
let section = NSCollectionLayoutSection(group: group)
|
||
section.interGroupSpacing = 14
|
||
return section
|
||
}
|
||
}
|
||
|
||
private func call(_ phone: String) {
|
||
guard !phone.isEmpty, let url = URL(string: "tel:\(phone)") else { return }
|
||
UIApplication.shared.open(url)
|
||
}
|
||
|
||
private func confirmDeleteAlbum() {
|
||
let alert = UIAlertController(title: "删除相册", message: "确定删除该相册吗?已有购买素材的相册无法删除。", preferredStyle: .alert)
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||
guard let self else { return }
|
||
Task { await self.viewModel.deleteAlbum(api: self.api) }
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
@objc private func allTabTapped() {
|
||
Task { await viewModel.selectTab(.all, api: api) }
|
||
}
|
||
|
||
@objc private func purchasedTabTapped() {
|
||
Task { await viewModel.selectTab(.purchased, api: api) }
|
||
}
|
||
|
||
@objc private func selectTapped() {
|
||
viewModel.toggleSelectionMode()
|
||
}
|
||
|
||
@objc private func aiRetouchTapped() {
|
||
let materialIds = Array(viewModel.selectedMaterialIds)
|
||
guard !materialIds.isEmpty else { return }
|
||
let scenicId = scenicIdProvider()
|
||
guard scenicId > 0 else {
|
||
showToast("请先选择景区")
|
||
return
|
||
}
|
||
|
||
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||
viewModel: TravelAlbumAIRetouchTemplateViewModel(
|
||
albumId: viewModel.albumId,
|
||
scenicId: scenicId,
|
||
materialIds: materialIds
|
||
),
|
||
api: api,
|
||
onSubmitted: { [weak self] submission in
|
||
guard let self else { return }
|
||
self.viewModel.completeAIRetouchSubmission()
|
||
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 }
|
||
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||
guard let self else { return }
|
||
Task { await self.viewModel.deleteSelectedMaterials(api: self.api) }
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
@objc private func uploadTapped() {
|
||
let album = viewModel.album
|
||
let controller = WiredCameraTransferViewController(
|
||
viewModel: WiredCameraTransferViewModel(
|
||
albumId: album?.id ?? viewModel.albumId,
|
||
albumTitle: album?.name ?? "",
|
||
headerPhone: album?.displayPhone ?? ""
|
||
)
|
||
)
|
||
navigationController?.pushViewController(controller, animated: true)
|
||
}
|
||
|
||
private func presentPreview(startingWith material: TravelAlbumMaterial) {
|
||
guard let startIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) else { return }
|
||
let previewViewModel = viewModel
|
||
let previewAPI = api
|
||
let controller = TravelAlbumPhotoPreviewViewController(
|
||
projects: viewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
|
||
totalCount: viewModel.currentPhotoCount,
|
||
startProjectIndex: startIndex,
|
||
configuration: previewConfiguration,
|
||
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
|
||
albumId: viewModel.albumId,
|
||
scenicIdProvider: scenicIdProvider,
|
||
aiRetouchAPI: previewAPI,
|
||
loadMore: {
|
||
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
|
||
return (
|
||
previewViewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
|
||
previewViewModel.currentPhotoCount
|
||
)
|
||
},
|
||
reload: { materialId in
|
||
let material = try await previewViewModel.refreshMaterial(id: materialId, api: previewAPI)
|
||
return TravelAlbumPreviewProject(material: material)
|
||
},
|
||
onProjectDeleted: { materialId in
|
||
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
||
}
|
||
)
|
||
present(controller, animated: true)
|
||
}
|
||
}
|
||
|
||
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
|
||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
|
||
if viewModel.isSelectionMode {
|
||
viewModel.toggleMaterialSelection(material)
|
||
} else {
|
||
presentPreview(startingWith: material)
|
||
}
|
||
}
|
||
|
||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||
let visible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? 0
|
||
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: visible, api: api) }
|
||
}
|
||
}
|
||
|
||
/// 相册管理页专用视觉常量,避免污染全局主题。
|
||
private enum TravelAlbumDetailStyle {
|
||
static let primary = UIColor(hex: 0x1677FF)
|
||
static let pageBackground = UIColor(hex: 0xF8FAFD)
|
||
static let textPrimary = UIColor(hex: 0x172033)
|
||
static let textSecondary = UIColor(hex: 0x7F8A9E)
|
||
static let border = UIColor(hex: 0xDCE4EF)
|
||
static let danger = UIColor(hex: 0xE53935)
|
||
|
||
static func badgeColor(for kind: TravelAlbumMaterialBadgeKind) -> UIColor {
|
||
switch kind {
|
||
case .purchased: UIColor(hex: 0x475569)
|
||
case .pending: UIColor(hex: 0xB45309)
|
||
case .processing: UIColor(hex: 0x1D4ED8)
|
||
case .retouched: UIColor(hex: 0x047857)
|
||
case .cover: UIColor(hex: 0x6D28D9)
|
||
case .failed: UIColor(hex: 0xB91C1C)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 旅拍相册摘要卡,展示封面、名称、用户手机号与创建时间。
|
||
private final class TravelAlbumInfoCard: UIView {
|
||
var onCall: ((String) -> Void)?
|
||
private var phone = ""
|
||
|
||
private let backgroundImageView = UIImageView(image: UIImage(named: "travel_album_header_background"))
|
||
private let coverImageView = UIImageView()
|
||
private let coverPhotoIconView = UIImageView(image: UIImage(named: "travel_album_cover_photo_icon"))
|
||
private let nameLabel = UILabel()
|
||
private let phoneLabel = UILabel()
|
||
private let timeLabel = UILabel()
|
||
private let callButton = UIButton(type: .system)
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
clipsToBounds = true
|
||
layer.cornerRadius = 16
|
||
|
||
backgroundImageView.contentMode = .scaleAspectFill
|
||
coverImageView.contentMode = .scaleAspectFill
|
||
coverImageView.clipsToBounds = true
|
||
coverImageView.layer.cornerRadius = 12
|
||
coverImageView.backgroundColor = UIColor(hex: 0xDCEEFF)
|
||
coverPhotoIconView.contentMode = .scaleAspectFit
|
||
coverPhotoIconView.layer.shadowColor = UIColor.black.withAlphaComponent(0.3).cgColor
|
||
coverPhotoIconView.layer.shadowOpacity = 1
|
||
coverPhotoIconView.layer.shadowRadius = 2
|
||
coverPhotoIconView.layer.shadowOffset = .zero
|
||
|
||
nameLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||
nameLabel.lineBreakMode = .byTruncatingTail
|
||
phoneLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||
phoneLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||
timeLabel.font = .systemFont(ofSize: 11)
|
||
timeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||
timeLabel.adjustsFontSizeToFitWidth = true
|
||
timeLabel.minimumScaleFactor = 0.85
|
||
|
||
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||
callButton.tintColor = TravelAlbumDetailStyle.primary
|
||
callButton.backgroundColor = .white.withAlphaComponent(0.9)
|
||
callButton.layer.cornerRadius = 22
|
||
callButton.layer.shadowColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.18).cgColor
|
||
callButton.layer.shadowOpacity = 1
|
||
callButton.layer.shadowRadius = 6
|
||
callButton.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||
callButton.accessibilityLabel = "拨打相册用户电话"
|
||
|
||
addSubview(backgroundImageView)
|
||
addSubview(coverImageView)
|
||
coverImageView.addSubview(coverPhotoIconView)
|
||
addSubview(nameLabel)
|
||
addSubview(phoneLabel)
|
||
addSubview(timeLabel)
|
||
addSubview(callButton)
|
||
|
||
backgroundImageView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||
coverImageView.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(16)
|
||
make.top.bottom.equalToSuperview().inset(20)
|
||
make.width.equalTo(68)
|
||
}
|
||
coverPhotoIconView.snp.makeConstraints { make in
|
||
make.trailing.bottom.equalToSuperview().inset(5)
|
||
make.size.equalTo(22)
|
||
}
|
||
callButton.snp.makeConstraints { make in
|
||
make.trailing.equalToSuperview().offset(-16)
|
||
make.centerY.equalToSuperview()
|
||
make.size.equalTo(44)
|
||
}
|
||
nameLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(21)
|
||
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
|
||
make.trailing.equalTo(callButton.snp.leading).offset(-10)
|
||
}
|
||
phoneLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(nameLabel.snp.bottom).offset(8)
|
||
make.leading.trailing.equalTo(nameLabel)
|
||
}
|
||
timeLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(phoneLabel.snp.bottom).offset(6)
|
||
make.leading.trailing.equalTo(nameLabel)
|
||
}
|
||
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func apply(album: TravelAlbum, coverURL: String) {
|
||
phone = album.displayPhone
|
||
nameLabel.text = album.name.isEmpty ? "旅拍相册" : album.name
|
||
phoneLabel.text = TravelAlbumDisplayFormatter.maskPhone(album.displayPhone)
|
||
timeLabel.text = "创建于 \(TravelAlbumDisplayFormatter.creationTimeText(album.createdAt))"
|
||
if let url = URL(string: coverURL), !coverURL.isEmpty {
|
||
coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo.fill"))
|
||
coverImageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.45)
|
||
} else {
|
||
coverImageView.image = UIImage(systemName: "photo.fill")
|
||
coverImageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35)
|
||
coverImageView.contentMode = .center
|
||
}
|
||
callButton.isEnabled = !phone.isEmpty
|
||
callButton.alpha = phone.isEmpty ? 0.45 : 1
|
||
}
|
||
|
||
@objc private func callTapped() {
|
||
onCall?(phone)
|
||
}
|
||
}
|
||
|
||
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
|
||
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||
|
||
private let imageView = UIImageView()
|
||
private let checkImageView = UIImageView()
|
||
private let badgeView = UIView()
|
||
private let badgeLabel = UILabel()
|
||
private let nameLabel = UILabel()
|
||
private let sizeLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
imageView.contentMode = .scaleAspectFill
|
||
imageView.clipsToBounds = true
|
||
imageView.layer.cornerRadius = 12
|
||
imageView.backgroundColor = UIColor(hex: 0xEAF4FF)
|
||
checkImageView.tintColor = .white
|
||
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||
checkImageView.layer.cornerRadius = 11
|
||
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
||
badgeView.layer.cornerRadius = 5
|
||
badgeView.clipsToBounds = true
|
||
badgeView.isHidden = true
|
||
badgeView.isAccessibilityElement = false
|
||
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
|
||
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
|
||
badgeLabel.textColor = .white
|
||
badgeLabel.textAlignment = .center
|
||
badgeLabel.isAccessibilityElement = false
|
||
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
|
||
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||
sizeLabel.font = .systemFont(ofSize: 10)
|
||
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||
|
||
contentView.addSubview(imageView)
|
||
imageView.addSubview(badgeView)
|
||
badgeView.addSubview(badgeLabel)
|
||
imageView.addSubview(checkImageView)
|
||
contentView.addSubview(nameLabel)
|
||
contentView.addSubview(sizeLabel)
|
||
imageView.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(imageView.snp.width)
|
||
}
|
||
checkImageView.snp.makeConstraints { make in
|
||
make.top.trailing.equalToSuperview().inset(6)
|
||
make.size.equalTo(22)
|
||
}
|
||
badgeView.snp.makeConstraints { make in
|
||
make.top.leading.equalToSuperview().inset(6)
|
||
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
||
}
|
||
badgeLabel.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview().inset(
|
||
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
||
)
|
||
}
|
||
nameLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(imageView.snp.bottom).offset(6)
|
||
make.leading.trailing.equalToSuperview()
|
||
}
|
||
sizeLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(nameLabel.snp.bottom).offset(2)
|
||
make.leading.trailing.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
|
||
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
|
||
imageView.contentMode = .scaleAspectFill
|
||
if let url = URL(string: urlString), !urlString.isEmpty {
|
||
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo.fill"))
|
||
} else {
|
||
imageView.image = UIImage(systemName: "photo.fill")
|
||
imageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35)
|
||
imageView.contentMode = .center
|
||
}
|
||
checkImageView.isHidden = !selectionMode
|
||
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||
let badge = material.badgePresentation
|
||
badgeView.isHidden = badge == nil
|
||
badgeLabel.text = badge?.text
|
||
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
||
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
|
||
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
|
||
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
||
}
|
||
}
|