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 }}

+ {{ 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 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 @@ + + + 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 @@ + + + + + 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,