Files
suixinkan_ios_new/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift
汉秋 d2fe5d71e4 Add TravelAlbum, ProfileSpace, and wired camera transfer modules.
Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 09:41:05 +08:00

438 lines
15 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
/// 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()
guard !userID.isEmpty else { return }
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)
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 {
if let bound = photoStore?.albumID(forPhoto: photoID) {
return bound == context.albumId
}
return sessionNewPhotoIDs.contains(photoID)
}
private static let captureTimestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
}