diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 3d3b8b0..7399e74 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -29,6 +29,7 @@ struct RootView: View { @State private var walletAPI: WalletAPI @State private var scenicPermissionAPI: ScenicPermissionAPI @State private var taskAPI: TaskAPI + @State private var assetsAPI: AssetsAPI @State private var authSessionCoordinator: AuthSessionCoordinator @State private var sessionBootstrapper: SessionBootstrapper @@ -51,6 +52,7 @@ struct RootView: View { _walletAPI = State(initialValue: WalletAPI(client: apiClient)) _scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient)) _taskAPI = State(initialValue: TaskAPI(client: apiClient)) + _assetsAPI = State(initialValue: AssetsAPI(client: apiClient)) _authSessionCoordinator = State( initialValue: AuthSessionCoordinator( tokenStore: tokenStore, @@ -90,6 +92,7 @@ struct RootView: View { .environment(walletAPI) .environment(scenicPermissionAPI) .environment(taskAPI) + .environment(assetsAPI) .environment(authSessionCoordinator) .task { apiClient.bindAuthTokenProvider { appSession.token } diff --git a/suixinkan/Features/Assets/API/AssetsAPI.swift b/suixinkan/Features/Assets/API/AssetsAPI.swift new file mode 100644 index 0000000..542d9ff --- /dev/null +++ b/suixinkan/Features/Assets/API/AssetsAPI.swift @@ -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 + + /// 创建云盘文件夹。 + 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 { + 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 + } +} diff --git a/suixinkan/Features/Assets/Assets.md b/suixinkan/Features/Assets/Assets.md new file mode 100644 index 0000000..a89d969 --- /dev/null +++ b/suixinkan/Features/Assets/Assets.md @@ -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 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。 diff --git a/suixinkan/Features/Assets/Models/AssetsModels.swift b/suixinkan/Features/Assets/Models/AssetsModels.swift new file mode 100644 index 0000000..a4bfa6b --- /dev/null +++ b/suixinkan/Features/Assets/Models/AssetsModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift b/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift new file mode 100644 index 0000000..71410af --- /dev/null +++ b/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift @@ -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 { + 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: ",") + } +} diff --git a/suixinkan/Features/Assets/Views/CloudStorageViews.swift b/suixinkan/Features/Assets/Views/CloudStorageViews.swift new file mode 100644 index 0000000..a3e9910 --- /dev/null +++ b/suixinkan/Features/Assets/Views/CloudStorageViews.swift @@ -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 { + 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 + } +} diff --git a/suixinkan/Features/Assets/Views/MediaLibraryViews.swift b/suixinkan/Features/Assets/Views/MediaLibraryViews.swift new file mode 100644 index 0000000..a257d1b --- /dev/null +++ b/suixinkan/Features/Assets/Views/MediaLibraryViews.swift @@ -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 + } + } +} diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index 975c313..c66e893 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -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 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index e55f27f..71c2a10 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -32,6 +32,10 @@ enum HomeRoute: Hashable { case taskManagement case taskCreate case taskDetail(id: Int, summary: PhotographerTaskItem?) + case cloudStorage + case cloudStorageTransit + case materialLibrary + case materialUpload case modulePlaceholder(uri: String, title: String) } diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 8dc7ba4..4c00470 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -95,6 +95,14 @@ enum HomeMenuRouter { return .destination(.taskManagement) case "task_create": 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": return .destination(.moreFunctions) case "fly", "pilot_controller": @@ -115,12 +123,8 @@ enum HomeMenuRouter { "pm", "pm_manager", "project_edit", - "cloud_management", - "cloud_storage_transit", "album_list", "album_trailer", - "asset_management", - "material_upload", "sample_management", "sample_upload", "live_stream_management", diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index b35bfee..20b88a8 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -36,6 +36,14 @@ extension HomeRoute { TaskCreateView() case .taskDetail(let id, let summary): TaskDetailView(taskId: id, summary: summary) + case .cloudStorage: + CloudStorageView() + case .cloudStorageTransit: + CloudStorageTransitView() + case .materialLibrary: + MediaLibraryView() + case .materialUpload: + MediaLibraryUploadView() case let .modulePlaceholder(uri, title): HomeMigrationModuleView(title: title, uri: uri) } diff --git a/suixinkan/Features/Tasks/Models/TaskModels.swift b/suixinkan/Features/Tasks/Models/TaskModels.swift index a02dcc5..91760c8 100644 --- a/suixinkan/Features/Tasks/Models/TaskModels.swift +++ b/suixinkan/Features/Tasks/Models/TaskModels.swift @@ -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 { /// 将 String、数字和 Bool 宽松解码为字符串。 func decodeLossyString(forKey key: Key) throws -> String { diff --git a/suixinkanTests/Assets/AssetsAPITests.swift b/suixinkanTests/Assets/AssetsAPITests.swift new file mode 100644 index 0000000..0f302b5 --- /dev/null +++ b/suixinkanTests/Assets/AssetsAPITests.swift @@ -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]) +} diff --git a/suixinkanTests/Assets/AssetsViewModelTests.swift b/suixinkanTests/Assets/AssetsViewModelTests.swift new file mode 100644 index 0000000..6097162 --- /dev/null +++ b/suixinkanTests/Assets/AssetsViewModelTests.swift @@ -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] = [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 { + 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) + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 0930a20..c3f4ea1 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -35,6 +35,10 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management", 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: "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: ""), .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。