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

指定/勾选/批量上传支持恢复仅本地持久化的待传照片,手动上传与 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

@ -80,21 +80,42 @@ final class CameraTransferPipeline {
/// asset
func uploadAssets(withIDs assetIDs: [String]) async {
guard uploadSink != nil else { return }
for assetID in assetIDs {
if activeUploadAssetIDs.contains(assetID) { continue }
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
if tasks[index].status == .uploaded { continue }
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
}
}
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) }
if tasks[index].localURL == nil {
await downloadAsset(assetID: assetID)
}
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
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)
}
}
}
///
func retryUpload(assetID: String, filename: String, localPath: String) async {

View File

@ -59,6 +59,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
- 有选中项 → 上传选中照片
- 无选中项 → 取消选择
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
- 指定上传会同时处理当前管道任务和本地持久化记录;仅存在本地记录的照片会先按本地路径恢复到传输管道,再上传 OSS 并登记素材
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
5. **格式 Chip**:当前固定展示 JPG与「不修图」一样不可点击RAW 等格式后续扩展
6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
@ -76,7 +77,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
## 有线传图性能策略
- 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。
- 连拍上传进度采用节流通知,下载/上传关键状态立即刷新,普通进度合并后再更新 UI边拍边传自动队列最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
- 连拍上传进度采用节流通知和任务签名去重,下载/上传关键状态立即刷新,普通进度合并后再更新 UI边拍边传、指定上传和勾选上传最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
- `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。
- 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。

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

View File

@ -25,11 +25,12 @@ enum WiredTransferDesign {
static let filterChipHeight: CGFloat = 34
static let filterChipCornerRadius: CGFloat = 8
static let tabRowHeight: CGFloat = 68
static let thumbnailSize: CGFloat = 48
static let photoRowHeight: CGFloat = 74
static let thumbnailSize: CGFloat = 56
static let thumbnailCornerRadius: CGFloat = 6
static let bottomButtonHeight: CGFloat = 44
static let bottomButtonCornerRadius: CGFloat = 10
static let progressBarHeight: CGFloat = 2
static let progressBarHeight: CGFloat = 3
static let cardHorizontalPadding: CGFloat = 16
static let cardOverlap: CGFloat = 10
}

View File

@ -20,9 +20,9 @@ struct WiredTransferPhotoRow: View {
private var canSelect: Bool { photo.canSelectForSpecifyUpload }
private var canRetry: Bool { photo.status == .failed || photo.status == .pending }
private var rowOpacity: Double { selectUploadMode && !canSelect ? 0.45 : 1 }
private var showsProgress: Bool { photo.status == .transferring || photo.status == .uploading }
var body: some View {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 8) {
HStack(alignment: .center, spacing: 8) {
if selectUploadMode {
@ -34,7 +34,7 @@ struct WiredTransferPhotoRow: View {
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius))
.opacity(rowOpacity)
VStack(alignment: .leading, spacing: 3) {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Text(photo.fileName)
.font(.system(size: 12, weight: .medium))
@ -48,6 +48,7 @@ struct WiredTransferPhotoRow: View {
.font(.system(size: 10))
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
.lineLimit(1)
progressSlot
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@ -74,17 +75,8 @@ struct WiredTransferPhotoRow: View {
}
}
}
.frame(height: WiredTransferDesign.photoRowHeight)
.padding(.horizontal, 10)
.padding(.vertical, 8)
if photo.status == .transferring || photo.status == .uploading {
ProgressView(value: Double(photo.progress), total: 100)
.tint(AppDesign.primary)
.frame(height: WiredTransferDesign.progressBarHeight)
.padding(.horizontal, 10)
.padding(.bottom, 8)
}
}
}
private var metaText: String {
@ -92,6 +84,13 @@ struct WiredTransferPhotoRow: View {
return "\(photo.fileSizeText) \(photo.resolutionText)"
}
private var progressSlot: some View {
ProgressView(value: showsProgress ? Double(photo.progress) : 0, total: 100)
.tint(AppDesign.primary)
.opacity(showsProgress ? 1 : 0)
.frame(height: WiredTransferDesign.progressBarHeight)
}
@ViewBuilder
private var thumbnailView: some View {
if let url = URL(string: photo.thumbnailURL), !photo.thumbnailURL.isEmpty {

View File

@ -60,6 +60,24 @@ final class CameraTransferPipelineTests: XCTestCase {
XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded)
}
///
func testManualUploadAssetsUsesConcurrentLimit() async throws {
let camera = MockCameraService()
let sink = MockSlowUploadSink()
let pipeline = CameraTransferPipeline(cameraService: camera)
pipeline.configure(uploadSink: sink, uploadEnabled: true)
camera.listedAssets = (1 ... 6).map { index in
CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024)
}
await pipeline.syncExistingPhotos()
await pipeline.uploadAssets(withIDs: camera.listedAssets.map(\.id))
XCTAssertEqual(sink.uploadCount, 6)
XCTAssertGreaterThan(sink.maxActiveUploads, 1)
XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3)
}
///
func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws {
let camera = MockCameraService()

View File

@ -296,6 +296,65 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.photos.first?.status, .uploaded)
}
///
func testBatchUploadAllUploadsPersistedPendingFiles() async throws {
let albumID = 7711
let userID = "batch-persist-user"
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试"))
let fileURLs = try (1 ... 5).map { index in
let url = CameraDownloadStorage.uniqueLocalURL(for: "batch_persist_\(index).JPG")
try Data(repeating: UInt8(0xA0 + index), count: 32).write(to: url)
return url
}
defer {
fileURLs.forEach { try? FileManager.default.removeItem(at: $0) }
}
let records = fileURLs.enumerated().map { offset, url in
let index = offset + 1
return makePhotoRecord(
id: "batch_persist_\(index)",
fileName: "batch_persist_\(index).JPG",
localPath: CameraDownloadStorage.relativePath(for: url),
albumID: albumID,
userID: userID
)
}
let store = WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
userIDProvider: { userID }
)
store.save(albumID: albumID, records: records)
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: ""),
cameraService: MockWiredCameraService()
)
let oss = WiredTransferMockOSSUploadService()
oss.uploadDelayNanoseconds = 150_000_000
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: oss,
scenicID: 1,
accountContext: account
)
XCTAssertEqual(viewModel.photos.filter(\.isNotUploaded).count, 5)
await viewModel.batchUploadAll(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: oss,
scenicID: 1
)
XCTAssertEqual(Set(oss.uploadedFileNames), Set((1 ... 5).map { "batch_persist_\($0).JPG" }))
XCTAssertGreaterThan(oss.maxActiveUploads, 1)
XCTAssertLessThanOrEqual(oss.maxActiveUploads, 3)
XCTAssertTrue(viewModel.photos.allSatisfy { $0.status == .uploaded })
}
///
func testSelectTransferModePersistsOption() async {
let account = makeTransferModeTestAccount()
@ -444,6 +503,32 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
)
}
private func makePhotoRecord(
id: String,
fileName: String,
localPath: String,
albumID: Int,
userID: String,
status: WiredTransferUploadStatus = .pending
) -> WiredTransferPhotoRecord {
WiredTransferPhotoRecord(
id: id,
sourceId: id,
fileName: fileName,
localPath: localPath,
thumbnailPath: "",
capturedAt: "2026-05-20 12:05:18",
fileSizeBytes: 32,
status: status.rawValue,
progress: 0,
errorMessage: nil,
albumId: albumID,
userId: userID,
remoteURL: "",
updatedAt: Date().timeIntervalSince1970
)
}
private func makeJPEGData() -> Data {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8))
return renderer.jpegData(withCompressionQuality: 0.9) { context in
@ -516,10 +601,22 @@ private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
@MainActor
private final class WiredTransferMockOSSUploadService: OSSUploadServing {
private(set) var uploadedFileNames: [String] = []
var uploadDelayNanoseconds: UInt64 = 0
private(set) var activeUploads = 0
private(set) var maxActiveUploads = 0
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
activeUploads += 1
maxActiveUploads = max(maxActiveUploads, activeUploads)
if uploadDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: uploadDelayNanoseconds)
}
activeUploads -= 1
uploadedFileNames.append(fileName)
onProgress(100)
return "https://cdn/mock/\(fileName)"
}