对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。
重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -64,11 +64,15 @@
|
|||||||
};
|
};
|
||||||
A00000022FE9000000000002 /* suixinkanTests */ = {
|
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = suixinkanTests;
|
path = suixinkanTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = suixinkanUITests;
|
path = suixinkanUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -324,14 +328,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// 相机下载文件本地存储工具,负责目录创建、相对路径持久化与安全文件名处理。
|
/// 相机下载文件本地存储工具,负责目录创建、相对路径持久化与安全文件名处理。
|
||||||
enum CameraDownloadStorage {
|
enum CameraDownloadStorage {
|
||||||
@ -23,9 +25,21 @@ enum CameraDownloadStorage {
|
|||||||
return directory
|
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`。
|
/// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/1730_DSC.JPG`。
|
||||||
static func relativePath(for url: URL) -> String {
|
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。
|
/// 将持久化的相对或旧版绝对路径解析为当前沙盒内的文件 URL。
|
||||||
@ -82,6 +96,13 @@ enum CameraDownloadStorage {
|
|||||||
return downloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)")
|
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 名称。
|
/// 将相机 PTP 文件名转为 iOS 文件系统可安全使用的 ASCII 名称。
|
||||||
static func sanitizeFilename(_ filename: String) -> String {
|
static func sanitizeFilename(_ filename: String) -> String {
|
||||||
let trimmed = filename
|
let trimmed = filename
|
||||||
@ -111,4 +132,57 @@ enum CameraDownloadStorage {
|
|||||||
}
|
}
|
||||||
return url.absoluteString
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,9 +16,14 @@ final class CameraTransferPipeline {
|
|||||||
private let cameraService: any CameraServiceProtocol
|
private let cameraService: any CameraServiceProtocol
|
||||||
private var uploadSink: (any CameraAssetUploadSink)?
|
private var uploadSink: (any CameraAssetUploadSink)?
|
||||||
private var uploadEnabled = true
|
private var uploadEnabled = true
|
||||||
private var isProcessing = false
|
private var activeUploadAssetIDs: Set<String> = []
|
||||||
|
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
||||||
|
private var lastDeferredNotifyDate = Date.distantPast
|
||||||
|
private var lastNotifiedProgressByAssetID: [String: Int] = [:]
|
||||||
private var inFlightAssetIDs: Set<String> = []
|
private var inFlightAssetIDs: Set<String> = []
|
||||||
private let maxRetries = 3
|
private let maxRetries = 3
|
||||||
|
private let deferredNotifyInterval: TimeInterval = 0.15
|
||||||
|
private let maxConcurrentUploads = 3
|
||||||
|
|
||||||
/// 初始化传输管道,并注入相机服务。
|
/// 初始化传输管道,并注入相机服务。
|
||||||
init(cameraService: any CameraServiceProtocol) {
|
init(cameraService: any CameraServiceProtocol) {
|
||||||
@ -67,25 +72,62 @@ final class CameraTransferPipeline {
|
|||||||
tasks[index].errorMessage = nil
|
tasks[index].errorMessage = nil
|
||||||
}
|
}
|
||||||
notify()
|
notify()
|
||||||
await processUploadQueue()
|
processUploadQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 手动上传指定 asset 列表(拍完再传模式)。
|
/// 手动上传指定 asset 列表(拍完再传模式)。
|
||||||
func uploadAssets(withIDs assetIDs: [String]) async {
|
func uploadAssets(withIDs assetIDs: [String]) async {
|
||||||
guard uploadEnabled, uploadSink != nil else { return }
|
guard uploadSink != nil else { return }
|
||||||
for assetID in assetIDs {
|
for assetID in assetIDs {
|
||||||
|
if activeUploadAssetIDs.contains(assetID) { continue }
|
||||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
|
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
|
||||||
if tasks[index].status == .uploaded { continue }
|
if tasks[index].status == .uploaded { continue }
|
||||||
if tasks[index].localURL == nil {
|
if tasks[index].localURL == nil {
|
||||||
await downloadAsset(at: index)
|
await downloadAsset(assetID: assetID)
|
||||||
}
|
}
|
||||||
if tasks[index].status == .downloaded || tasks[index].status == .failed {
|
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
|
||||||
tasks[index].status = .downloaded
|
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
|
||||||
await uploadTask(at: index)
|
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 {
|
func syncExistingPhotos() async {
|
||||||
guard cameraService.connectionState.isConnected else { return }
|
guard cameraService.connectionState.isConnected else { return }
|
||||||
@ -128,28 +170,33 @@ final class CameraTransferPipeline {
|
|||||||
inFlightAssetIDs.insert(asset.id)
|
inFlightAssetIDs.insert(asset.id)
|
||||||
defer { inFlightAssetIDs.remove(asset.id) }
|
defer { inFlightAssetIDs.remove(asset.id) }
|
||||||
|
|
||||||
let taskIndex: Int
|
let assetID = asset.id
|
||||||
if let existingIndex = tasks.firstIndex(where: { $0.assetID == asset.id }) {
|
if let existingIndex = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
||||||
taskIndex = existingIndex
|
tasks[existingIndex].status = .downloading
|
||||||
tasks[taskIndex].status = .downloading
|
tasks[existingIndex].errorMessage = nil
|
||||||
tasks[taskIndex].errorMessage = nil
|
|
||||||
} else {
|
} 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)
|
tasks.insert(task, at: 0)
|
||||||
taskIndex = 0
|
|
||||||
}
|
}
|
||||||
notify()
|
notify()
|
||||||
|
|
||||||
await downloadAsset(at: taskIndex, asset: asset)
|
await downloadAsset(assetID: assetID, asset: asset)
|
||||||
|
|
||||||
if uploadEnabled, uploadSink != nil, tasks[taskIndex].status == .downloaded {
|
if uploadEnabled,
|
||||||
await processUploadQueue()
|
uploadSink != nil,
|
||||||
|
tasks.first(where: { $0.assetID == assetID })?.status == .downloaded {
|
||||||
|
processUploadQueue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadAsset(at index: Int, asset: CameraAsset? = nil) async {
|
/// 下载指定 asset 到本地;始终按 assetID 定位任务,避免连拍时 index 失效。
|
||||||
guard tasks.indices.contains(index) else { return }
|
private func downloadAsset(assetID: String, asset: CameraAsset? = nil) async {
|
||||||
let assetID = tasks[index].assetID
|
guard tasks.contains(where: { $0.assetID == assetID }) else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let resolvedAsset: CameraAsset
|
let resolvedAsset: CameraAsset
|
||||||
@ -174,31 +221,44 @@ final class CameraTransferPipeline {
|
|||||||
try FileManager.default.moveItem(at: localURL, to: destination)
|
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)
|
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].status = .downloaded
|
||||||
tasks[index].progress = 0
|
tasks[index].progress = 0
|
||||||
notify()
|
notify()
|
||||||
} catch {
|
} catch {
|
||||||
|
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||||
tasks[index].status = .failed
|
tasks[index].status = .failed
|
||||||
tasks[index].errorMessage = error.localizedDescription
|
tasks[index].errorMessage = error.localizedDescription
|
||||||
notify()
|
notify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processUploadQueue() async {
|
/// 并发上传已下载任务;边拍边传时限制并发数,避免网络与内存被打满。
|
||||||
|
private func processUploadQueue() {
|
||||||
guard uploadEnabled, uploadSink != nil else { return }
|
guard uploadEnabled, uploadSink != nil else { return }
|
||||||
guard !isProcessing else { return }
|
|
||||||
isProcessing = true
|
|
||||||
defer { isProcessing = false }
|
|
||||||
|
|
||||||
let pendingIndices = tasks.indices.filter { tasks[$0].status == .downloaded }
|
while activeUploadAssetIDs.count < maxConcurrentUploads,
|
||||||
for index in pendingIndices {
|
let task = tasks.first(where: { $0.status == .downloaded && !activeUploadAssetIDs.contains($0.assetID) }) {
|
||||||
await uploadTask(at: index)
|
startConcurrentUpload(assetID: task.assetID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadTask(at index: Int) async {
|
private func startConcurrentUpload(assetID: String) {
|
||||||
guard tasks.indices.contains(index),
|
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 sink = uploadSink,
|
||||||
let localURL = tasks[index].localURL
|
let localURL = tasks[index].localURL
|
||||||
else { return }
|
else { return }
|
||||||
@ -207,33 +267,41 @@ final class CameraTransferPipeline {
|
|||||||
tasks[index].errorMessage = nil
|
tasks[index].errorMessage = nil
|
||||||
notify()
|
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
|
var attempt = 0
|
||||||
|
|
||||||
while attempt < maxRetries {
|
while attempt < maxRetries {
|
||||||
do {
|
do {
|
||||||
let remoteURL = try await sink.upload(
|
let remoteURL = try await sink.upload(
|
||||||
localURL: localURL,
|
localURL: localURL,
|
||||||
fileName: tasks[index].filename,
|
fileName: filename,
|
||||||
fileType: fileType,
|
fileType: fileType,
|
||||||
progress: { [weak self] progress in
|
progress: { [weak self] progress in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self, self.tasks.indices.contains(index) else { return }
|
guard let self,
|
||||||
self.tasks[index].progress = progress
|
let taskIndex = self.tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||||
self.notify()
|
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
|
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||||
tasks[index].remoteURL = remoteURL
|
tasks[taskIndex].status = .uploaded
|
||||||
tasks[index].progress = 100
|
tasks[taskIndex].remoteURL = remoteURL
|
||||||
|
tasks[taskIndex].progress = 100
|
||||||
|
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||||
notify()
|
notify()
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
attempt += 1
|
attempt += 1
|
||||||
|
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||||
if attempt >= maxRetries {
|
if attempt >= maxRetries {
|
||||||
tasks[index].status = .failed
|
tasks[taskIndex].status = .failed
|
||||||
tasks[index].errorMessage = error.localizedDescription
|
tasks[taskIndex].errorMessage = error.localizedDescription
|
||||||
|
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||||
notify()
|
notify()
|
||||||
} else {
|
} else {
|
||||||
let delay = UInt64(pow(2.0, Double(attempt)))
|
let delay = UInt64(pow(2.0, Double(attempt)))
|
||||||
@ -243,7 +311,33 @@ final class CameraTransferPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notify() {
|
private func notify(_ mode: CameraTransferNotifyMode = .immediate) {
|
||||||
onTasksUpdated?(tasks)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -24,6 +24,8 @@ struct CameraTransferTask: Identifiable, Equatable, Codable {
|
|||||||
let filename: String
|
let filename: String
|
||||||
var localPath: String?
|
var localPath: String?
|
||||||
var remoteURL: String?
|
var remoteURL: String?
|
||||||
|
/// 相机文件拍摄时间,格式 `yyyy-MM-dd HH:mm:ss`。
|
||||||
|
var capturedAt: String?
|
||||||
var status: CameraTransferStatus
|
var status: CameraTransferStatus
|
||||||
var progress: Int
|
var progress: Int
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
@ -34,6 +36,7 @@ struct CameraTransferTask: Identifiable, Equatable, Codable {
|
|||||||
filename: String,
|
filename: String,
|
||||||
localPath: String? = nil,
|
localPath: String? = nil,
|
||||||
remoteURL: String? = nil,
|
remoteURL: String? = nil,
|
||||||
|
capturedAt: String? = nil,
|
||||||
status: CameraTransferStatus = .pending,
|
status: CameraTransferStatus = .pending,
|
||||||
progress: Int = 0,
|
progress: Int = 0,
|
||||||
errorMessage: String? = nil
|
errorMessage: String? = nil
|
||||||
@ -43,11 +46,36 @@ struct CameraTransferTask: Identifiable, Equatable, Codable {
|
|||||||
self.filename = filename
|
self.filename = filename
|
||||||
self.localPath = localPath
|
self.localPath = localPath
|
||||||
self.remoteURL = remoteURL
|
self.remoteURL = remoteURL
|
||||||
|
self.capturedAt = capturedAt
|
||||||
self.status = status
|
self.status = status
|
||||||
self.progress = progress
|
self.progress = progress
|
||||||
self.errorMessage = errorMessage
|
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,支持相对路径与旧版绝对路径。
|
/// 本地文件 URL,支持相对路径与旧版绝对路径。
|
||||||
var localURL: URL? {
|
var localURL: URL? {
|
||||||
guard let localPath, !localPath.isEmpty else { return nil }
|
guard let localPath, !localPath.isEmpty else { return nil }
|
||||||
|
|||||||
@ -18,7 +18,13 @@ protocol TravelAlbumServing {
|
|||||||
func albumInfo(id: Int) async throws -> TravelAlbumItem
|
func albumInfo(id: Int) async throws -> TravelAlbumItem
|
||||||
func editAlbum(_ request: TravelAlbumEditRequest) async throws
|
func editAlbum(_ request: TravelAlbumEditRequest) async throws
|
||||||
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws
|
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws
|
||||||
func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumMaterial>
|
func materialList(
|
||||||
|
userEquityTravelId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderBy: Int,
|
||||||
|
isPurchased: Int?
|
||||||
|
) async throws -> ListPayload<TravelAlbumMaterial>
|
||||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
|
||||||
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws
|
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws
|
||||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
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<TravelAlbumMaterial> {
|
func materialList(
|
||||||
try await client.send(
|
userEquityTravelId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderBy: Int = 2,
|
||||||
|
isPurchased: Int? = nil
|
||||||
|
) async throws -> ListPayload<TravelAlbumMaterial> {
|
||||||
|
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(
|
APIRequest(
|
||||||
method: .get,
|
method: .get,
|
||||||
path: "\(travelAlbumBase)/material-list",
|
path: "\(travelAlbumBase)/material-list",
|
||||||
queryItems: [
|
queryItems: 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")
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -161,6 +161,18 @@ struct TravelAlbumMaterial: Decodable, Identifiable, Equatable {
|
|||||||
createdAt = container.lossyString(forKey: .createdAt)
|
createdAt = container.lossyString(forKey: .createdAt)
|
||||||
updatedAt = container.lossyString(forKey: .updatedAt)
|
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 id: String
|
||||||
let sourceId: String
|
let sourceId: String
|
||||||
let fileName: String
|
let fileName: String
|
||||||
|
/// 列表使用的缩略图 URL,优先指向本地小图或远程封面。
|
||||||
let thumbnailURL: String
|
let thumbnailURL: String
|
||||||
|
/// 预览使用的大图 URL,优先指向本地原图。
|
||||||
|
let previewURL: String
|
||||||
let capturedAt: String
|
let capturedAt: String
|
||||||
let fileSizeText: String
|
let fileSizeText: String
|
||||||
let resolutionText: String
|
let resolutionText: String
|
||||||
@ -321,6 +336,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
|||||||
sourceId: String? = nil,
|
sourceId: String? = nil,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
thumbnailURL: String,
|
thumbnailURL: String,
|
||||||
|
previewURL: String = "",
|
||||||
capturedAt: String,
|
capturedAt: String,
|
||||||
fileSizeText: String,
|
fileSizeText: String,
|
||||||
resolutionText: String = "",
|
resolutionText: String = "",
|
||||||
@ -332,6 +348,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
|||||||
self.sourceId = sourceId ?? id
|
self.sourceId = sourceId ?? id
|
||||||
self.fileName = fileName
|
self.fileName = fileName
|
||||||
self.thumbnailURL = thumbnailURL
|
self.thumbnailURL = thumbnailURL
|
||||||
|
self.previewURL = previewURL
|
||||||
self.capturedAt = capturedAt
|
self.capturedAt = capturedAt
|
||||||
self.fileSizeText = fileSizeText
|
self.fileSizeText = fileSizeText
|
||||||
self.resolutionText = resolutionText
|
self.resolutionText = resolutionText
|
||||||
@ -385,8 +402,15 @@ struct WiredTransferPhotoRecord: Codable, Equatable {
|
|||||||
let updatedAt: TimeInterval
|
let updatedAt: TimeInterval
|
||||||
|
|
||||||
func toPhotoItem() -> WiredTransferPhotoItem {
|
func toPhotoItem() -> WiredTransferPhotoItem {
|
||||||
let previewCandidates = [thumbnailPath, localPath, remoteURL]
|
let thumbnailCandidates = [thumbnailPath, remoteURL]
|
||||||
let thumbnailURL = previewCandidates.compactMap { path -> String? in
|
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 }
|
guard !path.isEmpty else { return nil }
|
||||||
if path.hasPrefix("http") { return path }
|
if path.hasPrefix("http") { return path }
|
||||||
let preview = CameraDownloadStorage.previewURLString(from: path)
|
let preview = CameraDownloadStorage.previewURLString(from: path)
|
||||||
@ -398,6 +422,7 @@ struct WiredTransferPhotoRecord: Codable, Equatable {
|
|||||||
sourceId: sourceId,
|
sourceId: sourceId,
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
thumbnailURL: thumbnailURL,
|
thumbnailURL: thumbnailURL,
|
||||||
|
previewURL: previewURL,
|
||||||
capturedAt: capturedAt,
|
capturedAt: capturedAt,
|
||||||
fileSizeText: Self.formatFileSize(fileSizeBytes),
|
fileSizeText: Self.formatFileSize(fileSizeBytes),
|
||||||
status: WiredTransferUploadStatus(rawValue: status) ?? .pending,
|
status: WiredTransferUploadStatus(rawValue: status) ?? .pending,
|
||||||
@ -465,6 +490,16 @@ extension Array where Element == WiredTransferPhotoItem {
|
|||||||
Set(filter { $0.canSelectForSpecifyUpload }.map(\.id))
|
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 分钟时间段分组列表。
|
/// 构建 30 分钟时间段分组列表。
|
||||||
func buildPhotoSections() -> [WiredTransferPhotoSection] {
|
func buildPhotoSections() -> [WiredTransferPhotoSection] {
|
||||||
let grouped = Dictionary(grouping: compactMap { photo -> (Date, WiredTransferPhotoItem)? in
|
let grouped = Dictionary(grouping: compactMap { photo -> (Date, WiredTransferPhotoItem)? in
|
||||||
|
|||||||
@ -122,6 +122,9 @@ final class WiredTransferPhotoStore {
|
|||||||
guard let data = defaults.data(forKey: storageKey(albumID)),
|
guard let data = defaults.data(forKey: storageKey(albumID)),
|
||||||
var records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
var records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||||||
else { return }
|
else { return }
|
||||||
|
records
|
||||||
|
.filter { $0.id == photoID && $0.userId == userIDProvider() && $0.albumId == albumID }
|
||||||
|
.forEach { deleteRecordFiles($0) }
|
||||||
records = records.filter { record in
|
records = records.filter { record in
|
||||||
!deletedIDs.contains(record.id)
|
!deletedIDs.contains(record.id)
|
||||||
&& record.userId == userIDProvider()
|
&& record.userId == userIDProvider()
|
||||||
|
|||||||
@ -15,9 +15,29 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 |
|
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 |
|
||||||
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
|
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
|
||||||
| `TravelAlbumDetailView` | 相册详情与素材管理 |
|
| `TravelAlbumDetailView` | 相册详情与素材管理(UI 对齐 Android) |
|
||||||
| `WiredCameraTransferView` | 有线传图页(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 结构
|
## 有线传图 UI 结构
|
||||||
|
|
||||||
Android 对照:`zhiflyfollow/.../WiredCameraTransferScreen.kt`
|
Android 对照:`zhiflyfollow/.../WiredCameraTransferScreen.kt`
|
||||||
@ -50,6 +70,13 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
|||||||
4. 素材登记:`POST .../travel-album/upload-material`
|
4. 素材登记:`POST .../travel-album/upload-material`
|
||||||
5. 本地照片路径以相对 Documents 的路径持久化(如 `CameraDownloads/xxx.JPG`),加载时自动迁移旧版绝对路径
|
5. 本地照片路径以相对 Documents 的路径持久化(如 `CameraDownloads/xxx.JPG`),加载时自动迁移旧版绝对路径
|
||||||
|
|
||||||
|
## 有线传图性能策略
|
||||||
|
|
||||||
|
- 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。
|
||||||
|
- 连拍上传进度采用节流通知,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传自动队列最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
|
||||||
|
- `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。
|
||||||
|
- 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。
|
||||||
|
|
||||||
## 解耦关系
|
## 解耦关系
|
||||||
|
|
||||||
- `CameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
|
- `CameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
|
||||||
|
|||||||
@ -177,38 +177,145 @@ struct TravelAlbumCodeSheetState: Identifiable {
|
|||||||
var id: Int { albumID }
|
var id: Int { albumID }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册详情 ViewModel。
|
/// 旅拍相册详情 ViewModel,对齐 Android TravelAlbumDetailViewModel。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TravelAlbumDetailViewModel: ObservableObject {
|
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 album: TravelAlbumItem?
|
||||||
@Published var materials: [TravelAlbumMaterial] = []
|
@Published var materials: [TravelAlbumMaterial] = []
|
||||||
|
@Published var allPhotoCount = 0
|
||||||
|
@Published var selectedTab = tabAll
|
||||||
|
@Published var orderBy = 2
|
||||||
|
@Published var isSelectionMode = false
|
||||||
|
@Published var selectedMaterialIDs: Set<Int> = []
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var isDeleting = false
|
@Published var isDeleting = false
|
||||||
|
@Published var showDeleteAlbumConfirm = false
|
||||||
|
@Published var showDeleteMaterialConfirm = false
|
||||||
@Published var errorMessage: String?
|
@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 {
|
func load(api: any TravelAlbumServing, albumID: Int) async {
|
||||||
isLoading = true
|
await refreshAll(api: api, albumID: albumID)
|
||||||
defer { isLoading = false }
|
}
|
||||||
do {
|
|
||||||
async let info = api.albumInfo(id: albumID)
|
/// 切换 Tab 并重载素材。
|
||||||
async let list = api.materialList(userEquityTravelId: albumID, page: 1, pageSize: 100)
|
func selectTab(_ tab: Int, api: any TravelAlbumServing) async {
|
||||||
album = try await info
|
guard selectedTab != tab else { return }
|
||||||
materials = try await list.list
|
selectedTab = tab
|
||||||
errorMessage = nil
|
isSelectionMode = false
|
||||||
} catch {
|
selectedMaterialIDs = []
|
||||||
errorMessage = error.localizedDescription
|
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 {
|
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||||
do {
|
guard isSelectionMode else { return }
|
||||||
try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: materialID))
|
guard material.status == Self.materialStatusUnpurchased else {
|
||||||
await load(api: api, albumID: albumID)
|
errorMessage = "仅未购买素材可删除"
|
||||||
} catch {
|
return
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
}
|
||||||
|
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
|
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,编排相机连接、传输管道与本地持久化。
|
/// 有线传图 ViewModel,编排相机连接、传输管道与本地持久化。
|
||||||
@ -237,7 +418,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片"
|
static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片"
|
||||||
static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")!
|
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 selectedTabIndex = 0
|
||||||
@Published var sidebarExpanded = true
|
@Published var sidebarExpanded = true
|
||||||
@Published var selectedTimeSlotID: String?
|
@Published var selectedTimeSlotID: String?
|
||||||
@ -258,6 +443,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
private var photoStore: WiredTransferPhotoStore?
|
private var photoStore: WiredTransferPhotoStore?
|
||||||
private var userIDProvider: (() -> String)?
|
private var userIDProvider: (() -> String)?
|
||||||
private var sessionNewPhotoIDs: Set<String> = []
|
private var sessionNewPhotoIDs: Set<String> = []
|
||||||
|
private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
|
||||||
|
private var lastProgressPersistDates: [String: Date] = [:]
|
||||||
private var wasConnected = false
|
private var wasConnected = false
|
||||||
private var disconnectAlertTask: Task<Void, Never>?
|
private var disconnectAlertTask: Task<Void, Never>?
|
||||||
|
|
||||||
@ -276,7 +463,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
pipeline.onTasksUpdated = { [weak self] tasks in
|
pipeline.onTasksUpdated = { [weak self] tasks in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.applyPipelineTasks(tasks)
|
await self?.applyPipelineTasks(tasks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -325,6 +512,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
func selectTab(_ index: Int) {
|
func selectTab(_ index: Int) {
|
||||||
selectedTabIndex = index
|
selectedTabIndex = index
|
||||||
selectedTimeSlotID = nil
|
selectedTimeSlotID = nil
|
||||||
|
refreshDerivedPhotoState(rebuildGroups: true)
|
||||||
if selectUploadMode {
|
if selectUploadMode {
|
||||||
syncSelectionWithVisiblePhotos()
|
syncSelectionWithVisiblePhotos()
|
||||||
}
|
}
|
||||||
@ -432,16 +620,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前 Tab 可见照片。
|
|
||||||
var visiblePhotos: [WiredTransferPhotoItem] {
|
|
||||||
photos.filtered(byTabIndex: selectedTabIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 预览 URL。
|
/// 预览 URL。
|
||||||
func previewURL(for photoID: String) -> String {
|
func previewURL(for photoID: String) -> String {
|
||||||
guard let photo = photos.first(where: { $0.id == photoID }) else { return "" }
|
guard let photo = photos.first(where: { $0.id == photoID }) else { return "" }
|
||||||
if !photo.thumbnailURL.isEmpty { return photo.thumbnailURL }
|
if !photo.previewURL.isEmpty { return photo.previewURL }
|
||||||
return ""
|
return photo.thumbnailURL
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量上传所有待上传照片。
|
/// 批量上传所有待上传照片。
|
||||||
@ -474,14 +657,48 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 重试单张照片上传。
|
/// 重试单张照片上传。
|
||||||
func retryPhoto(id: String) async {
|
func retryPhoto(
|
||||||
await pipeline.uploadAssets(withIDs: [id])
|
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) {
|
func deletePhoto(id: String) {
|
||||||
photoStore?.remove(albumID: context.albumId, photoID: id)
|
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)
|
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() {
|
private func syncSelectionWithVisiblePhotos() {
|
||||||
let visibleIDs = Set(visiblePhotos.map(\.id))
|
let visibleIDs = Set(visiblePhotos.map(\.id))
|
||||||
selectedPhotoIDs = selectedPhotoIDs.intersection(visibleIDs)
|
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) {
|
private func handleConnectionStateChange(_ state: CameraConnectionState) {
|
||||||
if wasConnected, !state.isConnected {
|
if wasConnected, !state.isConnected {
|
||||||
triggerCameraDisconnectedAlert()
|
triggerCameraDisconnectedAlert()
|
||||||
@ -553,73 +876,182 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
private func loadPersistedPhotos() {
|
private func loadPersistedPhotos() {
|
||||||
guard context.albumId > 0, let photoStore else { return }
|
guard context.albumId > 0, let photoStore else { return }
|
||||||
let records = photoStore.load(albumID: context.albumId)
|
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]) {
|
private func applyPipelineTasks(_ tasks: [CameraTransferTask]) async {
|
||||||
guard let photoStore, let userIDProvider else { return }
|
guard let userIDProvider else { return }
|
||||||
let userID = userIDProvider()
|
let userID = userIDProvider()
|
||||||
|
|
||||||
var merged = photos.filter { photo in
|
// 保留已有元数据,避免上传进度回调反复重置 capturedAt 导致列表分组抖动甚至崩溃。
|
||||||
!tasks.contains { $0.assetID == photo.id || $0.filename == photo.fileName }
|
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 {
|
for task in tasks {
|
||||||
guard belongsToCurrentAlbum(task.assetID) else { continue }
|
guard belongsToCurrentAlbum(task.assetID) else { continue }
|
||||||
|
|
||||||
let status: WiredTransferUploadStatus
|
let status = mapPipelineStatus(task.status)
|
||||||
switch task.status {
|
|
||||||
case .pending, .downloading:
|
|
||||||
status = .transferring
|
|
||||||
case .downloaded:
|
|
||||||
status = .pending
|
|
||||||
case .uploading:
|
|
||||||
status = .uploading
|
|
||||||
case .uploaded:
|
|
||||||
status = .uploaded
|
|
||||||
case .failed:
|
|
||||||
status = .failed
|
|
||||||
}
|
|
||||||
|
|
||||||
let capturedAt = Self.captureTimestampFormatter.string(from: Date())
|
|
||||||
let storedLocalPath = task.localPath ?? ""
|
let storedLocalPath = task.localPath ?? ""
|
||||||
let localURL = task.localURL
|
let localURL = task.localURL
|
||||||
let fileSizeBytes: Int64
|
let fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL)
|
||||||
if let localURL {
|
|
||||||
fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64) ?? 0
|
let existing = existingByID[task.assetID] ?? existingByFileName[task.filename]
|
||||||
} else {
|
let existingRecord = persistedRecordsByID[task.assetID]
|
||||||
fileSizeBytes = 0
|
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(
|
let item = WiredTransferPhotoItem(
|
||||||
id: task.assetID,
|
id: task.assetID,
|
||||||
|
sourceId: existing?.sourceId ?? task.assetID,
|
||||||
fileName: task.filename,
|
fileName: task.filename,
|
||||||
thumbnailURL: thumbnailURL,
|
thumbnailURL: thumbnailURL,
|
||||||
|
previewURL: previewURL,
|
||||||
capturedAt: capturedAt,
|
capturedAt: capturedAt,
|
||||||
fileSizeText: WiredTransferPhotoRecord.formatFileSize(fileSizeBytes),
|
fileSizeText: fileSizeText,
|
||||||
|
resolutionText: existing?.resolutionText ?? "",
|
||||||
status: status,
|
status: status,
|
||||||
progress: task.progress,
|
progress: task.progress,
|
||||||
errorMessage: task.errorMessage
|
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)
|
sessionNewPhotoIDs.insert(task.assetID)
|
||||||
|
|
||||||
guard !userID.isEmpty else { continue }
|
guard !userID.isEmpty else { continue }
|
||||||
let record = item.toRecord(
|
let remoteURL = task.remoteURL ?? ""
|
||||||
albumId: context.albumId,
|
if shouldPersistPipelineUpdate(
|
||||||
userId: userID,
|
photoID: task.assetID,
|
||||||
|
status: status,
|
||||||
localPath: storedLocalPath,
|
localPath: storedLocalPath,
|
||||||
fileSizeBytes: fileSizeBytes,
|
thumbnailPath: thumbnailPath,
|
||||||
remoteURL: task.remoteURL ?? ""
|
remoteURL: remoteURL,
|
||||||
)
|
progress: task.progress,
|
||||||
var records = photoStore.load(albumID: context.albumId)
|
now: Date()
|
||||||
records.removeAll { $0.id == record.id }
|
) {
|
||||||
records.insert(record, at: 0)
|
let record = item.toRecord(
|
||||||
photoStore.save(albumID: context.albumId, records: records)
|
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
|
return bound == context.albumId
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let captureTimestampFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Int>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// 旅拍相册详情页,展示素材网格与管理操作。
|
/// 旅拍相册详情页,展示素材网格与管理操作,对齐 Android TravelAlbumDetailScreen。
|
||||||
struct TravelAlbumDetailView: View {
|
struct TravelAlbumDetailView: View {
|
||||||
let albumID: Int
|
let albumID: Int
|
||||||
|
|
||||||
@ -16,9 +16,13 @@ struct TravelAlbumDetailView: View {
|
|||||||
@EnvironmentObject private var toastCenter: ToastCenter
|
@EnvironmentObject private var toastCenter: ToastCenter
|
||||||
@Environment(\.globalLoading) private var globalLoading
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
@StateObject private var viewModel = TravelAlbumDetailViewModel()
|
@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 {
|
private var photoStore: WiredTransferPhotoStore {
|
||||||
WiredTransferPhotoStore(
|
WiredTransferPhotoStore(
|
||||||
@ -28,125 +32,186 @@ struct TravelAlbumDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
if viewModel.isLoading, viewModel.album == nil {
|
||||||
if let album = viewModel.album {
|
ProgressView()
|
||||||
albumHeader(album)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
} else if viewModel.album != nil {
|
||||||
|
contentView
|
||||||
if viewModel.materials.isEmpty, !viewModel.isLoading {
|
} else {
|
||||||
AppContentUnavailableView("暂无素材", systemImage: "photo.on.rectangle")
|
Text("加载失败")
|
||||||
.frame(maxWidth: .infinity, minHeight: 200)
|
.font(.system(size: 14))
|
||||||
} else {
|
.foregroundStyle(TravelAlbumDetailDesign.text333)
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
ForEach(viewModel.materials) { material in
|
|
||||||
materialCell(material)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
|
||||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
|
||||||
}
|
}
|
||||||
.background(Color(hex: 0xF5F7FA))
|
.background(TravelAlbumDetailDesign.pageBackground.ignoresSafeArea())
|
||||||
.navigationTitle(viewModel.album?.name ?? "相册详情")
|
.navigationTitle("相册管理")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
if let album = viewModel.album {
|
Menu {
|
||||||
NavigationLink {
|
Button("删除相册", role: .destructive) {
|
||||||
WiredCameraTransferView(context: WiredTransferContext(
|
viewModel.showDeleteAlbumConfirm = true
|
||||||
albumId: album.id,
|
|
||||||
albumName: album.name,
|
|
||||||
phone: album.displayPhone,
|
|
||||||
orderNumber: album.orderNumber
|
|
||||||
))
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "cable.connector")
|
|
||||||
}
|
}
|
||||||
.accessibilityLabel("有线传图")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
|
||||||
showDeleteConfirm = true
|
|
||||||
} label: {
|
} 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) {
|
Button("删除", role: .destructive) {
|
||||||
Task { await deleteAlbum() }
|
Task { await deleteAlbum() }
|
||||||
}
|
}
|
||||||
Button("取消", role: .cancel) {}
|
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) {
|
.task(id: albumID) {
|
||||||
globalLoading.show()
|
globalLoading.show()
|
||||||
defer { globalLoading.hide() }
|
defer { globalLoading.hide() }
|
||||||
await viewModel.load(api: travelAlbumAPI, albumID: albumID)
|
await viewModel.refreshAll(api: travelAlbumAPI, albumID: albumID)
|
||||||
}
|
}
|
||||||
.refreshable {
|
.onChange(of: navigateToWiredTransfer) { isActive in
|
||||||
await viewModel.load(api: travelAlbumAPI, albumID: albumID)
|
guard !isActive, didReturnFromWiredTransfer else { return }
|
||||||
|
didReturnFromWiredTransfer = false
|
||||||
|
Task { await viewModel.refreshAll(api: travelAlbumAPI, albumID: albumID) }
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.errorMessage) { message in
|
.onChange(of: viewModel.errorMessage) { message in
|
||||||
guard let message, !message.isEmpty else { return }
|
guard let message, !message.isEmpty else { return }
|
||||||
toastCenter.show(message)
|
toastCenter.show(message)
|
||||||
|
viewModel.errorMessage = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func albumHeader(_ album: TravelAlbumItem) -> some View {
|
private var contentView: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(spacing: TravelAlbumDetailDesign.sectionSpacing) {
|
||||||
Text(album.type == 1 ? "先拍再买" : "买了再拍")
|
if let album = viewModel.album {
|
||||||
.font(.system(size: AppMetrics.FontSize.caption))
|
TravelAlbumDetailInfoCard(album: album) { phone in
|
||||||
.foregroundStyle(AppDesign.primary)
|
dialPhone(phone)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
} 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)
|
.padding(.horizontal, TravelAlbumDetailDesign.contentHorizontalPadding)
|
||||||
.frame(maxWidth: .infinity)
|
.padding(.top, 12)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.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 {
|
private func deleteAlbum() async {
|
||||||
@ -158,4 +223,20 @@ struct TravelAlbumDetailView: View {
|
|||||||
dismiss()
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,7 +53,7 @@ struct WiredCameraTransferView: View {
|
|||||||
cameraDeviceName: viewModel.cameraDeviceName,
|
cameraDeviceName: viewModel.cameraDeviceName,
|
||||||
transferModeOption: viewModel.transferModeOption,
|
transferModeOption: viewModel.transferModeOption,
|
||||||
tabTitles: tabTitles,
|
tabTitles: tabTitles,
|
||||||
tabCounts: viewModel.photos.transferTabCounts,
|
tabCounts: viewModel.tabCounts,
|
||||||
selectedTabIndex: viewModel.selectedTabIndex,
|
selectedTabIndex: viewModel.selectedTabIndex,
|
||||||
onDeviceUsageClick: viewModel.onDeviceUsageClick,
|
onDeviceUsageClick: viewModel.onDeviceUsageClick,
|
||||||
onRefreshCameraFiles: {
|
onRefreshCameraFiles: {
|
||||||
@ -168,11 +168,11 @@ struct WiredCameraTransferView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var sidebarGroups: [WiredTransferDateGroup] {
|
private var sidebarGroups: [WiredTransferDateGroup] {
|
||||||
visiblePhotos.buildSidebarGroups()
|
viewModel.sidebarGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
private var photoSections: [WiredTransferPhotoSection] {
|
private var photoSections: [WiredTransferPhotoSection] {
|
||||||
visiblePhotos.buildPhotoSections()
|
viewModel.photoSections
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectAllBar: some View {
|
private var selectAllBar: some View {
|
||||||
@ -202,7 +202,16 @@ struct WiredCameraTransferView: View {
|
|||||||
onToggleSidebar: viewModel.toggleSidebar,
|
onToggleSidebar: viewModel.toggleSidebar,
|
||||||
onTimeSlotSelected: viewModel.selectTimeSlot,
|
onTimeSlotSelected: viewModel.selectTimeSlot,
|
||||||
onPhotoClick: openPhotoPreview,
|
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,
|
onDelete: viewModel.deletePhoto,
|
||||||
onTogglePhotoSelection: viewModel.togglePhotoSelection
|
onTogglePhotoSelection: viewModel.togglePhotoSelection
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
|
|
||||||
/// 有线传图照片预览,支持本地 file:// 与远程 URL。
|
/// 有线传图照片预览,支持本地 file:// 与远程 URL。
|
||||||
@ -19,7 +18,8 @@ struct WiredTransferPhotoPreviewSheet: View {
|
|||||||
self.imageSources = imageSources
|
self.imageSources = imageSources
|
||||||
self.startIndex = startIndex
|
self.startIndex = startIndex
|
||||||
self.onDismiss = onDismiss
|
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 {
|
var body: some View {
|
||||||
@ -46,12 +46,7 @@ struct WiredTransferPhotoPreviewSheet: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func previewImage(_ source: String) -> some View {
|
private func previewImage(_ source: String) -> some View {
|
||||||
if source.hasPrefix("file://"), let url = URL(string: source),
|
if let url = URL(string: source), !source.isEmpty {
|
||||||
let uiImage = UIImage(contentsOfFile: url.path) {
|
|
||||||
Image(uiImage: uiImage)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
} else if let url = URL(string: source), !source.isEmpty {
|
|
||||||
KFImage(url)
|
KFImage(url)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@ -23,36 +24,40 @@ struct WiredTransferPhotoRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
if selectUploadMode {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
Button(action: onToggleSelection) {
|
if selectUploadMode {
|
||||||
WiredTransferSelectIndicator(selected: selected, enabled: canSelect)
|
WiredTransferSelectIndicator(selected: selected, enabled: canSelect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!canSelect)
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnailView
|
thumbnailView
|
||||||
.frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize)
|
.frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius))
|
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius))
|
||||||
.opacity(rowOpacity)
|
.opacity(rowOpacity)
|
||||||
.onTapGesture {
|
|
||||||
if !selectUploadMode { onPhotoClick() }
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(photo.fileName)
|
Text(photo.fileName)
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity))
|
.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)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 0)
|
|
||||||
WiredTransferStatusBadge(status: photo.status)
|
|
||||||
.opacity(rowOpacity)
|
|
||||||
}
|
}
|
||||||
Text(metaText)
|
}
|
||||||
.font(.system(size: 10))
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
|
.contentShape(Rectangle())
|
||||||
.lineLimit(1)
|
.onTapGesture {
|
||||||
|
if selectUploadMode {
|
||||||
|
if canSelect { onToggleSelection() }
|
||||||
|
} else {
|
||||||
|
onPhotoClick()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !selectUploadMode {
|
if !selectUploadMode {
|
||||||
@ -71,10 +76,6 @@ struct WiredTransferPhotoRow: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
if selectUploadMode, canSelect { onToggleSelection() }
|
|
||||||
}
|
|
||||||
|
|
||||||
if photo.status == .transferring || photo.status == .uploading {
|
if photo.status == .transferring || photo.status == .uploading {
|
||||||
ProgressView(value: Double(photo.progress), total: 100)
|
ProgressView(value: Double(photo.progress), total: 100)
|
||||||
@ -93,18 +94,20 @@ struct WiredTransferPhotoRow: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var thumbnailView: some View {
|
private var thumbnailView: some View {
|
||||||
if photo.thumbnailURL.hasPrefix("file://"), let url = URL(string: photo.thumbnailURL) {
|
if let url = URL(string: photo.thumbnailURL), !photo.thumbnailURL.isEmpty {
|
||||||
if let uiImage = UIImage(contentsOfFile: url.path) {
|
KFImage(url)
|
||||||
Image(uiImage: uiImage)
|
.placeholder { thumbnailPlaceholder }
|
||||||
.resizable()
|
.setProcessor(
|
||||||
.aspectRatio(contentMode: .fill)
|
DownsamplingImageProcessor(
|
||||||
} else {
|
size: CGSize(
|
||||||
thumbnailPlaceholder
|
width: WiredTransferDesign.thumbnailSize,
|
||||||
}
|
height: WiredTransferDesign.thumbnailSize
|
||||||
} else if !photo.thumbnailURL.isEmpty {
|
)
|
||||||
RemoteImage(urlString: photo.thumbnailURL) {
|
)
|
||||||
thumbnailPlaceholder
|
)
|
||||||
}
|
.scaleFactor(UIScreen.main.scale)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
} else {
|
} else {
|
||||||
thumbnailPlaceholder
|
thumbnailPlaceholder
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,72 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
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
|
@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
|
@MainActor
|
||||||
private final class MockUploadSink: CameraAssetUploadSink {
|
private final class MockUploadSink: CameraAssetUploadSink {
|
||||||
var uploadCount = 0
|
var uploadCount = 0
|
||||||
@ -77,3 +191,18 @@ private final class MockUploadSink: CameraAssetUploadSink {
|
|||||||
return "https://cdn/mock/\(fileName)"
|
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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -61,6 +61,29 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(body["file_url"] as? String, "https://cdn/a.jpg")
|
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(
|
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
|
#"{"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(
|
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
|
#"{"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 {
|
private final class TravelAlbumRecordingURLSession: URLSessionProtocol {
|
||||||
|
|||||||
@ -81,6 +81,139 @@ final class TravelAlbumViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTAssertTrue(name.hasSuffix("003"))
|
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<TravelAlbumMaterial>.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<TravelAlbumItem> {
|
||||||
|
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<TravelAlbumItem>.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<TravelAlbumMaterial> {
|
||||||
|
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
|
@MainActor
|
||||||
@ -108,7 +241,13 @@ private final class MockTravelAlbumAPI: TravelAlbumServing {
|
|||||||
|
|
||||||
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
|
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
|
||||||
|
|
||||||
func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumMaterial> {
|
func materialList(
|
||||||
|
userEquityTravelId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderBy: Int,
|
||||||
|
isPurchased: Int?
|
||||||
|
) async throws -> ListPayload<TravelAlbumMaterial> {
|
||||||
ListPayload(total: 0, list: [])
|
ListPayload(total: 0, list: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
import UIKit
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -15,7 +16,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
func testFirstNewPipelineTaskAppearsInPhotos() async throws {
|
func testFirstNewPipelineTaskAppearsInPhotos() async throws {
|
||||||
let camera = MockWiredCameraService()
|
let camera = MockWiredCameraService()
|
||||||
let context = WiredTransferContext(
|
let context = WiredTransferContext(
|
||||||
albumId: 1,
|
albumId: 6603,
|
||||||
albumName: "测试相册",
|
albumName: "测试相册",
|
||||||
phone: "",
|
phone: "",
|
||||||
orderNumber: ""
|
orderNumber: ""
|
||||||
@ -125,10 +126,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
||||||
cameraService: MockWiredCameraService()
|
cameraService: MockWiredCameraService()
|
||||||
)
|
)
|
||||||
viewModel.photos = [
|
viewModel.replacePhotos([
|
||||||
makePendingPhoto(id: "today", capturedAt: today),
|
makePendingPhoto(id: "today", capturedAt: today),
|
||||||
makePendingPhoto(id: "old", capturedAt: "2020-01-01 09:00:00"),
|
makePendingPhoto(id: "old", capturedAt: "2020-01-01 09:00:00"),
|
||||||
]
|
])
|
||||||
|
|
||||||
await viewModel.onSpecifyUploadOptionSelected(
|
await viewModel.onSpecifyUploadOptionSelected(
|
||||||
WiredCameraTransferViewModel.specifyUploadTodayCaptured,
|
WiredCameraTransferViewModel.specifyUploadTodayCaptured,
|
||||||
@ -139,23 +140,257 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
XCTAssertNil(viewModel.errorMessage)
|
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 {
|
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
|
||||||
let viewModel = WiredCameraTransferViewModel(
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
||||||
cameraService: MockWiredCameraService()
|
cameraService: MockWiredCameraService()
|
||||||
)
|
)
|
||||||
viewModel.photos = [
|
viewModel.replacePhotos([
|
||||||
makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"),
|
makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"),
|
||||||
makePendingPhoto(id: "pending-2", capturedAt: "2026-05-20 12:18:06"),
|
makePendingPhoto(id: "pending-2", capturedAt: "2026-05-20 12:18:06"),
|
||||||
WiredTransferPhotoItem(
|
WiredTransferPhotoItem(
|
||||||
id: "uploaded-1",
|
id: "uploaded-1",
|
||||||
fileName: "uploaded.JPG",
|
fileName: "uploaded.JPG",
|
||||||
thumbnailURL: "",
|
thumbnailURL: "",
|
||||||
|
previewURL: "",
|
||||||
capturedAt: "2026-05-20 12:20:00",
|
capturedAt: "2026-05-20 12:20:00",
|
||||||
fileSizeText: "1 MB",
|
fileSizeText: "1 MB",
|
||||||
status: .uploaded
|
status: .uploaded
|
||||||
),
|
),
|
||||||
]
|
])
|
||||||
return viewModel
|
return viewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +404,14 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
status: .pending
|
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
|
@MainActor
|
||||||
@ -210,7 +453,13 @@ private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
|
|||||||
func albumInfo(id: Int) async throws -> TravelAlbumItem { throw APIError.emptyData }
|
func albumInfo(id: Int) async throws -> TravelAlbumItem { throw APIError.emptyData }
|
||||||
func editAlbum(_ request: TravelAlbumEditRequest) async throws {}
|
func editAlbum(_ request: TravelAlbumEditRequest) async throws {}
|
||||||
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
|
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
|
||||||
func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumMaterial> {
|
func materialList(
|
||||||
|
userEquityTravelId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderBy: Int,
|
||||||
|
isPurchased: Int?
|
||||||
|
) async throws -> ListPayload<TravelAlbumMaterial> {
|
||||||
ListPayload(total: 0, list: [])
|
ListPayload(total: 0, list: [])
|
||||||
}
|
}
|
||||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ final class WiredTransferPhotoGroupingTests: XCTestCase {
|
|||||||
let sections = photos.buildPhotoSections()
|
let sections = photos.buildPhotoSections()
|
||||||
XCTAssertEqual(sections.count, 2)
|
XCTAssertEqual(sections.count, 2)
|
||||||
XCTAssertEqual(sections[0].photos.count, 2)
|
XCTAssertEqual(sections[0].photos.count, 2)
|
||||||
|
XCTAssertEqual(sections[0].photos[0].id, "2")
|
||||||
XCTAssertTrue(sections[0].headerTitle.contains("12:00 - 12:30"))
|
XCTAssertTrue(sections[0].headerTitle.contains("12:00 - 12:30"))
|
||||||
|
|
||||||
let sidebar = photos.buildSidebarGroups()
|
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())
|
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 可选集合。
|
/// 测试 Tab 计数与 pending 可选集合。
|
||||||
func testTransferTabCountsAndSelectablePendingIDs() {
|
func testTransferTabCountsAndSelectablePendingIDs() {
|
||||||
let photos = [
|
let photos = [
|
||||||
|
|||||||
Reference in New Issue
Block a user