diff --git a/AGENTS.md b/AGENTS.md
index c4b3789..6d96090 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` 为准,不从任务或进度文档推断。
@@ -38,6 +45,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` 进入 |
@@ -81,14 +89,19 @@ pnpm build
- 当前测试使用 Node.js 内置测试运行器,执行 `pnpm test`;项目仍未声明 lint 脚本,不得声称 lint 已通过。
- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目依赖清单和锁文件。
-## 一期/二期/三期快速索引
+## 全量验收脚本
-按阶段开展客户工作台任务时,先核对对应阶段文档,再按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位;阶段范围以用户确认、需求确认表和设计文档为准,原型只用于视觉与交互补充。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
+- 本地 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 范围边界 | 代码入口 | 开发首读 |
+## Client 全量验收快速索引
+
+当前按客户侧全量业务闭环验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 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` 定位。
diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue
index a0a3985..c8d4e7e 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(() => {
+ if (auth.hasPermission('merchant:notification:view')) 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..fe36814
--- /dev/null
+++ b/src/modules/notifications/api/notifications.ts
@@ -0,0 +1,58 @@
+import { apiRequest } from '@/api/http'
+import type { NotificationDetail, NotificationPage, NotificationQuery, NotificationStatus, NotificationSummary } from '../contracts'
+
+/**
+ * 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
+ category: string
+ sentAt: string
+ readAt?: string | null
+ status: NotificationStatus
+ body?: string
+ sourceType?: string | null
+ sourceId?: string | null
+}
+
+interface NotificationPageWire {
+ 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.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: { 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.body ?? '' }
+}
+
+export async function markMyNotificationRead(id: string): Promise {
+ await apiRequest({ method: 'PUT', url: `/merchant/v1/notifications/${encodeURIComponent(id)}/read` })
+}
+
+export async function markAllMyNotificationsRead(): Promise {
+ await apiRequest<{ updatedCount: number }>({ method: 'PUT', url: '/merchant/v1/notifications/read-all' })
+}
+
+export async function getMyUnreadNotificationCount(): Promise {
+ 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
new file mode 100644
index 0000000..3e092e8
--- /dev/null
+++ b/src/modules/notifications/contracts.ts
@@ -0,0 +1,30 @@
+export type NotificationStatus = 'UNREAD' | 'READ'
+export type NotificationType = 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
+ pages: number
+}
diff --git a/src/modules/notifications/notifications.test.mjs b/src/modules/notifications/notifications.test.mjs
new file mode 100644
index 0000000..8c5d2db
--- /dev/null
+++ b/src/modules/notifications/notifications.test.mjs
@@ -0,0 +1,38 @@
+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('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: '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, /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'/)
+ assert.doesNotMatch(source, /method: 'POST'/)
+})
+
+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, /暂无消息/)
+ assert.doesNotMatch(source, /清空已读|删除消息/)
+ assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(\)\]\)/)
+})
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..b5714a0
--- /dev/null
+++ b/src/modules/notifications/routes.ts
@@ -0,0 +1,24 @@
+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: '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: 'active',
+ meta: { title: '消息详情', description: '查看消息正文', permission: 'merchant:notification: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..c588830
--- /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..439146a
--- /dev/null
+++ b/src/modules/notifications/views/NotificationsView.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
消息中心
我的消息
仅显示当前登录企业和成员可见的站内消息。
+
+
+
+ 查看标为已读{{ 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/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs
index 198443d..2c9babd 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: ['我的消息'],
+ },
],
)
})
@@ -89,6 +93,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 59a4a56..f7ca0bf 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 { 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; }
.client-user-copy strong { color: #25324a; font-size: 12px; }
@@ -49,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,