Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
lujiuyin d04641b623 feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
2026-08-27 16:03:40 +08:00

852 lines
37 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
private var refreshState = TravelAlbumReturnRefreshState()
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) }
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard refreshState.beginRefreshIfNeeded() else { return }
Task {
await viewModel.refreshAll(api: api)
await MainActor.run {
self.applyViewModel()
self.refreshState.finishRefresh()
}
}
}
@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()
selectButton.isHidden = false
selectButton.isEnabled = true
selectButton.alpha = 1
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
selectButton.accessibilityValue = 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: !refreshState.suppressesSnapshotAnimations
)
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] _ in self?.handleAIRetouchSubmitted() }
)
present(controller, animated: true)
}
private func handleAIRetouchSubmitted() {
showToast("AI修图任务已提交,完成后将通过消息通知")
Task { await viewModel.refreshAfterAIRetouchSubmission(api: api) }
}
@objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count
guard count > 0 else { return }
let alert = UIAlertController(
title: "删除素材",
message: viewModel.deleteSelectedConfirmationMessage,
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() {
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 ?? "",
initialTransferMode: mode,
initialAutoRetouchConfiguration: album?.autoRetouchConfiguration ?? .disabled,
api: api
)
)
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)
},
onAIRetouchSubmitted: { [weak self] in self?.handleAIRetouchSubmitted() }
)
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 purchaseBadgeView = UIView()
private let purchaseBadgeLabel = UILabel()
private let aiRetouchBadgeView = UIView()
private let aiRetouchBadgeLabel = 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"
configureBadge(
purchaseBadgeView,
label: purchaseBadgeLabel,
viewIdentifier: "travelAlbum.materialPurchaseBadge",
labelIdentifier: "travelAlbum.materialPurchaseBadgeLabel"
)
configureBadge(
aiRetouchBadgeView,
label: aiRetouchBadgeLabel,
viewIdentifier: "travelAlbum.materialAIRetouchBadge",
labelIdentifier: "travelAlbum.materialAIRetouchBadgeLabel"
)
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(purchaseBadgeView)
imageView.addSubview(aiRetouchBadgeView)
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)
}
aiRetouchBadgeView.snp.makeConstraints { make in
make.top.leading.equalToSuperview().inset(6)
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
}
purchaseBadgeView.snp.makeConstraints { make in
make.leading.bottom.equalToSuperview().inset(6)
make.trailing.lessThanOrEqualToSuperview().inset(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 purchaseBadge = material.purchaseBadgePresentation
let aiRetouchBadge = material.aiRetouchBadgePresentation
applyBadge(purchaseBadge, to: purchaseBadgeView, label: purchaseBadgeLabel)
applyBadge(aiRetouchBadge, to: aiRetouchBadgeView, label: aiRetouchBadgeLabel)
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
let purchaseAccessibilityText = purchaseBadge.map { ",购买状态:\($0.text)" } ?? ""
let aiRetouchAccessibilityText = aiRetouchBadge.map { ",修图状态:\($0.text)" } ?? ""
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
+ purchaseAccessibilityText
+ aiRetouchAccessibilityText
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
}
private func configureBadge(
_ badgeView: UIView,
label: UILabel,
viewIdentifier: String,
labelIdentifier: String
) {
badgeView.layer.cornerRadius = 5
badgeView.clipsToBounds = true
badgeView.isHidden = true
badgeView.isAccessibilityElement = false
badgeView.accessibilityIdentifier = viewIdentifier
label.font = .systemFont(ofSize: 10, weight: .semibold)
label.textColor = .white
label.textAlignment = .center
label.lineBreakMode = .byTruncatingTail
label.isAccessibilityElement = false
label.accessibilityIdentifier = labelIdentifier
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
badgeView.addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
)
}
}
private func applyBadge(
_ presentation: TravelAlbumMaterialBadgePresentation?,
to badgeView: UIView,
label: UILabel
) {
badgeView.isHidden = presentation == nil
label.text = presentation?.text
badgeView.backgroundColor = presentation.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
}
}