Files
suixinkan_uikit/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift
汉秋 5138c1c11a 完善实名认证、钱包与打卡点模块 UI 与业务逻辑。
对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:28:10 +08:00

447 lines
18 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// CloudStoragePickForTaskViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `CloudStorageListForTaskScreen`
final class CloudStoragePickForTaskViewController: BaseViewController {
var onConfirmed: (([CloudFile]) -> Void)?
private let viewModel: CloudStoragePickForTaskViewModel
private let taskAPI = NetworkServices.shared.taskAPI
private let searchField = UITextField()
private let pathScrollView = UIScrollView()
private let pathStack = UIStackView()
private let filterBar = UIStackView()
private let sortButton = UIButton(type: .system)
private let typeButton = UIButton(type: .system)
private let displayModeButton = UIButton(type: .system)
private let refreshControl = UIRefreshControl()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, Int>!
private let confirmButton = AppButton(title: "确定")
private let bottomBar = UIView()
private var isGridMode = true
///
init(importedFileIDs: Set<Int> = [], allowedFileType: Int? = nil) {
viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs, allowedFileType: allowedFileType)
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() {
searchField.placeholder = "搜索文件"
searchField.borderStyle = .roundedRect
searchField.font = .app(.body)
searchField.returnKeyType = .search
searchField.delegate = self
pathStack.axis = .horizontal
pathStack.spacing = AppSpacing.xs
pathScrollView.showsHorizontalScrollIndicator = false
pathScrollView.addSubview(pathStack)
pathStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalToSuperview()
}
configureMenuButton(sortButton, imageName: "arrow.up.arrow.down")
configureMenuButton(typeButton, imageName: "line.3.horizontal.decrease")
displayModeButton.tintColor = AppColor.textSecondary
displayModeButton.backgroundColor = AppColor.inputBackground
displayModeButton.layer.cornerRadius = AppRadius.xs
displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside)
filterBar.axis = .horizontal
filterBar.spacing = AppSpacing.sm
filterBar.distribution = .fill
filterBar.addArrangedSubview(sortButton)
filterBar.addArrangedSubview(typeButton)
filterBar.addArrangedSubview(displayModeButton)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = AppColor.pageBackground
collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier)
collectionView.delegate = self
collectionView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView) {
[weak self] collectionView, indexPath, fileID in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: CloudPickCell.reuseIdentifier,
for: indexPath
) as! CloudPickCell
if let file = self?.viewModel.files.first(where: { $0.id == fileID }) {
cell.apply(
file: file,
isSelected: self?.viewModel.isSelected(fileID) == true,
isImported: self?.viewModel.isImported(fileID) == true,
isGridMode: self?.isGridMode == true
)
}
return cell
}
bottomBar.backgroundColor = .white
view.addSubview(searchField)
view.addSubview(pathScrollView)
view.addSubview(filterBar)
view.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(confirmButton)
}
override func setupConstraints() {
searchField.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(40)
}
pathScrollView.snp.makeConstraints { make in
make.top.equalTo(searchField.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(28)
}
filterBar.snp.makeConstraints { make in
make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
}
sortButton.snp.makeConstraints { make in make.width.equalTo(typeButton) }
displayModeButton.snp.makeConstraints { make in make.width.equalTo(36) }
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
confirmButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(filterBar.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refresh(api: taskAPI) }
}
@MainActor
private func applyViewModel() {
rebuildPathButtons()
rebuildFilterMenus()
var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.files.map(\.id))
let existingItems = Set(dataSource.snapshot().itemIdentifiers)
let itemsToReconfigure = snapshot.itemIdentifiers.filter(existingItems.contains)
snapshot.reconfigureItems(itemsToReconfigure)
dataSource.apply(snapshot, animatingDifferences: false)
let selectedCount = viewModel.selectedFileList.count
confirmButton.setTitle(selectedCount > 0 ? "确认选择(\(selectedCount))" : "确认选择", for: .normal)
if viewModel.isLoading && viewModel.files.isEmpty {
showLoading()
} else {
hideLoading()
}
if !viewModel.isRefreshing {
refreshControl.endRefreshing()
}
}
private func rebuildPathButtons() {
pathStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for (index, item) in viewModel.pathStack.enumerated() {
let button = UIButton(type: .system)
button.setTitle(item.name, for: .normal)
button.titleLabel?.font = .app(.caption)
button.tag = index
button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside)
pathStack.addArrangedSubview(button)
if index < viewModel.pathStack.count - 1 {
let arrow = UILabel()
arrow.text = ">"
arrow.font = .app(.caption)
arrow.textColor = AppColor.textTertiary
pathStack.addArrangedSubview(arrow)
}
}
}
@objc private func pathTapped(_ sender: UIButton) {
Task { await viewModel.navigateToPathIndex(sender.tag, api: taskAPI) }
}
@objc private func confirmTapped() {
guard !viewModel.selectedFileList.isEmpty else {
showToast("请至少选择一个文件")
return
}
onConfirmed?(viewModel.selectedFileList)
navigationController?.popViewController(animated: true)
}
@objc private func refreshTriggered() {
Task { await viewModel.refresh(api: taskAPI) }
}
@objc private func displayModeTapped() {
isGridMode.toggle()
collectionView.setCollectionViewLayout(makeLayout(), animated: true)
applyViewModel()
}
private func configureMenuButton(_ button: UIButton, imageName: String) {
button.tintColor = AppColor.textSecondary
button.backgroundColor = AppColor.inputBackground
button.layer.cornerRadius = AppRadius.xs
button.contentHorizontalAlignment = .leading
button.titleLabel?.font = .app(.body)
button.setTitleColor(AppColor.textPrimary, for: .normal)
button.setImage(UIImage(systemName: imageName), for: .normal)
button.semanticContentAttribute = .forceRightToLeft
button.showsMenuAsPrimaryAction = true
}
private func rebuildFilterMenus() {
let sortItems = [(1, "创建时间顺序"), (2, "创建时间倒序")]
sortButton.setTitle(sortItems.first(where: { $0.0 == viewModel.sortType })?.1 ?? "排序", for: .normal)
sortButton.menu = UIMenu(children: sortItems.map { value, title in
UIAction(title: title, state: value == viewModel.sortType ? .on : .off) { [weak self] _ in
guard let self else { return }
self.viewModel.updateSortType(value)
Task { await self.viewModel.refresh(api: self.taskAPI) }
}
})
let typeItems = [(0, "全部"), (2, "图片"), (1, "视频"), (99, "文件夹")]
typeButton.setTitle(typeItems.first(where: { $0.0 == viewModel.filterType })?.1 ?? "全部", for: .normal)
typeButton.menu = UIMenu(children: typeItems.map { value, title in
UIAction(title: title, state: value == viewModel.filterType ? .on : .off) { [weak self] _ in
guard let self else { return }
self.viewModel.updateFilterType(value)
Task { await self.viewModel.refresh(api: self.taskAPI) }
}
})
displayModeButton.setImage(
UIImage(systemName: isGridMode ? "list.bullet" : "square.grid.2x2"),
for: .normal
)
displayModeButton.accessibilityLabel = isGridMode ? "列表显示" : "宫格显示"
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
let isGridMode = isGridMode
return UICollectionViewCompositionalLayout { _, environment in
let columns = isGridMode ? 2 : 1
let spacing: CGFloat = 8
let inset = AppSpacing.md * 2
let availableWidth = environment.container.effectiveContentSize.width - inset
let totalSpacing = spacing * CGFloat(columns - 1)
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
let itemHeight = isGridMode ? itemWidth + 38 : 72
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemHeight)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemHeight)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
repeatingSubitem: item,
count: columns
)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
section.contentInsets = NSDirectionalEdgeInsets(
top: 0,
leading: AppSpacing.md,
bottom: AppSpacing.md,
trailing: AppSpacing.md
)
return section
}
}
}
extension CloudStoragePickForTaskViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let fileID = dataSource.itemIdentifier(for: indexPath),
let file = viewModel.files.first(where: { $0.id == fileID }) else {
return
}
Task { await viewModel.handleItemTap(file, api: taskAPI) }
}
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 {
Task { await viewModel.loadMore(api: taskAPI) }
}
}
}
extension CloudStoragePickForTaskViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
viewModel.updateSearchText(textField.text ?? "")
textField.resignFirstResponder()
Task { await viewModel.refresh(api: taskAPI) }
return true
}
}
/// cell
private final class CloudPickCell: UICollectionViewCell {
static let reuseIdentifier = "CloudPickCell"
private let imageView = UIImageView()
private let nameLabel = UILabel()
private let selectionIconView = UIImageView(image: UIImage(systemName: "checkmark.circle.fill"))
private let folderIconView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = AppRadius.sm
contentView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
folderIconView.image = UIImage(systemName: "folder.fill")
folderIconView.tintColor = AppColor.primary
folderIconView.contentMode = .scaleAspectFit
nameLabel.font = .app(.caption)
nameLabel.textColor = AppColor.textPrimary
nameLabel.numberOfLines = 2
nameLabel.textAlignment = .center
selectionIconView.tintColor = AppColor.primary
selectionIconView.contentMode = .scaleAspectFit
selectionIconView.isHidden = true
contentView.addSubview(imageView)
contentView.addSubview(folderIconView)
contentView.addSubview(nameLabel)
contentView.addSubview(selectionIconView)
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(contentView.snp.width)
}
folderIconView.snp.makeConstraints { make in
make.center.equalTo(imageView)
make.width.height.equalTo(36)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs)
}
selectionIconView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
make.width.height.equalTo(20)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.kf.cancelDownloadTask()
imageView.image = nil
folderIconView.isHidden = true
selectionIconView.isHidden = true
contentView.alpha = 1
}
func apply(file: CloudFile, isSelected: Bool, isImported: Bool, isGridMode: Bool) {
nameLabel.text = file.name
contentView.layer.borderWidth = isSelected ? 2 : 0
contentView.layer.borderColor = AppColor.primary.cgColor
selectionIconView.isHidden = !isSelected
if isGridMode {
imageView.snp.remakeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(imageView.snp.width)
}
nameLabel.textAlignment = .center
nameLabel.numberOfLines = 2
nameLabel.snp.remakeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs)
}
} else {
imageView.snp.remakeConstraints { make in
make.top.bottom.leading.equalToSuperview()
make.width.equalTo(imageView.snp.height)
}
nameLabel.textAlignment = .left
nameLabel.numberOfLines = 2
nameLabel.snp.remakeConstraints { make in
make.leading.equalTo(imageView.snp.trailing).offset(AppSpacing.sm)
make.trailing.equalToSuperview().inset(AppSpacing.sm)
make.centerY.equalToSuperview()
}
}
if file.isFolder {
contentView.alpha = 1
imageView.image = nil
folderIconView.isHidden = false
return
}
folderIconView.isHidden = true
let preview = file.coverUrl.isEmpty ? file.fileUrl : file.coverUrl
if let url = URL(string: preview), !preview.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(systemName: file.isVideo ? "video" : "photo")
imageView.tintColor = AppColor.textTertiary
}
if isImported {
contentView.alpha = 0.5
} else {
contentView.alpha = 1
}
}
}