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

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

@ -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,71 +20,63 @@ 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) {
HStack(alignment: .center, spacing: 8) {
if selectUploadMode {
WiredTransferSelectIndicator(selected: selected, enabled: canSelect)
}
if selectUploadMode {
WiredTransferSelectIndicator(selected: selected, enabled: canSelect)
}
thumbnailView
.frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize)
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius))
.opacity(rowOpacity)
thumbnailView
.frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize)
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius))
.opacity(rowOpacity)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Text(photo.fileName)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity))
.lineLimit(1)
Spacer(minLength: 0)
WiredTransferStatusBadge(status: photo.status)
.opacity(rowOpacity)
}
Text(metaText)
.font(.system(size: 10))
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Text(photo.fileName)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity))
.lineLimit(1)
Spacer(minLength: 0)
WiredTransferStatusBadge(status: photo.status)
.opacity(rowOpacity)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
if selectUploadMode {
if canSelect { onToggleSelection() }
} else {
onPhotoClick()
}
}
if !selectUploadMode {
Menu {
Button("重传") { onRetry() }
.disabled(!canRetry)
Button("删除", role: .destructive) { onDelete() }
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 16))
.foregroundStyle(WiredTransferDesign.text666)
.frame(width: 32, height: 32)
.rotationEffect(.degrees(90))
}
Text(metaText)
.font(.system(size: 10))
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
.lineLimit(1)
progressSlot
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
if selectUploadMode {
if canSelect { onToggleSelection() }
} else {
onPhotoClick()
}
}
.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)
if !selectUploadMode {
Menu {
Button("重传") { onRetry() }
.disabled(!canRetry)
Button("删除", role: .destructive) { onDelete() }
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 16))
.foregroundStyle(WiredTransferDesign.text666)
.frame(width: 32, height: 32)
.rotationEffect(.degrees(90))
}
}
}
.frame(height: WiredTransferDesign.photoRowHeight)
.padding(.horizontal, 10)
}
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 {