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

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

View File

@ -0,0 +1,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)
}
}