Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
T

582 lines
20 KiB
Swift

//
// 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 {
/// 生成购买状态角标;购买状态与修图状态互不覆盖。
var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
isPurchased
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
: nil
}
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
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<Item: Decodable & Sendable & Equatable>: 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]
}
}
/// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
switch self {
case .initial:
return category == .atmosphere
case .reretouch(_, _, .all):
return category == .refined || category == .atmosphere
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
}
}