// // WiredCameraTransferView.swift // suixinkan // // Created by Codex on 2026/6/29. // import SwiftUI /// 有线传图页,连接相机并上传照片到旅拍相册。 struct WiredCameraTransferView: View { let context: WiredTransferContext @EnvironmentObject private var accountContext: AccountContext @Environment(\.travelAlbumAPI) private var travelAlbumAPI @Environment(\.ossUploadService) private var ossUploadService @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var scenePhase @StateObject private var viewModel: WiredCameraTransferViewModel @State private var previewSources: [String]? @State private var previewStartIndex = 0 init(context: WiredTransferContext) { self.context = context _viewModel = StateObject(wrappedValue: WiredCameraTransferViewModel(context: context)) } 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) { WiredTransferHeaderView( albumTitle: context.albumName, headerPhone: context.phone, onBack: { dismiss() } ) WiredTransferControlCard( scenicSpotLabel: scenicSpotLabel, deviceStorageInfo: viewModel.deviceStorageInfo, isCameraConnected: viewModel.isCameraConnected, isRefreshingCameraFiles: viewModel.isRefreshingCameraFiles, isSearchingCamera: viewModel.isSearchingCamera, cameraDeviceName: viewModel.cameraDeviceName, transferModeOption: viewModel.transferModeOption, tabTitles: tabTitles, tabCounts: viewModel.tabCounts, 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(WiredTransferDesign.pageBackground.ignoresSafeArea()) .toolbar(.hidden, for: .navigationBar) .task { await viewModel.start( api: travelAlbumAPI, ossService: ossUploadService, scenicID: scenicID, accountContext: accountContext ) } .onDisappear { viewModel.detachFromUI() } .onChange(of: scenePhase) { phase in guard phase == .active, !viewModel.isCameraConnected else { return } Task { await viewModel.restartCameraSearch() } } .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 .error(let message): toastCenter.show(message) default: break } } .alert("手机内存不足", isPresented: $viewModel.showDeviceStorageAlert) { Button("我知道了", role: .cancel) {} } message: { Text("请及时清理内存") } .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: scenicID ) } } .presentationDetents([.height(360)]) .presentationDragIndicator(.hidden) } .sheet(isPresented: $viewModel.showHelpDoc) { NavigationStack { WiredTransferHelpDocView(url: WiredCameraTransferViewModel.helpDocURL) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { viewModel.showHelpDoc = false } } } } } .sheet(item: previewBinding) { item in WiredTransferPhotoPreviewSheet( imageSources: item.sources, startIndex: item.startIndex, onDismiss: { previewSources = nil } ) } } private var visiblePhotos: [WiredTransferPhotoItem] { viewModel.visiblePhotos } private var sidebarGroups: [WiredTransferDateGroup] { viewModel.sidebarGroups } private var photoSections: [WiredTransferPhotoSection] { viewModel.photoSections } 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 var contentSection: some View { if visiblePhotos.isEmpty { WiredTransferEmptyState() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { 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, api: travelAlbumAPI, ossService: ossUploadService, scenicID: scenicID ) } }, onDelete: viewModel.deletePhoto, onTogglePhotoSelection: viewModel.togglePhotoSelection ) } } private var previewBinding: Binding { Binding( get: { guard let previewSources else { return nil } return PreviewPayload(sources: previewSources, startIndex: previewStartIndex) }, set: { newValue in if newValue == nil { previewSources = nil } } ) } private func openPhotoPreview(photoID: String) { let entries = visiblePhotos.compactMap { photo -> String? in let url = viewModel.previewURL(for: photo.id) return url.isEmpty ? nil : url } guard let startIndex = visiblePhotos.firstIndex(where: { $0.id == photoID }), !viewModel.previewURL(for: photoID).isEmpty else { toastCenter.show("暂无法预览该照片") return } 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 }