feat: redesign travel album management
|
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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 642 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 588 B |
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||