Files
suixinkan_ios_new/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift
汉秋 be9c844bd3 将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。
恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 11:22:07 +08:00

267 lines
9.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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,
isCameraConnected: viewModel.isCameraConnected,
onBatchUploadClick: {
Task {
await viewModel.onBatchUploadButtonClick(
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: scenicID
)
}
},
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick,
onHistoryImportClick: viewModel.onHistoryImportClick
)
}
.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 }
}
}
}
}
.fullScreenCover(isPresented: $viewModel.showHistoryImport) {
if let importVM = viewModel.makeHistoryImportViewModel(
accountKey: accountContext.accountCachePrefix ?? "guest_"
) {
CameraHistoryImportView(viewModel: importVM)
}
}
.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<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 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
}