style(web): 通知中心列表行布局重构与时间标签格式化

- TopBar 通知行改为 row-main/row-head/row-foot 结构化布局,标题加粗、分类改为 pill、箭头随标题右侧
- formatNotificationTime 改名 formatNotificationTimeLabel,新增 ISO/短日期直通匹配与超长截断兜底
- 更新 sidebar-document-unread-dot 测试
This commit is contained in:
caoxiaozhu
2026-06-23 09:42:56 +08:00
parent 0122f3b250
commit 8094333e3b
2 changed files with 62 additions and 19 deletions

View File

@@ -204,18 +204,22 @@
<span class="notification-type-icon" :class="item.tone">
<i :class="resolveNotificationIcon(item)"></i>
</span>
<span class="notification-copy">
<span class="notification-row-main">
<span class="notification-row-head">
<span class="notification-title-line">
<strong>{{ item.title }}</strong>
<strong class="notification-row-title">{{ item.title }}</strong>
<b v-if="item.badge">{{ item.badge }}</b>
</span>
<small>{{ item.description }}</small>
<span class="notification-meta">
<em>{{ item.category || '系统通知' }}</em>
<time>{{ item.time }}</time>
<span class="notification-row-action" aria-hidden="true">
<i class="mdi mdi-chevron-right"></i>
</span>
</span>
<small class="notification-context">{{ item.description }}</small>
<span class="notification-row-foot">
<span class="notification-category-pill">{{ item.category || '系统通知' }}</span>
<time class="notification-time">{{ item.timeLabel || item.time }}</time>
</span>
</span>
<i class="mdi mdi-chevron-right notification-row-arrow"></i>
</button>
</div>
<div v-else class="notification-empty">
@@ -516,12 +520,28 @@ function normalizeNotificationId(value) {
return String(value || '').trim()
}
function formatNotificationTime(value) {
const date = new Date(value)
if (!Number.isFinite(date.getTime())) {
function formatNotificationTimeLabel(value) {
const raw = String(value || '').trim()
if (!raw) {
return '最近更新'
}
const normalized = raw.replace('T', ' ')
const isoMatched = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/)
if (isoMatched) {
return `${isoMatched[2]}-${isoMatched[3]} ${isoMatched[4]}:${isoMatched[5]}`
}
const shortMatched = normalized.match(/^(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/)
if (shortMatched) {
return `${shortMatched[1]}-${shortMatched[2]} ${shortMatched[3]}:${shortMatched[4]}`
}
const date = new Date(raw)
if (!Number.isFinite(date.getTime())) {
return raw.length > 16 ? `${raw.slice(0, 16)}...` : raw
}
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
@@ -563,7 +583,8 @@ const documentNotificationItems = computed(() =>
kind: 'document',
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
description: resolveDocumentNotificationDescription(row),
time: formatNotificationTime(row.updatedAt || row.createdAt),
time: row.updatedAt || row.createdAt,
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
category: row.sourceLabel || '单据中心',
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
unread,
@@ -587,12 +608,15 @@ const workbenchNotificationItems = computed(() => (
if (!id || isNotificationHidden(id)) {
return null
}
const notificationTime = item.time || item.updatedAt || item.due
return {
...item,
id,
kind: 'workbench',
category: item.category || '个人工作台',
time: notificationTime,
timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due),
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
icon: item.icon || resolveNotificationIcon(item)
}

View File

@@ -96,6 +96,25 @@ test('topbar bell owns document center unread notifications', () => {
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
})
test('topbar notification popover uses inbox-style rows with formatted time labels', () => {
assert.match(topbar, /class="notification-row-main"/)
assert.match(topbar, /class="notification-row-head"/)
assert.match(topbar, /class="notification-row-title"/)
assert.match(topbar, /class="notification-context"/)
assert.match(topbar, /class="notification-row-foot"/)
assert.match(topbar, /class="notification-category-pill"/)
assert.match(topbar, /class="notification-time"/)
assert.match(topbar, /class="notification-row-action"/)
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(row\.updatedAt \|\| row\.createdAt\)/)
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(item\.time \|\| item\.updatedAt \|\| item\.due\)/)
assert.doesNotMatch(topbar, /<time>\{\{ item\.time \}\}<\/time>/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*36px minmax\(0,\s*1fr\);/)
assert.match(topbarStyles, /\.notification-context\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /\.notification-row-foot\s*\{[\s\S]*justify-content:\s*space-between;/)
assert.match(topbarStyles, /\.notification-time\s*\{[\s\S]*font-variant-numeric:\s*tabular-nums;/)
assert.match(topbarStyles, /\.notification-row-action\s*\{[\s\S]*width:\s*28px;[\s\S]*height:\s*28px;/)
})
test('topbar notification state is persisted through backend API with local fallback', () => {
assert.match(notificationStatesService, /apiRequest\('\/notification-states'\)/)
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'/)