feat: 添加云盘与消息中心功能

This commit is contained in:
2026-07-09 22:37:43 +08:00
parent 8e356973bd
commit f20ec7f06c
91 changed files with 5437 additions and 89 deletions

View File

@ -0,0 +1,144 @@
//
// CloudDriveAPI.swift
// suixinkan
//
import Foundation
/// 便 ViewModel
@MainActor
protocol CloudDriveServing {
///
func cloudFileList(
parentFolderId: Int,
name: String,
type: Int,
orderBy: Int,
page: Int,
pageSize: Int
) async throws -> CloudFileListResponse
///
func createCloudFolder(parentFolderId: Int, name: String) async throws
/// OSS
func uploadCloudFile(parentFolderId: Int, fileUrl: String, fileName: String) async throws
///
func deleteCloudFiles(_ request: CloudFileDeleteRequest) async throws
///
func moveCloudFiles(_ request: CloudFileMoveRequest) async throws
///
func checkUploadPermission() async throws -> CloudUploadPermissionResponse
///
func modifyCloudFolderName(fileId: Int, name: String) async throws
///
func modifyCloudFileName(fileId: Int, fileName: String) async throws
}
@MainActor
/// API Android `NetworkApi` cloud-driver
final class CloudDriveAPI: CloudDriveServing {
private let client: APIClient
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 -> CloudFileListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/cloud-driver/list",
queryItems: [
URLQueryItem(name: "parent_folder_id", value: String(parentFolderId)),
URLQueryItem(name: "name", value: name),
URLQueryItem(name: "type", value: String(type)),
URLQueryItem(name: "order_by", value: String(orderBy)),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "page_size", value: String(pageSize)),
]
)
)
}
func createCloudFolder(parentFolderId: Int, name: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/cloud-driver/folder-create",
body: CloudFolderCreateRequest(parentFolderId: parentFolderId, name: name)
)
)
}
func uploadCloudFile(parentFolderId: Int, fileUrl: String, fileName: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/cloud-driver/file-upload",
body: CloudFileUploadRequest(parentFolderId: parentFolderId, fileUrl: fileUrl, fileName: fileName)
)
)
}
func deleteCloudFiles(_ request: CloudFileDeleteRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/cloud-driver/delete",
body: request
)
)
}
func moveCloudFiles(_ request: CloudFileMoveRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/cloud-driver/move",
body: request
)
)
}
func checkUploadPermission() async throws -> CloudUploadPermissionResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/cloud-driver/check-upload-permission"
)
)
}
func modifyCloudFolderName(fileId: Int, name: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/cloud-driver/folder-edit",
body: CloudFolderModifyRequest(fileId: fileId, name: name)
)
)
}
func modifyCloudFileName(fileId: Int, fileName: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/cloud-driver/file-edit",
body: CloudFileModifyRequest(fileId: fileId, fileName: fileName)
)
)
}
}

View File

@ -0,0 +1,165 @@
//
// CloudDriveModels.swift
// suixinkan
//
import Foundation
/// / Android `CloudFileEntity`
struct CloudFile: Codable, Sendable, Equatable, 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
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 = 0,
parentFolderId: Int = 0,
fileUrl: String = "",
coverUrl: String = "",
updatedAt: String = "",
name: String = "",
createdAt: String = "",
childNum: Int = 0,
type: Int = 0,
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
}
///
var isFolder: Bool { type == 99 }
///
var isImage: Bool { type == 2 }
///
var isVideo: Bool { type == 1 }
///
var isSelectableMedia: Bool { isImage || isVideo }
}
///
struct CloudFileListResponse: Codable, Sendable, Equatable {
let total: Int
let list: [CloudFile]
init(total: Int = 0, list: [CloudFile] = []) {
self.total = total
self.list = list
}
}
///
struct CloudPathItem: Codable, Sendable, Equatable, Hashable {
let id: Int
let name: String
}
///
struct CloudUploadPermissionResponse: Decodable, Sendable, Equatable {
let canUpload: Bool
let reason: String
enum CodingKeys: String, CodingKey {
case canUpload = "can_upload"
case reason
}
}
///
struct CloudFileDeleteRequest: Encodable, Sendable, Equatable {
let list: [CloudFileOperationItem]
}
///
struct CloudFileMoveRequest: Encodable, Sendable, Equatable {
let targetFolderId: Int
let list: [CloudFileOperationItem]
enum CodingKeys: String, CodingKey {
case targetFolderId = "target_folder_id"
case list
}
}
/// /
struct CloudFileOperationItem: Codable, Sendable, Equatable {
let id: Int
let type: Int
}
///
struct CloudFolderCreateRequest: Encodable, Sendable, Equatable {
let parentFolderId: Int
let name: String
enum CodingKeys: String, CodingKey {
case parentFolderId = "parent_folder_id"
case name
}
}
///
struct CloudFileUploadRequest: Encodable, Sendable, 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 CloudFolderModifyRequest: Encodable, Sendable, Equatable {
let fileId: Int
let name: String
enum CodingKeys: String, CodingKey {
case fileId = "id"
case name
}
}
///
struct CloudFileModifyRequest: Encodable, Sendable, Equatable {
let fileId: Int
let fileName: String
enum CodingKeys: String, CodingKey {
case fileId = "id"
case fileName = "file_name"
}
}

View File

@ -0,0 +1,44 @@
//
// CloudTransferJSONStore.swift
// suixinkan
//
import Foundation
/// JSON
final class CloudTransferJSONStore: CloudTransferTaskStoring {
private let fileURL: URL
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
/// JSON
init(fileURL: URL? = nil) {
if let fileURL {
self.fileURL = fileURL
} else {
let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
self.fileURL = baseURL
.appendingPathComponent("suixinkan", isDirectory: true)
.appendingPathComponent("cloud_transfer_tasks.json")
}
}
func loadTasks() -> [CloudTransferTask] {
guard let data = try? Data(contentsOf: fileURL) else { return [] }
return (try? decoder.decode([CloudTransferTask].self, from: data)) ?? []
}
func saveTasks(_ tasks: [CloudTransferTask]) {
do {
let directory = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let data = try encoder.encode(tasks)
try data.write(to: fileURL, options: [.atomic])
} catch {
#if DEBUG
print("CloudTransferJSONStore save failed: \(error)")
#endif
}
}
}

View File

@ -0,0 +1,300 @@
//
// CloudTransferManager.swift
// suixinkan
//
import Foundation
@MainActor
/// /
final class CloudTransferManager {
static let shared = CloudTransferManager(
store: CloudTransferJSONStore(),
uploader: NetworkServices.shared.ossUploadService,
api: NetworkServices.shared.cloudDriveAPI,
downloader: CloudURLSessionDownloader(),
photoSaver: CloudPhotoLibrarySaver(),
scenicIdProvider: { AppStore.shared.currentScenicId }
)
private(set) var uploadTasks: [CloudTransferTask] = []
private(set) var downloadTasks: [CloudTransferTask] = []
var onTasksChange: (() -> Void)?
var onUploadCompleted: ((Int) -> Void)?
var onShowMessage: ((String) -> Void)?
private let store: CloudTransferTaskStoring
private let uploader: any CloudDriveUploading
private let api: any CloudDriveServing
private let downloader: any CloudFileDownloading
private let photoSaver: any CloudPhotoSaving
private let scenicIdProvider: () -> Int
private var runningTasks: [String: Task<Void, Never>] = [:]
///
init(
store: CloudTransferTaskStoring,
uploader: any CloudDriveUploading,
api: any CloudDriveServing,
downloader: any CloudFileDownloading,
photoSaver: any CloudPhotoSaving,
scenicIdProvider: @escaping () -> Int
) {
self.store = store
self.uploader = uploader
self.api = api
self.downloader = downloader
self.photoSaver = photoSaver
self.scenicIdProvider = scenicIdProvider
let tasks = store.loadTasks()
uploadTasks = tasks.filter { $0.kind == .upload }
downloadTasks = tasks.filter { $0.kind == .download }
}
///
func addUploadTask(
localFileURL: URL,
fileName: String,
fileType: Int,
fileSize: Int64,
parentFolderId: Int
) {
let task = CloudTransferTask(
kind: .upload,
fileName: fileName,
fileType: fileType,
fileSize: fileSize,
parentFolderId: parentFolderId,
localPath: localFileURL.path,
status: .pending
)
uploadTasks.insert(task, at: 0)
persistAndNotify()
startUpload(task.id)
onShowMessage?("已添加上传任务")
}
///
func addDownloadTask(fileURL: String, fileName: String, fileType: Int, fileSize: Int64 = 0) {
let task = CloudTransferTask(
kind: .download,
fileName: fileName,
fileType: fileType,
fileSize: fileSize,
remoteURL: fileURL,
status: .pending
)
downloadTasks.insert(task, at: 0)
persistAndNotify()
startDownload(task.id)
onShowMessage?("已添加下载任务,可在传输管理查看")
}
///
func pauseUpload(id: String) {
runningTasks[id]?.cancel()
runningTasks[id] = nil
updateUploadTask(id: id) { task in
task.status = .paused
task.updatedAt = Date().timeIntervalSince1970
}
}
///
func resumeUpload(id: String) {
startUpload(id)
}
///
func cancelUpload(id: String) {
runningTasks[id]?.cancel()
runningTasks[id] = nil
if let task = uploadTasks.first(where: { $0.id == id }), !task.localPath.isEmpty {
try? FileManager.default.removeItem(atPath: task.localPath)
}
uploadTasks.removeAll { $0.id == id }
persistAndNotify()
}
///
func pauseDownload(id: String) {
runningTasks[id]?.cancel()
runningTasks[id] = nil
updateDownloadTask(id: id) { task in
task.status = .paused
task.updatedAt = Date().timeIntervalSince1970
}
}
///
func resumeDownload(id: String) {
startDownload(id)
}
///
func cancelDownload(id: String) {
runningTasks[id]?.cancel()
runningTasks[id] = nil
downloadTasks.removeAll { $0.id == id }
persistAndNotify()
}
private func startUpload(_ id: String) {
guard runningTasks[id] == nil,
let index = uploadTasks.firstIndex(where: { $0.id == id }) else { return }
uploadTasks[index].status = .uploading
uploadTasks[index].errorMessage = ""
uploadTasks[index].progress = max(uploadTasks[index].progress, 1)
uploadTasks[index].updatedAt = Date().timeIntervalSince1970
let seed = uploadTasks[index]
persistAndNotify()
runningTasks[id] = Task { [weak self] in
guard let self else { return }
do {
guard FileManager.default.fileExists(atPath: seed.localPath) else {
throw CloudTransferError.localFileMissing
}
let data = try Data(contentsOf: URL(fileURLWithPath: seed.localPath))
try Task.checkCancellation()
let uploadedURL = try await uploader.uploadCloudDriveFile(
data: data,
fileName: seed.fileName,
fileType: seed.fileType,
scenicId: scenicIdProvider()
) { [weak self] progress in
Task { @MainActor in
self?.updateUploadProgress(id: id, progress: progress)
}
}
try Task.checkCancellation()
try await api.uploadCloudFile(
parentFolderId: seed.parentFolderId,
fileUrl: uploadedURL,
fileName: seed.fileName
)
try? FileManager.default.removeItem(atPath: seed.localPath)
runningTasks[id] = nil
uploadTasks.removeAll { $0.id == id }
persistAndNotify()
onUploadCompleted?(seed.parentFolderId)
} catch is CancellationError {
runningTasks[id] = nil
markUploadPaused(id: id)
} catch {
runningTasks[id] = nil
markUploadFailed(id: id, message: error.localizedDescription)
}
}
}
private func startDownload(_ id: String) {
guard runningTasks[id] == nil,
let index = downloadTasks.firstIndex(where: { $0.id == id }) else { return }
downloadTasks[index].status = .downloading
downloadTasks[index].errorMessage = ""
downloadTasks[index].progress = max(downloadTasks[index].progress, 1)
downloadTasks[index].updatedAt = Date().timeIntervalSince1970
let seed = downloadTasks[index]
persistAndNotify()
runningTasks[id] = Task { [weak self] in
guard let self else { return }
do {
let localURL = try await downloader.downloadFile(
remoteURL: seed.remoteURL,
fileName: seed.fileName
) { [weak self] progress in
Task { @MainActor in
self?.updateDownloadProgress(id: id, progress: progress)
}
}
try Task.checkCancellation()
try await photoSaver.saveToPhotoLibrary(fileURL: localURL, fileType: seed.fileType)
try? FileManager.default.removeItem(at: localURL)
runningTasks[id] = nil
updateDownloadTask(id: id) { task in
task.status = .completed
task.progress = 100
task.updatedAt = Date().timeIntervalSince1970
}
} catch is CancellationError {
runningTasks[id] = nil
markDownloadPaused(id: id)
} catch {
runningTasks[id] = nil
markDownloadFailed(id: id, message: error.localizedDescription)
}
}
}
private func updateUploadProgress(id: String, progress: Int) {
updateUploadTask(id: id) { task in
task.progress = max(task.progress, progress.coerceIn(0 ... 100))
task.updatedAt = Date().timeIntervalSince1970
}
}
private func updateDownloadProgress(id: String, progress: Int) {
updateDownloadTask(id: id) { task in
task.progress = max(task.progress, progress.coerceIn(0 ... 100))
task.updatedAt = Date().timeIntervalSince1970
}
}
private func markUploadPaused(id: String) {
updateUploadTask(id: id) { task in
task.status = .paused
task.updatedAt = Date().timeIntervalSince1970
}
}
private func markDownloadPaused(id: String) {
updateDownloadTask(id: id) { task in
task.status = .paused
task.updatedAt = Date().timeIntervalSince1970
}
}
private func markUploadFailed(id: String, message: String) {
updateUploadTask(id: id) { task in
task.status = .failed
task.errorMessage = message
task.updatedAt = Date().timeIntervalSince1970
}
}
private func markDownloadFailed(id: String, message: String) {
updateDownloadTask(id: id) { task in
task.status = .failed
task.errorMessage = message
task.updatedAt = Date().timeIntervalSince1970
}
}
private func updateUploadTask(id: String, mutation: (inout CloudTransferTask) -> Void) {
guard let index = uploadTasks.firstIndex(where: { $0.id == id }) else { return }
mutation(&uploadTasks[index])
persistAndNotify()
}
private func updateDownloadTask(id: String, mutation: (inout CloudTransferTask) -> Void) {
guard let index = downloadTasks.firstIndex(where: { $0.id == id }) else { return }
mutation(&downloadTasks[index])
persistAndNotify()
}
private func persistAndNotify() {
store.saveTasks(uploadTasks + downloadTasks)
onTasksChange?()
}
}
private extension Comparable {
func coerceIn(_ range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}

View File

@ -0,0 +1,97 @@
//
// CloudTransferModels.swift
// suixinkan
//
import Foundation
///
enum CloudTransferKind: String, Codable, Sendable {
case upload
case download
}
///
enum CloudTransferStatus: String, Codable, Sendable {
case pending
case uploading
case downloading
case paused
case failed
case completed
///
var title: String {
switch self {
case .pending:
"等待中"
case .uploading:
"上传中"
case .downloading:
"下载中"
case .paused:
"已暂停"
case .failed:
"失败"
case .completed:
"已完成"
}
}
}
///
struct CloudTransferTask: Codable, Sendable, Equatable, Hashable, Identifiable {
let id: String
let kind: CloudTransferKind
let fileName: String
let fileType: Int
let fileSize: Int64
let parentFolderId: Int
let localPath: String
let remoteURL: String
var status: CloudTransferStatus
var progress: Int
var errorMessage: String
var createdAt: TimeInterval
var updatedAt: TimeInterval
///
init(
id: String = UUID().uuidString.replacingOccurrences(of: "-", with: ""),
kind: CloudTransferKind,
fileName: String,
fileType: Int,
fileSize: Int64,
parentFolderId: Int = 0,
localPath: String = "",
remoteURL: String = "",
status: CloudTransferStatus = .pending,
progress: Int = 0,
errorMessage: String = "",
createdAt: TimeInterval = Date().timeIntervalSince1970,
updatedAt: TimeInterval = Date().timeIntervalSince1970
) {
self.id = id
self.kind = kind
self.fileName = fileName
self.fileType = fileType
self.fileSize = fileSize
self.parentFolderId = parentFolderId
self.localPath = localPath
self.remoteURL = remoteURL
self.status = status
self.progress = progress
self.errorMessage = errorMessage
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
///
protocol CloudTransferTaskStoring: AnyObject {
///
func loadTasks() -> [CloudTransferTask]
///
func saveTasks(_ tasks: [CloudTransferTask])
}

View File

@ -0,0 +1,113 @@
//
// CloudTransferWorkers.swift
// suixinkan
//
import Foundation
import Photos
/// OSS 便
@MainActor
protocol CloudDriveUploading {
/// URL
func uploadCloudDriveFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String
}
extension OSSUploadService: CloudDriveUploading {}
///
protocol CloudFileDownloading {
/// URL
func downloadFile(
remoteURL: String,
fileName: String,
onProgress: @escaping @Sendable (Int) -> Void
) async throws -> URL
}
/// URLSession
struct CloudURLSessionDownloader: CloudFileDownloading {
func downloadFile(
remoteURL: String,
fileName: String,
onProgress: @escaping @Sendable (Int) -> Void
) async throws -> URL {
guard let url = URL(string: remoteURL) else {
throw CloudTransferError.invalidURL
}
onProgress(1)
let (temporaryURL, _) = try await URLSession.shared.download(from: url)
try Task.checkCancellation()
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cloud_drive_downloads", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destinationURL = directory.appendingPathComponent(CloudTransferFileName.sanitized(fileName))
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.moveItem(at: temporaryURL, to: destinationURL)
onProgress(100)
return destinationURL
}
}
///
protocol CloudPhotoSaving {
///
func saveToPhotoLibrary(fileURL: URL, fileType: Int) async throws
}
/// Photos
struct CloudPhotoLibrarySaver: CloudPhotoSaving {
func saveToPhotoLibrary(fileURL: URL, fileType: Int) async throws {
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
guard status == .authorized || status == .limited else {
throw CloudTransferError.photoPermissionDenied
}
try await PHPhotoLibrary.shared().performChanges {
if fileType == 1 {
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL)
} else {
PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL)
}
}
}
}
///
enum CloudTransferError: LocalizedError, Equatable {
case invalidURL
case localFileMissing
case photoPermissionDenied
var errorDescription: String? {
switch self {
case .invalidURL:
"文件地址不存在"
case .localFileMissing:
"本地文件不存在"
case .photoPermissionDenied:
"请允许访问相册后再下载"
}
}
}
///
enum CloudTransferFileName {
///
static func sanitized(_ fileName: String) -> String {
let trimmed = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
var unsafe = CharacterSet.controlCharacters
unsafe.formUnion(CharacterSet(charactersIn: "/\\"))
let value = trimmed.unicodeScalars.reduce(into: "") { result, scalar in
result += unsafe.contains(scalar) ? "_" : String(scalar)
}
return value.isEmpty ? "cloud_file" : value
}
}

View File

@ -0,0 +1,353 @@
//
// CloudDriveListViewModel.swift
// suixinkan
//
import Foundation
///
enum CloudDriveDisplayMode: Sendable, Equatable {
case grid
case list
}
///
enum CloudDriveSortType: Int, CaseIterable, Sendable {
case createdAscending = 1
case createdDescending = 2
/// Android
var title: String {
switch self {
case .createdAscending:
"创建时间顺序"
case .createdDescending:
"创建时间倒序"
}
}
}
///
enum CloudDriveFilterType: Int, CaseIterable, Sendable {
case all = 0
case video = 1
case image = 2
case folder = 99
/// Android
var title: String {
switch self {
case .all:
"全部类型"
case .video:
"视频"
case .image:
"图片"
case .folder:
"文件夹"
}
}
}
private struct CloudDriveCacheKey: Hashable {
let folderId: Int
let name: String
let type: Int
let orderBy: Int
}
private struct CloudDriveFolderCache {
let list: [CloudFile]
let total: Int
let page: Int
}
/// ViewModel Android `CloudStorageListViewModel`
final class CloudDriveListViewModel {
private(set) var pathStack: [CloudPathItem] = [CloudPathItem(id: 0, name: "云盘")]
private(set) var files: [CloudFile] = []
private(set) var searchText = ""
private(set) var filterType: CloudDriveFilterType = .all
private(set) var sortType: CloudDriveSortType = .createdDescending
private(set) var displayMode: CloudDriveDisplayMode = .grid
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var canLoadMore = false
private(set) var currentControlFile: CloudFile?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private var currentPage = 1
private var totalCount = 0
private let pageSize = 20
private var folderCache: [CloudDriveCacheKey: CloudDriveFolderCache] = [:]
private var dirtyFolders: Set<Int> = []
/// ID
var currentFolderID: Int {
pathStack.last?.id ?? 0
}
///
func updateSearchText(_ text: String) {
searchText = text
notifyStateChange()
}
/// /
func toggleDisplayMode() {
displayMode = displayMode == .grid ? .list : .grid
notifyStateChange()
}
///
func chooseSortType(_ type: CloudDriveSortType, api: any CloudDriveServing) async {
sortType = type
await refresh(api: api, showLoading: true)
}
///
func chooseFilterType(_ type: CloudDriveFilterType, api: any CloudDriveServing) async {
filterType = type
await refresh(api: api, showLoading: true)
}
///
func refresh(api: any CloudDriveServing, showLoading: Bool = false) async {
currentPage = 1
isRefreshing = true
notifyStateChange()
await loadPage(api: api, reset: true, showLoading: showLoading)
isRefreshing = false
notifyStateChange()
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any CloudDriveServing) async {
guard canLoadMore, !isLoading, lastVisibleIndex >= files.count - 6 else { return }
currentPage += 1
await loadPage(api: api, reset: false, showLoading: false)
}
/// UI
func handleFileTap(_ file: CloudFile, api: any CloudDriveServing) async -> CloudFile? {
if file.isFolder {
searchText = ""
pathStack.append(CloudPathItem(id: file.id, name: file.name))
await refresh(api: api, showLoading: true)
return nil
}
guard file.isSelectableMedia else { return nil }
return file
}
///
func setControlFile(_ file: CloudFile) {
currentControlFile = file
notifyStateChange()
}
/// false UI pop
func navigateBack(api: any CloudDriveServing) async -> Bool {
guard pathStack.count > 1 else { return false }
pathStack.removeLast()
await restoreOrReload(api: api)
return true
}
///
func goHome(api: any CloudDriveServing) async {
pathStack = [pathStack.first ?? CloudPathItem(id: 0, name: "云盘")]
await restoreOrReload(api: api)
}
///
func navigateToPathIndex(_ index: Int, api: any CloudDriveServing) async {
guard index >= 0, index < pathStack.count else { return }
pathStack = Array(pathStack.prefix(index + 1))
await restoreOrReload(api: api)
}
///
func checkCanUpload(api: any CloudDriveServing) async -> Bool {
do {
let response = try await api.checkUploadPermission()
if !response.canUpload {
onShowMessage?(response.reason)
}
return response.canUpload
} catch {
onShowMessage?(error.localizedDescription)
return false
}
}
///
func addFolder(name: String, api: any CloudDriveServing) async {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else {
onShowMessage?("名称不能为空")
return
}
guard pathStack.count < 5 else {
onShowMessage?("不能创建更深的文件夹")
return
}
await performLoading {
try await api.createCloudFolder(parentFolderId: currentFolderID, name: trimmedName)
invalidateFolderCache(currentFolderID)
await refresh(api: api, showLoading: false)
}
}
///
func deleteCurrentFile(api: any CloudDriveServing) async {
guard let file = currentControlFile else { return }
await performLoading {
try await api.deleteCloudFiles(
CloudFileDeleteRequest(list: [CloudFileOperationItem(id: file.id, type: file.type)])
)
invalidateFolderCache(currentFolderID)
await refresh(api: api, showLoading: false)
}
}
///
func moveCurrentFile(targetFolderId: Int, api: any CloudDriveServing) async {
guard let file = currentControlFile else { return }
await performLoading {
try await api.moveCloudFiles(
CloudFileMoveRequest(
targetFolderId: targetFolderId,
list: [CloudFileOperationItem(id: file.id, type: file.type)]
)
)
invalidateFolderCache(currentFolderID)
invalidateFolderCache(targetFolderId)
await refresh(api: api, showLoading: false)
}
}
///
func saveCurrentFileName(_ name: String, api: any CloudDriveServing) async {
guard let file = currentControlFile else { return }
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else {
onShowMessage?("名称不能为空")
return
}
guard trimmedName != file.name else { return }
await performLoading {
if file.isFolder {
try await api.modifyCloudFolderName(fileId: file.id, name: trimmedName)
} else {
try await api.modifyCloudFileName(fileId: file.id, fileName: trimmedName)
}
invalidateFolderCache(currentFolderID)
await refresh(api: api, showLoading: false)
}
}
///
func handleUploadCompleted(parentFolderId: Int, api: any CloudDriveServing) async {
if parentFolderId == currentFolderID {
invalidateFolderCache(parentFolderId)
await refresh(api: api, showLoading: false)
} else {
invalidateFolderCache(parentFolderId)
}
}
/// Android +
func moveTargetFolders() -> [CloudFile] {
let root = CloudFile(id: 0, name: "云盘", type: 99)
let folders = files.filter { $0.isFolder }
return ([root] + folders).reduce(into: [CloudFile]()) { result, item in
guard !result.contains(where: { $0.id == item.id }) else { return }
result.append(item)
}
}
private func loadPage(api: any CloudDriveServing, reset: Bool, showLoading: Bool) async {
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let response = try await api.cloudFileList(
parentFolderId: currentFolderID,
name: searchText,
type: filterType.rawValue,
orderBy: sortType.rawValue,
page: currentPage,
pageSize: pageSize
)
totalCount = response.total
files = reset ? response.list : files + response.list
canLoadMore = files.count < totalCount
folderCache[currentKey()] = CloudDriveFolderCache(list: files, total: totalCount, page: currentPage)
} catch is CancellationError {
return
} catch {
if reset {
files = []
}
canLoadMore = false
onShowMessage?(error.localizedDescription)
}
}
private func restoreOrReload(api: any CloudDriveServing) async {
if dirtyFolders.remove(currentFolderID) != nil {
await refresh(api: api, showLoading: true)
return
}
if let cached = folderCache[currentKey()] {
files = cached.list
totalCount = cached.total
currentPage = cached.page
canLoadMore = files.count < totalCount
notifyStateChange()
} else {
await refresh(api: api, showLoading: true)
}
}
private func performLoading(_ operation: () async throws -> Void) async {
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
try await operation()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func currentKey() -> CloudDriveCacheKey {
CloudDriveCacheKey(
folderId: currentFolderID,
name: searchText,
type: filterType.rawValue,
orderBy: sortType.rawValue
)
}
private func invalidateFolderCache(_ folderId: Int) {
folderCache = folderCache.filter { $0.key.folderId != folderId }
dirtyFolders.insert(folderId)
}
private func notifyStateChange() {
onStateChange?()
}
}