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 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: "文件名倒序" case .fileNameDesc: "文件名倒序"
} }
} }
var compactTitle: String {
switch self {
case .createdAsc: "时间 ↑"
case .createdDesc: "时间 ↓"
case .fileNameAsc: "名称 ↑"
case .fileNameDesc: "名称 ↓"
}
}
} }
private(set) var album: TravelAlbum? private(set) var album: TravelAlbum?
private(set) var materials: [TravelAlbumMaterial] = [] private(set) var materials: [TravelAlbumMaterial] = []
private(set) var allPhotoCount = 0 private(set) var allPhotoCount = 0
private(set) var purchasedPhotoCount = 0
private(set) var selectedTab: Tab = .all private(set) var selectedTab: Tab = .all
private(set) var sortOption: SortOption = .createdDesc private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true private(set) var isLoading = true
@@ -65,6 +75,7 @@ final class TravelAlbumDetailViewModel {
notifyStateChange() notifyStateChange()
await loadAlbumInfo(api: api) await loadAlbumInfo(api: api)
await loadAllPhotoCount(api: api) await loadAllPhotoCount(api: api)
await loadPurchasedPhotoCount(api: api)
await loadMaterials(reset: true, api: api) await loadMaterials(reset: true, api: api)
isRefreshing = false isRefreshing = false
isLoading = 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。 /// 切换素材 tab。
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async { func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return } guard selectedTab != tab else { return }
@@ -144,6 +179,8 @@ final class TravelAlbumDetailViewModel {
canLoadMore = materials.count < response.total canLoadMore = materials.count < response.total
if selectedTab == .all { if selectedTab == .all {
allPhotoCount = response.total allPhotoCount = response.total
} else {
purchasedPhotoCount = response.total
} }
isLoadingMore = false isLoadingMore = false
notifyStateChange() notifyStateChange()
@@ -7,15 +7,15 @@ import Kingfisher
import SnapKit import SnapKit
import UIKit import UIKit
/// 相册详情管理页,对齐 Android `TravelAlbumDetailScreen`。 /// 相册管理页,展示相册摘要、素材筛选排序、分页与批量操作。
final class TravelAlbumDetailViewController: BaseViewController { final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing private let api: any TravelAlbumServing
private let scrollContainer = UIView() private let contentView = UIView()
private let infoCard = TravelAlbumInfoCard() private let infoCard = TravelAlbumInfoCard()
private let manageCard = UIView() private let sectionTitleLabel = UILabel()
private let tabStack = UIStackView() private let segmentedControl = UIView()
private let allTabButton = UIButton(type: .system) private let allTabButton = UIButton(type: .system)
private let purchasedTabButton = UIButton(type: .system) private let purchasedTabButton = UIButton(type: .system)
private let sortButton = UIButton(type: .system) private let sortButton = UIButton(type: .system)
@@ -23,10 +23,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>! private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
private let bottomBar = UIView() private let bottomBar = UIView()
private let bottomActionStack = UIStackView() private let bottomDivider = UIView()
private let deleteSelectedButton = UIButton(type: .system) private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system)
private var isDeleteSelectedButtonVisible = false
init(albumId: Int, api: (any TravelAlbumServing)? = nil) { init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId) viewModel = TravelAlbumDetailViewModel(albumId: albumId)
@@ -40,7 +39,20 @@ final class TravelAlbumDetailViewController: BaseViewController {
} }
override func setupNavigationBar() { 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( navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"), image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [ menu: UIMenu(children: [
@@ -49,29 +61,35 @@ final class TravelAlbumDetailViewController: BaseViewController {
}, },
]) ])
) )
navigationItem.rightBarButtonItem?.accessibilityLabel = "更多操作"
} }
override func setupUI() { override func setupUI() {
view.backgroundColor = AppColor.pageBackground view.backgroundColor = TravelAlbumDetailStyle.pageBackground
manageCard.backgroundColor = .white sectionTitleLabel.text = "照片"
manageCard.layer.cornerRadius = 12 sectionTitleLabel.textColor = TravelAlbumDetailStyle.textPrimary
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor sectionTitleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
manageCard.layer.shadowOpacity = 1
manageCard.layer.shadowRadius = 6
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
tabStack.axis = .horizontal segmentedControl.backgroundColor = .white
tabStack.spacing = 8 segmentedControl.layer.cornerRadius = 9
configurePillButton(allTabButton) segmentedControl.layer.borderWidth = 1
configurePillButton(purchasedTabButton) segmentedControl.layer.borderColor = TravelAlbumDetailStyle.border.cgColor
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill")) segmentedControl.clipsToBounds = true
configureIconButton(selectButton, image: UIImage(systemName: "circle")) configureTabButtonBase(allTabButton)
configureTabButtonBase(purchasedTabButton)
configureSortButton()
configureSelectButton()
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.showsVerticalScrollIndicator = false
collectionView.delegate = self collectionView.delegate = self
collectionView.register(TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier) collectionView.register(
TravelAlbumMaterialCell.self,
forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier
)
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) { dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
[weak self] collectionView, indexPath, material in [weak self] collectionView, indexPath, material in
let cell = collectionView.dequeueReusableCell( let cell = collectionView.dequeueReusableCell(
@@ -86,74 +104,93 @@ final class TravelAlbumDetailViewController: BaseViewController {
return cell return cell
} }
bottomBar.backgroundColor = AppColor.pageBackground bottomBar.backgroundColor = .white
bottomActionStack.axis = .vertical bottomDivider.backgroundColor = TravelAlbumDetailStyle.border.withAlphaComponent(0.65)
bottomActionStack.spacing = 8 configureBottomButton(uploadButton, title: "上传照片", color: TravelAlbumDetailStyle.primary)
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935)) var uploadConfiguration = uploadButton.configuration
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary) 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.isHidden = true
deleteSelectedButton.alpha = 0
view.addSubview(scrollContainer) view.addSubview(contentView)
scrollContainer.addSubview(infoCard) contentView.addSubview(infoCard)
scrollContainer.addSubview(manageCard) contentView.addSubview(sectionTitleLabel)
manageCard.addSubview(tabStack) contentView.addSubview(segmentedControl)
tabStack.addArrangedSubview(allTabButton) segmentedControl.addSubview(allTabButton)
tabStack.addArrangedSubview(purchasedTabButton) segmentedControl.addSubview(purchasedTabButton)
manageCard.addSubview(sortButton) contentView.addSubview(sortButton)
manageCard.addSubview(selectButton) contentView.addSubview(selectButton)
manageCard.addSubview(collectionView) contentView.addSubview(collectionView)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(bottomActionStack) bottomBar.addSubview(bottomDivider)
bottomActionStack.addArrangedSubview(deleteSelectedButton) bottomBar.addSubview(uploadButton)
bottomActionStack.addArrangedSubview(uploadButton) bottomBar.addSubview(deleteSelectedButton)
} }
override func setupConstraints() { override func setupConstraints() {
bottomBar.snp.makeConstraints { make in bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview() 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.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) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
} }
deleteSelectedButton.snp.makeConstraints { make in 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) contentView.snp.makeConstraints { make in
}
scrollContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(16) make.leading.trailing.equalToSuperview().inset(18)
make.bottom.equalTo(bottomBar.snp.top).offset(-8) make.bottom.equalTo(bottomBar.snp.top)
} }
infoCard.snp.makeConstraints { make in infoCard.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview() make.top.leading.trailing.equalToSuperview()
make.height.greaterThanOrEqualTo(76) make.height.equalTo(116)
} }
manageCard.snp.makeConstraints { make in sectionTitleLabel.snp.makeConstraints { make in
make.top.equalTo(infoCard.snp.bottom).offset(12) make.top.equalTo(infoCard.snp.bottom).offset(20)
make.leading.trailing.bottom.equalToSuperview() make.leading.equalToSuperview()
} }
tabStack.snp.makeConstraints { make in segmentedControl.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12) 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) make.height.equalTo(32)
} }
sortButton.snp.makeConstraints { make in sortButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack) make.centerY.equalTo(segmentedControl)
make.trailing.equalTo(selectButton.snp.leading).offset(-8) make.trailing.equalTo(selectButton.snp.leading).offset(-8)
make.size.equalTo(32) make.width.equalTo(74)
} make.height.equalTo(32)
selectButton.snp.makeConstraints { make in make.leading.greaterThanOrEqualTo(segmentedControl.snp.trailing).offset(8)
make.centerY.equalTo(tabStack)
make.trailing.equalToSuperview().offset(-12)
make.size.equalTo(32)
} }
collectionView.snp.makeConstraints { make in collectionView.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom).offset(12) make.top.equalTo(segmentedControl.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview().inset(12) make.leading.trailing.bottom.equalToSuperview()
} }
} }
@@ -161,7 +198,6 @@ final class TravelAlbumDetailViewController: BaseViewController {
infoCard.onCall = { [weak self] phone in self?.call(phone) } infoCard.onCall = { [weak self] phone in self?.call(phone) }
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside) allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), 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) selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside) deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
@@ -188,15 +224,36 @@ final class TravelAlbumDetailViewController: BaseViewController {
@MainActor @MainActor
private func applyViewModel() { private func applyViewModel() {
if let album = viewModel.album { 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(allTabButton, title: "全部 \(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased) configureTabButton(
selectButton.isHidden = viewModel.selectedTab != .all purchasedTabButton,
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal) title: "已购 \(viewModel.purchasedPhotoCount)",
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary selected: viewModel.selectedTab == .purchased
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal) )
setDeleteSelectedButtonVisible(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
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>() var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
snapshot.appendSections([0]) 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.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16 button.accessibilityTraits.insert(.button)
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 14)
)
} }
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) { private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
button.setTitle(title, for: .normal) button.setTitle(title, for: .normal)
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF) button.backgroundColor = selected ? TravelAlbumDetailStyle.primary : .white
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal) button.setTitleColor(selected ? .white : TravelAlbumDetailStyle.textSecondary, for: .normal)
button.accessibilityTraits = selected ? [.button, .selected] : [.button]
} }
private func configureIconButton(_ button: UIButton, image: UIImage?) { private func configureSortButton() {
button.setImage(image, for: .normal) var configuration = UIButton.Configuration.plain()
button.backgroundColor = UIColor(hex: 0xEAF4FF) configuration.image = UIImage(named: "travel_album_sort_icon")
button.tintColor = AppColor.primary configuration.imagePadding = 4
button.layer.cornerRadius = 16 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) { private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
button.setTitle(title, for: .normal) var configuration = UIButton.Configuration.filled()
button.setTitleColor(.white, for: .normal) configuration.title = title
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) configuration.baseBackgroundColor = color
button.backgroundColor = color configuration.baseForegroundColor = .white
button.layer.cornerRadius = 10 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) { private func makeSortMenu() -> UIMenu {
guard visible != isDeleteSelectedButtonVisible else { return } let actions = TravelAlbumDetailViewModel.SortOption.allCases.map { option in
isDeleteSelectedButtonVisible = visible UIAction(
title: option.title,
let changes = { state: option == viewModel.sortOption ? .on : .off
self.deleteSelectedButton.isHidden = !visible ) { [weak self] _ in
self.deleteSelectedButton.alpha = visible ? 1 : 0 guard let self else { return }
self.view.layoutIfNeeded() Task { await self.viewModel.setSortOption(option, api: self.api) }
}
} }
guard view.window != nil else { return UIMenu(title: "排序方式", options: .singleSelection, children: actions)
changes()
return
}
view.layoutIfNeeded()
UIView.animate(
withDuration: 0.25,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: changes
)
} }
private func makeLayout() -> UICollectionViewCompositionalLayout { private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in UICollectionViewCompositionalLayout { _, environment in
let spacing: CGFloat = 8 let spacing: CGFloat = 10
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3 let width = floor((environment.container.effectiveContentSize.width - spacing * 2) / 3)
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 38)) let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(width),
heightDimension: .absolute(width + 40)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize) 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) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
group.interItemSpacing = .fixed(spacing) group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group) let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 12 section.interGroupSpacing = 14
return section return section
} }
} }
@@ -303,24 +386,13 @@ final class TravelAlbumDetailViewController: BaseViewController {
Task { await viewModel.selectTab(.purchased, api: api) } 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() { @objc private func selectTapped() {
viewModel.toggleSelectionMode() viewModel.toggleSelectionMode()
} }
@objc private func deleteSelectedTapped() { @objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count let count = viewModel.selectedMaterialIds.count
guard count > 0 else { return }
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert) let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in 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 { private final class TravelAlbumInfoCard: UIView {
var onCall: ((String) -> Void)? var onCall: ((String) -> Void)?
private var phone = "" 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 phoneLabel = UILabel()
private let timeLabel = UILabel() private let timeLabel = UILabel()
private let callButton = UIButton(type: .system) private let callButton = UIButton(type: .system)
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .white clipsToBounds = true
layer.cornerRadius = 12 layer.cornerRadius = 16
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
layer.shadowOpacity = 1 backgroundImageView.contentMode = .scaleAspectFill
layer.shadowRadius = 6 coverImageView.contentMode = .scaleAspectFill
layer.shadowOffset = CGSize(width: 0, height: 2) 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.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary callButton.tintColor = TravelAlbumDetailStyle.primary
callButton.backgroundColor = UIColor(hex: 0xEAF4FF) callButton.backgroundColor = .white.withAlphaComponent(0.9)
callButton.layer.cornerRadius = 20 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) addSubview(backgroundImageView)
iconBox.addSubview(iconView) addSubview(coverImageView)
coverImageView.addSubview(coverPhotoIconView)
addSubview(nameLabel)
addSubview(phoneLabel) addSubview(phoneLabel)
addSubview(timeLabel) addSubview(timeLabel)
addSubview(callButton) addSubview(callButton)
iconBox.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(14) backgroundImageView.snp.makeConstraints { $0.edges.equalToSuperview() }
make.size.equalTo(48) 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 coverPhotoIconView.snp.makeConstraints { make in
make.center.equalToSuperview() make.trailing.bottom.equalToSuperview().inset(5)
make.size.equalTo(28) 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 phoneLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16) make.top.equalTo(nameLabel.snp.bottom).offset(8)
make.leading.equalTo(iconBox.snp.trailing).offset(12) make.leading.trailing.equalTo(nameLabel)
make.trailing.equalTo(callButton.snp.leading).offset(-12)
} }
timeLabel.snp.makeConstraints { make in timeLabel.snp.makeConstraints { make in
make.top.equalTo(phoneLabel.snp.bottom).offset(6) make.top.equalTo(phoneLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(phoneLabel) make.leading.trailing.equalTo(nameLabel)
make.bottom.equalToSuperview().offset(-16)
}
callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(40)
} }
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
} }
@@ -424,10 +530,21 @@ private final class TravelAlbumInfoCard: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func apply(album: TravelAlbum) { func apply(album: TravelAlbum, coverURL: String) {
phone = album.displayPhone phone = album.displayPhone
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))" nameLabel.text = album.name.isEmpty ? "旅拍相册" : album.name
timeLabel.text = "创建时间 \(album.createdAt)" 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() { @objc private func callTapped() {
@@ -435,12 +552,11 @@ private final class TravelAlbumInfoCard: UIView {
} }
} }
/// 旅拍相册素材网格 cell。 /// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
private final class TravelAlbumMaterialCell: UICollectionViewCell { private final class TravelAlbumMaterialCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumMaterialCell" static let reuseIdentifier = "TravelAlbumMaterialCell"
private let imageView = UIImageView() private let imageView = UIImageView()
private let statusLabel = UILabel()
private let checkImageView = UIImageView() private let checkImageView = UIImageView()
private let nameLabel = UILabel() private let nameLabel = UILabel()
private let sizeLabel = UILabel() private let sizeLabel = UILabel()
@@ -449,25 +565,18 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
super.init(frame: frame) super.init(frame: frame)
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
imageView.layer.cornerRadius = 8 imageView.layer.cornerRadius = 12
statusLabel.text = "已上传" imageView.backgroundColor = UIColor(hex: 0xEAF4FF)
statusLabel.font = .systemFont(ofSize: 10)
statusLabel.textColor = .white
statusLabel.backgroundColor = UIColor(hex: 0x34C759)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
checkImageView.tintColor = .white checkImageView.tintColor = .white
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35) checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
checkImageView.layer.cornerRadius = 10 checkImageView.layer.cornerRadius = 11
nameLabel.font = .systemFont(ofSize: 11) nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
nameLabel.textColor = AppColor.textPrimary nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
nameLabel.lineBreakMode = .byTruncatingMiddle nameLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
contentView.addSubview(imageView) contentView.addSubview(imageView)
imageView.addSubview(statusLabel)
imageView.addSubview(checkImageView) imageView.addSubview(checkImageView)
contentView.addSubview(nameLabel) contentView.addSubview(nameLabel)
contentView.addSubview(sizeLabel) contentView.addSubview(sizeLabel)
@@ -475,17 +584,12 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
make.top.leading.trailing.equalToSuperview() make.top.leading.trailing.equalToSuperview()
make.height.equalTo(imageView.snp.width) 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 checkImageView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4) make.top.trailing.equalToSuperview().inset(6)
make.size.equalTo(20) make.size.equalTo(22)
} }
nameLabel.snp.makeConstraints { make in 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() make.leading.trailing.equalToSuperview()
} }
sizeLabel.snp.makeConstraints { make in sizeLabel.snp.makeConstraints { make in
@@ -501,17 +605,20 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) { func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
imageView.contentMode = .scaleAspectFill
if let url = URL(string: urlString), !urlString.isEmpty { 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 { } else {
imageView.image = UIImage(systemName: "photo") imageView.image = UIImage(systemName: "photo.fill")
imageView.tintColor = AppColor.primary imageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35)
imageView.backgroundColor = UIColor(hex: 0xEAF2FF) imageView.contentMode = .center
} }
checkImageView.isHidden = !selectionMode checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? AppColor.primary : .white checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
nameLabel.text = material.fileName nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize) 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.infoResponse = TravelAlbum(id: 2, name: "详情")
api.materialListResponses = [ api.materialListResponses = [
TravelAlbumListResponse(total: 2, list: []), TravelAlbumListResponse(total: 2, list: []),
TravelAlbumListResponse(total: 1, list: []),
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]), TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
] ]
let viewModel = TravelAlbumDetailViewModel(albumId: 2) let viewModel = TravelAlbumDetailViewModel(albumId: 2)
@@ -88,7 +89,28 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.album?.name, "详情") XCTAssertEqual(viewModel.album?.name, "详情")
XCTAssertEqual(viewModel.allPhotoCount, 2) XCTAssertEqual(viewModel.allPhotoCount, 2)
XCTAssertEqual(viewModel.purchasedPhotoCount, 1)
XCTAssertEqual(viewModel.materials.count, 2) 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 { func testTabAndSortTriggerMaterialRequests() async {
@@ -106,6 +128,41 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(api.materialRequests.last?.orderBy, 4) 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() { func testOnlyUnpurchasedMaterialCanBeSelected() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2) let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode() viewModel.toggleSelectionMode()
@@ -954,6 +1011,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var createResponse = TravelAlbumCreateResponse(id: 0) var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum() var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = [] var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var materialListFailingCallIndexes: Set<Int> = []
var uploadMaterialResponse = TravelAlbumMaterial() var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: []) var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
@@ -993,6 +1051,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
orderBy: Int, orderBy: Int,
isPurchased: Int? isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> { ) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
let callIndex = materialRequests.count
materialRequests.append( materialRequests.append(
MaterialRequest( MaterialRequest(
userEquityTravelId: userEquityTravelId, userEquityTravelId: userEquityTravelId,
@@ -1002,6 +1061,9 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
isPurchased: isPurchased isPurchased: isPurchased
) )
) )
if materialListFailingCallIndexes.contains(callIndex) {
throw APIError.serverCode(500, "素材数量加载失败")
}
if materialListResponses.isEmpty { return TravelAlbumListResponse() } if materialListResponses.isEmpty { return TravelAlbumListResponse() }
return materialListResponses.removeFirst() return materialListResponses.removeFirst()
} }