未绑定相册的新拍照片被错误过滤导致列表始终为空;同时调整入口卡片点击区域与详情素材网格布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
439 lines
15 KiB
Swift
439 lines
15 KiB
Swift
//
|
||
// TravelAlbumViewModels.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/29.
|
||
//
|
||
|
||
import Foundation
|
||
import Combine
|
||
|
||
/// 旅拍相册入口 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。
|
||
@MainActor
|
||
final class TravelAlbumDetailViewModel: ObservableObject {
|
||
@Published var album: TravelAlbumItem?
|
||
@Published var materials: [TravelAlbumMaterial] = []
|
||
@Published var isLoading = false
|
||
@Published var isDeleting = false
|
||
@Published var errorMessage: String?
|
||
|
||
/// 加载相册详情与素材。
|
||
func load(api: any TravelAlbumServing, albumID: Int) async {
|
||
isLoading = true
|
||
defer { isLoading = false }
|
||
do {
|
||
async let info = api.albumInfo(id: albumID)
|
||
async let list = api.materialList(userEquityTravelId: albumID, page: 1, pageSize: 100)
|
||
album = try await info
|
||
materials = try await list.list
|
||
errorMessage = nil
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 删除素材。
|
||
func deleteMaterial(api: any TravelAlbumServing, materialID: Int, albumID: Int) async {
|
||
do {
|
||
try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: materialID))
|
||
await load(api: api, albumID: albumID)
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 删除相册。
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 有线传图 ViewModel,编排相机连接、传输管道与本地持久化。
|
||
@MainActor
|
||
final class WiredCameraTransferViewModel: ObservableObject {
|
||
static let modeLiveCapture = "边拍边传"
|
||
static let modePostShootTransfer = "拍完再传"
|
||
|
||
@Published var photos: [WiredTransferPhotoItem] = []
|
||
@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 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> = []
|
||
|
||
/// 初始化有线传图 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?.connectionState = state
|
||
}
|
||
}
|
||
pipeline.onTasksUpdated = { [weak self] tasks in
|
||
Task { @MainActor in
|
||
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 ?? "" }
|
||
}
|
||
let uploader = TravelAlbumMaterialUploader(
|
||
api: api,
|
||
ossService: ossService,
|
||
albumID: context.albumId,
|
||
scenicID: scenicID
|
||
)
|
||
let autoUpload = transferModeOption == Self.modeLiveCapture
|
||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
||
loadPersistedPhotos()
|
||
await pipeline.connect()
|
||
}
|
||
|
||
/// 断开相机连接。
|
||
func stop() async {
|
||
await pipeline.disconnect()
|
||
}
|
||
|
||
/// 切换传输模式。
|
||
func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
|
||
guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return }
|
||
transferModeOption = 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 batchUploadAll() async {
|
||
let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id)
|
||
await pipeline.uploadAssets(withIDs: pendingIDs)
|
||
}
|
||
|
||
/// 上传选中照片。
|
||
func uploadSelected() async {
|
||
await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs))
|
||
selectUploadMode = false
|
||
selectedPhotoIDs.removeAll()
|
||
}
|
||
|
||
/// 重试单张照片上传。
|
||
func retryPhoto(id: String) async {
|
||
await pipeline.uploadAssets(withIDs: [id])
|
||
}
|
||
|
||
/// 删除本地照片记录。
|
||
func deletePhoto(id: String) {
|
||
photoStore?.remove(albumID: context.albumId, photoID: id)
|
||
photos.removeAll { $0.id == id }
|
||
}
|
||
|
||
/// 切换照片选中状态。
|
||
func togglePhotoSelection(id: String) {
|
||
if selectedPhotoIDs.contains(id) {
|
||
selectedPhotoIDs.remove(id)
|
||
} else {
|
||
selectedPhotoIDs.insert(id)
|
||
}
|
||
}
|
||
|
||
/// 同步相机历史照片。
|
||
func syncExistingPhotos() async {
|
||
await pipeline.syncExistingPhotos()
|
||
}
|
||
|
||
/// 重试失败上传。
|
||
func retryFailedUploads() async {
|
||
await pipeline.retryFailedUploads()
|
||
}
|
||
|
||
private func loadPersistedPhotos() {
|
||
guard context.albumId > 0, let photoStore else { return }
|
||
let records = photoStore.load(albumID: context.albumId)
|
||
photos = records.map { $0.toPhotoItem() }
|
||
}
|
||
|
||
private func applyPipelineTasks(_ tasks: [CameraTransferTask]) {
|
||
guard let photoStore, let userIDProvider else { return }
|
||
let userID = userIDProvider()
|
||
|
||
var merged = photos.filter { photo in
|
||
!tasks.contains { $0.assetID == photo.id || $0.filename == photo.fileName }
|
||
}
|
||
|
||
for task in tasks {
|
||
guard belongsToCurrentAlbum(task.assetID) else { continue }
|
||
|
||
let status: WiredTransferUploadStatus
|
||
switch task.status {
|
||
case .pending, .downloading:
|
||
status = .transferring
|
||
case .downloaded:
|
||
status = .pending
|
||
case .uploading:
|
||
status = .uploading
|
||
case .uploaded:
|
||
status = .uploaded
|
||
case .failed:
|
||
status = .failed
|
||
}
|
||
|
||
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 item = WiredTransferPhotoItem(
|
||
id: task.assetID,
|
||
fileName: task.filename,
|
||
thumbnailURL: thumbnailURL,
|
||
capturedAt: capturedAt,
|
||
fileSizeText: WiredTransferPhotoRecord.formatFileSize(fileSizeBytes),
|
||
status: status,
|
||
progress: task.progress,
|
||
errorMessage: task.errorMessage
|
||
)
|
||
merged.insert(item, at: 0)
|
||
sessionNewPhotoIDs.insert(task.assetID)
|
||
|
||
guard !userID.isEmpty else { continue }
|
||
let record = item.toRecord(
|
||
albumId: context.albumId,
|
||
userId: userID,
|
||
localPath: localPath,
|
||
fileSizeBytes: fileSizeBytes,
|
||
remoteURL: task.remoteURL ?? ""
|
||
)
|
||
var records = photoStore.load(albumID: context.albumId)
|
||
records.removeAll { $0.id == record.id }
|
||
records.insert(record, at: 0)
|
||
photoStore.save(albumID: context.albumId, records: records)
|
||
}
|
||
|
||
photos = merged.sorted { $0.capturedAt > $1.capturedAt }
|
||
}
|
||
|
||
/// 判断照片是否归属当前相册;未绑定的会话新拍默认归属当前页。
|
||
private func belongsToCurrentAlbum(_ photoID: String) -> Bool {
|
||
guard let bound = photoStore?.albumID(forPhoto: photoID) else {
|
||
return true
|
||
}
|
||
return bound == context.albumId
|
||
}
|
||
|
||
private static let captureTimestampFormatter: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||
return formatter
|
||
}()
|
||
}
|