refactor: 重构项目结构

- 优化 `车站-设备-告警`  轮询机制
- 改进设备卡片的布局
- 支持修改设备
- 告警轮询中获取完整告警数据
- 车站告警详情支持导出完整的 `今日告警列表`
- 支持将状态持久化到 `IndexedDB`
- 新增轮询控制 (调试模式)
- 新增离线开发模式 (调试模式)
- 新增 `IndexedDB` 数据控制 (调试模式)
This commit is contained in:
yangsy
2025-12-11 13:42:22 +08:00
commit 37781216b2
278 changed files with 17988 additions and 0 deletions

225
src/pages/station-page.vue Normal file
View File

@@ -0,0 +1,225 @@
<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 { useLineDevicesQuery } from '@/composables';
import { useAlarmStore, useDeviceStore, useSettingStore, useStationStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { NButton, NButtonGroup, NCheckbox, NFlex, NGrid, NGridItem, NScrollbar } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref } from 'vue';
const settingStore = useSettingStore();
const { stationGridCols: stationGridColumns } = storeToRefs(settingStore);
const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore);
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const alarmStore = useAlarmStore();
const { lineAlarms } = storeToRefs(alarmStore);
const { refetch: refetchLineDevicesQuery } = useLineDevicesQuery();
// 操作栏
// 当点击操作栏中的一个按钮时,其他按钮会被禁用
type Action = 'export-icmp' | 'export-record' | 'sync-camera' | 'sync-nvr' | null;
const selectedAction = ref<Action>(null);
const showOperation = ref(false);
const stationSelectable = ref(false);
const stationSelection = ref<Record<Station['code'], boolean>>({});
const showIcmpExportModal = ref(false);
const showRecordCheckExportModal = ref(false);
const onToggleSelectAll = (checked: boolean) => {
if (!checked) {
stationSelection.value = {};
} else {
stationSelection.value = Object.fromEntries(stations.value.map((station) => [station.code, true]));
}
};
const onAction = (action: Action) => {
selectedAction.value = action;
if (action === null) return;
showOperation.value = true;
stationSelectable.value = true;
};
const onCancel = () => {
selectedAction.value = null;
showOperation.value = false;
stationSelectable.value = false;
stationSelection.value = {};
};
const onFinish = onCancel;
const { mutate: syncCamera, isPending: cameraSyncing } = useMutation({
mutationFn: async () => {
const stationCodes = Object.entries(stationSelection.value)
.filter(([, selected]) => selected)
.map(([code]) => code);
const results = await Promise.allSettled(stationCodes.map((stationCode) => syncCameraApi({ stationCode })));
return results.map((result, index) => ({ ...result, stationCode: stationCodes[index] }));
},
onSuccess: (results) => {
const successCount = results.filter((result) => result.status === 'fulfilled').length;
const failures = results.filter((result) => result.status === 'rejected');
const failureCount = failures.length;
if (failureCount > 0) {
const failedStations = failures.map((f) => stations.value.find((s) => s.code === f.stationCode)?.name).join('、');
if (successCount === 0) {
window.$message.error('摄像机同步全部失败');
window.$message.error(`${failedStations}`);
} else {
window.$message.warning(`摄像机同步完成:成功${successCount}个车站,失败${failureCount}个车站`);
window.$message.warning(`${failedStations}`);
}
} else {
window.$message.success('摄像机同步成功');
}
if (successCount > 0) {
// 摄像机同步后,需要重新查询一次设备,待测试
refetchLineDevicesQuery();
}
onFinish();
},
onError: (error) => {
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
onCancel();
},
});
const { mutate: syncNvrChannels, isPending: nvrChannelsSyncing } = useMutation({
mutationFn: async () => {
const stationCodes = Object.entries(stationSelection.value)
.filter(([, selected]) => selected)
.map(([code]) => code);
const results = await Promise.allSettled(stationCodes.map((stationCode) => syncNvrChannelsApi({ stationCode })));
return results.map((result, index) => ({ ...result, stationCode: stationCodes[index] }));
},
onSuccess: (results) => {
const successCount = results.filter((result) => result.status === 'fulfilled').length;
const failures = results.filter((result) => result.status === 'rejected');
const failureCount = failures.length;
if (failureCount > 0) {
const failedStations = failures.map((failure) => stations.value.find((station) => station.code === failure.stationCode)?.name).join('、');
if (successCount === 0) {
window.$message.error('录像机通道同步全部失败');
window.$message.error(`${failedStations}`);
} else {
window.$message.warning(`录像机通道同步完成:成功${successCount}个车站,失败${failureCount}个车站`);
window.$message.warning(`${failedStations}`);
}
} else {
window.$message.success('录像机通道同步成功');
}
onFinish();
},
onError: (error) => {
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
onCancel();
},
});
const confirming = computed(() => cameraSyncing.value || nvrChannelsSyncing.value);
const onConfirm = async () => {
const noStationSelected = !Object.values(stationSelection.value).some((selected) => selected);
if (selectedAction.value === 'export-icmp') {
if (noStationSelected) {
window.$message.warning('请选择要导出设备状态的车站');
return;
}
showIcmpExportModal.value = true;
} else if (selectedAction.value === 'export-record') {
if (noStationSelected) {
window.$message.warning('请选择要导出录像诊断的车站');
return;
}
showRecordCheckExportModal.value = true;
} else if (selectedAction.value === 'sync-camera') {
if (noStationSelected) {
window.$message.warning('请选择要同步摄像机的车站');
return;
}
syncCamera();
} else if (selectedAction.value === 'sync-nvr') {
if (noStationSelected) {
window.$message.warning('请选择要同步录像机通道的车站');
return;
}
syncNvrChannels();
} else {
return;
}
};
// 车站卡片的事件
const selectedStation = ref<Station>();
const showDeviceParamConfigModal = ref(false);
const showDeviceDetailModal = ref(false);
const showAlarmDetailModal = ref(false);
const onClickConfig: StationCardProps['onClickConfig'] = (station) => {
selectedStation.value = station;
showDeviceParamConfigModal.value = true;
};
const onClickDetail: StationCardProps['onClickDetail'] = (type, station) => {
selectedStation.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: 8px 8px 0 8px">
<NButtonGroup>
<NButton secondary :focusable="false" :disabled="!!selectedAction && selectedAction !== 'export-icmp'" @click="() => onAction('export-icmp')">导出设备状态</NButton>
<NButton secondary :focusable="false" :disabled="!!selectedAction && selectedAction !== 'export-record'" @click="() => onAction('export-record')">导出录像诊断</NButton>
<NButton secondary :focusable="false" :disabled="!!selectedAction && selectedAction !== 'sync-camera'" @click="() => onAction('sync-camera')">同步摄像机</NButton>
<NButton secondary :focusable="false" :disabled="!!selectedAction && selectedAction !== 'sync-nvr'" @click="() => onAction('sync-nvr')">同步录像机通道</NButton>
</NButtonGroup>
<template v-if="showOperation">
<NCheckbox label="全选" @update:checked="onToggleSelectAll" />
<NButton tertiary size="small" type="primary" :focusable="false" :loading="confirming" @click="onConfirm">确定</NButton>
<NButton tertiary size="small" type="tertiary" :focusable="false" :disabled="confirming" @click="onCancel">取消</NButton>
</template>
</NFlex>
<!-- 车站 -->
<NGrid :cols="stationGridColumns" :x-gap="6" :y-gap="6" style="padding: 8px">
<NGridItem v-for="station in stations" :key="station.code">
<StationCard
:station="station"
:devices="lineDevices[station.code] ?? initStationDevices()"
:alarms="lineAlarms[station.code] ?? initStationAlarms()"
:selectable="stationSelectable"
v-model:selected="stationSelection[station.code]"
@click-detail="onClickDetail"
@click-config="onClickConfig"
/>
</NGridItem>
</NGrid>
</NScrollbar>
<IcmpExportModal v-model:show="showIcmpExportModal" :stations="stations.filter((station) => stationSelection[station.code])" @after-leave="onFinish" />
<RecordCheckExportModal v-model:show="showRecordCheckExportModal" :stations="stations.filter((station) => stationSelection[station.code])" @after-leave="onFinish" />
<DeviceParamConfigModal v-model:show="showDeviceParamConfigModal" :station="selectedStation" />
<DeviceDetailModal v-model:show="showDeviceDetailModal" :station="selectedStation" />
<AlarmDetailModal v-model:show="showAlarmDetailModal" :station="selectedStation" />
</template>
<style scoped lang="scss"></style>