This commit is contained in:
2026-08-12 18:49:54 +08:00
parent 13a856cef9
commit 71b3361361
9 changed files with 67 additions and 48 deletions
+2 -2
View File
@@ -141,7 +141,7 @@ watch(mobileMenuOpen, async (open, wasOpen) => {
}) })
onMounted(() => { onMounted(() => {
void notifications.refreshUnreadCount() if (auth.hasPermission('merchant:notification:view')) void notifications.refreshUnreadCount()
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
window.addEventListener('beforeunload', handleBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload)
desktopMediaQuery = window.matchMedia('(min-width: 721px)') desktopMediaQuery = window.matchMedia('(min-width: 721px)')
@@ -202,7 +202,7 @@ async function handleLogout() {
<p v-if="pageDescription">{{ pageDescription }}</p> <p v-if="pageDescription">{{ pageDescription }}</p>
</div> </div>
<div class="client-user"> <div class="client-user">
<button class="client-header-notifications" type="button" disabled title="消息中心接口待接入" aria-label="我的消息,接口待接入"><Bell :size="19" /><span v-if="notifications.unreadCount !== null && notifications.unreadCount > 0" class="client-header-notifications-badge">{{ notifications.unreadCount > 99 ? '99+' : notifications.unreadCount }}</span></button> <RouterLink v-if="auth.hasPermission('merchant:notification:view')" class="client-header-notifications" to="/notifications" aria-label="打开我的消息"><Bell :size="19" /><span v-if="notifications.unreadCount !== null && notifications.unreadCount > 0" class="client-header-notifications-badge">{{ notifications.unreadCount > 99 ? '99+' : notifications.unreadCount }}</span></RouterLink>
<span class="client-avatar">{{ auth.user?.name.slice(0, 1) }}</span> <span class="client-avatar">{{ auth.user?.name.slice(0, 1) }}</span>
<span class="client-user-copy"><strong>{{ auth.user?.name }}</strong><small>{{ auth.user?.roles.join('、') }}</small></span> <span class="client-user-copy"><strong>{{ auth.user?.name }}</strong><small>{{ auth.user?.roles.join('、') }}</small></span>
</div> </div>
+27 -21
View File
@@ -1,52 +1,58 @@
import { apiRequest, ApiError } from '@/api/http' import { apiRequest } from '@/api/http'
import type { NotificationDetail, NotificationPage, NotificationQuery, NotificationStatus, NotificationSummary } from '../contracts' import type { NotificationDetail, NotificationPage, NotificationQuery, NotificationStatus, NotificationSummary } from '../contracts'
/** /**
* Message-center endpoints are intentionally kept behind this adapter until * The authenticated merchant session supplies the enterprise and member scope.
* the server contract is frozen. No caller may provide a customer/user scope. * No caller may provide tenant, customer, merchant, or user identifiers.
*/ */
interface NotificationWire { interface NotificationWire {
id: string id: string
title: string title: string
summary?: string | null category: string
content?: string | null sentAt: string
type: string readAt?: string | null
sentAt?: string | null
createdAt?: string | null
status: NotificationStatus status: NotificationStatus
body?: string
sourceType?: string | null
sourceId?: string | null
} }
interface NotificationPageWire { interface NotificationPageWire {
records?: NotificationWire[] records: NotificationWire[]
items?: NotificationWire[] total: number
total?: number page: number
page?: number size: number
size?: number pages: number
} }
function mapSummary(item: NotificationWire): NotificationSummary { 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<NotificationPage> { export async function getMyNotifications(query: NotificationQuery = {}): Promise<NotificationPage> {
const response = await apiRequest<NotificationPageWire>({ method: 'GET', url: '/merchant/v1/notifications', params: query }) const response = await apiRequest<NotificationPageWire>({
const records = (response.records ?? response.items ?? []).map(mapSummary) method: 'GET',
return { records, total: response.total ?? records.length, page: response.page ?? query.page ?? 1, size: response.size ?? query.size ?? 20 } 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<NotificationDetail> { export async function getMyNotification(id: string): Promise<NotificationDetail> {
const response = await apiRequest<NotificationWire>({ method: 'GET', url: `/merchant/v1/notifications/${encodeURIComponent(id)}` }) const response = await apiRequest<NotificationWire>({ 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<void> { export async function markMyNotificationRead(id: string): Promise<void> {
await apiRequest<void>({ method: 'POST', url: `/merchant/v1/notifications/${encodeURIComponent(id)}/read` }) await apiRequest<NotificationWire>({ method: 'PUT', url: `/merchant/v1/notifications/${encodeURIComponent(id)}/read` })
} }
export async function markAllMyNotificationsRead(): Promise<void> { export async function markAllMyNotificationsRead(): Promise<void> {
await apiRequest<void>({ 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<number> { export async function getMyUnreadNotificationCount(): Promise<number> {
throw new ApiError('未读数量接口尚未接入,请稍后重试', 'NOTIFICATIONS_UNREAD_COUNT_PENDING') const response = await apiRequest<{ unreadCount: number }>({ method: 'GET', url: '/merchant/v1/notifications/unread-count' })
return response.unreadCount
} }
+2 -2
View File
@@ -1,5 +1,5 @@
export type NotificationStatus = 'UNREAD' | 'READ' export type NotificationStatus = 'UNREAD' | 'READ'
export type NotificationType = 'SYSTEM' | 'BUSINESS' | 'TASK' | string export type NotificationType = string
export interface NotificationSummary { export interface NotificationSummary {
id: string id: string
@@ -26,5 +26,5 @@ export interface NotificationPage {
total: number total: number
page: number page: number
size: number size: number
pages: number
} }
@@ -4,17 +4,22 @@ import test from 'node:test'
const moduleRoot = new URL('./', import.meta.url) 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') const source = await readFile(new URL('routes.ts', moduleRoot), 'utf8')
assert.match(source, /name: 'notifications-list'[\s\S]*release: 'prototype'/) assert.match(source, /name: 'notifications-list'[\s\S]*release: 'active'/)
assert.match(source, /name: 'notification-detail'[\s\S]*release: 'prototype'/) 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 () => { 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') const source = await readFile(new URL('api/notifications.ts', moduleRoot), 'utf8')
assert.doesNotMatch(source, /tenantId|customerId|merchantId|userId/) assert.doesNotMatch(source, /tenantId|customerId|merchantId|userId/)
assert.match(source, /merchant\/v1\/notifications/) assert.match(source, /merchant\/v1\/notifications/)
assert.match(source, /unread-count/)
assert.match(source, /read-all/) 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 () => { test('keeps customer message actions first and covers the requested local states', async () => {
+4 -5
View File
@@ -9,17 +9,16 @@ export const notificationsModule = {
path: 'notifications', path: 'notifications',
name: 'notifications-list', name: 'notifications-list',
component: () => import('./views/NotificationsView.vue'), component: () => import('./views/NotificationsView.vue'),
release: 'prototype', release: 'active',
meta: { title: '我的消息', description: '查看本企业成员可见的站内消息', permission: 'notifications:view' }, meta: { title: '我的消息', description: '查看本企业成员可见的站内消息', permission: 'merchant:notification:view' },
navigation: { key: 'notifications-list', title: '我的消息', icon: Bell }, navigation: { key: 'notifications-list', title: '我的消息', icon: Bell },
}, },
{ {
path: 'notifications/:notificationId', path: 'notifications/:notificationId',
name: 'notification-detail', name: 'notification-detail',
component: () => import('./views/NotificationDetailView.vue'), component: () => import('./views/NotificationDetailView.vue'),
release: 'prototype', release: 'active',
meta: { title: '消息详情', description: '查看消息正文', permission: 'notifications:view', keepAlive: false }, meta: { title: '消息详情', description: '查看消息正文', permission: 'merchant:notification:view', keepAlive: false },
}, },
], ],
} satisfies ClientModuleManifest } satisfies ClientModuleManifest
@@ -3,12 +3,12 @@ import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Bell, RefreshCcw } from '@lucide/vue' import { ArrowLeft, Bell, RefreshCcw } from '@lucide/vue'
import { ApiError } from '@/api/http' import { ApiError } from '@/api/http'
import { useNotificationsStore } from '../public'
import { getMyNotification, markMyNotificationRead } from '../api/notifications' import { getMyNotification, markMyNotificationRead } from '../api/notifications'
import type { NotificationDetail } from '../contracts' import type { NotificationDetail } from '../contracts'
const route = useRoute(); const router = useRouter(); const detail = ref<NotificationDetail | null>(null); const loading = ref(false); const errorMessage = ref('') const route = useRoute(); const router = useRouter(); const notifications = useNotificationsStore(); const detail = ref<NotificationDetail | null>(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}} 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()}) onMounted(()=>{void load()})
</script> </script>
<template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><p class="meta">{{ detail?.sentAt }} · {{ detail?.type }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template> <template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><p class="meta">{{ detail?.sentAt }} · {{ detail?.type }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template>
<style scoped>.notification-detail{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700}.content-card{min-height:240px;padding:24px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.meta{color:var(--muted);font-size:12px}.content{white-space:pre-wrap;line-height:1.8;color:var(--navy)}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}@media(max-width:720px){.content-card{padding:16px}.page-heading h1{font-size:21px}}</style> <style scoped>.notification-detail{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700}.content-card{min-height:240px;padding:24px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.meta{color:var(--muted);font-size:12px}.content{white-space:pre-wrap;line-height:1.8;color:var(--navy)}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}@media(max-width:720px){.content-card{padding:16px}.page-heading h1{font-size:21px}}</style>
@@ -3,22 +3,24 @@ import { onMounted, reactive, ref } from 'vue'
import { Bell, RefreshCcw } from '@lucide/vue' import { Bell, RefreshCcw } from '@lucide/vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ApiError } from '@/api/http' import { ApiError } from '@/api/http'
import { useNotificationsStore } from '../public'
import { getMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notifications' 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 router = useRouter()
const notifications = useNotificationsStore()
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const page = ref<NotificationPage>({ records: [], total: 0, page: 1, size: 20 }) const page = ref<NotificationPage>({ records: [], total: 0, page: 1, size: 20, pages: 0 })
const filters = reactive<{ status?: NotificationStatus }>({}) const filters = reactive<{ status?: NotificationStatus; type?: NotificationType }>({})
function messageOf(error: unknown) { return error instanceof ApiError ? error.message : '消息加载失败,请稍后重试' } 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 = '' 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 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 load() } 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 } }) } function openDetail(row: NotificationSummary) { void router.push({ name: 'notification-detail', params: { notificationId: row.id } }) }
onMounted(() => { void load() }) onMounted(() => { void load() })
</script> </script>
@@ -27,13 +29,14 @@ onMounted(() => { void load() })
<div class="notification-page"> <div class="notification-page">
<header class="page-heading"><div><p class="eyebrow">消息中心</p><h1>我的消息</h1><p>仅显示当前登录企业和成员可见的站内消息。</p></div><Bell :size="34" stroke-width="1.5" aria-hidden="true" /></header> <header class="page-heading"><div><p class="eyebrow">消息中心</p><h1>我的消息</h1><p>仅显示当前登录企业和成员可见的站内消息。</p></div><Bell :size="34" stroke-width="1.5" aria-hidden="true" /></header>
<section class="content-card"> <section class="content-card">
<form class="filter-bar" @submit.prevent="load"><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="消息状态"><el-option label="未读" value="UNREAD" /><el-option label="已读" value="READ" /></el-select><el-button type="primary" native-type="submit" :loading="loading">查询</el-button><el-button :disabled="loading" @click="filters.status = undefined; load()">重置</el-button><el-button class="mark-all" :disabled="loading" @click="markAllRead">全部标为已读</el-button></form> <form class="filter-bar" @submit.prevent="load(1)"><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="消息状态"><el-option label="未读" value="UNREAD" /><el-option label="已读" value="READ" /></el-select><el-input v-model="filters.type" clearable maxlength="64" placeholder="消息分类" aria-label="消息分类" /><el-button type="primary" native-type="submit" :loading="loading">查询</el-button><el-button :disabled="loading" @click="filters.status = undefined; filters.type = undefined; load(1)">重置</el-button><el-button class="mark-all" :disabled="loading" @click="markAllRead">全部标为已读</el-button></form>
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load"><RefreshCcw :size="15" />重试</el-button></div> <div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load(page.page)"><RefreshCcw :size="15" />重试</el-button></div>
<el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column prop="type" label="类型" width="110" /><el-table-column prop="sentAt" label="发送时间" width="170" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>接口接入后,这里会显示本企业成员可见的消息。</span></div></template></el-table> <el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column prop="type" label="分类" width="140" /><el-table-column prop="sentAt" label="发送时间" width="180" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>当前没有可查看的站内消息。</span></div></template></el-table>
<div v-if="!errorMessage && page.total > 0" class="pagination-row"><span>共 {{ page.total }} 条</span><el-pagination :current-page="page.page" :page-size="page.size" layout="prev, pager, next" :total="page.total" @current-change="load" /></div>
</section> </section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.notification-page{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.page-heading p:not(.eyebrow){margin:0;color:var(--muted);font-size:13px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.content-card{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.filter-bar{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.mark-all{margin-left:auto}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px;text-align:center}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}.state-panel p{margin:0}.table-empty{display:flex;align-items:center;flex-direction:column;gap:8px;padding:42px 12px;color:var(--muted)}.table-empty strong{color:var(--navy)}.table-empty span{font-size:12px}@media(max-width:720px){.content-card{padding:12px}.mark-all{margin-left:0;width:100%}.filter-bar .el-select,.filter-bar>.el-button{flex:1}.page-heading h1{font-size:21px}} .notification-page{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.page-heading p:not(.eyebrow){margin:0;color:var(--muted);font-size:13px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.content-card{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.filter-bar{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.mark-all{margin-left:auto}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px;text-align:center}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}.state-panel p{margin:0}.table-empty{display:flex;align-items:center;flex-direction:column;gap:8px;padding:42px 12px;color:var(--muted)}.table-empty strong{color:var(--navy)}.table-empty span{font-size:12px}.pagination-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:14px;color:var(--muted);font-size:12px}@media(max-width:720px){.content-card{padding:12px}.mark-all{margin-left:0;width:100%}.filter-bar .el-select,.filter-bar>.el-button{flex:1}.pagination-row{align-items:flex-start;flex-direction:column}.page-heading h1{font-size:21px}}
</style> </style>
+5
View File
@@ -62,6 +62,10 @@ test('keeps only implemented navigation groups visible', () => {
children: ['企业资料', '企业成员', '角色管理', '菜单权限'], children: ['企业资料', '企业成员', '角色管理', '菜单权限'],
}, },
{ title: '我的信息', children: undefined }, { title: '我的信息', children: undefined },
{
title: '消息中心',
children: ['我的消息'],
},
], ],
) )
}) })
@@ -88,6 +92,7 @@ test('flattens visible leaves with unique paths', () => {
'/organization/roles', '/organization/roles',
'/organization/permissions', '/organization/permissions',
'/account/profile', '/account/profile',
'/notifications',
]) ])
assert.equal(new Set(paths).size, paths.length) assert.equal(new Set(paths).size, paths.length)
assert.ok(leaves.every((leaf) => leaf.permission.trim().length > 0)) assert.ok(leaves.every((leaf) => leaf.permission.trim().length > 0))
+2 -1
View File
@@ -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-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-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 { 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-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-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 { display: flex; flex-direction: column; }
@@ -52,6 +52,7 @@
.client-sidebar-group-trigger:focus-visible, .client-sidebar-group-trigger:focus-visible,
.client-sidebar-link:focus-visible, .client-sidebar-link:focus-visible,
.client-header-menu-button:focus-visible, .client-header-menu-button:focus-visible,
.client-header-notifications:focus-visible,
.client-sidebar-brand button:focus-visible, .client-sidebar-brand button:focus-visible,
.client-sidebar-logout:focus-visible { outline: 2px solid #6db9ff; outline-offset: 2px; } .client-sidebar-logout:focus-visible { outline: 2px solid #6db9ff; outline-offset: 2px; }
.client-sidebar-brand button, .client-sidebar-brand button,