From aea0e16279cb7d3c735388a125f284577a084304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Wed, 16 Sep 2026 10:32:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=AB=99=E5=86=85=E9=80=9A=E7=9F=A5):=20?= =?UTF-8?q?=E9=93=83=E9=93=9B=E6=94=AF=E6=8C=81=E6=8C=89=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=A4=A7=E7=B1=BB=E7=AD=9B=E9=80=89=EF=BC=88=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=BC=82=E5=B8=B8/=E4=BB=BB=E5=8A=A1=E5=BC=82=E5=B8=B8/?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=BC=82=E5=B8=B8/=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来铃铛只能按关键字和日期筛,任务失败、密钥欠费、麦象异常全混在一起, 用户没法只看自己关心的那类。 后端 NotificationService 增加 scene→大类映射(未知 scene 归「系统通知」兜底), 列表接口新增 category 参数,落到 SQL 是 scene IN (...);「系统通知」作兜底类 还要纳入未登记的 scene,故表达为「= system 或 不在其它三类里」。列表项返回 category,前端不必各自维护一份 scene 映射。前后端两个通知接口同步加参数。 前端铃铛筛选区新增一行类型 chip(全部 + 四类),与关键字、日期一起参与 hasFilter 与重置;空分类不下发,避免后端查询落空。 --- .../AdminNotificationController.java | 8 +- .../controller/NotificationController.java | 8 +- .../model/dto/NotificationPageQuery.java | 10 ++- .../model/vo/NotificationItemVo.java | 2 + .../service/NotificationService.java | 82 +++++++++++++++-- .../AdminNotificationControllerTest.java | 4 +- .../NotificationControllerTest.java | 4 +- .../service/NotificationServiceTest.java | 89 ++++++++++++++++++- .../shared/api/types/modules/notification.ts | 37 +++++++- .../shared/components/NotificationBell.vue | 73 ++++++++++++++- frontend-vue/tests/notification-bell.test.ts | 30 ++++++- 11 files changed, 318 insertions(+), 29 deletions(-) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationController.java index e8308625..5d3be573 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationController.java @@ -44,20 +44,24 @@ public class AdminNotificationController { @GetMapping @Operation(summary = "通知分页列表", - description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;startDate/endDate 为年月日闭区间。") + description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;" + + "category 按类型大类筛选(system_error/task_error/config_error/system_notice);" + + "startDate/endDate 为年月日闭区间。") public ApiResponse page( HttpServletRequest request, @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Long pageSize, @Parameter(description = "只看未读") @RequestParam(required = false, defaultValue = "false") Boolean onlyUnread, @Parameter(description = "关键字(标题/内容)") @RequestParam(required = false) String keyword, + @Parameter(description = "类型大类:system_error/task_error/config_error/system_notice") + @RequestParam(required = false) String category, @Parameter(description = "起始日期(yyyy-MM-dd,含)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, @Parameter(description = "结束日期(yyyy-MM-dd,含)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { Long userId = currentAdminId(request); return ApiResponse.success(notificationService.page(userId, NotificationService.AUDIENCE_ADMIN, - NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, startDate, endDate))); + NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, category, startDate, endDate))); } @PostMapping("/{id}/read") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/NotificationController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/NotificationController.java index 4046e59e..63765029 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/NotificationController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/controller/NotificationController.java @@ -44,20 +44,24 @@ public class NotificationController { @GetMapping @Operation(summary = "通知分页列表", - description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;startDate/endDate 为年月日闭区间。") + description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;" + + "category 按类型大类筛选(system_error/task_error/config_error/system_notice);" + + "startDate/endDate 为年月日闭区间。") public ApiResponse page( HttpServletRequest request, @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Long pageSize, @Parameter(description = "只看未读") @RequestParam(required = false, defaultValue = "false") Boolean onlyUnread, @Parameter(description = "关键字(标题/内容)") @RequestParam(required = false) String keyword, + @Parameter(description = "类型大类:system_error/task_error/config_error/system_notice") + @RequestParam(required = false) String category, @Parameter(description = "起始日期(yyyy-MM-dd,含)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, @Parameter(description = "结束日期(yyyy-MM-dd,含)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { Long userId = currentUserId(request); return ApiResponse.success(notificationService.page(userId, NotificationService.AUDIENCE_USER, - NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, startDate, endDate))); + NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, category, startDate, endDate))); } @PostMapping("/{id}/read") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/dto/NotificationPageQuery.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/dto/NotificationPageQuery.java index b850d4ce..d1bc1bab 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/dto/NotificationPageQuery.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/dto/NotificationPageQuery.java @@ -5,8 +5,9 @@ import lombok.Data; import java.time.LocalDate; /** - * 通知列表查询条件:分页 + 未读过滤 + 关键字(标题/内容模糊)+ 创建日期区间(年月日闭区间)。 - * 铃铛面板的「按天搜索」与「内容搜索」都走这里,空值表示不限制。 + * 通知列表查询条件:分页 + 未读过滤 + 关键字(标题/内容模糊)+ 创建日期区间(年月日闭区间) + * + 类型大类(system_error/task_error/config_error/system_notice)。 + * 铃铛面板的「按天搜索」「内容搜索」「类型筛选」都走这里,空值表示不限制。 */ @Data public class NotificationPageQuery { @@ -15,16 +16,19 @@ public class NotificationPageQuery { private Long pageSize; private Boolean onlyUnread; private String keyword; + private String category; private LocalDate startDate; private LocalDate endDate; public static NotificationPageQuery of(Long page, Long pageSize, Boolean onlyUnread, - String keyword, LocalDate startDate, LocalDate endDate) { + String keyword, String category, + LocalDate startDate, LocalDate endDate) { NotificationPageQuery query = new NotificationPageQuery(); query.setPage(page); query.setPageSize(pageSize); query.setOnlyUnread(onlyUnread); query.setKeyword(keyword); + query.setCategory(category); query.setStartDate(startDate); query.setEndDate(endDate); return query; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationItemVo.java index c9dc8f33..d706a3e5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/vo/NotificationItemVo.java @@ -11,6 +11,8 @@ public class NotificationItemVo { private Long id; /** 场景:secret_balance/secret_invalid/task_failed/service_down/system */ private String scene; + /** 类型大类(铃铛筛选用):system_error/task_error/config_error/system_notice */ + private String category; /** 级别:info/warning/error */ private String level; private String title; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationService.java index c22a8567..883b6a4f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/service/NotificationService.java @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * 站内通知存取:桌面端用户(audience=user)与后台管理员(audience=admin)共用一张表, @@ -40,6 +41,27 @@ public class NotificationService { public static final String SCENE_MAIXIANG_ANOMALY = "maixiang_anomaly"; public static final String SCENE_SYSTEM = "system"; + /** + * 通知大类:scene 太细(技术语义),铃铛按用户看得懂的四类归并筛选。 + * null/空分类表示不筛选,见 {@link #scenesOfCategory}。 + */ + public static final String CATEGORY_SYSTEM_ERROR = "system_error"; + public static final String CATEGORY_TASK_ERROR = "task_error"; + public static final String CATEGORY_CONFIG_ERROR = "config_error"; + public static final String CATEGORY_SYSTEM_NOTICE = "system_notice"; + + /** + * scene → 大类。未知 scene(历史遗留或新增未登记)归入「系统通知」, + * 保证任一通知只属于一类、筛选项互斥且完备。 + */ + private static final Map SCENE_TO_CATEGORY = Map.of( + SCENE_SERVICE_DOWN, CATEGORY_SYSTEM_ERROR, + SCENE_MAIXIANG_ANOMALY, CATEGORY_SYSTEM_ERROR, + SCENE_TASK_FAILED, CATEGORY_TASK_ERROR, + SCENE_SECRET_BALANCE, CATEGORY_CONFIG_ERROR, + SCENE_SECRET_INVALID, CATEGORY_CONFIG_ERROR, + SCENE_SYSTEM, CATEGORY_SYSTEM_NOTICE); + private static final long MAX_PAGE_SIZE = 100L; private static final int TITLE_MAX_LENGTH = 128; private static final int CONTENT_MAX_LENGTH = 512; @@ -143,12 +165,35 @@ public class NotificationService { vo.setPage(safePage); vo.setPageSize(safeSize); vo.setUnreadCount(unreadCount(userId, audience)); - log.info("[notification] 列表查询 userId={} audience={} page={} size={} onlyUnread={} keyword={} 起={} 止={} 命中={}", - userId, audience, safePage, safeSize, onlyUnread, normalize(safe.getKeyword()), - safe.getStartDate(), safe.getEndDate(), total); + log.info("[notification] 列表查询 userId={} audience={} page={} size={} onlyUnread={} category={} keyword={} 起={} 止={} 命中={}", + userId, audience, safePage, safeSize, onlyUnread, normalize(safe.getCategory()), + normalize(safe.getKeyword()), safe.getStartDate(), safe.getEndDate(), total); return vo; } + /** scene → 用户可见大类;未知 scene(历史遗留/新增未登记)一律归「系统通知」。 */ + public static String categoryOf(String scene) { + return SCENE_TO_CATEGORY.getOrDefault(normalize(scene), CATEGORY_SYSTEM_NOTICE); + } + + /** + * 大类对应的 scene 集合,供查询侧做 scene IN (...) 过滤。 + * 空值或未知大类返回空列表,调用方据此跳过该筛选(不报错,避免前端传错值就查不到数据)。 + */ + public static List scenesOfCategory(String category) { + String wanted = normalize(category); + if (wanted.isEmpty()) { + return List.of(); + } + List scenes = new ArrayList<>(); + for (Map.Entry entry : SCENE_TO_CATEGORY.entrySet()) { + if (entry.getValue().equals(wanted)) { + scenes.add(entry.getKey()); + } + } + return scenes; + } + /** 摘要:未读数 + 最新通知 id(前端轮询判断是否有新通知)。 */ public NotificationSummaryVo summary(Long userId, String audience) { NotificationSummaryVo vo = new NotificationSummaryVo(); @@ -242,9 +287,11 @@ public class NotificationService { /** * 列表筛选:关键字模糊匹配标题/内容;日期按「年月日」闭区间 - * (起日 00:00 起含、止日次日 00:00 前不含,避免当天 23:59 漏行)。 + * (起日 00:00 起含、止日次日 00:00 前不含,避免当天 23:59 漏行); + * 类型按大类映射成 scene 集合过滤。 */ private void applyFilters(LambdaQueryWrapper wrapper, NotificationPageQuery query) { + applyCategoryFilter(wrapper, query.getCategory()); String keyword = normalize(query.getKeyword()); if (!keyword.isEmpty()) { wrapper.and(nested -> nested.like(UserNotificationEntity::getTitle, keyword) @@ -258,8 +305,28 @@ public class NotificationService { } } - private boolean existsByDedupeKey(String dedupeKey) { - return selectByDedupeKey(dedupeKey) != null; + /** + * 类型筛选:「系统通知」是兜底类,除 scene=system 外还要包含所有未登记 scene + * (否则直接改库塞入的 scene 在筛选时会凭空消失),故表达为「= system 或 不在其它三类里」; + * 未知分类一律不追加条件,避免前端传错值就查不到任何数据。 + */ + private void applyCategoryFilter(LambdaQueryWrapper wrapper, String category) { + String wanted = normalize(category); + List scenes = wanted.isEmpty() ? List.of() : scenesOfCategory(wanted); + if (scenes.isEmpty()) { + return; + } + if (CATEGORY_SYSTEM_NOTICE.equals(wanted)) { + List classified = new ArrayList<>(SCENE_TO_CATEGORY.keySet()); + classified.remove(SCENE_SYSTEM); + wrapper.and(nested -> nested.eq(UserNotificationEntity::getScene, SCENE_SYSTEM) + .or().notIn(UserNotificationEntity::getScene, classified)); + return; + } + wrapper.in(UserNotificationEntity::getScene, scenes); + } + + private boolean existsByDedupeKey(String dedupeKey) { return selectByDedupeKey(dedupeKey) != null; } private UserNotificationEntity selectByDedupeKey(String dedupeKey) { @@ -273,6 +340,7 @@ public class NotificationService { NotificationItemVo vo = new NotificationItemVo(); vo.setId(row.getId()); vo.setScene(row.getScene()); + vo.setCategory(categoryOf(row.getScene())); vo.setLevel(row.getLevel()); vo.setTitle(row.getTitle()); vo.setContent(row.getContent()); @@ -282,7 +350,7 @@ public class NotificationService { return vo; } - private String normalize(String value) { + private static String normalize(String value) { return value == null ? "" : value.trim(); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationControllerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationControllerTest.java index a63bb103..8de84dfe 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationControllerTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationControllerTest.java @@ -34,7 +34,8 @@ class AdminNotificationControllerTest { when(adminAuthSupport.requireAdmin(request)).thenReturn(admin(24L)); controller.summary(request); - controller.page(request, 1L, 20L, false, "余额", LocalDate.of(2026, 9, 10), LocalDate.of(2026, 9, 13)); + controller.page(request, 1L, 20L, false, "余额", NotificationService.CATEGORY_CONFIG_ERROR, + LocalDate.of(2026, 9, 10), LocalDate.of(2026, 9, 13)); verify(notificationService).summary(24L, NotificationService.AUDIENCE_ADMIN); ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(NotificationPageQuery.class); @@ -44,6 +45,7 @@ class AdminNotificationControllerTest { assertThat(query.getPageSize()).isEqualTo(20L); assertThat(query.getOnlyUnread()).isFalse(); assertThat(query.getKeyword()).isEqualTo("余额"); + assertThat(query.getCategory()).isEqualTo(NotificationService.CATEGORY_CONFIG_ERROR); assertThat(query.getStartDate()).isEqualTo(LocalDate.of(2026, 9, 10)); assertThat(query.getEndDate()).isEqualTo(LocalDate.of(2026, 9, 13)); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/NotificationControllerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/NotificationControllerTest.java index 2baf382d..ef0491d8 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/NotificationControllerTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/NotificationControllerTest.java @@ -33,7 +33,8 @@ class NotificationControllerTest { when(adminAuthSupport.requireUser(request)).thenReturn(user(1095L)); controller.summary(request); - controller.page(request, 2L, 20L, true, "欠费", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 13)); + controller.page(request, 2L, 20L, true, "欠费", NotificationService.CATEGORY_TASK_ERROR, + LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 13)); verify(notificationService).summary(1095L, NotificationService.AUDIENCE_USER); ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(NotificationPageQuery.class); @@ -43,6 +44,7 @@ class NotificationControllerTest { assertThat(query.getPageSize()).isEqualTo(20L); assertThat(query.getOnlyUnread()).isTrue(); assertThat(query.getKeyword()).isEqualTo("欠费"); + assertThat(query.getCategory()).isEqualTo(NotificationService.CATEGORY_TASK_ERROR); assertThat(query.getStartDate()).isEqualTo(LocalDate.of(2026, 9, 1)); assertThat(query.getEndDate()).isEqualTo(LocalDate.of(2026, 9, 13)); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationServiceTest.java index aff98175..677f044d 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationServiceTest.java @@ -133,7 +133,7 @@ class NotificationServiceTest { when(mapper.selectList(any())).thenReturn(List.of(first, second)); NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_USER, - NotificationPageQuery.of(1L, 20L, false, null, null, null)); + NotificationPageQuery.of(1L, 20L, false, null, null, null, null)); assertThat(page.getItems()).hasSize(2); assertThat(page.getItems().get(0).getRead()).isTrue(); @@ -149,7 +149,7 @@ class NotificationServiceTest { when(mapper.selectCount(any())).thenReturn(0L, 0L); NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_ADMIN, - NotificationPageQuery.of(0L, 500L, true, null, null, null)); + NotificationPageQuery.of(0L, 500L, true, null, null, null, null)); assertThat(page.getItems()).isEmpty(); assertThat(page.getPage()).isEqualTo(1L); @@ -204,7 +204,7 @@ class NotificationServiceTest { when(mapper.selectList(any())).thenReturn(List.of()); service.page(24L, NotificationService.AUDIENCE_ADMIN, - NotificationPageQuery.of(1L, 20L, true, null, null, null)); + NotificationPageQuery.of(1L, 20L, true, null, null, null, null)); ArgumentCaptor> countCaptor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); @@ -224,7 +224,7 @@ class NotificationServiceTest { when(mapper.selectList(any())).thenReturn(List.of()); service.page(24L, NotificationService.AUDIENCE_ADMIN, NotificationPageQuery.of( - 1L, 20L, false, "余额不足", LocalDate.of(2026, 9, 10), LocalDate.of(2026, 9, 13))); + 1L, 20L, false, "余额不足", null, LocalDate.of(2026, 9, 10), LocalDate.of(2026, 9, 13))); ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); @@ -235,6 +235,87 @@ class NotificationServiceTest { .contains("%余额不足%", LocalDateTime.of(2026, 9, 10, 0, 0), LocalDateTime.of(2026, 9, 14, 0, 0)); } + /** 类型大类映射:scene 归并成四类,未知 scene 兜底「系统通知」,保证筛选互斥且完备。 */ + @Test + void categoryOfMapsScenesToUserVisibleGroups() { + assertThat(NotificationService.categoryOf(NotificationService.SCENE_TASK_FAILED)) + .isEqualTo(NotificationService.CATEGORY_TASK_ERROR); + assertThat(NotificationService.categoryOf(NotificationService.SCENE_SERVICE_DOWN)) + .isEqualTo(NotificationService.CATEGORY_SYSTEM_ERROR); + assertThat(NotificationService.categoryOf(NotificationService.SCENE_MAIXIANG_ANOMALY)) + .isEqualTo(NotificationService.CATEGORY_SYSTEM_ERROR); + assertThat(NotificationService.categoryOf(NotificationService.SCENE_SECRET_BALANCE)) + .isEqualTo(NotificationService.CATEGORY_CONFIG_ERROR); + assertThat(NotificationService.categoryOf(NotificationService.SCENE_SECRET_INVALID)) + .isEqualTo(NotificationService.CATEGORY_CONFIG_ERROR); + assertThat(NotificationService.categoryOf(NotificationService.SCENE_SYSTEM)) + .isEqualTo(NotificationService.CATEGORY_SYSTEM_NOTICE); + // 历史遗留/新增未登记的 scene 不能被筛丢 + assertThat(NotificationService.categoryOf("legacy_scene")) + .isEqualTo(NotificationService.CATEGORY_SYSTEM_NOTICE); + assertThat(NotificationService.categoryOf(null)) + .isEqualTo(NotificationService.CATEGORY_SYSTEM_NOTICE); + } + + /** 类型筛选落到 SQL 上是 scene IN (...);空/未知分类不追加条件,避免前端传错值就查不到数据。 */ + @Test + @SuppressWarnings("unchecked") + void pageAppliesCategoryAsSceneInFilter() { + when(mapper.selectCount(any())).thenReturn(1L, 0L); + when(mapper.selectList(any())).thenReturn(List.of()); + + service.page(24L, NotificationService.AUDIENCE_USER, NotificationPageQuery.of( + 1L, 20L, false, null, NotificationService.CATEGORY_SYSTEM_ERROR, null, null)); + + ArgumentCaptor> listCaptor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectList(listCaptor.capture()); + LambdaQueryWrapper wrapper = listCaptor.getValue(); + assertThat(wrapper.getSqlSegment()).contains("scene"); + assertThat(wrapper.getParamNameValuePairs().values()) + .contains(NotificationService.SCENE_SERVICE_DOWN, NotificationService.SCENE_MAIXIANG_ANOMALY); + + assertThat(NotificationService.scenesOfCategory(null)).isEmpty(); + assertThat(NotificationService.scenesOfCategory("")).isEmpty(); + assertThat(NotificationService.scenesOfCategory("not_a_category")).isEmpty(); + } + + /** 「系统通知」是兜底类:除 scene=system 外还要纳入未登记 scene,否则那些通知筛选时会消失。 */ + @Test + @SuppressWarnings("unchecked") + void pageTreatsSystemNoticeCategoryAsCatchAll() { + when(mapper.selectCount(any())).thenReturn(1L, 0L); + when(mapper.selectList(any())).thenReturn(List.of()); + + service.page(24L, NotificationService.AUDIENCE_USER, NotificationPageQuery.of( + 1L, 20L, false, null, NotificationService.CATEGORY_SYSTEM_NOTICE, null, null)); + + ArgumentCaptor> listCaptor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectList(listCaptor.capture()); + LambdaQueryWrapper wrapper = listCaptor.getValue(); + assertThat(wrapper.getSqlSegment()).contains("scene"); + assertThat(wrapper.getParamNameValuePairs().values()) + .contains(NotificationService.SCENE_SYSTEM, NotificationService.SCENE_TASK_FAILED); + } + + /** 列表项必须带类型大类,前端据此展示与筛选,不必各自维护一份 scene 映射。 */ + @Test + void pageItemsCarryCategory() { + UserNotificationEntity row = new UserNotificationEntity(); + row.setId(9L); + row.setScene(NotificationService.SCENE_SECRET_BALANCE); + when(mapper.selectCount(any())).thenReturn(1L, 0L); + when(mapper.selectList(any())).thenReturn(List.of(row)); + + NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_USER, + NotificationPageQuery.of(1L, 20L, false, null, null, null, null)); + + assertThat(page.getItems()).hasSize(1); + assertThat(page.getItems().get(0).getCategory()) + .isEqualTo(NotificationService.CATEGORY_CONFIG_ERROR); + } + @Test @SuppressWarnings("unchecked") void summaryAndUnreadCountScopeByReceiverAndAudience() { diff --git a/frontend-vue/src/shared/api/types/modules/notification.ts b/frontend-vue/src/shared/api/types/modules/notification.ts index 0bb30656..89b8e9a8 100644 --- a/frontend-vue/src/shared/api/types/modules/notification.ts +++ b/frontend-vue/src/shared/api/types/modules/notification.ts @@ -11,11 +11,33 @@ export type NotificationScene = | 'maixiang_anomaly' | 'system' +/** + * 通知类型大类:后端按 scene 归并出的四类,铃铛筛选与标签都用它。 + * 未知/历史遗留 scene 由后端兜底归入 system_notice。 + */ +export type NotificationCategory = + | 'system_error' + | 'task_error' + | 'config_error' + | 'system_notice' + +/** 类型筛选项(顺序即下拉顺序):全部 + 四个大类。 */ +export const NOTIFICATION_CATEGORY_OPTIONS: ReadonlyArray<{ + value: NotificationCategory + label: string +}> = [ + { value: 'system_error', label: '系统异常' }, + { value: 'task_error', label: '任务异常' }, + { value: 'config_error', label: '配置异常' }, + { value: 'system_notice', label: '系统通知' }, +] + export type NotificationLevel = 'info' | 'warning' | 'error' export interface NotificationItem { id: number scene: string + category: string level: string title: string content: string @@ -44,19 +66,24 @@ export function fetchNotificationSummary() { ) } -/** 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 */ +/** + * 列表查询参数:关键字匹配标题/内容;category 按类型大类筛选; + * startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 + */ export interface NotificationListParams { page?: number pageSize?: number onlyUnread?: boolean keyword?: string + category?: string startDate?: string endDate?: string } /** - * 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 - * 空关键字/日期一律不下发(空串会让后端日期绑定失败返回 400)。 + * 列表查询参数:关键字匹配标题/内容;category 按类型大类筛选; + * startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 + * 空关键字/日期/分类一律不下发(空串会让后端日期绑定失败返回 400)。 */ export function buildNotificationListQuery( params: NotificationListParams = {}, @@ -70,6 +97,10 @@ export function buildNotificationListQuery( if (keyword) { query.keyword = keyword } + const category = (params.category ?? '').trim() + if (category) { + query.category = category + } if (params.startDate) { query.startDate = params.startDate } diff --git a/frontend-vue/src/shared/components/NotificationBell.vue b/frontend-vue/src/shared/components/NotificationBell.vue index e024d611..d5a81a2b 100644 --- a/frontend-vue/src/shared/components/NotificationBell.vue +++ b/frontend-vue/src/shared/components/NotificationBell.vue @@ -104,6 +104,26 @@ +
+ + +

{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}

-

试试调整搜索词或时间范围

+

试试调整类型、搜索词或时间范围

    @@ -262,6 +282,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { ElMessage } from 'element-plus' import { + NOTIFICATION_CATEGORY_OPTIONS, fetchNotificationList, fetchNotificationSummary, markAllNotificationsRead, @@ -306,6 +327,8 @@ const loadError = ref('') const keyword = ref('') /** 日期区间(el-date-picker daterange,YYYY-MM-DD 字符串;未选为 null)。 */ const dateRange = ref<[string, string] | null>(null) +/** 类型大类(system_error/task_error/config_error/system_notice);空串表示不按类型筛选。 */ +const category = ref('') const badgeText = computed(() => formatUnreadBadge(unreadCount.value)) const unreadChipText = computed(() => @@ -313,7 +336,9 @@ const unreadChipText = computed(() => ) const groupedItems = computed(() => groupNotificationsByDay(items.value)) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) -const hasFilter = computed(() => Boolean(keyword.value.trim() || dateRange.value)) +const hasFilter = computed(() => + Boolean(keyword.value.trim() || dateRange.value || category.value), +) let pollTimer: number | null = null let searchTimer: number | null = null @@ -372,6 +397,7 @@ async function loadPage(targetPage: number) { page: targetPage, pageSize: pageSize.value, keyword: keyword.value, + category: category.value, ...normalizedDayRange(), }) const list = Array.isArray(result?.items) ? result.items : [] @@ -429,9 +455,20 @@ function clearKeyword() { void loadPage(1) } +/** 类型筛选:重复点同一个不重复请求;切换后回到第一页。 */ +function selectCategory(value: string) { + if (category.value === value) { + return + } + category.value = value + console.log('[notification] 类型筛选切换为', value || '全部') + void loadPage(1) +} + function resetFilters() { keyword.value = '' dateRange.value = null + category.value = '' void loadPage(1) } @@ -799,6 +836,38 @@ onUnmounted(() => { background: var(--bell-text-mute); } +/* 类型筛选 chip 行:比下拉更省纵向空间,与深色控制台风格一致 */ +.bell-category-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.bell-chip { + height: 24px; + padding: 0 10px; + border: 1px solid var(--bell-border); + border-radius: 12px; + background: var(--bell-input-bg); + color: var(--bell-text-mute); + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} + +.bell-chip:hover { + color: var(--bell-text); + border-color: var(--bell-accent); +} + +.bell-chip.is-active { + border-color: var(--bell-accent); + background: var(--bell-accent-soft); + color: var(--bell-accent); + font-weight: 500; +} + .bell-search-days { display: flex; align-items: center; diff --git a/frontend-vue/tests/notification-bell.test.ts b/frontend-vue/tests/notification-bell.test.ts index c111aae3..f322a441 100644 --- a/frontend-vue/tests/notification-bell.test.ts +++ b/frontend-vue/tests/notification-bell.test.ts @@ -8,7 +8,10 @@ import { readLastNotifiedId, writeLastNotifiedId, } from '../src/shared/utils/notification-bell.ts' -import { buildNotificationListQuery } from '../src/shared/api/types/modules/notification.ts' +import { + NOTIFICATION_CATEGORY_OPTIONS, + buildNotificationListQuery, +} from '../src/shared/api/types/modules/notification.ts' function createStorage() { const store = new Map() @@ -133,13 +136,14 @@ test('groupNotificationsByDay 时间缺失/非法归入未知时间组', () => { ) }) -test('buildNotificationListQuery:关键字去空白、空条件不下发、日期原样透传', () => { +test('buildNotificationListQuery:关键字去空白、空条件不下发、分类与日期原样透传', () => { assert.deepEqual(buildNotificationListQuery(), { page: 1, pageSize: 20, onlyUnread: 'false' }) const query = buildNotificationListQuery({ page: 2, pageSize: 10, keyword: ' 余额不足 ', + category: 'config_error', startDate: '2026-09-10', endDate: '2026-09-13', }) @@ -148,13 +152,31 @@ test('buildNotificationListQuery:关键字去空白、空条件不下发、日 pageSize: 10, onlyUnread: 'false', keyword: '余额不足', + category: 'config_error', startDate: '2026-09-10', endDate: '2026-09-13', }) - // 面板清空筛选后不能带空串日期(后端日期绑定会 400) - const cleared = buildNotificationListQuery({ keyword: ' ', startDate: '', endDate: '' }) + // 面板清空筛选后不能带空串日期(后端日期绑定会 400),空分类同样不下发 + const cleared = buildNotificationListQuery({ + keyword: ' ', + category: ' ', + startDate: '', + endDate: '', + }) assert.equal('keyword' in cleared, false) + assert.equal('category' in cleared, false) assert.equal('startDate' in cleared, false) assert.equal('endDate' in cleared, false) }) + +/** 类型筛选项必须与后端四个大类逐一对应,漏一个用户就永远筛不到那类通知。 */ +test('类型筛选项覆盖后端四个大类且标签非空', () => { + assert.deepEqual( + NOTIFICATION_CATEGORY_OPTIONS.map((option) => option.value), + ['system_error', 'task_error', 'config_error', 'system_notice'], + ) + for (const option of NOTIFICATION_CATEGORY_OPTIONS) { + assert.ok(option.label.trim().length > 0, `分类 ${option.value} 缺中文标签`) + } +})