feat: 添加云盘与消息中心功能
This commit is contained in:
144
suixinkan/Features/CloudDrive/API/CloudDriveAPI.swift
Normal file
144
suixinkan/Features/CloudDrive/API/CloudDriveAPI.swift
Normal 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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
165
suixinkan/Features/CloudDrive/Models/CloudDriveModels.swift
Normal file
165
suixinkan/Features/CloudDrive/Models/CloudDriveModels.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
@ -37,8 +37,12 @@ enum HomeRouteHandler {
|
||||
selectTab(.statistics, from: viewController)
|
||||
case "system_settings":
|
||||
viewController.navigationController?.pushViewController(SettingViewController(), animated: true)
|
||||
case "message_center":
|
||||
viewController.navigationController?.pushViewController(MessageCenterViewController(), animated: true)
|
||||
case "space_settings":
|
||||
viewController.navigationController?.pushViewController(ProfileSpaceSettingsViewController(), animated: true)
|
||||
case "cloud_management":
|
||||
viewController.navigationController?.pushViewController(CloudDriveListViewController(), animated: true)
|
||||
case "pilot_cert":
|
||||
let controller = RealNameAuthViewController()
|
||||
viewController.navigationController?.pushViewController(controller, animated: true)
|
||||
|
||||
67
suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift
Normal file
67
suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// MessageCenterAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 消息中心服务协议,抽象列表、已读和删除接口。
|
||||
@MainActor
|
||||
protocol MessageCenterServing {
|
||||
/// 拉取消息列表。
|
||||
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse
|
||||
|
||||
/// 标记消息已读。
|
||||
func markAsRead(messageId: Int) async throws
|
||||
|
||||
/// 删除消息。
|
||||
func delete(messageId: Int) async throws
|
||||
}
|
||||
|
||||
/// 消息中心 API,对齐 Android `NetworkApi` 的 msg 区域。
|
||||
@MainActor
|
||||
final class MessageCenterAPI: MessageCenterServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化消息中心 API。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// GET /api/app/msg/list
|
||||
func list(lastId: Int = 0, limit: Int = 20, unread: Int = 0) async throws -> MessageListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/msg/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "last_id", value: String(max(lastId, 0))),
|
||||
URLQueryItem(name: "limit", value: String(max(limit, 1))),
|
||||
URLQueryItem(name: "unread", value: String(max(unread, 0))),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// POST /api/app/msg/read
|
||||
func markAsRead(messageId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/msg/read",
|
||||
queryItems: [URLQueryItem(name: "id", value: String(messageId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// POST /api/app/msg/delete
|
||||
func delete(messageId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/msg/delete",
|
||||
body: MessageDeleteRequest(id: messageId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,301 @@
|
||||
//
|
||||
// MessageCenterModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 可解码的 JSON 值,用于承接消息 `extra_data` 原始结构。
|
||||
enum MessageJSONValue: Decodable, Hashable, Sendable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: MessageJSONValue])
|
||||
case array([MessageJSONValue])
|
||||
case null
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([MessageJSONValue].self) {
|
||||
self = .array(value)
|
||||
} else {
|
||||
self = .object(try container.decode([String: MessageJSONValue].self))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息中心列表响应,对齐 Android `MsgResponse`。
|
||||
struct MessageListResponse: Decodable, Equatable, Sendable {
|
||||
let hasMore: Bool
|
||||
let lastId: Int
|
||||
let items: [MessageItem]
|
||||
let isRefresh: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hasMore = "has_more"
|
||||
case lastId = "last_id"
|
||||
case items
|
||||
case isRefresh = "is_refresh"
|
||||
}
|
||||
|
||||
init(hasMore: Bool = false, lastId: Int = 0, items: [MessageItem] = [], isRefresh: Bool = false) {
|
||||
self.hasMore = hasMore
|
||||
self.lastId = lastId
|
||||
self.items = items
|
||||
self.isRefresh = isRefresh
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
hasMore = try container.decodeLossyBool(forKey: .hasMore) ?? false
|
||||
lastId = try container.decodeLossyInt(forKey: .lastId) ?? 0
|
||||
items = (try? container.decodeIfPresent([MessageItem].self, forKey: .items)) ?? []
|
||||
isRefresh = try container.decodeLossyBool(forKey: .isRefresh) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条消息实体,对齐 Android `MsgEntity`。
|
||||
struct MessageItem: Decodable, Hashable, Sendable {
|
||||
let id: Int
|
||||
let receiverId: Int
|
||||
let receiverType: String
|
||||
let type: Int
|
||||
let typeName: String
|
||||
let title: String
|
||||
let content: String
|
||||
let pushAt: String
|
||||
let isRead: Bool
|
||||
let readAt: String
|
||||
let pushChannel: Int
|
||||
let pushChannelName: String
|
||||
let extraData: [String: MessageJSONValue]?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case receiverId = "receiver_id"
|
||||
case receiverType = "receiver_type"
|
||||
case type
|
||||
case typeName = "type_name"
|
||||
case title
|
||||
case content
|
||||
case pushAt = "push_at"
|
||||
case isRead = "is_read"
|
||||
case readAt = "read_at"
|
||||
case pushChannel = "push_channel"
|
||||
case pushChannelName = "push_channel_name"
|
||||
case extraData = "extra_data"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
receiverId: Int = 0,
|
||||
receiverType: String = "",
|
||||
type: Int = 0,
|
||||
typeName: String = "",
|
||||
title: String = "",
|
||||
content: String = "",
|
||||
pushAt: String = "",
|
||||
isRead: Bool = false,
|
||||
readAt: String = "",
|
||||
pushChannel: Int = 0,
|
||||
pushChannelName: String = "",
|
||||
extraData: [String: MessageJSONValue]? = nil,
|
||||
createdAt: String = "",
|
||||
updatedAt: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.receiverId = receiverId
|
||||
self.receiverType = receiverType
|
||||
self.type = type
|
||||
self.typeName = typeName
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.pushAt = pushAt
|
||||
self.isRead = isRead
|
||||
self.readAt = readAt
|
||||
self.pushChannel = pushChannel
|
||||
self.pushChannelName = pushChannelName
|
||||
self.extraData = extraData
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
receiverId = try container.decodeLossyInt(forKey: .receiverId) ?? 0
|
||||
receiverType = try container.decodeLossyString(forKey: .receiverType)
|
||||
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||
typeName = try container.decodeLossyString(forKey: .typeName)
|
||||
title = try container.decodeLossyString(forKey: .title)
|
||||
content = try container.decodeLossyString(forKey: .content)
|
||||
pushAt = try container.decodeLossyString(forKey: .pushAt)
|
||||
isRead = try container.decodeLossyBool(forKey: .isRead) ?? false
|
||||
readAt = try container.decodeLossyString(forKey: .readAt)
|
||||
pushChannel = try container.decodeLossyInt(forKey: .pushChannel) ?? 0
|
||||
pushChannelName = try container.decodeLossyString(forKey: .pushChannelName)
|
||||
extraData = try? container.decodeIfPresent([String: MessageJSONValue].self, forKey: .extraData)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
||||
}
|
||||
|
||||
/// Android 列表标题使用 `type_name`。
|
||||
var displayTitle: String {
|
||||
typeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// Android 列表优先展示 `push_at`,否则格式化 `created_at`。
|
||||
var displayTime: String {
|
||||
let pushTime = pushAt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !pushTime.isEmpty { return pushTime }
|
||||
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
|
||||
}
|
||||
|
||||
/// 返回已读状态的消息副本。
|
||||
func markedRead() -> MessageItem {
|
||||
MessageItem(
|
||||
id: id,
|
||||
receiverId: receiverId,
|
||||
receiverType: receiverType,
|
||||
type: type,
|
||||
typeName: typeName,
|
||||
title: title,
|
||||
content: content,
|
||||
pushAt: pushAt,
|
||||
isRead: true,
|
||||
readAt: readAt,
|
||||
pushChannel: pushChannel,
|
||||
pushChannelName: pushChannelName,
|
||||
extraData: extraData,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息删除请求体,对齐 Android `MsgDeleteRequest`。
|
||||
struct MessageDeleteRequest: Encodable, Sendable {
|
||||
let id: Int
|
||||
}
|
||||
|
||||
/// 消息中心日期显示工具,对齐 Android `DateUtils.formatDateTime` 的用户可见规则。
|
||||
enum MessageDateFormatter {
|
||||
private static let calendar = Calendar.current
|
||||
|
||||
static func formatDateTime(_ input: String?) -> String? {
|
||||
guard let input = input?.trimmingCharacters(in: .whitespacesAndNewlines), !input.isEmpty else { return nil }
|
||||
guard let date = parseDate(input) else { return nil }
|
||||
|
||||
let today = Date()
|
||||
let startOfInput = calendar.startOfDay(for: date)
|
||||
let startOfToday = calendar.startOfDay(for: today)
|
||||
let dayDifference = calendar.dateComponents([.day], from: startOfInput, to: startOfToday).day ?? 0
|
||||
let inputComponents = calendar.dateComponents([.year, .month, .day, .weekday], from: date)
|
||||
let todayComponents = calendar.dateComponents([.year, .month], from: today)
|
||||
|
||||
if dayDifference == 0 {
|
||||
return "今天"
|
||||
}
|
||||
if (1 ... 6).contains(dayDifference), let weekday = inputComponents.weekday {
|
||||
return weekdayName(weekday)
|
||||
}
|
||||
if inputComponents.year == todayComponents.year, inputComponents.month == todayComponents.month {
|
||||
return "本月\(inputComponents.day ?? 0)日"
|
||||
}
|
||||
if inputComponents.year == todayComponents.year {
|
||||
return "\(inputComponents.month ?? 0)月\(inputComponents.day ?? 0)日"
|
||||
}
|
||||
return "\(inputComponents.year ?? 0)年\(inputComponents.month ?? 0)月\(inputComponents.day ?? 0)日"
|
||||
}
|
||||
|
||||
private static func parseDate(_ input: String) -> Date? {
|
||||
if let timestamp = Double(input) {
|
||||
let seconds = timestamp > 1_000_000_000_000 ? timestamp / 1000 : timestamp
|
||||
return Date(timeIntervalSince1970: seconds)
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd"] {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: input) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func weekdayName(_ weekday: Int) -> String {
|
||||
switch weekday {
|
||||
case 1: return "周日"
|
||||
case 2: return "周一"
|
||||
case 3: return "周二"
|
||||
case 4: return "周三"
|
||||
case 5: return "周四"
|
||||
case 6: return "周五"
|
||||
case 7: return "周六"
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) { return intValue }
|
||||
if let doubleValue = Double(text) { return Int(doubleValue) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value != 0
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if ["true", "1", "yes"].contains(text) { return true }
|
||||
if ["false", "0", "no"].contains(text) { return false }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
//
|
||||
// MessageCenterViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 消息中心列表页 ViewModel,负责分页、刷新和未读点击处理。
|
||||
final class MessageCenterViewModel {
|
||||
private(set) var items: [MessageItem] = []
|
||||
private(set) var isLoading = false
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isLoadingMore = false
|
||||
private(set) var canLoadMore = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onOpenDetail: ((MessageItem) -> Void)?
|
||||
|
||||
private let pageSize = 20
|
||||
private var lastId = 0
|
||||
|
||||
/// 首次加载消息列表。
|
||||
func loadInitial(api: any MessageCenterServing) async {
|
||||
await load(reset: true, showLoading: true, api: api)
|
||||
}
|
||||
|
||||
/// 下拉刷新消息列表。
|
||||
func refresh(api: any MessageCenterServing) async {
|
||||
guard !isLoading else { return }
|
||||
isRefreshing = true
|
||||
notifyStateChange()
|
||||
await load(reset: true, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 滚动到底部附近时加载更多。
|
||||
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any MessageCenterServing) async {
|
||||
guard lastVisibleIndex >= items.count - 4 else { return }
|
||||
await load(reset: false, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 点击消息:未读先标记已读,成功后进入详情;已读直接进入详情。
|
||||
func selectMessage(id: Int, api: any MessageCenterServing) async {
|
||||
guard let item = items.first(where: { $0.id == id }) else { return }
|
||||
if item.isRead {
|
||||
onOpenDetail?(item)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
try await api.markAsRead(messageId: id)
|
||||
let readItem = item.markedRead()
|
||||
items = items.map { $0.id == id ? readItem : $0 }
|
||||
onOpenDetail?(readItem)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?("标记已读失败")
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除后从本地列表移除消息。
|
||||
func removeMessage(id: Int) {
|
||||
items.removeAll { $0.id == id }
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func load(reset: Bool, showLoading: Bool, api: any MessageCenterServing) async {
|
||||
if reset {
|
||||
lastId = 0
|
||||
canLoadMore = false
|
||||
} else {
|
||||
guard canLoadMore, !isLoading, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
}
|
||||
|
||||
isLoading = showLoading
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
isLoadingMore = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.list(lastId: reset ? 0 : lastId, limit: pageSize, unread: 0)
|
||||
lastId = response.lastId
|
||||
items = reset ? response.items : items + response.items
|
||||
canLoadMore = response.hasMore && response.lastId > 0
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
if reset {
|
||||
items = []
|
||||
lastId = 0
|
||||
canLoadMore = false
|
||||
}
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息详情页 ViewModel,负责删除当前消息。
|
||||
final class MessageDetailViewModel {
|
||||
private(set) var message: MessageItem
|
||||
private(set) var isDeleting = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onDeleted: ((Int) -> Void)?
|
||||
|
||||
/// 初始化消息详情 ViewModel。
|
||||
init(message: MessageItem) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
/// 删除当前消息。
|
||||
func delete(api: any MessageCenterServing) async {
|
||||
guard message.id > 0 else {
|
||||
onShowMessage?("消息ID无效")
|
||||
return
|
||||
}
|
||||
|
||||
isDeleting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isDeleting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
try await api.delete(messageId: message.id)
|
||||
onShowMessage?("删除成功")
|
||||
onDeleted?(message.id)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onShowMessage?("删除失败: \(detail.isEmpty ? "请稍后重试" : detail)")
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@ -89,86 +89,6 @@ struct AvailableOrder: Decodable, Sendable, Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘文件/文件夹实体,对齐 Android `CloudFileEntity`。
|
||||
struct CloudFile: Decodable, 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: Decodable, Sendable, Equatable {
|
||||
let total: Int
|
||||
let list: [CloudFile]
|
||||
|
||||
init(total: Int = 0, list: [CloudFile] = []) {
|
||||
self.total = total
|
||||
self.list = list
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘路径节点,用于面包屑导航。
|
||||
struct CloudPathItem: Sendable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// 任务素材来源。
|
||||
enum TaskFileSource: String, Sendable, Equatable {
|
||||
case cloud
|
||||
|
||||
Reference in New Issue
Block a user