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

@ -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