feat(站内通知): 铃铛面板支持时间/内容搜索、按天分组与分页

- 列表接口加 keyword(标题/内容模糊)与 startDate/endDate(年月日闭区间)参数,
  两端控制器透传,服务端补筛选日志
- 两端铃铛面板:搜索框(防抖 300ms)+ 日期区间 + 按年月日分组 + 翻页,
  面板改 Teleport 到 body(挂在顶栏时会被页面 el-select 压住,提 z-index 无效)
- 固化可见范围回归测试:超管全量/管理员只看本组/普通用户只看自己,
  含读写两侧的 user_id+audience 裁剪断言与两端控制器身份来源断言
This commit is contained in:
2026-09-13 23:59:06 +08:00
parent 9ffd68bae5
commit ff1ffbbfa3
18 changed files with 1426 additions and 136 deletions
@@ -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 } })
}
+15 -1
View File
@@ -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],
)
})