增强有线传图手动上传并发能力,并稳定列表行布局。

指定/勾选/批量上传支持恢复仅本地持久化的待传照片,手动上传与 pipeline 均按 3 路并发;pipeline 任务签名去重避免状态回退,照片行固定高度并预留进度条占位。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 15:10:47 +08:00
parent 5ebcec1cfc
commit 660852ffad
7 changed files with 283 additions and 72 deletions

View File

@ -417,6 +417,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
static let specifyUploadAllPending = "上传所有未上传的照片"
static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片"
static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")!
private static let manualUploadConcurrency = 3
@Published private(set) var photos: [WiredTransferPhotoItem] = []
@Published private(set) var visiblePhotos: [WiredTransferPhotoItem] = []
@ -444,6 +445,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
private var userIDProvider: (() -> String)?
private var sessionNewPhotoIDs: Set<String> = []
private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:]
private var lastProgressPersistDates: [String: Date] = [:]
private var wasConnected = false
private var disconnectAlertTask: Task<Void, Never>?
@ -639,7 +641,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
errorMessage = "暂无未上传的照片"
return
}
await pipeline.uploadAssets(withIDs: pendingIDs)
await uploadPendingPhotoIDs(pendingIDs)
}
///
@ -650,13 +652,13 @@ final class WiredCameraTransferViewModel: ObservableObject {
errorMessage = "暂无今日拍摄且未上传的照片"
return
}
await pipeline.uploadAssets(withIDs: pendingIDs)
await uploadPendingPhotoIDs(pendingIDs)
}
///
func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs))
await uploadPendingPhotoIDs(Array(selectedPhotoIDs))
exitSelectUploadMode()
}
@ -699,10 +701,57 @@ final class WiredCameraTransferViewModel: ObservableObject {
await refreshPhotoFromPipelineTask(id: id)
}
private func uploadPendingPhotoIDs(_ ids: [String]) async {
let pendingIDs = ids.filter { id in
photos.first(where: { $0.id == id })?.isNotUploaded == true
}
var currentIndex = 0
while currentIndex < pendingIDs.count {
let upperBound = min(currentIndex + Self.manualUploadConcurrency, pendingIDs.count)
let batch = pendingIDs[currentIndex ..< upperBound]
let uploadTasks = batch.map { id in
Task { @MainActor in
await self.uploadSinglePendingPhoto(id: id)
}
}
for task in uploadTasks {
await task.value
}
currentIndex = upperBound
}
}
private func uploadSinglePendingPhoto(id: String) async {
updatePhotoStatus(id: id, status: .uploading, progress: 0, errorMessage: nil)
if pipeline.task(forAssetID: id) != nil {
await pipeline.uploadAssets(withIDs: [id])
await refreshPhotoFromPipelineTask(id: id)
return
}
guard let record = persistedRecord(for: id),
let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath),
FileManager.default.fileExists(atPath: localURL.path) else {
let message = "本地文件不存在"
updatePhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message)
persistPhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message)
return
}
await pipeline.retryUpload(
assetID: id,
filename: record.fileName,
localPath: record.localPath
)
await refreshPhotoFromPipelineTask(id: id)
}
///
func deletePhoto(id: String) {
photoStore?.remove(albumID: context.albumId, photoID: id)
persistedRecordsByID.removeValue(forKey: id)
lastAppliedPipelineSignatures.removeValue(forKey: id)
lastProgressPersistDates.removeValue(forKey: id)
setPhotos(photos.filter { $0.id != id }, rebuildGroups: true)
}
@ -905,16 +954,24 @@ final class WiredCameraTransferViewModel: ObservableObject {
for task in tasks {
guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue }
let signature = PipelineTaskSignature(task: task)
guard lastAppliedPipelineSignatures[task.assetID] != signature else { continue }
let status = mapPipelineStatus(task.status)
let existing = existingByID[task.assetID] ?? existingByFileName[task.filename]
if shouldIgnoreStalePipelineStatus(existing: existing?.status, incoming: status) {
lastAppliedPipelineSignatures[task.assetID] = signature
continue
}
let storedLocalPath = task.localPath ?? ""
let localURL = task.localURL
let fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL)
let existingRecord = persistedRecordsByID[task.assetID]
let fileSizeBytes: Int64
if let existingRecord, existingRecord.fileSizeBytes > 0 {
fileSizeBytes = existingRecord.fileSizeBytes
} else {
fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL)
}
let capturedAt = await resolveCapturedAt(
existing: existing,
taskCapturedAt: task.capturedAt,
@ -973,6 +1030,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
)
persistRecord(record)
}
lastAppliedPipelineSignatures[task.assetID] = signature
}
}
@ -1091,3 +1149,19 @@ final class WiredCameraTransferViewModel: ObservableObject {
return bound == context.albumId
}
}
private struct PipelineTaskSignature: Equatable {
let status: CameraTransferStatus
let progress: Int
let localPath: String?
let remoteURL: String?
let errorMessage: String?
init(task: CameraTransferTask) {
status = task.status
progress = task.progress
localPath = task.localPath
remoteURL = task.remoteURL
errorMessage = task.errorMessage
}
}