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

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmCameraResultVO, Station } from '@/apis';
import { CameraCurrentDiag, CameraHistoryDiag, CameraUpdate, DeviceRawCard } from '@/components';
import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
ndmDevice: NdmCameraResultVO;
station: Station;
}>();
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const showPageHeader = computed(() => {
return !!route.query['from'];
});
const onBack = () => {
router.push({ path: `${route.query['from']}` });
};
const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => {
activeTabName.value = name;
};
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) {
activeTabName.value = '当前诊断';
}
});
</script>
<template>
<NCard hoverable style="height: 100%" :header-style="{ padding: '12px' }" :content-style="{ height: '100%', padding: '0', overflow: 'hidden' }">
<template #header>
<NPageHeader v-if="showPageHeader" @back="onBack" />
<NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab>
</NTabs>
</template>
<template #default>
<NScrollbar x-scrollable :content-style="{ padding: '0 12px 12px 12px' }">
<template v-if="activeTabName === '当前诊断'">
<CameraCurrentDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '历史诊断'">
<CameraHistoryDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '修改设备'">
<CameraUpdate :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '原始数据'">
<DeviceRawCard :ndm-device="ndmDevice" />
</template>
</NScrollbar>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { NdmCameraResultVO, Station } from '@/apis';
import { DeviceCommonCard, DeviceHeaderCard } from '@/components';
import { NFlex } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmCameraResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const commonInfo = computed(() => {
const {
createdTime,
updatedTime,
manufacturer,
gb28181Enabled,
onvifPort,
onvifUsername,
onvifPassword,
onvifMajorIndex,
onvifMinorIndex,
icmpEnabled,
community,
//
} = ndmDevice.value;
return {
创建时间: createdTime ?? '-',
更新时间: updatedTime ?? '-',
制造商: manufacturer ?? '-',
GB28181启用: `${!!gb28181Enabled ? '是' : '否'}`,
ONVIF端口: `${onvifPort ?? '-'}`,
ONVIF用户名: onvifUsername ?? '-',
ONVIF密码: onvifPassword ?? '-',
ONVIF主流索引: `${onvifMajorIndex ?? '-'}`,
ONVIF辅流索引: `${onvifMinorIndex ?? '-'}`,
ICMP启用: `${!!icmpEnabled ? '是' : '否'}`,
团体字符串: community ?? '-',
};
});
</script>
<template>
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceCommonCard :common-info="commonInfo" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { NdmCameraResultVO, Station } from '@/apis';
import { DeviceAlarmHistoryCard, DeviceIcmpHistoryCard, HistoryDiagFilterCard, type DeviceAlarmHistoryCardProps, type DeviceIcmpHistoryCardProps } from '@/components';
import dayjs from 'dayjs';
import { NFlex, type SelectOption } from 'naive-ui';
import { onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmCameraResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const historyDiagOptions: SelectOption[] = [
{ label: '设备状态', value: 'icmp' },
{ label: '设备告警', value: 'alarm' },
];
const getWeekRange = (): [number, number] => {
const now = dayjs();
const todayEnd = now.endOf('date');
const weekAgo = now.subtract(1, 'week').startOf('date');
return [weekAgo.valueOf(), todayEnd.valueOf()];
};
const range = ref<[number, number]>(getWeekRange());
const selected = ref<string[]>([...historyDiagOptions.map((option) => `${option.value}`)]);
const loading = ref<boolean>(false);
const icmpLoading = ref<boolean>(false);
const alarmLoading = ref<boolean>(false);
watch([icmpLoading, alarmLoading], (loadings) => {
loading.value = loadings.some((loading) => loading);
});
const icmpHistoryQueryFn = ref<() => void>();
const onExposeIcmpHistoryQueryFn: DeviceIcmpHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
icmpHistoryQueryFn.value = queryFn;
};
const alarmHistoryQueryFn = ref<() => void>();
const onExposeAlarmHistoryQueryFn: DeviceAlarmHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
alarmHistoryQueryFn.value = queryFn;
};
const queryData = () => {
if (selected.value.includes('icmp')) icmpHistoryQueryFn.value?.();
if (selected.value.includes('alarm')) alarmHistoryQueryFn.value?.();
};
const onQuery = () => {
queryData();
};
onMounted(() => {
queryData();
});
</script>
<template>
<NFlex vertical>
<HistoryDiagFilterCard :options="historyDiagOptions" v-model:loading="loading" v-model:range="range" v-model:selected="selected" @query="onQuery" />
<DeviceIcmpHistoryCard
v-if="selected.includes('icmp')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="icmpLoading"
@expose-query-fn="onExposeIcmpHistoryQueryFn"
/>
<DeviceAlarmHistoryCard
v-if="selected.includes('alarm')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeAlarmHistoryQueryFn"
/>
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { detailCameraApi, icmpEntityByDeviceId, updateCameraApi, type NdmCameraResultVO, type NdmCameraUpdateVO, type Station } from '@/apis';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { isString } from 'es-toolkit';
import { NButton, NCard, NFlex, NForm, NFormItem, NFormItemGi, NGrid, NInput, NSwitch, type FormInst, type FormRules } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs, useTemplateRef, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmCameraResultVO;
station: Station;
}>();
const deviceStore = useDeviceStore();
const { ndmDevice, station } = toRefs(props);
const localDevice = ref<NdmCameraUpdateVO>({ ...ndmDevice.value });
watch(ndmDevice, (newDevice) => {
localDevice.value = { ...newDevice };
});
const canEditDeviceId = computed(() => {
const { deviceId } = ndmDevice.value;
if (!isString(deviceId)) return true;
if (deviceId.length === 0) return true;
return false;
});
const validatorAbortController = ref<AbortController>(new AbortController());
const abortController = ref<AbortController>(new AbortController());
const formInst = useTemplateRef<FormInst>('formInst');
const formRules: FormRules = {
deviceId: {
trigger: ['input'],
asyncValidator: async (rule, value: string) => {
await validateDeviceIdDuplicated({ deviceId: value }).catch((error) => {
if (isCancel(error)) return;
throw error;
});
},
},
};
const { mutateAsync: validateDeviceIdDuplicated } = useMutation({
mutationFn: async (params: { deviceId?: string }) => {
const { deviceId } = params;
if (!deviceId) throw new Error('请输入设备ID');
const deviceIdPattern = /^\d{4}06\d{4}$/;
if (!deviceIdPattern.test(deviceId)) throw new Error('设备ID不符合规范');
validatorAbortController.value.abort();
validatorAbortController.value = new AbortController();
const icmpEntities = await icmpEntityByDeviceId(deviceId, {
stationCode: station.value.code,
signal: validatorAbortController.value.signal,
});
if (icmpEntities.length > 0) throw new Error('该设备ID已存在');
},
});
const { mutate: updateDevice, isPending: loading } = useMutation({
mutationFn: async () => {
await formInst.value?.validate().catch(() => {
window.$message.error('表单验证失败');
return;
});
abortController.value.abort();
abortController.value = new AbortController();
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateCameraApi(localDevice.value, { stationCode, signal });
const result = await detailCameraApi(`${localDevice.value.id}`, { stationCode, signal });
return result;
},
onSuccess: (newDevice) => {
localDevice.value = { ...newDevice };
deviceStore.patchDevice(station.value.code, { ...newDevice });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
validatorAbortController.value.abort();
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<NForm size="small" :ref="'formInst'" :model="localDevice" :rules="formRules">
<NGrid>
<NFormItemGi span="8" label-placement="left" label="ICMP启用">
<NSwitch :value="destr(localDevice.icmpEnabled)" @update:value="(enabled: boolean) => (localDevice.icmpEnabled = enabled)" />
</NFormItemGi>
<NFormItemGi span="8" label-placement="left" label="SNMP启用">
<NSwitch :value="destr(localDevice.snmpEnabled)" @update:value="(enabled: boolean) => (localDevice.snmpEnabled = enabled)" />
</NFormItemGi>
</NGrid>
<NFormItem label-placement="left" label="设备ID" path="deviceId">
<NInput v-model:value="localDevice.deviceId" :disabled="!canEditDeviceId" />
</NFormItem>
<NFormItem label-placement="left" label="设备描述">
<NInput v-model:value="localDevice.description" />
</NFormItem>
<NFormItem label-placement="left" label="上游设备">
<NInput v-model:value="localDevice.linkDescription" />
</NFormItem>
</NForm>
</template>
<template #action>
<NFlex justify="end">
<NButton secondary size="small" :loading="loading" @click="() => updateDevice()">更新</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import CameraCard from './camera-card.vue';
import CameraCurrentDiag from './camera-current-diag.vue';
import CameraHistoryDiag from './camera-history-diag.vue';
import CameraUpdate from './camera-update.vue';
export { CameraCard, CameraCurrentDiag, CameraHistoryDiag, CameraUpdate };