diff --git a/admin-frontend-vue/e2e/notification-panel.spec.ts b/admin-frontend-vue/e2e/notification-panel.spec.ts new file mode 100644 index 00000000..d3c61123 --- /dev/null +++ b/admin-frontend-vue/e2e/notification-panel.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Page } from '@playwright/test' + +// 站内通知铃铛面板验收:关键字/年月日区间搜索、按天分组、分页。 +// 依赖 scripts/mock-admin-server.mjs 的通知夹具(14 条:09-13 六条、09-12 四条、09-10 四条)。 + +async function openBell(page: Page) { + await page.goto('/admin-vue/') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') + await page.locator('.admin-notification-bell .bell-trigger').click() + await expect(page.locator('.bell-panel')).toBeVisible() + await expect(page.locator('.bell-item').first()).toBeVisible() +} + +test('test_notification_panel_day_group_and_pagination', async ({ page }) => { + await openBell(page) + + const panel = page.locator('.bell-panel') + const nextPage = panel.getByRole('button', { name: '下一页' }) + + // 每页 10 条,跨两个日期分组,页脚显示页码与总数 + await expect(page.locator('.bell-item')).toHaveCount(10) + await expect(page.locator('.bell-day-head')).toHaveCount(2) + await expect(page.locator('.bell-page-info')).toHaveText('1 / 2') + await expect(page.locator('.bell-page-total')).toHaveText('共 14 条') + await expect(page.locator('.bell-day-head').first()).toHaveText(/^(今天|昨天|\d{4}年\d{1,2}月\d{1,2}日)$/) + + const page2Request = page.waitForRequest( + (request) => request.url().includes('/api/admin/notifications?') && request.url().includes('page=2'), + ) + await nextPage.click() + await page2Request + + await expect(page.locator('.bell-item')).toHaveCount(4) + await expect(page.locator('.bell-page-info')).toHaveText('2 / 2') + await expect(page.locator('.bell-day-head')).toHaveCount(1) + await expect(nextPage).toBeDisabled() +}) + +test('test_notification_panel_search_by_keyword', async ({ page }) => { + await openBell(page) + + const keywordRequest = page.waitForRequest((request) => + decodeURIComponent(request.url()).includes('keyword=余额不足'), + ) + await page.locator('.bell-search-input').fill('余额不足') + await keywordRequest + + // 关键字命中标题/内容:夹具里 5 条含「余额不足」 + await expect(page.locator('.bell-item')).toHaveCount(5) + await expect(page.locator('.bell-page-total')).toHaveText('共 5 条') + await expect(page.locator('.bell-search-reset')).toBeVisible() + + await page.locator('.bell-search-reset').click() + await expect(page.locator('.bell-item')).toHaveCount(10) + await expect(page.locator('.bell-search-reset')).toBeHidden() +}) + +test('test_notification_panel_search_by_day_range', async ({ page }) => { + await openBell(page) + + const rangeRequest = page.waitForRequest( + (request) => + request.url().includes('startDate=2026-09-12') && request.url().includes('endDate=2026-09-12'), + ) + await page.locator('.bell-date-input').first().fill('2026-09-12') + await page.locator('.bell-date-input').nth(1).fill('2026-09-12') + await rangeRequest + + await expect(page.locator('.bell-item')).toHaveCount(4) + await expect(page.locator('.bell-day-head')).toHaveCount(1) + await expect(page.locator('.bell-page-total')).toHaveText('共 4 条') + await expect(page.locator('.bell-empty')).toHaveCount(0) + + // 起止倒挂自动交换:09-13 起 / 09-10 止 → 实际按 09-10 ~ 09-13 查询(全量 14 条) + await page.locator('.bell-date-input').first().fill('2026-09-13') + await page.locator('.bell-date-input').nth(1).fill('2026-09-10') + await expect(page.locator('.bell-page-total')).toHaveText('共 14 条') + await expect(page.locator('.bell-item')).toHaveCount(10) +}) + +test('test_notification_panel_not_covered_by_page_content', async ({ page }) => { + await openBell(page) + + // 回归:面板挂在顶栏内时曾被页面 el-select 压住(提 z-index 无效),现改为 Teleport 到 body。 + // 这里在面板内取多点做命中测试,最上层元素必须属于面板自身。 + const covered = await page.evaluate(() => { + const panel = document.querySelector('.bell-panel') as HTMLElement + const rect = panel.getBoundingClientRect() + const points: Array<[number, number]> = [ + [rect.left + 30, rect.top + 130], + [rect.left + rect.width / 2, rect.top + rect.height / 2], + [rect.right - 30, rect.bottom - 60], + ] + return points + .map(([x, y]) => document.elementsFromPoint(x, y)[0]) + .filter((el) => !!el && el !== panel && !panel.contains(el)) + .map((el) => `${el!.tagName}.${String((el as HTMLElement).className).slice(0, 40)}`) + }) + expect(covered, '面板被页面元素遮挡').toEqual([]) +}) + +test('test_notification_panel_reference_screenshot', async ({ page }) => { + await openBell(page) + + // 同日多条不得重叠:逐条校验纵向排布严格递增(截图伪影与真实布局问题的分界) + const tops = await page + .locator('.bell-item') + .evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top)) + for (let i = 1; i < tops.length; i += 1) { + expect(tops[i], `第 ${i + 1} 条应排在第 ${i} 条下方`).toBeGreaterThan(tops[i - 1]) + } + + await page.screenshot({ path: 'test-results/notification-panel-admin-page.jpg', animations: 'disabled' }) + await page.locator('.bell-panel').screenshot({ path: 'test-results/notification-panel-admin.jpg', animations: 'disabled' }) + + await page.locator('.bell-search-input').fill('余额不足') + await expect(page.locator('.bell-item')).toHaveCount(5) + await page.locator('.bell-panel').screenshot({ + path: 'test-results/notification-panel-admin-search.jpg', + animations: 'disabled', + }) +}) diff --git a/admin-frontend-vue/scripts/mock-admin-server.mjs b/admin-frontend-vue/scripts/mock-admin-server.mjs index 68e5fb5d..4c4db56f 100644 --- a/admin-frontend-vue/scripts/mock-admin-server.mjs +++ b/admin-frontend-vue/scripts/mock-admin-server.mjs @@ -142,6 +142,57 @@ function json(res, payload, status = 200) { res.end(body) } +/* ===================== 站内通知夹具(铃铛面板:搜索/按天分组/分页验收用) ===================== */ +const NOTIFICATIONS = [ + ...[6, 5, 4, 3, 2, 1].map((seq) => notif(seq, '2026-09-13', 22, seq, seq <= 3)), + ...[4, 3, 2, 1].map((seq) => notif(20 + seq, '2026-09-12', 20, seq, true)), + ...[4, 3, 2, 1].map((seq) => notif(30 + seq, '2026-09-10', 18, seq, true)), +] + +function notif(id, day, hour, seq, read) { + const minutes = String(seq * 7).padStart(2, '0') + return { + id, + scene: id % 3 === 0 ? 'secret_balance' : 'task_failed', + level: id % 3 === 0 ? 'error' : 'warning', + title: id % 3 === 0 ? `用户密钥异常:测试用户${seq}` : `跟价任务失败`, + content: id % 3 === 0 ? `用户 测试用户${seq}(uid=${1100 + seq})的代理设置对应服务商余额不足` : `用户 测试用户${seq} 有 ${seq} 个跟价任务失败`, + read, + readAt: read ? `${day} ${hour}:${minutes}:00` : null, + createdAt: `${day} ${hour}:${minutes}:00`, + } +} + +/** 通知列表:关键字匹配标题/内容,日期按年月日区间(与 Java 侧同语义)。 */ +function notificationPage(searchParams) { + const keyword = (searchParams.get('keyword') || '').trim() + const startDate = searchParams.get('startDate') || '' + const endDate = searchParams.get('endDate') || '' + const page = Math.max(1, Number(searchParams.get('page') || 1)) + const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('pageSize') || 20))) + let rows = NOTIFICATIONS + if (keyword) { + rows = rows.filter((item) => `${item.title}${item.content}`.includes(keyword)) + } + if (startDate) { + rows = rows.filter((item) => item.createdAt.slice(0, 10) >= startDate) + } + if (endDate) { + rows = rows.filter((item) => item.createdAt.slice(0, 10) <= endDate) + } + const start = (page - 1) * pageSize + return { + success: true, + data: { + items: rows.slice(start, start + pageSize), + total: rows.length, + page, + pageSize, + unreadCount: rows.filter((item) => !item.read).length, + }, + } +} + const server = createServer((req, res) => { const url = (req.url || '').split('?')[0] const method = req.method || 'GET' @@ -180,6 +231,18 @@ const server = createServer((req, res) => { data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 }, }) } + if (url === '/api/admin/notifications/summary') { + return json(res, { + success: true, + data: { + unreadCount: NOTIFICATIONS.filter((item) => !item.read).length, + latestId: NOTIFICATIONS.reduce((max, item) => Math.max(max, item.id), 0), + }, + }) + } + if (url === '/api/admin/notifications') { + return json(res, notificationPage(new URL(req.url || '/', 'http://127.0.0.1').searchParams)) + } if (url.startsWith('/api/')) { return json(res, { success: true, data: { items: [], total: 0 } }) } diff --git a/admin-frontend-vue/src/api/notifications.ts b/admin-frontend-vue/src/api/notifications.ts index 0984bdc8..081c31b5 100644 --- a/admin-frontend-vue/src/api/notifications.ts +++ b/admin-frontend-vue/src/api/notifications.ts @@ -32,14 +32,28 @@ export async function fetchNotificationSummary(): Promise(data) } +/** 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 */ +export interface AdminNotificationListParams { + page?: number + pageSize?: number + onlyUnread?: boolean + keyword?: string + startDate?: string + endDate?: string +} + export async function fetchNotificationList( - params: { page?: number; pageSize?: number; onlyUnread?: boolean } = {}, + params: AdminNotificationListParams = {}, ): Promise { + const keyword = (params.keyword ?? '').trim() const { data } = await http.get('/api/admin/notifications', { params: { page: params.page ?? 1, pageSize: params.pageSize ?? 20, onlyUnread: params.onlyUnread ? 'true' : 'false', + keyword: keyword || undefined, + startDate: params.startDate || undefined, + endDate: params.endDate || undefined, }, }) return unwrap(data) diff --git a/admin-frontend-vue/src/components/NotificationBell.vue b/admin-frontend-vue/src/components/NotificationBell.vue index 9568459c..d37e0d4b 100644 --- a/admin-frontend-vue/src/components/NotificationBell.vue +++ b/admin-frontend-vue/src/components/NotificationBell.vue @@ -24,7 +24,16 @@ {{ badgeText }} - + @@ -83,15 +141,18 @@ import { NOTIFICATION_POLL_INTERVAL_MS, formatNotificationTime, formatUnreadBadge, + groupNotificationsByDay, hasNewNotification, readLastNotifiedId, writeLastNotifiedId, } from '@/layout/notification-bell-model' -const PAGE_SIZE = 20 +const PAGE_SIZE = 10 const session = useAdminSessionStore() const rootRef = ref(null) +const panelRef = ref(null) +const panelStyle = ref>({}) const unreadCount = ref(0) const items = ref([]) @@ -101,16 +162,34 @@ const panelOpen = ref(false) const loading = ref(false) const markingAll = ref(false) const loadError = ref('') +const keyword = ref('') +const startDate = ref('') +const endDate = ref('') const badgeText = computed(() => formatUnreadBadge(unreadCount.value)) -const hasMore = computed(() => items.value.length < total.value) +const groupedItems = computed(() => groupNotificationsByDay(items.value)) +const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))) +const hasFilter = computed(() => Boolean(keyword.value.trim() || startDate.value || endDate.value)) let pollTimer: number | null = null +let searchTimer: number | null = null function currentUid(): number | string { return session.user?.id ?? 0 } +/** 起止日期倒挂时自动交换,避免用户只看到「没有符合条件的通知」不知原因。 */ +function normalizedDayRange(): { startDate?: string; endDate?: string } { + let start = startDate.value + let end = endDate.value + if (start && end && start > end) { + const swapped = start + start = end + end = swapped + } + return { startDate: start || undefined, endDate: end || undefined } +} + /** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */ async function refreshSummary() { try { @@ -132,15 +211,21 @@ async function loadPage(targetPage: number) { if (loading.value) return loading.value = true try { - const result = await fetchNotificationList({ page: targetPage, pageSize: PAGE_SIZE }) + const result = await fetchNotificationList({ + page: targetPage, + pageSize: PAGE_SIZE, + keyword: keyword.value, + ...normalizedDayRange(), + }) const list = Array.isArray(result?.items) ? result.items : [] - items.value = targetPage <= 1 ? list : [...items.value, ...list] - total.value = Number(result?.total ?? items.value.length) + items.value = list + total.value = Number(result?.total ?? list.length) unreadCount.value = Number(result?.unreadCount ?? unreadCount.value) page.value = targetPage loadError.value = '' - if (targetPage <= 1) { - const latest = items.value.length ? Number(items.value[0].id) : 0 + // 无筛选时首页就是最新数据,顺带记录「已提醒过的最大 id」;有筛选时最新 id 不代表真实最新 + if (targetPage <= 1 && !hasFilter.value && list.length) { + const latest = Number(list[0].id) if (latest > 0) { writeLastNotifiedId(currentUid(), Math.max(readLastNotifiedId(currentUid()), latest)) } @@ -154,14 +239,50 @@ async function loadPage(targetPage: number) { } } -function loadMore() { - if (!hasMore.value) return - void loadPage(page.value + 1) +function goPage(targetPage: number) { + const safe = Math.min(Math.max(1, targetPage), totalPages.value) + if (safe === page.value || loading.value) return + void loadPage(safe) +} + +/** 搜索/日期变化后回到第一页重新查询。 */ +function reload() { + void loadPage(1) +} + +/** 关键字输入防抖 300ms,避免每敲一个字都打一次接口。 */ +function onKeywordInput() { + if (searchTimer != null) { + window.clearTimeout(searchTimer) + } + searchTimer = window.setTimeout(() => { + searchTimer = null + void loadPage(1) + }, 300) +} + +function resetFilters() { + keyword.value = '' + startDate.value = '' + endDate.value = '' + void loadPage(1) +} + +/** 面板挂到 body 后用 fixed 定位,位置按触发器实时算(右对齐在铃铛下方)。 */ +function syncPanelPosition() { + const trigger = rootRef.value + if (!trigger) return + const rect = trigger.getBoundingClientRect() + panelStyle.value = { + top: `${Math.round(rect.bottom + 10)}px`, + right: `${Math.max(8, Math.round(window.innerWidth - rect.right))}px`, + } } function togglePanel() { panelOpen.value = !panelOpen.value if (panelOpen.value) { + syncPanelPosition() void loadPage(1) } } @@ -198,12 +319,19 @@ function formatTime(value: string | null) { return formatNotificationTime(value) } -/** 点击组件外部关闭面板。 */ +/** 点击组件外部关闭面板(面板已 Teleport 到 body,需连同面板自身一起判断)。 */ function onDocumentMouseDown(event: MouseEvent) { if (!panelOpen.value) return - const root = rootRef.value - if (root && event.target instanceof Node && !root.contains(event.target)) { - panelOpen.value = false + const target = event.target + if (!(target instanceof Node)) return + if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) return + panelOpen.value = false +} + +/** 窗口尺寸变化时重新对齐面板。 */ +function onWindowResize() { + if (panelOpen.value) { + syncPanelPosition() } } @@ -217,6 +345,7 @@ function onVisibilityChange() { onMounted(() => { document.addEventListener('mousedown', onDocumentMouseDown) document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('resize', onWindowResize) void refreshSummary() pollTimer = window.setInterval(() => { if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { @@ -229,10 +358,15 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('mousedown', onDocumentMouseDown) document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('resize', onWindowResize) if (pollTimer != null) { window.clearInterval(pollTimer) pollTimer = null } + if (searchTimer != null) { + window.clearTimeout(searchTimer) + searchTimer = null + } }) @@ -290,13 +424,11 @@ onUnmounted(() => { } .bell-panel { - position: absolute; - top: calc(100% + 10px); - right: 0; + position: fixed; z-index: 3200; width: 380px; max-width: calc(100vw - 32px); - max-height: 460px; + max-height: 520px; display: flex; flex-direction: column; border: 1px solid var(--admin-border, #d8e3ee); @@ -422,29 +554,113 @@ onUnmounted(() => { word-break: break-all; } -.bell-panel-foot { - border-top: 1px solid #edf1f5; - text-align: center; +.bell-search { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid #edf1f5; + background: #fafcfe; } -.bell-more { +.bell-search-input, +.bell-date-input { + height: 28px; + padding: 0 8px; + box-sizing: border-box; + border: 1px solid #d8e3ee; + border-radius: 6px; + background: #ffffff; + color: var(--admin-text, #24384d); + font-size: 12px; + outline: none; +} + +.bell-search-input { width: 100%; - padding: 9px 0; - border: none; - background: transparent; +} + +.bell-search-input:focus, +.bell-date-input:focus { + border-color: var(--admin-primary-strong, #2f5d8b); +} + +.bell-search-days { + display: flex; + align-items: center; + gap: 6px; +} + +.bell-date-input { + flex: 1; + min-width: 0; +} + +.bell-date-sep { + color: #8a99a8; + font-size: 12px; +} + +.bell-search-reset { + flex-shrink: 0; + padding: 0 8px; + height: 28px; + border: 1px solid #d8e3ee; + border-radius: 6px; + background: #ffffff; color: var(--admin-primary-strong, #2f5d8b); font-size: 12px; cursor: pointer; } -.bell-more:disabled { - opacity: 0.55; +.bell-search-reset:hover { + background: #edf5fb; +} + +.bell-day-head { + padding: 6px 14px; + background: #f4f7fa; + color: #7c8b9a; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.bell-panel-foot { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 7px 12px; + border-top: 1px solid #edf1f5; +} + +.bell-page-btn { + padding: 3px 10px; + border: 1px solid #d8e3ee; + border-radius: 6px; + background: #ffffff; + color: var(--admin-primary-strong, #2f5d8b); + font-size: 12px; + cursor: pointer; +} + +.bell-page-btn:hover:not(:disabled) { + background: #edf5fb; +} + +.bell-page-btn:disabled { + opacity: 0.5; cursor: not-allowed; } -.bell-foot-note { - display: block; - padding: 8px 0; +.bell-page-info { + color: var(--admin-text, #24384d); + font-size: 12px; + font-weight: 600; +} + +.bell-page-total { color: #9aa6b3; font-size: 11px; } diff --git a/admin-frontend-vue/src/layout/notification-bell-model.ts b/admin-frontend-vue/src/layout/notification-bell-model.ts index c5dc222d..5981fea1 100644 --- a/admin-frontend-vue/src/layout/notification-bell-model.ts +++ b/admin-frontend-vue/src/layout/notification-bell-model.ts @@ -56,6 +56,64 @@ export function writeLastNotifiedId(uid: string | number | null | undefined, id: } } +/** 通知所属日期键(本地时区 yyyy-MM-dd);时间缺失/非法返回空串。 */ +export function notificationDayKey(value: string | null | undefined): string { + if (!value) { + return '' + } + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + const pad = (input: number) => String(input).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +/** 日期组头文案:今天 / 昨天 / 2026年9月11日;空键兜底「未知时间」。 */ +export function formatDayLabel(day: string, now: Date = new Date()): string { + if (!day) { + return '未知时间' + } + if (day === notificationDayKey(now.toISOString())) { + return '今天' + } + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + if (day === notificationDayKey(yesterday.toISOString())) { + return '昨天' + } + const [year, month, date] = day.split('-').map((part) => Number(part)) + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(date)) { + return day + } + return `${year}年${month}月${date}日` +} + +export interface NotificationDayGroup { + day: string + label: string + items: T[] +} + +/** 按年月日分组(组内保持入参顺序,列表本身已按 id 倒序)。 */ +export function groupNotificationsByDay( + items: T[], + now: Date = new Date(), +): NotificationDayGroup[] { + const groups: NotificationDayGroup[] = [] + const indexByDay = new Map>() + for (const item of items ?? []) { + const day = notificationDayKey(item.createdAt) + let group = indexByDay.get(day) + if (!group) { + group = { day, label: formatDayLabel(day, now), items: [] } + indexByDay.set(day, group) + groups.push(group) + } + group.items.push(item) + } + return groups +} + /** 通知时间展示:今天只显示 HH:mm;昨天显示「昨天 HH:mm」;更早显示 MM-DD HH:mm。 */ export function formatNotificationTime( value: string | null | undefined, diff --git a/admin-frontend-vue/tests/notification-bell.test.ts b/admin-frontend-vue/tests/notification-bell.test.ts index 073ddb28..af04a8ec 100644 --- a/admin-frontend-vue/tests/notification-bell.test.ts +++ b/admin-frontend-vue/tests/notification-bell.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { formatNotificationTime, formatUnreadBadge, + groupNotificationsByDay, hasNewNotification, readLastNotifiedId, writeLastNotifiedId, @@ -80,3 +81,49 @@ test('test_时间展示:今天/昨天/更早', () => { assert.equal(formatNotificationTime(null, now), '') assert.equal(formatNotificationTime('not-a-date', now), '') }) + +test('test_按年月日分组:今天/昨天/更早,组内保持原顺序', () => { + const now = new Date(2026, 8, 13, 15, 30) + const items = [ + { id: 4, createdAt: new Date(2026, 8, 13, 15, 0).toISOString() }, + { id: 3, createdAt: new Date(2026, 8, 13, 9, 0).toISOString() }, + { id: 2, createdAt: new Date(2026, 8, 12, 23, 0).toISOString() }, + { id: 1, createdAt: new Date(2026, 8, 1, 8, 0).toISOString() }, + ] + + const groups = groupNotificationsByDay(items, now) + + assert.equal(groups.length, 3) + assert.deepEqual( + groups.map((group) => [group.day, group.label]), + [ + ['2026-09-13', '今天'], + ['2026-09-12', '昨天'], + ['2026-09-01', '2026年9月1日'], + ], + ) + assert.deepEqual( + groups[0].items.map((item) => item.id), + [4, 3], + '同一天的多条合为一组且保持倒序', + ) +}) + +test('test_按年月日分组:时间缺失/非法归入未知时间组', () => { + const now = new Date(2026, 8, 13, 15, 30) + const groups = groupNotificationsByDay( + [ + { id: 2, createdAt: null }, + { id: 1, createdAt: 'not-a-date' }, + ], + now, + ) + + assert.equal(groups.length, 1) + assert.equal(groups[0].day, '') + assert.equal(groups[0].label, '未知时间') + assert.deepEqual( + groups[0].items.map((item) => item.id), + [2, 1], + ) +}) 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 b17cdbec..edbf8c80 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 @@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.notification.controller; import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery; import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo; import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo; import com.nanri.aiimage.modules.notification.service.NotificationService; @@ -11,6 +12,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -18,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDate; + /** * 后台站内通知(铃铛):超管与管理员共用,按登录管理员维度读写 audience=admin 的通知; * 可见范围(全量 or 分组内成员)在通知生成时已按数据权限过滤。 @@ -39,15 +43,21 @@ public class AdminNotificationController { } @GetMapping - @Operation(summary = "通知分页列表") + @Operation(summary = "通知分页列表", + description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;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, defaultValue = "false") Boolean onlyUnread, + @Parameter(description = "关键字(标题/内容)") @RequestParam(required = false) String keyword, + @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, page, pageSize, Boolean.TRUE.equals(onlyUnread))); + return ApiResponse.success(notificationService.page(userId, NotificationService.AUDIENCE_ADMIN, + NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, 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 c94be8dc..d2175af4 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 @@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.notification.controller; import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery; import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo; import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo; import com.nanri.aiimage.modules.notification.service.NotificationService; @@ -11,6 +12,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -18,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDate; + /** * 桌面端站内通知(铃铛):当前登录用户维度,用户身份一律从 JWT 解析; * 只读写 audience=user 的通知,不感知后台管理员通知。 @@ -39,15 +43,21 @@ public class NotificationController { } @GetMapping - @Operation(summary = "通知分页列表", description = "onlyUnread=true 时只返回未读;附带未读总数。") + @Operation(summary = "通知分页列表", + description = "onlyUnread=true 时只返回未读;keyword 模糊匹配标题/内容;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, defaultValue = "false") Boolean onlyUnread, + @Parameter(description = "关键字(标题/内容)") @RequestParam(required = false) String keyword, + @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, page, pageSize, Boolean.TRUE.equals(onlyUnread))); + return ApiResponse.success(notificationService.page(userId, NotificationService.AUDIENCE_USER, + NotificationPageQuery.of(page, pageSize, onlyUnread, keyword, 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 new file mode 100644 index 00000000..b850d4ce --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/notification/model/dto/NotificationPageQuery.java @@ -0,0 +1,32 @@ +package com.nanri.aiimage.modules.notification.model.dto; + +import lombok.Data; + +import java.time.LocalDate; + +/** + * 通知列表查询条件:分页 + 未读过滤 + 关键字(标题/内容模糊)+ 创建日期区间(年月日闭区间)。 + * 铃铛面板的「按天搜索」与「内容搜索」都走这里,空值表示不限制。 + */ +@Data +public class NotificationPageQuery { + + private Long page; + private Long pageSize; + private Boolean onlyUnread; + private String keyword; + private LocalDate startDate; + private LocalDate endDate; + + public static NotificationPageQuery of(Long page, Long pageSize, Boolean onlyUnread, + String keyword, LocalDate startDate, LocalDate endDate) { + NotificationPageQuery query = new NotificationPageQuery(); + query.setPage(page); + query.setPageSize(pageSize); + query.setOnlyUnread(onlyUnread); + query.setKeyword(keyword); + query.setStartDate(startDate); + query.setEndDate(endDate); + return query; + } +} 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 28a4099a..5b62f46a 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 @@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.notification.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.nanri.aiimage.modules.notification.mapper.UserNotificationMapper; +import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery; import com.nanri.aiimage.modules.notification.model.entity.UserNotificationEntity; import com.nanri.aiimage.modules.notification.model.vo.NotificationItemVo; import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo; @@ -113,16 +114,20 @@ public class NotificationService { return true; } - /** 分页查询(id 倒序);onlyUnread=true 时只返回未读。 */ public NotificationPageVo page(Long userId, String audience, long page, long pageSize, boolean onlyUnread) { - long safePage = page < 1 ? 1L : page; - long safeSize = pageSize < 1 ? 20L : Math.min(pageSize, MAX_PAGE_SIZE); - LambdaQueryWrapper countWrapper = baseWrapper(userId, audience, onlyUnread); + /** 分页查询(id 倒序);onlyUnread=true 时只返回未读,keyword/日期区间为空表示不限制。 */ + public NotificationPageVo page(Long userId, String audience, NotificationPageQuery query) { + NotificationPageQuery safe = query == null ? new NotificationPageQuery() : query; + long safePage = safe.getPage() == null || safe.getPage() < 1 ? 1L : safe.getPage(); + long safeSize = safe.getPageSize() == null || safe.getPageSize() < 1 + ? 20L : Math.min(safe.getPageSize(), MAX_PAGE_SIZE); + boolean onlyUnread = Boolean.TRUE.equals(safe.getOnlyUnread()); + LambdaQueryWrapper countWrapper = baseWrapper(userId, audience, onlyUnread, safe); Long totalValue = userNotificationMapper.selectCount(countWrapper); long total = totalValue == null ? 0L : totalValue; long offset = Math.max(0L, (safePage - 1) * safeSize); List rows = total == 0 ? List.of() - : userNotificationMapper.selectList(baseWrapper(userId, audience, onlyUnread) + : userNotificationMapper.selectList(baseWrapper(userId, audience, onlyUnread, safe) .orderByDesc(UserNotificationEntity::getId) .last("limit " + offset + "," + safeSize)); @@ -136,6 +141,9 @@ 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); return vo; } @@ -207,16 +215,36 @@ public class NotificationService { return deleted; } - private LambdaQueryWrapper baseWrapper(Long userId, String audience, boolean onlyUnread) { + private LambdaQueryWrapper baseWrapper(Long userId, String audience, boolean onlyUnread, + NotificationPageQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(UserNotificationEntity::getUserId, userId) .eq(UserNotificationEntity::getAudience, audience); if (onlyUnread) { wrapper.isNull(UserNotificationEntity::getReadAt); } + applyFilters(wrapper, query); return wrapper; } + /** + * 列表筛选:关键字模糊匹配标题/内容;日期按「年月日」闭区间 + * (起日 00:00 起含、止日次日 00:00 前不含,避免当天 23:59 漏行)。 + */ + private void applyFilters(LambdaQueryWrapper wrapper, NotificationPageQuery query) { + String keyword = normalize(query.getKeyword()); + if (!keyword.isEmpty()) { + wrapper.and(nested -> nested.like(UserNotificationEntity::getTitle, keyword) + .or().like(UserNotificationEntity::getContent, keyword)); + } + if (query.getStartDate() != null) { + wrapper.ge(UserNotificationEntity::getCreatedAt, query.getStartDate().atStartOfDay()); + } + if (query.getEndDate() != null) { + wrapper.lt(UserNotificationEntity::getCreatedAt, query.getEndDate().plusDays(1).atStartOfDay()); + } + } + private boolean existsByDedupeKey(String dedupeKey) { return selectByDedupeKey(dedupeKey) != null; } 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 new file mode 100644 index 00000000..d6788dac --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/AdminNotificationControllerTest.java @@ -0,0 +1,69 @@ +package com.nanri.aiimage.modules.notification.controller; + +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery; +import com.nanri.aiimage.modules.notification.service.NotificationService; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 后台通知可见范围回归:登录管理员只能按自己的 uid 读写 audience=admin 的通知副本。 + * 「超管看全部、管理员只看本组」在生成时按数据权限裁剪(见 NotificationDispatchService), + * 这里守住另一半:任何管理员都不可能通过接口读到别人的副本或桌面端(audience=user)的通知。 + */ +class AdminNotificationControllerTest { + + private final NotificationService notificationService = mock(NotificationService.class); + private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class); + private final HttpServletRequest request = mock(HttpServletRequest.class); + private final AdminNotificationController controller = + new AdminNotificationController(notificationService, adminAuthSupport); + + @Test + void listAndSummaryUseOperatorIdAndAdminAudience() { + 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)); + + verify(notificationService).summary(24L, NotificationService.AUDIENCE_ADMIN); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(NotificationPageQuery.class); + verify(notificationService).page(eq(24L), eq(NotificationService.AUDIENCE_ADMIN), queryCaptor.capture()); + NotificationPageQuery query = queryCaptor.getValue(); + assertThat(query.getPage()).isEqualTo(1L); + assertThat(query.getPageSize()).isEqualTo(20L); + assertThat(query.getOnlyUnread()).isFalse(); + assertThat(query.getKeyword()).isEqualTo("余额"); + assertThat(query.getStartDate()).isEqualTo(LocalDate.of(2026, 9, 10)); + assertThat(query.getEndDate()).isEqualTo(LocalDate.of(2026, 9, 13)); + } + + @Test + void readMarkingStaysWithinOwnAdminAudienceRows() { + when(adminAuthSupport.requireAdmin(request)).thenReturn(admin(1L)); + + controller.markRead(request, 18L); + controller.markAllRead(request); + + verify(notificationService).markRead(1L, NotificationService.AUDIENCE_ADMIN, 18L); + verify(notificationService).markAllRead(1L, NotificationService.AUDIENCE_ADMIN); + } + + private AdminUserEntity admin(Long id) { + AdminUserEntity entity = new AdminUserEntity(); + entity.setId(id); + entity.setUsername("管理员" + id); + entity.setIsAdmin(1); + return entity; + } +} 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 new file mode 100644 index 00000000..6c09f210 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/controller/NotificationControllerTest.java @@ -0,0 +1,67 @@ +package com.nanri.aiimage.modules.notification.controller; + +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery; +import com.nanri.aiimage.modules.notification.service.NotificationService; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 桌面端通知可见范围回归:接收者只能来自 JWT 解析出的本人, + * 且一律按 audience=user 读写 —— 普通用户只能看到自己的通知,读不到后台管理员通知。 + */ +class NotificationControllerTest { + + private final NotificationService notificationService = mock(NotificationService.class); + private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class); + private final HttpServletRequest request = mock(HttpServletRequest.class); + private final NotificationController controller = + new NotificationController(notificationService, adminAuthSupport); + + @Test + void listAndSummaryUseJwtUserAndUserAudience() { + 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)); + + verify(notificationService).summary(1095L, NotificationService.AUDIENCE_USER); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(NotificationPageQuery.class); + verify(notificationService).page(eq(1095L), eq(NotificationService.AUDIENCE_USER), queryCaptor.capture()); + NotificationPageQuery query = queryCaptor.getValue(); + assertThat(query.getPage()).isEqualTo(2L); + assertThat(query.getPageSize()).isEqualTo(20L); + assertThat(query.getOnlyUnread()).isTrue(); + assertThat(query.getKeyword()).isEqualTo("欠费"); + assertThat(query.getStartDate()).isEqualTo(LocalDate.of(2026, 9, 1)); + assertThat(query.getEndDate()).isEqualTo(LocalDate.of(2026, 9, 13)); + } + + @Test + void readMarkingStaysWithinOwnUserAudienceRows() { + when(adminAuthSupport.requireUser(request)).thenReturn(user(1095L)); + + controller.markRead(request, 42L); + controller.markAllRead(request); + + verify(notificationService).markRead(1095L, NotificationService.AUDIENCE_USER, 42L); + verify(notificationService).markAllRead(1095L, NotificationService.AUDIENCE_USER); + } + + private AdminUserEntity user(Long id) { + AdminUserEntity entity = new AdminUserEntity(); + entity.setId(id); + entity.setUsername("用户" + id); + return entity; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchServiceTest.java index f2a65eff..35d04d81 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/notification/service/NotificationDispatchServiceTest.java @@ -14,6 +14,8 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class NotificationDispatchServiceTest { @@ -71,6 +73,58 @@ class NotificationDispatchServiceTest { eq("标题"), eq("内容"), eq("task_failed_admin:20:PRICE_TRACK:2026091310:1")); } + @Test + void pushToAdminsDeliversSuperAndInScopeAdminsWithOwnDedupeKey() { + AdminUserEntity superAdmin = admin(1L, "超管甲"); + AdminUserEntity inScope = admin(2L, "主管乙"); + AdminUserEntity outOfScope = admin(3L, "主管丙"); + when(adminUserMapper.selectList(any())).thenReturn(List.of(superAdmin, inScope, outOfScope)); + when(adminAuthSupport.currentRole(superAdmin)).thenReturn("super_admin"); + when(adminAuthSupport.currentRole(inScope)).thenReturn("admin"); + when(adminAuthSupport.currentRole(outOfScope)).thenReturn("admin"); + when(userDataScopeSupport.resolveVisibleUserIds(2L)).thenReturn(List.of(2L, 20L)); + when(userDataScopeSupport.resolveVisibleUserIds(3L)).thenReturn(List.of(3L, 30L)); + when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(true); + + int pushed = service.pushToAdmins(NotificationService.SCENE_TASK_FAILED, + NotificationService.LEVEL_WARNING, "标题", "内容", + "task_failed_admin:20:PRICE_TRACK:2026091310", 20L); + + // 超管全量 + 本组主管各落一条,各带自己的 dedupe 后缀(已读状态互不影响) + assertThat(pushed).isEqualTo(2); + verify(notificationService).pushOrRefresh(eq(1L), eq(NotificationService.AUDIENCE_ADMIN), + eq(NotificationService.SCENE_TASK_FAILED), eq(NotificationService.LEVEL_WARNING), + eq("标题"), eq("内容"), eq("task_failed_admin:20:PRICE_TRACK:2026091310:1")); + verify(notificationService).pushOrRefresh(eq(2L), eq(NotificationService.AUDIENCE_ADMIN), + eq(NotificationService.SCENE_TASK_FAILED), eq(NotificationService.LEVEL_WARNING), + eq("标题"), eq("内容"), eq("task_failed_admin:20:PRICE_TRACK:2026091310:2")); + // 非本组主管一条都不落 + verify(notificationService, never()).pushOrRefresh(eq(3L), anyString(), anyString(), + anyString(), anyString(), anyString(), anyString()); + } + + @Test + void globalEventReachesEveryAdminByDesign() { + AdminUserEntity superAdmin = admin(1L, "超管甲"); + AdminUserEntity adminA = admin(2L, "主管乙"); + AdminUserEntity adminB = admin(3L, "主管丙"); + when(adminUserMapper.selectList(any())).thenReturn(List.of(superAdmin, adminA, adminB)); + when(adminAuthSupport.currentRole(superAdmin)).thenReturn("super_admin"); + when(adminAuthSupport.currentRole(adminA)).thenReturn("admin"); + when(adminAuthSupport.currentRole(adminB)).thenReturn("admin"); + when(userDataScopeSupport.resolveVisibleUserIds(anyLong())).thenReturn(List.of()); + when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(true); + + // 服务探测失败这类全局事件(subjectUserId=null)按设计推给所有管理员,不受分组限制 + int pushed = service.pushToAdmins(NotificationService.SCENE_SERVICE_DOWN, + NotificationService.LEVEL_ERROR, "品牌检测服务不可用", "探测失败", + "service_down:brand-service:2026091310", null); + + assertThat(pushed).isEqualTo(3); + } + @Test void pushToUserUsesUserAudience() { when(notificationService.pushOrRefresh(anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString())) 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 30a6b8d1..aff98175 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 @@ -1,8 +1,11 @@ package com.nanri.aiimage.modules.notification.service; import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.nanri.aiimage.modules.notification.mapper.UserNotificationMapper; +import com.nanri.aiimage.modules.notification.model.dto.NotificationPageQuery; import com.nanri.aiimage.modules.notification.model.entity.UserNotificationEntity; import com.nanri.aiimage.modules.notification.model.vo.NotificationPageVo; import com.nanri.aiimage.modules.notification.model.vo.NotificationSummaryVo; @@ -11,6 +14,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -128,7 +132,8 @@ class NotificationServiceTest { when(mapper.selectCount(any())).thenReturn(2L, 1L); when(mapper.selectList(any())).thenReturn(List.of(first, second)); - NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_USER, 1, 20, false); + NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_USER, + NotificationPageQuery.of(1L, 20L, false, null, null, null)); assertThat(page.getItems()).hasSize(2); assertThat(page.getItems().get(0).getRead()).isTrue(); @@ -143,7 +148,8 @@ class NotificationServiceTest { void pageClampsPageSizeAndSkipsQueryWhenEmpty() { when(mapper.selectCount(any())).thenReturn(0L, 0L); - NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_ADMIN, 0, 500, true); + NotificationPageVo page = service.page(7L, NotificationService.AUDIENCE_ADMIN, + NotificationPageQuery.of(0L, 500L, true, null, null, null)); assertThat(page.getItems()).isEmpty(); assertThat(page.getPage()).isEqualTo(1L); @@ -186,4 +192,91 @@ class NotificationServiceTest { assertThat(deleted).isEqualTo(2); verify(mapper).delete(any()); } + + /** + * 可见范围回归:读取侧必须按「接收者 user_id + 端 audience」双条件裁剪。 + * 一旦有人漏掉 user_id,管理员/用户就会看到别人的通知;漏掉 audience 则两端通知串台。 + */ + @Test + @SuppressWarnings("unchecked") + void pageScopesQueryByReceiverAndAudience() { + when(mapper.selectCount(any())).thenReturn(2L, 1L); + when(mapper.selectList(any())).thenReturn(List.of()); + + service.page(24L, NotificationService.AUDIENCE_ADMIN, + NotificationPageQuery.of(1L, 20L, true, null, null, null)); + + ArgumentCaptor> countCaptor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper, times(2)).selectCount(countCaptor.capture()); + countCaptor.getAllValues().forEach(wrapper -> assertScopedTo(wrapper, 24L, "admin")); + ArgumentCaptor> listCaptor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectList(listCaptor.capture()); + assertScopedTo(listCaptor.getValue(), 24L, "admin"); + } + + /** 关键字匹配标题/内容;日期按「年月日」闭区间(止日次日 0 点为上界,当天 23:59 不漏)。 */ + @Test + @SuppressWarnings("unchecked") + void pageAppliesKeywordAndDayRangeFilters() { + when(mapper.selectCount(any())).thenReturn(1L, 0L); + 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))); + + ArgumentCaptor> listCaptor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectList(listCaptor.capture()); + LambdaQueryWrapper wrapper = listCaptor.getValue(); + assertThat(wrapper.getSqlSegment()).contains("title").contains("content").contains("created_at"); + assertThat(wrapper.getParamNameValuePairs().values()) + .contains("%余额不足%", LocalDateTime.of(2026, 9, 10, 0, 0), LocalDateTime.of(2026, 9, 14, 0, 0)); + } + + @Test + @SuppressWarnings("unchecked") + void summaryAndUnreadCountScopeByReceiverAndAudience() { + when(mapper.selectCount(any())).thenReturn(3L); + when(mapper.selectOne(any())).thenReturn(null); + + service.summary(1095L, NotificationService.AUDIENCE_USER); + service.unreadCount(1095L, NotificationService.AUDIENCE_USER); + + ArgumentCaptor> countCaptor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper, times(2)).selectCount(countCaptor.capture()); + countCaptor.getAllValues().forEach(wrapper -> assertScopedTo(wrapper, 1095L, "user")); + ArgumentCaptor> oneCaptor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectOne(oneCaptor.capture()); + assertScopedTo(oneCaptor.getValue(), 1095L, "user"); + } + + @Test + @SuppressWarnings("unchecked") + void markReadCannotTouchOtherReceiversOrOtherAudience() { + when(mapper.update(any(), any())).thenReturn(1); + + service.markRead(24L, NotificationService.AUDIENCE_ADMIN, 42L); + service.markAllRead(24L, NotificationService.AUDIENCE_ADMIN); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(mapper, times(2)).update(any(), captor.capture()); + captor.getAllValues().forEach(wrapper -> assertScopedTo(wrapper, 24L, "admin")); + // 单条已读还必须锁定通知 id,避免参数错位时误标他人的行 + assertThat(captor.getAllValues().get(0).getSqlSegment()).contains("id ="); + } + + private void assertScopedTo(LambdaQueryWrapper wrapper, long userId, String audience) { + assertThat(wrapper.getSqlSegment()).contains("user_id").contains("audience"); + assertThat(wrapper.getParamNameValuePairs().values()).contains(userId, audience); + } + + private void assertScopedTo(LambdaUpdateWrapper wrapper, long userId, String audience) { + assertThat(wrapper.getSqlSegment()).contains("user_id").contains("audience"); + assertThat(wrapper.getParamNameValuePairs().values()).contains(userId, audience); + } } diff --git a/frontend-vue/src/shared/api/types/modules/notification.ts b/frontend-vue/src/shared/api/types/modules/notification.ts index fc1a77c4..859dac50 100644 --- a/frontend-vue/src/shared/api/types/modules/notification.ts +++ b/frontend-vue/src/shared/api/types/modules/notification.ts @@ -38,14 +38,45 @@ export function fetchNotificationSummary() { ) } -export function fetchNotificationList(params: { page?: number; pageSize?: number; onlyUnread?: boolean } = {}) { +/** 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 */ +export interface NotificationListParams { + page?: number + pageSize?: number + onlyUnread?: boolean + keyword?: string + startDate?: string + endDate?: string +} + +/** + * 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 + * 空关键字/日期一律不下发(空串会让后端日期绑定失败返回 400)。 + */ +export function buildNotificationListQuery( + params: NotificationListParams = {}, +): Record { + const query: Record = { + page: params.page ?? 1, + pageSize: params.pageSize ?? 20, + onlyUnread: params.onlyUnread ? 'true' : 'false', + } + const keyword = (params.keyword ?? '').trim() + if (keyword) { + query.keyword = keyword + } + if (params.startDate) { + query.startDate = params.startDate + } + if (params.endDate) { + query.endDate = params.endDate + } + return query +} + +export function fetchNotificationList(params: NotificationListParams = {}) { return unwrapJavaResponse( get>( - buildJavaUrl(API_ENDPOINTS.notification.list, { - page: params.page ?? 1, - pageSize: params.pageSize ?? 20, - onlyUnread: params.onlyUnread ? 'true' : 'false', - }), + buildJavaUrl(API_ENDPOINTS.notification.list, buildNotificationListQuery(params)), ), ) } diff --git a/frontend-vue/src/shared/components/NotificationBell.vue b/frontend-vue/src/shared/components/NotificationBell.vue index 76fd4e33..19187ef5 100644 --- a/frontend-vue/src/shared/components/NotificationBell.vue +++ b/frontend-vue/src/shared/components/NotificationBell.vue @@ -24,7 +24,17 @@ {{ badgeText }} - + @@ -83,12 +144,13 @@ import { currentNotificationUid, formatNotificationTime, formatUnreadBadge, + groupNotificationsByDay, hasNewNotification, readLastNotifiedId, writeLastNotifiedId, } from '@/shared/utils/notification-bell.ts' -const PAGE_SIZE = 20 +const PAGE_SIZE = 10 /** 顶栏主题:工具页/配置页深色(dark),桌面入口首页浅色(light)。 */ const props = withDefaults(defineProps<{ theme?: 'dark' | 'light' }>(), { @@ -97,6 +159,8 @@ const props = withDefaults(defineProps<{ theme?: 'dark' | 'light' }>(), { const theme = computed(() => props.theme) const rootRef = ref(null) +const panelRef = ref(null) +const panelStyle = ref>({}) const uid = currentNotificationUid() const unreadCount = ref(0) @@ -107,11 +171,29 @@ const panelOpen = ref(false) const loading = ref(false) const markingAll = ref(false) const loadError = ref('') +const keyword = ref('') +const startDate = ref('') +const endDate = ref('') const badgeText = computed(() => formatUnreadBadge(unreadCount.value)) -const hasMore = computed(() => items.value.length < total.value) +const groupedItems = computed(() => groupNotificationsByDay(items.value)) +const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))) +const hasFilter = computed(() => Boolean(keyword.value.trim() || startDate.value || endDate.value)) let pollTimer: number | null = null +let searchTimer: number | null = null + +/** 起止日期倒挂时自动交换,避免用户只看到「没有符合条件的通知」不知原因。 */ +function normalizedDayRange(): { startDate?: string; endDate?: string } { + let start = startDate.value + let end = endDate.value + if (start && end && start > end) { + const swapped = start + start = end + end = swapped + } + return { startDate: start || undefined, endDate: end || undefined } +} /** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */ async function refreshSummary() { @@ -134,15 +216,21 @@ async function loadPage(targetPage: number) { if (loading.value) return loading.value = true try { - const result = await fetchNotificationList({ page: targetPage, pageSize: PAGE_SIZE }) + const result = await fetchNotificationList({ + page: targetPage, + pageSize: PAGE_SIZE, + keyword: keyword.value, + ...normalizedDayRange(), + }) const list = Array.isArray(result?.items) ? result.items : [] - items.value = targetPage <= 1 ? list : [...items.value, ...list] - total.value = Number(result?.total ?? items.value.length) + items.value = list + total.value = Number(result?.total ?? list.length) unreadCount.value = Number(result?.unreadCount ?? unreadCount.value) page.value = targetPage loadError.value = '' - if (targetPage <= 1) { - const latest = items.value.length ? Number(items.value[0].id) : 0 + // 无筛选时首页就是最新数据,顺带记录「已提醒过的最大 id」;有筛选时最新 id 不代表真实最新 + if (targetPage <= 1 && !hasFilter.value && list.length) { + const latest = Number(list[0].id) if (latest > 0) { writeLastNotifiedId(uid, Math.max(readLastNotifiedId(uid), latest)) } @@ -156,14 +244,50 @@ async function loadPage(targetPage: number) { } } -function loadMore() { - if (!hasMore.value) return - void loadPage(page.value + 1) +function goPage(targetPage: number) { + const safe = Math.min(Math.max(1, targetPage), totalPages.value) + if (safe === page.value || loading.value) return + void loadPage(safe) +} + +/** 搜索/日期变化后回到第一页重新查询。 */ +function reload() { + void loadPage(1) +} + +/** 关键字输入防抖 300ms,避免每敲一个字都打一次接口。 */ +function onKeywordInput() { + if (searchTimer != null) { + window.clearTimeout(searchTimer) + } + searchTimer = window.setTimeout(() => { + searchTimer = null + void loadPage(1) + }, 300) +} + +function resetFilters() { + keyword.value = '' + startDate.value = '' + endDate.value = '' + void loadPage(1) +} + +/** 面板挂到 body 后用 fixed 定位,位置按触发器实时算(右对齐在铃铛下方)。 */ +function syncPanelPosition() { + const trigger = rootRef.value + if (!trigger) return + const rect = trigger.getBoundingClientRect() + panelStyle.value = { + top: `${Math.round(rect.bottom + 10)}px`, + right: `${Math.max(8, Math.round(window.innerWidth - rect.right))}px`, + } } function togglePanel() { panelOpen.value = !panelOpen.value if (panelOpen.value) { + syncPanelPosition() void loadPage(1) } } @@ -200,12 +324,19 @@ function formatTime(value: string | null) { return formatNotificationTime(value) } -/** 点击组件外部关闭面板。 */ +/** 点击组件外部关闭面板(面板已 Teleport 到 body,需连同面板自身一起判断)。 */ function onDocumentMouseDown(event: MouseEvent) { if (!panelOpen.value) return - const root = rootRef.value - if (root && event.target instanceof Node && !root.contains(event.target)) { - panelOpen.value = false + const target = event.target + if (!(target instanceof Node)) return + if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) return + panelOpen.value = false +} + +/** 窗口尺寸变化时重新对齐面板。 */ +function onWindowResize() { + if (panelOpen.value) { + syncPanelPosition() } } @@ -219,6 +350,7 @@ function onVisibilityChange() { onMounted(() => { document.addEventListener('mousedown', onDocumentMouseDown) document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('resize', onWindowResize) void refreshSummary() pollTimer = window.setInterval(() => { if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { @@ -231,10 +363,15 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('mousedown', onDocumentMouseDown) document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('resize', onWindowResize) if (pollTimer != null) { window.clearInterval(pollTimer) pollTimer = null } + if (searchTimer != null) { + window.clearTimeout(searchTimer) + searchTimer = null + } }) @@ -291,13 +428,11 @@ onUnmounted(() => { } .bell-panel { - position: absolute; - top: calc(100% + 10px); - right: 0; + position: fixed; z-index: 3200; width: 360px; max-width: calc(100vw - 32px); - max-height: 440px; + max-height: 520px; display: flex; flex-direction: column; border: 1px solid #2c3540; @@ -424,28 +559,113 @@ onUnmounted(() => { } .bell-panel-foot { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 7px 12px; border-top: 1px solid #262d35; - text-align: center; } -.bell-more { +.bell-search { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid #262d35; + background: #141920; +} + +.bell-search-input, +.bell-date-input { + height: 28px; + padding: 0 8px; + box-sizing: border-box; + border: 1px solid #2c3540; + border-radius: 6px; + background: #1b2027; + color: #dce6f0; + font-size: 12px; + outline: none; + color-scheme: dark; +} + +.bell-search-input { width: 100%; - padding: 9px 0; - border: none; - background: transparent; +} + +.bell-search-input:focus, +.bell-date-input:focus { + border-color: #4a86c7; +} + +.bell-search-days { + display: flex; + align-items: center; + gap: 6px; +} + +.bell-date-input { + flex: 1; + min-width: 0; +} + +.bell-date-sep { + color: #6f7a86; + font-size: 12px; +} + +.bell-search-reset { + flex-shrink: 0; + height: 28px; + padding: 0 8px; + border: 1px solid #2c3540; + border-radius: 6px; + background: #1b2027; color: #8dc4ff; font-size: 12px; cursor: pointer; } -.bell-more:disabled { - opacity: 0.55; +.bell-search-reset:hover { + background: #212832; +} + +.bell-day-head { + padding: 6px 14px; + background: #1b232e; + color: #8b97a4; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.bell-page-btn { + padding: 3px 10px; + border: 1px solid #2c3540; + border-radius: 6px; + background: #1b2027; + color: #8dc4ff; + font-size: 12px; + cursor: pointer; +} + +.bell-page-btn:hover:not(:disabled) { + background: #212832; +} + +.bell-page-btn:disabled { + opacity: 0.5; cursor: not-allowed; } -.bell-foot-note { - display: block; - padding: 8px 0; +.bell-page-info { + color: #dce6f0; + font-size: 12px; + font-weight: 600; +} + +.bell-page-total { color: #6f7a86; font-size: 11px; } @@ -461,73 +681,97 @@ onUnmounted(() => { color: #333; } -.notification-bell--light .bell-panel { +.bell-panel--light { border-color: #e2e8f0; background: #ffffff; box-shadow: 0 18px 48px rgba(31, 45, 61, 0.18); } -.notification-bell--light .bell-panel-head { +.bell-panel--light .bell-panel-head { border-bottom-color: #edf1f5; } -.notification-bell--light .bell-panel-title { +.bell-panel--light .bell-panel-title { color: #333; } -.notification-bell--light .bell-read-all { +.bell-panel--light .bell-read-all { color: #4c5bd4; } -.notification-bell--light .bell-empty { +.bell-panel--light .bell-empty { color: #999; } -.notification-bell--light .bell-item { +.bell-panel--light .bell-item { border-bottom-color: #f0f3f7; } -.notification-bell--light .bell-item:hover { +.bell-panel--light .bell-item:hover { background: #f7f9fc; } -.notification-bell--light .bell-item--unread { +.bell-panel--light .bell-item--unread { background: #f2f6ff; } -.notification-bell--light .bell-item-title { +.bell-panel--light .bell-item-title { color: #333; } -.notification-bell--light .bell-item--error .bell-item-title { +.bell-panel--light .bell-item--error .bell-item-title { color: #c0392b; } -.notification-bell--light .bell-item--warning .bell-item-title { +.bell-panel--light .bell-item--warning .bell-item-title { color: #a8793e; } -.notification-bell--light .bell-item--info .bell-item-title { +.bell-panel--light .bell-item--info .bell-item-title { color: #4c5bd4; } -.notification-bell--light .bell-item-time { +.bell-panel--light .bell-item-time { color: #9aa6b3; } -.notification-bell--light .bell-item-content { +.bell-panel--light .bell-item-content { color: #667085; } -.notification-bell--light .bell-panel-foot { +.bell-panel--light .bell-search { + background: #f8fafc; + border-bottom-color: #edf1f5; +} + +.bell-panel--light .bell-search-input, +.bell-panel--light .bell-date-input { + border-color: #e2e8f0; + background: #ffffff; + color: #333; + color-scheme: light; +} + +.bell-panel--light .bell-day-head { + background: #f4f7fa; + color: #7c8b9a; +} + +.bell-panel--light .bell-panel-foot { border-top-color: #edf1f5; } -.notification-bell--light .bell-more { +.bell-panel--light .bell-page-btn { + border-color: #e2e8f0; + background: #ffffff; color: #4c5bd4; } -.notification-bell--light .bell-foot-note { +.bell-panel--light .bell-page-info { + color: #333; +} + +.bell-panel--light .bell-page-total { color: #9aa6b3; } diff --git a/frontend-vue/src/shared/utils/notification-bell.ts b/frontend-vue/src/shared/utils/notification-bell.ts index e3ede37b..65104708 100644 --- a/frontend-vue/src/shared/utils/notification-bell.ts +++ b/frontend-vue/src/shared/utils/notification-bell.ts @@ -64,6 +64,64 @@ export function currentNotificationUid(): string { } } +/** 通知所属日期键(本地时区 yyyy-MM-dd);时间缺失/非法返回空串。 */ +export function notificationDayKey(value: string | null | undefined): string { + if (!value) { + return '' + } + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + const pad = (input: number) => String(input).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +/** 日期组头文案:今天 / 昨天 / 2026年9月11日;空键兜底「未知时间」。 */ +export function formatDayLabel(day: string, now: Date = new Date()): string { + if (!day) { + return '未知时间' + } + if (day === notificationDayKey(now.toISOString())) { + return '今天' + } + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + if (day === notificationDayKey(yesterday.toISOString())) { + return '昨天' + } + const [year, month, date] = day.split('-').map((part) => Number(part)) + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(date)) { + return day + } + return `${year}年${month}月${date}日` +} + +export interface NotificationDayGroup { + day: string + label: string + items: T[] +} + +/** 按年月日分组(组内保持入参顺序,列表本身已按 id 倒序)。 */ +export function groupNotificationsByDay( + items: T[], + now: Date = new Date(), +): NotificationDayGroup[] { + const groups: NotificationDayGroup[] = [] + const indexByDay = new Map>() + for (const item of items ?? []) { + const day = notificationDayKey(item.createdAt) + let group = indexByDay.get(day) + if (!group) { + group = { day, label: formatDayLabel(day, now), items: [] } + indexByDay.set(day, group) + groups.push(group) + } + group.items.push(item) + } + return groups +} + /** 通知时间展示:今天只显示 HH:mm;昨天显示「昨天 HH:mm」;更早显示 MM-DD HH:mm。 */ export function formatNotificationTime( value: string | null | undefined, diff --git a/frontend-vue/tests/notification-bell.test.ts b/frontend-vue/tests/notification-bell.test.ts index 40a7d07c..c111aae3 100644 --- a/frontend-vue/tests/notification-bell.test.ts +++ b/frontend-vue/tests/notification-bell.test.ts @@ -3,10 +3,12 @@ import assert from 'node:assert/strict' import { formatNotificationTime, formatUnreadBadge, + groupNotificationsByDay, hasNewNotification, readLastNotifiedId, writeLastNotifiedId, } from '../src/shared/utils/notification-bell.ts' +import { buildNotificationListQuery } from '../src/shared/api/types/modules/notification.ts' function createStorage() { const store = new Map() @@ -84,3 +86,75 @@ test('formatNotificationTime 今天/昨天/更早展示', () => { assert.equal(formatNotificationTime(null, now), '') assert.equal(formatNotificationTime('not-a-date', now), '') }) + +test('groupNotificationsByDay 按年月日分组:今天/昨天/更早,组内保持原顺序', () => { + const now = new Date(2026, 8, 13, 15, 30) + const items = [ + { id: 4, createdAt: new Date(2026, 8, 13, 15, 0).toISOString() }, + { id: 3, createdAt: new Date(2026, 8, 13, 9, 0).toISOString() }, + { id: 2, createdAt: new Date(2026, 8, 12, 23, 0).toISOString() }, + { id: 1, createdAt: new Date(2026, 8, 1, 8, 0).toISOString() }, + ] + + const groups = groupNotificationsByDay(items, now) + + assert.equal(groups.length, 3) + assert.deepEqual( + groups.map((group) => [group.day, group.label]), + [ + ['2026-09-13', '今天'], + ['2026-09-12', '昨天'], + ['2026-09-01', '2026年9月1日'], + ], + ) + assert.deepEqual( + groups[0].items.map((item) => item.id), + [4, 3], + '同一天的多条合为一组且保持倒序', + ) +}) + +test('groupNotificationsByDay 时间缺失/非法归入未知时间组', () => { + const now = new Date(2026, 8, 13, 15, 30) + const groups = groupNotificationsByDay( + [ + { id: 2, createdAt: null }, + { id: 1, createdAt: 'not-a-date' }, + ], + now, + ) + + assert.equal(groups.length, 1) + assert.equal(groups[0].day, '') + assert.equal(groups[0].label, '未知时间') + assert.deepEqual( + groups[0].items.map((item) => item.id), + [2, 1], + ) +}) + +test('buildNotificationListQuery:关键字去空白、空条件不下发、日期原样透传', () => { + assert.deepEqual(buildNotificationListQuery(), { page: 1, pageSize: 20, onlyUnread: 'false' }) + + const query = buildNotificationListQuery({ + page: 2, + pageSize: 10, + keyword: ' 余额不足 ', + startDate: '2026-09-10', + endDate: '2026-09-13', + }) + assert.deepEqual(query, { + page: 2, + pageSize: 10, + onlyUnread: 'false', + keyword: '余额不足', + startDate: '2026-09-10', + endDate: '2026-09-13', + }) + + // 面板清空筛选后不能带空串日期(后端日期绑定会 400) + const cleared = buildNotificationListQuery({ keyword: ' ', startDate: '', endDate: '' }) + assert.equal('keyword' in cleared, false) + assert.equal('startDate' in cleared, false) + assert.equal('endDate' in cleared, false) +})