Compare commits

3 Commits
manual ... main

Author SHA1 Message Date
yangsy
db831e82ff docs: 更新 README.md 2026-02-02 15:04:46 +08:00
yangsy
6bf205f461 fix: 关闭调试模式时遗漏了重置 useLocalDB 状态 2026-01-30 14:48:54 +08:00
yangsy
cf3d19d89d refactor: 录像诊断导出面板统一使用批量接口 2026-01-30 14:14:53 +08:00
7 changed files with 724083 additions and 87 deletions

143
README.md
View File

@@ -39,3 +39,146 @@ pnpm build
```
在执行 `pnpm build` 之前,你可以在 `package.json` 中修改 `version` 字段,将其设置为你期望的版本号,构建完成后,项目的根目录中除了 `dist` 目录外,还会生成三个压缩包,文件名的格式统一为 `ndm-web-platform_v<version>_<datetime>`,文件格式则分别为 `zip``tar``tar.gz`
## 业务结构
所有业务相关的页面都在 `src/pages` 目录下,路由配置在 `src/router/index.ts` 文件,除登录页之外,其余页面都作为 `src/layouts/app-layout.vue` 的子路由。
```bash
src/
router/
index.ts # 路由配置文件
layouts/
app-layout.vue # 布局
pages/
login/
login-page.vue # 登录页面
station/
station-page.vue # 车站状态页面(首页)
device/
device-page.vue # 设备诊断页面
alarm/
alarm-ignore-page.vue # 告警忽略管理页面
alarm-log-page.vue # 设备告警记录页面
log/
call-log-page.vue # 上级调用日志页面
vimp-log-page.vue # 视频平台日志页面
permission/
permission-page.vue # 权限管理页面
error/
not-found-page.vue # 404 页面
```
## 数据轮询
由于后端服务的架构限制,需要前端向所有车站服务依次发送请求来获取数据,需要获取的数据包含车站状态、设备数据以及告警数据,因此需要设计一套数据轮询方案,定期从所有车站服务获取数据。
在项目中,`src/composables/query/` 目录下是所有数据轮询相关的代码,其中与业务相关的代码主要包括:
- `use-line-stations-query.ts`: 查询所有车站
- `use-line-devices-query.ts`: 查询所有设备
- `use-line-alarms-query.ts`: 查询所有告警
- `use-user-permission-query.ts`: 查询用户权限
在描述整个数据轮询流程之前,我们要明确项目中必须存在的几个关键概念:
- 车站相关车站query + 车站store
- 设备相关设备query + 设备store
- 告警相关告警query + 告警store
- 权限相关权限query + 权限store
整个数据轮询流程采用“单点驱动 + 变更监听 + 级联触发”的模式,如下图所示。
![数据轮询流程](./docs/assets/query-chain.png)
1. 轮询入口车站query
- 触发条件以120秒的周期自动轮询车站列表
- 数据流向车站store
2. 核心调度权限query
- 触发条件车站query执行后触发
- 数据流向权限store并计算当前用户在各车站的权限
- 数据监听监听车站和权限变化触发设备query和告警query
3. 设备query & 告警query
- 触发条件被动触发由权限query主动调用
- 数据流向设备store & 告警store
## 调试模式
在设置面板中有一系列与调试模式有关的设置项,主要用于开发和故障排查。
### 开启方式
调试模式默认隐藏,通过以下方式开启:
1. 使用快捷键 `Ctrl + Alt + D` 唤起验证弹窗
2. 输入授权码进行验证(授权码对应环境变量 `.env` 中的 `VITE_DEBUG_CODE`
3. 验证通过后,在“系统设置”面板中会出现 **调试** 分组
### 设置项说明
#### 数据设置
- **显示设备原始数据**
- 控制是否在设备详情页显示“原始数据”标签页
- 开启后可查看设备接口返回的原始 JSON 数据,便于排查字段缺失或格式错误
#### 网络设置
- **轮询车站**
- 控制是否定时拉取车站状态,进而触发权限、设备及告警数据的更新
- 关闭后将暂停所有业务数据的自动轮询机制
- **主动请求**
- 控制组件挂载时是否自动发起数据请求
- 涵盖设备在线状态检测、用户登录验证等逻辑,关闭后组件在初始化时将不再自动拉取数据
- **订阅消息**
- 控制是否通过 WebSocket (STOMP) 接收实时告警或状态推送
- 关闭后将不再处理后端推送的实时消息
- **模拟用户**
- 开启后使用内置的超管用户绕过登录
- 开启时会自动进入调试模式,便于开发环境快速测试
#### 数据库设置
- **直接操作本地数据库**
- 控制某些业务逻辑(如交换机端口、安防箱回路)是否直接读写本地 IndexedDB
- 用于在无后端环境或特定测试场景下验证本地数据逻辑
## 离线开发
项目支持在无后端服务的情况下正常启动,具体操作取决于你的本地环境是否已有历史数据。
### 场景一:已有本地缓存
如果你的浏览器曾接入过现场环境IndexedDB 中已保存了车站、设备等数据,只需在设置中关闭网络请求即可进入离线模式:
1. 开启调试模式(`Ctrl + Alt + D`)。
2. 在“网络设置”中,关闭 **轮询车站**、**主动请求** 和 **订阅消息**
3. 此时平台将停止向后端发起请求,直接展示本地缓存的历史数据。
### 场景二:全新环境启动(新人推荐)
如果你是首次拉取项目且无法连接后端,需要按以下步骤操作:
1. **模拟登录**
在登录页按 `F12` 打开控制台,输入以下命令强制进入平台:
```javascript
window.$mockUser.value = true;
```
执行后平台将自动完成以下操作:
- 注入测试 Token 和管理员身份信息
- 关闭所有网络请求(轮询、主动请求、消息订阅)
- 开启调试模式
- 自动跳转至平台首页
2. **导入模拟数据**
进入平台后,页面默认为空。需导入预设数据以填充内容:
- 打开“系统设置”(已自动开启调试模式)。
- 在 **调试** -> **数据库设置** 中,勾选 **直接操作本地数据库**。
- 点击该选项下方的 **导入数据** 按钮。
- 依次导入项目根目录 `docs/data/` 下的三个文件:
- `ndm-station-store.json`(车站数据)
- `ndm-device-store.json`(设备数据)
- `ndm-alarm-store.json`(告警数据)
> **注意**:每次导入一个文件后,平台会自动刷新页面以应用数据。请等待刷新完成后,重新打开设置面板导入下一个文件。

BIN
docs/assets/query-chain.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

527708
docs/data/ndm-alarm-store.json Normal file

File diff suppressed because it is too large Load Diff

195971
docs/data/ndm-device-store.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,203 @@
{
"stations": [
{
"code": "1075",
"name": "吴中路控制中心",
"online": true,
"ip": "10.18.128.10",
"occ": true
},
{
"code": "1001",
"name": "虹桥火车站",
"online": true,
"ip": "10.18.129.10"
},
{
"code": "1002",
"name": "虹桥2号航站楼",
"online": true,
"ip": "10.18.131.10"
},
{
"code": "1003",
"name": "虹桥一号航站楼",
"online": true,
"ip": "10.18.133.10"
},
{
"code": "1004",
"name": "上海动物园",
"online": true,
"ip": "10.18.135.10"
},
{
"code": "1005",
"name": "龙溪路",
"online": true,
"ip": "10.18.137.10"
},
{
"code": "1006",
"name": "水城路",
"online": true,
"ip": "10.18.139.10"
},
{
"code": "1007",
"name": "伊犁路",
"online": true,
"ip": "10.18.141.10"
},
{
"code": "1008",
"name": "宋园路",
"online": true,
"ip": "10.18.143.10"
},
{
"code": "1009",
"name": "虹桥路",
"online": true,
"ip": "10.18.145.10"
},
{
"code": "1010",
"name": "交通大学",
"online": true,
"ip": "10.18.147.10"
},
{
"code": "1011",
"name": "图书馆",
"online": true,
"ip": "10.18.149.10"
},
{
"code": "1012",
"name": "陕西南路",
"online": true,
"ip": "10.18.151.10"
},
{
"code": "1013",
"name": "新天地",
"online": true,
"ip": "10.18.153.10"
},
{
"code": "1014",
"name": "老西门",
"online": true,
"ip": "10.18.155.10"
},
{
"code": "1015",
"name": "豫园",
"online": true,
"ip": "10.18.157.10"
},
{
"code": "1016",
"name": "南京东路",
"online": true,
"ip": "10.18.159.10"
},
{
"code": "1017",
"name": "天潼路",
"online": true,
"ip": "10.18.161.10"
},
{
"code": "1018",
"name": "四川北路",
"online": true,
"ip": "10.18.163.10"
},
{
"code": "1019",
"name": "海伦路",
"online": true,
"ip": "10.18.165.10"
},
{
"code": "1020",
"name": "邮电新村",
"online": true,
"ip": "10.18.167.10"
},
{
"code": "1021",
"name": "四平路",
"online": true,
"ip": "10.18.169.10"
},
{
"code": "1022",
"name": "同济大学",
"online": true,
"ip": "10.18.171.10"
},
{
"code": "1023",
"name": "国权路",
"online": true,
"ip": "10.18.173.10"
},
{
"code": "1024",
"name": "五角场",
"online": true,
"ip": "10.18.175.10"
},
{
"code": "1025",
"name": "江湾体育场",
"online": true,
"ip": "10.18.177.10"
},
{
"code": "1026",
"name": "三门路",
"online": true,
"ip": "10.18.179.10"
},
{
"code": "1027",
"name": "殷高东路",
"online": true,
"ip": "10.18.181.10"
},
{
"code": "1028",
"name": "新江湾城",
"online": true,
"ip": "10.18.183.10"
},
{
"code": "1029",
"name": "航中路",
"online": true,
"ip": "10.18.185.10"
},
{
"code": "1030",
"name": "紫藤路",
"online": true,
"ip": "10.18.187.10"
},
{
"code": "1031",
"name": "龙柏新村",
"online": true,
"ip": "10.18.189.10"
},
{
"code": "1032",
"name": "吴中路基地",
"online": true,
"ip": "10.18.244.10"
}
]
}

View File

@@ -1,14 +1,11 @@
<script setup lang="ts">
import { batchExportRecordCheckApi, getRecordCheckApi, pageDefParameterApi, type NdmNvrResultVO, type Station } from '@/apis';
import { exportRecordDiagCsv, isNvrCluster, transformRecordChecks } from '@/helpers';
import { useDeviceStore } from '@/stores';
import { batchExportRecordCheckApi, pageDefParameterApi, type Station } from '@/apis';
import { downloadByData, parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import { NButton, NFlex, NGrid, NGridItem, NModal, NScrollbar, NSpin } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs } from 'vue';
import { ref, toRefs } from 'vue';
const props = defineProps<{
stations: Station[];
@@ -20,65 +17,22 @@ const emit = defineEmits<{
const show = defineModel<boolean>('show');
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const { stations } = toRefs(props);
const nvrClusterRecord = computed(() => {
const clusterMap: Record<Station['code'], { stationName: Station['name']; clusters: NdmNvrResultVO[] }> = {};
stations.value.forEach((station) => {
clusterMap[station.code] = {
stationName: station.name,
clusters: [],
};
const stationDevices = lineDevices.value[station.code];
const nvrs = stationDevices?.['ndmNvr'] ?? [];
nvrs.forEach((nvr) => {
if (isNvrCluster(nvr)) {
clusterMap[station.code]?.clusters?.push(nvr);
}
});
});
return clusterMap;
});
const abortController = ref<AbortController>(new AbortController());
const { mutate: exportRecordDiags, isPending: exporting } = useMutation({
mutationFn: async (params: { clusters: NdmNvrResultVO[]; stationCode: Station['code'] }) => {
const { clusters, stationCode } = params;
if (clusters.length === 0) {
const stationName = nvrClusterRecord.value[stationCode]?.stationName ?? '';
window.$message.info(`${stationName} 没有录像诊断数据`);
return;
}
const cluster = clusters.at(0);
if (!cluster) return;
const { mutate: batchExportRecordCheck, isPending: batchExporting } = useMutation({
mutationFn: async (params: { stations: Station[] }) => {
const timer = setTimeout(() => {
if (!batchExporting.value) return;
window.$message.info('导出耗时较长,请耐心等待...', { duration: 0 });
}, 3000);
try {
abortController.value.abort();
abortController.value = new AbortController();
const checks = await getRecordCheckApi(cluster, 90, [], { stationCode: stationCode, signal: abortController.value.signal });
return checks;
},
onSuccess: (checks, { stationCode }) => {
if (!checks || checks.length === 0) {
window.$message.info(`没有录像诊断数据`);
return;
}
const recordDiags = transformRecordChecks(checks);
exportRecordDiagCsv(recordDiags, nvrClusterRecord.value[stationCode]?.stationName ?? '');
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const { mutate: batchExportRecordCheck, isPending: batchExporting } = useMutation({
mutationFn: async () => {
const { records = [] } = await pageDefParameterApi({
const { records = [] } = await pageDefParameterApi(
{
model: {
key: 'NVR_GAP_SECONDS',
},
@@ -87,26 +41,42 @@ const { mutate: batchExportRecordCheck, isPending: batchExporting } = useMutatio
size: 1,
sort: 'id',
order: 'descending',
});
const gapSeconds = parseInt(records.at(0)?.value ?? '5');
window.$message.info('导出耗时较长,请耐心等待...');
const data = await batchExportRecordCheckApi(
{
checkDuration: 90,
gapSeconds,
stationCode: stations.value.map((station) => station.code),
},
{
signal: abortController.value.signal,
},
);
return data;
const gapSeconds = parseInt(records.at(0)?.value ?? '5');
abortController.value.abort();
abortController.value = new AbortController();
const data = await batchExportRecordCheckApi(
{
checkDuration: 90,
gapSeconds,
stationCode: params.stations.map((station) => station.code),
},
onSuccess: (data) => {
{
signal: abortController.value.signal,
},
);
return data;
} finally {
window.$message.destroyAll();
clearTimeout(timer);
}
},
onSuccess: (data, { stations }) => {
const time = dayjs().format('YYYY-MM-DD_HH-mm-ss');
downloadByData(data, `录像缺失记录_${time}.xlsx`);
let stationName = '';
if (stations.length === 1) {
const name = stations.at(0)?.name;
if (!!name) {
stationName = `${name}_`;
}
}
downloadByData(data, `${stationName}录像缺失记录_${time}.xlsx`);
},
onError: (error) => {
if (isCancel(error)) return;
@@ -126,11 +96,11 @@ const onAfterLeave = () => {
<NModal v-model:show="show" preset="card" title="导出录像诊断" @after-leave="onAfterLeave" style="width: 800px">
<template #default>
<NScrollbar style="height: 300px">
<NSpin size="small" :show="exporting">
<NSpin size="small" :show="batchExporting">
<NGrid :cols="6">
<template v-for="({ stationName, clusters }, code) in nvrClusterRecord" :key="code">
<template v-for="station in stations" :key="station.code">
<NGridItem>
<NButton text type="info" style="height: 30px" @click="() => exportRecordDiags({ clusters, stationCode: code })">{{ stationName }}</NButton>
<NButton text type="info" style="height: 30px" @click="() => batchExportRecordCheck({ stations: [station] })">{{ station.name }}</NButton>
</NGridItem>
</template>
</NGrid>
@@ -139,7 +109,7 @@ const onAfterLeave = () => {
</template>
<template #action>
<NFlex justify="flex-end" align="center">
<NButton secondary :loading="batchExporting" @click="() => batchExportRecordCheck()">导出全部</NButton>
<NButton secondary :loading="batchExporting" @click="() => batchExportRecordCheck({ stations })">导出全部</NButton>
</NFlex>
</template>
</NModal>

View File

@@ -47,6 +47,7 @@ export const useSettingStore = defineStore(
activeRequests.value = true;
subscribeMessages.value = false;
mockUser.value = false;
useLocalDB.value = false;
}
});