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,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"
}
}
}