Files
xk-admin/apps/web-antd/src/layouts/basic.vue
李琦 beba5e8feb feat: 订单双视图、工作台日历组件、医生排班管理与挂号语音提醒
- 订单双视图:商品订单/处方/挂号列表新增卡片视图(CardList)、视图切换组件 view-mode-switch、stat-islands 统计岛、constants 字典
- 工作台:新增工作日历 Widget、待办日历、即将到访预约、公告滚动 NoticeTicker、日历面板 CalendarPanel
- 医生排班:新增排班 API、ScheduleDrawer 抽屉、schedule-calendar 组件、门店设置弹窗
- 日志与通知:新增排班变更日志页、排班变更通知视图
- 挂号提醒:新增挂号语音播报资源与 register-notify 工具
- 桌面端:新增 apps/desktop 壳及 desktop 工具方法
- 其他:处方/订单导出、聊天设置与 WebSocket 等小幅优化
2026-08-14 17:46:50 +08:00

380 lines
11 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 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>