// // 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 { 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 } }