diff --git a/docs/design/travel-album-management-redesign-v2.png b/docs/design/travel-album-management-redesign-v2.png new file mode 100644 index 0000000..d08e465 Binary files /dev/null and b/docs/design/travel-album-management-redesign-v2.png differ diff --git a/suixinkan/Assets.xcassets/TravelAlbum/Contents.json b/suixinkan/Assets.xcassets/TravelAlbum/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/TravelAlbum/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/Contents.json new file mode 100644 index 0000000..ffabbe8 --- /dev/null +++ b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/Contents.json @@ -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 } +} diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/travel_album_cover_photo_icon.png b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/travel_album_cover_photo_icon.png new file mode 100644 index 0000000..f05867b Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/travel_album_cover_photo_icon.png differ diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/travel_album_cover_photo_icon@2x.png b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/travel_album_cover_photo_icon@2x.png new file mode 100644 index 0000000..1214764 Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/travel_album_cover_photo_icon@2x.png differ diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/travel_album_cover_photo_icon@3x.png b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/travel_album_cover_photo_icon@3x.png new file mode 100644 index 0000000..04163ce Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_cover_photo_icon.imageset/travel_album_cover_photo_icon@3x.png differ diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/Contents.json new file mode 100644 index 0000000..6d2b572 --- /dev/null +++ b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/Contents.json @@ -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 } +} diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/travel_album_header_background@1x.png b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/travel_album_header_background@1x.png new file mode 100644 index 0000000..d6f48f3 Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/travel_album_header_background@1x.png differ diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/travel_album_header_background@2x.png b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/travel_album_header_background@2x.png new file mode 100644 index 0000000..ee8a0b1 Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/travel_album_header_background@2x.png differ diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/travel_album_header_background@3x.png b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/travel_album_header_background@3x.png new file mode 100644 index 0000000..426dde9 Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_header_background.imageset/travel_album_header_background@3x.png differ diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/Contents.json new file mode 100644 index 0000000..b921c04 --- /dev/null +++ b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/Contents.json @@ -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 } +} diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/travel_album_sort_icon.png b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/travel_album_sort_icon.png new file mode 100644 index 0000000..5ec6376 Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/travel_album_sort_icon.png differ diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/travel_album_sort_icon@2x.png b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/travel_album_sort_icon@2x.png new file mode 100644 index 0000000..828faf4 Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/travel_album_sort_icon@2x.png differ diff --git a/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/travel_album_sort_icon@3x.png b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/travel_album_sort_icon@3x.png new file mode 100644 index 0000000..aa3b77d Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbum/travel_album_sort_icon.imageset/travel_album_sort_icon@3x.png differ diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index a045383..d785fe2 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -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 + } } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift index a4e5297..1a6f84a 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift @@ -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() diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index 5632320..7bcff0c 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -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! 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(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() 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 } } diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 80dd3f4..c31a0d7 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -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] = [] + var materialListFailingCallIndexes: Set = [] 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 { + 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() }