// // TravelAlbumViewModels.swift // suixinkan // // Created by Codex on 2026/6/29. // import Foundation import Combine import UIKit /// 旅拍相册入口 ViewModel,负责列表、创建任务与小程序码。 @MainActor final class TravelAlbumEntryViewModel: ObservableObject { static let modePreShoot = 1 static let modePreOrder = 2 static let apiTypePreShoot = 1 static let apiTypePreOrder = 2 @Published var albums: [TravelAlbumItem] = [] @Published var total = 0 @Published var isLoading = false @Published var isCreating = false @Published var showCreateSheet = false @Published var bindableOrders: [TravelAlbumAvailableOrder] = [] @Published var codeSheet: TravelAlbumCodeSheetState? @Published var errorMessage: String? /// 加载相册列表。 func loadAlbums(api: any TravelAlbumServing) async { isLoading = true defer { isLoading = false } do { let payload = try await api.albumList(page: 1, pageSize: 20) albums = payload.list total = payload.total errorMessage = nil } catch { errorMessage = error.localizedDescription } } /// 打开创建弹窗并加载可绑定订单。 func openCreateSheet(api: any TravelAlbumServing, scenicSelected: Bool) async { guard !isCreating else { return } guard scenicSelected else { errorMessage = "请先选择景区" return } showCreateSheet = true await loadBindableOrders(api: api) } /// 加载可绑定订单。 func loadBindableOrders(api: any TravelAlbumServing) async { do { bindableOrders = try await api.availableOrders() } catch { errorMessage = error.localizedDescription } } /// 确认创建旅拍相册任务。 func confirmCreateTask( api: any TravelAlbumServing, mode: Int, freeCount: String, singlePrice: String, packagePrice: String, order: TravelAlbumAvailableOrder?, scenicSelected: Bool ) async -> Bool { guard !isCreating else { return false } guard scenicSelected else { errorMessage = "请先选择景区" return false } let apiType = mode == Self.modePreOrder ? Self.apiTypePreOrder : Self.apiTypePreShoot let albumName: String if mode == Self.modePreOrder { albumName = order?.projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? order!.projectName : Self.buildTodayAlbumName(existingNames: albums.map(\.name)) } else { albumName = Self.buildTodayAlbumName(existingNames: albums.map(\.name)) } if mode == Self.modePreOrder, order == nil { errorMessage = "请选择绑定订单" return false } if mode == Self.modePreShoot, singlePrice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { errorMessage = "请输入单张照片价格" return false } let materialPrice = Double(singlePrice.trimmingCharacters(in: .whitespacesAndNewlines)) if mode == Self.modePreShoot, materialPrice == nil { errorMessage = "请输入有效的单张照片价格" return false } let request: TravelAlbumCreateRequest if apiType == Self.apiTypePreShoot { request = TravelAlbumCreateRequest( name: albumName, type: apiType, orderNumber: nil, materialNum: Int(freeCount) ?? 0, materialPrice: materialPrice, materialPackagePrice: Double(packagePrice) ?? 0, photoPrice: 0 ) } else { request = TravelAlbumCreateRequest( name: albumName, type: apiType, orderNumber: order?.orderNumber, materialNum: nil, materialPrice: nil, materialPackagePrice: nil, photoPrice: nil ) } isCreating = true defer { isCreating = false } do { _ = try await api.createAlbum(request) showCreateSheet = false await loadAlbums(api: api) errorMessage = nil return true } catch { let message = error.localizedDescription if message.contains("订单已关联") { await loadBindableOrders(api: api) } errorMessage = message.isEmpty ? "创建失败" : message return false } } /// 打开小程序码弹窗。 func openAlbumCode(api: any TravelAlbumServing, album: TravelAlbumItem) async { codeSheet = TravelAlbumCodeSheetState(albumID: album.id, albumName: album.name, isLoading: true, qrImageURL: "") do { let response = try await api.mpCode(id: album.id) codeSheet?.qrImageURL = response.mpCodeOssURL.trimmingCharacters(in: .whitespacesAndNewlines) codeSheet?.isLoading = false } catch { codeSheet?.isLoading = false errorMessage = error.localizedDescription } } /// 生成当日相册名称后缀。 static func buildTodayAlbumName(existingNames: [String]) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" let prefix = formatter.string(from: Date()) let todayNames = existingNames.filter { $0.hasPrefix(prefix) } let nextIndex = todayNames.count + 1 return String(format: "%@-%03d", prefix, nextIndex) } } /// 小程序码弹窗状态。 struct TravelAlbumCodeSheetState: Identifiable { let albumID: Int let albumName: String var isLoading: Bool var qrImageURL: String var id: Int { albumID } } /// 旅拍相册详情 ViewModel,对齐 Android TravelAlbumDetailViewModel。 @MainActor final class TravelAlbumDetailViewModel: ObservableObject { static let tabAll = 0 static let tabPurchased = 1 static let materialStatusUnpurchased = 1 static let sortOptions: [(value: Int, label: String)] = [ (1, "创建时间正序"), (2, "创建时间倒序"), (3, "文件名正序"), (4, "文件名倒序") ] @Published var album: TravelAlbumItem? @Published var materials: [TravelAlbumMaterial] = [] @Published var allPhotoCount = 0 @Published var selectedTab = tabAll @Published var orderBy = 2 @Published var isSelectionMode = false @Published var selectedMaterialIDs: Set = [] @Published var isLoading = false @Published var isDeleting = false @Published var showDeleteAlbumConfirm = false @Published var showDeleteMaterialConfirm = false @Published var errorMessage: String? private var albumID = 0 private var currentPage = 1 private var canLoadMore = false private var isLoadingMore = false private static let pageSize = 30 private static let loadMoreThreshold = 6 private static let purchasedFilterYes = 1 /// 刷新相册详情、总数与素材列表。 func refreshAll(api: any TravelAlbumServing, albumID: Int) async { self.albumID = albumID async let infoTask: Void = loadAlbumInfo(api: api, albumID: albumID, showPageLoading: album == nil) async let countTask: Void = loadAllPhotoCount(api: api, albumID: albumID) async let materialsTask: Void = loadMaterials(api: api, albumID: albumID, reset: true) _ = await (infoTask, countTask, materialsTask) } /// 加载相册详情与素材(兼容旧调用)。 func load(api: any TravelAlbumServing, albumID: Int) async { await refreshAll(api: api, albumID: albumID) } /// 切换 Tab 并重载素材。 func selectTab(_ tab: Int, api: any TravelAlbumServing) async { guard selectedTab != tab else { return } selectedTab = tab isSelectionMode = false selectedMaterialIDs = [] await loadMaterials(api: api, albumID: albumID, reset: true) } /// 设置排序并重载素材。 func setOrderBy(_ value: Int, api: any TravelAlbumServing) async { guard orderBy != value else { return } orderBy = value await loadMaterials(api: api, albumID: albumID, reset: true) } /// 切换批量选择模式(仅全部照片 Tab)。 func toggleSelectionMode() { guard selectedTab == Self.tabAll else { return } isSelectionMode.toggle() if !isSelectionMode { selectedMaterialIDs = [] } } /// 切换素材选中状态。 func toggleMaterialSelection(_ material: TravelAlbumMaterial) { guard isSelectionMode else { return } guard material.status == Self.materialStatusUnpurchased else { errorMessage = "仅未购买素材可删除" return } if selectedMaterialIDs.contains(material.id) { selectedMaterialIDs.remove(material.id) } else { selectedMaterialIDs.insert(material.id) } } /// 滚动接近底部时加载更多。 func loadMoreIfNeeded(lastVisibleIndex: Int, api: any TravelAlbumServing) async { guard lastVisibleIndex >= materials.count - Self.loadMoreThreshold else { return } await loadMaterials(api: api, albumID: albumID, reset: false) } /// 请求删除选中素材确认。 func requestDeleteSelectedMaterials() { guard !selectedMaterialIDs.isEmpty else { errorMessage = "请选择要删除的素材" return } showDeleteMaterialConfirm = true } /// 确认删除选中素材。 @discardableResult func confirmDeleteSelectedMaterials(api: any TravelAlbumServing) async -> Bool { let ids = Array(selectedMaterialIDs) guard !ids.isEmpty else { showDeleteMaterialConfirm = false return false } showDeleteMaterialConfirm = false isDeleting = true defer { isDeleting = false } for id in ids { do { try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: id)) } catch { errorMessage = error.localizedDescription return false } } isSelectionMode = false selectedMaterialIDs = [] await refreshAll(api: api, albumID: albumID) return true } /// 构建有线传图上下文。 func wiredTransferContext() -> WiredTransferContext? { guard let album else { return nil } return WiredTransferContext( albumId: album.id, albumName: album.name, phone: album.displayPhone, orderNumber: album.orderNumber ) } /// 删除相册。 func deleteAlbum(api: any TravelAlbumServing, albumID: Int, photoStore: WiredTransferPhotoStore) async -> Bool { isDeleting = true defer { isDeleting = false } do { try await api.deleteAlbum(TravelAlbumDeleteRequest(id: albumID)) _ = photoStore.clearAlbum(albumID: albumID) return true } catch { errorMessage = error.localizedDescription return false } } private func loadAlbumInfo(api: any TravelAlbumServing, albumID: Int, showPageLoading: Bool) async { if showPageLoading { isLoading = true } defer { if showPageLoading { isLoading = false } } do { album = try await api.albumInfo(id: albumID) errorMessage = nil } catch { errorMessage = error.localizedDescription } } private func loadAllPhotoCount(api: any TravelAlbumServing, albumID: Int) async { do { let payload = try await api.materialList( userEquityTravelId: albumID, page: 1, pageSize: 1, orderBy: orderBy, isPurchased: nil ) allPhotoCount = payload.total } catch { errorMessage = error.localizedDescription } } private func loadMaterials(api: any TravelAlbumServing, albumID: Int, reset: Bool) async { if reset { currentPage = 1 canLoadMore = false } else { guard canLoadMore, !isLoadingMore else { return } isLoadingMore = true currentPage += 1 } let isPurchasedFilter: Int? = selectedTab == Self.tabPurchased ? Self.purchasedFilterYes : nil do { let payload = try await api.materialList( userEquityTravelId: albumID, page: currentPage, pageSize: Self.pageSize, orderBy: orderBy, isPurchased: isPurchasedFilter ) if reset { materials = payload.list } else { materials.append(contentsOf: payload.list) } canLoadMore = materials.count < payload.total if selectedTab == Self.tabAll { allPhotoCount = payload.total } errorMessage = nil } catch { if !reset, currentPage > 1 { currentPage -= 1 } errorMessage = error.localizedDescription } if !reset { isLoadingMore = false } } } /// 有线传图 ViewModel,编排相机连接、传输管道与本地持久化。 @MainActor final class WiredCameraTransferViewModel: ObservableObject { static let modeLiveCapture = "边拍边传" static let modePostShootTransfer = "拍后传输" /// 当前仅支持 JPG,后续再扩展 RAW 等格式。 static let formatJPG = "JPG" static let specifyUploadAllPending = "上传所有未上传的照片" static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片" static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")! private static let manualUploadConcurrency = 3 @Published private(set) var photos: [WiredTransferPhotoItem] = [] @Published private(set) var visiblePhotos: [WiredTransferPhotoItem] = [] @Published private(set) var sidebarGroups: [WiredTransferDateGroup] = [] @Published private(set) var photoSections: [WiredTransferPhotoSection] = [] @Published private(set) var tabCounts: [Int] = [0, 0, 0] @Published var selectedTabIndex = 0 @Published var sidebarExpanded = true @Published var selectedTimeSlotID: String? @Published var selectUploadMode = false @Published var selectedPhotoIDs: Set = [] @Published var transferModeOption = modeLiveCapture @Published var showSpecifyUploadSheet = false @Published var showDeviceStorageAlert = false @Published var showCameraDisconnectedAlert = false @Published var showHelpDoc = false @Published var isRefreshingCameraFiles = false @Published var isSearchingCamera = false @Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot() @Published var connectionState: CameraConnectionState = .disconnected @Published var errorMessage: String? let context: WiredTransferContext private let pipeline = WiredCameraTransferPipeline() private var photoStore: WiredTransferPhotoStore? private var userIDProvider: (() -> String)? private var downloadScope: CameraDownloadStorage.Scope? private var sessionNewPhotoIDs: Set = [] private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:] private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:] private var lastProgressPersistDates: [String: Date] = [:] private var deletedPhotoIDs: Set = [] private var wasConnected = false private var disconnectAlertTask: Task? private var isTearingDownCamera = false /// 用户主动离开页面时为 true,避免误弹「相机已断开」警告。 private var isLeavingPage = false /// 初始化有线传图 ViewModel。 init(context: WiredTransferContext) { self.context = context } /// 配置上传 Sink 并启动相机连接。 func start( api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int, accountContext: AccountContext ) async { isLeavingPage = false pipeline.attachToUI { [weak self] tasks in Task { @MainActor in await self?.applyPipelineTasks(tasks) } } let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_" let currentDownloadScope = CameraDownloadStorage.Scope( accountKey: accountCachePrefix, albumID: context.albumId ) downloadScope = currentDownloadScope if photoStore == nil { photoStore = WiredTransferPhotoStore( accountPrefixProvider: { accountCachePrefix }, userIDProvider: { accountContext.profile?.userId ?? "" } ) userIDProvider = { accountContext.profile?.userId ?? "" } if let cachedMode = photoStore?.loadTransferModeOption() { transferModeOption = cachedMode } } let uploader = TravelAlbumMaterialUploader( api: api, ossService: ossService, albumID: context.albumId, scenicID: scenicID ) let autoUpload = transferModeOption == Self.modeLiveCapture pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope) refreshDeviceStorageInfo() loadPersistedPhotos() connectionState = .disconnected while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) } detachFromUI() } /// 页面离开:仅 detach UI 回调,保持相机连接。 func stop() async { detachFromUI() } /// 页面离开:取消 UI 回调,不关闭相机会话。 func detachFromUI() { guard !isTearingDownCamera else { return } isTearingDownCamera = true isLeavingPage = true defer { isTearingDownCamera = false } disconnectAlertTask?.cancel() showCameraDisconnectedAlert = false pipeline.detachFromUI() } /// 刷新设备存储信息。 func refreshDeviceStorageInfo() { deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot() } /// 切换 Tab。 func selectTab(_ index: Int) { selectedTabIndex = index selectedTimeSlotID = nil refreshDerivedPhotoState(rebuildGroups: true) if selectUploadMode { syncSelectionWithVisiblePhotos() } } /// 折叠/展开时间侧栏。 func toggleSidebar() { sidebarExpanded.toggle() } /// 选中时间段并滚动定位。 func selectTimeSlot(_ slotID: String) { selectedTimeSlotID = slotID } /// 打开帮助文档。 func openHelpDoc() { showHelpDoc = true } /// 点击设备存储面板。 func onDeviceUsageClick() { showDeviceStorageAlert = true } /// 切换传输模式。 func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) { guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return } transferModeOption = option photoStore?.saveTransferModeOption(option) let autoUpload = option == Self.modeLiveCapture let uploader = TravelAlbumMaterialUploader( api: api, ossService: ossService, albumID: context.albumId, scenicID: scenicID ) pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: downloadScope) } /// 清除错误提示。 func clearErrorMessage() { errorMessage = nil } /// 刷新相机文件列表(OTG 能力已移除,仅刷新本地持久化记录)。 func refreshCameraFiles() async { guard !isRefreshingCameraFiles else { return } isRefreshingCameraFiles = true defer { isRefreshingCameraFiles = false } loadPersistedPhotos() } /// 重新搜索相机(OTG 能力已移除,保持未连接状态)。 func restartCameraSearch() async { guard !isSearchingCamera else { return } isSearchingCamera = true defer { isSearchingCamera = false } connectionState = .disconnected } /// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。 func onBatchUploadButtonClick(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { if !selectUploadMode { selectUploadMode = true selectedPhotoIDs.removeAll() return } if !selectedPhotoIDs.isEmpty { await uploadSelected(api: api, ossService: ossService, scenicID: scenicID) return } exitSelectUploadMode() } /// 指定上传按钮点击。 func onSpecifyUploadButtonClick() { guard !selectUploadMode else { return } showSpecifyUploadSheet = true } /// 关闭指定上传弹层。 func dismissSpecifyUploadSheet() { showSpecifyUploadSheet = false } /// 指定上传选项确认。 func onSpecifyUploadOptionSelected(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { dismissSpecifyUploadSheet() switch option { case Self.specifyUploadAllPending: await batchUploadAll(api: api, ossService: ossService, scenicID: scenicID) case Self.specifyUploadTodayCaptured: await uploadAllTodayCaptured(api: api, ossService: ossService, scenicID: scenicID) default: break } } /// 退出勾选模式。 func exitSelectUploadMode() { selectUploadMode = false selectedPhotoIDs.removeAll() } /// 切换可见 pending 全选状态。 func toggleSelectAllPendingInVisible() { let pendingIDs = visiblePhotos.selectablePendingPhotoIDs guard !pendingIDs.isEmpty else { return } if pendingIDs.isSubset(of: selectedPhotoIDs) { selectedPhotoIDs.subtract(pendingIDs) } else { selectedPhotoIDs.formUnion(pendingIDs) } } /// 预览 URL。 func previewURL(for photoID: String) -> String { guard let photo = photos.first(where: { $0.id == photoID }) else { return "" } if !photo.previewURL.isEmpty { return photo.previewURL } return photo.thumbnailURL } /// 批量上传所有待上传照片。 func batchUploadAll(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID) let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id) guard !pendingIDs.isEmpty else { errorMessage = "暂无未上传的照片" return } await uploadPendingPhotoIDs(pendingIDs) } /// 上传今日拍摄且未上传的照片。 func uploadAllTodayCaptured(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID) let pendingIDs = photos.filter { $0.isCapturedToday() && $0.isNotUploaded }.map(\.id) guard !pendingIDs.isEmpty else { errorMessage = "暂无今日拍摄且未上传的照片" return } await uploadPendingPhotoIDs(pendingIDs) } /// 上传选中照片。 func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID) await uploadPendingPhotoIDs(Array(selectedPhotoIDs)) exitSelectUploadMode() } /// 重试单张照片上传。 func retryPhoto( id: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int ) async { guard let photo = photos.first(where: { $0.id == id }) else { return } guard photo.status == .failed || photo.status == .pending else { errorMessage = "当前状态不可重传" return } ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID) updatePhotoStatus(id: id, status: .uploading, progress: 0, errorMessage: nil) if pipeline.task(forAssetID: id) != nil { await pipeline.uploadAssets(withIDs: [id]) await refreshPhotoFromPipelineTask(id: id) return } guard let record = persistedRecord(for: id), let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath), FileManager.default.fileExists(atPath: localURL.path) else { updatePhotoStatus(id: id, status: .failed, progress: 0, errorMessage: "本地文件不存在") errorMessage = "本地文件不存在,无法重传" persistPhotoStatus(id: id, status: .failed, progress: 0, errorMessage: "本地文件不存在") return } await pipeline.retryUpload( assetID: id, filename: record.fileName, localPath: record.localPath ) await refreshPhotoFromPipelineTask(id: id) } private func uploadPendingPhotoIDs(_ ids: [String]) async { let pendingIDs = ids.filter { id in photos.first(where: { $0.id == id })?.isNotUploaded == true } var currentIndex = 0 while currentIndex < pendingIDs.count { let upperBound = min(currentIndex + Self.manualUploadConcurrency, pendingIDs.count) let batch = pendingIDs[currentIndex ..< upperBound] let uploadTasks = batch.map { id in Task { @MainActor in await self.uploadSinglePendingPhoto(id: id) } } for task in uploadTasks { await task.value } currentIndex = upperBound } } private func uploadSinglePendingPhoto(id: String) async { updatePhotoStatus(id: id, status: .uploading, progress: 0, errorMessage: nil) if pipeline.task(forAssetID: id) != nil { await pipeline.uploadAssets(withIDs: [id]) await refreshPhotoFromPipelineTask(id: id) return } guard let record = persistedRecord(for: id), let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath), FileManager.default.fileExists(atPath: localURL.path) else { let message = "本地文件不存在" updatePhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message) persistPhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message) return } await pipeline.retryUpload( assetID: id, filename: record.fileName, localPath: record.localPath ) await refreshPhotoFromPipelineTask(id: id) } /// 删除本地照片记录。 func deletePhoto(id: String) { photoStore?.remove(albumID: context.albumId, photoID: id) deletedPhotoIDs.insert(id) pipeline.removeAsset(assetID: id) persistedRecordsByID.removeValue(forKey: id) lastAppliedPipelineSignatures.removeValue(forKey: id) lastProgressPersistDates.removeValue(forKey: id) sessionNewPhotoIDs.remove(id) setPhotos(photos.filter { $0.id != id }, rebuildGroups: true) } /// 切换照片选中状态。 func togglePhotoSelection(id: String) { guard selectUploadMode else { return } guard let photo = photos.first(where: { $0.id == id }), photo.canSelectForSpecifyUpload else { return } if selectedPhotoIDs.contains(id) { selectedPhotoIDs.remove(id) } else { selectedPhotoIDs.insert(id) } } /// 同步相机历史照片。 func syncExistingPhotos() async { await refreshCameraFiles() } /// 重试失败上传。 func retryFailedUploads() async { await pipeline.retryFailedUploads() } private func ensureUploadEnabled(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) { let uploader = TravelAlbumMaterialUploader( api: api, ossService: ossService, albumID: context.albumId, scenicID: scenicID ) pipeline.configure(uploadSink: uploader, uploadEnabled: true, downloadScope: downloadScope) } private func persistedRecord(for photoID: String) -> WiredTransferPhotoRecord? { persistedRecordsByID[photoID] } private func updatePhotoStatus( id: String, status: WiredTransferUploadStatus, progress: Int, errorMessage: String? ) { guard let index = photos.firstIndex(where: { $0.id == id }) else { return } let photo = photos[index] let updated = WiredTransferPhotoItem( id: photo.id, sourceId: photo.sourceId, fileName: photo.fileName, thumbnailURL: photo.thumbnailURL, previewURL: photo.previewURL, capturedAt: photo.capturedAt, fileSizeText: photo.fileSizeText, resolutionText: photo.resolutionText, status: status, progress: progress, errorMessage: errorMessage ) replacePhoto(updated, rebuildGroups: photo.status != status) } private func refreshPhotoFromPipelineTask(id: String) async { guard let task = pipeline.task(forAssetID: id) else { return } await applyPipelineTasks([task], enforceAlbumBinding: false) } private func persistPhotoStatus( id: String, status: WiredTransferUploadStatus, progress: Int, errorMessage: String? ) { guard context.albumId > 0, let userID = userIDProvider?(), !userID.isEmpty else { return } guard let existing = persistedRecordsByID[id] else { return } let updated = WiredTransferPhotoRecord( id: existing.id, sourceId: existing.sourceId, fileName: existing.fileName, localPath: existing.localPath, thumbnailPath: existing.thumbnailPath, capturedAt: existing.capturedAt, fileSizeBytes: existing.fileSizeBytes, status: status.rawValue, progress: progress, errorMessage: errorMessage, albumId: existing.albumId, userId: existing.userId, remoteURL: existing.remoteURL, updatedAt: Date().timeIntervalSince1970 ) persistRecord(updated) } private func syncSelectionWithVisiblePhotos() { let visibleIDs = Set(visiblePhotos.map(\.id)) selectedPhotoIDs = selectedPhotoIDs.intersection(visibleIDs) } /// 替换照片列表并刷新派生 UI 数据。 func replacePhotos(_ newPhotos: [WiredTransferPhotoItem]) { setPhotos(newPhotos.sortedByCaptureTimeDescending(), rebuildGroups: true) } private func setPhotos(_ newPhotos: [WiredTransferPhotoItem], rebuildGroups: Bool) { photos = newPhotos refreshDerivedPhotoState(rebuildGroups: rebuildGroups) } private func replacePhoto(_ photo: WiredTransferPhotoItem, rebuildGroups: Bool) { var updatedPhotos = photos if let index = updatedPhotos.firstIndex(where: { $0.id == photo.id }) { updatedPhotos[index] = photo } else { updatedPhotos.insert(photo, at: 0) } if rebuildGroups { updatedPhotos = updatedPhotos.sortedByCaptureTimeDescending() } setPhotos(updatedPhotos, rebuildGroups: rebuildGroups) } private func refreshDerivedPhotoState(rebuildGroups: Bool) { tabCounts = photos.transferTabCounts let filtered = photos.filtered(byTabIndex: selectedTabIndex) visiblePhotos = filtered if rebuildGroups { sidebarGroups = filtered.buildSidebarGroups() photoSections = filtered.buildPhotoSections() return } let latestByID = Dictionary(uniqueKeysWithValues: filtered.map { ($0.id, $0) }) photoSections = photoSections.map { section in WiredTransferPhotoSection( slotId: section.slotId, headerTitle: section.headerTitle, photos: section.photos.compactMap { latestByID[$0.id] } ) }.filter { !$0.photos.isEmpty } } private func persistRecord(_ record: WiredTransferPhotoRecord) { guard let photoStore, context.albumId > 0 else { return } persistedRecordsByID[record.id] = record photoStore.save(albumID: context.albumId, records: Array(persistedRecordsByID.values)) } private func handleConnectionStateChange(_ state: CameraConnectionState) { if wasConnected, !state.isConnected, !isLeavingPage { triggerCameraDisconnectedAlert() } wasConnected = state.isConnected connectionState = state } private func triggerCameraDisconnectedAlert() { disconnectAlertTask?.cancel() showCameraDisconnectedAlert = true UINotificationFeedbackGenerator().notificationOccurred(.warning) disconnectAlertTask = Task { try? await Task.sleep(nanoseconds: 5_000_000_000) guard !Task.isCancelled else { return } showCameraDisconnectedAlert = false } } /// 相机设备名称。 var cameraDeviceName: String { if case .connected(let name) = connectionState { return name } return "" } /// 是否已连接相机。 var isCameraConnected: Bool { connectionState.isConnected } private func loadPersistedPhotos() { guard context.albumId > 0, let photoStore else { return } deletedPhotoIDs = photoStore.loadDeletedIDs(albumID: context.albumId) pipeline.setExcludedAssetIDs(deletedPhotoIDs) let records = photoStore.load(albumID: context.albumId) persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) setPhotos(records.map { $0.toPhotoItem() }.sortedByCaptureTimeDescending(), rebuildGroups: true) } private func applyPipelineTasks(_ tasks: [CameraTransferTask], enforceAlbumBinding: Bool = true) async { guard let userIDProvider else { return } let userID = userIDProvider() // 保留已有元数据,避免上传进度回调反复重置 capturedAt 导致列表分组抖动甚至崩溃。 let existingByID = Dictionary(uniqueKeysWithValues: photos.map { ($0.id, $0) }) let existingByFileName = Dictionary( photos.map { ($0.fileName, $0) }, uniquingKeysWith: { first, _ in first } ) for task in tasks { guard !deletedPhotoIDs.contains(task.assetID) else { continue } guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue } let signature = PipelineTaskSignature(task: task) guard lastAppliedPipelineSignatures[task.assetID] != signature else { continue } let status = mapPipelineStatus(task.status) let existing = existingByID[task.assetID] ?? existingByFileName[task.filename] if shouldIgnoreStalePipelineStatus(existing: existing?.status, incoming: status) { lastAppliedPipelineSignatures[task.assetID] = signature continue } let storedLocalPath = task.localPath ?? "" let localURL = task.localURL let existingRecord = persistedRecordsByID[task.assetID] let fileSizeBytes: Int64 if let existingRecord, existingRecord.fileSizeBytes > 0 { fileSizeBytes = existingRecord.fileSizeBytes } else { fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL) } let capturedAt = await resolveCapturedAt( existing: existing, taskCapturedAt: task.capturedAt, localURL: localURL ) var thumbnailPath = existingRecord?.thumbnailPath ?? "" if thumbnailPath.isEmpty, let downloadScope, let localURL, status != .transferring, let generatedPath = await CameraThumbnailGenerator.generateThumbnail( for: localURL, assetID: task.assetID, scope: downloadScope ) { thumbnailPath = generatedPath } let thumbnailURL = thumbnailURLString(thumbnailPath: thumbnailPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "") let previewURL = previewURLString(localPath: storedLocalPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "", thumbnailPath: thumbnailPath) let fileSizeText = fileSizeBytes > 0 ? WiredTransferPhotoRecord.formatFileSize(fileSizeBytes) : (existing?.fileSizeText ?? "--") let item = WiredTransferPhotoItem( id: task.assetID, sourceId: existing?.sourceId ?? task.assetID, fileName: task.filename, thumbnailURL: thumbnailURL, previewURL: previewURL, capturedAt: capturedAt, fileSizeText: fileSizeText, resolutionText: existing?.resolutionText ?? "", status: status, progress: task.progress, errorMessage: task.errorMessage ) let rebuildGroups = existing == nil || existing?.capturedAt != item.capturedAt || (selectedTabIndex != 0 && existing?.status != item.status) replacePhoto(item, rebuildGroups: rebuildGroups) sessionNewPhotoIDs.insert(task.assetID) guard !userID.isEmpty else { continue } let remoteURL = task.remoteURL ?? "" if shouldPersistPipelineUpdate( photoID: task.assetID, status: status, localPath: storedLocalPath, thumbnailPath: thumbnailPath, remoteURL: remoteURL, progress: task.progress, now: Date() ) { let record = item.toRecord( albumId: context.albumId, userId: userID, localPath: storedLocalPath, thumbnailPath: thumbnailPath, fileSizeBytes: fileSizeBytes, remoteURL: remoteURL ) persistRecord(record) } lastAppliedPipelineSignatures[task.assetID] = signature } } /// 解析展示用拍摄时间:优先保留已有值,其次使用相机时间,最后回退到本地文件或当前时间。 private func thumbnailURLString(thumbnailPath: String, remoteURL: String) -> String { if !thumbnailPath.isEmpty { let localThumbnailURL = CameraDownloadStorage.previewURLString(from: thumbnailPath) if !localThumbnailURL.isEmpty { return localThumbnailURL } } return remoteURL } private func previewURLString(localPath: String, remoteURL: String, thumbnailPath: String) -> String { if !localPath.isEmpty { let localPreviewURL = CameraDownloadStorage.previewURLString(from: localPath) if !localPreviewURL.isEmpty { return localPreviewURL } } if !remoteURL.isEmpty { return remoteURL } if !thumbnailPath.isEmpty { return CameraDownloadStorage.previewURLString(from: thumbnailPath) } return "" } private func resolveCapturedAt( existing: WiredTransferPhotoItem?, taskCapturedAt: String?, localURL: URL? ) async -> String { if let existing, !existing.capturedAt.isEmpty { return existing.capturedAt } if let taskCapturedAt, !taskCapturedAt.isEmpty { return taskCapturedAt } if let localURL { let date = await Task.detached(priority: .utility) { let attributes = try? FileManager.default.attributesOfItem(atPath: localURL.path) return (attributes?[.creationDate] as? Date) ?? (attributes?[.modificationDate] as? Date) }.value if let date { return CameraTransferTask.capturedAtString(from: date) } } return CameraTransferTask.capturedAtString(from: Date()) } /// 将管道任务状态映射为列表展示状态。 private func mapPipelineStatus(_ status: CameraTransferStatus) -> WiredTransferUploadStatus { switch status { case .pending, .downloading: return .transferring case .downloaded: return .pending case .uploading: return .uploading case .uploaded: return .uploaded case .failed: return .failed } } /// 忽略异步通知乱序产生的旧状态,避免上传中或已上传被旧快照回退成待上传。 private func shouldIgnoreStalePipelineStatus( existing: WiredTransferUploadStatus?, incoming: WiredTransferUploadStatus ) -> Bool { switch (existing, incoming) { case (.uploaded?, .pending), (.uploaded?, .transferring), (.uploaded?, .uploading), (.uploaded?, .failed): return true case (.uploading?, .pending), (.uploading?, .transferring): return true default: return false } } /// 仅在状态、路径或远程 URL 变化,或进度跨越大步长时落盘,避免高频 IO。 private func shouldPersistPipelineUpdate( photoID: String, status: WiredTransferUploadStatus, localPath: String, thumbnailPath: String, remoteURL: String, progress: Int, now: Date ) -> Bool { guard let existing = persistedRecordsByID[photoID] else { lastProgressPersistDates[photoID] = now return true } if existing.status != status.rawValue { return true } if !localPath.isEmpty, existing.localPath != localPath { return true } if !thumbnailPath.isEmpty, existing.thumbnailPath != thumbnailPath { return true } if !remoteURL.isEmpty, existing.remoteURL != remoteURL { return true } if status == .uploaded || status == .failed { return true } if abs(progress - existing.progress) >= 10 { lastProgressPersistDates[photoID] = now return true } let lastPersistDate = lastProgressPersistDates[photoID] ?? .distantPast if now.timeIntervalSince(lastPersistDate) >= 1 { lastProgressPersistDates[photoID] = now return true } return false } /// 判断照片是否归属当前相册;未绑定的会话新拍默认归属当前页。 private func belongsToCurrentAlbum(_ photoID: String) -> Bool { guard let bound = photoStore?.albumID(forPhoto: photoID) else { return true } return bound == context.albumId } } private struct PipelineTaskSignature: Equatable { let status: CameraTransferStatus let progress: Int let localPath: String? let remoteURL: String? let errorMessage: String? init(task: CameraTransferTask) { status = task.status progress = task.progress localPath = task.localPath remoteURL = task.remoteURL errorMessage = task.errorMessage } }