Files
ndm-web-platform/src/pages/station/station-page.vue
yangsy 0c1fb418bd feat: 添加权限查询和管理机制
- 新增权限管理页面
- 改进轮询链,引入权限查询
- 支持订阅权限变更或轮询权限检测变更
- 应用权限到页面和组件
2026-01-22 10:34:37 +08:00

216 lines
9.0 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 setup lang="ts">
import { initStationAlarms, initStationDevices, syncCameraApi, syncNvrChannelsApi, type Station } from '@/apis';
import { AlarmDetailModal, DeviceDetailModal, DeviceParamConfigModal, IcmpExportModal, RecordCheckExportModal, StationCard, type StationCardProps } from '@/components';
import { useBatchActions, useLineDevicesQuery } from '@/composables';
import { useAlarmStore, useDeviceStore, usePermissionStore, useSettingStore } from '@/stores';
import { useMutation } from '@tanstack/vue-query';
import { useElementSize } from '@vueuse/core';
import { isCancel } from 'axios';
import { NButton, NButtonGroup, NCheckbox, NFlex, NGrid, NGridItem, NScrollbar } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, useTemplateRef } from 'vue';
const settingStore = useSettingStore();
const { stationGridCols } = storeToRefs(settingStore);
const permissionStore = usePermissionStore();
const stations = computed(() => permissionStore.stations.VIEW ?? []);
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const alarmStore = useAlarmStore();
const { lineAlarms } = storeToRefs(alarmStore);
const STATION_CARD_MIN_WIDTH = 230;
const STATION_GRID_PADDING = 8;
const STATION_GRID_GAP = 6;
const STATION_GRID_REF_NAME = 'stationGridRef';
const stationGridRef = useTemplateRef<HTMLDivElement>(STATION_GRID_REF_NAME);
const { width: stationGridWidth } = useElementSize(stationGridRef);
// 计算合适的车站布局列数
const actualStationGridColumns = computed(() => {
const currentStationCardWidth = (stationGridWidth.value - STATION_GRID_PADDING * 2 - (stationGridCols.value - 1) * STATION_GRID_GAP) / stationGridCols.value;
// 当卡片宽度大于最小宽度时,说明用户的设置没有问题,直接返回列数
if (currentStationCardWidth > STATION_CARD_MIN_WIDTH) return stationGridCols.value;
// 否则,说明用户的设置不合适,需要根据当前布局宽度重新计算列数
return Math.floor((stationGridWidth.value - STATION_GRID_PADDING * 2 + STATION_GRID_GAP) / STATION_CARD_MIN_WIDTH);
});
const showIcmpExportModal = ref(false);
const showRecordCheckExportModal = ref(false);
const abortController = ref(new AbortController());
const { batchActions, selectedAction, selectableStations, stationSelection, selectionProps, toggleSelectAction, toggleSelectAllStations, confirmAction, cancelAction } = useBatchActions(
stations,
abortController,
);
const { refetch: refetchLineDevicesQuery } = useLineDevicesQuery();
const { mutate: syncCamera, isPending: cameraSyncing } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
const signal = abortController.value.signal;
const stationCodes = Object.entries(stationSelection.value)
.filter(([, selected]) => selected)
.map(([code]) => code);
const requests = await Promise.allSettled(stationCodes.map((stationCode) => syncCameraApi({ stationCode, signal })));
return requests.map((result, index) => ({ ...result, stationCode: stationCodes[index] }));
},
onSuccess: (requests) => {
type PromiseRequest = (typeof requests)[number];
const successRequests: PromiseRequest[] = [];
const failedRequests: PromiseRequest[] = [];
const canceledRequests: PromiseRequest[] = [];
for (const request of requests) {
if (request.status === 'fulfilled') {
successRequests.push(request);
} else if (isCancel(request.reason)) {
canceledRequests.push(request);
} else {
failedRequests.push(request);
}
}
const notices: string[] = [`成功 ${successRequests.length} 个车站`, `失败 ${failedRequests.length} 个车站`];
if (canceledRequests.length > 0) notices.push(`取消 ${canceledRequests.length} 个车站`);
window.$notification.info({
title: '摄像机同步结果',
content: notices.join(''),
duration: 3000,
});
if (successRequests.length > 0) {
// 摄像机同步后,需要重新查询一次设备
refetchLineDevicesQuery();
}
cancelAction();
},
});
const { mutate: syncNvrChannels, isPending: nvrChannelsSyncing } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
const signal = abortController.value.signal;
const stationCodes = Object.entries(stationSelection.value)
.filter(([, selected]) => selected)
.map(([code]) => code);
const requests = await Promise.allSettled(stationCodes.map((stationCode) => syncNvrChannelsApi({ stationCode, signal })));
return requests.map((result, index) => ({ ...result, stationCode: stationCodes[index] }));
},
onSuccess: (requests) => {
type PromiseRequest = (typeof requests)[number];
const successRequests: PromiseRequest[] = [];
const failedRequests: PromiseRequest[] = [];
const canceledRequests: PromiseRequest[] = [];
for (const request of requests) {
if (request.status === 'fulfilled') {
successRequests.push(request);
} else if (isCancel(request.reason)) {
canceledRequests.push(request);
} else {
failedRequests.push(request);
}
}
const notices: string[] = [`成功 ${successRequests.length} 个车站`, `失败 ${failedRequests.length} 个车站`];
if (canceledRequests.length > 0) notices.push(`取消 ${canceledRequests.length} 个车站`);
window.$notification.info({
title: '录像机通道同步结果',
content: notices.join(''),
duration: 3000,
});
cancelAction();
},
});
const confirming = computed(() => cameraSyncing.value || nvrChannelsSyncing.value);
const onClickConfirmAction = () => {
confirmAction({
'export-icmp': () => {
showIcmpExportModal.value = true;
},
'export-record': () => {
showRecordCheckExportModal.value = true;
},
'sync-camera': () => {
syncCamera();
},
'sync-nvr': () => {
syncNvrChannels();
},
});
};
// 车站卡片的事件
const modalStation = ref<Station>();
const showDeviceParamConfigModal = ref(false);
const showDeviceDetailModal = ref(false);
const showAlarmDetailModal = ref(false);
const onClickConfig: StationCardProps['onClickConfig'] = (station) => {
modalStation.value = station;
showDeviceParamConfigModal.value = true;
};
const onClickDetail: StationCardProps['onClickDetail'] = (type, station) => {
modalStation.value = station;
if (type === 'device') {
showDeviceDetailModal.value = true;
}
if (type === 'alarm') {
showAlarmDetailModal.value = true;
}
};
</script>
<template>
<NScrollbar content-style="padding-right: 8px" style="width: 100%; height: 100%">
<!-- 工具栏 -->
<NFlex align="center" :style="{ padding: `${STATION_GRID_PADDING}px ${STATION_GRID_PADDING}px 0 ${STATION_GRID_PADDING}px` }">
<NButtonGroup>
<template v-for="batchAction in batchActions" :key="batchAction.key">
<NButton :secondary="!batchAction.active" :focusable="false" @click="() => toggleSelectAction(batchAction)">{{ batchAction.label }}</NButton>
</template>
</NButtonGroup>
<template v-if="selectedAction">
<NCheckbox label="全选" :disabled="selectionProps.disabled" :checked="selectionProps.checked" :indeterminate="selectionProps.indeterminate" @update:checked="toggleSelectAllStations" />
<NButton tertiary size="small" type="primary" :focusable="false" :loading="confirming" @click="onClickConfirmAction">确定</NButton>
<NButton tertiary size="small" type="tertiary" :focusable="false" @click="cancelAction">取消</NButton>
</template>
</NFlex>
<!-- 车站 -->
<div :ref="STATION_GRID_REF_NAME">
<NGrid :cols="actualStationGridColumns" :x-gap="STATION_GRID_GAP" :y-gap="STATION_GRID_GAP" :style="{ padding: `${STATION_GRID_PADDING}px` }">
<NGridItem v-for="station in stations" :key="station.code">
<StationCard
:station="station"
:devices="lineDevices[station.code] ?? initStationDevices()"
:alarms="lineAlarms[station.code] ?? initStationAlarms()"
:selectable="!!selectableStations.find((selectable) => selectable.code === station.code)"
v-model:selected="stationSelection[station.code]"
@click-detail="onClickDetail"
@click-config="onClickConfig"
/>
</NGridItem>
</NGrid>
</div>
</NScrollbar>
<IcmpExportModal v-model:show="showIcmpExportModal" :stations="stations.filter((station) => stationSelection[station.code])" @after-leave="cancelAction" />
<RecordCheckExportModal v-model:show="showRecordCheckExportModal" :stations="stations.filter((station) => stationSelection[station.code])" @after-leave="cancelAction" />
<DeviceParamConfigModal v-model:show="showDeviceParamConfigModal" :station="modalStation" />
<DeviceDetailModal v-model:show="showDeviceDetailModal" :station="modalStation" />
<AlarmDetailModal v-model:show="showAlarmDetailModal" :station="modalStation" />
</template>
<style scoped lang="scss"></style>