// // AssetsModels.swift // suixinkan // // Created by Codex on 2026/6/23. // import Foundation /// 云盘文件实体,表示相册云盘中的文件或文件夹。 struct CloudDriveFile: Decodable, Identifiable, Hashable { let id: Int let parentFolderId: Int let fileUrl: String let coverUrl: String let updatedAt: String let name: String let createdAt: String let childNum: Int let type: Int let fileSize: Int64 /// 判断当前云盘项是否为文件夹。 var isFolder: Bool { type == 99 } /// 判断当前云盘项是否为视频文件。 var isVideo: Bool { type == 1 || Self.hasVideoExtension(name) || Self.hasVideoExtension(fileUrl) } /// 判断当前云盘项是否为图片文件。 var isImage: Bool { type == 2 || Self.hasImageExtension(name) || Self.hasImageExtension(fileUrl) } /// 返回可用于预览的远程地址。 var previewURLString: String { coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl } /// 根据文件扩展名判断是否是常见视频格式。 private static func hasVideoExtension(_ value: String) -> Bool { ["mp4", "mov", "m4v", "avi"].contains(pathExtension(for: value)) } /// 根据文件扩展名判断是否是常见图片格式。 private static func hasImageExtension(_ value: String) -> Bool { ["png", "jpg", "jpeg", "heic", "heif", "webp"].contains(pathExtension(for: value)) } /// 提取 URL 或本地路径中的扩展名。 private static func pathExtension(for value: String) -> String { URL(string: value)?.pathExtension.lowercased() ?? URL(fileURLWithPath: value).pathExtension.lowercased() } /// 云盘文件字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case id case parentFolderId = "parent_folder_id" case fileUrl = "file_url" case coverUrl = "cover_url" case updatedAt = "updated_at" case name case createdAt = "created_at" case childNum = "child_num" case type case fileSize = "file_size" } /// 构造本地路径根节点,供云盘面包屑维护当前目录。 init(id: Int, name: String) { self.id = id parentFolderId = 0 fileUrl = "" coverUrl = "" updatedAt = "" self.name = name createdAt = "" childNum = 0 type = 99 fileSize = 0 } /// 构造完整云盘文件实体,主要用于测试和本地状态拼装。 init( id: Int, parentFolderId: Int, fileUrl: String, coverUrl: String = "", updatedAt: String = "", name: String, createdAt: String = "", childNum: Int = 0, type: Int, fileSize: Int64 = 0 ) { self.id = id self.parentFolderId = parentFolderId self.fileUrl = fileUrl self.coverUrl = coverUrl self.updatedAt = updatedAt self.name = name self.createdAt = createdAt self.childNum = childNum self.type = type self.fileSize = fileSize } /// 宽松解码云盘文件字段。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeLossyInt(forKey: .id) ?? 0 parentFolderId = try container.decodeLossyInt(forKey: .parentFolderId) ?? 0 fileUrl = try container.decodeLossyString(forKey: .fileUrl) coverUrl = try container.decodeLossyString(forKey: .coverUrl) updatedAt = try container.decodeLossyString(forKey: .updatedAt) name = try container.decodeLossyString(forKey: .name) createdAt = try container.decodeLossyString(forKey: .createdAt) childNum = try container.decodeLossyInt(forKey: .childNum) ?? 0 type = try container.decodeLossyInt(forKey: .type) ?? 0 fileSize = try container.decodeLossyInt64(forKey: .fileSize) ?? 0 } } /// 云盘文件类型筛选实体,表示云盘页面的筛选范围。 enum CloudDriveFilter: Int, CaseIterable, Identifiable { case all = 0 case video = 1 case image = 2 case folder = 99 var id: Int { rawValue } /// 返回筛选项展示文案。 var title: String { switch self { case .all: "全部" case .video: "视频" case .image: "图片" case .folder: "文件夹" } } } /// 云盘排序实体,表示列表排序规则。 enum CloudDriveSort: Int, CaseIterable, Identifiable { case updatedDesc = 2 case createdDesc = 1 var id: Int { rawValue } /// 返回排序项展示文案。 var title: String { switch self { case .updatedDesc: "最近更新" case .createdDesc: "创建时间" } } } /// 云盘新建文件夹请求实体。 struct CloudFolderCreateRequest: Encodable, Equatable { let parentFolderId: Int let name: String /// 请求字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case parentFolderId = "parent_folder_id" case name } } /// 云盘文件上传登记请求实体。 struct CloudFileUploadRequest: Encodable, Equatable { let parentFolderId: Int let fileUrl: String let fileName: String /// 请求字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case parentFolderId = "parent_folder_id" case fileUrl = "file_url" case fileName = "file_name" } } /// 云盘批量操作项实体,表示移动或删除时传给后端的文件/文件夹。 struct CloudFileActionItem: Encodable, Equatable { let id: Int let type: Int } /// 云盘删除请求实体。 struct CloudFileDeleteRequest: Encodable, Equatable { let list: [CloudFileActionItem] } /// 云盘移动请求实体。 struct CloudFileMoveRequest: Encodable, Equatable { let targetFolderId: Int let list: [CloudFileActionItem] /// 请求字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case targetFolderId = "target_folder_id" case list } } /// 云盘文件夹重命名请求实体。 struct CloudFolderModifyRequest: Encodable, Equatable { let id: Int let name: String } /// 云盘文件重命名请求实体。 struct CloudFileModifyRequest: Encodable, Equatable { let id: Int let fileName: String /// 请求字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case id case fileName = "file_name" } } /// 云盘上传权限响应实体,表示当前账号是否允许继续上传。 struct CheckCloudUploadResponse: Decodable, Equatable { let canUpload: Bool let reason: String /// 响应字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case canUpload = "can_upload" case reason } /// 宽松解码上传权限字段。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) canUpload = try container.decodeLossyBool(forKey: .canUpload) ?? true reason = try container.decodeLossyString(forKey: .reason) } } /// 云盘本地待上传文件实体,表示 PhotosPicker 读取后的内存数据。 struct CloudLocalUploadFile: Identifiable, Equatable { let id = UUID() let data: Data let fileName: String let fileType: Int } /// 云盘传输方向实体,区分上传和下载记录。 enum CloudTransferDirection: String, Codable, Equatable { case upload case download } /// 云盘传输状态实体,表示一次上传或下载的阶段。 enum CloudTransferStatus: String, Codable, Equatable { case running case success case failed } /// 云盘传输记录实体,仅保存在本次 App 会话内。 struct CloudTransferItem: Identifiable, Equatable { let id: UUID let fileName: String let direction: CloudTransferDirection var progress: Int var status: CloudTransferStatus var message: String let createdAt: Date /// 创建一条新的传输记录。 init( id: UUID = UUID(), fileName: String, direction: CloudTransferDirection, progress: Int = 0, status: CloudTransferStatus = .running, message: String = "", createdAt: Date = Date() ) { self.id = id self.fileName = fileName self.direction = direction self.progress = progress self.status = status self.message = message self.createdAt = createdAt } } /// 素材库类型实体,当前只接管素材管理,样片管理后续单独迁移。 enum MediaLibraryKind: Int, CaseIterable, Identifiable { case material = 1 var id: Int { rawValue } /// 返回页面展示标题。 var title: String { switch self { case .material: "素材管理" } } } /// 素材审核状态筛选实体。 enum MediaLibraryAuditFilter: Int, CaseIterable, Identifiable { case all = -1 case pending = 0 case approved = 1 case rejected = 2 var id: Int { rawValue } /// 返回筛选项展示文案。 var title: String { switch self { case .all: "全部" case .pending: "待审核" case .approved: "已通过" case .rejected: "未通过" } } } /// 素材上下架状态实体。 enum MediaListingStatus: Int, Codable, Equatable { case offline = 0 case online = 1 /// 返回切换后的目标状态。 var toggled: MediaListingStatus { self == .online ? .offline : .online } /// 返回展示文案。 var title: String { self == .online ? "已上架" : "未上架" } } /// 素材库列表响应实体,包含分页列表和订单统计信息。 struct MediaLibraryListResponse: Decodable, Equatable { let total: Int let list: [MediaLibraryItem] let order: MediaLibraryOrderInfo? /// 响应字段映射。 enum CodingKeys: String, CodingKey { case total case list case order } /// 宽松解码列表响应字段。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) total = try container.decodeLossyInt(forKey: .total) ?? 0 list = (try? container.decodeIfPresent([MediaLibraryItem].self, forKey: .list)) ?? [] order = try? container.decodeIfPresent(MediaLibraryOrderInfo.self, forKey: .order) } } /// 素材订单统计实体,表示素材库顶部统计卡片。 struct MediaLibraryOrderInfo: Decodable, Equatable { let totalNum: Int let avgOrderAmount: Double let refundTotal: Double let avgChange: Double /// 响应字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case totalNum = "total_num" case avgOrderAmount = "avg_order_amount" case refundTotal = "refund_total" case avgChange = "avg_change" } /// 宽松解码统计字段。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) totalNum = try container.decodeLossyInt(forKey: .totalNum) ?? 0 avgOrderAmount = try container.decodeLossyDouble(forKey: .avgOrderAmount) ?? 0 refundTotal = try container.decodeLossyDouble(forKey: .refundTotal) ?? 0 avgChange = try container.decodeLossyDouble(forKey: .avgChange) ?? 0 } } /// 素材库列表项实体,表示素材列表中的单个素材。 struct MediaLibraryItem: Decodable, Identifiable, Hashable { let id: Int let name: String let coverUrl: String let createdAt: String let status: Int let downloadCount: Int let likesCount: Int let collectCount: Int let shareCount: Int let scenicSpotName: String let projectName: String let listingStatus: Int /// 判断素材是否通过审核。 var isApproved: Bool { status == 1 } /// 当前素材上下架状态。 var listing: MediaListingStatus { MediaListingStatus(rawValue: listingStatus) ?? .offline } /// 字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case id case name case coverUrl = "cover_url" case createdAt = "created_at" case status case downloadCount = "download_count" case likesCount = "likes_count" case collectCount = "collect_count" case shareCount = "share_count" case scenicSpotName = "scenic_spot_name" case projectName = "project_name" case listingStatus = "listing_status" } /// 宽松解码素材列表项。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeLossyInt(forKey: .id) ?? 0 name = try container.decodeLossyString(forKey: .name) coverUrl = try container.decodeLossyString(forKey: .coverUrl) createdAt = try container.decodeLossyString(forKey: .createdAt) status = try container.decodeLossyInt(forKey: .status) ?? 0 downloadCount = try container.decodeLossyInt(forKey: .downloadCount) ?? 0 likesCount = try container.decodeLossyInt(forKey: .likesCount) ?? 0 collectCount = try container.decodeLossyInt(forKey: .collectCount) ?? 0 shareCount = try container.decodeLossyInt(forKey: .shareCount) ?? 0 scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) projectName = try container.decodeLossyString(forKey: .projectName) listingStatus = try container.decodeLossyInt(forKey: .listingStatus) ?? 0 } } /// 素材详情实体,表示素材详情页展示的数据。 struct MediaLibraryDetail: Decodable, Equatable { let id: Int let name: String let coverUrl: String let description: String let status: Int let listingStatus: Int let likesCount: Int let downloadCount: Int let collectCount: Int let shareCount: Int let uploaderName: String let createdAt: String let mediaList: [MediaLibraryMediaItem] let scenicName: String let projectName: String /// 判断素材是否通过审核。 var isApproved: Bool { status == 1 } /// 当前素材上下架状态。 var listing: MediaListingStatus { MediaListingStatus(rawValue: listingStatus) ?? .offline } /// 字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case id case name case coverUrl = "cover_url" case description case status case listingStatus = "listing_status" case likesCount = "likes_count" case downloadCount = "download_count" case collectCount = "collect_count" case shareCount = "share_count" case uploaderName = "uploader_name" case createdAt = "created_at" case mediaList = "media_list" case scenicName = "scenic_name" case projectName = "project_name" } /// 宽松解码素材详情字段。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeLossyInt(forKey: .id) ?? 0 name = try container.decodeLossyString(forKey: .name) coverUrl = try container.decodeLossyString(forKey: .coverUrl) description = try container.decodeLossyString(forKey: .description) status = try container.decodeLossyInt(forKey: .status) ?? 0 listingStatus = try container.decodeLossyInt(forKey: .listingStatus) ?? 0 likesCount = try container.decodeLossyInt(forKey: .likesCount) ?? 0 downloadCount = try container.decodeLossyInt(forKey: .downloadCount) ?? 0 collectCount = try container.decodeLossyInt(forKey: .collectCount) ?? 0 shareCount = try container.decodeLossyInt(forKey: .shareCount) ?? 0 uploaderName = try container.decodeLossyString(forKey: .uploaderName) createdAt = try container.decodeLossyString(forKey: .createdAt) mediaList = (try? container.decodeIfPresent([MediaLibraryMediaItem].self, forKey: .mediaList)) ?? [] scenicName = try container.decodeLossyString(forKey: .scenicName) projectName = try container.decodeLossyString(forKey: .projectName) } } /// 素材媒体文件实体,表示详情页中的图片或视频资源。 struct MediaLibraryMediaItem: Decodable, Identifiable, Hashable { let id: Int let originalName: String let ossUrl: String let thumbnailUrl: String let type: Int let size: Int64 let showUrl: String /// 当前媒体是否为视频。 var isVideo: Bool { type == 1 || ["mp4", "mov", "m4v", "avi"].contains(URL(string: ossUrl)?.pathExtension.lowercased() ?? "") } /// 字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case id case originalName = "original_name" case ossUrl = "oss_url" case thumbnailUrl = "thumbnail_url" case type case size case showUrl = "show_url" } /// 宽松解码媒体文件字段。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeLossyInt(forKey: .id) ?? 0 originalName = try container.decodeLossyString(forKey: .originalName) ossUrl = try container.decodeLossyString(forKey: .ossUrl) thumbnailUrl = try container.decodeLossyString(forKey: .thumbnailUrl) type = try container.decodeLossyInt(forKey: .type) ?? 0 size = try container.decodeLossyInt64(forKey: .size) ?? 0 showUrl = try container.decodeLossyString(forKey: .showUrl) } } /// 素材上传文件尺寸实体。 struct MediaAlbumFileSize: Codable, Equatable { let width: Int let height: Int } /// 素材上传文件请求实体,表示上传到 OSS 后提交给服务端的媒体文件。 struct MediaAlbumUploadItem: Codable, Equatable { let originalName: String let ossUrl: String let size: Int64 let fileWidthSize: MediaAlbumFileSize /// 请求字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case originalName = "original_name" case ossUrl = "oss_url" case size case fileWidthSize = "file_width_size" } } /// 素材上传请求实体。 struct MediaAlbumUploadRequest: Encodable, Equatable { let name: String let type: Int let mediaType: Int let mediaList: [MediaAlbumUploadItem] let coverUrl: String let coverSize: MediaAlbumFileSize let scenicSpotId: Int let description: String let materialTag: String let projectId: Int /// 请求字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case name case type case mediaType = "media_type" case mediaList = "media_list" case coverUrl = "cover_url" case coverSize = "cover_size" case scenicSpotId = "scenic_spot_id" case description case materialTag = "material_tag" case projectId = "project_id" } } /// 素材编辑请求实体。 struct MediaAlbumEditRequest: Encodable, Equatable { let id: Int let name: String let type: Int let mediaType: Int let mediaList: [MediaAlbumUploadItem] let coverUrl: String let coverSize: MediaAlbumFileSize let scenicSpotId: Int let description: String let materialTag: String /// 请求字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case id case name case type case mediaType = "media_type" case mediaList = "media_list" case coverUrl = "cover_url" case coverSize = "cover_size" case scenicSpotId = "scenic_spot_id" case description case materialTag = "material_tag" } } /// 素材操作请求实体,用于上下架。 struct MediaAlbumOperationRequest: Encodable, Equatable { let id: Int let listingStatus: Int /// 请求字段映射,兼容后端下划线命名。 enum CodingKeys: String, CodingKey { case id case listingStatus = "listing_status" } } /// 素材删除请求实体。 struct MediaAlbumDeleteRequest: Encodable, Equatable { let id: Int } /// 素材标签新增请求实体。 struct MediaAlbumAddTagRequest: Encodable, Equatable { let name: String } /// 素材本地待上传文件实体,表示封面或媒体文件的内存数据。 struct MediaLocalUploadFile: Identifiable, Equatable { let id = UUID() let data: Data let fileName: String let fileType: Int let width: Int let height: Int } private extension KeyedDecodingContainer { /// 将 String、数字和 Bool 宽松解码为字符串。 func decodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "1" : "0" } return "" } /// 将 String、Double 和 Int 宽松解码为 Int。 func decodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } if let value = try? decodeIfPresent(String.self, forKey: key) { return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) } return nil } /// 将 String、Double 和 Int 宽松解码为 Int64。 func decodeLossyInt64(forKey key: Key) throws -> Int64? { if let value = try? decodeIfPresent(Int64.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return Int64(value) } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int64(value) } if let value = try? decodeIfPresent(String.self, forKey: key) { return Int64(value.trimmingCharacters(in: .whitespacesAndNewlines)) } return nil } /// 将 String、Int 和 Double 宽松解码为 Double。 func decodeLossyDouble(forKey key: Key) throws -> Double? { if let value = try? decodeIfPresent(Double.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return Double(value) } if let value = try? decodeIfPresent(String.self, forKey: key) { return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) } return nil } /// 将 String、Int 和 Bool 宽松解码为 Bool。 func decodeLossyBool(forKey key: Key) throws -> Bool? { if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return value != 0 } if let value = try? decodeIfPresent(String.self, forKey: key) { let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if ["1", "true", "yes"].contains(text) { return true } if ["0", "false", "no"].contains(text) { return false } } return nil } }