From 71b33613615185d60d438d189c1a47dca37233c7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 18:49:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layouts/AppLayout.vue | 4 +- .../notifications/api/notifications.ts | 48 +++++++++++-------- src/modules/notifications/contracts.ts | 4 +- .../notifications/notifications.test.mjs | 11 +++-- src/modules/notifications/routes.ts | 9 ++-- .../views/NotificationDetailView.vue | 6 +-- .../notifications/views/NotificationsView.vue | 25 +++++----- src/navigation/clientNavigation.test.mjs | 5 ++ src/styles/shell.css | 3 +- 9 files changed, 67 insertions(+), 48 deletions(-) diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue index 054fbeb..c8d4e7e 100644 --- a/src/layouts/AppLayout.vue +++ b/src/layouts/AppLayout.vue @@ -141,7 +141,7 @@ watch(mobileMenuOpen, async (open, wasOpen) => { }) onMounted(() => { - void notifications.refreshUnreadCount() + if (auth.hasPermission('merchant:notification:view')) void notifications.refreshUnreadCount() document.addEventListener('keydown', handleKeydown) window.addEventListener('beforeunload', handleBeforeUnload) desktopMediaQuery = window.matchMedia('(min-width: 721px)') @@ -202,7 +202,7 @@ async function handleLogout() {

{{ pageDescription }}

- + {{ notifications.unreadCount > 99 ? '99+' : notifications.unreadCount }} {{ auth.user?.name.slice(0, 1) }} {{ auth.user?.name }}{{ auth.user?.roles.join('、') }}
diff --git a/src/modules/notifications/api/notifications.ts b/src/modules/notifications/api/notifications.ts index e0765ec..fe36814 100644 --- a/src/modules/notifications/api/notifications.ts +++ b/src/modules/notifications/api/notifications.ts @@ -1,52 +1,58 @@ -import { apiRequest, ApiError } from '@/api/http' +import { apiRequest } from '@/api/http' import type { NotificationDetail, NotificationPage, NotificationQuery, NotificationStatus, NotificationSummary } from '../contracts' /** - * Message-center endpoints are intentionally kept behind this adapter until - * the server contract is frozen. No caller may provide a customer/user scope. + * The authenticated merchant session supplies the enterprise and member scope. + * No caller may provide tenant, customer, merchant, or user identifiers. */ interface NotificationWire { id: string title: string - summary?: string | null - content?: string | null - type: string - sentAt?: string | null - createdAt?: string | null + category: string + sentAt: string + readAt?: string | null status: NotificationStatus + body?: string + sourceType?: string | null + sourceId?: string | null } interface NotificationPageWire { - records?: NotificationWire[] - items?: NotificationWire[] - total?: number - page?: number - size?: number + records: NotificationWire[] + total: number + page: number + size: number + pages: number } function mapSummary(item: NotificationWire): NotificationSummary { - return { id: String(item.id), title: item.title, summary: item.summary ?? '', type: item.type, sentAt: item.sentAt ?? item.createdAt ?? '', status: item.status } + return { id: String(item.id), title: item.title, summary: item.body ? item.body.slice(0, 120) : '', type: item.category, sentAt: item.sentAt, status: item.status } } export async function getMyNotifications(query: NotificationQuery = {}): Promise { - const response = await apiRequest({ method: 'GET', url: '/merchant/v1/notifications', params: query }) - const records = (response.records ?? response.items ?? []).map(mapSummary) - return { records, total: response.total ?? records.length, page: response.page ?? query.page ?? 1, size: response.size ?? query.size ?? 20 } + const response = await apiRequest({ + method: 'GET', + url: '/merchant/v1/notifications', + params: { page: query.page, size: query.size, status: query.status, category: query.type }, + }) + const records = response.records.map(mapSummary) + return { records, total: response.total, page: response.page, size: response.size, pages: response.pages } } export async function getMyNotification(id: string): Promise { const response = await apiRequest({ method: 'GET', url: `/merchant/v1/notifications/${encodeURIComponent(id)}` }) - return { ...mapSummary(response), content: response.content ?? response.summary ?? '' } + return { ...mapSummary(response), content: response.body ?? '' } } export async function markMyNotificationRead(id: string): Promise { - await apiRequest({ method: 'POST', url: `/merchant/v1/notifications/${encodeURIComponent(id)}/read` }) + await apiRequest({ method: 'PUT', url: `/merchant/v1/notifications/${encodeURIComponent(id)}/read` }) } export async function markAllMyNotificationsRead(): Promise { - await apiRequest({ method: 'POST', url: '/merchant/v1/notifications/read-all' }) + await apiRequest<{ updatedCount: number }>({ method: 'PUT', url: '/merchant/v1/notifications/read-all' }) } export async function getMyUnreadNotificationCount(): Promise { - throw new ApiError('未读数量接口尚未接入,请稍后重试', 'NOTIFICATIONS_UNREAD_COUNT_PENDING') + const response = await apiRequest<{ unreadCount: number }>({ method: 'GET', url: '/merchant/v1/notifications/unread-count' }) + return response.unreadCount } diff --git a/src/modules/notifications/contracts.ts b/src/modules/notifications/contracts.ts index cf0ca18..3e092e8 100644 --- a/src/modules/notifications/contracts.ts +++ b/src/modules/notifications/contracts.ts @@ -1,5 +1,5 @@ export type NotificationStatus = 'UNREAD' | 'READ' -export type NotificationType = 'SYSTEM' | 'BUSINESS' | 'TASK' | string +export type NotificationType = string export interface NotificationSummary { id: string @@ -26,5 +26,5 @@ export interface NotificationPage { total: number page: number size: number + pages: number } - diff --git a/src/modules/notifications/notifications.test.mjs b/src/modules/notifications/notifications.test.mjs index 3bbeef8..4474723 100644 --- a/src/modules/notifications/notifications.test.mjs +++ b/src/modules/notifications/notifications.test.mjs @@ -4,17 +4,22 @@ import test from 'node:test' const moduleRoot = new URL('./', import.meta.url) -test('keeps message-center routes unreleased until the server contract is frozen', async () => { +test('activates message-center routes with the frozen merchant permission', async () => { const source = await readFile(new URL('routes.ts', moduleRoot), 'utf8') - assert.match(source, /name: 'notifications-list'[\s\S]*release: 'prototype'/) - assert.match(source, /name: 'notification-detail'[\s\S]*release: 'prototype'/) + assert.match(source, /name: 'notifications-list'[\s\S]*release: 'active'/) + assert.match(source, /name: 'notification-detail'[\s\S]*release: 'active'/) + assert.match(source, /merchant:notification:view/) }) test('does not accept browser-selected customer or user scope in the API boundary', async () => { const source = await readFile(new URL('api/notifications.ts', moduleRoot), 'utf8') assert.doesNotMatch(source, /tenantId|customerId|merchantId|userId/) assert.match(source, /merchant\/v1\/notifications/) + assert.match(source, /unread-count/) assert.match(source, /read-all/) + assert.match(source, /category: query\.type/) + assert.match(source, /method: 'PUT'/) + assert.doesNotMatch(source, /method: 'POST'/) }) test('keeps customer message actions first and covers the requested local states', async () => { diff --git a/src/modules/notifications/routes.ts b/src/modules/notifications/routes.ts index a03a3b8..b5714a0 100644 --- a/src/modules/notifications/routes.ts +++ b/src/modules/notifications/routes.ts @@ -9,17 +9,16 @@ export const notificationsModule = { path: 'notifications', name: 'notifications-list', component: () => import('./views/NotificationsView.vue'), - release: 'prototype', - meta: { title: '我的消息', description: '查看本企业成员可见的站内消息', permission: 'notifications:view' }, + release: 'active', + meta: { title: '我的消息', description: '查看本企业成员可见的站内消息', permission: 'merchant:notification:view' }, navigation: { key: 'notifications-list', title: '我的消息', icon: Bell }, }, { path: 'notifications/:notificationId', name: 'notification-detail', component: () => import('./views/NotificationDetailView.vue'), - release: 'prototype', - meta: { title: '消息详情', description: '查看消息正文', permission: 'notifications:view', keepAlive: false }, + release: 'active', + meta: { title: '消息详情', description: '查看消息正文', permission: 'merchant:notification:view', keepAlive: false }, }, ], } satisfies ClientModuleManifest - diff --git a/src/modules/notifications/views/NotificationDetailView.vue b/src/modules/notifications/views/NotificationDetailView.vue index ccb809f..915fad8 100644 --- a/src/modules/notifications/views/NotificationDetailView.vue +++ b/src/modules/notifications/views/NotificationDetailView.vue @@ -3,12 +3,12 @@ import { onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ArrowLeft, Bell, RefreshCcw } from '@lucide/vue' import { ApiError } from '@/api/http' +import { useNotificationsStore } from '../public' import { getMyNotification, markMyNotificationRead } from '../api/notifications' import type { NotificationDetail } from '../contracts' -const route = useRoute(); const router = useRouter(); const detail = ref(null); const loading = ref(false); const errorMessage = ref('') -async function load(){loading.value=true;errorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));if(detail.value.status==='UNREAD')await markMyNotificationRead(detail.value.id)}catch(error){errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}} +const route = useRoute(); const router = useRouter(); const notifications = useNotificationsStore(); const detail = ref(null); const loading = ref(false); const errorMessage = ref('') +async function load(){loading.value=true;errorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));if(detail.value.status==='UNREAD'){await markMyNotificationRead(detail.value.id);detail.value={...detail.value,status:'READ'};await notifications.refreshUnreadCount()}}catch(error){errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}} onMounted(()=>{void load()}) - diff --git a/src/modules/notifications/views/NotificationsView.vue b/src/modules/notifications/views/NotificationsView.vue index 6e2edc3..8b1780d 100644 --- a/src/modules/notifications/views/NotificationsView.vue +++ b/src/modules/notifications/views/NotificationsView.vue @@ -3,22 +3,24 @@ import { onMounted, reactive, ref } from 'vue' import { Bell, RefreshCcw } from '@lucide/vue' import { useRouter } from 'vue-router' import { ApiError } from '@/api/http' +import { useNotificationsStore } from '../public' import { getMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notifications' -import type { NotificationPage, NotificationStatus, NotificationSummary } from '../contracts' +import type { NotificationPage, NotificationStatus, NotificationSummary, NotificationType } from '../contracts' const router = useRouter() +const notifications = useNotificationsStore() const loading = ref(false) const errorMessage = ref('') -const page = ref({ records: [], total: 0, page: 1, size: 20 }) -const filters = reactive<{ status?: NotificationStatus }>({}) +const page = ref({ records: [], total: 0, page: 1, size: 20, pages: 0 }) +const filters = reactive<{ status?: NotificationStatus; type?: NotificationType }>({}) function messageOf(error: unknown) { return error instanceof ApiError ? error.message : '消息加载失败,请稍后重试' } -async function load() { +async function load(nextPage = page.value.page) { loading.value = true; errorMessage.value = '' - try { page.value = await getMyNotifications({ ...filters, page: 1, size: 20 }) } catch (error) { errorMessage.value = messageOf(error); page.value = { records: [], total: 0, page: 1, size: 20 } } finally { loading.value = false } + try { page.value = await getMyNotifications({ ...filters, page: nextPage, size: page.value.size }) } catch (error) { errorMessage.value = messageOf(error); page.value = { records: [], total: 0, page: nextPage, size: 20, pages: 0 } } finally { loading.value = false } } -async function markRead(row: NotificationSummary) { try { await markMyNotificationRead(row.id); await load() } catch (error) { errorMessage.value = messageOf(error) } } -async function markAllRead() { try { await markAllMyNotificationsRead(); await load() } catch (error) { errorMessage.value = messageOf(error) } } +async function markRead(row: NotificationSummary) { try { await markMyNotificationRead(row.id); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } } +async function markAllRead() { try { await markAllMyNotificationsRead(); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } } function openDetail(row: NotificationSummary) { void router.push({ name: 'notification-detail', params: { notificationId: row.id } }) } onMounted(() => { void load() }) @@ -27,13 +29,14 @@ onMounted(() => { void load() })

消息中心

我的消息

仅显示当前登录企业和成员可见的站内消息。

-
查询重置全部标为已读
- - +
查询重置全部标为已读 + + +
共 {{ page.total }} 条
diff --git a/src/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs index eafd3ac..2611688 100644 --- a/src/navigation/clientNavigation.test.mjs +++ b/src/navigation/clientNavigation.test.mjs @@ -62,6 +62,10 @@ test('keeps only implemented navigation groups visible', () => { children: ['企业资料', '企业成员', '角色管理', '菜单权限'], }, { title: '我的信息', children: undefined }, + { + title: '消息中心', + children: ['我的消息'], + }, ], ) }) @@ -88,6 +92,7 @@ test('flattens visible leaves with unique paths', () => { '/organization/roles', '/organization/permissions', '/account/profile', + '/notifications', ]) assert.equal(new Set(paths).size, paths.length) assert.ok(leaves.every((leaf) => leaf.permission.trim().length > 0)) diff --git a/src/styles/shell.css b/src/styles/shell.css index d917a0d..9d8c92f 100644 --- a/src/styles/shell.css +++ b/src/styles/shell.css @@ -6,7 +6,7 @@ .client-header-heading p { overflow: hidden; margin: 3px 0 0; color: #7c8798; font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } .client-user { display: flex; align-items: center; gap: 10px; } .client-header-notifications { position: relative; display: inline-grid; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #526176; place-items: center; } -.client-header-notifications:disabled { cursor: not-allowed; opacity: .58; } +.client-header-notifications { text-decoration: none; } .client-header-notifications-badge { position: absolute; top: -8px; right: -9px; min-width: 16px; padding: 1px 4px; border-radius: 10px; color: #fff; background: #d64545; font-size: 10px; line-height: 14px; text-align: center; } .client-avatar { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 50%; background: #e9f2ff; color: #1565c0; font-weight: 750; } .client-user-copy { display: flex; flex-direction: column; } @@ -52,6 +52,7 @@ .client-sidebar-group-trigger:focus-visible, .client-sidebar-link:focus-visible, .client-header-menu-button:focus-visible, +.client-header-notifications:focus-visible, .client-sidebar-brand button:focus-visible, .client-sidebar-logout:focus-visible { outline: 2px solid #6db9ff; outline-offset: 2px; } .client-sidebar-brand button,