Files
ndm-web-platform/src/layouts/app-layout.vue
2026-01-13 13:36:28 +08:00

260 lines
8.5 KiB
Vue

<script setup lang="ts">
import { SettingsDrawer, SyncCameraResultModal } from '@/components';
import { useLineStationsQuery, useStompClient, useVerifyUserQuery } from '@/composables';
import { LINE_ALARMS_QUERY_KEY, LINE_DEVICES_QUERY_KEY, LINE_STATIONS_MUTATION_KEY, LINE_STATIONS_QUERY_KEY, STATION_ALARMS_MUTATION_KEY, STATION_DEVICES_MUTATION_KEY } from '@/constants';
import { useAlarmStore, useSettingStore, useUserStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useIsFetching, useIsMutating, useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import { ChevronDownIcon, ChevronsLeftIcon, ChevronsRightIcon, ComputerIcon, KeyIcon, LogOutIcon, LogsIcon, MapPinIcon, SettingsIcon, SirenIcon } from 'lucide-vue-next';
import {
NBadge,
NButton,
NDropdown,
NFlex,
NIcon,
NLayout,
NLayoutContent,
NLayoutFooter,
NLayoutHeader,
NLayoutSider,
NMenu,
type DropdownOption,
type DropdownProps,
type MenuOption,
} from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, h, ref, watchEffect, type Component, type VNode } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
const alarmStore = useAlarmStore();
const { unreadAlarmCount } = storeToRefs(alarmStore);
const settingStore = useSettingStore();
const { menuCollpased, offlineDev } = storeToRefs(settingStore);
const { syncCameraResult, afterCheckSyncCameraResult } = useStompClient();
useVerifyUserQuery();
useLineStationsQuery();
// 全局loading状态依赖于轮询query的queryKey以及相关的mutationKey
const queryingCount = useIsFetching({
predicate: (query) => {
const pollingKeys = [LINE_STATIONS_QUERY_KEY, LINE_DEVICES_QUERY_KEY, LINE_ALARMS_QUERY_KEY];
const queryKey = query.options.queryKey;
return !!queryKey && Array.isArray(queryKey) && pollingKeys.some((key) => queryKey.includes(key));
},
});
const mutatingCount = useIsMutating({
predicate: (mutation) => {
const mutationKeys = [LINE_STATIONS_MUTATION_KEY, STATION_DEVICES_MUTATION_KEY, STATION_ALARMS_MUTATION_KEY];
const mutationKey = mutation.options.mutationKey;
return !!mutationKey && Array.isArray(mutationKey) && mutationKeys.some((key) => mutationKey.includes(key));
},
});
const appLoading = computed(() => queryingCount.value + mutatingCount.value > 0);
const onToggleMenuCollapsed = () => {
menuCollpased.value = !menuCollpased.value;
};
const menuOptions: MenuOption[] = [
{
label: () => h(RouterLink, { to: '/station' }, { default: () => '车站状态' }),
key: '/station',
icon: renderIcon(MapPinIcon),
},
{
label: () => h(RouterLink, { to: '/device' }, { default: () => '设备诊断' }),
key: '/device',
icon: renderIcon(ComputerIcon),
},
{
label: '设备告警',
key: '/alarm',
icon: renderIcon(SirenIcon),
children: [
{
label: () => h(RouterLink, { to: '/alarm/alarm-log' }, { default: () => '设备告警记录' }),
key: '/alarm/alarm-log',
},
{
label: () => h(RouterLink, { to: '/alarm/alarm-ignore' }, { default: () => '告警忽略管理' }),
key: '/alarm/alarm-ignore',
},
],
},
{
label: '系统日志',
key: '/log',
icon: renderIcon(LogsIcon),
children: [
{
label: () => h(RouterLink, { to: '/log/vimp-log' }, { default: () => '视频平台日志' }),
key: '/log/vimp-log',
},
{
label: () => h(RouterLink, { to: '/log/call-log' }, { default: () => '上级调用日志' }),
key: '/log/call-log',
},
],
},
{
label: () => h(RouterLink, { to: '/permission' }, { default: () => '权限管理' }),
key: '/permission',
show: userStore.isLamp,
icon: renderIcon(KeyIcon),
},
];
const dropdownOptions: DropdownOption[] = [
{
label: '退出登录',
key: 'logout',
icon: renderIcon(LogOutIcon),
onClick: async () => {
try {
await userStore.userLogout();
router.push({ path: '/login' });
} catch (error) {
console.error(error);
window.$message.error('退出登录失败');
}
},
},
];
const onSelectDropdownOption: DropdownProps['onSelect'] = (value: string, option: DropdownOption) => {
if (typeof option['onClick'] === 'function') {
option['onClick']();
}
};
const showSettingsDrawer = ref(false);
const openSettingsDrawer = () => {
showSettingsDrawer.value = true;
};
const routeToRoot = () => {
router.push({ path: '/' });
};
const routeToAlarmPage = () => {
alarmStore.clearUnreadAlarms();
if (route.path !== '/alarm/alarm-log') {
router.push({ path: '/alarm/alarm-log' });
}
};
const { mutate: getUserInfo } = useMutation({
mutationFn: async (params?: { signal?: AbortSignal }) => {
const { signal } = params ?? {};
await userStore.userGetInfo({ signal });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
// 判断是否为离线开发模式 决定是否自动发送获取用户信息请求
watchEffect((onCleanup) => {
if (offlineDev.value) return;
const abortController = new AbortController();
getUserInfo({ signal: abortController.signal });
onCleanup(() => abortController.abort());
});
function renderIcon(icon: Component): () => VNode {
return () => h(NIcon, null, { default: () => h(icon) });
}
</script>
<template>
<NLayout has-sider style="min-width: 1440px">
<!-- 左侧菜单 -->
<NLayoutSider bordered :collapsed="menuCollpased" collapse-mode="width" :collapsed-width="64" @update:collapsed="onToggleMenuCollapsed">
<NFlex vertical justify="space-between" :size="0" style="height: 100%">
<NMenu :collapsed="menuCollpased" :collapsed-width="64" :collapsed-icon-size="20" :value="route.path" :options="menuOptions" />
<NButton block quaternary :focusable="false" @click="onToggleMenuCollapsed">
<template #icon>
<NIcon :component="menuCollpased ? ChevronsRightIcon : ChevronsLeftIcon" />
</template>
</NButton>
</NFlex>
</NLayoutSider>
<!-- 主体内容区域 -->
<NLayout>
<NLayoutHeader bordered class="app-layout-header">
<NFlex justify="space-between" align="center" :size="8" style="width: 100%; height: 100%">
<NFlex align="center">
<h3 style="margin: 0 0 0 16px; cursor: pointer" @click="routeToRoot">网络设备管理平台</h3>
<NButton text size="tiny" :loading="appLoading"></NButton>
</NFlex>
<NFlex align="center" :size="0" style="height: 100%">
<NDropdown trigger="hover" show-arrow :options="dropdownOptions" @select="onSelectDropdownOption">
<NButton :focusable="false" quaternary icon-placement="right" style="height: 100%">
<template #default>
<span>{{ userInfo?.nickName ?? '' }}</span>
</template>
<template #icon>
<NIcon :component="ChevronDownIcon" />
</template>
</NButton>
</NDropdown>
<NButton quaternary :focusable="false" style="height: 100%" @click="openSettingsDrawer">
<template #icon>
<NIcon :component="SettingsIcon" />
</template>
</NButton>
</NFlex>
</NFlex>
</NLayoutHeader>
<NLayoutContent class="app-layout-content">
<RouterView />
</NLayoutContent>
<NLayoutFooter bordered class="app-layout-footer">
<NFlex :align="'center'" style="height: 100%; margin: 0 16px">
<NBadge :value="unreadAlarmCount">
<NButton secondary strong @click="routeToAlarmPage">
<template #icon>
<NIcon :component="SirenIcon" />
</template>
</NButton>
</NBadge>
</NFlex>
</NLayoutFooter>
</NLayout>
</NLayout>
<SettingsDrawer v-model:show="showSettingsDrawer" />
<SyncCameraResultModal :sync-camera-result="syncCameraResult" @after-leave="afterCheckSyncCameraResult" />
</template>
<style scoped lang="scss">
$layout-header-height: 48px;
$layout-footer-height: 48px;
.app-layout-header {
height: $layout-header-height;
}
.app-layout-content {
height: calc(100vh - $layout-header-height - $layout-footer-height);
}
.app-layout-footer {
height: $layout-footer-height;
}
</style>