对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。

重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 13:53:59 +08:00
parent c71b45bdfd
commit 5e620623eb
23 changed files with 2000 additions and 288 deletions

View File

@ -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";

View File

@ -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
}
} }

View File

@ -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,23 +72,60 @@ 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)
}
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
tasks[refreshedIndex].status = .downloaded
tasks[refreshedIndex].errorMessage = nil
await uploadTask(assetID: assetID)
}
}
}
///
func retryUpload(assetID: String, filename: String, localPath: String) async {
guard uploadSink != nil else { return }
guard !activeUploadAssetIDs.contains(assetID) else { return }
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
if tasks[index].status == .uploaded { return }
if tasks[index].localPath?.isEmpty != false, !localPath.isEmpty {
tasks[index].localPath = localPath
} }
if tasks[index].status == .downloaded || tasks[index].status == .failed {
tasks[index].status = .downloaded tasks[index].status = .downloaded
await uploadTask(at: index) 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)
} }
/// ///
@ -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) {
switch mode {
case .immediate:
pendingDeferredNotifyTask?.cancel()
pendingDeferredNotifyTask = nil
onTasksUpdated?(tasks) 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
}

View File

@ -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 }

View File

@ -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,
APIRequest( page: Int,
method: .get, pageSize: Int,
path: "\(travelAlbumBase)/material-list", orderBy: Int = 2,
queryItems: [ isPurchased: Int? = nil
) async throws -> ListPayload<TravelAlbumMaterial> {
var queryItems = [
URLQueryItem(name: "user_equity_travel_id", value: "\(userEquityTravelId)"), URLQueryItem(name: "user_equity_travel_id", value: "\(userEquityTravelId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"), URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"), URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"),
URLQueryItem(name: "order_by", value: "2") URLQueryItem(name: "order_by", value: "\(max(orderBy, 1))")
] ]
if let isPurchased {
queryItems.append(URLQueryItem(name: "is_purchased", value: "\(isPurchased)"))
}
return try await client.send(
APIRequest(
method: .get,
path: "\(travelAlbumBase)/material-list",
queryItems: queryItems
) )
) )
} }

View File

@ -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

View File

@ -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()

View File

@ -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` 协议

View File

@ -177,39 +177,146 @@ 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) {
guard isSelectionMode else { return }
guard material.status == Self.materialStatusUnpurchased else {
errorMessage = "仅未购买素材可删除"
return
}
if selectedMaterialIDs.contains(material.id) {
selectedMaterialIDs.remove(material.id)
} else {
selectedMaterialIDs.insert(material.id)
}
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any TravelAlbumServing) async {
guard lastVisibleIndex >= materials.count - Self.loadMoreThreshold else { return }
await loadMaterials(api: api, albumID: albumID, reset: false)
}
///
func requestDeleteSelectedMaterials() {
guard !selectedMaterialIDs.isEmpty else {
errorMessage = "请选择要删除的素材"
return
}
showDeleteMaterialConfirm = true
}
///
@discardableResult
func confirmDeleteSelectedMaterials(api: any TravelAlbumServing) async -> Bool {
let ids = Array(selectedMaterialIDs)
guard !ids.isEmpty else {
showDeleteMaterialConfirm = false
return false
}
showDeleteMaterialConfirm = false
isDeleting = true
defer { isDeleting = false }
for id in ids {
do { do {
try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: materialID)) try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: id))
await load(api: api, albumID: albumID)
} catch { } catch {
errorMessage = error.localizedDescription 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
)
}
/// ///
func deleteAlbum(api: any TravelAlbumServing, albumID: Int, photoStore: WiredTransferPhotoStore) async -> Bool { func deleteAlbum(api: any TravelAlbumServing, albumID: Int, photoStore: WiredTransferPhotoStore) async -> Bool {
@ -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(
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]) 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 remoteURL = task.remoteURL ?? ""
if shouldPersistPipelineUpdate(
photoID: task.assetID,
status: status,
localPath: storedLocalPath,
thumbnailPath: thumbnailPath,
remoteURL: remoteURL,
progress: task.progress,
now: Date()
) {
let record = item.toRecord( let record = item.toRecord(
albumId: context.albumId, albumId: context.albumId,
userId: userID, userId: userID,
localPath: storedLocalPath, localPath: storedLocalPath,
thumbnailPath: thumbnailPath,
fileSizeBytes: fileSizeBytes, fileSizeBytes: fileSizeBytes,
remoteURL: task.remoteURL ?? "" remoteURL: remoteURL
) )
var records = photoStore.load(albumID: context.albumId) persistRecord(record)
records.removeAll { $0.id == record.id } }
records.insert(record, at: 0) }
photoStore.save(albumID: context.albumId, records: records)
} }
photos = merged.sorted { $0.capturedAt > $1.capturedAt } /// 使退
private func 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
}()
} }

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}

View File

@ -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 {
AppContentUnavailableView("暂无素材", systemImage: "photo.on.rectangle")
.frame(maxWidth: .infinity, minHeight: 200)
} else { } else {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { Text("加载失败")
ForEach(viewModel.materials) { material in .font(.system(size: 14))
materialCell(material) .foregroundStyle(TravelAlbumDetailDesign.text333)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
} .background(TravelAlbumDetailDesign.pageBackground.ignoresSafeArea())
} .navigationTitle("相册管理")
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle(viewModel.album?.name ?? "相册详情")
.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: { } label: {
Image(systemName: "cable.connector") Image(systemName: "ellipsis")
.foregroundStyle(TravelAlbumDetailDesign.text333)
} }
.accessibilityLabel("有线传图") .accessibilityLabel("更多")
}
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Image(systemName: "trash")
}
.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 { TravelAlbumDetailPhotoManageCard(
ZStack(alignment: .topTrailing) { materials: viewModel.materials,
RemoteImage( allPhotoCount: viewModel.allPhotoCount,
urlString: material.coverURL.isEmpty ? material.fileURL : material.coverURL, selectedTab: viewModel.selectedTab,
contentMode: .fill orderBy: viewModel.orderBy,
) { isSelectionMode: viewModel.isSelectionMode,
Color(hex: 0xE5E7EB) 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)
}
.padding(.horizontal, TravelAlbumDetailDesign.contentHorizontalPadding)
.padding(.top, 12)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
Menu { private var bottomBar: some View {
Button(role: .destructive) { VStack(spacing: 8) {
Task { if viewModel.isSelectionMode, !viewModel.selectedMaterialIDs.isEmpty {
await viewModel.deleteMaterial(api: travelAlbumAPI, materialID: material.id, albumID: albumID) Button {
} viewModel.requestDeleteSelectedMaterials()
} label: { } label: {
Label("删除", systemImage: "trash") Text("删除选中(\(viewModel.selectedMaterialIDs.count))")
} .font(.system(size: 16))
} label: {
Image(systemName: "ellipsis")
.padding(6)
.background(Color.black.opacity(0.35))
.foregroundStyle(.white) .foregroundStyle(.white)
.clipShape(Circle())
.padding(4)
}
}
.aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 8)) .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
} }

View File

@ -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
) )

View File

@ -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()

View File

@ -3,6 +3,7 @@
// suixinkan // suixinkan
// //
import Kingfisher
import SwiftUI import SwiftUI
import UIKit import UIKit
@ -22,22 +23,16 @@ 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) { HStack(alignment: .center, spacing: 8) {
if selectUploadMode { if selectUploadMode {
Button(action: onToggleSelection) {
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) {
@ -54,6 +49,16 @@ struct WiredTransferPhotoRow: View {
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity)) .foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
.lineLimit(1) .lineLimit(1)
} }
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
if selectUploadMode {
if canSelect { onToggleSelection() }
} else {
onPhotoClick()
}
}
if !selectUploadMode { if !selectUploadMode {
Menu { Menu {
@ -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,21 +94,23 @@ 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 }
.setProcessor(
DownsamplingImageProcessor(
size: CGSize(
width: WiredTransferDesign.thumbnailSize,
height: WiredTransferDesign.thumbnailSize
)
)
)
.scaleFactor(UIScreen.main.scale)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} else { } else {
thumbnailPlaceholder thumbnailPlaceholder
} }
} else if !photo.thumbnailURL.isEmpty {
RemoteImage(urlString: photo.thumbnailURL) {
thumbnailPlaceholder
}
} else {
thumbnailPlaceholder
}
} }
private var thumbnailPlaceholder: some View { private var thumbnailPlaceholder: some View {

View File

@ -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)"
}
}

View File

@ -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 {

View File

@ -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: [])
} }

View File

@ -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 {

View File

@ -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 = [