为 album_list 与 album_trailer 接入首页路由,在 Profile 中新增 DEBUG 首页菜单预览,并扩展 Assets 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
966 lines
30 KiB
Swift
966 lines
30 KiB
Swift
//
|
||
// 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
|
||
}
|
||
|
||
/// 相册文件夹实体,表示相册管理列表中的一个相册。
|
||
struct AlbumFolderItem: Decodable, Identifiable, Hashable {
|
||
let id: Int
|
||
let name: String
|
||
let coverFileId: Int
|
||
let countVideo: Int
|
||
let countImage: Int
|
||
let coverFile: AlbumFileItem?
|
||
let createTime: String
|
||
let remark: String
|
||
|
||
/// 相册封面地址,优先使用封面文件的预览地址。
|
||
var coverURLString: String {
|
||
coverFile?.previewURLString ?? ""
|
||
}
|
||
|
||
/// 相册内文件总数。
|
||
var totalCount: Int {
|
||
countVideo + countImage
|
||
}
|
||
|
||
/// 字段映射,兼容旧接口下划线命名。
|
||
enum CodingKeys: String, CodingKey {
|
||
case id
|
||
case name
|
||
case coverFileId = "cover_file_id"
|
||
case countVideo = "count_video"
|
||
case countImage = "count_image"
|
||
case coverFile = "cover"
|
||
case createTime = "created_at"
|
||
case remark
|
||
}
|
||
|
||
/// 宽松解码相册文件夹字段。
|
||
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)
|
||
coverFileId = try container.decodeLossyInt(forKey: .coverFileId) ?? 0
|
||
countVideo = try container.decodeLossyInt(forKey: .countVideo) ?? 0
|
||
countImage = try container.decodeLossyInt(forKey: .countImage) ?? 0
|
||
coverFile = try? container.decodeIfPresent(AlbumFileItem.self, forKey: .coverFile)
|
||
createTime = try container.decodeLossyString(forKey: .createTime)
|
||
remark = try container.decodeLossyString(forKey: .remark)
|
||
}
|
||
}
|
||
|
||
/// 相册文件实体,表示相册详情中的图片或视频。
|
||
struct AlbumFileItem: Decodable, Identifiable, Hashable {
|
||
let id: Int
|
||
let fileName: String
|
||
let fileType: Int
|
||
let fileUrl: String
|
||
let coverUrl: String
|
||
let fileSize: Int64
|
||
let fileSizeHuman: String
|
||
let remark: String
|
||
|
||
/// 判断当前文件是否为视频。
|
||
var isVideo: Bool {
|
||
fileType == 1 || Self.hasVideoExtension(fileName) || Self.hasVideoExtension(fileUrl)
|
||
}
|
||
|
||
/// 判断当前文件是否为图片。
|
||
var isImage: Bool {
|
||
fileType == 2 || Self.hasImageExtension(fileName) || Self.hasImageExtension(fileUrl)
|
||
}
|
||
|
||
/// 返回图片或视频封面的预览地址。
|
||
var previewURLString: String {
|
||
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
|
||
}
|
||
|
||
/// 返回文件大小展示文案。
|
||
var displayFileSize: String {
|
||
if !fileSizeHuman.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
return fileSizeHuman
|
||
}
|
||
return fileSize.assetsFormattedFileSize
|
||
}
|
||
|
||
/// 根据文件扩展名判断是否为视频。
|
||
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 fileName = "file_name"
|
||
case fileType = "file_type"
|
||
case fileUrl = "file_url"
|
||
case coverUrl = "cover_url"
|
||
case fileSize = "file_size"
|
||
case fileSizeHuman = "file_size_human"
|
||
case remark
|
||
}
|
||
|
||
/// 宽松解码相册文件字段。
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||
fileName = try container.decodeLossyString(forKey: .fileName)
|
||
fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0
|
||
fileUrl = try container.decodeLossyString(forKey: .fileUrl)
|
||
coverUrl = try container.decodeLossyString(forKey: .coverUrl)
|
||
fileSize = try container.decodeLossyInt64(forKey: .fileSize) ?? 0
|
||
fileSizeHuman = try container.decodeLossyString(forKey: .fileSizeHuman)
|
||
remark = try container.decodeLossyString(forKey: .remark)
|
||
}
|
||
}
|
||
|
||
/// 相册文件类型筛选实体,表示相册详情中的图片或视频 Tab。
|
||
enum AlbumFileTab: Int, CaseIterable, Identifiable {
|
||
case image = 2
|
||
case video = 1
|
||
|
||
var id: Int { rawValue }
|
||
|
||
/// 返回页面展示标题。
|
||
var title: String {
|
||
switch self {
|
||
case .image:
|
||
"图片"
|
||
case .video:
|
||
"视频"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 新建相册请求实体。
|
||
struct AlbumFolderAddRequest: Encodable, Equatable {
|
||
let scenicId: String
|
||
let name: String
|
||
let remark: String
|
||
let cloudFolderType: Int
|
||
|
||
/// 请求字段映射,兼容旧接口下划线命名。
|
||
enum CodingKeys: String, CodingKey {
|
||
case scenicId = "scenic_id"
|
||
case name
|
||
case remark
|
||
case cloudFolderType = "cloud_folder_type"
|
||
}
|
||
}
|
||
|
||
/// 编辑相册请求实体,用于改名、备注和封面。
|
||
struct AlbumFolderEditRequest: Encodable, Equatable {
|
||
let folderId: Int
|
||
let coverFileId: Int?
|
||
let name: String?
|
||
let remark: String?
|
||
|
||
/// 请求字段映射,兼容旧接口下划线命名。
|
||
enum CodingKeys: String, CodingKey {
|
||
case folderId = "id"
|
||
case coverFileId = "cover_file_id"
|
||
case name
|
||
case remark
|
||
}
|
||
}
|
||
|
||
/// 删除相册文件请求实体。
|
||
struct AlbumFileDeleteRequest: Encodable, Equatable {
|
||
let idList: [Int]
|
||
let folderId: Int
|
||
|
||
/// 请求字段映射,兼容旧接口下划线命名。
|
||
enum CodingKeys: String, CodingKey {
|
||
case idList = "id_list"
|
||
case folderId = "folder_id"
|
||
}
|
||
}
|
||
|
||
/// 相册本地待上传文件实体,表示 PhotosPicker 读取后的图片或视频。
|
||
struct AlbumLocalUploadFile: Identifiable, Equatable {
|
||
let id = UUID()
|
||
let data: Data
|
||
let fileName: String
|
||
let fileType: Int
|
||
}
|
||
|
||
private extension Int64 {
|
||
/// 返回资产模块通用文件大小展示文案。
|
||
var assetsFormattedFileSize: String {
|
||
let value = Double(self)
|
||
if value >= 1_073_741_824 {
|
||
return String(format: "%.1fGB", value / 1_073_741_824)
|
||
}
|
||
if value >= 1_048_576 {
|
||
return String(format: "%.1fMB", value / 1_048_576)
|
||
}
|
||
if value >= 1024 {
|
||
return String(format: "%.1fKB", value / 1024)
|
||
}
|
||
return "\(self)B"
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|