对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。
重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,34 @@
|
||||
//
|
||||
// MiddleTruncatedText.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 文件名中间省略展示,对齐 Android MiddleEllipsisTextView。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 中间省略文本,用于长文件名展示。
|
||||
struct MiddleTruncatedText: UIViewRepresentable {
|
||||
let text: String
|
||||
var font: UIFont = .systemFont(ofSize: 11, weight: .regular)
|
||||
var textColor: UIColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1)
|
||||
|
||||
/// 创建中间省略 UILabel。
|
||||
func makeUIView(context: Context) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.lineBreakMode = .byTruncatingMiddle
|
||||
label.numberOfLines = 1
|
||||
label.setContentHuggingPriority(.required, for: .vertical)
|
||||
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
return label
|
||||
}
|
||||
|
||||
/// 更新文本样式。
|
||||
func updateUIView(_ uiView: UILabel, context: Context) {
|
||||
uiView.text = text
|
||||
uiView.font = font
|
||||
uiView.textColor = textColor
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
//
|
||||
// TravelAlbumDetailDesign.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 旅拍相册详情页设计 Token,对齐 Android TravelAlbumDetailScreen。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 旅拍相册详情页专用色值与尺寸。
|
||||
enum TravelAlbumDetailDesign {
|
||||
static let pageBackground = Color(hex: 0xF5F5F5)
|
||||
static let text333 = Color(hex: 0x333333)
|
||||
static let text999 = Color(hex: 0x999999)
|
||||
static let lightBlue = Color(hex: 0xEAF4FF)
|
||||
static let deleteRed = Color(hex: 0xE53935)
|
||||
static let uploadedGreen = Color(hex: 0x34C759)
|
||||
static let borderLight = Color(hex: 0xEEEEEE)
|
||||
static let selectionInactiveBackground = Color(hex: 0xF4F4F4)
|
||||
static let cardShadow = Color.black.opacity(0.08)
|
||||
|
||||
static let cardCornerRadius: CGFloat = 12
|
||||
static let cardShadowRadius: CGFloat = 2
|
||||
static let infoIconSize: CGFloat = 48
|
||||
static let infoIconCornerRadius: CGFloat = 10
|
||||
static let callButtonSize: CGFloat = 40
|
||||
static let tabPillCornerRadius: CGFloat = 18
|
||||
static let actionCircleSize: CGFloat = 32
|
||||
static let gridHorizontalSpacing: CGFloat = 8
|
||||
static let gridVerticalSpacing: CGFloat = 12
|
||||
static let gridPadding: CGFloat = 12
|
||||
static let thumbnailCornerRadius: CGFloat = 8
|
||||
static let uploadedBadgeCornerRadius: CGFloat = 4
|
||||
static let bottomButtonHeight: CGFloat = 48
|
||||
static let bottomButtonCornerRadius: CGFloat = 10
|
||||
static let contentHorizontalPadding: CGFloat = 16
|
||||
static let sectionSpacing: CGFloat = 12
|
||||
static let bottomBarHorizontalPadding: CGFloat = 20
|
||||
static let bottomBarVerticalPadding: CGFloat = 12
|
||||
static let selectionCheckSize: CGFloat = 20
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
//
|
||||
// TravelAlbumDetailInfoCard.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 旅拍相册详情信息卡片,对齐 Android AlbumInfoCard。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 旅拍相册详情顶部信息卡片。
|
||||
struct TravelAlbumDetailInfoCard: View {
|
||||
let album: TravelAlbumItem
|
||||
let onCallClick: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.infoIconCornerRadius)
|
||||
.fill(AppDesign.primary)
|
||||
.frame(width: TravelAlbumDetailDesign.infoIconSize, height: TravelAlbumDetailDesign.infoIconSize)
|
||||
.overlay {
|
||||
Image(systemName: "list.clipboard.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("手机号 \(OrderListUI.maskPhone(album.displayPhone))")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(TravelAlbumDetailDesign.text333)
|
||||
Text("创建时间 \(album.createdAt)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(TravelAlbumDetailDesign.text999)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button {
|
||||
onCallClick(album.displayPhone)
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(TravelAlbumDetailDesign.lightBlue)
|
||||
.frame(width: TravelAlbumDetailDesign.callButtonSize, height: TravelAlbumDetailDesign.callButtonSize)
|
||||
.overlay {
|
||||
Image(systemName: "phone.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("拨打电话")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.cardCornerRadius))
|
||||
.shadow(color: TravelAlbumDetailDesign.cardShadow, radius: TravelAlbumDetailDesign.cardShadowRadius, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
//
|
||||
// TravelAlbumDetailMaterialGridItem.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 旅拍相册详情素材网格项,对齐 Android MaterialGridItem。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 旅拍相册详情素材网格单元。
|
||||
struct TravelAlbumDetailMaterialGridItem: View {
|
||||
let material: TravelAlbumMaterial
|
||||
let isSelectionMode: Bool
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
let onPreview: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
thumbnailSection
|
||||
|
||||
MiddleTruncatedText(
|
||||
text: material.fileName,
|
||||
font: .systemFont(ofSize: 11),
|
||||
textColor: UIColor(TravelAlbumDetailDesign.text333)
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14, alignment: .leading)
|
||||
|
||||
Text(WiredTransferPhotoRecord.formatFileSize(Int64(material.fileSize)))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(TravelAlbumDetailDesign.text999)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if isSelectionMode {
|
||||
onSelect()
|
||||
} else {
|
||||
onPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1:1 缩略图区域,限制在网格单元宽度内避免重叠溢出。
|
||||
private var thumbnailSection: some View {
|
||||
Color.clear
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.overlay {
|
||||
RemoteImage(
|
||||
urlString: material.thumbnailURLString,
|
||||
contentMode: .fill
|
||||
) {
|
||||
Color(hex: 0xE5E7EB)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.thumbnailCornerRadius))
|
||||
.overlay(alignment: .topLeading) {
|
||||
Text("已上传")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(TravelAlbumDetailDesign.uploadedGreen)
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.uploadedBadgeCornerRadius))
|
||||
.padding(4)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isSelectionMode {
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: TravelAlbumDetailDesign.selectionCheckSize))
|
||||
.foregroundStyle(isSelected ? AppDesign.primary : .white)
|
||||
.padding(4)
|
||||
.background(Color.black.opacity(0.4), in: Circle())
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
//
|
||||
// TravelAlbumDetailPhotoManageCard.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 旅拍相册详情照片管理卡片,对齐 Android PhotoManageCard。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 旅拍相册详情照片管理区域。
|
||||
struct TravelAlbumDetailPhotoManageCard: View {
|
||||
let materials: [TravelAlbumMaterial]
|
||||
let allPhotoCount: Int
|
||||
let selectedTab: Int
|
||||
let orderBy: Int
|
||||
let isSelectionMode: Bool
|
||||
let selectedMaterialIDs: Set<Int>
|
||||
let onTabSelect: (Int) -> Void
|
||||
let onOrderBySelect: (Int) -> Void
|
||||
let onToggleSelectionMode: () -> Void
|
||||
let onMaterialClick: (TravelAlbumMaterial) -> Void
|
||||
let onMaterialPreview: (TravelAlbumMaterial) -> Void
|
||||
let onLoadMore: (Int) -> Void
|
||||
|
||||
private let gridColumns = [
|
||||
GridItem(.flexible(), spacing: TravelAlbumDetailDesign.gridHorizontalSpacing),
|
||||
GridItem(.flexible(), spacing: TravelAlbumDetailDesign.gridHorizontalSpacing),
|
||||
GridItem(.flexible())
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
tabToolbar
|
||||
Divider().overlay(TravelAlbumDetailDesign.borderLight)
|
||||
photoGrid
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.cardCornerRadius))
|
||||
.shadow(color: TravelAlbumDetailDesign.cardShadow, radius: TravelAlbumDetailDesign.cardShadowRadius, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private var tabToolbar: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
tabPill(
|
||||
text: "全部照片\(allPhotoCount)",
|
||||
selected: selectedTab == TravelAlbumDetailViewModel.tabAll
|
||||
) {
|
||||
onTabSelect(TravelAlbumDetailViewModel.tabAll)
|
||||
}
|
||||
tabPill(
|
||||
text: "已购照片",
|
||||
selected: selectedTab == TravelAlbumDetailViewModel.tabPurchased
|
||||
) {
|
||||
onTabSelect(TravelAlbumDetailViewModel.tabPurchased)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
sortMenu
|
||||
|
||||
if selectedTab == TravelAlbumDetailViewModel.tabAll {
|
||||
Button(action: onToggleSelectionMode) {
|
||||
Circle()
|
||||
.fill(isSelectionMode ? TravelAlbumDetailDesign.lightBlue : TravelAlbumDetailDesign.selectionInactiveBackground)
|
||||
.frame(width: TravelAlbumDetailDesign.actionCircleSize, height: TravelAlbumDetailDesign.actionCircleSize)
|
||||
.overlay {
|
||||
Image(systemName: isSelectionMode ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(isSelectionMode ? AppDesign.primary : TravelAlbumDetailDesign.text999)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("选择")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private var sortMenu: some View {
|
||||
Menu {
|
||||
ForEach(TravelAlbumDetailViewModel.sortOptions, id: \.value) { option in
|
||||
Button {
|
||||
onOrderBySelect(option.value)
|
||||
} label: {
|
||||
if orderBy == option.value {
|
||||
Label(option.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(option.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(TravelAlbumDetailDesign.lightBlue)
|
||||
.frame(width: TravelAlbumDetailDesign.actionCircleSize, height: TravelAlbumDetailDesign.actionCircleSize)
|
||||
.overlay {
|
||||
Image(systemName: "line.3.horizontal.decrease")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("筛选")
|
||||
}
|
||||
|
||||
private var photoGrid: some View {
|
||||
ScrollView {
|
||||
if materials.isEmpty {
|
||||
Text("暂无照片")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(TravelAlbumDetailDesign.text999)
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
} else {
|
||||
LazyVGrid(
|
||||
columns: gridColumns,
|
||||
spacing: TravelAlbumDetailDesign.gridVerticalSpacing
|
||||
) {
|
||||
ForEach(Array(materials.enumerated()), id: \.element.id) { index, material in
|
||||
TravelAlbumDetailMaterialGridItem(
|
||||
material: material,
|
||||
isSelectionMode: isSelectionMode,
|
||||
isSelected: selectedMaterialIDs.contains(material.id),
|
||||
onSelect: { onMaterialClick(material) },
|
||||
onPreview: { onMaterialPreview(material) }
|
||||
)
|
||||
.onAppear {
|
||||
onLoadMore(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(TravelAlbumDetailDesign.gridPadding)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
/// Tab 胶囊按钮。
|
||||
private func tabPill(text: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: selected ? .medium : .regular))
|
||||
.foregroundStyle(selected ? Color.white : AppDesign.primary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(selected ? AppDesign.primary : TravelAlbumDetailDesign.lightBlue)
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.tabPillCornerRadius))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ struct WiredCameraTransferView: View {
|
||||
cameraDeviceName: viewModel.cameraDeviceName,
|
||||
transferModeOption: viewModel.transferModeOption,
|
||||
tabTitles: tabTitles,
|
||||
tabCounts: viewModel.photos.transferTabCounts,
|
||||
tabCounts: viewModel.tabCounts,
|
||||
selectedTabIndex: viewModel.selectedTabIndex,
|
||||
onDeviceUsageClick: viewModel.onDeviceUsageClick,
|
||||
onRefreshCameraFiles: {
|
||||
@ -168,11 +168,11 @@ struct WiredCameraTransferView: View {
|
||||
}
|
||||
|
||||
private var sidebarGroups: [WiredTransferDateGroup] {
|
||||
visiblePhotos.buildSidebarGroups()
|
||||
viewModel.sidebarGroups
|
||||
}
|
||||
|
||||
private var photoSections: [WiredTransferPhotoSection] {
|
||||
visiblePhotos.buildPhotoSections()
|
||||
viewModel.photoSections
|
||||
}
|
||||
|
||||
private var selectAllBar: some View {
|
||||
@ -202,7 +202,16 @@ struct WiredCameraTransferView: View {
|
||||
onToggleSidebar: viewModel.toggleSidebar,
|
||||
onTimeSlotSelected: viewModel.selectTimeSlot,
|
||||
onPhotoClick: openPhotoPreview,
|
||||
onRetry: { id in Task { await viewModel.retryPhoto(id: id) } },
|
||||
onRetry: { id in
|
||||
Task {
|
||||
await viewModel.retryPhoto(
|
||||
id: id,
|
||||
api: travelAlbumAPI,
|
||||
ossService: ossUploadService,
|
||||
scenicID: scenicID
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelete: viewModel.deletePhoto,
|
||||
onTogglePhotoSelection: viewModel.togglePhotoSelection
|
||||
)
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Kingfisher
|
||||
|
||||
/// 有线传图照片预览,支持本地 file:// 与远程 URL。
|
||||
@ -19,7 +18,8 @@ struct WiredTransferPhotoPreviewSheet: View {
|
||||
self.imageSources = imageSources
|
||||
self.startIndex = startIndex
|
||||
self.onDismiss = onDismiss
|
||||
_currentIndex = State(initialValue: startIndex)
|
||||
let safeIndex = imageSources.isEmpty ? 0 : min(max(startIndex, 0), imageSources.count - 1)
|
||||
_currentIndex = State(initialValue: safeIndex)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -46,12 +46,7 @@ struct WiredTransferPhotoPreviewSheet: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func previewImage(_ source: String) -> some View {
|
||||
if source.hasPrefix("file://"), let url = URL(string: source),
|
||||
let uiImage = UIImage(contentsOfFile: url.path) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else if let url = URL(string: source), !source.isEmpty {
|
||||
if let url = URL(string: source), !source.isEmpty {
|
||||
KFImage(url)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@ -23,36 +24,40 @@ struct WiredTransferPhotoRow: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if selectUploadMode {
|
||||
Button(action: onToggleSelection) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if selectUploadMode {
|
||||
WiredTransferSelectIndicator(selected: selected, enabled: canSelect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSelect)
|
||||
}
|
||||
|
||||
thumbnailView
|
||||
.frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize)
|
||||
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius))
|
||||
.opacity(rowOpacity)
|
||||
.onTapGesture {
|
||||
if !selectUploadMode { onPhotoClick() }
|
||||
}
|
||||
thumbnailView
|
||||
.frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize)
|
||||
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius))
|
||||
.opacity(rowOpacity)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 4) {
|
||||
Text(photo.fileName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 4) {
|
||||
Text(photo.fileName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
WiredTransferStatusBadge(status: photo.status)
|
||||
.opacity(rowOpacity)
|
||||
}
|
||||
Text(metaText)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
WiredTransferStatusBadge(status: photo.status)
|
||||
.opacity(rowOpacity)
|
||||
}
|
||||
Text(metaText)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if selectUploadMode {
|
||||
if canSelect { onToggleSelection() }
|
||||
} else {
|
||||
onPhotoClick()
|
||||
}
|
||||
}
|
||||
|
||||
if !selectUploadMode {
|
||||
@ -71,10 +76,6 @@ struct WiredTransferPhotoRow: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if selectUploadMode, canSelect { onToggleSelection() }
|
||||
}
|
||||
|
||||
if photo.status == .transferring || photo.status == .uploading {
|
||||
ProgressView(value: Double(photo.progress), total: 100)
|
||||
@ -93,18 +94,20 @@ struct WiredTransferPhotoRow: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnailView: some View {
|
||||
if photo.thumbnailURL.hasPrefix("file://"), let url = URL(string: photo.thumbnailURL) {
|
||||
if let uiImage = UIImage(contentsOfFile: url.path) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
thumbnailPlaceholder
|
||||
}
|
||||
} else if !photo.thumbnailURL.isEmpty {
|
||||
RemoteImage(urlString: photo.thumbnailURL) {
|
||||
thumbnailPlaceholder
|
||||
}
|
||||
if let url = URL(string: photo.thumbnailURL), !photo.thumbnailURL.isEmpty {
|
||||
KFImage(url)
|
||||
.placeholder { thumbnailPlaceholder }
|
||||
.setProcessor(
|
||||
DownsamplingImageProcessor(
|
||||
size: CGSize(
|
||||
width: WiredTransferDesign.thumbnailSize,
|
||||
height: WiredTransferDesign.thumbnailSize
|
||||
)
|
||||
)
|
||||
)
|
||||
.scaleFactor(UIScreen.main.scale)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
thumbnailPlaceholder
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user