feat: 接入消息未读数、全部已读与首页红点角标同步。
固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
204
docs/消息中心未读数量与全部已读接口需求.md
Normal file
204
docs/消息中心未读数量与全部已读接口需求.md
Normal 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. 所有接口继续使用项目现有统一鉴权、业务状态码和响应信封格式。
|
||||||
|
|
||||||
@ -1,6 +1,32 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import UserNotifications
|
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 的最小能力边界,便于推送生命周期逻辑使用测试替身。
|
/// 极光 SDK 的最小能力边界,便于推送生命周期逻辑使用测试替身。
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol PushSDKProviding: AnyObject {
|
protocol PushSDKProviding: AnyObject {
|
||||||
@ -85,6 +111,7 @@ final class PushNotificationManager: NSObject {
|
|||||||
private let appStore: AppStore
|
private let appStore: AppStore
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let router: any PushRouting
|
private let router: any PushRouting
|
||||||
|
private let applicationIconBadgeSetter: any ApplicationIconBadgeSetting
|
||||||
|
|
||||||
private var isInitialized = false
|
private var isInitialized = false
|
||||||
private var didRequestAuthorization = false
|
private var didRequestAuthorization = false
|
||||||
@ -99,13 +126,15 @@ final class PushNotificationManager: NSObject {
|
|||||||
api: (any PushRegistrationServing)? = nil,
|
api: (any PushRegistrationServing)? = nil,
|
||||||
appStore: AppStore = .shared,
|
appStore: AppStore = .shared,
|
||||||
defaults: UserDefaults = .standard,
|
defaults: UserDefaults = .standard,
|
||||||
router: (any PushRouting)? = nil
|
router: (any PushRouting)? = nil,
|
||||||
|
applicationIconBadgeSetter: (any ApplicationIconBadgeSetting)? = nil
|
||||||
) {
|
) {
|
||||||
self.sdk = sdk ?? JPushSDKAdapter()
|
self.sdk = sdk ?? JPushSDKAdapter()
|
||||||
self.api = api ?? NetworkServices.shared.pushAPI
|
self.api = api ?? NetworkServices.shared.pushAPI
|
||||||
self.appStore = appStore
|
self.appStore = appStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.router = router ?? PushRouteCoordinator.shared
|
self.router = router ?? PushRouteCoordinator.shared
|
||||||
|
self.applicationIconBadgeSetter = applicationIconBadgeSetter ?? SystemApplicationIconBadgeSetter.shared
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +181,12 @@ final class PushNotificationManager: NSObject {
|
|||||||
uploadTask = nil
|
uploadTask = nil
|
||||||
queuedForcedUpload = false
|
queuedForcedUpload = false
|
||||||
router.resetPendingRoute()
|
router.resetPendingRoute()
|
||||||
|
Task { await updateApplicationIconBadgeCount(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将桌面 App Icon 角标更新为最新未读消息数量。
|
||||||
|
func updateApplicationIconBadgeCount(_ count: Int) async {
|
||||||
|
await applicationIconBadgeSetter.setBadgeCount(max(count, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。
|
/// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。
|
||||||
@ -204,6 +239,7 @@ final class PushNotificationManager: NSObject {
|
|||||||
/// 处理后台静默通知并向极光上报到达,不触发页面跳转。
|
/// 处理后台静默通知并向极光上报到达,不触发页面跳转。
|
||||||
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
|
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
|
||||||
JPUSHService.handleRemoteNotification(userInfo)
|
JPUSHService.handleRemoteNotification(userInfo)
|
||||||
|
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func observeJPushNetworkLogin() {
|
private func observeJPushNetworkLogin() {
|
||||||
@ -317,6 +353,9 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
|
|||||||
withCompletionHandler completionHandler: @escaping (Int) -> Void
|
withCompletionHandler completionHandler: @escaping (Int) -> Void
|
||||||
) {
|
) {
|
||||||
JPUSHService.handleRemoteNotification(notification.request.content.userInfo)
|
JPUSHService.handleRemoteNotification(notification.request.content.userInfo)
|
||||||
|
Task { @MainActor in
|
||||||
|
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
|
||||||
|
}
|
||||||
let options: UNNotificationPresentationOptions = [.banner, .list, .sound, .badge]
|
let options: UNNotificationPresentationOptions = [.banner, .list, .sound, .badge]
|
||||||
completionHandler(Int(options.rawValue))
|
completionHandler(Int(options.rawValue))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,18 +17,23 @@ final class HomeCommonMenuStore {
|
|||||||
/// 读取已保存的常用 URI;无记录时返回空数组。
|
/// 读取已保存的常用 URI;无记录时返回空数组。
|
||||||
func savedCommonURIs(accountScope: String, roleCode: String) -> [String] {
|
func savedCommonURIs(accountScope: String, roleCode: String) -> [String] {
|
||||||
guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return [] }
|
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 列表。
|
/// 保存常用 URI 列表。
|
||||||
func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) {
|
func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) {
|
||||||
guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return }
|
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 个)。
|
/// 根据权限生成默认常用 URI(最多 4 个)。
|
||||||
static func defaultCommonURIs(from permissions: [HomePermissionItem]) -> [String] {
|
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
|
return uris.count > 4 ? Array(uris.prefix(4)) : uris
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,8 +56,9 @@ final class HomeCommonMenuStore {
|
|||||||
commonURIs: [String]
|
commonURIs: [String]
|
||||||
) -> (common: [HomeMenuItem], more: [HomeMenuItem]) {
|
) -> (common: [HomeMenuItem], more: [HomeMenuItem]) {
|
||||||
let commonSet = Set(commonURIs)
|
let commonSet = Set(commonURIs)
|
||||||
let common = allMenus.filter { commonSet.contains($0.uri) }
|
let customizableMenus = allMenus.filter { HomeMenuCatalog.isCustomizable(uri: $0.uri) }
|
||||||
let more = allMenus.filter { !commonSet.contains($0.uri) }
|
let common = customizableMenus.filter { commonSet.contains($0.uri) }
|
||||||
|
let more = customizableMenus.filter { !commonSet.contains($0.uri) }
|
||||||
return (common, more)
|
return (common, more)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import Foundation
|
|||||||
/// 首页菜单静态 catalog,与服务端 permission URI 取交集后展示。
|
/// 首页菜单静态 catalog,与服务端 permission URI 取交集后展示。
|
||||||
enum HomeMenuCatalog {
|
enum HomeMenuCatalog {
|
||||||
|
|
||||||
|
private static let fixedHomeEntryURIs: Set<String> = ["message_center"]
|
||||||
|
|
||||||
/// 全部已知菜单项,对齐 Android `Constants.menuList`。
|
/// 全部已知菜单项,对齐 Android `Constants.menuList`。
|
||||||
static let allItems: [HomeMenuItem] = [
|
static let allItems: [HomeMenuItem] = [
|
||||||
HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"),
|
HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"),
|
||||||
@ -45,6 +47,7 @@ enum HomeMenuCatalog {
|
|||||||
/// 将服务端权限映射为可展示菜单,保持服务端顺序。
|
/// 将服务端权限映射为可展示菜单,保持服务端顺序。
|
||||||
static func visibleMenus(from permissions: [HomePermissionItem]) -> [HomeMenuItem] {
|
static func visibleMenus(from permissions: [HomePermissionItem]) -> [HomeMenuItem] {
|
||||||
permissions.compactMap { permission in
|
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 }
|
guard let catalog = allItems.first(where: { $0.uri == permission.uri }) else { return nil }
|
||||||
return HomeMenuItem(
|
return HomeMenuItem(
|
||||||
uri: catalog.uri,
|
uri: catalog.uri,
|
||||||
@ -59,4 +62,9 @@ enum HomeMenuCatalog {
|
|||||||
static func item(for uri: String) -> HomeMenuItem? {
|
static func item(for uri: String) -> HomeMenuItem? {
|
||||||
allItems.first { $0.uri == uri }
|
allItems.first { $0.uri == uri }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断入口是否允许出现在首页常用应用及其自定义页面中。
|
||||||
|
static func isCustomizable(uri: String) -> Bool {
|
||||||
|
!fixedHomeEntryURIs.contains(uri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,7 @@ final class AllFunctionsViewModel {
|
|||||||
|
|
||||||
/// 将菜单加入常用应用。
|
/// 将菜单加入常用应用。
|
||||||
func addToCommon(_ menu: HomeMenuItem) {
|
func addToCommon(_ menu: HomeMenuItem) {
|
||||||
|
guard HomeMenuCatalog.isCustomizable(uri: menu.uri) else { return }
|
||||||
guard !commonMenus.contains(where: { $0.uri == menu.uri }) else { return }
|
guard !commonMenus.contains(where: { $0.uri == menu.uri }) else { return }
|
||||||
commonMenus.append(menu)
|
commonMenus.append(menu)
|
||||||
moreMenus.removeAll { $0.uri == menu.uri }
|
moreMenus.removeAll { $0.uri == menu.uri }
|
||||||
|
|||||||
@ -25,6 +25,7 @@ final class HomeViewModel {
|
|||||||
private(set) var reportTimeText = ""
|
private(set) var reportTimeText = ""
|
||||||
private(set) var isLocationReporting = false
|
private(set) var isLocationReporting = false
|
||||||
private(set) var needsPermissionReload = false
|
private(set) var needsPermissionReload = false
|
||||||
|
private(set) var unreadMessageCount = 0
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@ -65,6 +66,7 @@ final class HomeViewModel {
|
|||||||
var isOnline: Bool { locationStateStore.isOnline }
|
var isOnline: Bool { locationStateStore.isOnline }
|
||||||
var countdownDisplayText: String { locationStateStore.countdownDisplayText }
|
var countdownDisplayText: String { locationStateStore.countdownDisplayText }
|
||||||
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
||||||
|
var hasUnreadMessages: Bool { unreadMessageCount > 0 }
|
||||||
|
|
||||||
/// 首次进入首页时加载权限并恢复在线状态。
|
/// 首次进入首页时加载权限并恢复在线状态。
|
||||||
func initialize(api: HomeAPI) async {
|
func initialize(api: HomeAPI) async {
|
||||||
@ -76,6 +78,28 @@ final class HomeViewModel {
|
|||||||
/// 账号切换后标记需要重新拉取权限。
|
/// 账号切换后标记需要重新拉取权限。
|
||||||
func markNeedsPermissionReload() {
|
func markNeedsPermissionReload() {
|
||||||
needsPermissionReload = true
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询待审核记录并决定进入申请页或审核状态页。
|
/// 查询待审核记录并决定进入申请页或审核状态页。
|
||||||
|
|||||||
@ -11,8 +11,14 @@ protocol MessageCenterServing {
|
|||||||
/// 拉取消息列表。
|
/// 拉取消息列表。
|
||||||
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse
|
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
|
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
|
/// POST /api/app/msg/read
|
||||||
func markAsRead(messageId: Int) async throws {
|
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse {
|
||||||
let _: EmptyPayload = try await client.send(
|
try await client.send(
|
||||||
APIRequest(
|
APIRequest(
|
||||||
method: .post,
|
method: .post,
|
||||||
path: "/api/app/msg/read",
|
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
|
/// POST /api/app/msg/delete
|
||||||
func delete(messageId: Int) async throws {
|
func delete(messageId: Int) async throws {
|
||||||
let _: EmptyPayload = try await client.send(
|
let _: EmptyPayload = try await client.send(
|
||||||
|
|||||||
@ -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`。
|
/// 消息中心列表响应,对齐 Android `MsgResponse`。
|
||||||
struct MessageListResponse: Decodable, Equatable, Sendable {
|
struct MessageListResponse: Decodable, Equatable, Sendable {
|
||||||
let hasMore: Bool
|
let hasMore: Bool
|
||||||
|
|||||||
@ -11,11 +11,13 @@ final class MessageCenterViewModel {
|
|||||||
private(set) var isLoading = false
|
private(set) var isLoading = false
|
||||||
private(set) var isRefreshing = false
|
private(set) var isRefreshing = false
|
||||||
private(set) var isLoadingMore = false
|
private(set) var isLoadingMore = false
|
||||||
|
private(set) var isMarkingAllAsRead = false
|
||||||
private(set) var canLoadMore = false
|
private(set) var canLoadMore = false
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
var onOpenDetail: ((MessageItem) -> Void)?
|
var onOpenDetail: ((MessageItem) -> Void)?
|
||||||
|
var onUnreadMessageCountChange: ((Int) -> Void)?
|
||||||
|
|
||||||
private let pageSize = 20
|
private let pageSize = 20
|
||||||
private var lastId = 0
|
private var lastId = 0
|
||||||
@ -55,9 +57,10 @@ final class MessageCenterViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await api.markAsRead(messageId: id)
|
let response = try await api.markAsRead(messageId: id)
|
||||||
let readItem = item.markedRead()
|
let readItem = item.markedRead()
|
||||||
items = items.map { $0.id == id ? readItem : $0 }
|
items = items.map { $0.id == id ? readItem : $0 }
|
||||||
|
notifyUnreadMessageCountChanged(response.unreadCount)
|
||||||
onOpenDetail?(readItem)
|
onOpenDetail?(readItem)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
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) {
|
func removeMessage(id: Int) {
|
||||||
items.removeAll { $0.id == id }
|
items.removeAll { $0.id == id }
|
||||||
@ -110,6 +137,16 @@ final class MessageCenterViewModel {
|
|||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
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,负责删除当前消息。
|
/// 消息详情页 ViewModel,负责删除当前消息。
|
||||||
|
|||||||
@ -13,6 +13,7 @@ final class HomeViewController: BaseViewController {
|
|||||||
|
|
||||||
private let viewModel = HomeViewModel()
|
private let viewModel = HomeViewModel()
|
||||||
private let homeAPI = NetworkServices.shared.homeAPI
|
private let homeAPI = NetworkServices.shared.homeAPI
|
||||||
|
private let messageCenterAPI = NetworkServices.shared.messageCenterAPI
|
||||||
|
|
||||||
private let scenicHeaderView = HomeScenicHeaderView()
|
private let scenicHeaderView = HomeScenicHeaderView()
|
||||||
private var collectionView: UICollectionView!
|
private var collectionView: UICollectionView!
|
||||||
@ -74,6 +75,7 @@ final class HomeViewController: BaseViewController {
|
|||||||
Task { @MainActor in self?.showToast(message) }
|
Task { @MainActor in self?.showToast(message) }
|
||||||
}
|
}
|
||||||
scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() }
|
scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() }
|
||||||
|
scenicHeaderView.onMessageTap = { [weak self] in self?.openMessageCenter() }
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@ -88,11 +90,26 @@ final class HomeViewController: BaseViewController {
|
|||||||
name: NotificationName.scenicDidChange,
|
name: NotificationName.scenicDidChange,
|
||||||
object: nil
|
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) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||||
|
if hasInitialized {
|
||||||
|
Task { await refreshUnreadMessageStatus() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
@ -272,6 +289,7 @@ final class HomeViewController: BaseViewController {
|
|||||||
private func initializeHome() async {
|
private func initializeHome() async {
|
||||||
showLoading()
|
showLoading()
|
||||||
await viewModel.initialize(api: homeAPI)
|
await viewModel.initialize(api: homeAPI)
|
||||||
|
await refreshUnreadMessageStatus()
|
||||||
hideLoading()
|
hideLoading()
|
||||||
|
|
||||||
applyViewModel()
|
applyViewModel()
|
||||||
@ -287,7 +305,10 @@ final class HomeViewController: BaseViewController {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
scenicHeaderView.apply(scenicName: viewModel.currentScenicName)
|
scenicHeaderView.apply(
|
||||||
|
scenicName: viewModel.currentScenicName,
|
||||||
|
hasUnreadMessages: viewModel.hasUnreadMessages
|
||||||
|
)
|
||||||
|
|
||||||
let showWorkBlock = !viewModel.isMinimalTopRole
|
let showWorkBlock = !viewModel.isMinimalTopRole
|
||||||
let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil
|
let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil
|
||||||
@ -499,6 +520,10 @@ final class HomeViewController: BaseViewController {
|
|||||||
pushScenicSelection()
|
pushScenicSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func openMessageCenter() {
|
||||||
|
navigationController?.pushViewController(MessageCenterViewController(), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func pushScenicSelection() {
|
private func pushScenicSelection() {
|
||||||
navigationController?.setNavigationBarHidden(false, animated: true)
|
navigationController?.setNavigationBarHidden(false, animated: true)
|
||||||
let controller = ScenicSelectionViewController()
|
let controller = ScenicSelectionViewController()
|
||||||
@ -529,6 +554,7 @@ final class HomeViewController: BaseViewController {
|
|||||||
viewModel.markNeedsPermissionReload()
|
viewModel.markNeedsPermissionReload()
|
||||||
Task {
|
Task {
|
||||||
await viewModel.reloadIfNeeded(api: homeAPI)
|
await viewModel.reloadIfNeeded(api: homeAPI)
|
||||||
|
await refreshUnreadMessageStatus()
|
||||||
applyViewModel()
|
applyViewModel()
|
||||||
await evaluateDialogsWithDelay()
|
await evaluateDialogsWithDelay()
|
||||||
}
|
}
|
||||||
@ -541,6 +567,21 @@ final class HomeViewController: BaseViewController {
|
|||||||
applyViewModel()
|
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 {
|
extension HomeViewController: UICollectionViewDelegate {
|
||||||
|
|||||||
@ -9,10 +9,16 @@ import UIKit
|
|||||||
/// 首页顶部景区选择条。
|
/// 首页顶部景区选择条。
|
||||||
final class HomeScenicHeaderView: UIView {
|
final class HomeScenicHeaderView: UIView {
|
||||||
|
|
||||||
|
/// 点击景区名称时触发。
|
||||||
var onTap: (() -> Void)?
|
var onTap: (() -> Void)?
|
||||||
|
|
||||||
|
/// 点击消息入口时触发。
|
||||||
|
var onMessageTap: (() -> Void)?
|
||||||
|
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||||
|
private let messageButton = UIButton(type: .system)
|
||||||
|
private let unreadDotView = UIView()
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@ -25,8 +31,24 @@ final class HomeScenicHeaderView: UIView {
|
|||||||
arrowView.contentMode = .scaleAspectFit
|
arrowView.contentMode = .scaleAspectFit
|
||||||
arrowView.setContentCompressionResistancePriority(.required, for: .horizontal)
|
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(titleLabel)
|
||||||
addSubview(arrowView)
|
addSubview(arrowView)
|
||||||
|
addSubview(messageButton)
|
||||||
|
messageButton.addSubview(unreadDotView)
|
||||||
|
|
||||||
titleLabel.snp.makeConstraints { make in
|
titleLabel.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(safeAreaLayoutGuide).offset(AppSpacing.screenHorizontalInset)
|
make.leading.equalTo(safeAreaLayoutGuide).offset(AppSpacing.screenHorizontalInset)
|
||||||
@ -34,12 +56,23 @@ final class HomeScenicHeaderView: UIView {
|
|||||||
}
|
}
|
||||||
arrowView.snp.makeConstraints { make in
|
arrowView.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs)
|
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.centerY.equalTo(safeAreaLayoutGuide)
|
||||||
make.width.height.equalTo(16)
|
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))
|
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||||
|
tap.delegate = self
|
||||||
addGestureRecognizer(tap)
|
addGestureRecognizer(tap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,12 +81,26 @@ final class HomeScenicHeaderView: UIView {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply(scenicName: String) {
|
/// 更新景区名称与未读消息状态。
|
||||||
|
func apply(scenicName: String, hasUnreadMessages: Bool) {
|
||||||
let trimmed = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
titleLabel.text = trimmed.isEmpty ? "请选择景区" : trimmed
|
titleLabel.text = trimmed.isEmpty ? "请选择景区" : trimmed
|
||||||
|
unreadDotView.isHidden = !hasUnreadMessages
|
||||||
|
messageButton.accessibilityLabel = hasUnreadMessages ? "消息中心,有未读消息" : "消息中心"
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleTap() {
|
@objc private func handleTap() {
|
||||||
onTap?()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,12 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
|
|||||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
private let footerSpinner = UIActivityIndicatorView(style: .medium)
|
private let footerSpinner = UIActivityIndicatorView(style: .medium)
|
||||||
private let emptyLabel = UILabel()
|
private let emptyLabel = UILabel()
|
||||||
|
private lazy var markAllReadButton = UIBarButtonItem(
|
||||||
|
title: "全部已读",
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(markAllReadTapped)
|
||||||
|
)
|
||||||
|
|
||||||
private var dataSource: UITableViewDiffableDataSource<Section, MessageItem>!
|
private var dataSource: UITableViewDiffableDataSource<Section, MessageItem>!
|
||||||
|
|
||||||
@ -46,6 +52,7 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
|
|||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "消息中心"
|
title = "消息中心"
|
||||||
|
navigationItem.rightBarButtonItem = markAllReadButton
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
@ -79,7 +86,8 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
|
|||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
tableView.snp.makeConstraints { make in
|
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
|
loadingIndicator.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
make.center.equalToSuperview()
|
||||||
@ -103,6 +111,11 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
|
|||||||
self?.openDetail(message)
|
self?.openDetail(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModel.onUnreadMessageCountChange = { count in
|
||||||
|
Task { @MainActor in
|
||||||
|
await PushNotificationManager.shared.updateApplicationIconBadgeCount(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
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() {
|
private func configureDataSource() {
|
||||||
dataSource = UITableViewDiffableDataSource<Section, MessageItem>(tableView: tableView) { tableView, indexPath, item in
|
dataSource = UITableViewDiffableDataSource<Section, MessageItem>(tableView: tableView) { tableView, indexPath, item in
|
||||||
let cell = tableView.dequeueReusableCell(
|
let cell = tableView.dequeueReusableCell(
|
||||||
@ -154,6 +173,7 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
|
|||||||
}
|
}
|
||||||
|
|
||||||
tableView.tableFooterView = footerView()
|
tableView.tableFooterView = footerView()
|
||||||
|
markAllReadButton.isEnabled = !viewModel.isLoading && !viewModel.isMarkingAllAsRead
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applySnapshot(animated: Bool) {
|
private func applySnapshot(animated: Bool) {
|
||||||
|
|||||||
@ -29,7 +29,7 @@ final class AllFunctionsViewModelTests: XCTestCase {
|
|||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLoadFunctionsUsesDefaultFirstFourWhenNoSavedURIs() {
|
func testLoadFunctionsUsesDefaultFirstFourAndExcludesMessageCenter() {
|
||||||
appStore.permissions.savePermissionItems([
|
appStore.permissions.savePermissionItems([
|
||||||
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
||||||
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"),
|
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"),
|
||||||
@ -44,7 +44,7 @@ final class AllFunctionsViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.commonMenus.map(\.uri), [
|
XCTAssertEqual(viewModel.commonMenus.map(\.uri), [
|
||||||
"wallet", "cloud_management", "task_management", "system_settings",
|
"wallet", "cloud_management", "task_management", "system_settings",
|
||||||
])
|
])
|
||||||
XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["message_center"])
|
XCTAssertTrue(viewModel.moreMenus.isEmpty)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
menuStore.savedCommonURIs(
|
menuStore.savedCommonURIs(
|
||||||
accountScope: appStore.session.accountCachePrefix,
|
accountScope: appStore.session.accountCachePrefix,
|
||||||
|
|||||||
@ -23,14 +23,26 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDefaultCommonURIsTakeFirstFour() {
|
func testDefaultCommonURIsTakeFirstFourAndExcludeMessageCenter() {
|
||||||
let permissions = (1...6).map {
|
let permissions = [
|
||||||
|
HomePermissionItem(id: "0", name: "消息", uri: "message_center"),
|
||||||
|
] + (1...5).map {
|
||||||
HomePermissionItem(id: "\($0)", name: "菜单\($0)", uri: "uri_\($0)")
|
HomePermissionItem(id: "\($0)", name: "菜单\($0)", uri: "uri_\($0)")
|
||||||
}
|
}
|
||||||
let uris = HomeCommonMenuStore.defaultCommonURIs(from: permissions)
|
let uris = HomeCommonMenuStore.defaultCommonURIs(from: permissions)
|
||||||
XCTAssertEqual(uris, ["uri_1", "uri_2", "uri_3", "uri_4"])
|
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() {
|
func testSavedCommonURIsOverrideDefault() {
|
||||||
let permissions = [
|
let permissions = [
|
||||||
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
||||||
@ -65,8 +77,12 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
HomeMenuItem(uri: "wallet", title: "钱包", iconName: "wallet.pass"),
|
HomeMenuItem(uri: "wallet", title: "钱包", iconName: "wallet.pass"),
|
||||||
HomeMenuItem(uri: "cloud_management", title: "云盘", iconName: "icloud"),
|
HomeMenuItem(uri: "cloud_management", title: "云盘", iconName: "icloud"),
|
||||||
HomeMenuItem(uri: "task_management", title: "任务", iconName: "checklist"),
|
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.common.map(\.uri), ["wallet", "task_management"])
|
||||||
XCTAssertEqual(split.more.map(\.uri), ["cloud_management"])
|
XCTAssertEqual(split.more.map(\.uri), ["cloud_management"])
|
||||||
}
|
}
|
||||||
@ -75,7 +91,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
let item = HomeMenuCatalog.item(for: "report_photographer")
|
let item = HomeMenuCatalog.item(for: "report_photographer")
|
||||||
|
|
||||||
XCTAssertEqual(item?.title, "举报摄影师")
|
XCTAssertEqual(item?.title, "举报摄影师")
|
||||||
XCTAssertEqual(item?.iconName, "shield.fill")
|
XCTAssertEqual(item?.iconName, "shield.lefthalf.filled")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHomeMenuCatalogUsesIOS16CompatibleIcons() {
|
func testHomeMenuCatalogUsesIOS16CompatibleIcons() {
|
||||||
|
|||||||
@ -90,10 +90,10 @@ final class HomeViewModelTests: XCTestCase {
|
|||||||
let menuStore = HomeCommonMenuStore(defaults: defaults)
|
let menuStore = HomeCommonMenuStore(defaults: defaults)
|
||||||
appStore.permissions.savePermissionItems([
|
appStore.permissions.savePermissionItems([
|
||||||
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
||||||
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"),
|
HomePermissionItem(id: "2", name: "消息", uri: "message_center"),
|
||||||
HomePermissionItem(id: "3", name: "任务", uri: "task_management"),
|
HomePermissionItem(id: "3", name: "云盘", uri: "cloud_management"),
|
||||||
HomePermissionItem(id: "4", name: "设置", uri: "system_settings"),
|
HomePermissionItem(id: "4", name: "任务", uri: "task_management"),
|
||||||
HomePermissionItem(id: "5", name: "消息", uri: "message_center"),
|
HomePermissionItem(id: "5", name: "设置", uri: "system_settings"),
|
||||||
])
|
])
|
||||||
let viewModel = HomeViewModel(appStore: appStore, commonMenuStore: menuStore)
|
let viewModel = HomeViewModel(appStore: appStore, commonMenuStore: menuStore)
|
||||||
|
|
||||||
@ -231,6 +231,50 @@ final class HomeViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(session.requests.count, 1)
|
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() {
|
func testRefreshLocalDisplayStateClearsWhenNameMissing() {
|
||||||
appStore.session.currentScenicId = 10
|
appStore.session.currentScenicId = 10
|
||||||
appStore.session.currentScenicName = ""
|
appStore.session.currentScenicName = ""
|
||||||
@ -283,6 +327,45 @@ final class HomeViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
return nil
|
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 {
|
private extension UIColor {
|
||||||
|
|||||||
@ -53,21 +53,37 @@ final class MessageCenterAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123"))
|
XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testReadAndDeleteUseAndroidPathsAndBody() async throws {
|
func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws {
|
||||||
let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}")])
|
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))
|
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)
|
try await api.delete(messageId: 21)
|
||||||
|
|
||||||
XCTAssertEqual(session.requests[0].httpMethod, "POST")
|
XCTAssertEqual(unread.unreadCount, 12)
|
||||||
XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/read")
|
XCTAssertEqual(session.requests[0].httpMethod, "GET")
|
||||||
let readQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems
|
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(readQuery?.first { $0.name == "id" }?.value, "21")
|
||||||
|
|
||||||
XCTAssertEqual(session.requests[1].httpMethod, "POST")
|
XCTAssertEqual(readAll, MessageReadAllResponse(updatedCount: 11, unreadCount: 0))
|
||||||
XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/delete")
|
XCTAssertEqual(session.requests[2].httpMethod, "POST")
|
||||||
let deleteBody = try bodyJSON(session.requests[1])
|
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)
|
XCTAssertEqual(deleteBody["id"] as? Int, 21)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,17 +32,77 @@ final class MessageCenterViewModelTests: XCTestCase {
|
|||||||
func testUnreadSelectionMarksReadAndOpensDetail() async {
|
func testUnreadSelectionMarksReadAndOpensDetail() async {
|
||||||
let api = MockMessageCenterAPI()
|
let api = MockMessageCenterAPI()
|
||||||
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 3, isRead: false)])]
|
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 3, isRead: false)])]
|
||||||
|
api.readResponse = MessageUnreadCountResponse(unreadCount: 4)
|
||||||
let viewModel = MessageCenterViewModel()
|
let viewModel = MessageCenterViewModel()
|
||||||
var opened: MessageItem?
|
var opened: MessageItem?
|
||||||
|
var callbackCount: Int?
|
||||||
viewModel.onOpenDetail = { opened = $0 }
|
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.loadInitial(api: api)
|
||||||
await viewModel.selectMessage(id: 3, api: api)
|
await viewModel.selectMessage(id: 3, api: api)
|
||||||
|
await fulfillment(of: [unreadChanged], timeout: 1)
|
||||||
|
|
||||||
XCTAssertEqual(api.readCalls, [3])
|
XCTAssertEqual(api.readCalls, [3])
|
||||||
XCTAssertEqual(opened?.id, 3)
|
XCTAssertEqual(opened?.id, 3)
|
||||||
XCTAssertEqual(opened?.isRead, true)
|
XCTAssertEqual(opened?.isRead, true)
|
||||||
XCTAssertEqual(viewModel.items.first?.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 {
|
func testReadSelectionOpensDetailWithoutCallingReadAPI() async {
|
||||||
@ -125,8 +185,12 @@ private final class MockMessageCenterAPI: MessageCenterServing {
|
|||||||
var listResponses: [MessageListResponse] = []
|
var listResponses: [MessageListResponse] = []
|
||||||
var listCalls: [(lastId: Int, limit: Int, unread: Int)] = []
|
var listCalls: [(lastId: Int, limit: Int, unread: Int)] = []
|
||||||
var readCalls: [Int] = []
|
var readCalls: [Int] = []
|
||||||
|
var readResponse = MessageUnreadCountResponse()
|
||||||
|
var readAllResponse = MessageReadAllResponse()
|
||||||
|
var readAllCallCount = 0
|
||||||
var deleteCalls: [Int] = []
|
var deleteCalls: [Int] = []
|
||||||
var readError: Error?
|
var readError: Error?
|
||||||
|
var readAllError: Error?
|
||||||
var deleteError: Error?
|
var deleteError: Error?
|
||||||
|
|
||||||
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
|
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
|
||||||
@ -135,9 +199,20 @@ private final class MockMessageCenterAPI: MessageCenterServing {
|
|||||||
return listResponses.removeFirst()
|
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)
|
readCalls.append(messageId)
|
||||||
if let readError { throw readError }
|
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 {
|
func delete(messageId: Int) async throws {
|
||||||
|
|||||||
@ -158,6 +158,28 @@ final class PushNotificationTests: XCTestCase {
|
|||||||
XCTAssertEqual(router.destinations, [.task])
|
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() {
|
func testRouteCoordinatorWaitsForLoginAndDeduplicatesColdStartResponse() {
|
||||||
let coordinator = PushRouteCoordinator(appStore: appStore)
|
let coordinator = PushRouteCoordinator(appStore: appStore)
|
||||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
@ -189,7 +211,8 @@ final class PushNotificationTests: XCTestCase {
|
|||||||
private func makeManager(
|
private func makeManager(
|
||||||
sdk: PushSDKMock? = nil,
|
sdk: PushSDKMock? = nil,
|
||||||
api: PushRegistrationAPIMock? = nil,
|
api: PushRegistrationAPIMock? = nil,
|
||||||
router: PushRouterMock? = nil
|
router: PushRouterMock? = nil,
|
||||||
|
badgeSetter: ApplicationIconBadgeSetterMock? = nil
|
||||||
) -> PushNotificationManager {
|
) -> PushNotificationManager {
|
||||||
let resolvedSDK: PushSDKMock
|
let resolvedSDK: PushSDKMock
|
||||||
if let sdk {
|
if let sdk {
|
||||||
@ -217,7 +240,8 @@ final class PushNotificationTests: XCTestCase {
|
|||||||
api: resolvedAPI,
|
api: resolvedAPI,
|
||||||
appStore: appStore,
|
appStore: appStore,
|
||||||
defaults: defaults,
|
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 测试替身,记录初始化与通知授权行为。
|
/// 可控的极光 SDK 测试替身,记录初始化与通知授权行为。
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class PushSDKMock: PushSDKProviding {
|
private final class PushSDKMock: PushSDKProviding {
|
||||||
|
|||||||
Reference in New Issue
Block a user