feat: 接入消息未读数、全部已读与首页红点角标同步。

固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-21 18:02:43 +08:00
parent e7f1d777dd
commit caeeb9a1cf
18 changed files with 748 additions and 37 deletions

View File

@ -1,6 +1,32 @@
import UIKit
import UserNotifications
/// App 便
@MainActor
protocol ApplicationIconBadgeSetting: AnyObject {
///
func setBadgeCount(_ count: Int) async
}
/// 使 UserNotifications App
@MainActor
final class SystemApplicationIconBadgeSetter: ApplicationIconBadgeSetting {
///
static let shared = SystemApplicationIconBadgeSetter()
private init() {}
func setBadgeCount(_ count: Int) async {
do {
try await UNUserNotificationCenter.current().setBadgeCount(max(count, 0))
} catch {
#if DEBUG
print("Update application icon badge failed: \(error.localizedDescription)")
#endif
}
}
}
/// SDK 便使
@MainActor
protocol PushSDKProviding: AnyObject {
@ -85,6 +111,7 @@ final class PushNotificationManager: NSObject {
private let appStore: AppStore
private let defaults: UserDefaults
private let router: any PushRouting
private let applicationIconBadgeSetter: any ApplicationIconBadgeSetting
private var isInitialized = false
private var didRequestAuthorization = false
@ -99,13 +126,15 @@ final class PushNotificationManager: NSObject {
api: (any PushRegistrationServing)? = nil,
appStore: AppStore = .shared,
defaults: UserDefaults = .standard,
router: (any PushRouting)? = nil
router: (any PushRouting)? = nil,
applicationIconBadgeSetter: (any ApplicationIconBadgeSetting)? = nil
) {
self.sdk = sdk ?? JPushSDKAdapter()
self.api = api ?? NetworkServices.shared.pushAPI
self.appStore = appStore
self.defaults = defaults
self.router = router ?? PushRouteCoordinator.shared
self.applicationIconBadgeSetter = applicationIconBadgeSetter ?? SystemApplicationIconBadgeSetter.shared
super.init()
}
@ -152,6 +181,12 @@ final class PushNotificationManager: NSObject {
uploadTask = nil
queuedForcedUpload = false
router.resetPendingRoute()
Task { await updateApplicationIconBadgeCount(0) }
}
/// App Icon
func updateApplicationIconBadgeCount(_ count: Int) async {
await applicationIconBadgeSetter.setBadgeCount(max(count, 0))
}
/// App Registration ID
@ -204,6 +239,7 @@ final class PushNotificationManager: NSObject {
///
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
JPUSHService.handleRemoteNotification(userInfo)
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
}
private func observeJPushNetworkLogin() {
@ -317,6 +353,9 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
withCompletionHandler completionHandler: @escaping (Int) -> Void
) {
JPUSHService.handleRemoteNotification(notification.request.content.userInfo)
Task { @MainActor in
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
}
let options: UNNotificationPresentationOptions = [.banner, .list, .sound, .badge]
completionHandler(Int(options.rawValue))
}

View File

@ -17,18 +17,23 @@ final class HomeCommonMenuStore {
/// URI
func savedCommonURIs(accountScope: String, roleCode: String) -> [String] {
guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return [] }
return defaults.stringArray(forKey: key) ?? []
let saved = defaults.stringArray(forKey: key) ?? []
let sanitized = saved.filter(HomeMenuCatalog.isCustomizable(uri:))
if sanitized != saved {
defaults.set(sanitized, forKey: key)
}
return sanitized
}
/// URI
func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) {
guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return }
defaults.set(uris, forKey: key)
defaults.set(uris.filter(HomeMenuCatalog.isCustomizable(uri:)), forKey: key)
}
/// URI 4
static func defaultCommonURIs(from permissions: [HomePermissionItem]) -> [String] {
let uris = permissions.map(\.uri)
let uris = permissions.map(\.uri).filter(HomeMenuCatalog.isCustomizable(uri:))
return uris.count > 4 ? Array(uris.prefix(4)) : uris
}
@ -51,8 +56,9 @@ final class HomeCommonMenuStore {
commonURIs: [String]
) -> (common: [HomeMenuItem], more: [HomeMenuItem]) {
let commonSet = Set(commonURIs)
let common = allMenus.filter { commonSet.contains($0.uri) }
let more = allMenus.filter { !commonSet.contains($0.uri) }
let customizableMenus = allMenus.filter { HomeMenuCatalog.isCustomizable(uri: $0.uri) }
let common = customizableMenus.filter { commonSet.contains($0.uri) }
let more = customizableMenus.filter { !commonSet.contains($0.uri) }
return (common, more)
}

View File

@ -8,6 +8,8 @@ import Foundation
/// catalog permission URI
enum HomeMenuCatalog {
private static let fixedHomeEntryURIs: Set<String> = ["message_center"]
/// Android `Constants.menuList`
static let allItems: [HomeMenuItem] = [
HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"),
@ -45,6 +47,7 @@ enum HomeMenuCatalog {
///
static func visibleMenus(from permissions: [HomePermissionItem]) -> [HomeMenuItem] {
permissions.compactMap { permission in
guard isCustomizable(uri: permission.uri) else { return nil }
guard let catalog = allItems.first(where: { $0.uri == permission.uri }) else { return nil }
return HomeMenuItem(
uri: catalog.uri,
@ -59,4 +62,9 @@ enum HomeMenuCatalog {
static func item(for uri: String) -> HomeMenuItem? {
allItems.first { $0.uri == uri }
}
///
static func isCustomizable(uri: String) -> Bool {
!fixedHomeEntryURIs.contains(uri)
}
}

View File

@ -48,6 +48,7 @@ final class AllFunctionsViewModel {
///
func addToCommon(_ menu: HomeMenuItem) {
guard HomeMenuCatalog.isCustomizable(uri: menu.uri) else { return }
guard !commonMenus.contains(where: { $0.uri == menu.uri }) else { return }
commonMenus.append(menu)
moreMenus.removeAll { $0.uri == menu.uri }

View File

@ -25,6 +25,7 @@ final class HomeViewModel {
private(set) var reportTimeText = ""
private(set) var isLocationReporting = false
private(set) var needsPermissionReload = false
private(set) var unreadMessageCount = 0
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@ -65,6 +66,7 @@ final class HomeViewModel {
var isOnline: Bool { locationStateStore.isOnline }
var countdownDisplayText: String { locationStateStore.countdownDisplayText }
var reminderMinutes: Int { locationStateStore.reminderMinutes }
var hasUnreadMessages: Bool { unreadMessageCount > 0 }
/// 线
func initialize(api: HomeAPI) async {
@ -76,6 +78,28 @@ final class HomeViewModel {
///
func markNeedsPermissionReload() {
needsPermissionReload = true
unreadMessageCount = 0
}
/// `true`
func refreshUnreadMessageStatus(api: any MessageCenterServing) async -> Bool {
do {
let response = try await api.unreadCount()
unreadMessageCount = response.unreadCount
notifyStateChange()
return true
} catch is CancellationError {
return false
} catch {
//
return false
}
}
/// 使
func updateUnreadMessageCount(_ count: Int) {
unreadMessageCount = max(count, 0)
notifyStateChange()
}
///

View File

@ -11,8 +11,14 @@ protocol MessageCenterServing {
///
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse
///
func markAsRead(messageId: Int) async throws
///
func unreadCount() async throws -> MessageUnreadCountResponse
///
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse
///
func markAllAsRead() async throws -> MessageReadAllResponse
///
func delete(messageId: Int) async throws
@ -43,9 +49,16 @@ final class MessageCenterAPI: MessageCenterServing {
)
}
/// GET /api/app/msg/unread-count
func unreadCount() async throws -> MessageUnreadCountResponse {
try await client.send(
APIRequest(method: .get, path: "/api/app/msg/unread-count")
)
}
/// POST /api/app/msg/read
func markAsRead(messageId: Int) async throws {
let _: EmptyPayload = try await client.send(
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/app/msg/read",
@ -54,6 +67,13 @@ final class MessageCenterAPI: MessageCenterServing {
)
}
/// POST /api/app/msg/read-all
func markAllAsRead() async throws -> MessageReadAllResponse {
try await client.send(
APIRequest(method: .post, path: "/api/app/msg/read-all")
)
}
/// POST /api/app/msg/delete
func delete(messageId: Int) async throws {
let _: EmptyPayload = try await client.send(

View File

@ -32,6 +32,46 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
}
}
///
struct MessageUnreadCountResponse: Decodable, Equatable, Sendable {
let unreadCount: Int
enum CodingKeys: String, CodingKey {
case unreadCount = "unread_count"
}
init(unreadCount: Int = 0) {
self.unreadCount = max(unreadCount, 0)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0)
}
}
///
struct MessageReadAllResponse: Decodable, Equatable, Sendable {
let updatedCount: Int
let unreadCount: Int
enum CodingKeys: String, CodingKey {
case updatedCount = "updated_count"
case unreadCount = "unread_count"
}
init(updatedCount: Int = 0, unreadCount: Int = 0) {
self.updatedCount = max(updatedCount, 0)
self.unreadCount = max(unreadCount, 0)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
updatedCount = max(try container.decodeLossyInt(forKey: .updatedCount) ?? 0, 0)
unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0)
}
}
/// Android `MsgResponse`
struct MessageListResponse: Decodable, Equatable, Sendable {
let hasMore: Bool

View File

@ -11,11 +11,13 @@ final class MessageCenterViewModel {
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var isLoadingMore = false
private(set) var isMarkingAllAsRead = false
private(set) var canLoadMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onOpenDetail: ((MessageItem) -> Void)?
var onUnreadMessageCountChange: ((Int) -> Void)?
private let pageSize = 20
private var lastId = 0
@ -55,9 +57,10 @@ final class MessageCenterViewModel {
}
do {
try await api.markAsRead(messageId: id)
let response = try await api.markAsRead(messageId: id)
let readItem = item.markedRead()
items = items.map { $0.id == id ? readItem : $0 }
notifyUnreadMessageCountChanged(response.unreadCount)
onOpenDetail?(readItem)
} catch is CancellationError {
return
@ -66,6 +69,30 @@ final class MessageCenterViewModel {
}
}
///
func markAllAsRead(api: any MessageCenterServing) async -> MessageReadAllResponse? {
guard !isLoading, !isMarkingAllAsRead else { return nil }
isMarkingAllAsRead = true
notifyStateChange()
defer {
isMarkingAllAsRead = false
notifyStateChange()
}
do {
let response = try await api.markAllAsRead()
items = items.map { $0.isRead ? $0 : $0.markedRead() }
notifyUnreadMessageCountChanged(response.unreadCount)
onShowMessage?(response.updatedCount > 0 ? "已全部标记为已读" : "暂无未读消息")
return response
} catch is CancellationError {
return nil
} catch {
onShowMessage?("全部标记已读失败,请稍后重试")
return nil
}
}
///
func removeMessage(id: Int) {
items.removeAll { $0.id == id }
@ -110,6 +137,16 @@ final class MessageCenterViewModel {
private func notifyStateChange() {
onStateChange?()
}
private func notifyUnreadMessageCountChanged(_ count: Int) {
let normalizedCount = max(count, 0)
onUnreadMessageCountChange?(normalizedCount)
NotificationCenter.default.post(
name: NotificationName.unreadMessageCountDidChange,
object: nil,
userInfo: [NotificationUserInfoKey.unreadCount: normalizedCount]
)
}
}
/// ViewModel

View File

@ -13,6 +13,7 @@ final class HomeViewController: BaseViewController {
private let viewModel = HomeViewModel()
private let homeAPI = NetworkServices.shared.homeAPI
private let messageCenterAPI = NetworkServices.shared.messageCenterAPI
private let scenicHeaderView = HomeScenicHeaderView()
private var collectionView: UICollectionView!
@ -74,6 +75,7 @@ final class HomeViewController: BaseViewController {
Task { @MainActor in self?.showToast(message) }
}
scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() }
scenicHeaderView.onMessageTap = { [weak self] in self?.openMessageCenter() }
collectionView.delegate = self
NotificationCenter.default.addObserver(
@ -88,11 +90,26 @@ final class HomeViewController: BaseViewController {
name: NotificationName.scenicDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleUnreadMessageCountDidChange(_:)),
name: NotificationName.unreadMessageCountDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleUnreadMessageCountDidChange(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
if hasInitialized {
Task { await refreshUnreadMessageStatus() }
}
}
override func viewDidAppear(_ animated: Bool) {
@ -272,6 +289,7 @@ final class HomeViewController: BaseViewController {
private func initializeHome() async {
showLoading()
await viewModel.initialize(api: homeAPI)
await refreshUnreadMessageStatus()
hideLoading()
applyViewModel()
@ -287,7 +305,10 @@ final class HomeViewController: BaseViewController {
@MainActor
private func applyViewModel() {
scenicHeaderView.apply(scenicName: viewModel.currentScenicName)
scenicHeaderView.apply(
scenicName: viewModel.currentScenicName,
hasUnreadMessages: viewModel.hasUnreadMessages
)
let showWorkBlock = !viewModel.isMinimalTopRole
let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil
@ -499,6 +520,10 @@ final class HomeViewController: BaseViewController {
pushScenicSelection()
}
private func openMessageCenter() {
navigationController?.pushViewController(MessageCenterViewController(), animated: true)
}
private func pushScenicSelection() {
navigationController?.setNavigationBarHidden(false, animated: true)
let controller = ScenicSelectionViewController()
@ -529,6 +554,7 @@ final class HomeViewController: BaseViewController {
viewModel.markNeedsPermissionReload()
Task {
await viewModel.reloadIfNeeded(api: homeAPI)
await refreshUnreadMessageStatus()
applyViewModel()
await evaluateDialogsWithDelay()
}
@ -541,6 +567,21 @@ final class HomeViewController: BaseViewController {
applyViewModel()
}
}
@objc private func handleUnreadMessageCountDidChange(_ notification: Notification) {
guard hasInitialized else { return }
if let count = notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int {
viewModel.updateUnreadMessageCount(count)
Task { await PushNotificationManager.shared.updateApplicationIconBadgeCount(count) }
} else {
Task { await refreshUnreadMessageStatus() }
}
}
private func refreshUnreadMessageStatus() async {
guard await viewModel.refreshUnreadMessageStatus(api: messageCenterAPI) else { return }
await PushNotificationManager.shared.updateApplicationIconBadgeCount(viewModel.unreadMessageCount)
}
}
extension HomeViewController: UICollectionViewDelegate {

View File

@ -9,10 +9,16 @@ import UIKit
///
final class HomeScenicHeaderView: UIView {
///
var onTap: (() -> Void)?
///
var onMessageTap: (() -> Void)?
private let titleLabel = UILabel()
private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down"))
private let messageButton = UIButton(type: .system)
private let unreadDotView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
@ -25,8 +31,24 @@ final class HomeScenicHeaderView: UIView {
arrowView.contentMode = .scaleAspectFit
arrowView.setContentCompressionResistancePriority(.required, for: .horizontal)
var messageConfiguration = UIButton.Configuration.plain()
messageConfiguration.image = UIImage(systemName: "bell")
messageConfiguration.baseForegroundColor = AppColor.textPrimary
messageConfiguration.contentInsets = .zero
messageButton.configuration = messageConfiguration
messageButton.accessibilityIdentifier = "home_message_button"
messageButton.addTarget(self, action: #selector(handleMessageTap), for: .touchUpInside)
unreadDotView.backgroundColor = AppColor.danger
unreadDotView.layer.cornerRadius = 4
unreadDotView.isUserInteractionEnabled = false
unreadDotView.isHidden = true
unreadDotView.accessibilityIdentifier = "home_message_unread_dot"
addSubview(titleLabel)
addSubview(arrowView)
addSubview(messageButton)
messageButton.addSubview(unreadDotView)
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(safeAreaLayoutGuide).offset(AppSpacing.screenHorizontalInset)
@ -34,12 +56,23 @@ final class HomeScenicHeaderView: UIView {
}
arrowView.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs)
make.trailing.lessThanOrEqualTo(safeAreaLayoutGuide).offset(-AppSpacing.screenHorizontalInset)
make.trailing.lessThanOrEqualTo(messageButton.snp.leading).offset(-AppSpacing.xs)
make.centerY.equalTo(safeAreaLayoutGuide)
make.width.height.equalTo(16)
}
messageButton.snp.makeConstraints { make in
make.trailing.equalTo(safeAreaLayoutGuide).offset(-AppSpacing.screenHorizontalInset)
make.centerY.equalTo(safeAreaLayoutGuide)
make.width.height.equalTo(44)
}
unreadDotView.snp.makeConstraints { make in
make.centerX.equalTo(messageButton.snp.centerX).offset(9)
make.centerY.equalTo(messageButton.snp.centerY).offset(-9)
make.width.height.equalTo(8)
}
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tap.delegate = self
addGestureRecognizer(tap)
}
@ -48,12 +81,26 @@ final class HomeScenicHeaderView: UIView {
fatalError("init(coder:) has not been implemented")
}
func apply(scenicName: String) {
///
func apply(scenicName: String, hasUnreadMessages: Bool) {
let trimmed = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = trimmed.isEmpty ? "请选择景区" : trimmed
unreadDotView.isHidden = !hasUnreadMessages
messageButton.accessibilityLabel = hasUnreadMessages ? "消息中心,有未读消息" : "消息中心"
}
@objc private func handleTap() {
onTap?()
}
@objc private func handleMessageTap() {
onMessageTap?()
}
}
extension HomeScenicHeaderView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard let touchedView = touch.view else { return true }
return touchedView !== messageButton && !touchedView.isDescendant(of: messageButton)
}
}

View File

@ -19,6 +19,12 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let footerSpinner = UIActivityIndicatorView(style: .medium)
private let emptyLabel = UILabel()
private lazy var markAllReadButton = UIBarButtonItem(
title: "全部已读",
style: .plain,
target: self,
action: #selector(markAllReadTapped)
)
private var dataSource: UITableViewDiffableDataSource<Section, MessageItem>!
@ -46,6 +52,7 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
override func setupNavigationBar() {
title = "消息中心"
navigationItem.rightBarButtonItem = markAllReadButton
}
override func setupUI() {
@ -79,7 +86,8 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
override func setupConstraints() {
tableView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.bottom.equalToSuperview()
}
loadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
@ -103,6 +111,11 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
self?.openDetail(message)
}
}
viewModel.onUnreadMessageCountChange = { count in
Task { @MainActor in
await PushNotificationManager.shared.updateApplicationIconBadgeCount(count)
}
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
@ -125,6 +138,12 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
}
}
@objc private func markAllReadTapped() {
Task { @MainActor in
await viewModel.markAllAsRead(api: api)
}
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, MessageItem>(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
@ -154,6 +173,7 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
}
tableView.tableFooterView = footerView()
markAllReadButton.isEnabled = !viewModel.isLoading && !viewModel.isMarkingAllAsRead
}
private func applySnapshot(animated: Bool) {