新增资产模块,包含云存储与媒体库流程
为云端与素材管理页面接入首页路由,将共享云存储模型迁入 Assets 模块,并补充 API/ViewModel 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -29,6 +29,7 @@ struct RootView: View {
|
|||||||
@State private var walletAPI: WalletAPI
|
@State private var walletAPI: WalletAPI
|
||||||
@State private var scenicPermissionAPI: ScenicPermissionAPI
|
@State private var scenicPermissionAPI: ScenicPermissionAPI
|
||||||
@State private var taskAPI: TaskAPI
|
@State private var taskAPI: TaskAPI
|
||||||
|
@State private var assetsAPI: AssetsAPI
|
||||||
@State private var authSessionCoordinator: AuthSessionCoordinator
|
@State private var authSessionCoordinator: AuthSessionCoordinator
|
||||||
@State private var sessionBootstrapper: SessionBootstrapper
|
@State private var sessionBootstrapper: SessionBootstrapper
|
||||||
|
|
||||||
@ -51,6 +52,7 @@ struct RootView: View {
|
|||||||
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
||||||
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient))
|
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient))
|
||||||
_taskAPI = State(initialValue: TaskAPI(client: apiClient))
|
_taskAPI = State(initialValue: TaskAPI(client: apiClient))
|
||||||
|
_assetsAPI = State(initialValue: AssetsAPI(client: apiClient))
|
||||||
_authSessionCoordinator = State(
|
_authSessionCoordinator = State(
|
||||||
initialValue: AuthSessionCoordinator(
|
initialValue: AuthSessionCoordinator(
|
||||||
tokenStore: tokenStore,
|
tokenStore: tokenStore,
|
||||||
@ -90,6 +92,7 @@ struct RootView: View {
|
|||||||
.environment(walletAPI)
|
.environment(walletAPI)
|
||||||
.environment(scenicPermissionAPI)
|
.environment(scenicPermissionAPI)
|
||||||
.environment(taskAPI)
|
.environment(taskAPI)
|
||||||
|
.environment(assetsAPI)
|
||||||
.environment(authSessionCoordinator)
|
.environment(authSessionCoordinator)
|
||||||
.task {
|
.task {
|
||||||
apiClient.bindAuthTokenProvider { appSession.token }
|
apiClient.bindAuthTokenProvider { appSession.token }
|
||||||
|
|||||||
212
suixinkan/Features/Assets/API/AssetsAPI.swift
Normal file
212
suixinkan/Features/Assets/API/AssetsAPI.swift
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
//
|
||||||
|
// AssetsAPI.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// 资产服务协议,抽象云盘和素材管理接口以便 ViewModel 测试替换。
|
||||||
|
@MainActor
|
||||||
|
protocol AssetsServing {
|
||||||
|
/// 获取云盘文件列表。
|
||||||
|
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload<CloudDriveFile>
|
||||||
|
|
||||||
|
/// 创建云盘文件夹。
|
||||||
|
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws
|
||||||
|
|
||||||
|
/// 登记已上传到 OSS 的云盘文件。
|
||||||
|
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws
|
||||||
|
|
||||||
|
/// 删除云盘文件或文件夹。
|
||||||
|
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws
|
||||||
|
|
||||||
|
/// 移动云盘文件或文件夹。
|
||||||
|
func cloudFileMove(_ request: CloudFileMoveRequest) async throws
|
||||||
|
|
||||||
|
/// 重命名云盘文件夹。
|
||||||
|
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws
|
||||||
|
|
||||||
|
/// 重命名云盘文件。
|
||||||
|
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws
|
||||||
|
|
||||||
|
/// 检查当前账号是否允许上传云盘文件。
|
||||||
|
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse
|
||||||
|
|
||||||
|
/// 获取素材列表。
|
||||||
|
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse
|
||||||
|
|
||||||
|
/// 获取素材详情。
|
||||||
|
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail
|
||||||
|
|
||||||
|
/// 更新素材上下架状态。
|
||||||
|
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws
|
||||||
|
|
||||||
|
/// 删除素材。
|
||||||
|
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws
|
||||||
|
|
||||||
|
/// 新增素材标签。
|
||||||
|
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws
|
||||||
|
|
||||||
|
/// 上传素材。
|
||||||
|
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws
|
||||||
|
|
||||||
|
/// 编辑素材。
|
||||||
|
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 资产 API,负责封装相册云盘和素材管理相关接口。
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class AssetsAPI: AssetsServing {
|
||||||
|
@ObservationIgnored private let client: APIClient
|
||||||
|
|
||||||
|
/// 初始化资产 API,并注入共享网络客户端。
|
||||||
|
init(client: APIClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取云盘文件列表。
|
||||||
|
func cloudFileList(
|
||||||
|
parentFolderId: Int,
|
||||||
|
name: String = "",
|
||||||
|
type: Int = 0,
|
||||||
|
orderBy: Int = 2,
|
||||||
|
page: Int = 1,
|
||||||
|
pageSize: Int = 20
|
||||||
|
) async throws -> ListPayload<CloudDriveFile> {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/yf-handset-app/photog/cloud-driver/list",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "parent_folder_id", value: "\(parentFolderId)"),
|
||||||
|
URLQueryItem(name: "name", value: name),
|
||||||
|
URLQueryItem(name: "type", value: "\(type)"),
|
||||||
|
URLQueryItem(name: "order_by", value: "\(orderBy)"),
|
||||||
|
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||||
|
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建云盘文件夹。
|
||||||
|
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-create", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记已上传到 OSS 的云盘文件。
|
||||||
|
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-upload", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除云盘文件或文件夹。
|
||||||
|
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/delete", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 移动云盘文件或文件夹。
|
||||||
|
func cloudFileMove(_ request: CloudFileMoveRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/move", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重命名云盘文件夹。
|
||||||
|
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-edit", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重命名云盘文件。
|
||||||
|
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-edit", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查当前账号是否允许上传云盘文件。
|
||||||
|
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(method: .get, path: "/api/yf-handset-app/photog/cloud-driver/check-upload-permission")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取素材列表。
|
||||||
|
func mediaAlbumList(
|
||||||
|
kind: MediaLibraryKind = .material,
|
||||||
|
keyword: String = "",
|
||||||
|
status: Int? = nil,
|
||||||
|
page: Int = 1,
|
||||||
|
pageSize: Int = 10
|
||||||
|
) async throws -> MediaLibraryListResponse {
|
||||||
|
var query = [
|
||||||
|
URLQueryItem(name: "type", value: "\(kind.rawValue)"),
|
||||||
|
URLQueryItem(name: "keyword", value: keyword),
|
||||||
|
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||||
|
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||||
|
]
|
||||||
|
if let status, status >= 0 {
|
||||||
|
query.append(URLQueryItem(name: "status", value: "\(status)"))
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取素材详情。
|
||||||
|
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/app/media-album/detail",
|
||||||
|
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新素材上下架状态。
|
||||||
|
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/app/media-album/operation", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除素材。
|
||||||
|
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/app/media-album/delete", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增素材标签。
|
||||||
|
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/app/media-album/add-tag", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传素材。
|
||||||
|
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑素材。
|
||||||
|
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws {
|
||||||
|
_ = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/app/media-album/edit", body: request)
|
||||||
|
) as EmptyPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
56
suixinkan/Features/Assets/Assets.md
Normal file
56
suixinkan/Features/Assets/Assets.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Assets 模块业务逻辑
|
||||||
|
|
||||||
|
## 模块职责
|
||||||
|
|
||||||
|
Assets 模块负责首页中的相册云盘和素材管理入口。
|
||||||
|
|
||||||
|
- `cloud_management` 进入 `CloudStorageView`。
|
||||||
|
- `cloud_storage_transit` 进入 `CloudStorageTransitView`。
|
||||||
|
- `asset_management` 进入 `MediaLibraryView(kind: .material)`。
|
||||||
|
- `material_upload` 进入 `MediaLibraryUploadView`。
|
||||||
|
|
||||||
|
`album_list`、`album_trailer`、`sample_management`、`sample_upload` 本轮仍保持占位,后续按相册管理、相册预览上传、样片管理独立迁移。
|
||||||
|
|
||||||
|
## 云盘逻辑
|
||||||
|
|
||||||
|
`CloudStorageViewModel` 只保存云盘模块内状态,包括目录路径、文件列表、筛选、排序、分页和当前操作状态。
|
||||||
|
|
||||||
|
云盘上传流程:
|
||||||
|
|
||||||
|
1. 页面通过 `PhotosPicker` 选择图片或视频。
|
||||||
|
2. `CloudStorageViewModel` 调用 `checkCloudUploadPermission`。
|
||||||
|
3. 权限通过后调用 `OSSUploadService.uploadCloudFile` 上传到 OSS。
|
||||||
|
4. 上传成功后调用 `cloudFileUpload` 把文件 URL 写入云盘。
|
||||||
|
5. 刷新当前目录列表。
|
||||||
|
|
||||||
|
下载文件只保存到 App 沙盒 `Documents/CloudDownloads`,不自动写入系统相册。上传/下载记录由 `CloudTransferStore` 保存,生命周期仅限本次 App 会话,不落盘。
|
||||||
|
|
||||||
|
## 素材管理逻辑
|
||||||
|
|
||||||
|
`MediaLibraryViewModel` 管理素材列表、关键词、审核状态、分页、详情、删除和上下架。
|
||||||
|
|
||||||
|
素材上下架规则:
|
||||||
|
|
||||||
|
- 审核通过的素材可以上下架。
|
||||||
|
- 审核未通过或待审核素材禁止上下架。
|
||||||
|
- 操作成功后刷新素材列表。
|
||||||
|
|
||||||
|
`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验素材名称、当前景区、打卡点、封面、媒体文件和标签长度。
|
||||||
|
|
||||||
|
素材上传流程:
|
||||||
|
|
||||||
|
1. 页面通过 `PhotosPicker` 选择封面和媒体文件。
|
||||||
|
2. 打卡点来源于 `ScenicSpotContext.spots`。
|
||||||
|
3. 提交时先把封面和媒体文件上传 OSS。
|
||||||
|
4. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID 和标签提交给素材接口。
|
||||||
|
5. 上传失败时不提交素材接口。
|
||||||
|
|
||||||
|
## 缓存边界
|
||||||
|
|
||||||
|
云盘文件列表、素材列表、上传进度、下载记录、OSS STS、本地文件数据和素材表单都不进入 `AppSession`、`AccountContext` 或 TabBar 状态。
|
||||||
|
|
||||||
|
图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data`。
|
||||||
|
|
||||||
|
## 测试要求
|
||||||
|
|
||||||
|
新增云盘或素材逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。
|
||||||
756
suixinkan/Features/Assets/Models/AssetsModels.swift
Normal file
756
suixinkan/Features/Assets/Models/AssetsModels.swift
Normal 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 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 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
|
||||||
|
}
|
||||||
|
}
|
||||||
580
suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift
Normal file
580
suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift
Normal file
@ -0,0 +1,580 @@
|
|||||||
|
//
|
||||||
|
// AssetsViewModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class CloudTransferStore {
|
||||||
|
static let shared = CloudTransferStore()
|
||||||
|
|
||||||
|
private(set) var records: [CloudTransferItem] = []
|
||||||
|
|
||||||
|
/// 新增一条传输记录并返回记录 ID。
|
||||||
|
@discardableResult
|
||||||
|
func start(fileName: String, direction: CloudTransferDirection) -> UUID {
|
||||||
|
let id = UUID()
|
||||||
|
records.insert(CloudTransferItem(id: id, fileName: fileName, direction: direction), at: 0)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新指定传输记录的进度。
|
||||||
|
func update(id: UUID, progress: Int, message: String = "") {
|
||||||
|
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
records[index].progress = max(0, min(100, progress))
|
||||||
|
if !message.isEmpty {
|
||||||
|
records[index].message = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记指定传输记录成功。
|
||||||
|
func succeed(id: UUID, message: String = "已完成") {
|
||||||
|
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
records[index].progress = 100
|
||||||
|
records[index].status = .success
|
||||||
|
records[index].message = message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记指定传输记录失败。
|
||||||
|
func fail(id: UUID, message: String) {
|
||||||
|
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
records[index].status = .failed
|
||||||
|
records[index].message = message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空当前会话传输记录。
|
||||||
|
func clear() {
|
||||||
|
records = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘 ViewModel,负责文件列表、筛选、分页、文件操作和上传闭环。
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class CloudStorageViewModel {
|
||||||
|
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||||||
|
var files: [CloudDriveFile] = []
|
||||||
|
var total = 0
|
||||||
|
var page = 1
|
||||||
|
var searchText = ""
|
||||||
|
var selectedFilter: CloudDriveFilter = .all
|
||||||
|
var selectedSort: CloudDriveSort = .updatedDesc
|
||||||
|
var isLoading = false
|
||||||
|
var isLoadingMore = false
|
||||||
|
var isMutating = false
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
private let pageSize = 20
|
||||||
|
|
||||||
|
/// 当前目录 ID。
|
||||||
|
var currentFolderId: Int {
|
||||||
|
path.last?.id ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断当前目录是否还有下一页。
|
||||||
|
var hasMore: Bool {
|
||||||
|
files.count < total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新加载当前目录第一页。
|
||||||
|
func reload(api: any AssetsServing) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let payload = try await requestFiles(api: api, page: 1)
|
||||||
|
page = 1
|
||||||
|
files = payload.list
|
||||||
|
total = payload.total
|
||||||
|
} catch {
|
||||||
|
files = []
|
||||||
|
total = 0
|
||||||
|
page = 1
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载当前目录下一页。
|
||||||
|
func loadMore(api: any AssetsServing) async {
|
||||||
|
guard hasMore, !isLoadingMore else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
defer { isLoadingMore = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let nextPage = page + 1
|
||||||
|
let payload = try await requestFiles(api: api, page: nextPage)
|
||||||
|
page = nextPage
|
||||||
|
total = payload.total
|
||||||
|
files.append(contentsOf: payload.list)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进入指定文件夹并刷新列表。
|
||||||
|
func enterFolder(_ folder: CloudDriveFile, api: any AssetsServing) async {
|
||||||
|
guard folder.isFolder else { return }
|
||||||
|
path.append(folder)
|
||||||
|
await reload(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回到指定面包屑目录并刷新列表。
|
||||||
|
func popToFolder(at index: Int, api: any AssetsServing) async {
|
||||||
|
guard path.indices.contains(index) else { return }
|
||||||
|
path = Array(path.prefix(index + 1))
|
||||||
|
await reload(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建文件夹并刷新当前目录。
|
||||||
|
func createFolder(name: String, api: any AssetsServing) async -> Bool {
|
||||||
|
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !folderName.isEmpty else {
|
||||||
|
errorMessage = "请输入文件夹名称"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await mutate(api: api) {
|
||||||
|
try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: currentFolderId, name: folderName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重命名文件或文件夹并刷新当前目录。
|
||||||
|
func rename(_ item: CloudDriveFile, newName: String, api: any AssetsServing) async -> Bool {
|
||||||
|
let fileName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !fileName.isEmpty else {
|
||||||
|
errorMessage = "请输入名称"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await mutate(api: api) {
|
||||||
|
if item.isFolder {
|
||||||
|
try await api.cloudFolderEdit(CloudFolderModifyRequest(id: item.id, name: fileName))
|
||||||
|
} else {
|
||||||
|
try await api.cloudFileEdit(CloudFileModifyRequest(id: item.id, fileName: fileName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除文件或文件夹并刷新当前目录。
|
||||||
|
func delete(_ item: CloudDriveFile, api: any AssetsServing) async -> Bool {
|
||||||
|
await mutate(api: api) {
|
||||||
|
let action = CloudFileActionItem(id: item.id, type: item.type)
|
||||||
|
try await api.cloudFileDelete(CloudFileDeleteRequest(list: [action]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 移动文件或文件夹并刷新当前目录。
|
||||||
|
func move(_ item: CloudDriveFile, targetFolderId: Int, api: any AssetsServing) async -> Bool {
|
||||||
|
await mutate(api: api) {
|
||||||
|
let action = CloudFileActionItem(id: item.id, type: item.type)
|
||||||
|
try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: targetFolderId, list: [action]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传本地文件到 OSS 后写入云盘。
|
||||||
|
func upload(
|
||||||
|
localFiles: [CloudLocalUploadFile],
|
||||||
|
scenicId: Int?,
|
||||||
|
api: any AssetsServing,
|
||||||
|
uploadService: any OSSUploadServing,
|
||||||
|
transferStore: CloudTransferStore
|
||||||
|
) async -> Bool {
|
||||||
|
guard let scenicId else {
|
||||||
|
errorMessage = "缺少当前景区,无法上传"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard !localFiles.isEmpty else { return true }
|
||||||
|
|
||||||
|
isMutating = true
|
||||||
|
defer { isMutating = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let permission = try await api.checkCloudUploadPermission()
|
||||||
|
guard permission.canUpload else {
|
||||||
|
let reason = permission.reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
errorMessage = reason.isEmpty ? "当前账号暂无上传权限" : reason
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in localFiles {
|
||||||
|
let transferId = transferStore.start(fileName: file.fileName, direction: .upload)
|
||||||
|
do {
|
||||||
|
let url = try await uploadService.uploadCloudFile(
|
||||||
|
data: file.data,
|
||||||
|
fileName: file.fileName,
|
||||||
|
fileType: file.fileType,
|
||||||
|
scenicId: scenicId
|
||||||
|
) { progress in
|
||||||
|
Task { @MainActor in
|
||||||
|
transferStore.update(id: transferId, progress: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await api.cloudFileUpload(
|
||||||
|
CloudFileUploadRequest(parentFolderId: currentFolderId, fileUrl: url, fileName: file.fileName)
|
||||||
|
)
|
||||||
|
transferStore.succeed(id: transferId)
|
||||||
|
} catch {
|
||||||
|
transferStore.fail(id: transferId, message: error.localizedDescription)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await reload(api: api)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行一次云盘变更操作并刷新当前目录。
|
||||||
|
private func mutate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
|
||||||
|
isMutating = true
|
||||||
|
defer { isMutating = false }
|
||||||
|
do {
|
||||||
|
try await operation()
|
||||||
|
await reload(api: api)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 组装并发送云盘文件列表请求。
|
||||||
|
private func requestFiles(api: any AssetsServing, page: Int) async throws -> ListPayload<CloudDriveFile> {
|
||||||
|
try await api.cloudFileList(
|
||||||
|
parentFolderId: currentFolderId,
|
||||||
|
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
type: selectedFilter.rawValue,
|
||||||
|
orderBy: selectedSort.rawValue,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材库 ViewModel,负责素材列表、筛选、分页、详情和上下架。
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class MediaLibraryViewModel {
|
||||||
|
let kind: MediaLibraryKind
|
||||||
|
var items: [MediaLibraryItem] = []
|
||||||
|
var selectedDetail: MediaLibraryDetail?
|
||||||
|
var orderInfo: MediaLibraryOrderInfo?
|
||||||
|
var total = 0
|
||||||
|
var page = 1
|
||||||
|
var keyword = ""
|
||||||
|
var selectedAuditFilter: MediaLibraryAuditFilter = .all
|
||||||
|
var isLoading = false
|
||||||
|
var isLoadingMore = false
|
||||||
|
var isLoadingDetail = false
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
private let pageSize = 10
|
||||||
|
|
||||||
|
/// 初始化素材库 ViewModel。
|
||||||
|
init(kind: MediaLibraryKind = .material) {
|
||||||
|
self.kind = kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断当前列表是否还有下一页。
|
||||||
|
var hasMore: Bool {
|
||||||
|
items.count < total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新加载素材列表第一页。
|
||||||
|
func reload(api: any AssetsServing) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await requestList(api: api, page: 1)
|
||||||
|
page = 1
|
||||||
|
items = response.list
|
||||||
|
total = response.total
|
||||||
|
orderInfo = response.order
|
||||||
|
} catch {
|
||||||
|
items = []
|
||||||
|
total = 0
|
||||||
|
orderInfo = nil
|
||||||
|
page = 1
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载素材列表下一页。
|
||||||
|
func loadMore(api: any AssetsServing) async {
|
||||||
|
guard hasMore, !isLoadingMore else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
defer { isLoadingMore = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let nextPage = page + 1
|
||||||
|
let response = try await requestList(api: api, page: nextPage)
|
||||||
|
page = nextPage
|
||||||
|
total = response.total
|
||||||
|
orderInfo = response.order ?? orderInfo
|
||||||
|
items.append(contentsOf: response.list)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载素材详情,失败时保留已有详情或摘要。
|
||||||
|
func loadDetail(id: Int, api: any AssetsServing) async {
|
||||||
|
isLoadingDetail = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isLoadingDetail = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
selectedDetail = try await api.mediaAlbumDetail(id: id)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新素材上下架状态,审核未通过时禁止操作。
|
||||||
|
func toggleListing(_ item: MediaLibraryItem, api: any AssetsServing) async -> Bool {
|
||||||
|
guard item.isApproved else {
|
||||||
|
errorMessage = "审核通过后才能上下架"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await operate(api: api) {
|
||||||
|
let target = item.listing.toggled
|
||||||
|
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: item.id, listingStatus: target.rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新素材详情页上下架状态,审核未通过时禁止操作。
|
||||||
|
func toggleListing(detail: MediaLibraryDetail, api: any AssetsServing) async -> Bool {
|
||||||
|
guard detail.isApproved else {
|
||||||
|
errorMessage = "审核通过后才能上下架"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await operate(api: api) {
|
||||||
|
let target = detail.listing.toggled
|
||||||
|
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: detail.id, listingStatus: target.rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除素材并刷新列表。
|
||||||
|
func delete(id: Int, api: any AssetsServing) async -> Bool {
|
||||||
|
await operate(api: api) {
|
||||||
|
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行素材变更操作并刷新列表。
|
||||||
|
private func operate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
|
||||||
|
do {
|
||||||
|
try await operation()
|
||||||
|
await reload(api: api)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 组装并发送素材列表请求。
|
||||||
|
private func requestList(api: any AssetsServing, page: Int) async throws -> MediaLibraryListResponse {
|
||||||
|
try await api.mediaAlbumList(
|
||||||
|
kind: kind,
|
||||||
|
keyword: keyword.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
status: selectedAuditFilter.rawValue >= 0 ? selectedAuditFilter.rawValue : nil,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材。
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class MediaLibraryEditorViewModel {
|
||||||
|
var name = ""
|
||||||
|
var description = ""
|
||||||
|
var selectedSpotId: Int?
|
||||||
|
var tagsText = ""
|
||||||
|
var coverFile: MediaLocalUploadFile?
|
||||||
|
var mediaFiles: [MediaLocalUploadFile] = []
|
||||||
|
var isSubmitting = false
|
||||||
|
var errorMessage: String?
|
||||||
|
var didSubmitSuccessfully = false
|
||||||
|
|
||||||
|
private let editingId: Int?
|
||||||
|
|
||||||
|
/// 初始化上传或编辑素材表单。
|
||||||
|
init(detail: MediaLibraryDetail? = nil) {
|
||||||
|
editingId = detail?.id
|
||||||
|
name = detail?.name ?? ""
|
||||||
|
description = detail?.description ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断当前是否处于编辑模式。
|
||||||
|
var isEditing: Bool {
|
||||||
|
editingId != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加封面本地文件。
|
||||||
|
func setCover(_ file: MediaLocalUploadFile) {
|
||||||
|
coverFile = file
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加素材媒体文件。
|
||||||
|
func addMediaFiles(_ files: [MediaLocalUploadFile]) {
|
||||||
|
mediaFiles.append(contentsOf: files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 移除指定媒体文件。
|
||||||
|
func removeMediaFile(id: UUID) {
|
||||||
|
mediaFiles.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交素材上传或编辑。
|
||||||
|
func submit(
|
||||||
|
kind: MediaLibraryKind = .material,
|
||||||
|
scenicId: Int?,
|
||||||
|
api: any AssetsServing,
|
||||||
|
uploadService: any OSSUploadServing
|
||||||
|
) async -> Bool {
|
||||||
|
guard validate(scenicId: scenicId) else { return false }
|
||||||
|
guard !isSubmitting else { return false }
|
||||||
|
isSubmitting = true
|
||||||
|
defer { isSubmitting = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let actualScenicId = scenicId ?? 0
|
||||||
|
let coverUpload = try await uploadCover(scenicId: actualScenicId, uploadService: uploadService)
|
||||||
|
let uploads = try await uploadMediaFiles(scenicId: actualScenicId, uploadService: uploadService)
|
||||||
|
if let editingId {
|
||||||
|
let request = MediaAlbumEditRequest(
|
||||||
|
id: editingId,
|
||||||
|
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
type: kind.rawValue,
|
||||||
|
mediaType: resolvedMediaType(),
|
||||||
|
mediaList: uploads,
|
||||||
|
coverUrl: coverUpload.url,
|
||||||
|
coverSize: coverUpload.size,
|
||||||
|
scenicSpotId: selectedSpotId ?? 0,
|
||||||
|
description: description,
|
||||||
|
materialTag: normalizedTags()
|
||||||
|
)
|
||||||
|
try await api.mediaAlbumEdit(request)
|
||||||
|
} else {
|
||||||
|
let request = MediaAlbumUploadRequest(
|
||||||
|
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
type: kind.rawValue,
|
||||||
|
mediaType: resolvedMediaType(),
|
||||||
|
mediaList: uploads,
|
||||||
|
coverUrl: coverUpload.url,
|
||||||
|
coverSize: coverUpload.size,
|
||||||
|
scenicSpotId: selectedSpotId ?? 0,
|
||||||
|
description: description,
|
||||||
|
materialTag: normalizedTags(),
|
||||||
|
projectId: 0
|
||||||
|
)
|
||||||
|
try await api.mediaAlbumUpload(request)
|
||||||
|
}
|
||||||
|
didSubmitSuccessfully = true
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验素材表单。
|
||||||
|
private func validate(scenicId: Int?) -> Bool {
|
||||||
|
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard scenicId != nil else {
|
||||||
|
errorMessage = "缺少当前景区,无法提交素材"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard !title.isEmpty else {
|
||||||
|
errorMessage = "请输入素材名称"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard selectedSpotId != nil else {
|
||||||
|
errorMessage = "请选择打卡点"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard coverFile != nil else {
|
||||||
|
errorMessage = "请选择封面"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard !mediaFiles.isEmpty else {
|
||||||
|
errorMessage = "请选择素材文件"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard normalizedTags().count <= 120 else {
|
||||||
|
errorMessage = "标签内容过长"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传封面并返回 URL 和尺寸。
|
||||||
|
private func uploadCover(
|
||||||
|
scenicId: Int,
|
||||||
|
uploadService: any OSSUploadServing
|
||||||
|
) async throws -> (url: String, size: MediaAlbumFileSize) {
|
||||||
|
guard let coverFile else {
|
||||||
|
throw APIError.emptyData
|
||||||
|
}
|
||||||
|
let url = try await uploadService.uploadCloudFile(
|
||||||
|
data: coverFile.data,
|
||||||
|
fileName: coverFile.fileName,
|
||||||
|
fileType: coverFile.fileType,
|
||||||
|
scenicId: scenicId,
|
||||||
|
onProgress: { _ in }
|
||||||
|
)
|
||||||
|
return (url, MediaAlbumFileSize(width: coverFile.width, height: coverFile.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传素材媒体文件并组装服务端请求项。
|
||||||
|
private func uploadMediaFiles(
|
||||||
|
scenicId: Int,
|
||||||
|
uploadService: any OSSUploadServing
|
||||||
|
) async throws -> [MediaAlbumUploadItem] {
|
||||||
|
var result: [MediaAlbumUploadItem] = []
|
||||||
|
for file in mediaFiles {
|
||||||
|
let url = try await uploadService.uploadCloudFile(
|
||||||
|
data: file.data,
|
||||||
|
fileName: file.fileName,
|
||||||
|
fileType: file.fileType,
|
||||||
|
scenicId: scenicId,
|
||||||
|
onProgress: { _ in }
|
||||||
|
)
|
||||||
|
result.append(
|
||||||
|
MediaAlbumUploadItem(
|
||||||
|
originalName: file.fileName,
|
||||||
|
ossUrl: url,
|
||||||
|
size: Int64(file.data.count),
|
||||||
|
fileWidthSize: MediaAlbumFileSize(width: file.width, height: file.height)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据已选文件推断素材媒体类型。
|
||||||
|
private func resolvedMediaType() -> Int {
|
||||||
|
mediaFiles.contains { $0.fileType == 1 } ? 1 : 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规范化用户输入的标签文本。
|
||||||
|
private func normalizedTags() -> String {
|
||||||
|
tagsText
|
||||||
|
.split { $0 == "," || $0 == "," || $0 == " " || $0 == "\n" }
|
||||||
|
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
629
suixinkan/Features/Assets/Views/CloudStorageViews.swift
Normal file
629
suixinkan/Features/Assets/Views/CloudStorageViews.swift
Normal file
@ -0,0 +1,629 @@
|
|||||||
|
//
|
||||||
|
// CloudStorageViews.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVKit
|
||||||
|
import Foundation
|
||||||
|
import PhotosUI
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 相册云盘页面,展示文件夹浏览、筛选、上传、预览和文件操作。
|
||||||
|
struct CloudStorageView: View {
|
||||||
|
@Environment(AccountContext.self) private var accountContext
|
||||||
|
@Environment(AssetsAPI.self) private var assetsAPI
|
||||||
|
@Environment(OSSUploadService.self) private var uploadService
|
||||||
|
@Environment(RouterPath.self) private var router
|
||||||
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
|
@State private var viewModel = CloudStorageViewModel()
|
||||||
|
@State private var transferStore = CloudTransferStore.shared
|
||||||
|
@State private var selectedItems: [PhotosPickerItem] = []
|
||||||
|
@State private var actionItem: CloudDriveFile?
|
||||||
|
@State private var previewItem: CloudDriveFile?
|
||||||
|
@State private var isCreatingFolder = false
|
||||||
|
@State private var folderName = ""
|
||||||
|
@State private var isRenaming = false
|
||||||
|
@State private var renamingItem: CloudDriveFile?
|
||||||
|
@State private var renameText = ""
|
||||||
|
@State private var movingItem: CloudDriveFile?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
toolbarSection
|
||||||
|
breadcrumbSection
|
||||||
|
filterSection
|
||||||
|
contentSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||||
|
}
|
||||||
|
.background(Color(hex: 0xF5F7FA))
|
||||||
|
.navigationTitle("相册云盘")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
router.navigate(to: .home(.cloudStorageTransit))
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.arrow.down")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("传输记录")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
folderName = ""
|
||||||
|
isCreatingFolder = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "folder.badge.plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("新建文件夹")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await reload(showLoading: false) }
|
||||||
|
.task {
|
||||||
|
guard viewModel.files.isEmpty else { return }
|
||||||
|
await reload(showLoading: true)
|
||||||
|
}
|
||||||
|
.onChange(of: selectedItems) { _, items in
|
||||||
|
Task { await upload(items) }
|
||||||
|
}
|
||||||
|
.confirmationDialog("文件操作", isPresented: actionDialogBinding, presenting: actionItem) { item in
|
||||||
|
Button("预览") { previewItem = item }
|
||||||
|
Button("下载") { Task { await download(item) } }
|
||||||
|
Button("重命名") {
|
||||||
|
renamingItem = item
|
||||||
|
renameText = item.name
|
||||||
|
isRenaming = true
|
||||||
|
}
|
||||||
|
Button("移动") { movingItem = item }
|
||||||
|
Button("删除", role: .destructive) { Task { await delete(item) } }
|
||||||
|
}
|
||||||
|
.alert("新建文件夹", isPresented: $isCreatingFolder) {
|
||||||
|
TextField("文件夹名称", text: $folderName)
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
Button("创建") { Task { await createFolder() } }
|
||||||
|
}
|
||||||
|
.alert("重命名", isPresented: $isRenaming) {
|
||||||
|
TextField("名称", text: $renameText)
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
Button("保存") { Task { await renameSelectedItem() } }
|
||||||
|
}
|
||||||
|
.sheet(item: $movingItem) { item in
|
||||||
|
CloudMoveFolderSheet(item: item, folders: moveTargets) { folderId in
|
||||||
|
Task { await move(item, targetFolderId: folderId) }
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
.sheet(item: $previewItem) { item in
|
||||||
|
CloudFilePreviewView(item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 顶部工具区域,包含上传入口和当前统计。
|
||||||
|
private var toolbarSection: some View {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||||
|
Text("当前目录")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
Text(viewModel.path.last?.name ?? "云盘")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
PhotosPicker(selection: $selectedItems, maxSelectionCount: 20, matching: .any(of: [.images, .videos])) {
|
||||||
|
Label("上传", systemImage: "square.and.arrow.up")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 面包屑区域,展示当前云盘路径。
|
||||||
|
private var breadcrumbSection: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||||
|
ForEach(Array(viewModel.path.enumerated()), id: \.element.id) { index, folder in
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.popToFolder(at: index, api: assetsAPI) }
|
||||||
|
} label: {
|
||||||
|
Text(folder.name)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(index == viewModel.path.count - 1 ? AppDesign.primary : AppDesign.textSecondary)
|
||||||
|
|
||||||
|
if index < viewModel.path.count - 1 {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 筛选区域,包含搜索、类型和排序。
|
||||||
|
private var filterSection: some View {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
TextField("搜索文件或文件夹", text: $viewModel.searchText)
|
||||||
|
.submitLabel(.search)
|
||||||
|
.onSubmit { Task { await reload(showLoading: true) } }
|
||||||
|
Button("搜索") {
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||||
|
|
||||||
|
Picker("类型", selection: $viewModel.selectedFilter) {
|
||||||
|
ForEach(CloudDriveFilter.allCases) { filter in
|
||||||
|
Text(filter.title).tag(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: viewModel.selectedFilter) { _, _ in
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("排序", selection: $viewModel.selectedSort) {
|
||||||
|
ForEach(CloudDriveSort.allCases) { sort in
|
||||||
|
Text(sort.title).tag(sort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: viewModel.selectedSort) { _, _ in
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表内容区域,展示空状态、文件行和加载更多。
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentSection: some View {
|
||||||
|
if viewModel.files.isEmpty {
|
||||||
|
AssetsEmptyState(title: "暂无文件", message: viewModel.errorMessage ?? "当前目录还没有云盘文件。")
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.files) { item in
|
||||||
|
CloudDriveFileRow(item: item) {
|
||||||
|
if item.isFolder {
|
||||||
|
Task { await viewModel.enterFolder(item, api: assetsAPI) }
|
||||||
|
} else {
|
||||||
|
previewItem = item
|
||||||
|
}
|
||||||
|
} moreAction: {
|
||||||
|
actionItem = item
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
guard item.id == viewModel.files.last?.id else { return }
|
||||||
|
Task { await viewModel.loadMore(api: assetsAPI) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoadingMore {
|
||||||
|
ProgressView()
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 操作弹窗的绑定,关闭时同步清空当前文件。
|
||||||
|
private var actionDialogBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { actionItem != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented { actionItem = nil }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可移动到的目标文件夹,当前版本提供根目录和当前目录可见文件夹。
|
||||||
|
private var moveTargets: [CloudDriveFile] {
|
||||||
|
[CloudDriveFile(id: 0, name: "云盘")] + viewModel.files.filter(\.isFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新加载当前目录。
|
||||||
|
private func reload(showLoading: Bool) async {
|
||||||
|
await globalLoading.withOptionalLoading(showLoading) {
|
||||||
|
await viewModel.reload(api: assetsAPI)
|
||||||
|
}
|
||||||
|
if let message = viewModel.errorMessage {
|
||||||
|
toastCenter.show(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建文件夹。
|
||||||
|
private func createFolder() async {
|
||||||
|
let success = await globalLoading.withLoading {
|
||||||
|
await viewModel.createFolder(name: folderName, api: assetsAPI)
|
||||||
|
}
|
||||||
|
toastCenter.show(success ? "文件夹已创建" : (viewModel.errorMessage ?? "创建失败"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重命名当前选中的文件或文件夹。
|
||||||
|
private func renameSelectedItem() async {
|
||||||
|
guard let item = renamingItem else { return }
|
||||||
|
let success = await globalLoading.withLoading {
|
||||||
|
await viewModel.rename(item, newName: renameText, api: assetsAPI)
|
||||||
|
}
|
||||||
|
toastCenter.show(success ? "已重命名" : (viewModel.errorMessage ?? "重命名失败"))
|
||||||
|
renamingItem = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除文件或文件夹。
|
||||||
|
private func delete(_ item: CloudDriveFile) async {
|
||||||
|
let success = await globalLoading.withLoading {
|
||||||
|
await viewModel.delete(item, api: assetsAPI)
|
||||||
|
}
|
||||||
|
toastCenter.show(success ? "已删除" : (viewModel.errorMessage ?? "删除失败"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 移动文件或文件夹。
|
||||||
|
private func move(_ item: CloudDriveFile, targetFolderId: Int) async {
|
||||||
|
movingItem = nil
|
||||||
|
let success = await globalLoading.withLoading {
|
||||||
|
await viewModel.move(item, targetFolderId: targetFolderId, api: assetsAPI)
|
||||||
|
}
|
||||||
|
toastCenter.show(success ? "已移动" : (viewModel.errorMessage ?? "移动失败"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 PhotosPicker 结果并上传到云盘。
|
||||||
|
private func upload(_ items: [PhotosPickerItem]) async {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
defer { selectedItems = [] }
|
||||||
|
let localFiles = await AssetPickerLoader.loadCloudFiles(from: items)
|
||||||
|
let success = await globalLoading.withLoading {
|
||||||
|
await viewModel.upload(
|
||||||
|
localFiles: localFiles,
|
||||||
|
scenicId: accountContext.currentScenic?.id,
|
||||||
|
api: assetsAPI,
|
||||||
|
uploadService: uploadService,
|
||||||
|
transferStore: transferStore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
toastCenter.show(success ? "上传完成" : (viewModel.errorMessage ?? "上传失败"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载云盘文件到 App 沙盒 Documents/CloudDownloads。
|
||||||
|
private func download(_ item: CloudDriveFile) async {
|
||||||
|
guard let url = URL(string: item.fileUrl), !item.isFolder else {
|
||||||
|
toastCenter.show("文件地址无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let transferId = transferStore.start(fileName: item.name, direction: .download)
|
||||||
|
do {
|
||||||
|
let (tempURL, _) = try await URLSession.shared.download(from: url)
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let documents = try fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let folder = documents.appendingPathComponent("CloudDownloads", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
let destination = folder.appendingPathComponent(uniqueFileName(item.name, in: folder))
|
||||||
|
if fileManager.fileExists(atPath: destination.path) {
|
||||||
|
try fileManager.removeItem(at: destination)
|
||||||
|
}
|
||||||
|
try fileManager.moveItem(at: tempURL, to: destination)
|
||||||
|
transferStore.succeed(id: transferId, message: destination.lastPathComponent)
|
||||||
|
toastCenter.show("已下载到 CloudDownloads")
|
||||||
|
} catch {
|
||||||
|
transferStore.fail(id: transferId, message: error.localizedDescription)
|
||||||
|
toastCenter.show("下载失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成不覆盖已有文件的下载文件名。
|
||||||
|
private func uniqueFileName(_ name: String, in folder: URL) -> String {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let base = URL(fileURLWithPath: name).deletingPathExtension().lastPathComponent.assetsNonEmpty ?? "download"
|
||||||
|
let ext = URL(fileURLWithPath: name).pathExtension
|
||||||
|
var candidate = name
|
||||||
|
var index = 1
|
||||||
|
while fileManager.fileExists(atPath: folder.appendingPathComponent(candidate).path) {
|
||||||
|
candidate = ext.isEmpty ? "\(base)-\(index)" : "\(base)-\(index).\(ext)"
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘文件行,展示图标、名称、大小和操作按钮。
|
||||||
|
private struct CloudDriveFileRow: View {
|
||||||
|
let item: CloudDriveFile
|
||||||
|
let openAction: () -> Void
|
||||||
|
let moreAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
Button(action: openAction) {
|
||||||
|
thumbnail
|
||||||
|
.frame(width: 54, height: 54)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button(action: openAction) {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||||
|
Text(item.name)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(item.isFolder ? "\(item.childNum) 项" : "\(item.fileSize.formattedFileSize) · \(item.createdAt)")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button(action: moreAction) {
|
||||||
|
Image(systemName: "ellipsis")
|
||||||
|
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘文件缩略图。
|
||||||
|
@ViewBuilder
|
||||||
|
private var thumbnail: some View {
|
||||||
|
if item.isFolder {
|
||||||
|
Image(systemName: "folder.fill")
|
||||||
|
.font(.system(size: 30, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.warning)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(hex: 0xFFF7ED))
|
||||||
|
} else {
|
||||||
|
RemoteImage(urlString: item.previewURLString, contentMode: .fill) {
|
||||||
|
Image(systemName: item.isVideo ? "play.rectangle.fill" : "photo")
|
||||||
|
.font(.system(size: 26, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppDesign.primarySoft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘移动目标选择弹窗。
|
||||||
|
private struct CloudMoveFolderSheet: View {
|
||||||
|
let item: CloudDriveFile
|
||||||
|
let folders: [CloudDriveFile]
|
||||||
|
let selectAction: (Int) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List(folders.filter { $0.id != item.id }) { folder in
|
||||||
|
Button {
|
||||||
|
selectAction(folder.id)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Label(folder.name, systemImage: folder.id == 0 ? "externaldrive" : "folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("移动到")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘文件预览页面,支持图片和视频的安全预览。
|
||||||
|
private struct CloudFilePreviewView: View {
|
||||||
|
let item: CloudDriveFile
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if item.isVideo, let url = URL(string: item.fileUrl) {
|
||||||
|
VideoPlayer(player: AVPlayer(url: url))
|
||||||
|
} else {
|
||||||
|
RemoteImage(urlString: item.previewURLString, contentMode: .fit) {
|
||||||
|
AssetsEmptyState(title: "无法预览", message: item.name)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black.opacity(item.isVideo ? 1 : 0.04))
|
||||||
|
.navigationTitle(item.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("关闭") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘传输记录页面,展示本次会话内上传和下载记录。
|
||||||
|
struct CloudStorageTransitView: View {
|
||||||
|
@State private var transferStore = CloudTransferStore.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if transferStore.records.isEmpty {
|
||||||
|
AssetsEmptyState(title: "暂无传输记录", message: "上传和下载记录只保留在本次 App 会话内。")
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
} else {
|
||||||
|
ForEach(transferStore.records) { record in
|
||||||
|
CloudTransferRow(record: record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.background(Color(hex: 0xF5F7FA))
|
||||||
|
.navigationTitle("传输记录")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("清空") { transferStore.clear() }
|
||||||
|
.disabled(transferStore.records.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘传输记录行。
|
||||||
|
private struct CloudTransferRow: View {
|
||||||
|
let record: CloudTransferItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: record.direction == .upload ? "arrow.up.circle.fill" : "arrow.down.circle.fill")
|
||||||
|
.foregroundStyle(record.status == .failed ? Color.red : AppDesign.primary)
|
||||||
|
Text(record.fileName)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(record.status.title)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||||
|
.foregroundStyle(record.status.color)
|
||||||
|
}
|
||||||
|
ProgressView(value: Double(record.progress), total: 100)
|
||||||
|
if !record.message.isEmpty {
|
||||||
|
Text(record.message)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 资产模块空状态视图。
|
||||||
|
struct AssetsEmptyState: View {
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Image(systemName: "tray")
|
||||||
|
.font(.system(size: 34, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(AppMetrics.Spacing.xLarge)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PhotosPicker 数据加载器,把系统选择结果转换为模块内本地文件实体。
|
||||||
|
enum AssetPickerLoader {
|
||||||
|
/// 加载云盘本地文件。
|
||||||
|
@MainActor
|
||||||
|
static func loadCloudFiles(from items: [PhotosPickerItem]) async -> [CloudLocalUploadFile] {
|
||||||
|
var result: [CloudLocalUploadFile] = []
|
||||||
|
for item in items {
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
|
||||||
|
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) }
|
||||||
|
let ext = isVideo ? "mp4" : "jpg"
|
||||||
|
let fileName = "cloud_\(UUID().uuidString).\(ext)"
|
||||||
|
result.append(CloudLocalUploadFile(data: data, fileName: fileName, fileType: isVideo ? 1 : 2))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载素材本地文件。
|
||||||
|
@MainActor
|
||||||
|
static func loadMediaFiles(from items: [PhotosPickerItem]) async -> [MediaLocalUploadFile] {
|
||||||
|
var result: [MediaLocalUploadFile] = []
|
||||||
|
for item in items {
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
|
||||||
|
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) }
|
||||||
|
let ext = isVideo ? "mp4" : "jpg"
|
||||||
|
let image = isVideo ? nil : UIImage(data: data)
|
||||||
|
let fileName = "media_\(UUID().uuidString).\(ext)"
|
||||||
|
result.append(
|
||||||
|
MediaLocalUploadFile(
|
||||||
|
data: data,
|
||||||
|
fileName: fileName,
|
||||||
|
fileType: isVideo ? 1 : 2,
|
||||||
|
width: Int(image?.size.width ?? 0),
|
||||||
|
height: Int(image?.size.height ?? 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension CloudTransferStatus {
|
||||||
|
/// 返回传输状态展示文案。
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .running:
|
||||||
|
"进行中"
|
||||||
|
case .success:
|
||||||
|
"完成"
|
||||||
|
case .failed:
|
||||||
|
"失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回传输状态展示颜色。
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .running:
|
||||||
|
AppDesign.primary
|
||||||
|
case .success:
|
||||||
|
AppDesign.success
|
||||||
|
case .failed:
|
||||||
|
.red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Int64 {
|
||||||
|
/// 返回文件大小展示文案。
|
||||||
|
var formattedFileSize: 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
/// 返回去除空白后的非空字符串,供资产页面避免依赖其他文件私有扩展。
|
||||||
|
var assetsNonEmpty: String? {
|
||||||
|
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? nil : value
|
||||||
|
}
|
||||||
|
}
|
||||||
610
suixinkan/Features/Assets/Views/MediaLibraryViews.swift
Normal file
610
suixinkan/Features/Assets/Views/MediaLibraryViews.swift
Normal file
@ -0,0 +1,610 @@
|
|||||||
|
//
|
||||||
|
// MediaLibraryViews.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import PhotosUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 素材管理页面,展示素材列表、筛选、统计和上下架入口。
|
||||||
|
struct MediaLibraryView: View {
|
||||||
|
@Environment(AssetsAPI.self) private var assetsAPI
|
||||||
|
@Environment(RouterPath.self) private var router
|
||||||
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
|
@State private var viewModel = MediaLibraryViewModel(kind: .material)
|
||||||
|
@State private var selectedItem: MediaLibraryItem?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
filterSection
|
||||||
|
statsSection
|
||||||
|
contentSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||||
|
}
|
||||||
|
.background(Color(hex: 0xF5F7FA))
|
||||||
|
.navigationTitle("素材管理")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
router.navigate(to: .home(.materialUpload))
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("上传素材")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await reload(showLoading: false) }
|
||||||
|
.task {
|
||||||
|
guard viewModel.items.isEmpty else { return }
|
||||||
|
await reload(showLoading: true)
|
||||||
|
}
|
||||||
|
.navigationDestination(item: $selectedItem) { item in
|
||||||
|
MediaLibraryDetailView(item: item, listViewModel: viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 筛选区域,包含关键词和审核状态。
|
||||||
|
private var filterSection: some View {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
TextField("搜索素材名称", text: $viewModel.keyword)
|
||||||
|
.submitLabel(.search)
|
||||||
|
.onSubmit { Task { await reload(showLoading: true) } }
|
||||||
|
Button("搜索") {
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||||
|
|
||||||
|
Picker("审核状态", selection: $viewModel.selectedAuditFilter) {
|
||||||
|
ForEach(MediaLibraryAuditFilter.allCases) { filter in
|
||||||
|
Text(filter.title).tag(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: viewModel.selectedAuditFilter) { _, _ in
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计区域,展示素材订单概览。
|
||||||
|
@ViewBuilder
|
||||||
|
private var statsSection: some View {
|
||||||
|
if let info = viewModel.orderInfo {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
MediaStatCard(title: "订单数", value: "\(info.totalNum)")
|
||||||
|
MediaStatCard(title: "均价", value: String(format: "%.2f", info.avgOrderAmount))
|
||||||
|
MediaStatCard(title: "退款", value: String(format: "%.2f", info.refundTotal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表内容区域。
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentSection: some View {
|
||||||
|
if viewModel.items.isEmpty {
|
||||||
|
AssetsEmptyState(title: "暂无素材", message: viewModel.errorMessage ?? "当前筛选条件下没有素材。")
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.items) { item in
|
||||||
|
MediaLibraryCard(item: item) {
|
||||||
|
selectedItem = item
|
||||||
|
} toggleAction: {
|
||||||
|
Task { await toggleListing(item) }
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
guard item.id == viewModel.items.last?.id else { return }
|
||||||
|
Task { await viewModel.loadMore(api: assetsAPI) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoadingMore {
|
||||||
|
ProgressView()
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新加载素材列表。
|
||||||
|
private func reload(showLoading: Bool) async {
|
||||||
|
await globalLoading.withOptionalLoading(showLoading) {
|
||||||
|
await viewModel.reload(api: assetsAPI)
|
||||||
|
}
|
||||||
|
if let message = viewModel.errorMessage {
|
||||||
|
toastCenter.show(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换素材上下架状态。
|
||||||
|
private func toggleListing(_ item: MediaLibraryItem) async {
|
||||||
|
let success = await globalLoading.withLoading {
|
||||||
|
await viewModel.toggleListing(item, api: assetsAPI)
|
||||||
|
}
|
||||||
|
toastCenter.show(success ? "状态已更新" : (viewModel.errorMessage ?? "操作失败"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材统计卡片。
|
||||||
|
private struct MediaStatCard: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材列表卡片。
|
||||||
|
private struct MediaLibraryCard: View {
|
||||||
|
let item: MediaLibraryItem
|
||||||
|
let openAction: () -> Void
|
||||||
|
let toggleAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: openAction) {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
RemoteImage(urlString: item.coverUrl, contentMode: .fill) {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppDesign.primarySoft)
|
||||||
|
}
|
||||||
|
.frame(width: 74, height: 74)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||||
|
Text(item.name)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(item.scenicSpotName.assetsNonEmpty ?? item.projectName.assetsNonEmpty ?? "未关联打卡点")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||||
|
MediaStatusBadge(text: item.status.auditTitle, color: item.status.auditColor)
|
||||||
|
MediaStatusBadge(text: item.listing.title, color: item.listing == .online ? AppDesign.success : AppDesign.placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: toggleAction) {
|
||||||
|
Image(systemName: item.listing == .online ? "arrow.down.circle" : "arrow.up.circle")
|
||||||
|
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(item.isApproved ? AppDesign.primary : AppDesign.placeholder)
|
||||||
|
.disabled(!item.isApproved)
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材状态标签。
|
||||||
|
private struct MediaStatusBadge: View {
|
||||||
|
let text: String
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||||||
|
.background(color.opacity(0.1), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材详情页面,展示基础信息、统计、媒体文件和操作按钮。
|
||||||
|
struct MediaLibraryDetailView: View {
|
||||||
|
let item: MediaLibraryItem
|
||||||
|
let listViewModel: MediaLibraryViewModel
|
||||||
|
|
||||||
|
@Environment(AssetsAPI.self) private var assetsAPI
|
||||||
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
|
@State private var isEditing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
headerSection
|
||||||
|
mediaSection
|
||||||
|
actionSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||||
|
}
|
||||||
|
.background(Color(hex: 0xF5F7FA))
|
||||||
|
.navigationTitle("素材详情")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
await globalLoading.withOptionalLoading(listViewModel.selectedDetail?.id != item.id) {
|
||||||
|
await listViewModel.loadDetail(id: item.id, api: assetsAPI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isEditing) {
|
||||||
|
MediaLibraryUploadView(editingDetail: listViewModel.selectedDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材详情头部信息。
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||||
|
RemoteImage(urlString: currentCoverURL, contentMode: .fill) {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 40, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppDesign.primarySoft)
|
||||||
|
}
|
||||||
|
.frame(height: 180)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||||
|
Text(currentName)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.title2, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Text(listViewModel.selectedDetail?.description.assetsNonEmpty ?? "暂无描述")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
HStack {
|
||||||
|
MediaStatusBadge(text: currentStatus.auditTitle, color: currentStatus.auditColor)
|
||||||
|
MediaStatusBadge(text: currentListing.title, color: currentListing == .online ? AppDesign.success : AppDesign.placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材媒体文件区域。
|
||||||
|
@ViewBuilder
|
||||||
|
private var mediaSection: some View {
|
||||||
|
let media = listViewModel.selectedDetail?.mediaList ?? []
|
||||||
|
if media.isEmpty {
|
||||||
|
AssetsEmptyState(title: "暂无媒体文件", message: "详情接口暂未返回素材文件。")
|
||||||
|
} else {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
|
||||||
|
ForEach(media) { file in
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||||
|
RemoteImage(urlString: file.thumbnailUrl.assetsNonEmpty ?? file.showUrl.assetsNonEmpty ?? file.ossUrl, contentMode: .fill) {
|
||||||
|
Image(systemName: file.isVideo ? "play.rectangle.fill" : "photo")
|
||||||
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppDesign.primarySoft)
|
||||||
|
}
|
||||||
|
.frame(height: 96)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||||
|
Text(file.originalName)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.xSmall)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 操作按钮区域。
|
||||||
|
private var actionSection: some View {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Button("编辑素材") {
|
||||||
|
isEditing = true
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
|
||||||
|
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Button(currentListing == .online ? "下架" : "上架") {
|
||||||
|
Task { await toggleListing() }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.sheetButtonHeight)
|
||||||
|
.background(Color.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||||
|
|
||||||
|
Button("删除", role: .destructive) {
|
||||||
|
Task { await delete() }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.sheetButtonHeight)
|
||||||
|
.background(Color.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentName: String { listViewModel.selectedDetail?.name ?? item.name }
|
||||||
|
private var currentCoverURL: String { listViewModel.selectedDetail?.coverUrl ?? item.coverUrl }
|
||||||
|
private var currentStatus: Int { listViewModel.selectedDetail?.status ?? item.status }
|
||||||
|
private var currentListing: MediaListingStatus { listViewModel.selectedDetail?.listing ?? item.listing }
|
||||||
|
|
||||||
|
/// 切换详情素材上下架状态。
|
||||||
|
private func toggleListing() async {
|
||||||
|
guard let detail = listViewModel.selectedDetail else { return }
|
||||||
|
let success = await globalLoading.withLoading {
|
||||||
|
await listViewModel.toggleListing(detail: detail, api: assetsAPI)
|
||||||
|
}
|
||||||
|
toastCenter.show(success ? "状态已更新" : (listViewModel.errorMessage ?? "操作失败"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除素材并返回列表。
|
||||||
|
private func delete() async {
|
||||||
|
let success = await globalLoading.withLoading {
|
||||||
|
await listViewModel.delete(id: item.id, api: assetsAPI)
|
||||||
|
}
|
||||||
|
toastCenter.show(success ? "已删除" : (listViewModel.errorMessage ?? "删除失败"))
|
||||||
|
if success { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传或编辑页面,负责选择封面、媒体文件和打卡点后提交。
|
||||||
|
struct MediaLibraryUploadView: View {
|
||||||
|
let editingDetail: MediaLibraryDetail?
|
||||||
|
|
||||||
|
@Environment(AccountContext.self) private var accountContext
|
||||||
|
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||||
|
@Environment(AssetsAPI.self) private var assetsAPI
|
||||||
|
@Environment(OSSUploadService.self) private var uploadService
|
||||||
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
|
@State private var viewModel: MediaLibraryEditorViewModel
|
||||||
|
@State private var coverSelection: PhotosPickerItem?
|
||||||
|
@State private var mediaSelection: [PhotosPickerItem] = []
|
||||||
|
|
||||||
|
/// 初始化素材上传或编辑页面。
|
||||||
|
init(editingDetail: MediaLibraryDetail? = nil) {
|
||||||
|
self.editingDetail = editingDetail
|
||||||
|
_viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
formSection
|
||||||
|
spotSection
|
||||||
|
coverSection
|
||||||
|
mediaSection
|
||||||
|
submitButton
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||||
|
}
|
||||||
|
.background(Color(hex: 0xF5F7FA))
|
||||||
|
.navigationTitle(viewModel.isEditing ? "编辑素材" : "上传素材")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onChange(of: coverSelection) { _, item in
|
||||||
|
Task { await loadCover(item) }
|
||||||
|
}
|
||||||
|
.onChange(of: mediaSelection) { _, items in
|
||||||
|
Task { await loadMedia(items) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 表单基础信息区域。
|
||||||
|
private var formSection: some View {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
TextField("素材名称", text: $viewModel.name)
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||||
|
|
||||||
|
TextField("标签,用逗号分隔", text: $viewModel.tagsText)
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||||
|
|
||||||
|
TextField("描述", text: $viewModel.description, axis: .vertical)
|
||||||
|
.lineLimit(3...5)
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 88)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打卡点选择区域。
|
||||||
|
private var spotSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
Text("打卡点")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
if scenicSpotContext.spots.isEmpty {
|
||||||
|
AssetsEmptyState(title: "暂无打卡点", message: "当前景区暂未加载到可选打卡点。")
|
||||||
|
} else {
|
||||||
|
Picker("打卡点", selection: $viewModel.selectedSpotId) {
|
||||||
|
Text("请选择").tag(nil as Int?)
|
||||||
|
ForEach(scenicSpotContext.spots) { spot in
|
||||||
|
Text(spot.name).tag(Optional(spot.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 封面选择区域。
|
||||||
|
private var coverSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
Text("封面")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
PhotosPicker(selection: $coverSelection, matching: .images) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)
|
||||||
|
.fill(Color.white)
|
||||||
|
if let coverFile = viewModel.coverFile, let image = UIImage(data: coverFile.data) {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else if let url = editingDetail?.coverUrl, !url.isEmpty {
|
||||||
|
RemoteImage(urlString: url, contentMode: .fill) {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 34, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Label("选择封面", systemImage: "photo.badge.plus")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 170)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 媒体文件选择区域。
|
||||||
|
private var mediaSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
HStack {
|
||||||
|
Text("媒体文件")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
Spacer()
|
||||||
|
PhotosPicker(selection: $mediaSelection, maxSelectionCount: 20, matching: .any(of: [.images, .videos])) {
|
||||||
|
Label("添加", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.mediaFiles.isEmpty {
|
||||||
|
AssetsEmptyState(title: "未选择素材文件", message: "可选择图片或视频,提交前会先上传到 OSS。")
|
||||||
|
} else {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
|
||||||
|
ForEach(viewModel.mediaFiles) { file in
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
MediaLocalPreview(file: file)
|
||||||
|
Button {
|
||||||
|
viewModel.removeMediaFile(id: file.id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, Color.black.opacity(0.5))
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.xxSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交按钮。
|
||||||
|
private var submitButton: some View {
|
||||||
|
Button {
|
||||||
|
Task { await submit() }
|
||||||
|
} label: {
|
||||||
|
Text(viewModel.isSubmitting ? "提交中..." : "提交")
|
||||||
|
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||||
|
.disabled(viewModel.isSubmitting)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载封面选择结果。
|
||||||
|
private func loadCover(_ item: PhotosPickerItem?) async {
|
||||||
|
guard let item else { return }
|
||||||
|
let files = await AssetPickerLoader.loadMediaFiles(from: [item])
|
||||||
|
if let file = files.first {
|
||||||
|
viewModel.setCover(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载媒体选择结果。
|
||||||
|
private func loadMedia(_ items: [PhotosPickerItem]) async {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
defer { mediaSelection = [] }
|
||||||
|
let files = await AssetPickerLoader.loadMediaFiles(from: items)
|
||||||
|
viewModel.addMediaFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交素材表单。
|
||||||
|
private func submit() async {
|
||||||
|
let success = await globalLoading.withLoading {
|
||||||
|
await viewModel.submit(
|
||||||
|
scenicId: accountContext.currentScenic?.id,
|
||||||
|
api: assetsAPI,
|
||||||
|
uploadService: uploadService
|
||||||
|
)
|
||||||
|
}
|
||||||
|
toastCenter.show(success ? "素材已提交" : (viewModel.errorMessage ?? "提交失败"))
|
||||||
|
if success { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地素材文件预览卡。
|
||||||
|
private struct MediaLocalPreview: View {
|
||||||
|
let file: MediaLocalUploadFile
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if file.fileType == 2, let image = UIImage(data: file.data) {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "play.rectangle.fill")
|
||||||
|
.font(.system(size: 30, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppDesign.primarySoft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 96)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Int {
|
||||||
|
/// 返回素材审核状态文案。
|
||||||
|
var auditTitle: String {
|
||||||
|
switch self {
|
||||||
|
case 1:
|
||||||
|
"已通过"
|
||||||
|
case 2:
|
||||||
|
"未通过"
|
||||||
|
default:
|
||||||
|
"待审核"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回素材审核状态颜色。
|
||||||
|
var auditColor: Color {
|
||||||
|
switch self {
|
||||||
|
case 1:
|
||||||
|
AppDesign.success
|
||||||
|
case 2:
|
||||||
|
.red
|
||||||
|
default:
|
||||||
|
AppDesign.warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
|
|
||||||
## 后续迁移
|
## 后续迁移
|
||||||
|
|
||||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
|
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload` 已由 `Features/Assets` 接管。`album_list`、`album_trailer`、`sample_management`、`sample_upload` 后续按相册和样片模块独立迁移。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
|
||||||
|
|
||||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||||
|
|||||||
@ -32,6 +32,10 @@ enum HomeRoute: Hashable {
|
|||||||
case taskManagement
|
case taskManagement
|
||||||
case taskCreate
|
case taskCreate
|
||||||
case taskDetail(id: Int, summary: PhotographerTaskItem?)
|
case taskDetail(id: Int, summary: PhotographerTaskItem?)
|
||||||
|
case cloudStorage
|
||||||
|
case cloudStorageTransit
|
||||||
|
case materialLibrary
|
||||||
|
case materialUpload
|
||||||
case modulePlaceholder(uri: String, title: String)
|
case modulePlaceholder(uri: String, title: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -95,6 +95,14 @@ enum HomeMenuRouter {
|
|||||||
return .destination(.taskManagement)
|
return .destination(.taskManagement)
|
||||||
case "task_create":
|
case "task_create":
|
||||||
return .destination(.taskCreate)
|
return .destination(.taskCreate)
|
||||||
|
case "cloud_management":
|
||||||
|
return .destination(.cloudStorage)
|
||||||
|
case "cloud_storage_transit":
|
||||||
|
return .destination(.cloudStorageTransit)
|
||||||
|
case "asset_management":
|
||||||
|
return .destination(.materialLibrary)
|
||||||
|
case "material_upload":
|
||||||
|
return .destination(.materialUpload)
|
||||||
case "store", "more_functions":
|
case "store", "more_functions":
|
||||||
return .destination(.moreFunctions)
|
return .destination(.moreFunctions)
|
||||||
case "fly", "pilot_controller":
|
case "fly", "pilot_controller":
|
||||||
@ -115,12 +123,8 @@ enum HomeMenuRouter {
|
|||||||
"pm",
|
"pm",
|
||||||
"pm_manager",
|
"pm_manager",
|
||||||
"project_edit",
|
"project_edit",
|
||||||
"cloud_management",
|
|
||||||
"cloud_storage_transit",
|
|
||||||
"album_list",
|
"album_list",
|
||||||
"album_trailer",
|
"album_trailer",
|
||||||
"asset_management",
|
|
||||||
"material_upload",
|
|
||||||
"sample_management",
|
"sample_management",
|
||||||
"sample_upload",
|
"sample_upload",
|
||||||
"live_stream_management",
|
"live_stream_management",
|
||||||
|
|||||||
@ -36,6 +36,14 @@ extension HomeRoute {
|
|||||||
TaskCreateView()
|
TaskCreateView()
|
||||||
case .taskDetail(let id, let summary):
|
case .taskDetail(let id, let summary):
|
||||||
TaskDetailView(taskId: id, summary: summary)
|
TaskDetailView(taskId: id, summary: summary)
|
||||||
|
case .cloudStorage:
|
||||||
|
CloudStorageView()
|
||||||
|
case .cloudStorageTransit:
|
||||||
|
CloudStorageTransitView()
|
||||||
|
case .materialLibrary:
|
||||||
|
MediaLibraryView()
|
||||||
|
case .materialUpload:
|
||||||
|
MediaLibraryUploadView()
|
||||||
case let .modulePlaceholder(uri, title):
|
case let .modulePlaceholder(uri, title):
|
||||||
HomeMigrationModuleView(title: title, uri: uri)
|
HomeMigrationModuleView(title: title, uri: uri)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -283,76 +283,6 @@ struct UploadFileItem: Encodable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 云盘文件实体,表示发布任务时可选择的云盘文件或文件夹。
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据文件扩展名判断是否是常见视频格式。
|
|
||||||
private static func hasVideoExtension(_ value: String) -> Bool {
|
|
||||||
["mp4", "mov", "m4v", "avi"].contains(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(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 = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension KeyedDecodingContainer {
|
private extension KeyedDecodingContainer {
|
||||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||||
func decodeLossyString(forKey key: Key) throws -> String {
|
func decodeLossyString(forKey key: Key) throws -> String {
|
||||||
|
|||||||
188
suixinkanTests/Assets/AssetsAPITests.swift
Normal file
188
suixinkanTests/Assets/AssetsAPITests.swift
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
//
|
||||||
|
// AssetsAPITests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 资产 API 测试,覆盖云盘和素材接口请求以及宽松模型解码。
|
||||||
|
final class AssetsAPITests: XCTestCase {
|
||||||
|
/// 测试云盘列表接口使用正确 path 和 query。
|
||||||
|
func testCloudFileListUsesExpectedPathAndQuery() async throws {
|
||||||
|
let session = AssetsRecordingURLSession(data: Self.cloudListResponse)
|
||||||
|
let api = AssetsAPI(client: APIClient(session: session))
|
||||||
|
|
||||||
|
let payload = try await api.cloudFileList(parentFolderId: 9, name: " 照片 ", type: 2, orderBy: 1, page: 0, pageSize: 0)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/cloud-driver/list")
|
||||||
|
let query = assetsQueryItems(from: request)
|
||||||
|
XCTAssertEqual(query["parent_folder_id"], "9")
|
||||||
|
XCTAssertEqual(query["name"], " 照片 ")
|
||||||
|
XCTAssertEqual(query["type"], "2")
|
||||||
|
XCTAssertEqual(query["order_by"], "1")
|
||||||
|
XCTAssertEqual(query["page"], "1")
|
||||||
|
XCTAssertEqual(query["page_size"], "1")
|
||||||
|
XCTAssertEqual(payload.total, 2)
|
||||||
|
XCTAssertEqual(payload.list.dropFirst().first?.fileSize, 2048)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试云盘写操作使用正确 path 和 body。
|
||||||
|
func testCloudMutationRequestsUseExpectedBodies() async throws {
|
||||||
|
let session = AssetsRecordingURLSession(data: Self.emptyResponse)
|
||||||
|
let api = AssetsAPI(client: APIClient(session: session))
|
||||||
|
|
||||||
|
try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: 1, name: "新文件夹"))
|
||||||
|
try await api.cloudFileUpload(CloudFileUploadRequest(parentFolderId: 1, fileUrl: "https://cdn/a.jpg", fileName: "a.jpg"))
|
||||||
|
try await api.cloudFileDelete(CloudFileDeleteRequest(list: [CloudFileActionItem(id: 2, type: 99)]))
|
||||||
|
try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: 3, list: [CloudFileActionItem(id: 2, type: 2)]))
|
||||||
|
try await api.cloudFolderEdit(CloudFolderModifyRequest(id: 2, name: "改名"))
|
||||||
|
try await api.cloudFileEdit(CloudFileModifyRequest(id: 4, fileName: "b.jpg"))
|
||||||
|
|
||||||
|
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||||
|
"/api/yf-handset-app/photog/cloud-driver/folder-create",
|
||||||
|
"/api/yf-handset-app/photog/cloud-driver/file-upload",
|
||||||
|
"/api/yf-handset-app/photog/cloud-driver/delete",
|
||||||
|
"/api/yf-handset-app/photog/cloud-driver/move",
|
||||||
|
"/api/yf-handset-app/photog/cloud-driver/folder-edit",
|
||||||
|
"/api/yf-handset-app/photog/cloud-driver/file-edit"
|
||||||
|
])
|
||||||
|
let uploadBody = try bodyObject(from: session.requests[1])
|
||||||
|
XCTAssertEqual(uploadBody["parent_folder_id"] as? Int, 1)
|
||||||
|
XCTAssertEqual(uploadBody["file_url"] as? String, "https://cdn/a.jpg")
|
||||||
|
let moveBody = try bodyObject(from: session.requests[3])
|
||||||
|
XCTAssertEqual(moveBody["target_folder_id"] as? Int, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试云盘上传权限接口兼容字符串布尔值。
|
||||||
|
func testCloudUploadPermissionDecodesLossyFields() async throws {
|
||||||
|
let session = AssetsRecordingURLSession(data: Self.permissionResponse)
|
||||||
|
let api = AssetsAPI(client: APIClient(session: session))
|
||||||
|
|
||||||
|
let permission = try await api.checkCloudUploadPermission()
|
||||||
|
|
||||||
|
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/cloud-driver/check-upload-permission")
|
||||||
|
XCTAssertFalse(permission.canUpload)
|
||||||
|
XCTAssertEqual(permission.reason, "空间不足")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试素材列表和详情接口使用正确 path 并宽松解码字段。
|
||||||
|
func testMediaListAndDetailDecodeLossyFields() async throws {
|
||||||
|
let session = AssetsRecordingURLSession(responses: [Self.mediaListResponse, Self.mediaDetailResponse])
|
||||||
|
let api = AssetsAPI(client: APIClient(session: session))
|
||||||
|
|
||||||
|
let list = try await api.mediaAlbumList(kind: .material, keyword: "航拍", status: 1, page: 0, pageSize: 0)
|
||||||
|
let detail = try await api.mediaAlbumDetail(id: 7)
|
||||||
|
|
||||||
|
XCTAssertEqual(session.requests.first?.url?.path, "/api/app/media-album/list")
|
||||||
|
let query = assetsQueryItems(from: try XCTUnwrap(session.requests.first))
|
||||||
|
XCTAssertEqual(query["type"], "1")
|
||||||
|
XCTAssertEqual(query["status"], "1")
|
||||||
|
XCTAssertEqual(query["page"], "1")
|
||||||
|
XCTAssertEqual(query["page_size"], "1")
|
||||||
|
XCTAssertEqual(list.order?.totalNum, 12)
|
||||||
|
XCTAssertEqual(list.list.first?.likesCount, 3)
|
||||||
|
XCTAssertEqual(session.requests.last?.url?.path, "/api/app/media-album/detail")
|
||||||
|
XCTAssertEqual(detail.mediaList.first?.size, 4096)
|
||||||
|
XCTAssertTrue(detail.mediaList.first?.isVideo == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试素材写操作 path 和请求体。
|
||||||
|
func testMediaMutationRequestsUseExpectedBodies() async throws {
|
||||||
|
let session = AssetsRecordingURLSession(data: Self.emptyResponse)
|
||||||
|
let api = AssetsAPI(client: APIClient(session: session))
|
||||||
|
let upload = MediaAlbumUploadRequest(
|
||||||
|
name: "素材",
|
||||||
|
type: 1,
|
||||||
|
mediaType: 2,
|
||||||
|
mediaList: [MediaAlbumUploadItem(originalName: "a.jpg", ossUrl: "https://cdn/a.jpg", size: 10, fileWidthSize: MediaAlbumFileSize(width: 100, height: 80))],
|
||||||
|
coverUrl: "https://cdn/c.jpg",
|
||||||
|
coverSize: MediaAlbumFileSize(width: 100, height: 80),
|
||||||
|
scenicSpotId: 6,
|
||||||
|
description: "描述",
|
||||||
|
materialTag: "航拍",
|
||||||
|
projectId: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, listingStatus: 1))
|
||||||
|
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1))
|
||||||
|
try await api.mediaAlbumAddTag(MediaAlbumAddTagRequest(name: "航拍"))
|
||||||
|
try await api.mediaAlbumUpload(upload)
|
||||||
|
try await api.mediaAlbumEdit(MediaAlbumEditRequest(id: 2, name: "素材", type: 1, mediaType: 2, mediaList: upload.mediaList, coverUrl: upload.coverUrl, coverSize: upload.coverSize, scenicSpotId: 6, description: "描述", materialTag: "航拍"))
|
||||||
|
|
||||||
|
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||||
|
"/api/app/media-album/operation",
|
||||||
|
"/api/app/media-album/delete",
|
||||||
|
"/api/app/media-album/add-tag",
|
||||||
|
"/api/app/media-album/upload",
|
||||||
|
"/api/app/media-album/edit"
|
||||||
|
])
|
||||||
|
let body = try bodyObject(from: session.requests[3])
|
||||||
|
XCTAssertEqual(body["scenic_spot_id"] as? Int, 6)
|
||||||
|
XCTAssertEqual((body["media_list"] as? [[String: Any]])?.first?["oss_url"] as? String, "https://cdn/a.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static let emptyResponse = #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)!
|
||||||
|
fileprivate static let permissionResponse = #"{"code":100000,"msg":"success","data":{"can_upload":"0","reason":"空间不足"}}"#.data(using: .utf8)!
|
||||||
|
fileprivate static let cloudListResponse = """
|
||||||
|
{"code":100000,"msg":"success","data":{"total":"2","list":[
|
||||||
|
{"id":"1","parent_folder_id":0,"file_url":"","cover_url":"","updated_at":"2026","name":"文件夹","created_at":"2026","child_num":"3","type":"99","file_size":"0"},
|
||||||
|
{"id":2,"parent_folder_id":"0","file_url":"https://cdn/a.jpg","cover_url":"","updated_at":"2026","name":"a.jpg","created_at":"2026","child_num":0,"type":2,"file_size":"2048"}
|
||||||
|
]}}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
fileprivate static let mediaListResponse = """
|
||||||
|
{"code":100000,"msg":"success","data":{"total":"1","order":{"total_num":"12","avg_order_amount":"8.5","refund_total":1,"avg_change":"0.2"},"list":[
|
||||||
|
{"id":"7","name":"航拍","cover_url":"https://cdn/c.jpg","created_at":"2026","status":"1","download_count":"2","likes_count":"3","collect_count":"4","share_count":"5","scenic_spot_name":"东门","project_name":"项目","listing_status":"1"}
|
||||||
|
]}}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
fileprivate static let mediaDetailResponse = """
|
||||||
|
{"code":100000,"msg":"success","data":{"id":"7","name":"航拍","cover_url":"https://cdn/c.jpg","description":"描述","status":"1","listing_status":"1","likes_count":"3","download_count":"2","collect_count":"4","share_count":"5","uploader_name":"摄影师","created_at":"2026","scenic_name":"景区","project_name":"项目","media_list":[
|
||||||
|
{"id":"9","original_name":"v.mp4","oss_url":"https://cdn/v.mp4","thumbnail_url":"","type":"1","size":"4096","show_url":"https://cdn/v.mp4"}
|
||||||
|
]}}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 资产 API 测试用 URLSession,记录请求并返回固定响应。
|
||||||
|
private final class AssetsRecordingURLSession: URLSessionProtocol {
|
||||||
|
private var responses: [Data]
|
||||||
|
private(set) var requests: [URLRequest] = []
|
||||||
|
|
||||||
|
/// 初始化单一响应。
|
||||||
|
init(data: Data) {
|
||||||
|
responses = [data]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化顺序响应。
|
||||||
|
init(responses: [Data]) {
|
||||||
|
self.responses = responses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录请求并返回下一份响应。
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
requests.append(request)
|
||||||
|
let data = responses.isEmpty ? AssetsAPITests.emptyResponse : responses.removeFirst()
|
||||||
|
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
|
return (data, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从请求中提取 query 字典。
|
||||||
|
private func assetsQueryItems(from request: URLRequest) -> [String: String] {
|
||||||
|
guard
|
||||||
|
let url = request.url,
|
||||||
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将请求体解析成字典。
|
||||||
|
private func bodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||||
|
let data = try XCTUnwrap(request.httpBody)
|
||||||
|
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
}
|
||||||
364
suixinkanTests/Assets/AssetsViewModelTests.swift
Normal file
364
suixinkanTests/Assets/AssetsViewModelTests.swift
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
//
|
||||||
|
// AssetsViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 资产 ViewModel 测试,覆盖云盘和素材管理核心业务逻辑。
|
||||||
|
final class AssetsViewModelTests: XCTestCase {
|
||||||
|
/// 测试云盘无上传权限时不会继续上传 OSS 或写入云盘。
|
||||||
|
func testCloudUploadStopsWhenPermissionDenied() async {
|
||||||
|
let api = MockAssetsService()
|
||||||
|
api.uploadPermission = false
|
||||||
|
api.uploadReason = "空间不足"
|
||||||
|
let uploader = MockAssetsUploader()
|
||||||
|
let viewModel = CloudStorageViewModel()
|
||||||
|
|
||||||
|
let success = await viewModel.upload(
|
||||||
|
localFiles: [CloudLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)],
|
||||||
|
scenicId: 9,
|
||||||
|
api: api,
|
||||||
|
uploadService: uploader,
|
||||||
|
transferStore: CloudTransferStore()
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(success)
|
||||||
|
XCTAssertEqual(viewModel.errorMessage, "空间不足")
|
||||||
|
XCTAssertEqual(uploader.uploadedFiles.count, 0)
|
||||||
|
XCTAssertEqual(api.cloudUploadRequests.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试云盘上传会先上传 OSS,再登记云盘文件并刷新列表。
|
||||||
|
func testCloudUploadUploadsOSSBeforeCloudRecord() async {
|
||||||
|
let api = MockAssetsService()
|
||||||
|
let uploader = MockAssetsUploader()
|
||||||
|
let transferStore = CloudTransferStore()
|
||||||
|
let viewModel = CloudStorageViewModel()
|
||||||
|
|
||||||
|
let success = await viewModel.upload(
|
||||||
|
localFiles: [CloudLocalUploadFile(data: Data([1, 2]), fileName: "a.jpg", fileType: 2)],
|
||||||
|
scenicId: 9,
|
||||||
|
api: api,
|
||||||
|
uploadService: uploader,
|
||||||
|
transferStore: transferStore
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(success)
|
||||||
|
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"])
|
||||||
|
XCTAssertEqual(api.cloudUploadRequests.first?.fileUrl, "https://cdn.example.com/a.jpg")
|
||||||
|
XCTAssertEqual(transferStore.records.first?.status, .success)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试云盘分页、进入文件夹和返回文件夹。
|
||||||
|
func testCloudPaginationAndFolderNavigation() async {
|
||||||
|
let folder = CloudDriveFile(id: 2, parentFolderId: 0, fileUrl: "", name: "子目录", type: 99)
|
||||||
|
let api = MockAssetsService()
|
||||||
|
api.cloudPages = [
|
||||||
|
ListPayload(total: 2, list: [folder]),
|
||||||
|
ListPayload(total: 2, list: [CloudDriveFile(id: 3, parentFolderId: 0, fileUrl: "https://cdn/a.jpg", name: "a.jpg", type: 2)])
|
||||||
|
]
|
||||||
|
let viewModel = CloudStorageViewModel()
|
||||||
|
|
||||||
|
await viewModel.reload(api: api)
|
||||||
|
await viewModel.loadMore(api: api)
|
||||||
|
await viewModel.enterFolder(folder, api: api)
|
||||||
|
await viewModel.popToFolder(at: 0, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.cloudListRequests.first?.parentFolderId, 0)
|
||||||
|
XCTAssertEqual(api.cloudListRequests[1].page, 2)
|
||||||
|
XCTAssertEqual(api.cloudListRequests[2].parentFolderId, 2)
|
||||||
|
XCTAssertEqual(viewModel.currentFolderId, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试素材列表筛选和分页请求参数。
|
||||||
|
func testMediaListFilterAndPagination() async {
|
||||||
|
let api = MockAssetsService()
|
||||||
|
api.mediaListResponses = [
|
||||||
|
MediaLibraryListResponse.fixture(total: 2, ids: [1]),
|
||||||
|
MediaLibraryListResponse.fixture(total: 2, ids: [2])
|
||||||
|
]
|
||||||
|
let viewModel = MediaLibraryViewModel(kind: .material)
|
||||||
|
viewModel.keyword = " 航拍 "
|
||||||
|
viewModel.selectedAuditFilter = .approved
|
||||||
|
|
||||||
|
await viewModel.reload(api: api)
|
||||||
|
await viewModel.loadMore(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.mediaListRequests.first?.keyword, "航拍")
|
||||||
|
XCTAssertEqual(api.mediaListRequests.first?.status, 1)
|
||||||
|
XCTAssertEqual(api.mediaListRequests.last?.page, 2)
|
||||||
|
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试审核未通过素材禁止上下架。
|
||||||
|
func testRejectedMediaCannotToggleListing() async {
|
||||||
|
let api = MockAssetsService()
|
||||||
|
let viewModel = MediaLibraryViewModel()
|
||||||
|
let item = MediaLibraryItem.fixture(id: 1, status: 2, listingStatus: 0)
|
||||||
|
|
||||||
|
let success = await viewModel.toggleListing(item, api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(success)
|
||||||
|
XCTAssertEqual(api.mediaOperationRequests.count, 0)
|
||||||
|
XCTAssertEqual(viewModel.errorMessage, "审核通过后才能上下架")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试上传素材会先上传封面和媒体文件,再提交最终 OSS URL。
|
||||||
|
func testMediaUploadUploadsFilesBeforeSubmit() async {
|
||||||
|
let api = MockAssetsService()
|
||||||
|
let uploader = MockAssetsUploader()
|
||||||
|
let viewModel = MediaLibraryEditorViewModel()
|
||||||
|
viewModel.name = "航拍素材"
|
||||||
|
viewModel.selectedSpotId = 6
|
||||||
|
viewModel.tagsText = "航拍,夜景"
|
||||||
|
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
|
||||||
|
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
|
||||||
|
|
||||||
|
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
|
||||||
|
|
||||||
|
XCTAssertTrue(success)
|
||||||
|
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"])
|
||||||
|
XCTAssertEqual(api.mediaUploadRequests.first?.coverUrl, "https://cdn.example.com/cover.jpg")
|
||||||
|
XCTAssertEqual(api.mediaUploadRequests.first?.mediaList.first?.ossUrl, "https://cdn.example.com/a.jpg")
|
||||||
|
XCTAssertEqual(api.mediaUploadRequests.first?.materialTag, "航拍,夜景")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试素材上传失败时不会提交素材接口。
|
||||||
|
func testMediaUploadFailureDoesNotSubmit() async {
|
||||||
|
let api = MockAssetsService()
|
||||||
|
let uploader = MockAssetsUploader()
|
||||||
|
uploader.shouldFail = true
|
||||||
|
let viewModel = MediaLibraryEditorViewModel()
|
||||||
|
viewModel.name = "航拍素材"
|
||||||
|
viewModel.selectedSpotId = 6
|
||||||
|
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
|
||||||
|
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
|
||||||
|
|
||||||
|
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
|
||||||
|
|
||||||
|
XCTAssertFalse(success)
|
||||||
|
XCTAssertEqual(api.mediaUploadRequests.count, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 资产服务测试替身,记录请求并返回预设数据。
|
||||||
|
@MainActor
|
||||||
|
private final class MockAssetsService: AssetsServing {
|
||||||
|
struct CloudListRequest: Equatable {
|
||||||
|
let parentFolderId: Int
|
||||||
|
let name: String
|
||||||
|
let type: Int
|
||||||
|
let orderBy: Int
|
||||||
|
let page: Int
|
||||||
|
let pageSize: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MediaListRequest: Equatable {
|
||||||
|
let keyword: String
|
||||||
|
let status: Int?
|
||||||
|
let page: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var cloudPages: [ListPayload<CloudDriveFile>] = [ListPayload(total: 0, list: [])]
|
||||||
|
var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])]
|
||||||
|
var uploadPermission = true
|
||||||
|
var uploadReason = ""
|
||||||
|
var cloudListRequests: [CloudListRequest] = []
|
||||||
|
var cloudUploadRequests: [CloudFileUploadRequest] = []
|
||||||
|
var mediaListRequests: [MediaListRequest] = []
|
||||||
|
var mediaOperationRequests: [MediaAlbumOperationRequest] = []
|
||||||
|
var mediaUploadRequests: [MediaAlbumUploadRequest] = []
|
||||||
|
var mediaEditRequests: [MediaAlbumEditRequest] = []
|
||||||
|
|
||||||
|
/// 返回云盘文件列表。
|
||||||
|
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload<CloudDriveFile> {
|
||||||
|
cloudListRequests.append(CloudListRequest(parentFolderId: parentFolderId, name: name, type: type, orderBy: orderBy, page: page, pageSize: pageSize))
|
||||||
|
return cloudPages.isEmpty ? ListPayload(total: 0, list: []) : cloudPages.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建文件夹。
|
||||||
|
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {}
|
||||||
|
|
||||||
|
/// 登记云盘文件。
|
||||||
|
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws {
|
||||||
|
cloudUploadRequests.append(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除云盘文件。
|
||||||
|
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {}
|
||||||
|
|
||||||
|
/// 移动云盘文件。
|
||||||
|
func cloudFileMove(_ request: CloudFileMoveRequest) async throws {}
|
||||||
|
|
||||||
|
/// 重命名文件夹。
|
||||||
|
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {}
|
||||||
|
|
||||||
|
/// 重命名文件。
|
||||||
|
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {}
|
||||||
|
|
||||||
|
/// 检查上传权限。
|
||||||
|
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse {
|
||||||
|
try JSONDecoder().decode(CheckCloudUploadResponse.self, from: #"{"can_upload":\#(uploadPermission ? 1 : 0),"reason":"\#(uploadReason)"}"#.data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回素材列表。
|
||||||
|
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse {
|
||||||
|
mediaListRequests.append(MediaListRequest(keyword: keyword, status: status, page: page))
|
||||||
|
return mediaListResponses.isEmpty ? MediaLibraryListResponse.fixture(total: 0, ids: []) : mediaListResponses.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回素材详情。
|
||||||
|
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail {
|
||||||
|
MediaLibraryDetail.fixture(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新上下架。
|
||||||
|
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws {
|
||||||
|
mediaOperationRequests.append(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除素材。
|
||||||
|
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {}
|
||||||
|
|
||||||
|
/// 新增标签。
|
||||||
|
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws {}
|
||||||
|
|
||||||
|
/// 上传素材。
|
||||||
|
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
|
||||||
|
mediaUploadRequests.append(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑素材。
|
||||||
|
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws {
|
||||||
|
mediaEditRequests.append(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSS 上传测试替身,记录上传文件并返回固定 CDN URL。
|
||||||
|
@MainActor
|
||||||
|
private final class MockAssetsUploader: OSSUploadServing {
|
||||||
|
struct UploadedFile: Equatable {
|
||||||
|
let fileName: String
|
||||||
|
let fileType: Int
|
||||||
|
let scenicId: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadedFiles: [UploadedFile] = []
|
||||||
|
var shouldFail = false
|
||||||
|
|
||||||
|
/// 上传用户头像。
|
||||||
|
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传实名认证图片。
|
||||||
|
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传云盘文件。
|
||||||
|
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传相册文件。
|
||||||
|
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传任务附件。
|
||||||
|
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传打卡点图片。
|
||||||
|
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传景区申请图片。
|
||||||
|
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传银行卡图片。
|
||||||
|
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通用测试上传流程。
|
||||||
|
private func upload(fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
if shouldFail {
|
||||||
|
throw APIError.networkFailed("上传失败")
|
||||||
|
}
|
||||||
|
uploadedFiles.append(UploadedFile(fileName: fileName, fileType: fileType, scenicId: scenicId))
|
||||||
|
onProgress(100)
|
||||||
|
return "https://cdn.example.com/\(fileName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension MediaLibraryListResponse {
|
||||||
|
/// 创建素材列表测试数据。
|
||||||
|
static func fixture(total: Int, ids: [Int]) -> MediaLibraryListResponse {
|
||||||
|
let list = ids.map { MediaLibraryItem.fixture(id: $0, status: 1, listingStatus: 0) }
|
||||||
|
let payload = [
|
||||||
|
"total": total,
|
||||||
|
"list": list.map { ["id": $0.id, "name": $0.name, "status": $0.status, "listing_status": $0.listingStatus] },
|
||||||
|
"order": ["total_num": 0, "avg_order_amount": 0, "refund_total": 0, "avg_change": 0]
|
||||||
|
] as [String: Any]
|
||||||
|
let data = try! JSONSerialization.data(withJSONObject: payload)
|
||||||
|
return try! JSONDecoder().decode(MediaLibraryListResponse.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension MediaLibraryItem {
|
||||||
|
/// 创建素材列表项测试数据。
|
||||||
|
static func fixture(id: Int, status: Int, listingStatus: Int) -> MediaLibraryItem {
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"id": id,
|
||||||
|
"name": "素材\(id)",
|
||||||
|
"cover_url": "",
|
||||||
|
"created_at": "",
|
||||||
|
"status": status,
|
||||||
|
"download_count": 0,
|
||||||
|
"likes_count": 0,
|
||||||
|
"collect_count": 0,
|
||||||
|
"share_count": 0,
|
||||||
|
"scenic_spot_name": "",
|
||||||
|
"project_name": "",
|
||||||
|
"listing_status": listingStatus
|
||||||
|
]
|
||||||
|
let data = try! JSONSerialization.data(withJSONObject: payload)
|
||||||
|
return try! JSONDecoder().decode(MediaLibraryItem.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension MediaLibraryDetail {
|
||||||
|
/// 创建素材详情测试数据。
|
||||||
|
static func fixture(id: Int) -> MediaLibraryDetail {
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"id": id,
|
||||||
|
"name": "素材\(id)",
|
||||||
|
"cover_url": "",
|
||||||
|
"description": "",
|
||||||
|
"status": 1,
|
||||||
|
"listing_status": 0,
|
||||||
|
"likes_count": 0,
|
||||||
|
"download_count": 0,
|
||||||
|
"collect_count": 0,
|
||||||
|
"share_count": 0,
|
||||||
|
"uploader_name": "",
|
||||||
|
"created_at": "",
|
||||||
|
"media_list": [],
|
||||||
|
"scenic_name": "",
|
||||||
|
"project_name": ""
|
||||||
|
]
|
||||||
|
let data = try! JSONSerialization.data(withJSONObject: payload)
|
||||||
|
return try! JSONDecoder().decode(MediaLibraryDetail.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,6 +35,10 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management", title: ""), .destination(.taskManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management", title: ""), .destination(.taskManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management_editor", title: ""), .destination(.taskManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management_editor", title: ""), .destination(.taskManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_create", title: ""), .destination(.taskCreate))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_create", title: ""), .destination(.taskCreate))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_management", title: ""), .destination(.cloudStorage))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_storage_transit", title: ""), .destination(.cloudStorageTransit))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "asset_management", title: ""), .destination(.materialLibrary))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试已知但未迁移的首页路由会进入安全占位。
|
/// 测试已知但未迁移的首页路由会进入安全占位。
|
||||||
@ -47,6 +51,14 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""),
|
HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""),
|
||||||
.destination(.modulePlaceholder(uri: "scenic_settlement_review", title: "结算审核"))
|
.destination(.modulePlaceholder(uri: "scenic_settlement_review", title: "结算审核"))
|
||||||
)
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
HomeMenuRouter.resolve(uri: "album_list", title: ""),
|
||||||
|
.destination(.modulePlaceholder(uri: "album_list", title: "相册管理"))
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
HomeMenuRouter.resolve(uri: "sample_upload", title: ""),
|
||||||
|
.destination(.modulePlaceholder(uri: "sample_upload", title: "上传样片"))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
||||||
|
|||||||
Reference in New Issue
Block a user