Files
xk-admin/apps/web-antd/src/layouts/basic.vue

346 lines
9.7 KiB
Vue
Raw Normal View History

2024-05-19 21:20:42 +08:00
<script lang="ts" setup>
/**
* 管理端基础布局:铃铛站内信轮询 + 动作宿主挂载
*/
import type { NotificationItem } from '@vben/layouts';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
2025-03-19 17:06:36 +08:00
import { AuthenticationLoginExpiredModal, VbenIcon } from '@vben/common-ui';
import { useWatermark } from '@vben/hooks';
import { $t } from '@vben/locales';
import {
BasicLayout,
LockScreen,
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
2024-05-19 21:20:42 +08:00
import { notification } from 'ant-design-vue';
import { Bell, Users } from 'lucide-vue-next';
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
2024-07-30 21:10:28 +08:00
import { useAuthStore } from '#/store';
import { formatTimeToRelative } from '#/util/tool';
import LoginForm from '#/views/_core/authentication/login.vue';
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';
const router = useRouter();
2026-05-30 16:58:05 +08:00
const switchAccountModalRef = ref<InstanceType<typeof SwitchAccountModal>>();
const notifications = ref<NotificationItem[]>([]);
const unreadCount = ref(0);
/** 拉取未读列表 + 角标数字 */
const getNoticeList = async () => {
try {
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 {
id: item.id,
// Notification 默认用组件当 icon;我们走自定义 content 槽,这里占位即可
icon: Bell as any,
message: item.detail || item.type_name || '',
title: item.title || '通知',
date: formatTimeToRelative(item.created_at),
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,
);
} catch (error) {
console.error('获取消息列表失败:', error);
}
};
getNoticeList();
let intervalId = setInterval(getNoticeList, 20_000);
const restartInterval = () => {
clearInterval(intervalId);
getNoticeList();
intervalId = setInterval(getNoticeList, 20_000);
};
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'readStatus') {
restartInterval();
}
};
const originalSetItem = localStorage.setItem.bind(localStorage);
localStorage.setItem = (key, value) => {
originalSetItem(key, value);
if (key === 'readStatus') {
restartInterval();
}
};
window.addEventListener('storage', handleStorageChange);
const stopRefreshListen = onNoticeRefresh(() => restartInterval());
onUnmounted(() => {
clearInterval(intervalId);
window.removeEventListener('storage', handleStorageChange);
localStorage.setItem = originalSetItem;
stopRefreshListen();
});
const goNoticeList = () => {
router.push('/notice/list');
};
/** 从铃铛列表移除并扣减角标 */
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,
});
};
2024-05-19 21:20:42 +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();
const { destroyWatermark, updateWatermark } = useWatermark();
const { isDark } = usePreferences();
const showDot = computed(() => unreadCount.value > 0);
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
]);
const avatar = computed(() => {
2024-07-30 21:10:28 +08:00
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
});
async function handleLogout() {
await authStore.logout(false);
2025-03-07 08:18:45 +08:00
destroyWatermark();
2024-05-19 21:20:42 +08:00
}
function handleNoticeClear() {
readAllApi().then(() => {
notifications.value = [];
unreadCount.value = 0;
notification.success({
message: '标记成功',
duration: 2,
});
emitNoticeRefresh();
});
2024-05-19 21:20:42 +08:00
}
/** 全部已读:必须走后端 API */
function handleMakeAll() {
readAllApi().then(() => {
notifications.value = [];
unreadCount.value = 0;
notification.success({
message: '标记成功',
duration: 2,
2026-04-13 16:05:42 +08:00
});
});
2026-04-13 16:05:42 +08:00
}
watch(
() => ({
enable: preferences.app.watermark,
isDark: isDark.value,
}),
async ({ enable }) => {
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}`,
});
} else {
destroyWatermark();
}
},
{ immediate: true },
);
2024-05-19 21:20:42 +08:00
</script>
<template>
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #header-right-55>
<DoctorTransferFloat v-if="showDoctorTransfer" />
</template>
2024-05-19 21:20:42 +08:00
<template #user-dropdown>
<!-- 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
:dot="showDot"
:count="unreadCount"
2024-05-19 21:20:42 +08:00
:notifications="notifications"
@clear="handleNoticeClear"
@make-all="handleMakeAll"
@read="handleMarkRead"
@view-all="goNoticeList"
2026-04-13 16:05:42 +08:00
@on-click="handleClick"
>
<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>
<template #extra>
<AuthenticationLoginExpiredModal
2024-07-30 21:10:28 +08:00
v-model:open="accessStore.loginExpired"
2024-07-18 21:31:34 +08:00
:avatar
>
<LoginForm />
</AuthenticationLoginExpiredModal>
2026-05-30 16:58:05 +08:00
<SwitchAccountModal ref="switchAccountModalRef" />
</template>
<template #lock-screen>
<LockScreen :avatar @to-login="handleLogout" />
</template>
2024-05-19 21:20:42 +08:00
</BasicLayout>
</template>