Extend media library for sample kind with project selection, wire home routing for sample entries, and expand Assets tests. Co-authored-by: Cursor <cursoragent@cursor.com>
975 lines
32 KiB
Swift
975 lines
32 KiB
Swift
//
|
||
// AssetsViewModels.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/23.
|
||
//
|
||
|
||
import Foundation
|
||
import Observation
|
||
|
||
/// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。
|
||
@MainActor
|
||
@Observable
|
||
final class CloudTransferStore {
|
||
static let shared = CloudTransferStore()
|
||
|
||
private(set) var records: [CloudTransferItem] = []
|
||
|
||
/// 新增一条传输记录并返回记录 ID。
|
||
@discardableResult
|
||
func start(fileName: String, direction: CloudTransferDirection) -> UUID {
|
||
let id = UUID()
|
||
records.insert(CloudTransferItem(id: id, fileName: fileName, direction: direction), at: 0)
|
||
return id
|
||
}
|
||
|
||
/// 更新指定传输记录的进度。
|
||
func update(id: UUID, progress: Int, message: String = "") {
|
||
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
|
||
records[index].progress = max(0, min(100, progress))
|
||
if !message.isEmpty {
|
||
records[index].message = message
|
||
}
|
||
}
|
||
|
||
/// 标记指定传输记录成功。
|
||
func succeed(id: UUID, message: String = "已完成") {
|
||
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
|
||
records[index].progress = 100
|
||
records[index].status = .success
|
||
records[index].message = message
|
||
}
|
||
|
||
/// 标记指定传输记录失败。
|
||
func fail(id: UUID, message: String) {
|
||
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
|
||
records[index].status = .failed
|
||
records[index].message = message
|
||
}
|
||
|
||
/// 清空当前会话传输记录。
|
||
func clear() {
|
||
records = []
|
||
}
|
||
}
|
||
|
||
/// 云盘 ViewModel,负责文件列表、筛选、分页、文件操作和上传闭环。
|
||
@MainActor
|
||
@Observable
|
||
final class CloudStorageViewModel {
|
||
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||
var files: [CloudDriveFile] = []
|
||
var total = 0
|
||
var page = 1
|
||
var searchText = ""
|
||
var selectedFilter: CloudDriveFilter = .all
|
||
var selectedSort: CloudDriveSort = .updatedDesc
|
||
var isLoading = false
|
||
var isLoadingMore = false
|
||
var isMutating = false
|
||
var errorMessage: String?
|
||
|
||
private let pageSize = 20
|
||
|
||
/// 当前目录 ID。
|
||
var currentFolderId: Int {
|
||
path.last?.id ?? 0
|
||
}
|
||
|
||
/// 判断当前目录是否还有下一页。
|
||
var hasMore: Bool {
|
||
files.count < total
|
||
}
|
||
|
||
/// 重新加载当前目录第一页。
|
||
func reload(api: any AssetsServing) async {
|
||
isLoading = true
|
||
errorMessage = nil
|
||
defer { isLoading = false }
|
||
|
||
do {
|
||
let payload = try await requestFiles(api: api, page: 1)
|
||
page = 1
|
||
files = payload.list
|
||
total = payload.total
|
||
} catch {
|
||
files = []
|
||
total = 0
|
||
page = 1
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 加载当前目录下一页。
|
||
func loadMore(api: any AssetsServing) async {
|
||
guard hasMore, !isLoadingMore else { return }
|
||
isLoadingMore = true
|
||
defer { isLoadingMore = false }
|
||
|
||
do {
|
||
let nextPage = page + 1
|
||
let payload = try await requestFiles(api: api, page: nextPage)
|
||
page = nextPage
|
||
total = payload.total
|
||
files.append(contentsOf: payload.list)
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 进入指定文件夹并刷新列表。
|
||
func enterFolder(_ folder: CloudDriveFile, api: any AssetsServing) async {
|
||
guard folder.isFolder else { return }
|
||
path.append(folder)
|
||
await reload(api: api)
|
||
}
|
||
|
||
/// 回到指定面包屑目录并刷新列表。
|
||
func popToFolder(at index: Int, api: any AssetsServing) async {
|
||
guard path.indices.contains(index) else { return }
|
||
path = Array(path.prefix(index + 1))
|
||
await reload(api: api)
|
||
}
|
||
|
||
/// 创建文件夹并刷新当前目录。
|
||
func createFolder(name: String, api: any AssetsServing) async -> Bool {
|
||
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !folderName.isEmpty else {
|
||
errorMessage = "请输入文件夹名称"
|
||
return false
|
||
}
|
||
return await mutate(api: api) {
|
||
try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: currentFolderId, name: folderName))
|
||
}
|
||
}
|
||
|
||
/// 重命名文件或文件夹并刷新当前目录。
|
||
func rename(_ item: CloudDriveFile, newName: String, api: any AssetsServing) async -> Bool {
|
||
let fileName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !fileName.isEmpty else {
|
||
errorMessage = "请输入名称"
|
||
return false
|
||
}
|
||
return await mutate(api: api) {
|
||
if item.isFolder {
|
||
try await api.cloudFolderEdit(CloudFolderModifyRequest(id: item.id, name: fileName))
|
||
} else {
|
||
try await api.cloudFileEdit(CloudFileModifyRequest(id: item.id, fileName: fileName))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 删除文件或文件夹并刷新当前目录。
|
||
func delete(_ item: CloudDriveFile, api: any AssetsServing) async -> Bool {
|
||
await mutate(api: api) {
|
||
let action = CloudFileActionItem(id: item.id, type: item.type)
|
||
try await api.cloudFileDelete(CloudFileDeleteRequest(list: [action]))
|
||
}
|
||
}
|
||
|
||
/// 移动文件或文件夹并刷新当前目录。
|
||
func move(_ item: CloudDriveFile, targetFolderId: Int, api: any AssetsServing) async -> Bool {
|
||
await mutate(api: api) {
|
||
let action = CloudFileActionItem(id: item.id, type: item.type)
|
||
try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: targetFolderId, list: [action]))
|
||
}
|
||
}
|
||
|
||
/// 上传本地文件到 OSS 后写入云盘。
|
||
func upload(
|
||
localFiles: [CloudLocalUploadFile],
|
||
scenicId: Int?,
|
||
api: any AssetsServing,
|
||
uploadService: any OSSUploadServing,
|
||
transferStore: CloudTransferStore
|
||
) async -> Bool {
|
||
guard let scenicId else {
|
||
errorMessage = "缺少当前景区,无法上传"
|
||
return false
|
||
}
|
||
guard !localFiles.isEmpty else { return true }
|
||
|
||
isMutating = true
|
||
defer { isMutating = false }
|
||
|
||
do {
|
||
let permission = try await api.checkCloudUploadPermission()
|
||
guard permission.canUpload else {
|
||
let reason = permission.reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
errorMessage = reason.isEmpty ? "当前账号暂无上传权限" : reason
|
||
return false
|
||
}
|
||
|
||
for file in localFiles {
|
||
let transferId = transferStore.start(fileName: file.fileName, direction: .upload)
|
||
do {
|
||
let url = try await uploadService.uploadCloudFile(
|
||
data: file.data,
|
||
fileName: file.fileName,
|
||
fileType: file.fileType,
|
||
scenicId: scenicId
|
||
) { progress in
|
||
Task { @MainActor in
|
||
transferStore.update(id: transferId, progress: progress)
|
||
}
|
||
}
|
||
try await api.cloudFileUpload(
|
||
CloudFileUploadRequest(parentFolderId: currentFolderId, fileUrl: url, fileName: file.fileName)
|
||
)
|
||
transferStore.succeed(id: transferId)
|
||
} catch {
|
||
transferStore.fail(id: transferId, message: error.localizedDescription)
|
||
throw error
|
||
}
|
||
}
|
||
await reload(api: api)
|
||
return true
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// 执行一次云盘变更操作并刷新当前目录。
|
||
private func mutate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
|
||
isMutating = true
|
||
defer { isMutating = false }
|
||
do {
|
||
try await operation()
|
||
await reload(api: api)
|
||
return true
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// 组装并发送云盘文件列表请求。
|
||
private func requestFiles(api: any AssetsServing, page: Int) async throws -> ListPayload<CloudDriveFile> {
|
||
try await api.cloudFileList(
|
||
parentFolderId: currentFolderId,
|
||
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
type: selectedFilter.rawValue,
|
||
orderBy: selectedSort.rawValue,
|
||
page: page,
|
||
pageSize: pageSize
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 媒体库 ViewModel,负责素材或样片列表、筛选、分页、详情和上下架。
|
||
@MainActor
|
||
@Observable
|
||
final class MediaLibraryViewModel {
|
||
let kind: MediaLibraryKind
|
||
var items: [MediaLibraryItem] = []
|
||
var selectedDetail: MediaLibraryDetail?
|
||
var orderInfo: MediaLibraryOrderInfo?
|
||
var total = 0
|
||
var page = 1
|
||
var keyword = ""
|
||
var selectedAuditFilter: MediaLibraryAuditFilter = .all
|
||
var isLoading = false
|
||
var isLoadingMore = false
|
||
var isLoadingDetail = false
|
||
var errorMessage: String?
|
||
|
||
private let pageSize = 10
|
||
|
||
/// 初始化媒体库 ViewModel。
|
||
init(kind: MediaLibraryKind = .material) {
|
||
self.kind = kind
|
||
}
|
||
|
||
/// 判断当前列表是否还有下一页。
|
||
var hasMore: Bool {
|
||
items.count < total
|
||
}
|
||
|
||
/// 重新加载媒体库列表第一页。
|
||
func reload(api: any AssetsServing) async {
|
||
isLoading = true
|
||
errorMessage = nil
|
||
defer { isLoading = false }
|
||
|
||
do {
|
||
let response = try await requestList(api: api, page: 1)
|
||
page = 1
|
||
items = response.list
|
||
total = response.total
|
||
orderInfo = response.order
|
||
} catch {
|
||
items = []
|
||
total = 0
|
||
orderInfo = nil
|
||
page = 1
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 加载媒体库列表下一页。
|
||
func loadMore(api: any AssetsServing) async {
|
||
guard hasMore, !isLoadingMore else { return }
|
||
isLoadingMore = true
|
||
defer { isLoadingMore = false }
|
||
|
||
do {
|
||
let nextPage = page + 1
|
||
let response = try await requestList(api: api, page: nextPage)
|
||
page = nextPage
|
||
total = response.total
|
||
orderInfo = response.order ?? orderInfo
|
||
items.append(contentsOf: response.list)
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 加载媒体库详情,失败时保留已有详情或摘要。
|
||
func loadDetail(id: Int, api: any AssetsServing) async {
|
||
isLoadingDetail = true
|
||
errorMessage = nil
|
||
defer { isLoadingDetail = false }
|
||
|
||
do {
|
||
selectedDetail = try await api.mediaAlbumDetail(id: id, 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
|
||
@Observable
|
||
final class MediaLibraryEditorViewModel {
|
||
var name = ""
|
||
var description = ""
|
||
var selectedSpotId: Int?
|
||
var selectedProjectId: Int?
|
||
var projects: [PhotographerProjectItem] = []
|
||
var tagsText = ""
|
||
var coverFile: MediaLocalUploadFile?
|
||
var mediaFiles: [MediaLocalUploadFile] = []
|
||
var isSubmitting = false
|
||
var isLoadingProjects = 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 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
|
||
@Observable
|
||
final class AlbumListViewModel {
|
||
var folders: [AlbumFolderItem] = []
|
||
var total = 0
|
||
var page = 1
|
||
var searchText = ""
|
||
var startTime = ""
|
||
var endTime = ""
|
||
var isLoading = false
|
||
var isLoadingMore = false
|
||
var isMutating = false
|
||
var errorMessage: String?
|
||
|
||
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
|
||
@Observable
|
||
final class AlbumDetailViewModel {
|
||
let folderId: Int
|
||
var folder: AlbumFolderItem?
|
||
var files: [AlbumFileItem] = []
|
||
var selectedTab: AlbumFileTab = .image
|
||
var total = 0
|
||
var page = 1
|
||
var isLoading = false
|
||
var isLoadingMore = false
|
||
var isMutating = false
|
||
var errorMessage: String?
|
||
|
||
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
|
||
@Observable
|
||
final class AlbumTrailerViewModel {
|
||
var folders: [AlbumFolderItem] = []
|
||
var selectedFolderId: Int?
|
||
var localFiles: [AlbumLocalUploadFile] = []
|
||
var uploadProgress = 0
|
||
var isLoadingFolders = false
|
||
var isSubmitting = false
|
||
var errorMessage: String?
|
||
var didUploadSuccessfully = false
|
||
|
||
/// 加载可上传的相册列表。
|
||
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
|
||
}
|
||
}
|