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

411 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
/**
* 管理端基础布局:铃铛站内信轮询 + 动作宿主挂载
*/
import type { NotificationItem } from '@vben/layouts';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
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';
import { notification } from 'ant-design-vue';
import { Bell, Users } from 'lucide-vue-next';
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
import PublicAccountLockBanner from '#/components/public-account-lock/PublicAccountLockBanner.vue';
import PublicAccountLockHeaderChip from '#/components/public-account-lock/PublicAccountLockHeaderChip.vue';
import PublicAccountLockOverlay from '#/components/public-account-lock/PublicAccountLockOverlay.vue';
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
import { useAuthStore } from '#/store';
import { desktop, isElectron } from '#/util/desktop';
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();
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,
});
// 桌面端且窗口未聚焦时补发系统通知(页内 Toast 此时用户看不到),点击跳消息中心
if (isElectron() && !document.hasFocus()) {
desktop.notify({
title: '您有新消息',
body: `您有 ${items.length} 条未读消息请注意查收`,
route: '/notice/list',
});
desktop.flashFrame();
}
}
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());
// 桌面端:未读数变化同步任务栏/Dock 角标(浏览器环境 no-op)
watch(unreadCount, (count) => desktop.setBadge(count));
// 桌面端:系统通知点击后主进程回传目标路由,这里执行实际跳转
desktop.onNavigate((route) => {
router.push(route);
});
// 桌面端:托盘「切换账号」→ 打开与头像下拉相同的切换账号弹窗
desktop.onOpenSwitchAccount(() => {
switchAccountModalRef.value?.openModal();
});
// 桌面端:自动更新事件轻量提示(下载完成后的安装确认由主进程弹窗负责;强制更新则主进程直接重启)
desktop.onUpdateEvent((payload) => {
if (payload.type === 'available') {
const version = (payload.data as { version?: string })?.version;
notification.info({
message: '发现新版本',
description: `新版本 ${version ? `v${version} ` : ''}正在后台下载,完成后将提示安装`,
duration: 5,
});
} else if (payload.type === 'progress') {
const percent = (payload.data as { percent?: number })?.percent;
// 约每 25% 提示一次(25/50/75/100),避免刷屏与 0% 噪音
if (typeof percent === 'number' && percent >= 25 && percent % 25 === 0) {
notification.info({
message: '正在下载更新',
description: `已下载 ${percent}%`,
duration: 2,
key: 'desktop-update-progress',
});
}
} else if (payload.type === 'downloaded') {
const version = (payload.data as { version?: string })?.version;
notification.success({
message: '更新已下载完成',
description: `新版本 ${version ? `v${version} ` : ''}已就绪,请按提示重启安装`,
duration: 6,
key: 'desktop-update-progress',
});
}
});
onUnmounted(() => {
clearInterval(intervalId);
window.removeEventListener('storage', handleStorageChange);
localStorage.setItem = originalSetItem;
stopRefreshListen();
// 布局卸载(登出)时清空桌面角标,避免残留未读数
desktop.setBadge(0);
// 解绑系统通知跳转回调,避免登出后点击残留通知仍对已登出会话执行 router.push
desktop.offNavigate();
desktop.offOpenSwitchAccount();
});
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,
});
};
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 */
}
}
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);
});
const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{
handler: () => {
switchAccountModalRef.value?.openModal();
},
icon: Users,
text: '切换账号',
},
]);
const avatar = computed(() => {
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
});
async function handleLogout() {
await authStore.logout(false);
destroyWatermark();
}
function handleNoticeClear() {
readAllApi().then(() => {
notifications.value = [];
unreadCount.value = 0;
notification.success({
message: '标记成功',
duration: 2,
});
emitNoticeRefresh();
});
}
/** 全部已读:必须走后端 API */
function handleMakeAll() {
readAllApi().then(() => {
notifications.value = [];
unreadCount.value = 0;
notification.success({
message: '标记成功',
duration: 2,
});
});
}
watch(
() => ({
enable: preferences.app.watermark,
isDark: isDark.value,
}),
async ({ enable }) => {
if (enable) {
await updateWatermark({
content: `${userStore.userInfo?.nick_name}\r\n${userStore.userInfo?.phone || import.meta.env.VITE_APP_TITLE}`,
});
} else {
destroyWatermark();
}
},
{ immediate: true },
);
</script>
<template>
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #header-right-55>
<PublicAccountLockHeaderChip />
<DoctorTransferFloat v-if="showDoctorTransfer" />
</template>
<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>
</template>
<template #notification>
<Notification
:dot="showDot"
:count="unreadCount"
:notifications="notifications"
@clear="handleNoticeClear"
@make-all="handleMakeAll"
@read="handleMarkRead"
@view-all="goNoticeList"
@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 />
</template>
<template #extra>
<AuthenticationLoginExpiredModal
v-model:open="accessStore.loginExpired"
:avatar
>
<LoginForm />
</AuthenticationLoginExpiredModal>
<SwitchAccountModal ref="switchAccountModalRef" />
</template>
<template #lock-screen>
<LockScreen :avatar @to-login="handleLogout" />
</template>
</BasicLayout>
<PublicAccountLockBanner />
<PublicAccountLockOverlay />
</template>