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:
@ -0,0 +1,63 @@
|
||||
//
|
||||
// TravelAlbumMaterialUploader.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 旅拍相册素材上传器,实现 OSS 上传 + 后端 upload-material 登记。
|
||||
@MainActor
|
||||
final class TravelAlbumMaterialUploader: CameraAssetUploadSink {
|
||||
private let api: any TravelAlbumServing
|
||||
private let ossService: any OSSUploadServing
|
||||
private let albumID: Int
|
||||
private let scenicID: Int
|
||||
|
||||
/// 初始化上传器,注入 API、OSS 与业务上下文。
|
||||
init(api: any TravelAlbumServing, ossService: any OSSUploadServing, albumID: Int, scenicID: Int) {
|
||||
self.api = api
|
||||
self.ossService = ossService
|
||||
self.albumID = albumID
|
||||
self.scenicID = scenicID
|
||||
}
|
||||
|
||||
/// 上传本地文件到 OSS 并登记到旅拍相册。
|
||||
func upload(
|
||||
localURL: URL,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
progress: @escaping @Sendable (Int) -> Void
|
||||
) async throws -> String {
|
||||
guard albumID > 0 else {
|
||||
throw APIError.serverCode(0, "请先选择有效的相册")
|
||||
}
|
||||
guard FileManager.default.fileExists(atPath: localURL.path) else {
|
||||
throw APIError.serverCode(0, "相机文件读取失败")
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: localURL)
|
||||
guard !data.isEmpty else {
|
||||
throw APIError.serverCode(0, "相机文件读取失败")
|
||||
}
|
||||
|
||||
let remoteURL = try await ossService.uploadAlbumFile(
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
fileType: fileType,
|
||||
scenicId: scenicID,
|
||||
onProgress: progress
|
||||
)
|
||||
|
||||
_ = try await api.uploadMaterial(
|
||||
TravelAlbumUploadMaterialRequest(
|
||||
userEquityTravelId: albumID,
|
||||
fileName: fileName,
|
||||
fileURL: remoteURL
|
||||
)
|
||||
)
|
||||
|
||||
return remoteURL
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
//
|
||||
// TravelAlbumSession.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 旅拍相册跨页会话上下文,保存当前操作的相册任务。
|
||||
enum TravelAlbumSession {
|
||||
static var currentAlbum: TravelAlbumItem?
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
//
|
||||
// WiredTransferPhotoStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 有线传图本地记录仓库,按相册与用户维度持久化到 UserDefaults。
|
||||
final class WiredTransferPhotoStore {
|
||||
private let defaults: UserDefaults
|
||||
private let accountPrefixProvider: () -> String
|
||||
private let userIDProvider: () -> String
|
||||
|
||||
private static let keyPrefix = "wired_transfer_photos_"
|
||||
private static let deletedSuffix = "_deleted"
|
||||
private static let bindingsSuffix = "_photo_bindings"
|
||||
|
||||
/// 初始化仓库,注入账号前缀与用户 ID 提供者。
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
accountPrefixProvider: @escaping () -> String,
|
||||
userIDProvider: @escaping () -> String
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.accountPrefixProvider = accountPrefixProvider
|
||||
self.userIDProvider = userIDProvider
|
||||
}
|
||||
|
||||
/// 查询照片绑定的相册 ID。
|
||||
func albumID(forPhoto photoID: String) -> Int? {
|
||||
guard !photoID.isEmpty else { return nil }
|
||||
return loadBindings().first { $0.photoID == photoID }?.albumID
|
||||
}
|
||||
|
||||
/// 绑定照片到相册。
|
||||
func bindPhotoToAlbum(photoID: String, albumID: Int) {
|
||||
guard !photoID.isEmpty, albumID > 0, !userIDProvider().isEmpty else { return }
|
||||
var bindings = loadBindings().filter { $0.photoID != photoID }
|
||||
bindings.append(PhotoAlbumBinding(photoID: photoID, albumID: albumID))
|
||||
saveBindings(bindings)
|
||||
}
|
||||
|
||||
/// 加载已删除照片 ID。
|
||||
func loadDeletedIDs(albumID: Int) -> Set<String> {
|
||||
guard albumID > 0, !userIDProvider().isEmpty else { return [] }
|
||||
let ids = defaults.stringArray(forKey: deletedKey(albumID)) ?? []
|
||||
return Set(ids)
|
||||
}
|
||||
|
||||
/// 加载相册下的传图记录。
|
||||
func load(albumID: Int) -> [WiredTransferPhotoRecord] {
|
||||
guard albumID > 0 else { return [] }
|
||||
let userID = userIDProvider()
|
||||
guard !userID.isEmpty else { return [] }
|
||||
|
||||
let deletedIDs = loadDeletedIDs(albumID: albumID)
|
||||
guard let data = defaults.data(forKey: storageKey(albumID)),
|
||||
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||||
else { return [] }
|
||||
|
||||
return records
|
||||
.filter { record in
|
||||
!deletedIDs.contains(record.id)
|
||||
&& record.userId == userID
|
||||
&& record.albumId == albumID
|
||||
&& record.isDisplayable
|
||||
}
|
||||
.map { $0.normalizeInterruptedTransfer() }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
.also { items in
|
||||
items.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存相册传图记录。
|
||||
func save(albumID: Int, records: [WiredTransferPhotoRecord]) {
|
||||
guard albumID > 0 else { return }
|
||||
let userID = userIDProvider()
|
||||
guard !userID.isEmpty else { return }
|
||||
|
||||
let normalized = records
|
||||
.filter { $0.userId == userID && $0.albumId == albumID }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
normalized.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
|
||||
|
||||
if let data = try? JSONEncoder().encode(normalized) {
|
||||
defaults.set(data, forKey: storageKey(albumID))
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记并移除单张照片。
|
||||
func remove(albumID: Int, photoID: String) {
|
||||
guard albumID > 0, !photoID.isEmpty else { return }
|
||||
markDeleted(albumID: albumID, photoID: photoID)
|
||||
let deletedIDs = loadDeletedIDs(albumID: albumID)
|
||||
guard let data = defaults.data(forKey: storageKey(albumID)),
|
||||
var records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||||
else { return }
|
||||
records = records.filter { record in
|
||||
!deletedIDs.contains(record.id)
|
||||
&& record.userId == userIDProvider()
|
||||
&& record.albumId == albumID
|
||||
}
|
||||
save(albumID: albumID, records: records)
|
||||
}
|
||||
|
||||
/// 标记照片已删除。
|
||||
func markDeleted(albumID: Int, photoID: String) {
|
||||
guard albumID > 0, !photoID.isEmpty, !userIDProvider().isEmpty else { return }
|
||||
let updated = loadDeletedIDs(albumID: albumID).union([photoID])
|
||||
defaults.set(Array(updated), forKey: deletedKey(albumID))
|
||||
}
|
||||
|
||||
/// 清空相册关联的本地缓存。
|
||||
func clearAlbum(albumID: Int) -> Set<String> {
|
||||
guard albumID > 0 else { return [] }
|
||||
let userID = userIDProvider()
|
||||
guard !userID.isEmpty else { return [] }
|
||||
|
||||
guard let data = defaults.data(forKey: storageKey(albumID)),
|
||||
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||||
else { return [] }
|
||||
|
||||
let userRecords = records.filter { $0.userId == userID }
|
||||
userRecords.forEach { deleteRecordFiles($0) }
|
||||
|
||||
var bindings = loadBindings().filter { $0.albumID != albumID }
|
||||
saveBindings(bindings)
|
||||
|
||||
defaults.removeObject(forKey: storageKey(albumID))
|
||||
defaults.removeObject(forKey: deletedKey(albumID))
|
||||
|
||||
return Set(userRecords.map(\.id))
|
||||
}
|
||||
|
||||
private func deleteRecordFiles(_ record: WiredTransferPhotoRecord) {
|
||||
deleteFileIfExists(record.localPath)
|
||||
deleteFileIfExists(record.thumbnailPath)
|
||||
}
|
||||
|
||||
private func deleteFileIfExists(_ path: String) {
|
||||
guard !path.isEmpty else { return }
|
||||
let url = URL(fileURLWithPath: path)
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func storageKey(_ albumID: Int) -> String {
|
||||
"\(accountPrefixProvider())\(Self.keyPrefix)\(albumID)"
|
||||
}
|
||||
|
||||
private func deletedKey(_ albumID: Int) -> String {
|
||||
"\(storageKey(albumID))\(Self.deletedSuffix)"
|
||||
}
|
||||
|
||||
private func bindingsKey() -> String {
|
||||
"\(accountPrefixProvider())\(Self.bindingsSuffix)"
|
||||
}
|
||||
|
||||
private struct PhotoAlbumBinding: Codable {
|
||||
let photoID: String
|
||||
let albumID: Int
|
||||
}
|
||||
|
||||
private func loadBindings() -> [PhotoAlbumBinding] {
|
||||
guard let data = defaults.data(forKey: bindingsKey()),
|
||||
let bindings = try? JSONDecoder().decode([PhotoAlbumBinding].self, from: data)
|
||||
else { return [] }
|
||||
return bindings
|
||||
}
|
||||
|
||||
private func saveBindings(_ bindings: [PhotoAlbumBinding]) {
|
||||
if let data = try? JSONEncoder().encode(bindings) {
|
||||
defaults.set(data, forKey: bindingsKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension WiredTransferPhotoRecord {
|
||||
var isDisplayable: Bool {
|
||||
!localPath.isEmpty || !remoteURL.isEmpty
|
||||
}
|
||||
|
||||
func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord {
|
||||
guard status == WiredTransferUploadStatus.transferring.rawValue
|
||||
|| status == WiredTransferUploadStatus.uploading.rawValue
|
||||
else { return self }
|
||||
|
||||
var copy = self
|
||||
copy = WiredTransferPhotoRecord(
|
||||
id: id,
|
||||
sourceId: sourceId,
|
||||
fileName: fileName,
|
||||
localPath: localPath,
|
||||
thumbnailPath: thumbnailPath,
|
||||
capturedAt: capturedAt,
|
||||
fileSizeBytes: fileSizeBytes,
|
||||
status: remoteURL.isEmpty ? WiredTransferUploadStatus.pending.rawValue : WiredTransferUploadStatus.uploaded.rawValue,
|
||||
progress: remoteURL.isEmpty ? 0 : 100,
|
||||
errorMessage: errorMessage,
|
||||
albumId: albumId,
|
||||
userId: userId,
|
||||
remoteURL: remoteURL,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
@discardableResult
|
||||
func also(_ body: (Self) -> Void) -> Self {
|
||||
body(self)
|
||||
return self
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user