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:
453
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal file
453
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal file
@ -0,0 +1,453 @@
|
||||
//
|
||||
// TravelAlbumModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 旅拍相册用户摘要。
|
||||
struct TravelAlbumUser: Decodable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let phone: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, phone
|
||||
}
|
||||
|
||||
init(id: Int = 0, phone: String = "") {
|
||||
self.id = id
|
||||
self.phone = phone
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = container.lossyInt(forKey: .id)
|
||||
phone = container.lossyString(forKey: .phone)
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册任务实体。
|
||||
struct TravelAlbumItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let storeUserId: Int
|
||||
let name: String
|
||||
/// 1 先拍后买,2 先买后拍
|
||||
let type: Int
|
||||
let orderNumber: String
|
||||
let materialNum: Int
|
||||
let materialPrice: Int
|
||||
let materialPackagePrice: Int
|
||||
let photoPrice: Int
|
||||
let coverURL: String
|
||||
let userId: Int
|
||||
let status: Int
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let user: TravelAlbumUser?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case storeUserId = "store_user_id"
|
||||
case name, type
|
||||
case orderNumber = "order_number"
|
||||
case materialNum = "material_num"
|
||||
case materialPrice = "material_price"
|
||||
case materialPackagePrice = "material_package_price"
|
||||
case photoPrice = "photo_price"
|
||||
case coverURL = "cover_url"
|
||||
case userId = "user_id"
|
||||
case status
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case user
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = container.lossyInt(forKey: .id)
|
||||
storeUserId = container.lossyInt(forKey: .storeUserId)
|
||||
name = container.lossyString(forKey: .name)
|
||||
type = container.lossyInt(forKey: .type)
|
||||
orderNumber = container.lossyString(forKey: .orderNumber)
|
||||
materialNum = container.lossyInt(forKey: .materialNum)
|
||||
materialPrice = container.lossyInt(forKey: .materialPrice)
|
||||
materialPackagePrice = container.lossyInt(forKey: .materialPackagePrice)
|
||||
photoPrice = container.lossyInt(forKey: .photoPrice)
|
||||
coverURL = container.lossyString(forKey: .coverURL)
|
||||
userId = container.lossyInt(forKey: .userId)
|
||||
status = container.lossyInt(forKey: .status)
|
||||
createdAt = container.lossyString(forKey: .createdAt)
|
||||
updatedAt = container.lossyString(forKey: .updatedAt)
|
||||
user = try? container.decodeIfPresent(TravelAlbumUser.self, forKey: .user)
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
user?.phone ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// 可绑定订单实体(买了再拍)。
|
||||
struct TravelAlbumAvailableOrder: Decodable, Identifiable, Equatable, Hashable {
|
||||
let projectName: String
|
||||
let orderNumber: String
|
||||
let userPhone: String
|
||||
let userId: Int
|
||||
|
||||
var id: String { orderNumber }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case orderNumber = "order_number"
|
||||
case userPhone = "user_phone"
|
||||
case userId = "user_id"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
projectName = container.lossyString(forKey: .projectName)
|
||||
orderNumber = container.lossyString(forKey: .orderNumber)
|
||||
userPhone = container.lossyString(forKey: .userPhone)
|
||||
userId = container.lossyInt(forKey: .userId)
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册素材实体。
|
||||
struct TravelAlbumMaterial: Decodable, Identifiable, Equatable {
|
||||
let id: Int
|
||||
let userEquityTravelId: Int
|
||||
let status: Int
|
||||
let orderNumber: String
|
||||
let userId: Int
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let fileURL: String
|
||||
let fileSize: Int
|
||||
let coverURL: String
|
||||
let isPurchased: Bool
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
case status
|
||||
case orderNumber = "order_number"
|
||||
case userId = "user_id"
|
||||
case fileName = "file_name"
|
||||
case fileType = "file_type"
|
||||
case fileURL = "file_url"
|
||||
case fileSize = "file_size"
|
||||
case coverURL = "cover_url"
|
||||
case isPurchased = "is_purchased"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = container.lossyInt(forKey: .id)
|
||||
userEquityTravelId = container.lossyInt(forKey: .userEquityTravelId)
|
||||
status = container.lossyInt(forKey: .status)
|
||||
orderNumber = container.lossyString(forKey: .orderNumber)
|
||||
userId = container.lossyInt(forKey: .userId)
|
||||
fileName = container.lossyString(forKey: .fileName)
|
||||
fileType = container.lossyInt(forKey: .fileType)
|
||||
fileURL = container.lossyString(forKey: .fileURL)
|
||||
fileSize = container.lossyInt(forKey: .fileSize)
|
||||
coverURL = container.lossyString(forKey: .coverURL)
|
||||
isPurchased = container.lossyBool(forKey: .isPurchased)
|
||||
createdAt = container.lossyString(forKey: .createdAt)
|
||||
updatedAt = container.lossyString(forKey: .updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建旅拍相册请求。
|
||||
struct TravelAlbumCreateRequest: Encodable {
|
||||
let name: String
|
||||
let type: Int
|
||||
let orderNumber: String?
|
||||
let materialNum: Int?
|
||||
let materialPrice: Double?
|
||||
let materialPackagePrice: Double?
|
||||
let photoPrice: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, type
|
||||
case orderNumber = "order_number"
|
||||
case materialNum = "material_num"
|
||||
case materialPrice = "material_price"
|
||||
case materialPackagePrice = "material_package_price"
|
||||
case photoPrice = "photo_price"
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建旅拍相册响应。
|
||||
struct TravelAlbumCreateResponse: Decodable {
|
||||
let id: Int
|
||||
|
||||
init(id: Int) {
|
||||
self.id = id
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = container.lossyInt(forKey: .id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传素材登记请求。
|
||||
struct TravelAlbumUploadMaterialRequest: Encodable {
|
||||
let userEquityTravelId: Int
|
||||
let fileName: String
|
||||
let fileURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
case fileName = "file_name"
|
||||
case fileURL = "file_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除旅拍相册请求。
|
||||
struct TravelAlbumDeleteRequest: Encodable {
|
||||
let id: Int
|
||||
}
|
||||
|
||||
/// 编辑旅拍相册请求。
|
||||
struct TravelAlbumEditRequest: Encodable {
|
||||
let id: Int
|
||||
let name: String?
|
||||
let materialPackagePrice: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name
|
||||
case materialPackagePrice = "material_package_price"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除素材请求。
|
||||
struct TravelAlbumDeleteMaterialRequest: Encodable {
|
||||
let id: Int
|
||||
}
|
||||
|
||||
/// 小程序码响应。
|
||||
struct TravelAlbumMpCodeResponse: Decodable {
|
||||
let mpCodeOssURL: String
|
||||
|
||||
init(mpCodeOssURL: String) {
|
||||
self.mpCodeOssURL = mpCodeOssURL
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case mpCodeOssURL = "mp_code_oss_url"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
mpCodeOssURL = container.lossyString(forKey: .mpCodeOssURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// 有线传图页导航上下文。
|
||||
struct WiredTransferContext: Hashable {
|
||||
let albumId: Int
|
||||
let albumName: String
|
||||
let phone: String
|
||||
let orderNumber: String
|
||||
}
|
||||
|
||||
/// 有线传图上传状态。
|
||||
enum WiredTransferUploadStatus: String, Codable, Equatable {
|
||||
case uploaded = "UPLOADED"
|
||||
case pending = "PENDING"
|
||||
case transferring = "TRANSFERRING"
|
||||
case uploading = "UPLOADING"
|
||||
case failed = "FAILED"
|
||||
|
||||
var displayLabel: String {
|
||||
switch self {
|
||||
case .uploaded: "已上传"
|
||||
case .pending: "待上传"
|
||||
case .transferring: "传输中"
|
||||
case .uploading: "上传中"
|
||||
case .failed: "上传失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 有线传图列表项。
|
||||
struct WiredTransferPhotoItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
let sourceId: String
|
||||
let fileName: String
|
||||
let thumbnailURL: String
|
||||
let capturedAt: String
|
||||
let fileSizeText: String
|
||||
let status: WiredTransferUploadStatus
|
||||
let progress: Int
|
||||
let errorMessage: String?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
sourceId: String? = nil,
|
||||
fileName: String,
|
||||
thumbnailURL: String,
|
||||
capturedAt: String,
|
||||
fileSizeText: String,
|
||||
status: WiredTransferUploadStatus,
|
||||
progress: Int = 0,
|
||||
errorMessage: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.sourceId = sourceId ?? id
|
||||
self.fileName = fileName
|
||||
self.thumbnailURL = thumbnailURL
|
||||
self.capturedAt = capturedAt
|
||||
self.fileSizeText = fileSizeText
|
||||
self.status = status
|
||||
self.progress = progress
|
||||
self.errorMessage = errorMessage
|
||||
}
|
||||
|
||||
var canSelectForSpecifyUpload: Bool {
|
||||
status == .pending
|
||||
}
|
||||
|
||||
var isNotUploaded: Bool {
|
||||
status == .pending || status == .failed
|
||||
}
|
||||
}
|
||||
|
||||
/// 有线传图持久化记录。
|
||||
struct WiredTransferPhotoRecord: Codable, Equatable {
|
||||
let id: String
|
||||
let sourceId: String
|
||||
let fileName: String
|
||||
let localPath: String
|
||||
let thumbnailPath: String
|
||||
let capturedAt: String
|
||||
let fileSizeBytes: Int64
|
||||
let status: String
|
||||
let progress: Int
|
||||
let errorMessage: String?
|
||||
let albumId: Int
|
||||
let userId: String
|
||||
let remoteURL: String
|
||||
let updatedAt: TimeInterval
|
||||
|
||||
func toPhotoItem() -> WiredTransferPhotoItem {
|
||||
let previewPath = [thumbnailPath, localPath, remoteURL]
|
||||
.first { path in
|
||||
!path.isEmpty && (path.hasPrefix("http") || FileManager.default.fileExists(atPath: path))
|
||||
} ?? ""
|
||||
let thumbnailURL: String
|
||||
if previewPath.hasPrefix("http") {
|
||||
thumbnailURL = previewPath
|
||||
} else if !previewPath.isEmpty {
|
||||
thumbnailURL = "file://\(previewPath)"
|
||||
} else {
|
||||
thumbnailURL = ""
|
||||
}
|
||||
return WiredTransferPhotoItem(
|
||||
id: id,
|
||||
sourceId: sourceId,
|
||||
fileName: fileName,
|
||||
thumbnailURL: thumbnailURL,
|
||||
capturedAt: capturedAt,
|
||||
fileSizeText: Self.formatFileSize(fileSizeBytes),
|
||||
status: WiredTransferUploadStatus(rawValue: status) ?? .pending,
|
||||
progress: progress,
|
||||
errorMessage: errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
static func formatFileSize(_ bytes: Int64) -> String {
|
||||
guard bytes > 0 else { return "--" }
|
||||
let mb = Double(bytes) / 1024.0 / 1024.0
|
||||
if mb >= 1024 {
|
||||
return String(format: "%.2f GB", mb / 1024.0)
|
||||
}
|
||||
return String(format: "%.2f MB", mb)
|
||||
}
|
||||
}
|
||||
|
||||
extension WiredTransferPhotoItem {
|
||||
/// 转为持久化记录。
|
||||
func toRecord(
|
||||
albumId: Int,
|
||||
userId: String,
|
||||
localPath: String,
|
||||
thumbnailPath: String = "",
|
||||
fileSizeBytes: Int64,
|
||||
remoteURL: String = ""
|
||||
) -> WiredTransferPhotoRecord {
|
||||
WiredTransferPhotoRecord(
|
||||
id: id,
|
||||
sourceId: sourceId,
|
||||
fileName: fileName,
|
||||
localPath: localPath,
|
||||
thumbnailPath: thumbnailPath,
|
||||
capturedAt: capturedAt,
|
||||
fileSizeBytes: fileSizeBytes,
|
||||
status: status.rawValue,
|
||||
progress: progress,
|
||||
errorMessage: errorMessage,
|
||||
albumId: albumId,
|
||||
userId: userId,
|
||||
remoteURL: remoteURL,
|
||||
updatedAt: Date().timeIntervalSince1970
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == WiredTransferPhotoItem {
|
||||
/// 按 Tab 过滤:0 全部,1 已上传,2 失败。
|
||||
func filtered(byTabIndex index: Int) -> [WiredTransferPhotoItem] {
|
||||
switch index {
|
||||
case 1: filter { $0.status == .uploaded }
|
||||
case 2: filter { $0.status == .failed }
|
||||
default: self
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回各 Tab 计数。
|
||||
var transferTabCounts: [Int] {
|
||||
[count, filter { $0.status == .uploaded }.count, filter { $0.status == .failed }.count]
|
||||
}
|
||||
|
||||
/// 可指定上传的 photo id 集合。
|
||||
var selectablePendingPhotoIDs: Set<String> {
|
||||
Set(filter { $0.canSelectForSpecifyUpload }.map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func lossyString(forKey key: Key) -> String {
|
||||
if let value = try? decode(String.self, forKey: key) { return value }
|
||||
if let value = try? decode(Int.self, forKey: key) { return "\(value)" }
|
||||
if let value = try? decode(Double.self, forKey: key) { return "\(value)" }
|
||||
return ""
|
||||
}
|
||||
|
||||
func lossyInt(forKey key: Key) -> Int {
|
||||
if let value = try? decode(Int.self, forKey: key) { return value }
|
||||
if let value = try? decode(String.self, forKey: key), let intValue = Int(value) { return intValue }
|
||||
if let value = try? decode(Double.self, forKey: key) { return Int(value) }
|
||||
return 0
|
||||
}
|
||||
|
||||
func lossyBool(forKey key: Key) -> Bool {
|
||||
if let value = try? decode(Bool.self, forKey: key) { return value }
|
||||
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
|
||||
if let value = try? decode(String.self, forKey: key) {
|
||||
return value == "1" || value.lowercased() == "true"
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user