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>
This commit is contained in:
2026-06-30 09:41:05 +08:00
parent 5692134efc
commit d2fe5d71e4
48 changed files with 6477 additions and 11 deletions

View File

@ -0,0 +1,437 @@
//
// 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
}()
}