feat(站内通知): 铃铛支持按类型大类筛选(系统异常/任务异常/配置异常/系统通知)

原来铃铛只能按关键字和日期筛,任务失败、密钥欠费、麦象异常全混在一起,
用户没法只看自己关心的那类。

后端 NotificationService 增加 scene→大类映射(未知 scene 归「系统通知」兜底),
列表接口新增 category 参数,落到 SQL 是 scene IN (...);「系统通知」作兜底类
还要纳入未登记的 scene,故表达为「= system 或 不在其它三类里」。列表项返回
category,前端不必各自维护一份 scene 映射。前后端两个通知接口同步加参数。

前端铃铛筛选区新增一行类型 chip(全部 + 四类),与关键字、日期一起参与
hasFilter 与重置;空分类不下发,避免后端查询落空。
This commit is contained in:
2026-09-16 10:32:11 +08:00
parent f566573fce
commit aea0e16279
11 changed files with 318 additions and 29 deletions
@@ -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<NotificationPageVo> 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")
@@ -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<NotificationPageVo> 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")
@@ -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;
@@ -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;
@@ -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<String, String> 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<String> scenesOfCategory(String category) {
String wanted = normalize(category);
if (wanted.isEmpty()) {
return List.of();
}
List<String> scenes = new ArrayList<>();
for (Map.Entry<String, String> 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<UserNotificationEntity> 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<UserNotificationEntity> wrapper, String category) {
String wanted = normalize(category);
List<String> scenes = wanted.isEmpty() ? List.of() : scenesOfCategory(wanted);
if (scenes.isEmpty()) {
return;
}
if (CATEGORY_SYSTEM_NOTICE.equals(wanted)) {
List<String> 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();
}
@@ -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<NotificationPageQuery> 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));
}
@@ -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<NotificationPageQuery> 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));
}
@@ -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<LambdaQueryWrapper<UserNotificationEntity>> 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<LambdaQueryWrapper<UserNotificationEntity>> 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<LambdaQueryWrapper<UserNotificationEntity>> listCaptor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(listCaptor.capture());
LambdaQueryWrapper<UserNotificationEntity> 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<LambdaQueryWrapper<UserNotificationEntity>> listCaptor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(listCaptor.capture());
LambdaQueryWrapper<UserNotificationEntity> 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() {
@@ -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
}
@@ -104,6 +104,26 @@
</svg>
</button>
</div>
<div class="bell-category-chips" role="group" aria-label="按类型筛选">
<button
type="button"
class="bell-chip"
:class="{ 'is-active': !category }"
@click="selectCategory('')"
>
全部
</button>
<button
v-for="option in NOTIFICATION_CATEGORY_OPTIONS"
:key="option.value"
type="button"
class="bell-chip"
:class="{ 'is-active': category === option.value }"
@click="selectCategory(option.value)"
>
{{ option.label }}
</button>
</div>
<div class="bell-search-days">
<el-date-picker
:model-value="dateRange"
@@ -154,7 +174,7 @@
</svg>
</span>
<p class="bell-state-text">{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}</p>
<p v-if="hasFilter" class="bell-state-sub">试试调整搜索词或时间范围</p>
<p v-if="hasFilter" class="bell-state-sub">试试调整类型搜索词或时间范围</p>
</div>
<ul v-else class="bell-list">
@@ -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 daterangeYYYY-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;
+26 -4
View File
@@ -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<string, string>()
@@ -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} 缺中文标签`)
}
})