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