// // TravelAlbumModels.swift // suixinkan // import Foundation /// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。 struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable { let id: Int let phone: String init(id: Int = 0, phone: String = "") { self.id = id self.phone = phone } } /// 旅拍相册实体,对齐 Android `TravelAlbumEntity`。 struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable { let id: Int let storeUserId: Int let name: String 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 case 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( id: Int = 0, storeUserId: Int = 0, name: String = "", type: Int = 0, orderNumber: String = "", materialNum: Int = 0, materialPrice: Int = 0, materialPackagePrice: Int = 0, photoPrice: Int = 0, coverUrl: String = "", userId: Int = 0, status: Int = 0, createdAt: String = "", updatedAt: String = "", user: TravelAlbumUser? = nil ) { self.id = id self.storeUserId = storeUserId self.name = name self.type = type self.orderNumber = orderNumber self.materialNum = materialNum self.materialPrice = materialPrice self.materialPackagePrice = materialPackagePrice self.photoPrice = photoPrice self.coverUrl = coverUrl self.userId = userId self.status = status self.createdAt = createdAt self.updatedAt = updatedAt self.user = user } /// 展示用手机号。 var displayPhone: String { user?.phone ?? "" } } /// 旅拍相册可绑定订单,对齐 Android `TravelAlbumAvailableOrderEntity`。 struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable { let projectName: String let orderNumber: String let userPhone: String let userId: Int enum CodingKeys: String, CodingKey { case projectName = "project_name" case orderNumber = "order_number" case userPhone = "user_phone" case userId = "user_id" } init(projectName: String = "", orderNumber: String = "", userPhone: String = "", userId: Int = 0) { self.projectName = projectName self.orderNumber = orderNumber self.userPhone = userPhone self.userId = userId } } /// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。 struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable { 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 aiRetouchStatus: Int let aiRetouchStatusName: String let aiRetouchBatchId: Int let aiRefinedURL: String let aiAtmosphereURL: String 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 aiRetouchStatus = "ai_retouch_status" case aiRetouchStatusName = "ai_retouch_status_name" case aiRetouchBatchId = "ai_retouch_batch_id" case aiRefinedURL = "ai_refined_url" case aiAtmosphereURL = "ai_atmosphere_url" case createdAt = "created_at" case updatedAt = "updated_at" } /// 从素材接口解码;AI 修图扩展字段缺失、为空或类型异常时使用安全默认值。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(Int.self, forKey: .id) userEquityTravelId = try container.decode(Int.self, forKey: .userEquityTravelId) status = try container.decode(Int.self, forKey: .status) orderNumber = try container.decode(String.self, forKey: .orderNumber) userId = try container.decode(Int.self, forKey: .userId) fileName = try container.decode(String.self, forKey: .fileName) fileType = try container.decode(Int.self, forKey: .fileType) fileUrl = try container.decode(String.self, forKey: .fileUrl) fileSize = try container.decode(Int.self, forKey: .fileSize) coverUrl = try container.decode(String.self, forKey: .coverUrl) isPurchased = try container.decode(Bool.self, forKey: .isPurchased) aiRetouchStatus = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchStatus)) ?? 0 aiRetouchStatusName = ( try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName) ) ?? "" aiRetouchBatchId = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchBatchId)) ?? 0 aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? "" aiAtmosphereURL = ( try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL) ) ?? "" createdAt = try container.decode(String.self, forKey: .createdAt) updatedAt = try container.decode(String.self, forKey: .updatedAt) } init( id: Int = 0, userEquityTravelId: Int = 0, status: Int = 0, orderNumber: String = "", userId: Int = 0, fileName: String = "", fileType: Int = 0, fileUrl: String = "", fileSize: Int = 0, coverUrl: String = "", isPurchased: Bool = false, aiRetouchStatus: Int = 0, aiRetouchStatusName: String = "", aiRetouchBatchId: Int = 0, aiRefinedURL: String = "", aiAtmosphereURL: String = "", createdAt: String = "", updatedAt: String = "" ) { self.id = id self.userEquityTravelId = userEquityTravelId self.status = status self.orderNumber = orderNumber self.userId = userId self.fileName = fileName self.fileType = fileType self.fileUrl = fileUrl self.fileSize = fileSize self.coverUrl = coverUrl self.isPurchased = isPurchased self.aiRetouchStatus = aiRetouchStatus self.aiRetouchStatusName = aiRetouchStatusName self.aiRetouchBatchId = aiRetouchBatchId self.aiRefinedURL = aiRefinedURL self.aiAtmosphereURL = aiAtmosphereURL self.createdAt = createdAt self.updatedAt = updatedAt } } /// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。 enum TravelAlbumMaterialBadgeKind: Sendable, Equatable { case purchased case pending case processing case retouched case cover case failed } /// 相册素材网格角标展示内容。 struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable { let kind: TravelAlbumMaterialBadgeKind let text: String } extension TravelAlbumMaterial { /// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。 var badgePresentation: TravelAlbumMaterialBadgePresentation? { if aiRetouchStatus == 0 { return isPurchased ? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") : nil } let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines) switch aiRetouchStatus { case 1: return TravelAlbumMaterialBadgePresentation( kind: .pending, text: statusName.isEmpty ? "待处理" : statusName ) case 2: return TravelAlbumMaterialBadgePresentation( kind: .processing, text: statusName.isEmpty ? "修图中" : statusName ) case 3: return TravelAlbumMaterialBadgePresentation( kind: statusName == "AI封面" ? .cover : .retouched, text: statusName.isEmpty ? "AI已修" : statusName ) case 4: return TravelAlbumMaterialBadgePresentation( kind: .failed, text: statusName.isEmpty ? "失败" : statusName ) default: return nil } } } /// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。 struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable { 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 case type case orderNumber = "order_number" case materialNum = "material_num" case materialPrice = "material_price" case materialPackagePrice = "material_package_price" case photoPrice = "photo_price" } } /// 旅拍相册素材上传登记请求体,对齐 Android `TravelAlbumUploadMaterialRequest`。 struct TravelAlbumUploadMaterialRequest: Encodable, Sendable, Equatable { let userEquityTravelId: Int let fileName: String let fileUrl: String let clientPhotoId: String enum CodingKeys: String, CodingKey { case userEquityTravelId = "user_equity_travel_id" case fileName = "file_name" case fileUrl = "file_url" case clientPhotoId = "client_photo_id" } } /// 旅拍相册服务端已登记的客户端照片 ID 集合。 struct TravelAlbumMaterialClientPhotoIDsResponse: Decodable, Sendable, Equatable { let clientPhotoIds: [String] enum CodingKeys: String, CodingKey { case clientPhotoIds = "client_photo_ids" } } /// 旅拍相册通用分页响应。 struct TravelAlbumListResponse: Decodable, Sendable, Equatable { let total: Int let list: [Item] init(total: Int = 0, list: [Item] = []) { self.total = total self.list = list } } /// 旅拍相册创建响应。 struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable { let id: Int } /// 旅拍相册小程序码响应。 struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable { let mpCodeOssUrl: String enum CodingKeys: String, CodingKey { case mpCodeOssUrl = "mp_code_oss_url" } } /// AI 修图模板类别,决定页面分组、选择规则和提交字段。 enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable { case refined case atmosphere case cover /// 模板分组展示标题。 var title: String { switch self { case .refined: "原图精修" case .atmosphere: "氛围感修图" case .cover: "封面风格" } } } /// AI 修图模板页工作流,明确区分首次批量修图与单张结果覆盖重修。 enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable { /// 对一个相册内的原始素材发起首次 AI 修图。 case initial(albumId: Int, materialIds: [Int]) /// 对单张素材的既有 AI 结果发起覆盖重修。 case reretouch(materialId: Int, batchId: Int, type: TravelAlbumAIReretouchType) /// 当前页面需要展示的模板分组。 var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] { switch self { case .initial(_, let materialIds): var categories: [TravelAlbumAIRetouchTemplateCategory] = [.refined, .atmosphere] if materialIds.count >= 4 { categories.append(.cover) } return categories case .reretouch(_, _, .refined): return [.refined] case .reretouch(_, _, .atmosphere): return [.atmosphere] case .reretouch(_, _, .all): return [.refined, .atmosphere] } } /// 当前分组是否允许不选择;首次修图和原图 Tab 覆盖重修的氛围感模板选填。 func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { guard category == .atmosphere else { return false } switch self { case .initial: return true case .reretouch(_, _, .all): return true case .reretouch: return false } } /// 工作流目标是否满足接口的最小参数要求。 var isValid: Bool { switch self { case .initial(let albumId, let materialIds): return albumId > 0 && !materialIds.isEmpty case .reretouch(let materialId, let batchId, _): return materialId > 0 && batchId > 0 } } } /// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。 struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable { let id: Int let name: String let previewURL: String let beforeURL: String let afterURL: String enum CodingKeys: String, CodingKey { case id case name case previewURL = "preview_url" case beforeURL = "before_url" case afterURL = "after_url" } /// 创建 AI 修图模板。 init( id: Int, name: String, previewURL: String, beforeURL: String = "", afterURL: String = "" ) { self.id = id self.name = name self.previewURL = previewURL self.beforeURL = beforeURL self.afterURL = afterURL } /// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(Int.self, forKey: .id) name = try container.decode(String.self, forKey: .name) previewURL = try container.decode(String.self, forKey: .previewURL) beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? "" afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? "" } } /// AI 修图模板接口响应,按原图、氛围感和封面风格分组。 struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable { let refinedTemplates: [TravelAlbumAIRetouchTemplate] let atmosphereTemplates: [TravelAlbumAIRetouchTemplate] let coverTemplates: [TravelAlbumAIRetouchTemplate] let remainingQuota: Int enum CodingKeys: String, CodingKey { case refinedTemplates = "refined_templates" case atmosphereTemplates = "atmosphere_templates" case coverTemplates = "cover_templates" case remainingQuota = "remaining_quota" } /// 创建分组模板响应,默认各组为空。 init( refinedTemplates: [TravelAlbumAIRetouchTemplate] = [], atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [], coverTemplates: [TravelAlbumAIRetouchTemplate] = [], remainingQuota: Int = 0 ) { self.refinedTemplates = refinedTemplates self.atmosphereTemplates = atmosphereTemplates self.coverTemplates = coverTemplates self.remainingQuota = max(0, remainingQuota) } /// 解码模板和剩余额度;旧响应缺少额度时按零处理,避免误提交付费任务。 init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) refinedTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .refinedTemplates) atmosphereTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .atmosphereTemplates) coverTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .coverTemplates) remainingQuota = max(0, try container.decodeIfPresent(Int.self, forKey: .remainingQuota) ?? 0) } } /// 提交 AI 修图任务的请求参数。 struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable { let userEquityTravelId: Int let materialIds: [Int] let refinedTemplateId: Int let atmosphereTemplateId: Int? let coverTemplateId: Int? enum CodingKeys: String, CodingKey { case userEquityTravelId = "user_equity_travel_id" case materialIds = "material_ids" case refinedTemplateId = "refined_template_id" case atmosphereTemplateId = "atmosphere_template_id" case coverTemplateId = "cover_template_id" } } /// 重新修图类型,决定后端覆盖的 AI 结果及必需模板字段。 enum TravelAlbumAIReretouchType: Int, Encodable, Sendable, Equatable { case refined = 1 case atmosphere = 2 case all = 3 } /// 提交单张素材重新修图的最小请求参数。 struct TravelAlbumAIReretouchRequest: Encodable, Sendable, Equatable { let id: Int let aiRetouchBatchId: Int let type: TravelAlbumAIReretouchType let refinedTemplateId: Int? let atmosphereTemplateId: Int? enum CodingKeys: String, CodingKey { case id case aiRetouchBatchId = "ai_retouch_batch_id" case type case refinedTemplateId = "refined_template_id" case atmosphereTemplateId = "atmosphere_template_id" } } /// 旅拍相册展示格式化工具。 enum TravelAlbumDisplayFormatter { /// 脱敏手机号。 static func maskPhone(_ phone: String) -> String { guard phone.count >= 7 else { return phone.isEmpty ? "--" : phone } return "\(phone.prefix(3))****\(phone.suffix(4))" } /// 生成文件大小文本。 static func fileSizeText(_ bytes: Int) -> String { let value = Double(max(bytes, 0)) if value >= 1024 * 1024 * 1024 { return String(format: "%.1fGB", value / 1024 / 1024 / 1024) } if value >= 1024 * 1024 { return String(format: "%.1fMB", value / 1024 / 1024) } if value >= 1024 { return String(format: "%.1fKB", value / 1024) } return "\(Int(value))B" } /// 创建时间短展示。 static func shortTaskTime(_ text: String) -> String { if text.count >= 16 { return String(text.prefix(16)) } return text } /// 将服务端创建时间格式化为摘要卡使用的 `yyyy/MM/dd HH:mm`。 static func creationTimeText(_ text: String) -> String { guard !text.isEmpty else { return "--" } let normalized = text.replacingOccurrences(of: "T", with: " ") guard normalized.count >= 16 else { return normalized } return String(normalized.prefix(16)).replacingOccurrences(of: "-", with: "/") } /// 计算相册摘要卡封面,按相册、素材缩略图、素材原图顺序回退。 static func albumCoverURL(album: TravelAlbum?, materials: [TravelAlbumMaterial]) -> String { if let coverURL = album?.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines), !coverURL.isEmpty { return coverURL } guard let first = materials.first else { return "" } let materialCover = first.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines) return materialCover.isEmpty ? first.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) : materialCover } }