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

@ -0,0 +1,204 @@
# 消息中心未读数量与全部已读接口需求
## 1. 需求背景
iOS App 需要实现以下功能:
1. 首页展示消息中心入口,有未读消息时显示红点。
2. 获取最新未读消息数量后,同步更新 App 桌面图标右上角的角标数量。
3. 用户点击某条未读消息并成功标记已读后,立即更新最新未读数量和桌面角标。
4. 消息中心导航栏右上角提供“全部已读”按钮。
5. 全部标记已读成功后,更新首页红点,并将桌面角标更新为服务端返回的最新未读数量。
## 2. 当前已有接口
### 2.1 获取消息列表
```http
GET /api/app/msg/list
```
当前参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `last_id` | int | 否 | 分页游标,首次请求传 `0` |
| `limit` | int | 否 | 每页数量 |
| `unread` | int | 否 | `0` 获取全部消息,`1` 只获取未读消息 |
该接口仅返回分页消息,没有返回未读消息总数。客户端若通过分页遍历全部未读消息来统计数量,请求次数和数据流量都会随着未读消息数量增长,因此不适合作为桌面角标的常规数据来源。
### 2.2 标记单条消息已读
```http
POST /api/app/msg/read?id={message_id}
```
当前接口可以标记单条消息已读,但成功响应没有返回操作后的最新未读数量。
## 3. 需要新增或调整的接口
### 3.1 获取未读消息总数
建议新增:
```http
GET /api/app/msg/unread-count
```
#### 请求说明
- 不需要业务请求参数。
- 根据当前登录 Token 识别用户及业务账号。
- 返回当前账号下尚未读取的消息总数。
#### 成功响应示例
```json
{
"code": 100000,
"msg": "success",
"data": {
"unread_count": 12
}
}
```
#### 字段说明
| 字段 | 类型 | 必有 | 说明 |
| --- | --- | --- | --- |
| `unread_count` | int | 是 | 当前账号的最新未读消息数量,最小值为 `0` |
### 3.2 全部标记为已读
建议新增:
```http
POST /api/app/msg/read-all
```
#### 请求说明
- 不需要业务请求参数和请求体。
- 根据当前登录 Token 识别用户及业务账号。
- 将当前账号下请求执行时已经存在的所有未读消息标记为已读。
- 接口需要支持幂等调用;没有未读消息时仍返回成功。
#### 成功响应示例
```json
{
"code": 100000,
"msg": "success",
"data": {
"updated_count": 12,
"unread_count": 0
}
}
```
#### 字段说明
| 字段 | 类型 | 必有 | 说明 |
| --- | --- | --- | --- |
| `updated_count` | int | 是 | 本次实际被更新为已读的消息数量 |
| `unread_count` | int | 是 | 操作完成后的最新未读消息数量 |
> 客户端应以响应中的 `unread_count` 为准,不应直接假定其一定为 `0`。例如“全部已读”执行期间如果有新消息到达,操作完成后仍可能存在新的未读消息。
### 3.3 调整单条已读接口响应
保留现有接口:
```http
POST /api/app/msg/read?id={message_id}
```
建议成功响应增加最新未读数量:
```json
{
"code": 100000,
"msg": "success",
"data": {
"unread_count": 11
}
}
```
#### 字段说明
| 字段 | 类型 | 必有 | 说明 |
| --- | --- | --- | --- |
| `unread_count` | int | 是 | 单条消息标记已读后的最新未读消息数量 |
#### 幂等要求
- 对已经处于已读状态的消息重复调用时,接口仍返回成功和最新 `unread_count`
- 消息不存在或不属于当前账号时,返回明确的业务错误,不能修改其他账号的消息。
## 4. 统一约定
### 4.1 账号范围
- 所有接口必须根据登录 Token 限定当前用户及当前业务账号的数据范围。
- 景区账号、门店账号等不同业务账号之间的未读数量不能互相污染。
- 切换账号后,客户端会重新调用未读数量接口。
### 4.2 数值约束
- `unread_count``updated_count` 必须返回 JSON 整数。
- 数值不得为负数。
- 即使数量为 `0`,字段也必须存在,不能返回 `null`、空字符串或省略字段。
### 4.3 一致性与并发
- 单条已读或全部已读操作及其响应中的 `unread_count` 应基于同一账号范围。
- `read-all` 应尽量在事务内完成批量更新和剩余未读数统计。
- 如果操作期间有新消息写入,响应应返回操作完成时数据库中的真实 `unread_count`
- 客户端始终以最近一次成功接口响应中的 `unread_count` 更新首页红点和桌面角标。
### 4.4 错误响应
沿用现有统一业务响应结构,例如:
```json
{
"code": 100001,
"msg": "消息不存在或无权操作",
"data": null
}
```
建议至少区分以下情况:
- Token 无效或登录已过期。
- 单条消息不存在。
- 单条消息不属于当前账号。
- 数据库更新失败。
## 5. 客户端调用时机
| 场景 | 调用接口 | 客户端处理 |
| --- | --- | --- |
| 登录后首次进入首页 | `GET /msg/unread-count` | 更新首页红点和桌面角标 |
| App 回到前台 | `GET /msg/unread-count` | 校准最新未读数量 |
| 前台收到新消息推送 | `GET /msg/unread-count` | 更新首页红点和桌面角标 |
| 点击一条未读消息 | `POST /msg/read` | 使用返回的 `unread_count` 更新红点和桌面角标 |
| 点击“全部已读” | `POST /msg/read-all` | 刷新消息列表,使用返回的 `unread_count` 更新红点和桌面角标 |
| 切换业务账号 | `GET /msg/unread-count` | 使用新账号的数量覆盖旧账号角标 |
| 退出登录 | 无 | 客户端将桌面角标清零 |
## 6. 验收标准
1. 未读数量为 `0` 时,接口稳定返回 `{"unread_count": 0}`
2. 存在多页未读消息时,未读数量接口返回完整总数,而不是当前页数量。
3. 单条未读消息标记成功后,返回的 `unread_count` 与数据库实际剩余未读数一致。
4. 对同一消息重复调用单条已读接口不会重复扣减未读数量。
5. 全部已读接口可一次处理当前账号的所有未读消息,并返回实际更新数量。
6. 重复调用全部已读接口仍成功,返回 `updated_count = 0`
7. 不同业务账号的未读数量相互隔离。
8. 并发到达新消息时,响应中的 `unread_count` 能反映操作完成时的真实剩余数量。
9. 所有接口继续使用项目现有统一鉴权、业务状态码和响应信封格式。

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) {

View File

@ -29,7 +29,7 @@ final class AllFunctionsViewModelTests: XCTestCase {
super.tearDown()
}
func testLoadFunctionsUsesDefaultFirstFourWhenNoSavedURIs() {
func testLoadFunctionsUsesDefaultFirstFourAndExcludesMessageCenter() {
appStore.permissions.savePermissionItems([
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"),
@ -44,7 +44,7 @@ final class AllFunctionsViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.commonMenus.map(\.uri), [
"wallet", "cloud_management", "task_management", "system_settings",
])
XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["message_center"])
XCTAssertTrue(viewModel.moreMenus.isEmpty)
XCTAssertEqual(
menuStore.savedCommonURIs(
accountScope: appStore.session.accountCachePrefix,

View File

@ -23,14 +23,26 @@ final class HomeCommonMenuStoreTests: XCTestCase {
super.tearDown()
}
func testDefaultCommonURIsTakeFirstFour() {
let permissions = (1...6).map {
func testDefaultCommonURIsTakeFirstFourAndExcludeMessageCenter() {
let permissions = [
HomePermissionItem(id: "0", name: "消息", uri: "message_center"),
] + (1...5).map {
HomePermissionItem(id: "\($0)", name: "菜单\($0)", uri: "uri_\($0)")
}
let uris = HomeCommonMenuStore.defaultCommonURIs(from: permissions)
XCTAssertEqual(uris, ["uri_1", "uri_2", "uri_3", "uri_4"])
}
func testSavedMessageCenterIsRemovedFromLegacyCommonURIs() {
let key = "scenic_user_u1_role_photographer_common_uris"
defaults.set(["message_center", "wallet"], forKey: key)
let uris = store.savedCommonURIs(accountScope: "scenic_user_u1", roleCode: "photographer")
XCTAssertEqual(uris, ["wallet"])
XCTAssertEqual(defaults.stringArray(forKey: key), ["wallet"])
}
func testSavedCommonURIsOverrideDefault() {
let permissions = [
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
@ -65,8 +77,12 @@ final class HomeCommonMenuStoreTests: XCTestCase {
HomeMenuItem(uri: "wallet", title: "钱包", iconName: "wallet.pass"),
HomeMenuItem(uri: "cloud_management", title: "云盘", iconName: "icloud"),
HomeMenuItem(uri: "task_management", title: "任务", iconName: "checklist"),
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"),
]
let split = HomeCommonMenuStore.splitMenus(from: menus, commonURIs: ["wallet", "task_management"])
let split = HomeCommonMenuStore.splitMenus(
from: menus,
commonURIs: ["wallet", "task_management", "message_center"]
)
XCTAssertEqual(split.common.map(\.uri), ["wallet", "task_management"])
XCTAssertEqual(split.more.map(\.uri), ["cloud_management"])
}
@ -75,7 +91,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
let item = HomeMenuCatalog.item(for: "report_photographer")
XCTAssertEqual(item?.title, "举报摄影师")
XCTAssertEqual(item?.iconName, "shield.fill")
XCTAssertEqual(item?.iconName, "shield.lefthalf.filled")
}
func testHomeMenuCatalogUsesIOS16CompatibleIcons() {

View File

@ -90,10 +90,10 @@ final class HomeViewModelTests: XCTestCase {
let menuStore = HomeCommonMenuStore(defaults: defaults)
appStore.permissions.savePermissionItems([
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"),
HomePermissionItem(id: "3", name: "任务", uri: "task_management"),
HomePermissionItem(id: "4", name: "设置", uri: "system_settings"),
HomePermissionItem(id: "5", name: "消息", uri: "message_center"),
HomePermissionItem(id: "2", name: "消息", uri: "message_center"),
HomePermissionItem(id: "3", name: "云盘", uri: "cloud_management"),
HomePermissionItem(id: "4", name: "任务", uri: "task_management"),
HomePermissionItem(id: "5", name: "设置", uri: "system_settings"),
])
let viewModel = HomeViewModel(appStore: appStore, commonMenuStore: menuStore)
@ -231,6 +231,50 @@ final class HomeViewModelTests: XCTestCase {
XCTAssertEqual(session.requests.count, 1)
}
func testUnreadMessageStatusUsesCountEndpointAndUpdatesBadgeState() async {
let api = HomeMessageCenterAPIMock()
api.unreadCountResponses = [
MessageUnreadCountResponse(unreadCount: 9),
MessageUnreadCountResponse(unreadCount: 0),
]
let viewModel = HomeViewModel(appStore: appStore)
await viewModel.refreshUnreadMessageStatus(api: api)
XCTAssertTrue(viewModel.hasUnreadMessages)
XCTAssertEqual(viewModel.unreadMessageCount, 9)
await viewModel.refreshUnreadMessageStatus(api: api)
XCTAssertFalse(viewModel.hasUnreadMessages)
XCTAssertEqual(viewModel.unreadMessageCount, 0)
XCTAssertEqual(api.unreadCountCallCount, 2)
}
func testScenicHeaderMessageButtonShowsUnreadStateAndHandlesTap() throws {
let headerView = HomeScenicHeaderView(frame: CGRect(x: 0, y: 0, width: 390, height: 100))
var tapCount = 0
headerView.onMessageTap = { tapCount += 1 }
headerView.apply(scenicName: "测试景区", hasUnreadMessages: true)
headerView.layoutIfNeeded()
let button = try XCTUnwrap(
findSubview(in: headerView) { $0.accessibilityIdentifier == "home_message_button" } as? UIButton
)
let unreadDot = try XCTUnwrap(
findSubview(in: headerView) { $0.accessibilityIdentifier == "home_message_unread_dot" }
)
XCTAssertEqual(button.bounds.size, CGSize(width: 44, height: 44))
XCTAssertEqual(button.accessibilityLabel, "消息中心,有未读消息")
XCTAssertFalse(unreadDot.isHidden)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(tapCount, 1)
headerView.apply(scenicName: "测试景区", hasUnreadMessages: false)
XCTAssertEqual(button.accessibilityLabel, "消息中心")
XCTAssertTrue(unreadDot.isHidden)
}
func testRefreshLocalDisplayStateClearsWhenNameMissing() {
appStore.session.currentScenicId = 10
appStore.session.currentScenicName = ""
@ -283,6 +327,45 @@ final class HomeViewModelTests: XCTestCase {
}
return nil
}
private func findSubview(in view: UIView, matching predicate: (UIView) -> Bool) -> UIView? {
if predicate(view) {
return view
}
for subview in view.subviews {
if let match = findSubview(in: subview, matching: predicate) {
return match
}
}
return nil
}
}
///
@MainActor
private final class HomeMessageCenterAPIMock: MessageCenterServing {
var unreadCountResponses: [MessageUnreadCountResponse] = []
var unreadCountCallCount = 0
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
MessageListResponse()
}
func unreadCount() async throws -> MessageUnreadCountResponse {
unreadCountCallCount += 1
guard !unreadCountResponses.isEmpty else { return MessageUnreadCountResponse() }
return unreadCountResponses.removeFirst()
}
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse {
MessageUnreadCountResponse()
}
func markAllAsRead() async throws -> MessageReadAllResponse {
MessageReadAllResponse()
}
func delete(messageId: Int) async throws {}
}
private extension UIColor {

View File

@ -53,21 +53,37 @@ final class MessageCenterAPITests: XCTestCase {
XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123"))
}
func testReadAndDeleteUseAndroidPathsAndBody() async throws {
let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}")])
func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws {
let session = MockURLSession(responses: [
envelopeJSON(#"{"unread_count":"12"}"#),
envelopeJSON(#"{"unread_count":11}"#),
envelopeJSON(#"{"updated_count":11,"unread_count":0}"#),
envelopeJSON("{}"),
])
let api = MessageCenterAPI(client: APIClient(environment: .testing, session: session))
try await api.markAsRead(messageId: 21)
let unread = try await api.unreadCount()
let read = try await api.markAsRead(messageId: 21)
let readAll = try await api.markAllAsRead()
try await api.delete(messageId: 21)
XCTAssertEqual(session.requests[0].httpMethod, "POST")
XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/read")
let readQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(unread.unreadCount, 12)
XCTAssertEqual(session.requests[0].httpMethod, "GET")
XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/unread-count")
XCTAssertEqual(read.unreadCount, 11)
XCTAssertEqual(session.requests[1].httpMethod, "POST")
XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/read")
let readQuery = URLComponents(url: try XCTUnwrap(session.requests[1].url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(readQuery?.first { $0.name == "id" }?.value, "21")
XCTAssertEqual(session.requests[1].httpMethod, "POST")
XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/delete")
let deleteBody = try bodyJSON(session.requests[1])
XCTAssertEqual(readAll, MessageReadAllResponse(updatedCount: 11, unreadCount: 0))
XCTAssertEqual(session.requests[2].httpMethod, "POST")
XCTAssertEqual(session.requests[2].url?.path, "/api/app/msg/read-all")
XCTAssertEqual(session.requests[3].httpMethod, "POST")
XCTAssertEqual(session.requests[3].url?.path, "/api/app/msg/delete")
let deleteBody = try bodyJSON(session.requests[3])
XCTAssertEqual(deleteBody["id"] as? Int, 21)
}

View File

@ -32,17 +32,77 @@ final class MessageCenterViewModelTests: XCTestCase {
func testUnreadSelectionMarksReadAndOpensDetail() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 3, isRead: false)])]
api.readResponse = MessageUnreadCountResponse(unreadCount: 4)
let viewModel = MessageCenterViewModel()
var opened: MessageItem?
var callbackCount: Int?
viewModel.onOpenDetail = { opened = $0 }
viewModel.onUnreadMessageCountChange = { callbackCount = $0 }
let unreadChanged = expectation(
forNotification: NotificationName.unreadMessageCountDidChange,
object: nil,
handler: { notification in
notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int == 4
}
)
await viewModel.loadInitial(api: api)
await viewModel.selectMessage(id: 3, api: api)
await fulfillment(of: [unreadChanged], timeout: 1)
XCTAssertEqual(api.readCalls, [3])
XCTAssertEqual(opened?.id, 3)
XCTAssertEqual(opened?.isRead, true)
XCTAssertEqual(viewModel.items.first?.isRead, true)
XCTAssertEqual(callbackCount, 4)
}
func testMarkAllAsReadUpdatesItemsAndPublishesLatestCount() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(items: [
makeMessage(id: 1, isRead: false),
makeMessage(id: 2, isRead: true),
])]
api.readAllResponse = MessageReadAllResponse(updatedCount: 1, unreadCount: 0)
let viewModel = MessageCenterViewModel()
var callbackCount: Int?
var message: String?
viewModel.onUnreadMessageCountChange = { callbackCount = $0 }
viewModel.onShowMessage = { message = $0 }
let unreadChanged = expectation(
forNotification: NotificationName.unreadMessageCountDidChange,
object: nil,
handler: { notification in
notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int == 0
}
)
await viewModel.loadInitial(api: api)
let response = await viewModel.markAllAsRead(api: api)
await fulfillment(of: [unreadChanged], timeout: 1)
XCTAssertEqual(api.readAllCallCount, 1)
XCTAssertEqual(response, MessageReadAllResponse(updatedCount: 1, unreadCount: 0))
XCTAssertTrue(viewModel.items.allSatisfy(\.isRead))
XCTAssertEqual(callbackCount, 0)
XCTAssertEqual(message, "已全部标记为已读")
}
func testMarkAllAsReadFailureKeepsUnreadState() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(items: [makeMessage(id: 6, isRead: false)])]
api.readAllError = TestError(message: "fail")
let viewModel = MessageCenterViewModel()
var message: String?
viewModel.onShowMessage = { message = $0 }
await viewModel.loadInitial(api: api)
let response = await viewModel.markAllAsRead(api: api)
XCTAssertNil(response)
XCTAssertEqual(api.readAllCallCount, 1)
XCTAssertEqual(viewModel.items.first?.isRead, false)
XCTAssertEqual(message, "全部标记已读失败,请稍后重试")
}
func testReadSelectionOpensDetailWithoutCallingReadAPI() async {
@ -125,8 +185,12 @@ private final class MockMessageCenterAPI: MessageCenterServing {
var listResponses: [MessageListResponse] = []
var listCalls: [(lastId: Int, limit: Int, unread: Int)] = []
var readCalls: [Int] = []
var readResponse = MessageUnreadCountResponse()
var readAllResponse = MessageReadAllResponse()
var readAllCallCount = 0
var deleteCalls: [Int] = []
var readError: Error?
var readAllError: Error?
var deleteError: Error?
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
@ -135,9 +199,20 @@ private final class MockMessageCenterAPI: MessageCenterServing {
return listResponses.removeFirst()
}
func markAsRead(messageId: Int) async throws {
func unreadCount() async throws -> MessageUnreadCountResponse {
MessageUnreadCountResponse()
}
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse {
readCalls.append(messageId)
if let readError { throw readError }
return readResponse
}
func markAllAsRead() async throws -> MessageReadAllResponse {
readAllCallCount += 1
if let readAllError { throw readAllError }
return readAllResponse
}
func delete(messageId: Int) async throws {

View File

@ -158,6 +158,28 @@ final class PushNotificationTests: XCTestCase {
XCTAssertEqual(router.destinations, [.task])
}
func testRemoteNotificationNotifiesUnreadMessageStateChange() async {
let manager = makeManager()
let unreadChanged = expectation(
forNotification: NotificationName.unreadMessageCountDidChange,
object: nil
)
manager.handleRemoteNotification(["route": "message_center"])
await fulfillment(of: [unreadChanged], timeout: 1)
}
func testApplicationIconBadgeUsesLatestNonnegativeCount() async {
let badgeSetter = ApplicationIconBadgeSetterMock()
let manager = makeManager(badgeSetter: badgeSetter)
await manager.updateApplicationIconBadgeCount(12)
await manager.updateApplicationIconBadgeCount(-2)
XCTAssertEqual(badgeSetter.counts, [12, 0])
}
func testRouteCoordinatorWaitsForLoginAndDeduplicatesColdStartResponse() {
let coordinator = PushRouteCoordinator(appStore: appStore)
let window = UIWindow(frame: UIScreen.main.bounds)
@ -189,7 +211,8 @@ final class PushNotificationTests: XCTestCase {
private func makeManager(
sdk: PushSDKMock? = nil,
api: PushRegistrationAPIMock? = nil,
router: PushRouterMock? = nil
router: PushRouterMock? = nil,
badgeSetter: ApplicationIconBadgeSetterMock? = nil
) -> PushNotificationManager {
let resolvedSDK: PushSDKMock
if let sdk {
@ -217,7 +240,8 @@ final class PushNotificationTests: XCTestCase {
api: resolvedAPI,
appStore: appStore,
defaults: defaults,
router: resolvedRouter
router: resolvedRouter,
applicationIconBadgeSetter: badgeSetter ?? ApplicationIconBadgeSetterMock()
)
}
@ -233,6 +257,16 @@ final class PushNotificationTests: XCTestCase {
}
}
/// App
@MainActor
private final class ApplicationIconBadgeSetterMock: ApplicationIconBadgeSetting {
private(set) var counts: [Int] = []
func setBadgeCount(_ count: Int) async {
counts.append(count)
}
}
/// SDK
@MainActor
private final class PushSDKMock: PushSDKProviding {