对齐有线传图页 Android UI,并改用相对路径持久化本地照片。
还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 有线传图页,连接相机并上传照片到旅拍相册。
|
||||
struct WiredCameraTransferView: View {
|
||||
@ -16,8 +15,11 @@ struct WiredCameraTransferView: View {
|
||||
@Environment(\.travelAlbumAPI) private var travelAlbumAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject private var viewModel: WiredCameraTransferViewModel
|
||||
@State private var previewSources: [String]?
|
||||
@State private var previewStartIndex = 0
|
||||
|
||||
init(context: WiredTransferContext) {
|
||||
self.context = context
|
||||
@ -30,22 +32,73 @@ struct WiredCameraTransferView: View {
|
||||
}
|
||||
|
||||
private let tabTitles = ["全部", "已上传", "失败"]
|
||||
private var scenicID: Int { accountContext.currentScenic?.id ?? 0 }
|
||||
private var scenicSpotLabel: String? {
|
||||
accountContext.currentScenic?.name
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
tabSection
|
||||
photoListSection
|
||||
bottomBar
|
||||
WiredTransferHeaderView(
|
||||
albumTitle: context.albumName,
|
||||
headerPhone: context.phone,
|
||||
onBack: { dismiss() }
|
||||
)
|
||||
|
||||
WiredTransferControlCard(
|
||||
scenicSpotLabel: scenicSpotLabel,
|
||||
deviceStorageInfo: viewModel.deviceStorageInfo,
|
||||
isCameraConnected: viewModel.isCameraConnected,
|
||||
isRefreshingCameraFiles: viewModel.isRefreshingCameraFiles,
|
||||
cameraDeviceName: viewModel.cameraDeviceName,
|
||||
transferModeOption: viewModel.transferModeOption,
|
||||
tabTitles: tabTitles,
|
||||
tabCounts: viewModel.photos.transferTabCounts,
|
||||
selectedTabIndex: viewModel.selectedTabIndex,
|
||||
onDeviceUsageClick: viewModel.onDeviceUsageClick,
|
||||
onRefreshCameraFiles: {
|
||||
Task { await viewModel.refreshCameraFiles() }
|
||||
},
|
||||
onHelpClick: viewModel.openHelpDoc,
|
||||
onTransferModeSelected: { option in
|
||||
viewModel.selectTransferMode(
|
||||
option,
|
||||
api: travelAlbumAPI,
|
||||
ossService: ossUploadService,
|
||||
scenicID: scenicID
|
||||
)
|
||||
},
|
||||
onTabSelected: viewModel.selectTab
|
||||
)
|
||||
|
||||
if viewModel.selectUploadMode, !visiblePhotos.isEmpty {
|
||||
selectAllBar
|
||||
}
|
||||
|
||||
contentSection
|
||||
|
||||
WiredTransferBottomBar(
|
||||
selectUploadMode: viewModel.selectUploadMode,
|
||||
selectedCount: viewModel.selectedPhotoIDs.count,
|
||||
onBatchUploadClick: {
|
||||
Task {
|
||||
await viewModel.onBatchUploadButtonClick(
|
||||
api: travelAlbumAPI,
|
||||
ossService: ossUploadService,
|
||||
scenicID: scenicID
|
||||
)
|
||||
}
|
||||
},
|
||||
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick
|
||||
)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("有线传图")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.task {
|
||||
await viewModel.start(
|
||||
api: travelAlbumAPI,
|
||||
ossService: ossUploadService,
|
||||
scenicID: accountContext.currentScenic?.id ?? 0,
|
||||
scenicID: scenicID,
|
||||
accountContext: accountContext
|
||||
)
|
||||
}
|
||||
@ -55,247 +108,140 @@ struct WiredCameraTransferView: View {
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
guard let message, !message.isEmpty else { return }
|
||||
toastCenter.show(message)
|
||||
viewModel.clearErrorMessage()
|
||||
}
|
||||
.onChange(of: viewModel.connectionState) { state in
|
||||
switch state {
|
||||
case .connected(let name):
|
||||
toastCenter.show("已连接 \(name)")
|
||||
case .disconnected:
|
||||
break
|
||||
case .error(let message):
|
||||
toastCenter.show(message)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.confirmationDialog("指定上传", isPresented: $viewModel.showSpecifyUploadSheet, titleVisibility: .visible) {
|
||||
Button("上传全部待上传") {
|
||||
Task { await viewModel.batchUploadAll() }
|
||||
}
|
||||
Button("上传选中项") {
|
||||
Task { await viewModel.uploadSelected() }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
.alert("手机内存不足", isPresented: $viewModel.showDeviceStorageAlert) {
|
||||
Button("我知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text("请及时清理内存")
|
||||
}
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(context.albumName)
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
if !context.phone.isEmpty {
|
||||
Text(context.phone)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
connectionBadge
|
||||
}
|
||||
|
||||
Picker("传输模式", selection: Binding(
|
||||
get: { viewModel.transferModeOption },
|
||||
set: { newValue in
|
||||
viewModel.selectTransferMode(
|
||||
newValue,
|
||||
.alert("相机已断开", isPresented: $viewModel.showCameraDisconnectedAlert) {
|
||||
Button("我知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text("相机已断开,请检查 USB 连接。")
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showSpecifyUploadSheet) {
|
||||
SpecifyUploadBottomSheet(isPresented: $viewModel.showSpecifyUploadSheet) { option in
|
||||
Task {
|
||||
await viewModel.onSpecifyUploadOptionSelected(
|
||||
option,
|
||||
api: travelAlbumAPI,
|
||||
ossService: ossUploadService,
|
||||
scenicID: accountContext.currentScenic?.id ?? 0
|
||||
scenicID: scenicID
|
||||
)
|
||||
}
|
||||
)) {
|
||||
Text(WiredCameraTransferViewModel.modeLiveCapture).tag(WiredCameraTransferViewModel.modeLiveCapture)
|
||||
Text(WiredCameraTransferViewModel.modePostShootTransfer).tag(WiredCameraTransferViewModel.modePostShootTransfer)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.presentationDetents([.height(340)])
|
||||
.presentationDragIndicator(.hidden)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
.background(Color.white)
|
||||
}
|
||||
|
||||
private var connectionBadge: some View {
|
||||
VStack(spacing: 4) {
|
||||
Circle()
|
||||
.stroke(connectionColor, lineWidth: 3)
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay {
|
||||
Image(systemName: "camera.fill")
|
||||
.foregroundStyle(connectionColor)
|
||||
}
|
||||
Text(connectionText)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: 72)
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionColor: Color {
|
||||
switch viewModel.connectionState {
|
||||
case .connected: AppDesign.success
|
||||
case .error: Color.red
|
||||
case .searching, .connecting: AppDesign.warning
|
||||
case .disconnected: AppDesign.placeholder
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionText: String {
|
||||
switch viewModel.connectionState {
|
||||
case .connected(let name):
|
||||
name
|
||||
default:
|
||||
viewModel.connectionState.displayText
|
||||
}
|
||||
}
|
||||
|
||||
private var tabSection: some View {
|
||||
let counts = viewModel.photos.transferTabCounts
|
||||
return HStack(spacing: 0) {
|
||||
ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in
|
||||
Button {
|
||||
viewModel.selectedTabIndex = index
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedTabIndex == index ? .semibold : .regular))
|
||||
Text("\(counts[index])")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.sheet(isPresented: $viewModel.showHelpDoc) {
|
||||
NavigationStack {
|
||||
WiredTransferHelpDocView(url: WiredCameraTransferViewModel.helpDocURL)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("关闭") { viewModel.showHelpDoc = false }
|
||||
}
|
||||
}
|
||||
.foregroundStyle(viewModel.selectedTabIndex == index ? AppDesign.primary : AppDesign.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(viewModel.selectedTabIndex == index ? AppDesign.primarySoft : Color.clear)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.background(Color.white)
|
||||
}
|
||||
|
||||
private var photoListSection: some View {
|
||||
let visiblePhotos = viewModel.photos.filtered(byTabIndex: viewModel.selectedTabIndex)
|
||||
return ScrollView {
|
||||
if visiblePhotos.isEmpty {
|
||||
AppContentUnavailableView("暂无照片", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity, minHeight: 260)
|
||||
} else {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(visiblePhotos) { photo in
|
||||
photoRow(photo)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.sheet(item: previewBinding) { item in
|
||||
WiredTransferPhotoPreviewSheet(
|
||||
imageSources: item.sources,
|
||||
startIndex: item.startIndex,
|
||||
onDismiss: { previewSources = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func photoRow(_ photo: WiredTransferPhotoItem) -> some View {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.selectUploadMode, photo.canSelectForSpecifyUpload {
|
||||
Button {
|
||||
viewModel.togglePhotoSelection(id: photo.id)
|
||||
} label: {
|
||||
Image(systemName: viewModel.selectedPhotoIDs.contains(photo.id) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(viewModel.selectedPhotoIDs.contains(photo.id) ? AppDesign.primary : AppDesign.placeholder)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
private var visiblePhotos: [WiredTransferPhotoItem] {
|
||||
viewModel.visiblePhotos
|
||||
}
|
||||
|
||||
thumbnailView(for: photo)
|
||||
.frame(width: 64, height: 64)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
private var sidebarGroups: [WiredTransferDateGroup] {
|
||||
visiblePhotos.buildSidebarGroups()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(photo.fileName)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
.lineLimit(1)
|
||||
Text(photo.capturedAt)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
HStack {
|
||||
Text(photo.status.displayLabel)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(statusColor(photo.status))
|
||||
if photo.status == .uploading || photo.status == .transferring {
|
||||
Text("\(photo.progress)%")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
private var photoSections: [WiredTransferPhotoSection] {
|
||||
visiblePhotos.buildPhotoSections()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if photo.status == .failed {
|
||||
Button("重试") {
|
||||
Task { await viewModel.retryPhoto(id: photo.id) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
private var selectAllBar: some View {
|
||||
let pendingIDs = visiblePhotos.selectablePendingPhotoIDs
|
||||
let allSelected = !pendingIDs.isEmpty && pendingIDs.isSubset(of: viewModel.selectedPhotoIDs)
|
||||
return WiredTransferSelectAllBar(
|
||||
selectedCount: viewModel.selectedPhotoIDs.count,
|
||||
selectableCount: pendingIDs.count,
|
||||
allSelected: allSelected,
|
||||
onToggleSelectAll: viewModel.toggleSelectAllPendingInVisible
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func thumbnailView(for photo: WiredTransferPhotoItem) -> 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 {
|
||||
placeholderThumbnail
|
||||
}
|
||||
private var contentSection: some View {
|
||||
if visiblePhotos.isEmpty {
|
||||
WiredTransferEmptyState()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
RemoteImage(urlString: photo.thumbnailURL) {
|
||||
placeholderThumbnail
|
||||
}
|
||||
WiredTransferPhotoListPanel(
|
||||
sidebarExpanded: viewModel.sidebarExpanded,
|
||||
sidebarGroups: sidebarGroups,
|
||||
photoSections: photoSections,
|
||||
selectedTimeSlotID: viewModel.selectedTimeSlotID,
|
||||
selectUploadMode: viewModel.selectUploadMode,
|
||||
selectedPhotoIDs: viewModel.selectedPhotoIDs,
|
||||
onToggleSidebar: viewModel.toggleSidebar,
|
||||
onTimeSlotSelected: viewModel.selectTimeSlot,
|
||||
onPhotoClick: openPhotoPreview,
|
||||
onRetry: { id in Task { await viewModel.retryPhoto(id: id) } },
|
||||
onDelete: viewModel.deletePhoto,
|
||||
onTogglePhotoSelection: viewModel.togglePhotoSelection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholderThumbnail: some View {
|
||||
Color(hex: 0xE5E7EB)
|
||||
.overlay {
|
||||
Image(systemName: "photo")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
private var previewBinding: Binding<PreviewPayload?> {
|
||||
Binding(
|
||||
get: {
|
||||
guard let previewSources else { return nil }
|
||||
return PreviewPayload(sources: previewSources, startIndex: previewStartIndex)
|
||||
},
|
||||
set: { newValue in
|
||||
if newValue == nil { previewSources = nil }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func statusColor(_ status: WiredTransferUploadStatus) -> Color {
|
||||
switch status {
|
||||
case .uploaded: AppDesign.success
|
||||
case .failed: Color.red
|
||||
case .uploading, .transferring: AppDesign.warning
|
||||
case .pending: AppDesign.textSecondary
|
||||
private func openPhotoPreview(photoID: String) {
|
||||
let entries = visiblePhotos.compactMap { photo -> String? in
|
||||
let url = viewModel.previewURL(for: photo.id)
|
||||
return url.isEmpty ? nil : url
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button("同步历史") {
|
||||
Task { await viewModel.syncExistingPhotos() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("批量上传") {
|
||||
Task { await viewModel.batchUploadAll() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("指定上传") {
|
||||
viewModel.selectUploadMode = true
|
||||
viewModel.showSpecifyUploadSheet = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
guard let startIndex = visiblePhotos.firstIndex(where: { $0.id == photoID }),
|
||||
!viewModel.previewURL(for: photoID).isEmpty else {
|
||||
toastCenter.show("暂无法预览该照片")
|
||||
return
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
.background(Color.white)
|
||||
let mappedIndex = visiblePhotos.prefix(startIndex + 1).filter {
|
||||
!viewModel.previewURL(for: $0.id).isEmpty
|
||||
}.count - 1
|
||||
previewStartIndex = max(mappedIndex, 0)
|
||||
previewSources = entries
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览 Sheet 载荷。
|
||||
private struct PreviewPayload: Identifiable {
|
||||
let id = UUID()
|
||||
let sources: [String]
|
||||
let startIndex: Int
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user