// // 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 WiredTransferTimeSlot: Identifiable, Equatable { let id: String let dateLabel: String let slotStartLabel: String let photoCount: Int } /// 有线传图时间侧栏日期分组。 struct WiredTransferDateGroup: Equatable { let dateLabel: String let slots: [WiredTransferTimeSlot] } /// 有线传图列表分组(30 分钟时间段)。 struct WiredTransferPhotoSection: Identifiable, Equatable { var id: String { slotId } let slotId: String let headerTitle: String let photos: [WiredTransferPhotoItem] } /// 有线传图列表项。 struct WiredTransferPhotoItem: Identifiable, Equatable { let id: String let sourceId: String let fileName: String let thumbnailURL: String let capturedAt: String let fileSizeText: String let resolutionText: String let status: WiredTransferUploadStatus let progress: Int let errorMessage: String? init( id: String, sourceId: String? = nil, fileName: String, thumbnailURL: String, capturedAt: String, fileSizeText: String, resolutionText: 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.resolutionText = resolutionText self.status = status self.progress = progress self.errorMessage = errorMessage } var canSelectForSpecifyUpload: Bool { status == .pending } var isNotUploaded: Bool { status == .pending || status == .failed } /// 解析拍摄时间。 func capturedDate() -> Date? { Self.capturedAtFormatter.date(from: capturedAt) } /// 是否为今日拍摄。 func isCapturedToday(calendar: Calendar = .current) -> Bool { guard let date = capturedDate() else { return false } return calendar.isDateInToday(date) } private static let capturedAtFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" return formatter }() } /// 有线传图持久化记录。 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 previewCandidates = [thumbnailPath, localPath, remoteURL] let thumbnailURL = previewCandidates.compactMap { path -> String? in guard !path.isEmpty else { return nil } if path.hasPrefix("http") { return path } let preview = CameraDownloadStorage.previewURLString(from: path) return preview.isEmpty ? nil : preview }.first ?? "" 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)) } /// 构建 30 分钟时间段分组列表。 func buildPhotoSections() -> [WiredTransferPhotoSection] { let grouped = Dictionary(grouping: compactMap { photo -> (Date, WiredTransferPhotoItem)? in guard let date = photo.capturedDate() else { return nil } return (WiredTransferTimeGrouping.halfHourSlotStart(for: date), photo) }) { $0.0 } return grouped.keys .sorted(by: >) .map { slotStart in let photos = (grouped[slotStart] ?? []) .map(\.1) .sorted { ($0.capturedDate() ?? .distantPast) > ($1.capturedDate() ?? .distantPast) } return WiredTransferPhotoSection( slotId: WiredTransferTimeGrouping.slotID(for: slotStart), headerTitle: WiredTransferTimeGrouping.sectionHeaderTitle(for: slotStart), photos: photos ) } } /// 构建时间侧栏分组。 func buildSidebarGroups() -> [WiredTransferDateGroup] { var slotCounts: [Date: Int] = [:] for photo in self { guard let date = photo.capturedDate() else { continue } let slotStart = WiredTransferTimeGrouping.halfHourSlotStart(for: date) slotCounts[slotStart, default: 0] += 1 } let calendar = Calendar.current let groupedByDay = Dictionary(grouping: slotCounts.keys) { calendar.startOfDay(for: $0) } return groupedByDay.keys .sorted(by: >) .map { day in let slots = (groupedByDay[day] ?? []) .sorted(by: >) .compactMap { slotStart -> WiredTransferTimeSlot? in guard let count = slotCounts[slotStart] else { return nil } return WiredTransferTimeSlot( id: WiredTransferTimeGrouping.slotID(for: slotStart), dateLabel: WiredTransferTimeGrouping.dateLabel(for: slotStart), slotStartLabel: WiredTransferTimeGrouping.timeLabel(for: slotStart), photoCount: count ) } return WiredTransferDateGroup( dateLabel: WiredTransferTimeGrouping.dateLabel(for: day), slots: slots ) } } } /// 有线传图 30 分钟时间段分组工具。 enum WiredTransferTimeGrouping { /// 计算 30 分钟时间段起点。 static func halfHourSlotStart(for date: Date, calendar: Calendar = .current) -> Date { var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) let minute = (components.minute ?? 0) / 30 * 30 components.minute = minute components.second = 0 components.nanosecond = 0 return calendar.date(from: components) ?? date } /// 时间段唯一标识。 static func slotID(for slotStart: Date) -> String { slotIDFormatter.string(from: slotStart) } /// 分组标题,例如「05月20日 12:00 - 12:30」。 static func sectionHeaderTitle(for slotStart: Date) -> String { "\(dateLabel(for: slotStart)) \(timeLabel(for: slotStart)) - \(timeLabel(for: slotStart.addingTimeInterval(30 * 60)))" } /// 日期标签,例如「05月20日」。 static func dateLabel(for date: Date) -> String { dateLabelFormatter.string(from: date) } /// 时间标签,例如「12:05」。 static func timeLabel(for date: Date) -> String { timeLabelFormatter.string(from: date) } private static let slotIDFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd'T'HH:mm" return formatter }() private static let dateLabelFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "MM月dd日" return formatter }() private static let timeLabelFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "HH:mm" return formatter }() } 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 } }