Compare commits

33 Commits

Author SHA1 Message Date
yangsy
5f97e78a03 release: v0.39.0 2026-03-11 15:20:52 +08:00
yangsy
f9f761b4e9 chore: 版本信息和更新日志 2026-03-11 15:20:03 +08:00
yangsy
4090c7e6c5 fix: 使用设备名称和IP地址组合作为设备树中设备节点的key 2026-03-11 15:17:19 +08:00
yangsy
38b43b1c45 feat: 改进设备树搜索功能,增加搜索类型选择 2026-03-11 15:17:19 +08:00
yangsy
9eafc7871b chore: 添加2号线API代理配置 2026-03-11 15:17:19 +08:00
yangsy
3eb5b06f59 fix: 录像缺失信息渲染条件 2026-03-11 15:17:19 +08:00
yangsy
f2fc2e732d docs: 更新README.md 2026-03-11 15:17:19 +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
yangsy
b020226538 refactor: 用 useQuery 重构录像诊断卡片 2026-01-29 13:42:41 +08:00
yangsy
b79b1df57e fix: 修复服务状态和推流统计卡片的渲染条件 2026-01-29 13:41:01 +08:00
yangsy
9b21beed0f fix: 下游设备配置添加权限校验 2026-01-28 15:57:23 +08:00
yangsy
aa4684273b feat: 添加批量导出录像诊断并优化导出体验 2026-01-28 14:31:37 +08:00
yangsy
36e839142a fix: 简化设备树选中状态与路由同步的逻辑,修复选中的设备类型被异常还原的问题 2026-01-27 11:05:25 +08:00
yangsy
03006a8f06 fix: 完善设备卡片标签页切换逻辑 2026-01-26 14:05:16 +08:00
yangsy
0af52c62ce feat: 添加权限查询和管理机制
- 新增权限管理页面
- 改进轮询链,引入权限查询
- 支持订阅权限变更或轮询权限检测变更
- 应用权限到页面和组件
2026-01-26 10:54:24 +08:00
yangsy
82789c78a9 feat: 车站卡片布局列数自适应 2026-01-21 15:23:08 +08:00
yangsy
6771abec31 refactor: 移除polling-store,重构setting-store 2026-01-20 11:22:24 +08:00
yangsy
b7b6b216fb feat: 设备告警记录页面添加告警恢复状态和确认状态筛选 2026-01-16 14:36:46 +08:00
yangsy
97f05f21ae refactor: 调整API数据结构目录 2026-01-15 14:57:33 +08:00
97 changed files with 726759 additions and 817 deletions

2
.env
View File

@@ -19,7 +19,7 @@ VITE_LAMP_PASSWORD = fjoc(1KHP(Ls&Bje)C
VITE_LAMP_AUTHORIZATION = Y3VlZGVzX2FkbWluOmN1ZWRlc19hZG1pbl9zZWNyZXQ= VITE_LAMP_AUTHORIZATION = Y3VlZGVzX2FkbWluOmN1ZWRlc19hZG1pbl9zZWNyZXQ=
# 当需要重置localStorage时, 修改此变量 # 当需要重置localStorage时, 修改此变量
VITE_STORAGE_VERSION = 4 VITE_STORAGE_VERSION = 5
# 调试码 # 调试码
VITE_DEBUG_CODE = ndm_debug VITE_DEBUG_CODE = ndm_debug

144
README.md
View File

@@ -40,18 +40,148 @@ 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
## 调试模式 ## 调试模式
调试模式中,用户可以查看设备的原始诊断数据,也可以对轮询器进行控制,或者启用离线开发模式,系统不会自动调用一些主动触发的请求 设置面板中有一系列与调试模式有关的设置项,主要用于开发和故障排查
### 开启调试模 ### 开启
在非登录页的任意页面中,使用键盘组合键 `Ctrl+Alt+D`,系统会弹出一个输入框,输入环境变量 `.env` 中的 `VITE_DEBUG_CODE` 对应的值即可开启调试模式,如需关闭调试模式,再次使用上述组合键并点击 `确认` 按钮即可。 调试模式默认隐藏,通过以下方式开启:
注意调试模式与其内部的功能之间没有联动关系,例如在开启调试模式后可以关闭轮询或者启用离线开发模式,但是在关闭调试模式后,轮询不会重新被开启,离线开发模式也不会被关闭,因此在关闭离线开发模式前,请务必确保系统处于正确的运行状态下。 1. 使用快捷键 `Ctrl + Alt + D` 唤起验证弹窗
2. 输入授权码进行验证(授权码对应环境变量 `.env` 中的 `VITE_DEBUG_CODE`
3. 验证通过后,在“系统设置”面板中会出现 **调试** 分组
### 关于离线开发模式 ### 设置项说明
由于离线开发模式涉及到登录操作,因此项目中将离线开发模式暴露到了全局变量 `window.$offlineDev` 中,允许在登录页中直接开启离线开发模式。 #### 数据设置
如果你第一次启动这个项目,系统在正常情况下会先跳转至登录页,此时如果希望开启离线模式,可以直接打开浏览器的开发者工具,在控制台输入 `window.$offlineDev.value = true` 即可,系统会直接跳转到首页。 - **显示设备原始数据**
- 控制是否在设备详情页显示“原始数据”标签页
- 开启后可查看设备接口返回的原始 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"
}
]
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "ndm-web-platform", "name": "ndm-web-platform",
"version": "0.0.0", "version": "0.39.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {

307
public/changelogs.json Normal file
View File

@@ -0,0 +1,307 @@
[
{
"version": "0.39.0",
"date": "2026-03-02",
"changes": {
"fixes": [{ "content": "修复设备树搜索时节点错乱的问题" }],
"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-11 14:35:45"
} }

View File

@@ -7,10 +7,10 @@ import { dateZhCN, NConfigProvider, NDialogProvider, NLoadingBarProvider, NMessa
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { themeMode, offlineDev } = storeToRefs(settingStore); const { themeMode, mockUser } = storeToRefs(settingStore);
// 允许通过控制台启用离线开发模式 (登录页适用) // 允许通过控制台启用离线开发模式 (登录页适用)
window.$offlineDev = offlineDev; window.$mockUser = mockUser;
useVersionCheckQuery(); useVersionCheckQuery();
</script> </script>

View File

@@ -3,4 +3,5 @@ export interface Station {
name: string; name: string;
online: boolean; online: boolean;
ip: string; ip: string;
occ?: boolean; // 是否为控制中心
} }

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

@@ -0,0 +1,21 @@
import type { BaseModel, ReduceForPageQuery, ReduceForSaveVO, ReduceForUpdateVO } from '@/apis';
import type { Nullable, Optional } from '@/types';
export interface BaseEmployee extends BaseModel {
userId: string;
realName: string;
defUser: Nullable<
{
username: string;
nickName: string;
} & BaseModel
>;
}
export type BaseEmployeeResultVO = Nullable<BaseEmployee>;
export type BaseEmployeeSaveVO = Partial<Omit<BaseEmployee, ReduceForSaveVO>>;
export type BaseEmployeeUpdateVO = Optional<Omit<BaseEmployee, ReduceForUpdateVO>>;
export type BaseEmployeePageQuery = Partial<Omit<BaseEmployee, ReduceForPageQuery>>;

View File

@@ -1,3 +1 @@
export * from './model'; export * from './base-employee';
export * from './page';
export * from './reduce';

View File

@@ -1,5 +1,5 @@
import type { Nullable, Optional } from '@/types'; import type { Nullable, Optional } from '@/types';
import type { ReduceForPageQuery, ReduceForSaveVO, ReduceForUpdateVO } from '../../base'; import type { ReduceForPageQuery, ReduceForSaveVO, ReduceForUpdateVO } from '../../schema';
import type { NdmAlarmHost } from './alarm'; import type { NdmAlarmHost } from './alarm';
import type { NdmSecurityBox, NdmSwitch } from './other'; import type { NdmSecurityBox, NdmSwitch } from './other';
import type { NdmNvr } from './storage'; import type { NdmNvr } from './storage';

View File

@@ -1,2 +1,3 @@
export * from './ndm-permission';
export * from './ndm-security-box'; export * from './ndm-security-box';
export * from './ndm-switch'; export * from './ndm-switch';

View File

@@ -0,0 +1,34 @@
import type { BaseModel, ReduceForPageQuery, ReduceForSaveVO, ReduceForUpdateVO, Station } from '@/apis';
import type { PermissionType } from '@/enums';
import type { Nullable, Optional } from '@/types';
export interface NdmPermission extends BaseModel {
/**
* 员工ID
*/
employeeId: string;
/**
* 服务器IP地址
*/
ipAddress: string;
/**
* 站号
*/
stationCode: Station['code'];
/**
* 站名
*/
name: string;
/**
* 权限类型
*/
type: PermissionType;
}
export type NdmPermissionResultVO = Nullable<NdmPermission>;
export type NdmPermissionSaveVO = Partial<Omit<NdmPermission, ReduceForSaveVO>>;
export type NdmPermissionUpdateVO = Optional<Omit<NdmPermission, ReduceForUpdateVO>>;
export type NdmPermissionPageQuery = Partial<Omit<NdmPermission, ReduceForPageQuery>>;

View File

@@ -1,4 +1,5 @@
export * from './base'; export * from './base';
export * from './biz'; export * from './biz';
export * from './common'; export * from './common';
export * from './schema';
export * from './system'; export * from './system';

View File

@@ -0,0 +1,3 @@
export * from './model';
export * from './page';
export * from './reduce';

View File

@@ -0,0 +1,21 @@
import type { BaseEmployeePageQuery, BaseEmployeeResultVO, PageParams, PageResult } from '@/apis';
import { userClient } from '@/apis/client';
import { unwrapResponse } from '@/utils';
export const pageBaseEmployeeApi = async (pageQuery: PageParams<BaseEmployeePageQuery>, options?: { signal?: AbortSignal }) => {
const { signal } = options ?? {};
const client = userClient;
const endpoint = '/api/base/baseEmployee/page';
const resp = await client.post<PageResult<BaseEmployeeResultVO>>(endpoint, pageQuery, { signal });
const data = unwrapResponse(resp);
return data;
};
export const detailBaseEmployeeApi = async (id: string, options?: { signal?: AbortSignal }) => {
const { signal } = options ?? {};
const client = userClient;
const endpoint = `/api/base/baseEmployee/detail`;
const resp = await client.get<BaseEmployeeResultVO>(endpoint, { params: { id }, signal });
const data = unwrapResponse(resp);
return data;
};

View File

@@ -0,0 +1 @@
export * from './base-employee';

View File

@@ -48,3 +48,13 @@ export const reloadAllRecordCheckApi = async (dayOffset: number, options?: { sta
if (!data) throw new Error(`${data}`); if (!data) throw new Error(`${data}`);
return data; return data;
}; };
export const batchExportRecordCheckApi = async (params: { checkDuration: number; gapSeconds: number; stationCode: Station['code'][] }, options?: { signal?: AbortSignal }) => {
const { signal } = options ?? {};
const { checkDuration, gapSeconds, stationCode } = params;
const client = userClient;
const endpoint = `/api/ndm/ndmRecordCheck/batchExportByTemplate`;
const resp = await client.post<Blob>(endpoint, { checkDuration, gapSeconds, stationCode }, { responseType: 'blob', retRaw: true, signal });
const data = unwrapResponse(resp);
return data;
};

View File

@@ -1,3 +1,4 @@
export * from './ndm-permission';
export * from './ndm-security-box'; export * from './ndm-security-box';
export * from './ndm-service-available'; export * from './ndm-service-available';
export * from './ndm-switch'; export * from './ndm-switch';

View File

@@ -0,0 +1,83 @@
import {
ndmClient,
userClient,
type NdmPermissionPageQuery,
type NdmPermissionResultVO,
type NdmPermissionSaveVO,
type NdmPermissionUpdateVO,
type PageParams,
type PageResult,
type Station,
} from '@/apis';
import type { PermissionTypeEnum } from '@/enums';
import { unwrapResponse } from '@/utils';
export const permissionTypesApi = async (options?: { stationCode?: Station['code']; signal?: AbortSignal }) => {
const { stationCode, signal } = options ?? {};
const client = stationCode ? ndmClient : userClient;
const prefix = stationCode ? `/${stationCode}` : '';
const endpoint = `${prefix}/api/ndm/ndmPermission/types`;
const resp = await client.get<PermissionTypeEnum>(endpoint, { signal });
const data = unwrapResponse(resp);
return data;
};
export const pagePermissionApi = async (pageQuery: PageParams<NdmPermissionPageQuery>, options?: { stationCode?: Station['code']; signal?: AbortSignal }) => {
const { stationCode, signal } = options ?? {};
const client = stationCode ? ndmClient : userClient;
const prefix = stationCode ? `/${stationCode}` : '';
const endpoint = `${prefix}/api/ndm/ndmPermission/page`;
const resp = await client.post<PageResult<NdmPermissionResultVO>>(endpoint, pageQuery, { signal });
const data = unwrapResponse(resp);
return data;
};
export const detailPermissionApi = async (id: string, options?: { stationCode?: Station['code']; signal?: AbortSignal }) => {
const { stationCode, signal } = options ?? {};
const client = stationCode ? ndmClient : userClient;
const prefix = stationCode ? `/${stationCode}` : '';
const endpoint = `${prefix}/api/ndm/ndmPermission/detail`;
const resp = await client.get<NdmPermissionResultVO>(endpoint, { params: { id }, signal });
const data = unwrapResponse(resp);
return data;
};
export const savePermissionApi = async (saveVO: NdmPermissionSaveVO, options?: { stationCode?: Station['code']; signal?: AbortSignal }) => {
const { stationCode, signal } = options ?? {};
const client = stationCode ? ndmClient : userClient;
const prefix = stationCode ? `/${stationCode}` : '';
const endpoint = `${prefix}/api/ndm/ndmPermission`;
const resp = await client.post<NdmPermissionResultVO>(endpoint, saveVO, { signal });
const result = unwrapResponse(resp);
return result;
};
export const updatePermissionApi = async (updateVO: NdmPermissionUpdateVO, options?: { stationCode?: Station['code']; signal?: AbortSignal }) => {
const { stationCode, signal } = options ?? {};
const client = stationCode ? ndmClient : userClient;
const prefix = stationCode ? `/${stationCode}` : '';
const endpoint = `${prefix}/api/ndm/ndmPermission`;
const resp = await client.put<NdmPermissionResultVO>(endpoint, updateVO, { signal });
const result = unwrapResponse(resp);
return result;
};
export const deletePermissionApi = async (ids: string[], options?: { stationCode?: Station['code']; signal?: AbortSignal }) => {
const { stationCode, signal } = options ?? {};
const client = stationCode ? ndmClient : userClient;
const prefix = stationCode ? `/${stationCode}` : '';
const endpoint = `${prefix}/api/ndm/ndmPermission`;
const resp = await client.delete<boolean>(endpoint, ids, { signal });
const result = unwrapResponse(resp);
return result;
};
export const modifyPermissionApi = async (params: { employeeId: string; saveList: NdmPermissionSaveVO[]; removeList: string[] }, options?: { stationCode?: Station['code']; signal?: AbortSignal }) => {
const { stationCode, signal } = options ?? {};
const client = stationCode ? ndmClient : userClient;
const prefix = stationCode ? `/${stationCode}` : '';
const endpoint = `${prefix}/api/ndm/ndmPermission/modify`;
const resp = await client.post<boolean>(endpoint, params, { signal });
const result = unwrapResponse(resp);
return result;
};

View File

@@ -1,2 +1,3 @@
export * from './base';
export * from './biz'; export * from './biz';
export * from './system'; export * from './system';

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,240 +0,0 @@
<script setup lang="ts">
import { getChannelListApi, getRecordCheckApi, reloadAllRecordCheckApi, reloadRecordCheckApi, type NdmNvrResultVO, type NdmRecordCheck, type RecordItem, type Station } from '@/apis';
import { exportRecordDiagCsv, transformRecordChecks } from '@/helpers';
import { parseErrorFeedback } from '@/utils';
import { useMutation } 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 { computed, onBeforeUnmount, onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmNvrResultVO;
station: Station;
}>();
const themeVars = useThemeVars();
const { ndmDevice, station } = toRefs(props);
const recordChecks = ref<NdmRecordCheck[]>([]);
const lossInput = ref<number>(0);
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 abortController = ref<AbortController>(new AbortController());
const { mutate: getRecordCheckByParentId, isPending: loading } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
const checks = await getRecordCheckApi(ndmDevice.value, 90, [], { stationCode: station.value.code, signal: abortController.value.signal });
return checks;
},
onSuccess: (checks) => {
recordChecks.value = checks;
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
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: () => {
getRecordCheckByParentId();
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onMounted(() => {
getRecordCheckByParentId();
});
watch(
() => ndmDevice.value.id,
(devieDbId) => {
if (devieDbId) {
recordChecks.value = [];
getRecordCheckByParentId();
}
},
);
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="() => getRecordCheckByParentId()">
<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,651 @@
<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>
<template v-if="dailyCheckContext.info.percent > 0">
<div>缺失时长:{{ formatDuration(dailyCheckContext.info.total, { withinDay: true }) }}</div>
<div>缺失比例:{{ dailyCheckContext.info.percent.toFixed(2) }}%</div>
<div style="font-size: xx-small; opacity: 0.5; cursor: pointer" @click="onClickDailyCheck">点击查看详情</div>
</template>
<template v-else>
<div>录像完整</div>
</template>
</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

@@ -13,7 +13,9 @@ import {
type Station, type Station,
} from '@/apis'; } from '@/apis';
import { SecurityBoxCircuitLinkModal } from '@/components'; import { SecurityBoxCircuitLinkModal } from '@/components';
import { usePermission } from '@/composables';
import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants'; import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useDeviceStore, useSettingStore } from '@/stores'; import { useDeviceStore, useSettingStore } from '@/stores';
import { parseErrorFeedback } from '@/utils'; import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
@@ -38,7 +40,9 @@ const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore); const { lineDevices } = storeToRefs(deviceStore);
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore); const { useLocalDB } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station, circuits } = toRefs(props); const { ndmDevice, station, circuits } = toRefs(props);
@@ -223,6 +227,7 @@ const onSelectDropdownOption = (key: string, option: DropdownOption) => {
const onContextmenu = (payload: PointerEvent, circuitIndex: number) => { const onContextmenu = (payload: PointerEvent, circuitIndex: number) => {
payload.stopPropagation(); payload.stopPropagation();
payload.preventDefault(); payload.preventDefault();
if (!hasPermission(station.value.code, PERMISSION_TYPE_LITERALS.OPERATION)) return;
const { clientX, clientY } = payload; const { clientX, clientY } = payload;
contextmenu.value = { x: clientX, y: clientY, circuitIndex }; contextmenu.value = { x: clientX, y: clientY, circuitIndex };
showContextmenu.value = true; showContextmenu.value = true;
@@ -258,8 +263,8 @@ const { mutate: unlinkDevice } = useMutation({
delete modifiedUpperLinkDescription.downstream?.[circuitIndex]; delete modifiedUpperLinkDescription.downstream?.[circuitIndex];
modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperLinkDescription); modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperLinkDescription);
// 3. 发起update请求并获取最新的设备详情离线模式下直接修改本地数据) // 3. 发起update请求并获取最新的设备详情使用本地数据库时直接修改本地数据)
if (offlineDev.value) { if (useLocalDB.value) {
return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice }; return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice };
} }
const stationCode = station.value.code; const stationCode = station.value.code;

View File

@@ -23,7 +23,7 @@ const show = defineModel<boolean>('show', { default: false });
const deviceStore = useDeviceStore(); const deviceStore = useDeviceStore();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore); const { useLocalDB } = storeToRefs(settingStore);
const { ndmDevice, station, circuitIndex } = toRefs(props); const { ndmDevice, station, circuitIndex } = toRefs(props);
@@ -150,8 +150,8 @@ const { mutate: linkPortToDevice, isPending: linking } = useMutation({
} }
modifiedLowerDevice.linkDescription = JSON.stringify(modifiedLowerDeviceLinkDescription); modifiedLowerDevice.linkDescription = JSON.stringify(modifiedLowerDeviceLinkDescription);
// 3. 发起update请求并获取最新的设备详情离线模式下直接修改本地数据) // 3. 发起update请求并获取最新的设备详情使用本地数据库时直接修改本地数据)
if (offlineDev.value) { if (useLocalDB.value) {
return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice }; return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice };
} }
const stationCode = station.value.code; const stationCode = station.value.code;

View File

@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { detailDeviceApi, updateDeviceApi, type LinkDescription, type NdmDeviceResultVO, type NdmSwitchLinkDescription, type NdmSwitchPortInfo, type NdmSwitchResultVO, type Station } from '@/apis'; import { detailDeviceApi, updateDeviceApi, type LinkDescription, type NdmDeviceResultVO, type NdmSwitchLinkDescription, type NdmSwitchPortInfo, type NdmSwitchResultVO, type Station } from '@/apis';
import { SwitchPortLinkModal } from '@/components'; import { SwitchPortLinkModal } from '@/components';
import { usePermission } from '@/composables';
import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants'; import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { getPortStatusValue, transformPortSpeed } from '@/helpers'; import { getPortStatusValue, transformPortSpeed } from '@/helpers';
import { useDeviceStore, useSettingStore } from '@/stores'; import { useDeviceStore, useSettingStore } from '@/stores';
import { parseErrorFeedback } from '@/utils'; import { parseErrorFeedback } from '@/utils';
@@ -25,7 +27,9 @@ const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore); const { lineDevices } = storeToRefs(deviceStore);
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore); const { useLocalDB } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station, ports } = toRefs(props); const { ndmDevice, station, ports } = toRefs(props);
@@ -172,6 +176,7 @@ const onSelectDropdownOption = (key: string, option: DropdownOption) => {
const onContextmenu = (payload: PointerEvent, port: NdmSwitchPortInfo) => { const onContextmenu = (payload: PointerEvent, port: NdmSwitchPortInfo) => {
payload.stopPropagation(); payload.stopPropagation();
payload.preventDefault(); payload.preventDefault();
if (!hasPermission(station.value.code, PERMISSION_TYPE_LITERALS.OPERATION)) return;
const { clientX, clientY } = payload; const { clientX, clientY } = payload;
contextmenu.value = { x: clientX, y: clientY, port }; contextmenu.value = { x: clientX, y: clientY, port };
showContextmenu.value = true; showContextmenu.value = true;
@@ -208,8 +213,8 @@ const { mutate: unlinkDevice } = useMutation({
delete modifiedUpperLinkDescription.downstream?.[port.portName]; delete modifiedUpperLinkDescription.downstream?.[port.portName];
modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperLinkDescription); modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperLinkDescription);
// 3. 发起update请求并获取最新的设备详情离线模式下直接修改本地数据) // 3. 发起update请求并获取最新的设备详情使用本地数据库时直接修改本地数据)
if (offlineDev.value) { if (useLocalDB.value) {
return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice }; return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice };
} }
const stationCode = station.value.code; const stationCode = station.value.code;

View File

@@ -32,7 +32,7 @@ const show = defineModel<boolean>('show', { default: false });
const deviceStore = useDeviceStore(); const deviceStore = useDeviceStore();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore); const { useLocalDB } = storeToRefs(settingStore);
const { ndmDevice, station, port } = toRefs(props); const { ndmDevice, station, port } = toRefs(props);
@@ -160,8 +160,8 @@ const { mutate: linkPortToDevice, isPending: linking } = useMutation({
} }
modifiedLowerDevice.linkDescription = JSON.stringify(modifiedLowerDeviceLinkDescription); modifiedLowerDevice.linkDescription = JSON.stringify(modifiedLowerDeviceLinkDescription);
// 3. 发起update请求并获取最新的设备详情离线模式下直接修改本地数据) // 3. 发起update请求并获取最新的设备详情使用本地数据库时直接修改本地数据)
if (offlineDev.value) { if (useLocalDB.value) {
return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice }; return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice };
} }
const stationCode = station.value.code; const stationCode = station.value.code;

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmAlarmHostResultVO, Station } from '@/apis'; import type { NdmAlarmHostResultVO, Station } from '@/apis';
import { AlarmHostCurrentDiag, AlarmHostHistoryDiag, AlarmHostUpdate, DeviceRawCard } from '@/components'; import { AlarmHostCurrentDiag, AlarmHostHistoryDiag, AlarmHostUpdate, DeviceRawCard } from '@/components';
import { usePermission } from '@/composables';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useSettingStore } from '@/stores'; import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui'; import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -16,7 +18,9 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore); const { showDeviceRawData } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station } = toRefs(props); const { ndmDevice, station } = toRefs(props);
@@ -31,8 +35,8 @@ const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => { const onTabChange = (name: string) => {
activeTabName.value = name; activeTabName.value = name;
}; };
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => { watch([ndmDevice, showDeviceRawData], ([newDevice, showRaw], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) { if (newDevice.id !== oldDevice.id || (!showRaw && activeTabName.value === '原始数据')) {
activeTabName.value = '当前诊断'; activeTabName.value = '当前诊断';
} }
}); });
@@ -45,8 +49,8 @@ watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
<NTabs :value="activeTabName" @update:value="onTabChange"> <NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab> <NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab> <NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab> <NTab v-if="hasPermission(station.code, PERMISSION_TYPE_LITERALS.OPERATION)" name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab> <NTab v-if="showDeviceRawData" name="原始数据">原始数据</NTab>
</NTabs> </NTabs>
</template> </template>
<template #default> <template #default>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmCameraResultVO, Station } from '@/apis'; import type { NdmCameraResultVO, Station } from '@/apis';
import { CameraCurrentDiag, CameraHistoryDiag, CameraUpdate, DeviceRawCard } from '@/components'; import { CameraCurrentDiag, CameraHistoryDiag, CameraUpdate, DeviceRawCard } from '@/components';
import { usePermission } from '@/composables';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useSettingStore } from '@/stores'; import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui'; import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -16,7 +18,9 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore); const { showDeviceRawData } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station } = toRefs(props); const { ndmDevice, station } = toRefs(props);
@@ -31,8 +35,8 @@ const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => { const onTabChange = (name: string) => {
activeTabName.value = name; activeTabName.value = name;
}; };
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => { watch([ndmDevice, showDeviceRawData], ([newDevice, showRaw], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) { if (newDevice.id !== oldDevice.id || (!showRaw && activeTabName.value === '原始数据')) {
activeTabName.value = '当前诊断'; activeTabName.value = '当前诊断';
} }
}); });
@@ -45,8 +49,8 @@ watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
<NTabs :value="activeTabName" @update:value="onTabChange"> <NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab> <NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab> <NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab> <NTab v-if="hasPermission(station.code, PERMISSION_TYPE_LITERALS.OPERATION)" name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab> <NTab v-if="showDeviceRawData" name="原始数据">原始数据</NTab>
</NTabs> </NTabs>
</template> </template>
<template #default> <template #default>

View File

@@ -31,7 +31,7 @@ const props = defineProps<{
}>(); }>();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore); const { activeRequests } = storeToRefs(settingStore);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -49,7 +49,7 @@ const QUERY_KEY = 'camera-installation-area-query';
const { data: installationArea } = useQuery({ const { data: installationArea } = useQuery({
queryKey: computed(() => [QUERY_KEY, ndmDevice.value.gbCode, station.value.code]), queryKey: computed(() => [QUERY_KEY, ndmDevice.value.gbCode, station.value.code]),
enabled: computed(() => !offlineDev.value), enabled: computed(() => activeRequests.value),
gcTime: 0, gcTime: 0,
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const UNKNOWN_NAME = '-'; const UNKNOWN_NAME = '-';
@@ -107,8 +107,8 @@ const { data: installationArea } = useQuery({
return `${tier1Area.name}-${tier2Area.name}`; return `${tier1Area.name}-${tier2Area.name}`;
}, },
}); });
watch(offlineDev, (offline) => { watch(activeRequests, (active) => {
if (offline) { if (!active) {
queryClient.cancelQueries({ queryKey: [QUERY_KEY] }); queryClient.cancelQueries({ queryKey: [QUERY_KEY] });
} }
}); });

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmDecoderResultVO, Station } from '@/apis'; import type { NdmDecoderResultVO, Station } from '@/apis';
import { DecoderCurrentDiag, DecoderHistoryDiag, DecoderUpdate, DeviceRawCard } from '@/components'; import { DecoderCurrentDiag, DecoderHistoryDiag, DecoderUpdate, DeviceRawCard } from '@/components';
import { usePermission } from '@/composables';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useSettingStore } from '@/stores'; import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui'; import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -16,7 +18,9 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore); const { showDeviceRawData } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station } = toRefs(props); const { ndmDevice, station } = toRefs(props);
@@ -31,8 +35,8 @@ const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => { const onTabChange = (name: string) => {
activeTabName.value = name; activeTabName.value = name;
}; };
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => { watch([ndmDevice, showDeviceRawData], ([newDevice, showRaw], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) { if (newDevice.id !== oldDevice.id || (!showRaw && activeTabName.value === '原始数据')) {
activeTabName.value = '当前诊断'; activeTabName.value = '当前诊断';
} }
}); });
@@ -45,8 +49,8 @@ watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
<NTabs :value="activeTabName" @update:value="onTabChange"> <NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab> <NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab> <NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab> <NTab v-if="hasPermission(station.code, PERMISSION_TYPE_LITERALS.OPERATION)" name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab> <NTab v-if="showDeviceRawData" name="原始数据">原始数据</NTab>
</NTabs> </NTabs>
</template> </template>
<template #default> <template #default>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmKeyboardResultVO, Station } from '@/apis'; import type { NdmKeyboardResultVO, Station } from '@/apis';
import { DeviceRawCard, KeyboardCurrentDiag, KeyboardHistoryDiag, KeyboardUpdate } from '@/components'; import { DeviceRawCard, KeyboardCurrentDiag, KeyboardHistoryDiag, KeyboardUpdate } from '@/components';
import { usePermission } from '@/composables';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useSettingStore } from '@/stores'; import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui'; import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -16,7 +18,9 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore); const { showDeviceRawData } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station } = toRefs(props); const { ndmDevice, station } = toRefs(props);
@@ -31,8 +35,8 @@ const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => { const onTabChange = (name: string) => {
activeTabName.value = name; activeTabName.value = name;
}; };
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => { watch([ndmDevice, showDeviceRawData], ([newDevice, showRaw], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) { if (newDevice.id !== oldDevice.id || (!showRaw && activeTabName.value === '原始数据')) {
activeTabName.value = '当前诊断'; activeTabName.value = '当前诊断';
} }
}); });
@@ -45,8 +49,8 @@ watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
<NTabs :value="activeTabName" @update:value="onTabChange"> <NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab> <NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab> <NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab> <NTab v-if="hasPermission(station.code, PERMISSION_TYPE_LITERALS.OPERATION)" name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab> <NTab v-if="showDeviceRawData" name="原始数据">原始数据</NTab>
</NTabs> </NTabs>
</template> </template>
<template #default> <template #default>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmNvrResultVO, Station } from '@/apis'; import type { NdmNvrResultVO, Station } from '@/apis';
import { DeviceRawCard, NvrCurrentDiag, NvrHistoryDiag, NvrUpdate } from '@/components'; import { DeviceRawCard, NvrCurrentDiag, NvrHistoryDiag, NvrUpdate } from '@/components';
import { usePermission } from '@/composables';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useSettingStore } from '@/stores'; import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui'; import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -16,7 +18,9 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore); const { showDeviceRawData } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station } = toRefs(props); const { ndmDevice, station } = toRefs(props);
@@ -31,8 +35,8 @@ const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => { const onTabChange = (name: string) => {
activeTabName.value = name; activeTabName.value = name;
}; };
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => { watch([ndmDevice, showDeviceRawData], ([newDevice, showRaw], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) { if (newDevice.id !== oldDevice.id || (!showRaw && activeTabName.value === '原始数据')) {
activeTabName.value = '当前诊断'; activeTabName.value = '当前诊断';
} }
}); });
@@ -45,8 +49,8 @@ watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
<NTabs :value="activeTabName" @update:value="onTabChange"> <NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab> <NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab> <NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab> <NTab v-if="hasPermission(station.code, PERMISSION_TYPE_LITERALS.OPERATION)" name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab> <NTab v-if="showDeviceRawData" name="原始数据">原始数据</NTab>
</NTabs> </NTabs>
</template> </template>
<template #default> <template #default>

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

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmSecurityBoxResultVO, Station } from '@/apis'; import type { NdmSecurityBoxResultVO, Station } from '@/apis';
import { DeviceRawCard, SecurityBoxCurrentDiag, SecurityBoxHistoryDiag, SecurityBoxUpdate } from '@/components'; import { DeviceRawCard, SecurityBoxCurrentDiag, SecurityBoxHistoryDiag, SecurityBoxUpdate } from '@/components';
import { usePermission } from '@/composables';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useSettingStore } from '@/stores'; import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui'; import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -16,7 +18,9 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore); const { showDeviceRawData } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station } = toRefs(props); const { ndmDevice, station } = toRefs(props);
@@ -31,8 +35,8 @@ const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => { const onTabChange = (name: string) => {
activeTabName.value = name; activeTabName.value = name;
}; };
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => { watch([ndmDevice, showDeviceRawData], ([newDevice, showRaw], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) { if (newDevice.id !== oldDevice.id || (!showRaw && activeTabName.value === '原始数据')) {
activeTabName.value = '当前诊断'; activeTabName.value = '当前诊断';
} }
}); });
@@ -45,8 +49,8 @@ watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
<NTabs :value="activeTabName" @update:value="onTabChange"> <NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab> <NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab> <NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab> <NTab v-if="hasPermission(station.code, PERMISSION_TYPE_LITERALS.OPERATION)" name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab> <NTab v-if="showDeviceRawData" name="原始数据">原始数据</NTab>
</NTabs> </NTabs>
</template> </template>
<template #default> <template #default>

View File

@@ -13,7 +13,7 @@ const props = defineProps<{
}>(); }>();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore); const { activeRequests } = storeToRefs(settingStore);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -25,7 +25,7 @@ const MEDIA_SERVER_ALIVE_QUERY_KEY = 'media-server-alive-query';
const VIDEO_SERVER_ALIVE_QUERY_KEY = 'video-server-alive-query'; const VIDEO_SERVER_ALIVE_QUERY_KEY = 'video-server-alive-query';
const { data: isMediaServerAlive } = useQuery({ const { data: isMediaServerAlive } = useQuery({
queryKey: computed(() => [MEDIA_SERVER_ALIVE_QUERY_KEY, ndmDevice.value.id, ndmDevice.value.lastDiagTime]), queryKey: computed(() => [MEDIA_SERVER_ALIVE_QUERY_KEY, ndmDevice.value.id, ndmDevice.value.lastDiagTime]),
enabled: computed(() => !offlineDev.value && deviceType.value === DEVICE_TYPE_LITERALS.ndmMediaServer), enabled: computed(() => activeRequests.value && deviceType.value === DEVICE_TYPE_LITERALS.ndmMediaServer),
refetchInterval: 30 * 1000, refetchInterval: 30 * 1000,
gcTime: 0, gcTime: 0,
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
@@ -35,15 +35,15 @@ const { data: isMediaServerAlive } = useQuery({
}); });
const { data: isSipServerAlive } = useQuery({ const { data: isSipServerAlive } = useQuery({
queryKey: computed(() => [VIDEO_SERVER_ALIVE_QUERY_KEY, ndmDevice.value.id, ndmDevice.value.lastDiagTime]), queryKey: computed(() => [VIDEO_SERVER_ALIVE_QUERY_KEY, ndmDevice.value.id, ndmDevice.value.lastDiagTime]),
enabled: computed(() => !offlineDev.value && deviceType.value === DEVICE_TYPE_LITERALS.ndmVideoServer), enabled: computed(() => activeRequests.value && deviceType.value === DEVICE_TYPE_LITERALS.ndmVideoServer),
refetchInterval: 30 * 1000, refetchInterval: 30 * 1000,
gcTime: 0, gcTime: 0,
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
return await isSipServerAliveApi({ stationCode: station.value.code, signal }); return await isSipServerAliveApi({ stationCode: station.value.code, signal });
}, },
}); });
watch(offlineDev, (offline) => { watch(activeRequests, (active) => {
if (offline) { if (!active) {
queryClient.cancelQueries({ queryKey: [MEDIA_SERVER_ALIVE_QUERY_KEY] }); queryClient.cancelQueries({ queryKey: [MEDIA_SERVER_ALIVE_QUERY_KEY] });
queryClient.cancelQueries({ queryKey: [VIDEO_SERVER_ALIVE_QUERY_KEY] }); queryClient.cancelQueries({ queryKey: [VIDEO_SERVER_ALIVE_QUERY_KEY] });
} }
@@ -56,7 +56,7 @@ watch(offlineDev, (offline) => {
<span>服务状态</span> <span>服务状态</span>
</template> </template>
<template #default> <template #default>
<template v-if="offlineDev"> <template v-if="!activeRequests">
<span>-</span> <span>-</span>
</template> </template>
<template v-else> <template v-else>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmServerResultVO, Station } from '@/apis'; import type { NdmServerResultVO, Station } from '@/apis';
import { DeviceRawCard, ServerCurrentDiag, ServerHistoryDiag, ServerUpdate } from '@/components'; import { DeviceRawCard, ServerCurrentDiag, ServerHistoryDiag, ServerUpdate } from '@/components';
import { usePermission } from '@/composables';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useSettingStore } from '@/stores'; import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui'; import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -16,7 +18,9 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore); const { showDeviceRawData } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station } = toRefs(props); const { ndmDevice, station } = toRefs(props);
@@ -31,8 +35,8 @@ const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => { const onTabChange = (name: string) => {
activeTabName.value = name; activeTabName.value = name;
}; };
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => { watch([ndmDevice, showDeviceRawData], ([newDevice, showRaw], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) { if (newDevice.id !== oldDevice.id || (!showRaw && activeTabName.value === '原始数据')) {
activeTabName.value = '当前诊断'; activeTabName.value = '当前诊断';
} }
}); });
@@ -45,8 +49,8 @@ watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
<NTabs :value="activeTabName" @update:value="onTabChange"> <NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab> <NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab> <NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab> <NTab v-if="hasPermission(station.code, PERMISSION_TYPE_LITERALS.OPERATION)" name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab> <NTab v-if="showDeviceRawData" name="原始数据">原始数据</NTab>
</NTabs> </NTabs>
</template> </template>
<template #default> <template #default>

View File

@@ -13,7 +13,7 @@ const props = defineProps<{
}>(); }>();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore); const { activeRequests } = storeToRefs(settingStore);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -27,7 +27,7 @@ const SERVER_STREAM_PUSH_KEY = 'server-stream-push-query';
const { data: streamPushes } = useQuery({ const { data: streamPushes } = useQuery({
queryKey: computed(() => [SERVER_STREAM_PUSH_KEY, ndmDevice.value.id, ndmDevice.value.lastDiagTime]), queryKey: computed(() => [SERVER_STREAM_PUSH_KEY, ndmDevice.value.id, ndmDevice.value.lastDiagTime]),
enabled: computed(() => !offlineDev.value && showCard.value), enabled: computed(() => activeRequests.value && showCard.value),
refetchInterval: 30 * 1000, refetchInterval: 30 * 1000,
gcTime: 0, gcTime: 0,
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
@@ -35,8 +35,8 @@ const { data: streamPushes } = useQuery({
return streamPushes; return streamPushes;
}, },
}); });
watch(offlineDev, (offline) => { watch(activeRequests, (active) => {
if (offline) { if (!active) {
queryClient.cancelQueries({ queryKey: [SERVER_STREAM_PUSH_KEY] }); queryClient.cancelQueries({ queryKey: [SERVER_STREAM_PUSH_KEY] });
} }
}); });
@@ -70,7 +70,7 @@ const streamPushStat = computed(() => {
<span>推流统计</span> <span>推流统计</span>
</template> </template>
<template #default> <template #default>
<template v-if="offlineDev"> <template v-if="!activeRequests">
<span>-</span> <span>-</span>
</template> </template>
<template v-else> <template v-else>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmSwitchResultVO, Station } from '@/apis'; import type { NdmSwitchResultVO, Station } from '@/apis';
import { DeviceRawCard, SwitchCurrentDiag, SwitchHistoryDiag, SwitchUpdate } from '@/components'; import { DeviceRawCard, SwitchCurrentDiag, SwitchHistoryDiag, SwitchUpdate } from '@/components';
import { usePermission } from '@/composables';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useSettingStore } from '@/stores'; import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui'; import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -16,7 +18,9 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore); const { showDeviceRawData } = storeToRefs(settingStore);
const { hasPermission } = usePermission();
const { ndmDevice, station } = toRefs(props); const { ndmDevice, station } = toRefs(props);
@@ -31,8 +35,8 @@ const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => { const onTabChange = (name: string) => {
activeTabName.value = name; activeTabName.value = name;
}; };
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => { watch([ndmDevice, showDeviceRawData], ([newDevice, showRaw], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) { if (newDevice.id !== oldDevice.id || (!showRaw && activeTabName.value === '原始数据')) {
activeTabName.value = '当前诊断'; activeTabName.value = '当前诊断';
} }
}); });
@@ -45,8 +49,8 @@ watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
<NTabs :value="activeTabName" @update:value="onTabChange"> <NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab> <NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab> <NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab> <NTab v-if="hasPermission(station.code, PERMISSION_TYPE_LITERALS.OPERATION)" name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab> <NTab v-if="showDeviceRawData" name="原始数据">原始数据</NTab>
</NTabs> </NTabs>
</template> </template>
<template #default> <template #default>

View File

@@ -1,25 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { initStationDevices, type NdmDeviceResultVO, type NdmNvrResultVO, type Station } from '@/apis'; import { initStationDevices, type NdmDeviceResultVO, type NdmNvrResultVO, type Station } from '@/apis';
import { useDeviceTree, type UseDeviceTreeReturn } from '@/composables'; import { useDeviceTree, usePermission, type UseDeviceTreeReturn } from '@/composables';
import { DEVICE_TYPE_NAMES, DEVICE_TYPE_LITERALS, tryGetDeviceType, type DeviceType } from '@/enums'; import { DEVICE_TYPE_NAMES, DEVICE_TYPE_LITERALS, tryGetDeviceType, type DeviceType, PERMISSION_TYPE_LITERALS } from '@/enums';
import { isNvrCluster } from '@/helpers'; import { isNvrCluster } from '@/helpers';
import { useDeviceStore, useStationStore } from '@/stores'; import { useDeviceStore, usePermissionStore } from '@/stores';
import { watchImmediate } from '@vueuse/core'; import { watchDebounced, watchImmediate } from '@vueuse/core';
import destr from 'destr'; import destr from 'destr';
import { isFunction } from 'es-toolkit'; import { isFunction } from 'es-toolkit';
import { import {
NButton, NButton,
NDropdown, NDropdown,
NFlex, NFlex,
NGrid,
NGridItem,
NInput, NInput,
NRadio, NRadio,
NRadioGroup, NRadioGroup,
NSelect,
NTab, NTab,
NTabs, NTabs,
NTag, NTag,
NTree, NTree,
useThemeVars, useThemeVars,
type DropdownOption, type DropdownOption,
type SelectOption,
type TagProps, type TagProps,
type TreeInst, type TreeInst,
type TreeOption, type TreeOption,
@@ -27,7 +31,7 @@ import {
type TreeProps, type TreeProps,
} from 'naive-ui'; } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, h, nextTick, onBeforeUnmount, ref, toRefs, useTemplateRef, watch, type CSSProperties } from 'vue'; import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, toRefs, useTemplateRef, watch, type CSSProperties } from 'vue';
const props = defineProps<{ const props = defineProps<{
/** /**
@@ -60,20 +64,22 @@ const { station, events, syncRoute, devicePrefixLabel } = toRefs(props);
const themeVars = useThemeVars(); const themeVars = useThemeVars();
const { hasPermission } = usePermission();
const { const {
// 设备选择 // 设备选择
selectedStationCode, selectedStationCode,
selectedDeviceType, selectedDeviceType,
selectedDevice, selectedDevice,
syncFromRoute,
syncToRoute,
selectDevice, selectDevice,
// 设备管理 // 设备管理
exportDevice, exportDevice,
exportDeviceTemplate, exportDeviceTemplate,
importDevice, importDevice,
deleteDevice, deleteDevice,
} = useDeviceTree({ } = useDeviceTree();
syncRoute: computed(() => !!syncRoute.value),
});
// 将 `selectDevice` 函数暴露给父组件 // 将 `selectDevice` 函数暴露给父组件
emit('exposeSelectDeviceFn', selectDevice); emit('exposeSelectDeviceFn', selectDevice);
@@ -87,8 +93,9 @@ const onSelectDevice = (device: NdmDeviceResultVO, stationCode: Station['code'])
emit('afterSelectDevice', device, stationCode); emit('afterSelectDevice', device, stationCode);
}; };
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations } = storeToRefs(stationStore); const stations = computed(() => permissionStore.stations.VIEW ?? []);
const deviceStore = useDeviceStore(); const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore); const { lineDevices } = storeToRefs(deviceStore);
@@ -220,13 +227,17 @@ const nodeProps: TreeProps['nodeProps'] = ({ option }) => {
payload.stopPropagation(); payload.stopPropagation();
payload.preventDefault(); payload.preventDefault();
// 仅当事件列表包含 `manage` 时才显示右键菜单 // 如果事件列表包含 `manage`,则直接结束逻辑
if (!events.value?.includes('manage')) return; if (!events.value?.includes('manage')) return;
const { clientX, clientY } = payload;
const stationCode = option['stationCode'] as Station['code']; const stationCode = option['stationCode'] as Station['code'];
// 仅当用户在该车站拥有操作权限时才显示右键菜单
if (!hasPermission(stationCode, PERMISSION_TYPE_LITERALS.OPERATION)) return;
const deviceType = option['deviceType'] as DeviceType | undefined; const deviceType = option['deviceType'] as DeviceType | undefined;
const device = option['device'] as NdmDeviceResultVO | undefined; const device = option['device'] as NdmDeviceResultVO | undefined;
const { clientX, clientY } = payload;
contextmenu.value = { x: clientX, y: clientY, stationCode, deviceType, device }; contextmenu.value = { x: clientX, y: clientY, stationCode, deviceType, device };
showContextmenu.value = true; showContextmenu.value = true;
}, },
@@ -341,7 +352,7 @@ const lineDeviceTreeData = computed<Record<Station['code'], TreeOption[]>>(() =>
const device = dev as NdmDeviceResultVO; const device = dev as NdmDeviceResultVO;
return { return {
label: `${device.name}`, label: `${device.name}`,
key: `${device.id}`, key: `${device.name}${device.ipAddress}`,
prefix: () => renderDeviceNodePrefix(device, stationCode), prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`, suffix: () => `${device.ipAddress}`,
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站 // 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
@@ -375,13 +386,13 @@ const stationDeviceTreeData = computed<TreeOption[]>(() => {
children: clusters.map<TreeOption>((device) => { children: clusters.map<TreeOption>((device) => {
return { return {
label: `${device.name}`, label: `${device.name}`,
key: `${device.id}`, key: `${device.name}${device.ipAddress}`,
prefix: () => renderDeviceNodePrefix(device, stationCode), prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`, suffix: () => `${device.ipAddress}`,
children: singletons.map<TreeOption>((device) => { children: singletons.map<TreeOption>((device) => {
return { return {
label: `${device.name}`, label: `${device.name}`,
key: `${device.id}`, key: `${device.name}${device.ipAddress}`,
prefix: () => renderDeviceNodePrefix(device, stationCode), prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`, suffix: () => `${device.ipAddress}`,
stationCode, stationCode,
@@ -403,7 +414,7 @@ const stationDeviceTreeData = computed<TreeOption[]>(() => {
children: stationDevices[deviceType].map<TreeOption>((device) => { children: stationDevices[deviceType].map<TreeOption>((device) => {
return { return {
label: `${device.name}`, label: `${device.name}`,
key: `${device.id}`, key: `${device.name}${device.ipAddress}`,
prefix: () => renderDeviceNodePrefix(device, stationCode), prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`, suffix: () => `${device.ipAddress}`,
stationCode, stationCode,
@@ -418,20 +429,26 @@ const stationDeviceTreeData = computed<TreeOption[]>(() => {
// ========== 设备树搜索 ========== // ========== 设备树搜索 ==========
const searchInput = ref(''); const searchInput = ref('');
const searchTypeOptions: SelectOption[] = [
{ label: '设备名称', value: 'name' },
{ label: 'IP地址', value: 'ipAddress' },
];
type SearchType = 'name' | 'ipAddress';
const typeInput = ref<SearchType>('name');
const statusInput = ref(''); const statusInput = ref('');
// 设备树将搜索框单选框的值都交给NTree的pattern属性 // 设备树将搜索框、选择器以及单选框的值都交给NTree的pattern属性
// 但是如果一个车站下没有匹配的设备,那么这个车站节点也不会显示 // 但是如果一个车站下没有匹配的设备,那么这个车站节点也不会显示
const searchPattern = computed(() => { const searchPattern = computed(() => {
const search = searchInput.value; const search = searchInput.value;
const status = statusInput.value; const status = statusInput.value;
if (!search && !status) return ''; // 如果pattern非空会导致NTree组件认为筛选完成UI上发生全量匹配 if (!search && !status) return ''; // 如果pattern非空会导致NTree组件认为筛选完成UI上发生全量匹配
return JSON.stringify({ search: searchInput.value, status: statusInput.value }); return JSON.stringify({ search: searchInput.value, type: typeInput.value, status: statusInput.value });
}); });
const searchFilter = (pattern: string, node: TreeOption): boolean => { const searchFilter = (pattern: string, node: TreeOption): boolean => {
const { search, status } = destr<{ search: string; status: string }>(pattern); const { search, type, status } = destr<{ search: string; type: SearchType; status: string }>(pattern);
const device = node['device'] as NdmDeviceResultVO | undefined; const device = node['device'] as NdmDeviceResultVO | undefined;
const { name, ipAddress, deviceId, deviceStatus } = device ?? {}; const { deviceStatus } = device ?? {};
const searchMatched = (name ?? '').includes(search) || (ipAddress ?? '').includes(search) || (deviceId ?? '').includes(search); const searchMatched = !!device?.[type]?.includes(search);
const statusMatched = status === '' || status === deviceStatus; const statusMatched = status === '' || status === deviceStatus;
return searchMatched && statusMatched; return searchMatched && statusMatched;
}; };
@@ -477,11 +494,38 @@ const onLocateDeviceTree = async () => {
animated.value = true; animated.value = true;
}; };
// 渲染全线设备树时,当选择的设备发生变化,则定位设备树
// 当选择的设备发生变化时,定位设备树,并同步选中状态到路由参数
// 暂时不考虑多次执行的问题,因为当选择的设备在设备树视口内时,不会发生滚动 // 暂时不考虑多次执行的问题,因为当选择的设备在设备树视口内时,不会发生滚动
watch(selectedDevice, async () => { watch(selectedDevice, async (newDevice, oldDevice) => {
if (!!station.value) return; if (!!station.value) return;
await onLocateDeviceTree(); if (newDevice?.id === oldDevice?.id) return;
// console.log('selectedDevice changed');
onLocateDeviceTree();
syncToRoute();
});
// 当全线设备发生变化时,从路由参数同步选中状态
// 但lineDevices是shallowRef因此需要深度侦听才能获取内部变化
// 而单纯的深度侦听又可能会引发性能问题,因此尝试使用防抖侦听
watchDebounced(
lineDevices,
(newLineDevices) => {
if (syncRoute.value) {
// console.log('lineDevices changed');
syncFromRoute(newLineDevices);
}
},
{
debounce: 500,
deep: true,
},
);
onMounted(() => {
if (syncRoute.value) {
syncFromRoute(lineDevices.value);
}
}); });
</script> </script>
@@ -489,7 +533,14 @@ watch(selectedDevice, async () => {
<div style="height: 100%; display: flex; flex-direction: column"> <div style="height: 100%; display: flex; flex-direction: column">
<!-- 搜索和筛选 --> <!-- 搜索和筛选 -->
<div style="padding: 12px; flex: 0 0 auto"> <div style="padding: 12px; flex: 0 0 auto">
<NInput v-model:value="searchInput" placeholder="搜索设备名称、设备ID或IP地址" clearable /> <NGrid :cols="10" :x-gap="8">
<NGridItem :span="7">
<NInput v-model:value="searchInput" placeholder="搜索设备名称或IP地址" clearable />
</NGridItem>
<NGridItem :span="3">
<NSelect v-model:value="typeInput" :options="searchTypeOptions" placeholder="搜索类型" />
</NGridItem>
</NGrid>
<NFlex align="center"> <NFlex align="center">
<NRadioGroup v-model:value="statusInput"> <NRadioGroup v-model:value="statusInput">
<NRadio value="">全部</NRadio> <NRadio value="">全部</NRadio>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { retentionDaysApi, snapStatusApi, type LineAlarms, type LineDevices, type Station, type VersionInfo } from '@/apis'; import { retentionDaysApi, snapStatusApi, type LineAlarms, type LineDevices, type Station, type VersionInfo } from '@/apis';
import { ThemeSwitch } from '@/components'; import { ThemeSwitch } from '@/components';
import { usePermission } from '@/composables';
import { NDM_ALARM_STORE_ID, NDM_DEVICE_STORE_ID, NDM_STATION_STORE_ID } from '@/constants'; import { NDM_ALARM_STORE_ID, NDM_DEVICE_STORE_ID, NDM_STATION_STORE_ID } from '@/constants';
import { usePollingStore, useSettingStore } from '@/stores'; import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { useSettingStore, useStationStore } from '@/stores';
import { downloadByData, getAppEnvConfig, parseErrorFeedback, sleep } from '@/utils'; import { downloadByData, getAppEnvConfig, parseErrorFeedback, sleep } from '@/utils';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
@@ -11,14 +13,24 @@ 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 { 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 });
const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore);
const occStation = computed(() => stations.value.find((station) => !!station.occ));
const settingsStore = useSettingStore(); const settingsStore = useSettingStore();
const { menuCollpased, stationGridCols, debugModeEnabled, offlineDev } = storeToRefs(settingsStore); const { menuCollpased, stationGridCols, debugMode, showDeviceRawData, pollingStations, activeRequests, subscribeMessages, mockUser, useLocalDB } = storeToRefs(settingsStore);
const { hasPermission } = usePermission();
const versionInfo = ref<VersionInfo>({ version: '', buildTime: '' }); const versionInfo = ref<VersionInfo>({ version: '', buildTime: '' });
@@ -123,11 +135,11 @@ const enableDebugMode = () => {
return; return;
} }
showDebugCodeModal.value = false; showDebugCodeModal.value = false;
settingsStore.enableDebugMode(); debugMode.value = true;
}; };
const disableDebugMode = () => { const disableDebugMode = () => {
showDebugCodeModal.value = false; showDebugCodeModal.value = false;
settingsStore.disableDebugMode(); debugMode.value = false;
}; };
useEventListener('keydown', (event) => { useEventListener('keydown', (event) => {
const { ctrlKey, altKey, code } = event; const { ctrlKey, altKey, code } = event;
@@ -138,23 +150,13 @@ useEventListener('keydown', (event) => {
const expectToShowDebugCodeInput = ref(false); const expectToShowDebugCodeInput = ref(false);
const onModalAfterEnter = () => { const onModalAfterEnter = () => {
expectToShowDebugCodeInput.value = !debugModeEnabled.value; expectToShowDebugCodeInput.value = !debugMode.value;
}; };
const onModalAfterLeave = () => { const onModalAfterLeave = () => {
expectToShowDebugCodeInput.value = false; expectToShowDebugCodeInput.value = false;
debugCode.value = ''; 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 IndexedDbStoreId = typeof NDM_STATION_STORE_ID | typeof NDM_DEVICE_STORE_ID | typeof NDM_ALARM_STORE_ID;
type IndexedDbStoreStates = { type IndexedDbStoreStates = {
[NDM_STATION_STORE_ID]: { stations: Station[] }; [NDM_STATION_STORE_ID]: { stations: Station[] };
@@ -172,8 +174,9 @@ const exportFromIndexedDB = async <K extends IndexedDbStoreId>(storeId: K, optio
}; };
const importToIndexedDB = async <K extends IndexedDbStoreId>(storeId: K, options?: { successMsg?: string; errorMsg?: string }) => { const importToIndexedDB = async <K extends IndexedDbStoreId>(storeId: K, options?: { successMsg?: string; errorMsg?: string }) => {
const { successMsg, errorMsg } = options ?? {}; const { successMsg, errorMsg } = options ?? {};
pollingStore.stopPolling(); pollingStations.value = false;
offlineDev.value = true; activeRequests.value = false;
subscribeMessages.value = false;
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.type = 'file';
fileInput.accept = '.json'; fileInput.accept = '.json';
@@ -196,8 +199,9 @@ const importToIndexedDB = async <K extends IndexedDbStoreId>(storeId: K, options
}; };
}; };
const deleteFromIndexedDB = async (storeId: IndexedDbStoreId) => { const deleteFromIndexedDB = async (storeId: IndexedDbStoreId) => {
pollingStore.stopPolling(); pollingStations.value = false;
offlineDev.value = true; activeRequests.value = false;
subscribeMessages.value = false;
await localforage.removeItem(storeId).catch((error) => { await localforage.removeItem(storeId).catch((error) => {
window.$message.error(`${error}`); window.$message.error(`${error}`);
return; return;
@@ -266,8 +270,8 @@ const onSelectDropdownOption = (key: string, option: DropdownOption) => {
} }
}; };
watch([offlineDev, show], ([offline, entered]) => { watch([activeRequests, show], ([active, entered]) => {
if (!offline) { if (!active) return;
if (entered) { if (entered) {
getRetentionDays(); getRetentionDays();
getSnapStatus(); getSnapStatus();
@@ -275,7 +279,6 @@ watch([offlineDev, show], ([offline, entered]) => {
abortControllers.value.retentionDays.abort(); abortControllers.value.retentionDays.abort();
abortControllers.value.snapStatus.abort(); abortControllers.value.snapStatus.abort();
} }
}
}); });
const onDrawerAfterEnter = () => { const onDrawerAfterEnter = () => {
getVersionInfo(); getVersionInfo();
@@ -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>
@@ -303,6 +311,7 @@ const onDrawerAfterLeave = () => {
<NInputNumber v-model:value="stationGridCols" :min="1" :max="10" /> <NInputNumber v-model:value="stationGridCols" :min="1" :max="10" />
</NFormItem> </NFormItem>
<template v-if="!!occStation && hasPermission(occStation.code, PERMISSION_TYPE_LITERALS.OPERATION)">
<NDivider>告警</NDivider> <NDivider>告警</NDivider>
<NFormItem label="告警画面截图保留天数" label-placement="left"> <NFormItem label="告警画面截图保留天数" label-placement="left">
<NFlex justify="space-between" align="center" style="width: 100%"> <NFlex justify="space-between" align="center" style="width: 100%">
@@ -322,16 +331,35 @@ const onDrawerAfterLeave = () => {
</NButtonGroup> </NButtonGroup>
</NFlex> </NFlex>
</NFormItem> </NFormItem>
</template>
<template v-if="debugModeEnabled"> <template v-if="debugMode">
<NDivider title-placement="center">调试</NDivider> <NDivider title-placement="center">调试</NDivider>
<NFormItem label="启用轮询" label-placement="left"> <NFormItem label="调试模式" label-placement="left">
<NSwitch size="small" :value="pollingEnabled" @update:value="onPollingEnabledUpdate" /> <NSwitch size="small" v-model:value="debugMode" />
</NFormItem> </NFormItem>
<NFormItem label="离线开发" label-placement="left"> <NDivider title-placement="left" dashed>数据设置</NDivider>
<NSwitch size="small" v-model:value="offlineDev" /> <NFormItem label="显示设备原始数据" label-placement="left">
<NSwitch size="small" v-model:value="showDeviceRawData" />
</NFormItem> </NFormItem>
<NFormItem label="本地数据库" label-placement="left"> <NDivider title-placement="left" dashed>网络设置</NDivider>
<NFormItem label="轮询车站" label-placement="left">
<NSwitch size="small" v-model:value="pollingStations" />
</NFormItem>
<NFormItem label="主动请求" label-placement="left">
<NSwitch size="small" v-model:value="activeRequests" />
</NFormItem>
<NFormItem label="订阅消息" label-placement="left">
<NSwitch size="small" v-model:value="subscribeMessages" />
</NFormItem>
<NFormItem label="模拟用户" label-placement="left">
<NSwitch size="small" v-model:value="mockUser" />
</NFormItem>
<NDivider title-placement="left" dashed>数据库设置</NDivider>
<NFormItem label="直接操作本地数据库" label-placement="left">
<NSwitch size="small" v-model:value="useLocalDB" />
</NFormItem>
<NFormItem label="数据操作" label-placement="left">
<NFlex> <NFlex>
<NDropdown trigger="click" :options="exportDropdownOptions" @select="onSelectDropdownOption"> <NDropdown trigger="click" :options="exportDropdownOptions" @select="onSelectDropdownOption">
<NButton secondary size="small"> <NButton secondary size="small">
@@ -363,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>
@@ -371,7 +408,7 @@ const onDrawerAfterLeave = () => {
<NModal v-model:show="showDebugCodeModal" preset="dialog" type="info" @after-enter="onModalAfterEnter" @after-leave="onModalAfterLeave"> <NModal v-model:show="showDebugCodeModal" preset="dialog" type="info" @after-enter="onModalAfterEnter" @after-leave="onModalAfterLeave">
<template #header> <template #header>
<NText v-if="!debugModeEnabled">请输入调试码</NText> <NText v-if="!debugMode">请输入调试码</NText>
<NText v-else>确认关闭调试模式</NText> <NText v-else>确认关闭调试模式</NText>
</template> </template>
<template #default> <template #default>
@@ -379,7 +416,7 @@ const onDrawerAfterLeave = () => {
</template> </template>
<template #action> <template #action>
<NButton @click="showDebugCodeModal = false">取消</NButton> <NButton @click="showDebugCodeModal = false">取消</NButton>
<NButton v-if="!debugModeEnabled" type="primary" @click="enableDebugMode">启用</NButton> <NButton v-if="!debugMode" type="primary" @click="enableDebugMode">启用</NButton>
<NButton v-else type="primary" @click="disableDebugMode">确认</NButton> <NButton v-else type="primary" @click="disableDebugMode">确认</NButton>
</template> </template>
</NModal> </NModal>

View File

@@ -5,14 +5,14 @@ import { storeToRefs } from 'pinia';
import type { ComponentInstance } from 'vue'; import type { ComponentInstance } from 'vue';
const settingsStore = useSettingStore(); const settingsStore = useSettingStore();
const { darkThemeEnabled } = storeToRefs(settingsStore); const { darkMode } = storeToRefs(settingsStore);
// 使外部能够获取NSwitch的类型提示 // 使外部能够获取NSwitch的类型提示
defineExpose({} as ComponentInstance<typeof NSwitch>); defineExpose({} as ComponentInstance<typeof NSwitch>);
</script> </script>
<template> <template>
<NSwitch v-model:value="darkThemeEnabled"> <NSwitch v-model:value="darkMode">
<template #unchecked-icon> <template #unchecked-icon>
<NIcon> <NIcon>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -1,3 +1,4 @@
export * from './device'; export * from './device';
export * from './global'; export * from './global';
export * from './permission';
export * from './station'; export * from './station';

View File

@@ -0,0 +1,6 @@
import type { ComponentInstance } from 'vue';
import PermissionConfigModal from './permission-config-modal.vue';
export type PermissionConfigModalProps = ComponentInstance<typeof PermissionConfigModal>['$props'];
export { PermissionConfigModal };

View File

@@ -0,0 +1,302 @@
<script setup lang="ts">
import { detailBaseEmployeeApi, modifyPermissionApi, pagePermissionApi, type BaseEmployeeResultVO, type NdmPermissionResultVO, type NdmPermissionSaveVO, type Station } from '@/apis';
import { PERMISSION_TYPE_LITERALS, PERMISSION_TYPE_NAMES, type PermissionType } from '@/enums';
import { useStationStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { objectEntries } from '@vueuse/core';
import { isCancel } from 'axios';
import { cloneDeep } from 'es-toolkit';
import { NButton, NCheckbox, NDataTable, NFlex, NModal, NText, type DataTableColumn, type DataTableColumns } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, h, ref, toRefs } from 'vue';
type NdmPermissionSaveOrResultVO = NdmPermissionSaveVO | NdmPermissionResultVO;
const props = defineProps<{
employeeId?: string;
}>();
const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore);
const show = defineModel<boolean>('show', { default: false });
const { employeeId } = toRefs(props);
const abortController = ref<AbortController>(new AbortController());
const employee = ref<BaseEmployeeResultVO>();
const { mutateAsync: getEmployeeAsync } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
if (!employeeId.value) return;
const signal = abortController.value.signal;
const data = await detailBaseEmployeeApi(employeeId.value, { signal });
return data;
},
onSuccess: (data) => {
if (!data) return;
employee.value = data;
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
// 从后端获取的原始权限列表
const originalList = ref<NdmPermissionResultVO[]>([]);
// 当前用户配置的权限列表
const currentList = ref<NdmPermissionSaveOrResultVO[]>([]);
const { mutate: getPermissions, isPending: permissionsLoading } = useMutation({
mutationFn: async () => {
if (!employeeId.value) throw new Error('员工ID不能为空');
abortController.value.abort();
abortController.value = new AbortController();
const signal = abortController.value.signal;
const data = await pagePermissionApi(
{
model: {
employeeId: employeeId.value,
},
current: 1,
size: Object.keys(PERMISSION_TYPE_LITERALS).length * stations.value.length,
},
{ signal },
);
return data;
},
onSuccess: (data) => {
if (!data) return;
const { records } = data;
originalList.value = cloneDeep(records);
currentList.value = cloneDeep(records);
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onUpdatePermissionChecked = (checked: boolean, stationCode: Station['code'], permissionType: PermissionType) => {
if (!employeeId.value) return;
if (checked) {
const existed = currentList.value.some((permission) => permission.stationCode === stationCode && permission.type === permissionType);
if (!existed) {
const saveVO: NdmPermissionSaveVO = {
employeeId: employeeId.value,
stationCode,
type: permissionType,
};
currentList.value.push(saveVO);
}
} else {
const index = currentList.value.findIndex((permission) => permission.stationCode === stationCode && permission.type === permissionType);
if (index !== -1) {
currentList.value.splice(index, 1);
}
}
};
const tableColumns = computed<DataTableColumns<Station>>(() => {
return [
{
title: () => {
const permissionCount = currentList.value.length;
const permissionTypeCount = objectEntries(PERMISSION_TYPE_LITERALS).length;
const checked = permissionCount === stations.value.length * permissionTypeCount;
const indeterminate = permissionCount > 0 && permissionCount < stations.value.length * permissionTypeCount;
return h(NCheckbox, {
checked,
indeterminate,
onUpdateChecked: (checked) => {
objectEntries(PERMISSION_TYPE_LITERALS).forEach(([permissionType]) => {
stations.value.forEach((station) => {
onUpdatePermissionChecked(checked, station.code, permissionType);
});
});
},
});
},
key: 'row-check',
align: 'center',
width: 60,
fixed: 'left',
render: (rowData) => {
const { code: stationCode } = rowData;
const permissionTypeCount = objectEntries(PERMISSION_TYPE_LITERALS).length;
const stationCheckedPermissions = currentList.value.filter((permission) => permission.stationCode === stationCode);
const checked = stationCheckedPermissions.length === permissionTypeCount;
const indeterminate = stationCheckedPermissions.length > 0 && stationCheckedPermissions.length < permissionTypeCount;
return h(NCheckbox, {
checked,
indeterminate,
onUpdateChecked: (checked) => {
objectEntries(PERMISSION_TYPE_LITERALS).forEach(([permissionType]) => {
onUpdatePermissionChecked(checked, stationCode, permissionType);
});
},
});
},
},
{ title: '车站编号', key: 'code', align: 'center', width: 120 },
{ title: '车站名称', key: 'name', align: 'center', width: 360 },
// 「权限」列
...objectEntries(PERMISSION_TYPE_NAMES).map<DataTableColumn<Station>>(([permissionType, title]) => ({
title: () => {
const permissionCount = currentList.value.filter((permission) => permission.type === permissionType).length;
const checked = permissionCount === stations.value.length;
const indeterminate = permissionCount > 0 && permissionCount < stations.value.length;
return h(
NFlex,
{
justify: 'center',
align: 'center',
},
{
default: () => [
h(NCheckbox, {
checked,
indeterminate,
onUpdateChecked: (checked) => {
stations.value.forEach((station) => {
onUpdatePermissionChecked(checked, station.code, permissionType);
});
},
}),
h('span', title),
],
},
);
},
key: permissionType,
align: 'center',
render: (rowData) => {
const { code: stationCode } = rowData;
return h(NCheckbox, {
checked: currentList.value.some((permission) => permission.stationCode === stationCode && permission.type === permissionType),
onUpdateChecked: (checked) => onUpdatePermissionChecked(checked, stationCode, permissionType),
});
},
})),
];
});
const { mutate: savePermissions, isPending: permissionsSaving } = useMutation({
mutationFn: async () => {
if (!employeeId.value) throw new Error('员工ID不能为空');
abortController.value.abort();
abortController.value = new AbortController();
const signal = abortController.value.signal;
// 执行diff计算生成需要保存的权限列表和需要删除的权限ID列表
const saveList: NdmPermissionSaveVO[] = [];
const removeList: string[] = [];
// 遍历当前状态,如果权限不在原始权限列表中,说明是需要新增的权限
currentList.value.forEach((permission) => {
const { stationCode, type } = permission;
if (!stationCode || !type) return;
if (!originalList.value.some((permission) => permission.stationCode === stationCode && permission.type === type)) {
saveList.push({
employeeId: employeeId.value,
stationCode,
type,
});
}
});
// 遍历原始状态,如果权限不在当前状态中,说明是需要删除的权限
originalList.value.forEach((permission) => {
const { id, stationCode, type } = permission;
if (!id) return;
if (!currentList.value.some((permission) => permission.stationCode === stationCode && permission.type === type)) {
removeList.push(id);
}
});
await modifyPermissionApi(
{
employeeId: employeeId.value,
saveList,
removeList,
},
{
signal,
},
);
},
onSuccess: () => {
window.$message.success('权限配置保存成功');
getPermissions();
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onAfterEnter = () => {
getEmployeeAsync().then(() => getPermissions());
};
const onAfterLeave = () => {
employee.value = undefined;
originalList.value = [];
currentList.value = [];
};
const onClose = () => {
abortController.value.abort();
show.value = false;
};
</script>
<template>
<NModal
v-model:show="show"
preset="card"
style="width: 100vw; height: 100vh"
:content-style="{ height: '100%', overflow: 'hidden' }"
:close-on-esc="false"
:mask-closable="false"
:auto-focus="false"
@after-enter="onAfterEnter"
@after-leave="onAfterLeave"
@close="onClose"
>
<template #header>
<span>{{ `配置权限 - ${employee?.realName ?? ''}` }}</span>
</template>
<template #default>
<NDataTable flex-height style="height: 100%" :columns="tableColumns" :data="stations" :loading="permissionsLoading" :single-line="false" />
</template>
<template #footer>
<NText depth="3" style="font-size: smaller">*未勾选任何权限的用户将被认为拥有所有权限</NText>
</template>
<template #action>
<NFlex justify="end">
<NButton size="small" @click="onClose">取消</NButton>
<NButton type="primary" size="small" :loading="permissionsSaving" @click="() => savePermissions()">保存</NButton>
</NFlex>
</template>
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { getRecordCheckApi, type NdmNvrResultVO, type Station } from '@/apis'; import { batchExportRecordCheckApi, pageDefParameterApi, type Station } from '@/apis';
import { exportRecordDiagCsv, isNvrCluster, transformRecordChecks } from '@/helpers'; import { downloadByData, parseErrorFeedback } from '@/utils';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios'; import { isCancel } from 'axios';
import { NButton, NGrid, NGridItem, NModal, NScrollbar, NSpin } from 'naive-ui'; import dayjs from 'dayjs';
import { storeToRefs } from 'pinia'; import { NButton, NFlex, NGrid, NGridItem, NModal, NScrollbar, NSpin } from 'naive-ui';
import { computed, ref, toRefs } from 'vue'; import { ref, toRefs } from 'vue';
const props = defineProps<{ const props = defineProps<{
stations: Station[]; stations: Station[];
@@ -19,50 +17,66 @@ 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; {
model: {
key: 'NVR_GAP_SECONDS',
}, },
onSuccess: (checks, { stationCode }) => { extra: {},
if (!checks || checks.length === 0) return; current: 1,
const recordDiags = transformRecordChecks(checks); size: 1,
exportRecordDiagCsv(recordDiags, nvrClusterRecord.value[stationCode]?.stationName ?? ''); 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: 90,
gapSeconds,
stationCode: params.stations.map((station) => station.code),
},
{
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');
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;
@@ -73,6 +87,7 @@ const { mutate: exportRecordDiags, isPending: exporting } = useMutation({
}); });
const onAfterLeave = () => { const onAfterLeave = () => {
abortController.value.abort();
emit('afterLeave'); emit('afterLeave');
}; };
</script> </script>
@@ -81,17 +96,22 @@ 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>
</NSpin> </NSpin>
</NScrollbar> </NScrollbar>
</template> </template>
<template #action>
<NFlex justify="flex-end" align="center">
<NButton secondary :loading="batchExporting" @click="() => batchExportRecordCheck({ stations })">导出全部</NButton>
</NFlex>
</template>
</NModal> </NModal>
</template> </template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Station, StationAlarms, StationDevices } from '@/apis'; import type { Station, StationAlarms, StationDevices } from '@/apis';
import { DEVICE_TYPE_LITERALS } from '@/enums'; import { usePermission } from '@/composables';
import { DEVICE_TYPE_LITERALS, PERMISSION_TYPE_LITERALS } from '@/enums';
import axios from 'axios'; import axios from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { isFunction } from 'es-toolkit'; import { isFunction } from 'es-toolkit';
@@ -24,6 +25,8 @@ const emit = defineEmits<{
clickConfig: [station: Station]; clickConfig: [station: Station];
}>(); }>();
const { hasPermission } = usePermission();
const { station, devices, alarms, selectable } = toRefs(props); const { station, devices, alarms, selectable } = toRefs(props);
const onlineDeviceCount = computed(() => { const onlineDeviceCount = computed(() => {
@@ -71,7 +74,7 @@ const openDeviceConfigModal = () => {
emit('clickConfig', station.value); emit('clickConfig', station.value);
}; };
const dropdownOptions: DropdownOption[] = [ const dropdownOptions = computed<DropdownOption[]>(() => [
{ {
label: '视频平台', label: '视频平台',
key: 'video-platform', key: 'video-platform',
@@ -80,9 +83,10 @@ const dropdownOptions: DropdownOption[] = [
{ {
label: '设备配置', label: '设备配置',
key: 'device-config', key: 'device-config',
show: hasPermission(station.value.code, PERMISSION_TYPE_LITERALS.OPERATION),
onSelect: openDeviceConfigModal, onSelect: openDeviceConfigModal,
}, },
]; ]);
const onSelectDropdownOption = (key: string, option: DropdownOption) => { const onSelectDropdownOption = (key: string, option: DropdownOption) => {
const onSelect = option['onSelect']; const onSelect = option['onSelect'];

View File

@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Station, SyncCameraResult } from '@/apis'; import type { Station, SyncCameraResult } from '@/apis';
import { useStationStore } from '@/stores'; import { usePermissionStore } from '@/stores';
import { watchDebounced } from '@vueuse/core'; import { watchDebounced } from '@vueuse/core';
import { EditIcon, PlusCircleIcon, Trash2Icon } from 'lucide-vue-next'; import { EditIcon, PlusCircleIcon, Trash2Icon } from 'lucide-vue-next';
import { NFlex, NIcon, NList, NListItem, NModal, NScrollbar, NStatistic, NText, NThing } from 'naive-ui'; import { NFlex, NIcon, NList, NListItem, NModal, NScrollbar, NStatistic, NText, NThing } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs } from 'vue'; import { computed, ref, toRefs } from 'vue';
const props = defineProps<{ const props = defineProps<{
@@ -15,8 +14,8 @@ const emit = defineEmits<{
afterLeave: []; afterLeave: [];
}>(); }>();
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations } = storeToRefs(stationStore); const stations = computed(() => permissionStore.stations.VIEW ?? []);
const { syncCameraResult } = toRefs(props); const { syncCameraResult } = toRefs(props);

View File

@@ -1,11 +1,14 @@
import { usePermission } from '../permission';
import { deleteCameraIgnoreApi, pageCameraIgnoreApi, saveCameraIgnoreApi, updateDeviceAlarmLogApi, type NdmDeviceAlarmLogResultVO } from '@/apis'; import { deleteCameraIgnoreApi, pageCameraIgnoreApi, saveCameraIgnoreApi, updateDeviceAlarmLogApi, type NdmDeviceAlarmLogResultVO } from '@/apis';
import { DEVICE_TYPE_LITERALS, tryGetDeviceType } from '@/enums'; import { DEVICE_TYPE_LITERALS, PERMISSION_TYPE_LITERALS, tryGetDeviceType } from '@/enums';
import { parseErrorFeedback } from '@/utils'; import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { NButton, NFlex, NPopconfirm, type DataTableColumn, type DataTableRowData } from 'naive-ui'; import { NButton, NFlex, NPopconfirm, type DataTableColumn, type DataTableRowData } from 'naive-ui';
import { h, type Ref } from 'vue'; import { h, type Ref } from 'vue';
export const useAlarmActionColumn = (tableData: Ref<DataTableRowData[]>) => { export const useAlarmActionColumn = (tableData: Ref<DataTableRowData[]>) => {
const { hasPermission } = usePermission();
const { mutate: confirmAlarm } = useMutation({ const { mutate: confirmAlarm } = useMutation({
mutationFn: async (params: { id: string | null }) => { mutationFn: async (params: { id: string | null }) => {
const { id } = params; const { id } = params;
@@ -115,7 +118,9 @@ export const useAlarmActionColumn = (tableData: Ref<DataTableRowData[]>) => {
default: () => '确认告警?', default: () => '确认告警?',
}, },
), ),
tryGetDeviceType(rowData.deviceType) === DEVICE_TYPE_LITERALS.ndmCamera && [ tryGetDeviceType(rowData.deviceType) === DEVICE_TYPE_LITERALS.ndmCamera &&
rowData.stationCode &&
hasPermission(rowData.stationCode, PERMISSION_TYPE_LITERALS.OPERATION) && [
h( h(
NPopconfirm, NPopconfirm,
{ {

View File

@@ -1,39 +1,33 @@
import type { LineDevices, NdmDeviceResultVO, Station } from '@/apis'; import type { LineDevices, NdmDeviceResultVO, Station } from '@/apis';
import { tryGetDeviceType, type DeviceType } from '@/enums'; import { tryGetDeviceType, type DeviceType } from '@/enums';
import { useDeviceStore } from '@/stores'; import { ref } from 'vue';
import { watchDebounced } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { onMounted, ref, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
export const useDeviceSelection = (options?: { syncRoute?: MaybeRefOrGetter<boolean> }) => { export const useDeviceSelection = () => {
const { syncRoute } = options ?? {};
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const selectedStationCode = ref<Station['code']>(); const selectedStationCode = ref<Station['code']>();
const selectedDeviceType = ref<DeviceType>(); const selectedDeviceType = ref<DeviceType>();
const selectedDevice = ref<NdmDeviceResultVO>(); const selectedDevice = ref<NdmDeviceResultVO>();
const initFromRoute = (lineDevices: LineDevices) => { // 从路由参数同步选中的车站、设备类型以及设备
const { stationCode, deviceType, deviceDbId } = route.query; const syncFromRoute = (lineDevices: LineDevices) => {
if (stationCode) { // console.log('sync from route');
selectedStationCode.value = stationCode as Station['code']; const { stationCode: routeStationCode, deviceType: routeDeviceType, deviceDbId: routeDeviceDbId } = route.query;
if (routeStationCode) {
selectedStationCode.value = routeStationCode as Station['code'];
} }
if (deviceType) { if (routeDeviceType) {
selectedDeviceType.value = deviceType as DeviceType; selectedDeviceType.value = routeDeviceType as DeviceType;
} }
if (deviceDbId && selectedStationCode.value && selectedDeviceType.value) { if (routeDeviceDbId && selectedStationCode.value && selectedDeviceType.value) {
const selectedDeviceDbId = deviceDbId as string; const selectedDeviceDbId = routeDeviceDbId as string;
const stationDevices = lineDevices[selectedStationCode.value]; const stationDevices = lineDevices[selectedStationCode.value];
if (stationDevices) { if (stationDevices) {
const devices = stationDevices[selectedDeviceType.value]; const classifiedDevices = stationDevices[selectedDeviceType.value];
if (devices) { if (classifiedDevices) {
const device = devices.find((device) => device.id === selectedDeviceDbId); const device = classifiedDevices.find((device) => device.id === selectedDeviceDbId);
if (device) { if (device) {
selectedDevice.value = device; selectedDevice.value = device;
} }
@@ -51,7 +45,9 @@ export const useDeviceSelection = (options?: { syncRoute?: MaybeRefOrGetter<bool
} }
}; };
// 将选中的车站、设备类型以及设备ID同步到路由参数
const syncToRoute = () => { const syncToRoute = () => {
// console.log('sync to route');
const query = { ...route.query }; const query = { ...route.query };
// 当选中的设备发生变化时删除fromPage参数 // 当选中的设备发生变化时删除fromPage参数
if (selectedDevice.value?.id && route.query.deviceDbId !== selectedDevice.value.id) { if (selectedDevice.value?.id && route.query.deviceDbId !== selectedDevice.value.id) {
@@ -69,39 +65,13 @@ export const useDeviceSelection = (options?: { syncRoute?: MaybeRefOrGetter<bool
router.replace({ query }); router.replace({ query });
}; };
watch(selectedDevice, () => {
if (toValue(syncRoute)) {
syncToRoute();
}
});
// lineDevices是shallowRef因此需要深度侦听才能获取内部变化
// 而单纯的深度侦听又可能会引发性能问题,因此尝试使用防抖侦听
watchDebounced(
lineDevices,
(newLineDevices) => {
if (toValue(syncRoute)) {
initFromRoute(newLineDevices);
}
},
{
debounce: 500,
deep: true,
},
);
onMounted(() => {
if (toValue(syncRoute)) {
initFromRoute(lineDevices.value);
}
});
return { return {
selectedStationCode, selectedStationCode,
selectedDeviceType, selectedDeviceType,
selectedDevice, selectedDevice,
initFromRoute, syncFromRoute,
syncToRoute,
selectDevice, selectDevice,
}; };
}; };

View File

@@ -1,11 +1,8 @@
import type { MaybeRefOrGetter } from 'vue';
import { useDeviceManagement } from './use-device-management'; import { useDeviceManagement } from './use-device-management';
import { useDeviceSelection } from './use-device-selection'; import { useDeviceSelection } from './use-device-selection';
export const useDeviceTree = (options?: { syncRoute?: MaybeRefOrGetter<boolean> }) => { export const useDeviceTree = () => {
const { syncRoute } = options ?? {}; const deviceSelection = useDeviceSelection();
const deviceSelection = useDeviceSelection({ syncRoute });
const deviceManagement = useDeviceManagement(); const deviceManagement = useDeviceManagement();
return { return {

View File

@@ -1,5 +1,6 @@
export * from './alarm'; export * from './alarm';
export * from './device'; export * from './device';
export * from './permission';
export * from './query'; export * from './query';
export * from './station'; export * from './station';
export * from './stomp'; export * from './stomp';

View File

@@ -0,0 +1 @@
export * from './use-permission';

View File

@@ -0,0 +1,14 @@
import type { PermissionType } from '@/enums';
import { usePermissionStore } from '@/stores';
export const usePermission = () => {
const permissionStore = usePermissionStore();
const hasPermission = (stationCode: string, permissionType: PermissionType) => {
return !!permissionStore.permissions[stationCode]?.includes(permissionType);
};
return {
hasPermission,
};
};

View File

@@ -1,5 +1,6 @@
export * from './use-line-alarms-query'; export * from './use-line-alarms-query';
export * from './use-line-devices-query'; export * from './use-line-devices-query';
export * from './use-line-stations-query'; export * from './use-line-stations-query';
export * from './use-user-permission-query';
export * from './use-verify-user-query'; export * from './use-verify-user-query';
export * from './use-version-check-query'; export * from './use-version-check-query';

View File

@@ -1,12 +1,11 @@
import { initStationAlarms, pageDeviceAlarmLogApi, type Station } from '@/apis'; import { initStationAlarms, pageDeviceAlarmLogApi, type Station } from '@/apis';
import { LINE_ALARMS_QUERY_KEY, STATION_ALARMS_MUTATION_KEY } from '@/constants'; import { LINE_ALARMS_QUERY_KEY, STATION_ALARMS_MUTATION_KEY } from '@/constants';
import { tryGetDeviceType } from '@/enums'; import { tryGetDeviceType } from '@/enums';
import { useAlarmStore, useStationStore } from '@/stores'; import { useAlarmStore, usePermissionStore } from '@/stores';
import { parseErrorFeedback } from '@/utils'; import { parseErrorFeedback } from '@/utils';
import { CancelledError, useMutation, useQuery } from '@tanstack/vue-query'; import { CancelledError, useMutation, useQuery } from '@tanstack/vue-query';
import { isCancel } from 'axios'; import { isCancel } from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
export const useStationAlarmsMutation = () => { export const useStationAlarmsMutation = () => {
@@ -69,8 +68,9 @@ export const useStationAlarmsMutation = () => {
* @see [use-line-stations-query.ts](./use-line-stations-query.ts) * @see [use-line-stations-query.ts](./use-line-stations-query.ts)
*/ */
export const useLineAlarmsQuery = () => { export const useLineAlarmsQuery = () => {
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations } = storeToRefs(stationStore); const stations = computed(() => permissionStore.stations.VIEW ?? []);
const { mutateAsync: getStationAlarms } = useStationAlarmsMutation(); const { mutateAsync: getStationAlarms } = useStationAlarmsMutation();
return useQuery({ return useQuery({

View File

@@ -1,10 +1,9 @@
import { getAllDevicesApi, initStationDevices, type Station } from '@/apis'; import { getAllDevicesApi, initStationDevices, type Station } from '@/apis';
import { LINE_DEVICES_QUERY_KEY, STATION_DEVICES_MUTATION_KEY } from '@/constants'; import { LINE_DEVICES_QUERY_KEY, STATION_DEVICES_MUTATION_KEY } from '@/constants';
import { useDeviceStore, useStationStore } from '@/stores'; import { useDeviceStore, usePermissionStore } from '@/stores';
import { parseErrorFeedback } from '@/utils'; import { parseErrorFeedback } from '@/utils';
import { CancelledError, useMutation, useQuery } from '@tanstack/vue-query'; import { CancelledError, useMutation, useQuery } from '@tanstack/vue-query';
import { isCancel } from 'axios'; import { isCancel } from 'axios';
import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
export const useStationDevicesMutation = () => { export const useStationDevicesMutation = () => {
@@ -36,8 +35,9 @@ export const useStationDevicesMutation = () => {
* @see [use-line-stations-query.ts](./use-line-stations-query.ts) * @see [use-line-stations-query.ts](./use-line-stations-query.ts)
*/ */
export const useLineDevicesQuery = () => { export const useLineDevicesQuery = () => {
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations } = storeToRefs(stationStore); const stations = computed(() => permissionStore.stations.VIEW ?? []);
const { mutateAsync: getStationDevices } = useStationDevicesMutation(); const { mutateAsync: getStationDevices } = useStationDevicesMutation();
return useQuery({ return useQuery({

View File

@@ -1,14 +1,12 @@
import { batchVerifyApi, type Station } from '@/apis'; import { batchVerifyApi, type Station } from '@/apis';
import { LINE_STATIONS_MUTATION_KEY, LINE_STATIONS_QUERY_KEY } from '@/constants'; import { LINE_STATIONS_MUTATION_KEY, LINE_STATIONS_QUERY_KEY } from '@/constants';
import { usePollingStore, useStationStore } from '@/stores'; import { useSettingStore, useStationStore } from '@/stores';
import { getAppEnvConfig, parseErrorFeedback } from '@/utils'; import { getAppEnvConfig, parseErrorFeedback } from '@/utils';
import { CancelledError, useMutation, useQuery } from '@tanstack/vue-query'; import { CancelledError, useMutation, useQuery } from '@tanstack/vue-query';
import axios, { isCancel } from 'axios'; import axios, { isCancel } from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import { useLineDevicesQuery } from './use-line-devices-query';
import { useLineAlarmsQuery } from './use-line-alarms-query';
export const useLineStationsMutation = () => { export const useLineStationsMutation = () => {
const stationStore = useStationStore(); const stationStore = useStationStore();
@@ -17,12 +15,13 @@ export const useLineStationsMutation = () => {
mutationKey: [LINE_STATIONS_MUTATION_KEY], mutationKey: [LINE_STATIONS_MUTATION_KEY],
mutationFn: async (params: { signal?: AbortSignal }) => { mutationFn: async (params: { signal?: AbortSignal }) => {
const { signal } = params; const { signal } = params;
const { data: ndmStationList } = await axios.get<{ code: string; name: string }[]>(`/minio/ndm/ndm-stations.json?_t=${dayjs().unix()}`, { signal }); const { data: ndmStationList } = await axios.get<Omit<Station, 'online' | 'ip'>[]>(`/minio/ndm/ndm-stations.json?_t=${dayjs().unix()}`, { signal });
const stations = ndmStationList.map<Station>((station) => ({ const stations = ndmStationList.map<Station>((station) => ({
code: station.code ?? '', code: station.code ?? '',
name: station.name ?? '', name: station.name ?? '',
online: false, online: false,
ip: '', ip: '',
occ: station.occ,
})); }));
const verifyList = await batchVerifyApi({ signal }); const verifyList = await batchVerifyApi({ signal });
return stations.map((station) => ({ return stations.map((station) => ({
@@ -44,16 +43,14 @@ export const useLineStationsMutation = () => {
}; };
export const useLineStationsQuery = () => { export const useLineStationsQuery = () => {
const pollingStore = usePollingStore(); const settingStore = useSettingStore();
const { pollingEnabled } = storeToRefs(pollingStore); const { pollingStations } = storeToRefs(settingStore);
const { requestInterval } = getAppEnvConfig(); const { requestInterval } = getAppEnvConfig();
const { mutateAsync: getLineStations } = useLineStationsMutation(); const { mutateAsync: getLineStations } = useLineStationsMutation();
const { refetch: refetchLineDevicesQuery } = useLineDevicesQuery();
const { refetch: refetchLineAlarmsQuery } = useLineAlarmsQuery();
return useQuery({ return useQuery({
queryKey: computed(() => [LINE_STATIONS_QUERY_KEY]), queryKey: computed(() => [LINE_STATIONS_QUERY_KEY]),
enabled: computed(() => pollingEnabled.value), enabled: computed(() => pollingStations.value),
refetchInterval: requestInterval * 1000, refetchInterval: requestInterval * 1000,
staleTime: (requestInterval * 1000) / 2, staleTime: (requestInterval * 1000) / 2,
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
@@ -62,12 +59,6 @@ export const useLineStationsQuery = () => {
const endTime = performance.now(); const endTime = performance.now();
console.log(`${LINE_STATIONS_QUERY_KEY}: ${endTime - startTime} ms`); console.log(`${LINE_STATIONS_QUERY_KEY}: ${endTime - startTime} ms`);
if (!pollingEnabled.value) return null;
await refetchLineDevicesQuery();
if (!pollingEnabled.value) return null;
await refetchLineAlarmsQuery();
return null; return null;
}, },
}); });

View File

@@ -0,0 +1,67 @@
import { useLineDevicesQuery } from './use-line-devices-query';
import { useLineAlarmsQuery } from './use-line-alarms-query';
import { pagePermissionApi } from '@/apis';
import { USER_PERMISSION_QUERY_KEY } from '@/constants';
import { PERMISSION_TYPE_LITERALS } from '@/enums';
import { usePermissionStore, useSettingStore, useStationStore, useUserStore } from '@/stores';
import { useQuery } from '@tanstack/vue-query';
import { storeToRefs } from 'pinia';
import { computed, watch } from 'vue';
import { useLineStationsQuery } from './use-line-stations-query';
export const useUserPermissionQuery = () => {
const settingStore = useSettingStore();
const { pollingStations, activeRequests } = storeToRefs(settingStore);
const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore);
const permissionStore = usePermissionStore();
const { permissions } = storeToRefs(permissionStore);
const { dataUpdatedAt: stationsUpdatedTime } = useLineStationsQuery();
const { refetch: refetchLineDevicesQuery } = useLineDevicesQuery();
const { refetch: refetchLineAlarmsQuery } = useLineAlarmsQuery();
watch([permissions, stationsUpdatedTime], async ([newPermissions, newUpdatedTime], [oldPermissions, oldUpdatedTime]) => {
const newPermissionsJson = JSON.stringify(newPermissions);
const oldPermissionsJson = JSON.stringify(oldPermissions);
if (newPermissionsJson === oldPermissionsJson && newUpdatedTime === oldUpdatedTime) return;
// 设备查询和告警查询依赖pollingEnabdled
// 当关闭轮询时,只会取消当前正在执行的查询,
// 所以如果在关闭轮询时refetch还未执行那么这一次取消就是无效的refetch依然会执行
// 所以在每个refetch被调用前都需要检查pollingEnabled否则就可能会取消失败
if (!pollingStations.value) return;
await refetchLineDevicesQuery();
if (!pollingStations.value) return;
await refetchLineAlarmsQuery();
});
return useQuery({
queryKey: computed(() => [USER_PERMISSION_QUERY_KEY]),
// 启用【车站轮询】或【主动请求】时,都认为查询被启用
enabled: computed(() => (pollingStations.value || activeRequests.value) && userInfo.value?.['employeeId'] && stations.value.length > 0),
// 当启用【车站轮询】时刷新间隔为10秒缓存时间为5秒
refetchInterval: computed(() => (pollingStations.value ? 10 * 1000 : undefined)),
staleTime: computed(() => (pollingStations.value ? 5 * 1000 : undefined)),
queryFn: async ({ signal }) => {
const { records } = await pagePermissionApi(
{
model: {
employeeId: userInfo.value['employeeId'],
},
current: 1,
size: Object.keys(PERMISSION_TYPE_LITERALS).length * stations.value.length,
},
{
signal,
},
);
permissionStore.setPermRecords(records);
return null;
},
});
};

View File

@@ -8,17 +8,17 @@ import { computed, watch } from 'vue';
export const useVerifyUserQuery = () => { export const useVerifyUserQuery = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore); const { activeRequests } = storeToRefs(settingStore);
watch(offlineDev, (offline) => { watch(activeRequests, (active) => {
if (offline) { if (!active) {
queryClient.cancelQueries({ queryKey: [VERIFY_USER_QUERY_KEY] }); queryClient.cancelQueries({ queryKey: [VERIFY_USER_QUERY_KEY] });
} }
}); });
return useQuery({ return useQuery({
queryKey: [VERIFY_USER_QUERY_KEY], queryKey: [VERIFY_USER_QUERY_KEY],
enabled: computed(() => !offlineDev.value), enabled: computed(() => activeRequests.value),
refetchInterval: 10 * 1000, refetchInterval: 10 * 1000,
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
await verifyApi({ signal }); await verifyApi({ signal });

View File

@@ -1,4 +1,8 @@
import { usePermission } from '../permission';
import { type Station } from '@/apis'; import { type Station } from '@/apis';
import { PERMISSION_TYPE_LITERALS, type PermissionType } from '@/enums';
import { objectEntries } from '@vueuse/core';
import type { CheckboxProps } from 'naive-ui';
import { computed, ref, watch, type Ref } from 'vue'; import { computed, ref, watch, type Ref } from 'vue';
type BatchActionKey = 'export-icmp' | 'export-record' | 'sync-camera' | 'sync-nvr'; type BatchActionKey = 'export-icmp' | 'export-record' | 'sync-camera' | 'sync-nvr';
@@ -6,29 +10,36 @@ type BatchActionKey = 'export-icmp' | 'export-record' | 'sync-camera' | 'sync-nv
type BatchAction = { type BatchAction = {
label: string; label: string;
key: BatchActionKey; key: BatchActionKey;
permission: PermissionType;
active: boolean; active: boolean;
}; };
export const useBatchActions = (stations: Ref<Station[]>, abortController?: Ref<AbortController | undefined>) => { export const useBatchActions = (stations: Ref<Station[]>, abortController?: Ref<AbortController | undefined>) => {
const { hasPermission } = usePermission();
const batchActions = ref<BatchAction[]>([ const batchActions = ref<BatchAction[]>([
{ {
label: '导出设备状态', label: '导出设备状态',
key: 'export-icmp', key: 'export-icmp',
permission: PERMISSION_TYPE_LITERALS.VIEW,
active: false, active: false,
}, },
{ {
label: '导出录像诊断', label: '导出录像诊断',
key: 'export-record', key: 'export-record',
permission: PERMISSION_TYPE_LITERALS.VIEW,
active: false, active: false,
}, },
{ {
label: '同步摄像机', label: '同步摄像机',
key: 'sync-camera', key: 'sync-camera',
permission: PERMISSION_TYPE_LITERALS.OPERATION,
active: false, active: false,
}, },
{ {
label: '同步录像机通道', label: '同步录像机通道',
key: 'sync-nvr', key: 'sync-nvr',
permission: PERMISSION_TYPE_LITERALS.OPERATION,
active: false, active: false,
}, },
]); ]);
@@ -39,11 +50,33 @@ export const useBatchActions = (stations: Ref<Station[]>, abortController?: Ref<
const selectableStations = computed(() => { const selectableStations = computed(() => {
if (!selectedAction.value) return []; if (!selectedAction.value) return [];
return stations.value; const result: Station[] = [];
if (selectedAction.value.permission === PERMISSION_TYPE_LITERALS.VIEW) {
result.push(...stations.value.filter((station) => hasPermission(station.code, PERMISSION_TYPE_LITERALS.VIEW)));
}
if (selectedAction.value.permission === PERMISSION_TYPE_LITERALS.OPERATION) {
result.push(...stations.value.filter((station) => hasPermission(station.code, PERMISSION_TYPE_LITERALS.OPERATION)));
}
return result;
}); });
const stationSelection = ref<Record<Station['code'], boolean>>({}); const stationSelection = ref<Record<Station['code'], boolean>>({});
const selectionProps = computed<CheckboxProps>(() => {
const selectableStationsLength = selectableStations.value.length;
const selectedStationsLength = objectEntries(stationSelection.value).filter(([, selected]) => selected).length;
const disabled = selectableStationsLength === 0;
const checked = selectableStationsLength > 0 && selectedStationsLength === selectableStationsLength;
const indeterminate = selectableStationsLength > 0 && selectedStationsLength > 0 && selectedStationsLength < selectableStationsLength;
return {
disabled,
checked,
indeterminate,
};
});
const toggleSelectAction = (action: BatchAction) => { const toggleSelectAction = (action: BatchAction) => {
batchActions.value.forEach((batchAction) => { batchActions.value.forEach((batchAction) => {
if (batchAction.key === action.key) { if (batchAction.key === action.key) {
@@ -95,6 +128,8 @@ export const useBatchActions = (stations: Ref<Station[]>, abortController?: Ref<
selectableStations, selectableStations,
stationSelection, stationSelection,
selectionProps,
toggleSelectAction, toggleSelectAction,
toggleSelectAllStations, toggleSelectAllStations,

View File

@@ -1,12 +1,12 @@
import type { NdmDeviceAlarmLogResultVO, Station, SyncCameraResult } from '@/apis'; import type { NdmDeviceAlarmLogResultVO, Station, SyncCameraResult } from '@/apis';
import { ALARM_TOPIC, SYNC_CAMERA_STATUS_TOPIC } from '@/constants'; import { ALARM_TOPIC, PERMISSION_TOPIC, SYNC_CAMERA_STATUS_TOPIC } from '@/constants';
import { useSettingStore, useStationStore, useUnreadStore } from '@/stores'; import { useSettingStore, useStationStore, useUnreadStore, useUserStore } from '@/stores';
import { Client } from '@stomp/stompjs'; import { Client } from '@stomp/stompjs';
import { watchDebounced } from '@vueuse/core'; import { watchDebounced } from '@vueuse/core';
import destr from 'destr'; import destr from 'destr';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useStationAlarmsMutation } from '../query'; import { useStationAlarmsMutation, useUserPermissionQuery } from '../query';
const getBrokerUrl = () => { const getBrokerUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -24,8 +24,12 @@ export const useStompClient = () => {
const { unreadLineAlarms } = storeToRefs(unreadStore); const { unreadLineAlarms } = storeToRefs(unreadStore);
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore); const { subscribeMessages } = storeToRefs(settingStore);
const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
const { refetch: refetchUserPermissionQuery } = useUserPermissionQuery();
const { mutate: refreshStationAlarms } = useStationAlarmsMutation(); const { mutate: refreshStationAlarms } = useStationAlarmsMutation();
const stompClient = ref<Client | null>(null); const stompClient = ref<Client | null>(null);
@@ -47,6 +51,11 @@ export const useStompClient = () => {
unreadStore.pushUnreadAlarm(alarm); unreadStore.pushUnreadAlarm(alarm);
} }
}); });
stompClient.value?.subscribe(PERMISSION_TOPIC, (message) => {
const employeeId = destr<string>(message.body);
if (userInfo.value?.['employeeId'] !== employeeId) return;
refetchUserPermissionQuery();
});
stompClient.value?.subscribe(SYNC_CAMERA_STATUS_TOPIC, (message) => { stompClient.value?.subscribe(SYNC_CAMERA_STATUS_TOPIC, (message) => {
const { stationCode, startTime, endTime, insertList, updateList, deleteList } = destr<SyncCameraResult>(message.body); const { stationCode, startTime, endTime, insertList, updateList, deleteList } = destr<SyncCameraResult>(message.body);
syncCameraResult.value[stationCode] = { stationCode, startTime, endTime, insertList, updateList, deleteList }; syncCameraResult.value[stationCode] = { stationCode, startTime, endTime, insertList, updateList, deleteList };
@@ -55,6 +64,7 @@ export const useStompClient = () => {
onDisconnect: () => { onDisconnect: () => {
console.log('Stomp连接断开'); console.log('Stomp连接断开');
stompClient.value?.unsubscribe(ALARM_TOPIC); stompClient.value?.unsubscribe(ALARM_TOPIC);
stompClient.value?.unsubscribe(PERMISSION_TOPIC);
stompClient.value?.unsubscribe(SYNC_CAMERA_STATUS_TOPIC); stompClient.value?.unsubscribe(SYNC_CAMERA_STATUS_TOPIC);
}, },
onStompError: (frame) => { onStompError: (frame) => {
@@ -66,7 +76,7 @@ export const useStompClient = () => {
window.$message.error('WebSocket错误'); window.$message.error('WebSocket错误');
}, },
}); });
if (!offlineDev.value) { if (subscribeMessages.value) {
stompClient.value.activate(); stompClient.value.activate();
} }
}); });
@@ -76,11 +86,11 @@ export const useStompClient = () => {
stompClient.value = null; stompClient.value = null;
}); });
watch(offlineDev, (offline) => { watch(subscribeMessages, (subscribe) => {
if (offline) { if (subscribe) {
stompClient.value?.deactivate();
} else {
stompClient.value?.activate(); stompClient.value?.activate();
} else {
stompClient.value?.deactivate();
} }
}); });
@@ -90,8 +100,8 @@ export const useStompClient = () => {
watchDebounced( watchDebounced(
() => Object.entries(unreadLineAlarms.value).map(([stationCode, stationAlarms]) => ({ stationCode, count: stationAlarms['unclassified'].length })), () => Object.entries(unreadLineAlarms.value).map(([stationCode, stationAlarms]) => ({ stationCode, count: stationAlarms['unclassified'].length })),
(newValue, oldValue) => { (newValue, oldValue) => {
// 启用离线模式时,跳过处理 // 关闭消息订阅时,跳过处理
if (offlineDev.value) return; if (!subscribeMessages.value) return;
if (newValue.length === 0) return; if (newValue.length === 0) return;
const codes: Station['code'][] = []; const codes: Station['code'][] = [];
newValue.forEach(({ stationCode, count }) => { newValue.forEach(({ stationCode, count }) => {

View File

@@ -1,5 +1,6 @@
export const LINE_ALARMS_QUERY_KEY = 'line-alarms'; export const LINE_ALARMS_QUERY_KEY = 'line-alarms';
export const LINE_DEVICES_QUERY_KEY = 'line-devices'; export const LINE_DEVICES_QUERY_KEY = 'line-devices';
export const LINE_STATIONS_QUERY_KEY = 'line-stations'; export const LINE_STATIONS_QUERY_KEY = 'line-stations';
export const USER_PERMISSION_QUERY_KEY = 'user-permission';
export const VERIFY_USER_QUERY_KEY = 'verify-user'; export const VERIFY_USER_QUERY_KEY = 'verify-user';
export const VERSION_CHECK_QUERY_KEY = 'version-check'; export const VERSION_CHECK_QUERY_KEY = 'version-check';

View File

@@ -1,3 +1,3 @@
export const ALARM_TOPIC = '/topic/deviceAlarm'; export const ALARM_TOPIC = '/topic/deviceAlarm';
export const PERMISSION_TOPIC = '/topic/permission';
export const SYNC_CAMERA_STATUS_TOPIC = '/topic/syncCameraStatus'; export const SYNC_CAMERA_STATUS_TOPIC = '/topic/syncCameraStatus';

View File

@@ -1,5 +1,6 @@
export const NDM_ALARM_STORE_ID = 'ndm-alarm-store'; export const NDM_ALARM_STORE_ID = 'ndm-alarm-store';
export const NDM_DEVICE_STORE_ID = 'ndm-device-store'; export const NDM_DEVICE_STORE_ID = 'ndm-device-store';
export const NDM_PERMISSION_STORE_ID = 'ndm-permission-store';
export const NDM_POLLIING_STORE_ID = 'ndm-polling-store'; export const NDM_POLLIING_STORE_ID = 'ndm-polling-store';
export const NDM_SETTING_STORE_ID = 'ndm-setting-store'; export const NDM_SETTING_STORE_ID = 'ndm-setting-store';
export const NDM_STATION_STORE_ID = 'ndm-station-store'; export const NDM_STATION_STORE_ID = 'ndm-station-store';

View File

@@ -1,3 +1,4 @@
export * from './alarm-type'; export * from './alarm-type';
export * from './device-type'; export * from './device-type';
export * from './fault-level'; export * from './fault-level';
export * from './permission-type';

View File

@@ -0,0 +1,13 @@
export const PERMISSION_TYPE_LITERALS = {
VIEW: 'VIEW',
OPERATION: 'OPERATION',
} as const;
export type PermissionType = keyof typeof PERMISSION_TYPE_LITERALS;
export const PERMISSION_TYPE_NAMES = {
[PERMISSION_TYPE_LITERALS.VIEW]: '查看',
[PERMISSION_TYPE_LITERALS.OPERATION]: '操作',
} as const;
export type PermissionTypeEnum = typeof PERMISSION_TYPE_NAMES;

2
src/global.d.ts vendored
View File

@@ -7,6 +7,6 @@ declare global {
$loadingBar: ReturnType<typeof useLoadingBar>; $loadingBar: ReturnType<typeof useLoadingBar>;
$message: ReturnType<typeof useMessage>; $message: ReturnType<typeof useMessage>;
$notification: ReturnType<typeof useNotification>; $notification: ReturnType<typeof useNotification>;
$offlineDev: Ref<boolean>; $mockUser: Ref<boolean>;
} }
} }

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

@@ -1,12 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { SettingsDrawer, SyncCameraResultModal } from '@/components'; import { SettingsDrawer, SyncCameraResultModal } from '@/components';
import { useLineStationsQuery, useStompClient, useVerifyUserQuery } from '@/composables'; import { useLineStationsQuery, useStompClient, useUserPermissionQuery, useVerifyUserQuery } from '@/composables';
import { LINE_ALARMS_QUERY_KEY, LINE_DEVICES_QUERY_KEY, LINE_STATIONS_MUTATION_KEY, LINE_STATIONS_QUERY_KEY, STATION_ALARMS_MUTATION_KEY, STATION_DEVICES_MUTATION_KEY } from '@/constants'; import { LINE_ALARMS_QUERY_KEY, LINE_DEVICES_QUERY_KEY, LINE_STATIONS_MUTATION_KEY, LINE_STATIONS_QUERY_KEY, STATION_ALARMS_MUTATION_KEY, STATION_DEVICES_MUTATION_KEY } from '@/constants';
import { useSettingStore, useUnreadStore, useUserStore } from '@/stores'; import { useSettingStore, useUnreadStore, useUserStore } from '@/stores';
import { parseErrorFeedback } from '@/utils'; import { useIsFetching, useIsMutating } from '@tanstack/vue-query';
import { useIsFetching, useIsMutating, useMutation } from '@tanstack/vue-query'; import { ChevronDownIcon, ChevronsLeftIcon, ChevronsRightIcon, ComputerIcon, KeyRoundIcon, LogOutIcon, LogsIcon, MapPinIcon, SettingsIcon, SirenIcon } from 'lucide-vue-next';
import { isCancel } from 'axios';
import { ChevronDownIcon, ChevronsLeftIcon, ChevronsRightIcon, ComputerIcon, LogOutIcon, LogsIcon, MapPinIcon, SettingsIcon, SirenIcon } from 'lucide-vue-next';
import { import {
NBadge, NBadge,
NButton, NButton,
@@ -24,25 +22,26 @@ import {
type MenuOption, type MenuOption,
} from 'naive-ui'; } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, h, ref, watchEffect, type Component, type VNode } from 'vue'; import { computed, h, ref, type Component, type VNode } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router'; import { RouterLink, useRoute, useRouter } from 'vue-router';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore); const { userInfo, isLamp } = storeToRefs(userStore);
const unreadStore = useUnreadStore(); const unreadStore = useUnreadStore();
const { unreadAlarmCount } = storeToRefs(unreadStore); const { unreadAlarmCount } = storeToRefs(unreadStore);
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { menuCollpased, offlineDev } = storeToRefs(settingStore); const { menuCollpased } = storeToRefs(settingStore);
const { syncCameraResult, afterCheckSyncCameraResult } = useStompClient(); const { syncCameraResult, afterCheckSyncCameraResult } = useStompClient();
useVerifyUserQuery(); useVerifyUserQuery();
useLineStationsQuery(); useLineStationsQuery();
useUserPermissionQuery();
// 全局loading状态依赖于轮询query的queryKey以及相关的mutationKey // 全局loading状态依赖于轮询query的queryKey以及相关的mutationKey
const queryingCount = useIsFetching({ const queryingCount = useIsFetching({
@@ -65,7 +64,7 @@ const onToggleMenuCollapsed = () => {
menuCollpased.value = !menuCollpased.value; menuCollpased.value = !menuCollpased.value;
}; };
const menuOptions: MenuOption[] = [ const menuOptions = computed<MenuOption[]>(() => [
{ {
label: () => h(RouterLink, { to: '/station' }, { default: () => '车站状态' }), label: () => h(RouterLink, { to: '/station' }, { default: () => '车站状态' }),
key: '/station', key: '/station',
@@ -106,7 +105,13 @@ const menuOptions: MenuOption[] = [
}, },
], ],
}, },
]; {
label: () => h(RouterLink, { to: '/permission' }, { default: () => '权限管理' }),
key: '/permission',
show: isLamp.value,
icon: renderIcon(KeyRoundIcon),
},
]);
const dropdownOptions: DropdownOption[] = [ const dropdownOptions: DropdownOption[] = [
{ {
@@ -147,27 +152,6 @@ const routeToAlarmPage = () => {
} }
}; };
const { mutate: getUserInfo } = useMutation({
mutationFn: async (params?: { signal?: AbortSignal }) => {
const { signal } = params ?? {};
await userStore.userGetInfo({ signal });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
// 判断是否为离线开发模式 决定是否自动发送获取用户信息请求
watchEffect((onCleanup) => {
if (offlineDev.value) return;
const abortController = new AbortController();
getUserInfo({ signal: abortController.signal });
onCleanup(() => abortController.abort());
});
function renderIcon(icon: Component): () => VNode { function renderIcon(icon: Component): () => VNode {
return () => h(NIcon, null, { default: () => h(icon) }); return () => h(NIcon, null, { default: () => h(icon) });
} }

View File

@@ -16,8 +16,9 @@ const NDM_TYPES: Record<string, DeviceType> = {
<script setup lang="ts"> <script setup lang="ts">
import { deleteCameraIgnoreApi, pageCameraIgnoreApi, type NdmCameraIgnore, type NdmCameraIgnoreResultVO, type PageQueryExtra, type Station } from '@/apis'; import { deleteCameraIgnoreApi, pageCameraIgnoreApi, type NdmCameraIgnore, type NdmCameraIgnoreResultVO, type PageQueryExtra, type Station } from '@/apis';
import { DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, type DeviceType } from '@/enums'; import { usePermission } from '@/composables';
import { useDeviceStore, useStationStore } from '@/stores'; import { DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, PERMISSION_TYPE_LITERALS, type DeviceType } from '@/enums';
import { useDeviceStore, usePermissionStore } from '@/stores';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios'; import { isCancel } from 'axios';
import { import {
@@ -46,8 +47,13 @@ interface SearchFields extends PageQueryExtra<NdmCameraIgnore> {
// deviceId_like?: string; // deviceId_like?: string;
} }
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations } = storeToRefs(stationStore); const { permissions } = storeToRefs(permissionStore);
const { hasPermission } = usePermission();
const stations = computed(() => permissionStore.stations.VIEW ?? []);
const deviceStore = useDeviceStore(); const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore); const { lineDevices } = storeToRefs(deviceStore);
@@ -64,6 +70,14 @@ const stationSelectOptions = computed<SelectOption[]>(() => {
// })); // }));
// }); // });
// 权限变化时,需要刷新表格数据
watch(permissions, (newPermissions, oldPermissions) => {
const oldPermissionsJson = JSON.stringify(oldPermissions);
const newPermissionsJson = JSON.stringify(newPermissions);
if (oldPermissionsJson === newPermissionsJson) return;
onClickReset();
});
const searchFields = ref<SearchFields>({}); const searchFields = ref<SearchFields>({});
const resetSearchFields = () => { const resetSearchFields = () => {
searchFields.value = {}; searchFields.value = {};
@@ -84,7 +98,7 @@ watch(searchFields, () => {
searchFieldsChanged.value = true; searchFieldsChanged.value = true;
}); });
const tableColumns: DataTableColumns<NdmCameraIgnoreResultVO> = [ const tableColumns = computed<DataTableColumns<NdmCameraIgnoreResultVO>>(() => [
{ title: '忽略时间', key: 'createdTime', align: 'center' }, { title: '忽略时间', key: 'createdTime', align: 'center' },
// { title: '更新时间', key: 'updatedTime' }, // { title: '更新时间', key: 'updatedTime' },
{ {
@@ -142,6 +156,11 @@ const tableColumns: DataTableColumns<NdmCameraIgnoreResultVO> = [
align: 'center', align: 'center',
width: 120, width: 120,
render: (rowData) => { render: (rowData) => {
const { deviceId } = rowData;
if (!deviceId) return null;
const stationCode = deviceId.slice(0, 4);
if (!stationCode) return null;
if (!hasPermission(stationCode, PERMISSION_TYPE_LITERALS.OPERATION)) return null;
return h( return h(
NPopconfirm, NPopconfirm,
{ {
@@ -167,7 +186,7 @@ const tableColumns: DataTableColumns<NdmCameraIgnoreResultVO> = [
); );
}, },
}, },
]; ]);
const { mutate: cancelIgnore } = useMutation({ const { mutate: cancelIgnore } = useMutation({
mutationFn: async (params: { id?: string; signal?: AbortSignal }) => { mutationFn: async (params: { id?: string; signal?: AbortSignal }) => {
@@ -192,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,9 +1,9 @@
<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';
import { useDeviceStore, useStationStore, useUnreadStore } from '@/stores'; import { useDeviceStore, usePermissionStore, useUnreadStore } 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 { watchDebounced } from '@vueuse/core'; import { watchDebounced } from '@vueuse/core';
@@ -37,13 +37,17 @@ interface SearchFields extends PageQueryExtra<NdmDeviceAlarmLog> {
alarmType_in: string[]; alarmType_in: string[];
faultLevel_in: string[]; faultLevel_in: string[];
alarmDate: [number, number]; alarmDate: [number, number];
alarmCategory: string;
alarmConfirm: string;
} }
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations } = storeToRefs(stationStore); const { permissions } = storeToRefs(permissionStore);
const stations = computed(() => permissionStore.stations.VIEW ?? []);
const deviceStore = useDeviceStore(); const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore); const { lineDevices } = storeToRefs(deviceStore);
@@ -76,6 +80,14 @@ const faultLevelSelectOptions = computed<SelectOption[]>(() => {
})); }));
}); });
// 权限变化时,需要刷新表格数据
watch(permissions, (newPermissions, oldPermissions) => {
const oldPermissionsJson = JSON.stringify(oldPermissions);
const newPermissionsJson = JSON.stringify(newPermissions);
if (oldPermissionsJson === newPermissionsJson) return;
onClickReset();
});
// 未读告警数量被清零时,代表从别的页面跳转过来,需要刷新告警表格数据 // 未读告警数量被清零时,代表从别的页面跳转过来,需要刷新告警表格数据
const unreadCountCleared = computed(() => unreadAlarmCount.value === 0); const unreadCountCleared = computed(() => unreadAlarmCount.value === 0);
watch(unreadCountCleared, (newValue, oldValue) => { watch(unreadCountCleared, (newValue, oldValue) => {
@@ -105,6 +117,8 @@ const searchFields = ref<SearchFields>({
alarmType_in: [], alarmType_in: [],
faultLevel_in: [], faultLevel_in: [],
alarmDate: [dayjs().startOf('date').valueOf(), dayjs().endOf('date').valueOf()], alarmDate: [dayjs().startOf('date').valueOf(), dayjs().endOf('date').valueOf()],
alarmCategory: '',
alarmConfirm: '',
}); });
const resetSearchFields = () => { const resetSearchFields = () => {
searchFields.value = { searchFields.value = {
@@ -114,19 +128,30 @@ const resetSearchFields = () => {
alarmType_in: [], alarmType_in: [],
faultLevel_in: [], faultLevel_in: [],
alarmDate: [dayjs().startOf('date').valueOf(), dayjs().endOf('date').valueOf()], alarmDate: [dayjs().startOf('date').valueOf(), dayjs().endOf('date').valueOf()],
alarmCategory: '',
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]);
const deviceNameLike = searchFields.value.deviceName_like;
const alarmTypeIn = searchFields.value.alarmType_in;
const faultLevelIn = searchFields.value.faultLevel_in;
const alarmDateGe = searchFields.value.alarmDate[0]; const alarmDateGe = searchFields.value.alarmDate[0];
const alarmDateLe = searchFields.value.alarmDate[1]; const alarmDateLe = searchFields.value.alarmDate[1];
return { return {
stationCode_in: stationCodeIn ? (stationCodeIn.length > 0 ? [...stationCodeIn] : undefined) : undefined, stationCode_in: stationCodeIn.length > 0 ? [...stationCodeIn] : stations.value.map((station) => station.code),
deviceType_in: deviceTypeIn ? (deviceTypeIn.length > 0 ? [...deviceTypeIn] : undefined) : undefined, deviceType_in: deviceTypeIn.length > 0 ? [...deviceTypeIn] : undefined,
deviceName_like: !!searchFields.value.deviceName_like ? searchFields.value.deviceName_like : undefined, deviceName_like: deviceNameLike.length > 0 ? deviceNameLike : undefined,
alarmType_in: searchFields.value.alarmType_in.length > 0 ? [...searchFields.value.alarmType_in] : undefined, alarmType_in: alarmTypeIn.length > 0 ? [...alarmTypeIn] : undefined,
faultLevel_in: searchFields.value.faultLevel_in.length > 0 ? [...searchFields.value.faultLevel_in] : undefined, faultLevel_in: faultLevelIn.length > 0 ? [...faultLevelIn] : undefined,
alarmDate_ge: alarmDateGe, alarmDate_ge: alarmDateGe,
alarmDate_le: alarmDateLe, alarmDate_le: alarmDateLe,
}; };
@@ -193,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,
@@ -225,7 +250,7 @@ const { mutate: getTableData, isPending: tableLoading } = useMutation({
const res = await pageDeviceAlarmLogApi( const res = await pageDeviceAlarmLogApi(
{ {
model: {}, model: getModelFields(),
extra: getExtraFields(), extra: getExtraFields(),
current: pagination.page ?? 1, current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE, size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
@@ -280,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,
@@ -333,6 +358,28 @@ onBeforeUnmount(() => {
<NFormItemGi span="1" label="告警级别" label-placement="left"> <NFormItemGi span="1" label="告警级别" label-placement="left">
<NSelect multiple clearable placeholder="请选择告警级别" v-model:value="searchFields.faultLevel_in" :options="faultLevelSelectOptions" /> <NSelect multiple clearable placeholder="请选择告警级别" v-model:value="searchFields.faultLevel_in" :options="faultLevelSelectOptions" />
</NFormItemGi> </NFormItemGi>
<NFormItemGi span="1" label="恢复状态" label-placement="left">
<NSelect
clearable
placeholder="请选择恢复状态"
v-model:value="searchFields.alarmCategory"
:options="[
{ label: '未恢复', value: '1' },
{ label: '已恢复', value: '2' },
]"
/>
</NFormItemGi>
<NFormItemGi span="1" label="确认状态" label-placement="left">
<NSelect
clearable
placeholder="请选择确认状态"
v-model:value="searchFields.alarmConfirm"
:options="[
{ label: '未确认', value: '2' },
{ label: '已确认', value: '1' },
]"
/>
</NFormItemGi>
<NFormItemGi span="1" label="告警时间" label-placement="left"> <NFormItemGi span="1" label="告警时间" label-placement="left">
<NDatePicker v-model:value="searchFields.alarmDate" type="datetimerange" /> <NDatePicker v-model:value="searchFields.alarmDate" type="datetimerange" />
</NFormItemGi> </NFormItemGi>

View File

@@ -3,13 +3,12 @@ import type { NdmDeviceResultVO, Station } from '@/apis';
import { DeviceRenderer, DeviceTree, type DeviceTreeProps } from '@/components'; import { DeviceRenderer, DeviceTree, type DeviceTreeProps } from '@/components';
import type { UseDeviceSelectionReturn } from '@/composables'; import type { UseDeviceSelectionReturn } from '@/composables';
import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants'; import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants';
import { useStationStore } from '@/stores'; import { usePermissionStore } from '@/stores';
import { NLayout, NLayoutContent, NLayoutSider } from 'naive-ui'; import { NLayout, NLayoutContent, NLayoutSider } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { computed, provide, ref } from 'vue';
import { provide, ref } from 'vue';
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations } = storeToRefs(stationStore); const stations = computed(() => permissionStore.stations.VIEW ?? []);
const selectedStation = ref<Station>(); const selectedStation = ref<Station>();
const selectedDevice = ref<NdmDeviceResultVO>(); const selectedDevice = ref<NdmDeviceResultVO>();

View File

@@ -32,7 +32,7 @@ const callLogTypeOptions: SelectOption[] = [
<script setup lang="ts"> <script setup lang="ts">
import { exportCallLogApi, pageCallLogApi, type NdmCallLog, type NdmCallLogResultVO, type PageQueryExtra, type Station } from '@/apis'; import { exportCallLogApi, pageCallLogApi, type NdmCallLog, type NdmCallLogResultVO, type PageQueryExtra, type Station } from '@/apis';
import { useStationStore } from '@/stores'; import { usePermissionStore } 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';
@@ -63,8 +63,11 @@ interface SearchFields extends PageQueryExtra<NdmCallLog> {
createdTime: [string, string]; createdTime: [string, string];
} }
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations, onlineStations } = storeToRefs(stationStore); const { permissions } = storeToRefs(permissionStore);
const stations = computed(() => permissionStore.stations.VIEW ?? []);
const onlineStations = computed(() => stations.value.filter((station) => station.online));
const stationSelectOptions = computed(() => { const stationSelectOptions = computed(() => {
return stations.value.map<SelectOption>((station) => ({ return stations.value.map<SelectOption>((station) => ({
@@ -74,6 +77,14 @@ const stationSelectOptions = computed(() => {
})); }));
}); });
// 权限变化时,需要刷新表格数据
watch(permissions, (newPermissions, oldPermissions) => {
const oldPermissionsJson = JSON.stringify(oldPermissions);
const newPermissionsJson = JSON.stringify(newPermissions);
if (oldPermissionsJson === newPermissionsJson) return;
onClickReset();
});
const searchFields = ref<SearchFields>({ const searchFields = ref<SearchFields>({
logType_in: [], logType_in: [],
createdTime: [dayjs().startOf('date').subtract(1, 'week').format('YYYY-MM-DD HH:mm:ss'), dayjs().endOf('date').format('YYYY-MM-DD HH:mm:ss')], createdTime: [dayjs().startOf('date').subtract(1, 'week').format('YYYY-MM-DD HH:mm:ss'), dayjs().endOf('date').format('YYYY-MM-DD HH:mm:ss')],
@@ -93,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,
@@ -132,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

@@ -29,7 +29,7 @@ const vimpLogTypeOptions: SelectOption[] = [
<script setup lang="ts"> <script setup lang="ts">
import { exportVimpLogApi, pageVimpLogApi, type NdmVimpLog, type NdmVimpLogResultVO, type PageQueryExtra, type Station } from '@/apis'; import { exportVimpLogApi, pageVimpLogApi, type NdmVimpLog, type NdmVimpLogResultVO, type PageQueryExtra, type Station } from '@/apis';
import { useStationStore } from '@/stores'; import { usePermissionStore } 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';
@@ -59,8 +59,11 @@ interface SearchFields extends PageQueryExtra<NdmVimpLog> {
createdTime: [string, string]; createdTime: [string, string];
} }
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations, onlineStations } = storeToRefs(stationStore); const { permissions } = storeToRefs(permissionStore);
const stations = computed(() => permissionStore.stations.VIEW ?? []);
const onlineStations = computed(() => stations.value.filter((station) => station.online));
const stationSelectOptions = computed(() => { const stationSelectOptions = computed(() => {
return stations.value.map<SelectOption>((station) => ({ return stations.value.map<SelectOption>((station) => ({
@@ -70,6 +73,14 @@ const stationSelectOptions = computed(() => {
})); }));
}); });
// 权限变化时,需要刷新表格数据
watch(permissions, (newPermissions, oldPermissions) => {
const oldPermissionsJson = JSON.stringify(oldPermissions);
const newPermissionsJson = JSON.stringify(newPermissions);
if (oldPermissionsJson === newPermissionsJson) return;
onClickReset();
});
const searchFields = ref<SearchFields>({ const searchFields = ref<SearchFields>({
logType_in: [], logType_in: [],
createdTime: [dayjs().startOf('date').subtract(1, 'week').format('YYYY-MM-DD HH:mm:ss'), dayjs().endOf('date').format('YYYY-MM-DD HH:mm:ss')] as [string, string], createdTime: [dayjs().startOf('date').subtract(1, 'week').format('YYYY-MM-DD HH:mm:ss'), dayjs().endOf('date').format('YYYY-MM-DD HH:mm:ss')] as [string, string],
@@ -84,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,
@@ -99,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' },
@@ -114,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

@@ -22,8 +22,7 @@ const { mutate: login, isPending: loading } = useMutation({
mutationFn: async (params: LoginParams) => { mutationFn: async (params: LoginParams) => {
const userStore = useUserStore(); const userStore = useUserStore();
await userStore.userLogin(params); await userStore.userLogin(params);
const [err] = await userClient.post<void>(`/api/ndm/ndmKeepAlive/verify`, {}, { timeout: 5000 }); await userStore.userGetInfo();
if (err) throw err;
}, },
onSuccess: () => { onSuccess: () => {
window.$message.success('登录成功'); window.$message.success('登录成功');

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import { pageBaseEmployeeApi, type BaseEmployeePageQuery, type BaseEmployeeResultVO } from '@/apis';
import { PermissionConfigModal } from '@/components';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import { KeyIcon } from 'lucide-vue-next';
import { NButton, NDataTable, NFlex, NForm, NFormItemGi, NGrid, NGridItem, NInput, type DataTableColumns, type DataTableRowData, type PaginationProps } from 'naive-ui';
import { h, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
interface SearchFields extends BaseEmployeePageQuery {}
const searchFields = ref<SearchFields>({});
const resetSearchFields = () => {
searchFields.value = {
realName: '',
};
};
const getModelFields = (): BaseEmployeePageQuery => {
return {
realName: searchFields.value.realName,
};
};
const searchFieldsChanged = ref(false);
watch(searchFields, () => {
searchFieldsChanged.value = true;
});
const showPermissionConfigModal = ref(false);
const selectedEmployeeId = ref('');
const tableColumns: DataTableColumns<BaseEmployeeResultVO> = [
{ title: '姓名', key: 'realName', align: 'center' },
{ title: '创建时间', key: 'createdTime', align: 'center' },
{
title: '操作',
key: 'action',
align: 'center',
width: 150,
render: (rowData) => {
return h(
NButton,
{
secondary: true,
type: 'info',
size: 'small',
onClick: () => {
const { id } = rowData;
if (!id) return;
selectedEmployeeId.value = id;
showPermissionConfigModal.value = true;
},
},
{
icon: () => h(KeyIcon),
default: () => '配置权限',
},
);
},
},
];
const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 20;
const pagination = reactive<PaginationProps>({
showSizePicker: true,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
pageSizes: [5, 10, 20, 50, 80, 100],
itemCount: 0,
prefix: ({ itemCount }) => {
return h('div', {}, { default: () => `${itemCount}` });
},
onUpdatePage: (page: number) => {
pagination.page = page;
getTableData();
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
getTableData();
},
});
const abortController = ref(new AbortController());
const { mutate: getTableData, isPending: tableLoading } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
const signal = abortController.value.signal;
const res = await pageBaseEmployeeApi(
{
model: getModelFields(),
extra: {},
current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
order: 'descending',
sort: 'id',
},
{
signal,
},
);
return res;
},
onSuccess: (res) => {
const { records, size, total } = res;
pagination.pageSize = parseInt(size);
pagination.itemCount = parseInt(total);
tableData.value = records;
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onClickReset = () => {
resetSearchFields();
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
pagination.itemCount = 0;
getTableData();
};
const onClickQuery = () => {
if (searchFieldsChanged.value) {
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
searchFieldsChanged.value = false;
}
getTableData();
};
onMounted(() => {
getTableData();
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script>
<template>
<NFlex vertical :size="0" style="height: 100%">
<!-- 查询面板 -->
<NForm style="flex: 0 0 auto; padding: 8px">
<NGrid cols="3" :x-gap="24">
<NFormItemGi span="1" label="姓名" label-placement="left">
<NInput v-model:value="searchFields.realName" />
</NFormItemGi>
</NGrid>
<!-- 操作按钮 -->
<NGrid :cols="1">
<NGridItem>
<NFlex>
<NButton @click="onClickReset">重置</NButton>
<NButton type="primary" :loading="tableLoading" @click="onClickQuery">查询</NButton>
</NFlex>
</NGridItem>
</NGrid>
</NForm>
<!-- 数据表格工具栏 -->
<NFlex align="center" style="padding: 8px; flex: 0 0 auto">
<div style="font-size: medium">用户权限列表</div>
<NFlex style="margin-left: auto">
<!-- <NButton type="primary" :loading="exporting" @click="() => exportTableData()">导出</NButton> -->
</NFlex>
</NFlex>
<!-- 数据表格 -->
<NDataTable remote :columns="tableColumns" :data="tableData" :pagination="pagination" :loading="tableLoading" :single-line="false" flex-height style="height: 100%; padding: 8px; flex: 1 1 auto" />
</NFlex>
<PermissionConfigModal v-model:show="showPermissionConfigModal" :employee-id="selectedEmployeeId" />
</template>
<style scoped lang="scss"></style>

View File

@@ -2,19 +2,19 @@
import { initStationAlarms, initStationDevices, syncCameraApi, syncNvrChannelsApi, type Station } from '@/apis'; import { initStationAlarms, initStationDevices, syncCameraApi, syncNvrChannelsApi, type Station } from '@/apis';
import { AlarmDetailModal, DeviceDetailModal, DeviceParamConfigModal, IcmpExportModal, RecordCheckExportModal, StationCard, type StationCardProps } from '@/components'; import { AlarmDetailModal, DeviceDetailModal, DeviceParamConfigModal, IcmpExportModal, RecordCheckExportModal, StationCard, type StationCardProps } from '@/components';
import { useBatchActions, useLineDevicesQuery } from '@/composables'; import { useBatchActions, useLineDevicesQuery } from '@/composables';
import { useAlarmStore, useDeviceStore, useSettingStore, useStationStore } from '@/stores'; import { useAlarmStore, useDeviceStore, usePermissionStore, useSettingStore } from '@/stores';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { objectEntries } from '@vueuse/core'; import { useElementSize } from '@vueuse/core';
import { isCancel } from 'axios'; import { isCancel } from 'axios';
import { NButton, NButtonGroup, NCheckbox, NFlex, NGrid, NGridItem, NScrollbar } from 'naive-ui'; import { NButton, NButtonGroup, NCheckbox, NFlex, NGrid, NGridItem, NScrollbar } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, ref } from 'vue'; import { computed, ref, useTemplateRef } from 'vue';
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { stationGridCols: stationGridColumns } = storeToRefs(settingStore); const { stationGridCols } = storeToRefs(settingStore);
const stationStore = useStationStore(); const permissionStore = usePermissionStore();
const { stations } = storeToRefs(stationStore); const stations = computed(() => permissionStore.stations.VIEW ?? []);
const deviceStore = useDeviceStore(); const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore); const { lineDevices } = storeToRefs(deviceStore);
@@ -22,12 +22,30 @@ const { lineDevices } = storeToRefs(deviceStore);
const alarmStore = useAlarmStore(); const alarmStore = useAlarmStore();
const { lineAlarms } = storeToRefs(alarmStore); const { lineAlarms } = storeToRefs(alarmStore);
const STATION_CARD_MIN_WIDTH = 230;
const STATION_GRID_PADDING = 8;
const STATION_GRID_GAP = 6;
const STATION_GRID_REF_NAME = 'stationGridRef';
const stationGridRef = useTemplateRef<HTMLDivElement>(STATION_GRID_REF_NAME);
const { width: stationGridWidth } = useElementSize(stationGridRef);
// 计算合适的车站布局列数
const actualStationGridColumns = computed(() => {
const currentStationCardWidth = (stationGridWidth.value - STATION_GRID_PADDING * 2 - (stationGridCols.value - 1) * STATION_GRID_GAP) / stationGridCols.value;
// 当卡片宽度大于最小宽度时,说明用户的设置没有问题,直接返回列数
if (currentStationCardWidth > STATION_CARD_MIN_WIDTH) return stationGridCols.value;
// 否则,说明用户的设置不合适,需要根据当前布局宽度重新计算列数
return Math.floor((stationGridWidth.value - STATION_GRID_PADDING * 2 + STATION_GRID_GAP) / STATION_CARD_MIN_WIDTH);
});
const showIcmpExportModal = ref(false); const showIcmpExportModal = ref(false);
const showRecordCheckExportModal = ref(false); const showRecordCheckExportModal = ref(false);
const abortController = ref(new AbortController()); const abortController = ref(new AbortController());
const { batchActions, selectedAction, selectableStations, stationSelection, toggleSelectAction, toggleSelectAllStations, confirmAction, cancelAction } = useBatchActions(stations, abortController); const { batchActions, selectedAction, selectableStations, stationSelection, selectionProps, toggleSelectAction, toggleSelectAllStations, confirmAction, cancelAction } = useBatchActions(
stations,
abortController,
);
const { refetch: refetchLineDevicesQuery } = useLineDevicesQuery(); const { refetch: refetchLineDevicesQuery } = useLineDevicesQuery();
@@ -63,7 +81,7 @@ const { mutate: syncCamera, isPending: cameraSyncing } = useMutation({
window.$notification.info({ window.$notification.info({
title: '摄像机同步结果', title: '摄像机同步结果',
content: notices.join(''), content: notices.join(''),
duration: 3000, duration: 10000,
}); });
if (successRequests.length > 0) { if (successRequests.length > 0) {
// 摄像机同步后,需要重新查询一次设备 // 摄像机同步后,需要重新查询一次设备
@@ -105,7 +123,7 @@ const { mutate: syncNvrChannels, isPending: nvrChannelsSyncing } = useMutation({
window.$notification.info({ window.$notification.info({
title: '录像机通道同步结果', title: '录像机通道同步结果',
content: notices.join(''), content: notices.join(''),
duration: 3000, duration: 10000,
}); });
cancelAction(); cancelAction();
}, },
@@ -155,26 +173,22 @@ const onClickDetail: StationCardProps['onClickDetail'] = (type, station) => {
<template> <template>
<NScrollbar content-style="padding-right: 8px" style="width: 100%; height: 100%"> <NScrollbar content-style="padding-right: 8px" style="width: 100%; height: 100%">
<!-- 工具栏 --> <!-- 工具栏 -->
<NFlex align="center" style="padding: 8px 8px 0 8px"> <NFlex align="center" :style="{ padding: `${STATION_GRID_PADDING}px ${STATION_GRID_PADDING}px 0 ${STATION_GRID_PADDING}px` }">
<NButtonGroup> <NButtonGroup>
<template v-for="batchAction in batchActions" :key="batchAction.key"> <template v-for="batchAction in batchActions" :key="batchAction.key">
<NButton :secondary="!batchAction.active" :focusable="false" @click="() => toggleSelectAction(batchAction)">{{ batchAction.label }}</NButton> <NButton :secondary="!batchAction.active" :focusable="false" @click="() => toggleSelectAction(batchAction)">{{ batchAction.label }}</NButton>
</template> </template>
</NButtonGroup> </NButtonGroup>
<template v-if="selectedAction"> <template v-if="selectedAction">
<NCheckbox <NCheckbox label="全选" :disabled="selectionProps.disabled" :checked="selectionProps.checked" :indeterminate="selectionProps.indeterminate" @update:checked="toggleSelectAllStations" />
label="全选"
:disabled="selectableStations.length === 0"
:checked="selectableStations.length > 0 && selectableStations.length === objectEntries(stationSelection).filter(([, selected]) => selected).length"
@update:checked="toggleSelectAllStations"
/>
<NButton tertiary size="small" type="primary" :focusable="false" :loading="confirming" @click="onClickConfirmAction">确定</NButton> <NButton tertiary size="small" type="primary" :focusable="false" :loading="confirming" @click="onClickConfirmAction">确定</NButton>
<NButton tertiary size="small" type="tertiary" :focusable="false" @click="cancelAction">取消</NButton> <NButton tertiary size="small" type="tertiary" :focusable="false" @click="cancelAction">取消</NButton>
</template> </template>
</NFlex> </NFlex>
<!-- 车站 --> <!-- 车站 -->
<NGrid :cols="stationGridColumns" :x-gap="6" :y-gap="6" style="padding: 8px"> <div :ref="STATION_GRID_REF_NAME">
<NGrid :cols="actualStationGridColumns" :x-gap="STATION_GRID_GAP" :y-gap="STATION_GRID_GAP" :style="{ padding: `${STATION_GRID_PADDING}px` }">
<NGridItem v-for="station in stations" :key="station.code"> <NGridItem v-for="station in stations" :key="station.code">
<StationCard <StationCard
:station="station" :station="station"
@@ -187,6 +201,7 @@ const onClickDetail: StationCardProps['onClickDetail'] = (type, station) => {
/> />
</NGridItem> </NGridItem>
</NGrid> </NGrid>
</div>
</NScrollbar> </NScrollbar>
<IcmpExportModal v-model:show="showIcmpExportModal" :stations="stations.filter((station) => stationSelection[station.code])" @after-leave="cancelAction" /> <IcmpExportModal v-model:show="showIcmpExportModal" :stations="stations.filter((station) => stationSelection[station.code])" @after-leave="cancelAction" />

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

@@ -47,9 +47,22 @@ const router = createRouter({
}, },
], ],
}, },
{
path: 'permission',
component: () => import('@/pages/permission/permission-page.vue'),
beforeEnter: () => {
const userStore = useUserStore();
if (userStore.isLamp) return true;
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,6 +1,6 @@
export * from './alarm'; export * from './alarm';
export * from './device'; export * from './device';
export * from './polling'; export * from './permission';
export * from './setting'; export * from './setting';
export * from './station'; export * from './station';
export * from './unread'; export * from './unread';

86
src/stores/permission.ts Normal file
View File

@@ -0,0 +1,86 @@
import type { NdmPermissionResultVO, Station } from '@/apis';
import { NDM_PERMISSION_STORE_ID } from '@/constants';
import { PERMISSION_TYPE_NAMES, type PermissionType } from '@/enums';
import { useSettingStore, useStationStore } from '@/stores';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { objectEntries } from '@vueuse/core';
type Permissions = Record<Station['code'], PermissionType[]>;
export const usePermissionStore = defineStore(
NDM_PERMISSION_STORE_ID,
() => {
const stationStore = useStationStore();
const settingStore = useSettingStore();
const permissionRecords = ref<NdmPermissionResultVO[] | null>(null);
const permissions = computed<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;
// 如果权限记录不存在,则不做权限配置
if (!records) return result;
// 如果该用户没有任何权限记录,则开放所有权限,否则根据记录配置权限
if (records.length === 0) {
stationStore.stations.forEach((station) => {
result[station.code] = [...objectEntries(PERMISSION_TYPE_NAMES).map(([permType]) => permType)];
});
} else {
stationStore.stations.forEach((station) => {
result[station.code] = [];
const stationPermRecords = records.filter((record) => record.stationCode === station.code);
if (stationPermRecords.length === 0) return;
stationPermRecords.forEach(({ type: permType }) => {
if (!permType) return;
result[station.code]?.push(permType);
});
});
}
return result;
});
// 按权限对车站进行分类
const stations = computed(() => {
const result: Partial<Record<PermissionType, Station[]>> = {};
// 按原始的车站顺序进行遍历,保持显示顺序不变
stationStore.stations.forEach((station) => {
const permissionTypes = permissions.value[station.code];
if (!permissionTypes) return;
permissionTypes.forEach((permissionType) => {
if (!result[permissionType]) result[permissionType] = [];
result[permissionType].push(station);
});
});
return result;
});
const setPermRecords = (records: NdmPermissionResultVO[]) => {
permissionRecords.value = records;
};
return {
permissionRecords,
permissions,
stations,
setPermRecords,
};
},
{
persist: true,
},
);

View File

@@ -1,36 +1,72 @@
import { NDM_SETTING_STORE_ID } from '@/constants'; import { useUserStore } from './user';
import { LINE_ALARMS_QUERY_KEY, LINE_DEVICES_QUERY_KEY, LINE_STATIONS_QUERY_KEY, NDM_SETTING_STORE_ID } from '@/constants';
import router from '@/router';
import { useQueryClient } from '@tanstack/vue-query';
import { darkTheme, lightTheme } from 'naive-ui'; import { darkTheme, lightTheme } from 'naive-ui';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useUserStore } from './user';
import router from '@/router';
export const useSettingStore = defineStore( export const useSettingStore = defineStore(
NDM_SETTING_STORE_ID, NDM_SETTING_STORE_ID,
() => { () => {
const darkThemeEnabled = ref(true); const queryClient = useQueryClient();
// 主题设置
const darkMode = ref(true);
const themeMode = computed(() => { const themeMode = computed(() => {
return darkThemeEnabled.value ? darkTheme : lightTheme; return darkMode.value ? darkTheme : lightTheme;
}); });
// 布局设置
const menuCollpased = ref(false); const menuCollpased = ref(false);
const stationGridCols = ref(6); const stationGridCols = ref(6);
const debugModeEnabled = ref(false); // 调试模式
const enableDebugMode = () => { const debugMode = ref(false);
debugModeEnabled.value = true; /* 数据设置 */
}; // 显示设备原始数据
const disableDebugMode = () => { const showDeviceRawData = ref(false);
debugModeEnabled.value = false; /* 网络设置 */
}; // 轮询车站
const pollingStations = ref(true);
// 主动请求
const activeRequests = ref(true);
// 订阅消息
const subscribeMessages = ref(true);
// 模拟用户
const mockUser = ref(false);
/* 数据库设置 */
// 使用本地数据库
const useLocalDB = ref(false);
// 离线开发模式 watch(debugMode, (newValue, oldValue) => {
// 控制 版本轮询 stomp连接 app-layout中的自动getUserInfo // 监听关闭调试模式
const offlineDev = ref(false); if (oldValue && !newValue) {
watch(offlineDev, (newValue, oldValue) => { showDeviceRawData.value = false;
// 如果启用离线开发模式且当前未登录 自动填写token以绕过路由守卫并跳过登录页 pollingStations.value = true;
activeRequests.value = true;
subscribeMessages.value = false;
mockUser.value = false;
useLocalDB.value = false;
}
});
watch(pollingStations, (newValue, oldValue) => {
// 监听关闭车站轮询
if (oldValue && !newValue) {
queryClient.cancelQueries({ queryKey: [LINE_STATIONS_QUERY_KEY] });
queryClient.cancelQueries({ queryKey: [LINE_DEVICES_QUERY_KEY] });
queryClient.cancelQueries({ queryKey: [LINE_ALARMS_QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [LINE_STATIONS_QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [LINE_DEVICES_QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [LINE_ALARMS_QUERY_KEY] });
}
});
watch(mockUser, (newValue, oldValue) => {
// 监听启用模拟用户
if (!oldValue && newValue) { if (!oldValue && newValue) {
// 如果启当前未登录填写token以绕过路由守卫
const userStore = useUserStore(); const userStore = useUserStore();
if (!userStore.userLoginResult) { if (!userStore.userLoginResult) {
userStore.userLoginResult = { userStore.userLoginResult = {
@@ -42,9 +78,11 @@ export const useSettingStore = defineStore(
expiration: '', expiration: '',
}; };
} }
// 如果token为空填写token
if (!userStore.userLoginResult.token) { if (!userStore.userLoginResult.token) {
userStore.userLoginResult.token = 'test'; userStore.userLoginResult.token = 'test';
} }
// 如果用户信息为空,填写用户信息
if (!userStore.userInfo) { if (!userStore.userInfo) {
userStore.userInfo = { userStore.userInfo = {
id: '2', id: '2',
@@ -55,35 +93,42 @@ export const useSettingStore = defineStore(
tenantId: '1', tenantId: '1',
}; };
} }
// 如果当前路由为登录页,跳转到首页
if (router.currentRoute.value.path === '/login') { if (router.currentRoute.value.path === '/login') {
router.push({ path: '/' }); router.push({ path: '/' });
} }
// 开启模拟用户时,也开启调试模式,但关闭其他的网络设置
debugMode.value = true;
pollingStations.value = false;
activeRequests.value = false;
subscribeMessages.value = false;
} }
}); });
return { return {
darkThemeEnabled, darkMode,
themeMode, themeMode,
menuCollpased, menuCollpased,
stationGridCols, stationGridCols,
debugModeEnabled, debugMode,
enableDebugMode, showDeviceRawData,
disableDebugMode, pollingStations,
activeRequests,
offlineDev, subscribeMessages,
mockUser,
useLocalDB,
}; };
}, },
{ {
persist: [ persist: [
{ {
omit: ['debugModeEnabled'], omit: ['showDeviceRawData'],
storage: window.localStorage, storage: window.localStorage,
}, },
{ {
pick: ['debugModeEnabled'], pick: ['showDeviceRawData'],
storage: window.sessionStorage, storage: window.sessionStorage,
}, },
], ],

View File

@@ -41,6 +41,79 @@ 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 line02ApiProxyList: ProxyItem[] = [
{ key: '/0275/api', target: 'http://10.14.128.10:18760', rewrite: ['/0275/api', '/api'] },
{ key: '/0202/api', target: 'http://10.14.129.10:18760', rewrite: ['/0202/api', '/api'] },
{ key: '/0203/api', target: 'http://10.14.131.10:18760', rewrite: ['/0203/api', '/api'] },
{ key: '/0204/api', target: 'http://10.14.133.10:18760', rewrite: ['/0204/api', '/api'] },
{ key: '/0205/api', target: 'http://10.14.135.10:18760', rewrite: ['/0205/api', '/api'] },
{ key: '/0206/api', target: 'http://10.14.137.10:18760', rewrite: ['/0206/api', '/api'] },
{ key: '/0207/api', target: 'http://10.14.139.10:18760', rewrite: ['/0207/api', '/api'] },
{ key: '/0208/api', target: 'http://10.14.141.10:18760', rewrite: ['/0208/api', '/api'] },
{ key: '/0209/api', target: 'http://10.14.143.10:18760', rewrite: ['/0209/api', '/api'] },
{ key: '/0210/api', target: 'http://10.14.145.10:18760', rewrite: ['/0210/api', '/api'] },
{ key: '/0211/api', target: 'http://10.14.147.10:18760', rewrite: ['/0211/api', '/api'] },
{ key: '/0212/api', target: 'http://10.14.149.10:18760', rewrite: ['/0212/api', '/api'] },
{ key: '/0213/api', target: 'http://10.14.151.10:18760', rewrite: ['/0213/api', '/api'] },
{ key: '/0214/api', target: 'http://10.14.153.10:18760', rewrite: ['/0214/api', '/api'] },
{ key: '/0215/api', target: 'http://10.14.155.10:18760', rewrite: ['/0215/api', '/api'] },
{ key: '/0216/api', target: 'http://10.14.157.10:18760', rewrite: ['/0216/api', '/api'] },
{ key: '/0217/api', target: 'http://10.14.159.10:18760', rewrite: ['/0217/api', '/api'] },
{ key: '/0224/api', target: 'http://10.14.161.10:18760', rewrite: ['/0224/api', '/api'] },
{ key: '/0225/api', target: 'http://10.14.163.10:18760', rewrite: ['/0225/api', '/api'] },
{ key: '/0226/api', target: 'http://10.14.165.10:18760', rewrite: ['/0226/api', '/api'] },
{ key: '/0227/api', target: 'http://10.14.167.10:18760', rewrite: ['/0227/api', '/api'] },
{ key: '/0228/api', target: 'http://10.14.169.10:18760', rewrite: ['/0228/api', '/api'] },
{ key: '/0229/api', target: 'http://10.14.171.10:18760', rewrite: ['/0229/api', '/api'] },
{ key: '/0230/api', target: 'http://10.14.173.10:18760', rewrite: ['/0230/api', '/api'] },
{ key: '/0231/api', target: 'http://10.14.175.10:18760', rewrite: ['/0231/api', '/api'] },
{ key: '/0232/api', target: 'http://10.14.177.10:18760', rewrite: ['/0232/api', '/api'] },
{ key: '/0233/api', target: 'http://10.14.179.10:18760', rewrite: ['/0233/api', '/api'] },
{ key: '/0234/api', target: 'http://10.14.181.10:18760', rewrite: ['/0234/api', '/api'] },
{ key: '/0235/api', target: 'http://10.14.183.10:18760', rewrite: ['/0235/api', '/api'] },
{ key: '/0236/api', target: 'http://10.14.185.10:18760', rewrite: ['/0236/api', '/api'] },
{ key: '/0237/api', target: 'http://10.14.187.10:18760', rewrite: ['/0237/api', '/api'] },
{ key: '/0238/api', target: 'http://10.14.191.10:18760', rewrite: ['/0238/api', '/api'] },
{ key: '/0280/api', target: 'http://10.14.244.10:18760', rewrite: ['/0280/api', '/api'] },
{ key: '/0281/api', target: 'http://10.14.248.10:18760', rewrite: ['/0281/api', '/api'] },
{ key: '/0282/api', target: 'http://10.14.252.10:18760', rewrite: ['/0282/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 +176,16 @@ 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.14.128.10:9000', rewrite: ['/minio', ''] },
// { key: '/api', target: 'http://10.14.128.10:18760' },
// { key: '/ws', target: 'ws://10.14.128.10:18103', ws: true },
...line02ApiProxyList,
// { 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 },