feat: redesign travel album management

This commit is contained in:
2026-08-03 16:20:31 +08:00
parent 1cdce616e4
commit dcdab3cff7
18 changed files with 451 additions and 197 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "travel_album_cover_photo_icon.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "travel_album_cover_photo_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "travel_album_cover_photo_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "travel_album_header_background@1x.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "travel_album_header_background@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "travel_album_header_background@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "travel_album_sort_icon.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "travel_album_sort_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "travel_album_sort_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

@@ -277,4 +277,22 @@ enum TravelAlbumDisplayFormatter {
}
return text
}
/// 将服务端创建时间格式化为摘要卡使用的 `yyyy/MM/dd HH:mm`。
static func creationTimeText(_ text: String) -> String {
guard !text.isEmpty else { return "--" }
let normalized = text.replacingOccurrences(of: "T", with: " ")
guard normalized.count >= 16 else { return normalized }
return String(normalized.prefix(16)).replacingOccurrences(of: "-", with: "/")
}
/// 计算相册摘要卡封面,按相册、素材缩略图、素材原图顺序回退。
static func albumCoverURL(album: TravelAlbum?, materials: [TravelAlbumMaterial]) -> String {
if let coverURL = album?.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines), !coverURL.isEmpty {
return coverURL
}
guard let first = materials.first else { return "" }
let materialCover = first.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines)
return materialCover.isEmpty ? first.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) : materialCover
}
}
@@ -27,11 +27,21 @@ final class TravelAlbumDetailViewModel {
case .fileNameDesc: "文件名倒序"
}
}
var compactTitle: String {
switch self {
case .createdAsc: "时间 ↑"
case .createdDesc: "时间 ↓"
case .fileNameAsc: "名称 ↑"
case .fileNameDesc: "名称 ↓"
}
}
}
private(set) var album: TravelAlbum?
private(set) var materials: [TravelAlbumMaterial] = []
private(set) var allPhotoCount = 0
private(set) var purchasedPhotoCount = 0
private(set) var selectedTab: Tab = .all
private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true
@@ -65,6 +75,7 @@ final class TravelAlbumDetailViewModel {
notifyStateChange()
await loadAlbumInfo(api: api)
await loadAllPhotoCount(api: api)
await loadPurchasedPhotoCount(api: api)
await loadMaterials(reset: true, api: api)
isRefreshing = false
isLoading = false
@@ -102,6 +113,30 @@ final class TravelAlbumDetailViewModel {
}
}
/// 拉取已购照片数量;失败时保留已有数量,不影响素材列表加载。
func loadPurchasedPhotoCount(api: any TravelAlbumServing) async {
do {
let response = try await api.materialList(
userEquityTravelId: albumId,
page: 1,
pageSize: 1,
orderBy: sortOption.rawValue,
isPurchased: 1
)
purchasedPhotoCount = response.total
notifyStateChange()
} catch is CancellationError {
return
} catch {
return
}
}
/// 摘要卡封面地址,依次回退相册封面、首张素材封面和首张素材原图。
var displayCoverURL: String {
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: materials)
}
/// 切换素材 tab。
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return }
@@ -144,6 +179,8 @@ final class TravelAlbumDetailViewModel {
canLoadMore = materials.count < response.total
if selectedTab == .all {
allPhotoCount = response.total
} else {
purchasedPhotoCount = response.total
}
isLoadingMore = false
notifyStateChange()
@@ -7,15 +7,15 @@ import Kingfisher
import SnapKit
import UIKit
/// 相册详情管理页,对齐 Android `TravelAlbumDetailScreen`。
/// 相册管理页,展示相册摘要、素材筛选排序、分页与批量操作。
final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing
private let scrollContainer = UIView()
private let contentView = UIView()
private let infoCard = TravelAlbumInfoCard()
private let manageCard = UIView()
private let tabStack = UIStackView()
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)
@@ -23,10 +23,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
private let bottomBar = UIView()
private let bottomActionStack = UIStackView()
private let bottomDivider = UIView()
private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system)
private var isDeleteSelectedButtonVisible = false
init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
@@ -40,7 +39,20 @@ final class TravelAlbumDetailViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "相册管理"
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
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [
@@ -49,29 +61,35 @@ final class TravelAlbumDetailViewController: BaseViewController {
},
])
)
navigationItem.rightBarButtonItem?.accessibilityLabel = "更多操作"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
view.backgroundColor = TravelAlbumDetailStyle.pageBackground
manageCard.backgroundColor = .white
manageCard.layer.cornerRadius = 12
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
manageCard.layer.shadowOpacity = 1
manageCard.layer.shadowRadius = 6
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
sectionTitleLabel.text = "照片"
sectionTitleLabel.textColor = TravelAlbumDetailStyle.textPrimary
sectionTitleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
tabStack.axis = .horizontal
tabStack.spacing = 8
configurePillButton(allTabButton)
configurePillButton(purchasedTabButton)
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill"))
configureIconButton(selectButton, image: UIImage(systemName: "circle"))
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 = .white
collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.showsVerticalScrollIndicator = false
collectionView.delegate = self
collectionView.register(TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier)
collectionView.register(
TravelAlbumMaterialCell.self,
forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier
)
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
[weak self] collectionView, indexPath, material in
let cell = collectionView.dequeueReusableCell(
@@ -86,74 +104,93 @@ final class TravelAlbumDetailViewController: BaseViewController {
return cell
}
bottomBar.backgroundColor = AppColor.pageBackground
bottomActionStack.axis = .vertical
bottomActionStack.spacing = 8
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
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 = "上传照片"
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: TravelAlbumDetailStyle.danger)
deleteSelectedButton.isHidden = true
deleteSelectedButton.alpha = 0
view.addSubview(scrollContainer)
scrollContainer.addSubview(infoCard)
scrollContainer.addSubview(manageCard)
manageCard.addSubview(tabStack)
tabStack.addArrangedSubview(allTabButton)
tabStack.addArrangedSubview(purchasedTabButton)
manageCard.addSubview(sortButton)
manageCard.addSubview(selectButton)
manageCard.addSubview(collectionView)
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(bottomActionStack)
bottomActionStack.addArrangedSubview(deleteSelectedButton)
bottomActionStack.addArrangedSubview(uploadButton)
bottomBar.addSubview(bottomDivider)
bottomBar.addSubview(uploadButton)
bottomBar.addSubview(deleteSelectedButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
bottomActionStack.snp.makeConstraints { make in
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(20)
make.leading.trailing.equalToSuperview().inset(18)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
deleteSelectedButton.snp.makeConstraints { make in
make.height.equalTo(48).priority(.high)
make.edges.equalTo(uploadButton)
}
uploadButton.snp.makeConstraints { make in
make.height.equalTo(48).priority(.high)
}
scrollContainer.snp.makeConstraints { make in
contentView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
make.leading.trailing.equalToSuperview().inset(18)
make.bottom.equalTo(bottomBar.snp.top)
}
infoCard.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.greaterThanOrEqualTo(76)
make.height.equalTo(116)
}
manageCard.snp.makeConstraints { make in
make.top.equalTo(infoCard.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview()
sectionTitleLabel.snp.makeConstraints { make in
make.top.equalTo(infoCard.snp.bottom).offset(20)
make.leading.equalToSuperview()
}
tabStack.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12)
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(tabStack)
make.centerY.equalTo(segmentedControl)
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
make.size.equalTo(32)
}
selectButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack)
make.trailing.equalToSuperview().offset(-12)
make.size.equalTo(32)
make.width.equalTo(74)
make.height.equalTo(32)
make.leading.greaterThanOrEqualTo(segmentedControl.snp.trailing).offset(8)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
make.top.equalTo(segmentedControl.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
}
}
@@ -161,7 +198,6 @@ final class TravelAlbumDetailViewController: BaseViewController {
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)
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
@@ -188,15 +224,36 @@ final class TravelAlbumDetailViewController: BaseViewController {
@MainActor
private func applyViewModel() {
if let album = viewModel.album {
infoCard.apply(album: album)
infoCard.apply(album: album, coverURL: viewModel.displayCoverURL)
}
configureTabButton(allTabButton, title: "全部照片\(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased)
selectButton.isHidden = viewModel.selectedTab != .all
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal)
setDeleteSelectedButtonVisible(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
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
deleteSelectedButton.setTitle("删除选中(\(selectedCount))", for: .normal)
deleteSelectedButton.isEnabled = selectedCount > 0
deleteSelectedButton.alpha = selectedCount > 0 ? 1 : 0.45
deleteSelectedButton.isHidden = !viewModel.isSelectionMode
uploadButton.isHidden = viewModel.isSelectionMode
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
snapshot.appendSections([0])
@@ -213,69 +270,95 @@ final class TravelAlbumDetailViewController: BaseViewController {
}
}
private func configurePillButton(_ button: UIButton) {
private func configureTabButtonBase(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 14)
)
button.accessibilityTraits.insert(.button)
}
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
button.setTitle(title, for: .normal)
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF)
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal)
button.backgroundColor = selected ? TravelAlbumDetailStyle.primary : .white
button.setTitleColor(selected ? .white : TravelAlbumDetailStyle.textSecondary, for: .normal)
button.accessibilityTraits = selected ? [.button, .selected] : [.button]
}
private func configureIconButton(_ button: UIButton, image: UIImage?) {
button.setImage(image, for: .normal)
button.backgroundColor = UIColor(hex: 0xEAF4FF)
button.tintColor = AppColor.primary
button.layer.cornerRadius = 16
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) {
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = color
button.layer.cornerRadius = 10
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 setDeleteSelectedButtonVisible(_ visible: Bool) {
guard visible != isDeleteSelectedButtonVisible else { return }
isDeleteSelectedButtonVisible = visible
let changes = {
self.deleteSelectedButton.isHidden = !visible
self.deleteSelectedButton.alpha = visible ? 1 : 0
self.view.layoutIfNeeded()
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) }
}
}
guard view.window != nil else {
changes()
return
}
view.layoutIfNeeded()
UIView.animate(
withDuration: 0.25,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: changes
)
return UIMenu(title: "排序方式", options: .singleSelection, children: actions)
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in
let spacing: CGFloat = 8
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 38))
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 + 38))
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 = 12
section.interGroupSpacing = 14
return section
}
}
@@ -303,24 +386,13 @@ final class TravelAlbumDetailViewController: BaseViewController {
Task { await viewModel.selectTab(.purchased, api: api) }
}
@objc private func sortTapped() {
let alert = UIAlertController(title: "排序", message: nil, preferredStyle: .actionSheet)
TravelAlbumDetailViewModel.SortOption.allCases.forEach { option in
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.setSortOption(option, api: self.api) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func selectTapped() {
viewModel.toggleSelectionMode()
}
@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
@@ -355,66 +427,100 @@ extension TravelAlbumDetailViewController: UICollectionViewDelegate {
}
}
/// 旅拍相册详情信息卡。
/// 相册管理页专用视觉常量,避免污染全局主题。
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)
}
/// 旅拍相册摘要卡,展示封面、名称、用户手机号与创建时间。
private final class TravelAlbumInfoCard: UIView {
var onCall: ((String) -> Void)?
private var phone = ""
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
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)
backgroundColor = .white
layer.cornerRadius = 12
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
layer.shadowOpacity = 1
layer.shadowRadius = 6
layer.shadowOffset = CGSize(width: 0, height: 2)
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
let iconBox = UIView()
iconBox.backgroundColor = AppColor.primary
iconBox.layer.cornerRadius = 10
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
phoneLabel.font = .systemFont(ofSize: 14, weight: .medium)
phoneLabel.textColor = AppColor.textPrimary
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = AppColor.textTertiary
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary
callButton.backgroundColor = UIColor(hex: 0xEAF4FF)
callButton.layer.cornerRadius = 20
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(iconBox)
iconBox.addSubview(iconView)
addSubview(backgroundImageView)
addSubview(coverImageView)
coverImageView.addSubview(coverPhotoIconView)
addSubview(nameLabel)
addSubview(phoneLabel)
addSubview(timeLabel)
addSubview(callButton)
iconBox.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(14)
make.size.equalTo(48)
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)
}
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
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.equalToSuperview().offset(16)
make.leading.equalTo(iconBox.snp.trailing).offset(12)
make.trailing.equalTo(callButton.snp.leading).offset(-12)
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(phoneLabel)
make.bottom.equalToSuperview().offset(-16)
}
callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(40)
make.leading.trailing.equalTo(nameLabel)
}
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
}
@@ -424,10 +530,21 @@ private final class TravelAlbumInfoCard: UIView {
fatalError("init(coder:) has not been implemented")
}
func apply(album: TravelAlbum) {
func apply(album: TravelAlbum, coverURL: String) {
phone = album.displayPhone
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))"
timeLabel.text = "创建时间 \(album.createdAt)"
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() {
@@ -435,12 +552,11 @@ private final class TravelAlbumInfoCard: UIView {
}
}
/// 旅拍相册素材网格 cell。
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
private final class TravelAlbumMaterialCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumMaterialCell"
private let imageView = UIImageView()
private let statusLabel = UILabel()
private let checkImageView = UIImageView()
private let nameLabel = UILabel()
private let sizeLabel = UILabel()
@@ -449,25 +565,18 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
super.init(frame: frame)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 8
statusLabel.text = "已上传"
statusLabel.font = .systemFont(ofSize: 10)
statusLabel.textColor = .white
statusLabel.backgroundColor = UIColor(hex: 0x34C759)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
imageView.layer.cornerRadius = 12
imageView.backgroundColor = UIColor(hex: 0xEAF4FF)
checkImageView.tintColor = .white
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
checkImageView.layer.cornerRadius = 10
nameLabel.font = .systemFont(ofSize: 11)
nameLabel.textColor = AppColor.textPrimary
checkImageView.layer.cornerRadius = 11
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
nameLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
contentView.addSubview(imageView)
imageView.addSubview(statusLabel)
imageView.addSubview(checkImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(sizeLabel)
@@ -475,17 +584,12 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(imageView.snp.width)
}
statusLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(4)
make.height.equalTo(18)
make.width.equalTo(48)
}
checkImageView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(20)
make.top.trailing.equalToSuperview().inset(6)
make.size.equalTo(22)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(4)
make.top.equalTo(imageView.snp.bottom).offset(6)
make.leading.trailing.equalToSuperview()
}
sizeLabel.snp.makeConstraints { make in
@@ -501,17 +605,20 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
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"))
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo.fill"))
} else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.primary
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
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 ? AppColor.primary : .white
nameLabel.text = material.fileName
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
}
}
@@ -80,6 +80,7 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
api.infoResponse = TravelAlbum(id: 2, name: "详情")
api.materialListResponses = [
TravelAlbumListResponse(total: 2, list: []),
TravelAlbumListResponse(total: 1, list: []),
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
@@ -88,7 +89,28 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.album?.name, "详情")
XCTAssertEqual(viewModel.allPhotoCount, 2)
XCTAssertEqual(viewModel.purchasedPhotoCount, 1)
XCTAssertEqual(viewModel.materials.count, 2)
XCTAssertEqual(api.materialRequests.map(\.pageSize), [1, 1, 30])
XCTAssertEqual(api.materialRequests.map(\.isPurchased), [nil, 1, nil])
}
func testCountFailuresDoNotBlockMaterialList() async {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "详情")
api.materialListFailingCallIndexes = [0, 1]
api.materialListResponses = [
TravelAlbumListResponse(total: 3, list: [TravelAlbumMaterial(id: 7)]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.refreshAll(api: api)
XCTAssertEqual(api.materialRequests.count, 3)
XCTAssertEqual(viewModel.materials.map(\.id), [7])
XCTAssertEqual(viewModel.allPhotoCount, 3)
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
XCTAssertFalse(viewModel.isLoading)
}
func testTabAndSortTriggerMaterialRequests() async {
@@ -106,6 +128,41 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
}
func testSortOptionsProvideCompactCurrentStateTitles() {
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.createdAsc.compactTitle, "时间 ↑")
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.createdDesc.compactTitle, "时间 ↓")
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.fileNameAsc.compactTitle, "名称 ↑")
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.fileNameDesc.compactTitle, "名称 ↓")
}
func testAlbumCoverFallsBackToFirstMaterial() {
let album = TravelAlbum(id: 2, coverUrl: "")
let coverMaterial = TravelAlbumMaterial(id: 1, fileUrl: "original", coverUrl: "https://cdn.example.com/cover.jpg")
let originalMaterial = TravelAlbumMaterial(id: 2, fileUrl: "https://cdn.example.com/original.jpg", coverUrl: "")
XCTAssertEqual(
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: [coverMaterial]),
"https://cdn.example.com/cover.jpg"
)
XCTAssertEqual(
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: [originalMaterial]),
"https://cdn.example.com/original.jpg"
)
XCTAssertEqual(
TravelAlbumDisplayFormatter.albumCoverURL(
album: TravelAlbum(id: 2, coverUrl: "https://cdn.example.com/album.jpg"),
materials: [coverMaterial]
),
"https://cdn.example.com/album.jpg"
)
}
func testCreationTimeFormatting() {
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText("2026-08-03 14:05:22"), "2026/08/03 14:05")
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText("2026-08-03T14:05:22+08:00"), "2026/08/03 14:05")
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
}
func testOnlyUnpurchasedMaterialCanBeSelected() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode()
@@ -954,6 +1011,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var materialListFailingCallIndexes: Set<Int> = []
var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
@@ -993,6 +1051,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
orderBy: Int,
isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
let callIndex = materialRequests.count
materialRequests.append(
MaterialRequest(
userEquityTravelId: userEquityTravelId,
@@ -1002,6 +1061,9 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
isPurchased: isPurchased
)
)
if materialListFailingCallIndexes.contains(callIndex) {
throw APIError.serverCode(500, "素材数量加载失败")
}
if materialListResponses.isEmpty { return TravelAlbumListResponse() }
return materialListResponses.removeFirst()
}