Files
suixinkan_ios_new/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift
汉秋 90d135b5ac 完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。
引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 15:45:35 +08:00

1222 lines
44 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// TravelAlbumViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
import UIKit
/// ViewModel
@MainActor
final class TravelAlbumEntryViewModel: ObservableObject {
static let modePreShoot = 1
static let modePreOrder = 2
static let apiTypePreShoot = 1
static let apiTypePreOrder = 2
@Published var albums: [TravelAlbumItem] = []
@Published var total = 0
@Published var isLoading = false
@Published var isCreating = false
@Published var showCreateSheet = false
@Published var bindableOrders: [TravelAlbumAvailableOrder] = []
@Published var codeSheet: TravelAlbumCodeSheetState?
@Published var errorMessage: String?
///
func loadAlbums(api: any TravelAlbumServing) async {
isLoading = true
defer { isLoading = false }
do {
let payload = try await api.albumList(page: 1, pageSize: 20)
albums = payload.list
total = payload.total
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
///
func openCreateSheet(api: any TravelAlbumServing, scenicSelected: Bool) async {
guard !isCreating else { return }
guard scenicSelected else {
errorMessage = "请先选择景区"
return
}
showCreateSheet = true
await loadBindableOrders(api: api)
}
///
func loadBindableOrders(api: any TravelAlbumServing) async {
do {
bindableOrders = try await api.availableOrders()
} catch {
errorMessage = error.localizedDescription
}
}
///
func confirmCreateTask(
api: any TravelAlbumServing,
mode: Int,
freeCount: String,
singlePrice: String,
packagePrice: String,
order: TravelAlbumAvailableOrder?,
scenicSelected: Bool
) async -> Bool {
guard !isCreating else { return false }
guard scenicSelected else {
errorMessage = "请先选择景区"
return false
}
let apiType = mode == Self.modePreOrder ? Self.apiTypePreOrder : Self.apiTypePreShoot
let albumName: String
if mode == Self.modePreOrder {
albumName = order?.projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? order!.projectName
: Self.buildTodayAlbumName(existingNames: albums.map(\.name))
} else {
albumName = Self.buildTodayAlbumName(existingNames: albums.map(\.name))
}
if mode == Self.modePreOrder, order == nil {
errorMessage = "请选择绑定订单"
return false
}
if mode == Self.modePreShoot, singlePrice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
errorMessage = "请输入单张照片价格"
return false
}
let materialPrice = Double(singlePrice.trimmingCharacters(in: .whitespacesAndNewlines))
if mode == Self.modePreShoot, materialPrice == nil {
errorMessage = "请输入有效的单张照片价格"
return false
}
let request: TravelAlbumCreateRequest
if apiType == Self.apiTypePreShoot {
request = TravelAlbumCreateRequest(
name: albumName,
type: apiType,
orderNumber: nil,
materialNum: Int(freeCount) ?? 0,
materialPrice: materialPrice,
materialPackagePrice: Double(packagePrice) ?? 0,
photoPrice: 0
)
} else {
request = TravelAlbumCreateRequest(
name: albumName,
type: apiType,
orderNumber: order?.orderNumber,
materialNum: nil,
materialPrice: nil,
materialPackagePrice: nil,
photoPrice: nil
)
}
isCreating = true
defer { isCreating = false }
do {
_ = try await api.createAlbum(request)
showCreateSheet = false
await loadAlbums(api: api)
errorMessage = nil
return true
} catch {
let message = error.localizedDescription
if message.contains("订单已关联") {
await loadBindableOrders(api: api)
}
errorMessage = message.isEmpty ? "创建失败" : message
return false
}
}
///
func openAlbumCode(api: any TravelAlbumServing, album: TravelAlbumItem) async {
codeSheet = TravelAlbumCodeSheetState(albumID: album.id, albumName: album.name, isLoading: true, qrImageURL: "")
do {
let response = try await api.mpCode(id: album.id)
codeSheet?.qrImageURL = response.mpCodeOssURL.trimmingCharacters(in: .whitespacesAndNewlines)
codeSheet?.isLoading = false
} catch {
codeSheet?.isLoading = false
errorMessage = error.localizedDescription
}
}
///
static func buildTodayAlbumName(existingNames: [String]) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let prefix = formatter.string(from: Date())
let todayNames = existingNames.filter { $0.hasPrefix(prefix) }
let nextIndex = todayNames.count + 1
return String(format: "%@-%03d", prefix, nextIndex)
}
}
///
struct TravelAlbumCodeSheetState: Identifiable {
let albumID: Int
let albumName: String
var isLoading: Bool
var qrImageURL: String
var id: Int { albumID }
}
/// ViewModel Android TravelAlbumDetailViewModel
@MainActor
final class TravelAlbumDetailViewModel: ObservableObject {
static let tabAll = 0
static let tabPurchased = 1
static let materialStatusUnpurchased = 1
static let sortOptions: [(value: Int, label: String)] = [
(1, "创建时间正序"),
(2, "创建时间倒序"),
(3, "文件名正序"),
(4, "文件名倒序")
]
@Published var album: TravelAlbumItem?
@Published var materials: [TravelAlbumMaterial] = []
@Published var allPhotoCount = 0
@Published var selectedTab = tabAll
@Published var orderBy = 2
@Published var isSelectionMode = false
@Published var selectedMaterialIDs: Set<Int> = []
@Published var isLoading = false
@Published var isDeleting = false
@Published var showDeleteAlbumConfirm = false
@Published var showDeleteMaterialConfirm = false
@Published var errorMessage: String?
private var albumID = 0
private var currentPage = 1
private var canLoadMore = false
private var isLoadingMore = false
private static let pageSize = 30
private static let loadMoreThreshold = 6
private static let purchasedFilterYes = 1
///
func refreshAll(api: any TravelAlbumServing, albumID: Int) async {
self.albumID = albumID
async let infoTask: Void = loadAlbumInfo(api: api, albumID: albumID, showPageLoading: album == nil)
async let countTask: Void = loadAllPhotoCount(api: api, albumID: albumID)
async let materialsTask: Void = loadMaterials(api: api, albumID: albumID, reset: true)
_ = await (infoTask, countTask, materialsTask)
}
///
func load(api: any TravelAlbumServing, albumID: Int) async {
await refreshAll(api: api, albumID: albumID)
}
/// Tab
func selectTab(_ tab: Int, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return }
selectedTab = tab
isSelectionMode = false
selectedMaterialIDs = []
await loadMaterials(api: api, albumID: albumID, reset: true)
}
///
func setOrderBy(_ value: Int, api: any TravelAlbumServing) async {
guard orderBy != value else { return }
orderBy = value
await loadMaterials(api: api, albumID: albumID, reset: true)
}
/// Tab
func toggleSelectionMode() {
guard selectedTab == Self.tabAll else { return }
isSelectionMode.toggle()
if !isSelectionMode {
selectedMaterialIDs = []
}
}
///
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
guard isSelectionMode else { return }
guard material.status == Self.materialStatusUnpurchased else {
errorMessage = "仅未购买素材可删除"
return
}
if selectedMaterialIDs.contains(material.id) {
selectedMaterialIDs.remove(material.id)
} else {
selectedMaterialIDs.insert(material.id)
}
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any TravelAlbumServing) async {
guard lastVisibleIndex >= materials.count - Self.loadMoreThreshold else { return }
await loadMaterials(api: api, albumID: albumID, reset: false)
}
///
func requestDeleteSelectedMaterials() {
guard !selectedMaterialIDs.isEmpty else {
errorMessage = "请选择要删除的素材"
return
}
showDeleteMaterialConfirm = true
}
///
@discardableResult
func confirmDeleteSelectedMaterials(api: any TravelAlbumServing) async -> Bool {
let ids = Array(selectedMaterialIDs)
guard !ids.isEmpty else {
showDeleteMaterialConfirm = false
return false
}
showDeleteMaterialConfirm = false
isDeleting = true
defer { isDeleting = false }
for id in ids {
do {
try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: id))
} catch {
errorMessage = error.localizedDescription
return false
}
}
isSelectionMode = false
selectedMaterialIDs = []
await refreshAll(api: api, albumID: albumID)
return true
}
/// 线
func wiredTransferContext() -> WiredTransferContext? {
guard let album else { return nil }
return WiredTransferContext(
albumId: album.id,
albumName: album.name,
phone: album.displayPhone,
orderNumber: album.orderNumber
)
}
///
func deleteAlbum(api: any TravelAlbumServing, albumID: Int, photoStore: WiredTransferPhotoStore) async -> Bool {
isDeleting = true
defer { isDeleting = false }
do {
try await api.deleteAlbum(TravelAlbumDeleteRequest(id: albumID))
_ = photoStore.clearAlbum(albumID: albumID)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
private func loadAlbumInfo(api: any TravelAlbumServing, albumID: Int, showPageLoading: Bool) async {
if showPageLoading {
isLoading = true
}
defer {
if showPageLoading {
isLoading = false
}
}
do {
album = try await api.albumInfo(id: albumID)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
private func loadAllPhotoCount(api: any TravelAlbumServing, albumID: Int) async {
do {
let payload = try await api.materialList(
userEquityTravelId: albumID,
page: 1,
pageSize: 1,
orderBy: orderBy,
isPurchased: nil
)
allPhotoCount = payload.total
} catch {
errorMessage = error.localizedDescription
}
}
private func loadMaterials(api: any TravelAlbumServing, albumID: Int, reset: Bool) async {
if reset {
currentPage = 1
canLoadMore = false
} else {
guard canLoadMore, !isLoadingMore else { return }
isLoadingMore = true
currentPage += 1
}
let isPurchasedFilter: Int? = selectedTab == Self.tabPurchased ? Self.purchasedFilterYes : nil
do {
let payload = try await api.materialList(
userEquityTravelId: albumID,
page: currentPage,
pageSize: Self.pageSize,
orderBy: orderBy,
isPurchased: isPurchasedFilter
)
if reset {
materials = payload.list
} else {
materials.append(contentsOf: payload.list)
}
canLoadMore = materials.count < payload.total
if selectedTab == Self.tabAll {
allPhotoCount = payload.total
}
errorMessage = nil
} catch {
if !reset, currentPage > 1 {
currentPage -= 1
}
errorMessage = error.localizedDescription
}
if !reset {
isLoadingMore = false
}
}
}
/// 线 ViewModel
@MainActor
final class WiredCameraTransferViewModel: ObservableObject {
static let modeLiveCapture = "边拍边传"
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")!
private static let manualUploadConcurrency = 3
@Published private(set) var photos: [WiredTransferPhotoItem] = []
@Published private(set) var visiblePhotos: [WiredTransferPhotoItem] = []
@Published private(set) var sidebarGroups: [WiredTransferDateGroup] = []
@Published private(set) var photoSections: [WiredTransferPhotoSection] = []
@Published private(set) var tabCounts: [Int] = [0, 0, 0]
@Published var selectedTabIndex = 0
@Published var sidebarExpanded = true
@Published var selectedTimeSlotID: String?
@Published var selectUploadMode = false
@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 isSearchingCamera = false
@Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
@Published var connectionState: CameraConnectionState = .disconnected
@Published var errorMessage: String?
let context: WiredTransferContext
private let pipeline: CameraTransferPipeline
private var photoStore: WiredTransferPhotoStore?
private var userIDProvider: (() -> String)?
private var downloadScope: CameraDownloadStorage.Scope?
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>?
private var isTearingDownCamera = false
/// true
private var isLeavingPage = false
/// 线 ViewModel
init(
context: WiredTransferContext,
cameraService: any CameraServiceProtocol
) {
self.context = context
self.pipeline = CameraTransferPipeline(cameraService: cameraService)
}
/// Sink
func start(
api: any TravelAlbumServing,
ossService: any OSSUploadServing,
scenicID: Int,
accountContext: AccountContext
) async {
isLeavingPage = false
pipeline.attachToUI(
onConnectionStateChange: { [weak self] state in
self?.handleConnectionStateChange(state)
},
onTasksUpdated: { [weak self] tasks in
Task { @MainActor in
await self?.applyPipelineTasks(tasks)
}
}
)
let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
let currentDownloadScope = CameraDownloadStorage.Scope(
accountKey: accountCachePrefix,
albumID: context.albumId
)
downloadScope = currentDownloadScope
if photoStore == nil {
photoStore = WiredTransferPhotoStore(
accountPrefixProvider: { accountCachePrefix },
userIDProvider: { accountContext.profile?.userId ?? "" }
)
userIDProvider = { accountContext.profile?.userId ?? "" }
if let cachedMode = photoStore?.loadTransferModeOption() {
transferModeOption = cachedMode
}
}
let uploader = TravelAlbumMaterialUploader(
api: api,
ossService: ossService,
albumID: context.albumId,
scenicID: scenicID
)
let autoUpload = transferModeOption == Self.modeLiveCapture
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
refreshDeviceStorageInfo()
loadPersistedPhotos()
do {
await pipeline.connect()
connectionState = pipeline.connectionState
wasConnected = connectionState.isConnected
while !Task.isCancelled {
try await Task.sleep(nanoseconds: 1_000_000_000)
}
} catch is CancellationError {
// task
} catch {
errorMessage = error.localizedDescription
}
detachFromUI()
}
/// detach UI
func stop() async {
detachFromUI()
}
/// UI
func detachFromUI() {
guard !isTearingDownCamera else { return }
isTearingDownCamera = true
isLeavingPage = true
defer { isTearingDownCamera = false }
disconnectAlertTask?.cancel()
showCameraDisconnectedAlert = false
pipeline.detachFromUI()
}
///
func refreshDeviceStorageInfo() {
deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
}
/// Tab
func selectTab(_ index: Int) {
selectedTabIndex = index
selectedTimeSlotID = nil
refreshDerivedPhotoState(rebuildGroups: true)
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 }
transferModeOption = option
photoStore?.saveTransferModeOption(option)
let autoUpload = option == Self.modeLiveCapture
let uploader = TravelAlbumMaterialUploader(
api: api,
ossService: ossService,
albumID: context.albumId,
scenicID: scenicID
)
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: downloadScope)
}
///
func clearErrorMessage() {
errorMessage = nil
}
/// USB
func refreshCameraFiles() async {
if connectionState.isConnected {
guard !isRefreshingCameraFiles else { return }
isRefreshingCameraFiles = true
defer { isRefreshingCameraFiles = false }
await pipeline.syncExistingPhotos()
return
}
await restartCameraSearch()
}
/// USB
func restartCameraSearch() async {
guard !connectionState.isConnected, !isSearchingCamera else { return }
isSearchingCamera = true
defer { isSearchingCamera = false }
await pipeline.restartCameraDiscovery()
connectionState = pipeline.connectionState
}
/// / /
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)
}
}
/// URL
func previewURL(for photoID: String) -> String {
guard let photo = photos.first(where: { $0.id == photoID }) else { return "" }
if !photo.previewURL.isEmpty { return photo.previewURL }
return photo.thumbnailURL
}
///
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 uploadPendingPhotoIDs(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 uploadPendingPhotoIDs(pendingIDs)
}
///
func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
await uploadPendingPhotoIDs(Array(selectedPhotoIDs))
exitSelectUploadMode()
}
///
func retryPhoto(
id: String,
api: any TravelAlbumServing,
ossService: any OSSUploadServing,
scenicID: Int
) async {
guard let photo = photos.first(where: { $0.id == id }) else { return }
guard photo.status == .failed || photo.status == .pending else {
errorMessage = "当前状态不可重传"
return
}
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
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 {
updatePhotoStatus(id: id, status: .failed, progress: 0, errorMessage: "本地文件不存在")
errorMessage = "本地文件不存在,无法重传"
persistPhotoStatus(id: id, status: .failed, progress: 0, errorMessage: "本地文件不存在")
return
}
await pipeline.retryUpload(
assetID: id,
filename: record.fileName,
localPath: record.localPath
)
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)
}
///
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 {
selectedPhotoIDs.insert(id)
}
}
///
func syncExistingPhotos() async {
await refreshCameraFiles()
}
///
func retryFailedUploads() async {
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, downloadScope: downloadScope)
}
private func persistedRecord(for photoID: String) -> WiredTransferPhotoRecord? {
persistedRecordsByID[photoID]
}
private func updatePhotoStatus(
id: String,
status: WiredTransferUploadStatus,
progress: Int,
errorMessage: String?
) {
guard let index = photos.firstIndex(where: { $0.id == id }) else { return }
let photo = photos[index]
let updated = WiredTransferPhotoItem(
id: photo.id,
sourceId: photo.sourceId,
fileName: photo.fileName,
thumbnailURL: photo.thumbnailURL,
previewURL: photo.previewURL,
capturedAt: photo.capturedAt,
fileSizeText: photo.fileSizeText,
resolutionText: photo.resolutionText,
status: status,
progress: progress,
errorMessage: errorMessage
)
replacePhoto(updated, rebuildGroups: photo.status != status)
}
private func refreshPhotoFromPipelineTask(id: String) async {
guard let task = pipeline.task(forAssetID: id) else { return }
await applyPipelineTasks([task], enforceAlbumBinding: false)
}
private func persistPhotoStatus(
id: String,
status: WiredTransferUploadStatus,
progress: Int,
errorMessage: String?
) {
guard context.albumId > 0,
let userID = userIDProvider?(), !userID.isEmpty else { return }
guard let existing = persistedRecordsByID[id] else { return }
let updated = WiredTransferPhotoRecord(
id: existing.id,
sourceId: existing.sourceId,
fileName: existing.fileName,
localPath: existing.localPath,
thumbnailPath: existing.thumbnailPath,
capturedAt: existing.capturedAt,
fileSizeBytes: existing.fileSizeBytes,
status: status.rawValue,
progress: progress,
errorMessage: errorMessage,
albumId: existing.albumId,
userId: existing.userId,
remoteURL: existing.remoteURL,
updatedAt: Date().timeIntervalSince1970
)
persistRecord(updated)
}
private func syncSelectionWithVisiblePhotos() {
let visibleIDs = Set(visiblePhotos.map(\.id))
selectedPhotoIDs = selectedPhotoIDs.intersection(visibleIDs)
}
/// UI
func replacePhotos(_ newPhotos: [WiredTransferPhotoItem]) {
setPhotos(newPhotos.sortedByCaptureTimeDescending(), rebuildGroups: true)
}
private func setPhotos(_ newPhotos: [WiredTransferPhotoItem], rebuildGroups: Bool) {
photos = newPhotos
refreshDerivedPhotoState(rebuildGroups: rebuildGroups)
}
private func replacePhoto(_ photo: WiredTransferPhotoItem, rebuildGroups: Bool) {
var updatedPhotos = photos
if let index = updatedPhotos.firstIndex(where: { $0.id == photo.id }) {
updatedPhotos[index] = photo
} else {
updatedPhotos.insert(photo, at: 0)
}
if rebuildGroups {
updatedPhotos = updatedPhotos.sortedByCaptureTimeDescending()
}
setPhotos(updatedPhotos, rebuildGroups: rebuildGroups)
}
private func refreshDerivedPhotoState(rebuildGroups: Bool) {
tabCounts = photos.transferTabCounts
let filtered = photos.filtered(byTabIndex: selectedTabIndex)
visiblePhotos = filtered
if rebuildGroups {
sidebarGroups = filtered.buildSidebarGroups()
photoSections = filtered.buildPhotoSections()
return
}
let latestByID = Dictionary(uniqueKeysWithValues: filtered.map { ($0.id, $0) })
photoSections = photoSections.map { section in
WiredTransferPhotoSection(
slotId: section.slotId,
headerTitle: section.headerTitle,
photos: section.photos.compactMap { latestByID[$0.id] }
)
}.filter { !$0.photos.isEmpty }
}
private func persistRecord(_ record: WiredTransferPhotoRecord) {
guard let photoStore, context.albumId > 0 else { return }
persistedRecordsByID[record.id] = record
photoStore.save(albumID: context.albumId, records: Array(persistedRecordsByID.values))
}
private func handleConnectionStateChange(_ state: CameraConnectionState) {
if wasConnected, !state.isConnected, !isLeavingPage {
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)
persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
setPhotos(records.map { $0.toPhotoItem() }.sortedByCaptureTimeDescending(), rebuildGroups: true)
}
private func applyPipelineTasks(_ tasks: [CameraTransferTask], enforceAlbumBinding: Bool = true) async {
guard let userIDProvider else { return }
let userID = userIDProvider()
// capturedAt
let existingByID = Dictionary(uniqueKeysWithValues: photos.map { ($0.id, $0) })
let existingByFileName = Dictionary(
photos.map { ($0.fileName, $0) },
uniquingKeysWith: { first, _ in first }
)
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 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,
localURL: localURL
)
var thumbnailPath = existingRecord?.thumbnailPath ?? ""
if thumbnailPath.isEmpty,
let downloadScope,
let localURL,
status != .transferring,
let generatedPath = await CameraThumbnailGenerator.generateThumbnail(
for: localURL,
assetID: task.assetID,
scope: downloadScope
) {
thumbnailPath = generatedPath
}
let thumbnailURL = thumbnailURLString(thumbnailPath: thumbnailPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "")
let previewURL = previewURLString(localPath: storedLocalPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "", thumbnailPath: thumbnailPath)
let fileSizeText = fileSizeBytes > 0
? WiredTransferPhotoRecord.formatFileSize(fileSizeBytes)
: (existing?.fileSizeText ?? "--")
let item = WiredTransferPhotoItem(
id: task.assetID,
sourceId: existing?.sourceId ?? task.assetID,
fileName: task.filename,
thumbnailURL: thumbnailURL,
previewURL: previewURL,
capturedAt: capturedAt,
fileSizeText: fileSizeText,
resolutionText: existing?.resolutionText ?? "",
status: status,
progress: task.progress,
errorMessage: task.errorMessage
)
let rebuildGroups = existing == nil
|| existing?.capturedAt != item.capturedAt
|| (selectedTabIndex != 0 && existing?.status != item.status)
replacePhoto(item, rebuildGroups: rebuildGroups)
sessionNewPhotoIDs.insert(task.assetID)
guard !userID.isEmpty else { continue }
let remoteURL = task.remoteURL ?? ""
if shouldPersistPipelineUpdate(
photoID: task.assetID,
status: status,
localPath: storedLocalPath,
thumbnailPath: thumbnailPath,
remoteURL: remoteURL,
progress: task.progress,
now: Date()
) {
let record = item.toRecord(
albumId: context.albumId,
userId: userID,
localPath: storedLocalPath,
thumbnailPath: thumbnailPath,
fileSizeBytes: fileSizeBytes,
remoteURL: remoteURL
)
persistRecord(record)
}
lastAppliedPipelineSignatures[task.assetID] = signature
}
}
/// 使退
private func thumbnailURLString(thumbnailPath: String, remoteURL: String) -> String {
if !thumbnailPath.isEmpty {
let localThumbnailURL = CameraDownloadStorage.previewURLString(from: thumbnailPath)
if !localThumbnailURL.isEmpty { return localThumbnailURL }
}
return remoteURL
}
private func previewURLString(localPath: String, remoteURL: String, thumbnailPath: String) -> String {
if !localPath.isEmpty {
let localPreviewURL = CameraDownloadStorage.previewURLString(from: localPath)
if !localPreviewURL.isEmpty { return localPreviewURL }
}
if !remoteURL.isEmpty { return remoteURL }
if !thumbnailPath.isEmpty {
return CameraDownloadStorage.previewURLString(from: thumbnailPath)
}
return ""
}
private func resolveCapturedAt(
existing: WiredTransferPhotoItem?,
taskCapturedAt: String?,
localURL: URL?
) async -> String {
if let existing, !existing.capturedAt.isEmpty {
return existing.capturedAt
}
if let taskCapturedAt, !taskCapturedAt.isEmpty {
return taskCapturedAt
}
if let localURL {
let date = await Task.detached(priority: .utility) {
let attributes = try? FileManager.default.attributesOfItem(atPath: localURL.path)
return (attributes?[.creationDate] as? Date)
?? (attributes?[.modificationDate] as? Date)
}.value
if let date {
return CameraTransferTask.capturedAtString(from: date)
}
}
return CameraTransferTask.capturedAtString(from: Date())
}
///
private func mapPipelineStatus(_ status: CameraTransferStatus) -> WiredTransferUploadStatus {
switch status {
case .pending, .downloading:
return .transferring
case .downloaded:
return .pending
case .uploading:
return .uploading
case .uploaded:
return .uploaded
case .failed:
return .failed
}
}
/// 退
private func shouldIgnoreStalePipelineStatus(
existing: WiredTransferUploadStatus?,
incoming: WiredTransferUploadStatus
) -> Bool {
switch (existing, incoming) {
case (.uploaded?, .pending), (.uploaded?, .transferring), (.uploaded?, .uploading), (.uploaded?, .failed):
return true
case (.uploading?, .pending), (.uploading?, .transferring):
return true
default:
return false
}
}
/// URL IO
private func shouldPersistPipelineUpdate(
photoID: String,
status: WiredTransferUploadStatus,
localPath: String,
thumbnailPath: String,
remoteURL: String,
progress: Int,
now: Date
) -> Bool {
guard let existing = persistedRecordsByID[photoID] else {
lastProgressPersistDates[photoID] = now
return true
}
if existing.status != status.rawValue { return true }
if !localPath.isEmpty, existing.localPath != localPath { return true }
if !thumbnailPath.isEmpty, existing.thumbnailPath != thumbnailPath { return true }
if !remoteURL.isEmpty, existing.remoteURL != remoteURL { return true }
if status == .uploaded || status == .failed { return true }
if abs(progress - existing.progress) >= 10 {
lastProgressPersistDates[photoID] = now
return true
}
let lastPersistDate = lastProgressPersistDates[photoID] ?? .distantPast
if now.timeIntervalSince(lastPersistDate) >= 1 {
lastProgressPersistDates[photoID] = now
return true
}
return false
}
///
private func belongsToCurrentAlbum(_ photoID: String) -> Bool {
guard let bound = photoStore?.albumID(forPhoto: photoID) else {
return true
}
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
}
}