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

@ -27,6 +27,8 @@ final class NetworkServices {
let materialManagementAPI: MaterialManagementAPI
let punchPointAPI: PunchPointAPI
let liveAPI: LiveAPI
let cloudDriveAPI: CloudDriveAPI
let messageCenterAPI: MessageCenterAPI
let wildPhotographerReportAPI: WildPhotographerReportAPI
let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService
@ -50,6 +52,8 @@ final class NetworkServices {
materialManagementAPI = MaterialManagementAPI(client: client)
punchPointAPI = PunchPointAPI(client: client)
liveAPI = LiveAPI(client: client)
cloudDriveAPI = CloudDriveAPI(client: client)
messageCenterAPI = MessageCenterAPI(client: client)
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI)
@ -78,6 +82,8 @@ final class NetworkServices {
materialManagementAPI = MaterialManagementAPI(client: apiClient)
punchPointAPI = PunchPointAPI(client: apiClient)
liveAPI = LiveAPI(client: apiClient)
cloudDriveAPI = CloudDriveAPI(client: apiClient)
messageCenterAPI = MessageCenterAPI(client: apiClient)
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient)
ossUploadService = OSSUploadService(configService: uploadAPI)

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_add_folder.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_add_photo.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_add_video.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_current_path_arrow.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_delete.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_details.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_download.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_filter.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_item_details.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_move.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_play.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_search.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_share.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_type_folder.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_type_photo.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_file_type_video.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_folder_type.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_go_home.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_list_sort.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_show_type_list.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_show_type_table.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "icon_cloud_storage_transit.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_msg1.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_msg2.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_msg3.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_msg4.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_msg5.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_msg8.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "ic_msg999.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -179,6 +179,24 @@ final class OSSUploadService {
)
}
/// Android `cloud_driver` / `cloud_driver_test`
func uploadCloudDriveFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
try await uploadFile(
data: data,
fileName: fileName,
fileType: fileType,
scenicId: scenicId,
moduleType: "cloud_driver",
onProgress: onProgress
)
}
///
func uploadWildReportAttachment(
data: Data,
@ -280,7 +298,7 @@ enum OSSUploadPolicy {
formatter.timeZone = timeZone
formatter.dateFormat = "yyyyMMdd"
let currentDate = formatter.string(from: date)
let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "")
let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "").lowercased()
let safeName = sanitizedFileName(fileName)
switch moduleType {
case "user_avatar":
@ -307,6 +325,16 @@ enum OSSUploadPolicy {
return "live_covers/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "alive_album":
return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "cloud_driver":
let userId = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let ownerId = userId.isEmpty ? String(scenicId) : userId
let month = String(currentDate.dropFirst(4).prefix(2))
#if DEBUG
let root = "cloud_driver_test"
#else
let root = "cloud_driver"
#endif
return "\(root)/\(ownerId)/\(String(currentDate.prefix(4)))/\(month)/\(safeName)"
case "photog_report":
return "photog_report/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
default:

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?()
}
}

View File

@ -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)

View 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)
)
)
}
}

View File

@ -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
}
}

View File

@ -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?()
}
}

View File

@ -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

View File

@ -0,0 +1,137 @@
//
// CloudDriveActionSheetViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android
final class CloudDriveActionSheetViewController: UIViewController {
///
struct Item {
///
let title: String
///
let image: UIImage?
///
let isDestructive: Bool
///
let handler: () -> Void
}
private let sheetTitle: String?
private let items: [Item]
private let dimView = UIView()
private let panelView = UIView()
private let stackView = UIStackView()
///
init(title: String? = nil, items: [Item]) {
self.sheetTitle = title
self.items = items
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
}
private func setupUI() {
view.backgroundColor = .clear
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
panelView.backgroundColor = .white
panelView.layer.cornerRadius = 18
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true
stackView.axis = .vertical
stackView.spacing = 0
view.addSubview(dimView)
view.addSubview(panelView)
panelView.addSubview(stackView)
if let sheetTitle {
let titleLabel = UILabel()
titleLabel.text = sheetTitle
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
titleLabel.snp.makeConstraints { make in
make.height.equalTo(54)
}
stackView.addArrangedSubview(titleLabel)
}
for item in items {
stackView.addArrangedSubview(makeItemButton(item))
}
let spacer = UIView()
spacer.backgroundColor = AppColor.pageBackground
spacer.snp.makeConstraints { make in
make.height.equalTo(8)
}
stackView.addArrangedSubview(spacer)
let cancelButton = UIButton(type: .system)
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textPrimary, for: .normal)
cancelButton.titleLabel?.font = .app(.body)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
cancelButton.snp.makeConstraints { make in
make.height.equalTo(54)
}
stackView.addArrangedSubview(cancelButton)
}
private func setupConstraints() {
dimView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
panelView.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
stackView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
private func makeItemButton(_ item: Item) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(item.title, for: .normal)
button.setImage(item.image, for: .normal)
button.tintColor = item.isDestructive ? AppColor.danger : AppColor.textPrimary
button.setTitleColor(item.isDestructive ? AppColor.danger : AppColor.textPrimary, for: .normal)
button.titleLabel?.font = .app(.body)
button.contentHorizontalAlignment = .left
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.xl, bottom: 0, right: AppSpacing.xl)
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.md)
button.addAction(UIAction { [weak self] _ in
self?.dismiss(animated: true) {
item.handler()
}
}, for: .touchUpInside)
button.snp.makeConstraints { make in
make.height.equalTo(54)
}
return button
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
}

View File

@ -0,0 +1,149 @@
//
// CloudDriveDetailSheetViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class CloudDriveDetailSheetViewController: UIViewController {
private let file: CloudFile
private let onSave: (String) -> Void
private let dimView = UIView()
private let panelView = UIView()
private let titleLabel = UILabel()
private let nameTitleLabel = UILabel()
private let nameField = UITextField()
private let infoLabel = UILabel()
private let cancelButton = UIButton(type: .system)
private let saveButton = UIButton(type: .system)
///
init(file: CloudFile, onSave: @escaping (String) -> Void) {
self.file = file
self.onSave = onSave
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
}
private func setupUI() {
view.backgroundColor = .clear
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
panelView.backgroundColor = .white
panelView.layer.cornerRadius = 18
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true
titleLabel.text = "详情"
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
nameTitleLabel.text = "文件名称"
nameTitleLabel.font = .app(.caption)
nameTitleLabel.textColor = AppColor.textSecondary
nameField.text = file.name
nameField.font = .app(.body)
nameField.textColor = AppColor.textPrimary
nameField.clearButtonMode = .whileEditing
nameField.returnKeyType = .done
nameField.delegate = self
nameField.backgroundColor = AppColor.inputBackground
nameField.layer.cornerRadius = AppRadius.xs
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: AppSpacing.sm, height: 1))
nameField.leftViewMode = .always
infoLabel.text = "\(file.createdAt) | \(CloudDriveFormatter.fileSize(file.fileSize))\n类型:\(CloudDriveFormatter.typeName(file.type))"
infoLabel.font = .app(.caption)
infoLabel.textColor = AppColor.textTertiary
infoLabel.numberOfLines = 0
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
cancelButton.titleLabel?.font = .app(.body)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
saveButton.setTitle("保存", for: .normal)
saveButton.setTitleColor(.white, for: .normal)
saveButton.titleLabel?.font = .app(.bodyMedium)
saveButton.backgroundColor = AppColor.primary
saveButton.layer.cornerRadius = AppRadius.xs
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
view.addSubview(dimView)
view.addSubview(panelView)
[titleLabel, nameTitleLabel, nameField, infoLabel, cancelButton, saveButton].forEach(panelView.addSubview)
}
private func setupConstraints() {
dimView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
panelView.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(54)
}
nameTitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
nameField.snp.makeConstraints { make in
make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalTo(nameTitleLabel)
make.height.equalTo(42)
}
infoLabel.snp.makeConstraints { make in
make.top.equalTo(nameField.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalTo(nameTitleLabel)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(infoLabel.snp.bottom).offset(AppSpacing.lg)
make.leading.equalToSuperview().offset(AppSpacing.md)
make.height.equalTo(44)
make.width.equalTo(saveButton)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
}
saveButton.snp.makeConstraints { make in
make.top.bottom.width.equalTo(cancelButton)
make.leading.equalTo(cancelButton.snp.trailing).offset(AppSpacing.md)
make.trailing.equalToSuperview().inset(AppSpacing.md)
}
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
@objc private func saveTapped() {
let name = nameField.text ?? ""
dismiss(animated: true) { [onSave] in
onSave(name)
}
}
}
extension CloudDriveDetailSheetViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}

View File

@ -0,0 +1,189 @@
//
// CloudDriveFileCell.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Cell Android
final class CloudDriveFileCell: UICollectionViewCell {
static let reuseIdentifier = "CloudDriveFileCell"
var onDetails: (() -> Void)?
private let thumbnailView = UIImageView()
private let folderIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_folder_type", fallbackSystemName: "folder.fill"))
private let playIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_play", fallbackSystemName: "play.circle.fill"))
private let extensionLabel = CloudDrivePaddingLabel()
private let nameLabel = UILabel()
private let detailLabel = UILabel()
private let moreButton = UIButton(type: .system)
private var gridConstraints: [Constraint] = []
private var listConstraints: [Constraint] = []
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
thumbnailView.kf.cancelDownloadTask()
thumbnailView.image = nil
folderIconView.isHidden = true
playIconView.isHidden = true
extensionLabel.isHidden = true
onDetails = nil
}
///
func apply(file: CloudFile, mode: CloudDriveDisplayMode) {
nameLabel.text = file.name
detailLabel.text = mode == .grid ? CloudDriveFormatter.typeName(file.type) : CloudDriveFormatter.fileDescription(file)
configureThumbnail(file)
gridConstraints.forEach { mode == .grid ? $0.activate() : $0.deactivate() }
listConstraints.forEach { mode == .list ? $0.activate() : $0.deactivate() }
contentView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0
contentView.layer.borderWidth = mode == .grid ? 1 : 0
contentView.backgroundColor = mode == .grid ? .white : .clear
thumbnailView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0
nameLabel.textAlignment = mode == .grid ? .left : .left
}
private func setupUI() {
contentView.clipsToBounds = true
contentView.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor
thumbnailView.contentMode = .scaleAspectFill
thumbnailView.clipsToBounds = true
thumbnailView.backgroundColor = AppColor.warningBackground
folderIconView.tintColor = AppColor.warning
folderIconView.contentMode = .scaleAspectFit
playIconView.tintColor = .white
playIconView.contentMode = .scaleAspectFit
playIconView.isHidden = true
extensionLabel.font = .app(.caption)
extensionLabel.textColor = .white
extensionLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5)
extensionLabel.layer.cornerRadius = AppRadius.xs
extensionLabel.clipsToBounds = true
extensionLabel.isHidden = true
nameLabel.font = .app(.body)
nameLabel.textColor = AppColor.textPrimary
nameLabel.numberOfLines = 1
detailLabel.font = .app(.caption)
detailLabel.textColor = UIColor(hex: 0x4B5563)
detailLabel.numberOfLines = 1
moreButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_details", fallbackSystemName: "ellipsis"), for: .normal)
moreButton.tintColor = AppColor.textSecondary
moreButton.addTarget(self, action: #selector(moreTapped), for: .touchUpInside)
contentView.addSubview(thumbnailView)
contentView.addSubview(folderIconView)
contentView.addSubview(playIconView)
contentView.addSubview(extensionLabel)
contentView.addSubview(nameLabel)
contentView.addSubview(detailLabel)
contentView.addSubview(moreButton)
}
private func setupConstraints() {
thumbnailView.snp.makeConstraints { make in
gridConstraints.append(make.top.leading.trailing.equalToSuperview().constraint)
gridConstraints.append(make.height.equalTo(thumbnailView.snp.width).multipliedBy(0.56).constraint)
listConstraints.append(make.leading.centerY.equalToSuperview().constraint)
listConstraints.append(make.width.height.equalTo(48).constraint)
}
folderIconView.snp.makeConstraints { make in
make.center.equalTo(thumbnailView)
make.width.height.equalTo(42)
}
playIconView.snp.makeConstraints { make in
make.center.equalTo(thumbnailView)
make.width.height.equalTo(28)
}
extensionLabel.snp.makeConstraints { make in
make.top.leading.equalTo(thumbnailView).offset(AppSpacing.xs)
}
nameLabel.snp.makeConstraints { make in
gridConstraints.append(make.top.equalTo(thumbnailView.snp.bottom).offset(AppSpacing.xs).constraint)
gridConstraints.append(make.leading.equalToSuperview().offset(AppSpacing.sm).constraint)
listConstraints.append(make.top.equalToSuperview().offset(AppSpacing.sm).constraint)
listConstraints.append(make.leading.equalTo(thumbnailView.snp.trailing).offset(AppSpacing.sm).constraint)
make.trailing.equalTo(moreButton.snp.leading).offset(-AppSpacing.xs)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(AppSpacing.xxs)
make.leading.equalTo(nameLabel)
make.trailing.equalTo(nameLabel)
}
moreButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.xs)
make.centerY.equalTo(nameLabel.snp.bottom).offset(4)
make.width.height.equalTo(32)
}
listConstraints.forEach { $0.deactivate() }
}
private func configureThumbnail(_ file: CloudFile) {
if file.isFolder {
thumbnailView.image = nil
thumbnailView.backgroundColor = AppColor.warningBackground
folderIconView.isHidden = false
extensionLabel.isHidden = true
return
}
folderIconView.isHidden = true
thumbnailView.backgroundColor = AppColor.inputBackground
let preview = file.isVideo ? file.coverUrl : file.fileUrl
if let url = URL(string: preview), !preview.isEmpty {
let placeholder = CloudDriveAsset.image(
named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
fallbackSystemName: file.isVideo ? "video" : "photo"
)
thumbnailView.kf.setImage(with: url, placeholder: placeholder)
} else {
thumbnailView.image = CloudDriveAsset.image(
named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
fallbackSystemName: file.isVideo ? "video" : "photo"
)
thumbnailView.tintColor = AppColor.textTertiary
}
playIconView.isHidden = !file.isVideo
extensionLabel.text = CloudDriveFormatter.fileExtension(file.fileUrl.isEmpty ? file.name : file.fileUrl)
extensionLabel.isHidden = false
}
@objc private func moreTapped() {
onDetails?()
}
}
///
private final class CloudDrivePaddingLabel: UILabel {
var insets = UIEdgeInsets(top: 3, left: 5, bottom: 3, right: 5)
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom)
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: insets))
}
}

View File

@ -0,0 +1,62 @@
//
// CloudDriveFormatter.swift
// suixinkan
//
import Foundation
import UIKit
///
enum CloudDriveFormatter {
///
static func typeName(_ type: Int) -> String {
switch type {
case 1:
"视频"
case 2:
"图片"
case 99:
"文件夹"
default:
"文件"
}
}
///
static func fileDescription(_ file: CloudFile) -> String {
if file.isFolder {
return "\(file.childNum) 个项目"
}
return "\(typeName(file.type)) \(fileSize(file.fileSize))"
}
///
static func fileSize(_ bytes: Int64) -> String {
guard bytes > 0 else { return "0B" }
let units = ["B", "KB", "MB", "GB", "TB"]
var value = Double(bytes)
var index = 0
while value >= 1024, index < units.count - 1 {
value /= 1024
index += 1
}
if index == 0 {
return "\(Int(value))\(units[index])"
}
return String(format: "%.1f%@", value, units[index])
}
/// URL
static func fileExtension(_ value: String) -> String {
let ext = URL(string: value)?.pathExtension ?? URL(fileURLWithPath: value).pathExtension
return ext.isEmpty ? typeName(0) : ext.uppercased()
}
}
/// Android 访
enum CloudDriveAsset {
/// 使
static func image(named name: String, fallbackSystemName: String) -> UIImage? {
UIImage(named: name)?.withRenderingMode(.alwaysOriginal) ?? UIImage(systemName: fallbackSystemName)
}
}

View File

@ -0,0 +1,529 @@
//
// CloudDriveListViewController.swift
// suixinkan
//
import Kingfisher
import PhotosUI
import SnapKit
import UIKit
import UniformTypeIdentifiers
/// Android `CloudStorageListScreen`
final class CloudDriveListViewController: BaseViewController {
private let viewModel = CloudDriveListViewModel()
private let api: any CloudDriveServing
private let transferManager: CloudTransferManager
private let pathScrollView = UIScrollView()
private let pathStackView = UIStackView()
private let searchContainer = UIView()
private let searchIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_search", fallbackSystemName: "magnifyingglass"))
private let searchField = UITextField()
private let sortButton = UIButton(type: .system)
private let filterButton = UIButton(type: .system)
private let displayModeButton = UIButton(type: .system)
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, CloudFile>!
private let uploadButton = AppButton(title: "上传")
private let bottomBar = UIView()
///
init(
api: (any CloudDriveServing)? = nil,
transferManager: CloudTransferManager = .shared
) {
self.api = api ?? NetworkServices.shared.cloudDriveAPI
self.transferManager = transferManager
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "相册云盘"
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: CloudDriveAsset.image(named: "icon_cloud_storage_transit", fallbackSystemName: "arrow.up.arrow.down.circle"),
style: .plain,
target: self,
action: #selector(openTransferManager)
)
}
override func setupUI() {
view.backgroundColor = .white
pathScrollView.showsHorizontalScrollIndicator = false
pathStackView.axis = .horizontal
pathStackView.alignment = .center
pathStackView.spacing = AppSpacing.xs
pathScrollView.addSubview(pathStackView)
searchContainer.backgroundColor = AppColor.inputBackground
searchContainer.layer.cornerRadius = AppRadius.xs
searchIconView.tintColor = AppColor.textTertiary
searchField.placeholder = "搜索文件名称"
searchField.font = .app(.body)
searchField.textColor = AppColor.textPrimary
searchField.returnKeyType = .search
searchField.delegate = self
searchContainer.addSubview(searchIconView)
searchContainer.addSubview(searchField)
configureFilterButton(sortButton)
configureFilterButton(filterButton)
displayModeButton.tintColor = AppColor.textPrimary
displayModeButton.backgroundColor = .white
displayModeButton.layer.cornerRadius = AppRadius.xs
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.register(CloudDriveFileCell.self, forCellWithReuseIdentifier: CloudDriveFileCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Int, CloudFile>(collectionView: collectionView) { [weak self] collectionView, indexPath, file in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: CloudDriveFileCell.reuseIdentifier,
for: indexPath
) as! CloudDriveFileCell
cell.apply(file: file, mode: self?.viewModel.displayMode ?? .grid)
cell.onDetails = { [weak self] in self?.showControlSheet(for: file) }
return cell
}
bottomBar.backgroundColor = .white
bottomBar.layer.borderColor = AppColor.border.cgColor
bottomBar.layer.borderWidth = 0.5
view.addSubview(pathScrollView)
view.addSubview(searchContainer)
view.addSubview(sortButton)
view.addSubview(filterButton)
view.addSubview(displayModeButton)
view.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(uploadButton)
}
override func setupConstraints() {
pathScrollView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(52)
}
pathStackView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalToSuperview()
}
searchContainer.snp.makeConstraints { make in
make.top.equalTo(pathScrollView.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
}
searchIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.sm)
make.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
searchField.snp.makeConstraints { make in
make.leading.equalTo(searchIconView.snp.trailing).offset(AppSpacing.sm)
make.top.bottom.trailing.equalToSuperview().inset(AppSpacing.xs)
}
sortButton.snp.makeConstraints { make in
make.top.equalTo(searchContainer.snp.bottom).offset(AppSpacing.md)
make.leading.equalToSuperview().offset(AppSpacing.md)
make.height.equalTo(36)
}
filterButton.snp.makeConstraints { make in
make.top.width.height.equalTo(sortButton)
make.leading.equalTo(sortButton.snp.trailing).offset(AppSpacing.sm)
}
displayModeButton.snp.makeConstraints { make in
make.centerY.equalTo(sortButton)
make.leading.equalTo(filterButton.snp.trailing).offset(AppSpacing.sm)
make.trailing.equalToSuperview().inset(6)
make.width.height.equalTo(36)
}
sortButton.snp.makeConstraints { make in
make.width.equalTo(filterButton)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
uploadButton.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(sortButton.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
}
override func bindActions() {
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
transferManager.onUploadCompleted = { [weak self] folderId in
guard let self else { return }
Task { await self.viewModel.handleUploadCompleted(parentFolderId: folderId, api: self.api) }
}
transferManager.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
configureMenus()
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refresh(api: api, showLoading: true) }
}
@MainActor
private func applyViewModel() {
rebuildBreadcrumb()
searchField.text = viewModel.searchText
sortButton.setTitle(viewModel.sortType.title, for: .normal)
filterButton.setTitle(viewModel.filterType.title, for: .normal)
let displayIconName = viewModel.displayMode == .grid ? "icon_cloud_storage_show_type_list" : "icon_cloud_storage_show_type_table"
let displayFallback = viewModel.displayMode == .grid ? "list.bullet" : "square.grid.2x2"
displayModeButton.setImage(CloudDriveAsset.image(named: displayIconName, fallbackSystemName: displayFallback), for: .normal)
sortButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_list_sort", fallbackSystemName: "arrow.up.arrow.down"), for: .normal)
filterButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_filter", fallbackSystemName: "line.3.horizontal.decrease.circle"), for: .normal)
collectionView.setCollectionViewLayout(makeLayout(), animated: false)
var snapshot = NSDiffableDataSourceSnapshot<Int, CloudFile>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.files)
dataSource.apply(snapshot, animatingDifferences: true)
if viewModel.isLoading && viewModel.files.isEmpty {
showLoading()
} else {
hideLoading()
}
}
private func rebuildBreadcrumb() {
pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
let homeButton = UIButton(type: .system)
homeButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_go_home", fallbackSystemName: "house.fill"), for: .normal)
homeButton.tintColor = AppColor.textPrimary
homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside)
homeButton.snp.makeConstraints { make in
make.width.height.equalTo(24)
}
pathStackView.addArrangedSubview(homeButton)
for (index, item) in viewModel.pathStack.enumerated() {
if index > 0 {
let arrow = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_current_path_arrow", fallbackSystemName: "chevron.right"))
arrow.tintColor = AppColor.textTertiary
arrow.snp.makeConstraints { make in
make.width.equalTo(8)
}
pathStackView.addArrangedSubview(arrow)
}
let button = UIButton(type: .system)
button.setTitle(item.name, for: .normal)
button.titleLabel?.font = .app(index == viewModel.pathStack.count - 1 ? .body : .caption)
button.setTitleColor(index == viewModel.pathStack.count - 1 ? .black : AppColor.textSecondary, for: .normal)
button.tag = index
button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside)
pathStackView.addArrangedSubview(button)
}
}
private func configureMenus() {
sortButton.menu = UIMenu(children: CloudDriveSortType.allCases.map { type in
UIAction(title: type.title) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.chooseSortType(type, api: self.api) }
}
})
sortButton.showsMenuAsPrimaryAction = true
filterButton.menu = UIMenu(children: CloudDriveFilterType.allCases.map { type in
UIAction(title: type.title) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.chooseFilterType(type, api: self.api) }
}
})
filterButton.showsMenuAsPrimaryAction = true
}
private func configureFilterButton(_ button: UIButton) {
button.backgroundColor = AppColor.inputBackground
button.layer.cornerRadius = AppRadius.xs
button.titleLabel?.font = .app(.caption)
button.setTitleColor(AppColor.textPrimary, for: .normal)
button.contentHorizontalAlignment = .left
button.imageView?.contentMode = .scaleAspectFit
button.tintColor = AppColor.textPrimary
button.semanticContentAttribute = .forceLeftToRight
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.xs)
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.sm, bottom: 0, right: AppSpacing.sm)
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] _, environment in
let mode = self?.viewModel.displayMode ?? .grid
switch mode {
case .grid:
let columns = 2
let spacing: CGFloat = 15
let horizontalInset: CGFloat = 12
let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2
let itemWidth = (availableWidth - spacing) / CGFloat(columns)
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemWidth * 0.82))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemWidth * 0.82))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: columns)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: horizontalInset, bottom: 12, trailing: horizontalInset)
section.interGroupSpacing = spacing
return section
case .list:
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(72))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: AppSpacing.md, bottom: AppSpacing.md, trailing: AppSpacing.md)
return section
}
}
}
private func showControlSheet(for file: CloudFile) {
viewModel.setControlFile(file)
let sheet = CloudDriveActionSheetViewController(title: "请选择", items: [
CloudDriveActionSheetViewController.Item(
title: "删除",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_delete", fallbackSystemName: "trash"),
isDestructive: true
) { [weak self] in self?.confirmDelete() },
CloudDriveActionSheetViewController.Item(
title: "下载",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_download", fallbackSystemName: "arrow.down.circle"),
isDestructive: false
) { [weak self] in self?.download(file) },
CloudDriveActionSheetViewController.Item(
title: "移动",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_move", fallbackSystemName: "folder"),
isDestructive: false
) { [weak self] in self?.showMoveDialog() },
CloudDriveActionSheetViewController.Item(
title: "详情",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_item_details", fallbackSystemName: "info.circle"),
isDestructive: false
) { [weak self] in self?.showDetailDialog(file) }
])
present(sheet, animated: true)
}
private func confirmDelete() {
showAlert(title: "删除确认", message: "删除后无法恢复,确认删除吗?") { [weak self] in
guard let self else { return }
Task { await self.viewModel.deleteCurrentFile(api: self.api) }
}
}
private func download(_ file: CloudFile) {
guard file.isSelectableMedia else {
showToast("当前类型不支持下载")
return
}
guard !file.fileUrl.isEmpty else {
showToast("文件地址不存在")
return
}
transferManager.addDownloadTask(fileURL: file.fileUrl, fileName: file.name, fileType: file.type, fileSize: file.fileSize)
}
private func showMoveDialog() {
let items = viewModel.moveTargetFolders().map { folder in
CloudDriveActionSheetViewController.Item(
title: folder.name,
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_type_folder", fallbackSystemName: "folder"),
isDestructive: false
) { [weak self] in
guard let self else { return }
Task { await self.viewModel.moveCurrentFile(targetFolderId: folder.id, api: self.api) }
}
}
present(CloudDriveActionSheetViewController(title: "移动到", items: items), animated: true)
}
private func showDetailDialog(_ file: CloudFile) {
let sheet = CloudDriveDetailSheetViewController(file: file) { [weak self] name in
guard let self else { return }
Task { await self.viewModel.saveCurrentFileName(name, api: self.api) }
}
present(sheet, animated: true)
}
@objc private func uploadTapped() {
let sheet = CloudDriveActionSheetViewController(title: "选择上传类型", items: [
CloudDriveActionSheetViewController.Item(
title: "上传图片",
image: CloudDriveAsset.image(named: "icon_cloud_storage_add_photo", fallbackSystemName: "photo"),
isDestructive: false
) { [weak self] in self?.beginPick(type: 1) },
CloudDriveActionSheetViewController.Item(
title: "上传视频",
image: CloudDriveAsset.image(named: "icon_cloud_storage_add_video", fallbackSystemName: "video"),
isDestructive: false
) { [weak self] in self?.beginPick(type: 2) },
CloudDriveActionSheetViewController.Item(
title: "新建文件夹",
image: CloudDriveAsset.image(named: "icon_cloud_storage_add_folder", fallbackSystemName: "folder.badge.plus"),
isDestructive: false
) { [weak self] in self?.showCreateFolderDialog() }
])
present(sheet, animated: true)
}
private func beginPick(type: Int) {
Task {
guard await viewModel.checkCanUpload(api: api) else { return }
await MainActor.run {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selectionLimit = 1
configuration.filter = type == 1 ? .images : .videos
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
picker.view.tag = type
present(picker, animated: true)
}
}
}
private func showCreateFolderDialog() {
guard viewModel.pathStack.count < 5 else {
showToast("不能创建更深的文件夹")
return
}
let alert = UIAlertController(title: "新建文件夹", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "请输入文件夹名称"
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self, weak alert] _ in
guard let self else { return }
let name = alert?.textFields?.first?.text ?? ""
Task { await self.viewModel.addFolder(name: name, api: self.api) }
})
present(alert, animated: true)
}
@objc private func displayModeTapped() {
viewModel.toggleDisplayMode()
}
@objc private func openTransferManager() {
navigationController?.pushViewController(CloudDriveTransferViewController(manager: transferManager), animated: true)
}
@objc private func homeTapped() {
Task { await viewModel.goHome(api: api) }
}
@objc private func pathTapped(_ sender: UIButton) {
Task { await viewModel.navigateToPathIndex(sender.tag, api: api) }
}
private func openPreview(file: CloudFile) {
navigationController?.pushViewController(CloudDrivePreviewViewController(file: file), animated: true)
}
private func stagePickedFile(result: PHPickerResult, fileType: Int) {
let provider = result.itemProvider
let typeIdentifier = fileType == 2 ? UTType.image.identifier : UTType.movie.identifier
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] url, error in
guard let self else { return }
if let error {
Task { @MainActor in self.showToast(error.localizedDescription) }
return
}
guard let url else { return }
do {
let stagedURL = try Self.copyPickedFileToStage(url: url, suggestedName: provider.suggestedName, fileType: fileType)
let size = (try? FileManager.default.attributesOfItem(atPath: stagedURL.path)[.size] as? NSNumber)?.int64Value ?? 0
Task { @MainActor in
self.transferManager.addUploadTask(
localFileURL: stagedURL,
fileName: stagedURL.lastPathComponent,
fileType: fileType,
fileSize: size,
parentFolderId: self.viewModel.currentFolderID
)
}
} catch {
Task { @MainActor in self.showToast(error.localizedDescription) }
}
}
}
private static func copyPickedFileToStage(url: URL, suggestedName: String?, fileType: Int) throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cloud_drive_uploads", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let ext = url.pathExtension.isEmpty ? (fileType == 2 ? "jpg" : "mp4") : url.pathExtension
let baseName = CloudTransferFileName.sanitized(suggestedName ?? "cloud_upload")
let fileName = baseName.lowercased().hasSuffix(".\(ext.lowercased())") ? baseName : "\(baseName).\(ext)"
let destination = directory.appendingPathComponent("\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))_\(fileName)")
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.copyItem(at: url, to: destination)
return destination
}
}
extension CloudDriveListViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
viewModel.updateSearchText(textField.text ?? "")
textField.resignFirstResponder()
Task { await viewModel.refresh(api: api, showLoading: true) }
return true
}
}
extension CloudDriveListViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let file = dataSource.itemIdentifier(for: indexPath) else { return }
Task {
if let preview = await viewModel.handleFileTap(file, api: api) {
await MainActor.run { self.openPreview(file: preview) }
}
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let frameHeight = scrollView.frame.size.height
if offsetY > contentHeight - frameHeight - 120 {
let lastIndex = max(0, viewModel.files.count - 1)
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: lastIndex, api: api) }
}
}
}
extension CloudDriveListViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
let type = picker.view.tag
picker.dismiss(animated: true)
guard let result = results.first else { return }
stagePickedFile(result: result, fileType: type == 2 ? 1 : 2)
}
}

View File

@ -0,0 +1,75 @@
//
// CloudDrivePreviewViewController.swift
// suixinkan
//
import AVKit
import Kingfisher
import SnapKit
import UIKit
///
final class CloudDrivePreviewViewController: BaseViewController {
private let file: CloudFile
private let imageView = UIImageView()
private var playerViewController: AVPlayerViewController?
///
init(file: CloudFile) {
self.file = file
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = ""
navigationController?.navigationBar.tintColor = .white
}
override func setupUI() {
view.backgroundColor = .black
if file.isVideo {
setupVideo()
} else {
setupImage()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.navigationBar.tintColor = AppColor.primary
playerViewController?.player?.pause()
}
private func setupImage() {
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
view.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
if let url = URL(string: file.fileUrl) {
imageView.kf.setImage(with: url)
}
}
private func setupVideo() {
guard let url = URL(string: file.fileUrl) else { return }
let player = AVPlayer(url: url)
let controller = AVPlayerViewController()
controller.player = player
controller.view.backgroundColor = .black
addChild(controller)
view.addSubview(controller.view)
controller.view.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
controller.didMove(toParent: self)
playerViewController = controller
player.play()
}
}

View File

@ -0,0 +1,234 @@
//
// CloudDriveTransferViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `CloudStorageTransitScreen`
final class CloudDriveTransferViewController: BaseViewController {
private enum Tab: Int {
case upload
case download
}
private let manager: CloudTransferManager
private let segmentedControl = UISegmentedControl(items: ["上传", "下载"])
private let tableView = UITableView(frame: .zero, style: .plain)
private var selectedTab: Tab = .upload
///
init(manager: CloudTransferManager = .shared) {
self.manager = manager
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "传输管理"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
segmentedControl.selectedSegmentIndex = selectedTab.rawValue
tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none
tableView.dataSource = self
tableView.delegate = self
tableView.register(CloudTransferCell.self, forCellReuseIdentifier: CloudTransferCell.reuseIdentifier)
view.addSubview(segmentedControl)
view.addSubview(tableView)
}
override func setupConstraints() {
segmentedControl.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
manager.onTasksChange = { [weak self] in
Task { @MainActor in self?.tableView.reloadData() }
}
}
private var visibleTasks: [CloudTransferTask] {
selectedTab == .upload ? manager.uploadTasks : manager.downloadTasks
}
@objc private func tabChanged() {
selectedTab = Tab(rawValue: segmentedControl.selectedSegmentIndex) ?? .upload
tableView.reloadData()
}
}
extension CloudDriveTransferViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
visibleTasks.count
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
112
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: CloudTransferCell.reuseIdentifier, for: indexPath) as! CloudTransferCell
let task = visibleTasks[indexPath.row]
cell.apply(task: task)
cell.onPrimaryAction = { [weak self] in
guard let self else { return }
if task.kind == .upload {
task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id)
} else {
task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id)
}
}
cell.onCancel = { [weak self] in
guard let self else { return }
task.kind == .upload ? self.manager.cancelUpload(id: task.id) : self.manager.cancelDownload(id: task.id)
}
return cell
}
}
/// Cell
private final class CloudTransferCell: UITableViewCell {
static let reuseIdentifier = "CloudTransferCell"
var onPrimaryAction: (() -> Void)?
var onCancel: (() -> Void)?
private let cardView = UIView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let statusLabel = UILabel()
private let primaryButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private let progressView = UIProgressView(progressViewStyle: .default)
private let percentLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
onPrimaryAction = nil
onCancel = nil
}
func apply(task: CloudTransferTask) {
iconView.image = CloudDriveAsset.image(
named: task.fileType == 1 ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
fallbackSystemName: task.fileType == 1 ? "video.fill" : "photo.fill"
)
titleLabel.text = task.fileName
statusLabel.text = "\(task.status.title) \(CloudDriveFormatter.fileSize(task.fileSize))"
progressView.progress = Float(task.progress) / 100
percentLabel.text = "\(task.progress)%"
let isRunning = task.status == .uploading || task.status == .downloading
primaryButton.setImage(UIImage(systemName: isRunning ? "pause.fill" : "play.fill"), for: .normal)
primaryButton.isHidden = task.status == .completed
}
private func setupUI() {
backgroundColor = .clear
selectionStyle = .none
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.sm
cardView.clipsToBounds = true
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
titleLabel.font = .app(.body)
titleLabel.textColor = AppColor.textPrimary
titleLabel.lineBreakMode = .byTruncatingMiddle
statusLabel.font = .app(.caption)
statusLabel.textColor = AppColor.textTertiary
progressView.progressTintColor = AppColor.primary
percentLabel.font = .app(.caption)
percentLabel.textColor = AppColor.textSecondary
percentLabel.textAlignment = .right
primaryButton.tintColor = AppColor.primary
cancelButton.tintColor = AppColor.textSecondary
cancelButton.setImage(UIImage(systemName: "xmark"), for: .normal)
primaryButton.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
contentView.addSubview(cardView)
cardView.addSubview(iconView)
cardView.addSubview(titleLabel)
cardView.addSubview(statusLabel)
cardView.addSubview(primaryButton)
cardView.addSubview(cancelButton)
cardView.addSubview(progressView)
cardView.addSubview(percentLabel)
}
private func setupConstraints() {
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
iconView.snp.makeConstraints { make in
make.leading.top.equalToSuperview().offset(AppSpacing.sm)
make.width.height.equalTo(48)
}
cancelButton.snp.makeConstraints { make in
make.trailing.top.equalToSuperview().inset(AppSpacing.xs)
make.width.height.equalTo(36)
}
primaryButton.snp.makeConstraints { make in
make.trailing.equalTo(cancelButton.snp.leading)
make.centerY.equalTo(cancelButton)
make.width.height.equalTo(36)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm)
make.top.equalTo(iconView)
make.trailing.equalTo(primaryButton.snp.leading).offset(-AppSpacing.xs)
}
statusLabel.snp.makeConstraints { make in
make.leading.trailing.equalTo(titleLabel)
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xxs)
}
progressView.snp.makeConstraints { make in
make.leading.equalTo(iconView)
make.trailing.equalToSuperview().inset(AppSpacing.sm)
make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.sm)
}
percentLabel.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(AppSpacing.xxs)
make.trailing.equalTo(progressView)
}
}
@objc private func primaryTapped() {
onPrimaryAction?()
}
@objc private func cancelTapped() {
onCancel?()
}
}

View File

@ -0,0 +1,309 @@
//
// MessageCenterViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `MsgScreen`
final class MessageCenterViewController: BaseViewController, UITableViewDelegate {
private enum Section {
case main
}
private let viewModel: MessageCenterViewModel
private let api: any MessageCenterServing
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let footerSpinner = UIActivityIndicatorView(style: .medium)
private let emptyLabel = UILabel()
private var dataSource: UITableViewDiffableDataSource<Section, MessageItem>!
///
init(
viewModel: MessageCenterViewModel = MessageCenterViewModel(),
api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI
) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
Task { @MainActor in
await viewModel.loadInitial(api: api)
}
}
override func setupNavigationBar() {
title = "消息中心"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 0)
tableView.refreshControl = refreshControl
tableView.register(MessageCenterCell.self, forCellReuseIdentifier: MessageCenterCell.reuseIdentifier)
loadingIndicator.hidesWhenStopped = true
loadingIndicator.color = AppColor.primary
footerSpinner.hidesWhenStopped = true
footerSpinner.color = AppColor.primary
emptyLabel.text = "暂无消息"
emptyLabel.font = .app(.body)
emptyLabel.textColor = UIColor(hex: 0x7B8EAA)
emptyLabel.textAlignment = .center
view.addSubview(tableView)
view.addSubview(loadingIndicator)
configureDataSource()
}
override func setupConstraints() {
tableView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
loadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
override func bindActions() {
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.applyState()
}
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in
self?.showToast(message)
}
}
viewModel.onOpenDetail = { [weak self] message in
Task { @MainActor in
self?.openDetail(message)
}
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
Task { @MainActor in
await viewModel.selectMessage(id: item.id, api: api)
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task { @MainActor in
await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row, api: api)
}
}
@objc private func refreshTriggered() {
Task { @MainActor in
await viewModel.refresh(api: api)
}
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, MessageItem>(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: MessageCenterCell.reuseIdentifier,
for: indexPath
) as! MessageCenterCell
cell.configure(with: item)
return cell
}
applySnapshot(animated: false)
}
private func applyState() {
applySnapshot(animated: true)
if viewModel.isRefreshing {
if !refreshControl.isRefreshing { refreshControl.beginRefreshing() }
} else {
refreshControl.endRefreshing()
}
if viewModel.isLoading {
loadingIndicator.startAnimating()
tableView.backgroundView = nil
} else {
loadingIndicator.stopAnimating()
tableView.backgroundView = viewModel.items.isEmpty ? emptyBackgroundView() : nil
}
tableView.tableFooterView = footerView()
}
private func applySnapshot(animated: Bool) {
var snapshot = NSDiffableDataSourceSnapshot<Section, MessageItem>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.items)
dataSource.apply(snapshot, animatingDifferences: animated)
}
private func emptyBackgroundView() -> UIView {
let container = UIView(frame: tableView.bounds)
emptyLabel.frame = container.bounds
emptyLabel.autoresizingMask = [.flexibleWidth, .flexibleHeight]
container.addSubview(emptyLabel)
return container
}
private func footerView() -> UIView? {
guard !viewModel.items.isEmpty, viewModel.isLoadingMore else { return nil }
let container = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 44))
footerSpinner.center = CGPoint(x: container.bounds.midX, y: container.bounds.midY)
footerSpinner.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin]
container.addSubview(footerSpinner)
footerSpinner.startAnimating()
return container
}
private func openDetail(_ message: MessageItem) {
let controller = MessageDetailViewController(message: message, api: api)
controller.onDeleted = { [weak self] id in
self?.viewModel.removeMessage(id: id)
}
navigationController?.pushViewController(controller, animated: true)
}
}
/// Cell Android `MsgItem`
final class MessageCenterCell: UITableViewCell {
static let reuseIdentifier = "MessageCenterCell"
private let cardView = UIView()
private let typeImageView = UIImageView()
private let titleLabel = UILabel()
private let timeLabel = UILabel()
private let unreadDot = UIView()
private let contentLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func configure(with item: MessageItem) {
typeImageView.image = UIImage(named: MessageTypeIcon.assetName(for: item.type))
titleLabel.text = item.displayTitle
timeLabel.text = item.displayTime
contentLabel.text = item.content
unreadDot.isHidden = item.isRead
}
private func setupUI() {
backgroundColor = .clear
contentView.backgroundColor = .clear
selectionStyle = .none
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
typeImageView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = UIColor(hex: 0x333333)
titleLabel.numberOfLines = 1
titleLabel.lineBreakMode = .byTruncatingTail
timeLabel.font = .systemFont(ofSize: 12, weight: .thin)
timeLabel.textColor = UIColor(hex: 0x7B8EAA)
timeLabel.numberOfLines = 1
unreadDot.backgroundColor = UIColor(hex: 0xEF4444)
unreadDot.layer.cornerRadius = 4
unreadDot.clipsToBounds = true
contentLabel.font = .systemFont(ofSize: 14)
contentLabel.textColor = UIColor(hex: 0x2E3746)
contentLabel.numberOfLines = 2
contentLabel.lineBreakMode = .byTruncatingTail
contentView.addSubview(cardView)
[typeImageView, titleLabel, timeLabel, unreadDot, contentLabel].forEach(cardView.addSubview)
}
private func setupConstraints() {
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(6)
make.leading.trailing.equalToSuperview().inset(16)
}
typeImageView.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
make.size.equalTo(50)
}
unreadDot.snp.makeConstraints { make in
make.top.equalTo(typeImageView.snp.top).offset(2)
make.trailing.equalToSuperview().inset(16)
make.size.equalTo(8)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(typeImageView.snp.top).offset(1)
make.leading.equalTo(typeImageView.snp.trailing).offset(8)
make.trailing.lessThanOrEqualTo(unreadDot.snp.leading).offset(-8)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(1)
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().inset(16)
}
contentLabel.snp.makeConstraints { make in
make.top.equalTo(typeImageView.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().inset(16)
}
}
}
/// Android `getMessageTypeIcon`
enum MessageTypeIcon {
static func assetName(for type: Int) -> String {
switch type {
case 1, 7:
return "ic_msg1"
case 2:
return "ic_msg2"
case 3:
return "ic_msg3"
case 4:
return "ic_msg4"
case 5:
return "ic_msg5"
case 8:
return "ic_msg8"
default:
return "ic_msg999"
}
}
}

View File

@ -0,0 +1,169 @@
//
// MessageDetailViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `MsgDetailScreen`
final class MessageDetailViewController: BaseViewController {
var onDeleted: ((Int) -> Void)?
private let viewModel: MessageDetailViewModel
private let api: any MessageCenterServing
private let scrollView = UIScrollView()
private let contentView = UIView()
private let typeImageView = UIImageView()
private let titleLabel = UILabel()
private let timeContainer = UIView()
private let timeLabel = UILabel()
private let bodyLabel = UILabel()
private let bottomBar = UIView()
private let deleteButton = UIButton(type: .system)
///
init(message: MessageItem, api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI) {
viewModel = MessageDetailViewModel(message: message)
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "消息详情"
}
override func setupUI() {
view.backgroundColor = .white
typeImageView.contentMode = .scaleAspectFit
typeImageView.image = UIImage(named: MessageTypeIcon.assetName(for: viewModel.message.type))
titleLabel.text = viewModel.message.displayTitle
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
titleLabel.textColor = .black
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 0
timeContainer.backgroundColor = UIColor(hex: 0xF4F4F4)
timeContainer.layer.cornerRadius = 4
timeContainer.clipsToBounds = true
timeLabel.text = viewModel.message.createdAt
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = UIColor(hex: 0x4B5563)
timeLabel.textAlignment = .center
timeLabel.numberOfLines = 1
bodyLabel.text = viewModel.message.content
bodyLabel.font = .systemFont(ofSize: 16)
bodyLabel.textColor = .black
bodyLabel.numberOfLines = 0
bodyLabel.textAlignment = .left
bottomBar.backgroundColor = .white
deleteButton.setTitle("删除并返回", for: .normal)
deleteButton.setTitleColor(.white, for: .normal)
deleteButton.titleLabel?.font = .systemFont(ofSize: 16)
deleteButton.backgroundColor = UIColor(hex: 0xEF4444)
deleteButton.layer.cornerRadius = 8
deleteButton.clipsToBounds = true
view.addSubview(scrollView)
scrollView.addSubview(contentView)
[typeImageView, titleLabel, timeContainer, bodyLabel].forEach(contentView.addSubview)
timeContainer.addSubview(timeLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(deleteButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
deleteButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(32)
make.height.equalTo(52)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
contentView.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide)
make.width.equalTo(scrollView.frameLayoutGuide)
make.height.greaterThanOrEqualTo(scrollView.frameLayoutGuide)
}
typeImageView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(32)
make.centerX.equalToSuperview()
make.size.equalTo(64)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(typeImageView.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(32)
}
timeContainer.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.centerX.equalToSuperview()
}
timeLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4))
}
bodyLabel.snp.makeConstraints { make in
make.top.equalTo(timeContainer.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(32)
make.bottom.lessThanOrEqualToSuperview().inset(24)
}
}
override func bindActions() {
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.applyState()
}
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in
self?.showToast(message)
}
}
viewModel.onDeleted = { [weak self] id in
Task { @MainActor in
self?.onDeleted?(id)
self?.navigationController?.popViewController(animated: true)
}
}
}
@objc private func deleteTapped() {
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确认", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
await self.viewModel.delete(api: self.api)
}
})
present(alert, animated: true)
}
private func applyState() {
deleteButton.isEnabled = !viewModel.isDeleting
deleteButton.alpha = viewModel.isDeleting ? 0.65 : 1
if viewModel.isDeleting {
showLoading()
} else {
hideLoading()
}
}
}

View File

@ -25,6 +25,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
private let nicknameField = UITextField()
private let editProfileButton = UIButton(type: .system)
private let introductionTextView = UITextView()
private let introductionPlaceholderLabel = UILabel()
private let certificationWrap = ProfileTagWrapView()
private let attrWrap = ProfileTagWrapView()
@ -139,13 +140,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
hideLoading()
}
saveButton.isEnabled = viewModel.hasChanges
avatarImageView.loadRemoteImage(urlString: viewModel.avatarURL)
updateAvatar(urlString: viewModel.avatarURL)
realNameLabel.text = "姓名: \(viewModel.realName)"
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : "昵称: \(viewModel.nickname)"
nicknameField.isEnabled = viewModel.isEditingProfile
introductionTextView.isEditable = viewModel.isEditingProfile
introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction
introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x565656)
updateIntroductionPlaceholder()
editProfileButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "square.and.pencil"), for: .normal)
certificationWrap.setItems(viewModel.scenicCertification, style: .certification, removable: false)
attrWrap.setItems(viewModel.attrLabels, style: .label, removable: true) { [weak self] label in
@ -204,6 +206,9 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
nicknameField.font = .systemFont(ofSize: 16)
nicknameField.textColor = .black
nicknameField.borderStyle = .none
nicknameField.placeholder = "请输入昵称"
nicknameField.returnKeyType = .done
nicknameField.delegate = self
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
editProfileButton.tintColor = AppColor.primary
editProfileButton.snp.makeConstraints { make in
@ -221,6 +226,13 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
introductionTextView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(24)
}
introductionPlaceholderLabel.text = "请输入个人简介"
introductionPlaceholderLabel.font = introductionTextView.font
introductionPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA)
introductionTextView.addSubview(introductionPlaceholderLabel)
introductionPlaceholderLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
textStack.addArrangedSubview(realNameLabel)
textStack.addArrangedSubview(nicknameRow)
@ -357,6 +369,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
row.alignment = .fill
field.placeholder = placeholder
field.font = .systemFont(ofSize: 14)
field.returnKeyType = .done
field.delegate = self
field.layer.cornerRadius = 8
field.layer.borderColor = AppColor.border.cgColor
field.layer.borderWidth = 1
@ -390,12 +404,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x565656)
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
let toLabel = UILabel()
toLabel.text = ""
toLabel.font = .systemFont(ofSize: 14)
toLabel.textColor = UIColor(hex: 0x565656)
let spacer = UIView()
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(spacer)
row.addArrangedSubview(start)
row.addArrangedSubview(toLabel)
row.addArrangedSubview(end)
@ -408,6 +424,24 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
return row
}
private func updateAvatar(urlString: String?) {
let trimmed = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else {
avatarImageView.kf.cancelDownloadTask()
avatarImageView.backgroundColor = AppColor.inputBackground
avatarImageView.image = UIImage(systemName: "person.crop.circle.fill")
avatarImageView.tintColor = UIColor(hex: 0xB6BECA)
avatarImageView.contentMode = .scaleAspectFit
return
}
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.loadRemoteImage(urlString: trimmed)
}
private func updateIntroductionPlaceholder() {
introductionPlaceholderLabel.isHidden = !viewModel.isEditingProfile || !introductionTextView.text.isEmpty
}
private func updateStatusButtons() {
for button in statusButtons {
let selected = button.tag == viewModel.acceptOrderStatus
@ -553,6 +587,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
let alert = UIAlertController(title: "添加设备", message: nil, preferredStyle: .alert)
alert.addTextField { field in
field.placeholder = "请输入设备名称"
field.returnKeyType = .done
field.delegate = self
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
@ -590,9 +626,25 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
}
}
extension ProfileSpaceSettingsViewController: UITextViewDelegate {
extension ProfileSpaceSettingsViewController: UITextFieldDelegate, UITextViewDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
func textViewDidChange(_ textView: UITextView) {
viewModel.updateIntroduction(textView.text)
updateIntroductionPlaceholder()
}
func textView(
_ textView: UITextView,
shouldChangeTextIn range: NSRange,
replacementText text: String
) -> Bool {
guard text == "\n" else { return true }
textView.resignFirstResponder()
return false
}
}
@ -636,6 +688,7 @@ final class ProfileAddScheduleViewController: BaseViewController {
private let endButton = ProfileTimeButton()
private let orderButton = UIButton(type: .system)
private let remarkTextView = UITextView()
private let remarkPlaceholderLabel = UILabel()
private let countLabel = UILabel()
init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) {
@ -719,6 +772,8 @@ final class ProfileAddScheduleViewController: BaseViewController {
private func configureNameField() {
nameField.placeholder = "请输入日程名称"
nameField.font = .systemFont(ofSize: 14)
nameField.returnKeyType = .done
nameField.delegate = self
nameField.layer.borderColor = AppColor.border.cgColor
nameField.layer.borderWidth = 1
nameField.layer.cornerRadius = 8
@ -771,6 +826,17 @@ final class ProfileAddScheduleViewController: BaseViewController {
remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
remarkTextView.layer.borderWidth = 1
remarkTextView.layer.cornerRadius = 8
remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)
remarkTextView.textContainer.lineFragmentPadding = 0
remarkPlaceholderLabel.text = "请输入备注信息"
remarkPlaceholderLabel.font = remarkTextView.font
remarkPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA)
remarkTextView.addSubview(remarkPlaceholderLabel)
remarkPlaceholderLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.equalToSuperview().offset(12)
make.trailing.equalToSuperview().inset(12)
}
countLabel.text = "0/200"
countLabel.textAlignment = .right
countLabel.font = .systemFont(ofSize: 12)
@ -845,13 +911,29 @@ final class ProfileAddScheduleViewController: BaseViewController {
}
}
extension ProfileAddScheduleViewController: UITextViewDelegate {
extension ProfileAddScheduleViewController: UITextFieldDelegate, UITextViewDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 200 {
textView.text = String(textView.text.prefix(200))
}
remarkPlaceholderLabel.isHidden = !textView.text.isEmpty
countLabel.text = "\(textView.text.count)/200"
}
func textView(
_ textView: UITextView,
shouldChangeTextIn range: NSRange,
replacementText text: String
) -> Bool {
guard text == "\n" else { return true }
textView.resignFirstResponder()
return false
}
}
///