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 @@
+
+返回我的消息消息详情
{{ detail?.title || '消息详情' }}
{{ detail?.sentAt }} · {{ detail?.type }}
{{ detail?.content || '暂无消息内容' }}
+
+
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 @@
+
+
+
+
+
消息中心
我的消息
仅显示当前登录企业和成员可见的站内消息。
+
+
+
+ 查看标为已读{{ row.status === 'UNREAD' ? '未读' : '已读' }}暂无消息接口接入后,这里会显示本企业成员可见的消息。
+
+
+
+
+
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; }