feat(站内通知): 铃铛支持按类型大类筛选(系统异常/任务异常/配置异常/系统通知)
原来铃铛只能按关键字和日期筛,任务失败、密钥欠费、麦象异常全混在一起, 用户没法只看自己关心的那类。 后端 NotificationService 增加 scene→大类映射(未知 scene 归「系统通知」兜底), 列表接口新增 category 参数,落到 SQL 是 scene IN (...);「系统通知」作兜底类 还要纳入未登记的 scene,故表达为「= system 或 不在其它三类里」。列表项返回 category,前端不必各自维护一份 scene 映射。前后端两个通知接口同步加参数。 前端铃铛筛选区新增一行类型 chip(全部 + 四类),与关键字、日期一起参与 hasFilter 与重置;空分类不下发,避免后端查询落空。
This commit is contained in:
@@ -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 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;
|
||||
|
||||
@@ -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} 缺中文标签`)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user