对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。
重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user