From 5e620623eb704dcb02eca799778866f9909595de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 30 Jun 2026 13:53:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E9=BD=90=E6=97=85=E6=8B=8D=E7=9B=B8?= =?UTF-8?q?=E5=86=8C=E8=AF=A6=E6=83=85=E9=A1=B5=20Android=20UI=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E7=BD=91=E6=A0=BC=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E4=B8=8E=E5=B8=83=E5=B1=80=E9=97=AE=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 8 +- .../Storage/CameraDownloadStorage.swift | 76 ++- .../CameraTransferPipeline.swift | 178 ++++-- .../CameraTransfer/CameraTransferTask.swift | 28 + .../TravelAlbum/API/TravelAlbumAPI.swift | 34 +- .../Models/TravelAlbumModels.swift | 39 +- .../Services/WiredTransferPhotoStore.swift | 3 + suixinkan/Features/TravelAlbum/TravelAlbum.md | 29 +- .../ViewModels/TravelAlbumViewModels.swift | 580 +++++++++++++++--- .../MiddleTruncatedText.swift | 34 + .../TravelAlbumDetailDesign.swift | 41 ++ .../TravelAlbumDetailInfoCard.swift | 57 ++ .../TravelAlbumDetailMaterialGridItem.swift | 80 +++ .../TravelAlbumDetailPhotoManageCard.swift | 153 +++++ .../Views/TravelAlbumDetailView.swift | 267 +++++--- .../Views/WiredCameraTransferView.swift | 17 +- .../WiredTransferPhotoPreviewSheet.swift | 11 +- .../WiredTransfer/WiredTransferPhotoRow.swift | 83 +-- .../CameraTransferPipelineTests.swift | 129 ++++ .../TravelAlbum/TravelAlbumAPITests.swift | 27 + .../TravelAlbumViewModelTests.swift | 141 ++++- .../WiredCameraTransferViewModelTests.swift | 261 +++++++- .../WiredTransferPhotoGroupingTests.swift | 12 + 23 files changed, 2000 insertions(+), 288 deletions(-) create mode 100644 suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/MiddleTruncatedText.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailDesign.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailInfoCard.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailMaterialGridItem.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailPhotoManageCard.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index f20a244..45a585d 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,11 +64,15 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanUITests; sourceTree = ""; }; @@ -324,14 +328,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift b/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift index 21c46bd..e4e18ab 100644 --- a/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift +++ b/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift @@ -6,6 +6,8 @@ // import Foundation +import ImageIO +import UniformTypeIdentifiers /// 相机下载文件本地存储工具,负责目录创建、相对路径持久化与安全文件名处理。 enum CameraDownloadStorage { @@ -23,9 +25,21 @@ enum CameraDownloadStorage { return directory } + /// 相机照片列表缩略图目录。 + static var thumbnailsDirectory: URL { + let directory = downloadsDirectory.appendingPathComponent("Thumbnails", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + /// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/1730_DSC.JPG`。 static func relativePath(for url: URL) -> String { - "\(cameraDownloadsFolderName)/\(url.lastPathComponent)" + let standardizedURL = url.standardizedFileURL + let thumbnailsPath = thumbnailsDirectory.standardizedFileURL.path + if standardizedURL.path.hasPrefix(thumbnailsPath) { + return "\(cameraDownloadsFolderName)/Thumbnails/\(url.lastPathComponent)" + } + return "\(cameraDownloadsFolderName)/\(url.lastPathComponent)" } /// 将持久化的相对或旧版绝对路径解析为当前沙盒内的文件 URL。 @@ -82,6 +96,13 @@ enum CameraDownloadStorage { return downloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)") } + /// 生成带 asset ID 的唯一缩略图文件 URL。 + static func uniqueThumbnailURL(for assetID: String) -> URL { + let safeName = sanitizeFilename(assetID) + .replacingOccurrences(of: ".", with: "_") + return thumbnailsDirectory.appendingPathComponent("\(safeName)_thumb.jpg") + } + /// 将相机 PTP 文件名转为 iOS 文件系统可安全使用的 ASCII 名称。 static func sanitizeFilename(_ filename: String) -> String { let trimmed = filename @@ -111,4 +132,57 @@ enum CameraDownloadStorage { } return url.absoluteString } + + /// 后台读取文件大小,避免在列表刷新路径上阻塞主线程。 + static func fileSizeBytes(for url: URL?) async -> Int64 { + guard let url else { return 0 } + return await Task.detached(priority: .utility) { + (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 + }.value + } +} + +/// 相机本地照片缩略图生成器,负责为列表生成小尺寸预览图。 +enum CameraThumbnailGenerator { + private static let maxPixelSize: CGFloat = 160 + + /// 为本地照片生成列表缩略图,成功时返回相对 Documents 的持久化路径。 + static func generateThumbnail(for imageURL: URL, assetID: String) async -> String? { + await Task.detached(priority: .utility) { + guard FileManager.default.fileExists(atPath: imageURL.path), + let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { + return nil + } + + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize + ] + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + + let destination = CameraDownloadStorage.uniqueThumbnailURL(for: assetID) + if FileManager.default.fileExists(atPath: destination.path) { + try? FileManager.default.removeItem(at: destination) + } + guard let imageDestination = CGImageDestinationCreateWithURL( + destination as CFURL, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { + return nil + } + + CGImageDestinationAddImage(imageDestination, thumbnail, [ + kCGImageDestinationLossyCompressionQuality: 0.78 + ] as CFDictionary) + + guard CGImageDestinationFinalize(imageDestination) else { return nil } + return CameraDownloadStorage.relativePath(for: destination) + }.value + } } diff --git a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift index 8372433..fdd79f6 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift @@ -16,9 +16,14 @@ final class CameraTransferPipeline { private let cameraService: any CameraServiceProtocol private var uploadSink: (any CameraAssetUploadSink)? private var uploadEnabled = true - private var isProcessing = false + private var activeUploadAssetIDs: Set = [] + private var pendingDeferredNotifyTask: Task? + private var lastDeferredNotifyDate = Date.distantPast + private var lastNotifiedProgressByAssetID: [String: Int] = [:] private var inFlightAssetIDs: Set = [] private let maxRetries = 3 + private let deferredNotifyInterval: TimeInterval = 0.15 + private let maxConcurrentUploads = 3 /// 初始化传输管道,并注入相机服务。 init(cameraService: any CameraServiceProtocol) { @@ -67,25 +72,62 @@ final class CameraTransferPipeline { tasks[index].errorMessage = nil } notify() - await processUploadQueue() + processUploadQueue() } /// 手动上传指定 asset 列表(拍完再传模式)。 func uploadAssets(withIDs assetIDs: [String]) async { - guard uploadEnabled, uploadSink != nil else { return } + guard uploadSink != nil else { return } for assetID in assetIDs { + if activeUploadAssetIDs.contains(assetID) { continue } guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue } if tasks[index].status == .uploaded { continue } if tasks[index].localURL == nil { - await downloadAsset(at: index) + await downloadAsset(assetID: assetID) } - if tasks[index].status == .downloaded || tasks[index].status == .failed { - tasks[index].status = .downloaded - await uploadTask(at: index) + guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue } + if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed { + tasks[refreshedIndex].status = .downloaded + tasks[refreshedIndex].errorMessage = nil + await uploadTask(assetID: assetID) } } } + /// 重传单张照片;若管道中无任务则根据本地相对路径恢复。 + func retryUpload(assetID: String, filename: String, localPath: String) async { + guard uploadSink != nil else { return } + guard !activeUploadAssetIDs.contains(assetID) else { return } + + if let index = tasks.firstIndex(where: { $0.assetID == assetID }) { + if tasks[index].status == .uploaded { return } + if tasks[index].localPath?.isEmpty != false, !localPath.isEmpty { + tasks[index].localPath = localPath + } + tasks[index].status = .downloaded + tasks[index].errorMessage = nil + } else { + guard !localPath.isEmpty, + let localURL = CameraDownloadStorage.resolveLocalURL(from: localPath), + FileManager.default.fileExists(atPath: localURL.path) else { + return + } + tasks.insert( + CameraTransferTask( + assetID: assetID, + filename: filename, + localPath: localPath, + capturedAt: CameraTransferTask.capturedAtString(forLocalPath: localPath), + status: .downloaded + ), + at: 0 + ) + } + + notify() + await uploadTask(assetID: assetID) + } + /// 同步相机内已有照片到任务列表(不自动上传)。 func syncExistingPhotos() async { guard cameraService.connectionState.isConnected else { return } @@ -128,28 +170,33 @@ final class CameraTransferPipeline { inFlightAssetIDs.insert(asset.id) defer { inFlightAssetIDs.remove(asset.id) } - let taskIndex: Int - if let existingIndex = tasks.firstIndex(where: { $0.assetID == asset.id }) { - taskIndex = existingIndex - tasks[taskIndex].status = .downloading - tasks[taskIndex].errorMessage = nil + let assetID = asset.id + if let existingIndex = tasks.firstIndex(where: { $0.assetID == assetID }) { + tasks[existingIndex].status = .downloading + tasks[existingIndex].errorMessage = nil } else { - let task = CameraTransferTask(assetID: asset.id, filename: asset.filename, status: .downloading) + let task = CameraTransferTask( + assetID: asset.id, + filename: asset.filename, + capturedAt: CameraTransferTask.capturedAtString(from: asset.creationDate), + status: .downloading + ) tasks.insert(task, at: 0) - taskIndex = 0 } notify() - await downloadAsset(at: taskIndex, asset: asset) + await downloadAsset(assetID: assetID, asset: asset) - if uploadEnabled, uploadSink != nil, tasks[taskIndex].status == .downloaded { - await processUploadQueue() + if uploadEnabled, + uploadSink != nil, + tasks.first(where: { $0.assetID == assetID })?.status == .downloaded { + processUploadQueue() } } - private func downloadAsset(at index: Int, asset: CameraAsset? = nil) async { - guard tasks.indices.contains(index) else { return } - let assetID = tasks[index].assetID + /// 下载指定 asset 到本地;始终按 assetID 定位任务,避免连拍时 index 失效。 + private func downloadAsset(assetID: String, asset: CameraAsset? = nil) async { + guard tasks.contains(where: { $0.assetID == assetID }) else { return } do { let resolvedAsset: CameraAsset @@ -174,31 +221,44 @@ final class CameraTransferPipeline { try FileManager.default.moveItem(at: localURL, to: destination) } + guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } tasks[index].localPath = CameraDownloadStorage.relativePath(for: destination) + if tasks[index].capturedAt?.isEmpty != false { + tasks[index].capturedAt = CameraTransferTask.capturedAtString(from: resolvedAsset.creationDate) + } tasks[index].status = .downloaded tasks[index].progress = 0 notify() } catch { + guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } tasks[index].status = .failed tasks[index].errorMessage = error.localizedDescription notify() } } - private func processUploadQueue() async { + /// 并发上传已下载任务;边拍边传时限制并发数,避免网络与内存被打满。 + private func processUploadQueue() { guard uploadEnabled, uploadSink != nil else { return } - guard !isProcessing else { return } - isProcessing = true - defer { isProcessing = false } - let pendingIndices = tasks.indices.filter { tasks[$0].status == .downloaded } - for index in pendingIndices { - await uploadTask(at: index) + while activeUploadAssetIDs.count < maxConcurrentUploads, + let task = tasks.first(where: { $0.status == .downloaded && !activeUploadAssetIDs.contains($0.assetID) }) { + startConcurrentUpload(assetID: task.assetID) } } - private func uploadTask(at index: Int) async { - guard tasks.indices.contains(index), + private func startConcurrentUpload(assetID: String) { + guard !activeUploadAssetIDs.contains(assetID) else { return } + activeUploadAssetIDs.insert(assetID) + Task { @MainActor in + await self.uploadTask(assetID: assetID) + self.activeUploadAssetIDs.remove(assetID) + self.processUploadQueue() + } + } + + private func uploadTask(assetID: String) async { + guard let index = tasks.firstIndex(where: { $0.assetID == assetID }), let sink = uploadSink, let localURL = tasks[index].localURL else { return } @@ -207,33 +267,41 @@ final class CameraTransferPipeline { tasks[index].errorMessage = nil notify() - let fileType = tasks[index].filename.lowercased().hasSuffix(".mp4") ? 1 : 2 + let filename = tasks[index].filename + let fileType = filename.lowercased().hasSuffix(".mp4") ? 1 : 2 var attempt = 0 - while attempt < maxRetries { do { let remoteURL = try await sink.upload( localURL: localURL, - fileName: tasks[index].filename, + fileName: filename, fileType: fileType, progress: { [weak self] progress in Task { @MainActor in - guard let self, self.tasks.indices.contains(index) else { return } - self.tasks[index].progress = progress - self.notify() + guard let self, + let taskIndex = self.tasks.firstIndex(where: { $0.assetID == assetID }) else { return } + let lastNotifiedProgress = self.lastNotifiedProgressByAssetID[assetID] ?? -1 + guard progress == 100 || progress - lastNotifiedProgress >= 10 else { return } + self.lastNotifiedProgressByAssetID[assetID] = progress + self.tasks[taskIndex].progress = progress + self.notify(progress == 100 ? .immediate : .deferred) } } ) - tasks[index].status = .uploaded - tasks[index].remoteURL = remoteURL - tasks[index].progress = 100 + guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } + tasks[taskIndex].status = .uploaded + tasks[taskIndex].remoteURL = remoteURL + tasks[taskIndex].progress = 100 + lastNotifiedProgressByAssetID.removeValue(forKey: assetID) notify() return } catch { attempt += 1 + guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } if attempt >= maxRetries { - tasks[index].status = .failed - tasks[index].errorMessage = error.localizedDescription + tasks[taskIndex].status = .failed + tasks[taskIndex].errorMessage = error.localizedDescription + lastNotifiedProgressByAssetID.removeValue(forKey: assetID) notify() } else { let delay = UInt64(pow(2.0, Double(attempt))) @@ -243,7 +311,33 @@ final class CameraTransferPipeline { } } - private func notify() { - onTasksUpdated?(tasks) + private func notify(_ mode: CameraTransferNotifyMode = .immediate) { + switch mode { + case .immediate: + pendingDeferredNotifyTask?.cancel() + pendingDeferredNotifyTask = nil + onTasksUpdated?(tasks) + case .deferred: + let elapsed = Date().timeIntervalSince(lastDeferredNotifyDate) + if elapsed >= deferredNotifyInterval { + lastDeferredNotifyDate = Date() + onTasksUpdated?(tasks) + return + } + guard pendingDeferredNotifyTask == nil else { return } + let delay = UInt64((deferredNotifyInterval - elapsed) * 1_000_000_000) + pendingDeferredNotifyTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { return } + self.lastDeferredNotifyDate = Date() + self.pendingDeferredNotifyTask = nil + self.onTasksUpdated?(self.tasks) + } + } } } + +private enum CameraTransferNotifyMode { + case immediate + case deferred +} diff --git a/suixinkan/Core/CameraTransfer/CameraTransferTask.swift b/suixinkan/Core/CameraTransfer/CameraTransferTask.swift index 146c085..3bd0c82 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferTask.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferTask.swift @@ -24,6 +24,8 @@ struct CameraTransferTask: Identifiable, Equatable, Codable { let filename: String var localPath: String? var remoteURL: String? + /// 相机文件拍摄时间,格式 `yyyy-MM-dd HH:mm:ss`。 + var capturedAt: String? var status: CameraTransferStatus var progress: Int var errorMessage: String? @@ -34,6 +36,7 @@ struct CameraTransferTask: Identifiable, Equatable, Codable { filename: String, localPath: String? = nil, remoteURL: String? = nil, + capturedAt: String? = nil, status: CameraTransferStatus = .pending, progress: Int = 0, errorMessage: String? = nil @@ -43,11 +46,36 @@ struct CameraTransferTask: Identifiable, Equatable, Codable { self.filename = filename self.localPath = localPath self.remoteURL = remoteURL + self.capturedAt = capturedAt self.status = status self.progress = progress self.errorMessage = errorMessage } + /// 将日期格式化为列表使用的拍摄时间字符串。 + static func capturedAtString(from date: Date?) -> String { + Self.capturedAtFormatter.string(from: date ?? Date()) + } + + /// 从本地文件推断拍摄时间。 + static func capturedAtString(forLocalPath path: String) -> String? { + guard let url = CameraDownloadStorage.resolveLocalURL(from: path), + FileManager.default.fileExists(atPath: url.path), + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + else { return nil } + + let date = (attributes[.creationDate] as? Date) + ?? (attributes[.modificationDate] as? Date) + return date.map { capturedAtString(from: $0) } + } + + private static let capturedAtFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() + /// 本地文件 URL,支持相对路径与旧版绝对路径。 var localURL: URL? { guard let localPath, !localPath.isEmpty else { return nil } diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift index 35fe2f2..3b90642 100644 --- a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -18,7 +18,13 @@ protocol TravelAlbumServing { func albumInfo(id: Int) async throws -> TravelAlbumItem func editAlbum(_ request: TravelAlbumEditRequest) async throws func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws - func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload + func materialList( + userEquityTravelId: Int, + page: Int, + pageSize: Int, + orderBy: Int, + isPurchased: Int? + ) async throws -> ListPayload func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse @@ -88,17 +94,27 @@ final class TravelAlbumAPI: TravelAlbumServing { } /// 获取相册素材列表。 - func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload { - try await client.send( + func materialList( + userEquityTravelId: Int, + page: Int, + pageSize: Int, + orderBy: Int = 2, + isPurchased: Int? = nil + ) async throws -> ListPayload { + var queryItems = [ + URLQueryItem(name: "user_equity_travel_id", value: "\(userEquityTravelId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"), + URLQueryItem(name: "order_by", value: "\(max(orderBy, 1))") + ] + if let isPurchased { + queryItems.append(URLQueryItem(name: "is_purchased", value: "\(isPurchased)")) + } + return try await client.send( APIRequest( method: .get, path: "\(travelAlbumBase)/material-list", - queryItems: [ - URLQueryItem(name: "user_equity_travel_id", value: "\(userEquityTravelId)"), - URLQueryItem(name: "page", value: "\(max(page, 1))"), - URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"), - URLQueryItem(name: "order_by", value: "2") - ] + queryItems: queryItems ) ) } diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index ea31f2f..3edc0f2 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -161,6 +161,18 @@ struct TravelAlbumMaterial: Decodable, Identifiable, Equatable { createdAt = container.lossyString(forKey: .createdAt) updatedAt = container.lossyString(forKey: .updatedAt) } + + /// 预览用大图 URL,优先原图地址。 + var previewURLString: String { + if !fileURL.isEmpty { return fileURL } + return coverURL + } + + /// 列表缩略图 URL,优先封面。 + var thumbnailURLString: String { + if !coverURL.isEmpty { return coverURL } + return fileURL + } } /// 创建旅拍相册请求。 @@ -308,7 +320,10 @@ struct WiredTransferPhotoItem: Identifiable, Equatable { let id: String let sourceId: String let fileName: String + /// 列表使用的缩略图 URL,优先指向本地小图或远程封面。 let thumbnailURL: String + /// 预览使用的大图 URL,优先指向本地原图。 + let previewURL: String let capturedAt: String let fileSizeText: String let resolutionText: String @@ -321,6 +336,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable { sourceId: String? = nil, fileName: String, thumbnailURL: String, + previewURL: String = "", capturedAt: String, fileSizeText: String, resolutionText: String = "", @@ -332,6 +348,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable { self.sourceId = sourceId ?? id self.fileName = fileName self.thumbnailURL = thumbnailURL + self.previewURL = previewURL self.capturedAt = capturedAt self.fileSizeText = fileSizeText self.resolutionText = resolutionText @@ -385,8 +402,15 @@ struct WiredTransferPhotoRecord: Codable, Equatable { let updatedAt: TimeInterval func toPhotoItem() -> WiredTransferPhotoItem { - let previewCandidates = [thumbnailPath, localPath, remoteURL] - let thumbnailURL = previewCandidates.compactMap { path -> String? in + let thumbnailCandidates = [thumbnailPath, remoteURL] + let thumbnailURL = thumbnailCandidates.compactMap { path -> String? in + guard !path.isEmpty else { return nil } + if path.hasPrefix("http") { return path } + let preview = CameraDownloadStorage.previewURLString(from: path) + return preview.isEmpty ? nil : preview + }.first ?? "" + let previewCandidates = [localPath, remoteURL, thumbnailPath] + let previewURL = previewCandidates.compactMap { path -> String? in guard !path.isEmpty else { return nil } if path.hasPrefix("http") { return path } let preview = CameraDownloadStorage.previewURLString(from: path) @@ -398,6 +422,7 @@ struct WiredTransferPhotoRecord: Codable, Equatable { sourceId: sourceId, fileName: fileName, thumbnailURL: thumbnailURL, + previewURL: previewURL, capturedAt: capturedAt, fileSizeText: Self.formatFileSize(fileSizeBytes), status: WiredTransferUploadStatus(rawValue: status) ?? .pending, @@ -465,6 +490,16 @@ extension Array where Element == WiredTransferPhotoItem { Set(filter { $0.canSelectForSpecifyUpload }.map(\.id)) } + /// 按拍摄时间倒序排列,最新照片在最前。 + func sortedByCaptureTimeDescending() -> [WiredTransferPhotoItem] { + sorted { lhs, rhs in + let leftDate = lhs.capturedDate() ?? .distantPast + let rightDate = rhs.capturedDate() ?? .distantPast + if leftDate != rightDate { return leftDate > rightDate } + return lhs.id > rhs.id + } + } + /// 构建 30 分钟时间段分组列表。 func buildPhotoSections() -> [WiredTransferPhotoSection] { let grouped = Dictionary(grouping: compactMap { photo -> (Date, WiredTransferPhotoItem)? in diff --git a/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift index 4fd39a5..dd25189 100644 --- a/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift @@ -122,6 +122,9 @@ final class WiredTransferPhotoStore { guard let data = defaults.data(forKey: storageKey(albumID)), var records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data) else { return } + records + .filter { $0.id == photoID && $0.userId == userIDProvider() && $0.albumId == albumID } + .forEach { deleteRecordFiles($0) } records = records.filter { record in !deletedIDs.contains(record.id) && record.userId == userIDProvider() diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index 83002b6..ab8dca4 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -15,9 +15,29 @@ | --- | --- | | `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 | | `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 | -| `TravelAlbumDetailView` | 相册详情与素材管理 | +| `TravelAlbumDetailView` | 相册详情与素材管理(UI 对齐 Android) | | `WiredCameraTransferView` | 有线传图页(UI 对齐 Android) | +## 相册详情 UI 结构 + +Android 对照:`zhiflyfollow/.../TravelAlbumDetailScreen.kt` + +``` +TravelAlbumDetailInfoCard # 信息卡片:手机号 + 创建时间 + 拨号 +TravelAlbumDetailPhotoManageCard # 白卡片:Tab / 排序 / 批量选择 + 网格 +TravelAlbumDetailMaterialGridItem# 缩略图 + 已上传角标 + 文件名 + 大小 +BottomBar # 上传照片 + 条件删除选中 +``` + +## 相册详情交互流程 + +1. **Tab**:全部照片 / 已购照片,切换后重载素材列表 +2. **排序**:创建时间正序/倒序、文件名正序/倒序 +3. **批量删除**:仅在「全部照片」Tab 可选,且仅未购买素材可删 +4. **上传照片**:底部按钮进入 `WiredCameraTransferView`,返回后自动刷新 +5. **删除相册**:右上角更多菜单 +6. **图片预览**:非选择模式下点击网格项打开大图预览,可左右滑动浏览当前列表 + ## 有线传图 UI 结构 Android 对照:`zhiflyfollow/.../WiredCameraTransferScreen.kt` @@ -50,6 +70,13 @@ SpecifyUploadBottomSheet # 指定上传选项弹层 4. 素材登记:`POST .../travel-album/upload-material` 5. 本地照片路径以相对 Documents 的路径持久化(如 `CameraDownloads/xxx.JPG`),加载时自动迁移旧版绝对路径 +## 有线传图性能策略 + +- 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。 +- 连拍上传进度采用节流通知,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传自动队列最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。 +- `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。 +- 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。 + ## 解耦关系 - `CameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index d028c69..c5a375b 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -177,38 +177,145 @@ struct TravelAlbumCodeSheetState: Identifiable { var id: Int { albumID } } -/// 旅拍相册详情 ViewModel。 +/// 旅拍相册详情 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 { - 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 + 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 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 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 + ) } /// 删除相册。 @@ -224,6 +331,80 @@ final class TravelAlbumDetailViewModel: ObservableObject { 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,编排相机连接、传输管道与本地持久化。 @@ -237,7 +418,11 @@ final class WiredCameraTransferViewModel: ObservableObject { static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片" static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")! - @Published var photos: [WiredTransferPhotoItem] = [] + @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? @@ -258,6 +443,8 @@ final class WiredCameraTransferViewModel: ObservableObject { private var photoStore: WiredTransferPhotoStore? private var userIDProvider: (() -> String)? private var sessionNewPhotoIDs: Set = [] + private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:] + private var lastProgressPersistDates: [String: Date] = [:] private var wasConnected = false private var disconnectAlertTask: Task? @@ -276,7 +463,7 @@ final class WiredCameraTransferViewModel: ObservableObject { } pipeline.onTasksUpdated = { [weak self] tasks in Task { @MainActor in - self?.applyPipelineTasks(tasks) + await self?.applyPipelineTasks(tasks) } } } @@ -325,6 +512,7 @@ final class WiredCameraTransferViewModel: ObservableObject { func selectTab(_ index: Int) { selectedTabIndex = index selectedTimeSlotID = nil + refreshDerivedPhotoState(rebuildGroups: true) if selectUploadMode { syncSelectionWithVisiblePhotos() } @@ -432,16 +620,11 @@ final class WiredCameraTransferViewModel: ObservableObject { } } - /// 当前 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 "" + if !photo.previewURL.isEmpty { return photo.previewURL } + return photo.thumbnailURL } /// 批量上传所有待上传照片。 @@ -474,14 +657,48 @@ final class WiredCameraTransferViewModel: ObservableObject { } /// 重试单张照片上传。 - func retryPhoto(id: String) async { - await pipeline.uploadAssets(withIDs: [id]) + 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]) + 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 + ) } /// 删除本地照片记录。 func deletePhoto(id: String) { photoStore?.remove(albumID: context.albumId, photoID: id) - photos.removeAll { $0.id == id } + persistedRecordsByID.removeValue(forKey: id) + lastProgressPersistDates.removeValue(forKey: id) + setPhotos(photos.filter { $0.id != id }, rebuildGroups: true) } /// 切换照片选中状态。 @@ -515,11 +732,117 @@ final class WiredCameraTransferViewModel: ObservableObject { pipeline.configure(uploadSink: uploader, uploadEnabled: true) } + 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 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 { triggerCameraDisconnectedAlert() @@ -553,73 +876,182 @@ final class WiredCameraTransferViewModel: ObservableObject { private func loadPersistedPhotos() { guard context.albumId > 0, let photoStore else { return } let records = photoStore.load(albumID: context.albumId) - photos = records.map { $0.toPhotoItem() } + persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) + setPhotos(records.map { $0.toPhotoItem() }.sortedByCaptureTimeDescending(), rebuildGroups: true) } - private func applyPipelineTasks(_ tasks: [CameraTransferTask]) { - guard let photoStore, let userIDProvider else { return } + private func applyPipelineTasks(_ tasks: [CameraTransferTask]) async { + guard let userIDProvider else { return } let userID = userIDProvider() - var merged = photos.filter { photo in - !tasks.contains { $0.assetID == photo.id || $0.filename == photo.fileName } - } + // 保留已有元数据,避免上传进度回调反复重置 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 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 status = mapPipelineStatus(task.status) 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 fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL) + + let existing = existingByID[task.assetID] ?? existingByFileName[task.filename] + let existingRecord = persistedRecordsByID[task.assetID] + let capturedAt = await resolveCapturedAt( + existing: existing, + taskCapturedAt: task.capturedAt, + localURL: localURL + ) + var thumbnailPath = existingRecord?.thumbnailPath ?? "" + if thumbnailPath.isEmpty, + let localURL, + status != .transferring, + let generatedPath = await CameraThumbnailGenerator.generateThumbnail(for: localURL, assetID: task.assetID) { + thumbnailPath = generatedPath } - let thumbnailURL = localURL.map(\.absoluteString) ?? "" + 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: WiredTransferPhotoRecord.formatFileSize(fileSizeBytes), + fileSizeText: fileSizeText, + resolutionText: existing?.resolutionText ?? "", status: status, progress: task.progress, errorMessage: task.errorMessage ) - merged.insert(item, at: 0) + 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 record = item.toRecord( - albumId: context.albumId, - userId: userID, + let remoteURL = task.remoteURL ?? "" + if shouldPersistPipelineUpdate( + photoID: task.assetID, + status: status, 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) + 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) + } } + } - photos = merged.sorted { $0.capturedAt > $1.capturedAt } + /// 解析展示用拍摄时间:优先保留已有值,其次使用相机时间,最后回退到本地文件或当前时间。 + 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 + } + } + + /// 仅在状态、路径或远程 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 } /// 判断照片是否归属当前相册;未绑定的会话新拍默认归属当前页。 @@ -629,10 +1061,4 @@ final class WiredCameraTransferViewModel: ObservableObject { } return bound == context.albumId } - - private static let captureTimestampFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - return formatter - }() } diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/MiddleTruncatedText.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/MiddleTruncatedText.swift new file mode 100644 index 0000000..85ab5a3 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/MiddleTruncatedText.swift @@ -0,0 +1,34 @@ +// +// MiddleTruncatedText.swift +// suixinkan +// +// 文件名中间省略展示,对齐 Android MiddleEllipsisTextView。 +// + +import SwiftUI +import UIKit + +/// 中间省略文本,用于长文件名展示。 +struct MiddleTruncatedText: UIViewRepresentable { + let text: String + var font: UIFont = .systemFont(ofSize: 11, weight: .regular) + var textColor: UIColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1) + + /// 创建中间省略 UILabel。 + func makeUIView(context: Context) -> UILabel { + let label = UILabel() + label.lineBreakMode = .byTruncatingMiddle + label.numberOfLines = 1 + label.setContentHuggingPriority(.required, for: .vertical) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .vertical) + return label + } + + /// 更新文本样式。 + func updateUIView(_ uiView: UILabel, context: Context) { + uiView.text = text + uiView.font = font + uiView.textColor = textColor + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailDesign.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailDesign.swift new file mode 100644 index 0000000..d5939f2 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailDesign.swift @@ -0,0 +1,41 @@ +// +// TravelAlbumDetailDesign.swift +// suixinkan +// +// 旅拍相册详情页设计 Token,对齐 Android TravelAlbumDetailScreen。 +// + +import SwiftUI + +/// 旅拍相册详情页专用色值与尺寸。 +enum TravelAlbumDetailDesign { + static let pageBackground = Color(hex: 0xF5F5F5) + static let text333 = Color(hex: 0x333333) + static let text999 = Color(hex: 0x999999) + static let lightBlue = Color(hex: 0xEAF4FF) + static let deleteRed = Color(hex: 0xE53935) + static let uploadedGreen = Color(hex: 0x34C759) + static let borderLight = Color(hex: 0xEEEEEE) + static let selectionInactiveBackground = Color(hex: 0xF4F4F4) + static let cardShadow = Color.black.opacity(0.08) + + static let cardCornerRadius: CGFloat = 12 + static let cardShadowRadius: CGFloat = 2 + static let infoIconSize: CGFloat = 48 + static let infoIconCornerRadius: CGFloat = 10 + static let callButtonSize: CGFloat = 40 + static let tabPillCornerRadius: CGFloat = 18 + static let actionCircleSize: CGFloat = 32 + static let gridHorizontalSpacing: CGFloat = 8 + static let gridVerticalSpacing: CGFloat = 12 + static let gridPadding: CGFloat = 12 + static let thumbnailCornerRadius: CGFloat = 8 + static let uploadedBadgeCornerRadius: CGFloat = 4 + static let bottomButtonHeight: CGFloat = 48 + static let bottomButtonCornerRadius: CGFloat = 10 + static let contentHorizontalPadding: CGFloat = 16 + static let sectionSpacing: CGFloat = 12 + static let bottomBarHorizontalPadding: CGFloat = 20 + static let bottomBarVerticalPadding: CGFloat = 12 + static let selectionCheckSize: CGFloat = 20 +} diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailInfoCard.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailInfoCard.swift new file mode 100644 index 0000000..9287a8a --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailInfoCard.swift @@ -0,0 +1,57 @@ +// +// TravelAlbumDetailInfoCard.swift +// suixinkan +// +// 旅拍相册详情信息卡片,对齐 Android AlbumInfoCard。 +// + +import SwiftUI + +/// 旅拍相册详情顶部信息卡片。 +struct TravelAlbumDetailInfoCard: View { + let album: TravelAlbumItem + let onCallClick: (String) -> Void + + var body: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.infoIconCornerRadius) + .fill(AppDesign.primary) + .frame(width: TravelAlbumDetailDesign.infoIconSize, height: TravelAlbumDetailDesign.infoIconSize) + .overlay { + Image(systemName: "list.clipboard.fill") + .font(.system(size: 28)) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 6) { + Text("手机号 \(OrderListUI.maskPhone(album.displayPhone))") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(TravelAlbumDetailDesign.text333) + Text("创建时间 \(album.createdAt)") + .font(.system(size: 12)) + .foregroundStyle(TravelAlbumDetailDesign.text999) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + onCallClick(album.displayPhone) + } label: { + Circle() + .fill(TravelAlbumDetailDesign.lightBlue) + .frame(width: TravelAlbumDetailDesign.callButtonSize, height: TravelAlbumDetailDesign.callButtonSize) + .overlay { + Image(systemName: "phone.fill") + .font(.system(size: 20)) + .foregroundStyle(AppDesign.primary) + } + } + .buttonStyle(.plain) + .accessibilityLabel("拨打电话") + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.cardCornerRadius)) + .shadow(color: TravelAlbumDetailDesign.cardShadow, radius: TravelAlbumDetailDesign.cardShadowRadius, x: 0, y: 1) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailMaterialGridItem.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailMaterialGridItem.swift new file mode 100644 index 0000000..dbaa897 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailMaterialGridItem.swift @@ -0,0 +1,80 @@ +// +// TravelAlbumDetailMaterialGridItem.swift +// suixinkan +// +// 旅拍相册详情素材网格项,对齐 Android MaterialGridItem。 +// + +import SwiftUI + +/// 旅拍相册详情素材网格单元。 +struct TravelAlbumDetailMaterialGridItem: View { + let material: TravelAlbumMaterial + let isSelectionMode: Bool + let isSelected: Bool + let onSelect: () -> Void + let onPreview: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + thumbnailSection + + MiddleTruncatedText( + text: material.fileName, + font: .systemFont(ofSize: 11), + textColor: UIColor(TravelAlbumDetailDesign.text333) + ) + .frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14, alignment: .leading) + + Text(WiredTransferPhotoRecord.formatFileSize(Int64(material.fileSize))) + .font(.system(size: 10)) + .foregroundStyle(TravelAlbumDetailDesign.text999) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + if isSelectionMode { + onSelect() + } else { + onPreview() + } + } + } + + /// 1:1 缩略图区域,限制在网格单元宽度内避免重叠溢出。 + private var thumbnailSection: some View { + Color.clear + .aspectRatio(1, contentMode: .fit) + .overlay { + RemoteImage( + urlString: material.thumbnailURLString, + contentMode: .fill + ) { + Color(hex: 0xE5E7EB) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.thumbnailCornerRadius)) + .overlay(alignment: .topLeading) { + Text("已上传") + .font(.system(size: 10)) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(TravelAlbumDetailDesign.uploadedGreen) + .clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.uploadedBadgeCornerRadius)) + .padding(4) + } + .overlay(alignment: .topTrailing) { + if isSelectionMode { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: TravelAlbumDetailDesign.selectionCheckSize)) + .foregroundStyle(isSelected ? AppDesign.primary : .white) + .padding(4) + .background(Color.black.opacity(0.4), in: Circle()) + .padding(4) + } + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailPhotoManageCard.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailPhotoManageCard.swift new file mode 100644 index 0000000..c3db245 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetail/TravelAlbumDetailPhotoManageCard.swift @@ -0,0 +1,153 @@ +// +// TravelAlbumDetailPhotoManageCard.swift +// suixinkan +// +// 旅拍相册详情照片管理卡片,对齐 Android PhotoManageCard。 +// + +import SwiftUI + +/// 旅拍相册详情照片管理区域。 +struct TravelAlbumDetailPhotoManageCard: View { + let materials: [TravelAlbumMaterial] + let allPhotoCount: Int + let selectedTab: Int + let orderBy: Int + let isSelectionMode: Bool + let selectedMaterialIDs: Set + let onTabSelect: (Int) -> Void + let onOrderBySelect: (Int) -> Void + let onToggleSelectionMode: () -> Void + let onMaterialClick: (TravelAlbumMaterial) -> Void + let onMaterialPreview: (TravelAlbumMaterial) -> Void + let onLoadMore: (Int) -> Void + + private let gridColumns = [ + GridItem(.flexible(), spacing: TravelAlbumDetailDesign.gridHorizontalSpacing), + GridItem(.flexible(), spacing: TravelAlbumDetailDesign.gridHorizontalSpacing), + GridItem(.flexible()) + ] + + var body: some View { + VStack(spacing: 0) { + tabToolbar + Divider().overlay(TravelAlbumDetailDesign.borderLight) + photoGrid + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.cardCornerRadius)) + .shadow(color: TravelAlbumDetailDesign.cardShadow, radius: TravelAlbumDetailDesign.cardShadowRadius, x: 0, y: 1) + } + + private var tabToolbar: some View { + HStack(spacing: 8) { + HStack(spacing: 8) { + tabPill( + text: "全部照片\(allPhotoCount)", + selected: selectedTab == TravelAlbumDetailViewModel.tabAll + ) { + onTabSelect(TravelAlbumDetailViewModel.tabAll) + } + tabPill( + text: "已购照片", + selected: selectedTab == TravelAlbumDetailViewModel.tabPurchased + ) { + onTabSelect(TravelAlbumDetailViewModel.tabPurchased) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + sortMenu + + if selectedTab == TravelAlbumDetailViewModel.tabAll { + Button(action: onToggleSelectionMode) { + Circle() + .fill(isSelectionMode ? TravelAlbumDetailDesign.lightBlue : TravelAlbumDetailDesign.selectionInactiveBackground) + .frame(width: TravelAlbumDetailDesign.actionCircleSize, height: TravelAlbumDetailDesign.actionCircleSize) + .overlay { + Image(systemName: isSelectionMode ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18)) + .foregroundStyle(isSelectionMode ? AppDesign.primary : TravelAlbumDetailDesign.text999) + } + } + .buttonStyle(.plain) + .accessibilityLabel("选择") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + } + + private var sortMenu: some View { + Menu { + ForEach(TravelAlbumDetailViewModel.sortOptions, id: \.value) { option in + Button { + onOrderBySelect(option.value) + } label: { + if orderBy == option.value { + Label(option.label, systemImage: "checkmark") + } else { + Text(option.label) + } + } + } + } label: { + Circle() + .fill(TravelAlbumDetailDesign.lightBlue) + .frame(width: TravelAlbumDetailDesign.actionCircleSize, height: TravelAlbumDetailDesign.actionCircleSize) + .overlay { + Image(systemName: "line.3.horizontal.decrease") + .font(.system(size: 16)) + .foregroundStyle(AppDesign.primary) + } + } + .accessibilityLabel("筛选") + } + + private var photoGrid: some View { + ScrollView { + if materials.isEmpty { + Text("暂无照片") + .font(.system(size: 14)) + .foregroundStyle(TravelAlbumDetailDesign.text999) + .frame(maxWidth: .infinity, minHeight: 180) + } else { + LazyVGrid( + columns: gridColumns, + spacing: TravelAlbumDetailDesign.gridVerticalSpacing + ) { + ForEach(Array(materials.enumerated()), id: \.element.id) { index, material in + TravelAlbumDetailMaterialGridItem( + material: material, + isSelectionMode: isSelectionMode, + isSelected: selectedMaterialIDs.contains(material.id), + onSelect: { onMaterialClick(material) }, + onPreview: { onMaterialPreview(material) } + ) + .onAppear { + onLoadMore(index) + } + } + } + .padding(TravelAlbumDetailDesign.gridPadding) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + + /// Tab 胶囊按钮。 + private func tabPill(text: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(text) + .font(.system(size: 13, weight: selected ? .medium : .regular)) + .foregroundStyle(selected ? Color.white : AppDesign.primary) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(selected ? AppDesign.primary : TravelAlbumDetailDesign.lightBlue) + .clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.tabPillCornerRadius)) + } + .buttonStyle(.plain) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift index 55cda6d..843c63b 100644 --- a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift @@ -7,7 +7,7 @@ import SwiftUI -/// 旅拍相册详情页,展示素材网格与管理操作。 +/// 旅拍相册详情页,展示素材网格与管理操作,对齐 Android TravelAlbumDetailScreen。 struct TravelAlbumDetailView: View { let albumID: Int @@ -16,9 +16,13 @@ struct TravelAlbumDetailView: View { @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL @StateObject private var viewModel = TravelAlbumDetailViewModel() - @State private var showDeleteConfirm = false + @State private var showMoreMenu = false + @State private var navigateToWiredTransfer = false + @State private var didReturnFromWiredTransfer = false + @State private var previewPayload: TravelAlbumMaterialPreviewPayload? private var photoStore: WiredTransferPhotoStore { WiredTransferPhotoStore( @@ -28,125 +32,186 @@ struct TravelAlbumDetailView: View { } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { - if let album = viewModel.album { - albumHeader(album) - } - - if viewModel.materials.isEmpty, !viewModel.isLoading { - AppContentUnavailableView("暂无素材", systemImage: "photo.on.rectangle") - .frame(maxWidth: .infinity, minHeight: 200) - } else { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - ForEach(viewModel.materials) { material in - materialCell(material) - } - } - } + Group { + if viewModel.isLoading, viewModel.album == nil { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.album != nil { + contentView + } else { + Text("加载失败") + .font(.system(size: 14)) + .foregroundStyle(TravelAlbumDetailDesign.text333) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) - .padding(.vertical, AppMetrics.Spacing.medium) } - .background(Color(hex: 0xF5F7FA)) - .navigationTitle(viewModel.album?.name ?? "相册详情") + .background(TravelAlbumDetailDesign.pageBackground.ignoresSafeArea()) + .navigationTitle("相册管理") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - if let album = viewModel.album { - NavigationLink { - WiredCameraTransferView(context: WiredTransferContext( - albumId: album.id, - albumName: album.name, - phone: album.displayPhone, - orderNumber: album.orderNumber - )) - } label: { - Image(systemName: "cable.connector") + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button("删除相册", role: .destructive) { + viewModel.showDeleteAlbumConfirm = true } - .accessibilityLabel("有线传图") - } - - Button(role: .destructive) { - showDeleteConfirm = true } label: { - Image(systemName: "trash") + Image(systemName: "ellipsis") + .foregroundStyle(TravelAlbumDetailDesign.text333) } - .accessibilityLabel("删除相册") + .accessibilityLabel("更多") } } - .confirmationDialog("确认删除该相册?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + .safeAreaInset(edge: .bottom) { + bottomBar + } + .background(wiredTransferNavigationLink) + .sheet(item: $previewPayload) { payload in + WiredTransferPhotoPreviewSheet( + imageSources: payload.sources, + startIndex: payload.startIndex, + onDismiss: { previewPayload = nil } + ) + } + .confirmationDialog("删除相册", isPresented: $viewModel.showDeleteAlbumConfirm, titleVisibility: .visible) { Button("删除", role: .destructive) { Task { await deleteAlbum() } } Button("取消", role: .cancel) {} + } message: { + Text("确定删除该相册吗?已有购买素材的相册无法删除。") + } + .confirmationDialog("删除素材", isPresented: $viewModel.showDeleteMaterialConfirm, titleVisibility: .visible) { + Button("删除", role: .destructive) { + Task { await deleteSelectedMaterials() } + } + Button("取消", role: .cancel) {} + } message: { + Text("确定删除选中的 \(viewModel.selectedMaterialIDs.count) 张素材吗?") } .task(id: albumID) { globalLoading.show() defer { globalLoading.hide() } - await viewModel.load(api: travelAlbumAPI, albumID: albumID) + await viewModel.refreshAll(api: travelAlbumAPI, albumID: albumID) } - .refreshable { - await viewModel.load(api: travelAlbumAPI, albumID: albumID) + .onChange(of: navigateToWiredTransfer) { isActive in + guard !isActive, didReturnFromWiredTransfer else { return } + didReturnFromWiredTransfer = false + Task { await viewModel.refreshAll(api: travelAlbumAPI, albumID: albumID) } } .onChange(of: viewModel.errorMessage) { message in guard let message, !message.isEmpty else { return } toastCenter.show(message) + viewModel.errorMessage = nil } } - private func albumHeader(_ album: TravelAlbumItem) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(album.type == 1 ? "先拍再买" : "买了再拍") - .font(.system(size: AppMetrics.FontSize.caption)) - .foregroundStyle(AppDesign.primary) - if !album.displayPhone.isEmpty { - Label(album.displayPhone, systemImage: "phone.fill") - .font(.system(size: AppMetrics.FontSize.subheadline)) - .foregroundStyle(AppDesign.textSecondary) - } - if !album.orderNumber.isEmpty { - Text("订单号:\(album.orderNumber)") - .font(.system(size: AppMetrics.FontSize.caption)) - .foregroundStyle(AppDesign.textSecondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(AppMetrics.Spacing.large) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) - } - - private func materialCell(_ material: TravelAlbumMaterial) -> some View { - ZStack(alignment: .topTrailing) { - RemoteImage( - urlString: material.coverURL.isEmpty ? material.fileURL : material.coverURL, - contentMode: .fill - ) { - Color(hex: 0xE5E7EB) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Menu { - Button(role: .destructive) { - Task { - await viewModel.deleteMaterial(api: travelAlbumAPI, materialID: material.id, albumID: albumID) - } - } label: { - Label("删除", systemImage: "trash") + private var contentView: some View { + VStack(spacing: TravelAlbumDetailDesign.sectionSpacing) { + if let album = viewModel.album { + TravelAlbumDetailInfoCard(album: album) { phone in + dialPhone(phone) } - } label: { - Image(systemName: "ellipsis") - .padding(6) - .background(Color.black.opacity(0.35)) - .foregroundStyle(.white) - .clipShape(Circle()) - .padding(4) } + + TravelAlbumDetailPhotoManageCard( + materials: viewModel.materials, + allPhotoCount: viewModel.allPhotoCount, + selectedTab: viewModel.selectedTab, + orderBy: viewModel.orderBy, + isSelectionMode: viewModel.isSelectionMode, + selectedMaterialIDs: viewModel.selectedMaterialIDs, + onTabSelect: { tab in + Task { await viewModel.selectTab(tab, api: travelAlbumAPI) } + }, + onOrderBySelect: { order in + Task { await viewModel.setOrderBy(order, api: travelAlbumAPI) } + }, + onToggleSelectionMode: { + viewModel.toggleSelectionMode() + }, + onMaterialClick: { material in + viewModel.toggleMaterialSelection(material) + }, + onMaterialPreview: { material in + openMaterialPreview(material) + }, + onLoadMore: { index in + Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: index, api: travelAlbumAPI) } + } + ) + .frame(maxHeight: .infinity) } - .aspectRatio(1, contentMode: .fit) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, TravelAlbumDetailDesign.contentHorizontalPadding) + .padding(.top, 12) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private var bottomBar: some View { + VStack(spacing: 8) { + if viewModel.isSelectionMode, !viewModel.selectedMaterialIDs.isEmpty { + Button { + viewModel.requestDeleteSelectedMaterials() + } label: { + Text("删除选中(\(viewModel.selectedMaterialIDs.count))") + .font(.system(size: 16)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: TravelAlbumDetailDesign.bottomButtonHeight) + .background(TravelAlbumDetailDesign.deleteRed) + .clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.bottomButtonCornerRadius)) + } + .buttonStyle(.plain) + } + + Button { + didReturnFromWiredTransfer = true + navigateToWiredTransfer = true + } label: { + Text("上传照片") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: TravelAlbumDetailDesign.bottomButtonHeight) + .background(AppDesign.primary) + .clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.bottomButtonCornerRadius)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, TravelAlbumDetailDesign.bottomBarHorizontalPadding) + .padding(.vertical, TravelAlbumDetailDesign.bottomBarVerticalPadding) + .background(TravelAlbumDetailDesign.pageBackground) + } + + @ViewBuilder + private var wiredTransferNavigationLink: some View { + if let context = viewModel.wiredTransferContext() { + NavigationLink(isActive: $navigateToWiredTransfer) { + WiredCameraTransferView(context: context) + } label: { + EmptyView() + } + .hidden() + } + } + + /// 拨打客户电话。 + private func dialPhone(_ phone: String) { + let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return } + openURL(url) + } + + /// 打开素材大图预览,支持左右滑动浏览当前列表。 + private func openMaterialPreview(_ material: TravelAlbumMaterial) { + let previewMaterials = viewModel.materials.filter { !$0.previewURLString.isEmpty } + guard !material.previewURLString.isEmpty else { + toastCenter.show("暂无法预览该照片") + return + } + let sources = previewMaterials.map(\.previewURLString) + let startIndex = previewMaterials.firstIndex(where: { $0.id == material.id }) ?? 0 + previewPayload = TravelAlbumMaterialPreviewPayload(sources: sources, startIndex: startIndex) } private func deleteAlbum() async { @@ -158,4 +223,20 @@ struct TravelAlbumDetailView: View { dismiss() } } + + private func deleteSelectedMaterials() async { + globalLoading.show() + defer { globalLoading.hide() } + let success = await viewModel.confirmDeleteSelectedMaterials(api: travelAlbumAPI) + if success { + toastCenter.show("删除成功") + } + } +} + +/// 旅拍相册素材预览 Sheet 载荷。 +private struct TravelAlbumMaterialPreviewPayload: Identifiable { + let id = UUID() + let sources: [String] + let startIndex: Int } diff --git a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift index 16ae33a..4f191f7 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift @@ -53,7 +53,7 @@ struct WiredCameraTransferView: View { cameraDeviceName: viewModel.cameraDeviceName, transferModeOption: viewModel.transferModeOption, tabTitles: tabTitles, - tabCounts: viewModel.photos.transferTabCounts, + tabCounts: viewModel.tabCounts, selectedTabIndex: viewModel.selectedTabIndex, onDeviceUsageClick: viewModel.onDeviceUsageClick, onRefreshCameraFiles: { @@ -168,11 +168,11 @@ struct WiredCameraTransferView: View { } private var sidebarGroups: [WiredTransferDateGroup] { - visiblePhotos.buildSidebarGroups() + viewModel.sidebarGroups } private var photoSections: [WiredTransferPhotoSection] { - visiblePhotos.buildPhotoSections() + viewModel.photoSections } private var selectAllBar: some View { @@ -202,7 +202,16 @@ struct WiredCameraTransferView: View { onToggleSidebar: viewModel.toggleSidebar, onTimeSlotSelected: viewModel.selectTimeSlot, onPhotoClick: openPhotoPreview, - onRetry: { id in Task { await viewModel.retryPhoto(id: id) } }, + onRetry: { id in + Task { + await viewModel.retryPhoto( + id: id, + api: travelAlbumAPI, + ossService: ossUploadService, + scenicID: scenicID + ) + } + }, onDelete: viewModel.deletePhoto, onTogglePhotoSelection: viewModel.togglePhotoSelection ) diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoPreviewSheet.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoPreviewSheet.swift index 25ac28a..c1daabe 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoPreviewSheet.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoPreviewSheet.swift @@ -4,7 +4,6 @@ // import SwiftUI -import UIKit import Kingfisher /// 有线传图照片预览,支持本地 file:// 与远程 URL。 @@ -19,7 +18,8 @@ struct WiredTransferPhotoPreviewSheet: View { self.imageSources = imageSources self.startIndex = startIndex self.onDismiss = onDismiss - _currentIndex = State(initialValue: startIndex) + let safeIndex = imageSources.isEmpty ? 0 : min(max(startIndex, 0), imageSources.count - 1) + _currentIndex = State(initialValue: safeIndex) } var body: some View { @@ -46,12 +46,7 @@ struct WiredTransferPhotoPreviewSheet: View { @ViewBuilder private func previewImage(_ source: String) -> some View { - if source.hasPrefix("file://"), let url = URL(string: source), - let uiImage = UIImage(contentsOfFile: url.path) { - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - } else if let url = URL(string: source), !source.isEmpty { + if let url = URL(string: source), !source.isEmpty { KFImage(url) .resizable() .scaledToFit() diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift index c0395f6..f3e664d 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift @@ -3,6 +3,7 @@ // suixinkan // +import Kingfisher import SwiftUI import UIKit @@ -23,36 +24,40 @@ struct WiredTransferPhotoRow: View { var body: some View { VStack(spacing: 0) { HStack(alignment: .center, spacing: 8) { - if selectUploadMode { - Button(action: onToggleSelection) { + HStack(alignment: .center, spacing: 8) { + if selectUploadMode { WiredTransferSelectIndicator(selected: selected, enabled: canSelect) } - .buttonStyle(.plain) - .disabled(!canSelect) - } - thumbnailView - .frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize) - .clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius)) - .opacity(rowOpacity) - .onTapGesture { - if !selectUploadMode { onPhotoClick() } - } + thumbnailView + .frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize) + .clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius)) + .opacity(rowOpacity) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 4) { - Text(photo.fileName) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity)) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 4) { + Text(photo.fileName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity)) + .lineLimit(1) + Spacer(minLength: 0) + WiredTransferStatusBadge(status: photo.status) + .opacity(rowOpacity) + } + Text(metaText) + .font(.system(size: 10)) + .foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity)) .lineLimit(1) - Spacer(minLength: 0) - WiredTransferStatusBadge(status: photo.status) - .opacity(rowOpacity) } - Text(metaText) - .font(.system(size: 10)) - .foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity)) - .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + if selectUploadMode { + if canSelect { onToggleSelection() } + } else { + onPhotoClick() + } } if !selectUploadMode { @@ -71,10 +76,6 @@ struct WiredTransferPhotoRow: View { } .padding(.horizontal, 10) .padding(.vertical, 8) - .contentShape(Rectangle()) - .onTapGesture { - if selectUploadMode, canSelect { onToggleSelection() } - } if photo.status == .transferring || photo.status == .uploading { ProgressView(value: Double(photo.progress), total: 100) @@ -93,18 +94,20 @@ struct WiredTransferPhotoRow: View { @ViewBuilder private var thumbnailView: some View { - if photo.thumbnailURL.hasPrefix("file://"), let url = URL(string: photo.thumbnailURL) { - if let uiImage = UIImage(contentsOfFile: url.path) { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fill) - } else { - thumbnailPlaceholder - } - } else if !photo.thumbnailURL.isEmpty { - RemoteImage(urlString: photo.thumbnailURL) { - thumbnailPlaceholder - } + if let url = URL(string: photo.thumbnailURL), !photo.thumbnailURL.isEmpty { + KFImage(url) + .placeholder { thumbnailPlaceholder } + .setProcessor( + DownsamplingImageProcessor( + size: CGSize( + width: WiredTransferDesign.thumbnailSize, + height: WiredTransferDesign.thumbnailSize + ) + ) + ) + .scaleFactor(UIScreen.main.scale) + .resizable() + .aspectRatio(contentMode: .fill) } else { thumbnailPlaceholder } diff --git a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift index da6e3f3..fc7f430 100644 --- a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift +++ b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift @@ -37,6 +37,72 @@ final class CameraTransferPipelineTests: XCTestCase { } try? FileManager.default.removeItem(at: tempURL) } + + /// 测试重传可从相对路径恢复管道任务并上传。 + func testRetryUploadRehydratesFromRelativePath() async throws { + let camera = MockCameraService() + let sink = MockUploadSink() + let pipeline = CameraTransferPipeline(cameraService: camera) + pipeline.configure(uploadSink: sink, uploadEnabled: false) + + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG") + try Data(repeating: 0xAB, count: 16).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let relativePath = CameraDownloadStorage.relativePath(for: fileURL) + await pipeline.retryUpload( + assetID: "asset_retry_001", + filename: "retry_pipeline.JPG", + localPath: relativePath + ) + + XCTAssertEqual(sink.uploadCount, 1) + XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded) + } + + /// 测试边拍边传连拍时,多张照片会按并发上限同时上传。 + func testBurstCaptureUploadsAllAssetsWhileFirstUploadInProgress() async throws { + let camera = MockBurstCameraService() + let sink = MockSlowUploadSink() + let pipeline = CameraTransferPipeline(cameraService: camera) + pipeline.configure(uploadSink: sink, uploadEnabled: true) + + for index in 1 ... 10 { + let filename = "DSC_\(index).JPG" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + try Data(repeating: UInt8(index), count: 8).write(to: tempURL) + camera.downloadURLs[filename] = tempURL + camera.onNewAsset?( + CameraAsset(id: "ptp_\(index)", filename: filename, fileSize: 1024) + ) + } + + try await Task.sleep(nanoseconds: 2_000_000_000) + + XCTAssertEqual(sink.uploadCount, 10) + XCTAssertGreaterThan(sink.maxActiveUploads, 1) + XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3) + for index in 1 ... 10 { + XCTAssertEqual(pipeline.task(forAssetID: "ptp_\(index)")?.status, .uploaded) + } + } + + /// 测试高频上传进度会被合并通知,避免连拍时频繁刷新 SwiftUI 列表。 + func testUploadProgressNotificationsAreThrottled() async throws { + let camera = MockCameraService() + let sink = MockChattyUploadSink() + let pipeline = CameraTransferPipeline(cameraService: camera) + pipeline.configure(uploadSink: sink, uploadEnabled: true) + + var updateCount = 0 + pipeline.onTasksUpdated = { _ in updateCount += 1 } + + camera.onNewAsset?(CameraAsset(id: "ptp_chatty", filename: "chatty.JPG", fileSize: 1024)) + try await Task.sleep(nanoseconds: 800_000_000) + + XCTAssertEqual(pipeline.task(forAssetID: "ptp_chatty")?.status, .uploaded) + XCTAssertLessThan(updateCount, 18) + } } @MainActor @@ -62,6 +128,54 @@ private final class MockCameraService: CameraServiceProtocol { } } +@MainActor +private final class MockBurstCameraService: CameraServiceProtocol { + let brand: CameraBrand = .sony + var connectionState: CameraConnectionState = .connected(deviceName: "Mock") + var onConnectionStateChange: ((CameraConnectionState) -> Void)? + var onNewAsset: ((CameraAsset) -> Void)? + var downloadURLs: [String: URL] = [:] + + func connect() async {} + func disconnect() async {} + + func listAssets() async throws -> [CameraAsset] { [] } + + func downloadAsset(_ asset: CameraAsset) async throws -> URL { + try await Task.sleep(nanoseconds: 30_000_000) + if let url = downloadURLs[asset.filename] { + return url + } + let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename) + if !FileManager.default.fileExists(atPath: url.path) { + try Data(repeating: 0xFF, count: 8).write(to: url) + } + return url + } +} + +@MainActor +private final class MockSlowUploadSink: CameraAssetUploadSink { + var uploadCount = 0 + var activeUploads = 0 + var maxActiveUploads = 0 + + func upload( + localURL: URL, + fileName: String, + fileType: Int, + progress: @escaping @Sendable (Int) -> Void + ) async throws -> String { + uploadCount += 1 + activeUploads += 1 + maxActiveUploads = max(maxActiveUploads, activeUploads) + try await Task.sleep(nanoseconds: 150_000_000) + activeUploads -= 1 + progress(100) + return "https://cdn/mock/\(fileName)" + } +} + @MainActor private final class MockUploadSink: CameraAssetUploadSink { var uploadCount = 0 @@ -77,3 +191,18 @@ private final class MockUploadSink: CameraAssetUploadSink { return "https://cdn/mock/\(fileName)" } } + +@MainActor +private final class MockChattyUploadSink: CameraAssetUploadSink { + func upload( + localURL: URL, + fileName: String, + fileType: Int, + progress: @escaping @Sendable (Int) -> Void + ) async throws -> String { + for value in 0 ... 100 { + progress(value) + } + return "https://cdn/mock/\(fileName)" + } +} diff --git a/suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift index 6fcb12b..75b85c6 100644 --- a/suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift @@ -61,6 +61,29 @@ final class TravelAlbumAPITests: XCTestCase { XCTAssertEqual(body["file_url"] as? String, "https://cdn/a.jpg") } + /// 测试素材列表接口携带 order_by 与 is_purchased 参数。 + func testMaterialListUsesOrderByAndPurchasedQuery() async throws { + let session = TravelAlbumRecordingURLSession(data: Self.materialListResponse) + let api = TravelAlbumAPI(client: APIClient(session: session)) + + _ = try await api.materialList( + userEquityTravelId: 8, + page: 2, + pageSize: 30, + orderBy: 3, + isPurchased: 1 + ) + + let components = try XCTUnwrap(URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false)) + let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) }) + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/travel-album/material-list") + XCTAssertEqual(query["user_equity_travel_id"], "8") + XCTAssertEqual(query["page"], "2") + XCTAssertEqual(query["page_size"], "30") + XCTAssertEqual(query["order_by"], "3") + XCTAssertEqual(query["is_purchased"], "1") + } + fileprivate static let listResponse = Data( #"{"code":100000,"msg":"success","data":{"total":"1","list":[{"id":"8","store_user_id":"1","name":"2026-06-29-001","type":"1","order_number":"","material_num":"3","material_price":"990","material_package_price":"9900","photo_price":"0","cover_url":"","user_id":"100","status":"1","created_at":"2026","updated_at":"2026","user":{"id":"200","phone":"13800138000"}}]}}"#.utf8 ) @@ -72,6 +95,10 @@ final class TravelAlbumAPITests: XCTestCase { fileprivate static let uploadMaterialResponse = Data( #"{"code":100000,"msg":"success","data":{"id":"11","user_equity_travel_id":"8","status":"1","order_number":"","user_id":"100","file_name":"DSC_001.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}}"#.utf8 ) + + fileprivate static let materialListResponse = Data( + #"{"code":100000,"msg":"success","data":{"total":"0","list":[]}}"#.utf8 + ) } private final class TravelAlbumRecordingURLSession: URLSessionProtocol { diff --git a/suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift index 7dd928d..2d952db 100644 --- a/suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift @@ -81,6 +81,139 @@ final class TravelAlbumViewModelTests: XCTestCase { ) XCTAssertTrue(name.hasSuffix("003")) } + + /// 测试切换已购 Tab 时携带 is_purchased 参数并重置选择状态。 + func testDetailSelectPurchasedTabUsesPurchasedFilter() async { + let viewModel = TravelAlbumDetailViewModel() + let api = MockTravelAlbumDetailAPI() + + await viewModel.refreshAll(api: api, albumID: 8) + viewModel.isSelectionMode = true + viewModel.selectedMaterialIDs = [1] + + await viewModel.selectTab(TravelAlbumDetailViewModel.tabPurchased, api: api) + + XCTAssertEqual(viewModel.selectedTab, TravelAlbumDetailViewModel.tabPurchased) + XCTAssertFalse(viewModel.isSelectionMode) + XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty) + XCTAssertEqual(api.materialListCalls.last?.isPurchased, 1) + } + + /// 测试排序变更触发重载。 + func testDetailSetOrderByReloadsMaterials() async { + let viewModel = TravelAlbumDetailViewModel() + let api = MockTravelAlbumDetailAPI() + + await viewModel.refreshAll(api: api, albumID: 8) + let initialCount = api.materialListCalls.count + + await viewModel.setOrderBy(3, api: api) + + XCTAssertEqual(viewModel.orderBy, 3) + XCTAssertGreaterThan(api.materialListCalls.count, initialCount) + XCTAssertEqual(api.materialListCalls.last?.orderBy, 3) + } + + /// 测试仅未购买素材可选中。 + func testDetailToggleSelectionRejectsPurchasedMaterial() async { + let viewModel = TravelAlbumDetailViewModel() + viewModel.isSelectionMode = true + + let purchased = try! decodeMaterial(id: 2, status: 2) + viewModel.toggleMaterialSelection(purchased) + XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty) + XCTAssertEqual(viewModel.errorMessage, "仅未购买素材可删除") + + let unpurchased = try! decodeMaterial(id: 3, status: TravelAlbumDetailViewModel.materialStatusUnpurchased) + viewModel.toggleMaterialSelection(unpurchased) + XCTAssertEqual(viewModel.selectedMaterialIDs, [3]) + } + + /// 测试批量删除按顺序调用 API。 + func testDetailConfirmDeleteSelectedMaterialsDeletesInOrder() async { + let viewModel = TravelAlbumDetailViewModel() + let api = MockTravelAlbumDetailAPI() + viewModel.selectedMaterialIDs = [11, 12, 13] + + let success = await viewModel.confirmDeleteSelectedMaterials(api: api) + + XCTAssertTrue(success) + XCTAssertEqual(Set(api.deletedMaterialIDs), Set([11, 12, 13])) + XCTAssertFalse(viewModel.isSelectionMode) + XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty) + } + + /// 测试全部 Tab 下 allPhotoCount 与 total 同步。 + func testDetailAllPhotoCountSyncsWithTotalOnAllTab() async { + let viewModel = TravelAlbumDetailViewModel() + let api = MockTravelAlbumDetailAPI(materialTotal: 42) + + await viewModel.refreshAll(api: api, albumID: 8) + + XCTAssertEqual(viewModel.allPhotoCount, 42) + } +} + +private func decodeMaterial(id: Int, status: Int) throws -> TravelAlbumMaterial { + let json = """ + {"code":100000,"msg":"success","data":{"id":"\(id)","user_equity_travel_id":"8","status":"\(status)","order_number":"","user_id":"100","file_name":"DSC.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}} + """ + let data = Data(json.utf8) + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data) + return try XCTUnwrap(envelope.data) +} + +@MainActor +private final class MockTravelAlbumDetailAPI: TravelAlbumServing { + struct MaterialListCall { + let orderBy: Int + let isPurchased: Int? + let page: Int + } + + var materialTotal: Int + var materialListCalls: [MaterialListCall] = [] + var deletedMaterialIDs: [Int] = [] + + init(materialTotal: Int = 5) { + self.materialTotal = materialTotal + } + + func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] } + func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse { + TravelAlbumCreateResponse(id: 1) + } + func albumList(page: Int, pageSize: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + func albumInfo(id: Int) async throws -> TravelAlbumItem { + let data = Data( + #"{"code":100000,"msg":"success","data":{"id":"8","store_user_id":"1","name":"2026-06-30-001","type":"1","order_number":"","material_num":"3","material_price":"990","material_package_price":"9900","photo_price":"0","cover_url":"","user_id":"100","status":"1","created_at":"2026-06-30","updated_at":"2026-06-30","user":{"id":"200","phone":"13800138000"}}}"#.utf8 + ) + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data) + return try XCTUnwrap(envelope.data) + } + func editAlbum(_ request: TravelAlbumEditRequest) async throws {} + func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {} + func materialList( + userEquityTravelId: Int, + page: Int, + pageSize: Int, + orderBy: Int, + isPurchased: Int? + ) async throws -> ListPayload { + materialListCalls.append(MaterialListCall(orderBy: orderBy, isPurchased: isPurchased, page: page)) + return ListPayload(total: materialTotal, list: []) + } + func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { + throw APIError.emptyData + } + func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws { + deletedMaterialIDs.append(request.id) + } + func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse { + TravelAlbumMpCodeResponse(mpCodeOssURL: "") + } } @MainActor @@ -108,7 +241,13 @@ private final class MockTravelAlbumAPI: TravelAlbumServing { func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {} - func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + func materialList( + userEquityTravelId: Int, + page: Int, + pageSize: Int, + orderBy: Int, + isPurchased: Int? + ) async throws -> ListPayload { ListPayload(total: 0, list: []) } diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift index 58e4ebb..7e4aa4b 100644 --- a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -6,6 +6,7 @@ // import XCTest +import UIKit @testable import suixinkan @MainActor @@ -15,7 +16,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase { func testFirstNewPipelineTaskAppearsInPhotos() async throws { let camera = MockWiredCameraService() let context = WiredTransferContext( - albumId: 1, + albumId: 6603, albumName: "测试相册", phone: "", orderNumber: "" @@ -125,10 +126,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase { context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""), cameraService: MockWiredCameraService() ) - viewModel.photos = [ + viewModel.replacePhotos([ makePendingPhoto(id: "today", capturedAt: today), makePendingPhoto(id: "old", capturedAt: "2020-01-01 09:00:00"), - ] + ]) await viewModel.onSpecifyUploadOptionSelected( WiredCameraTransferViewModel.specifyUploadTodayCaptured, @@ -139,23 +140,257 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertNil(viewModel.errorMessage) } + /// 测试上传进度更新时保留 capturedAt,避免列表分组抖动。 + func testPipelineProgressUpdatePreservesCapturedAt() async throws { + let camera = MockWiredCameraService() + let context = WiredTransferContext( + albumId: 1, + albumName: "测试相册", + phone: "", + orderNumber: "" + ) + let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) + let account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) + + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1, + accountContext: account + ) + let originalCapturedAt = "2026-05-20 12:05:18" + viewModel.replacePhotos([ + WiredTransferPhotoItem( + id: "ptp_progress", + fileName: "DSC_PROGRESS.JPG", + thumbnailURL: "", + previewURL: "", + capturedAt: originalCapturedAt, + fileSizeText: "2 MB", + status: .uploading, + progress: 10 + ), + ]) + + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG") + try Data(repeating: 0xAB, count: 16).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + camera.downloadURL = fileURL + camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16)) + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(viewModel.photos.first?.capturedAt, originalCapturedAt) + } + + /// 测试较新拍摄时间的照片排在列表前面。 + func testNewerCapturedPhotoAppearsFirst() async throws { + let camera = MockWiredCameraService() + let context = WiredTransferContext( + albumId: 6604, + albumName: "测试相册", + phone: "", + orderNumber: "" + ) + let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) + let account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) + + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1, + accountContext: account + ) + + let fileURL = FileManager.default.temporaryDirectory + let olderURL = fileURL.appendingPathComponent("OLDER.JPG") + let newerURL = fileURL.appendingPathComponent("NEWER.JPG") + try Data(repeating: 0xAA, count: 8).write(to: olderURL) + try Data(repeating: 0xBB, count: 8).write(to: newerURL) + defer { + try? FileManager.default.removeItem(at: olderURL) + try? FileManager.default.removeItem(at: newerURL) + } + + camera.downloadURL = olderURL + camera.onNewAsset?( + CameraAsset( + id: "ptp_old", + filename: "OLDER.JPG", + fileSize: 8, + creationDate: Date(timeIntervalSince1970: 1_700_000_000) + ) + ) + try await Task.sleep(nanoseconds: 400_000_000) + + camera.downloadURL = newerURL + camera.onNewAsset?( + CameraAsset( + id: "ptp_new", + filename: "NEWER.JPG", + fileSize: 8, + creationDate: Date(timeIntervalSince1970: 1_800_000_000) + ) + ) + try await Task.sleep(nanoseconds: 400_000_000) + + XCTAssertEqual(viewModel.photos.count, 2) + XCTAssertEqual(viewModel.photos[0].id, "ptp_new") + XCTAssertEqual(viewModel.photos[1].id, "ptp_old") + } + + /// 测试拍后传输模式下重传会从持久化记录恢复上传。 + func testRetryPhotoInPostShootModeUploadsPersistedFile() async throws { + let camera = MockWiredCameraService() + let context = WiredTransferContext( + albumId: 1, + albumName: "测试相册", + phone: "", + orderNumber: "" + ) + let account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) + + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_failed.JPG") + try Data(repeating: 0xCD, count: 32).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let photoID = "persist_failed_001" + let record = WiredTransferPhotoRecord( + id: photoID, + sourceId: photoID, + fileName: "retry_failed.JPG", + localPath: CameraDownloadStorage.relativePath(for: fileURL), + thumbnailPath: "", + capturedAt: "2026-05-20 12:05:18", + fileSizeBytes: 32, + status: WiredTransferUploadStatus.failed.rawValue, + progress: 0, + errorMessage: "upload failed", + albumId: 1, + userId: "101", + remoteURL: "", + updatedAt: Date().timeIntervalSince1970 + ) + let store = WiredTransferPhotoStore( + accountPrefixProvider: { account.accountCachePrefix ?? "guest_" }, + userIDProvider: { "101" } + ) + store.save(albumID: 1, records: [record]) + + let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) + viewModel.transferModeOption = WiredCameraTransferViewModel.modePostShootTransfer + + let api = MockTravelAlbumAPIForWiredTransfer() + let oss = WiredTransferMockOSSUploadService() + await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account) + + XCTAssertEqual(viewModel.photos.first?.id, photoID) + XCTAssertEqual(viewModel.photos.first?.status, .failed) + + await viewModel.retryPhoto(id: photoID, api: api, ossService: oss, scenicID: 1) + try await Task.sleep(nanoseconds: 500_000_000) + + XCTAssertEqual(viewModel.photos.first?.status, .uploaded) + } + + /// 测试下载完成后生成本地缩略图,并优先用于列表展示。 + func testDownloadedPhotoGeneratesThumbnailForList() async throws { + let camera = MockWiredCameraService() + let albumID = 6601 + let context = WiredTransferContext(albumId: albumID, albumName: "缩略图相册", phone: "", orderNumber: "") + let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) + let account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: "thumb-user", displayName: "测试")) + + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1, + accountContext: account + ) + + let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG") + try makeJPEGData().write(to: imageURL) + defer { try? FileManager.default.removeItem(at: imageURL) } + + camera.downloadURL = imageURL + camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128)) + try await Task.sleep(nanoseconds: 900_000_000) + + let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" }) + XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/Thumbnails/")) + XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/")) + XCTAssertFalse(photo.previewURL.contains("/Thumbnails/")) + } + + /// 测试旧记录没有缩略图时列表先占位,但仍保留原图预览能力。 + func testLegacyRecordWithoutThumbnailKeepsPreviewURL() async throws { + let albumID = 6602 + let account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: "legacy-user", displayName: "测试")) + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy_only.JPG") + try makeJPEGData().write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let record = WiredTransferPhotoRecord( + id: "legacy_photo_001", + sourceId: "legacy_photo_001", + fileName: "legacy_only.JPG", + localPath: CameraDownloadStorage.relativePath(for: fileURL), + thumbnailPath: "", + capturedAt: "2026-05-20 12:05:18", + fileSizeBytes: 128, + status: WiredTransferUploadStatus.pending.rawValue, + progress: 0, + errorMessage: nil, + albumId: albumID, + userId: "legacy-user", + remoteURL: "", + updatedAt: Date().timeIntervalSince1970 + ) + let store = WiredTransferPhotoStore( + accountPrefixProvider: { account.accountCachePrefix ?? "guest_" }, + userIDProvider: { "legacy-user" } + ) + store.save(albumID: albumID, records: [record]) + + let viewModel = WiredCameraTransferViewModel( + context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: ""), + cameraService: MockWiredCameraService() + ) + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1, + accountContext: account + ) + + let photo = try XCTUnwrap(viewModel.photos.first) + XCTAssertEqual(photo.thumbnailURL, "") + XCTAssertFalse(photo.previewURL.isEmpty) + } + private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel { let viewModel = WiredCameraTransferViewModel( context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""), cameraService: MockWiredCameraService() ) - viewModel.photos = [ + viewModel.replacePhotos([ makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"), makePendingPhoto(id: "pending-2", capturedAt: "2026-05-20 12:18:06"), WiredTransferPhotoItem( id: "uploaded-1", fileName: "uploaded.JPG", thumbnailURL: "", + previewURL: "", capturedAt: "2026-05-20 12:20:00", fileSizeText: "1 MB", status: .uploaded ), - ] + ]) return viewModel } @@ -169,6 +404,14 @@ final class WiredCameraTransferViewModelTests: XCTestCase { status: .pending ) } + + private func makeJPEGData() -> Data { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8)) + return renderer.jpegData(withCompressionQuality: 0.9) { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) + } + } } @MainActor @@ -210,7 +453,13 @@ private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing { func albumInfo(id: Int) async throws -> TravelAlbumItem { throw APIError.emptyData } func editAlbum(_ request: TravelAlbumEditRequest) async throws {} func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {} - func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + func materialList( + userEquityTravelId: Int, + page: Int, + pageSize: Int, + orderBy: Int, + isPurchased: Int? + ) async throws -> ListPayload { ListPayload(total: 0, list: []) } func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { diff --git a/suixinkanTests/TravelAlbum/WiredTransferPhotoGroupingTests.swift b/suixinkanTests/TravelAlbum/WiredTransferPhotoGroupingTests.swift index f16fd7a..d5d0f29 100644 --- a/suixinkanTests/TravelAlbum/WiredTransferPhotoGroupingTests.swift +++ b/suixinkanTests/TravelAlbum/WiredTransferPhotoGroupingTests.swift @@ -19,6 +19,7 @@ final class WiredTransferPhotoGroupingTests: XCTestCase { let sections = photos.buildPhotoSections() XCTAssertEqual(sections.count, 2) XCTAssertEqual(sections[0].photos.count, 2) + XCTAssertEqual(sections[0].photos[0].id, "2") XCTAssertTrue(sections[0].headerTitle.contains("12:00 - 12:30")) let sidebar = photos.buildSidebarGroups() @@ -39,6 +40,17 @@ final class WiredTransferPhotoGroupingTests: XCTestCase { XCTAssertFalse(makePhoto(id: "old", capturedAt: "2020-01-01 10:00:00", status: .pending).isCapturedToday()) } + /// 测试按拍摄时间倒序排列,最新照片在最前。 + func testSortedByCaptureTimeDescending() { + let photos = [ + makePhoto(id: "1", capturedAt: "2026-05-20 12:05:18", status: .uploaded), + makePhoto(id: "2", capturedAt: "2026-05-20 12:18:06", status: .pending), + makePhoto(id: "3", capturedAt: "2026-05-20 11:55:51", status: .failed), + ] + + XCTAssertEqual(photos.sortedByCaptureTimeDescending().map(\.id), ["2", "1", "3"]) + } + /// 测试 Tab 计数与 pending 可选集合。 func testTransferTabCountsAndSelectablePendingIDs() { let photos = [