389 lines
14 KiB
Vue
389 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { retentionDaysApi, snapStatusApi, type LineAlarms, type LineDevices, type Station, type VersionInfo } from '@/apis';
|
|
import { ThemeSwitch } from '@/components';
|
|
import { NDM_ALARM_STORE_ID, NDM_DEVICE_STORE_ID, NDM_STATION_STORE_ID } from '@/constants';
|
|
import { usePollingStore, useSettingStore } from '@/stores';
|
|
import { downloadByData, getAppEnvConfig, parseErrorFeedback, sleep } from '@/utils';
|
|
import { useMutation } from '@tanstack/vue-query';
|
|
import { useEventListener } from '@vueuse/core';
|
|
import axios, { isCancel } from 'axios';
|
|
import destr from 'destr';
|
|
import { isFunction } from 'es-toolkit';
|
|
import localforage from 'localforage';
|
|
import { DownloadIcon, Trash2Icon, UploadIcon } from 'lucide-vue-next';
|
|
import { NButton, NButtonGroup, NDivider, NDrawer, NDrawerContent, NDropdown, NFlex, NFormItem, NIcon, NInput, NInputNumber, NModal, NSwitch, NText, type DropdownOption } from 'naive-ui';
|
|
import { storeToRefs } from 'pinia';
|
|
import { ref, watch } from 'vue';
|
|
|
|
const show = defineModel<boolean>('show', { default: false });
|
|
|
|
const settingsStore = useSettingStore();
|
|
const { menuCollpased, stationGridCols, debugModeEnabled, offlineDev } = storeToRefs(settingsStore);
|
|
|
|
const versionInfo = ref<VersionInfo>({ version: '', buildTime: '' });
|
|
|
|
const { mutate: getVersionInfo } = useMutation({
|
|
mutationFn: async () => {
|
|
const { data } = await axios.get<VersionInfo>(`/manifest.json?t=${Date.now()}`);
|
|
return data;
|
|
},
|
|
onSuccess: (data) => {
|
|
versionInfo.value = data;
|
|
},
|
|
onError: (error) => {
|
|
console.error(error);
|
|
const errorFeedback = parseErrorFeedback(error);
|
|
window.$message.error(errorFeedback);
|
|
},
|
|
});
|
|
|
|
const abortControllers = ref({
|
|
retentionDays: new AbortController(),
|
|
snapStatus: new AbortController(),
|
|
});
|
|
|
|
const retentionDays = ref(0);
|
|
const { mutate: getRetentionDays, isPending: retentionDaysLoading } = useMutation({
|
|
mutationFn: async () => {
|
|
abortControllers.value.retentionDays.abort();
|
|
abortControllers.value.retentionDays = new AbortController();
|
|
const signal = abortControllers.value.retentionDays.signal;
|
|
const days = await retentionDaysApi('get', { signal });
|
|
return days;
|
|
},
|
|
onSuccess: (days) => {
|
|
retentionDays.value = days;
|
|
},
|
|
onError: (error) => {
|
|
if (isCancel(error)) return;
|
|
console.error(error);
|
|
const errorFeedback = parseErrorFeedback(error);
|
|
window.$message.error(errorFeedback);
|
|
},
|
|
});
|
|
const { mutate: saveRetentionDays, isPending: retentionDaysSaving } = useMutation({
|
|
mutationFn: async () => {
|
|
abortControllers.value.retentionDays.abort();
|
|
abortControllers.value.retentionDays = new AbortController();
|
|
const signal = abortControllers.value.retentionDays.signal;
|
|
await retentionDaysApi('post', { days: retentionDays.value, signal });
|
|
},
|
|
onError: (error) => {
|
|
if (isCancel(error)) return;
|
|
console.error(error);
|
|
const errorFeedback = parseErrorFeedback(error);
|
|
window.$message.error(errorFeedback);
|
|
// 修改失败,刷新 retentionDays
|
|
getRetentionDays();
|
|
},
|
|
});
|
|
|
|
const snapStatus = ref(false);
|
|
const { mutate: getSnapStatus, isPending: snapStatusLoading } = useMutation({
|
|
mutationFn: async () => {
|
|
abortControllers.value.snapStatus.abort();
|
|
abortControllers.value.snapStatus = new AbortController();
|
|
const signal = abortControllers.value.snapStatus.signal;
|
|
const status = await snapStatusApi('get', { signal });
|
|
return status;
|
|
},
|
|
onSuccess: (status) => {
|
|
snapStatus.value = status;
|
|
},
|
|
onError: (error) => {
|
|
if (isCancel(error)) return;
|
|
console.error(error);
|
|
const errorFeedback = parseErrorFeedback(error);
|
|
window.$message.error(errorFeedback);
|
|
},
|
|
});
|
|
const { mutate: saveSnapStatus, isPending: snapStatusSaving } = useMutation({
|
|
mutationFn: async () => {
|
|
abortControllers.value.snapStatus.abort();
|
|
abortControllers.value.snapStatus = new AbortController();
|
|
const signal = abortControllers.value.snapStatus.signal;
|
|
await snapStatusApi('post', { doSnap: snapStatus.value, signal });
|
|
},
|
|
onError: (error) => {
|
|
if (isCancel(error)) return;
|
|
console.error(error);
|
|
const errorFeedback = parseErrorFeedback(error);
|
|
window.$message.error(errorFeedback);
|
|
// 修改失败,刷新 snapStatus
|
|
getSnapStatus();
|
|
},
|
|
});
|
|
|
|
const showDebugCodeModal = ref(false);
|
|
const debugCode = ref('');
|
|
const enableDebugMode = () => {
|
|
const { debugCode: expectedDebugCode } = getAppEnvConfig();
|
|
if (debugCode.value !== expectedDebugCode) {
|
|
window.$message.error('调试授权码错误');
|
|
return;
|
|
}
|
|
showDebugCodeModal.value = false;
|
|
settingsStore.enableDebugMode();
|
|
};
|
|
const disableDebugMode = () => {
|
|
showDebugCodeModal.value = false;
|
|
settingsStore.disableDebugMode();
|
|
};
|
|
useEventListener('keydown', (event) => {
|
|
const { ctrlKey, altKey, code } = event;
|
|
if (ctrlKey && altKey && code === 'KeyD') {
|
|
showDebugCodeModal.value = true;
|
|
}
|
|
});
|
|
|
|
const expectToShowDebugCodeInput = ref(false);
|
|
const onModalAfterEnter = () => {
|
|
expectToShowDebugCodeInput.value = !debugModeEnabled.value;
|
|
};
|
|
const onModalAfterLeave = () => {
|
|
expectToShowDebugCodeInput.value = false;
|
|
debugCode.value = '';
|
|
};
|
|
|
|
const pollingStore = usePollingStore();
|
|
const { pollingEnabled } = storeToRefs(pollingStore);
|
|
const onPollingEnabledUpdate = (enabled: boolean) => {
|
|
if (enabled) {
|
|
pollingStore.startPolling();
|
|
} else {
|
|
pollingStore.stopPolling();
|
|
}
|
|
};
|
|
|
|
type IndexedDbStoreId = typeof NDM_STATION_STORE_ID | typeof NDM_DEVICE_STORE_ID | typeof NDM_ALARM_STORE_ID;
|
|
type IndexedDbStoreStates = {
|
|
[NDM_STATION_STORE_ID]: { stations: Station[] };
|
|
[NDM_DEVICE_STORE_ID]: { lineDevices: LineDevices };
|
|
[NDM_ALARM_STORE_ID]: { lineAlarms: LineAlarms; unreadLineAlarms: LineAlarms };
|
|
};
|
|
const exportFromIndexedDB = async <K extends IndexedDbStoreId>(storeId: K, options?: { errorMsg?: string }) => {
|
|
const { errorMsg } = options ?? {};
|
|
const data = await localforage.getItem<IndexedDbStoreStates[K]>(storeId);
|
|
if (!data) {
|
|
window.$message.error(errorMsg ?? '导出数据失败');
|
|
return;
|
|
}
|
|
downloadByData(JSON.stringify(data, null, 2), `${storeId}.json`);
|
|
};
|
|
const importToIndexedDB = async <K extends IndexedDbStoreId>(storeId: K, options?: { successMsg?: string; errorMsg?: string }) => {
|
|
const { successMsg, errorMsg } = options ?? {};
|
|
pollingStore.stopPolling();
|
|
offlineDev.value = true;
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.accept = '.json';
|
|
fileInput.click();
|
|
fileInput.onchange = async () => {
|
|
const file = fileInput.files?.[0];
|
|
if (!file) {
|
|
window.$message.error(errorMsg ?? '导入数据失败');
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.readAsText(file, 'utf-8');
|
|
reader.onload = async () => {
|
|
const data = destr<IndexedDbStoreStates[K]>(reader.result as string);
|
|
await localforage.setItem(storeId, data);
|
|
window.$message.success(`${successMsg ?? '导入数据成功'},即将刷新页面`);
|
|
await sleep(2000);
|
|
window.location.reload();
|
|
};
|
|
};
|
|
};
|
|
const deleteFromIndexedDB = async (storeId: IndexedDbStoreId) => {
|
|
pollingStore.stopPolling();
|
|
offlineDev.value = true;
|
|
await localforage.removeItem(storeId).catch((error) => {
|
|
window.$message.error(`${error}`);
|
|
return;
|
|
});
|
|
window.$message.success('删除成功,即将刷新页面');
|
|
await sleep(2000);
|
|
window.location.reload();
|
|
};
|
|
|
|
const exportDropdownOptions: DropdownOption[] = [
|
|
{
|
|
label: '导出车站',
|
|
key: 'exportStations',
|
|
onSelect: () => exportFromIndexedDB(NDM_STATION_STORE_ID),
|
|
},
|
|
{
|
|
label: '导出设备',
|
|
key: 'exportDevices',
|
|
onSelect: () => exportFromIndexedDB(NDM_DEVICE_STORE_ID),
|
|
},
|
|
{
|
|
label: '导出告警',
|
|
key: 'exportAlarms',
|
|
onSelect: () => exportFromIndexedDB(NDM_ALARM_STORE_ID),
|
|
},
|
|
];
|
|
const importDropdownOptions: DropdownOption[] = [
|
|
{
|
|
label: '导入车站',
|
|
key: 'importStations',
|
|
onSelect: () => importToIndexedDB(NDM_STATION_STORE_ID),
|
|
},
|
|
{
|
|
label: '导入设备',
|
|
key: 'importDevices',
|
|
onSelect: () => importToIndexedDB(NDM_DEVICE_STORE_ID),
|
|
},
|
|
{
|
|
label: '导入告警',
|
|
key: 'importAlarms',
|
|
onSelect: () => importToIndexedDB(NDM_ALARM_STORE_ID),
|
|
},
|
|
];
|
|
const deleteDropdownOptions: DropdownOption[] = [
|
|
{
|
|
label: '删除车站',
|
|
key: 'deleteStations',
|
|
onSelect: () => deleteFromIndexedDB(NDM_STATION_STORE_ID),
|
|
},
|
|
{
|
|
label: '删除设备',
|
|
key: 'deleteDevices',
|
|
onSelect: () => deleteFromIndexedDB(NDM_DEVICE_STORE_ID),
|
|
},
|
|
{
|
|
label: '删除告警',
|
|
key: 'deleteAlarms',
|
|
onSelect: () => deleteFromIndexedDB(NDM_ALARM_STORE_ID),
|
|
},
|
|
];
|
|
|
|
const onSelectDropdownOption = (key: string, option: DropdownOption) => {
|
|
const onSelect = option['onSelect'];
|
|
if (isFunction(onSelect)) {
|
|
onSelect();
|
|
}
|
|
};
|
|
|
|
watch([offlineDev, show], ([offline, entered]) => {
|
|
if (!offline) {
|
|
if (entered) {
|
|
getRetentionDays();
|
|
getSnapStatus();
|
|
} else {
|
|
abortControllers.value.retentionDays.abort();
|
|
abortControllers.value.snapStatus.abort();
|
|
}
|
|
}
|
|
});
|
|
const onDrawerAfterEnter = () => {
|
|
getVersionInfo();
|
|
};
|
|
const onDrawerAfterLeave = () => {
|
|
abortControllers.value.retentionDays.abort();
|
|
abortControllers.value.snapStatus.abort();
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<NDrawer v-model:show="show" :width="560" :auto-focus="false" @after-enter="onDrawerAfterEnter" @after-leave="onDrawerAfterLeave">
|
|
<NDrawerContent closable title="系统设置" :native-scrollbar="false">
|
|
<NFlex vertical>
|
|
<NDivider>主题</NDivider>
|
|
<NFormItem label="深色模式" label-placement="left">
|
|
<ThemeSwitch size="small" />
|
|
</NFormItem>
|
|
|
|
<NDivider>布局</NDivider>
|
|
<NFormItem label="折叠菜单" label-placement="left">
|
|
<NSwitch size="small" v-model:value="menuCollpased" />
|
|
</NFormItem>
|
|
<NFormItem label="车站列数" label-placement="left">
|
|
<NInputNumber v-model:value="stationGridCols" :min="1" :max="10" />
|
|
</NFormItem>
|
|
|
|
<NDivider>告警</NDivider>
|
|
<NFormItem label="告警画面截图保留天数" label-placement="left">
|
|
<NFlex justify="space-between" align="center" style="width: 100%">
|
|
<NInputNumber v-model:value="retentionDays" :min="1" :max="15" />
|
|
<NButtonGroup>
|
|
<NButton secondary size="small" :disabled="retentionDaysSaving" :loading="retentionDaysLoading" @click="() => getRetentionDays()">刷新</NButton>
|
|
<NButton secondary size="small" :disabled="retentionDaysLoading" :loading="retentionDaysSaving" @click="() => saveRetentionDays()">保存</NButton>
|
|
</NButtonGroup>
|
|
</NFlex>
|
|
</NFormItem>
|
|
<NFormItem label="自动获取告警画面截图" label-placement="left">
|
|
<NFlex justify="space-between" align="center" style="width: 100%">
|
|
<NSwitch size="small" v-model:value="snapStatus" />
|
|
<NButtonGroup>
|
|
<NButton secondary size="small" :disabled="snapStatusSaving" :loading="snapStatusLoading" @click="() => getSnapStatus()">刷新</NButton>
|
|
<NButton secondary size="small" :disabled="snapStatusLoading" :loading="snapStatusSaving" @click="() => saveSnapStatus()">保存</NButton>
|
|
</NButtonGroup>
|
|
</NFlex>
|
|
</NFormItem>
|
|
|
|
<template v-if="debugModeEnabled">
|
|
<NDivider title-placement="center">调试</NDivider>
|
|
<NFormItem label="启用轮询" label-placement="left">
|
|
<NSwitch size="small" :value="pollingEnabled" @update:value="onPollingEnabledUpdate" />
|
|
</NFormItem>
|
|
<NFormItem label="离线开发" label-placement="left">
|
|
<NSwitch size="small" v-model:value="offlineDev" />
|
|
</NFormItem>
|
|
<NFormItem label="本地数据库" label-placement="left">
|
|
<NFlex>
|
|
<NDropdown trigger="click" :options="exportDropdownOptions" @select="onSelectDropdownOption">
|
|
<NButton secondary size="small">
|
|
<template #icon>
|
|
<NIcon :component="DownloadIcon" />
|
|
</template>
|
|
<template #default>导出</template>
|
|
</NButton>
|
|
</NDropdown>
|
|
<NDropdown trigger="click" :options="importDropdownOptions" @select="onSelectDropdownOption">
|
|
<NButton secondary size="small">
|
|
<template #icon>
|
|
<NIcon :component="UploadIcon" />
|
|
</template>
|
|
<template #default>导入</template>
|
|
</NButton>
|
|
</NDropdown>
|
|
<NDropdown trigger="click" :options="deleteDropdownOptions" @select="onSelectDropdownOption">
|
|
<NButton secondary size="small">
|
|
<template #icon>
|
|
<NIcon :component="Trash2Icon" />
|
|
</template>
|
|
<template #default>删除</template>
|
|
</NButton>
|
|
</NDropdown>
|
|
</NFlex>
|
|
</NFormItem>
|
|
</template>
|
|
</NFlex>
|
|
<template #footer>
|
|
<NFlex vertical justify="flex-end" align="center" style="width: 100%; font-size: 12px; gap: 4px">
|
|
<NText :depth="3">平台版本: {{ versionInfo.version }} ({{ versionInfo.buildTime }})</NText>
|
|
</NFlex>
|
|
</template>
|
|
</NDrawerContent>
|
|
</NDrawer>
|
|
|
|
<NModal v-model:show="showDebugCodeModal" preset="dialog" type="info" @after-enter="onModalAfterEnter" @after-leave="onModalAfterLeave">
|
|
<template #header>
|
|
<NText v-if="!debugModeEnabled">请输入调试码</NText>
|
|
<NText v-else>确认关闭调试模式</NText>
|
|
</template>
|
|
<template #default>
|
|
<NInput v-if="expectToShowDebugCodeInput" v-model:value="debugCode" placeholder="输入调试码" @keyup.enter="enableDebugMode" />
|
|
</template>
|
|
<template #action>
|
|
<NButton @click="showDebugCodeModal = false">取消</NButton>
|
|
<NButton v-if="!debugModeEnabled" type="primary" @click="enableDebugMode">启用</NButton>
|
|
<NButton v-else type="primary" @click="disableDebugMode">确认</NButton>
|
|
</template>
|
|
</NModal>
|
|
</template>
|
|
|
|
<style scoped lang="scss"></style>
|