From 13a856cef985c1be73a36caa8d03ac3c79813fd1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 18:24:03 +0800 Subject: [PATCH 1/6] =?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 | 6 ++- .../notifications/api/notifications.ts | 52 +++++++++++++++++++ src/modules/notifications/contracts.ts | 30 +++++++++++ .../notifications/notifications.test.mjs | 29 +++++++++++ src/modules/notifications/public.ts | 2 + src/modules/notifications/routes.ts | 25 +++++++++ .../notifications/store/notifications.ts | 27 ++++++++++ .../views/NotificationDetailView.vue | 14 +++++ .../notifications/views/NotificationsView.vue | 39 ++++++++++++++ src/modules/registry.ts | 2 + src/styles/shell.css | 3 ++ 11 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/modules/notifications/api/notifications.ts create mode 100644 src/modules/notifications/contracts.ts create mode 100644 src/modules/notifications/notifications.test.mjs create mode 100644 src/modules/notifications/public.ts create mode 100644 src/modules/notifications/routes.ts create mode 100644 src/modules/notifications/store/notifications.ts create mode 100644 src/modules/notifications/views/NotificationDetailView.vue create mode 100644 src/modules/notifications/views/NotificationsView.vue diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue index a0a3985..054fbeb 100644 --- a/src/layouts/AppLayout.vue +++ b/src/layouts/AppLayout.vue @@ -2,11 +2,12 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessageBox } from 'element-plus' -import { LogOut, Menu, X } from '@lucide/vue' +import { Bell, LogOut, Menu, X } from '@lucide/vue' import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png' import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue' import WorkspaceTabs from '@/components/WorkspaceTabs.vue' import { useAuthStore } from '@/modules/auth/public' +import { useNotificationsStore } from '@/modules/notifications/public' import { getFirstAccessiblePath } from '@/navigation/clientNavigation' import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork' import { @@ -19,6 +20,7 @@ import { const route = useRoute() const router = useRouter() const auth = useAuthStore() +const notifications = useNotificationsStore() const workspace = useWorkspaceTabsStore() const unsaved = useUnsavedWorkStore() const pageTitle = computed(() => route.meta.title || '业务总览') @@ -139,6 +141,7 @@ watch(mobileMenuOpen, async (open, wasOpen) => { }) onMounted(() => { + void notifications.refreshUnreadCount() document.addEventListener('keydown', handleKeydown) window.addEventListener('beforeunload', handleBeforeUnload) desktopMediaQuery = window.matchMedia('(min-width: 721px)') @@ -199,6 +202,7 @@ async function handleLogout() {

{{ pageDescription }}

+ {{ 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 new file mode 100644 index 0000000..e0765ec --- /dev/null +++ b/src/modules/notifications/api/notifications.ts @@ -0,0 +1,52 @@ +import { apiRequest, ApiError } 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. + */ +interface NotificationWire { + id: string + title: string + summary?: string | null + content?: string | null + type: string + sentAt?: string | null + createdAt?: string | null + status: NotificationStatus +} + +interface NotificationPageWire { + records?: NotificationWire[] + items?: NotificationWire[] + total?: number + page?: number + size?: 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 } +} + +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 } +} + +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 ?? '' } +} + +export async function markMyNotificationRead(id: string): Promise { + await apiRequest({ method: 'POST', url: `/merchant/v1/notifications/${encodeURIComponent(id)}/read` }) +} + +export async function markAllMyNotificationsRead(): Promise { + await apiRequest({ method: 'POST', url: '/merchant/v1/notifications/read-all' }) +} + +export async function getMyUnreadNotificationCount(): Promise { + throw new ApiError('未读数量接口尚未接入,请稍后重试', 'NOTIFICATIONS_UNREAD_COUNT_PENDING') +} diff --git a/src/modules/notifications/contracts.ts b/src/modules/notifications/contracts.ts new file mode 100644 index 0000000..cf0ca18 --- /dev/null +++ b/src/modules/notifications/contracts.ts @@ -0,0 +1,30 @@ +export type NotificationStatus = 'UNREAD' | 'READ' +export type NotificationType = 'SYSTEM' | 'BUSINESS' | 'TASK' | string + +export interface NotificationSummary { + id: string + title: string + summary: string + type: NotificationType + sentAt: string + status: NotificationStatus +} + +export interface NotificationDetail extends NotificationSummary { + content: string +} + +export interface NotificationQuery { + status?: NotificationStatus + type?: NotificationType + page?: number + size?: number +} + +export interface NotificationPage { + records: NotificationSummary[] + total: number + page: number + size: number +} + diff --git a/src/modules/notifications/notifications.test.mjs b/src/modules/notifications/notifications.test.mjs new file mode 100644 index 0000000..3bbeef8 --- /dev/null +++ b/src/modules/notifications/notifications.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +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 () => { + 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'/) +}) + +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, /read-all/) +}) + +test('keeps customer message actions first and covers the requested local states', async () => { + const source = await readFile(new URL('views/NotificationsView.vue', moduleRoot), 'utf8') + const operationColumn = source.indexOf('label="操作"') + const titleColumn = source.indexOf('label="消息标题"') + assert.ok(operationColumn >= 0 && operationColumn < titleColumn) + assert.match(source, /全部标为已读/) + assert.match(source, /标为已读/) + assert.match(source, /state-panel--error/) + assert.match(source, /暂无消息/) +}) diff --git a/src/modules/notifications/public.ts b/src/modules/notifications/public.ts new file mode 100644 index 0000000..ed02e7a --- /dev/null +++ b/src/modules/notifications/public.ts @@ -0,0 +1,2 @@ +export { useNotificationsStore } from './store/notifications' + diff --git a/src/modules/notifications/routes.ts b/src/modules/notifications/routes.ts new file mode 100644 index 0000000..a03a3b8 --- /dev/null +++ b/src/modules/notifications/routes.ts @@ -0,0 +1,25 @@ +import { Bell } from '@lucide/vue' +import type { ClientModuleManifest } from '../types.ts' + +export const notificationsModule = { + key: 'notifications', + navigationGroup: { title: '消息中心', icon: Bell }, + routes: [ + { + path: 'notifications', + name: 'notifications-list', + component: () => import('./views/NotificationsView.vue'), + release: 'prototype', + meta: { title: '我的消息', description: '查看本企业成员可见的站内消息', permission: 'notifications: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 }, + }, + ], +} satisfies ClientModuleManifest + diff --git a/src/modules/notifications/store/notifications.ts b/src/modules/notifications/store/notifications.ts new file mode 100644 index 0000000..ebe0f92 --- /dev/null +++ b/src/modules/notifications/store/notifications.ts @@ -0,0 +1,27 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { getMyUnreadNotificationCount } from '../api/notifications' + +export const useNotificationsStore = defineStore('client-notifications', () => { + const unreadCount = ref(null) + const loading = ref(false) + const errorMessage = ref('') + + const hasUnread = computed(() => (unreadCount.value ?? 0) > 0) + + async function refreshUnreadCount() { + loading.value = true + errorMessage.value = '' + try { + unreadCount.value = await getMyUnreadNotificationCount() + } catch (error) { + unreadCount.value = null + errorMessage.value = error instanceof Error ? error.message : '未读数量加载失败' + } finally { + loading.value = false + } + } + + return { unreadCount, hasUnread, loading, errorMessage, refreshUnreadCount } +}) + diff --git a/src/modules/notifications/views/NotificationDetailView.vue b/src/modules/notifications/views/NotificationDetailView.vue new file mode 100644 index 0000000..ccb809f --- /dev/null +++ b/src/modules/notifications/views/NotificationDetailView.vue @@ -0,0 +1,14 @@ + + + + diff --git a/src/modules/notifications/views/NotificationsView.vue b/src/modules/notifications/views/NotificationsView.vue new file mode 100644 index 0000000..6e2edc3 --- /dev/null +++ b/src/modules/notifications/views/NotificationsView.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/src/modules/registry.ts b/src/modules/registry.ts index da9c712..e6e2cd4 100644 --- a/src/modules/registry.ts +++ b/src/modules/registry.ts @@ -7,6 +7,7 @@ import { orderManagementModule } from './order-management/routes.ts' import { overseasWarehouseModule } from './overseas-warehouse/routes.ts' import { systemModule } from './system/routes.ts' import { tenantManagementModule } from './tenant-management/routes.ts' +import { notificationsModule } from './notifications/routes.ts' import { getActiveWorkspaceRoutes, type ClientModuleManifest } from './types.ts' export const clientModuleRegistry = [ @@ -18,6 +19,7 @@ export const clientModuleRegistry = [ financeModule, tenantManagementModule, accountModule, + notificationsModule, systemModule, ] satisfies readonly ClientModuleManifest[] diff --git a/src/styles/shell.css b/src/styles/shell.css index a968c41..d917a0d 100644 --- a/src/styles/shell.css +++ b/src/styles/shell.css @@ -5,6 +5,9 @@ .client-header-heading h1 { overflow: hidden; margin: 0; color: #17233a; font-size: 17px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } .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-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; } .client-user-copy strong { color: #25324a; font-size: 12px; } 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 2/6] =?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, From 9999f3f26b3e922fede12aee5967e17ad405aee0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 19:30:17 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + src/modules/notifications/notifications.test.mjs | 4 ++++ src/modules/notifications/views/NotificationDetailView.vue | 2 +- src/modules/notifications/views/NotificationsView.vue | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c4b3789..15fcc2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ | 登录、注册与会话 | `src/modules/auth/` | Backend `auth/`、`iam/` | | 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`file/` | | 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` | +| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` | | 头程下单、订单与履约进度 | `src/modules/first-leg/` | Backend `order/`、`firstleg/`、`warehouse/` | | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 客户财务账单、收款与未收余额 | `src/modules/finance/` | Backend `finance/`;以登录态客户隔离,只读接口从 `api/finance.ts` 进入 | diff --git a/src/modules/notifications/notifications.test.mjs b/src/modules/notifications/notifications.test.mjs index 4474723..8c5d2db 100644 --- a/src/modules/notifications/notifications.test.mjs +++ b/src/modules/notifications/notifications.test.mjs @@ -16,6 +16,8 @@ test('does not accept browser-selected customer or user scope in the API boundar assert.doesNotMatch(source, /tenantId|customerId|merchantId|userId/) assert.match(source, /merchant\/v1\/notifications/) assert.match(source, /unread-count/) + assert.match(source, /notifications\/\$\{encodeURIComponent\(id\)\}/) + assert.match(source, /notifications\/\$\{encodeURIComponent\(id\)\}\/read/) assert.match(source, /read-all/) assert.match(source, /category: query\.type/) assert.match(source, /method: 'PUT'/) @@ -31,4 +33,6 @@ test('keeps customer message actions first and covers the requested local states assert.match(source, /标为已读/) assert.match(source, /state-panel--error/) assert.match(source, /暂无消息/) + assert.doesNotMatch(source, /清空已读|删除消息/) + assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(\)\]\)/) }) diff --git a/src/modules/notifications/views/NotificationDetailView.vue b/src/modules/notifications/views/NotificationDetailView.vue index 915fad8..c588830 100644 --- a/src/modules/notifications/views/NotificationDetailView.vue +++ b/src/modules/notifications/views/NotificationDetailView.vue @@ -10,5 +10,5 @@ const route = useRoute(); const router = useRouter(); const notifications = useN 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 8b1780d..439146a 100644 --- a/src/modules/notifications/views/NotificationsView.vue +++ b/src/modules/notifications/views/NotificationsView.vue @@ -22,7 +22,7 @@ async function load(nextPage = page.value.page) { 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() }) +onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()]) })