移除 Core 层 OTG 相机实现,保留旅拍有线传图 UI 与本地照片上传能力。
将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// 有线传图页展示的相机连接状态(UI 层使用,OTG 连接能力已移除)。
|
||||
enum CameraConnectionState: Equatable {
|
||||
case disconnected
|
||||
case searching
|
||||
case connecting
|
||||
case connected(deviceName: String)
|
||||
case error(String)
|
||||
|
||||
var displayText: String {
|
||||
switch self {
|
||||
case .disconnected:
|
||||
"未连接"
|
||||
case .searching:
|
||||
"正在搜索相机…"
|
||||
case .connecting:
|
||||
"正在连接…"
|
||||
case .connected(let name):
|
||||
"已连接:\(name)"
|
||||
case .error(let message):
|
||||
"错误:\(message)"
|
||||
}
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
if case .connected = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
//
|
||||
// CameraTransferModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 有线传图任务状态。
|
||||
enum CameraTransferStatus: String, Codable, Equatable {
|
||||
case pending
|
||||
case downloading
|
||||
case downloaded
|
||||
case uploading
|
||||
case uploaded
|
||||
case failed
|
||||
}
|
||||
|
||||
/// 有线传图任务,表示单张照片从本地到上传登记的记录。
|
||||
struct CameraTransferTask: Identifiable, Equatable, Codable {
|
||||
let id: UUID
|
||||
let assetID: String
|
||||
let filename: String
|
||||
var localPath: String?
|
||||
var remoteURL: String?
|
||||
/// 拍摄时间,格式 `yyyy-MM-dd HH:mm:ss`。
|
||||
var capturedAt: String?
|
||||
var status: CameraTransferStatus
|
||||
var progress: Int
|
||||
var errorMessage: String?
|
||||
|
||||
/// 初始化传图任务。
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
assetID: String,
|
||||
filename: String,
|
||||
localPath: String? = nil,
|
||||
remoteURL: String? = nil,
|
||||
capturedAt: String? = nil,
|
||||
status: CameraTransferStatus = .pending,
|
||||
progress: Int = 0,
|
||||
errorMessage: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.assetID = assetID
|
||||
self.filename = filename
|
||||
self.localPath = localPath
|
||||
self.remoteURL = remoteURL
|
||||
self.capturedAt = capturedAt
|
||||
self.status = status
|
||||
self.progress = progress
|
||||
self.errorMessage = errorMessage
|
||||
}
|
||||
|
||||
/// 将日期格式化为列表使用的拍摄时间字符串。
|
||||
static func capturedAtString(from date: Date?) -> String {
|
||||
Self.capturedAtFormatter.string(from: date ?? Date())
|
||||
}
|
||||
|
||||
/// 从本地文件推断拍摄时间。
|
||||
static func capturedAtString(forLocalPath path: String) -> String? {
|
||||
guard let url = CameraDownloadStorage.resolveLocalURL(from: path),
|
||||
FileManager.default.fileExists(atPath: url.path),
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||
else { return nil }
|
||||
|
||||
let date = (attributes[.creationDate] as? Date)
|
||||
?? (attributes[.modificationDate] as? Date)
|
||||
return date.map { capturedAtString(from: $0) }
|
||||
}
|
||||
|
||||
private static let capturedAtFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// 本地文件 URL,支持相对 Documents 的 scoped 路径与绝对路径。
|
||||
var localURL: URL? {
|
||||
guard let localPath, !localPath.isEmpty else { return nil }
|
||||
return CameraDownloadStorage.resolveLocalURL(from: localPath)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
//
|
||||
// CameraAssetUploadSink.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 本地照片上传登记入口,由旅拍相册模块实现 OSS 上传与后端登记。
|
||||
@MainActor
|
||||
protocol CameraAssetUploadSink: AnyObject {
|
||||
/// 上传本地文件并返回远端 URL。
|
||||
func upload(
|
||||
localURL: URL,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
progress: @escaping @Sendable (Int) -> Void
|
||||
) async throws -> String
|
||||
}
|
||||
@ -0,0 +1,221 @@
|
||||
//
|
||||
// CameraDownloadStorage.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 有线传图本地文件存储,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。
|
||||
enum CameraDownloadStorage {
|
||||
static let cameraDownloadsFolderName = "CameraDownloads"
|
||||
|
||||
/// 有线传图文件的业务隔离作用域。
|
||||
struct Scope: Equatable {
|
||||
let accountKey: String
|
||||
let albumID: Int
|
||||
|
||||
/// 初始化账号与相册维度的下载作用域。
|
||||
init(accountKey: String, albumID: Int) {
|
||||
self.accountKey = accountKey
|
||||
self.albumID = albumID
|
||||
}
|
||||
|
||||
var accountDirectoryName: String {
|
||||
CameraDownloadStorage.sanitizePathComponent(accountKey, fallback: "guest")
|
||||
}
|
||||
|
||||
var albumDirectoryName: String {
|
||||
"\(max(albumID, 0))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Documents 根目录。
|
||||
static var documentsDirectory: URL {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
/// 有线传图照片下载根目录。
|
||||
static var downloadsDirectory: URL {
|
||||
let directory = documentsDirectory.appendingPathComponent(cameraDownloadsFolderName, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 当前账号与相册的下载根目录。
|
||||
static func scopeDirectory(for scope: Scope) -> URL {
|
||||
let directory = downloadsDirectory
|
||||
.appendingPathComponent(scope.accountDirectoryName, isDirectory: true)
|
||||
.appendingPathComponent(scope.albumDirectoryName, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 当前账号与相册的原图目录。
|
||||
static func originalsDirectory(for scope: Scope) -> URL {
|
||||
let directory = scopeDirectory(for: scope).appendingPathComponent("originals", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 当前账号与相册的缩略图目录。
|
||||
static func thumbnailsDirectory(for scope: Scope) -> URL {
|
||||
let directory = scopeDirectory(for: scope).appendingPathComponent("thumbnails", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 将绝对路径转为相对 Documents 的路径。
|
||||
static func relativePath(for url: URL) -> String {
|
||||
let standardizedURL = url.standardizedFileURL
|
||||
let documentsPath = documentsDirectory.standardizedFileURL.path
|
||||
let filePath = standardizedURL.path
|
||||
if filePath == documentsPath {
|
||||
return ""
|
||||
}
|
||||
if filePath.hasPrefix(documentsPath + "/") {
|
||||
return String(filePath.dropFirst(documentsPath.count + 1))
|
||||
}
|
||||
let privateDocumentsPath = "/private" + documentsPath
|
||||
if filePath.hasPrefix(privateDocumentsPath + "/") {
|
||||
return String(filePath.dropFirst(privateDocumentsPath.count + 1))
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
/// 将持久化的相对路径、绝对路径或网络地址解析为可访问 URL。
|
||||
static func resolveLocalURL(from storedPath: String) -> URL? {
|
||||
guard !storedPath.isEmpty else { return nil }
|
||||
|
||||
if storedPath.hasPrefix("http://") || storedPath.hasPrefix("https://") {
|
||||
return URL(string: storedPath)
|
||||
}
|
||||
|
||||
if storedPath.hasPrefix("/") {
|
||||
return URL(fileURLWithPath: storedPath)
|
||||
}
|
||||
|
||||
return documentsDirectory.appendingPathComponent(storedPath)
|
||||
}
|
||||
|
||||
/// 生成当前账号与相册内带时间戳的唯一原图 URL,避免文件名冲突。
|
||||
static func uniqueLocalURL(for filename: String, scope: Scope) -> URL {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let safeName = sanitizeFilename(filename)
|
||||
return originalsDirectory(for: scope).appendingPathComponent("\(timestamp)_\(safeName)")
|
||||
}
|
||||
|
||||
/// 生成当前账号与相册内带 asset ID 的唯一缩略图文件 URL。
|
||||
static func uniqueThumbnailURL(for assetID: String, scope: Scope) -> URL {
|
||||
let safeName = sanitizeFilename(assetID)
|
||||
.replacingOccurrences(of: ".", with: "_")
|
||||
return thumbnailsDirectory(for: scope).appendingPathComponent("\(safeName)_thumb.jpg")
|
||||
}
|
||||
|
||||
/// 删除当前账号与相册的本地文件目录。
|
||||
static func removeScopeDirectory(for scope: Scope) {
|
||||
let directory = scopeDirectory(for: scope)
|
||||
if FileManager.default.fileExists(atPath: directory.path) {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
}
|
||||
|
||||
/// 将文件名转为 iOS 文件系统可安全使用的 ASCII 名称。
|
||||
static func sanitizeFilename(_ filename: String) -> String {
|
||||
let trimmed = filename
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: ":", with: "_")
|
||||
|
||||
let ascii = trimmed.unicodeScalars.filter { scalar in
|
||||
scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "." || scalar == "_" || scalar == "-")
|
||||
}
|
||||
|
||||
var name = String(String.UnicodeScalarView(ascii))
|
||||
if name.isEmpty {
|
||||
name = "DSC_\(Int(Date().timeIntervalSince1970)).JPG"
|
||||
}
|
||||
if !name.contains(".") {
|
||||
name += ".JPG"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/// 将账号等动态字符串转为安全目录片段。
|
||||
static func sanitizePathComponent(_ value: String, fallback: String) -> String {
|
||||
let trimmed = value
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: ":", with: "_")
|
||||
|
||||
let ascii = trimmed.unicodeScalars.filter { scalar in
|
||||
scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "_" || scalar == "-")
|
||||
}
|
||||
|
||||
let component = String(String.UnicodeScalarView(ascii))
|
||||
return component.isEmpty ? fallback : component
|
||||
}
|
||||
|
||||
/// 将本地存储路径转为可用于 UI 预览的 file URL 字符串。
|
||||
static func previewURLString(from storedPath: String) -> String {
|
||||
guard let url = resolveLocalURL(from: storedPath),
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
return ""
|
||||
}
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
/// 后台读取文件大小,避免在列表刷新路径上阻塞主线程。
|
||||
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, scope: CameraDownloadStorage.Scope) 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, scope: scope)
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
//
|
||||
// WiredCameraTransferPipeline.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 有线传图上传编排器,负责管理本地照片的上传任务(不含 USB/OTG 相机连接)。
|
||||
@MainActor
|
||||
final class WiredCameraTransferPipeline {
|
||||
var onTasksUpdated: (([CameraTransferTask]) -> Void)?
|
||||
|
||||
private(set) var tasks: [CameraTransferTask] = []
|
||||
private var uploadSink: (any CameraAssetUploadSink)?
|
||||
private var uploadEnabled = true
|
||||
private var downloadScope = CameraDownloadStorage.Scope(accountKey: "unscoped", albumID: 0)
|
||||
private var activeUploadAssetIDs: Set<String> = []
|
||||
private var autoUploadEligibleAssetIDs: Set<String> = []
|
||||
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
||||
private var lastDeferredNotifyDate = Date.distantPast
|
||||
private var lastNotifiedProgressByAssetID: [String: Int] = [:]
|
||||
private let maxRetries = 3
|
||||
private let deferredNotifyInterval: TimeInterval = 0.15
|
||||
private let maxConcurrentUploads = 3
|
||||
private var isUIAttached = false
|
||||
/// 用户已从列表删除、不应再展示的 asset ID。
|
||||
private(set) var excludedAssetIDs: Set<String> = []
|
||||
|
||||
/// 绑定 UI 回调。
|
||||
func attachToUI(onTasksUpdated: @escaping ([CameraTransferTask]) -> Void) {
|
||||
isUIAttached = true
|
||||
self.onTasksUpdated = onTasksUpdated
|
||||
}
|
||||
|
||||
/// 页面离开:取消 UI 回调与进行中的上传通知。
|
||||
func detachFromUI() {
|
||||
guard isUIAttached else { return }
|
||||
isUIAttached = false
|
||||
pendingDeferredNotifyTask?.cancel()
|
||||
pendingDeferredNotifyTask = nil
|
||||
activeUploadAssetIDs.removeAll()
|
||||
onTasksUpdated = nil
|
||||
}
|
||||
|
||||
/// 配置上传 Sink、自动上传开关与下载作用域。
|
||||
func configure(
|
||||
uploadSink: (any CameraAssetUploadSink)?,
|
||||
uploadEnabled: Bool,
|
||||
downloadScope: CameraDownloadStorage.Scope? = nil
|
||||
) {
|
||||
self.uploadSink = uploadSink
|
||||
self.uploadEnabled = uploadEnabled
|
||||
if let downloadScope {
|
||||
self.downloadScope = downloadScope
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试所有失败的上传任务。
|
||||
func retryFailedUploads() async {
|
||||
guard uploadEnabled, uploadSink != nil else { return }
|
||||
let failedAssetIDs = tasks.filter { $0.status == .failed }.map(\.assetID)
|
||||
for index in tasks.indices where tasks[index].status == .failed {
|
||||
tasks[index].status = .downloaded
|
||||
tasks[index].errorMessage = nil
|
||||
}
|
||||
notify()
|
||||
await uploadAssets(withIDs: failedAssetIDs)
|
||||
}
|
||||
|
||||
/// 手动上传指定 asset 列表。
|
||||
func uploadAssets(withIDs assetIDs: [String]) async {
|
||||
guard uploadSink != nil else { return }
|
||||
var seenAssetIDs: Set<String> = []
|
||||
let uniqueAssetIDs = assetIDs.filter { seenAssetIDs.insert($0).inserted }
|
||||
var currentIndex = 0
|
||||
while currentIndex < uniqueAssetIDs.count {
|
||||
let upperBound = min(currentIndex + maxConcurrentUploads, uniqueAssetIDs.count)
|
||||
let batch = uniqueAssetIDs[currentIndex ..< upperBound]
|
||||
let uploadTasks = batch.map { assetID in
|
||||
Task { @MainActor in
|
||||
await self.uploadAssetIfNeeded(assetID: assetID)
|
||||
}
|
||||
}
|
||||
for task in uploadTasks {
|
||||
await task.value
|
||||
}
|
||||
currentIndex = upperBound
|
||||
}
|
||||
}
|
||||
|
||||
/// 重传单张照片;若管道中无任务则根据本地相对路径恢复。
|
||||
func retryUpload(assetID: String, filename: String, localPath: String) async {
|
||||
guard uploadSink != nil else { return }
|
||||
guard !activeUploadAssetIDs.contains(assetID) else { return }
|
||||
|
||||
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
||||
if tasks[index].status == .uploaded { return }
|
||||
if tasks[index].localPath?.isEmpty != false, !localPath.isEmpty {
|
||||
tasks[index].localPath = localPath
|
||||
}
|
||||
tasks[index].status = .downloaded
|
||||
tasks[index].errorMessage = nil
|
||||
} else {
|
||||
guard !localPath.isEmpty,
|
||||
let localURL = CameraDownloadStorage.resolveLocalURL(from: localPath),
|
||||
FileManager.default.fileExists(atPath: localURL.path) else {
|
||||
return
|
||||
}
|
||||
tasks.insert(
|
||||
CameraTransferTask(
|
||||
assetID: assetID,
|
||||
filename: filename,
|
||||
localPath: localPath,
|
||||
capturedAt: CameraTransferTask.capturedAtString(forLocalPath: localPath),
|
||||
status: .downloaded
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
|
||||
notify()
|
||||
await uploadTask(assetID: assetID)
|
||||
}
|
||||
|
||||
/// 按 assetID 查找任务。
|
||||
func task(forAssetID assetID: String) -> CameraTransferTask? {
|
||||
tasks.first { $0.assetID == assetID }
|
||||
}
|
||||
|
||||
/// 同步用户已删除的 asset ID,防止再次带回。
|
||||
func setExcludedAssetIDs(_ ids: Set<String>) {
|
||||
excludedAssetIDs = ids
|
||||
}
|
||||
|
||||
/// 从管道移除 asset。
|
||||
func removeAsset(assetID: String) {
|
||||
guard !assetID.isEmpty else { return }
|
||||
excludedAssetIDs.insert(assetID)
|
||||
tasks.removeAll { $0.assetID == assetID }
|
||||
autoUploadEligibleAssetIDs.remove(assetID)
|
||||
activeUploadAssetIDs.remove(assetID)
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
}
|
||||
|
||||
private func uploadAssetIfNeeded(assetID: String) async {
|
||||
if activeUploadAssetIDs.contains(assetID) { return }
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
if tasks[index].status == .uploaded { return }
|
||||
|
||||
activeUploadAssetIDs.insert(assetID)
|
||||
defer { activeUploadAssetIDs.remove(assetID) }
|
||||
|
||||
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
|
||||
tasks[refreshedIndex].status = .downloaded
|
||||
tasks[refreshedIndex].errorMessage = nil
|
||||
await uploadTask(assetID: assetID)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadTask(assetID: String) async {
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }),
|
||||
let sink = uploadSink,
|
||||
let localURL = tasks[index].localURL
|
||||
else { return }
|
||||
|
||||
tasks[index].status = .uploading
|
||||
tasks[index].errorMessage = nil
|
||||
notify()
|
||||
|
||||
let filename = tasks[index].filename
|
||||
let fileType = filename.lowercased().hasSuffix(".mp4") ? 1 : 2
|
||||
var attempt = 0
|
||||
while attempt < maxRetries {
|
||||
do {
|
||||
let remoteURL = try await sink.upload(
|
||||
localURL: localURL,
|
||||
fileName: filename,
|
||||
fileType: fileType,
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
guard let self,
|
||||
let taskIndex = self.tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
let lastNotifiedProgress = self.lastNotifiedProgressByAssetID[assetID] ?? -1
|
||||
guard progress == 100 || progress - lastNotifiedProgress >= 10 else { return }
|
||||
self.lastNotifiedProgressByAssetID[assetID] = progress
|
||||
self.tasks[taskIndex].progress = progress
|
||||
self.notify(progress == 100 ? .immediate : .deferred)
|
||||
}
|
||||
}
|
||||
)
|
||||
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
tasks[taskIndex].status = .uploaded
|
||||
tasks[taskIndex].remoteURL = remoteURL
|
||||
tasks[taskIndex].progress = 100
|
||||
autoUploadEligibleAssetIDs.remove(assetID)
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
notify()
|
||||
return
|
||||
} catch {
|
||||
attempt += 1
|
||||
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
if attempt >= maxRetries {
|
||||
tasks[taskIndex].status = .failed
|
||||
tasks[taskIndex].errorMessage = error.localizedDescription
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
notify()
|
||||
} else {
|
||||
let delay = UInt64(pow(2.0, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: delay * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func notify(_ mode: WiredTransferNotifyMode = .immediate) {
|
||||
switch mode {
|
||||
case .immediate:
|
||||
pendingDeferredNotifyTask?.cancel()
|
||||
pendingDeferredNotifyTask = nil
|
||||
onTasksUpdated?(tasks)
|
||||
case .deferred:
|
||||
let elapsed = Date().timeIntervalSince(lastDeferredNotifyDate)
|
||||
if elapsed >= deferredNotifyInterval {
|
||||
lastDeferredNotifyDate = Date()
|
||||
onTasksUpdated?(tasks)
|
||||
return
|
||||
}
|
||||
guard pendingDeferredNotifyTask == nil else { return }
|
||||
let delay = UInt64((deferredNotifyInterval - elapsed) * 1_000_000_000)
|
||||
pendingDeferredNotifyTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.lastDeferredNotifyDate = Date()
|
||||
self.pendingDeferredNotifyTask = nil
|
||||
self.onTasksUpdated?(self.tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum WiredTransferNotifyMode {
|
||||
case immediate
|
||||
case deferred
|
||||
}
|
||||
@ -53,7 +53,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
|
||||
## 有线传图交互流程
|
||||
|
||||
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 同步相机历史文件
|
||||
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 刷新本地持久化记录
|
||||
2. **批量上传三态**:
|
||||
- 首次点击 → 进入勾选模式
|
||||
- 有选中项 → 上传选中照片
|
||||
@ -69,7 +69,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
## 业务流程
|
||||
|
||||
1. 创建任务:`POST .../travel-album/create`
|
||||
2. 进入有线传图:Sony/Canon USB 有线传图(`Core/CameraTethering`,自动识别品牌)
|
||||
2. 进入有线传图:有线传图 UI 页(USB/OTG 相机连接能力已移除,保留页面与本地照片上传)
|
||||
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
||||
4. 素材登记:`POST .../travel-album/upload-material`
|
||||
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录
|
||||
@ -83,18 +83,18 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
|
||||
## 解耦关系
|
||||
|
||||
- `CameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
|
||||
- `WiredCameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
|
||||
- `TravelAlbumMaterialUploader` 实现 OSS + 后端登记
|
||||
- 与「相册管理」(`Features/Assets`)API、Store、路由完全隔离
|
||||
|
||||
## 依赖模块
|
||||
|
||||
- `Core/CameraTethering` — USB/PTP 相机
|
||||
- `Core/CameraTransfer` — 传输编排
|
||||
- `Features/TravelAlbum/Services` — 本地存储、上传编排
|
||||
- `Core/Upload` — OSS STS 上传
|
||||
|
||||
## 二期待补齐
|
||||
|
||||
- RAW 预览
|
||||
- 格式 Chip 扩展 RAW / JPEG+RAW,并与 `CameraTransferPipeline` 联动
|
||||
- USB/OTG 相机有线连接与边拍边传
|
||||
- 格式 Chip 扩展 RAW / JPEG+RAW,并与 `WiredCameraTransferPipeline` 联动
|
||||
- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗)
|
||||
|
||||
@ -441,7 +441,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
|
||||
let context: WiredTransferContext
|
||||
private let pipeline: CameraTransferPipeline
|
||||
private let pipeline = WiredCameraTransferPipeline()
|
||||
private var photoStore: WiredTransferPhotoStore?
|
||||
private var userIDProvider: (() -> String)?
|
||||
private var downloadScope: CameraDownloadStorage.Scope?
|
||||
@ -457,12 +457,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
private var isLeavingPage = false
|
||||
|
||||
/// 初始化有线传图 ViewModel。
|
||||
init(
|
||||
context: WiredTransferContext,
|
||||
cameraService: any CameraServiceProtocol
|
||||
) {
|
||||
init(context: WiredTransferContext) {
|
||||
self.context = context
|
||||
self.pipeline = CameraTransferPipeline(cameraService: cameraService)
|
||||
}
|
||||
|
||||
/// 配置上传 Sink 并启动相机连接。
|
||||
@ -473,16 +469,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
accountContext: AccountContext
|
||||
) async {
|
||||
isLeavingPage = false
|
||||
pipeline.attachToUI(
|
||||
onConnectionStateChange: { [weak self] state in
|
||||
self?.handleConnectionStateChange(state)
|
||||
},
|
||||
onTasksUpdated: { [weak self] tasks in
|
||||
Task { @MainActor in
|
||||
await self?.applyPipelineTasks(tasks)
|
||||
}
|
||||
pipeline.attachToUI { [weak self] tasks in
|
||||
Task { @MainActor in
|
||||
await self?.applyPipelineTasks(tasks)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
|
||||
let currentDownloadScope = CameraDownloadStorage.Scope(
|
||||
@ -511,19 +502,10 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
|
||||
refreshDeviceStorageInfo()
|
||||
loadPersistedPhotos()
|
||||
connectionState = .disconnected
|
||||
|
||||
do {
|
||||
await pipeline.connect()
|
||||
connectionState = pipeline.connectionState
|
||||
wasConnected = connectionState.isConnected
|
||||
|
||||
while !Task.isCancelled {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// 用户返回或 task 取消。
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
detachFromUI()
|
||||
}
|
||||
@ -600,25 +582,20 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 刷新相机文件列表;未连接时重新搜索 USB 相机。
|
||||
/// 刷新相机文件列表(OTG 能力已移除,仅刷新本地持久化记录)。
|
||||
func refreshCameraFiles() async {
|
||||
if connectionState.isConnected {
|
||||
guard !isRefreshingCameraFiles else { return }
|
||||
isRefreshingCameraFiles = true
|
||||
defer { isRefreshingCameraFiles = false }
|
||||
await pipeline.syncExistingPhotos()
|
||||
return
|
||||
}
|
||||
await restartCameraSearch()
|
||||
guard !isRefreshingCameraFiles else { return }
|
||||
isRefreshingCameraFiles = true
|
||||
defer { isRefreshingCameraFiles = false }
|
||||
loadPersistedPhotos()
|
||||
}
|
||||
|
||||
/// 重新搜索 USB 相机。
|
||||
/// 重新搜索相机(OTG 能力已移除,保持未连接状态)。
|
||||
func restartCameraSearch() async {
|
||||
guard !connectionState.isConnected, !isSearchingCamera else { return }
|
||||
guard !isSearchingCamera else { return }
|
||||
isSearchingCamera = true
|
||||
defer { isSearchingCamera = false }
|
||||
await pipeline.restartCameraDiscovery()
|
||||
connectionState = pipeline.connectionState
|
||||
connectionState = .disconnected
|
||||
}
|
||||
|
||||
/// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。
|
||||
|
||||
@ -24,12 +24,7 @@ struct WiredCameraTransferView: View {
|
||||
|
||||
init(context: WiredTransferContext) {
|
||||
self.context = context
|
||||
_viewModel = StateObject(
|
||||
wrappedValue: WiredCameraTransferViewModel(
|
||||
context: context,
|
||||
cameraService: CameraServiceFactory.makeAutoDetect()
|
||||
)
|
||||
)
|
||||
_viewModel = StateObject(wrappedValue: WiredCameraTransferViewModel(context: context))
|
||||
}
|
||||
|
||||
private let tabTitles = ["全部", "已上传", "失败"]
|
||||
|
||||
Reference in New Issue
Block a user