拍后传输下载的历史照片切换模式后不再自动补传;刷新相机文件仅同步本地。重试上传后刷新单张状态,并忽略乱序 pipeline 通知导致的已上传回退。 Co-authored-by: Cursor <cursoragent@cursor.com>
1094 lines
40 KiB
Swift
1094 lines
40 KiB
Swift
//
|
||
// 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")!
|
||
|
||
@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 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 sessionNewPhotoIDs: Set<String> = []
|
||
private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
|
||
private var lastProgressPersistDates: [String: Date] = [:]
|
||
private var wasConnected = false
|
||
private var disconnectAlertTask: Task<Void, Never>?
|
||
|
||
/// 初始化有线传图 ViewModel。
|
||
init(
|
||
context: WiredTransferContext,
|
||
cameraService: any CameraServiceProtocol
|
||
) {
|
||
self.context = context
|
||
self.pipeline = CameraTransferPipeline(cameraService: cameraService)
|
||
|
||
pipeline.onConnectionStateChange = { [weak self] state in
|
||
Task { @MainActor in
|
||
self?.handleConnectionStateChange(state)
|
||
}
|
||
}
|
||
pipeline.onTasksUpdated = { [weak self] tasks in
|
||
Task { @MainActor in
|
||
await self?.applyPipelineTasks(tasks)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 配置上传 Sink 并启动相机连接。
|
||
func start(
|
||
api: any TravelAlbumServing,
|
||
ossService: any OSSUploadServing,
|
||
scenicID: Int,
|
||
accountContext: AccountContext
|
||
) async {
|
||
if photoStore == nil {
|
||
photoStore = WiredTransferPhotoStore(
|
||
accountPrefixProvider: { accountContext.accountCachePrefix ?? "guest_" },
|
||
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)
|
||
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
|
||
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)
|
||
}
|
||
|
||
/// 清除错误提示。
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 预览 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 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(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
||
await pipeline.uploadAssets(withIDs: 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)
|
||
}
|
||
|
||
/// 删除本地照片记录。
|
||
func deletePhoto(id: String) {
|
||
photoStore?.remove(albumID: context.albumId, photoID: id)
|
||
persistedRecordsByID.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)
|
||
}
|
||
|
||
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 {
|
||
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 status = mapPipelineStatus(task.status)
|
||
let existing = existingByID[task.assetID] ?? existingByFileName[task.filename]
|
||
if shouldIgnoreStalePipelineStatus(existing: existing?.status, incoming: status) {
|
||
continue
|
||
}
|
||
let storedLocalPath = task.localPath ?? ""
|
||
let localURL = task.localURL
|
||
let fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL)
|
||
|
||
let existingRecord = persistedRecordsByID[task.assetID]
|
||
let capturedAt = await resolveCapturedAt(
|
||
existing: existing,
|
||
taskCapturedAt: task.capturedAt,
|
||
localURL: localURL
|
||
)
|
||
var thumbnailPath = existingRecord?.thumbnailPath ?? ""
|
||
if thumbnailPath.isEmpty,
|
||
let localURL,
|
||
status != .transferring,
|
||
let generatedPath = await CameraThumbnailGenerator.generateThumbnail(for: localURL, assetID: task.assetID) {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 解析展示用拍摄时间:优先保留已有值,其次使用相机时间,最后回退到本地文件或当前时间。
|
||
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
|
||
}
|
||
}
|