From c71b45bdfd796373e94f1466681d0d943b178f86 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 11:16:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E9=BD=90=E6=9C=89=E7=BA=BF=E4=BC=A0?= =?UTF-8?q?=E5=9B=BE=E9=A1=B5=20Android=20UI=EF=BC=8C=E5=B9=B6=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E7=9B=B8=E5=AF=B9=E8=B7=AF=E5=BE=84=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E6=9C=AC=E5=9C=B0=E7=85=A7=E7=89=87=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。 Co-authored-by: Cursor --- .../Contents.json | 11 + .../Core/CameraTethering/CameraTethering.md | 2 +- .../Storage/CameraDownloadStorage.swift | 73 +++- .../CameraTransferPipeline.swift | 4 +- .../CameraTransfer/CameraTransferTask.swift | 4 +- .../Models/TravelAlbumModels.swift | 171 +++++++- .../Services/DeviceStorageHelper.swift | 58 +++ .../Services/WiredTransferPhotoStore.swift | 80 +++- suixinkan/Features/TravelAlbum/TravelAlbum.md | 35 +- .../ViewModels/TravelAlbumViewModels.swift | 222 ++++++++++- .../Views/WiredCameraTransferView.swift | 370 ++++++++---------- .../SpecifyUploadBottomSheet.swift | 97 +++++ .../WiredTransferBottomBar.swift | 55 +++ .../WiredTransferControlCard.swift | 238 +++++++++++ .../WiredTransfer/WiredTransferDesign.swift | 35 ++ .../WiredTransferEmptyState.swift | 66 ++++ .../WiredTransferFilterChip.swift | 49 +++ .../WiredTransferHeaderView.swift | 45 +++ .../WiredTransferHelpDocView.swift | 65 +++ .../WiredTransferPhotoListPanel.swift | 94 +++++ .../WiredTransferPhotoPreviewSheet.swift | 63 +++ .../WiredTransfer/WiredTransferPhotoRow.swift | 120 ++++++ .../WiredTransferSelectAllBar.swift | 48 +++ .../WiredTransferStatusBadge.swift | 39 ++ .../WiredTransferTimeSidebar.swift | 96 +++++ .../CameraDownloadStorageTests.swift | 47 +++ .../CameraTransferPipelineTests.swift | 11 +- .../WiredCameraTransferViewModelTests.swift | 87 ++++ .../WiredTransferPhotoGroupingTests.swift | 68 ++++ 29 files changed, 2097 insertions(+), 256 deletions(-) create mode 100644 suixinkan/Assets.xcassets/wired_transfer_empty.imageset/Contents.json create mode 100644 suixinkan/Features/TravelAlbum/Services/DeviceStorageHelper.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/SpecifyUploadBottomSheet.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferBottomBar.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferDesign.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferEmptyState.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferFilterChip.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferHeaderView.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferHelpDocView.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoListPanel.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoPreviewSheet.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferSelectAllBar.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferStatusBadge.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferTimeSidebar.swift create mode 100644 suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift create mode 100644 suixinkanTests/TravelAlbum/WiredTransferPhotoGroupingTests.swift diff --git a/suixinkan/Assets.xcassets/wired_transfer_empty.imageset/Contents.json b/suixinkan/Assets.xcassets/wired_transfer_empty.imageset/Contents.json new file mode 100644 index 0000000..6fe3320 --- /dev/null +++ b/suixinkan/Assets.xcassets/wired_transfer_empty.imageset/Contents.json @@ -0,0 +1,11 @@ +{ + "images" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Core/CameraTethering/CameraTethering.md b/suixinkan/Core/CameraTethering/CameraTethering.md index 69c4bd2..acec950 100644 --- a/suixinkan/Core/CameraTethering/CameraTethering.md +++ b/suixinkan/Core/CameraTethering/CameraTethering.md @@ -18,7 +18,7 @@ 1. `SonyCameraService.connect()` 启动 USB 设备搜索。 2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手。 -3. 拍摄事件触发 PTP 下载,文件写入 `Documents/CameraDownloads/`。 +3. 拍摄事件触发 PTP 下载,文件写入 `Documents/CameraDownloads/`,持久化路径为相对路径(如 `CameraDownloads/1730_DSC.JPG`)。 4. 通过 `onNewAsset` 回调通知上层有新照片。 ## 依赖 diff --git a/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift b/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift index 8e17b95..21c46bd 100644 --- a/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift +++ b/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift @@ -7,16 +7,74 @@ import Foundation -/// 相机下载文件本地存储工具,负责目录创建与安全文件名处理。 +/// 相机下载文件本地存储工具,负责目录创建、相对路径持久化与安全文件名处理。 enum CameraDownloadStorage { + static let cameraDownloadsFolderName = "CameraDownloads" + + /// Documents 根目录。 + static var documentsDirectory: URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + } + /// 相机照片下载根目录。 static var downloadsDirectory: URL { - let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let directory = documents.appendingPathComponent("CameraDownloads", isDirectory: true) + let directory = documentsDirectory.appendingPathComponent(cameraDownloadsFolderName, 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)" + } + + /// 将持久化的相对或旧版绝对路径解析为当前沙盒内的文件 URL。 + static func resolveLocalURL(from storedPath: String) -> URL? { + guard !storedPath.isEmpty else { return nil } + + if storedPath.hasPrefix("http://") || storedPath.hasPrefix("https://") { + return URL(string: storedPath) + } + + if storedPath.hasPrefix("/") { + let legacyURL = URL(fileURLWithPath: storedPath) + if FileManager.default.fileExists(atPath: legacyURL.path) { + return legacyURL + } + let candidate = downloadsDirectory.appendingPathComponent(legacyURL.lastPathComponent) + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + return legacyURL + } + + return documentsDirectory.appendingPathComponent(storedPath) + } + + /// 将旧版绝对路径迁移为相对路径;若文件已在当前 CameraDownloads 中则更新引用。 + static func migrateStoredPath(_ storedPath: String) -> String? { + guard storedPath.hasPrefix("/") else { return storedPath } + + let legacyURL = URL(fileURLWithPath: storedPath) + if FileManager.default.fileExists(atPath: legacyURL.path) { + return relativePath(for: legacyURL) + } + + let candidate = downloadsDirectory.appendingPathComponent(legacyURL.lastPathComponent) + if FileManager.default.fileExists(atPath: candidate.path) { + return relativePath(for: candidate) + } + + return relativePath(for: legacyURL) + } + + /// 判断文件是否位于 CameraDownloads 目录内。 + static func isInDownloadsDirectory(_ url: URL) -> Bool { + let downloadsPath = downloadsDirectory.standardizedFileURL.path + let filePath = url.standardizedFileURL.path + return filePath.hasPrefix(downloadsPath) || filePath.hasPrefix("/private" + downloadsPath) + } + /// 生成带时间戳的唯一本地文件 URL,避免文件名冲突。 static func uniqueLocalURL(for filename: String) -> URL { let timestamp = Int(Date().timeIntervalSince1970) @@ -44,4 +102,13 @@ enum CameraDownloadStorage { } return name } + + /// 将本地存储路径转为可用于 UI 预览的 file URL 字符串。 + static func previewURLString(from storedPath: String) -> String { + guard let url = resolveLocalURL(from: storedPath), + FileManager.default.fileExists(atPath: url.path) else { + return "" + } + return url.absoluteString + } } diff --git a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift index 54da877..8372433 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift @@ -164,7 +164,7 @@ final class CameraTransferPipeline { let localURL = try await cameraService.downloadAsset(resolvedAsset) let destination: URL - if localURL.path.hasPrefix(CameraDownloadStorage.downloadsDirectory.path) { + if CameraDownloadStorage.isInDownloadsDirectory(localURL) { destination = localURL } else { destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename) @@ -174,7 +174,7 @@ final class CameraTransferPipeline { try FileManager.default.moveItem(at: localURL, to: destination) } - tasks[index].localPath = destination.path + tasks[index].localPath = CameraDownloadStorage.relativePath(for: destination) tasks[index].status = .downloaded tasks[index].progress = 0 notify() diff --git a/suixinkan/Core/CameraTransfer/CameraTransferTask.swift b/suixinkan/Core/CameraTransfer/CameraTransferTask.swift index 14e559c..146c085 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferTask.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferTask.swift @@ -48,9 +48,9 @@ struct CameraTransferTask: Identifiable, Equatable, Codable { self.errorMessage = errorMessage } - /// 本地文件 URL,仅在路径有效时返回。 + /// 本地文件 URL,支持相对路径与旧版绝对路径。 var localURL: URL? { guard let localPath, !localPath.isEmpty else { return nil } - return URL(fileURLWithPath: localPath) + return CameraDownloadStorage.resolveLocalURL(from: localPath) } } diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index 2fa9ffe..ea31f2f 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -281,6 +281,28 @@ enum WiredTransferUploadStatus: String, Codable, Equatable { } } +/// 有线传图时间侧栏时间段。 +struct WiredTransferTimeSlot: Identifiable, Equatable { + let id: String + let dateLabel: String + let slotStartLabel: String + let photoCount: Int +} + +/// 有线传图时间侧栏日期分组。 +struct WiredTransferDateGroup: Equatable { + let dateLabel: String + let slots: [WiredTransferTimeSlot] +} + +/// 有线传图列表分组(30 分钟时间段)。 +struct WiredTransferPhotoSection: Identifiable, Equatable { + var id: String { slotId } + let slotId: String + let headerTitle: String + let photos: [WiredTransferPhotoItem] +} + /// 有线传图列表项。 struct WiredTransferPhotoItem: Identifiable, Equatable { let id: String @@ -289,6 +311,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable { let thumbnailURL: String let capturedAt: String let fileSizeText: String + let resolutionText: String let status: WiredTransferUploadStatus let progress: Int let errorMessage: String? @@ -300,6 +323,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable { thumbnailURL: String, capturedAt: String, fileSizeText: String, + resolutionText: String = "", status: WiredTransferUploadStatus, progress: Int = 0, errorMessage: String? = nil @@ -310,6 +334,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable { self.thumbnailURL = thumbnailURL self.capturedAt = capturedAt self.fileSizeText = fileSizeText + self.resolutionText = resolutionText self.status = status self.progress = progress self.errorMessage = errorMessage @@ -322,6 +347,24 @@ struct WiredTransferPhotoItem: Identifiable, Equatable { var isNotUploaded: Bool { status == .pending || status == .failed } + + /// 解析拍摄时间。 + func capturedDate() -> Date? { + Self.capturedAtFormatter.date(from: capturedAt) + } + + /// 是否为今日拍摄。 + func isCapturedToday(calendar: Calendar = .current) -> Bool { + guard let date = capturedDate() else { return false } + return calendar.isDateInToday(date) + } + + private static let capturedAtFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() } /// 有线传图持久化记录。 @@ -342,18 +385,14 @@ struct WiredTransferPhotoRecord: Codable, Equatable { let updatedAt: TimeInterval func toPhotoItem() -> WiredTransferPhotoItem { - let previewPath = [thumbnailPath, localPath, remoteURL] - .first { path in - !path.isEmpty && (path.hasPrefix("http") || FileManager.default.fileExists(atPath: path)) - } ?? "" - let thumbnailURL: String - if previewPath.hasPrefix("http") { - thumbnailURL = previewPath - } else if !previewPath.isEmpty { - thumbnailURL = "file://\(previewPath)" - } else { - thumbnailURL = "" - } + let previewCandidates = [thumbnailPath, localPath, remoteURL] + let thumbnailURL = previewCandidates.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 ?? "" + return WiredTransferPhotoItem( id: id, sourceId: sourceId, @@ -425,6 +464,114 @@ extension Array where Element == WiredTransferPhotoItem { var selectablePendingPhotoIDs: Set { Set(filter { $0.canSelectForSpecifyUpload }.map(\.id)) } + + /// 构建 30 分钟时间段分组列表。 + func buildPhotoSections() -> [WiredTransferPhotoSection] { + let grouped = Dictionary(grouping: compactMap { photo -> (Date, WiredTransferPhotoItem)? in + guard let date = photo.capturedDate() else { return nil } + return (WiredTransferTimeGrouping.halfHourSlotStart(for: date), photo) + }) { $0.0 } + + return grouped.keys + .sorted(by: >) + .map { slotStart in + let photos = (grouped[slotStart] ?? []) + .map(\.1) + .sorted { ($0.capturedDate() ?? .distantPast) > ($1.capturedDate() ?? .distantPast) } + return WiredTransferPhotoSection( + slotId: WiredTransferTimeGrouping.slotID(for: slotStart), + headerTitle: WiredTransferTimeGrouping.sectionHeaderTitle(for: slotStart), + photos: photos + ) + } + } + + /// 构建时间侧栏分组。 + func buildSidebarGroups() -> [WiredTransferDateGroup] { + var slotCounts: [Date: Int] = [:] + for photo in self { + guard let date = photo.capturedDate() else { continue } + let slotStart = WiredTransferTimeGrouping.halfHourSlotStart(for: date) + slotCounts[slotStart, default: 0] += 1 + } + + let calendar = Calendar.current + let groupedByDay = Dictionary(grouping: slotCounts.keys) { calendar.startOfDay(for: $0) } + + return groupedByDay.keys + .sorted(by: >) + .map { day in + let slots = (groupedByDay[day] ?? []) + .sorted(by: >) + .compactMap { slotStart -> WiredTransferTimeSlot? in + guard let count = slotCounts[slotStart] else { return nil } + return WiredTransferTimeSlot( + id: WiredTransferTimeGrouping.slotID(for: slotStart), + dateLabel: WiredTransferTimeGrouping.dateLabel(for: slotStart), + slotStartLabel: WiredTransferTimeGrouping.timeLabel(for: slotStart), + photoCount: count + ) + } + return WiredTransferDateGroup( + dateLabel: WiredTransferTimeGrouping.dateLabel(for: day), + slots: slots + ) + } + } +} + +/// 有线传图 30 分钟时间段分组工具。 +enum WiredTransferTimeGrouping { + /// 计算 30 分钟时间段起点。 + static func halfHourSlotStart(for date: Date, calendar: Calendar = .current) -> Date { + var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) + let minute = (components.minute ?? 0) / 30 * 30 + components.minute = minute + components.second = 0 + components.nanosecond = 0 + return calendar.date(from: components) ?? date + } + + /// 时间段唯一标识。 + static func slotID(for slotStart: Date) -> String { + slotIDFormatter.string(from: slotStart) + } + + /// 分组标题,例如「05月20日 12:00 - 12:30」。 + static func sectionHeaderTitle(for slotStart: Date) -> String { + "\(dateLabel(for: slotStart)) \(timeLabel(for: slotStart)) - \(timeLabel(for: slotStart.addingTimeInterval(30 * 60)))" + } + + /// 日期标签,例如「05月20日」。 + static func dateLabel(for date: Date) -> String { + dateLabelFormatter.string(from: date) + } + + /// 时间标签,例如「12:05」。 + static func timeLabel(for date: Date) -> String { + timeLabelFormatter.string(from: date) + } + + private static let slotIDFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm" + return formatter + }() + + private static let dateLabelFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "MM月dd日" + return formatter + }() + + private static let timeLabelFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "HH:mm" + return formatter + }() } private extension KeyedDecodingContainer { diff --git a/suixinkan/Features/TravelAlbum/Services/DeviceStorageHelper.swift b/suixinkan/Features/TravelAlbum/Services/DeviceStorageHelper.swift new file mode 100644 index 0000000..4b65ff4 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Services/DeviceStorageHelper.swift @@ -0,0 +1,58 @@ +// +// DeviceStorageHelper.swift +// suixinkan +// +// 读取本机型号与可用存储,对齐 Android DeviceStorageHelper。 +// + +import Foundation +import UIKit + +/// 设备存储快照,用于有线传图页设备信息面板。 +struct DeviceStorageSnapshot: Equatable { + let modelName: String + let availableGbText: String +} + +/// 设备存储信息读取工具。 +enum DeviceStorageHelper { + /// 读取当前设备型号与 Documents 目录可用空间。 + static func readStorageSnapshot() -> DeviceStorageSnapshot { + let availableBytes = availableDocumentsBytes() + let availableGb = Double(availableBytes) / 1024.0 / 1024.0 / 1024.0 + return DeviceStorageSnapshot( + modelName: buildModelName(), + availableGbText: String(format: "还剩 %.1fGB 可用", availableGb) + ) + } + + private static func buildModelName() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(cString: $0) + } + } + let marketingName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) + if !marketingName.isEmpty, marketingName != "iPhone", !marketingName.hasPrefix("iPad") { + return marketingName + } + if !machine.isEmpty { return machine } + return UIDevice.current.model + } + + private static func availableDocumentsBytes() -> Int64 { + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + guard let documentsURL else { return 0 } + let values = try? documentsURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + if let capacity = values?.volumeAvailableCapacityForImportantUsage { + return Int64(capacity) + } + if let attributes = try? FileManager.default.attributesOfFileSystem(forPath: documentsURL.path), + let freeSize = attributes[.systemFreeSize] as? NSNumber { + return freeSize.int64Value + } + return 0 + } +} diff --git a/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift index fbe5e41..4fd39a5 100644 --- a/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift @@ -60,20 +60,44 @@ final class WiredTransferPhotoStore { let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data) else { return [] } - return records - .filter { record in - !deletedIDs.contains(record.id) - && record.userId == userID - && record.albumId == albumID - && record.isDisplayable - } - .map { $0.normalizeInterruptedTransfer() } + let filteredRecords = records.filter { record in + !deletedIDs.contains(record.id) + && record.userId == userID + && record.albumId == albumID + } + + let normalizedRecords = filteredRecords.map { $0.normalizeInterruptedTransfer() } + let migratedRecords = normalizeStoredPaths(normalizedRecords, albumID: albumID) + + return migratedRecords + .filter(\.isDisplayable) .sorted { $0.capturedAt > $1.capturedAt } .also { items in items.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) } } } + /// 将旧版绝对路径迁移为相对路径并回写持久化。 + private func normalizeStoredPaths( + _ records: [WiredTransferPhotoRecord], + albumID: Int + ) -> [WiredTransferPhotoRecord] { + var migrated = records + var needsPersist = false + + for index in migrated.indices { + guard let normalized = migrated[index].withNormalizedStoredPaths(), + normalized != migrated[index] else { continue } + migrated[index] = normalized + needsPersist = true + } + + if needsPersist { + save(albumID: albumID, records: migrated) + } + return migrated + } + /// 保存相册传图记录。 func save(albumID: Int, records: [WiredTransferPhotoRecord]) { guard albumID > 0 else { return } @@ -142,7 +166,7 @@ final class WiredTransferPhotoStore { private func deleteFileIfExists(_ path: String) { guard !path.isEmpty else { return } - let url = URL(fileURLWithPath: path) + guard let url = CameraDownloadStorage.resolveLocalURL(from: path) else { return } if FileManager.default.fileExists(atPath: url.path) { try? FileManager.default.removeItem(at: url) } @@ -181,7 +205,43 @@ final class WiredTransferPhotoStore { private extension WiredTransferPhotoRecord { var isDisplayable: Bool { - !localPath.isEmpty || !remoteURL.isEmpty + if !remoteURL.isEmpty { return true } + if let url = CameraDownloadStorage.resolveLocalURL(from: localPath), + FileManager.default.fileExists(atPath: url.path) { + return true + } + if let url = CameraDownloadStorage.resolveLocalURL(from: thumbnailPath), + FileManager.default.fileExists(atPath: url.path) { + return true + } + return false + } + + /// 将 localPath/thumbnailPath 中的旧版绝对路径迁移为相对路径。 + func withNormalizedStoredPaths() -> WiredTransferPhotoRecord? { + let migratedLocalPath = localPath.isEmpty ? localPath : (CameraDownloadStorage.migrateStoredPath(localPath) ?? localPath) + let migratedThumbnailPath = thumbnailPath.isEmpty + ? thumbnailPath + : (CameraDownloadStorage.migrateStoredPath(thumbnailPath) ?? thumbnailPath) + + guard migratedLocalPath != localPath || migratedThumbnailPath != thumbnailPath else { return nil } + + return WiredTransferPhotoRecord( + id: id, + sourceId: sourceId, + fileName: fileName, + localPath: migratedLocalPath, + thumbnailPath: migratedThumbnailPath, + capturedAt: capturedAt, + fileSizeBytes: fileSizeBytes, + status: status, + progress: progress, + errorMessage: errorMessage, + albumId: albumId, + userId: userId, + remoteURL: remoteURL, + updatedAt: updatedAt + ) } func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord { diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index 8eac320..83002b6 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -7,7 +7,7 @@ ## 入口 - 权限 URI:`travel_album` → `TravelAlbumEntryView` -- Android 对照:`ui/travelalbum` +- Android 对照:`ui/travelalbum` → `WiredCameraTransferScreen.kt` ## 核心页面 @@ -16,7 +16,31 @@ | `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 | | `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 | | `TravelAlbumDetailView` | 相册详情与素材管理 | -| `WiredCameraTransferView` | 有线传图页 | +| `WiredCameraTransferView` | 有线传图页(UI 对齐 Android) | + +## 有线传图 UI 结构 + +Android 对照:`zhiflyfollow/.../WiredCameraTransferScreen.kt` + +``` +WiredTransferHeaderView # 蓝色顶栏:相册名 + 手机号 +WiredTransferControlCard # 浮动白卡片:设备存储 / 连接状态 / 筛选 Chip / Tab +WiredTransferSelectAllBar # 勾选模式全选栏(条件显示) +WiredTransferPhotoListPanel # 时间侧栏 + 30 分钟分组列表 +WiredTransferBottomBar # 批量上传 + 指定上传 +SpecifyUploadBottomSheet # 指定上传选项弹层 +``` + +## 有线传图交互流程 + +1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 同步相机历史文件 +2. **批量上传三态**: + - 首次点击 → 进入勾选模式 + - 有选中项 → 上传选中照片 + - 无选中项 → 取消选择 +3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」 +4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section +5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展 ## 业务流程 @@ -24,6 +48,7 @@ 2. 进入有线传图:Sony 相机 PTP 下载(`Core/CameraTethering`) 3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile` 4. 素材登记:`POST .../travel-album/upload-material` +5. 本地照片路径以相对 Documents 的路径持久化(如 `CameraDownloads/xxx.JPG`),加载时自动迁移旧版绝对路径 ## 解耦关系 @@ -36,3 +61,9 @@ - `Core/CameraTethering` — USB/PTP 相机 - `Core/CameraTransfer` — 传输编排 - `Core/Upload` — OSS STS 上传 + +## 二期待补齐 + +- RAW 预览 +- 格式 Chip 扩展 RAW / JPEG+RAW,并与 `CameraTransferPipeline` 联动 +- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗) diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index f16f69f..d028c69 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -7,6 +7,7 @@ import Foundation import Combine +import UIKit /// 旅拍相册入口 ViewModel,负责列表、创建任务与小程序码。 @MainActor @@ -229,7 +230,12 @@ final class TravelAlbumDetailViewModel: ObservableObject { @MainActor final class WiredCameraTransferViewModel: ObservableObject { static let modeLiveCapture = "边拍边传" - static let modePostShootTransfer = "拍完再传" + static let modePostShootTransfer = "拍后传输" + /// 当前仅支持 JPG,后续再扩展 RAW 等格式。 + static let formatJPG = "JPG" + static let specifyUploadAllPending = "上传所有未上传的照片" + static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片" + static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")! @Published var photos: [WiredTransferPhotoItem] = [] @Published var selectedTabIndex = 0 @@ -239,6 +245,11 @@ final class WiredCameraTransferViewModel: ObservableObject { @Published var selectedPhotoIDs: Set = [] @Published var transferModeOption = modeLiveCapture @Published var showSpecifyUploadSheet = false + @Published var showDeviceStorageAlert = false + @Published var showCameraDisconnectedAlert = false + @Published var showHelpDoc = false + @Published var isRefreshingCameraFiles = false + @Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot() @Published var connectionState: CameraConnectionState = .disconnected @Published var errorMessage: String? @@ -247,6 +258,8 @@ final class WiredCameraTransferViewModel: ObservableObject { private var photoStore: WiredTransferPhotoStore? private var userIDProvider: (() -> String)? private var sessionNewPhotoIDs: Set = [] + private var wasConnected = false + private var disconnectAlertTask: Task? /// 初始化有线传图 ViewModel。 init( @@ -258,7 +271,7 @@ final class WiredCameraTransferViewModel: ObservableObject { pipeline.onConnectionStateChange = { [weak self] state in Task { @MainActor in - self?.connectionState = state + self?.handleConnectionStateChange(state) } } pipeline.onTasksUpdated = { [weak self] tasks in @@ -290,15 +303,53 @@ final class WiredCameraTransferViewModel: ObservableObject { ) let autoUpload = transferModeOption == Self.modeLiveCapture pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload) + refreshDeviceStorageInfo() loadPersistedPhotos() await pipeline.connect() + connectionState = pipeline.connectionState + wasConnected = connectionState.isConnected } /// 断开相机连接。 func stop() async { + disconnectAlertTask?.cancel() await pipeline.disconnect() } + /// 刷新设备存储信息。 + func refreshDeviceStorageInfo() { + deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot() + } + + /// 切换 Tab。 + func selectTab(_ index: Int) { + selectedTabIndex = index + selectedTimeSlotID = nil + if selectUploadMode { + syncSelectionWithVisiblePhotos() + } + } + + /// 折叠/展开时间侧栏。 + func toggleSidebar() { + sidebarExpanded.toggle() + } + + /// 选中时间段并滚动定位。 + func selectTimeSlot(_ slotID: String) { + selectedTimeSlotID = slotID + } + + /// 打开帮助文档。 + func openHelpDoc() { + showHelpDoc = true + } + + /// 点击设备存储面板。 + func onDeviceUsageClick() { + showDeviceStorageAlert = true + } + /// 切换传输模式。 func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) { guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return } @@ -313,17 +364,113 @@ final class WiredCameraTransferViewModel: ObservableObject { pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload) } + /// 清除错误提示。 + func clearErrorMessage() { + errorMessage = nil + } + + /// 刷新相机文件列表。 + func refreshCameraFiles() async { + guard connectionState.isConnected, !isRefreshingCameraFiles else { return } + isRefreshingCameraFiles = true + defer { isRefreshingCameraFiles = false } + await pipeline.syncExistingPhotos() + } + + /// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。 + func onBatchUploadButtonClick(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { + if !selectUploadMode { + selectUploadMode = true + selectedPhotoIDs.removeAll() + return + } + if !selectedPhotoIDs.isEmpty { + await uploadSelected(api: api, ossService: ossService, scenicID: scenicID) + return + } + exitSelectUploadMode() + } + + /// 指定上传按钮点击。 + func onSpecifyUploadButtonClick() { + guard !selectUploadMode else { return } + showSpecifyUploadSheet = true + } + + /// 关闭指定上传弹层。 + func dismissSpecifyUploadSheet() { + showSpecifyUploadSheet = false + } + + /// 指定上传选项确认。 + func onSpecifyUploadOptionSelected(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { + dismissSpecifyUploadSheet() + switch option { + case Self.specifyUploadAllPending: + await batchUploadAll(api: api, ossService: ossService, scenicID: scenicID) + case Self.specifyUploadTodayCaptured: + await uploadAllTodayCaptured(api: api, ossService: ossService, scenicID: scenicID) + default: + break + } + } + + /// 退出勾选模式。 + func exitSelectUploadMode() { + selectUploadMode = false + selectedPhotoIDs.removeAll() + } + + /// 切换可见 pending 全选状态。 + func toggleSelectAllPendingInVisible() { + let pendingIDs = visiblePhotos.selectablePendingPhotoIDs + guard !pendingIDs.isEmpty else { return } + if pendingIDs.isSubset(of: selectedPhotoIDs) { + selectedPhotoIDs.subtract(pendingIDs) + } else { + selectedPhotoIDs.formUnion(pendingIDs) + } + } + + /// 当前 Tab 可见照片。 + var visiblePhotos: [WiredTransferPhotoItem] { + photos.filtered(byTabIndex: selectedTabIndex) + } + + /// 预览 URL。 + func previewURL(for photoID: String) -> String { + guard let photo = photos.first(where: { $0.id == photoID }) else { return "" } + if !photo.thumbnailURL.isEmpty { return photo.thumbnailURL } + return "" + } + /// 批量上传所有待上传照片。 - func batchUploadAll() async { + func batchUploadAll(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { + ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID) let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id) + guard !pendingIDs.isEmpty else { + errorMessage = "暂无未上传的照片" + return + } + await pipeline.uploadAssets(withIDs: pendingIDs) + } + + /// 上传今日拍摄且未上传的照片。 + func uploadAllTodayCaptured(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { + ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID) + let pendingIDs = photos.filter { $0.isCapturedToday() && $0.isNotUploaded }.map(\.id) + guard !pendingIDs.isEmpty else { + errorMessage = "暂无今日拍摄且未上传的照片" + return + } await pipeline.uploadAssets(withIDs: pendingIDs) } /// 上传选中照片。 - func uploadSelected() async { + func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { + ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID) await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs)) - selectUploadMode = false - selectedPhotoIDs.removeAll() + exitSelectUploadMode() } /// 重试单张照片上传。 @@ -339,6 +486,8 @@ final class WiredCameraTransferViewModel: ObservableObject { /// 切换照片选中状态。 func togglePhotoSelection(id: String) { + guard selectUploadMode else { return } + guard let photo = photos.first(where: { $0.id == id }), photo.canSelectForSpecifyUpload else { return } if selectedPhotoIDs.contains(id) { selectedPhotoIDs.remove(id) } else { @@ -348,7 +497,7 @@ final class WiredCameraTransferViewModel: ObservableObject { /// 同步相机历史照片。 func syncExistingPhotos() async { - await pipeline.syncExistingPhotos() + await refreshCameraFiles() } /// 重试失败上传。 @@ -356,6 +505,51 @@ final class WiredCameraTransferViewModel: ObservableObject { await pipeline.retryFailedUploads() } + private func ensureUploadEnabled(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) { + let uploader = TravelAlbumMaterialUploader( + api: api, + ossService: ossService, + albumID: context.albumId, + scenicID: scenicID + ) + pipeline.configure(uploadSink: uploader, uploadEnabled: true) + } + + private func syncSelectionWithVisiblePhotos() { + let visibleIDs = Set(visiblePhotos.map(\.id)) + selectedPhotoIDs = selectedPhotoIDs.intersection(visibleIDs) + } + + private func handleConnectionStateChange(_ state: CameraConnectionState) { + if wasConnected, !state.isConnected { + triggerCameraDisconnectedAlert() + } + wasConnected = state.isConnected + connectionState = state + } + + private func triggerCameraDisconnectedAlert() { + disconnectAlertTask?.cancel() + showCameraDisconnectedAlert = true + UINotificationFeedbackGenerator().notificationOccurred(.warning) + disconnectAlertTask = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard !Task.isCancelled else { return } + showCameraDisconnectedAlert = false + } + } + + /// 相机设备名称。 + var cameraDeviceName: String { + if case .connected(let name) = connectionState { return name } + return "" + } + + /// 是否已连接相机。 + var isCameraConnected: Bool { + connectionState.isConnected + } + private func loadPersistedPhotos() { guard context.albumId > 0, let photoStore else { return } let records = photoStore.load(albumID: context.albumId) @@ -388,9 +582,15 @@ final class WiredCameraTransferViewModel: ObservableObject { } let capturedAt = Self.captureTimestampFormatter.string(from: Date()) - let localPath = task.localPath ?? "" - let fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0 - let thumbnailURL = localPath.isEmpty ? "" : "file://\(localPath)" + let storedLocalPath = task.localPath ?? "" + let localURL = task.localURL + let fileSizeBytes: Int64 + if let localURL { + fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64) ?? 0 + } else { + fileSizeBytes = 0 + } + let thumbnailURL = localURL.map(\.absoluteString) ?? "" let item = WiredTransferPhotoItem( id: task.assetID, @@ -409,7 +609,7 @@ final class WiredCameraTransferViewModel: ObservableObject { let record = item.toRecord( albumId: context.albumId, userId: userID, - localPath: localPath, + localPath: storedLocalPath, fileSizeBytes: fileSizeBytes, remoteURL: task.remoteURL ?? "" ) diff --git a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift index 8bc58f4..16ae33a 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import UIKit /// 有线传图页,连接相机并上传照片到旅拍相册。 struct WiredCameraTransferView: View { @@ -16,8 +15,11 @@ struct WiredCameraTransferView: View { @Environment(\.travelAlbumAPI) private var travelAlbumAPI @Environment(\.ossUploadService) private var ossUploadService @EnvironmentObject private var toastCenter: ToastCenter + @Environment(\.dismiss) private var dismiss @StateObject private var viewModel: WiredCameraTransferViewModel + @State private var previewSources: [String]? + @State private var previewStartIndex = 0 init(context: WiredTransferContext) { self.context = context @@ -30,22 +32,73 @@ struct WiredCameraTransferView: View { } private let tabTitles = ["全部", "已上传", "失败"] + private var scenicID: Int { accountContext.currentScenic?.id ?? 0 } + private var scenicSpotLabel: String? { + accountContext.currentScenic?.name + } var body: some View { VStack(spacing: 0) { - headerSection - tabSection - photoListSection - bottomBar + WiredTransferHeaderView( + albumTitle: context.albumName, + headerPhone: context.phone, + onBack: { dismiss() } + ) + + WiredTransferControlCard( + scenicSpotLabel: scenicSpotLabel, + deviceStorageInfo: viewModel.deviceStorageInfo, + isCameraConnected: viewModel.isCameraConnected, + isRefreshingCameraFiles: viewModel.isRefreshingCameraFiles, + cameraDeviceName: viewModel.cameraDeviceName, + transferModeOption: viewModel.transferModeOption, + tabTitles: tabTitles, + tabCounts: viewModel.photos.transferTabCounts, + selectedTabIndex: viewModel.selectedTabIndex, + onDeviceUsageClick: viewModel.onDeviceUsageClick, + onRefreshCameraFiles: { + Task { await viewModel.refreshCameraFiles() } + }, + onHelpClick: viewModel.openHelpDoc, + onTransferModeSelected: { option in + viewModel.selectTransferMode( + option, + api: travelAlbumAPI, + ossService: ossUploadService, + scenicID: scenicID + ) + }, + onTabSelected: viewModel.selectTab + ) + + if viewModel.selectUploadMode, !visiblePhotos.isEmpty { + selectAllBar + } + + contentSection + + WiredTransferBottomBar( + selectUploadMode: viewModel.selectUploadMode, + selectedCount: viewModel.selectedPhotoIDs.count, + onBatchUploadClick: { + Task { + await viewModel.onBatchUploadButtonClick( + api: travelAlbumAPI, + ossService: ossUploadService, + scenicID: scenicID + ) + } + }, + onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick + ) } - .background(Color(hex: 0xF5F7FA)) - .navigationTitle("有线传图") - .navigationBarTitleDisplayMode(.inline) + .background(WiredTransferDesign.pageBackground.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) .task { await viewModel.start( api: travelAlbumAPI, ossService: ossUploadService, - scenicID: accountContext.currentScenic?.id ?? 0, + scenicID: scenicID, accountContext: accountContext ) } @@ -55,247 +108,140 @@ struct WiredCameraTransferView: View { .onChange(of: viewModel.errorMessage) { message in guard let message, !message.isEmpty else { return } toastCenter.show(message) + viewModel.clearErrorMessage() } .onChange(of: viewModel.connectionState) { state in switch state { case .connected(let name): toastCenter.show("已连接 \(name)") - case .disconnected: - break case .error(let message): toastCenter.show(message) default: break } } - .confirmationDialog("指定上传", isPresented: $viewModel.showSpecifyUploadSheet, titleVisibility: .visible) { - Button("上传全部待上传") { - Task { await viewModel.batchUploadAll() } - } - Button("上传选中项") { - Task { await viewModel.uploadSelected() } - } - Button("取消", role: .cancel) {} + .alert("手机内存不足", isPresented: $viewModel.showDeviceStorageAlert) { + Button("我知道了", role: .cancel) {} + } message: { + Text("请及时清理内存") } - } - - private var headerSection: some View { - VStack(spacing: AppMetrics.Spacing.small) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(context.albumName) - .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) - if !context.phone.isEmpty { - Text(context.phone) - .font(.system(size: AppMetrics.FontSize.caption)) - .foregroundStyle(AppDesign.textSecondary) - } - } - Spacer() - connectionBadge - } - - Picker("传输模式", selection: Binding( - get: { viewModel.transferModeOption }, - set: { newValue in - viewModel.selectTransferMode( - newValue, + .alert("相机已断开", isPresented: $viewModel.showCameraDisconnectedAlert) { + Button("我知道了", role: .cancel) {} + } message: { + Text("相机已断开,请检查 USB 连接。") + } + .sheet(isPresented: $viewModel.showSpecifyUploadSheet) { + SpecifyUploadBottomSheet(isPresented: $viewModel.showSpecifyUploadSheet) { option in + Task { + await viewModel.onSpecifyUploadOptionSelected( + option, api: travelAlbumAPI, ossService: ossUploadService, - scenicID: accountContext.currentScenic?.id ?? 0 + scenicID: scenicID ) } - )) { - Text(WiredCameraTransferViewModel.modeLiveCapture).tag(WiredCameraTransferViewModel.modeLiveCapture) - Text(WiredCameraTransferViewModel.modePostShootTransfer).tag(WiredCameraTransferViewModel.modePostShootTransfer) } - .pickerStyle(.segmented) + .presentationDetents([.height(340)]) + .presentationDragIndicator(.hidden) } - .padding(AppMetrics.Spacing.pageHorizontal) - .padding(.vertical, AppMetrics.Spacing.medium) - .background(Color.white) - } - - private var connectionBadge: some View { - VStack(spacing: 4) { - Circle() - .stroke(connectionColor, lineWidth: 3) - .frame(width: 44, height: 44) - .overlay { - Image(systemName: "camera.fill") - .foregroundStyle(connectionColor) - } - Text(connectionText) - .font(.system(size: 10)) - .foregroundStyle(AppDesign.textSecondary) - .multilineTextAlignment(.center) - .frame(width: 72) - } - } - - private var connectionColor: Color { - switch viewModel.connectionState { - case .connected: AppDesign.success - case .error: Color.red - case .searching, .connecting: AppDesign.warning - case .disconnected: AppDesign.placeholder - } - } - - private var connectionText: String { - switch viewModel.connectionState { - case .connected(let name): - name - default: - viewModel.connectionState.displayText - } - } - - private var tabSection: some View { - let counts = viewModel.photos.transferTabCounts - return HStack(spacing: 0) { - ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in - Button { - viewModel.selectedTabIndex = index - } label: { - VStack(spacing: 4) { - Text(title) - .font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedTabIndex == index ? .semibold : .regular)) - Text("\(counts[index])") - .font(.system(size: AppMetrics.FontSize.caption)) + .sheet(isPresented: $viewModel.showHelpDoc) { + NavigationStack { + WiredTransferHelpDocView(url: WiredCameraTransferViewModel.helpDocURL) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭") { viewModel.showHelpDoc = false } + } } - .foregroundStyle(viewModel.selectedTabIndex == index ? AppDesign.primary : AppDesign.textSecondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(viewModel.selectedTabIndex == index ? AppDesign.primarySoft : Color.clear) - } - .buttonStyle(.plain) } } - .background(Color.white) - } - - private var photoListSection: some View { - let visiblePhotos = viewModel.photos.filtered(byTabIndex: viewModel.selectedTabIndex) - return ScrollView { - if visiblePhotos.isEmpty { - AppContentUnavailableView("暂无照片", systemImage: "photo") - .frame(maxWidth: .infinity, minHeight: 260) - } else { - LazyVStack(spacing: AppMetrics.Spacing.small) { - ForEach(visiblePhotos) { photo in - photoRow(photo) - } - } - .padding(AppMetrics.Spacing.pageHorizontal) - .padding(.vertical, AppMetrics.Spacing.medium) - } + .sheet(item: previewBinding) { item in + WiredTransferPhotoPreviewSheet( + imageSources: item.sources, + startIndex: item.startIndex, + onDismiss: { previewSources = nil } + ) } } - private func photoRow(_ photo: WiredTransferPhotoItem) -> some View { - HStack(spacing: AppMetrics.Spacing.medium) { - if viewModel.selectUploadMode, photo.canSelectForSpecifyUpload { - Button { - viewModel.togglePhotoSelection(id: photo.id) - } label: { - Image(systemName: viewModel.selectedPhotoIDs.contains(photo.id) ? "checkmark.circle.fill" : "circle") - .foregroundStyle(viewModel.selectedPhotoIDs.contains(photo.id) ? AppDesign.primary : AppDesign.placeholder) - } - .buttonStyle(.plain) - } + private var visiblePhotos: [WiredTransferPhotoItem] { + viewModel.visiblePhotos + } - thumbnailView(for: photo) - .frame(width: 64, height: 64) - .clipShape(RoundedRectangle(cornerRadius: 8)) + private var sidebarGroups: [WiredTransferDateGroup] { + visiblePhotos.buildSidebarGroups() + } - VStack(alignment: .leading, spacing: 4) { - Text(photo.fileName) - .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) - .lineLimit(1) - Text(photo.capturedAt) - .font(.system(size: AppMetrics.FontSize.caption)) - .foregroundStyle(AppDesign.textSecondary) - HStack { - Text(photo.status.displayLabel) - .font(.system(size: AppMetrics.FontSize.caption)) - .foregroundStyle(statusColor(photo.status)) - if photo.status == .uploading || photo.status == .transferring { - Text("\(photo.progress)%") - .font(.system(size: AppMetrics.FontSize.caption)) - .foregroundStyle(AppDesign.textSecondary) - } - } - } + private var photoSections: [WiredTransferPhotoSection] { + visiblePhotos.buildPhotoSections() + } - Spacer() - - if photo.status == .failed { - Button("重试") { - Task { await viewModel.retryPhoto(id: photo.id) } - } - .font(.system(size: AppMetrics.FontSize.caption)) - } - } - .padding(AppMetrics.Spacing.medium) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + private var selectAllBar: some View { + let pendingIDs = visiblePhotos.selectablePendingPhotoIDs + let allSelected = !pendingIDs.isEmpty && pendingIDs.isSubset(of: viewModel.selectedPhotoIDs) + return WiredTransferSelectAllBar( + selectedCount: viewModel.selectedPhotoIDs.count, + selectableCount: pendingIDs.count, + allSelected: allSelected, + onToggleSelectAll: viewModel.toggleSelectAllPendingInVisible + ) } @ViewBuilder - private func thumbnailView(for photo: WiredTransferPhotoItem) -> 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 { - placeholderThumbnail - } + private var contentSection: some View { + if visiblePhotos.isEmpty { + WiredTransferEmptyState() + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - RemoteImage(urlString: photo.thumbnailURL) { - placeholderThumbnail - } + WiredTransferPhotoListPanel( + sidebarExpanded: viewModel.sidebarExpanded, + sidebarGroups: sidebarGroups, + photoSections: photoSections, + selectedTimeSlotID: viewModel.selectedTimeSlotID, + selectUploadMode: viewModel.selectUploadMode, + selectedPhotoIDs: viewModel.selectedPhotoIDs, + onToggleSidebar: viewModel.toggleSidebar, + onTimeSlotSelected: viewModel.selectTimeSlot, + onPhotoClick: openPhotoPreview, + onRetry: { id in Task { await viewModel.retryPhoto(id: id) } }, + onDelete: viewModel.deletePhoto, + onTogglePhotoSelection: viewModel.togglePhotoSelection + ) } } - private var placeholderThumbnail: some View { - Color(hex: 0xE5E7EB) - .overlay { - Image(systemName: "photo") - .foregroundStyle(AppDesign.placeholder) + private var previewBinding: Binding { + Binding( + get: { + guard let previewSources else { return nil } + return PreviewPayload(sources: previewSources, startIndex: previewStartIndex) + }, + set: { newValue in + if newValue == nil { previewSources = nil } } + ) } - private func statusColor(_ status: WiredTransferUploadStatus) -> Color { - switch status { - case .uploaded: AppDesign.success - case .failed: Color.red - case .uploading, .transferring: AppDesign.warning - case .pending: AppDesign.textSecondary + private func openPhotoPreview(photoID: String) { + let entries = visiblePhotos.compactMap { photo -> String? in + let url = viewModel.previewURL(for: photo.id) + return url.isEmpty ? nil : url } - } - - private var bottomBar: some View { - HStack(spacing: AppMetrics.Spacing.small) { - Button("同步历史") { - Task { await viewModel.syncExistingPhotos() } - } - .buttonStyle(.bordered) - - Button("批量上传") { - Task { await viewModel.batchUploadAll() } - } - .buttonStyle(.borderedProminent) - - Button("指定上传") { - viewModel.selectUploadMode = true - viewModel.showSpecifyUploadSheet = true - } - .buttonStyle(.bordered) + guard let startIndex = visiblePhotos.firstIndex(where: { $0.id == photoID }), + !viewModel.previewURL(for: photoID).isEmpty else { + toastCenter.show("暂无法预览该照片") + return } - .padding(AppMetrics.Spacing.pageHorizontal) - .padding(.vertical, AppMetrics.Spacing.medium) - .background(Color.white) + let mappedIndex = visiblePhotos.prefix(startIndex + 1).filter { + !viewModel.previewURL(for: $0.id).isEmpty + }.count - 1 + previewStartIndex = max(mappedIndex, 0) + previewSources = entries } } + +/// 预览 Sheet 载荷。 +private struct PreviewPayload: Identifiable { + let id = UUID() + let sources: [String] + let startIndex: Int +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/SpecifyUploadBottomSheet.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/SpecifyUploadBottomSheet.swift new file mode 100644 index 0000000..8e0855e --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/SpecifyUploadBottomSheet.swift @@ -0,0 +1,97 @@ +// +// SpecifyUploadBottomSheet.swift +// suixinkan +// + +import SwiftUI + +/// 指定上传 Bottom Sheet,对齐 Android SpecifyUploadBottomSheet。 +struct SpecifyUploadBottomSheet: View { + @Binding var isPresented: Bool + let onConfirm: (String) -> Void + + @State private var selectedOption = WiredCameraTransferViewModel.specifyUploadAllPending + + private let options = [ + WiredCameraTransferViewModel.specifyUploadAllPending, + WiredCameraTransferViewModel.specifyUploadTodayCaptured + ] + + var body: some View { + VStack(spacing: 0) { + ZStack { + Button("取消") { isPresented = false } + .font(.system(size: 16)) + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity, alignment: .leading) + Text("指定上传") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(WiredTransferDesign.text333) + } + .padding(.horizontal, 16) + .frame(height: 52) + + VStack(spacing: 12) { + ForEach(options, id: \.self) { option in + optionRow(option) + } + } + .padding(.horizontal, 16) + + Spacer(minLength: 20) + + HStack(spacing: 12) { + Button("取消") { isPresented = false } + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color.white) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(AppDesign.primary, lineWidth: 1) + } + + Button("确认上传") { + onConfirm(selectedOption) + } + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(AppDesign.primary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .background(WiredTransferDesign.pageBackground) + .onAppear { + selectedOption = options.first ?? WiredCameraTransferViewModel.specifyUploadAllPending + } + } + + private func optionRow(_ option: String) -> some View { + Button { + selectedOption = option + } label: { + HStack { + Text(option) + .font(.system(size: 15)) + .foregroundStyle(WiredTransferDesign.text333) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: selectedOption == option ? "largecircle.fill.circle" : "circle") + .foregroundStyle(selectedOption == option ? WiredTransferDesign.text333 : WiredTransferDesign.borderLight) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(WiredTransferDesign.borderLight, lineWidth: 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferBottomBar.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferBottomBar.swift new file mode 100644 index 0000000..926c735 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferBottomBar.swift @@ -0,0 +1,55 @@ +// +// WiredTransferBottomBar.swift +// suixinkan +// + +import SwiftUI + +/// 有线传图底部操作栏。 +struct WiredTransferBottomBar: View { + let selectUploadMode: Bool + let selectedCount: Int + let onBatchUploadClick: () -> Void + let onSpecifyUploadClick: () -> Void + + private var batchButtonTitle: String { + if !selectUploadMode { return "批量上传" } + if selectedCount > 0 { return "上传选中照片" } + return "取消选择" + } + + var body: some View { + VStack(spacing: 0) { + Divider().background(WiredTransferDesign.borderLight) + HStack(spacing: 12) { + Button(action: onBatchUploadClick) { + Text(batchButtonTitle) + .font(.system(size: 15)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: WiredTransferDesign.bottomButtonHeight) + .background(AppDesign.primary) + .clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.bottomButtonCornerRadius)) + } + .buttonStyle(.plain) + + Button(action: onSpecifyUploadClick) { + Text("指定上传") + .font(.system(size: 15)) + .foregroundStyle(selectUploadMode ? WiredTransferDesign.text999 : AppDesign.primary) + .frame(maxWidth: .infinity) + .frame(height: WiredTransferDesign.bottomButtonHeight) + .overlay { + RoundedRectangle(cornerRadius: WiredTransferDesign.bottomButtonCornerRadius) + .stroke(AppDesign.primary, lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(selectUploadMode) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .background(Color.white) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift new file mode 100644 index 0000000..d5df985 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift @@ -0,0 +1,238 @@ +// +// WiredTransferControlCard.swift +// suixinkan +// + +import SwiftUI + +/// 有线传图浮动控制卡片:连接区 + 筛选 + Tab。 +struct WiredTransferControlCard: View { + let scenicSpotLabel: String? + let deviceStorageInfo: DeviceStorageSnapshot + let isCameraConnected: Bool + let isRefreshingCameraFiles: Bool + let cameraDeviceName: String + let transferModeOption: String + let tabTitles: [String] + let tabCounts: [Int] + let selectedTabIndex: Int + let onDeviceUsageClick: () -> Void + let onRefreshCameraFiles: () -> Void + let onHelpClick: () -> Void + let onTransferModeSelected: (String) -> Void + let onTabSelected: (Int) -> Void + + private let modeOptions = [ + WiredCameraTransferViewModel.modeLiveCapture, + WiredCameraTransferViewModel.modePostShootTransfer + ] + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 12) { + connectionStatusRow + filterChipRow + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + + Divider().background(WiredTransferDesign.borderLight) + + WiredTransferStatsTabRow( + tabTitles: tabTitles, + counts: tabCounts, + selectedTabIndex: selectedTabIndex, + onTabSelected: onTabSelected + ) + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.cardCornerRadius)) + .shadow(color: WiredTransferDesign.cardShadow, radius: 6, x: 0, y: 2) + .padding(.horizontal, WiredTransferDesign.cardHorizontalPadding) + .offset(y: -WiredTransferDesign.cardOverlap) + } + + private var connectionStatusRow: some View { + HStack(alignment: .top, spacing: 10) { + deviceUsagePanel + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 8) { + connectionChip + Spacer(minLength: 4) + refreshButton + } + if !isCameraConnected { + helpDocLine + } + if let scenicSpotLabel, !scenicSpotLabel.isEmpty { + Text(scenicSpotLabel) + .font(.system(size: 10)) + .foregroundStyle(WiredTransferDesign.text999) + .lineLimit(1) + } + } + } + } + + private var deviceUsagePanel: some View { + Button(action: onDeviceUsageClick) { + VStack(spacing: 6) { + Text(deviceStorageInfo.modelName) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(WiredTransferDesign.text333) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(maxWidth: 88) + Text(deviceStorageInfo.availableGbText) + .font(.system(size: 10)) + .foregroundStyle(AppDesign.primary) + .lineLimit(1) + } + .frame(minWidth: WiredTransferDesign.statusRingMinWidth) + .padding(.horizontal, 8) + .padding(.vertical, 10) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(WiredTransferDesign.borderLight, lineWidth: 3) + } + } + .buttonStyle(.plain) + } + + private var connectionChip: some View { + Text(connectionChipText) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(isCameraConnected ? AppDesign.primary : WiredTransferDesign.danger) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background((isCameraConnected ? AppDesign.primary : WiredTransferDesign.danger).opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + private var connectionChipText: String { + if isCameraConnected { + let name = cameraDeviceName.isEmpty ? "已连接相机" : cameraDeviceName + return name + } + return "未连接 USB" + } + + private var refreshButton: some View { + Button(action: onRefreshCameraFiles) { + Text(refreshButtonTitle) + .font(.system(size: 12)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .frame(minWidth: 64) + .frame(height: 34) + .background(refreshButtonColor) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(!isCameraConnected || isRefreshingCameraFiles) + .opacity(isCameraConnected && !isRefreshingCameraFiles ? 1 : 0.85) + } + + private var refreshButtonTitle: String { + if isRefreshingCameraFiles { return "读取中" } + if isCameraConnected { return "刷新" } + return "等待连接" + } + + private var refreshButtonColor: Color { + if !isCameraConnected { return WiredTransferDesign.danger } + if isRefreshingCameraFiles { return WiredTransferDesign.text999 } + return AppDesign.primary + } + + private var helpDocLine: some View { + Button(action: onHelpClick) { + HStack(spacing: 0) { + Text("未连接,查看") + .foregroundStyle(WiredTransferDesign.text666) + Text("《帮助文档》") + .foregroundStyle(AppDesign.primary) + } + .font(.system(size: 10)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + + private var filterChipRow: some View { + GeometryReader { geometry in + let totalWeight: CGFloat = 1 + 0.72 + 1.15 + let spacing: CGFloat = 6 + let available = geometry.size.width - spacing * 2 + HStack(spacing: spacing) { + WiredTransferFilterChip(label: "不修图", options: [], enabled: false, onSelected: { _ in }) + .frame(width: available * 1 / totalWeight) + WiredTransferFilterChip( + label: WiredCameraTransferViewModel.formatJPG, + options: [], + enabled: false, + onSelected: { _ in } + ) + .frame(width: available * 0.72 / totalWeight) + WiredTransferFilterChip( + label: transferModeOption, + options: modeOptions, + onSelected: onTransferModeSelected + ) + .frame(width: available * 1.15 / totalWeight) + } + } + .frame(height: WiredTransferDesign.filterChipHeight) + } +} + +/// 统计 Tab 行,对齐 Android TransferStatsTabRow。 +struct WiredTransferStatsTabRow: View { + let tabTitles: [String] + let counts: [Int] + let selectedTabIndex: Int + let onTabSelected: (Int) -> Void + + var body: some View { + HStack(spacing: 0) { + ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in + if index > 0 { + Divider() + .frame(height: 32) + .background(WiredTransferDesign.borderLight) + } + tabItem(index: index, title: title) + } + } + .frame(height: WiredTransferDesign.tabRowHeight) + } + + private func tabItem(index: Int, title: String) -> some View { + Button { + onTabSelected(index) + } label: { + VStack(spacing: 2) { + Text("\(counts.indices.contains(index) ? counts[index] : 0)") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(countColor(for: index)) + Text(title) + .font(.system(size: 13)) + .foregroundStyle(selectedTabIndex == index ? AppDesign.primary : WiredTransferDesign.text666) + RoundedRectangle(cornerRadius: 1) + .fill(selectedTabIndex == index ? AppDesign.primary : Color.clear) + .frame(width: 24, height: 2) + .padding(.top, 2) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + } + + private func countColor(for index: Int) -> Color { + if selectedTabIndex == index { return AppDesign.primary } + if index == 2 { return WiredTransferDesign.danger } + return WiredTransferDesign.text333 + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferDesign.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferDesign.swift new file mode 100644 index 0000000..b5cfe24 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferDesign.swift @@ -0,0 +1,35 @@ +// +// WiredTransferDesign.swift +// suixinkan +// +// 有线传图页设计 Token,对齐 Android WiredCameraTransferScreen。 +// + +import SwiftUI + +/// 有线传图页专用色值与尺寸,对齐 Android zhiflyfollow 实现。 +enum WiredTransferDesign { + static let pageBackground = Color(hex: 0xF4F4F4) + static let text333 = Color(hex: 0x333333) + static let text666 = Color(hex: 0x666666) + static let text999 = Color(hex: 0x999999) + static let borderLight = Color(hex: 0xEEEEEE) + static let danger = Color(hex: 0xFF2B2B) + static let success = Color(hex: 0x09BE4F) + static let sidebarBackground = Color(hex: 0xF5F6F8) + static let cardShadow = Color.black.opacity(0.08) + + static let statusRingMinWidth: CGFloat = 76 + static let sidebarWidth: CGFloat = 76 + static let cardCornerRadius: CGFloat = 12 + static let filterChipHeight: CGFloat = 34 + static let filterChipCornerRadius: CGFloat = 8 + static let tabRowHeight: CGFloat = 68 + static let thumbnailSize: CGFloat = 48 + static let thumbnailCornerRadius: CGFloat = 6 + static let bottomButtonHeight: CGFloat = 44 + static let bottomButtonCornerRadius: CGFloat = 10 + static let progressBarHeight: CGFloat = 2 + static let cardHorizontalPadding: CGFloat = 16 + static let cardOverlap: CGFloat = 10 +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferEmptyState.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferEmptyState.swift new file mode 100644 index 0000000..2856c3b --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferEmptyState.swift @@ -0,0 +1,66 @@ +// +// WiredTransferEmptyState.swift +// suixinkan +// + +import SwiftUI + +/// 有线传图空态,对齐 Android TransferEmptyState。 +struct WiredTransferEmptyState: View { + var body: some View { + VStack(spacing: 8) { + WiredTransferEmptyIllustration() + .frame(width: 200, height: 150) + Text("暂无传输文件") + .font(.system(size: 14)) + .foregroundStyle(WiredTransferDesign.text999) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 32) + } +} + +/// 空态插图,对齐 Android ic_img_transfer 布局尺寸。 +private struct WiredTransferEmptyIllustration: View { + var body: some View { + ZStack { + Ellipse() + .fill(Color(hex: 0xE7E9EB)) + .frame(width: 180, height: 28) + .offset(y: 52) + RoundedRectangle(cornerRadius: 14) + .fill(Color.white) + .frame(width: 56, height: 96) + .overlay { + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: 0xE7E9EB)) + .frame(width: 36, height: 36) + .offset(y: -16) + } + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke(Color(hex: 0xDDE1E5), lineWidth: 2) + } + .offset(x: -36, y: -8) + RoundedRectangle(cornerRadius: 8) + .fill(WiredTransferDesign.text333) + .frame(width: 52, height: 34) + .overlay { + Circle() + .fill(AppDesign.primary) + .frame(width: 16, height: 16) + .offset(x: 8) + } + .offset(x: 42, y: 4) + HStack(spacing: 0) { + Rectangle() + .fill(AppDesign.primary) + .frame(width: 18, height: 4) + Image(systemName: "arrowtriangle.right.fill") + .font(.system(size: 8)) + .foregroundStyle(AppDesign.primary) + } + .offset(x: 4, y: 4) + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferFilterChip.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferFilterChip.swift new file mode 100644 index 0000000..f175b00 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferFilterChip.swift @@ -0,0 +1,49 @@ +// +// WiredTransferFilterChip.swift +// suixinkan +// + +import SwiftUI + +/// 有线传图筛选 Chip,对齐 Android FilterChipDropdown。 +struct WiredTransferFilterChip: View { + let label: String + let options: [String] + var enabled: Bool = true + let onSelected: (String) -> Void + + var body: some View { + Menu { + if options.isEmpty { + Button(label) {} + .disabled(true) + } else { + ForEach(options, id: \.self) { option in + Button(option) { onSelected(option) } + } + } + } label: { + HStack(spacing: 0) { + Text(label) + .font(.system(size: 11)) + .foregroundStyle(enabled && !options.isEmpty ? WiredTransferDesign.text666 : WiredTransferDesign.text999) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if enabled, !options.isEmpty { + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(WiredTransferDesign.text999) + } + } + .padding(.horizontal, enabled && !options.isEmpty ? 8 : 8) + .frame(height: WiredTransferDesign.filterChipHeight) + .background(WiredTransferDesign.pageBackground) + .clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.filterChipCornerRadius)) + .overlay { + RoundedRectangle(cornerRadius: WiredTransferDesign.filterChipCornerRadius) + .stroke(WiredTransferDesign.borderLight, lineWidth: 1) + } + } + .disabled(!enabled || options.isEmpty) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferHeaderView.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferHeaderView.swift new file mode 100644 index 0000000..11b1e6c --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferHeaderView.swift @@ -0,0 +1,45 @@ +// +// WiredTransferHeaderView.swift +// suixinkan +// + +import SwiftUI + +/// 有线传图页蓝色顶栏,展示相册名与手机号。 +struct WiredTransferHeaderView: View { + let albumTitle: String + let headerPhone: String + let onBack: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 2) { + Text(albumTitle) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + if !headerPhone.isEmpty { + Text(headerPhone) + .font(.system(size: 13)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 8) + } + .padding(.bottom, 22) + } + .frame(maxWidth: .infinity) + .background(AppDesign.primary) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferHelpDocView.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferHelpDocView.swift new file mode 100644 index 0000000..af4c891 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferHelpDocView.swift @@ -0,0 +1,65 @@ +// +// WiredTransferHelpDocView.swift +// suixinkan +// + +import SwiftUI +import WebKit + +/// 有线传图帮助文档 H5 页。 +struct WiredTransferHelpDocView: View { + let url: URL + @State private var isLoading = true + + var body: some View { + ZStack { + WiredTransferHelpWebView(url: url, isLoading: $isLoading) + if isLoading { + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white.opacity(0.4)) + } + } + .background(WiredTransferDesign.pageBackground.ignoresSafeArea()) + .navigationTitle("帮助文档") + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct WiredTransferHelpWebView: UIViewRepresentable { + let url: URL + @Binding var isLoading: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(isLoading: $isLoading) + } + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.navigationDelegate = context.coordinator + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + guard webView.url != url else { return } + isLoading = true + webView.load(URLRequest(url: url)) + } + + final class Coordinator: NSObject, WKNavigationDelegate { + @Binding var isLoading: Bool + + init(isLoading: Binding) { + _isLoading = isLoading + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isLoading = false + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + isLoading = false + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoListPanel.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoListPanel.swift new file mode 100644 index 0000000..3487e1a --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoListPanel.swift @@ -0,0 +1,94 @@ +// +// WiredTransferPhotoListPanel.swift +// suixinkan +// + +import SwiftUI + +/// 有线传图照片列表面板(侧栏 + 分组列表)。 +struct WiredTransferPhotoListPanel: View { + let sidebarExpanded: Bool + let sidebarGroups: [WiredTransferDateGroup] + let photoSections: [WiredTransferPhotoSection] + let selectedTimeSlotID: String? + let selectUploadMode: Bool + let selectedPhotoIDs: Set + let onToggleSidebar: () -> Void + let onTimeSlotSelected: (String) -> Void + let onPhotoClick: (String) -> Void + let onRetry: (String) -> Void + let onDelete: (String) -> Void + let onTogglePhotoSelection: (String) -> Void + + var body: some View { + HStack(spacing: 0) { + if sidebarExpanded, !sidebarGroups.isEmpty { + WiredTransferTimeSidebar( + groups: sidebarGroups, + selectedSlotID: selectedTimeSlotID, + onToggleSidebar: onToggleSidebar, + onTimeSlotSelected: onTimeSlotSelected + ) + Divider().background(WiredTransferDesign.borderLight) + } + + ZStack(alignment: .leading) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(photoSections) { section in + Section { + ForEach(section.photos) { photo in + WiredTransferPhotoRow( + photo: photo, + selectUploadMode: selectUploadMode, + selected: selectedPhotoIDs.contains(photo.id), + onToggleSelection: { onTogglePhotoSelection(photo.id) }, + onPhotoClick: { onPhotoClick(photo.id) }, + onRetry: { onRetry(photo.id) }, + onDelete: { onDelete(photo.id) } + ) + Divider() + .background(WiredTransferDesign.borderLight) + .padding(.horizontal, 10) + } + } header: { + sectionHeader(section.headerTitle) + .id(section.slotId) + } + } + } + .padding(.bottom, 8) + } + .onChange(of: selectedTimeSlotID) { slotID in + guard let slotID else { return } + withAnimation { + proxy.scrollTo(slotID, anchor: .top) + } + } + } + + if !sidebarExpanded, !sidebarGroups.isEmpty { + VStack { + Spacer() + WiredTransferSidebarExpandTab(onExpand: onToggleSidebar) + Spacer() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color.white) + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(size: 11)) + .foregroundStyle(WiredTransferDesign.text666) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(WiredTransferDesign.pageBackground) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoPreviewSheet.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoPreviewSheet.swift new file mode 100644 index 0000000..25ac28a --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoPreviewSheet.swift @@ -0,0 +1,63 @@ +// +// WiredTransferPhotoPreviewSheet.swift +// suixinkan +// + +import SwiftUI +import UIKit +import Kingfisher + +/// 有线传图照片预览,支持本地 file:// 与远程 URL。 +struct WiredTransferPhotoPreviewSheet: View { + let imageSources: [String] + let startIndex: Int + let onDismiss: () -> Void + + @State private var currentIndex: Int + + init(imageSources: [String], startIndex: Int, onDismiss: @escaping () -> Void) { + self.imageSources = imageSources + self.startIndex = startIndex + self.onDismiss = onDismiss + _currentIndex = State(initialValue: startIndex) + } + + var body: some View { + NavigationStack { + TabView(selection: $currentIndex) { + ForEach(Array(imageSources.enumerated()), id: \.offset) { index, source in + previewImage(source) + .tag(index) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + } + } + .tabViewStyle(.page(indexDisplayMode: imageSources.count > 1 ? .automatic : .never)) + .background(Color.black.ignoresSafeArea()) + .navigationTitle("图片预览") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭", action: onDismiss) + } + } + } + } + + @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 { + KFImage(url) + .resizable() + .scaledToFit() + } else { + Text("暂无法预览该照片") + .foregroundStyle(.white.opacity(0.8)) + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift new file mode 100644 index 0000000..c0395f6 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift @@ -0,0 +1,120 @@ +// +// WiredTransferPhotoRow.swift +// suixinkan +// + +import SwiftUI +import UIKit + +/// 有线传图照片列表行。 +struct WiredTransferPhotoRow: View { + let photo: WiredTransferPhotoItem + let selectUploadMode: Bool + let selected: Bool + let onToggleSelection: () -> Void + let onPhotoClick: () -> Void + let onRetry: () -> Void + let onDelete: () -> Void + + private var canSelect: Bool { photo.canSelectForSpecifyUpload } + private var canRetry: Bool { photo.status == .failed || photo.status == .pending } + private var rowOpacity: Double { selectUploadMode && !canSelect ? 0.45 : 1 } + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 8) { + if selectUploadMode { + Button(action: onToggleSelection) { + 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() } + } + + 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) + } + + if !selectUploadMode { + Menu { + Button("重传") { onRetry() } + .disabled(!canRetry) + Button("删除", role: .destructive) { onDelete() } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 16)) + .foregroundStyle(WiredTransferDesign.text666) + .frame(width: 32, height: 32) + .rotationEffect(.degrees(90)) + } + } + } + .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) + .tint(AppDesign.primary) + .frame(height: WiredTransferDesign.progressBarHeight) + .padding(.horizontal, 10) + .padding(.bottom, 8) + } + } + } + + private var metaText: String { + if photo.resolutionText.isEmpty { return photo.fileSizeText } + return "\(photo.fileSizeText) \(photo.resolutionText)" + } + + @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 + } + } else { + thumbnailPlaceholder + } + } + + private var thumbnailPlaceholder: some View { + WiredTransferDesign.pageBackground + .overlay { + Image(systemName: "photo") + .foregroundStyle(WiredTransferDesign.text999) + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferSelectAllBar.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferSelectAllBar.swift new file mode 100644 index 0000000..0585108 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferSelectAllBar.swift @@ -0,0 +1,48 @@ +// +// WiredTransferSelectAllBar.swift +// suixinkan +// + +import SwiftUI + +/// 指定上传勾选模式下的全选栏。 +struct WiredTransferSelectAllBar: View { + let selectedCount: Int + let selectableCount: Int + let allSelected: Bool + let onToggleSelectAll: () -> Void + + var body: some View { + VStack(spacing: 0) { + Button(action: onToggleSelectAll) { + HStack(spacing: 8) { + WiredTransferSelectIndicator(selected: allSelected, enabled: selectableCount > 0) + Text("全选") + .font(.system(size: 14)) + .foregroundStyle(selectableCount > 0 ? WiredTransferDesign.text333 : WiredTransferDesign.text999) + .frame(maxWidth: .infinity, alignment: .leading) + Text("已选 \(selectedCount) / \(selectableCount)") + .font(.system(size: 12)) + .foregroundStyle(WiredTransferDesign.text666) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + Divider().background(WiredTransferDesign.borderLight) + } + .background(Color.white) + } +} + +/// 勾选指示器。 +struct WiredTransferSelectIndicator: View { + let selected: Bool + var enabled: Bool = true + + var body: some View { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20)) + .foregroundStyle(enabled ? (selected ? AppDesign.primary : WiredTransferDesign.text999) : WiredTransferDesign.text999.opacity(0.5)) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferStatusBadge.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferStatusBadge.swift new file mode 100644 index 0000000..d9ad0bb --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferStatusBadge.swift @@ -0,0 +1,39 @@ +// +// WiredTransferStatusBadge.swift +// suixinkan +// + +import SwiftUI + +/// 有线传图上传状态 Badge。 +struct WiredTransferStatusBadge: View { + let status: WiredTransferUploadStatus + + var body: some View { + Text(status.displayLabel) + .font(.system(size: 9)) + .foregroundStyle(textColor) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + + private var textColor: Color { + switch status { + case .uploaded: WiredTransferDesign.success + case .pending: WiredTransferDesign.text666 + case .transferring, .uploading: AppDesign.primary + case .failed: WiredTransferDesign.danger + } + } + + private var backgroundColor: Color { + switch status { + case .uploaded: WiredTransferDesign.success.opacity(0.12) + case .pending: WiredTransferDesign.pageBackground + case .transferring, .uploading: AppDesign.primary.opacity(0.12) + case .failed: WiredTransferDesign.danger.opacity(0.12) + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferTimeSidebar.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferTimeSidebar.swift new file mode 100644 index 0000000..8383dd4 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferTimeSidebar.swift @@ -0,0 +1,96 @@ +// +// WiredTransferTimeSidebar.swift +// suixinkan +// + +import SwiftUI + +/// 有线传图时间侧栏。 +struct WiredTransferTimeSidebar: View { + let groups: [WiredTransferDateGroup] + let selectedSlotID: String? + let onToggleSidebar: () -> Void + let onTimeSlotSelected: (String) -> Void + + var body: some View { + VStack(spacing: 0) { + Button(action: onToggleSidebar) { + HStack(spacing: 2) { + Image(systemName: "chevron.left") + .font(.system(size: 12)) + Text("收起") + .font(.system(size: 11)) + } + .foregroundStyle(WiredTransferDesign.text666) + .frame(maxWidth: .infinity) + .padding(.horizontal, 8) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(groups, id: \.dateLabel) { group in + Text(group.dateLabel) + .font(.system(size: 12)) + .foregroundStyle(WiredTransferDesign.text999) + .padding(.leading, 10) + .padding(.top, 8) + .padding(.bottom, 4) + ForEach(group.slots) { slot in + timeSlotButton(slot) + } + } + } + .padding(.bottom, 12) + } + } + .frame(width: WiredTransferDesign.sidebarWidth) + .background(WiredTransferDesign.sidebarBackground) + } + + private func timeSlotButton(_ slot: WiredTransferTimeSlot) -> some View { + let selected = slot.id == selectedSlotID + return Button { + onTimeSlotSelected(slot.id) + } label: { + VStack(spacing: 2) { + HStack(spacing: 2) { + Image(systemName: "play.fill") + .font(.system(size: 8)) + .foregroundStyle(selected ? AppDesign.primary : WiredTransferDesign.text333) + Text(slot.slotStartLabel) + .font(.system(size: 13, weight: selected ? .semibold : .regular)) + .foregroundStyle(selected ? AppDesign.primary : WiredTransferDesign.text333) + } + Text("\(slot.photoCount)张") + .font(.system(size: 10)) + .foregroundStyle(WiredTransferDesign.text999) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .background(selected ? Color.white : Color.clear) + } + .buttonStyle(.plain) + } +} + +/// 侧栏收起后的展开按钮。 +struct WiredTransferSidebarExpandTab: View { + let onExpand: () -> Void + + var body: some View { + Button(action: onExpand) { + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundStyle(WiredTransferDesign.text666) + .padding(.horizontal, 4) + .padding(.vertical, 16) + .background(WiredTransferDesign.sidebarBackground) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.leading, 4) + } +} diff --git a/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift b/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift new file mode 100644 index 0000000..b62f484 --- /dev/null +++ b/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift @@ -0,0 +1,47 @@ +// +// CameraDownloadStorageTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// CameraDownloadStorage 相对路径持久化测试。 +final class CameraDownloadStorageTests: XCTestCase { + /// 测试相对路径前缀。 + func testRelativePathUsesCameraDownloadsPrefix() { + let fileURL = CameraDownloadStorage.downloadsDirectory.appendingPathComponent("1730_test.JPG") + XCTAssertEqual(CameraDownloadStorage.relativePath(for: fileURL), "CameraDownloads/1730_test.JPG") + } + + /// 测试相对路径解析。 + func testResolveLocalURLFromRelativePath() { + let stored = "CameraDownloads/1730_test.JPG" + let resolved = CameraDownloadStorage.resolveLocalURL(from: stored) + let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored) + XCTAssertEqual(resolved, expected) + } + + /// 测试旧版绝对路径迁移。 + func testMigrateStoredPathFromLegacyAbsolutePath() throws { + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy.jpg") + try Data("jpeg".utf8).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let migrated = CameraDownloadStorage.migrateStoredPath(fileURL.path) + XCTAssertEqual(migrated, "CameraDownloads/\(fileURL.lastPathComponent)") + XCTAssertEqual(CameraDownloadStorage.resolveLocalURL(from: migrated ?? "")?.path, fileURL.path) + } + + /// 测试预览 URL 字符串生成。 + func testPreviewURLStringFromRelativePath() throws { + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.jpg") + try Data("jpeg".utf8).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let stored = CameraDownloadStorage.relativePath(for: fileURL) + let preview = CameraDownloadStorage.previewURLString(from: stored) + XCTAssertFalse(preview.isEmpty) + XCTAssertTrue(preview.hasPrefix("file://")) + } +} diff --git a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift index 12ed5b7..da6e3f3 100644 --- a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift +++ b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift @@ -11,13 +11,16 @@ import XCTest @MainActor /// 相机传输管道测试,使用 Mock 相机与上传 Sink。 final class CameraTransferPipelineTests: XCTestCase { - /// 测试新照片下载后触发上传 Sink。 + /// 测试新照片下载后触发上传 Sink,并持久化相对路径。 func testNewAssetTriggersUploadSink() async { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) pipeline.configure(uploadSink: sink, uploadEnabled: true) + var latestTasks: [CameraTransferTask] = [] + pipeline.onTasksUpdated = { latestTasks = $0 } + let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG") try? Data(repeating: 0xFF, count: 8).write(to: tempURL) @@ -26,6 +29,12 @@ final class CameraTransferPipelineTests: XCTestCase { try? await Task.sleep(nanoseconds: 300_000_000) XCTAssertEqual(sink.uploadCount, 1) + let savedTask = latestTasks.first { $0.assetID == asset.id } + XCTAssertEqual(savedTask?.localPath?.hasPrefix("CameraDownloads/"), true) + XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false) + if let localURL = savedTask?.localURL { + try? FileManager.default.removeItem(at: localURL) + } try? FileManager.default.removeItem(at: tempURL) } } diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift index 47ff28e..58e4ebb 100644 --- a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -82,6 +82,93 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertTrue(viewModel.photos.isEmpty) } + + /// 测试批量上传按钮三态流转。 + func testBatchUploadButtonThreeStateFlow() async { + let viewModel = makeViewModelWithPendingPhotos() + let api = MockTravelAlbumAPIForWiredTransfer() + let oss = WiredTransferMockOSSUploadService() + + XCTAssertFalse(viewModel.selectUploadMode) + await viewModel.onBatchUploadButtonClick(api: api, ossService: oss, scenicID: 1) + XCTAssertTrue(viewModel.selectUploadMode) + XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty) + + viewModel.togglePhotoSelection(id: "pending-1") + XCTAssertEqual(viewModel.selectedPhotoIDs, ["pending-1"]) + + await viewModel.onBatchUploadButtonClick(api: api, ossService: oss, scenicID: 1) + XCTAssertFalse(viewModel.selectUploadMode) + XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty) + } + + /// 测试全选 pending 与取消全选。 + func testToggleSelectAllPendingInVisible() { + let viewModel = makeViewModelWithPendingPhotos() + viewModel.selectUploadMode = true + + viewModel.toggleSelectAllPendingInVisible() + XCTAssertEqual(viewModel.selectedPhotoIDs, ["pending-1", "pending-2"]) + + viewModel.toggleSelectAllPendingInVisible() + XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty) + } + + /// 测试指定上传今日拍摄筛选。 + func testUploadAllTodayCapturedFiltersByDate() async { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.locale = Locale(identifier: "zh_CN") + let today = formatter.string(from: Date()) + + let viewModel = WiredCameraTransferViewModel( + context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""), + cameraService: MockWiredCameraService() + ) + viewModel.photos = [ + makePendingPhoto(id: "today", capturedAt: today), + makePendingPhoto(id: "old", capturedAt: "2020-01-01 09:00:00"), + ] + + await viewModel.onSpecifyUploadOptionSelected( + WiredCameraTransferViewModel.specifyUploadTodayCaptured, + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1 + ) + XCTAssertNil(viewModel.errorMessage) + } + + private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel { + let viewModel = WiredCameraTransferViewModel( + context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""), + cameraService: MockWiredCameraService() + ) + viewModel.photos = [ + 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: "", + capturedAt: "2026-05-20 12:20:00", + fileSizeText: "1 MB", + status: .uploaded + ), + ] + return viewModel + } + + private func makePendingPhoto(id: String, capturedAt: String) -> WiredTransferPhotoItem { + WiredTransferPhotoItem( + id: id, + fileName: "\(id).JPG", + thumbnailURL: "", + capturedAt: capturedAt, + fileSizeText: "1 MB", + status: .pending + ) + } } @MainActor diff --git a/suixinkanTests/TravelAlbum/WiredTransferPhotoGroupingTests.swift b/suixinkanTests/TravelAlbum/WiredTransferPhotoGroupingTests.swift new file mode 100644 index 0000000..f16fd7a --- /dev/null +++ b/suixinkanTests/TravelAlbum/WiredTransferPhotoGroupingTests.swift @@ -0,0 +1,68 @@ +// +// WiredTransferPhotoGroupingTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 有线传图照片分组逻辑测试。 +final class WiredTransferPhotoGroupingTests: XCTestCase { + /// 测试 30 分钟时间段分组与侧栏构建。 + func testBuildPhotoSectionsAndSidebarGroups() { + 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), + ] + + let sections = photos.buildPhotoSections() + XCTAssertEqual(sections.count, 2) + XCTAssertEqual(sections[0].photos.count, 2) + XCTAssertTrue(sections[0].headerTitle.contains("12:00 - 12:30")) + + let sidebar = photos.buildSidebarGroups() + XCTAssertEqual(sidebar.count, 1) + XCTAssertEqual(sidebar[0].slots.count, 2) + XCTAssertEqual(sidebar[0].slots[0].photoCount, 2) + } + + /// 测试今日拍摄筛选。 + func testIsCapturedTodayUsesLocalCalendar() { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.locale = Locale(identifier: "zh_CN") + let today = formatter.string(from: Date()) + + let photo = makePhoto(id: "today", capturedAt: today, status: .pending) + XCTAssertTrue(photo.isCapturedToday()) + XCTAssertFalse(makePhoto(id: "old", capturedAt: "2020-01-01 10:00:00", status: .pending).isCapturedToday()) + } + + /// 测试 Tab 计数与 pending 可选集合。 + func testTransferTabCountsAndSelectablePendingIDs() { + 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 12:35:44", status: .failed), + ] + XCTAssertEqual(photos.transferTabCounts, [3, 1, 1]) + XCTAssertEqual(photos.selectablePendingPhotoIDs, ["2"]) + } + + private func makePhoto( + id: String, + capturedAt: String, + status: WiredTransferUploadStatus + ) -> WiredTransferPhotoItem { + WiredTransferPhotoItem( + id: id, + fileName: "\(id).JPG", + thumbnailURL: "", + capturedAt: capturedAt, + fileSizeText: "1.00 MB", + resolutionText: "4032x3024", + status: status + ) + } +}