对齐有线传图页 Android UI,并改用相对路径持久化本地照片。

还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 11:16:28 +08:00
parent e91a46c315
commit c71b45bdfd
29 changed files with 2097 additions and 256 deletions

View File

@ -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 ?? ""
)