feat(站内通知): 铃铛面板支持时间/内容搜索、按天分组与分页
- 列表接口加 keyword(标题/内容模糊)与 startDate/endDate(年月日闭区间)参数, 两端控制器透传,服务端补筛选日志 - 两端铃铛面板:搜索框(防抖 300ms)+ 日期区间 + 按年月日分组 + 翻页, 面板改 Teleport 到 body(挂在顶栏时会被页面 el-select 压住,提 z-index 无效) - 固化可见范围回归测试:超管全量/管理员只看本组/普通用户只看自己, 含读写两侧的 user_id+audience 裁剪断言与两端控制器身份来源断言
This commit is contained in:
@@ -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',
|
||||
})
|
||||
})
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -32,14 +32,28 @@ export async function fetchNotificationSummary(): Promise<AdminNotificationSumma
|
||||
return unwrap<AdminNotificationSummary>(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<AdminNotificationPage> {
|
||||
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<AdminNotificationPage>(data)
|
||||
|
||||
@@ -24,7 +24,16 @@
|
||||
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="panelOpen" class="bell-panel" role="dialog" aria-label="通知列表">
|
||||
<!-- 面板挂到 body 上(fixed 定位):顶栏内绝对定位时会被列表页 el-select 等页面内容压住,提 z-index 无效 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="panelOpen"
|
||||
ref="panelRef"
|
||||
class="bell-panel"
|
||||
:style="panelStyle"
|
||||
role="dialog"
|
||||
aria-label="通知列表"
|
||||
>
|
||||
<div class="bell-panel-head">
|
||||
<span class="bell-panel-title">通知</span>
|
||||
<button
|
||||
@@ -38,32 +47,81 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bell-search">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
class="bell-search-input"
|
||||
placeholder="搜索标题或内容"
|
||||
aria-label="搜索通知"
|
||||
@input="onKeywordInput"
|
||||
/>
|
||||
<div class="bell-search-days">
|
||||
<input
|
||||
v-model="startDate"
|
||||
type="date"
|
||||
class="bell-date-input"
|
||||
aria-label="起始日期"
|
||||
@change="reload"
|
||||
/>
|
||||
<span class="bell-date-sep">至</span>
|
||||
<input
|
||||
v-model="endDate"
|
||||
type="date"
|
||||
class="bell-date-input"
|
||||
aria-label="结束日期"
|
||||
@change="reload"
|
||||
/>
|
||||
<button v-if="hasFilter" type="button" class="bell-search-reset" @click="resetFilters">
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
|
||||
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
|
||||
<div v-else-if="!items.length" class="bell-empty">暂无通知</div>
|
||||
<div v-else-if="!items.length" class="bell-empty">{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}</div>
|
||||
<ul v-else class="bell-list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="bell-item"
|
||||
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||
@click="onItemClick(item)"
|
||||
>
|
||||
<div class="bell-item-head">
|
||||
<span class="bell-item-title">{{ item.title }}</span>
|
||||
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="bell-item-content">{{ item.content }}</div>
|
||||
</li>
|
||||
<template v-for="group in groupedItems" :key="group.day || 'unknown'">
|
||||
<li class="bell-day-head">{{ group.label }}</li>
|
||||
<li
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="bell-item"
|
||||
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||
@click="onItemClick(item)"
|
||||
>
|
||||
<div class="bell-item-head">
|
||||
<span class="bell-item-title">{{ item.title }}</span>
|
||||
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="bell-item-content">{{ item.content }}</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<div class="bell-panel-foot">
|
||||
<button v-if="hasMore" type="button" class="bell-more" :disabled="loading" @click="loadMore">
|
||||
{{ loading ? '加载中...' : '加载更多' }}
|
||||
<button
|
||||
type="button"
|
||||
class="bell-page-btn"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goPage(page - 1)"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span v-else-if="items.length" class="bell-foot-note">已显示全部</span>
|
||||
<span class="bell-page-info">{{ page }} / {{ totalPages }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="bell-page-btn"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goPage(page + 1)"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<span class="bell-page-total">共 {{ total }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<HTMLElement | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const panelStyle = ref<Record<string, string>>({})
|
||||
|
||||
const unreadCount = ref(0)
|
||||
const items = ref<AdminNotificationItem[]>([])
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<T> {
|
||||
day: string
|
||||
label: string
|
||||
items: T[]
|
||||
}
|
||||
|
||||
/** 按年月日分组(组内保持入参顺序,列表本身已按 id 倒序)。 */
|
||||
export function groupNotificationsByDay<T extends { createdAt: string | null }>(
|
||||
items: T[],
|
||||
now: Date = new Date(),
|
||||
): NotificationDayGroup<T>[] {
|
||||
const groups: NotificationDayGroup<T>[] = []
|
||||
const indexByDay = new Map<string, NotificationDayGroup<T>>()
|
||||
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,
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
})
|
||||
|
||||
+14
-4
@@ -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<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, 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")
|
||||
|
||||
+14
-4
@@ -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<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, 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")
|
||||
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
-6
@@ -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<UserNotificationEntity> 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<UserNotificationEntity> 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<UserNotificationEntity> 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<UserNotificationEntity> baseWrapper(Long userId, String audience, boolean onlyUnread) {
|
||||
private LambdaQueryWrapper<UserNotificationEntity> baseWrapper(Long userId, String audience, boolean onlyUnread,
|
||||
NotificationPageQuery query) {
|
||||
LambdaQueryWrapper<UserNotificationEntity> wrapper = new LambdaQueryWrapper<UserNotificationEntity>()
|
||||
.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<UserNotificationEntity> 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;
|
||||
}
|
||||
|
||||
+69
@@ -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<NotificationPageQuery> 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;
|
||||
}
|
||||
}
|
||||
+67
@@ -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<NotificationPageQuery> 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;
|
||||
}
|
||||
}
|
||||
+54
@@ -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()))
|
||||
|
||||
+95
-2
@@ -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<LambdaQueryWrapper<UserNotificationEntity>> countCaptor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper, times(2)).selectCount(countCaptor.capture());
|
||||
countCaptor.getAllValues().forEach(wrapper -> assertScopedTo(wrapper, 24L, "admin"));
|
||||
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> 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<LambdaQueryWrapper<UserNotificationEntity>> listCaptor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectList(listCaptor.capture());
|
||||
LambdaQueryWrapper<UserNotificationEntity> 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<LambdaQueryWrapper<UserNotificationEntity>> countCaptor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper, times(2)).selectCount(countCaptor.capture());
|
||||
countCaptor.getAllValues().forEach(wrapper -> assertScopedTo(wrapper, 1095L, "user"));
|
||||
ArgumentCaptor<LambdaQueryWrapper<UserNotificationEntity>> 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<LambdaUpdateWrapper<UserNotificationEntity>> 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<UserNotificationEntity> wrapper, long userId, String audience) {
|
||||
assertThat(wrapper.getSqlSegment()).contains("user_id").contains("audience");
|
||||
assertThat(wrapper.getParamNameValuePairs().values()).contains(userId, audience);
|
||||
}
|
||||
|
||||
private void assertScopedTo(LambdaUpdateWrapper<UserNotificationEntity> wrapper, long userId, String audience) {
|
||||
assertThat(wrapper.getSqlSegment()).contains("user_id").contains("audience");
|
||||
assertThat(wrapper.getParamNameValuePairs().values()).contains(userId, audience);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string | number> {
|
||||
const query: Record<string, string | number> = {
|
||||
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<JavaApiResponse<NotificationPage>>(
|
||||
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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,17 @@
|
||||
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="panelOpen" class="bell-panel" role="dialog" aria-label="通知列表">
|
||||
<!-- 面板挂到 body 上(fixed 定位):顶栏内绝对定位时会被页面内容压住(如 el-select),提 z-index 无效 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="panelOpen"
|
||||
ref="panelRef"
|
||||
class="bell-panel"
|
||||
:class="`bell-panel--${theme}`"
|
||||
:style="panelStyle"
|
||||
role="dialog"
|
||||
aria-label="通知列表"
|
||||
>
|
||||
<div class="bell-panel-head">
|
||||
<span class="bell-panel-title">通知</span>
|
||||
<button
|
||||
@@ -38,32 +48,83 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bell-search">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
class="bell-search-input"
|
||||
placeholder="搜索标题或内容"
|
||||
aria-label="搜索通知"
|
||||
@input="onKeywordInput"
|
||||
/>
|
||||
<div class="bell-search-days">
|
||||
<input
|
||||
v-model="startDate"
|
||||
type="date"
|
||||
class="bell-date-input"
|
||||
aria-label="起始日期"
|
||||
@change="reload"
|
||||
/>
|
||||
<span class="bell-date-sep">至</span>
|
||||
<input
|
||||
v-model="endDate"
|
||||
type="date"
|
||||
class="bell-date-input"
|
||||
aria-label="结束日期"
|
||||
@change="reload"
|
||||
/>
|
||||
<button v-if="hasFilter" type="button" class="bell-search-reset" @click="resetFilters">
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
|
||||
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
|
||||
<div v-else-if="!items.length" class="bell-empty">暂无通知</div>
|
||||
<div v-else-if="!items.length" class="bell-empty">
|
||||
{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}
|
||||
</div>
|
||||
<ul v-else class="bell-list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="bell-item"
|
||||
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||
@click="onItemClick(item)"
|
||||
>
|
||||
<div class="bell-item-head">
|
||||
<span class="bell-item-title">{{ item.title }}</span>
|
||||
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="bell-item-content">{{ item.content }}</div>
|
||||
</li>
|
||||
<template v-for="group in groupedItems" :key="group.day || 'unknown'">
|
||||
<li class="bell-day-head">{{ group.label }}</li>
|
||||
<li
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="bell-item"
|
||||
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||
@click="onItemClick(item)"
|
||||
>
|
||||
<div class="bell-item-head">
|
||||
<span class="bell-item-title">{{ item.title }}</span>
|
||||
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="bell-item-content">{{ item.content }}</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<div class="bell-panel-foot">
|
||||
<button v-if="hasMore" type="button" class="bell-more" :disabled="loading" @click="loadMore">
|
||||
{{ loading ? '加载中...' : '加载更多' }}
|
||||
<button
|
||||
type="button"
|
||||
class="bell-page-btn"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goPage(page - 1)"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span v-else-if="items.length" class="bell-foot-note">已显示全部</span>
|
||||
<span class="bell-page-info">{{ page }} / {{ totalPages }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="bell-page-btn"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goPage(page + 1)"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<span class="bell-page-total">共 {{ total }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<HTMLElement | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const panelStyle = ref<Record<string, string>>({})
|
||||
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
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<T> {
|
||||
day: string
|
||||
label: string
|
||||
items: T[]
|
||||
}
|
||||
|
||||
/** 按年月日分组(组内保持入参顺序,列表本身已按 id 倒序)。 */
|
||||
export function groupNotificationsByDay<T extends { createdAt: string | null }>(
|
||||
items: T[],
|
||||
now: Date = new Date(),
|
||||
): NotificationDayGroup<T>[] {
|
||||
const groups: NotificationDayGroup<T>[] = []
|
||||
const indexByDay = new Map<string, NotificationDayGroup<T>>()
|
||||
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,
|
||||
|
||||
@@ -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<string, string>()
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user