Compare commits

17 Commits

Author SHA1 Message Date
yangsy
1c71151a6b docs: 更新README.md 2026-03-02 15:39:06 +08:00
yangsy
68c5d12e14 chore: 版本信息和更新日志 2026-03-02 15:24:15 +08:00
yangsy
399fb6e9c1 chore: 版本信息和更新日志 2026-03-02 15:13:41 +08:00
yangsy
352cdc0142 fix: 在manifest.json末尾添加换行符
确保生成的JSON文件以换行符结尾,符合POSIX文本文件标准,便于工具处理和版本控制。
2026-03-02 15:10:31 +08:00
yangsy
d53e107ebc feat: 新增平台更新记录页面
- 添加 changelog 类型定义和导出
- 新增更新记录页面组件,支持从 JSON 文件加载并展示版本变更信息
- 在路由中添加更新记录页面路径
- 在设置抽屉中为版本信息添加点击跳转功能,可查看完整更新记录
- 添加包含历史版本变更的 changelogs.json 数据文件
2026-03-02 15:10:31 +08:00
yangsy
fd851bb8d6 refactor: 调整系统页面的目录结构 2026-03-02 15:10:31 +08:00
yangsy
837b243838 feat: 新版录像诊断卡片
- 按天聚合录像缺失片段,渲染为不同颜色
- 支持查看某天的录像缺失详情
2026-03-02 15:10:31 +08:00
yangsy
403c8d703e refactor: 移除旧版的录像诊断卡片与辅助函数 2026-02-26 11:15:55 +08:00
yangsy
89ff378eb7 feat: 添加1号线API代理配置 2026-02-24 15:26:44 +08:00
yangsy
7bdda5d546 refactor(record-check): 优化分组逻辑并改进缺失片段交互
- 移除 es-toolkit 依赖,使用原生 Map 进行分组以保留顺序
- 将录像片段过滤选项从数字改为语义化字符串
- 修复录像时间轴缺失片段悬停交互,避免嵌套 Popover
- 重置分页时同步设备 ID 和诊断时间变化
2026-02-12 01:55:49 +08:00
yangsy
5edd86ee80 feat: 启用mock用户时授予所有车站全部权限 2026-02-11 21:22:14 +08:00
yangsy
b1b2892ff7 fix: 修复视频平台和上级调用日志的默认查询没有携带logType参数的问题 2026-02-05 15:29:00 +08:00
yangsy
ecfc13dc69 feat: 将各查询页的默认分页size从10调整为20 2026-02-05 13:43:05 +08:00
yangsy
3d9825f58a refactor: 修复告警记录导出未添加条件筛选 2026-02-04 15:28:21 +08:00
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
32 changed files with 725210 additions and 963 deletions

View File

@@ -1,327 +0,0 @@
# 网络设备管理平台用户手册
## 1. 平台概述与基础操作
### 1.1 平台简介
网络设备管理平台(以下简称为“平台”)是专为地铁线路运管中心设计的综合性管理系统。本平台旨在对车站内的各类网络设备进行集中监测、数据查看、运行状态监控及异常告警管理,并提供数据分析与统计功能。
**支持管理的设备类型包括:**
- 摄像机
- 网络录像机
- 交换机
- 解码器
- 智能安防箱
- 媒体服务器
- 视频服务器
- 网络键盘
- 报警主机
### 1.2 登录与退出
**登录平台:**
1. 在浏览器地址栏输入平台访问地址。
2. 进入登录页面后,输入您的 **账号****密码**
3. 点击“登录”按钮。
4. 登录成功后,平台将默认跳转至 **车站状态模块 (首页)**
**退出登录:**
1. 在平台任意页面的 **顶部区域** 右侧,找到显示您昵称的用户按钮。
2. 将鼠标悬停或点击该按钮,在下拉菜单中选择 **“退出登录”**。
3. 平台将安全退出您的账号并返回登录页面。
### 1.3 界面概览
登录后,平台主界面主要由以下四个区域组成:
**1. 顶部区域**
位于页面最上方,包含:
- **平台标题**:点击可快速返回首页。
- **用户信息**:显示当前登录用户昵称及退出入口。
- **设置按钮**:点击可打开设置面板。
**2. 侧边菜单**
位于页面左侧,提供各功能模块的导航入口:
- **车站状态**:全线车站设备运行概览 (首页)。
- **设备诊断**:深入查看具体设备的详细运行参数。
- **设备告警**
- **设备告警记录**:查询历史告警记录。
- **告警忽略管理**:管理忽略设备。
- **系统日志**
- **视频平台日志**:审计视频平台操作。
- **上级调用日志**:审计上级调用指令。
- **权限管理**:用户与权限配置(仅超级管理员可见)。
**3. 底部状态栏**
位于页面最下方,主要用于 **未读告警提示**。当有新的设备告警产生时,此处的铃铛图标会显示红色未读数量徽标,点击可快速跳转至告警记录页面。
**4. 设置面板**
点击顶部区域的“设置”图标(⚙️)即可打开,包含:
- **通用设置**
- **主题**:切换深色/浅色模式,适应不同光照环境。
- **布局**:折叠/展开左侧菜单;调整 **车站卡片矩阵** 的显示列数1-10列
- **业务设置**
- **告警策略**配置告警截图保留天数等详见第4章仅限特定权限人员可见
### 1.4 权限与界面差异说明
本平台采用严格的权限控制机制,您的账号权限将直接决定您能看到的内容和能执行的操作。
**1. 查看权限**
- **影响范围**:决定您能看到哪些车站。
- **界面表现**:如果您没有某车站的查看权限,该车站在 **车站卡片矩阵**、设备树以及所有查询筛选器中都将 **完全不可见**
**2. 操作权限**
- **影响范围**:决定您能否对设备进行配置、控制或管理。
- **界面表现**:如果您没有某车站的操作权限,相关的 **功能按钮**(如“设备配置”、“同步数据”、“告警确认”等)将 **自动隐藏****变为不可用状态**
> **提示**:如果您发现找不到某个车站或缺少某个功能按钮,请联系管理员确认您是否拥有相应的权限。
## 2. 车站状态模块 (首页)
车站状态模块是用户登录后的默认页面,以 **车站卡片矩阵** 的形式展示全线各车站的设备整体运行状态。
### 2.1 车站卡片解读
每个卡片代表一个车站,直观展示该站的关键运行指标:
**1. 基础信息**
- **车站名称**:显示车站名称。
- **在线状态**:右上角的标签显示该车站服务器当前的在线/离线状态。
**2. 统计数据**
- **设备概览**:显示“设备总数”,以及通过颜色区分的“在线设备数”(绿色)和“离线设备数”(红色)。
- **告警概览**:显示“今日告警总数”,若有告警则以醒目颜色提示。
**3. 交互操作**
- **更多菜单**(点击卡片右上角的垂直三点图标):
- **视频平台**:跳转至该车站对应的第三方视频管理平台。
- **设备配置**打开参数配置窗口。_(注:仅当拥有该车站“操作权限”且车站在线时显示)_
- **查看设备详情**:点击设备概览区域右侧的 **详情按钮**(水平三点图标),弹出 **设备详情窗口**,展示该车站的设备树结构(支持进一步跳转至诊断页面)。
- **查看告警详情**:点击告警概览区域右侧的 **详情按钮**(水平三点图标),弹出 **告警详情窗口**,展示该车站当日的告警列表。
### 2.2 操作栏
位于页面顶部的工具栏,提供对多个车站的批量管理功能。
**1. 功能按钮与权限**
- **导出设备状态 / 导出录像诊断**_(需查看权限)_ 仅可选择您拥有“查看权限”的车站进行数据导出。
- **同步摄像机 / 同步录像机通道**_(需操作权限)_ 仅可选择您拥有“操作权限”的车站进行数据同步。
**2. 操作流程**
1. 点击操作栏中的任一功能按钮(例如“同步摄像机”)。
2. 此时进入**多选模式**所有车站卡片上会出现复选框。_(注:系统会自动根据当前操作所需的权限,禁用无权操作的车站复选框)_
3. 勾选您需要操作的车站,或勾选顶部的“全选”框。
4. 点击操作栏右侧出现的 **“确定”** 按钮执行操作:
- **对于导出类操作**:平台将弹出预览窗口,您可在确认数据无误后点击下载 Excel 文件。
- **对于同步类操作**:平台将直接在后台启动同步任务,任务完成后,页面右上角会弹出通知提示成功或失败的数量。
### 2.3 单站详情与配置
除了查看概览,您还可以对单个车站进行更细致的管理。
**1. 设备列表详情**
在设备详情窗口中,您可以浏览该车站下的所有设备。点击任意设备节点,可直接跳转至 **设备诊断页面** 查看该设备的详细指标。
**2. 参数配置** _(需操作权限)_
通过右上角菜单进入“设备配置”,您可以:
- **阈值配置**:设置交换机、服务器、录像机等设备的运行指标告警阈值(如 CPU 占用率、温度上限等)。
- **计划任务**:设置显示器等设备的自动亮屏和息屏计划。
## 3. 设备诊断模块
设备诊断模块用于查看和分析具体设备的详细运行状态。您可以通过侧边菜单的 **“设备诊断”** 进入,或从 **车站状态模块** 的设备详情、告警记录页面跳转进入告警跳转详见第4章
### 3.1 设备树 (侧边栏)
位于页面左侧,以树形结构展示全线所有车站及其下属设备。
**1. 搜索与筛选**
- **搜索框**:输入设备名称、设备 ID 或 IP 地址,可实时过滤设备树节点。
- **状态筛选**:点击单选框,可快速筛选 **“全部 / 在线 / 离线”** 设备。
**2. 设备树交互**
- **展开与选择**:按 **“车站 -> 设备类型 -> 具体设备”** 的层级展开,点击设备节点即可在右侧查看详情。
- **右键菜单管理** _(需操作权限)_
- **在车站节点上右键**
- **导入设备**:批量导入设备数据。
- **导出设备**:导出该车站的设备清单。
- **在设备节点上右键**
- **删除设备**:将该设备从平台中移除。
### 3.2 诊断详情页结构
选中设备后,右侧区域将展示该设备的详细信息。
**1. 功能标签页**
- **当前诊断**:展示设备实时的运行数据(默认视图)。头部信息栏和详细诊断指标均包含在此标签页中。
- **历史诊断**:以图表形式展示设备的历史健康度或关键指标变化趋势。
- **修改设备**_(需操作权限)_ 编辑设备的基础信息(如名称、安装位置等)。
**2. 头部信息栏 (位于“当前诊断”标签页顶部)**
- **基础状态**展示设备名称、IP 地址、在线/离线状态。
- **关联操作**
- **上游设备跳转**:如果设备有上游节点(如摄像机连接的交换机),点击可直接跳转查看上游设备。
- **管理入口**:点击 **“管理”** 按钮,可直接打开该设备自身的 Web 管理后台(如摄像机的 Web 配置页面)。
### 3.3 下游设备配置 (需操作权限)
对于交换机和智能安防箱,平台支持将其端口或电路与下游设备(如摄像机)进行关联,以便建立网络拓扑关系。
- **交换机配置**:在“当前诊断”的端口列表中,**右键点击**任意端口,选择 **“关联设备”**,可将该端口与下游设备绑定;若需解绑,右键选择 **“解除关联”** 即可。
- **智能安防箱配置**:在电路列表中,**右键点击**电路卡片,选择 **“关联设备”** 或 **“解除关联”** 进行配置。
### 3.4 设备诊断指标概览
根据设备类型的不同,“当前诊断”标签页将展示不同的关键指标。以下列举几种典型设备的展示重点:
- **交换机 / 服务器 / 录像机**:侧重硬件资源监控,展示 CPU、内存、磁盘等使用率仪表盘以及端口流量或通道录像状态。
- **摄像机 / 报警主机**侧重基础配置展示如制造商、固件版本、序列号、ONVIF/GB28181 协议配置等。
- **解码器 / 智能安防箱**:兼具硬件状态(如温度、风扇转速)与业务配置(如通道状态)的展示。
## 4. 设备告警模块
设备告警模块是运维人员处理设备异常的核心区域,包含告警记录查询、确认、忽略以及策略配置等功能。
### 4.1 设备告警记录
通过侧边菜单选择 **“设备告警” -> “设备告警记录”** 进入。
**1. 筛选查询**
顶部查询栏提供多维度的组合筛选功能,帮助您快速定位特定告警:
- **车站**:选择查询范围(仅显示您拥有“查看权限”的车站)。
- **设备类型/名称**:按设备属性精确查找。
- **告警属性**:按告警类型、级别(严重/紧急/一般等)、恢复状态(已恢复/未恢复)及确认状态进行筛选。
- **时间范围**:选择告警发生的起止时间。
**2. 列表操作**
- **告警确认** _(需操作权限)_:对于“未确认”的告警,点击操作列的 **“确认”** 按钮进行处理。确认后,系统将记录确认人及时间,且该按钮变为不可点击状态。
- **忽略设备** _(需操作权限)_:若某设备频繁误报,点击 **“忽略设备”** 将其加入忽略名单。被忽略的设备将不再产生新告警,直到被手动恢复。
- **跳转诊断**:点击列表中的设备名称,可直接跳转至该设备的诊断详情页。
**3. 数据导出**
点击页面右上角的 **“导出”** 按钮,可将当前筛选条件下的所有告警记录导出为 Excel 报表,便于线下归档与分析。
### 4.2 告警忽略管理
通过侧边菜单选择 **“设备告警” -> “告警忽略管理”** 进入。
**1. 列表查看**
展示所有当前处于“忽略状态”的设备列表,包含忽略时间、所属车站及设备信息。
**2. 取消忽略 (需操作权限)**
若需恢复对某设备的监控,点击操作列的 **“取消忽略”** 按钮。恢复后,该设备产生的新异常将重新触发告警。
### 4.3 告警策略配置 (系统设置)
部分高级告警策略需在全局设置中进行配置。
**1. 入口与权限**
- **入口**:点击顶部区域的“设置”图标,在设置面板中找到“告警”板块。
- **权限要求**:此板块仅对 **OCC (控制中心) 车站** 的操作员可见。
**2. 配置项**
- **告警画面截图保留天数**设置系统自动抓取的告警截图的存储期限1-15天。过期截图将被自动清理以释放存储空间。
- **自动获取告警画面截图**:开启/关闭告警触发时的自动截图功能。
## 5. 系统日志模块
系统日志模块用于审计和追踪系统内的关键操作记录。
### 5.1 视频平台日志
通过侧边菜单选择 **“系统日志” -> “视频平台日志”** 进入。此模块主要记录平台与第三方视频管理系统之间的交互操作。
**1. 查询功能**
- **车站筛选**:仅显示您拥有“查看权限”的车站日志。
- **操作类型**:筛选具体的交互指令类型(如“获取流地址”、“云台控制”等)。
- **时间范围**:按发生时间段进行精确检索。
**2. 数据导出**
支持将查询结果导出为 Excel 文件,用于故障排查或安全审计。
### 5.2 上级调用日志
通过侧边菜单选择 **“系统日志” -> “上级调用日志”** 进入。此模块记录上级系统(如公安或市级平台)调用本平台资源的请求记录。
**1. 查询功能**
- **车站筛选**:选择日志所属的车站范围。
- **调用类型**:筛选具体的调用指令(如“视频点播”、“录像回放”等)。
- **时间范围**:选择日志记录的时间段。
**2. 数据导出**
同样支持将查询结果导出,以便统计上级单位的调用频率和资源使用情况。
## 6. 权限管理模块
**(注:本模块仅对“超级管理员”可见,普通用户无法访问)**
权限管理模块用于配置系统用户及其对各车站的访问和操作权限。
### 6.1 用户列表管理
通过侧边菜单选择 **“权限管理”** 进入。
**1. 用户查找**
- **搜索**:在顶部搜索框输入 **用户名**,可快速定位目标用户。
- **重置**:点击重置按钮清空搜索条件,显示所有用户。
**2. 列表信息**
展示系统内所有注册用户的基本信息,包括账号、姓名等。
### 6.2 权限配置
点击用户列表右侧的 **“配置权限”** 按钮,进入该用户的权限分配界面。界面以矩阵形式展示,直观易懂。
**1. 权限矩阵解读**
- **行(车站)**:每一行代表一条线路上的具体车站。
- **列(权限类型)**
- **查看**:决定用户能否看到该车站的数据。
- **操作**:决定用户能否对该车站设备进行控制或修改配置。
**2. 批量操作技巧**
- **全选/反选**:点击表头的复选框,可一键授予或取消所有车站的某项权限。
- **单站全权**:点击某一行车站名称前的复选框(如有),可一键授予该车站的所有权限。
**3. 特殊规则说明**
**重要提示**:如果未给某用户勾选任何权限(即所有复选框均为空),平台默认该用户拥有 **超级管理员权限**,可访问所有车站并执行所有操作。请务必谨慎配置。

View File

@@ -1,185 +0,0 @@
# 《网络设备管理平台用户手册》编写计划
本计划已根据权限系统的实际逻辑VIEW/OPERATION进行了调整将在各章节中明确体现权限对界面和功能的影响。
## 阶段一:准备与入门
1. **平台简介**:平台用途与设备支持范围。
2. **登录与注销**:账号登录流程、退出操作。
3. **界面概览**
* **顶部区域 (Header)**:展示平台标题、当前用户信息及设置入口。
* **侧边菜单 (Sider)**:功能模块导航。
* **底部状态栏 (Footer)**:未读告警通知提示。
* **设置面板**
* **通用设置**:主题切换(深色模式)、布局调整(菜单折叠、车站列数)。
* **业务设置**:告警策略配置(详见阶段四)。
4. **权限与界面差异说明(新增)**
* 解释“查看权限”如何决定车站列表的显示(无权限则不显示)。
* 解释“操作权限”如何决定功能按钮的显隐(无权限则隐藏配置入口)。
## 阶段二:首页 - 车站状态监控
**涉及模块**`Station Status` (首页)
1. **车站卡片解读**
* **基础信息**:车站名称、在线/离线状态。
* **统计数据**:设备总数/在线数/离线数、今日告警总数。
* **交互操作**
* **右上角更多菜单**
* **视频平台**:跳转至视频管理平台。
* **设备配置**:打开参数配置窗口 **(注:仅当拥有该车站“操作权限”且车站在线时显示)**。
* **设备详情入口**:点击“设备总数”区域,弹出设备树模态框(可进一步跳转诊断页)。
* **告警详情入口**:点击“告警总数”区域,弹出告警列表模态框。
2. **操作栏 (Batch Actions)**
* **功能按钮与权限差异**
* **导出设备状态 / 导出录像诊断****(需查看权限)** 仅可选择拥有“查看权限”的车站进行导出。
* **同步摄像机 / 同步录像机通道****(需操作权限)** 仅可选择拥有“操作权限”的车站进行同步。
\*- **操作流程**
1. 点击操作栏中的任一功能按钮(如“同步摄像机”)。
2. 此时车站卡片上会出现复选框,且仅有权限的车站可选。
3. 勾选需要操作的车站(支持“全选”)。
4. 点击操作栏右侧出现的“确定”按钮:
* **导出类操作**:系统将弹出预览窗口,您可在窗口中确认数据并下载 Excel 文件。
* **同步类操作**:系统将直接在后台启动同步任务,任务完成后右上角会弹出结果通知(成功/失败数量)。
3. **单站详情与配置**
* **设备列表详情**:展示设备树,点击可跳转诊断页。
* **告警列表详情**。
* **参数配置**:配置阈值与计划任务 **(需操作权限)**。
## 阶段三:设备诊断模块
**涉及模块**`Device Diagnosis`
1. **设备树(侧边栏)**
* **数据范围**:说明仅展示拥有“查看权限”的车站及其下属设备。
* **搜索与筛选**:按名称/IP搜索按状态筛选。
* **树形交互**
* **展开与选择**:按“车站 -> 设备类型 -> 具体设备”层级展开与选择。
* **右键菜单管理****(需操作权限)**
* **车站节点右键**:支持 **导入设备**、**导出设备**。
* **设备节点右键**:支持 **删除设备**
2. **诊断详情页结构**
* **头部信息栏**状态、IP、关联跳转、Web管理入口。
* **功能标签页**
* **当前诊断**:实时数据展示。
* **历史诊断**:历史趋势查看。
* **修改设备**:编辑设备信息 **(注:仅对拥有“操作权限”的用户可见)**。
3. **特定设备诊断指标**
- **分类描述策略**
- **性能类设备**如交换机、服务器、NVR侧重硬件监控展示CPU/内存使用率仪表盘、端口状态、磁盘健康度等。
- **(补充) 下游设备关联配置**
- **交换机**:右键点击端口 -> 关联设备(如摄像机)。
- **安防箱**:右键点击电路 -> 关联设备。
- **操作权限要求****(需操作权限)**。
- **信息类设备**(如摄像机、报警主机):侧重参数展示,展示制造商、固件版本、协议配置等静态属性。
## 阶段四:告警管理模块
**涉及模块**`Alarm`
1. **子模块:告警记录 (Alarm Log)**
* **筛选查询**
* 组合筛选:车站(受查看权限限制)、设备类型、设备名称、告警类型、级别、状态、时间。
* **列表操作**
* **告警确认**:点击“确认”按钮处理告警 **(注:需拥有该车站的操作权限,否则按钮禁用/隐藏)**。
* **忽略设备**:将设备加入忽略列表 **(需操作权限)**。
* **数据导出**导出Excel报表。
2. **子模块:告警忽略 (Alarm Ignore)**
* **列表查看**:查看被忽略的设备记录。
* **取消忽略**:点击恢复监控 **(注:需拥有操作权限)**。
3. **告警策略配置 (系统设置)**
* **配置入口**:通过顶部导航栏“设置”按钮进入。
* **配置项**:告警画面截图保留天数、自动获取截图开关。
* **权限要求****(注:仅对 OCC 车站的操作员可见)**。
## 阶段五:日志管理模块
**涉及模块**`Log`
1. **子模块:上级调用日志 (Call Log)**
* **查询**:筛选车站(受查看权限限制)、日志类型(如视频点播、云台指令)、时间范围。
* **导出**:导出查询结果。
2. **子模块:视频平台日志 (Vimp Log)**
* **查询**:筛选车站(受查看权限限制)、内部操作类型、时间范围。
* **导出**:导出查询结果。
## 阶段六:权限管理模块
**涉及模块**`Permission`
**(注:本模块仅对“超级管理员”可见,普通用户无法访问)**
1. **用户列表管理**
* **搜索**:通过真实姓名查找用户。
* **重置**:清空搜索条件。
2. **权限配置 (Permission Config)**
* **矩阵式界面**
* **行**:对应各个车站。
* **列**:对应具体权限类型(如查看、操作)。
* **批量操作**:支持整行(单站全权)或整列(全站某权)一键勾选。
* **特殊规则说明**:若未给用户勾选任何权限,平台默认该用户拥有**所有权限**(超级管理员模式)。

146
README.md
View File

@@ -39,3 +39,149 @@ pnpm build
``` ```
在执行 `pnpm build` 之前,你可以在 `package.json` 中修改 `version` 字段,将其设置为你期望的版本号,构建完成后,项目的根目录中除了 `dist` 目录外,还会生成三个压缩包,文件名的格式统一为 `ndm-web-platform_v<version>_<datetime>`,文件格式则分别为 `zip``tar``tar.gz` 在执行 `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 # 权限管理页面
system/
changelog/
changelog-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`(告警数据)
> **注意**:每次导入一个文件后,平台会自动刷新页面以应用数据。请等待刷新完成后,重新打开设置面板导入下一个文件。

View File

@@ -11,7 +11,7 @@ const versionInfo = {
}; };
try { try {
await writeFile('./public/manifest.json', JSON.stringify(versionInfo, null, 2)); await writeFile('./public/manifest.json', `${JSON.stringify(versionInfo, null, 2)}\n`);
} catch (error) { } catch (error) {
console.error('写入manifest失败:', error); console.error('写入manifest失败:', error);
} }

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"
}
]
}

306
public/changelogs.json Normal file
View File

@@ -0,0 +1,306 @@
[
{
"version": "0.39.0",
"date": "2026-03-02",
"changes": {
"feats": [{ "content": "新版录像记录诊断卡片" }, { "content": "新增平台更新记录页面" }]
}
},
{
"version": "0.38.5",
"date": "2026-02-06",
"changes": {
"fixes": [{ "content": "修复视频平台和上级调用日志的默认查询没有携带logType参数的问题" }]
}
},
{
"version": "0.38.4",
"date": "2026-02-05",
"changes": {
"fixes": [{ "content": "修复告警记录导出未添加条件筛选" }, { "content": "将各查询页的默认分页size从10调整为20" }]
}
},
{
"version": "0.38.3",
"date": "2026-01-30",
"changes": {
"fixes": [{ "content": "修复录像诊断导出面板统一使用批量接口" }]
}
},
{
"version": "0.38.2",
"date": "2026-01-29",
"changes": {
"fixes": [{ "content": "修复服务状态和推流统计卡片的渲染条件" }, { "content": "用 useQuery 重构录像诊断卡片" }]
}
},
{
"version": "0.38.1",
"date": "2026-01-28",
"changes": {
"fixes": [{ "content": "下游设备配置添加权限校验" }]
}
},
{
"version": "0.38.0",
"date": "2026-01-28",
"changes": {
"fixes": [{ "content": "新增批量导出录像诊断功能并优化导出体验" }]
}
},
{
"version": "0.37.2",
"date": "2026-01-27",
"changes": {
"fixes": [{ "content": "修复设备树选中状态与路由同步的逻辑,修复选中的设备类型被异常还原的问题" }]
}
},
{
"version": "0.37.1",
"date": "2026-01-27",
"changes": {
"fixes": [{ "content": "完善设备卡片标签页切换逻辑" }]
}
},
{
"version": "0.37.0",
"date": "2026-01-22",
"changes": {
"feats": [{ "content": "添加权限查询和管理机制" }]
}
},
{
"version": "0.36.2",
"date": "2026-01-21",
"changes": {
"feats": [{ "content": "车站卡片布局列数自适应" }]
}
},
{
"version": "0.36.1",
"date": "2026-01-21",
"changes": {
"fixes": [{ "content": "重构内部状态管理" }]
}
},
{
"version": "0.36.0",
"date": "2026-01-16",
"changes": {
"feats": [{ "content": "设备告警记录页面添加告警恢复状态和确认状态筛选" }]
}
},
{
"version": "0.35.2",
"date": "2026-01-15",
"changes": {
"fixes": [
{ "content": "优化查询链的耗时和错误日志输出" },
{ "content": "优化车站状态页面的操作栏交互逻辑" },
{ "content": "简化录像诊断卡片逻辑" },
{ "content": "抽离未读告警状态,不再持久化" }
]
}
},
{
"version": "0.35.1",
"date": "2026-01-13",
"changes": {
"fixes": [{ "content": "修复设备硬件占用率卡片中showCard计算属性未获取原始值的问题" }]
}
},
{
"version": "0.35.0",
"date": "2026-01-13",
"changes": {
"feats": [{ "content": "更新图标" }]
}
},
{
"version": "0.34.1",
"date": "2026-01-08",
"changes": {
"fixes": [
{ "content": "修复数据表格标题错误" },
{ "content": "修复当API接口定义中没有响应数据时会意外抛出空数据异常的问题" },
{ "content": "未登录时启用离线开发模式后添加默认用户信息" },
{ "content": "修正告警页路由路径错误" },
{ "content": "将请求封装重构为函数模式" },
{ "content": "修复请求实例选择逻辑错误" }
]
}
},
{
"version": "0.34.0",
"date": "2026-01-04",
"changes": {
"fixes": [{ "content": "将表单中的“操作类型”标签改为“日志类型”" }, { "content": "移除操作参数和操作结果列" }, { "content": "修复操作类型列渲染错误的问题" }],
"feats": [{ "content": "上级调用日志添加更多数据" }]
}
},
{
"version": "0.33.0",
"date": "2026-01-04",
"changes": {
"fixes": [{ "content": "优化服务状态卡片的渲染条件" }, { "content": "添加防止设备自关联的校验" }],
"feats": [{ "content": "新增流媒体推流统计卡片" }, { "content": "新增告警画面截图相关设置" }]
}
},
{
"version": "0.32.0",
"date": "2025-12-30",
"changes": {
"feats": [{ "content": "新增告警画面截图相关设置" }]
}
},
{
"version": "0.31.0",
"date": "2025-12-30",
"changes": {
"feats": [{ "content": "新增告警忽略管理页面" }]
}
},
{
"version": "0.30.0",
"date": "2025-12-28",
"changes": {
"fixes": [{ "content": "调整路由结构,使告警板块支持子路由" }, { "content": "修复跳转设备时未检查deviceId存在性的问题" }],
"feats": [{ "content": "新增告警忽略管理页面" }, { "content": "支持查看摄像机告警画面截图" }, { "content": "查询页面卸载时取消未完成的请求" }]
}
},
{
"version": "0.29.0",
"date": "2025-12-26",
"changes": {
"feats": [{ "content": "扩展交换机端口诊断信息" }]
}
},
{
"version": "0.28.1",
"date": "2025-12-26",
"changes": {
"feats": [{ "content": "当下游设备不存在时自动解除关联" }]
}
},
{
"version": "0.28.0",
"date": "2025-12-26",
"changes": {
"feats": [{ "content": "告警记录支持点击设备跳转到设备详情" }, { "content": "设备关联与解除关联" }, { "content": "扩展设备树功能" }]
}
},
{
"version": "0.27.6",
"date": "2025-12-25",
"changes": {
"fixes": [{ "content": "移除所有设备更新表单中的上游设备字段" }]
}
},
{
"version": "0.27.5",
"date": "2025-12-25",
"changes": {
"fixes": [{ "content": "修复设备管理逻辑中错误处理的loading状态和取消逻辑的顺序" }]
}
},
{
"version": "0.27.4",
"date": "2025-12-24",
"changes": {
"fixes": [{ "content": "修复优化请求封装后获取摄像机画面截图请求异常的问题" }, { "content": "简化设备树的自动定位逻辑" }]
}
},
{
"version": "0.27.3",
"date": "2025-12-23",
"changes": {
"fixes": [{ "content": "次渲染全线设备树时不再区分是否从路由跳转而来,补全遗漏的取消监听" }]
}
},
{
"version": "0.27.2",
"date": "2025-12-23",
"changes": {
"feats": [{ "content": "摄像机卡片添加摄像机类型和建议安装区域" }]
}
},
{
"version": "0.26.3",
"date": "2025-12-19",
"changes": {
"feats": [{ "content": "调用新的设备告警日志导出接口" }]
}
},
{
"version": "0.26.2",
"date": "2025-12-19",
"changes": {
"fixes": [{ "content": "视频平台日志页面补全遗漏的操作类型字段" }]
}
},
{
"version": "0.26.1",
"date": "2025-12-19",
"changes": {
"fixes": [
{ "content": "修复由动画属性导致设备树在特定场景下无法自行滚动及展开节点失效的问题" },
{ "content": "设备树仅在非车站模式下显示收起和定位按钮" },
{ "content": "修复设备更新面板中错误的表单校验逻辑" },
{ "content": "简化设备树节点双击和点击事件的逻辑并添加注释" }
],
"feats": [{ "content": "细化设备树自动定位的触发条件" }, { "content": "渲染全线设备树时自动定位到所选设备" }]
}
},
{
"version": "0.26.0",
"date": "2025-12-17",
"changes": {
"fixes": [
{ "content": "简化设备树节点双击和点击事件的逻辑并添加注释" },
{ "content": "修复设备更新面板中错误的表单校验逻辑" },
{ "content": "426d92a - fix: 在导入和删除IndexedDB数据时停止轮询并启用离线开发模式以保证数据一致性" }
],
"feats": [{ "content": "新增设备树管理功能" }, { "content": "新增流媒体/信令服务状态卡片" }]
}
},
{
"version": "0.25.0",
"date": "2025-12-11",
"changes": {
"fixes": [{ "content": "改进设备卡片的布局" }, { "content": "改进内部状态管理" }],
"feats": [
{ "content": "全面优化平台数据轮询机制,提升平台性能" },
{ "content": "支持修改设备" },
{ "content": "告警轮询中获取完整告警数据" },
{ "content": "车站告警详情支持导出完整的今日告警列表" }
]
}
},
{
"version": "更早版本~0.25.0",
"date": "~2025-12-11",
"changes": {
"fixes": [
{ "content": "修复安防箱部分开关状态错误" },
{ "content": "优化版本更新机制" },
{ "content": "优化交互时的数据查询机制" },
{ "content": "优化获取摄像机告警时画面截图的交互体验" },
{ "content": "修复更改显示器息屏计划时的错误请求" },
{ "content": "修复开启实时告警刷新时的交互错误" },
{ "content": "修复404异常时的页面跳转错误" },
{ "content": "......." }
],
"feats": [
{ "content": "新增同步摄像机功能" },
{ "content": "支持多选车站导出设备列表" },
{ "content": "新增车站状态页面的操作栏" },
{ "content": "支持忽略摄像机告警" },
{ "content": "新增报警主机设备" },
{ "content": "设备告警页面支持实时刷新" },
{ "content": "新增支持获取摄像机告警时的画面截图" },
{ "content": "新增支持手动诊断设备" },
{ "content": "......" }
]
}
}
]

View File

@@ -1,4 +1,4 @@
{ {
"version": "", "version": "0.39.0",
"buildTime": "" "buildTime": "2026-03-02 15:14:53"
} }

View File

@@ -0,0 +1,13 @@
export interface ChangeLogDescription {
content: string;
}
export interface Changelog {
version: string;
date: string;
changes: {
breaks?: ChangeLogDescription[];
fixes?: ChangeLogDescription[];
feats?: ChangeLogDescription[];
};
}

View File

@@ -1 +1,2 @@
export * from './changelog';
export * from './version-info'; export * from './version-info';

View File

@@ -2,7 +2,7 @@ import DeviceCommonCard from './device-common-card.vue';
import DeviceHardwareCard from './device-hardware-card.vue'; import DeviceHardwareCard from './device-hardware-card.vue';
import DeviceHeaderCard from './device-header-card.vue'; import DeviceHeaderCard from './device-header-card.vue';
import NvrDiskCard from './nvr-disk-card.vue'; import NvrDiskCard from './nvr-disk-card.vue';
import NvrRecordCard from './nvr-record-card.vue'; import NvrRecordCheckCard from './nvr-record-check-card.vue';
import SecurityBoxCircuitCard from './security-box-circuit-card.vue'; import SecurityBoxCircuitCard from './security-box-circuit-card.vue';
import SecurityBoxCircuitLinkModal from './security-box-circuit-link-modal.vue'; import SecurityBoxCircuitLinkModal from './security-box-circuit-link-modal.vue';
import SecurityBoxEnvCard from './security-box-env-card.vue'; import SecurityBoxEnvCard from './security-box-env-card.vue';
@@ -14,7 +14,7 @@ export {
DeviceHardwareCard, DeviceHardwareCard,
DeviceHeaderCard, DeviceHeaderCard,
NvrDiskCard, NvrDiskCard,
NvrRecordCard, NvrRecordCheckCard,
SecurityBoxCircuitCard, SecurityBoxCircuitCard,
SecurityBoxCircuitLinkModal, SecurityBoxCircuitLinkModal,
SecurityBoxEnvCard, SecurityBoxEnvCard,

View File

@@ -1,235 +0,0 @@
<script setup lang="ts">
import { getChannelListApi, getRecordCheckApi, reloadAllRecordCheckApi, reloadRecordCheckApi, type NdmNvrResultVO, type RecordItem, type Station } from '@/apis';
import { exportRecordDiagCsv, transformRecordChecks } from '@/helpers';
import { useSettingStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import { DownloadIcon, RotateCwIcon } from 'lucide-vue-next';
import { NButton, NCard, NFlex, NIcon, NPagination, NPopconfirm, NPopover, NRadioButton, NRadioGroup, NTooltip, useThemeVars } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmNvrResultVO;
station: Station;
}>();
const settingStore = useSettingStore();
const { activeRequests } = storeToRefs(settingStore);
const themeVars = useThemeVars();
const queryClient = useQueryClient();
const { ndmDevice, station } = toRefs(props);
const lossInput = ref<number>(0);
const abortController = ref<AbortController>(new AbortController());
const NVR_RECORD_CHECK_KEY = 'nvr_record_check_query';
const {
data: recordChecks,
isFetching: loading,
refetch: refetchRecordChecks,
} = useQuery({
queryKey: computed(() => [NVR_RECORD_CHECK_KEY, ndmDevice.value.id, ndmDevice.value.lastDiagTime]),
enabled: computed(() => activeRequests.value),
refetchInterval: 30 * 1000,
gcTime: 0,
queryFn: async ({ signal }) => {
const checks = await getRecordCheckApi(ndmDevice.value, 90, [], { stationCode: station.value.code, signal });
return checks;
},
});
watch(activeRequests, (active) => {
if (!active) {
queryClient.cancelQueries({ queryKey: [NVR_RECORD_CHECK_KEY] });
}
});
const recordDiags = computed(() => {
return transformRecordChecks(recordChecks.value ?? []).filter((recordDiag) => {
if (lossInput.value === 0) {
return true;
} else if (lossInput.value === 1) {
return recordDiag.lostChunks.length > 0;
} else if (lossInput.value === 2) {
return recordDiag.lostChunks.length === 0;
}
return false;
});
});
const { mutate: reloadAllRecordCheck, isPending: reloading } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
await reloadAllRecordCheckApi(90, { stationCode: station.value.code, signal: abortController.value.signal });
},
onSuccess: () => {
window.$message.success('正在逐步刷新中,请稍后点击刷新按钮查看');
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onExportRecordCheck = () => {
exportRecordDiagCsv(recordDiags.value, station.value.name);
};
const page = ref(1);
const pageSize = ref(10);
const pagedRecordDiags = computed(() => {
const startIndex = (page.value - 1) * pageSize.value;
const endIndex = page.value * pageSize.value;
return recordDiags.value.slice(startIndex, endIndex);
});
const getLostChunkDOMStyle = (lostChunk: RecordItem, duration: RecordItem) => {
const chunk = dayjs(lostChunk.endTime).diff(dayjs(lostChunk.startTime));
const offset = dayjs(lostChunk.startTime).diff(dayjs(duration.startTime));
const total = dayjs(duration.endTime).diff(dayjs(duration.startTime));
return {
left: `${(offset / total) * 100}%`,
width: `${(chunk / total) * 100}%`,
};
};
const { mutate: reloadRecordCheckByGbId } = useMutation({
mutationFn: async (params: { gbCode: string }) => {
abortController.value.abort();
abortController.value = new AbortController();
const channelList = await getChannelListApi(ndmDevice.value, { stationCode: station.value.code, signal: abortController.value.signal });
const channel = channelList.find((channel) => channel.code === params.gbCode);
if (!channel) throw new Error('通道不存在');
window.$message.loading('刷新耗时较长, 请不要多次刷新, 并耐心等待...', {
duration: 1000 * 60 * 60 * 24 * 300,
});
const isSuccess = await reloadRecordCheckApi(channel, 90, { stationCode: station.value.code, signal: abortController.value.signal });
window.$message.destroyAll();
if (isSuccess) {
window.$message.success('刷新成功');
} else {
window.$message.error('刷新失败');
}
},
onSuccess: () => {
refetchRecordChecks();
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #header>
<NFlex align="center" :size="24">
<div>录像诊断</div>
<NPopconfirm @positive-click="() => reloadAllRecordCheck()">
<template #trigger>
<NButton secondary size="small" :loading="reloading">更新所有通道录像诊断</NButton>
</template>
<template #default>
<span>确认更新所有通道录像诊断吗?</span>
</template>
</NPopconfirm>
</NFlex>
</template>
<template #header-extra>
<NFlex>
<NTooltip trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle :loading="loading" @click="() => refetchRecordChecks()">
<template #icon>
<NIcon :component="RotateCwIcon" />
</template>
</NButton>
</template>
<template #default>
<span>刷新数据</span>
</template>
</NTooltip>
<NTooltip trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle @click="onExportRecordCheck">
<template #icon>
<NIcon :component="DownloadIcon" />
</template>
</NButton>
</template>
<template #default>
<span>导出录像诊断</span>
</template>
</NTooltip>
</NFlex>
</template>
<template #default>
<NFlex justify="flex-end" style="margin-bottom: 6px">
<NRadioGroup size="small" v-model:value="lossInput">
<NRadioButton label="全部" :value="0" />
<NRadioButton label="有缺失" :value="1" />
<NRadioButton label="无缺失" :value="2" />
</NRadioGroup>
</NFlex>
<template v-for="{ gbCode, channelName, recordDuration, lostChunks } in pagedRecordDiags" :key="gbCode">
<div style="display: flex; justify-content: space-between">
<div>
<span>{{ channelName }}</span>
<span>{{ '\u3000' }}</span>
<span>{{ recordDuration.startTime }} - {{ recordDuration.endTime }}</span>
</div>
<NPopconfirm trigger="click" @positive-click="() => reloadRecordCheckByGbId({ gbCode })">
<template #trigger>
<NButton ghost size="tiny" type="info">刷新</NButton>
</template>
<template #default>
<span>是否确认刷新?</span>
</template>
</NPopconfirm>
</div>
<div style="position: relative; height: 24px; margin: 2px 0" :style="{ backgroundColor: lostChunks.length > 0 ? themeVars.infoColor : themeVars.successColor }">
<template v-for="{ startTime, endTime } in lostChunks" :key="`${startTime}-${endTime}`">
<NPopover trigger="hover">
<template #trigger>
<div style="position: absolute; height: 100%; cursor: pointer; background-color: #eee" :style="getLostChunkDOMStyle({ startTime, endTime }, recordDuration)" />
</template>
<template #default>
<div>开始时间:{{ dayjs(startTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
<div>结束时间:{{ dayjs(endTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
</template>
</NPopover>
</template>
</div>
</template>
</template>
<template #action>
<NFlex justify="flex-end">
<NPagination size="small" :page="page" :page-size="pageSize" :page-count="Math.ceil(recordDiags.length / pageSize)" @update:page="(p) => (page = p)">
<template #prefix>
<span>{{ `共 ${recordDiags.length} 个通道` }}</span>
</template>
</NPagination>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,646 @@
<script lang="ts">
const DAY_RANGE_VALUE = 24 * 60 * 60 * 1000;
const formatDuration = (ms: number, options?: { withinDay?: boolean }) => {
const { withinDay = false } = options ?? {};
const duration = dayjs.duration(ms);
if (withinDay) {
if (duration.asDays() > 1) {
throw new Error('时长不能超过24小时');
}
}
const days = duration.days();
const hours = duration.hours();
const minutes = duration.minutes();
const seconds = duration.seconds();
let result = '';
if (days > 0) {
result += `${days}`;
}
if (hours > 0) {
result += `${hours}小时`;
}
if (minutes > 0) {
result += `${minutes}分钟`;
}
if (seconds > 0) {
result += `${seconds}`;
}
if (result === '') {
result = '0秒';
}
return result;
};
</script>
<script setup lang="ts">
import {
batchExportRecordCheckApi,
getChannelListApi,
getRecordCheckApi,
pageDefParameterApi,
reloadAllRecordCheckApi,
reloadRecordCheckApi,
type NdmNvrResultVO,
type RecordInfo,
type RecordItem,
type Station,
} from '@/apis';
import { useSettingStore } from '@/stores';
import { downloadByData, parseErrorFeedback } from '@/utils';
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { refDebounced } from '@vueuse/core';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import destr from 'destr';
import { DownloadIcon, RotateCwIcon } from 'lucide-vue-next';
import { NButton, NCard, NDataTable, NFlex, NIcon, NInput, NModal, NPagination, NPopconfirm, NPopover, NRadioButton, NRadioGroup, NTooltip, useThemeVars, type DataTableColumns } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, ref, toRefs, watch } from 'vue';
type DailyLossItem = {
date: string;
total: number; // 缺失时长单位ms
percent: number; // 缺失比例范围0-100
chunks: (RecordItem & { startValue: number; endValue: number })[];
};
type NdmRecordCheckAggregated = {
gbCode: string;
channelName: string;
range: RecordItem;
dailyLoss: DailyLossItem[];
};
const props = defineProps<{
ndmDevice: NdmNvrResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const settingStore = useSettingStore();
const { activeRequests } = storeToRefs(settingStore);
const themeVars = useThemeVars();
const queryClient = useQueryClient();
const filterType = ref<'all' | 'some' | 'none'>('all');
const abortController = ref<AbortController>(new AbortController());
const NVR_RECORD_CHECK_KEY = 'nvr-record-check-query';
const DAY_OFFSET = 90;
const {
data: recordChecks,
isFetching: loading,
refetch: refetchRecordChecks,
} = useQuery({
queryKey: computed(() => [NVR_RECORD_CHECK_KEY, ndmDevice.value.id, ndmDevice.value.lastDiagTime]),
enabled: computed(() => activeRequests.value),
refetchInterval: 30 * 1000,
gcTime: 0,
queryFn: async ({ signal }) => {
const checks = await getRecordCheckApi(ndmDevice.value, DAY_OFFSET, [], { stationCode: station.value.code, signal });
return checks;
},
});
watch(activeRequests, (active) => {
if (!active) queryClient.cancelQueries({ queryKey: [NVR_RECORD_CHECK_KEY] });
});
const { mutate: reloadAllRecordCheck, isPending: reloading } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
await reloadAllRecordCheckApi(DAY_OFFSET, { stationCode: station.value.code, signal: abortController.value.signal });
},
onSuccess: () => {
window.$message.success('正在逐步刷新中,请稍后点击刷新按钮查看');
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const { mutate: reloadRecordCheckByGbId } = useMutation({
mutationFn: async (params: { gbCode: string }) => {
abortController.value.abort();
abortController.value = new AbortController();
const channelList = await getChannelListApi(ndmDevice.value, { stationCode: station.value.code, signal: abortController.value.signal });
const channel = channelList.find((channel) => channel.code === params.gbCode);
if (!channel) throw new Error('通道不存在');
window.$message.loading('刷新耗时较长, 请不要多次刷新, 并耐心等待...', {
duration: 1000 * 60 * 60 * 24 * 300,
});
const isSuccess = await reloadRecordCheckApi(channel, DAY_OFFSET, { stationCode: station.value.code, signal: abortController.value.signal });
window.$message.destroyAll();
if (isSuccess) {
window.$message.success('刷新成功');
} else {
window.$message.error('刷新失败');
}
},
onSuccess: () => {
refetchRecordChecks();
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const { mutate: exportRecordCheck, isPending: exporting } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
const { records = [] } = await pageDefParameterApi(
{
model: {
key: 'NVR_GAP_SECONDS',
},
extra: {},
current: 1,
size: 1,
sort: 'id',
order: 'descending',
},
{
signal: abortController.value.signal,
},
);
const gapSeconds = parseInt(records.at(0)?.value ?? '5');
abortController.value.abort();
abortController.value = new AbortController();
const data = await batchExportRecordCheckApi(
{
checkDuration: DAY_OFFSET,
gapSeconds,
stationCode: [station.value.code],
},
{
signal: abortController.value.signal,
},
);
return data;
},
onSuccess: (data) => {
const time = dayjs().format('YYYY-MM-DD_HH-mm-ss');
downloadByData(data, `${station.value.name}_录像缺失记录_${time}.xlsx`);
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
abortController.value.abort();
});
// 按天聚合录像缺失片段
const ndmRecordChecksAggregated = computed(() => {
// 1. 解析diagInfo字段
const parsedChecks = (recordChecks.value ?? []).map((check) => {
return { ...check, diagInfo: destr<RecordInfo>(check.diagInfo) };
});
// 2. 按gbCode分组
// 原始数据的基本单元是一个通道在一天内的录像诊断,
// 所以我们要将相同通道的诊断数据组织到一起于是形成一个Map结构
const recordChecksMap = new Map<string, typeof parsedChecks>();
parsedChecks.forEach((check) => {
const { gbCode } = check;
if (!recordChecksMap.has(gbCode)) {
recordChecksMap.set(gbCode, []);
}
recordChecksMap.get(gbCode)?.push(check);
});
// 3. 按天进行聚合
// 我们的最终目标是从每个通道的录像记录中解析出缺失的录像片段,
// 并按天来组织这些片段形成NdmRecordCheckAggregated结构
const aggregated = Array.from(recordChecksMap.entries()).map<NdmRecordCheckAggregated>(([gbCode, checks]) => {
// 首先,将该通道的所有录像记录合并到一个数组中,
// 并对这些记录进行排序,确保按时间顺序排列
const records = checks
.flatMap((check) => {
return check.diagInfo.recordList.map((record) => {
const startValue = dayjs(record.startTime).valueOf();
const endValue = dayjs(record.endTime).valueOf();
const startTime = dayjs(record.startTime).format('YYYY-MM-DD HH:mm:ss');
const endTime = dayjs(record.endTime).format('YYYY-MM-DD HH:mm:ss');
return { startValue, endValue, startTime, endTime };
});
})
.sort(({ startValue: startValue1 }, { startValue: startValue2 }) => {
return startValue1 - startValue2;
});
const tomorrow = dayjs().add(1, 'day');
// 由于DAY_OFFSET实际上不包含今天而获取的数据又是包含今天的
// 所以实际的时间范围是 DAY_OFFSET + 1 天
const dateLength = DAY_OFFSET + 1;
// 初始化每日缺失记录,
// 在处理完成后,如果有一天的数据没有变化,就说明这一天没有缺失录像
const dailyLoss = Array.from({ length: dateLength }).map<NdmRecordCheckAggregated['dailyLoss'][number]>((_, index) => {
return {
date: tomorrow.subtract(dateLength - index, 'day').format('YYYY-MM-DD'),
total: 0,
percent: 0,
chunks: [],
};
});
// 开始解析按天组织的缺失录像片段,
// 缺失片段的持续时间很可能是跨天甚至是跨越多天的,所以为了将缺失片段分配到每一天,我们采用「游标 + 切片」的设计
// 首先,确定时间范围的开始和结束点
const rangeStart = dayjs(dailyLoss.at(0)?.date).startOf('day').valueOf();
const rangeEnd = dayjs(dailyLoss.at(-1)?.date).add(1, 'day').startOf('day').valueOf();
// 初始化时间游标,从第一天的开始时间开始
let timeCursor = rangeStart;
records.forEach((record) => {
const recordStart = record.startValue;
const recordEnd = record.endValue;
// 如果timeCursor < recordStart说明 [timeCursor, recordStart] 这段时间的录像是缺失的,
// 而这一段缺失有可能是跨天的,我们需要进行处理
while (timeCursor < recordStart) {
// 当前游标所属的日期
const cursorDate = dayjs(timeCursor).format('YYYY-MM-DD');
// 当前游标所属日期的末尾(下一天的开始时间)
const cursorDateEnd = dayjs(cursorDate).add(1, 'day').startOf('day').valueOf();
// 确定这一段缺失的终点,
// 要么是 [timeCursor, recordStart](没跨天),
// 要么是 [timeCursor, cursorDateEnd](跨天),
// 我们取较小的那个
const sliceEnd = Math.min(recordStart, cursorDateEnd);
// 只要这段缺失有效,就记下它
if (timeCursor < sliceEnd) {
const loss = dailyLoss.find((loss) => loss.date === cursorDate);
if (!!loss) {
const startValue = timeCursor;
const endValue = sliceEnd;
const startTime = dayjs(startValue).format('YYYY-MM-DD HH:mm:ss');
const endTime = dayjs(endValue).format('YYYY-MM-DD HH:mm:ss');
loss.chunks.push({ startValue, endValue, startTime, endTime });
loss.total += endValue - startValue;
loss.percent = (loss.total / DAY_RANGE_VALUE) * 100;
}
// 推进游标
timeCursor = sliceEnd;
} else {
// 假设这段缺失无效,说明这一天的数据有错乱,
// 我们推进游标到下一天的开始时间
timeCursor = cursorDateEnd;
}
}
// 上面我们处理了 [timeCursor, recordStart] 这段时间的缺失,
// 而 [recordStart, recordEnd] 这段时间的录像是完整的,
// 所以我们可以直接推进游标到 recordEnd
// 使用 Math.max 是为了防止两段录像记录交叉从而导致游标又发生回退
timeCursor = Math.max(timeCursor, recordEnd);
});
// 现在我们处理完了所有的录像记录但如果游标还没有到rangeEnd
// 说明还有一段缺失的录像记录没有被处理到,
// 我们需要将这一段缺失记录分配到最后一天
while (timeCursor < rangeEnd) {
const cursorDate = dayjs(timeCursor).format('YYYY-MM-DD');
const cursorDateEnd = dayjs(cursorDate).add(1, 'day').startOf('day').valueOf();
const sliceEnd = Math.min(rangeEnd, cursorDateEnd);
if (timeCursor < sliceEnd) {
const loss = dailyLoss.find((loss) => loss.date === cursorDate);
if (!!loss) {
const startValue = timeCursor;
const endValue = sliceEnd;
const startTime = dayjs(startValue).format('YYYY-MM-DD HH:mm:ss');
const endTime = dayjs(endValue).format('YYYY-MM-DD HH:mm:ss');
loss.chunks.push({ startValue, endValue, startTime, endTime });
loss.total += endValue - startValue;
loss.percent = (loss.total / DAY_RANGE_VALUE) * 100;
}
timeCursor = sliceEnd;
} else {
timeCursor = cursorDateEnd;
}
}
return {
gbCode: gbCode,
channelName: checks.at(-1)?.name ?? '',
range: {
startTime: records.at(0)?.startTime ?? '',
endTime: records.at(-1)?.endTime ?? '',
},
dailyLoss: dailyLoss,
};
});
// 最后我们把所有的gbCode按照字典序进行排序
return aggregated.sort((check1, check2) => {
return check1.gbCode.localeCompare(check2.gbCode);
});
});
const searchInput = ref<string>('');
const searchInputDebounced = refDebounced(searchInput, 100);
const ndmRecordChecksSearched = computed(() => {
if (!searchInputDebounced.value.trim()) {
return ndmRecordChecksAggregated.value;
}
return ndmRecordChecksAggregated.value.filter(({ channelName }) => {
return channelName.includes(searchInputDebounced.value);
});
});
const ndmRecordChecksFiltered = computed(() => {
// 最后一天就是「今天」录像不可能完整slice的时候别算进去
return ndmRecordChecksSearched.value.filter(({ dailyLoss }) => {
if (filterType.value === 'all') {
return true;
} else if (filterType.value === 'some') {
// return dailyLoss.slice(0, -1).some(({ percent }) => percent > 0);
for (let i = 0; i < dailyLoss.length - 1; i++) {
if ((dailyLoss[i]?.percent ?? 0) > 0) {
return true;
}
}
return false;
} else if (filterType.value === 'none') {
// return dailyLoss.slice(0, -1).every(({ percent }) => percent === 0);
for (let i = 0; i < dailyLoss.length - 1; i++) {
if ((dailyLoss[i]?.percent ?? 0) !== 0) {
return false;
}
}
return true;
}
return false;
});
});
const page = ref(1);
const pageSize = ref(10);
const ndmRecordChecksPaged = computed(() => {
const startIndex = (page.value - 1) * pageSize.value;
const endIndex = page.value * pageSize.value;
return ndmRecordChecksFiltered.value.slice(startIndex, endIndex);
});
// 当设备ID、最后诊断时间或筛选类型变化时重置分页为第一页
watch([() => ndmDevice.value.id, () => ndmDevice.value.lastDiagTime, filterType, searchInputDebounced], () => {
page.value = 1;
});
// 当设备ID变化时重置搜索内容并将筛选类型重置为「全部」
watch([() => ndmDevice.value.id], () => {
searchInput.value = '';
filterType.value = 'all';
});
// 录像诊断块的交互
const dailyCheckContext = ref<{
show: boolean;
x: number;
y: number;
info?: DailyLossItem;
}>({
show: false,
x: 0,
y: 0,
});
// 为了提升性能不循环渲染Popover而改为manual模式
// 但是当鼠标移动到Popover上时将触发录像诊断div块的mouseleave事件从而导致Popover隐藏。
// 为了解决这个问题当鼠标移出录像诊断块延迟100ms后再隐藏Popover
// 在延时期间如果鼠标再次移入录像诊断块或移入Popover则取消隐藏Popover的延迟操作
// 当鼠标离开Popover再次延时隐藏Popover。
const popoverTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const showDailyCheckPopover = (event: MouseEvent, dailyLossItem: DailyLossItem) => {
if (!!popoverTimer.value) {
clearTimeout(popoverTimer.value);
popoverTimer.value = null;
}
const { target } = event;
if (!target) return;
const { width, left, top } = (target as HTMLDivElement).getBoundingClientRect();
dailyCheckContext.value = {
show: true,
x: left + width / 2,
y: top,
info: dailyLossItem,
};
};
const hideDailyCheckPopover = () => {
popoverTimer.value = setTimeout(() => {
dailyCheckContext.value.show = false;
}, 100);
};
const onMouseEnterDailyCheckPopover = () => {
if (!!popoverTimer.value) {
clearTimeout(popoverTimer.value);
popoverTimer.value = null;
}
};
const onMouseLeaveDailyCheckPopover = () => {
hideDailyCheckPopover();
};
// 录像缺失详情弹窗
const showDailyLossModal = ref(false);
const onClickDailyCheck = () => {
const { info } = dailyCheckContext.value;
if (!info) return;
const { total } = info;
if (total === 0) return;
showDailyLossModal.value = true;
};
const columns: DataTableColumns<DailyLossItem['chunks'][number]> = [
{ title: '开始时间', key: 'startTime' },
{ title: '结束时间', key: 'endTime' },
{
title: '持续时间',
key: 'duration',
render: ({ startValue, endValue }) => {
return formatDuration(endValue - startValue, { withinDay: true });
},
},
];
</script>
<template>
<NCard hoverable size="small">
<template #header>
<NFlex align="center" :size="24">
<div>录像诊断</div>
<NPopconfirm @positive-click="() => reloadAllRecordCheck()">
<template #trigger>
<NButton secondary size="small" :loading="reloading">更新所有通道录像诊断</NButton>
</template>
<template #default>
<span>确认更新所有通道录像诊断吗?</span>
</template>
</NPopconfirm>
</NFlex>
</template>
<template #header-extra>
<NFlex>
<NTooltip trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle :loading="loading" @click="() => refetchRecordChecks()">
<template #icon>
<NIcon :component="RotateCwIcon" />
</template>
</NButton>
</template>
<template #default>
<span>刷新数据</span>
</template>
</NTooltip>
<NTooltip trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle :loading="exporting" @click="() => exportRecordCheck()">
<template #icon>
<NIcon :component="DownloadIcon" />
</template>
</NButton>
</template>
<template #default>
<span>导出录像诊断</span>
</template>
</NTooltip>
</NFlex>
</template>
<template #default>
<NFlex justify="flex-end" align="center" :wrap="false" style="width: 100%; margin-bottom: 6px">
<NInput v-model:value="searchInput" placeholder="搜索通道名称" clearable />
<NRadioGroup size="small" v-model:value="filterType">
<NRadioButton label="全部" :value="'all'" />
<NRadioButton label="有缺失" :value="'some'" />
<NRadioButton label="无缺失" :value="'none'" />
</NRadioGroup>
</NFlex>
<template v-for="{ gbCode, channelName, range, dailyLoss } in ndmRecordChecksPaged" :key="gbCode">
<div style="display: flex; justify-content: space-between">
<div>
<span>{{ channelName }}</span>
<span>{{ '\u3000' }}</span>
<span>{{ range.startTime }} - {{ range.endTime }}</span>
</div>
<NPopconfirm trigger="click" @positive-click="() => reloadRecordCheckByGbId({ gbCode })">
<template #trigger>
<NButton ghost size="tiny" type="info">刷新</NButton>
</template>
<template #default>
<span>是否确认刷新?</span>
</template>
</NPopconfirm>
</div>
<div
style="position: relative; height: 24px; margin: 2px 0; background-color: #ccc; display: grid"
:style="{
gridTemplateRows: `1fr`,
gridTemplateColumns: `repeat(${dailyLoss.length}, 1fr)`,
}"
>
<template v-for="({ date, total, percent, chunks }, index) in dailyLoss" :key="date">
<div
style="border-width: 0 1px; border-style: solid"
:style="{
cursor: percent > 0 ? 'pointer' : 'default',
borderColor: themeVars.baseColor,
backgroundColor: (() => {
// 如果是最后一天(今天),且录像的确持续到了最后一天,则不设置背景颜色
if (index === dailyLoss.length - 1) {
if (dayjs(dailyLoss.at(-1)?.date).startOf('day').diff(dayjs(range.endTime)) < 0) {
return 'transparent';
}
}
// 不缺失,设置为绿色
if (percent === 0) {
return `rgb(24, 160, 88)`;
}
// 将缺失占比映射到范围为 [0.2, 1] 的红色透明度通道
const opacity = 0.2 + (1 - 0.2) * (percent / 100);
return `rgba(208, 48, 80, ${opacity})`;
})(),
}"
@mouseenter="(event) => showDailyCheckPopover(event, { date, total, percent, chunks })"
@mouseleave="hideDailyCheckPopover"
@click="onClickDailyCheck"
></div>
</template>
</div>
</template>
</template>
<template #action>
<NFlex justify="flex-end">
<NPagination size="small" :page="page" :page-size="pageSize" :page-count="Math.ceil(ndmRecordChecksFiltered.length / pageSize)" @update:page="(p) => (page = p)">
<template #prefix>
<span>{{ `共 ${ndmRecordChecksFiltered.length} 个通道` }}</span>
</template>
</NPagination>
</NFlex>
</template>
</NCard>
<NPopover
trigger="manual"
:show="dailyCheckContext.show"
:x="dailyCheckContext.x"
:y="dailyCheckContext.y"
:show-arrow="false"
@mouseenter="onMouseEnterDailyCheckPopover"
@mouseleave="onMouseLeaveDailyCheckPopover"
>
<template #default>
<template v-if="!!dailyCheckContext.info">
<div>日期:{{ dailyCheckContext.info.date }}</div>
<div>缺失时长:{{ formatDuration(dailyCheckContext.info.total, { withinDay: true }) }}</div>
<div>缺失比例:{{ dailyCheckContext.info.percent.toFixed(2) }}%</div>
<div v-if="dailyCheckContext.info.percent > 0" style="font-size: xx-small; opacity: 0.5; cursor: pointer" @click="onClickDailyCheck">点击查看详情</div>
</template>
</template>
</NPopover>
<NModal v-model:show="showDailyLossModal" preset="card" title="录像缺失详情" style="width: 600px">
<template #default>
<template v-if="!!dailyCheckContext.info">
<div style="margin-bottom: 16px; font-weight: bold">{{ dailyCheckContext.info.date }} 共缺失 {{ dailyCheckContext.info.chunks.length }} 个录像片段</div>
<NDataTable :columns="columns" :data="dailyCheckContext.info.chunks" :pagination="{ pageSize: 10 }" size="small" :min-height="400" :max-height="400" />
</template>
</template>
</NModal>
</template>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmNvrDiagInfo, NdmNvrResultVO, Station } from '@/apis'; import type { NdmNvrDiagInfo, NdmNvrResultVO, Station } from '@/apis';
import { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, NvrDiskCard, NvrRecordCard } from '@/components'; import { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, NvrDiskCard, NvrRecordCheckCard } from '@/components';
import { isNvrCluster } from '@/helpers'; import { isNvrCluster } from '@/helpers';
import destr from 'destr'; import destr from 'destr';
import { NFlex } from 'naive-ui'; import { NFlex } from 'naive-ui';
@@ -47,7 +47,9 @@ const diskArray = computed(() => lastDiagInfo.value?.info?.groupInfoList);
<DeviceCommonCard :common-info="commonInfo" /> <DeviceCommonCard :common-info="commonInfo" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" /> <DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
<NvrDiskCard :disk-health="diskHealth" :disk-array="diskArray" /> <NvrDiskCard :disk-health="diskHealth" :disk-array="diskArray" />
<NvrRecordCard v-if="isNvrCluster(ndmDevice)" :ndm-device="ndmDevice" :station="station" /> <template v-if="isNvrCluster(ndmDevice)">
<NvrRecordCheckCard :ndm-device="ndmDevice" :station="station" />
</template>
</NFlex> </NFlex>
</template> </template>

View File

@@ -13,9 +13,12 @@ import destr from 'destr';
import { isFunction } from 'es-toolkit'; import { isFunction } from 'es-toolkit';
import localforage from 'localforage'; import localforage from 'localforage';
import { DownloadIcon, Trash2Icon, UploadIcon } from 'lucide-vue-next'; 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 { NButton, NButtonGroup, NDivider, NDrawer, NDrawerContent, NDropdown, NFlex, NFormItem, NIcon, NInput, NInputNumber, NModal, NSwitch, NText, NTooltip, type DropdownOption } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const show = defineModel<boolean>('show', { default: false }); const show = defineModel<boolean>('show', { default: false });
@@ -284,6 +287,11 @@ const onDrawerAfterLeave = () => {
abortControllers.value.retentionDays.abort(); abortControllers.value.retentionDays.abort();
abortControllers.value.snapStatus.abort(); abortControllers.value.snapStatus.abort();
}; };
const onClickVersion = () => {
show.value = false;
router.push({ path: '/changelog' });
};
</script> </script>
<template> <template>
@@ -383,7 +391,16 @@ const onDrawerAfterLeave = () => {
</NFlex> </NFlex>
<template #footer> <template #footer>
<NFlex vertical justify="flex-end" align="center" style="width: 100%; font-size: 12px; gap: 4px"> <NFlex vertical justify="flex-end" align="center" style="width: 100%; font-size: 12px; gap: 4px">
<NText :depth="3">平台版本: {{ versionInfo.version }} ({{ versionInfo.buildTime }})</NText> <NTooltip>
<template #trigger>
<div @click="onClickVersion">
<NText :depth="3" style="cursor: pointer">平台版本: {{ versionInfo.version }} ({{ versionInfo.buildTime }})</NText>
</div>
</template>
<template #default>
<NText :depth="3">点击可查看平台更新记录</NText>
</template>
</NTooltip>
</NFlex> </NFlex>
</template> </template>
</NDrawerContent> </NDrawerContent>

View File

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

View File

@@ -1,26 +0,0 @@
import type { Station } from '@/apis';
import type { NvrRecordDiag } from './record-check';
import { downloadByData, formatDuration } from '@/utils';
import dayjs from 'dayjs';
export const exportRecordDiagCsv = (recordDiags: NvrRecordDiag[], stationName: Station['name']) => {
const csvHeader = '通道名称,开始时间,结束时间,持续时长\n';
const csvRows = recordDiags
.map((channel) => {
if (channel.lostChunks.length === 0) {
return `${channel.channelName},,,`;
}
return channel.lostChunks
.map((loss) => {
const duration = formatDuration(loss.startTime, loss.endTime);
const startTime = dayjs(loss.startTime).format('YYYY-MM-DD HH:mm:ss');
const endTime = dayjs(loss.endTime).format('YYYY-MM-DD HH:mm:ss');
return `${channel.channelName},${startTime},${endTime},${duration}`;
})
.join('\n');
})
.join('\n');
const csvContent = csvHeader.concat(csvRows);
const time = dayjs().format('YYYY-MM-DD_HH-mm-ss');
downloadByData(csvContent, `${stationName}_录像缺失记录_${time}.csv`, 'text/csv;charset=utf-8', '\ufeff');
};

View File

@@ -1,5 +1,3 @@
export * from './device-alarm'; export * from './device-alarm';
export * from './export-record-diag-csv';
export * from './nvr-cluster'; export * from './nvr-cluster';
export * from './record-check';
export * from './switch-port'; export * from './switch-port';

View File

@@ -1,69 +0,0 @@
import type { NdmRecordCheck, RecordInfo, RecordItem } from '@/apis';
import dayjs from 'dayjs';
import destr from 'destr';
import { groupBy } from 'es-toolkit';
export type NvrRecordDiag = {
gbCode: string;
channelName: string;
recordDuration: RecordItem;
lostChunks: RecordItem[];
};
// 解析出丢失的录像时间段
export const transformRecordChecks = (rawRecordChecks: NdmRecordCheck[]): NvrRecordDiag[] => {
// 解析diagInfo
const parsedRecordChecks = rawRecordChecks.map((recordCheck) => ({
...recordCheck,
diagInfo: destr<RecordInfo>(recordCheck.diagInfo),
}));
// 按国标码分组
const recordChecksByGbCode = groupBy(parsedRecordChecks, (recordCheck) => recordCheck.gbCode);
// 提取分组后的国标码和录像诊断记录
const channelGbCodes = Object.keys(recordChecksByGbCode);
const recordChecksList = Object.values(recordChecksByGbCode);
// 初始化每个通道的录像诊断数据结构
const recordDiags = channelGbCodes.map((gbCode, index) => ({
gbCode,
channelName: recordChecksList.at(index)?.at(-1)?.name ?? '',
records: [] as RecordItem[],
lostChunks: [] as RecordItem[],
}));
// 写入同一gbCode的录像片段
recordChecksList.forEach((recordChecks, index) => {
recordChecks.forEach((recordCheck) => {
recordDiags.at(index)?.records.push(...recordCheck.diagInfo.recordList);
});
});
// 过滤掉没有录像记录的通道
const filteredRecordDiags = recordDiags.filter((recordDiag) => recordDiag.records.length > 0);
// 计算每个通道丢失的录像时间片段
filteredRecordDiags.forEach((recordDiag) => {
recordDiag.records.forEach((record, index, records) => {
const nextRecordItem = records.at(index + 1);
if (!!nextRecordItem) {
// 如果下一段录像的开始时间不等于当前录像的结束时间,则判定为丢失
const nextStartTime = nextRecordItem.startTime;
const currEndTime = record.endTime;
if (nextStartTime !== currEndTime) {
recordDiag.lostChunks.push({
startTime: currEndTime,
endTime: nextStartTime,
});
}
}
});
});
return recordDiags.map((recordDiag) => {
const firstRecord = recordDiag.records.at(0);
const startTime = firstRecord ? dayjs(firstRecord.startTime).format('YYYY-MM-DD HH:mm:ss') : '';
const lastRecord = recordDiag.records.at(-1);
const endTime = lastRecord ? dayjs(lastRecord.endTime).format('YYYY-MM-DD HH:mm:ss') : '';
return {
gbCode: recordDiag.gbCode,
channelName: recordDiag.channelName,
recordDuration: { startTime, endTime },
lostChunks: recordDiag.lostChunks,
};
});
};

View File

@@ -211,7 +211,7 @@ const { mutate: cancelIgnore } = useMutation({
const tableData = ref<DataTableRowData[]>([]); const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 20;
const pagination = reactive<PaginationProps>({ const pagination = reactive<PaginationProps>({
showSizePicker: true, showSizePicker: true,
page: 1, page: 1,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { exportDeviceAlarmLogApi, pageDeviceAlarmLogApi, type NdmDeviceAlarmLog, type NdmDeviceAlarmLogResultVO, type PageQueryExtra, type Station } from '@/apis'; import { exportDeviceAlarmLogApi, pageDeviceAlarmLogApi, type NdmDeviceAlarmLog, type NdmDeviceAlarmLogPageQuery, type NdmDeviceAlarmLogResultVO, type PageQueryExtra, type Station } from '@/apis';
import { useAlarmActionColumn, useCameraSnapColumn } from '@/composables'; import { useAlarmActionColumn, useCameraSnapColumn } from '@/composables';
import { ALARM_TYPES, DEVICE_TYPE_CODES, DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, FAULT_LEVELS, tryGetDeviceType, type DeviceType } from '@/enums'; import { ALARM_TYPES, DEVICE_TYPE_CODES, DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, FAULT_LEVELS, tryGetDeviceType, type DeviceType } from '@/enums';
import { renderAlarmDateCell, renderAlarmTypeCell, renderDeviceTypeCell, renderFaultLevelCell } from '@/helpers'; import { renderAlarmDateCell, renderAlarmTypeCell, renderDeviceTypeCell, renderFaultLevelCell } from '@/helpers';
@@ -132,6 +132,12 @@ const resetSearchFields = () => {
alarmConfirm: '', alarmConfirm: '',
}; };
}; };
const getModelFields = (): NdmDeviceAlarmLogPageQuery => {
return {
alarmCategory: searchFields.value.alarmCategory || undefined,
alarmConfirm: searchFields.value.alarmConfirm || undefined,
};
};
const getExtraFields = (): PageQueryExtra<NdmDeviceAlarmLog> => { const getExtraFields = (): PageQueryExtra<NdmDeviceAlarmLog> => {
const stationCodeIn = searchFields.value.stationCode_in; const stationCodeIn = searchFields.value.stationCode_in;
const deviceTypeIn = searchFields.value.deviceType_in.flatMap((deviceType) => DEVICE_TYPE_CODES[deviceType as DeviceType]); const deviceTypeIn = searchFields.value.deviceType_in.flatMap((deviceType) => DEVICE_TYPE_CODES[deviceType as DeviceType]);
@@ -212,7 +218,7 @@ const tableColumns: DataTableColumns<NdmDeviceAlarmLogResultVO> = [
alarmActionColumn, alarmActionColumn,
]; ];
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 20;
const pagination = reactive<PaginationProps>({ const pagination = reactive<PaginationProps>({
showSizePicker: true, showSizePicker: true,
page: 1, page: 1,
@@ -244,10 +250,7 @@ const { mutate: getTableData, isPending: tableLoading } = useMutation({
const res = await pageDeviceAlarmLogApi( const res = await pageDeviceAlarmLogApi(
{ {
model: { model: getModelFields(),
alarmCategory: searchFields.value.alarmCategory || undefined,
alarmConfirm: searchFields.value.alarmConfirm || undefined,
},
extra: getExtraFields(), extra: getExtraFields(),
current: pagination.page ?? 1, current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE, size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
@@ -302,7 +305,7 @@ const { mutate: exportTableData, isPending: exporting } = useMutation({
const data = await exportDeviceAlarmLogApi( const data = await exportDeviceAlarmLogApi(
{ {
model: {}, model: getModelFields(),
extra: getExtraFields(), extra: getExtraFields(),
current: pagination.page ?? 1, current: pagination.page ?? 1,
size: pagination.pageSize ?? 10, size: pagination.pageSize ?? 10,

View File

@@ -104,7 +104,7 @@ const getExtraFields = (): PageQueryExtra<NdmCallLog> => {
const method_like = searchFields.value.method_like; const method_like = searchFields.value.method_like;
const messageType_like = searchFields.value.messageType_like; const messageType_like = searchFields.value.messageType_like;
const cmdType_like = searchFields.value.cmdType_like; const cmdType_like = searchFields.value.cmdType_like;
const logType_in = searchFields.value.logType_in; const logType_in = searchFields.value.logType_in.length > 0 ? [...searchFields.value.logType_in] : [...callLogTypeOptions.map((option) => option.value)];
return { return {
createdTime_precisest, createdTime_precisest,
createdTime_preciseed, createdTime_preciseed,
@@ -143,7 +143,7 @@ const tableColumns: DataTableColumns<NdmCallLogResultVO> = [
const tableData = ref<DataTableRowData[]>([]); const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 20;
const pagination = reactive<PaginationProps>({ const pagination = reactive<PaginationProps>({
showSizePicker: true, showSizePicker: true,
page: 1, page: 1,

View File

@@ -95,7 +95,7 @@ const resetSearchFields = () => {
const getExtraFields = (): PageQueryExtra<NdmVimpLog> => { const getExtraFields = (): PageQueryExtra<NdmVimpLog> => {
const createdTime_precisest = searchFields.value.createdTime[0]; const createdTime_precisest = searchFields.value.createdTime[0];
const createdTime_preciseed = searchFields.value.createdTime[1]; const createdTime_preciseed = searchFields.value.createdTime[1];
const logType_in = (searchFields.value.logType_in ?? []).length > 0 ? [...searchFields.value.logType_in] : undefined; const logType_in = searchFields.value.logType_in.length > 0 ? [...searchFields.value.logType_in] : [...vimpLogTypeOptions.map((option) => option.value)];
return { return {
createdTime_precisest, createdTime_precisest,
createdTime_preciseed, createdTime_preciseed,
@@ -110,14 +110,7 @@ watch(searchFields, () => {
const tableColumns: DataTableColumns<NdmVimpLogResultVO> = [ const tableColumns: DataTableColumns<NdmVimpLogResultVO> = [
{ title: '时间', key: 'createdTime' }, { title: '时间', key: 'createdTime' },
{ { title: '操作类型', key: 'description' },
title: '操作类型',
key: 'logType',
render: (rowData) => {
const option = vimpLogTypeOptions.find((option) => option.value === rowData.logType);
return `${option?.label ?? ''}`;
},
},
{ title: '请求IP', key: 'requestIp' }, { title: '请求IP', key: 'requestIp' },
{ title: '耗时(ms)', key: 'consumedTime' }, { title: '耗时(ms)', key: 'consumedTime' },
{ title: '被调用设备', key: 'targetCode' }, { title: '被调用设备', key: 'targetCode' },
@@ -125,7 +118,7 @@ const tableColumns: DataTableColumns<NdmVimpLogResultVO> = [
const tableData = ref<DataTableRowData[]>([]); const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 20;
const pagination = reactive<PaginationProps>({ const pagination = reactive<PaginationProps>({
showSizePicker: true, showSizePicker: true,
page: 1, page: 1,

View File

@@ -63,7 +63,7 @@ const tableColumns: DataTableColumns<BaseEmployeeResultVO> = [
const tableData = ref<DataTableRowData[]>([]); const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 20;
const pagination = reactive<PaginationProps>({ const pagination = reactive<PaginationProps>({
showSizePicker: true, showSizePicker: true,
page: 1, page: 1,

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { Changelog } from '@/apis';
import { useQuery } from '@tanstack/vue-query';
import axios from 'axios';
import { NH1, NH2, NH3, NLi, NP, NScrollbar, NText, NUl } from 'naive-ui';
import { computed } from 'vue';
const CHENGELOGS_QUERY_KEY = 'changelogs-query';
const { data: changelogs = [] } = useQuery({
queryKey: computed(() => [CHENGELOGS_QUERY_KEY]),
queryFn: async ({ signal }) => {
const response = await axios.get<Changelog[]>(`changelogs.json?t=${Date.now()}`, { signal });
return response.data;
},
});
</script>
<template>
<NScrollbar content-style="padding: 32px 24px 56px 56px" style="width: 100%; height: 100%">
<NH1>平台更新记录</NH1>
<template v-for="{ version, date, changes } in changelogs" :key="version">
<NH2>{{ version }}</NH2>
<NP>
<NText code>{{ date }}</NText>
</NP>
<template v-if="(changes.breaks?.length ?? 0) > 0">
<NH3>重大变更</NH3>
<template v-for="({ content }, index) in changes.breaks" :key="index">
<NUl>
<NLi>{{ content }}</NLi>
</NUl>
</template>
</template>
<template v-if="(changes.fixes?.length ?? 0) > 0">
<NH3>修复</NH3>
<template v-for="({ content }, index) in changes.fixes" :key="index">
<NUl>
<NLi>{{ content }}</NLi>
</NUl>
</template>
</template>
<template v-if="(changes.feats?.length ?? 0) > 0">
<NH3>新增</NH3>
<template v-for="({ content }, index) in changes.feats" :key="index">
<NUl>
<NLi>{{ content }}</NLi>
</NUl>
</template>
</template>
</template>
</NScrollbar>
</template>
<style scoped></style>

View File

@@ -56,9 +56,13 @@ const router = createRouter({
return { path: '/404' }; return { path: '/404' };
}, },
}, },
{
path: 'changelog',
component: () => import('@/pages/system/changelog/changelog-page.vue'),
},
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
component: () => import('@/pages/error/not-found-page.vue'), component: () => import('@/pages/system/error/not-found-page.vue'),
}, },
], ],
}, },

View File

@@ -1,7 +1,7 @@
import type { NdmPermissionResultVO, Station } from '@/apis'; import type { NdmPermissionResultVO, Station } from '@/apis';
import { NDM_PERMISSION_STORE_ID } from '@/constants'; import { NDM_PERMISSION_STORE_ID } from '@/constants';
import { PERMISSION_TYPE_NAMES, type PermissionType } from '@/enums'; import { PERMISSION_TYPE_NAMES, type PermissionType } from '@/enums';
import { useStationStore } from '@/stores'; import { useSettingStore, useStationStore } from '@/stores';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { objectEntries } from '@vueuse/core'; import { objectEntries } from '@vueuse/core';
@@ -12,12 +12,21 @@ export const usePermissionStore = defineStore(
NDM_PERMISSION_STORE_ID, NDM_PERMISSION_STORE_ID,
() => { () => {
const stationStore = useStationStore(); const stationStore = useStationStore();
const settingStore = useSettingStore();
const permissionRecords = ref<NdmPermissionResultVO[] | null>(null); const permissionRecords = ref<NdmPermissionResultVO[] | null>(null);
const permissions = computed<Permissions>(() => { const permissions = computed<Permissions>(() => {
const result: Permissions = {}; const result: Permissions = {};
// 如果启用了mock用户则授予所有车站全部权限
if (settingStore.mockUser) {
stationStore.stations.forEach((station) => {
result[station.code] = [...objectEntries(PERMISSION_TYPE_NAMES).map(([permType]) => permType)];
});
return result;
}
const records = permissionRecords.value; const records = permissionRecords.value;
// 如果权限记录不存在,则不做权限配置 // 如果权限记录不存在,则不做权限配置

View File

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

View File

@@ -41,6 +41,41 @@ const getConfigureFn = (opts?: { ws?: boolean }): ProxyOptions['configure'] => {
} }
}; };
const line01ApiProxyList: ProxyItem[] = [
{ key: '/0175/api', target: 'http://10.14.0.10:18760', rewrite: ['/0175/api', '/api'] },
{ key: '/0176/api', target: 'http://10.14.97.10:18760', rewrite: ['/0176/api', '/api'] },
{ key: '/0168/api', target: 'http://10.14.116.10:18760', rewrite: ['/0168/api', '/api'] },
{ key: '/0181/api', target: 'http://10.14.120.10:18760', rewrite: ['/0181/api', '/api'] },
{ key: '/0101/api', target: 'http://10.14.1.10:18760', rewrite: ['/0101/api', '/api'] },
{ key: '/0102/api', target: 'http://10.14.3.10:18760', rewrite: ['/0102/api', '/api'] },
{ key: '/0103/api', target: 'http://10.14.5.10:18760', rewrite: ['/0103/api', '/api'] },
{ key: '/0104/api', target: 'http://10.14.7.10:18760', rewrite: ['/0104/api', '/api'] },
{ key: '/0105/api', target: 'http://10.14.9.10:18760', rewrite: ['/0105/api', '/api'] },
{ key: '/0106/api', target: 'http://10.14.11.10:18760', rewrite: ['/0106/api', '/api'] },
{ key: '/0107/api', target: 'http://10.14.13.10:18760', rewrite: ['/0107/api', '/api'] },
{ key: '/0108/api', target: 'http://10.14.15.10:18760', rewrite: ['/0108/api', '/api'] },
{ key: '/0109/api', target: 'http://10.14.17.10:18760', rewrite: ['/0109/api', '/api'] },
{ key: '/0110/api', target: 'http://10.14.19.10:18760', rewrite: ['/0110/api', '/api'] },
{ key: '/0111/api', target: 'http://10.14.21.10:18760', rewrite: ['/0111/api', '/api'] },
{ key: '/0112/api', target: 'http://10.14.23.10:18760', rewrite: ['/0112/api', '/api'] },
{ key: '/0113/api', target: 'http://10.14.25.10:18760', rewrite: ['/0113/api', '/api'] },
{ key: '/0114/api', target: 'http://10.14.27.10:18760', rewrite: ['/0114/api', '/api'] },
{ key: '/0115/api', target: 'http://10.14.29.10:18760', rewrite: ['/0115/api', '/api'] },
{ key: '/0116/api', target: 'http://10.14.31.10:18760', rewrite: ['/0116/api', '/api'] },
{ key: '/0117/api', target: 'http://10.14.33.10:18760', rewrite: ['/0117/api', '/api'] },
{ key: '/0118/api', target: 'http://10.14.35.10:18760', rewrite: ['/0118/api', '/api'] },
{ key: '/0119/api', target: 'http://10.14.37.10:18760', rewrite: ['/0119/api', '/api'] },
{ key: '/0120/api', target: 'http://10.14.39.10:18760', rewrite: ['/0120/api', '/api'] },
{ key: '/0121/api', target: 'http://10.14.41.10:18760', rewrite: ['/0121/api', '/api'] },
{ key: '/0122/api', target: 'http://10.14.43.10:18760', rewrite: ['/0122/api', '/api'] },
{ key: '/0123/api', target: 'http://10.14.45.10:18760', rewrite: ['/0123/api', '/api'] },
{ key: '/0124/api', target: 'http://10.14.47.10:18760', rewrite: ['/0124/api', '/api'] },
{ key: '/0125/api', target: 'http://10.14.49.10:18760', rewrite: ['/0125/api', '/api'] },
{ key: '/0126/api', target: 'http://10.14.51.10:18760', rewrite: ['/0126/api', '/api'] },
{ key: '/0127/api', target: 'http://10.14.53.10:18760', rewrite: ['/0127/api', '/api'] },
{ key: '/0128/api', target: 'http://10.14.55.10:18760', rewrite: ['/0128/api', '/api'] },
];
const line04ApiProxyList: ProxyItem[] = [ const line04ApiProxyList: ProxyItem[] = [
{ key: '/0475/api', target: 'http://10.15.128.10:18760', rewrite: ['/0475/api', '/api'] }, { key: '/0475/api', target: 'http://10.15.128.10:18760', rewrite: ['/0475/api', '/api'] },
{ key: '/0480/api', target: 'http://10.15.244.10:18760', rewrite: ['/0480/api', '/api'] }, { key: '/0480/api', target: 'http://10.15.244.10:18760', rewrite: ['/0480/api', '/api'] },
@@ -103,6 +138,11 @@ const line10ApiProxyList: ProxyItem[] = [
]; ];
const apiProxyList: ProxyItem[] = [ const apiProxyList: ProxyItem[] = [
// { key: '/minio', target: 'http://10.14.0.10:9000', rewrite: ['/minio', ''] },
// { key: '/api', target: 'http://10.14.0.10:18760' },
// { key: '/ws', target: 'ws://10.14.0.10:18103', ws: true },
...line01ApiProxyList,
// { key: '/minio', target: 'http://10.15.128.10:9000', rewrite: ['/minio', ''] }, // { key: '/minio', target: 'http://10.15.128.10:9000', rewrite: ['/minio', ''] },
// { key: '/api', target: 'http://10.15.128.10:18760' }, // { key: '/api', target: 'http://10.15.128.10:18760' },
// { key: '/ws', target: 'ws://10.15.128.10:18103', ws: true }, // { key: '/ws', target: 'ws://10.15.128.10:18103', ws: true },