fix: 优化相册素材状态与删除保护
This commit is contained in:
@@ -224,7 +224,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
|
/// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。
|
||||||
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
||||||
case purchased
|
case purchased
|
||||||
case pending
|
case pending
|
||||||
@@ -241,14 +241,15 @@ struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension TravelAlbumMaterial {
|
extension TravelAlbumMaterial {
|
||||||
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
|
/// 生成购买状态角标;购买状态与修图状态互不覆盖。
|
||||||
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
|
var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
if aiRetouchStatus == 0 {
|
isPurchased
|
||||||
return isPurchased
|
|
||||||
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
: nil
|
: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
|
||||||
|
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
switch aiRetouchStatus {
|
switch aiRetouchStatus {
|
||||||
case 1:
|
case 1:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
private(set) var isRefreshing = false
|
private(set) var isRefreshing = false
|
||||||
private(set) var isSelectionMode = false
|
private(set) var isSelectionMode = false
|
||||||
private(set) var selectedMaterialIds: Set<Int> = []
|
private(set) var selectedMaterialIds: Set<Int> = []
|
||||||
|
private var selectedMaterialPurchaseStates: [Int: Bool] = [:]
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@@ -142,12 +143,29 @@ final class TravelAlbumDetailViewModel {
|
|||||||
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前选中素材中的已购买数量。
|
||||||
|
var selectedPurchasedMaterialCount: Int {
|
||||||
|
selectedMaterialIds.count - selectedDeletableMaterialIds.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除确认弹窗文案;选中已购素材时明确说明保护规则与实际删除数量。
|
||||||
|
var deleteSelectedConfirmationMessage: String {
|
||||||
|
let selectedCount = selectedMaterialIds.count
|
||||||
|
let purchasedCount = selectedPurchasedMaterialCount
|
||||||
|
guard purchasedCount > 0 else {
|
||||||
|
return "确定删除选中的 \(selectedCount) 张素材吗?"
|
||||||
|
}
|
||||||
|
let deletableCount = selectedCount - purchasedCount
|
||||||
|
return "已选择 \(selectedCount) 张素材,其中 \(purchasedCount) 张已购买。"
|
||||||
|
+ "不会删除已购买的项目,只会删除 \(deletableCount) 张未购买的项目。"
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换素材 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 }
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
await loadMaterials(reset: true, api: api)
|
await loadMaterials(reset: true, api: api)
|
||||||
}
|
}
|
||||||
@@ -223,10 +241,9 @@ final class TravelAlbumDetailViewModel {
|
|||||||
|
|
||||||
/// 切换选择模式。
|
/// 切换选择模式。
|
||||||
func toggleSelectionMode() {
|
func toggleSelectionMode() {
|
||||||
guard selectedTab == .all else { return }
|
|
||||||
isSelectionMode.toggle()
|
isSelectionMode.toggle()
|
||||||
if !isSelectionMode {
|
if !isSelectionMode {
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
@@ -234,14 +251,12 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// 切换素材选中状态。
|
/// 切换素材选中状态。
|
||||||
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||||
guard isSelectionMode else { return }
|
guard isSelectionMode else { return }
|
||||||
guard material.status == 1 else {
|
|
||||||
onShowMessage?("仅未购买素材可删除")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if selectedMaterialIds.contains(material.id) {
|
if selectedMaterialIds.contains(material.id) {
|
||||||
selectedMaterialIds.remove(material.id)
|
selectedMaterialIds.remove(material.id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: material.id)
|
||||||
} else {
|
} else {
|
||||||
selectedMaterialIds.insert(material.id)
|
selectedMaterialIds.insert(material.id)
|
||||||
|
selectedMaterialPurchaseStates[material.id] = material.isPurchased
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
@@ -249,7 +264,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
||||||
func completeAIRetouchSubmission() {
|
func completeAIRetouchSubmission() {
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +279,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
||||||
let material = materials.remove(at: index)
|
let material = materials.remove(at: index)
|
||||||
selectedMaterialIds.remove(id)
|
selectedMaterialIds.remove(id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: id)
|
||||||
allPhotoCount = max(0, allPhotoCount - 1)
|
allPhotoCount = max(0, allPhotoCount - 1)
|
||||||
if material.isPurchased {
|
if material.isPurchased {
|
||||||
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
||||||
@@ -273,16 +289,20 @@ final class TravelAlbumDetailViewModel {
|
|||||||
|
|
||||||
/// 删除已选素材。
|
/// 删除已选素材。
|
||||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||||
let ids = selectedMaterialIds.sorted()
|
guard !selectedMaterialIds.isEmpty else {
|
||||||
guard !ids.isEmpty else {
|
|
||||||
onShowMessage?("请选择要删除的素材")
|
onShowMessage?("请选择要删除的素材")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let ids = selectedDeletableMaterialIds
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
onShowMessage?("选中的素材均已购买,未删除任何项目")
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try await api.batchDeleteMaterials(ids: ids)
|
try await api.batchDeleteMaterials(ids: ids)
|
||||||
onShowMessage?("删除成功")
|
onShowMessage?("删除成功")
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
await refreshAll(api: api)
|
await refreshAll(api: api)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
@@ -307,4 +327,18 @@ final class TravelAlbumDetailViewModel {
|
|||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func clearMaterialSelection() {
|
||||||
|
selectedMaterialIds = []
|
||||||
|
selectedMaterialPurchaseStates = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedDeletableMaterialIds: [Int] {
|
||||||
|
selectedMaterialIds
|
||||||
|
.filter { id in
|
||||||
|
guard selectedMaterialPurchaseStates[id] == false else { return false }
|
||||||
|
return materials.first(where: { $0.id == id })?.isPurchased != true
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,14 +293,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
sortButton.accessibilityValue = viewModel.sortOption.title
|
sortButton.accessibilityValue = viewModel.sortOption.title
|
||||||
sortButton.menu = makeSortMenu()
|
sortButton.menu = makeSortMenu()
|
||||||
|
|
||||||
let canSelectMaterials = viewModel.selectedTab == .all
|
|
||||||
selectButton.isHidden = false
|
selectButton.isHidden = false
|
||||||
selectButton.isEnabled = canSelectMaterials
|
selectButton.isEnabled = true
|
||||||
selectButton.alpha = canSelectMaterials ? 1 : 0.45
|
selectButton.alpha = 1
|
||||||
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
||||||
selectButton.accessibilityValue = canSelectMaterials
|
selectButton.accessibilityValue = viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭"
|
||||||
? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭")
|
|
||||||
: "已购照片不可删除"
|
|
||||||
|
|
||||||
let selectedCount = viewModel.selectedMaterialIds.count
|
let selectedCount = viewModel.selectedMaterialIds.count
|
||||||
let hasSelection = selectedCount > 0
|
let hasSelection = selectedCount > 0
|
||||||
@@ -487,7 +484,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
@objc private func deleteSelectedTapped() {
|
@objc private func deleteSelectedTapped() {
|
||||||
let count = viewModel.selectedMaterialIds.count
|
let count = viewModel.selectedMaterialIds.count
|
||||||
guard count > 0 else { return }
|
guard count > 0 else { return }
|
||||||
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
let alert = UIAlertController(
|
||||||
|
title: "删除素材",
|
||||||
|
message: viewModel.deleteSelectedConfirmationMessage,
|
||||||
|
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
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -694,14 +695,16 @@ private final class TravelAlbumInfoCard: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
|
/// 旅拍相册素材网格单元,独立展示购买、修图和选择状态。
|
||||||
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||||
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||||||
|
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
private let checkImageView = UIImageView()
|
private let checkImageView = UIImageView()
|
||||||
private let badgeView = UIView()
|
private let purchaseBadgeView = UIView()
|
||||||
private let badgeLabel = UILabel()
|
private let purchaseBadgeLabel = UILabel()
|
||||||
|
private let aiRetouchBadgeView = UIView()
|
||||||
|
private let aiRetouchBadgeLabel = UILabel()
|
||||||
private let nameLabel = UILabel()
|
private let nameLabel = UILabel()
|
||||||
private let sizeLabel = UILabel()
|
private let sizeLabel = UILabel()
|
||||||
|
|
||||||
@@ -715,18 +718,18 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
checkImageView.layer.cornerRadius = 11
|
checkImageView.layer.cornerRadius = 11
|
||||||
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
||||||
badgeView.layer.cornerRadius = 5
|
configureBadge(
|
||||||
badgeView.clipsToBounds = true
|
purchaseBadgeView,
|
||||||
badgeView.isHidden = true
|
label: purchaseBadgeLabel,
|
||||||
badgeView.isAccessibilityElement = false
|
viewIdentifier: "travelAlbum.materialPurchaseBadge",
|
||||||
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
|
labelIdentifier: "travelAlbum.materialPurchaseBadgeLabel"
|
||||||
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
|
)
|
||||||
badgeLabel.textColor = .white
|
configureBadge(
|
||||||
badgeLabel.textAlignment = .center
|
aiRetouchBadgeView,
|
||||||
badgeLabel.isAccessibilityElement = false
|
label: aiRetouchBadgeLabel,
|
||||||
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
|
viewIdentifier: "travelAlbum.materialAIRetouchBadge",
|
||||||
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
labelIdentifier: "travelAlbum.materialAIRetouchBadgeLabel"
|
||||||
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
)
|
||||||
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||||
nameLabel.lineBreakMode = .byTruncatingMiddle
|
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
@@ -734,8 +737,8 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||||||
|
|
||||||
contentView.addSubview(imageView)
|
contentView.addSubview(imageView)
|
||||||
imageView.addSubview(badgeView)
|
imageView.addSubview(purchaseBadgeView)
|
||||||
badgeView.addSubview(badgeLabel)
|
imageView.addSubview(aiRetouchBadgeView)
|
||||||
imageView.addSubview(checkImageView)
|
imageView.addSubview(checkImageView)
|
||||||
contentView.addSubview(nameLabel)
|
contentView.addSubview(nameLabel)
|
||||||
contentView.addSubview(sizeLabel)
|
contentView.addSubview(sizeLabel)
|
||||||
@@ -747,14 +750,13 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
make.top.trailing.equalToSuperview().inset(6)
|
make.top.trailing.equalToSuperview().inset(6)
|
||||||
make.size.equalTo(22)
|
make.size.equalTo(22)
|
||||||
}
|
}
|
||||||
badgeView.snp.makeConstraints { make in
|
aiRetouchBadgeView.snp.makeConstraints { make in
|
||||||
make.top.leading.equalToSuperview().inset(6)
|
make.top.leading.equalToSuperview().inset(6)
|
||||||
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
||||||
}
|
}
|
||||||
badgeLabel.snp.makeConstraints { make in
|
purchaseBadgeView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(
|
make.leading.bottom.equalToSuperview().inset(6)
|
||||||
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
make.trailing.lessThanOrEqualToSuperview().inset(6)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
nameLabel.snp.makeConstraints { make in
|
nameLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(imageView.snp.bottom).offset(6)
|
make.top.equalTo(imageView.snp.bottom).offset(6)
|
||||||
@@ -784,14 +786,54 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
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 ? TravelAlbumDetailStyle.primary : .white
|
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||||||
let badge = material.badgePresentation
|
let purchaseBadge = material.purchaseBadgePresentation
|
||||||
badgeView.isHidden = badge == nil
|
let aiRetouchBadge = material.aiRetouchBadgePresentation
|
||||||
badgeLabel.text = badge?.text
|
applyBadge(purchaseBadge, to: purchaseBadgeView, label: purchaseBadgeLabel)
|
||||||
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
applyBadge(aiRetouchBadge, to: aiRetouchBadgeView, label: aiRetouchBadgeLabel)
|
||||||
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
||||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||||||
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
|
let purchaseAccessibilityText = purchaseBadge.map { ",购买状态:\($0.text)" } ?? ""
|
||||||
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
|
let aiRetouchAccessibilityText = aiRetouchBadge.map { ",修图状态:\($0.text)" } ?? ""
|
||||||
|
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
|
||||||
|
+ purchaseAccessibilityText
|
||||||
|
+ aiRetouchAccessibilityText
|
||||||
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureBadge(
|
||||||
|
_ badgeView: UIView,
|
||||||
|
label: UILabel,
|
||||||
|
viewIdentifier: String,
|
||||||
|
labelIdentifier: String
|
||||||
|
) {
|
||||||
|
badgeView.layer.cornerRadius = 5
|
||||||
|
badgeView.clipsToBounds = true
|
||||||
|
badgeView.isHidden = true
|
||||||
|
badgeView.isAccessibilityElement = false
|
||||||
|
badgeView.accessibilityIdentifier = viewIdentifier
|
||||||
|
label.font = .systemFont(ofSize: 10, weight: .semibold)
|
||||||
|
label.textColor = .white
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.lineBreakMode = .byTruncatingTail
|
||||||
|
label.isAccessibilityElement = false
|
||||||
|
label.accessibilityIdentifier = labelIdentifier
|
||||||
|
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||||
|
badgeView.addSubview(label)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(
|
||||||
|
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyBadge(
|
||||||
|
_ presentation: TravelAlbumMaterialBadgePresentation?,
|
||||||
|
to badgeView: UIView,
|
||||||
|
label: UILabel
|
||||||
|
) {
|
||||||
|
badgeView.isHidden = presentation == nil
|
||||||
|
label.text = presentation?.text
|
||||||
|
badgeView.backgroundColor = presentation.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1149,9 +1149,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" })
|
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" })
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
|
func testMaterialCellShowsIndependentPurchaseAndAIRetouchBadgesWithoutOverlappingSelectionCheck() throws {
|
||||||
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
||||||
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
|
|
||||||
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
|
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
|
||||||
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
|
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
|
||||||
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
|
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
|
||||||
@@ -1170,12 +1169,12 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
|
|
||||||
let badgeView = try XCTUnwrap(
|
let badgeView = try XCTUnwrap(
|
||||||
cell.findSubview {
|
cell.findSubview {
|
||||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
let badgeLabel = try XCTUnwrap(
|
let badgeLabel = try XCTUnwrap(
|
||||||
cell.findSubview {
|
cell.findSubview {
|
||||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel"
|
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadgeLabel"
|
||||||
} as? UILabel
|
} as? UILabel
|
||||||
)
|
)
|
||||||
let selectionCheck = try XCTUnwrap(
|
let selectionCheck = try XCTUnwrap(
|
||||||
@@ -1188,22 +1187,61 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(badgeLabel.text, expectedText)
|
XCTAssertEqual(badgeLabel.text, expectedText)
|
||||||
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
|
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
|
||||||
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
|
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
|
||||||
XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true)
|
XCTAssertTrue(cell.accessibilityLabel?.contains("修图状态:\(expectedText)") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let combinedCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||||
|
combinedCell.apply(
|
||||||
|
material: TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2),
|
||||||
|
selectionMode: true,
|
||||||
|
selected: false
|
||||||
|
)
|
||||||
|
combinedCell.layoutIfNeeded()
|
||||||
|
let purchaseBadge = try XCTUnwrap(
|
||||||
|
combinedCell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let purchaseLabel = try XCTUnwrap(
|
||||||
|
combinedCell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadgeLabel"
|
||||||
|
} as? UILabel
|
||||||
|
)
|
||||||
|
let aiRetouchBadge = try XCTUnwrap(
|
||||||
|
combinedCell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
XCTAssertFalse(purchaseBadge.isHidden)
|
||||||
|
XCTAssertFalse(aiRetouchBadge.isHidden)
|
||||||
|
XCTAssertEqual(purchaseLabel.text, "已购")
|
||||||
|
XCTAssertEqual(purchaseBadge.backgroundColor?.travelAlbumTestHexRGB, 0x475569)
|
||||||
|
XCTAssertGreaterThan(purchaseBadge.frame.minY, aiRetouchBadge.frame.maxY)
|
||||||
|
XCTAssertEqual(purchaseBadge.frame.minX, 6, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(purchaseBadge.frame.maxY, 170, accuracy: 0.5)
|
||||||
|
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("购买状态:已购") == true)
|
||||||
|
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("修图状态:修图中") == true)
|
||||||
|
|
||||||
let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||||
hiddenCell.apply(
|
hiddenCell.apply(
|
||||||
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
|
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
|
||||||
selectionMode: false,
|
selectionMode: false,
|
||||||
selected: false
|
selected: false
|
||||||
)
|
)
|
||||||
let hiddenBadge = try XCTUnwrap(
|
let hiddenPurchaseBadge = try XCTUnwrap(
|
||||||
hiddenCell.findSubview {
|
hiddenCell.findSubview {
|
||||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
XCTAssertTrue(hiddenBadge.isHidden)
|
let hiddenAIRetouchBadge = try XCTUnwrap(
|
||||||
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true)
|
hiddenCell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
XCTAssertTrue(hiddenPurchaseBadge.isHidden)
|
||||||
|
XCTAssertTrue(hiddenAIRetouchBadge.isHidden)
|
||||||
|
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("购买状态:") == true)
|
||||||
|
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("修图状态:") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
||||||
|
|||||||
@@ -399,34 +399,48 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
XCTAssertEqual(material.aiAtmosphereURL, "")
|
XCTAssertEqual(material.aiAtmosphereURL, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTravelAlbumMaterialBadgePriorityAndFallbacks() {
|
func testTravelAlbumMaterialPurchaseAndAIRetouchBadgesAreIndependent() {
|
||||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation)
|
XCTAssertNil(TravelAlbumMaterial().purchaseBadgePresentation)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传")
|
TravelAlbumMaterial(isPurchased: true).purchaseBadgePresentation,
|
||||||
.badgePresentation,
|
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
|
)
|
||||||
|
XCTAssertNil(TravelAlbumMaterial(isPurchased: true).aiRetouchBadgePresentation)
|
||||||
|
let purchasedAndProcessing = TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
purchasedAndProcessing.purchaseBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation,
|
purchasedAndProcessing.aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
|
|
||||||
)
|
|
||||||
XCTAssertEqual(
|
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation,
|
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation,
|
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ")
|
||||||
|
.aiRetouchBadgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").aiRetouchBadgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 3).aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
|
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation,
|
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面")
|
||||||
|
.aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
|
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation,
|
TravelAlbumMaterial(aiRetouchStatus: 4).aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
|
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
|
||||||
)
|
)
|
||||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation)
|
XCTAssertNil(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知")
|
||||||
|
.aiRetouchBadgePresentation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDisplayFormatters() {
|
func testDisplayFormatters() {
|
||||||
|
|||||||
@@ -279,14 +279,22 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
|
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOnlyUnpurchasedMaterialCanBeSelected() {
|
func testAllMaterialsCanBeSelectedAndPurchasedTabSupportsSelectionMode() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [TravelAlbumListResponse(total: 1, list: [])]
|
||||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
await viewModel.selectTab(.purchased, api: api)
|
||||||
viewModel.toggleSelectionMode()
|
viewModel.toggleSelectionMode()
|
||||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2))
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, isPurchased: true))
|
||||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2, isPurchased: false))
|
||||||
|
|
||||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
|
XCTAssertTrue(viewModel.isSelectionMode)
|
||||||
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
|
XCTAssertEqual(viewModel.selectedMaterialIds, [1, 2])
|
||||||
|
XCTAssertEqual(viewModel.selectedPurchasedMaterialCount, 1)
|
||||||
|
XCTAssertEqual(
|
||||||
|
viewModel.deleteSelectedConfirmationMessage,
|
||||||
|
"已选择 2 张素材,其中 1 张已购买。不会删除已购买的项目,只会删除 1 张未购买的项目。"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
|
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
|
||||||
@@ -405,6 +413,42 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertFalse(viewModel.isSelectionMode)
|
XCTAssertFalse(viewModel.isSelectionMode)
|
||||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDeleteSelectedMaterialsFiltersPurchasedItemsFromBatchRequest() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||||
|
viewModel.toggleSelectionMode()
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 11, isPurchased: false))
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 12, isPurchased: true))
|
||||||
|
|
||||||
|
await viewModel.deleteSelectedMaterials(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedMaterialIDBatches, [[11]])
|
||||||
|
XCTAssertFalse(viewModel.isSelectionMode)
|
||||||
|
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteSelectedMaterialsDoesNotRequestAPIWhenAllSelectedItemsArePurchased() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||||
|
var message = ""
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
viewModel.toggleSelectionMode()
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
|
||||||
|
|
||||||
|
await viewModel.deleteSelectedMaterials(api: api)
|
||||||
|
|
||||||
|
XCTAssertTrue(api.deletedMaterialIDBatches.isEmpty)
|
||||||
|
XCTAssertEqual(message, "选中的素材均已购买,未删除任何项目")
|
||||||
|
XCTAssertTrue(viewModel.isSelectionMode)
|
||||||
|
XCTAssertEqual(viewModel.selectedMaterialIds, [10])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 有线传输 ViewModel 测试。
|
/// 有线传输 ViewModel 测试。
|
||||||
|
|||||||
Reference in New Issue
Block a user