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>
454 lines
13 KiB
Swift
454 lines
13 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|