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 @@
+
+返回我的消息消息详情
{{ 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; }
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 }}
-
+
{{ 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()})
返回我的消息消息详情
{{ detail?.title || '消息详情' }}
{{ detail?.sentAt }} · {{ detail?.type }}
{{ detail?.content || '暂无消息内容' }}
-
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() })
消息中心
我的消息
仅显示当前登录企业和成员可见的站内消息。
-
-
- 查看标为已读{{ row.status === 'UNREAD' ? '未读' : '已读' }}暂无消息接口接入后,这里会显示本企业成员可见的消息。
+
+
+ 查看标为已读{{ row.status === 'UNREAD' ? '未读' : '已读' }}暂无消息当前没有可查看的站内消息。
+
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()})
-返回我的消息消息详情
{{ detail?.title || '消息详情' }}
{{ detail?.sentAt }} · {{ detail?.type }}
{{ detail?.content || '暂无消息内容' }}
+返回我的消息消息详情
{{ detail?.title || '消息详情' }}
{{ detail?.sentAt }} · {{ detail?.type }}
{{ detail?.content || '暂无消息内容' }}
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()]) })
From b06f0d64980050fc80eb920612a1c5a52aff8084 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 12 Aug 2026 19:42:15 +0800
Subject: [PATCH 4/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 | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 15fcc2a..4f8bb1a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -82,14 +82,13 @@ pnpm build
- 当前测试使用 Node.js 内置测试运行器,执行 `pnpm test`;项目仍未声明 lint 脚本,不得声称 lint 已通过。
- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目依赖清单和锁文件。
-## 一期/二期/三期快速索引
+## Client 全量验收快速索引
-按阶段开展客户工作台任务时,先核对对应阶段文档,再按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位;阶段范围以用户确认、需求确认表和设计文档为准,原型只用于视觉与交互补充。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
+当前按客户侧全量业务闭环验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
-| 阶段 | Client 范围边界 | 代码入口 | 开发首读 |
+| 验收域 | Client 范围与代码入口 | 验收首读 |
|---|---|---|---|
-| 一期:客户与头程订单闭环 | 登录/注册、企业资料与资质、成员/角色权限、工作台和头程订单;订单停在已确认的七状态,不对客户开放取消写操作 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/` | `../../docs/一期/README.md`、一期闭环设计;核对资格门禁、幂等键、状态机、仓库主数据及真实接口错误处理 |
-| 二期:到仓与头程计费 | 客户侧展示已授权的入库、测量和费用结果,终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`;不暴露 Admin 内部备注、文件或未授权原始证据 | `src/modules/first-leg/` | `../../docs/二期/README.md`、二期主流程 |
-| 三期:国内出仓 | 客户仅查看柜次、装柜进度、授权回执和船期,不暴露照片、锁码或后台内部资料 | `src/modules/first-leg/`;统一订单候选入口在 `order-management/` | `../../docs/三期/README.md`、需求确认表、开工门禁、开发规划和页面清单;不自动纳入完整 WMS、报关后台操作或海运运营流程 |
+| 客户基础与订单 | 登录/注册、企业资料与资质、成员/角色权限、工作台和订单 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/`、`order-management/`;按客户可见契约核对权限、状态、幂等和错误处理 |
+| 履约与财务 | 入库计费、柜次进度、船期、消息和客户账单 | `src/modules/first-leg/`、`finance/`、`notifications/`;按服务端权限核对客户可见字段和只读/写入边界 |
-阶段边界:一期负责客户入驻与下单,二期负责到仓和计费并停在等待三期分柜,三期才进入国内出仓;不要把 PDA、分柜或装柜后台操作开放到客户工作台。财务作为独立专题按 `../../docs/财务管理/README.md` 与 `src/modules/finance/routes.ts` 定位;当前启用的是客户账单、收款记录和未收余额只读入口,不据此扩展客户侧完整应收应付操作。
+客户侧边界:仍以服务端权限和客户可见字段为准;PDA、平台后台操作和内部证据不因全量验收自动暴露到 Client。财务按 `../../docs/财务管理/` 与 `src/modules/finance/routes.ts` 定位。
From e3d4dbc567c1b34f29510ca4677c5d8b05ad3b8f Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 12 Aug 2026 19:56:40 +0800
Subject: [PATCH 5/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 | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index 4f8bb1a..eeab2d6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -29,6 +29,13 @@
- `src/styles/`:全局设计令牌和布局;优先复用现有 `--primary`、`--navy`、`--border`、`--muted`。
- 本应用多个模块稳定复用的前端模型放到应用内 `shared/`,客户工作台专属类型保留在对应业务模块。不要链接应用目录之外的 TypeScript 包,服务端契约以 `baiyue-firstleg` Controller/OpenAPI 为准。
+## 开发环境样式验收
+
+- 默认验收身份:`merchant_admin`,用于“客户端”的样式、路由、权限展示和交互验收。
+- 密码不写入仓库;从本机未提交环境变量 `CHONGCHONG_CLIENT_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取。若凭据不存在,再向用户索取,不重复询问账号名。
+- 该身份仅适用于开发环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;开发环境变更时只更新本机凭据。
+- 样式验收优先使用该身份进入真实页面,并按原型还原规则检查加载、空态、错误态、权限态和窄屏布局。
+
## 模块快速查询
先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。
From 842178d385481f8445fb797f18fdc922e2a40fdf Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 12 Aug 2026 20:06:28 +0800
Subject: [PATCH 6/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 | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index eeab2d6..6d96090 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -89,6 +89,12 @@ pnpm build
- 当前测试使用 Node.js 内置测试运行器,执行 `pnpm test`;项目仍未声明 lint 脚本,不得声称 lint 已通过。
- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目依赖清单和锁文件。
+## 全量验收脚本
+
+- 本地 Client 自动回归由后端仓库的 `../../baiyue-firstleg/scripts/frontend-full-regression.ps1` 统一编排;先执行 `-DryRun`,再运行 `pnpm test` 和 `pnpm typecheck`。
+- 测试环境接口验收入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;二期兼容链路再使用 `../../baiyue-firstleg/scripts/client-phase2-test-env-acceptance.mjs`。这些脚本会创建或修改测试数据,必须在当前对话获得明确测试环境授权后执行。
+- 脚本通过只证明自动化契约和接口检查通过;样式验收仍使用开发环境验收账号,通过浏览器逐页检查原型、客户可见字段、权限、错误态、空态和窄屏布局。
+
## Client 全量验收快速索引
当前按客户侧全量业务闭环验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。