refactor(projects): 消息提示增加等级区分
This commit is contained in:
@@ -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.level(1=普通 2=提醒 3=警告 4=严重,数字越大越紧急)
|
||||
* 来源口径:`2026-06-13-站内信消息等级-前端对接.html` 明确等级字典为 notify_message_level,
|
||||
* 显示名与颜色(hex)均走字典,前端按 level 取色不硬编码。
|
||||
*/
|
||||
export const NOTIFY_MESSAGE_LEVEL_DICT_CODE = 'notify_message_level';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -93,7 +93,8 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<!-- defer:BaseLayout 二次挂载时 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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -38,7 +38,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<SimpleScrollbar>
|
||||
<ElMenu
|
||||
mode="vertical"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
4
src/typings/api/dict.d.ts
vendored
4
src/typings/api/dict.d.ts
vendored
@@ -57,6 +57,8 @@ declare namespace Api {
|
||||
status: DictStatus;
|
||||
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||
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,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||
colorType?: string | null;
|
||||
/** 精确颜色(hex,#xxxxxx);存在时优先于 colorType */
|
||||
cssClass?: string | null;
|
||||
/** 备注,可用于下拉中文释义展示 */
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
2
src/typings/api/notify-message.d.ts
vendored
2
src/typings/api/notify-message.d.ts
vendored
@@ -25,6 +25,8 @@ declare namespace Api {
|
||||
templateContent: string;
|
||||
/** 消息类型,字典 system_notify_template_type */
|
||||
templateType: number;
|
||||
/** 消息等级(字典 notify_message_level,1=普通 2=提醒 3=警告 4=严重,数字越大越紧急);老消息缺省为普通(1) */
|
||||
level: number;
|
||||
/** 是否已读 */
|
||||
readStatus: boolean;
|
||||
/** 阅读时间;未读为 null */
|
||||
|
||||
@@ -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') : '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user