refactor(projects): 消息提示增加等级区分

This commit is contained in:
2026-06-13 14:59:31 +08:00
parent 80f028bcb9
commit 609a01dc8a
13 changed files with 167 additions and 34 deletions

View File

@@ -119,3 +119,12 @@ export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficu
* 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration
*/
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
/**
* 站内信消息等级字典编码
*
* 对应业务字段:站内信 NotifyMessage.level1=普通 2=提醒 3=警告 4=严重,数字越大越紧急)
* 来源口径:`2026-06-13-站内信消息等级-前端对接.html` 明确等级字典为 notify_message_level
* 显示名与颜色hex均走字典前端按 level 取色不硬编码。
*/
export const NOTIFY_MESSAGE_LEVEL_DICT_CODE = 'notify_message_level';

View File

@@ -1,18 +1,22 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
import { NOTIFY_MESSAGE_LEVEL_DICT_CODE } from '@/constants/dict';
import {
fetchGetMyNotifyMessagePage,
fetchGetUnreadNotifyCount,
fetchUpdateAllNotifyMessageRead,
fetchUpdateNotifyMessageRead
} from '@/service/api';
import { formatRelativeTime } from '@/utils/datetime';
import { useDictStore } from '@/store/modules/dict';
import { formatDateTime, formatRelativeTime } from '@/utils/datetime';
defineOptions({ name: 'NotificationBell' });
const dictStore = useDictStore();
const PAGE_SIZE = 10;
const UNREAD_COUNT_POLL_INTERVAL = 30 * 1000;
const UNREAD_COUNT_POLL_INTERVAL = 15 * 1000;
type TabKey = 'unread' | 'read';
@@ -43,10 +47,18 @@ const drawerOpen = ref(false);
const activeTab = ref<TabKey>('unread');
const searchKeyword = ref('');
const detailVisible = ref(false);
const detailMessage = ref<Api.NotifyMessage.NotifyMessage | null>(null);
function keywordParam() {
return searchKeyword.value.trim() || undefined;
}
/** 列表圆点颜色:跟随消息等级(与等级徽标同一字典色源);取不到时回 undefined由 CSS 兜底 */
function levelDotColor(level: number) {
return dictStore.getDictItem(NOTIFY_MESSAGE_LEVEL_DICT_CODE, level)?.colorType ?? undefined;
}
async function refreshUnreadCount() {
const { data, error } = await fetchGetUnreadNotifyCount();
if (!error && typeof data === 'number') {
@@ -180,6 +192,16 @@ async function markRead(item: Api.NotifyMessage.NotifyMessage) {
}
}
function openDetail(row: Api.NotifyMessage.NotifyMessage) {
// 弹框持有该行引用,正文不随未读列表移除而消失
detailMessage.value = row;
detailVisible.value = true;
// 未读消息「打开即已读」:后台静默标记,避免"看一半就跑到已读"
if (!row.readStatus) {
markRead(row);
}
}
async function markAllRead() {
const { error } = await fetchUpdateAllNotifyMessageRead();
if (error) return;
@@ -193,6 +215,8 @@ async function markAllRead() {
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
// 等级徽标颜色/文案走字典:若未在登录缓存内则按编码补拉一次(已缓存时不发请求)
dictStore.ensureDictData(NOTIFY_MESSAGE_LEVEL_DICT_CODE);
refreshUnreadCount();
pollTimer = setInterval(() => {
if (document.hidden) return;
@@ -253,12 +277,15 @@ onBeforeUnmount(() => {
v-for="row in listStates.unread.items"
:key="row.id"
class="notification-bell__row is-unread"
@click="markRead(row)"
@click="openDetail(row)"
>
<span class="notification-bell__row-dot" />
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
<div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
</div>
</div>
</li>
</ul>
@@ -273,18 +300,23 @@ onBeforeUnmount(() => {
<ElTabPane name="read">
<template #label>
<span class="notification-bell__tab-label">
已读
<span class="notification-bell__tab-count">{{ listStates.read.total }}</span>
</span>
<span class="notification-bell__tab-label">已读</span>
</template>
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
<ul v-if="listStates.read.items.length > 0" class="notification-bell__list">
<li v-for="row in listStates.read.items" :key="row.id" class="notification-bell__row">
<span class="notification-bell__row-dot" />
<li
v-for="row in listStates.read.items"
:key="row.id"
class="notification-bell__row"
@click="openDetail(row)"
>
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
<div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
</div>
</div>
</li>
</ul>
@@ -303,6 +335,28 @@ onBeforeUnmount(() => {
<ElButton @click="closeDrawer">关闭</ElButton>
</template>
</ElDrawer>
<ElDialog v-model="detailVisible" width="520px" align-center class="notification-bell__detail">
<template #header>
<div class="notification-bell__detail-head">
<span class="notification-bell__detail-sender">{{ detailMessage?.templateNickname || '系统通知' }}</span>
<DictTag
v-if="detailMessage"
:dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE"
:value="detailMessage.level"
size="small"
round
/>
</div>
</template>
<div v-if="detailMessage" class="notification-bell__detail-body">
<div class="notification-bell__detail-content">{{ detailMessage.templateContent }}</div>
<div class="notification-bell__detail-time">收到于 {{ formatDateTime(detailMessage.createTime) }}</div>
</div>
<template #footer>
<ElButton @click="detailVisible = false">关闭</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
@@ -484,18 +538,15 @@ onBeforeUnmount(() => {
gap: 10px;
padding: 12px 4px;
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease;
}
.notification-bell__row + .notification-bell__row {
border-top: 1px dashed var(--el-border-color-lighter);
}
.notification-bell__row.is-unread {
cursor: pointer;
transition: background-color 120ms ease;
}
.notification-bell__row.is-unread:hover {
.notification-bell__row:hover {
background-color: var(--el-fill-color-light);
}
@@ -527,8 +578,14 @@ onBeforeUnmount(() => {
font-weight: 500;
}
.notification-bell__row-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.notification-bell__row-time {
margin-top: 4px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
@@ -547,4 +604,41 @@ onBeforeUnmount(() => {
font-size: 12px;
user-select: none;
}
.notification-bell__detail-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.notification-bell__detail-head {
display: flex;
align-items: center;
gap: 10px;
padding-right: 8px;
min-width: 0;
}
.notification-bell__detail-sender {
min-width: 0;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-bell__detail-content {
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.notification-bell__detail-time {
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>

View File

@@ -12,7 +12,7 @@ const { selectedKeyDummy, handleSelect } = useMenu();
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu
ellipsis
class="w-full"

View File

@@ -93,7 +93,8 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<!-- deferBaseLayout 二次挂载时 GlobalMenu 已缓存为同步挂载目标 div 还未插入 document不延迟解析会静默失败且不重试 -->
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
<div class="mix-header-nav size-full min-w-0 flex-y-center">
<button
v-if="activeFirstLevelMenu"
@@ -161,7 +162,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
</div>
</div>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<FirstLevelMenu
:menus="allMenus"
:active-menu-key="activeFirstLevelMenuKey"

View File

@@ -55,7 +55,7 @@ watch(
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu
ellipsis
class="w-full"
@@ -66,7 +66,7 @@ watch(
<MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" />
</ElMenu>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar>
<ElMenu
mode="vertical"

View File

@@ -38,7 +38,7 @@ watch(
</script>
<template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar>
<ElMenu
mode="vertical"

View File

@@ -90,7 +90,7 @@ watch(
</script>
<template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu
:menus="allMenus"

View File

@@ -19,6 +19,7 @@ function createBatchDeleteQuery(ids: number[]) {
type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & {
colorType?: string | null;
color_type?: string | null;
css_class?: string | null;
};
type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & {
@@ -28,6 +29,7 @@ type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'>
type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & {
colorType?: string | null;
color_type?: string | null;
css_class?: string | null;
};
type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>;
@@ -37,20 +39,22 @@ function normalizeColorType(value?: string | null) {
}
function normalizeDictData(data: DictDataResponse): Api.Dict.DictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
return {
...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase),
cssClass: normalizeColorType(data.cssClass ?? cssClassFromSnakeCase)
};
}
function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
return {
...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase),
cssClass: normalizeColorType(data.cssClass ?? cssClassFromSnakeCase)
};
}

View File

@@ -4,8 +4,10 @@ import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJso
const NOTIFY_MESSAGE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notify-message`;
type NotifyMessageResponse = Omit<Api.NotifyMessage.NotifyMessage, 'id'> & {
type NotifyMessageResponse = Omit<Api.NotifyMessage.NotifyMessage, 'id' | 'level'> & {
id: string | number;
/** 后端老消息可能不带 level按可空接收normalize 时回落普通(1) */
level?: number | null;
};
type MyNotifyMessagePageResponse = Omit<Api.NotifyMessage.PageResult<Api.NotifyMessage.NotifyMessage>, 'list'> & {
@@ -15,7 +17,8 @@ type MyNotifyMessagePageResponse = Omit<Api.NotifyMessage.PageResult<Api.NotifyM
function normalizeNotifyMessage(data: NotifyMessageResponse): Api.NotifyMessage.NotifyMessage {
return {
...data,
id: normalizeStringId(data.id)
id: normalizeStringId(data.id),
level: data.level ?? 1
};
}

View File

@@ -28,6 +28,15 @@ function normalizeColorType(raw: unknown): string | null {
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null;
}
/**
* 解析字典项最终展示色hex
* 精确色 cssClass 优先(覆盖 colorType 落到语义色无法区分黄/橙等场景),其次 colorType
* 两者都不是合法 hex 时回落 null默认渲染
*/
function resolveDisplayColor(colorType: unknown, cssClass: unknown): string | null {
return normalizeColorType(cssClass) ?? normalizeColorType(colorType);
}
function normalizeFrontendDictData(
dictType: string,
list: Api.Dict.FrontendDictData[],
@@ -40,7 +49,7 @@ function normalizeFrontendDictData(
dictType: item.dictType || dictType,
sort: item.sort,
status: item.status ?? 0,
colorType: normalizeColorType(item.colorType),
colorType: resolveDisplayColor(item.colorType, item.cssClass),
remark: item.remark ?? null,
createTime: 0
}));
@@ -54,7 +63,7 @@ function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.D
value: String(item.value),
dictType: item.dictType || dictType,
status: item.status ?? 0,
colorType: normalizeColorType(item.colorType),
colorType: resolveDisplayColor(item.colorType, item.cssClass),
remark: item.remark ?? null
};
}

View File

@@ -57,6 +57,8 @@ declare namespace Api {
status: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null;
/** 精确颜色hex#xxxxxx存在时优先于 colorType用于 colorType 落到语义色无法区分的场景 */
cssClass?: string | null;
/** remark */
remark?: string | null;
/** create time */
@@ -77,6 +79,8 @@ declare namespace Api {
status?: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null;
/** 精确颜色hex#xxxxxx存在时优先于 colorType */
cssClass?: string | null;
/** 备注,可用于下拉中文释义展示 */
remark?: string | null;
}

View File

@@ -25,6 +25,8 @@ declare namespace Api {
templateContent: string;
/** 消息类型,字典 system_notify_template_type */
templateType: number;
/** 消息等级(字典 notify_message_level1=普通 2=提醒 3=警告 4=严重,数字越大越紧急);老消息缺省为普通(1) */
level: number;
/** 是否已读 */
readStatus: boolean;
/** 阅读时间;未读为 null */

View File

@@ -18,3 +18,10 @@ export function formatRelativeTime(value: string | number) {
return time.format('YYYY-MM-DD HH:mm');
}
/** 绝对时间展示YYYY-MM-DD HH:mm空值或非法值回空串 */
export function formatDateTime(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') return '';
const time = dayjs(value);
return time.isValid() ? time.format('YYYY-MM-DD HH:mm') : '';
}