增强有线传图手动上传并发能力,并稳定列表行布局。
指定/勾选/批量上传支持恢复仅本地持久化的待传照片,手动上传与 pipeline 均按 3 路并发;pipeline 任务签名去重避免状态回退,照片行固定高度并预留进度条占位。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -80,19 +80,40 @@ final class CameraTransferPipeline {
|
|||||||
/// 手动上传指定 asset 列表(拍完再传模式)。
|
/// 手动上传指定 asset 列表(拍完再传模式)。
|
||||||
func uploadAssets(withIDs assetIDs: [String]) async {
|
func uploadAssets(withIDs assetIDs: [String]) async {
|
||||||
guard uploadSink != nil else { return }
|
guard uploadSink != nil else { return }
|
||||||
for assetID in assetIDs {
|
var seenAssetIDs: Set<String> = []
|
||||||
if activeUploadAssetIDs.contains(assetID) { continue }
|
let uniqueAssetIDs = assetIDs.filter { seenAssetIDs.insert($0).inserted }
|
||||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
|
var currentIndex = 0
|
||||||
if tasks[index].status == .uploaded { continue }
|
while currentIndex < uniqueAssetIDs.count {
|
||||||
if tasks[index].localURL == nil {
|
let upperBound = min(currentIndex + maxConcurrentUploads, uniqueAssetIDs.count)
|
||||||
await downloadAsset(assetID: assetID)
|
let batch = uniqueAssetIDs[currentIndex ..< upperBound]
|
||||||
|
let uploadTasks = batch.map { assetID in
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.uploadAssetIfNeeded(assetID: assetID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
|
for task in uploadTasks {
|
||||||
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
|
await task.value
|
||||||
tasks[refreshedIndex].status = .downloaded
|
|
||||||
tasks[refreshedIndex].errorMessage = nil
|
|
||||||
await uploadTask(assetID: assetID)
|
|
||||||
}
|
}
|
||||||
|
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 { return }
|
||||||
|
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
|
||||||
|
tasks[refreshedIndex].status = .downloaded
|
||||||
|
tasks[refreshedIndex].errorMessage = nil
|
||||||
|
await uploadTask(assetID: assetID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
|||||||
- 有选中项 → 上传选中照片
|
- 有选中项 → 上传选中照片
|
||||||
- 无选中项 → 取消选择
|
- 无选中项 → 取消选择
|
||||||
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
||||||
|
- 指定上传会同时处理当前管道任务和本地持久化记录;仅存在本地记录的照片会先按本地路径恢复到传输管道,再上传 OSS 并登记素材
|
||||||
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
||||||
5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
||||||
6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
|
6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
|
||||||
@ -76,7 +77,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
|||||||
## 有线传图性能策略
|
## 有线传图性能策略
|
||||||
|
|
||||||
- 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。
|
- 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。
|
||||||
- 连拍上传进度采用节流通知,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传自动队列最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
|
- 连拍上传进度采用节流通知和任务签名去重,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传、指定上传和勾选上传最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
|
||||||
- `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。
|
- `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。
|
||||||
- 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。
|
- 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。
|
||||||
|
|
||||||
|
|||||||
@ -417,6 +417,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
static let specifyUploadAllPending = "上传所有未上传的照片"
|
static let specifyUploadAllPending = "上传所有未上传的照片"
|
||||||
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")!
|
||||||
|
private static let manualUploadConcurrency = 3
|
||||||
|
|
||||||
@Published private(set) var photos: [WiredTransferPhotoItem] = []
|
@Published private(set) var photos: [WiredTransferPhotoItem] = []
|
||||||
@Published private(set) var visiblePhotos: [WiredTransferPhotoItem] = []
|
@Published private(set) var visiblePhotos: [WiredTransferPhotoItem] = []
|
||||||
@ -444,6 +445,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
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 persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
|
||||||
|
private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:]
|
||||||
private var lastProgressPersistDates: [String: Date] = [:]
|
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>?
|
||||||
@ -639,7 +641,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
errorMessage = "暂无未上传的照片"
|
errorMessage = "暂无未上传的照片"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await pipeline.uploadAssets(withIDs: pendingIDs)
|
await uploadPendingPhotoIDs(pendingIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 上传今日拍摄且未上传的照片。
|
/// 上传今日拍摄且未上传的照片。
|
||||||
@ -650,13 +652,13 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
errorMessage = "暂无今日拍摄且未上传的照片"
|
errorMessage = "暂无今日拍摄且未上传的照片"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await pipeline.uploadAssets(withIDs: pendingIDs)
|
await uploadPendingPhotoIDs(pendingIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 上传选中照片。
|
/// 上传选中照片。
|
||||||
func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||||
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
||||||
await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs))
|
await uploadPendingPhotoIDs(Array(selectedPhotoIDs))
|
||||||
exitSelectUploadMode()
|
exitSelectUploadMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -699,10 +701,57 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
await refreshPhotoFromPipelineTask(id: id)
|
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) {
|
func deletePhoto(id: String) {
|
||||||
photoStore?.remove(albumID: context.albumId, photoID: id)
|
photoStore?.remove(albumID: context.albumId, photoID: id)
|
||||||
persistedRecordsByID.removeValue(forKey: id)
|
persistedRecordsByID.removeValue(forKey: id)
|
||||||
|
lastAppliedPipelineSignatures.removeValue(forKey: id)
|
||||||
lastProgressPersistDates.removeValue(forKey: id)
|
lastProgressPersistDates.removeValue(forKey: id)
|
||||||
setPhotos(photos.filter { $0.id != id }, rebuildGroups: true)
|
setPhotos(photos.filter { $0.id != id }, rebuildGroups: true)
|
||||||
}
|
}
|
||||||
@ -905,16 +954,24 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
for task in tasks {
|
for task in tasks {
|
||||||
guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue }
|
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 status = mapPipelineStatus(task.status)
|
||||||
let existing = existingByID[task.assetID] ?? existingByFileName[task.filename]
|
let existing = existingByID[task.assetID] ?? existingByFileName[task.filename]
|
||||||
if shouldIgnoreStalePipelineStatus(existing: existing?.status, incoming: status) {
|
if shouldIgnoreStalePipelineStatus(existing: existing?.status, incoming: status) {
|
||||||
|
lastAppliedPipelineSignatures[task.assetID] = signature
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let storedLocalPath = task.localPath ?? ""
|
let storedLocalPath = task.localPath ?? ""
|
||||||
let localURL = task.localURL
|
let localURL = task.localURL
|
||||||
let fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL)
|
|
||||||
|
|
||||||
let existingRecord = persistedRecordsByID[task.assetID]
|
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(
|
let capturedAt = await resolveCapturedAt(
|
||||||
existing: existing,
|
existing: existing,
|
||||||
taskCapturedAt: task.capturedAt,
|
taskCapturedAt: task.capturedAt,
|
||||||
@ -973,6 +1030,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
persistRecord(record)
|
persistRecord(record)
|
||||||
}
|
}
|
||||||
|
lastAppliedPipelineSignatures[task.assetID] = signature
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1091,3 +1149,19 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
return bound == context.albumId
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -25,11 +25,12 @@ enum WiredTransferDesign {
|
|||||||
static let filterChipHeight: CGFloat = 34
|
static let filterChipHeight: CGFloat = 34
|
||||||
static let filterChipCornerRadius: CGFloat = 8
|
static let filterChipCornerRadius: CGFloat = 8
|
||||||
static let tabRowHeight: CGFloat = 68
|
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 thumbnailCornerRadius: CGFloat = 6
|
||||||
static let bottomButtonHeight: CGFloat = 44
|
static let bottomButtonHeight: CGFloat = 44
|
||||||
static let bottomButtonCornerRadius: CGFloat = 10
|
static let bottomButtonCornerRadius: CGFloat = 10
|
||||||
static let progressBarHeight: CGFloat = 2
|
static let progressBarHeight: CGFloat = 3
|
||||||
static let cardHorizontalPadding: CGFloat = 16
|
static let cardHorizontalPadding: CGFloat = 16
|
||||||
static let cardOverlap: CGFloat = 10
|
static let cardOverlap: CGFloat = 10
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,71 +20,63 @@ struct WiredTransferPhotoRow: View {
|
|||||||
private var canSelect: Bool { photo.canSelectForSpecifyUpload }
|
private var canSelect: Bool { photo.canSelectForSpecifyUpload }
|
||||||
private var canRetry: Bool { photo.status == .failed || photo.status == .pending }
|
private var canRetry: Bool { photo.status == .failed || photo.status == .pending }
|
||||||
private var rowOpacity: Double { selectUploadMode && !canSelect ? 0.45 : 1 }
|
private var rowOpacity: Double { selectUploadMode && !canSelect ? 0.45 : 1 }
|
||||||
|
private var showsProgress: Bool { photo.status == .transferring || photo.status == .uploading }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
if selectUploadMode {
|
||||||
if selectUploadMode {
|
WiredTransferSelectIndicator(selected: selected, enabled: canSelect)
|
||||||
WiredTransferSelectIndicator(selected: selected, enabled: 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)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(photo.fileName)
|
Text(photo.fileName)
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity))
|
.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))
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
WiredTransferStatusBadge(status: photo.status)
|
||||||
|
.opacity(rowOpacity)
|
||||||
}
|
}
|
||||||
}
|
Text(metaText)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.font(.system(size: 10))
|
||||||
.contentShape(Rectangle())
|
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
|
||||||
.onTapGesture {
|
.lineLimit(1)
|
||||||
if selectUploadMode {
|
progressSlot
|
||||||
if canSelect { onToggleSelection() }
|
}
|
||||||
} else {
|
}
|
||||||
onPhotoClick()
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
.contentShape(Rectangle())
|
||||||
}
|
.onTapGesture {
|
||||||
|
if selectUploadMode {
|
||||||
if !selectUploadMode {
|
if canSelect { onToggleSelection() }
|
||||||
Menu {
|
} else {
|
||||||
Button("重传") { onRetry() }
|
onPhotoClick()
|
||||||
.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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
if photo.status == .transferring || photo.status == .uploading {
|
if !selectUploadMode {
|
||||||
ProgressView(value: Double(photo.progress), total: 100)
|
Menu {
|
||||||
.tint(AppDesign.primary)
|
Button("重传") { onRetry() }
|
||||||
.frame(height: WiredTransferDesign.progressBarHeight)
|
.disabled(!canRetry)
|
||||||
.padding(.horizontal, 10)
|
Button("删除", role: .destructive) { onDelete() }
|
||||||
.padding(.bottom, 8)
|
} 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 {
|
private var metaText: String {
|
||||||
@ -92,6 +84,13 @@ struct WiredTransferPhotoRow: View {
|
|||||||
return "\(photo.fileSizeText) \(photo.resolutionText)"
|
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
|
@ViewBuilder
|
||||||
private var thumbnailView: some View {
|
private var thumbnailView: some View {
|
||||||
if let url = URL(string: photo.thumbnailURL), !photo.thumbnailURL.isEmpty {
|
if let url = URL(string: photo.thumbnailURL), !photo.thumbnailURL.isEmpty {
|
||||||
|
|||||||
@ -60,6 +60,24 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded)
|
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 {
|
func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws {
|
||||||
let camera = MockCameraService()
|
let camera = MockCameraService()
|
||||||
|
|||||||
@ -296,6 +296,65 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.photos.first?.status, .uploaded)
|
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 {
|
func testSelectTransferModePersistsOption() async {
|
||||||
let account = makeTransferModeTestAccount()
|
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 {
|
private func makeJPEGData() -> Data {
|
||||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8))
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8))
|
||||||
return renderer.jpegData(withCompressionQuality: 0.9) { context in
|
return renderer.jpegData(withCompressionQuality: 0.9) { context in
|
||||||
@ -516,10 +601,22 @@ private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class WiredTransferMockOSSUploadService: OSSUploadServing {
|
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 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 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 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 {
|
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)
|
onProgress(100)
|
||||||
return "https://cdn/mock/\(fileName)"
|
return "https://cdn/mock/\(fileName)"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user