// // 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。 @MainActor final class TravelAlbumDetailViewModel: ObservableObject { @Published var album: TravelAlbumItem? @Published var materials: [TravelAlbumMaterial] = [] @Published var isLoading = false @Published var isDeleting = false @Published var errorMessage: String? /// 加载相册详情与素材。 func load(api: any TravelAlbumServing, albumID: Int) async { isLoading = true defer { isLoading = false } do { async let info = api.albumInfo(id: albumID) async let list = api.materialList(userEquityTravelId: albumID, page: 1, pageSize: 100) album = try await info materials = try await list.list errorMessage = nil } catch { errorMessage = error.localizedDescription } } /// 删除素材。 func deleteMaterial(api: any TravelAlbumServing, materialID: Int, albumID: Int) async { do { try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: materialID)) await load(api: api, albumID: albumID) } catch { errorMessage = error.localizedDescription } } /// 删除相册。 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 } } } /// 有线传图 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")! @Published var photos: [WiredTransferPhotoItem] = [] @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 deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot() @Published var connectionState: CameraConnectionState = .disconnected @Published var errorMessage: String? let context: WiredTransferContext private let pipeline: CameraTransferPipeline private var photoStore: WiredTransferPhotoStore? private var userIDProvider: (() -> String)? private var sessionNewPhotoIDs: Set = [] private var wasConnected = false private var disconnectAlertTask: Task? /// 初始化有线传图 ViewModel。 init( context: WiredTransferContext, cameraService: any CameraServiceProtocol ) { self.context = context self.pipeline = CameraTransferPipeline(cameraService: cameraService) pipeline.onConnectionStateChange = { [weak self] state in Task { @MainActor in self?.handleConnectionStateChange(state) } } pipeline.onTasksUpdated = { [weak self] tasks in Task { @MainActor in self?.applyPipelineTasks(tasks) } } } /// 配置上传 Sink 并启动相机连接。 func start( api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int, accountContext: AccountContext ) async { if photoStore == nil { photoStore = WiredTransferPhotoStore( accountPrefixProvider: { accountContext.accountCachePrefix ?? "guest_" }, userIDProvider: { accountContext.profile?.userId ?? "" } ) userIDProvider = { accountContext.profile?.userId ?? "" } } let uploader = TravelAlbumMaterialUploader( api: api, ossService: ossService, albumID: context.albumId, scenicID: scenicID ) let autoUpload = transferModeOption == Self.modeLiveCapture pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload) refreshDeviceStorageInfo() loadPersistedPhotos() await pipeline.connect() connectionState = pipeline.connectionState wasConnected = connectionState.isConnected } /// 断开相机连接。 func stop() async { disconnectAlertTask?.cancel() await pipeline.disconnect() } /// 刷新设备存储信息。 func refreshDeviceStorageInfo() { deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot() } /// 切换 Tab。 func selectTab(_ index: Int) { selectedTabIndex = index selectedTimeSlotID = nil 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 let autoUpload = option == Self.modeLiveCapture let uploader = TravelAlbumMaterialUploader( api: api, ossService: ossService, albumID: context.albumId, scenicID: scenicID ) pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload) } /// 清除错误提示。 func clearErrorMessage() { errorMessage = nil } /// 刷新相机文件列表。 func refreshCameraFiles() async { guard connectionState.isConnected, !isRefreshingCameraFiles else { return } isRefreshingCameraFiles = true defer { isRefreshingCameraFiles = false } await pipeline.syncExistingPhotos() } /// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。 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) } } /// 当前 Tab 可见照片。 var visiblePhotos: [WiredTransferPhotoItem] { photos.filtered(byTabIndex: selectedTabIndex) } /// 预览 URL。 func previewURL(for photoID: String) -> String { guard let photo = photos.first(where: { $0.id == photoID }) else { return "" } if !photo.thumbnailURL.isEmpty { return photo.thumbnailURL } return "" } /// 批量上传所有待上传照片。 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 pipeline.uploadAssets(withIDs: 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 pipeline.uploadAssets(withIDs: pendingIDs) } /// 上传选中照片。 func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID) await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs)) exitSelectUploadMode() } /// 重试单张照片上传。 func retryPhoto(id: String) async { await pipeline.uploadAssets(withIDs: [id]) } /// 删除本地照片记录。 func deletePhoto(id: String) { photoStore?.remove(albumID: context.albumId, photoID: id) photos.removeAll { $0.id == id } } /// 切换照片选中状态。 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) } private func syncSelectionWithVisiblePhotos() { let visibleIDs = Set(visiblePhotos.map(\.id)) selectedPhotoIDs = selectedPhotoIDs.intersection(visibleIDs) } private func handleConnectionStateChange(_ state: CameraConnectionState) { if wasConnected, !state.isConnected { 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 } let records = photoStore.load(albumID: context.albumId) photos = records.map { $0.toPhotoItem() } } private func applyPipelineTasks(_ tasks: [CameraTransferTask]) { guard let photoStore, let userIDProvider else { return } let userID = userIDProvider() var merged = photos.filter { photo in !tasks.contains { $0.assetID == photo.id || $0.filename == photo.fileName } } for task in tasks { guard belongsToCurrentAlbum(task.assetID) else { continue } let status: WiredTransferUploadStatus switch task.status { case .pending, .downloading: status = .transferring case .downloaded: status = .pending case .uploading: status = .uploading case .uploaded: status = .uploaded case .failed: status = .failed } let capturedAt = Self.captureTimestampFormatter.string(from: Date()) let storedLocalPath = task.localPath ?? "" let localURL = task.localURL let fileSizeBytes: Int64 if let localURL { fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64) ?? 0 } else { fileSizeBytes = 0 } let thumbnailURL = localURL.map(\.absoluteString) ?? "" let item = WiredTransferPhotoItem( id: task.assetID, fileName: task.filename, thumbnailURL: thumbnailURL, capturedAt: capturedAt, fileSizeText: WiredTransferPhotoRecord.formatFileSize(fileSizeBytes), status: status, progress: task.progress, errorMessage: task.errorMessage ) merged.insert(item, at: 0) sessionNewPhotoIDs.insert(task.assetID) guard !userID.isEmpty else { continue } let record = item.toRecord( albumId: context.albumId, userId: userID, localPath: storedLocalPath, fileSizeBytes: fileSizeBytes, remoteURL: task.remoteURL ?? "" ) var records = photoStore.load(albumID: context.albumId) records.removeAll { $0.id == record.id } records.insert(record, at: 0) photoStore.save(albumID: context.albumId, records: records) } photos = merged.sorted { $0.capturedAt > $1.capturedAt } } /// 判断照片是否归属当前相册;未绑定的会话新拍默认归属当前页。 private func belongsToCurrentAlbum(_ photoID: String) -> Bool { guard let bound = photoStore?.albumID(forPhoto: photoID) else { return true } return bound == context.albumId } private static let captureTimestampFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" return formatter }() }