This commit is contained in:
2026-08-17 19:52:08 +08:00
parent c6a1bcf611
commit 0f352bbdf9
14 changed files with 142 additions and 33 deletions
@@ -41,6 +41,17 @@ test('keeps customer message actions first and covers the requested local states
assert.match(source, /auth\.hasPermission\('merchant:notification:read-all'\)/)
assert.match(source, /v-if="canMarkAllRead"/)
assert.match(source, /v-if="canMarkRead && row\.status === 'UNREAD'"/)
assert.match(source, /notificationCategoryLabel\(row\.type\)/)
assert.match(source, /notificationSentAt\(row\.sentAt\)/)
})
test('uses friendly notification values while keeping unknown server codes visible', async () => {
const source = await readFile(new URL('presentation.ts', moduleRoot), 'utf8')
assert.match(source, /ORDER: '订单消息'/)
assert.match(source, /CLIENT_ACCEPTANCE: '验收消息'/)
assert.match(source, /categoryLabels\[code\.toUpperCase\(\)\] \?\? code/)
assert.match(source, /Number\.isNaN\(parsed\.getTime\(\)\).*return source/s)
assert.match(source, /toLocaleString\('zh-CN', \{ hour12: false \}\)/)
})
test('keeps detail readable without read permission and isolates auto-read failures', async () => {
@@ -50,6 +61,8 @@ test('keeps detail readable without read permission and isolates auto-read failu
assert.match(source, /readErrorMessage\.value=/)
assert.match(source, /v-if="readErrorMessage"/)
assert.match(source, /detail\.value=null;errorMessage\.value=/)
assert.match(source, /notificationSentAt\(detail\?\.sentAt\)/)
assert.match(source, /notificationCategoryLabel\(detail\?\.type\)/)
})
test('maps all three notification authorities into the client session', async () => {
+31
View File
@@ -0,0 +1,31 @@
const categoryLabels: Record<string, string> = {
SYSTEM: '系统通知',
SYSTEM_NOTICE: '系统通知',
BUSINESS: '业务提醒',
BUSINESS_REMINDER: '业务提醒',
ALERT: '异常告警',
EXCEPTION_ALERT: '异常告警',
WARNING: '异常告警',
TASK: '任务待办',
TASK_TODO: '任务待办',
ORDER: '订单消息',
INBOUND: '入库消息',
FINANCE: '财务消息',
CUSTOMS: '报关消息',
ACCEPTANCE: '验收消息',
CLIENT_ACCEPTANCE: '验收消息',
}
export function notificationCategoryLabel(value?: string | null) {
const code = value?.trim()
if (!code) return '—'
return categoryLabels[code.toUpperCase()] ?? code
}
export function notificationSentAt(value?: string | null) {
const source = value?.trim()
if (!source) return '—'
const parsed = new Date(source)
if (Number.isNaN(parsed.getTime())) return source
return parsed.toLocaleString('zh-CN', { hour12: false })
}
@@ -7,11 +7,12 @@ import { useNotificationsStore } from '../public'
import { useAuthStore } from '@/modules/auth/public'
import { getMyNotification, markMyNotificationRead } from '../api/notifications'
import type { NotificationDetail } from '../contracts'
import { notificationCategoryLabel, notificationSentAt } from '../presentation'
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const notifications = useNotificationsStore(); const detail = ref<NotificationDetail | null>(null); const loading = ref(false); const errorMessage = ref(''); const readErrorMessage = ref('')
const canMarkRead = computed(() => auth.hasPermission('merchant:notification:read'))
async function markReadIfAllowed(){if(!detail.value||detail.value.status!=='UNREAD'||!canMarkRead.value)return;try{await markMyNotificationRead(detail.value.id);detail.value={...detail.value,status:'READ'};await notifications.refreshUnreadCount()}catch(error){readErrorMessage.value=error instanceof ApiError?error.message:'消息已加载,但自动标记已读失败'}}
async function load(){loading.value=true;errorMessage.value='';readErrorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));await markReadIfAllowed()}catch(error){detail.value=null;errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}}
onMounted(()=>{void load()})
</script>
<template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load()"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><el-alert v-if="readErrorMessage" type="warning" :closable="false" :title="readErrorMessage" show-icon /><p class="meta">{{ detail?.sentAt }} · {{ detail?.type }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template>
<template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load()"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><el-alert v-if="readErrorMessage" type="warning" :closable="false" :title="readErrorMessage" show-icon /><p class="meta">{{ notificationSentAt(detail?.sentAt) }} · {{ notificationCategoryLabel(detail?.type) }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template>
<style scoped>.notification-detail{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700}.content-card{min-height:240px;padding:24px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.meta{color:var(--muted);font-size:12px}.content{white-space:pre-wrap;line-height:1.8;color:var(--navy)}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}@media(max-width:720px){.content-card{padding:16px}.page-heading h1{font-size:21px}}</style>
@@ -7,6 +7,7 @@ import { useNotificationsStore } from '../public'
import { useAuthStore } from '@/modules/auth/public'
import { getMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notifications'
import type { NotificationPage, NotificationStatus, NotificationSummary, NotificationType } from '../contracts'
import { notificationCategoryLabel, notificationSentAt } from '../presentation'
const router = useRouter()
const auth = useAuthStore()
@@ -35,7 +36,7 @@ onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()])
<section class="content-card">
<form class="filter-bar" @submit.prevent="load(1)"><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="消息状态"><el-option label="未读" value="UNREAD" /><el-option label="已读" value="READ" /></el-select><el-input v-model="filters.type" clearable maxlength="64" placeholder="消息分类" aria-label="消息分类" /><el-button type="primary" native-type="submit" :loading="loading">查询</el-button><el-button :disabled="loading" @click="filters.status = undefined; filters.type = undefined; load(1)">重置</el-button><el-button v-if="canMarkAllRead" class="mark-all" :disabled="loading" @click="markAllRead">全部标为已读</el-button></form>
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load(page.page)"><RefreshCcw :size="15" />重试</el-button></div>
<el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="canMarkRead && row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column prop="type" label="分类" width="140" /><el-table-column prop="sentAt" label="发送时间" width="180" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>当前没有可查看的站内消息。</span></div></template></el-table>
<el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="canMarkRead && row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column label="分类" width="140"><template #default="{ row }">{{ notificationCategoryLabel(row.type) }}</template></el-table-column><el-table-column label="发送时间" width="180"><template #default="{ row }">{{ notificationSentAt(row.sentAt) }}</template></el-table-column><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>当前没有可查看的站内消息。</span></div></template></el-table>
<div v-if="!errorMessage && page.total > 0" class="pagination-row"><span>共 {{ page.total }} 条</span><el-pagination :current-page="page.page" :page-size="page.size" layout="prev, pager, next" :total="page.total" @current-change="load" /></div>
</section>
</div>