// // 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! /// 初始化消息中心列表页。 init( viewModel: MessageCenterViewModel = MessageCenterViewModel(), api: (any MessageCenterServing)? = nil ) { self.viewModel = viewModel self.api = api ?? NetworkServices.shared.messageCenterAPI 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(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() 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" } } }