// // TravelAlbumViewModels.swift // suixinkan // // Created by Codex on 2026/6/29. // import Foundation import Combine /// 旅拍相册入口 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 = "拍完再传" @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 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 = [] /// 初始化有线传图 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?.connectionState = 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) loadPersistedPhotos() await pipeline.connect() } /// 断开相机连接。 func stop() async { await pipeline.disconnect() } /// 切换传输模式。 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 batchUploadAll() async { let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id) await pipeline.uploadAssets(withIDs: pendingIDs) } /// 上传选中照片。 func uploadSelected() async { await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs)) selectUploadMode = false selectedPhotoIDs.removeAll() } /// 重试单张照片上传。 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) { if selectedPhotoIDs.contains(id) { selectedPhotoIDs.remove(id) } else { selectedPhotoIDs.insert(id) } } /// 同步相机历史照片。 func syncExistingPhotos() async { await pipeline.syncExistingPhotos() } /// 重试失败上传。 func retryFailedUploads() async { await pipeline.retryFailedUploads() } 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() guard !userID.isEmpty else { return } 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 localPath = task.localPath ?? "" let fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0 let thumbnailURL = localPath.isEmpty ? "" : "file://\(localPath)" 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) let record = item.toRecord( albumId: context.albumId, userId: userID, localPath: localPath, 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 { if let bound = photoStore?.albumID(forPhoto: photoID) { return bound == context.albumId } return sessionNewPhotoIDs.contains(photoID) } private static let captureTimestampFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" return formatter }() }