// // WiredCameraTransferViewModel.swift // suixinkan // import Foundation /// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。 @MainActor final class WiredCameraTransferViewModel { let albumId: Int let albumTitle: String let headerPhone: String let scenicSpotLabel: String? private(set) var connectionState: ConnectionState = .idle { didSet { notifyStateChanged() } } private(set) var photos: [TravelAlbumOTGPhotoItem] = [] { didSet { notifyStateChanged() } } private(set) var selectedTab: TravelAlbumOTGTransferTab = .all { didSet { notifyStateChanged() } } private(set) var selectedPhotoIds: Set = [] { didSet { notifyStateChanged() } } private(set) var selectUploadMode = false { didSet { notifyStateChanged() } } private(set) var sonyMTPHint: String? private(set) var isContentCatalogReady = false let retouchOption = "不修图" private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg private(set) var transferModeOption = "边拍边传" var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onLiveShotSaved: ((String) -> Void)? private let connectionManager: any WiredCameraConnectionManaging private let storage: TravelAlbumOTGPhotoStore private let repository: TravelAlbumOTGPhotoRepository private let uploader: any TravelAlbumOTGUploading private let appStore: AppStore private var currentDriver: CameraDriver? private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:] private var deletedPhotoIds: Set = [] private var isUploading = false @MainActor init( albumId: Int, albumTitle: String, headerPhone: String, scenicSpotLabel: String? = nil, connectionManager: (any WiredCameraConnectionManaging)? = nil, storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), uploader: (any TravelAlbumOTGUploading)? = nil, appStore: AppStore = .shared ) { self.albumId = albumId self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle self.headerPhone = headerPhone self.scenicSpotLabel = scenicSpotLabel self.connectionManager = connectionManager ?? ConnectionManager.shared self.storage = storage self.repository = TravelAlbumOTGPhotoRepository(storage: storage) self.uploader = uploader ?? TravelAlbumOTGUploader() self.appStore = appStore } /// 相机状态文案。 var cameraStatusText: String { switch connectionState { case .idle, .disconnected: return "未连接 USB" case .searching: return "正在搜索" case .connecting: return "正在连接" case .connected: return "已连接" case .failed: return "连接失败" } } /// 操作按钮文案。 var actionButtonText: String { switch connectionState { case .idle, .disconnected, .failed: return "重新连接" case .searching, .connecting: return "等待连接" case .connected: return "刷新" } } /// 设备名称。 var deviceModelName: String { switch connectionState { case .connecting(let name), .connected(_, let name): return name default: return "设备存储" } } /// 当前设备容量占位文案。 var availableStorageText: String { "-- GB 可用" } /// Tab 统计。 var tabCounts: [Int] { [ photos.count, photos.filter { $0.status == .uploaded }.count, photos.filter { $0.status == .failed }.count, ] } /// 过滤后的照片列表。 func filteredPhotos() -> [TravelAlbumOTGPhotoItem] { switch selectedTab { case .all: return photos case .uploaded: return photos.filter { $0.status == .uploaded } case .failed: return photos.filter { $0.status == .failed } } } /// 绑定连接管理器并启动发现。 func start() { connectionManager.configureLiveTransfer(albumID: albumId) connectionManager.delegate = self syncFromConnectionManager() loadPersistedPhotos() connectionManager.start() } /// 页面消失时解绑 UI delegate,保留底层设备缓存。 func stop() { connectionManager.unbindDelegate() } /// 主动断开相机连接。 func disconnect() { connectionManager.disconnect() } /// 刷新相机文件列表或重新连接。 func refreshCameraFiles() { switch connectionState { case .connected: loadPersistedPhotos() case .idle, .disconnected, .failed: connectionManager.start() default: break } } /// 重新加载当前相册本地 OTG 照片。 func reloadLocalPhotos() { loadPersistedPhotos() } /// 切换 Tab。 func selectTab(_ tab: TravelAlbumOTGTransferTab) { selectedTab = tab selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id))) } /// 切换传输模式。 func selectTransferMode(_ option: String) { guard option == "边拍边传" || option == "拍后传输" else { return } transferModeOption = option } /// 切换上传格式。 func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) { guard photoFormatOption != option else { return } photoFormatOption = option applyMergedPhotos() } /// 批量上传按钮:首次进入选择模式,再次上传选中照片。 func onBatchUploadButtonClick() { if !selectUploadMode { selectUploadMode = true selectedPhotoIds = [] return } if selectedPhotoIds.isEmpty { selectUploadMode = false return } uploadSelectedTransferPhotos() } /// 指定上传:默认上传所有未上传照片。 func onSpecifyUploadButtonClick() { let ids = Set(photos.filter(\.isNotUploaded).map(\.id)) startUploadByIds(ids, emptyMessage: "暂无未上传的照片") } /// 创建相机历史照片选择导入页 ViewModel。 func makeCameraImportViewModel() -> TravelAlbumCameraImportViewModel? { guard isContentCatalogReady else { showMessage("相机照片目录尚未就绪,请稍候再试") return nil } guard let currentDriver else { showMessage("请先连接相机") return nil } if case .connected = connectionState { return TravelAlbumCameraImportViewModel( albumId: albumId, albumTitle: albumTitle, driver: currentDriver, storage: storage, appStore: appStore ) } showMessage("请先连接相机") return nil } /// 选择或取消选择照片。 func toggleTransferPhotoSelection(photoId: String) { guard selectUploadMode, photos.first(where: { $0.id == photoId })?.canSelectForUpload == true else { return } if selectedPhotoIds.contains(photoId) { selectedPhotoIds.remove(photoId) } else { selectedPhotoIds.insert(photoId) } } /// 上传选中照片。 func uploadSelectedTransferPhotos() { let ids = selectedPhotoIds guard !ids.isEmpty else { showMessage("请先选择照片") return } selectUploadMode = false selectedPhotoIds = [] startUploadByIds(ids, emptyMessage: "所选照片不可上传") } /// 重试上传单张照片。 func retryPhoto(photoId: String) { startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传") } /// 删除单张本地照片。 func deletePhoto(photoId: String) { storage.remove(albumId: albumId, photoId: photoId) deletedPhotoIds.insert(photoId) persistedRecordsById.removeValue(forKey: photoId) selectedPhotoIds.remove(photoId) applyMergedPhotos() showMessage("已删除") } /// 清空当前相册本地 OTG 缓存。 func clearLocalAlbumCache() { storage.clearAlbum(albumId: albumId) persistedRecordsById = [:] photos = [] } private func syncFromConnectionManager() { currentDriver = connectionManager.currentDriver isContentCatalogReady = connectionManager.isContentCatalogReady connectionState = connectionManager.state } private func loadPersistedPhotos() { deletedPhotoIds = storage.loadDeletedIds(albumId: albumId) let records = storage.load(albumId: albumId) persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) applyMergedPhotos() } private func applyMergedPhotos() { let persistedItems = persistedRecordsById.values .filter { !deletedPhotoIds.contains($0.id) } .map { $0.toPhotoItem() } photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt } } private func startUploadByIds(_ ids: Set, emptyMessage: String) { guard !isUploading else { showMessage("正在上传,请稍候") return } let targets = resolveUploadTargets(ids) guard !targets.isEmpty else { showMessage(emptyMessage) return } isUploading = true showMessage("开始上传 \(targets.count) 张照片") Task { for id in targets { await uploadPhoto(id: id) } isUploading = false notifyStateChanged() } } private func resolveUploadTargets(_ ids: Set) -> [String] { ids.filter { persistedRecordsById[$0] != nil } } private func uploadPhoto(id: String) async { do { let record = try await localRecordForUpload(id: id) updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) let material = try await uploader.upload( record: record, scenicId: appStore.currentScenicId ) { [weak self] progress in self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) } updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl) } catch { updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) showMessage(error.localizedDescription) } } private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord { if let record = persistedRecordsById[id], !record.localPath.isEmpty, FileManager.default.fileExists(atPath: record.localPath) { return record } throw TravelAlbumOTGUploadError.localFileMissing } private func updateRecord( id: String, status: TravelAlbumOTGUploadStatus, progress: Int, error: String?, remoteUrl: String? = nil ) { var record = persistedRecordsById[id] if record == nil, let item = photos.first(where: { $0.id == id }) { record = TravelAlbumOTGPhotoRecord( id: item.id, sourceId: item.sourceId, fileName: item.fileName, localPath: item.localPath, thumbnailPath: item.thumbnailURL?.isFileURL == true ? item.thumbnailURL?.path ?? "" : "", capturedAt: item.capturedAt, fileSizeBytes: item.fileSizeBytes, status: status, progress: progress, errorMessage: error, albumId: albumId, userId: appStore.userId, remoteUrl: remoteUrl ?? item.remoteUrl ) } guard var record else { return } record.status = status record.progress = min(max(progress, 0), 100) record.errorMessage = error if let remoteUrl { record.remoteUrl = remoteUrl } record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) persistedRecordsById[id] = record storage.upsert(record, albumId: albumId) applyMergedPhotos() } private func notifyStateChanged() { if Thread.isMainThread { onStateChange?() } else { DispatchQueue.main.async { [weak self] in self?.onStateChange?() } } } private func showMessage(_ message: String) { if Thread.isMainThread { onShowMessage?(message) } else { DispatchQueue.main.async { [weak self] in self?.onShowMessage?(message) } } } } extension WiredCameraTransferViewModel: ConnectionManagerDelegate { func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) { connectionState = state } func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) { currentDriver = driver } func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) { currentDriver = driver isContentCatalogReady = true notifyStateChanged() } func connectionManager(_ manager: ConnectionManager, didFail error: any Error) { showMessage(error.localizedDescription) } func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) { showMessage("长时间未找到相机,请尝试重新插拔连接线") } func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) { guard albumID == albumId else { return } loadPersistedPhotos() onLiveShotSaved?(filename) } }