Files
suixinkan_uikit/suixinkan/UI/MessageCenter/MessageCenterViewController.swift
汉秋 6336feff27 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:21:53 +08:00

310 lines
10 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.

//
// 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)? = 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<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"
}
}
}