新增资产模块,包含云存储与媒体库流程

为云端与素材管理页面接入首页路由,将共享云存储模型迁入 Assets 模块,并补充 API/ViewModel 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 16:10:20 +08:00
parent ffb16eca29
commit cac22fcc56
15 changed files with 3431 additions and 75 deletions

View File

@ -0,0 +1,756 @@
//
// 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 ""
}
/// StringDouble 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
}
/// StringDouble 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
}
/// StringInt 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
}
/// StringInt 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
}
}