- 订单双视图:商品订单/处方/挂号列表新增卡片视图(CardList)、视图切换组件 view-mode-switch、stat-islands 统计岛、constants 字典 - 工作台:新增工作日历 Widget、待办日历、即将到访预约、公告滚动 NoticeTicker、日历面板 CalendarPanel - 医生排班:新增排班 API、ScheduleDrawer 抽屉、schedule-calendar 组件、门店设置弹窗 - 日志与通知:新增排班变更日志页、排班变更通知视图 - 挂号提醒:新增挂号语音播报资源与 register-notify 工具 - 桌面端:新增 apps/desktop 壳及 desktop 工具方法 - 其他:处方/订单导出、聊天设置与 WebSocket 等小幅优化
380 lines
11 KiB
Vue
380 lines
11 KiB
Vue
<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 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.onUpdateEvent((payload) => {
|
||
if (payload.type === 'available') {
|
||
const version = (payload.data as { version?: string })?.version;
|
||
notification.info({
|
||
message: '发现新版本',
|
||
description: `新版本 ${version ? `v${version} ` : ''}正在后台下载,完成后将提示安装`,
|
||
duration: 5,
|
||
});
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
clearInterval(intervalId);
|
||
window.removeEventListener('storage', handleStorageChange);
|
||
localStorage.setItem = originalSetItem;
|
||
stopRefreshListen();
|
||
// 布局卸载(登出)时清空桌面角标,避免残留未读数
|
||
desktop.setBadge(0);
|
||
// 解绑系统通知跳转回调,避免登出后点击残留通知仍对已登出会话执行 router.push
|
||
desktop.offNavigate();
|
||
});
|
||
|
||
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>
|
||
<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>
|
||
</template>
|