2024-05-19 21:20:42 +08:00
|
|
|
|
<script lang="ts" setup>
|
2026-08-10 20:08:50 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 管理端基础布局:铃铛站内信轮询 + 动作宿主挂载
|
|
|
|
|
|
*/
|
2024-08-05 21:12:22 +08:00
|
|
|
|
import type { NotificationItem } from '@vben/layouts';
|
|
|
|
|
|
|
2026-08-10 20:08:50 +08:00
|
|
|
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
|
|
|
|
|
import { useRouter } from 'vue-router';
|
2025-03-19 17:06:36 +08:00
|
|
|
|
|
2026-08-10 20:08:50 +08:00
|
|
|
|
import { AuthenticationLoginExpiredModal, VbenIcon } from '@vben/common-ui';
|
2024-10-06 17:27:32 +08:00
|
|
|
|
import { useWatermark } from '@vben/hooks';
|
2026-08-10 20:08:50 +08:00
|
|
|
|
import { $t } from '@vben/locales';
|
2024-07-11 11:05:01 +08:00
|
|
|
|
import {
|
|
|
|
|
|
BasicLayout,
|
2024-07-12 12:14:09 +08:00
|
|
|
|
LockScreen,
|
2024-07-11 11:05:01 +08:00
|
|
|
|
Notification,
|
|
|
|
|
|
UserDropdown,
|
|
|
|
|
|
} from '@vben/layouts';
|
2026-04-24 15:42:07 +08:00
|
|
|
|
import { preferences, usePreferences } from '@vben/preferences';
|
2024-09-10 21:48:51 +08:00
|
|
|
|
import { useAccessStore, useUserStore } from '@vben/stores';
|
2024-05-19 21:20:42 +08:00
|
|
|
|
|
2026-08-10 20:08:50 +08:00
|
|
|
|
import { notification } from 'ant-design-vue';
|
|
|
|
|
|
import { Bell, Users } from 'lucide-vue-next';
|
2025-04-10 17:08:23 +08:00
|
|
|
|
|
2026-08-05 10:45:58 +08:00
|
|
|
|
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
|
2026-08-11 13:30:21 +08:00
|
|
|
|
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
|
2026-08-10 20:08:50 +08:00
|
|
|
|
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
|
2024-07-30 21:10:28 +08:00
|
|
|
|
import { useAuthStore } from '#/store';
|
2026-08-10 20:08:50 +08:00
|
|
|
|
import { formatTimeToRelative } from '#/util/tool';
|
2024-09-10 21:48:51 +08:00
|
|
|
|
import LoginForm from '#/views/_core/authentication/login.vue';
|
2026-08-10 20:08:50 +08:00
|
|
|
|
import NoticeActionHost from '#/views/notice/action/NoticeActionHost.vue';
|
|
|
|
|
|
import {
|
|
|
|
|
|
emitNoticeRefresh,
|
|
|
|
|
|
onNoticeRefresh,
|
|
|
|
|
|
openNoticeAction,
|
|
|
|
|
|
} from '#/views/notice/action/bus';
|
|
|
|
|
|
import { resolveNoticeColor } from '#/views/notice/action/types';
|
|
|
|
|
|
import {
|
|
|
|
|
|
getNoticeListApi,
|
|
|
|
|
|
getNoticeUnreadCountApi,
|
|
|
|
|
|
readAllApi,
|
|
|
|
|
|
readApi,
|
|
|
|
|
|
} from '#/views/notice/api';
|
2024-06-30 15:03:37 +08:00
|
|
|
|
|
2025-04-10 17:08:23 +08:00
|
|
|
|
const router = useRouter();
|
2026-05-30 16:58:05 +08:00
|
|
|
|
const switchAccountModalRef = ref<InstanceType<typeof SwitchAccountModal>>();
|
2025-04-10 17:08:23 +08:00
|
|
|
|
const notifications = ref<NotificationItem[]>([]);
|
2026-08-10 20:08:50 +08:00
|
|
|
|
const unreadCount = ref(0);
|
2025-04-10 17:08:23 +08:00
|
|
|
|
|
2026-08-10 20:08:50 +08:00
|
|
|
|
/** 拉取未读列表 + 角标数字 */
|
2025-04-10 17:08:23 +08:00
|
|
|
|
const getNoticeList = async () => {
|
|
|
|
|
|
try {
|
2026-08-10 20:08:50 +08:00
|
|
|
|
const [res, countRes] = await Promise.all([
|
|
|
|
|
|
getNoticeListApi({ status: 0, page: 1, pageSize: 20 }),
|
|
|
|
|
|
getNoticeUnreadCountApi().catch(() => null),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const items = res?.items || [];
|
|
|
|
|
|
const prevLen = notifications.value.length;
|
|
|
|
|
|
if (items.length > prevLen && prevLen > 0) {
|
|
|
|
|
|
notification.info({
|
|
|
|
|
|
message: '您有新消息',
|
|
|
|
|
|
description: `您有 ${items.length} 条未读消息请注意查收`,
|
|
|
|
|
|
duration: 5,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
notifications.value = items.map((item: any) => {
|
|
|
|
|
|
const color = resolveNoticeColor(item.type_color);
|
|
|
|
|
|
return {
|
2025-04-11 17:06:02 +08:00
|
|
|
|
id: item.id,
|
2026-08-10 20:08:50 +08:00
|
|
|
|
// Notification 默认用组件当 icon;我们走自定义 content 槽,这里占位即可
|
|
|
|
|
|
icon: Bell as any,
|
|
|
|
|
|
message: item.detail || item.type_name || '',
|
|
|
|
|
|
title: item.title || '通知',
|
2025-04-11 17:06:02 +08:00
|
|
|
|
date: formatTimeToRelative(item.created_at),
|
2026-08-10 20:08:50 +08:00
|
|
|
|
color: color.text,
|
|
|
|
|
|
colorBg: color.bg,
|
|
|
|
|
|
isRead: Number(item.status) === 1,
|
|
|
|
|
|
status: item.status,
|
|
|
|
|
|
type_code: item.type_code,
|
|
|
|
|
|
type_name: item.type_name,
|
|
|
|
|
|
type_icon: item.type_icon,
|
|
|
|
|
|
type_color: item.type_color,
|
|
|
|
|
|
action_type: item.action_type,
|
|
|
|
|
|
action_payload: item.action_payload,
|
|
|
|
|
|
detail: item.detail,
|
|
|
|
|
|
content: item.content,
|
|
|
|
|
|
edit_type: item.edit_type,
|
|
|
|
|
|
priority: item.priority,
|
|
|
|
|
|
raw: item,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
unreadCount.value = Number(
|
|
|
|
|
|
countRes?.count ?? res?.unread_total ?? items.length,
|
|
|
|
|
|
);
|
2025-04-10 17:08:23 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取消息列表失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
getNoticeList();
|
2025-08-28 11:00:39 +08:00
|
|
|
|
let intervalId = setInterval(getNoticeList, 20_000);
|
2025-04-10 17:08:23 +08:00
|
|
|
|
|
|
|
|
|
|
const restartInterval = () => {
|
|
|
|
|
|
clearInterval(intervalId);
|
2026-08-10 20:08:50 +08:00
|
|
|
|
getNoticeList();
|
2025-08-28 11:00:39 +08:00
|
|
|
|
intervalId = setInterval(getNoticeList, 20_000);
|
2025-04-10 17:08:23 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-10 20:08:50 +08:00
|
|
|
|
const handleStorageChange = (event: StorageEvent) => {
|
2025-04-10 17:08:23 +08:00
|
|
|
|
if (event.key === 'readStatus') {
|
|
|
|
|
|
restartInterval();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const originalSetItem = localStorage.setItem.bind(localStorage);
|
|
|
|
|
|
localStorage.setItem = (key, value) => {
|
|
|
|
|
|
originalSetItem(key, value);
|
|
|
|
|
|
if (key === 'readStatus') {
|
2026-08-10 20:08:50 +08:00
|
|
|
|
restartInterval();
|
2025-04-10 17:08:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-10 20:08:50 +08:00
|
|
|
|
window.addEventListener('storage', handleStorageChange);
|
|
|
|
|
|
const stopRefreshListen = onNoticeRefresh(() => restartInterval());
|
|
|
|
|
|
|
2025-04-10 17:08:23 +08:00
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
clearInterval(intervalId);
|
|
|
|
|
|
window.removeEventListener('storage', handleStorageChange);
|
2026-08-10 20:08:50 +08:00
|
|
|
|
localStorage.setItem = originalSetItem;
|
|
|
|
|
|
stopRefreshListen();
|
2025-04-10 17:08:23 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const goNoticeList = () => {
|
|
|
|
|
|
router.push('/notice/list');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-10 20:08:50 +08:00
|
|
|
|
/** 从铃铛列表移除并扣减角标 */
|
|
|
|
|
|
function removeFromBell(item: NotificationItem) {
|
|
|
|
|
|
notifications.value = notifications.value.filter((n) => n.id !== item.id);
|
|
|
|
|
|
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 行点击:立刻取消未读并打开场景 */
|
|
|
|
|
|
const handleItemClick = async (item: NotificationItem) => {
|
|
|
|
|
|
const raw = (item as any).raw || item;
|
|
|
|
|
|
const wasUnread = !item.isRead;
|
|
|
|
|
|
item.isRead = true;
|
|
|
|
|
|
if (wasUnread) {
|
|
|
|
|
|
removeFromBell(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
await openNoticeAction({
|
|
|
|
|
|
...raw,
|
|
|
|
|
|
id: item.id,
|
|
|
|
|
|
status: wasUnread ? 0 : 1,
|
2025-04-10 17:08:23 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
2024-05-19 21:20:42 +08:00
|
|
|
|
|
2026-08-10 20:08:50 +08:00
|
|
|
|
const handleClick = async (item: NotificationItem) => {
|
|
|
|
|
|
await handleItemClick(item);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 勾选已读:只标已读,不打开详情 */
|
|
|
|
|
|
async function handleMarkRead(item: NotificationItem) {
|
|
|
|
|
|
if (item.isRead) return;
|
|
|
|
|
|
item.isRead = true;
|
|
|
|
|
|
removeFromBell(item);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await readApi(Number(item.id));
|
|
|
|
|
|
emitNoticeRefresh();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-07-30 21:10:28 +08:00
|
|
|
|
const userStore = useUserStore();
|
|
|
|
|
|
const authStore = useAuthStore();
|
|
|
|
|
|
const accessStore = useAccessStore();
|
2024-10-06 17:27:32 +08:00
|
|
|
|
const { destroyWatermark, updateWatermark } = useWatermark();
|
2026-04-24 15:42:07 +08:00
|
|
|
|
const { isDark } = usePreferences();
|
2026-08-10 20:08:50 +08:00
|
|
|
|
const showDot = computed(() => unreadCount.value > 0);
|
2024-07-07 16:19:17 +08:00
|
|
|
|
|
2026-08-05 10:45:58 +08:00
|
|
|
|
const showDoctorTransfer = computed(() => {
|
|
|
|
|
|
const info = userStore.userInfo as Record<string, any> | null | undefined;
|
|
|
|
|
|
if (!info) return false;
|
|
|
|
|
|
return !!(info.doctor_id && info.store_id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2024-05-19 21:20:42 +08:00
|
|
|
|
const menus = computed(() => [
|
2025-11-10 17:51:10 +08:00
|
|
|
|
{
|
|
|
|
|
|
handler: () => {
|
|
|
|
|
|
router.push({ name: 'Profile' });
|
|
|
|
|
|
},
|
|
|
|
|
|
icon: 'lucide:user',
|
|
|
|
|
|
text: $t('page.auth.profile'),
|
|
|
|
|
|
},
|
2026-05-30 16:58:05 +08:00
|
|
|
|
{
|
|
|
|
|
|
handler: () => {
|
|
|
|
|
|
switchAccountModalRef.value?.openModal();
|
|
|
|
|
|
},
|
|
|
|
|
|
icon: Users,
|
|
|
|
|
|
text: '切换账号',
|
|
|
|
|
|
},
|
2024-05-19 21:20:42 +08:00
|
|
|
|
]);
|
|
|
|
|
|
|
2024-07-12 12:14:09 +08:00
|
|
|
|
const avatar = computed(() => {
|
2024-07-30 21:10:28 +08:00
|
|
|
|
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
|
2024-07-12 12:14:09 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2024-07-05 23:15:46 +08:00
|
|
|
|
async function handleLogout() {
|
2024-08-19 22:59:42 +08:00
|
|
|
|
await authStore.logout(false);
|
2025-03-07 08:18:45 +08:00
|
|
|
|
destroyWatermark();
|
2024-05-19 21:20:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleNoticeClear() {
|
2025-04-10 17:08:23 +08:00
|
|
|
|
readAllApi().then(() => {
|
|
|
|
|
|
notifications.value = [];
|
2026-08-10 20:08:50 +08:00
|
|
|
|
unreadCount.value = 0;
|
2025-04-10 17:08:23 +08:00
|
|
|
|
notification.success({
|
|
|
|
|
|
message: '标记成功',
|
|
|
|
|
|
duration: 2,
|
|
|
|
|
|
});
|
2026-08-10 20:08:50 +08:00
|
|
|
|
emitNoticeRefresh();
|
2025-04-10 17:08:23 +08:00
|
|
|
|
});
|
2024-05-19 21:20:42 +08:00
|
|
|
|
}
|
2024-07-07 16:19:17 +08:00
|
|
|
|
|
2026-08-10 20:08:50 +08:00
|
|
|
|
/** 全部已读:必须走后端 API */
|
2024-07-07 16:19:17 +08:00
|
|
|
|
function handleMakeAll() {
|
2026-08-10 20:08:50 +08:00
|
|
|
|
readAllApi().then(() => {
|
|
|
|
|
|
notifications.value = [];
|
|
|
|
|
|
unreadCount.value = 0;
|
|
|
|
|
|
notification.success({
|
|
|
|
|
|
message: '标记成功',
|
|
|
|
|
|
duration: 2,
|
2026-04-13 16:05:42 +08:00
|
|
|
|
});
|
2026-08-10 20:08:50 +08:00
|
|
|
|
});
|
2026-04-13 16:05:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-10-06 17:27:32 +08:00
|
|
|
|
watch(
|
2025-10-07 06:15:41 +08:00
|
|
|
|
() => ({
|
|
|
|
|
|
enable: preferences.app.watermark,
|
2026-04-24 15:42:07 +08:00
|
|
|
|
isDark: isDark.value,
|
2025-10-07 06:15:41 +08:00
|
|
|
|
}),
|
2026-08-10 20:08:50 +08:00
|
|
|
|
async ({ enable }) => {
|
2024-10-06 17:27:32 +08:00
|
|
|
|
if (enable) {
|
|
|
|
|
|
await updateWatermark({
|
2025-03-07 08:18:45 +08:00
|
|
|
|
content: `${userStore.userInfo?.nick_name}\r\n${userStore.userInfo?.phone || import.meta.env.VITE_APP_TITLE}`,
|
2024-10-06 17:27:32 +08:00
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
destroyWatermark();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-08-10 20:08:50 +08:00
|
|
|
|
{ immediate: true },
|
2024-10-06 17:27:32 +08:00
|
|
|
|
);
|
2024-05-19 21:20:42 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-06-06 23:24:54 +08:00
|
|
|
|
<BasicLayout
|
|
|
|
|
|
:avatar
|
|
|
|
|
|
:text="userStore.userInfo?.realName"
|
|
|
|
|
|
@clear-preferences-and-logout="handleLogout"
|
|
|
|
|
|
@logout="handleLogout"
|
|
|
|
|
|
>
|
2026-08-05 10:45:58 +08:00
|
|
|
|
<template #header-right-55>
|
|
|
|
|
|
<DoctorTransferFloat v-if="showDoctorTransfer" />
|
|
|
|
|
|
</template>
|
2024-05-19 21:20:42 +08:00
|
|
|
|
<template #user-dropdown>
|
2026-08-11 13:30:21 +08:00
|
|
|
|
<!-- VIP 徽标放在头像左侧,与用户下拉并排 -->
|
|
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
|
<HeaderVipBadge />
|
|
|
|
|
|
<UserDropdown
|
|
|
|
|
|
:avatar
|
|
|
|
|
|
:menus
|
|
|
|
|
|
:text="userStore.userInfo?.nick_name"
|
|
|
|
|
|
:description="userStore.userInfo?.email"
|
|
|
|
|
|
:tag-text="userStore.userInfo?.roles?.name"
|
|
|
|
|
|
@clear-preferences-and-logout="handleLogout"
|
|
|
|
|
|
@logout="handleLogout"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2024-05-19 21:20:42 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<template #notification>
|
|
|
|
|
|
<Notification
|
2024-07-07 16:19:17 +08:00
|
|
|
|
:dot="showDot"
|
2026-08-10 20:08:50 +08:00
|
|
|
|
:count="unreadCount"
|
2024-05-19 21:20:42 +08:00
|
|
|
|
:notifications="notifications"
|
|
|
|
|
|
@clear="handleNoticeClear"
|
2024-07-07 16:19:17 +08:00
|
|
|
|
@make-all="handleMakeAll"
|
2026-08-10 20:08:50 +08:00
|
|
|
|
@read="handleMarkRead"
|
2025-04-10 17:08:23 +08:00
|
|
|
|
@view-all="goNoticeList"
|
2026-04-13 16:05:42 +08:00
|
|
|
|
@on-click="handleClick"
|
2026-08-10 20:08:50 +08:00
|
|
|
|
>
|
|
|
|
|
|
<template #content="{ item }">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="!item.isRead"
|
|
|
|
|
|
class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
|
|
|
|
|
|
></span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl"
|
|
|
|
|
|
:class="[(item as any).colorBg || 'bg-muted', (item as any).color]"
|
|
|
|
|
|
>
|
|
|
|
|
|
<VbenIcon
|
|
|
|
|
|
v-if="(item as any).type_icon"
|
|
|
|
|
|
:icon="(item as any).type_icon"
|
|
|
|
|
|
class="size-5"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Bell v-else class="size-5" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div class="min-w-0 flex-1 pr-8 leading-none">
|
|
|
|
|
|
<div class="mb-1 flex items-center gap-2">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="(item as any).type_name"
|
|
|
|
|
|
class="truncate text-[11px] font-semibold"
|
|
|
|
|
|
:class="(item as any).color"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ (item as any).type_name }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="truncate text-sm font-semibold text-foreground">
|
|
|
|
|
|
{{ item.title }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p
|
|
|
|
|
|
v-if="item.message"
|
|
|
|
|
|
class="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item.message }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="mt-1 text-xs text-muted-foreground">{{ item.date }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Notification>
|
|
|
|
|
|
<NoticeActionHost />
|
2024-05-19 21:20:42 +08:00
|
|
|
|
</template>
|
2024-07-12 12:14:09 +08:00
|
|
|
|
<template #extra>
|
2024-07-11 20:11:11 +08:00
|
|
|
|
<AuthenticationLoginExpiredModal
|
2024-07-30 21:10:28 +08:00
|
|
|
|
v-model:open="accessStore.loginExpired"
|
2024-07-18 21:31:34 +08:00
|
|
|
|
:avatar
|
2024-09-10 21:48:51 +08:00
|
|
|
|
>
|
|
|
|
|
|
<LoginForm />
|
|
|
|
|
|
</AuthenticationLoginExpiredModal>
|
2026-05-30 16:58:05 +08:00
|
|
|
|
<SwitchAccountModal ref="switchAccountModalRef" />
|
2024-07-11 11:05:01 +08:00
|
|
|
|
</template>
|
2024-07-12 12:14:09 +08:00
|
|
|
|
<template #lock-screen>
|
2024-07-23 00:03:59 +08:00
|
|
|
|
<LockScreen :avatar @to-login="handleLogout" />
|
2024-07-12 12:14:09 +08:00
|
|
|
|
</template>
|
2024-05-19 21:20:42 +08:00
|
|
|
|
</BasicLayout>
|
|
|
|
|
|
</template>
|