对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。

重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 13:53:59 +08:00
parent c71b45bdfd
commit 5e620623eb
23 changed files with 2000 additions and 288 deletions

View File

@ -7,7 +7,7 @@
import SwiftUI
///
/// Android TravelAlbumDetailScreen
struct TravelAlbumDetailView: View {
let albumID: Int
@ -16,9 +16,13 @@ struct TravelAlbumDetailView: View {
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = TravelAlbumDetailViewModel()
@State private var showDeleteConfirm = false
@State private var showMoreMenu = false
@State private var navigateToWiredTransfer = false
@State private var didReturnFromWiredTransfer = false
@State private var previewPayload: TravelAlbumMaterialPreviewPayload?
private var photoStore: WiredTransferPhotoStore {
WiredTransferPhotoStore(
@ -28,125 +32,186 @@ struct TravelAlbumDetailView: View {
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
if let album = viewModel.album {
albumHeader(album)
}
if viewModel.materials.isEmpty, !viewModel.isLoading {
AppContentUnavailableView("暂无素材", systemImage: "photo.on.rectangle")
.frame(maxWidth: .infinity, minHeight: 200)
} else {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ForEach(viewModel.materials) { material in
materialCell(material)
}
}
}
Group {
if viewModel.isLoading, viewModel.album == nil {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.album != nil {
contentView
} else {
Text("加载失败")
.font(.system(size: 14))
.foregroundStyle(TravelAlbumDetailDesign.text333)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle(viewModel.album?.name ?? "相册详情")
.background(TravelAlbumDetailDesign.pageBackground.ignoresSafeArea())
.navigationTitle("相册管理")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
if let album = viewModel.album {
NavigationLink {
WiredCameraTransferView(context: WiredTransferContext(
albumId: album.id,
albumName: album.name,
phone: album.displayPhone,
orderNumber: album.orderNumber
))
} label: {
Image(systemName: "cable.connector")
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("删除相册", role: .destructive) {
viewModel.showDeleteAlbumConfirm = true
}
.accessibilityLabel("有线传图")
}
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Image(systemName: "trash")
Image(systemName: "ellipsis")
.foregroundStyle(TravelAlbumDetailDesign.text333)
}
.accessibilityLabel("删除相册")
.accessibilityLabel("更多")
}
}
.confirmationDialog("确认删除该相册?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
.safeAreaInset(edge: .bottom) {
bottomBar
}
.background(wiredTransferNavigationLink)
.sheet(item: $previewPayload) { payload in
WiredTransferPhotoPreviewSheet(
imageSources: payload.sources,
startIndex: payload.startIndex,
onDismiss: { previewPayload = nil }
)
}
.confirmationDialog("删除相册", isPresented: $viewModel.showDeleteAlbumConfirm, titleVisibility: .visible) {
Button("删除", role: .destructive) {
Task { await deleteAlbum() }
}
Button("取消", role: .cancel) {}
} message: {
Text("确定删除该相册吗?已有购买素材的相册无法删除。")
}
.confirmationDialog("删除素材", isPresented: $viewModel.showDeleteMaterialConfirm, titleVisibility: .visible) {
Button("删除", role: .destructive) {
Task { await deleteSelectedMaterials() }
}
Button("取消", role: .cancel) {}
} message: {
Text("确定删除选中的 \(viewModel.selectedMaterialIDs.count) 张素材吗?")
}
.task(id: albumID) {
globalLoading.show()
defer { globalLoading.hide() }
await viewModel.load(api: travelAlbumAPI, albumID: albumID)
await viewModel.refreshAll(api: travelAlbumAPI, albumID: albumID)
}
.refreshable {
await viewModel.load(api: travelAlbumAPI, albumID: albumID)
.onChange(of: navigateToWiredTransfer) { isActive in
guard !isActive, didReturnFromWiredTransfer else { return }
didReturnFromWiredTransfer = false
Task { await viewModel.refreshAll(api: travelAlbumAPI, albumID: albumID) }
}
.onChange(of: viewModel.errorMessage) { message in
guard let message, !message.isEmpty else { return }
toastCenter.show(message)
viewModel.errorMessage = nil
}
}
private func albumHeader(_ album: TravelAlbumItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(album.type == 1 ? "先拍再买" : "买了再拍")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.primary)
if !album.displayPhone.isEmpty {
Label(album.displayPhone, systemImage: "phone.fill")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
}
if !album.orderNumber.isEmpty {
Text("订单号:\(album.orderNumber)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.large)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private func materialCell(_ material: TravelAlbumMaterial) -> some View {
ZStack(alignment: .topTrailing) {
RemoteImage(
urlString: material.coverURL.isEmpty ? material.fileURL : material.coverURL,
contentMode: .fill
) {
Color(hex: 0xE5E7EB)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Menu {
Button(role: .destructive) {
Task {
await viewModel.deleteMaterial(api: travelAlbumAPI, materialID: material.id, albumID: albumID)
}
} label: {
Label("删除", systemImage: "trash")
private var contentView: some View {
VStack(spacing: TravelAlbumDetailDesign.sectionSpacing) {
if let album = viewModel.album {
TravelAlbumDetailInfoCard(album: album) { phone in
dialPhone(phone)
}
} label: {
Image(systemName: "ellipsis")
.padding(6)
.background(Color.black.opacity(0.35))
.foregroundStyle(.white)
.clipShape(Circle())
.padding(4)
}
TravelAlbumDetailPhotoManageCard(
materials: viewModel.materials,
allPhotoCount: viewModel.allPhotoCount,
selectedTab: viewModel.selectedTab,
orderBy: viewModel.orderBy,
isSelectionMode: viewModel.isSelectionMode,
selectedMaterialIDs: viewModel.selectedMaterialIDs,
onTabSelect: { tab in
Task { await viewModel.selectTab(tab, api: travelAlbumAPI) }
},
onOrderBySelect: { order in
Task { await viewModel.setOrderBy(order, api: travelAlbumAPI) }
},
onToggleSelectionMode: {
viewModel.toggleSelectionMode()
},
onMaterialClick: { material in
viewModel.toggleMaterialSelection(material)
},
onMaterialPreview: { material in
openMaterialPreview(material)
},
onLoadMore: { index in
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: index, api: travelAlbumAPI) }
}
)
.frame(maxHeight: .infinity)
}
.aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, TravelAlbumDetailDesign.contentHorizontalPadding)
.padding(.top, 12)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private var bottomBar: some View {
VStack(spacing: 8) {
if viewModel.isSelectionMode, !viewModel.selectedMaterialIDs.isEmpty {
Button {
viewModel.requestDeleteSelectedMaterials()
} label: {
Text("删除选中(\(viewModel.selectedMaterialIDs.count))")
.font(.system(size: 16))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: TravelAlbumDetailDesign.bottomButtonHeight)
.background(TravelAlbumDetailDesign.deleteRed)
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.bottomButtonCornerRadius))
}
.buttonStyle(.plain)
}
Button {
didReturnFromWiredTransfer = true
navigateToWiredTransfer = true
} label: {
Text("上传照片")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: TravelAlbumDetailDesign.bottomButtonHeight)
.background(AppDesign.primary)
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.bottomButtonCornerRadius))
}
.buttonStyle(.plain)
}
.padding(.horizontal, TravelAlbumDetailDesign.bottomBarHorizontalPadding)
.padding(.vertical, TravelAlbumDetailDesign.bottomBarVerticalPadding)
.background(TravelAlbumDetailDesign.pageBackground)
}
@ViewBuilder
private var wiredTransferNavigationLink: some View {
if let context = viewModel.wiredTransferContext() {
NavigationLink(isActive: $navigateToWiredTransfer) {
WiredCameraTransferView(context: context)
} label: {
EmptyView()
}
.hidden()
}
}
///
private func dialPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
openURL(url)
}
///
private func openMaterialPreview(_ material: TravelAlbumMaterial) {
let previewMaterials = viewModel.materials.filter { !$0.previewURLString.isEmpty }
guard !material.previewURLString.isEmpty else {
toastCenter.show("暂无法预览该照片")
return
}
let sources = previewMaterials.map(\.previewURLString)
let startIndex = previewMaterials.firstIndex(where: { $0.id == material.id }) ?? 0
previewPayload = TravelAlbumMaterialPreviewPayload(sources: sources, startIndex: startIndex)
}
private func deleteAlbum() async {
@ -158,4 +223,20 @@ struct TravelAlbumDetailView: View {
dismiss()
}
}
private func deleteSelectedMaterials() async {
globalLoading.show()
defer { globalLoading.hide() }
let success = await viewModel.confirmDeleteSelectedMaterials(api: travelAlbumAPI)
if success {
toastCenter.show("删除成功")
}
}
}
/// Sheet
private struct TravelAlbumMaterialPreviewPayload: Identifiable {
let id = UUID()
let sources: [String]
let startIndex: Int
}