对齐有线传图页 Android UI,并改用相对路径持久化本地照片。
还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -7,6 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
/// 旅拍相册入口 ViewModel,负责列表、创建任务与小程序码。
|
||||
@MainActor
|
||||
@ -229,7 +230,12 @@ final class TravelAlbumDetailViewModel: ObservableObject {
|
||||
@MainActor
|
||||
final class WiredCameraTransferViewModel: ObservableObject {
|
||||
static let modeLiveCapture = "边拍边传"
|
||||
static let modePostShootTransfer = "拍完再传"
|
||||
static let modePostShootTransfer = "拍后传输"
|
||||
/// 当前仅支持 JPG,后续再扩展 RAW 等格式。
|
||||
static let formatJPG = "JPG"
|
||||
static let specifyUploadAllPending = "上传所有未上传的照片"
|
||||
static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片"
|
||||
static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")!
|
||||
|
||||
@Published var photos: [WiredTransferPhotoItem] = []
|
||||
@Published var selectedTabIndex = 0
|
||||
@ -239,6 +245,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
@Published var selectedPhotoIDs: Set<String> = []
|
||||
@Published var transferModeOption = modeLiveCapture
|
||||
@Published var showSpecifyUploadSheet = false
|
||||
@Published var showDeviceStorageAlert = false
|
||||
@Published var showCameraDisconnectedAlert = false
|
||||
@Published var showHelpDoc = false
|
||||
@Published var isRefreshingCameraFiles = false
|
||||
@Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
|
||||
@Published var connectionState: CameraConnectionState = .disconnected
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ -247,6 +258,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
private var photoStore: WiredTransferPhotoStore?
|
||||
private var userIDProvider: (() -> String)?
|
||||
private var sessionNewPhotoIDs: Set<String> = []
|
||||
private var wasConnected = false
|
||||
private var disconnectAlertTask: Task<Void, Never>?
|
||||
|
||||
/// 初始化有线传图 ViewModel。
|
||||
init(
|
||||
@ -258,7 +271,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
|
||||
pipeline.onConnectionStateChange = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
self?.connectionState = state
|
||||
self?.handleConnectionStateChange(state)
|
||||
}
|
||||
}
|
||||
pipeline.onTasksUpdated = { [weak self] tasks in
|
||||
@ -290,15 +303,53 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
)
|
||||
let autoUpload = transferModeOption == Self.modeLiveCapture
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
||||
refreshDeviceStorageInfo()
|
||||
loadPersistedPhotos()
|
||||
await pipeline.connect()
|
||||
connectionState = pipeline.connectionState
|
||||
wasConnected = connectionState.isConnected
|
||||
}
|
||||
|
||||
/// 断开相机连接。
|
||||
func stop() async {
|
||||
disconnectAlertTask?.cancel()
|
||||
await pipeline.disconnect()
|
||||
}
|
||||
|
||||
/// 刷新设备存储信息。
|
||||
func refreshDeviceStorageInfo() {
|
||||
deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
|
||||
}
|
||||
|
||||
/// 切换 Tab。
|
||||
func selectTab(_ index: Int) {
|
||||
selectedTabIndex = index
|
||||
selectedTimeSlotID = nil
|
||||
if selectUploadMode {
|
||||
syncSelectionWithVisiblePhotos()
|
||||
}
|
||||
}
|
||||
|
||||
/// 折叠/展开时间侧栏。
|
||||
func toggleSidebar() {
|
||||
sidebarExpanded.toggle()
|
||||
}
|
||||
|
||||
/// 选中时间段并滚动定位。
|
||||
func selectTimeSlot(_ slotID: String) {
|
||||
selectedTimeSlotID = slotID
|
||||
}
|
||||
|
||||
/// 打开帮助文档。
|
||||
func openHelpDoc() {
|
||||
showHelpDoc = true
|
||||
}
|
||||
|
||||
/// 点击设备存储面板。
|
||||
func onDeviceUsageClick() {
|
||||
showDeviceStorageAlert = true
|
||||
}
|
||||
|
||||
/// 切换传输模式。
|
||||
func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
|
||||
guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return }
|
||||
@ -313,17 +364,113 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
||||
}
|
||||
|
||||
/// 清除错误提示。
|
||||
func clearErrorMessage() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 刷新相机文件列表。
|
||||
func refreshCameraFiles() async {
|
||||
guard connectionState.isConnected, !isRefreshingCameraFiles else { return }
|
||||
isRefreshingCameraFiles = true
|
||||
defer { isRefreshingCameraFiles = false }
|
||||
await pipeline.syncExistingPhotos()
|
||||
}
|
||||
|
||||
/// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。
|
||||
func onBatchUploadButtonClick(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||
if !selectUploadMode {
|
||||
selectUploadMode = true
|
||||
selectedPhotoIDs.removeAll()
|
||||
return
|
||||
}
|
||||
if !selectedPhotoIDs.isEmpty {
|
||||
await uploadSelected(api: api, ossService: ossService, scenicID: scenicID)
|
||||
return
|
||||
}
|
||||
exitSelectUploadMode()
|
||||
}
|
||||
|
||||
/// 指定上传按钮点击。
|
||||
func onSpecifyUploadButtonClick() {
|
||||
guard !selectUploadMode else { return }
|
||||
showSpecifyUploadSheet = true
|
||||
}
|
||||
|
||||
/// 关闭指定上传弹层。
|
||||
func dismissSpecifyUploadSheet() {
|
||||
showSpecifyUploadSheet = false
|
||||
}
|
||||
|
||||
/// 指定上传选项确认。
|
||||
func onSpecifyUploadOptionSelected(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||
dismissSpecifyUploadSheet()
|
||||
switch option {
|
||||
case Self.specifyUploadAllPending:
|
||||
await batchUploadAll(api: api, ossService: ossService, scenicID: scenicID)
|
||||
case Self.specifyUploadTodayCaptured:
|
||||
await uploadAllTodayCaptured(api: api, ossService: ossService, scenicID: scenicID)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出勾选模式。
|
||||
func exitSelectUploadMode() {
|
||||
selectUploadMode = false
|
||||
selectedPhotoIDs.removeAll()
|
||||
}
|
||||
|
||||
/// 切换可见 pending 全选状态。
|
||||
func toggleSelectAllPendingInVisible() {
|
||||
let pendingIDs = visiblePhotos.selectablePendingPhotoIDs
|
||||
guard !pendingIDs.isEmpty else { return }
|
||||
if pendingIDs.isSubset(of: selectedPhotoIDs) {
|
||||
selectedPhotoIDs.subtract(pendingIDs)
|
||||
} else {
|
||||
selectedPhotoIDs.formUnion(pendingIDs)
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 Tab 可见照片。
|
||||
var visiblePhotos: [WiredTransferPhotoItem] {
|
||||
photos.filtered(byTabIndex: selectedTabIndex)
|
||||
}
|
||||
|
||||
/// 预览 URL。
|
||||
func previewURL(for photoID: String) -> String {
|
||||
guard let photo = photos.first(where: { $0.id == photoID }) else { return "" }
|
||||
if !photo.thumbnailURL.isEmpty { return photo.thumbnailURL }
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 批量上传所有待上传照片。
|
||||
func batchUploadAll() async {
|
||||
func batchUploadAll(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
||||
let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id)
|
||||
guard !pendingIDs.isEmpty else {
|
||||
errorMessage = "暂无未上传的照片"
|
||||
return
|
||||
}
|
||||
await pipeline.uploadAssets(withIDs: pendingIDs)
|
||||
}
|
||||
|
||||
/// 上传今日拍摄且未上传的照片。
|
||||
func uploadAllTodayCaptured(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
||||
let pendingIDs = photos.filter { $0.isCapturedToday() && $0.isNotUploaded }.map(\.id)
|
||||
guard !pendingIDs.isEmpty else {
|
||||
errorMessage = "暂无今日拍摄且未上传的照片"
|
||||
return
|
||||
}
|
||||
await pipeline.uploadAssets(withIDs: pendingIDs)
|
||||
}
|
||||
|
||||
/// 上传选中照片。
|
||||
func uploadSelected() async {
|
||||
func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
||||
await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs))
|
||||
selectUploadMode = false
|
||||
selectedPhotoIDs.removeAll()
|
||||
exitSelectUploadMode()
|
||||
}
|
||||
|
||||
/// 重试单张照片上传。
|
||||
@ -339,6 +486,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
|
||||
/// 切换照片选中状态。
|
||||
func togglePhotoSelection(id: String) {
|
||||
guard selectUploadMode else { return }
|
||||
guard let photo = photos.first(where: { $0.id == id }), photo.canSelectForSpecifyUpload else { return }
|
||||
if selectedPhotoIDs.contains(id) {
|
||||
selectedPhotoIDs.remove(id)
|
||||
} else {
|
||||
@ -348,7 +497,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
|
||||
/// 同步相机历史照片。
|
||||
func syncExistingPhotos() async {
|
||||
await pipeline.syncExistingPhotos()
|
||||
await refreshCameraFiles()
|
||||
}
|
||||
|
||||
/// 重试失败上传。
|
||||
@ -356,6 +505,51 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
await pipeline.retryFailedUploads()
|
||||
}
|
||||
|
||||
private func ensureUploadEnabled(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
|
||||
let uploader = TravelAlbumMaterialUploader(
|
||||
api: api,
|
||||
ossService: ossService,
|
||||
albumID: context.albumId,
|
||||
scenicID: scenicID
|
||||
)
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: true)
|
||||
}
|
||||
|
||||
private func syncSelectionWithVisiblePhotos() {
|
||||
let visibleIDs = Set(visiblePhotos.map(\.id))
|
||||
selectedPhotoIDs = selectedPhotoIDs.intersection(visibleIDs)
|
||||
}
|
||||
|
||||
private func handleConnectionStateChange(_ state: CameraConnectionState) {
|
||||
if wasConnected, !state.isConnected {
|
||||
triggerCameraDisconnectedAlert()
|
||||
}
|
||||
wasConnected = state.isConnected
|
||||
connectionState = state
|
||||
}
|
||||
|
||||
private func triggerCameraDisconnectedAlert() {
|
||||
disconnectAlertTask?.cancel()
|
||||
showCameraDisconnectedAlert = true
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
||||
disconnectAlertTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
showCameraDisconnectedAlert = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机设备名称。
|
||||
var cameraDeviceName: String {
|
||||
if case .connected(let name) = connectionState { return name }
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 是否已连接相机。
|
||||
var isCameraConnected: Bool {
|
||||
connectionState.isConnected
|
||||
}
|
||||
|
||||
private func loadPersistedPhotos() {
|
||||
guard context.albumId > 0, let photoStore else { return }
|
||||
let records = photoStore.load(albumID: context.albumId)
|
||||
@ -388,9 +582,15 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
let capturedAt = Self.captureTimestampFormatter.string(from: Date())
|
||||
let localPath = task.localPath ?? ""
|
||||
let fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0
|
||||
let thumbnailURL = localPath.isEmpty ? "" : "file://\(localPath)"
|
||||
let storedLocalPath = task.localPath ?? ""
|
||||
let localURL = task.localURL
|
||||
let fileSizeBytes: Int64
|
||||
if let localURL {
|
||||
fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64) ?? 0
|
||||
} else {
|
||||
fileSizeBytes = 0
|
||||
}
|
||||
let thumbnailURL = localURL.map(\.absoluteString) ?? ""
|
||||
|
||||
let item = WiredTransferPhotoItem(
|
||||
id: task.assetID,
|
||||
@ -409,7 +609,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
let record = item.toRecord(
|
||||
albumId: context.albumId,
|
||||
userId: userID,
|
||||
localPath: localPath,
|
||||
localPath: storedLocalPath,
|
||||
fileSizeBytes: fileSizeBytes,
|
||||
remoteURL: task.remoteURL ?? ""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user