Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
373
suixinkan_ios/Features/Assets/API/AssetsAPI.swift
Normal file
373
suixinkan_ios/Features/Assets/API/AssetsAPI.swift
Normal file
@ -0,0 +1,373 @@
|
||||
//
|
||||
// AssetsAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 资产服务协议,抽象云盘、媒体库和相册接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
protocol AssetsServing {
|
||||
/// 获取云盘文件列表。
|
||||
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload<CloudDriveFile>
|
||||
|
||||
/// 创建云盘文件夹。
|
||||
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws
|
||||
|
||||
/// 登记已上传到 OSS 的云盘文件。
|
||||
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws
|
||||
|
||||
/// 删除云盘文件或文件夹。
|
||||
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws
|
||||
|
||||
/// 移动云盘文件或文件夹。
|
||||
func cloudFileMove(_ request: CloudFileMoveRequest) async throws
|
||||
|
||||
/// 重命名云盘文件夹。
|
||||
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws
|
||||
|
||||
/// 重命名云盘文件。
|
||||
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws
|
||||
|
||||
/// 检查当前账号是否允许上传云盘文件。
|
||||
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse
|
||||
|
||||
/// 获取素材或样片列表。
|
||||
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse
|
||||
|
||||
/// 获取素材或样片详情。
|
||||
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) 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
|
||||
|
||||
/// 获取样片上传可关联的项目列表。
|
||||
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem>
|
||||
|
||||
/// 获取相册文件夹列表。
|
||||
func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem>
|
||||
|
||||
/// 获取相册文件夹详情。
|
||||
func albumFolderInfo(id: Int) async throws -> AlbumFolderItem
|
||||
|
||||
/// 获取相册内文件列表。
|
||||
func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload<AlbumFileItem>
|
||||
|
||||
/// 新建相册文件夹。
|
||||
func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws
|
||||
|
||||
/// 编辑相册文件夹名称、备注或封面。
|
||||
func editAlbumFolder(folderId: Int, coverFileId: Int?, name: String?, remark: String?) async throws
|
||||
|
||||
/// 删除相册内文件。
|
||||
func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws
|
||||
|
||||
/// 登记已上传到 OSS 的相册文件。
|
||||
func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws
|
||||
}
|
||||
|
||||
/// 资产 API,负责封装相册云盘和素材管理相关接口。
|
||||
@MainActor
|
||||
final class AssetsAPI: AssetsServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化资产 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取云盘文件列表。
|
||||
func cloudFileList(
|
||||
parentFolderId: Int,
|
||||
name: String = "",
|
||||
type: Int = 0,
|
||||
orderBy: Int = 2,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ListPayload<CloudDriveFile> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/cloud-driver/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "parent_folder_id", value: "\(parentFolderId)"),
|
||||
URLQueryItem(name: "name", value: name),
|
||||
URLQueryItem(name: "type", value: "\(type)"),
|
||||
URLQueryItem(name: "order_by", value: "\(orderBy)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建云盘文件夹。
|
||||
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-create", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 登记已上传到 OSS 的云盘文件。
|
||||
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-upload", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除云盘文件或文件夹。
|
||||
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/delete", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 移动云盘文件或文件夹。
|
||||
func cloudFileMove(_ request: CloudFileMoveRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/move", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 重命名云盘文件夹。
|
||||
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-edit", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 重命名云盘文件。
|
||||
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-edit", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 检查当前账号是否允许上传云盘文件。
|
||||
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/cloud-driver/check-upload-permission")
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取素材或样片列表。
|
||||
func mediaAlbumList(
|
||||
kind: MediaLibraryKind = .material,
|
||||
keyword: String = "",
|
||||
status: Int? = nil,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
) async throws -> MediaLibraryListResponse {
|
||||
var query = [
|
||||
URLQueryItem(name: "type", value: "\(kind.rawValue)"),
|
||||
URLQueryItem(name: "keyword", value: keyword),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let status, status >= 0 {
|
||||
query.append(URLQueryItem(name: "status", value: "\(status)"))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: query)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取素材或样片详情。
|
||||
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind = .material) async throws -> MediaLibraryDetail {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/media-album/detail",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "id", value: "\(id)"),
|
||||
URLQueryItem(name: "type", value: "\(kind.rawValue)")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新素材或样片上下架状态。
|
||||
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
|
||||
}
|
||||
|
||||
/// 获取样片上传可关联的项目列表。
|
||||
func projectList(
|
||||
scenicId: Int,
|
||||
name: String? = nil,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 50
|
||||
) async throws -> ListPayload<PhotographerProjectItem> {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let name, !name.isEmpty {
|
||||
query.append(URLQueryItem(name: "name", value: name))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: query)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取相册文件夹列表。
|
||||
func albumFolderList(
|
||||
scenicId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10,
|
||||
name: String = "",
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil
|
||||
) async throws -> ListPayload<AlbumFolderItem> {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"),
|
||||
URLQueryItem(name: "cloud_folder_type", value: "1")
|
||||
]
|
||||
if !name.isEmpty {
|
||||
query.append(URLQueryItem(name: "name", value: name))
|
||||
}
|
||||
if let startTime, !startTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime, !endTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/album/folder-list", queryItems: query)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取相册文件夹详情。
|
||||
func albumFolderInfo(id: Int) async throws -> AlbumFolderItem {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/album/folder-info",
|
||||
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取相册内文件列表。
|
||||
func albumFileList(
|
||||
scenicId: Int,
|
||||
folderId: Int,
|
||||
fileType: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ListPayload<AlbumFileItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/album/file-list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "folder_id", value: "\(folderId)"),
|
||||
URLQueryItem(name: "file_type", value: "\(fileType)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 新建相册文件夹。
|
||||
func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/album/folder-add",
|
||||
body: AlbumFolderAddRequest(
|
||||
scenicId: "\(scenicId)",
|
||||
name: name,
|
||||
remark: remark,
|
||||
cloudFolderType: 1
|
||||
)
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 编辑相册文件夹名称、备注或封面。
|
||||
func editAlbumFolder(folderId: Int, coverFileId: Int? = nil, name: String? = nil, remark: String? = nil) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/album/folder-edit",
|
||||
body: AlbumFolderEditRequest(folderId: folderId, coverFileId: coverFileId, name: name, remark: remark)
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除相册内文件。
|
||||
func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/album/file-delete",
|
||||
body: AlbumFileDeleteRequest(idList: idList, folderId: folderId)
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 登记已上传到 OSS 的相册文件。
|
||||
func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/album/file-upload-url",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "file_url", value: fileURL),
|
||||
URLQueryItem(name: "folder_id", value: "\(folderId)")
|
||||
]
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
}
|
||||
90
suixinkan_ios/Features/Assets/Assets.md
Normal file
90
suixinkan_ios/Features/Assets/Assets.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Assets 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Assets 模块负责首页中的相册、相册云盘、素材管理和样片管理入口。
|
||||
|
||||
- `album_list` 进入 `AlbumListView`。
|
||||
- `album_trailer` 进入 `AlbumTrailerEntryView`。
|
||||
- `cloud_management` 进入 `CloudStorageView`。
|
||||
- `cloud_storage_transit` 进入 `CloudStorageTransitView`。
|
||||
- `asset_management` 进入 `MediaLibraryView(kind: .material)`。
|
||||
- `material_upload` 进入 `MediaLibraryUploadView`。
|
||||
- `sample_management` 进入 `MediaLibraryView(kind: .sample)`。
|
||||
- `sample_upload` 进入 `MediaLibraryUploadView(kind: .sample)`。
|
||||
|
||||
样片复用媒体库列表、详情、上下架、删除和 OSS 上传链路;上传样片额外要求选择关联项目。
|
||||
|
||||
## 相册管理逻辑
|
||||
|
||||
`AlbumListViewModel` 只保存相册列表、搜索条件、日期筛选、分页和创建状态。缺少当前景区时清空列表并停止请求接口。
|
||||
|
||||
相册列表流程:
|
||||
|
||||
1. 页面从 `AccountContext.currentScenic` 读取当前景区 ID。
|
||||
2. 调用 `albumFolderList` 获取 `cloud_folder_type = 1` 的相册文件夹。
|
||||
3. 支持按相册名称、开始时间、结束时间筛选。
|
||||
4. 新建相册调用 `addAlbumFolder`,成功后刷新第一页。
|
||||
|
||||
`AlbumDetailViewModel` 管理单个相册的信息和文件列表。图片和视频通过 `AlbumFileTab` 区分,图片使用 `file_type = 2`,视频使用 `file_type = 1`。详情页支持编辑相册名称、编辑备注、设置封面和删除相册文件,操作成功后刷新详情。
|
||||
|
||||
相册文件预览使用 `RemoteImage` 展示图片,视频使用系统 `VideoPlayer` 播放。
|
||||
|
||||
## 相册预览上传逻辑
|
||||
|
||||
`AlbumTrailerViewModel` 管理相册选择、本地图片/视频选择、上传进度和提交状态。
|
||||
|
||||
上传流程:
|
||||
|
||||
1. 页面加载当前景区下的相册列表。
|
||||
2. 用户通过 `PhotosPicker` 选择图片或视频。
|
||||
3. 提交时先调用 `OSSUploadService.uploadAlbumFile` 上传到 OSS。
|
||||
4. 上传成功后调用 `albumFileUploadURL` 把文件 URL 写入相册。
|
||||
5. 任一文件上传失败时停止后续入库,并保留错误提示。
|
||||
|
||||
本地文件数据、OSS STS 和上传进度只保存在当前上传流程内,不落盘。
|
||||
|
||||
## 云盘逻辑
|
||||
|
||||
`CloudStorageViewModel` 只保存云盘模块内状态,包括目录路径、文件列表、筛选、排序、分页和当前操作状态。
|
||||
|
||||
云盘上传流程:
|
||||
|
||||
1. 页面通过 `PhotosPicker` 选择图片或视频。
|
||||
2. `CloudStorageViewModel` 调用 `checkCloudUploadPermission`。
|
||||
3. 权限通过后调用 `OSSUploadService.uploadCloudFile` 上传到 OSS。
|
||||
4. 上传成功后调用 `cloudFileUpload` 把文件 URL 写入云盘。
|
||||
5. 刷新当前目录列表。
|
||||
|
||||
下载文件只保存到 App 沙盒 `Documents/CloudDownloads`,不自动写入系统相册。上传/下载记录由 `CloudTransferStore` 保存,生命周期仅限本次 App 会话,不落盘。
|
||||
|
||||
## 素材/样片管理逻辑
|
||||
|
||||
`MediaLibraryViewModel` 按 `MediaLibraryKind` 管理素材或样片列表、关键词、审核状态、分页、详情、删除和上下架。素材接口使用 `type = 1`,样片接口使用 `type = 2`。
|
||||
|
||||
媒体库上下架规则:
|
||||
|
||||
- 审核通过的素材或样片可以上下架。
|
||||
- 审核未通过或待审核的素材或样片禁止上下架。
|
||||
- 操作成功后刷新当前媒体库列表。
|
||||
|
||||
`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验名称、当前景区、打卡点、封面、媒体文件和标签长度。样片上传还会加载当前景区下的项目列表,并强制选择关联项目。
|
||||
|
||||
素材/样片上传流程:
|
||||
|
||||
1. 页面通过 `PhotosPicker` 选择封面和媒体文件。
|
||||
2. 打卡点来源于 `ScenicSpotContext.spots`。
|
||||
3. 样片上传从 `projectList` 获取可关联项目,提交时携带 `project_id`。
|
||||
4. 提交时先把封面和媒体文件上传 OSS。
|
||||
5. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID、标签和项目 ID 提交给媒体库接口。
|
||||
6. 上传失败时不提交媒体库接口。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
相册列表、相册文件列表、云盘文件列表、素材列表、样片列表、项目选择、上传进度、下载记录、OSS STS、本地文件数据和媒体库表单都不进入 `AppSession`、`AccountContext` 或 TabBar 状态。
|
||||
|
||||
图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data`。
|
||||
|
||||
## 测试要求
|
||||
|
||||
新增相册、云盘、素材或样片逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。
|
||||
1021
suixinkan_ios/Features/Assets/Models/AssetsModels.swift
Normal file
1021
suixinkan_ios/Features/Assets/Models/AssetsModels.swift
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,231 @@
|
||||
//
|
||||
// AssetsViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 云盘列表页。
|
||||
final class CloudStorageViewController: ModuleTableViewController {
|
||||
private let viewModel = CloudStorageViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "云盘"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "传输",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(openTransit)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.files.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let file = viewModel.files[indexPath.row]
|
||||
cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI)
|
||||
}
|
||||
|
||||
override func willDisplayTableRow(at indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.files.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(api: services.assetsAPI) }
|
||||
}
|
||||
|
||||
@objc private func openTransit() {
|
||||
navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension CloudStorageViewModel: ViewModelBindable {}
|
||||
|
||||
/// 云盘传输记录页。
|
||||
final class CloudStorageTransitViewController: ModuleTableViewController {
|
||||
private let store = CloudTransferStore.shared
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "传输记录"
|
||||
super.viewDidLoad()
|
||||
store.onChange = { [weak self] in self?.reloadTable() }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { store.records.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let record = store.records[indexPath.row]
|
||||
cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%")
|
||||
}
|
||||
|
||||
override func reloadContent() async {}
|
||||
}
|
||||
|
||||
enum MediaLibraryKindRoute {
|
||||
case material
|
||||
case sample
|
||||
}
|
||||
|
||||
/// 素材库 / 样片库列表页。
|
||||
final class MediaLibraryViewController: ModuleTableViewController {
|
||||
private let kind: MediaLibraryKindRoute
|
||||
private let viewModel: MediaLibraryViewModel
|
||||
|
||||
init(kind: MediaLibraryKindRoute = .material) {
|
||||
self.kind = kind
|
||||
switch kind {
|
||||
case .material:
|
||||
viewModel = MediaLibraryViewModel(kind: .material)
|
||||
case .sample:
|
||||
viewModel = MediaLibraryViewModel(kind: .sample)
|
||||
}
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = kind == .sample ? "样片库" : "素材库"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "上传",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(openUpload)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI)
|
||||
}
|
||||
|
||||
@objc private func openUpload() {
|
||||
navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaLibraryViewModel: ViewModelBindable {}
|
||||
|
||||
/// 素材 / 样片上传页。
|
||||
final class MediaLibraryUploadViewController: ModuleTableViewController {
|
||||
private let kind: MediaLibraryKindRoute
|
||||
private let viewModel = MediaLibraryEditorViewModel()
|
||||
private let nameField = UITextField()
|
||||
|
||||
init(kind: MediaLibraryKindRoute) {
|
||||
self.kind = kind
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = kind == .sample ? "上传样片" : "上传素材"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "提交",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(submit)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
nameField.placeholder = "素材名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableHeaderView = nameField
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.projects.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let project = viewModel.projects[indexPath.row]
|
||||
cell.configure(title: project.name, subtitle: project.statusName)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI)
|
||||
}
|
||||
|
||||
@objc private func submit() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
Task {
|
||||
let mediaKind: MediaLibraryKind = kind == .sample ? .sample : .material
|
||||
let success = await viewModel.submit(
|
||||
kind: mediaKind,
|
||||
scenicId: services.currentScenicId,
|
||||
api: services.assetsAPI,
|
||||
uploadService: services.ossUploadService
|
||||
)
|
||||
if success { navigationController?.popViewController(animated: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaLibraryEditorViewModel: ViewModelBindable {}
|
||||
|
||||
/// 相册列表页。
|
||||
final class AlbumListViewController: ModuleTableViewController {
|
||||
private let viewModel = AlbumListViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "相册"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
}
|
||||
|
||||
extension AlbumListViewModel: ViewModelBindable {}
|
||||
|
||||
/// 相册预告页。
|
||||
final class AlbumTrailerViewController: ModuleTableViewController {
|
||||
private let viewModel = AlbumTrailerViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "相册预告"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
}
|
||||
|
||||
extension AlbumTrailerViewModel: ViewModelBindable {}
|
||||
974
suixinkan_ios/Features/Assets/ViewModels/AssetsViewModels.swift
Normal file
974
suixinkan_ios/Features/Assets/ViewModels/AssetsViewModels.swift
Normal file
@ -0,0 +1,974 @@
|
||||
//
|
||||
// AssetsViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。
|
||||
@MainActor
|
||||
final class CloudTransferStore {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
static let shared = CloudTransferStore()
|
||||
|
||||
private(set) var records: [CloudTransferItem] = [] { didSet { onChange?() } }
|
||||
|
||||
/// 新增一条传输记录并返回记录 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
|
||||
final class CloudStorageViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] { didSet { onChange?() } }
|
||||
var files: [CloudDriveFile] = [] { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var selectedFilter: CloudDriveFilter = .all { didSet { onChange?() } }
|
||||
var selectedSort: CloudDriveSort = .updatedDesc { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var isMutating = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
/// 当前目录 ID。
|
||||
var currentFolderId: Int {
|
||||
path.last?.id ?? 0
|
||||
}
|
||||
|
||||
/// 判断当前目录是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
files.count < total
|
||||
}
|
||||
|
||||
/// 重新加载当前目录第一页。
|
||||
func reload(api: any AssetsServing) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await requestFiles(api: api, page: 1)
|
||||
page = 1
|
||||
files = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
files = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载当前目录下一页。
|
||||
func loadMore(api: any AssetsServing) async {
|
||||
guard hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let nextPage = page + 1
|
||||
let payload = try await requestFiles(api: api, page: nextPage)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
files.append(contentsOf: payload.list)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 进入指定文件夹并刷新列表。
|
||||
func enterFolder(_ folder: CloudDriveFile, api: any AssetsServing) async {
|
||||
guard folder.isFolder else { return }
|
||||
path.append(folder)
|
||||
await reload(api: api)
|
||||
}
|
||||
|
||||
/// 回到指定面包屑目录并刷新列表。
|
||||
func popToFolder(at index: Int, api: any AssetsServing) async {
|
||||
guard path.indices.contains(index) else { return }
|
||||
path = Array(path.prefix(index + 1))
|
||||
await reload(api: api)
|
||||
}
|
||||
|
||||
/// 创建文件夹并刷新当前目录。
|
||||
func createFolder(name: String, api: any AssetsServing) async -> Bool {
|
||||
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !folderName.isEmpty else {
|
||||
errorMessage = "请输入文件夹名称"
|
||||
return false
|
||||
}
|
||||
return await mutate(api: api) {
|
||||
try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: currentFolderId, name: folderName))
|
||||
}
|
||||
}
|
||||
|
||||
/// 重命名文件或文件夹并刷新当前目录。
|
||||
func rename(_ item: CloudDriveFile, newName: String, api: any AssetsServing) async -> Bool {
|
||||
let fileName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !fileName.isEmpty else {
|
||||
errorMessage = "请输入名称"
|
||||
return false
|
||||
}
|
||||
return await mutate(api: api) {
|
||||
if item.isFolder {
|
||||
try await api.cloudFolderEdit(CloudFolderModifyRequest(id: item.id, name: fileName))
|
||||
} else {
|
||||
try await api.cloudFileEdit(CloudFileModifyRequest(id: item.id, fileName: fileName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除文件或文件夹并刷新当前目录。
|
||||
func delete(_ item: CloudDriveFile, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api) {
|
||||
let action = CloudFileActionItem(id: item.id, type: item.type)
|
||||
try await api.cloudFileDelete(CloudFileDeleteRequest(list: [action]))
|
||||
}
|
||||
}
|
||||
|
||||
/// 移动文件或文件夹并刷新当前目录。
|
||||
func move(_ item: CloudDriveFile, targetFolderId: Int, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api) {
|
||||
let action = CloudFileActionItem(id: item.id, type: item.type)
|
||||
try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: targetFolderId, list: [action]))
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传本地文件到 OSS 后写入云盘。
|
||||
func upload(
|
||||
localFiles: [CloudLocalUploadFile],
|
||||
scenicId: Int?,
|
||||
api: any AssetsServing,
|
||||
uploadService: any OSSUploadServing,
|
||||
transferStore: CloudTransferStore
|
||||
) async -> Bool {
|
||||
guard let scenicId else {
|
||||
errorMessage = "缺少当前景区,无法上传"
|
||||
return false
|
||||
}
|
||||
guard !localFiles.isEmpty else { return true }
|
||||
|
||||
isMutating = true
|
||||
defer { isMutating = false }
|
||||
|
||||
do {
|
||||
let permission = try await api.checkCloudUploadPermission()
|
||||
guard permission.canUpload else {
|
||||
let reason = permission.reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
errorMessage = reason.isEmpty ? "当前账号暂无上传权限" : reason
|
||||
return false
|
||||
}
|
||||
|
||||
for file in localFiles {
|
||||
let transferId = transferStore.start(fileName: file.fileName, direction: .upload)
|
||||
do {
|
||||
let url = try await uploadService.uploadCloudFile(
|
||||
data: file.data,
|
||||
fileName: file.fileName,
|
||||
fileType: file.fileType,
|
||||
scenicId: scenicId
|
||||
) { progress in
|
||||
Task { @MainActor in
|
||||
transferStore.update(id: transferId, progress: progress)
|
||||
}
|
||||
}
|
||||
try await api.cloudFileUpload(
|
||||
CloudFileUploadRequest(parentFolderId: currentFolderId, fileUrl: url, fileName: file.fileName)
|
||||
)
|
||||
transferStore.succeed(id: transferId)
|
||||
} catch {
|
||||
transferStore.fail(id: transferId, message: error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await reload(api: api)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一次云盘变更操作并刷新当前目录。
|
||||
private func mutate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
|
||||
isMutating = true
|
||||
defer { isMutating = false }
|
||||
do {
|
||||
try await operation()
|
||||
await reload(api: api)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装并发送云盘文件列表请求。
|
||||
private func requestFiles(api: any AssetsServing, page: Int) async throws -> ListPayload<CloudDriveFile> {
|
||||
try await api.cloudFileList(
|
||||
parentFolderId: currentFolderId,
|
||||
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
type: selectedFilter.rawValue,
|
||||
orderBy: selectedSort.rawValue,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 媒体库 ViewModel,负责素材或样片列表、筛选、分页、详情和上下架。
|
||||
@MainActor
|
||||
final class MediaLibraryViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
let kind: MediaLibraryKind
|
||||
var items: [MediaLibraryItem] = [] { didSet { onChange?() } }
|
||||
var selectedDetail: MediaLibraryDetail? { didSet { onChange?() } }
|
||||
var orderInfo: MediaLibraryOrderInfo? { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var keyword = "" { didSet { onChange?() } }
|
||||
var selectedAuditFilter: MediaLibraryAuditFilter = .all { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var isLoadingDetail = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
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, kind: kind)
|
||||
} 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, type: kind.rawValue, 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, type: kind.rawValue, listingStatus: target.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除素材或样片并刷新列表。
|
||||
func delete(id: Int, api: any AssetsServing) async -> Bool {
|
||||
await operate(api: api) {
|
||||
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id, type: kind.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行媒体库变更操作并刷新列表。
|
||||
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
|
||||
final class MediaLibraryEditorViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var description = "" { didSet { onChange?() } }
|
||||
var selectedSpotId: Int? { didSet { onChange?() } }
|
||||
var selectedProjectId: Int? { didSet { onChange?() } }
|
||||
var projects: [PhotographerProjectItem] = [] { didSet { onChange?() } }
|
||||
var tagsText = "" { didSet { onChange?() } }
|
||||
var coverFile: MediaLocalUploadFile? { didSet { onChange?() } }
|
||||
var mediaFiles: [MediaLocalUploadFile] = [] { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
var isLoadingProjects = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var didSubmitSuccessfully = false { didSet { onChange?() } }
|
||||
|
||||
private let editingId: Int?
|
||||
|
||||
/// 初始化上传或编辑素材表单。
|
||||
init(detail: MediaLibraryDetail? = nil) {
|
||||
editingId = detail?.id
|
||||
name = detail?.name ?? ""
|
||||
description = detail?.description ?? ""
|
||||
}
|
||||
|
||||
/// 判断当前是否处于编辑模式。
|
||||
var isEditing: Bool {
|
||||
editingId != nil
|
||||
}
|
||||
|
||||
/// 加载样片上传可关联的项目列表。
|
||||
func loadProjects(scenicId: Int?, api: any AssetsServing) async {
|
||||
guard let scenicId else {
|
||||
projects = []
|
||||
errorMessage = "缺少当前景区,无法加载项目"
|
||||
return
|
||||
}
|
||||
isLoadingProjects = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingProjects = false }
|
||||
|
||||
do {
|
||||
let payload = try await api.projectList(scenicId: scenicId, name: nil, page: 1, pageSize: 50)
|
||||
projects = payload.list
|
||||
if let selectedProjectId, !projects.contains(where: { $0.id == selectedProjectId }) {
|
||||
self.selectedProjectId = nil
|
||||
}
|
||||
} catch {
|
||||
projects = []
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加封面本地文件。
|
||||
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(kind: kind, 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: kind == .sample ? (selectedProjectId ?? 0) : 0
|
||||
)
|
||||
try await api.mediaAlbumUpload(request)
|
||||
}
|
||||
didSubmitSuccessfully = true
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验素材或样片表单。
|
||||
private func validate(kind: MediaLibraryKind, scenicId: Int?) -> Bool {
|
||||
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard scenicId != nil else {
|
||||
errorMessage = "缺少当前景区,无法提交\(kind == .sample ? "样片" : "素材")"
|
||||
return false
|
||||
}
|
||||
guard !title.isEmpty else {
|
||||
errorMessage = "请输入\(kind == .sample ? "样片" : "素材")名称"
|
||||
return false
|
||||
}
|
||||
guard selectedSpotId != nil else {
|
||||
errorMessage = "请选择打卡点"
|
||||
return false
|
||||
}
|
||||
guard kind != .sample || selectedProjectId != nil else {
|
||||
errorMessage = "请选择关联项目"
|
||||
return false
|
||||
}
|
||||
guard coverFile != nil else {
|
||||
errorMessage = "请选择封面"
|
||||
return false
|
||||
}
|
||||
guard !mediaFiles.isEmpty else {
|
||||
errorMessage = "请选择\(kind == .sample ? "样片" : "素材")文件"
|
||||
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: ",")
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册列表 ViewModel,负责相册文件夹搜索、日期筛选、分页和新建相册。
|
||||
@MainActor
|
||||
final class AlbumListViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var folders: [AlbumFolderItem] = [] { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var startTime = "" { didSet { onChange?() } }
|
||||
var endTime = "" { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var isMutating = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
/// 判断当前相册列表是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
folders.count < total
|
||||
}
|
||||
|
||||
/// 重新加载相册第一页。
|
||||
func reload(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
folders = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = "缺少当前景区,无法加载相册"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await requestFolders(api: api, scenicId: scenicId, page: 1)
|
||||
self.page = 1
|
||||
folders = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
folders = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载相册下一页。
|
||||
func loadMore(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId, hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let nextPage = page + 1
|
||||
let payload = try await requestFolders(api: api, scenicId: scenicId, page: nextPage)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
folders.append(contentsOf: payload.list)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建相册并刷新列表。
|
||||
func createFolder(name: String, remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let scenicId else {
|
||||
errorMessage = "缺少当前景区,无法创建相册"
|
||||
return false
|
||||
}
|
||||
guard !folderName.isEmpty else {
|
||||
errorMessage = "请输入相册名称"
|
||||
return false
|
||||
}
|
||||
guard !isMutating else { return false }
|
||||
|
||||
isMutating = true
|
||||
defer { isMutating = false }
|
||||
|
||||
do {
|
||||
try await api.addAlbumFolder(scenicId: scenicId, name: folderName, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装相册列表请求参数。
|
||||
private func requestFolders(api: any AssetsServing, scenicId: Int, page: Int) async throws -> ListPayload<AlbumFolderItem> {
|
||||
try await api.albumFolderList(
|
||||
scenicId: scenicId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
startTime: startTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty,
|
||||
endTime: endTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册详情 ViewModel,负责相册信息、图片/视频列表和文件操作。
|
||||
@MainActor
|
||||
final class AlbumDetailViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
let folderId: Int
|
||||
var folder: AlbumFolderItem? { didSet { onChange?() } }
|
||||
var files: [AlbumFileItem] = [] { didSet { onChange?() } }
|
||||
var selectedTab: AlbumFileTab = .image { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var isMutating = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
/// 初始化相册详情 ViewModel。
|
||||
init(folderId: Int, summary: AlbumFolderItem? = nil) {
|
||||
self.folderId = folderId
|
||||
folder = summary
|
||||
}
|
||||
|
||||
/// 判断当前文件列表是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
files.count < total
|
||||
}
|
||||
|
||||
/// 重新加载相册信息和当前 Tab 文件列表。
|
||||
func reload(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
files = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = "缺少当前景区,无法加载相册内容"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
folder = try await api.albumFolderInfo(id: folderId)
|
||||
let listPayload = try await api.albumFileList(
|
||||
scenicId: scenicId,
|
||||
folderId: folderId,
|
||||
fileType: selectedTab.rawValue,
|
||||
page: 1,
|
||||
pageSize: pageSize
|
||||
)
|
||||
files = listPayload.list
|
||||
total = listPayload.total
|
||||
page = 1
|
||||
} catch {
|
||||
page = 1
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换图片或视频列表并重新加载第一页。
|
||||
func selectTab(_ tab: AlbumFileTab, api: any AssetsServing, scenicId: Int?) async {
|
||||
guard selectedTab != tab else { return }
|
||||
selectedTab = tab
|
||||
files = []
|
||||
total = 0
|
||||
page = 1
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
}
|
||||
|
||||
/// 加载当前 Tab 下一页文件。
|
||||
func loadMore(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId, hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let nextPage = page + 1
|
||||
let payload = try await api.albumFileList(
|
||||
scenicId: scenicId,
|
||||
folderId: folderId,
|
||||
fileType: selectedTab.rawValue,
|
||||
page: nextPage,
|
||||
pageSize: pageSize
|
||||
)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
files.append(contentsOf: payload.list)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除相册文件并刷新当前列表。
|
||||
func delete(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api, scenicId: scenicId) {
|
||||
try await api.deleteAlbumFiles(folderId: folderId, idList: [file.id])
|
||||
}
|
||||
}
|
||||
|
||||
/// 将指定相册文件设为封面。
|
||||
func setCover(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api, scenicId: scenicId) {
|
||||
try await api.editAlbumFolder(folderId: folderId, coverFileId: file.id, name: nil, remark: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新相册名称。
|
||||
func rename(name: String, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !folderName.isEmpty else {
|
||||
errorMessage = "请输入相册名称"
|
||||
return false
|
||||
}
|
||||
return await mutate(api: api, scenicId: scenicId) {
|
||||
try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: folderName, remark: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新相册备注。
|
||||
func updateRemark(_ remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool {
|
||||
await mutate(api: api, scenicId: scenicId) {
|
||||
try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: nil, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行相册变更操作并刷新详情。
|
||||
private func mutate(api: any AssetsServing, scenicId: Int?, operation: () async throws -> Void) async -> Bool {
|
||||
guard !isMutating else { return false }
|
||||
guard scenicId != nil else {
|
||||
errorMessage = "缺少当前景区,无法操作相册"
|
||||
return false
|
||||
}
|
||||
isMutating = true
|
||||
defer { isMutating = false }
|
||||
|
||||
do {
|
||||
try await operation()
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册预览上传 ViewModel,负责选择相册、本地文件上传和相册入库。
|
||||
@MainActor
|
||||
final class AlbumTrailerViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var folders: [AlbumFolderItem] = [] { didSet { onChange?() } }
|
||||
var selectedFolderId: Int? { didSet { onChange?() } }
|
||||
var localFiles: [AlbumLocalUploadFile] = [] { didSet { onChange?() } }
|
||||
var uploadProgress = 0 { didSet { onChange?() } }
|
||||
var isLoadingFolders = false { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var didUploadSuccessfully = false { didSet { onChange?() } }
|
||||
|
||||
/// 加载可上传的相册列表。
|
||||
func loadFolders(api: any AssetsServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
folders = []
|
||||
selectedFolderId = nil
|
||||
errorMessage = "缺少当前景区,无法加载相册"
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingFolders = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingFolders = false }
|
||||
|
||||
do {
|
||||
let payload = try await api.albumFolderList(
|
||||
scenicId: scenicId,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
name: "",
|
||||
startTime: nil,
|
||||
endTime: nil
|
||||
)
|
||||
folders = payload.list
|
||||
if selectedFolderId == nil {
|
||||
selectedFolderId = folders.first?.id
|
||||
}
|
||||
} catch {
|
||||
folders = []
|
||||
selectedFolderId = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加本地待上传文件。
|
||||
func addLocalFiles(_ files: [AlbumLocalUploadFile]) {
|
||||
localFiles.append(contentsOf: files)
|
||||
}
|
||||
|
||||
/// 移除一个本地待上传文件。
|
||||
func removeLocalFile(id: UUID) {
|
||||
localFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 上传本地文件到 OSS,并登记到指定相册。
|
||||
func submit(scenicId: Int?, api: any AssetsServing, uploadService: any OSSUploadServing) async -> Bool {
|
||||
guard let scenicId else {
|
||||
errorMessage = "缺少当前景区,无法上传"
|
||||
return false
|
||||
}
|
||||
guard let folderId = selectedFolderId else {
|
||||
errorMessage = "请选择相册"
|
||||
return false
|
||||
}
|
||||
guard !localFiles.isEmpty else {
|
||||
errorMessage = "请选择要上传的图片或视频"
|
||||
return false
|
||||
}
|
||||
guard !isSubmitting else { return false }
|
||||
|
||||
isSubmitting = true
|
||||
didUploadSuccessfully = false
|
||||
uploadProgress = 0
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let fileCount = max(localFiles.count, 1)
|
||||
for (index, file) in localFiles.enumerated() {
|
||||
let url = try await uploadService.uploadAlbumFile(
|
||||
data: file.data,
|
||||
fileName: file.fileName,
|
||||
fileType: file.fileType,
|
||||
scenicId: scenicId
|
||||
) { progress in
|
||||
Task { @MainActor in
|
||||
let base = Double(index) / Double(fileCount)
|
||||
let step = Double(progress) / Double(fileCount)
|
||||
self.uploadProgress = min(99, Int((base + step / 100) * 100))
|
||||
}
|
||||
}
|
||||
try await api.albumFileUploadURL(scenicId: scenicId, fileURL: url, folderId: folderId)
|
||||
}
|
||||
uploadProgress = 100
|
||||
didUploadSuccessfully = true
|
||||
localFiles = []
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 空字符串转 nil,方便可选筛选参数传递。
|
||||
var assetsNilIfEmpty: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user