11
This commit is contained in:
1 parent
569ea04236
commit
7ed145bb14
37 files changed
+3191
-994
No files matched your search
@@ -0,0 +1,202 @@
|
||||
# 设备统计页数据口径差异分析
|
||||
|
||||
> 排查日期:2026-09-17 19:00–19:10(页面 `http://localhost:8888/#/base/data/index`,admin 账号实测)
|
||||
> 结论先行:**页面上的"设备总数 323"、"设备 (39)"、"通道 (461)"三个数字来自三个不同的统计口径,彼此本就不会相等**。323 = 相机通道 311 + 门禁设备 8 + 道闸设备 4(通道数与设备数混加);39 = 设备表全量设备;461 = 编码/出入口两类设备名下全部通道。此外还发现在线率整数化失真、设备在线状态为 NULL 的"三不管"记录、区域排行总数与在线/离线之和不等等问题,详见下文。
|
||||
|
||||
---
|
||||
|
||||
## 一、实测数据快照
|
||||
|
||||
### 1.1 页面展示值
|
||||
|
||||
| 位置 | 展示值 |
|
||||
|---|---|
|
||||
| KPI 设备总数 | **323 台** |
|
||||
| KPI 在线设备 / 离线设备 | 83 台 / 237 台 |
|
||||
| KPI 总体在线率 | 26.00% |
|
||||
| KPI 今日告警 / 累计 | 0 条 / 6,406 条 |
|
||||
| 饼图(设备分类占比) | 监控 311、门禁 8、闸道 4(合计 323,与 KPI 自洽) |
|
||||
| 柱图(分类运行状态) | 监控 78/230、门禁 4/4、闸道 1/3(在线/离线) |
|
||||
| 区域设备排行 TOP5 | 教学楼 69、南门 15、办公楼 12、三营 12、1连 11(合计 119) |
|
||||
| 明细表 Tab | **设备 (39)**、**通道 (461)** |
|
||||
| 在线率趋势(近 7 日) | 仅 5 个数据点(09-11~09-14、09-17),全部 26 |
|
||||
|
||||
### 1.2 接口原始值(浏览器内直接调用后端)
|
||||
|
||||
`GET /admin/total/plan/total-device`(KPI 与饼图来源):
|
||||
|
||||
| 分类 | total | online | offline | onlineRate |
|
||||
|---|---|---|---|---|
|
||||
| 监控设备 camera | 311 | 78 | 230 | 25 |
|
||||
| 门禁设备 door | 8 | 4 | 4 | 50 |
|
||||
| 闸道设备 gate | 4 | 1 | 3 | 25 |
|
||||
| 合计 total | 323 | 83 | 237 | 26 |
|
||||
|
||||
`POST /admin/deviceManage/page`(设备 Tab 来源):**total = 39**,分类分布:
|
||||
|
||||
| device_category | 台数 | 在线/离线/状态NULL |
|
||||
|---|---|---|
|
||||
| 1 编码设备 | 21 | 7 / 12 / **2(is_online 为 NULL)** |
|
||||
| 5 | 4 | 4 / 0 / 0 |
|
||||
| 8 门禁设备 | 8 | 4 / 4 / 0 |
|
||||
| 35 出入口(道闸) | 4 | 1 / 3 / 0 |
|
||||
| 93 | 1 | 0 / 1 / 0 |
|
||||
| 992 | 1 | 0 / 1 / 0 |
|
||||
|
||||
`POST /admin/deviceChannel/cameraPage`(通道 Tab 来源):**total = 461**(在线 139 / 离线 319)。
|
||||
|
||||
全量通道 `GET /admin/deviceChannel/page`:**total = 481**,按"所属设备分类 × 通道 unit_type"矩阵:
|
||||
|
||||
| 设备分类 \ unit_type | 1(相机) | 3 | 4 | 7 | 14 | 16 | 小计 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 编码设备(21 台) | 295 | 120 | 34 | – | – | – | 449 |
|
||||
| 5(4 台) | 4 | – | – | – | – | – | 4 |
|
||||
| 8 门禁(8 台) | 8 | – | – | 8 | – | – | 16 |
|
||||
| 35 道闸(4 台) | 4 | – | – | – | 4 | 4 | 12 |
|
||||
| 93 / 992 | – | – | – | – | – | – | 0 |
|
||||
| **合计** | **311** | 120 | 34 | 8 | 4 | 4 | **481** |
|
||||
|
||||
---
|
||||
|
||||
## 二、为什么对不上:逐条解释
|
||||
|
||||
### 2.1 「设备总数 323」vs「设备 (39)」——统计对象完全不同
|
||||
|
||||
323 的构成(`TotalServiceImpl.totalDevice()`,jhkj-upms-biz `service/impl/total/TotalServiceImpl.java:168-253`):
|
||||
|
||||
```
|
||||
323 = 311("监控设备")+ 8(门禁设备)+ 4(道闸设备)
|
||||
```
|
||||
|
||||
关键在于 **311 不是设备数,是通道数**。两条统计 SQL(`DeviceManageMapper.xml`):
|
||||
|
||||
- 门禁/道闸走 `countByType`(xml:108):按 `device_manage.device_category`(8/35)分组数**设备**;
|
||||
- 监控走 `countChannelByUnitType`(xml:137):`device_channel inner join device_manage`,按 **`device_channel.unit_type = '1'`** 分组数**通道**,且**不限制设备分类**。
|
||||
|
||||
矩阵证实:311 = 编码设备的 295 条 + 分类5 设备的 4 条 + **门禁设备的 8 条** + **道闸设备的 4 条** unit_type=1 通道。
|
||||
|
||||
所以"设备总数"实际是「全库相机类通道数 + 门禁设备数 + 道闸设备数」的混合求和,并且**门禁/道闸设备的相机通道同时被计入"监控 311"和各自的设备数(8/4),存在交叉重复**。
|
||||
|
||||
而设备 Tab 的 39 是 `deviceManage/page` 对 `device_manage` 表全量分页(whereA 仅 `del_flag='0'`,`DeviceManageMapper.xml:33`),覆盖 6 种分类,其中**分类 5(4 台)、93、992 根本不在 KPI 统计范围内**。两个数字从定义上就不可能相等。
|
||||
|
||||
### 2.2 「监控 311」vs「通道 (461)」——两个交叉的通道集合
|
||||
|
||||
- 通道 Tab(`deviceChannel/cameraPage`)= `deviceCategoryList = ["1","35"]` 的设备名下**全部** 481 通道中的 461 条(449+12,`DhConstant.CAMERA_CATEGORY_LIST`,Controller 强制注入);
|
||||
- KPI 监控 = 全部分类设备的 **unit_type=1** 通道 311 条。
|
||||
|
||||
两集合的交集是 299(编码设备的 295 + 道闸设备的 4):通道 Tab 里多出 162 条非相机通道(unit_type 3/4/14/16),监控 311 里多出 12 条属于门禁(8)/分类5(4)设备的相机通道——这些设备的通道**不会出现在通道 Tab**(cameraPage 限定 1/35 分类设备)。
|
||||
|
||||
在线数同理:KPI 在线 83 中的 78 是"unit_type=1 通道中所属设备在线的条数";通道 Tab 在线 139 是"1/35 分类设备的通道中所属设备在线的条数"。集合不同,数字自然不同。
|
||||
|
||||
### 2.3 下钻点击数字与结果不一致——口径差异的直接体验
|
||||
|
||||
- 点击 KPI「在线设备 83」→ 设备 Tab 筛 `isOnline='1'` → 实际命中 **16 台**设备(7+4+4+1):83 里混着 78 条通道计数;
|
||||
- 点击饼图「监控设备 311」→ 设备 Tab 筛 `deviceCategory='1'` → 实际命中 **21 台**设备:311 是通道数、21 是设备数;
|
||||
- 点击 KPI「设备总数 323」→ 清空筛选 → **39 台**:323 本身是混合口径。
|
||||
|
||||
这是前端下钻功能"正确执行了筛选",但卡片数字与列表口径不匹配造成的困惑。
|
||||
|
||||
### 2.4 在线/离线之和 ≠ 总数——`is_online` 为 NULL 的设备
|
||||
|
||||
`countChannelByUnitType` 中 total=`count(*)`,online/offline=`sum(if(is_online='0'/'1'))`。当设备 `is_online` 为 NULL 时既不进 online 也不进 offline:
|
||||
|
||||
- 监控:78+230=308 ≠ 311,差 3 条(属于 2 台 is_online=NULL 的编码设备);
|
||||
- 通道 Tab:139+319=458 ≠ 461,同样差 3 条;
|
||||
- 设备表实测有 2 台分类 1 设备 `is_online=NULL`;设备明细表在线状态列把 NULL 渲染为"离线"(前端 `String(row.isOnline)==='1'` 判断),与统计口径又差一层。
|
||||
|
||||
### 2.5 在线率 26.00% 失真——先除后乘的精度丢失
|
||||
|
||||
`TotalServiceImpl.java:244`(分类内 181/194/212 同):
|
||||
|
||||
```java
|
||||
online.divide(total, 2, HALF_UP).multiply(100) // 83/323=0.2570 → 取2位=0.26 → 26.00
|
||||
```
|
||||
|
||||
真实值 83/323 = **25.70%**,因先保留 2 位小数再乘 100,所有在线率被整数化(监控 25.08%→25%、门禁 50%、道闸 25%)。趋势图 26% 同源。
|
||||
|
||||
### 2.6 区域排行总数 ≠ 在线 + 离线,且与其他数字都不可比
|
||||
|
||||
`cameraAreaTotalDevice`(`DeviceManageMapper.xml:152`):
|
||||
|
||||
- `totalCount = count(area_house_device.id)` —— 数的是**安装点位记录**,安装记录若未关联到有效通道/设备(left join 链断裂),只计入总数:教学楼 69 vs 41+4=45、南门 15 vs 0+11=11;
|
||||
- 同一通道安装到多个楼栋会重复计数;
|
||||
- 只统计"已安装到区域房屋"的通道:TOP5 合计 119,与 461/481/311 均无对齐关系;未安装通道(明细里大量"未安装")完全不进区域统计。
|
||||
|
||||
### 2.7 在线率趋势缺点
|
||||
|
||||
近 7 日只有 5 个点(09-15、09-16 缺失)。趋势来自 `device_online` 快照表(`deviceOfflineRate → deviceOnlineMapper.countByTime`),这两天无快照记录,前端未补点。
|
||||
|
||||
### 2.8 告警数据自洽(非口径问题)
|
||||
|
||||
今日 0、近 7 日趋势全 0、最新 6 条告警时间为 2026-08-31(云存储设备)——累计 6406 条均为历史数据,当日无新增,三个数字相互一致,无问题。
|
||||
|
||||
---
|
||||
|
||||
## 三、口径速查表
|
||||
|
||||
| 页面指标 | 来源接口 | 统计对象 | 实测值 |
|
||||
|---|---|---|---|
|
||||
| KPI 设备总数 | `GET /total/plan/total-device` | unit_type=1 通道(311) + 分类8设备(8) + 分类35设备(4) | 323 |
|
||||
| KPI 在线/离线 | 同上求和 | 混合口径,NULL 状态不计入 | 83 / 237 |
|
||||
| KPI 在线率 | 同上计算 | 先除(保留2位)后乘100,整数化 | 26(实际25.70) |
|
||||
| 饼图·监控 | total-device.camera | 全部分类设备的 unit_type=1 通道 | 311 |
|
||||
| 饼图·门禁/道闸 | total-device.door/gate | 分类 8 / 35 设备 | 8 / 4 |
|
||||
| 设备 Tab | `POST /deviceManage/page` | device_manage 全量(6 种分类) | 39 |
|
||||
| 通道 Tab | `POST /deviceChannel/cameraPage` | 分类 1/35 设备的全部通道 | 461 |
|
||||
| 区域排行 | `GET /total/plan/camera-area-total-device` | area_house_device 安装记录(可重复) | TOP5 合计 119 |
|
||||
| 在线率趋势 | `POST /total/plan/device-offline-rate` | device_online 按日快照 | 5 点,均 26 |
|
||||
|
||||
---
|
||||
|
||||
## 四、对齐建议(未实施,供决策)
|
||||
|
||||
1. **统一"设备"口径(根本解法)**:`totalDevice()` 的监控改为按 `device_category='1'` 数设备(与门禁/道闸一致),需要通道规模时单独提供"监控通道 N 路"指标;这样 KPI 三分类相加 = 设备 Tab 中分类 1/8/35 的设备数,下钻点击数字与结果一致。
|
||||
2. **明确未覆盖分类**:分类 5/93/992 要么纳入统计(并入总数或单列"其他"),要么在页面注明"统计范围为监控/门禁/道闸三类"。
|
||||
3. **消除交叉重复**:监控通道统计限定设备分类(如仅编码设备),避免门禁/道闸设备的相机通道同时计入两个分类。
|
||||
4. **修正在线率精度**:改为 `online.multiply(100).divide(total, 2, HALF_UP)`(先乘后除)。
|
||||
5. **处理 `is_online` NULL**:统计 SQL 与列表渲染统一口径(NULL 计入离线或单列"未知",二选一)。
|
||||
6. **区域排行**:`count(distinct 有效通道)` 替代 `count(hd.id)`,并过滤 join 断裂记录,使 总数 = 在线 + 离线。
|
||||
7. **趋势补点**:前端对缺失日期补 0 或空值断点提示,后端可补齐每日快照。
|
||||
|
||||
---
|
||||
|
||||
## 五、修复记录(2026-09-17 已实施并验证)
|
||||
|
||||
按第四节建议完成统一,实测页面数据:
|
||||
|
||||
| 指标 | 修复前 | 修复后 |
|
||||
|---|---|---|
|
||||
| KPI 设备总数 | 323(通道+设备混加) | **39**(= 设备 Tab 总数) |
|
||||
| 在线/离线 | 83/237(含 3 条 NULL 不计) | **16/23**(NULL 归离线,16+23=39) |
|
||||
| 总体在线率 | 26.00%(整数化失真) | **41.03%**(先乘后除) |
|
||||
| 饼图 | 监控311(通道口径) | 监控21/门禁8/道闸4/**其他6**,新增"监控通道 295 路"副指标 |
|
||||
| 区域排行 | 教学楼 69(重复计数) | 教学楼 28,每行 总=在线+离线 |
|
||||
| 趋势图 | 缺 09-15/16 两点 | 按日期序列补空点,缺失日断点显示 |
|
||||
|
||||
改动清单:后端 `TotalServiceImpl`(设备口径+calcOnlineRate 先乘后除)、`TotalDeviceVo`(+other/channel)、`DeviceOnlineServiceImpl`(快照总量含其他分类)、`DeviceManageMapper.xml`(NULL 归离线、区域排行 count(distinct channel) 重写)、`DeviceCameraAreaCountVo`(+areaId);前端 `deviceStatistics.ts`(类型)、`base/data/index.vue`(饼图含其他/监控通道副指标/趋势补点)。
|
||||
|
||||
已知事项:
|
||||
- 历史趋势快照仍是旧口径(25.7%),自修复后每日快照起写入新口径(41.03%),趋势图会出现一次台阶,属预期;
|
||||
- "其他设备"饼图扇区无下钻(聚合分类无单一 deviceCategory 值),点击不生效属预期。
|
||||
|
||||
补充优化(同日):
|
||||
- 趋势图"今天"的数据点以实时统计为准(快照每日定时写入、按天覆盖),避免 header 实时值与图上当天快照不一致;摘要改用"当前在线率";
|
||||
- 分类运行状态的 tooltip 与摘要文案补充各类在线率(监控/门禁/道闸/其他);
|
||||
- 手动触发过一次 `GET /device-online/task/device-onlone-total` 将当天旧口径快照(25.7%)覆盖为新口径(41.03%)。
|
||||
|
||||
快照刷新机制调整(同日晚,替代最初误加的 upms 内 @Scheduled 方案——**全工程调度统一在 jhkj-daemon-quartz 微服务,不得在业务服务里再加定时任务**):
|
||||
- 新增 `DeviceOnlineService.refreshTodaySnapshotQuietly()`(静默吞异常版 `taskDeviceOnloneTotal`);
|
||||
- 在 `ThirdDhDeviceServiceImpl` 的设备/通道变更入口尾部触发:`syncDhDevice`、`syncDhDeviceChannel`、`delDevice`、`syncPlatform`、`deleteLocalExtraDevices`、`syncMissingChannels`、`deleteLocalExtraChannels`(`syncMissingDevices` 内部经 `syncDhDeviceChannel` 已覆盖);
|
||||
- 大华"设备管理 crud"事件回调(deviceManageCrudCallback)路径全部经由上述入口,天然覆盖——即设备/通道同步或事件发生时,当天快照随之刷新,趋势"当天"点与实时偏差缩小到"距上次同步";外部调度漏跑导致断点的风险仅剩"当天完全无同步且无事件";
|
||||
- 前端趋势折线 `connectNulls: true`:历史缺失日视觉连续(悬停仍提示"无快照")。
|
||||
|
||||
## 附:关键代码位置
|
||||
|
||||
| 逻辑 | 文件 |
|
||||
|---|---|
|
||||
| 设备总览统计 | `jh-military-service/jhkj-upms/jhkj-upms-biz/.../service/impl/total/TotalServiceImpl.java:168-253` |
|
||||
| 分类设备统计 SQL | `.../resources/mapper/device/DeviceManageMapper.xml` `countByType`(108)、`countChannelByUnitType`(137) |
|
||||
| 区域统计 SQL | 同上 `cameraAreaTotalDevice`(152) |
|
||||
| 设备分页 | 同上 `pageList`(73)/`whereA`(33);Controller `DeviceManageController.java:67-71` |
|
||||
| 通道分页(Tab) | `DeviceChannelController.java:90-97`(强制 `CAMERA_CATEGORY_LIST=["1","35"]`,`DhConstant.java:7`) |
|
||||
| 在线率趋势 | `TotalServiceImpl.deviceOfflineRate`(259) → `deviceOnlineMapper.countByTime` |
|
||||
@@ -13,13 +13,15 @@ export interface DeviceStatusDetail {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备总览统计,分别包含总量、监控、门禁和道闸数据。
|
||||
* 设备总览统计,分别包含总量、监控、门禁、道闸、其他分类和监控通道数据。
|
||||
*/
|
||||
export interface DeviceTotalData {
|
||||
total: DeviceStatusDetail;
|
||||
camera: DeviceStatusDetail;
|
||||
door: DeviceStatusDetail;
|
||||
gate: DeviceStatusDetail;
|
||||
other?: DeviceStatusDetail;
|
||||
channel?: DeviceStatusDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,7 @@ export interface DeviceOnlineTrendItem {
|
||||
* 区域设备数量统计。
|
||||
*/
|
||||
export interface AreaDeviceStatisticsItem {
|
||||
areaId?: string;
|
||||
areaName: string;
|
||||
totalCount: number | string;
|
||||
onlineCount: number | string;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="access-direction-legend" role="group" aria-label="通行方向行底色说明">
|
||||
<span class="access-direction-legend__title">通行方向</span>
|
||||
<span class="access-direction-legend__item access-direction-legend__item--in">
|
||||
<span class="access-direction-legend__swatch" aria-hidden="true" />进入
|
||||
</span>
|
||||
<span class="access-direction-legend__item access-direction-legend__item--out">
|
||||
<span class="access-direction-legend__swatch" aria-hidden="true" />外出
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<span class="access-direction-tag" :class="`access-direction-tag--${direction}`">
|
||||
<svg v-if="direction !== 'neutral'" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path v-if="direction === 'in'" d="M12 4 4 12M4 5v7h7" />
|
||||
<path v-else d="m4 12 8-8M5 4h7v7" />
|
||||
</svg>
|
||||
{{ directionLabel }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getAccessDirection, type AccessRecordKind } from '/@/utils/accessDirection';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
kind: AccessRecordKind;
|
||||
value?: string | number | null;
|
||||
label?: string;
|
||||
options?: { value: string | number; label: string }[];
|
||||
}>(),
|
||||
{ options: () => [] }
|
||||
);
|
||||
|
||||
const direction = computed(() => getAccessDirection(props.value, props.kind));
|
||||
const directionLabel = computed(() => {
|
||||
const option = props.options.find((item) => String(item.value) === String(props.value));
|
||||
if (props.label || option?.label) return props.label || option?.label;
|
||||
if (direction.value === 'in') return props.kind === 'door' ? '进门' : '进入';
|
||||
if (direction.value === 'out') return props.kind === 'door' ? '出门' : '外出';
|
||||
return props.kind === 'door' && String(props.value) === '3' ? '进出门' : '未知方向';
|
||||
});
|
||||
</script>
|
||||
@@ -418,6 +418,7 @@
|
||||
let _isSupport = isSupport()
|
||||
for (let key in window.dhPlayerControl.videoList) {
|
||||
let currentThis = window.dhPlayerControl.videoList[key]
|
||||
if (currentThis.disposed) continue
|
||||
currentThis.send({
|
||||
method: 'common.version',
|
||||
info: {},
|
||||
@@ -430,12 +431,15 @@
|
||||
let interval = setInterval(() => {
|
||||
if (window.dhPlayerControl.loginFlag !== "LOGIN_PENDING") {
|
||||
clearInterval(interval)
|
||||
currentThis.loginWaitTimer = null
|
||||
resolve()
|
||||
}
|
||||
|
||||
}, 300)
|
||||
currentThis.loginWaitTimer = interval
|
||||
})
|
||||
p.then(() => {
|
||||
if (currentThis.disposed) return
|
||||
currentThis.create()
|
||||
window.isResetConnect = currentThis.setting.isResetConnect
|
||||
})
|
||||
@@ -1403,6 +1407,8 @@
|
||||
},
|
||||
//发送消息
|
||||
send: function (option, callBack) {
|
||||
// dispose 后只允许销毁命令,拦住晚到的播放、重连和位置刷新回调。
|
||||
if (this.disposed && option.method !== 'window.destroy') return
|
||||
option.session = window.dhPlayerControl.wsSession
|
||||
option.id = window.dhPlayerControl.wsConnectCount++
|
||||
if (option.info) {
|
||||
@@ -1425,6 +1431,7 @@
|
||||
|
||||
// 创建视频窗口
|
||||
create: function () {
|
||||
if (this.disposed) return
|
||||
var zoom = detectZoom()
|
||||
var rect = getRect.call(this)
|
||||
for (let i in rect) {
|
||||
@@ -1447,6 +1454,7 @@
|
||||
var that = this
|
||||
this.setLanguage() // 设置语言
|
||||
this.destroy().then(() => {
|
||||
if (this.disposed) return
|
||||
this.send(
|
||||
{
|
||||
method: 'window.create',
|
||||
@@ -1454,11 +1462,18 @@
|
||||
},
|
||||
{
|
||||
onSuccess: function (data) {
|
||||
that.status = "created"
|
||||
let dom = document.querySelector(`#${that.setting.videoId}`)
|
||||
if (!dom) {
|
||||
// 创建回包可能晚于卸载;此时不能丢弃句柄,也不能覆盖同 ID 的新实例。
|
||||
if (that.disposed || !dom || window.dhPlayerControl.videoList[that.setting.videoId] !== that) {
|
||||
if (data.data && typeof data.data.hwnd === 'number') {
|
||||
that.send({
|
||||
method: 'window.destroy',
|
||||
info: { hwnd: data.data.hwnd, isRefresh: false }
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
that.status = "created"
|
||||
if (data.data && typeof data.data.hwnd === 'number') {
|
||||
var hwnd = data.data.hwnd
|
||||
console.log(`${[0, 2].includes(that.setting.windowType) ? '实时预览窗口' : '录像回放窗口'}创建成功 hwnd: ${hwnd}\n插件版本: ${window.dhPlayerControl.DHPlayerVersion}`);
|
||||
@@ -1502,12 +1517,13 @@
|
||||
}
|
||||
},
|
||||
onError: function () {
|
||||
if (that.disposed) return
|
||||
// 断开连接
|
||||
socketClose();
|
||||
// 重连
|
||||
let socketTimer = setTimeout(() => {
|
||||
socketOpen();
|
||||
clearTimeout(socketTimer)
|
||||
that.reconnectTimer = setTimeout(() => {
|
||||
if (!that.disposed) socketOpen();
|
||||
clearTimeout(that.reconnectTimer)
|
||||
}, 3000)
|
||||
// that.setting.createError && that.setting.createError({
|
||||
// code: 1006,
|
||||
@@ -1572,6 +1588,7 @@
|
||||
},
|
||||
//刷新窗口位置
|
||||
handleAdjust: function () {
|
||||
if (this.disposed) return
|
||||
var _this = this
|
||||
// 每帧执行一次,减少DHplayer延时
|
||||
!this.setting.stopRefresh && this.changePosition()
|
||||
@@ -1640,6 +1657,44 @@
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 终止当前实例,用于组件卸载。destroy 仍供 create 内部重建使用。
|
||||
* 直接发送当前句柄的销毁命令,不等待原生回包、不关闭其他播放器共享的 WebSocket。
|
||||
*/
|
||||
dispose: function () {
|
||||
if (this.disposed) return
|
||||
const hwnd = this.setting.hwnd
|
||||
try {
|
||||
this.hide()
|
||||
} finally {
|
||||
this.disposed = true
|
||||
this.status = 'disposed'
|
||||
this.setting.stopRefresh = true
|
||||
clearTimeout(this.setting.socketTimer)
|
||||
clearTimeout(this.reconnectTimer)
|
||||
clearTimeout(this.setting.notifyrealmonitorTimeout)
|
||||
clearInterval(this.loginWaitTimer)
|
||||
window.cancelAnimationFrame(this.setting.refreshTimer)
|
||||
document.removeEventListener('click', this.setTopBind)
|
||||
window.removeEventListener('beforeunload', this.onbeforeunloadBind)
|
||||
document.removeEventListener('visibilitychange', this.visibilitychangeBind, true)
|
||||
try {
|
||||
if (typeof hwnd === 'number') {
|
||||
this.send({ method: 'window.destroy', info: { hwnd, isRefresh: false } })
|
||||
}
|
||||
} finally {
|
||||
if (window.dhPlayerControl.videoList[this.setting.videoId] === this) {
|
||||
delete window.dhPlayerControl.videoList[this.setting.videoId]
|
||||
}
|
||||
if (window.dhPlayerControl.hwndList[hwnd] === this.setting.videoId) {
|
||||
delete window.dhPlayerControl.hwndList[hwnd]
|
||||
}
|
||||
this.setting.hwnd = ''
|
||||
this.setting.channelList = []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 终止进程
|
||||
killDHPlayer: function () {
|
||||
this.send({
|
||||
@@ -2346,6 +2401,7 @@
|
||||
},
|
||||
//显示视频
|
||||
show: function () {
|
||||
if (this.disposed || this.setting.visible === false) return
|
||||
var that = this
|
||||
if (typeof this.setting.hwnd !== 'number' || this.setting.show) {
|
||||
return
|
||||
@@ -2777,6 +2833,7 @@
|
||||
}
|
||||
},
|
||||
initPlayer: function () {
|
||||
if (this.disposed) return
|
||||
let hwnd = ''
|
||||
if (window.dhPlayerControl.videoList[this.setting.videoId]) {
|
||||
hwnd = window.dhPlayerControl.videoList[this.setting.videoId].setting.hwnd
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="video-container" :class="{ 'preview-mode': currentWindowType === 0 }">
|
||||
<div v-if="loading" class="loading-mask">正在初始化...</div>
|
||||
<div id="video-plugin-preview-0" class="pane" />
|
||||
<div id="video-plugin-preview-3" class="pane" />
|
||||
<div id="video-plugin-preview-7" class="pane" />
|
||||
<div :id="getContainerId(0)" class="pane" />
|
||||
<div :id="getContainerId(3)" class="pane" />
|
||||
<div :id="getContainerId(7)" class="pane" />
|
||||
<div v-if="pageTotal > 1 && showBar && currentWindowType != 0" style="width: 100%; height: 40px;"></div>
|
||||
<div v-if="pageTotal > 1 && showBar" :style="videoPageStyle" class="video-container-page">
|
||||
<el-pagination class="video-pager" :current-page="pageCurrent" :page-size="pageSize" :total="pageTotal"
|
||||
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, computed, defineEmits } from "vue"
|
||||
import { onMounted, onBeforeUnmount, onActivated, onDeactivated, ref, reactive, computed, watch, nextTick, getCurrentInstance, defineAsyncComponent } from "vue"
|
||||
import { ElMessage } from "element-plus";
|
||||
import dayjs from "dayjs"
|
||||
|
||||
@@ -34,16 +34,21 @@ const TAG = "|视频预览|"
|
||||
|
||||
let VIDEO_PLAYER = null
|
||||
const currentWindowType = ref(null)
|
||||
let isReady = false
|
||||
/** @type {Array<() => void>} */
|
||||
let readyQueue = []
|
||||
const instanceId = getCurrentInstance().uid
|
||||
let generation = 0
|
||||
let isUnmounted = false
|
||||
let isActivated = true
|
||||
let requestedVisible = true
|
||||
let lastRealRequest = null
|
||||
let lastPlaybackRequest = null
|
||||
const realFallbackAttempted = new Set()
|
||||
const players = new Map()
|
||||
const isReadyByType = new Map()
|
||||
const readyWaitersByType = new Map()
|
||||
const initPromisesByType = new Map()
|
||||
const initResolversByType = new Map()
|
||||
const readyTimers = new Set()
|
||||
const closingPlayers = new Map()
|
||||
let pendingClose = Promise.resolve()
|
||||
let latestRealToken = 0
|
||||
let latestPlaybackToken = 0
|
||||
const loading = ref(false)
|
||||
@@ -105,20 +110,12 @@ function onCurrentChange(val) {
|
||||
})
|
||||
}
|
||||
|
||||
function waitForReady(type = props.windowType) {
|
||||
return new Promise((resolve) => {
|
||||
if (isReadyByType.get(type)) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const arr = readyWaitersByType.get(type) || []
|
||||
arr.push(resolve)
|
||||
readyWaitersByType.set(type, arr)
|
||||
})
|
||||
function canUsePlayer() {
|
||||
return !isUnmounted && isActivated && props.active
|
||||
}
|
||||
|
||||
function getContainerId(type) {
|
||||
return `video-plugin-preview-${type}`
|
||||
return `video-plugin-${instanceId}-${type}`
|
||||
}
|
||||
|
||||
function getDivisionByCount(count) {
|
||||
@@ -145,14 +142,14 @@ function clampByDivision(list) {
|
||||
return list
|
||||
}
|
||||
|
||||
function applyDivisionByCount(count) {
|
||||
function applyDivisionByCount(count, player = VIDEO_PLAYER) {
|
||||
if (!props.autoApplyDivision) return
|
||||
if (!VIDEO_PLAYER?.changeDivision) return
|
||||
if (!player?.changeDivision) return
|
||||
const nextDivision = getDivisionByCount(count)
|
||||
const currentDivision = Number(VIDEO_PLAYER?.setting?.division)
|
||||
const currentDivision = Number(player?.setting?.division)
|
||||
pageSize.value = nextDivision
|
||||
if (currentDivision === nextDivision) return
|
||||
VIDEO_PLAYER.changeDivision(nextDivision)
|
||||
player.changeDivision(nextDivision)
|
||||
}
|
||||
|
||||
function getRealFallbackKey(info) {
|
||||
@@ -193,20 +190,29 @@ function buildPlaybackSignature(list) {
|
||||
}).join(";")
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空就绪队列并执行
|
||||
*/
|
||||
function flushReadyQueue() {
|
||||
const tasks = readyQueue.splice(0)
|
||||
tasks.forEach((fn) => {
|
||||
try {
|
||||
fn()
|
||||
} catch (e) {
|
||||
console.log(TAG, "就绪队列任务执行失败,err:", e)
|
||||
}
|
||||
function activatePlayer(type, player) {
|
||||
VIDEO_PLAYER = player
|
||||
currentWindowType.value = type
|
||||
setActiveContainer(type)
|
||||
players.forEach((item, itemType) => {
|
||||
const visible = itemType === type && canUsePlayer() && requestedVisible && !document.hidden
|
||||
if (item.setting) item.setting.visible = visible
|
||||
if (visible) item.show?.()
|
||||
else item.hide?.()
|
||||
})
|
||||
}
|
||||
|
||||
function disposePlayer(player) {
|
||||
if (!player) return
|
||||
try { player.hide?.() } catch (e) { console.log(TAG, "隐藏播放器失败,err:", e) }
|
||||
try {
|
||||
const result = player.dispose ? player.dispose() : player.destroy?.()
|
||||
Promise.resolve(result).catch((e) => console.log(TAG, "销毁播放器失败,err:", e))
|
||||
} catch (e) {
|
||||
console.log(TAG, "销毁播放器失败,err:", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录信息
|
||||
* @typedef {Object} LoginInfo
|
||||
@@ -245,6 +251,8 @@ function flushReadyQueue() {
|
||||
* - 提供登录信息与播放器基础参数,支持父组件通过 ref 调用预览与回放方法
|
||||
*/
|
||||
const props = defineProps({
|
||||
/** 弹窗在离场动画结束前就会停用播放器,阻止晚到的查询重新开流。 */
|
||||
active: { type: Boolean, default: true },
|
||||
/** 是否展示云台 */
|
||||
visibleYt: { type: Boolean, default: true },
|
||||
/** 播放器类型:0-实时预览,3-录像回放,7-录像回放(支持倒放) */
|
||||
@@ -269,38 +277,56 @@ const props = defineProps({
|
||||
* 初始化播放器
|
||||
* @param {0|3|7} [type=props.windowType] 播放器类型(0预览/3回放/7回放支持倒放)
|
||||
*/
|
||||
async function initVideoPlayer(type = props.windowType) {
|
||||
if (players.has(type)) {
|
||||
VIDEO_PLAYER = players.get(type)
|
||||
currentWindowType.value = type
|
||||
isReady = !!isReadyByType.get(type)
|
||||
setActiveContainer(type)
|
||||
if (isReady) flushReadyQueue()
|
||||
return
|
||||
function initVideoPlayer(type = props.windowType) {
|
||||
if (!canUsePlayer()) return Promise.resolve(null)
|
||||
currentWindowType.value = type
|
||||
setActiveContainer(type)
|
||||
players.forEach((item, itemType) => {
|
||||
if (itemType !== type) {
|
||||
if (item.setting) item.setting.visible = false
|
||||
item.hide?.()
|
||||
}
|
||||
})
|
||||
if (players.has(type) && isReadyByType.get(type)) {
|
||||
const player = players.get(type)
|
||||
activatePlayer(type, player)
|
||||
return Promise.resolve(player)
|
||||
}
|
||||
if (initPromisesByType.get(type)) {
|
||||
return initPromisesByType.get(type)
|
||||
}
|
||||
const myGeneration = generation
|
||||
let player = null
|
||||
let failed = false
|
||||
let resolveInit
|
||||
const pending = new Promise((resolve) => { resolveInit = resolve })
|
||||
// 必须在查询配置之前占位,避免同时调用时创建出多个原生窗口。
|
||||
initPromisesByType.set(type, pending)
|
||||
const settle = (value) => {
|
||||
if (initPromisesByType.get(type) === pending) {
|
||||
initPromisesByType.delete(type)
|
||||
initResolversByType.delete(type)
|
||||
}
|
||||
resolveInit(value)
|
||||
}
|
||||
initResolversByType.set(type, settle)
|
||||
const isCurrent = () => canUsePlayer() && generation === myGeneration && !failed
|
||||
loading.value = true
|
||||
// 查询配置
|
||||
const { data: setting, code: settingCode } = await settingApi.getObj()
|
||||
if (settingCode != 0) {
|
||||
ElMessage.error("查询配置失败")
|
||||
loading.value = false
|
||||
return Promise.resolve()
|
||||
}
|
||||
const dhIccSetting = setting.dhIccSetting
|
||||
try {
|
||||
// 若已有缓存实例,直接复用并切换容器
|
||||
} catch (e) {
|
||||
console.log(TAG, "旧播放器销毁失败,err:", e)
|
||||
}
|
||||
isReady = false
|
||||
isReadyByType.set(type, false)
|
||||
setActiveContainer(type)
|
||||
const p = new Promise((resolve) => {
|
||||
VIDEO_PLAYER = new window.VideoPlayer({
|
||||
const initialize = async () => {
|
||||
const { data: setting, code: settingCode } = await settingApi.getObj()
|
||||
if (!isCurrent()) return settle(null)
|
||||
if (settingCode != 0 || !setting?.dhIccSetting) {
|
||||
ElMessage.error("查询配置失败")
|
||||
loading.value = false
|
||||
return settle(null)
|
||||
}
|
||||
const dhIccSetting = setting.dhIccSetting
|
||||
await nextTick()
|
||||
if (!isCurrent()) return settle(null)
|
||||
isReadyByType.set(type, false)
|
||||
player = new window.VideoPlayer({
|
||||
videoId: getContainerId(type),
|
||||
visible: currentWindowType.value === type && requestedVisible && !document.hidden,
|
||||
// 播放器类型:0-实时预览,3-录像回放,7-录像回放(支持倒放)
|
||||
windowType: type,
|
||||
// zh-中文 en-英文
|
||||
@@ -333,31 +359,34 @@ async function initVideoPlayer(type = props.windowType) {
|
||||
parentIframeShieldClass: [],
|
||||
// 创建播放器成功回调
|
||||
createSuccess: (versionInfo) => {
|
||||
if (!isCurrent()) return
|
||||
// 该回调触发后,我们可以进行实时预览/录像回放等操作
|
||||
console.log(TAG, "创建播放器成功,versionInfo:", versionInfo)
|
||||
isReady = true
|
||||
isReadyByType.set(type, true)
|
||||
setTimeout(() => {
|
||||
flushReadyQueue()
|
||||
const waiters = readyWaitersByType.get(type) || []
|
||||
waiters.forEach((fn) => {
|
||||
try { fn() } catch (_) { }
|
||||
})
|
||||
readyWaitersByType.delete(type)
|
||||
const timer = setTimeout(() => {
|
||||
readyTimers.delete(timer)
|
||||
if (!isCurrent()) return settle(null)
|
||||
if (currentWindowType.value === type) activatePlayer(type, player)
|
||||
else player.hide?.()
|
||||
loading.value = false
|
||||
resolve(null)
|
||||
settle(player)
|
||||
}, 500)
|
||||
readyTimers.add(timer)
|
||||
},
|
||||
// 创建播放器失败回调
|
||||
createError: (err) => {
|
||||
if (!isCurrent()) return
|
||||
failed = true
|
||||
console.log(TAG, "创建播放器失败,err:", err)
|
||||
isReady = false
|
||||
isReadyByType.set(type, false)
|
||||
if (players.get(type) === player) players.delete(type)
|
||||
if (VIDEO_PLAYER === player) VIDEO_PLAYER = null
|
||||
disposePlayer(player)
|
||||
if ([1001].includes(err.code)) {
|
||||
ElMessage.error(err.message)
|
||||
}
|
||||
loading.value = false
|
||||
resolve(null)
|
||||
settle(null)
|
||||
},
|
||||
// 插件公共回调
|
||||
dhPlayerMessage: (info, err) => {
|
||||
@@ -371,6 +400,7 @@ async function initVideoPlayer(type = props.windowType) {
|
||||
},
|
||||
// 实时预览失败回调
|
||||
realError: (info, err) => {
|
||||
if (!isCurrent() || players.get(type) !== player || currentWindowType.value !== 0 || !lastRealRequest) return
|
||||
console.log(TAG, "实时预览失败,info:", info, "err:", err)
|
||||
if ([204, 408, 10705].includes(err.code)) {
|
||||
try {
|
||||
@@ -392,7 +422,7 @@ async function initVideoPlayer(type = props.windowType) {
|
||||
nextList[index] = fallbackItem
|
||||
lastRealRequest = nextList
|
||||
console.log(TAG, "辅码流不可用,自动切换主码流重试", fallbackItem)
|
||||
VIDEO_PLAYER.startReal([fallbackItem])
|
||||
player.startReal([fallbackItem])
|
||||
} catch (e) {
|
||||
console.log(TAG, "自动切换主码流失败,err:", e)
|
||||
}
|
||||
@@ -420,23 +450,27 @@ async function initVideoPlayer(type = props.windowType) {
|
||||
},
|
||||
// 抓图成功回调
|
||||
snapshotSuccess: ({ base64Url, path }, info) => {
|
||||
if (!isCurrent()) return
|
||||
console.log(TAG, "抓图成功,base64Url:", base64Url, "path:", path, "info:", info)
|
||||
// 可选:保存图片到本地(演示示例)
|
||||
saveSnapshot(base64Url, `${info.channelName}-抓图-${dayjs().format("YYYY-MM-DD HH:mm:ss")}.jpg`)
|
||||
},
|
||||
// 录制视频成功回调
|
||||
videoDownloadSuccess: (path, info) => {
|
||||
if (!isCurrent()) return
|
||||
console.log(TAG, "录制视频成功,path:", path, "info:", info)
|
||||
// 可选:保存视频到本地(演示示例)
|
||||
saveVideo(path, `${info.channelName}-录像-${dayjs().format("YYYY-MM-DD HH:mm:ss")}.mp4`)
|
||||
},
|
||||
// 关闭视频窗口回调
|
||||
closeWindowSuccess: ({ isAll, snum, channelList }) => {
|
||||
if (!isCurrent() || VIDEO_PLAYER !== player) return
|
||||
console.log(TAG, "关闭视频窗口,isAll:", isAll, "snum:", snum, "channelList:", channelList)
|
||||
emitSetting()
|
||||
},
|
||||
// 鼠标单击窗口回调
|
||||
clickWindow: (snum) => {
|
||||
if (!isCurrent() || VIDEO_PLAYER !== player) return
|
||||
console.log(TAG, "鼠标单击窗口,snum:", snum)
|
||||
currentSnum = snum
|
||||
emitSetting()
|
||||
@@ -452,35 +486,33 @@ async function initVideoPlayer(type = props.windowType) {
|
||||
},
|
||||
// rtsp 流下载录像成功回调
|
||||
downloadRecordSuccess: (info) => {
|
||||
if (!isCurrent()) return
|
||||
console.log(TAG, "RTSP录像下载成功,info:", info)
|
||||
ElMessage.success(info.message)
|
||||
},
|
||||
// rtsp 流下载录像失败回调
|
||||
downloadRecordError: (info, err) => {
|
||||
if (!isCurrent()) return
|
||||
console.log(TAG, "RTSP录像下载失败,info:", info, "err:", err)
|
||||
ElMessage.error("RTSP录像下载失败")
|
||||
}
|
||||
})
|
||||
currentWindowType.value = type
|
||||
players.set(type, VIDEO_PLAYER)
|
||||
if (!isCurrent()) {
|
||||
disposePlayer(player)
|
||||
return settle(null)
|
||||
}
|
||||
players.set(type, player)
|
||||
if (currentWindowType.value === type) VIDEO_PLAYER = player
|
||||
console.log(TAG, "播放器已初始化,windowType:", type)
|
||||
})
|
||||
initPromisesByType.set(type, p)
|
||||
await p.finally(() => initPromisesByType.delete(type))
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保播放器类型与目标一致,不一致则重新创建
|
||||
* @param {0|3|7} target 目标类型
|
||||
* @returns {boolean} 是否发生重新创建
|
||||
*/
|
||||
function ensureWindowType(target) {
|
||||
if (currentWindowType.value !== target) {
|
||||
console.log(TAG, "检测到窗口类型不一致,当前:", currentWindowType.value, "目标:", target, "正在重新创建")
|
||||
initVideoPlayer(target)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
initialize().catch((e) => {
|
||||
if (myGeneration !== generation) return settle(null)
|
||||
disposePlayer(player)
|
||||
loading.value = false
|
||||
console.log(TAG, "初始化播放器失败,err:", e)
|
||||
settle(null)
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,22 +520,19 @@ function ensureWindowType(target) {
|
||||
* @param {RealOptions[]} channels 播放参数数组
|
||||
*/
|
||||
async function startReal(channels) {
|
||||
if (!canUsePlayer() || !Array.isArray(channels)) return
|
||||
if (!channels.length) return closeVideo()
|
||||
channels = clampByDivision(channels)
|
||||
const myToken = ++latestRealToken
|
||||
if (!VIDEO_PLAYER) {
|
||||
initVideoPlayer(0)
|
||||
} else {
|
||||
ensureWindowType(0)
|
||||
}
|
||||
++latestPlaybackToken
|
||||
lastRealRequest = channels
|
||||
realFallbackAttempted.clear()
|
||||
const showBtn = ["BTN_STREAM", "BTN_VOICE", "BTN_TALK", "BTN_RECORD", "BTN_PIC", "BTN_ENLARGE"]
|
||||
loading.value = true
|
||||
await waitForReady(0)
|
||||
if (myToken !== latestRealToken) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const player = await initVideoPlayer(0)
|
||||
await pendingClose
|
||||
if (!player || !canUsePlayer() || !requestedVisible || document.hidden || myToken !== latestRealToken) return
|
||||
activatePlayer(0, player)
|
||||
const now = Date.now()
|
||||
const signature = buildRealSignature(channels)
|
||||
if (signature && signature === lastRealSignature && now - lastRealStartAt < REAL_RESTART_GUARD_MS) {
|
||||
@@ -512,11 +541,11 @@ async function startReal(channels) {
|
||||
}
|
||||
lastRealSignature = signature
|
||||
lastRealStartAt = now
|
||||
applyDivisionByCount(Array.isArray(channels) ? channels.length : 1)
|
||||
applyDivisionByCount(channels.length, player)
|
||||
console.log(TAG, "开始实时预览,channels:", channels)
|
||||
VIDEO_PLAYER.startReal(channels)
|
||||
player.startReal(channels)
|
||||
for (let i = 0; i < channels.length; i++) {
|
||||
VIDEO_PLAYER.setTabControlBtn(showBtn, i)
|
||||
player.setTabControlBtn(showBtn, i)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
@@ -526,20 +555,17 @@ async function startReal(channels) {
|
||||
* @param {PlaybackOptions[]} items 回放参数数组
|
||||
*/
|
||||
async function startPlayback(items) {
|
||||
if (!canUsePlayer() || !Array.isArray(items)) return
|
||||
if (!items.length) return closeVideo()
|
||||
items = clampByDivision(items)
|
||||
const myToken = ++latestPlaybackToken
|
||||
if (!VIDEO_PLAYER) {
|
||||
initVideoPlayer(3)
|
||||
} else {
|
||||
ensureWindowType(3)
|
||||
}
|
||||
++latestRealToken
|
||||
lastPlaybackRequest = items
|
||||
loading.value = true
|
||||
await waitForReady(3)
|
||||
if (myToken !== latestPlaybackToken) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const player = await initVideoPlayer(3)
|
||||
await pendingClose
|
||||
if (!player || !canUsePlayer() || !requestedVisible || document.hidden || myToken !== latestPlaybackToken) return
|
||||
activatePlayer(3, player)
|
||||
const now = Date.now()
|
||||
const signature = buildPlaybackSignature(items)
|
||||
if (signature && signature === lastPlaybackSignature && now - lastPlaybackStartAt < REAL_RESTART_GUARD_MS) {
|
||||
@@ -548,14 +574,14 @@ async function startPlayback(items) {
|
||||
}
|
||||
lastPlaybackSignature = signature
|
||||
lastPlaybackStartAt = now
|
||||
applyDivisionByCount(Array.isArray(items) ? items.length : 1)
|
||||
applyDivisionByCount(items.length, player)
|
||||
console.log(TAG, "开始录像回放,items:", items)
|
||||
// try {
|
||||
// await closeVideo()
|
||||
// } catch (e) {
|
||||
// console.log(TAG, "开始回放前关闭旧视频失败,err:", e)
|
||||
// }
|
||||
VIDEO_PLAYER.startPlayback(items)
|
||||
player.startPlayback(items)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -564,21 +590,23 @@ async function startPlayback(items) {
|
||||
* @param {boolean} visible 是否显示
|
||||
*/
|
||||
function updateVisible(visible) {
|
||||
if (!VIDEO_PLAYER) return
|
||||
try {
|
||||
if (visible) VIDEO_PLAYER.show?.()
|
||||
else VIDEO_PLAYER.hide?.()
|
||||
console.log(TAG, "更新可见性,visible:", VIDEO_PLAYER.setting)
|
||||
} catch (e) {
|
||||
console.log(TAG, "更新可见性失败,err:", e)
|
||||
}
|
||||
requestedVisible = !!visible
|
||||
players.forEach((player, type) => {
|
||||
const show = requestedVisible && canUsePlayer() && !document.hidden && type === currentWindowType.value
|
||||
if (player.setting) player.setting.visible = show
|
||||
if (show) player.show?.()
|
||||
else player.hide?.()
|
||||
})
|
||||
}
|
||||
|
||||
async function handleVisibilityChange() {
|
||||
if (!canUsePlayer() || !requestedVisible) return
|
||||
if (document.hidden) {
|
||||
++latestRealToken
|
||||
++latestPlaybackToken
|
||||
if (!VIDEO_PLAYER) return
|
||||
if (currentWindowType.value === 0 && Array.isArray(lastRealRequest) && lastRealRequest.length) {
|
||||
VIDEO_PLAYER.closeVideo?.()
|
||||
stopOwnedPlayers()
|
||||
}
|
||||
if ((currentWindowType.value === 3 || currentWindowType.value === 7) && Array.isArray(lastPlaybackRequest)) {
|
||||
lastPlaybackRequest.forEach((item) => {
|
||||
@@ -592,11 +620,9 @@ async function handleVisibilityChange() {
|
||||
if (!VIDEO_PLAYER) return
|
||||
VIDEO_PLAYER.show?.()
|
||||
if (currentWindowType.value === 0 && Array.isArray(lastRealRequest) && lastRealRequest.length) {
|
||||
await waitForReady(0)
|
||||
await startReal(lastRealRequest)
|
||||
}
|
||||
if ((currentWindowType.value === 3 || currentWindowType.value === 7) && Array.isArray(lastPlaybackRequest)) {
|
||||
await waitForReady(currentWindowType.value)
|
||||
lastPlaybackRequest.forEach((item) => {
|
||||
if (item && typeof item.snum === "number") {
|
||||
VIDEO_PLAYER.controlPlayback?.({ snum: item.snum, state: 1 })
|
||||
@@ -704,34 +730,80 @@ function saveVideo(path, filename) {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeVideo() {
|
||||
try {
|
||||
if (VIDEO_PLAYER?.closeVideo) {
|
||||
await Promise.resolve().then(() => VIDEO_PLAYER.closeVideo())
|
||||
function stopOwnedPlayers() {
|
||||
const owned = new Set(players.values())
|
||||
if (VIDEO_PLAYER) owned.add(VIDEO_PLAYER)
|
||||
const tasks = [...owned].map((player) => {
|
||||
if (closingPlayers.has(player)) return closingPlayers.get(player)
|
||||
let result
|
||||
try {
|
||||
// 同步发出关闭命令,各个实例分别处理,避免一个窗口不回包卡住其他窗口。
|
||||
result = player.closeVideo?.()
|
||||
} catch (e) {
|
||||
console.log(TAG, "关闭播放失败,err:", e)
|
||||
}
|
||||
for (const p of players.values()) {
|
||||
try {
|
||||
if (p?.closeVideo) {
|
||||
await Promise.resolve().then(() => p.closeVideo())
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(TAG, "清空播放列表失败,err:", e)
|
||||
}
|
||||
}
|
||||
console.log(TAG, "关闭播放列表成功")
|
||||
} catch (e) {
|
||||
console.log(TAG, "关闭播放失败,err:", e)
|
||||
const task = Promise.resolve(result).catch((e) => {
|
||||
console.log(TAG, "关闭播放失败,err:", e)
|
||||
}).then(() => {
|
||||
if (player.setting) player.setting.channelList = []
|
||||
if (closingPlayers.get(player) === task) closingPlayers.delete(player)
|
||||
})
|
||||
closingPlayers.set(player, task)
|
||||
return task
|
||||
})
|
||||
pendingClose = Promise.all(tasks).then(() => undefined)
|
||||
return pendingClose
|
||||
}
|
||||
|
||||
function closeVideo() {
|
||||
++latestRealToken
|
||||
++latestPlaybackToken
|
||||
lastRealRequest = null
|
||||
lastPlaybackRequest = null
|
||||
lastRealSignature = ""
|
||||
lastPlaybackSignature = ""
|
||||
realFallbackAttempted.clear()
|
||||
loading.value = false
|
||||
ytRef.value?.close?.()
|
||||
return stopOwnedPlayers()
|
||||
}
|
||||
|
||||
function destroy(preserveRequests = false) {
|
||||
++generation
|
||||
++latestRealToken
|
||||
++latestPlaybackToken
|
||||
readyTimers.forEach(clearTimeout)
|
||||
readyTimers.clear()
|
||||
initResolversByType.forEach((resolve) => resolve(null))
|
||||
initResolversByType.clear()
|
||||
initPromisesByType.clear()
|
||||
isReadyByType.clear()
|
||||
closingPlayers.clear()
|
||||
pendingClose = Promise.resolve()
|
||||
lastRealSignature = ""
|
||||
lastPlaybackSignature = ""
|
||||
realFallbackAttempted.clear()
|
||||
loading.value = false
|
||||
ytRef.value?.close?.()
|
||||
const owned = new Set(players.values())
|
||||
if (VIDEO_PLAYER) owned.add(VIDEO_PLAYER)
|
||||
players.clear()
|
||||
VIDEO_PLAYER = null
|
||||
if (!preserveRequests) {
|
||||
lastRealRequest = null
|
||||
lastPlaybackRequest = null
|
||||
currentWindowType.value = null
|
||||
pageTotal.value = 0
|
||||
}
|
||||
owned.forEach(disposePlayer)
|
||||
}
|
||||
|
||||
function setTotal(total) {
|
||||
if (total > 0) {
|
||||
pageTotal.value = total
|
||||
console.log(TAG, "设置视频总数,total:", total)
|
||||
}
|
||||
pageTotal.value = Math.max(0, Number(total) || 0)
|
||||
}
|
||||
|
||||
function emitSetting() {
|
||||
if (!VIDEO_PLAYER || !canUsePlayer()) return
|
||||
emit("changeSetting", {
|
||||
...VIDEO_PLAYER.setting,
|
||||
currentSnum,
|
||||
@@ -747,6 +819,7 @@ function clickWindowCallback() {
|
||||
/* ************************************* start 云台相关 start ************************************* */
|
||||
|
||||
function visibleYt() {
|
||||
if (!VIDEO_PLAYER || !canUsePlayer()) return
|
||||
console.log("VIDEO_PLAYER:", VIDEO_PLAYER.setting);
|
||||
console.log("currentSnum:", currentSnum);
|
||||
if (currentSnum !== null && currentSnum !== undefined) {
|
||||
@@ -768,6 +841,7 @@ function visibleYt() {
|
||||
}
|
||||
|
||||
const updateYtChannel = () => {
|
||||
if (!VIDEO_PLAYER || !canUsePlayer()) return
|
||||
if (!ytRef.value?.isVisible) {
|
||||
return
|
||||
}
|
||||
@@ -792,23 +866,29 @@ onMounted(() => {
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
try {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
VIDEO_PLAYER?.closeVideo?.()
|
||||
VIDEO_PLAYER?.hide?.()
|
||||
// VIDEO_PLAYER?.destroy?.()
|
||||
players.forEach((p) => {
|
||||
try { p?.closeVideo?.() } catch (e) { console.log(TAG, "清空播放列表失败,err:", e) }
|
||||
try { p?.hide?.() } catch (e) { console.log(TAG, "隐藏缓存播放器失败,err:", e) }
|
||||
// try { p?.destroy?.() } catch (e) { console.log(TAG, "销毁缓存播放器失败,err:", e) }
|
||||
})
|
||||
console.log(TAG, "组件卸载,已隐藏播放器与缓存")
|
||||
} catch (e) {
|
||||
console.log(TAG, "隐藏播放器或缓存失败,err:", e)
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounted = true
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
destroy()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
isActivated = false
|
||||
destroy(true)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (isActivated) return
|
||||
isActivated = true
|
||||
if (!props.active || !requestedVisible) return
|
||||
if (currentWindowType.value === 0 && lastRealRequest?.length) startReal(lastRealRequest)
|
||||
else if (lastPlaybackRequest?.length) startPlayback(lastPlaybackRequest)
|
||||
})
|
||||
|
||||
watch(() => props.active, (active) => {
|
||||
if (!active) destroy()
|
||||
}, { flush: 'sync' })
|
||||
|
||||
// 暴露方法给父组件,通过 ref 调用
|
||||
defineExpose({
|
||||
initVideoPlayer,
|
||||
@@ -816,21 +896,22 @@ defineExpose({
|
||||
startPlayback,
|
||||
updateVisible,
|
||||
closeVideo,
|
||||
destroy: () => destroy(),
|
||||
setTotal,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-container {
|
||||
/* 原生插件按此节点的实际矩形创建窗口,宽度变化时由比例计算高度。 */
|
||||
flex: 0 1 auto;
|
||||
.video-container {
|
||||
/* 原生插件按此节点的实际矩形创建窗口,宽度变化时由比例计算高度。 */
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
position: relative;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// 通行方向独立于成功/失败、报警状态,所有人员和车辆表格共用同一组颜色。
|
||||
:root {
|
||||
--access-in-bg: #eef9f5;
|
||||
--access-in-hover-bg: #dff2e9;
|
||||
--access-in-active-bg: #cfe9dd;
|
||||
--access-in-accent: #16745b;
|
||||
--access-in-tag-bg: #dff2e9;
|
||||
--access-in-tag-border: #badfce;
|
||||
--access-out-bg: #eff5ff;
|
||||
--access-out-hover-bg: #e0ecff;
|
||||
--access-out-active-bg: #d2e3fc;
|
||||
--access-out-accent: #315fa8;
|
||||
--access-out-tag-bg: #e3edfc;
|
||||
--access-out-tag-border: #c4d7f4;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--access-in-bg: #19352f;
|
||||
--access-in-hover-bg: #21473d;
|
||||
--access-in-active-bg: #2b584a;
|
||||
--access-in-accent: #91dfbd;
|
||||
--access-in-tag-bg: #21473d;
|
||||
--access-in-tag-border: #3b725d;
|
||||
--access-out-bg: #20324b;
|
||||
--access-out-hover-bg: #29415f;
|
||||
--access-out-active-bg: #324f73;
|
||||
--access-out-accent: #a8caff;
|
||||
--access-out-tag-bg: #29415f;
|
||||
--access-out-tag-border: #49698f;
|
||||
}
|
||||
|
||||
.el-table.access-record-table {
|
||||
.el-table__body > tbody > tr {
|
||||
&.access-record-row--in {
|
||||
--access-row-bg: var(--access-in-bg);
|
||||
--access-row-hover-bg: var(--access-in-hover-bg);
|
||||
--access-row-active-bg: var(--access-in-active-bg);
|
||||
--access-row-accent: var(--access-in-accent);
|
||||
}
|
||||
|
||||
&.access-record-row--out {
|
||||
--access-row-bg: var(--access-out-bg);
|
||||
--access-row-hover-bg: var(--access-out-hover-bg);
|
||||
--access-row-active-bg: var(--access-out-active-bg);
|
||||
--access-row-accent: var(--access-out-accent);
|
||||
}
|
||||
|
||||
// 背景落在单元格上,兼容固定列和条纹;子表格按各自记录独立着色。
|
||||
&.access-record-row--in,
|
||||
&.access-record-row--out {
|
||||
> td.el-table__cell {
|
||||
background-color: var(--access-row-bg);
|
||||
}
|
||||
|
||||
> td.el-table__cell:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--access-row-accent);
|
||||
}
|
||||
|
||||
&:hover > td.el-table__cell,
|
||||
&.hover-row > td.el-table__cell {
|
||||
background-color: var(--access-row-hover-bg);
|
||||
}
|
||||
|
||||
&.current-row > td.el-table__cell {
|
||||
background-color: var(--access-row-active-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.access-direction-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 20px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-bottom: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
|
||||
&__title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__swatch {
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border: 1px solid var(--access-out-tag-border);
|
||||
border-left: 3px solid var(--access-out-accent);
|
||||
border-radius: 3px;
|
||||
background: var(--access-out-bg);
|
||||
}
|
||||
|
||||
&__item--in &__swatch {
|
||||
border-color: var(--access-in-tag-border);
|
||||
border-left-color: var(--access-in-accent);
|
||||
background: var(--access-in-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.access-direction-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
min-height: 26px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 5px;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
|
||||
&--in {
|
||||
border-color: var(--access-in-tag-border);
|
||||
background: var(--access-in-tag-bg);
|
||||
color: var(--access-in-accent);
|
||||
}
|
||||
|
||||
&--out {
|
||||
border-color: var(--access-out-tag-border);
|
||||
background: var(--access-out-tag-bg);
|
||||
color: var(--access-out-accent);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.el-table.access-record-table .el-table__body td.el-table__cell {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@
|
||||
@import './waves.scss';
|
||||
@import './dark.scss';
|
||||
@import './login.scss';
|
||||
@import './access-record.scss';
|
||||
@@ -0,0 +1,24 @@
|
||||
export type AccessRecordKind = 'door' | 'car' | 'gate';
|
||||
export type AccessDirection = 'in' | 'out' | 'neutral';
|
||||
|
||||
const directionCodes: Record<AccessRecordKind, Record<string, AccessDirection>> = {
|
||||
door: { '1': 'in', '2': 'out' },
|
||||
car: { '8': 'in', '9': 'out' },
|
||||
gate: { '1': 'in', enter: 'in', in: 'in', '2': 'out', exit: 'out', out: 'out' },
|
||||
};
|
||||
|
||||
// 门禁、过车抓拍和开关闸记录使用不同的方向编码;未知或双向记录保持中性。
|
||||
export function getAccessDirection(value: unknown, kind: AccessRecordKind): AccessDirection {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return 'neutral';
|
||||
const code = String(value).trim();
|
||||
return Object.prototype.hasOwnProperty.call(directionCodes[kind], code) ? directionCodes[kind][code] : 'neutral';
|
||||
}
|
||||
|
||||
function rowClassName(value: unknown, kind: AccessRecordKind): string {
|
||||
const direction = getAccessDirection(value, kind);
|
||||
return direction === 'neutral' ? '' : `access-record-row--${direction}`;
|
||||
}
|
||||
|
||||
export const doorRecordRowClassName = ({ row }: { row: { enterOrExit?: unknown } }) => rowClassName(row.enterOrExit, 'door');
|
||||
export const carRecordRowClassName = ({ row }: { row: { carDirect?: unknown } }) => rowClassName(row.carDirect, 'car');
|
||||
export const gateOperationRowClassName = ({ row }: { row: { carDirect?: unknown } }) => rowClassName(row.carDirect, 'gate');
|
||||
+600
-91
@@ -44,7 +44,12 @@
|
||||
</div>
|
||||
|
||||
<section class="kpi-grid" aria-label="设备核心指标" v-loading="dashboardLoading">
|
||||
<article v-for="item in kpiCards" :key="item.key" class="kpi-card" :class="`kpi-card--${item.tone}`">
|
||||
<component :is="item.clickable ? 'button' : 'article'" v-for="item in kpiCards" :key="item.key"
|
||||
:type="item.clickable ? 'button' : undefined" class="kpi-card"
|
||||
:class="[`kpi-card--${item.tone}`, { 'kpi-card--clickable': item.clickable, 'kpi-card--active': item.drill && activeDrill?.key === item.drill.key }]"
|
||||
:aria-pressed="item.drill ? String(activeDrill?.key === item.drill.key) : undefined"
|
||||
@click="handleKpiClick(item)">
|
||||
<span v-if="item.drill && activeDrill?.key === item.drill.key" class="kpi-card__drill-tag">筛选中</span>
|
||||
<div class="kpi-card__icon" aria-hidden="true">
|
||||
<el-icon>
|
||||
<component :is="item.icon" />
|
||||
@@ -58,7 +63,7 @@
|
||||
</div>
|
||||
<small>{{ item.description }}</small>
|
||||
</div>
|
||||
</article>
|
||||
</component>
|
||||
</section>
|
||||
|
||||
<section class="charts-grid" aria-label="设备统计图表">
|
||||
@@ -66,9 +71,11 @@
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h2>设备分类占比</h2>
|
||||
<p>监控、门禁与道闸设备构成</p>
|
||||
<p>监控、门禁、道闸与其他分类设备构成</p>
|
||||
</div>
|
||||
<el-tag type="info" effect="plain">当前</el-tag>
|
||||
<el-tooltip content="编码设备名下的相机通道数量,不占用设备总数" placement="top">
|
||||
<el-tag type="info" effect="plain">监控通道 {{ formatNumber(deviceTotal.channel?.total || 0) }} 路</el-tag>
|
||||
</el-tooltip>
|
||||
</header>
|
||||
<div ref="distributionChartRef" class="chart-canvas" role="img" :aria-label="distributionSummary"></div>
|
||||
<p class="chart-summary">{{ distributionSummary }}</p>
|
||||
@@ -163,79 +170,199 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="data-panel device-table-panel" aria-labelledby="device-list-title">
|
||||
<header class="panel-header panel-header--table">
|
||||
<section ref="deviceTablePanelRef" class="data-panel device-table-panel" aria-labelledby="device-list-title">
|
||||
<header class="panel-header panel-header--table device-panel-header">
|
||||
<div>
|
||||
<h2 id="device-list-title">全部设备运行明细</h2>
|
||||
<p>统计范围内所有设备的连接、同步和归属信息</p>
|
||||
<p>所有设备与通道的连接、同步和归属信息,点击指标或图表可快速筛选</p>
|
||||
</div>
|
||||
<span class="table-total">共 {{ formatNumber(deviceState.pagination?.total || 0) }} 台</span>
|
||||
<el-tag v-if="activeDrill" class="drill-tag" closable effect="light" type="primary"
|
||||
@close="clearDrillFilters">
|
||||
筛选中:{{ activeDrill.label }}
|
||||
</el-tag>
|
||||
<!-- 设备/通道视图切换(仅作分段控制器,内容由下方 v-show 控制渲染) -->
|
||||
<el-tabs v-model="activeTab" class="device-panel-tabs">
|
||||
<el-tab-pane :label="deviceTabLabel" name="device" />
|
||||
<el-tab-pane :label="channelTabLabel" name="channel" />
|
||||
</el-tabs>
|
||||
</header>
|
||||
|
||||
<el-form ref="deviceQueryRef" :model="deviceState.queryForm" :inline="true" class="device-filters"
|
||||
@keyup.enter="handleDeviceSearch">
|
||||
<el-form-item label="设备名称" prop="deviceName">
|
||||
<el-input v-model="deviceState.queryForm.deviceName" clearable placeholder="请输入设备名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="deviceCategory">
|
||||
<dict-select v-model="deviceState.queryForm.deviceCategory" :options="device_category" clearable
|
||||
placeholder="请选择设备类型" />
|
||||
</el-form-item>
|
||||
<el-form-item label="在线状态" prop="isOnline">
|
||||
<dict-select v-model="deviceState.queryForm.isOnline" :options="is_nline" clearable
|
||||
placeholder="请选择在线状态" />
|
||||
</el-form-item>
|
||||
<el-form-item class="device-filters__actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleDeviceSearch">查询</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="resetDeviceQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 设备视图 -->
|
||||
<div v-show="isDevicePaneVisible">
|
||||
<el-form ref="deviceQueryRef" :model="deviceState.queryForm" :inline="true" class="device-filters"
|
||||
@keyup.enter="handleDeviceSearch">
|
||||
<el-form-item label="设备名称" prop="deviceName">
|
||||
<el-input v-model="deviceState.queryForm.deviceName" clearable placeholder="请输入设备名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="deviceCategory">
|
||||
<dict-select v-model="deviceState.queryForm.deviceCategory" :options="device_category" clearable
|
||||
placeholder="请选择设备类型" />
|
||||
</el-form-item>
|
||||
<el-form-item label="在线状态" prop="isOnline">
|
||||
<dict-select v-model="deviceState.queryForm.isOnline" :options="is_nline" clearable
|
||||
placeholder="请选择在线状态" />
|
||||
</el-form-item>
|
||||
<el-form-item label="同步状态" prop="isSync">
|
||||
<dict-select v-model="deviceState.queryForm.isSync" :options="is_sync" clearable
|
||||
placeholder="请选择同步状态" />
|
||||
</el-form-item>
|
||||
<el-form-item class="device-filters__actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleDeviceSearch">查询</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="resetDeviceQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="table-scroll">
|
||||
<el-table :data="deviceState.dataList" v-loading="deviceState.loading" border empty-text="暂无设备数据"
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@sort-change="sortChangeHandle">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="deviceCode" label="设备编码" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceName" label="设备名称" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceCategory" label="设备类型" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="device_category" :value="row.deviceCategory" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceManufacturer" label="厂商类型" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_device_manufacturer" :value="row.deviceManufacturer" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="orgName" label="所属组织" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.orgName || "--" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isOnline" label="在线状态" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<tag-online :isOnline="String(row.isOnline) === '1'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isSync" label="同步状态" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<tag-sync :isSync="String(row.isSync) === '1'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="offlineReason" label="离线原因" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.offlineReason || "--" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-scroll">
|
||||
<el-table :data="deviceState.dataList" v-loading="deviceState.loading" border empty-text="暂无设备数据"
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@sort-change="sortChangeHandle">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="deviceCode" label="设备编码" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceName" label="设备名称" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceCategory" label="设备类型" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="device_category" :value="row.deviceCategory" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceManufacturer" label="厂商类型" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_device_manufacturer" :value="row.deviceManufacturer" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="orgName" label="所属组织" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.orgName || "--" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isOnline" label="在线状态" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<tag-online :isOnline="String(row.isOnline) === '1'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isSync" label="同步状态" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<tag-sync :isSync="String(row.isSync) === '1'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="offlineReason" label="离线原因" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.offlineReason || "--" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
|
||||
v-bind="deviceState.pagination" />
|
||||
</div>
|
||||
|
||||
<!-- 通道视图(首次切换时才渲染并加载) -->
|
||||
<div v-if="channelInited" v-show="isChannelPaneVisible">
|
||||
<el-form ref="channelQueryRef" :model="channelState.queryForm" :inline="true" class="device-filters"
|
||||
@keyup.enter="handleChannelSearch">
|
||||
<el-form-item label="通道名称" prop="channelName">
|
||||
<el-input v-model="channelState.queryForm.channelName" clearable placeholder="请输入通道名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属设备" prop="deviceName">
|
||||
<el-input v-model="channelState.queryForm.deviceName" clearable placeholder="请输入设备名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="在线状态" prop="isOnline">
|
||||
<dict-select v-model="channelState.queryForm.isOnline" :options="is_nline" clearable
|
||||
placeholder="请选择在线状态" />
|
||||
</el-form-item>
|
||||
<el-form-item label="同步状态" prop="isSync">
|
||||
<dict-select v-model="channelState.queryForm.isSync" :options="is_sync" clearable
|
||||
placeholder="请选择同步状态" />
|
||||
</el-form-item>
|
||||
<el-form-item class="device-filters__actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleChannelSearch">查询</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="resetChannelQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="channel-toolbar">
|
||||
<el-button plain :disabled="channelMultiple" icon="Delete" type="primary"
|
||||
v-auth="'admin_deviceChannel_del'" @click="handleChannelDelete(channelSelectObjs)">
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<el-table :data="channelState.dataList" v-loading="channelState.loading" border empty-text="暂无通道数据"
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="channelSelectionChangHandle">
|
||||
<el-table-column type="selection" width="40" align="center" />
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="channelCode" label="通道编码" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="channelName" label="通道名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceName" label="所属设备" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="cameraType" label="摄像头类型" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<dict-tag v-if="row.cameraType" :options="camera_type" :value="row.cameraType" />
|
||||
<el-tag type="danger" v-else>未知</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="capability" label="通道能力" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="channel-capability-tags">
|
||||
<el-tag v-for="(v, i) in parseDeviceCapability(row.capability)" :key="i">
|
||||
{{ v }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="installLocation" label="当前所在位置" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="color-success" v-if="row.installLocation">{{ row.installLocation }}</div>
|
||||
<div class="color-danger" v-else>未安装</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isOnline" label="在线状态" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<tag-online :isOnline="String(row.isOnline) === '1'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isSync" label="同步状态" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<tag-sync :isSync="String(row.isSync) === '1'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="grid grid-cols-3 gap-2 w-full">
|
||||
<el-button style="margin: 0;" class="!ml-0 w-full" icon="VideoPlay" text type="primary"
|
||||
@click="previewVideoRef.openDialog('channel', row.channelCode)">
|
||||
预览
|
||||
</el-button>
|
||||
<el-button style="margin: 0;" class="!ml-0 w-full" icon="VideoCamera" text type="primary"
|
||||
@click="replayVideoRef.openDialog('channel', row.channelCode)">
|
||||
回放
|
||||
</el-button>
|
||||
<el-button style="margin: 0;" class="!ml-0 w-full" icon="edit-pen" text type="primary"
|
||||
v-auth="'admin_deviceChannel_edit'"
|
||||
@click="channelFormDialogRef.openDialog(row.id, row.deviceCode)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button style="margin: 0;" class="!ml-0 w-full" icon="delete" text type="primary"
|
||||
v-auth="'admin_deviceChannel_del'" @click="handleChannelDelete([row.id])">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<pagination @size-change="channelSizeChangeHandle" @current-change="channelCurrentChangeHandle"
|
||||
v-bind="channelState.pagination" />
|
||||
</div>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
|
||||
v-bind="deviceState.pagination" />
|
||||
</section>
|
||||
|
||||
<!-- 通道行操作弹窗:编辑/预览/回放,复用设备管理组件保持操作一致 -->
|
||||
<device-channer-form ref="channelFormDialogRef" @refresh="handleChannelRefreshed" />
|
||||
<preview-video-dialog ref="previewVideoRef" />
|
||||
<replay-video-dialog ref="replayVideoRef" />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="baseData">
|
||||
import { computed, markRaw, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { computed, defineAsyncComponent, markRaw, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import type { Component, Ref } from "vue";
|
||||
// 统计图表统一使用 ECharts 实例,便于集中处理缩放与资源释放。
|
||||
import * as echarts from "echarts";
|
||||
@@ -255,8 +382,10 @@ import {
|
||||
import type { FormInstance } from "element-plus";
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { useDict } from "/@/hooks/dict";
|
||||
import { useMessage } from "/@/hooks/message";
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message";
|
||||
import { fetchList as fetchDeviceList } from "/@/api/device/deviceManage";
|
||||
import { cameraChannelPage, delObjs as delChannelObjs } from "/@/api/device/deviceChannel";
|
||||
import { parseDeviceCapability } from "/@/utils/dispose";
|
||||
import {
|
||||
AreaDeviceStatisticsItem,
|
||||
DeviceAlarmSummaryItem,
|
||||
@@ -276,6 +405,18 @@ import {
|
||||
type RangeValue = "week" | "month" | "year";
|
||||
type ChartKey = "distribution" | "status" | "online" | "area" | "alarm";
|
||||
|
||||
/**
|
||||
* 点击指标/图表下钻到设备明细表的筛选配置。
|
||||
*/
|
||||
interface DeviceDrill {
|
||||
key: string;
|
||||
label: string;
|
||||
filters: {
|
||||
deviceCategory?: string;
|
||||
isOnline?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface KpiCard {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -284,15 +425,25 @@ interface KpiCard {
|
||||
description: string;
|
||||
tone: "primary" | "success" | "danger" | "warning" | "info";
|
||||
icon: Component;
|
||||
clickable: boolean;
|
||||
drill?: DeviceDrill;
|
||||
action?: "alarm";
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
|
||||
// 通道视图复用设备管理的编辑与视频弹窗,保持两个页面操作一致。
|
||||
const DeviceChannerForm = defineAsyncComponent(() => import("/@/views/device/detail/components/device-channer-form.vue"));
|
||||
const PreviewVideoDialog = defineAsyncComponent(() => import("/@/views/device/components/preview-video/preview-video-dialog.vue"));
|
||||
const ReplayVideoDialog = defineAsyncComponent(() => import("/@/views/device/components/replay-video/replay-video-dialog.vue"));
|
||||
|
||||
// 页面使用现有字典组件,保证统计页与设备管理页的状态口径一致。
|
||||
const { device_category, is_nline, dh_device_manufacturer, alarm_type, handle_stat } = useDict(
|
||||
const { device_category, is_nline, is_sync, camera_type, dh_device_manufacturer, alarm_type, handle_stat } = useDict(
|
||||
"device_category",
|
||||
"is_nline",
|
||||
"is_sync",
|
||||
"camera_type",
|
||||
"dh_device_manufacturer",
|
||||
"alarm_type",
|
||||
"handle_stat"
|
||||
@@ -330,6 +481,8 @@ const deviceTotal = ref<DeviceTotalData>({
|
||||
camera: createEmptyDetail(),
|
||||
door: createEmptyDetail(),
|
||||
gate: createEmptyDetail(),
|
||||
other: createEmptyDetail(),
|
||||
channel: createEmptyDetail(),
|
||||
});
|
||||
|
||||
const alarmTotal = ref<DeviceAlarmTotalData>({
|
||||
@@ -339,12 +492,14 @@ const alarmTotal = ref<DeviceAlarmTotalData>({
|
||||
|
||||
// 设备明细表继续使用项目统一的 useTable,保留分页、排序和加载反馈。
|
||||
const deviceQueryRef = ref<FormInstance>();
|
||||
const deviceTablePanelRef = ref<HTMLElement | null>(null);
|
||||
const deviceState: BasicTableProps = reactive<BasicTableProps>({
|
||||
createdIsNeed: false,
|
||||
queryForm: {
|
||||
deviceName: "",
|
||||
deviceCategory: "",
|
||||
isOnline: "",
|
||||
isSync: "",
|
||||
},
|
||||
pagination: {
|
||||
current: 1,
|
||||
@@ -364,6 +519,61 @@ const {
|
||||
tableStyle,
|
||||
} = useTable(deviceState);
|
||||
|
||||
/* ------------------------- 通道视图(复刻 camera-list 的 Tab 模式) ------------------------- */
|
||||
const channelQueryRef = ref<FormInstance>();
|
||||
const channelFormDialogRef = ref();
|
||||
const previewVideoRef = ref();
|
||||
const replayVideoRef = ref();
|
||||
const activeTab = ref<"device" | "channel">("device");
|
||||
// 首次切换到通道 Tab 后才渲染内容,dirty 标记轮询刷新后待重新加载
|
||||
const channelInited = ref(false);
|
||||
const channelDirty = ref(true);
|
||||
const channelSelectObjs = ref<string[]>([]);
|
||||
const channelMultiple = ref(true);
|
||||
|
||||
const channelState: BasicTableProps = reactive<BasicTableProps>({
|
||||
createdIsNeed: false,
|
||||
queryForm: {
|
||||
channelName: "",
|
||||
deviceName: "",
|
||||
isOnline: "",
|
||||
isSync: "",
|
||||
installLocation: true,
|
||||
},
|
||||
pagination: {
|
||||
current: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
layout: "total, sizes, prev, pager, next, jumper",
|
||||
},
|
||||
pageList: cameraChannelPage,
|
||||
});
|
||||
|
||||
const {
|
||||
getDataList: getChannelDataList,
|
||||
currentChangeHandle: channelCurrentChangeHandle,
|
||||
sizeChangeHandle: channelSizeChangeHandle,
|
||||
} = useTable(channelState);
|
||||
|
||||
const isDevicePaneVisible = computed(() => activeTab.value === "device");
|
||||
const isChannelPaneVisible = computed(() => activeTab.value === "channel");
|
||||
const deviceTabLabel = computed(() => `设备 (${formatNumber(deviceState.pagination?.total || 0)})`);
|
||||
const channelTabLabel = computed(() => (channelInited.value ? `通道 (${formatNumber(channelState.pagination?.total || 0)})` : "通道"));
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab !== "channel") return;
|
||||
channelInited.value = true;
|
||||
if (channelDirty.value) {
|
||||
channelDirty.value = false;
|
||||
getChannelDataList();
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------- 点击下钻状态 ------------------------- */
|
||||
// 记录当前由 KPI 卡片/图表写入的下钻筛选,用于卡片高亮、筛选标签展示与再次点击取消。
|
||||
const activeDrill = ref<{ key: string; label: string } | null>(null);
|
||||
|
||||
const distributionChartRef = ref<HTMLDivElement | null>(null);
|
||||
const statusChartRef = ref<HTMLDivElement | null>(null);
|
||||
const onlineChartRef = ref<HTMLDivElement | null>(null);
|
||||
@@ -399,27 +609,33 @@ const kpiCards = computed<KpiCard[]>(() => [
|
||||
label: "设备总数",
|
||||
value: formatNumber(deviceTotal.value.total.total),
|
||||
suffix: "台",
|
||||
description: "当前纳管设备总量",
|
||||
description: "点击查看全部设备明细",
|
||||
tone: "primary",
|
||||
icon: markRaw(Monitor),
|
||||
clickable: true,
|
||||
drill: { key: "kpi-total", label: "全部设备", filters: {} },
|
||||
},
|
||||
{
|
||||
key: "online",
|
||||
label: "在线设备",
|
||||
value: formatNumber(deviceTotal.value.total.online),
|
||||
suffix: "台",
|
||||
description: "连接状态正常",
|
||||
description: "点击筛选在线设备",
|
||||
tone: "success",
|
||||
icon: markRaw(CircleCheck),
|
||||
clickable: true,
|
||||
drill: { key: "kpi-online", label: "在线设备", filters: { isOnline: "1" } },
|
||||
},
|
||||
{
|
||||
key: "offline",
|
||||
label: "离线设备",
|
||||
value: formatNumber(deviceTotal.value.total.offline),
|
||||
suffix: "台",
|
||||
description: "需要关注或排查",
|
||||
description: "点击筛选离线设备",
|
||||
tone: "danger",
|
||||
icon: markRaw(CircleClose),
|
||||
clickable: true,
|
||||
drill: { key: "kpi-offline", label: "离线设备", filters: { isOnline: "0" } },
|
||||
},
|
||||
{
|
||||
key: "rate",
|
||||
@@ -429,41 +645,78 @@ const kpiCards = computed<KpiCard[]>(() => [
|
||||
description: "在线设备占比",
|
||||
tone: "info",
|
||||
icon: markRaw(DataLine),
|
||||
clickable: false,
|
||||
},
|
||||
{
|
||||
key: "alarm",
|
||||
label: "今日告警",
|
||||
value: formatNumber(alarmTotal.value.todayAlarmCount),
|
||||
suffix: "条",
|
||||
description: `累计 ${formatNumber(alarmTotal.value.alarmCount)} 条`,
|
||||
description: `累计 ${formatNumber(alarmTotal.value.alarmCount)} 条,点击查看告警记录`,
|
||||
tone: "warning",
|
||||
icon: markRaw(Bell),
|
||||
clickable: true,
|
||||
action: "alarm",
|
||||
},
|
||||
]);
|
||||
|
||||
const categoryDetails = computed(() => [
|
||||
{ name: deviceTotal.value.camera.deviceName || "监控设备", value: deviceTotal.value.camera },
|
||||
{ name: deviceTotal.value.door.deviceName || "门禁设备", value: deviceTotal.value.door },
|
||||
{ name: deviceTotal.value.gate.deviceName || "道闸设备", value: deviceTotal.value.gate },
|
||||
]);
|
||||
// 分类明细统一采用设备口径(与设备列表一致),无设备的分类不参与图表展示。
|
||||
const categoryDetails = computed(() =>
|
||||
[
|
||||
{ name: deviceTotal.value.camera.deviceName || "监控设备", value: deviceTotal.value.camera },
|
||||
{ name: deviceTotal.value.door.deviceName || "门禁设备", value: deviceTotal.value.door },
|
||||
{ name: deviceTotal.value.gate.deviceName || "道闸设备", value: deviceTotal.value.gate },
|
||||
{ name: deviceTotal.value.other.deviceName || "其他设备", value: deviceTotal.value.other },
|
||||
].filter((item) => Number(item.value.total) > 0)
|
||||
);
|
||||
|
||||
const distributionSummary = computed(() => {
|
||||
const content = categoryDetails.value.map((item) => `${item.name}${formatNumber(item.value.total)}台`).join(",");
|
||||
return `设备分类占比:${content || "暂无数据"}。`;
|
||||
const channelTotal = formatNumber(deviceTotal.value.channel?.total || 0);
|
||||
return `设备分类占比:${content || "暂无数据"}。监控通道共 ${channelTotal} 路。`;
|
||||
});
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
const content = categoryDetails.value
|
||||
.map((item) => `${item.name}在线${formatNumber(item.value.online)}台、离线${formatNumber(item.value.offline)}台`)
|
||||
.map((item) => `${item.name}在线${formatNumber(item.value.online)}台、离线${formatNumber(item.value.offline)}台(在线率${formatPercent(item.value.onlineRate)})`)
|
||||
.join(";");
|
||||
return `分类运行状态:${content || "暂无数据"}。`;
|
||||
});
|
||||
|
||||
/**
|
||||
* 按当前时间范围的完整日期序列补齐趋势数据,缺失日期记为 null,
|
||||
* ECharts 折线对 null 显示断点,避免横轴日期缺失造成"数据连续"的误导。
|
||||
*/
|
||||
const onlineTrendDisplayList = computed<Array<{ recordTime: string; onlineDeviceTotalRate: number | null }>>(() => {
|
||||
const list = onlineTrendList.value;
|
||||
if (!list.length) return [];
|
||||
const end = dayjs().endOf("day");
|
||||
const start =
|
||||
selectedRange.value === "month"
|
||||
? dayjs().startOf("month")
|
||||
: selectedRange.value === "year"
|
||||
? dayjs().startOf("year")
|
||||
: dayjs().subtract(6, "day").startOf("day");
|
||||
const rateMap = new Map(list.map((item) => [dayjs(item.recordTime).format("YYYY-MM-DD"), Number(item.onlineDeviceTotalRate ?? 0)]));
|
||||
const todayKey = dayjs().format("YYYY-MM-DD");
|
||||
const realtimeRate = Number(deviceTotal.value.total.onlineRate || 0);
|
||||
const display: Array<{ recordTime: string; onlineDeviceTotalRate: number | null }> = [];
|
||||
for (let cursor = start; cursor.isBefore(end); cursor = cursor.add(1, "day")) {
|
||||
const key = cursor.format("YYYY-MM-DD");
|
||||
// 当天的快照由定时任务每日写入,当天以实时统计为准,次日起由快照接管。
|
||||
if (key === todayKey) {
|
||||
display.push({ recordTime: key, onlineDeviceTotalRate: realtimeRate });
|
||||
continue;
|
||||
}
|
||||
display.push({ recordTime: key, onlineDeviceTotalRate: rateMap.has(key) ? rateMap.get(key)! : null });
|
||||
}
|
||||
return display;
|
||||
});
|
||||
|
||||
const onlineTrendSummary = computed(() => {
|
||||
if (!onlineTrendList.value.length) return `${activeRangeLabel.value}暂无设备在线率趋势数据。`;
|
||||
const latest = onlineTrendList.value[onlineTrendList.value.length - 1];
|
||||
return `${activeRangeLabel.value}设备在线率趋势,共${onlineTrendList.value.length}个数据点,最新在线率为${formatPercent(
|
||||
latest.onlineDeviceTotalRate
|
||||
return `${activeRangeLabel.value}设备在线率趋势,共${onlineTrendList.value.length}个快照日,当前在线率为${formatPercent(
|
||||
deviceTotal.value.total.onlineRate
|
||||
)}。`;
|
||||
});
|
||||
|
||||
@@ -497,6 +750,8 @@ const normalizeDeviceTotal = (data?: Partial<DeviceTotalData>) => ({
|
||||
camera: normalizeDetail(data?.camera),
|
||||
door: normalizeDetail(data?.door),
|
||||
gate: normalizeDetail(data?.gate),
|
||||
other: normalizeDetail(data?.other),
|
||||
channel: normalizeDetail(data?.channel),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -523,9 +778,51 @@ const chartColors = {
|
||||
grid: "#e2e8f0",
|
||||
};
|
||||
|
||||
/**
|
||||
* 分类占比/分类状态图表点击后,按分类(与在线状态)下钻设备明细表。
|
||||
*/
|
||||
const handleCategoryChartClick = (key: "distribution" | "status", params: any) => {
|
||||
const detail = categoryDetails.value.find((item) => item.name === params?.name);
|
||||
if (!detail?.value.deviceCategory) return;
|
||||
const isOnline = key === "status" ? (params.seriesName === "在线" ? "1" : "0") : undefined;
|
||||
const filters: DeviceDrill["filters"] = { deviceCategory: detail.value.deviceCategory };
|
||||
if (isOnline !== undefined) filters.isOnline = isOnline;
|
||||
applyDeviceDrill({
|
||||
key: `${key}-${detail.value.deviceCategory}-${isOnline ?? "all"}`,
|
||||
label: isOnline !== undefined ? `${detail.name} · ${params.seriesName}` : detail.name,
|
||||
filters,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 可下钻图表绑定点击与指针反馈:悬停可点元素时显示手型,点击触发筛选或跳转。
|
||||
*/
|
||||
const bindChartInteractions = (key: ChartKey, instance: ECharts) => {
|
||||
const interactive = key === "distribution" || key === "status" || key === "alarm";
|
||||
if (!interactive) return;
|
||||
|
||||
instance.getZr().on("mousemove", (event: any) => {
|
||||
instance.getZr().setDefaultCursor(event?.target ? "pointer" : "default");
|
||||
});
|
||||
instance.getZr().on("globalout", () => instance.getZr().setDefaultCursor("default"));
|
||||
|
||||
if (key === "alarm") {
|
||||
// 告警趋势无对应设备筛选,点击数据点按当天跳转告警记录页。
|
||||
instance.on("click", (params: any) => {
|
||||
if (params?.name) navigateToAlarmPage({ alarmDate: params.name } as DeviceAlarmSummaryItem);
|
||||
});
|
||||
} else {
|
||||
instance.on("click", (params: any) => handleCategoryChartClick(key, params));
|
||||
}
|
||||
};
|
||||
|
||||
const getChartInstance = (key: ChartKey, element: HTMLDivElement | null) => {
|
||||
if (!element) return undefined;
|
||||
if (!chartInstances[key]) chartInstances[key] = markRaw(echarts.init(element));
|
||||
if (!chartInstances[key]) {
|
||||
const instance = markRaw(echarts.init(element));
|
||||
chartInstances[key] = instance;
|
||||
bindChartInteractions(key, instance);
|
||||
}
|
||||
return chartInstances[key];
|
||||
};
|
||||
|
||||
@@ -574,9 +871,9 @@ const renderDistributionChart = () => {
|
||||
setChartOption("distribution", distributionChartRef, {
|
||||
animation: !reduceMotion.value,
|
||||
animationDuration: 280,
|
||||
color: [chartColors.primary, chartColors.success, chartColors.warning],
|
||||
color: [chartColors.primary, chartColors.success, chartColors.warning, chartColors.muted],
|
||||
aria: { enabled: true, decal: { show: true } },
|
||||
tooltip: { ...commonTooltip, trigger: "item", formatter: "{b}<br/>数量:{c} 台<br/>占比:{d}%" },
|
||||
tooltip: { ...commonTooltip, trigger: "item", formatter: "{b}<br/>数量:{c} 台<br/>占比:{d}%<br/><span style=\"color:#909399\">点击筛选设备明细</span>" },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
left: "center",
|
||||
@@ -630,7 +927,18 @@ const renderStatusChart = () => {
|
||||
animation: !reduceMotion.value,
|
||||
animationDuration: 280,
|
||||
aria: { enabled: true, decal: { show: true } },
|
||||
tooltip: { ...commonTooltip, trigger: "axis", axisPointer: { type: "shadow" } },
|
||||
tooltip: {
|
||||
...commonTooltip,
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
formatter: (params: any) => {
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
const rows = list.map((p: any) => `${p.marker}${p.seriesName}:${p.value} 台`).join("<br/>");
|
||||
const detail = categoryDetails.value.find((item) => item.name === list[0]?.axisValue);
|
||||
const rateLine = detail ? `<br/>在线率:${formatPercent(detail.value.onlineRate)}` : "";
|
||||
return `${list[0]?.axisValue ?? ""}<br/>${rows}${rateLine}<br/><span style="color:#909399">点击筛选设备明细</span>`;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
@@ -687,6 +995,7 @@ const renderOnlineTrendChart = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const trendList = onlineTrendDisplayList.value;
|
||||
setChartOption("online", onlineChartRef, {
|
||||
animation: !reduceMotion.value,
|
||||
animationDuration: 280,
|
||||
@@ -694,7 +1003,7 @@ const renderOnlineTrendChart = () => {
|
||||
tooltip: {
|
||||
...commonTooltip,
|
||||
trigger: "axis",
|
||||
valueFormatter: (value: any) => `${Number(value).toFixed(2)}%`,
|
||||
valueFormatter: (value: any) => (value == null ? "无快照" : `${Number(value).toFixed(2)}%`),
|
||||
axisPointer: { type: "line", lineStyle: { color: chartColors.secondary, type: "dashed" } },
|
||||
},
|
||||
legend: {
|
||||
@@ -709,7 +1018,7 @@ const renderOnlineTrendChart = () => {
|
||||
xAxis: {
|
||||
type: "category",
|
||||
boundaryGap: false,
|
||||
data: onlineTrendList.value.map((item) => item.recordTime),
|
||||
data: trendList.map((item) => item.recordTime),
|
||||
axisLine: { lineStyle: { color: chartColors.grid } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: chartColors.muted, hideOverlap: true },
|
||||
@@ -728,10 +1037,12 @@ const renderOnlineTrendChart = () => {
|
||||
name: "设备在线率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
showSymbol: onlineTrendList.value.length <= 31,
|
||||
// 缺失快照的日期视觉上连续,悬停仍以"无快照"提示
|
||||
connectNulls: true,
|
||||
showSymbol: trendList.length <= 31,
|
||||
symbol: "circle",
|
||||
symbolSize: 7,
|
||||
data: onlineTrendList.value.map((item) => Number(item.onlineDeviceTotalRate || 0)),
|
||||
data: trendList.map((item) => item.onlineDeviceTotalRate),
|
||||
lineStyle: { color: chartColors.primary, width: 3 },
|
||||
itemStyle: { color: chartColors.primary, borderColor: "#ffffff", borderWidth: 2 },
|
||||
areaStyle: {
|
||||
@@ -824,7 +1135,15 @@ const renderAlarmTrendChart = () => {
|
||||
animation: !reduceMotion.value,
|
||||
animationDuration: 280,
|
||||
aria: { enabled: true },
|
||||
tooltip: { ...commonTooltip, trigger: "axis", valueFormatter: (value: any) => `${value} 条` },
|
||||
tooltip: {
|
||||
...commonTooltip,
|
||||
trigger: "axis",
|
||||
formatter: (params: any) => {
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
const rows = list.map((p: any) => `${p.marker}${p.seriesName}:${p.value} 条`).join("<br/>");
|
||||
return `${list[0]?.axisValue ?? ""}<br/>${rows}<br/><span style="color:#909399">点击查看当日告警</span>`;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
@@ -949,7 +1268,17 @@ const refreshDashboard = async (includeDeviceTable = true) => {
|
||||
try {
|
||||
const [statisticsErrors, alarmErrors] = await Promise.all([loadStatistics(), loadAlarmSummary()]);
|
||||
if (isUnmounted) return;
|
||||
if (includeDeviceTable && !deviceState.loading) await getDeviceDataList(false);
|
||||
if (includeDeviceTable) {
|
||||
if (!deviceState.loading) await getDeviceDataList(false);
|
||||
// 通道视图惰性加载:激活时随轮询刷新,未激活则置脏待切换时刷新。
|
||||
if (channelInited.value) {
|
||||
if (activeTab.value === "channel") {
|
||||
if (!channelState.loading) await getChannelDataList(false);
|
||||
} else {
|
||||
channelDirty.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
moduleErrors.value = [...statisticsErrors, ...alarmErrors];
|
||||
lastUpdated.value = dayjs().format("HH:mm:ss");
|
||||
} finally {
|
||||
@@ -969,7 +1298,59 @@ const handleRangeChange = () => {
|
||||
refreshDashboard(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用下钻筛选到设备明细表:切换到设备视图、写入筛选、回到第一页并滚动定位;
|
||||
* 再次点击同一来源则取消筛选。手动输入的设备名称等条件保留。
|
||||
*/
|
||||
const applyDeviceDrill = async (drill: DeviceDrill) => {
|
||||
const isToggleOff = activeDrill.value?.key === drill.key;
|
||||
activeTab.value = "device";
|
||||
|
||||
if (!isToggleOff && drill.key === "kpi-total") {
|
||||
// “设备总数”视为查看全部:清空设备视图的全部筛选条件。
|
||||
deviceQueryRef.value?.resetFields();
|
||||
Object.assign(deviceState.queryForm, { deviceName: "", deviceCategory: "", isOnline: "", isSync: "" });
|
||||
} else {
|
||||
Object.assign(deviceState.queryForm, { deviceCategory: "", isOnline: "" }, isToggleOff ? {} : drill.filters);
|
||||
}
|
||||
|
||||
activeDrill.value = isToggleOff ? null : { key: drill.key, label: drill.label };
|
||||
await getDeviceDataList();
|
||||
scrollToDeviceTable();
|
||||
};
|
||||
|
||||
const handleKpiClick = (item: KpiCard) => {
|
||||
if (!item.clickable) return;
|
||||
if (item.action === "alarm") {
|
||||
navigateToAlarmPage();
|
||||
return;
|
||||
}
|
||||
if (item.drill) applyDeviceDrill(item.drill);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭明细表头部的下钻筛选标签:仅清除下钻写入的分类/在线状态。
|
||||
*/
|
||||
const clearDrillFilters = () => {
|
||||
Object.assign(deviceState.queryForm, { deviceCategory: "", isOnline: "" });
|
||||
activeDrill.value = null;
|
||||
getDeviceDataList();
|
||||
};
|
||||
|
||||
/**
|
||||
* 下钻后滚动定位到设备明细表,遵循系统减弱动态效果偏好。
|
||||
*/
|
||||
const scrollToDeviceTable = () => {
|
||||
nextTick(() => {
|
||||
deviceTablePanelRef.value?.scrollIntoView({
|
||||
behavior: reduceMotion.value ? "auto" : "smooth",
|
||||
block: "start",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeviceSearch = () => {
|
||||
activeDrill.value = null;
|
||||
getDeviceDataList();
|
||||
};
|
||||
|
||||
@@ -979,10 +1360,59 @@ const resetDeviceQuery = () => {
|
||||
deviceName: "",
|
||||
deviceCategory: "",
|
||||
isOnline: "",
|
||||
isSync: "",
|
||||
});
|
||||
activeDrill.value = null;
|
||||
getDeviceDataList();
|
||||
};
|
||||
|
||||
/* ------------------------- 通道视图查询与操作 ------------------------- */
|
||||
const handleChannelSearch = () => {
|
||||
getChannelDataList();
|
||||
};
|
||||
|
||||
const resetChannelQuery = () => {
|
||||
channelQueryRef.value?.resetFields();
|
||||
Object.assign(channelState.queryForm, {
|
||||
channelName: "",
|
||||
deviceName: "",
|
||||
isOnline: "",
|
||||
isSync: "",
|
||||
});
|
||||
getChannelDataList();
|
||||
};
|
||||
|
||||
const channelSelectionChangHandle = (objs: { id: string }[]) => {
|
||||
channelSelectObjs.value = objs.map(({ id }) => id);
|
||||
channelMultiple.value = !objs.length;
|
||||
};
|
||||
|
||||
const handleChannelRefreshed = () => {
|
||||
getChannelDataList(false);
|
||||
};
|
||||
|
||||
const handleChannelDelete = async (ids: string[]) => {
|
||||
if (!ids.length) {
|
||||
message.warning("请先选择要删除的通道");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await useMessageBox().confirm("此操作将永久删除");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await delChannelObjs(ids);
|
||||
channelSelectObjs.value = [];
|
||||
channelMultiple.value = true;
|
||||
getChannelDataList(false);
|
||||
message.success("删除成功");
|
||||
} catch (err: any) {
|
||||
message.error(err.msg);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 跳转告警记录页时携带可用筛选条件,保持普通后台布局。
|
||||
*/
|
||||
@@ -1334,6 +1764,48 @@ onBeforeUnmount(() => {
|
||||
color: var(--dashboard-info);
|
||||
}
|
||||
|
||||
/* 可点击的指标卡:button 元素重置默认样式并提供完整交互态。 */
|
||||
.kpi-card--clickable {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--dashboard-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-card--active {
|
||||
border-color: currentColor;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
/* 下钻筛选生效标记:颜色之外同时提供文字标识。 */
|
||||
.kpi-card__drill-tag {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
@@ -1385,8 +1857,25 @@ onBeforeUnmount(() => {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.panel-header__metric,
|
||||
.table-total {
|
||||
.device-panel-header {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
/* 设备/通道分段切换器:仅作视图切换,内容区由 v-show 控制。 */
|
||||
.device-panel-tabs {
|
||||
margin: 0;
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header__metric {
|
||||
color: var(--dashboard-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -1448,6 +1937,20 @@ onBeforeUnmount(() => {
|
||||
min-width: auto !important;
|
||||
}
|
||||
|
||||
.channel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.channel-capability-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.device-table-panel :deep(.pagination-container) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
@@ -1518,6 +2021,10 @@ onBeforeUnmount(() => {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.device-panel-header .device-panel-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.device-filters {
|
||||
display: block;
|
||||
|
||||
@@ -1573,8 +2080,10 @@ onBeforeUnmount(() => {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.kpi-card,
|
||||
.kpi-card--clickable:active,
|
||||
.clickable-table :deep(.el-table__row) {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -36,9 +36,10 @@
|
||||
@queryTable="getDataList"></right-toolbar>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle">
|
||||
@sort-change="sortChangeHandle" :row-class-name="carRecordRowClassName">
|
||||
<el-table-column v-if="false" type="selection" width="40" align="center" />
|
||||
<el-table-column type="index" label="#" width="80" />
|
||||
|
||||
@@ -69,7 +70,7 @@
|
||||
<el-table-column label="车辆信息">
|
||||
<el-table-column prop="carDirect" width="120" label="行车方向">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_car_direct" :value="row.carDirect" />
|
||||
<AccessDirectionTag kind="car" :options="dh_car_direct" :value="row.carDirect" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="carColor" width="120" label="车辆颜色">
|
||||
@@ -167,6 +168,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="carAlarm">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { carRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { useDict } from '/@/hooks/dict';
|
||||
import { parseTime } from "/@/utils/formatTime"
|
||||
@@ -314,4 +318,4 @@ async function recoverPlaybackVideo(row: any) {
|
||||
display: inline-block;
|
||||
filter: blur(3px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -49,7 +49,8 @@
|
||||
<el-tab-pane label="过车数据" name="records" />
|
||||
<el-tab-pane label="清理数据" name="cleaned" />
|
||||
</el-tabs>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle" row-key="id" :row-class-name="tableRowClassName">
|
||||
<el-table-column v-if="activeTab === 'cleaned'" type="expand" width="64" label="详情">
|
||||
@@ -60,12 +61,12 @@
|
||||
<el-tag type="success" size="small">已保留</el-tag>
|
||||
</div>
|
||||
<el-table :data="[row.retainedRecord]" size="small" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" class="retained-record-table">
|
||||
:header-cell-style="tableStyle.headerCellStyle" class="retained-record-table access-record-table" :row-class-name="carRecordRowClassName">
|
||||
<el-table-column prop="id" min-width="190" label="记录ID" />
|
||||
<el-table-column prop="capTime" min-width="180" label="抓拍时间" />
|
||||
<el-table-column width="120" label="行车方向">
|
||||
<template #default="{ row: retainedRow }">
|
||||
<dict-tag :options="dh_car_direct" :value="retainedRow.carDirect" />
|
||||
<AccessDirectionTag kind="car" :options="dh_car_direct" :value="retainedRow.carDirect" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="180" label="车牌号码">
|
||||
@@ -123,7 +124,7 @@
|
||||
<el-table-column label="车辆信息">
|
||||
<el-table-column prop="carDirect" width="120" label="行车方向">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_car_direct" :value="row.carDirect" />
|
||||
<AccessDirectionTag kind="car" :options="dh_car_direct" :value="row.carDirect" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="strobeState" width="130" label="自动开闸结果">
|
||||
@@ -211,6 +212,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemCarRecord">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { carRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { ElLoading } from 'element-plus'
|
||||
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
@@ -282,7 +286,8 @@ const handleTabChange = () => {
|
||||
}
|
||||
|
||||
const tableRowClassName = ({ row }: { row: any }) => {
|
||||
return activeTab.value === 'cleaned' && !row.retainedRecord ? 'no-retained-record' : ''
|
||||
return [carRecordRowClassName({ row }), activeTab.value === 'cleaned' && !row.retainedRecord ? 'no-retained-record' : '']
|
||||
.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const exportExcel = () => {
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-row>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" height="50vh" border
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" height="50vh" border
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@sort-change="sortChangeHandle">
|
||||
@sort-change="sortChangeHandle" :row-class-name="carRecordRowClassName">
|
||||
<el-table-column type="selection" width="40" align="center" />
|
||||
<el-table-column type="index" label="#" width="80" />
|
||||
|
||||
@@ -25,7 +26,7 @@
|
||||
<el-table-column label="车辆信息">
|
||||
<el-table-column prop="carDirect" width="120" label="行车方向">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_car_direct" :value="row.carDirect" />
|
||||
<AccessDirectionTag kind="car" :options="dh_car_direct" :value="row.carDirect" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="carColor" width="120" label="车辆颜色">
|
||||
@@ -107,6 +108,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="carTimesTableDialog">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { carRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { useDict } from '/@/hooks/dict';
|
||||
|
||||
@@ -168,4 +172,4 @@ function openDialog(row: any) {
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -24,9 +24,10 @@
|
||||
<el-button icon="Refresh" @click="resetQuery">{{ $t('common.resetBtn') }}</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-table :data="state.dataList" v-loading="state.loading" height="50vh" border
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" height="50vh" border
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@sort-change="sortChangeHandle">
|
||||
@sort-change="sortChangeHandle" :row-class-name="carRecordRowClassName">
|
||||
<el-table-column prop="capTime" label="抓拍时间" min-width="180" />
|
||||
<el-table-column prop="personCode" min-width="120" label="异常出入类型">
|
||||
<template #default="{ row }">
|
||||
@@ -41,7 +42,7 @@
|
||||
<el-table-column label="车辆信息">
|
||||
<el-table-column prop="carDirect" width="120" label="行车方向">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_car_direct" :value="row.carDirect" />
|
||||
<AccessDirectionTag kind="car" :options="dh_car_direct" :value="row.carDirect" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="carColor" width="120" label="车辆颜色">
|
||||
@@ -116,6 +117,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="doorTimesTableDialog">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { carRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { useDict } from '/@/hooks/dict';
|
||||
import { parseTime } from "/@/utils/formatTime"
|
||||
@@ -170,4 +174,4 @@ function openDialog(query: any) {
|
||||
getDataList();
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
@@ -22,11 +22,16 @@
|
||||
<el-button icon="Download" @click="exportList">导出</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle">
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" :row-class-name="gateOperationRowClassName">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="devName" label="道闸设备" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="carDirectStr" label="方向" width="100" />
|
||||
<el-table-column prop="carDirectStr" label="方向" width="100">
|
||||
<template #default="{ row }">
|
||||
<AccessDirectionTag kind="gate" :value="row.carDirect" :label="row.carDirectStr" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="capDateStr" label="操作时间" width="180" />
|
||||
<el-table-column prop="operatorName" label="操作人" min-width="110" />
|
||||
<el-table-column prop="carNum" label="车牌号" min-width="120">
|
||||
@@ -46,6 +51,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="GateOperationList">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { gateOperationRowClassName } from '/@/utils/accessDirection';
|
||||
import dayjs from "dayjs";
|
||||
import * as GateApi from "/@/api/collection/gate";
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle">
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" :row-class-name="doorRecordRowClassName">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="swingTime" label="发生时间" width="180" />
|
||||
<el-table-column label="识别状态" width="120">
|
||||
@@ -56,7 +57,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="deptName" label="部门" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="enterOrExit" label="进出方向" width="110">
|
||||
<template #default="{ row }"><dict-tag :options="dh_enter_or_exit" :value="row.enterOrExit" /></template>
|
||||
<template #default="{ row }"><AccessDirectionTag kind="door" :options="dh_enter_or_exit" :value="row.enterOrExit" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceName" label="门禁设备" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="channelName" label="门禁通道" min-width="140" show-overflow-tooltip />
|
||||
@@ -81,6 +82,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="DoorCollectionRecordList">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { doorRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { h } from "vue";
|
||||
import { ElTag } from "element-plus";
|
||||
import * as DoorRecordApi from "/@/api/door/doorRecord";
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</el-tree>
|
||||
</div>
|
||||
<div class="dialog-video-player">
|
||||
<RtspPluginVideo ref="previewVideoRef" @changePage="videoChangePage" />
|
||||
<RtspPluginVideo ref="previewVideoRef" :active="state.dialogVisible" @changePage="videoChangePage" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -34,6 +34,7 @@ import * as DeviceManageApi from "/@/api/device/deviceManage"
|
||||
import * as DeviceChannelApi from "/@/api/device/deviceChannel"
|
||||
|
||||
const previewVideoRef = ref()
|
||||
let requestSequence = 0
|
||||
|
||||
const state = reactive<any>({
|
||||
dialogVisible: false,
|
||||
@@ -56,18 +57,22 @@ const channel = reactive<any>({
|
||||
|
||||
|
||||
const handleClose = () => {
|
||||
++requestSequence
|
||||
state.dialogVisible = false
|
||||
previewVideoRef.value?.destroy?.()
|
||||
}
|
||||
|
||||
// 打开弹窗
|
||||
const openDialog = async (type: string, code: string) => {
|
||||
if (!type || !code) return
|
||||
const requestId = ++requestSequence
|
||||
state.type = type
|
||||
state.code = code
|
||||
state.dialogVisible = true
|
||||
if (state.type === "device") {
|
||||
// 获取deviceManage信息
|
||||
await getdeviceManageData(code)
|
||||
if (requestId !== requestSequence || !state.dialogVisible) return
|
||||
await getDeviceChannelList()
|
||||
return
|
||||
}
|
||||
@@ -78,6 +83,7 @@ const openDialog = async (type: string, code: string) => {
|
||||
};
|
||||
|
||||
const videoChangePage = async ({ current, size }: any) => {
|
||||
const requestId = requestSequence
|
||||
state.current = current
|
||||
state.size = size
|
||||
console.log("state:", state);
|
||||
@@ -99,6 +105,7 @@ const videoChangePage = async ({ current, size }: any) => {
|
||||
snum: idx
|
||||
}))
|
||||
await previewVideoRef.value?.closeVideo()
|
||||
if (requestId !== requestSequence || !state.dialogVisible) return
|
||||
previewVideoRef.value?.setTotal(channelList.length)
|
||||
previewVideoRef.value?.startReal(pageList)
|
||||
}
|
||||
@@ -143,10 +150,12 @@ const handleNodeClick = (e: any) => {
|
||||
|
||||
// 初始化表单数据
|
||||
const getdeviceManageData = async (id: string) => {
|
||||
const requestId = requestSequence
|
||||
// 获取数据
|
||||
state.loading = true
|
||||
try {
|
||||
const { data, code } = await DeviceManageApi.getObj(id)
|
||||
if (requestId !== requestSequence || !state.dialogVisible) return
|
||||
if (code != 0) throw new Error()
|
||||
Object.assign(detail, data)
|
||||
} catch (error: any) {
|
||||
@@ -159,6 +168,7 @@ const getdeviceManageData = async (id: string) => {
|
||||
|
||||
// 查询通道树
|
||||
const getDeviceChannelList = async () => {
|
||||
const requestId = requestSequence
|
||||
state.treeLoading = true
|
||||
try {
|
||||
const _data: any = {}
|
||||
@@ -169,6 +179,7 @@ const getDeviceChannelList = async () => {
|
||||
_data.channelCode = state.code
|
||||
}
|
||||
const { data, code } = await DeviceChannelApi.list(_data)
|
||||
if (requestId !== requestSequence || !state.dialogVisible) return
|
||||
if (code != 0) throw new Error()
|
||||
channel.treeList = data
|
||||
channel.list = data
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="dialog-video-player">
|
||||
<RtspPluginVideo ref="replayVideoRef" @changePage="videoChangePage" />
|
||||
<RtspPluginVideo ref="replayVideoRef" :active="state.dialogVisible" @changePage="videoChangePage" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,6 +64,7 @@ const { dh_replay_source, dh_video_stream_type } = useDict("dh_replay_source", "
|
||||
let replayVideo: any = null
|
||||
|
||||
const replayVideoRef = ref()
|
||||
let requestSequence = 0
|
||||
|
||||
const state = reactive<any>({
|
||||
startTimestamp: null,
|
||||
@@ -126,21 +127,26 @@ const openDialog = async (type: string, code: string, timeRange?: { startTime: s
|
||||
}
|
||||
|
||||
const doSearch = async () => {
|
||||
const requestId = ++requestSequence
|
||||
// 确保接口查询与播放器时间戳一致
|
||||
syncTimestampsFromQuery()
|
||||
// 设备详情
|
||||
if (state.type === "device") {
|
||||
await getdeviceManageData(state.code);
|
||||
if (requestId !== requestSequence || !state.dialogVisible) return
|
||||
}
|
||||
// 设备通道详情
|
||||
await getDeviceChannelList(state.code)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
++requestSequence
|
||||
state.dialogVisible = false
|
||||
replayVideoRef.value?.destroy?.()
|
||||
}
|
||||
|
||||
const videoChangePage = async ({ current, size }: any) => {
|
||||
const requestId = requestSequence
|
||||
state.current = current
|
||||
state.size = size
|
||||
console.log("state:", state);
|
||||
@@ -168,6 +174,7 @@ const videoChangePage = async ({ current, size }: any) => {
|
||||
snum: idx
|
||||
}))
|
||||
await replayVideoRef.value?.closeVideo()
|
||||
if (requestId !== requestSequence || !state.dialogVisible) return
|
||||
replayVideoRef.value?.setTotal(channelList.length)
|
||||
replayVideoRef.value?.startPlayback(pageList)
|
||||
}
|
||||
@@ -222,10 +229,12 @@ const handleNodeClick = (e: any) => {
|
||||
|
||||
// 初始化表单数据
|
||||
const getdeviceManageData = async (deviceCode: string) => {
|
||||
const requestId = requestSequence
|
||||
// 获取数据
|
||||
state.loading = true
|
||||
try {
|
||||
const { data, code } = await DeviceManageApi.getByCode({ code: deviceCode })
|
||||
if (requestId !== requestSequence || !state.dialogVisible) return
|
||||
if (code != 0) throw new Error()
|
||||
Object.assign(detail, data)
|
||||
} catch (error: any) {
|
||||
@@ -238,6 +247,7 @@ const getdeviceManageData = async (deviceCode: string) => {
|
||||
|
||||
// 查询通道列表
|
||||
const getDeviceChannelList = async (codeStr: any) => {
|
||||
const requestId = requestSequence
|
||||
state.treeLoading = true
|
||||
try {
|
||||
const _data: any = {}
|
||||
@@ -251,6 +261,7 @@ const getDeviceChannelList = async (codeStr: any) => {
|
||||
_data.playbackGroupChannelCode = codeStr
|
||||
}
|
||||
const { data, code } = await DeviceChannelApi.list(_data)
|
||||
if (requestId !== requestSequence || !state.dialogVisible) return
|
||||
if (code != 0) throw new Error()
|
||||
if (!data || !data.length) {
|
||||
useMessage().error("查询不到设备通道")
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||
import * as DeviceManageApi from '/@/api/device/deviceManage';
|
||||
import * as DeviceChannelApi from '/@/api/device/deviceChannel';
|
||||
import { useMessage } from '/@/hooks/message';
|
||||
|
||||
export interface VideoChannel {
|
||||
channelCode: string;
|
||||
channelName?: string;
|
||||
channelSeq?: string | number;
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
export type ChannelSelectionMode = 'single' | 'multiple';
|
||||
interface VideoDialogOptions {
|
||||
mode: 'preview' | 'replay';
|
||||
getPlayOptions: () => Record<string, string | number> | null;
|
||||
}
|
||||
interface DialogPlayer {
|
||||
startReal: (channels: Record<string, unknown>[]) => Promise<void>;
|
||||
startPlayback: (channels: Record<string, unknown>[]) => Promise<void>;
|
||||
closeVideo: () => Promise<void>;
|
||||
destroy?: () => void;
|
||||
updateVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export function useVideoDialog(options: VideoDialogOptions) {
|
||||
const dialogVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadError = ref('');
|
||||
const channels = ref<VideoChannel[]>([]);
|
||||
const selectedCodes = ref<string[]>([]);
|
||||
const playingCodes = ref<string[]>([]);
|
||||
const selectionMode = ref<ChannelSelectionMode>('single');
|
||||
const playerRef = ref<DialogPlayer>();
|
||||
const deviceName = ref('');
|
||||
const currentPage = ref(1);
|
||||
const pageSize = 16;
|
||||
const divisionOptions = [1, 4, 9, 16];
|
||||
let sourceType = '';
|
||||
let sourceCode = '';
|
||||
let session = 0;
|
||||
let playRequest = 0;
|
||||
let appliedOptions: Record<string, string | number> = {};
|
||||
|
||||
const playingChannels = computed(() => {
|
||||
const codes = new Set(playingCodes.value);
|
||||
return channels.value.filter((channel) => codes.has(channel.channelCode));
|
||||
});
|
||||
const pageChannels = computed(() => playingChannels.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize));
|
||||
const division = computed(() => divisionOptions.find((count) => count >= pageChannels.value.length) || pageSize);
|
||||
const playingSummary = computed(() => {
|
||||
if (!playingChannels.value.length) return '尚未选择播放通道';
|
||||
if (playingChannels.value.length === 1) return playingChannels.value[0].channelName || '未命名通道';
|
||||
const summary = `共 ${playingChannels.value.length} 个通道`;
|
||||
return playingChannels.value.length > pageSize ? `${summary} · 当前第 ${(currentPage.value - 1) * pageSize + 1}–${Math.min(currentPage.value * pageSize, playingChannels.value.length)} 路` : summary;
|
||||
});
|
||||
|
||||
async function playPage() {
|
||||
const request = ++playRequest;
|
||||
const activeSession = session;
|
||||
const list = pageChannels.value.map((channel, snum) => ({
|
||||
...appliedOptions,
|
||||
channelId: channel.channelCode,
|
||||
channelName: channel.channelName,
|
||||
snum,
|
||||
}));
|
||||
await nextTick();
|
||||
const player = playerRef.value;
|
||||
if (!player || !dialogVisible.value || request !== playRequest) return;
|
||||
try {
|
||||
// 切换前清空旧窗口,避免取消选择的通道继续播放。
|
||||
await player.closeVideo();
|
||||
if (!dialogVisible.value || activeSession !== session || request !== playRequest || player !== playerRef.value) return;
|
||||
if (options.mode === 'preview') await player.startReal(list);
|
||||
else await player.startPlayback(list);
|
||||
} catch {
|
||||
if (dialogVisible.value && request === playRequest) useMessage().error('视频播放失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function playSelected() {
|
||||
if (loading.value || !selectedCodes.value.length) return;
|
||||
const nextOptions = options.getPlayOptions();
|
||||
if (!nextOptions) return;
|
||||
// 翻页沿用本次筛选快照,不受尚未提交的表单修改影响。
|
||||
appliedOptions = { ...nextOptions };
|
||||
playingCodes.value = [...selectedCodes.value];
|
||||
currentPage.value = 1;
|
||||
await playPage();
|
||||
}
|
||||
|
||||
function changeSelection(codes: string[]) {
|
||||
const available = new Set(channels.value.map((channel) => channel.channelCode));
|
||||
selectedCodes.value = [...new Set(codes)].filter((code) => available.has(code));
|
||||
if (selectionMode.value === 'single') {
|
||||
selectedCodes.value = selectedCodes.value.slice(0, 1);
|
||||
void playSelected();
|
||||
}
|
||||
}
|
||||
|
||||
function changeSelectionMode(mode: ChannelSelectionMode) {
|
||||
selectionMode.value = mode;
|
||||
if (mode === 'single') {
|
||||
selectedCodes.value = [selectedCodes.value[0] || playingCodes.value[0] || channels.value[0]?.channelCode].filter(Boolean);
|
||||
void playSelected();
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(page: number) {
|
||||
currentPage.value = Math.max(1, Math.min(page, Math.ceil(playingChannels.value.length / pageSize) || 1));
|
||||
await playPage();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
++session;
|
||||
++playRequest;
|
||||
dialogVisible.value = false;
|
||||
loading.value = false;
|
||||
playerRef.value?.updateVisible(false);
|
||||
if (playerRef.value?.destroy) playerRef.value.destroy();
|
||||
else void playerRef.value?.closeVideo();
|
||||
}
|
||||
|
||||
async function openDialog(type: string, code: string) {
|
||||
if (!type || !code) return;
|
||||
closeDialog();
|
||||
const activeSession = ++session;
|
||||
sourceType = type;
|
||||
sourceCode = code;
|
||||
channels.value = [];
|
||||
selectedCodes.value = [];
|
||||
playingCodes.value = [];
|
||||
deviceName.value = '';
|
||||
loadError.value = '';
|
||||
selectionMode.value = 'single';
|
||||
currentPage.value = 1;
|
||||
dialogVisible.value = true;
|
||||
loading.value = true;
|
||||
try {
|
||||
const query: Record<string, string> = {};
|
||||
if (type === 'device') {
|
||||
// 预览入口传设备 ID,回放入口传设备编码。
|
||||
const result = options.mode === 'preview' ? await DeviceManageApi.getObj(code) : await DeviceManageApi.getByCode({ code });
|
||||
if (activeSession !== session) return;
|
||||
if (result.code != 0 || !result.data?.deviceCode) throw new Error('获取设备信息失败');
|
||||
query.deviceCode = result.data.deviceCode;
|
||||
deviceName.value = result.data.deviceName || '';
|
||||
} else if (type === 'channel') query.channelCode = code;
|
||||
else if (type === 'playbackGroup') query.playbackGroupChannelCode = code;
|
||||
else throw new Error('不支持的视频来源');
|
||||
const result = await DeviceChannelApi.list(query);
|
||||
if (activeSession !== session) return;
|
||||
if (result.code != 0) throw new Error('加载通道失败,请重试');
|
||||
const seen = new Set<string>();
|
||||
channels.value = (Array.isArray(result.data) ? result.data : []).filter((channel: VideoChannel) => {
|
||||
const channelCode = channel.channelCode == null ? '' : String(channel.channelCode);
|
||||
if (!channelCode || seen.has(channelCode)) return false;
|
||||
seen.add(channelCode);
|
||||
return true;
|
||||
}).map((channel: VideoChannel) => ({ ...channel, channelCode: String(channel.channelCode) }));
|
||||
deviceName.value ||= channels.value[0]?.deviceName || (type === 'playbackGroup' ? '回放分组' : '设备视频');
|
||||
selectedCodes.value = channels.value.slice(0, 1).map((channel) => channel.channelCode);
|
||||
} catch (error) {
|
||||
if (activeSession === session) loadError.value = error instanceof Error ? error.message : '加载通道失败,请重试';
|
||||
} finally {
|
||||
if (activeSession === session) loading.value = false;
|
||||
}
|
||||
if (activeSession === session && dialogVisible.value) void playSelected();
|
||||
}
|
||||
|
||||
onBeforeUnmount(closeDialog);
|
||||
|
||||
return {
|
||||
dialogVisible, channels, selectedCodes, selectionMode, loading, loadError, playingCodes, playingChannels,
|
||||
deviceName, playerRef, currentPage, pageSize, division, divisionOptions, playingSummary,
|
||||
openDialog, closeDialog, changeSelection, changeSelectionMode, playSelected, changePage,
|
||||
reloadChannels: () => openDialog(sourceType, sourceCode),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<aside class="video-channel-panel" aria-label="通道选择">
|
||||
<div class="video-channel-panel__heading"><h3>通道列表</h3><span>{{ channels.length }}</span></div>
|
||||
<div class="video-channel-panel__tools">
|
||||
<el-input v-model="keyword" placeholder="搜索通道 / 设备" aria-label="搜索通道或设备" :prefix-icon="Search" clearable :disabled="loading" />
|
||||
<el-radio-group class="video-channel-panel__mode" :model-value="mode" aria-label="通道选择方式" :disabled="loading" @update:model-value="emit('update:mode', $event as ChannelSelectionMode)">
|
||||
<el-radio-button label="single">单选</el-radio-button>
|
||||
<el-radio-button label="multiple">多选</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="video-channel-panel__bulk">
|
||||
<span>{{ keyword ? `找到 ${filteredChannels.length} 个通道` : mode === 'single' ? '选择后直接播放' : '勾选通道后统一播放' }}</span>
|
||||
<div v-if="mode === 'multiple'">
|
||||
<el-button link type="primary" :disabled="loading || !filteredChannels.length" @click="selectAll">{{ keyword ? '全选结果' : '全选' }}</el-button>
|
||||
<el-button link :disabled="loading || !modelValue.length" @click="emit('update:modelValue', [])">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-loading="loading" class="video-channel-panel__list" :role="mode === 'single' ? 'radiogroup' : 'group'" aria-label="可选通道" :aria-busy="loading">
|
||||
<label v-for="channel in filteredChannels" :key="channel.channelCode" class="video-channel-row" :class="{ 'is-selected': modelValue.includes(channel.channelCode) }">
|
||||
<input :type="mode === 'single' ? 'radio' : 'checkbox'" :name="inputName" :value="channel.channelCode" :checked="modelValue.includes(channel.channelCode)" :disabled="loading" @change="toggleChannel(channel.channelCode, ($event.target as HTMLInputElement).checked)" />
|
||||
<span class="video-channel-row__icon"><el-icon aria-hidden="true"><VideoCamera /></el-icon></span>
|
||||
<span class="video-channel-row__text">
|
||||
<strong :title="channel.channelName || '未命名通道'">{{ channel.channelName || '未命名通道' }}</strong>
|
||||
<span :title="channel.deviceName">通道 {{ channel.channelSeq ?? '—' }}<template v-if="channel.deviceName"> · {{ channel.deviceName }}</template></span>
|
||||
</span>
|
||||
<span v-if="playingCodes.includes(channel.channelCode)" class="video-channel-row__current">当前</span>
|
||||
</label>
|
||||
<div v-if="!loading && !filteredChannels.length" class="video-channel-panel__empty">
|
||||
<el-icon aria-hidden="true"><Search v-if="keyword" /><VideoCamera v-else /></el-icon>
|
||||
<span>{{ error || (keyword ? '未找到匹配通道' : '暂无可用通道') }}</span>
|
||||
<el-button v-if="error" link type="primary" @click="emit('retry')">重新加载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-channel-panel__footer">
|
||||
<div class="video-channel-panel__selection" aria-live="polite"><span>已选 <strong>{{ modelValue.length }}</strong> 个通道</span><span v-if="hasChanges" class="video-channel-panel__pending">待播放</span></div>
|
||||
<el-button v-if="mode === 'multiple'" type="primary" :icon="VideoPlay" :disabled="loading || !modelValue.length" @click="emit('play')">播放所选</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, ref } from 'vue';
|
||||
import { Search, VideoCamera, VideoPlay } from '@element-plus/icons-vue';
|
||||
import type { ChannelSelectionMode, VideoChannel } from './useVideoDialog';
|
||||
|
||||
const props = defineProps<{
|
||||
channels: VideoChannel[];
|
||||
modelValue: string[];
|
||||
mode: ChannelSelectionMode;
|
||||
loading: boolean;
|
||||
playingCodes: string[];
|
||||
error?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', codes: string[]): void;
|
||||
(event: 'update:mode', mode: ChannelSelectionMode): void;
|
||||
(event: 'play'): void;
|
||||
(event: 'retry'): void;
|
||||
}>();
|
||||
const keyword = ref('');
|
||||
const inputName = `video-channel-${getCurrentInstance()?.uid}`;
|
||||
const filteredChannels = computed(() => {
|
||||
const query = keyword.value.trim().toLowerCase();
|
||||
return props.channels.filter((channel) => [channel.channelName, channel.channelSeq, channel.deviceName, channel.channelCode].join(' ').toLowerCase().includes(query));
|
||||
});
|
||||
const hasChanges = computed(() => props.mode === 'multiple' && (props.modelValue.length !== props.playingCodes.length || props.modelValue.some((code) => !props.playingCodes.includes(code))));
|
||||
const toggleChannel = (code: string, checked: boolean) => {
|
||||
if (props.mode === 'single') emit('update:modelValue', [code]);
|
||||
else emit('update:modelValue', checked ? [...new Set([...props.modelValue, code])] : props.modelValue.filter((value) => value !== code));
|
||||
};
|
||||
const selectAll = () => emit('update:modelValue', [...new Set([...props.modelValue, ...filteredChannels.value.map((channel) => channel.channelCode)])]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-channel-panel {
|
||||
width: 260px;
|
||||
flex: 0 0 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 18px 16px 14px;
|
||||
h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
> span { padding: 1px 7px; border-radius: 4px; background: var(--el-fill-color); color: var(--el-text-color-regular); font-size: 12px; }
|
||||
}
|
||||
&__tools { padding: 0 12px; }
|
||||
&__mode {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
:deep(.el-radio-button) { flex: 1; }
|
||||
:deep(.el-radio-button__inner) { width: 100%; }
|
||||
}
|
||||
&__bulk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
min-height: 42px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
.el-button { font-size: 12px; }
|
||||
.el-button + .el-button { margin-left: 8px; }
|
||||
}
|
||||
&__list { flex: 1; min-height: 100px; padding: 0 8px 8px; overflow-y: auto; scrollbar-width: thin; }
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 36px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
> .el-icon { font-size: 28px; color: var(--el-text-color-secondary); }
|
||||
}
|
||||
&__footer { display: flex; flex-direction: column; gap: 12px; padding: 14px 16px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
&__selection { display: flex; justify-content: space-between; align-items: center; gap: 8px; color: var(--el-text-color-regular); font-size: 12px; strong { color: var(--el-color-primary); font-weight: 600; } }
|
||||
&__pending { color: var(--el-color-primary); }
|
||||
}
|
||||
|
||||
.video-channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 66px;
|
||||
padding: 10px 8px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
&:hover { background: var(--el-fill-color-light); }
|
||||
&.is-selected { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary-light-7); }
|
||||
&:has(input:focus-visible) { outline: 2px solid var(--el-color-primary) !important; outline-offset: -2px; }
|
||||
input { width: 15px; height: 15px; flex: 0 0 15px; margin: 0; accent-color: var(--el-color-primary); cursor: pointer; }
|
||||
&__icon { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; color: var(--el-text-color-secondary); background: var(--el-fill-color-light); border-radius: 5px; }
|
||||
&.is-selected &__icon { color: var(--el-color-primary); background: var(--el-color-primary-light-8); }
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
strong, > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
strong { font-size: 13px; font-weight: 500; color: var(--el-text-color-primary); }
|
||||
> span { font-size: 11px; color: var(--el-text-color-regular); }
|
||||
}
|
||||
&__current { font-size: 11px; color: var(--el-color-primary); white-space: nowrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.video-channel-panel { width: 232px; flex-basis: 232px; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.video-channel-panel { width: 100%; flex-basis: auto; max-height: 290px; &__heading { padding-top: 12px; } &__list { min-height: 68px; } &__footer { flex-direction: row; align-items: center; justify-content: space-between; padding: 10px 16px; } }
|
||||
.video-channel-row { min-height: 58px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.video-channel-row { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
// 限定到视频弹窗,覆盖项目弹窗的全局滚动与高度规则。
|
||||
.el-overlay .el-overlay-dialog .el-dialog.device-video-dialog {
|
||||
--device-video-stage-bg: #111820;
|
||||
--device-video-stage-text: #aebaca;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1560px;
|
||||
height: min(860px, calc(100vh - 48px));
|
||||
height: min(860px, calc(100dvh - 48px));
|
||||
border-radius: 12px;
|
||||
|
||||
.el-dialog__header { flex: 0 0 auto; padding: 18px 24px; margin: 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.el-dialog__headerbtn { top: 12px; right: 16px; width: 40px; height: 40px; &:focus-visible { outline: 2px solid var(--el-color-primary) !important; outline-offset: -4px; } }
|
||||
.el-dialog__body { display: flex; flex: 1; min-height: 0; max-height: none !important; padding: 16px !important; overflow: hidden; background: var(--el-fill-color-lighter); }
|
||||
.el-dialog__footer { flex: 0 0 auto; padding: 12px 20px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.el-button:focus-visible, .el-radio-button:has(input:focus-visible) .el-radio-button__inner { outline: 2px solid var(--el-color-primary) !important; outline-offset: 2px; }
|
||||
.el-input__wrapper:focus-within, .el-select .el-input.is-focus .el-input__wrapper { box-shadow: 0 0 0 2px var(--el-color-primary) inset; }
|
||||
|
||||
.device-video-heading { display: flex; align-items: center; gap: 12px; min-width: 0; padding-right: 40px; h2 { margin: 0; white-space: nowrap; line-height: 28px; } }
|
||||
.device-video-heading__icon { display: grid; place-items: center; width: 32px; height: 32px; flex: 0 0 32px; border-radius: 8px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); font-size: 18px; }
|
||||
.device-video-heading__device { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--el-text-color-regular); border-left: 1px solid var(--el-border-color); padding-left: 12px; }
|
||||
.device-video-layout { display: flex; width: 100%; min-height: 0; min-width: 0; gap: 16px; }
|
||||
.device-video-workspace { display: flex; flex: 1; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: 8px; }
|
||||
.device-video-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex: 0 0 auto; min-height: 48px; padding: 10px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.device-video-toolbar__title { display: flex; align-items: center; gap: 8px; color: var(--el-text-color-primary); font-weight: 500; .el-icon { color: var(--el-text-color-regular); font-size: 16px; } }
|
||||
.device-video-toolbar__meta { color: var(--el-text-color-regular); font-size: 12px; white-space: nowrap; }
|
||||
.device-video-stage {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--device-video-stage-bg);
|
||||
container-type: size;
|
||||
.video-container {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
// 原生插件取实际 DOM 尺寸,保持 16:9 且不超出可用高度。
|
||||
@supports (width: 1cqh) { width: min(100cqw, calc(100cqh * 16 / 9)); }
|
||||
background: var(--device-video-stage-bg);
|
||||
}
|
||||
.loading-mask { display: grid; place-items: center; background: var(--device-video-stage-bg); color: var(--device-video-stage-text); font-size: 13px; }
|
||||
}
|
||||
.device-video-empty { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; text-align: center; color: var(--device-video-stage-text); > .el-icon { margin-bottom: 8px; font-size: 40px; } strong { color: #e4ebf3; font-size: 15px; font-weight: 500; } span { font-size: 13px; } }
|
||||
.device-video-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex: 0 0 auto; min-height: 42px; padding: 7px 16px; color: var(--el-text-color-regular); font-size: 12px; > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } }
|
||||
.device-video-query {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(330px, 1fr) minmax(100px, 132px) minmax(100px, 132px) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
flex: 0 0 auto;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
.el-form-item { min-width: 0; margin: 0; }
|
||||
.el-form-item__label { padding: 0; margin-bottom: 6px; font-size: 12px; line-height: 18px; }
|
||||
.el-form-item__content { min-width: 0; }
|
||||
.el-date-editor, .el-select { width: 100%; min-width: 0; }
|
||||
.el-date-editor .el-range-input { font-size: 12px; }
|
||||
.el-date-editor .el-range-separator { flex: 0 0 20px; font-size: 12px; }
|
||||
> .el-button { margin: 0; }
|
||||
}
|
||||
.device-video-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; > span { color: var(--el-text-color-regular); font-size: 12px; text-align: left; } }
|
||||
}
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.el-overlay .el-overlay-dialog .el-dialog.device-video-dialog .device-video-query { grid-template-columns: minmax(110px, 1fr) minmax(110px, 1fr) auto; .device-video-query__date { grid-column: 1 / -1; } }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.el-overlay .el-overlay-dialog .el-dialog.device-video-dialog {
|
||||
width: calc(100% - 16px) !important;
|
||||
height: calc(100vh - 16px);
|
||||
height: calc(100dvh - 16px);
|
||||
.el-dialog__header { padding: 12px 16px; }
|
||||
.el-dialog__body { padding: 8px !important; overflow-y: auto; }
|
||||
.el-dialog__footer { padding: 10px 16px; }
|
||||
.device-video-heading { gap: 8px; }
|
||||
.device-video-heading__device { padding-left: 8px; font-size: 12px; }
|
||||
.device-video-layout { flex-direction: column; gap: 8px; min-height: min-content; }
|
||||
.device-video-workspace { flex: 0 0 auto; min-height: 330px; overflow: visible; }
|
||||
.device-video-stage { flex: 0 0 auto; height: auto; min-height: 180px; aspect-ratio: 16 / 9; }
|
||||
.device-video-query { padding: 12px; gap: 8px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); > .el-button { grid-column: 1 / -1; } }
|
||||
.device-video-toolbar, .device-video-status { padding-left: 12px; padding-right: 12px; }
|
||||
.device-video-footer > span { max-width: 75%; line-height: 18px; }
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.el-dialog.device-video-dialog { --el-transition-duration: 0s; .el-dialog__close { transition: none !important; } }
|
||||
}
|
||||
@@ -64,6 +64,12 @@ const replayVideoRef = ref()
|
||||
const queryRef = ref()
|
||||
|
||||
const videoLoading = ref(false)
|
||||
let requestSequence = 0
|
||||
let pageActive = true
|
||||
|
||||
onActivated(() => { pageActive = true })
|
||||
onDeactivated(() => { pageActive = false; ++requestSequence; videoLoading.value = false })
|
||||
onBeforeUnmount(() => { pageActive = false; ++requestSequence })
|
||||
|
||||
const divisionSettingList = ref([9])
|
||||
|
||||
@@ -104,7 +110,12 @@ onMounted(() => {
|
||||
|
||||
// 树节点选择时
|
||||
const handleGetSelectNode = async (curClickedNode: any, checkedTreeNodes: any, isDeepTree: boolean, selectMap: any) => {
|
||||
if (!checkedTreeNodes || !checkedTreeNodes.length) return
|
||||
if (!checkedTreeNodes || !checkedTreeNodes.length) {
|
||||
state.channelCodeList = []
|
||||
state.deviceTreeMap = []
|
||||
await destroy()
|
||||
return
|
||||
}
|
||||
const list = checkedTreeNodes.filter((v: any) => v.deep == 2).map((v: any) => v.code)
|
||||
if (!list || !list.length) {
|
||||
await destroy()
|
||||
@@ -126,9 +137,13 @@ const videoChangePage = ({ current, size }: any) => {
|
||||
}
|
||||
|
||||
function init(type: string) {
|
||||
const requestId = ++requestSequence
|
||||
const isCurrent = () => pageActive && requestId === requestSequence && state.currentTab === type
|
||||
nextTick(async () => {
|
||||
if (!isCurrent()) return
|
||||
videoLoading.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
if (!isCurrent()) return
|
||||
// 查询通道列表
|
||||
let channelRecords = []
|
||||
let total = 0
|
||||
@@ -147,6 +162,7 @@ function init(type: string) {
|
||||
} catch (error) {
|
||||
console.log("分组播放意外的错误error:", error);
|
||||
}
|
||||
if (!isCurrent()) return
|
||||
switch (type) {
|
||||
case 'PREVIEW':
|
||||
const doorChannelList: any[] = []
|
||||
@@ -161,7 +177,8 @@ function init(type: string) {
|
||||
})
|
||||
}
|
||||
state.windowNum = doorChannelList.length > 9 ? 9 : doorChannelList.length
|
||||
previewVideoRef?.value?.closeVideo()
|
||||
await previewVideoRef?.value?.closeVideo()
|
||||
if (!isCurrent()) return
|
||||
previewVideoRef?.value?.setTotal(total)
|
||||
previewVideoRef.value?.startReal([...doorChannelList])
|
||||
break;
|
||||
@@ -183,7 +200,8 @@ function init(type: string) {
|
||||
})
|
||||
}
|
||||
state.windowNum = replayChannelList.length > 9 ? 9 : replayChannelList.length
|
||||
replayVideoRef?.value?.closeVideo()
|
||||
await replayVideoRef?.value?.closeVideo()
|
||||
if (!isCurrent()) return
|
||||
replayVideoRef?.value?.setTotal(total)
|
||||
replayVideoRef.value?.startPlayback([...replayChannelList])
|
||||
break;
|
||||
@@ -193,13 +211,16 @@ function init(type: string) {
|
||||
}
|
||||
|
||||
async function destroy() {
|
||||
++requestSequence
|
||||
videoLoading.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
try {
|
||||
previewVideoRef.value.destroy()
|
||||
replayVideoRef.value.destroy()
|
||||
await Promise.allSettled([
|
||||
previewVideoRef.value?.destroy?.(),
|
||||
replayVideoRef.value?.destroy?.()
|
||||
])
|
||||
} catch (error) {
|
||||
console.log("销毁视频失败", error);
|
||||
} finally {
|
||||
videoLoading.value = false
|
||||
}
|
||||
}
|
||||
@@ -285,4 +306,4 @@ function getDefaultDateRange() {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -31,9 +31,10 @@
|
||||
<el-button icon="Refresh" @click="resetQuery">{{ $t('common.resetBtn') }}</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-table :data="state.dataList" v-loading="state.loading" height="50vh" border
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" height="50vh" border
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@sort-change="sortChangeHandle">
|
||||
@sort-change="sortChangeHandle" :row-class-name="doorRecordRowClassName">
|
||||
<el-table-column prop="swingTime" label="抓拍时间" min-width="180" />
|
||||
<el-table-column prop="personCode" min-width="120" label="异常出入类型">
|
||||
<template #default="{ row }">
|
||||
@@ -73,7 +74,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="enterOrExit" min-width="100" label="进出门类型">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_enter_or_exit" :value="row.enterOrExit" />
|
||||
<AccessDirectionTag kind="door" :options="dh_enter_or_exit" :value="row.enterOrExit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="openResult" min-width="100" label="开门结果">
|
||||
@@ -105,6 +106,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="doorTimesTableDialog">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { doorRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { useDict } from '/@/hooks/dict';
|
||||
import { parseTime } from "/@/utils/formatTime"
|
||||
@@ -159,4 +163,4 @@ function openDialog(query: any) {
|
||||
getDataList();
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
@@ -33,9 +33,10 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-row>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle">
|
||||
@sort-change="sortChangeHandle" :row-class-name="doorRecordRowClassName">
|
||||
<el-table-column type="selection" width="40" align="center" />
|
||||
<el-table-column type="index" label="#" width="80" />
|
||||
|
||||
@@ -130,7 +131,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="enterOrExit" min-width="100" label="进出门类型">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_enter_or_exit" :value="row.enterOrExit" />
|
||||
<AccessDirectionTag kind="door" :options="dh_enter_or_exit" :value="row.enterOrExit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="openResult" min-width="100" label="开门结果">
|
||||
@@ -197,6 +198,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="doorAlarm">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { doorRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { useDict } from '/@/hooks/dict';
|
||||
import { parseTime } from "/@/utils/formatTime"
|
||||
@@ -347,4 +351,4 @@ async function recoverPlaybackVideo(row: any) {
|
||||
display: inline-block;
|
||||
filter: blur(3px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -45,7 +45,8 @@
|
||||
<el-tab-pane label="门禁数据" name="records" />
|
||||
<el-tab-pane label="清理数据" name="cleaned" />
|
||||
</el-tabs>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle" row-key="id" :row-class-name="tableRowClassName">
|
||||
<el-table-column v-if="activeTab === 'cleaned'" type="expand" width="64" label="详情">
|
||||
@@ -56,7 +57,7 @@
|
||||
<el-tag type="success" size="small">已保留</el-tag>
|
||||
</div>
|
||||
<el-table :data="[row.retainedRecord]" size="small" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" class="retained-record-table">
|
||||
:header-cell-style="tableStyle.headerCellStyle" class="retained-record-table access-record-table" :row-class-name="doorRecordRowClassName">
|
||||
<el-table-column prop="id" min-width="190" label="记录ID" />
|
||||
<el-table-column prop="swingTime" min-width="180" label="刷卡时间" />
|
||||
<el-table-column prop="personCode" min-width="120" label="人员编号">
|
||||
@@ -72,7 +73,7 @@
|
||||
<el-table-column prop="deptName" min-width="140" label="部门名称" show-overflow-tooltip />
|
||||
<el-table-column prop="enterOrExit" min-width="120" label="进出门类型">
|
||||
<template #default="{ row: retainedRow }">
|
||||
<dict-tag :options="dh_enter_or_exit" :value="retainedRow.enterOrExit" />
|
||||
<AccessDirectionTag kind="door" :options="dh_enter_or_exit" :value="retainedRow.enterOrExit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="openResult" min-width="100" label="开门结果">
|
||||
@@ -172,7 +173,7 @@
|
||||
|
||||
<el-table-column prop="enterOrExit" min-width="100" label="进出门类型">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_enter_or_exit" :value="row.enterOrExit" />
|
||||
<AccessDirectionTag kind="door" :options="dh_enter_or_exit" :value="row.enterOrExit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="openResult" min-width="100" label="开门结果">
|
||||
@@ -218,6 +219,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemDoorRecord">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { doorRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { ElLoading } from 'element-plus'
|
||||
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
@@ -279,7 +283,8 @@ const handleTabChange = () => {
|
||||
}
|
||||
|
||||
const tableRowClassName = ({ row }: { row: any }) => {
|
||||
return activeTab.value === 'cleaned' && !row.retainedRecord ? 'no-retained-record' : ''
|
||||
return [doorRecordRowClassName({ row }), activeTab.value === 'cleaned' && !row.retainedRecord ? 'no-retained-record' : '']
|
||||
.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="activity-feed" role="list" :aria-label="emptyText.replace('暂无', '')" tabindex="0">
|
||||
<article v-for="(record, index) in records" :key="record.id ?? index" class="activity-feed__item" role="listitem">
|
||||
<el-image class="activity-feed__image" :src="recordImage(record)" fit="cover" :alt="kind === 'door' ? '人员抓拍' : '车辆抓拍'" lazy>
|
||||
<template #error>
|
||||
<div class="activity-feed__placeholder" aria-hidden="true">
|
||||
<el-icon><User v-if="kind === 'door'" /><Van v-else /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="activity-feed__content">
|
||||
<div class="activity-feed__headline">
|
||||
<span class="activity-feed__name" :title="recordTitle(record)">{{ recordTitle(record) }}</span>
|
||||
<AccessDirectionTag v-if="recordDirection(record) != null" :kind="kind" :value="recordDirection(record)" />
|
||||
</div>
|
||||
<span class="activity-feed__location" :title="recordLocation(record)">{{ recordLocation(record) }}</span>
|
||||
<time class="activity-feed__time" :datetime="recordTime(record)?.replace(' ', 'T')">{{ recordTime(record) || '时间未知' }}</time>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!loading && !records.length" :image-size="52" :description="emptyText" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { User, Van } from '@element-plus/icons-vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { paseSingleImage } from '/@/utils/image';
|
||||
|
||||
interface ActivityRecord {
|
||||
id?: string | number;
|
||||
personName?: string;
|
||||
carNum?: string;
|
||||
channelName?: string;
|
||||
devChnName?: string;
|
||||
swingTime?: string;
|
||||
capTime?: string;
|
||||
parseRecordImage?: string;
|
||||
parseCarImgUrl?: string;
|
||||
enterOrExit?: string | number | null;
|
||||
carDirect?: string | number | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
kind: 'door' | 'car';
|
||||
records?: ActivityRecord[];
|
||||
loading?: boolean;
|
||||
emptyText?: string;
|
||||
}>(),
|
||||
{
|
||||
records: () => [],
|
||||
loading: false,
|
||||
emptyText: '暂无通行记录',
|
||||
}
|
||||
);
|
||||
|
||||
const recordTitle = (record: ActivityRecord) => (props.kind === 'door' ? record.personName || '未识别人员' : record.carNum || '未知车牌');
|
||||
const recordLocation = (record: ActivityRecord) => (props.kind === 'door' ? record.channelName : record.devChnName) || '位置未知';
|
||||
const recordTime = (record: ActivityRecord) => (props.kind === 'door' ? record.swingTime : record.capTime);
|
||||
const recordDirection = (record: ActivityRecord) => (props.kind === 'door' ? record.enterOrExit : record.carDirect);
|
||||
const recordImage = (record: ActivityRecord) => paseSingleImage((props.kind === 'door' ? record.parseRecordImage : record.parseCarImgUrl) || '');
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.activity-feed {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 12px;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--el-border-color) transparent;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary) !important;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 4px;
|
||||
background: var(--el-border-color);
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__image {
|
||||
flex: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
place-items: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name,
|
||||
&__location {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
&__location,
|
||||
&__time {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&__time {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
:deep(.access-direction-tag) {
|
||||
flex: none;
|
||||
min-height: 22px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
:deep(.el-empty) {
|
||||
min-height: 160px;
|
||||
|
||||
.el-empty__description {
|
||||
margin-top: 12px;
|
||||
}
|
||||
p {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,199 +1,196 @@
|
||||
<!-- 车辆信息 -->
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<com-title title="过军车数据">
|
||||
<div class="flex-1 flex justify-center cursor-pointer" @click="handleTodayClick">
|
||||
<span>昨日过军车{{ total.yesterday }}次</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center cursor-pointer" @click="handleMoreClick">
|
||||
<span>查看更多</span>
|
||||
<el-icon>
|
||||
<ArrowRightBold />
|
||||
</el-icon>
|
||||
</div>
|
||||
</com-title>
|
||||
<div v-if="false">
|
||||
<el-date-picker v-model="timer.queryParams.capTimeList" value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="datetimerange" @change="handleChange" :disabledDate="disabledDate" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
<div class="h-[5px]" />
|
||||
<div class="overflow-y-scroll flex-1">
|
||||
<div v-for="v in timer.data.records" :key="v.id" class="container flex items-center gap-2 mb-[5px]">
|
||||
<el-image class="w-[55px] h-[55px] rounded-[2px]" :src="paseSingleImage(v.parseCarImgUrl)"
|
||||
fit="cover" />
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="container-content">
|
||||
<span>车牌号:</span>
|
||||
<span>{{ v.carNum }}</span>
|
||||
</div>
|
||||
<div class="container-content">
|
||||
<span>位置:</span>
|
||||
<span>{{ v.devChnName }}</span>
|
||||
</div>
|
||||
<div class="container-content">
|
||||
<span>抓拍时间:</span>
|
||||
<span>{{ v.capTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="false" class="pb-[5px] flex justify-center items-center">
|
||||
<el-pagination size="small" :page-sizes="[20]" :page-size="20" background layout="prev, pager, next"
|
||||
@size-change="handleSizeChange" @current-change="handleCurrentChange" :total="timer.data.total" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="duty-panel duty-army-records">
|
||||
<com-title title="军车通行">
|
||||
<el-button class="duty-panel__more" text size="small" @click="handleMoreClick">
|
||||
更多<el-icon aria-hidden="true"><ArrowRightBold /></el-icon>
|
||||
</el-button>
|
||||
</com-title>
|
||||
<div class="duty-summary">
|
||||
<button class="duty-summary__today" type="button" @click="handleTodayClick" aria-label="查看今日军车通行记录">
|
||||
今日 <strong>{{ total.today }}</strong
|
||||
><span>次</span>
|
||||
</button>
|
||||
<span class="duty-summary__yesterday"
|
||||
>昨日 <strong>{{ total.yesterday }}</strong
|
||||
><span>次</span></span
|
||||
>
|
||||
</div>
|
||||
<ActivityFeed kind="car" :records="timer.data.records" :loading="loading" empty-text="暂无军车通行记录" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="carList">
|
||||
<script setup lang="ts" name="armyCarList">
|
||||
import ActivityFeed from './activity-feed.vue';
|
||||
import { dayjs } from 'element-plus';
|
||||
|
||||
import * as carApi from "/@/api/car/carRecord"
|
||||
import * as carApi from '/@/api/car/carRecord';
|
||||
|
||||
import { simplePoll } from "/@/utils/poll"
|
||||
import { paseSingleImage } from "/@/utils/image"
|
||||
import { simplePoll } from '/@/utils/poll';
|
||||
|
||||
const { push } = useRouter()
|
||||
const loading = ref(true);
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
const comTitle = defineAsyncComponent(() => import('./com-title.vue'));
|
||||
|
||||
const timer = reactive<any>({
|
||||
initial: null,
|
||||
interval: 30 * 1000,
|
||||
data: {
|
||||
records: [],
|
||||
total: 0
|
||||
},
|
||||
queryParams: {
|
||||
current: 1,
|
||||
size: 100,
|
||||
isArmyCar: "1",
|
||||
capTimeList: []
|
||||
}
|
||||
})
|
||||
initial: null,
|
||||
interval: 30 * 1000,
|
||||
data: {
|
||||
records: [],
|
||||
total: 0,
|
||||
},
|
||||
queryParams: {
|
||||
current: 1,
|
||||
size: 100,
|
||||
isArmyCar: '1',
|
||||
capTimeList: [],
|
||||
},
|
||||
});
|
||||
|
||||
const total = ref<any>({
|
||||
today: 0,
|
||||
yesterday: 0
|
||||
})
|
||||
today: 0,
|
||||
yesterday: 0,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
initListPoll()
|
||||
getTotal()
|
||||
})
|
||||
initListPoll();
|
||||
getTotal();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
timer.initial.destroy()
|
||||
timer.initial = null
|
||||
})
|
||||
timer.initial.destroy();
|
||||
timer.initial = null;
|
||||
});
|
||||
|
||||
// 查看更多
|
||||
const handleMoreClick = () => {
|
||||
const params = {
|
||||
isArmyCar: "1",
|
||||
}
|
||||
push({ path: "/car/carRecord/index", query: { data: JSON.stringify(params) } })
|
||||
}
|
||||
const params = {
|
||||
isArmyCar: '1',
|
||||
};
|
||||
push({
|
||||
path: '/car/carRecord/index',
|
||||
query: { data: JSON.stringify(params) },
|
||||
});
|
||||
};
|
||||
|
||||
const handleTodayClick = () => {
|
||||
const params = {
|
||||
capTimeList: [
|
||||
dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
||||
dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss')
|
||||
],
|
||||
isArmyCar: "1"
|
||||
}
|
||||
push({ path: "/car/carRecord/index", query: { data: JSON.stringify(params) } })
|
||||
}
|
||||
const params = {
|
||||
capTimeList: [dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss'), dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss')],
|
||||
isArmyCar: '1',
|
||||
};
|
||||
push({
|
||||
path: '/car/carRecord/index',
|
||||
query: { data: JSON.stringify(params) },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSizeChange = (val: number) => {
|
||||
timer.queryParams.current = 1
|
||||
timer.queryParams.size = val
|
||||
timer.initial.executeOnce()
|
||||
}
|
||||
timer.queryParams.current = 1;
|
||||
timer.queryParams.size = val;
|
||||
timer.initial.executeOnce();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (val: number) => {
|
||||
timer.queryParams.current = val
|
||||
timer.initial.executeOnce()
|
||||
}
|
||||
timer.queryParams.current = val;
|
||||
timer.initial.executeOnce();
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
handleCurrentChange(1)
|
||||
}
|
||||
handleCurrentChange(1);
|
||||
};
|
||||
|
||||
// 限制日期选择范围为昨天和今天
|
||||
function disabledDate(time: Date) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
// 只允许选择昨天和今天
|
||||
return time.getTime() < yesterday.getTime() || time.getTime() >= tomorrow.getTime()
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
// 只允许选择昨天和今天
|
||||
return time.getTime() < yesterday.getTime() || time.getTime() >= tomorrow.getTime();
|
||||
}
|
||||
|
||||
const getTotal = async () => {
|
||||
try {
|
||||
const _params = {}
|
||||
const { code, data } = await carApi.armyCarCountByDateType({ ..._params });
|
||||
if (code != 0) throw new Error("code!=0")
|
||||
console.log("军车出车次数(根据时间类型):", data);
|
||||
if (!data) return
|
||||
const today = data.find((v: any) => v.dateType == 'TODAY')
|
||||
const yesterday = data.find((v: any) => v.dateType == 'YESTERDAY')
|
||||
total.value = {
|
||||
today: today?.timeCount || 0,
|
||||
yesterday: yesterday?.timeCount || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("|军车出车次数(根据时间类型)|意外的错误:", error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const _params = {};
|
||||
const { code, data } = await carApi.armyCarCountByDateType({ ..._params });
|
||||
if (code != 0) throw new Error('code!=0');
|
||||
console.log('军车出车次数(根据时间类型):', data);
|
||||
if (!data) return;
|
||||
const today = data.find((v: any) => v.dateType == 'TODAY');
|
||||
const yesterday = data.find((v: any) => v.dateType == 'YESTERDAY');
|
||||
total.value = {
|
||||
today: today?.timeCount || 0,
|
||||
yesterday: yesterday?.timeCount || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('|军车出车次数(根据时间类型)|意外的错误:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 过车列表
|
||||
const initListPoll = () => {
|
||||
timer.initial = simplePoll(async () => {
|
||||
try {
|
||||
const { code, data } = await carApi.totalPage({ ...timer.queryParams });
|
||||
if (code != 0) throw new Error("code!=0")
|
||||
console.log("过车列表:", data);
|
||||
if (!data || !data.records) return
|
||||
timer.data.records = data.records
|
||||
timer.data.total = data.total
|
||||
console.log("timer.data.total:", timer.data.total);
|
||||
|
||||
} catch (error) {
|
||||
console.error("|过车列表|意外的错误:", error);
|
||||
}
|
||||
}, timer.interval);
|
||||
}
|
||||
|
||||
timer.initial = simplePoll(async () => {
|
||||
try {
|
||||
const { code, data } = await carApi.totalPage({ ...timer.queryParams });
|
||||
if (code != 0) throw new Error('code!=0');
|
||||
console.log('过车列表:', data);
|
||||
if (!data || !data.records) return;
|
||||
timer.data.records = data.records;
|
||||
timer.data.total = data.total;
|
||||
console.log('timer.data.total:', timer.data.total);
|
||||
} catch (error) {
|
||||
console.error('|过车列表|意外的错误:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}, timer.interval);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
padding: 4px 6px;
|
||||
background-color: white;
|
||||
box-shadow: 0px 4px 13px -2px rgba(210, 210, 210, 0.5);
|
||||
border-radius: 6px;
|
||||
.duty-summary {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
|
||||
.container-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&__today,
|
||||
&__yesterday {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
&__today {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
span:nth-child(1) {
|
||||
min-width: 70px;
|
||||
}
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
strong {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
strong {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,185 +1,143 @@
|
||||
<!-- 车辆信息 -->
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<com-title title="过车数据">
|
||||
<div class="flex items-center justify-center cursor-pointer" @click="handleMoreClick">
|
||||
<span>查看更多</span>
|
||||
<el-icon>
|
||||
<ArrowRightBold />
|
||||
</el-icon>
|
||||
</div>
|
||||
</com-title>
|
||||
<div v-if="false">
|
||||
<el-date-picker v-model="timer.queryParams.capTimeList" value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="datetimerange" @change="handleChange" :disabledDate="disabledDate" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
<div class="h-[5px]" />
|
||||
<div class="overflow-y-scroll flex-1">
|
||||
<div v-for="v in timer.data.records" :key="v.id" class="container flex items-center gap-2 mb-[5px]">
|
||||
<el-image class="w-[55px] h-[55px] rounded-[2px]" :src="paseSingleImage(v.parseCarImgUrl)"
|
||||
fit="cover" />
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="container-content">
|
||||
<span>车牌号:</span>
|
||||
<span>{{ v.carNum }}</span>
|
||||
</div>
|
||||
<div class="container-content">
|
||||
<span>位置:</span>
|
||||
<span>{{ v.devChnName }}</span>
|
||||
</div>
|
||||
<div class="container-content">
|
||||
<span>抓拍时间:</span>
|
||||
<span>{{ v.capTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-[5px] flex justify-center items-center">
|
||||
<el-pagination size="small" :page-sizes="[20]" :page-size="20" background layout="prev, pager, next"
|
||||
@size-change="handleSizeChange" @current-change="handleCurrentChange" :total="timer.data.total" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="duty-panel duty-car-records">
|
||||
<com-title title="车辆通行">
|
||||
<el-button class="duty-panel__more" text size="small" @click="handleMoreClick">
|
||||
更多<el-icon aria-hidden="true"><ArrowRightBold /></el-icon>
|
||||
</el-button>
|
||||
</com-title>
|
||||
<ActivityFeed kind="car" :records="timer.data.records" :loading="loading" empty-text="暂无车辆通行记录" />
|
||||
<footer v-if="timer.data.total > timer.queryParams.size" class="duty-record-footer">
|
||||
<el-pagination
|
||||
small
|
||||
:pager-count="5"
|
||||
:page-size="timer.queryParams.size"
|
||||
:current-page="timer.queryParams.current"
|
||||
layout="prev, pager, next"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
:total="timer.data.total"
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="carList">
|
||||
import ActivityFeed from './activity-feed.vue';
|
||||
import { dayjs } from 'element-plus';
|
||||
|
||||
import * as carApi from "/@/api/car/carRecord"
|
||||
import * as carApi from '/@/api/car/carRecord';
|
||||
|
||||
import { simplePoll } from "/@/utils/poll"
|
||||
import { paseSingleImage } from "/@/utils/image"
|
||||
import { simplePoll } from '/@/utils/poll';
|
||||
|
||||
const { push } = useRouter()
|
||||
const loading = ref(true);
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
const comTitle = defineAsyncComponent(() => import('./com-title.vue'));
|
||||
|
||||
const timer = reactive<any>({
|
||||
initial: null,
|
||||
interval: 30 * 1000,
|
||||
data: {
|
||||
records: [],
|
||||
total: 0
|
||||
},
|
||||
queryParams: {
|
||||
current: 1,
|
||||
size: 20,
|
||||
isArmyCar: "0",
|
||||
capTimeList: []
|
||||
}
|
||||
})
|
||||
initial: null,
|
||||
interval: 30 * 1000,
|
||||
data: {
|
||||
records: [],
|
||||
total: 0,
|
||||
},
|
||||
queryParams: {
|
||||
current: 1,
|
||||
size: 20,
|
||||
isArmyCar: '0',
|
||||
capTimeList: [],
|
||||
},
|
||||
});
|
||||
|
||||
const total = ref<any>({
|
||||
today: 0,
|
||||
yesterday: 0
|
||||
})
|
||||
today: 0,
|
||||
yesterday: 0,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
initListPoll()
|
||||
// getTotal()
|
||||
})
|
||||
initListPoll();
|
||||
// getTotal()
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
timer.initial.destroy()
|
||||
timer.initial = null
|
||||
})
|
||||
timer.initial.destroy();
|
||||
timer.initial = null;
|
||||
});
|
||||
|
||||
// 查看更多
|
||||
const handleMoreClick = () => {
|
||||
const params = {
|
||||
isArmyCar: "0",
|
||||
}
|
||||
push({ path: "/car/carRecord/index", query: { data: JSON.stringify(params) } })
|
||||
}
|
||||
const params = {
|
||||
isArmyCar: '0',
|
||||
};
|
||||
push({
|
||||
path: '/car/carRecord/index',
|
||||
query: { data: JSON.stringify(params) },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSizeChange = (val: number) => {
|
||||
timer.queryParams.current = 1
|
||||
timer.queryParams.size = val
|
||||
timer.initial.executeOnce()
|
||||
}
|
||||
timer.queryParams.current = 1;
|
||||
timer.queryParams.size = val;
|
||||
timer.initial.executeOnce();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (val: number) => {
|
||||
timer.queryParams.current = val
|
||||
timer.initial.executeOnce()
|
||||
}
|
||||
timer.queryParams.current = val;
|
||||
timer.initial.executeOnce();
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
handleCurrentChange(1)
|
||||
}
|
||||
handleCurrentChange(1);
|
||||
};
|
||||
|
||||
// 限制日期选择范围为昨天和今天
|
||||
function disabledDate(time: Date) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
// 只允许选择昨天和今天
|
||||
return time.getTime() < yesterday.getTime() || time.getTime() >= tomorrow.getTime()
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
// 只允许选择昨天和今天
|
||||
return time.getTime() < yesterday.getTime() || time.getTime() >= tomorrow.getTime();
|
||||
}
|
||||
|
||||
const getTotal = async () => {
|
||||
try {
|
||||
const _params = {}
|
||||
const { code, data } = await carApi.armyCarCountByDateType({ ..._params });
|
||||
if (code != 0) throw new Error("code!=0")
|
||||
console.log("军车出车次数(根据时间类型):", data);
|
||||
if (!data) return
|
||||
const today = data.find((v: any) => v.dateType == 'TODAY')
|
||||
const yesterday = data.find((v: any) => v.dateType == 'YESTERDAY')
|
||||
total.value = {
|
||||
today: today?.timeCount || 0,
|
||||
yesterday: yesterday?.timeCount || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("|军车出车次数(根据时间类型)|意外的错误:", error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const _params = {};
|
||||
const { code, data } = await carApi.armyCarCountByDateType({ ..._params });
|
||||
if (code != 0) throw new Error('code!=0');
|
||||
console.log('军车出车次数(根据时间类型):', data);
|
||||
if (!data) return;
|
||||
const today = data.find((v: any) => v.dateType == 'TODAY');
|
||||
const yesterday = data.find((v: any) => v.dateType == 'YESTERDAY');
|
||||
total.value = {
|
||||
today: today?.timeCount || 0,
|
||||
yesterday: yesterday?.timeCount || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('|军车出车次数(根据时间类型)|意外的错误:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 过车列表
|
||||
const initListPoll = () => {
|
||||
timer.initial = simplePoll(async () => {
|
||||
try {
|
||||
const { code, data } = await carApi.totalPage({ ...timer.queryParams });
|
||||
if (code != 0) throw new Error("code!=0")
|
||||
console.log("过车列表:", data);
|
||||
if (!data || !data.records) return
|
||||
timer.data.records = data.records
|
||||
timer.data.total = data.total
|
||||
console.log("timer.data.total:", timer.data.total);
|
||||
|
||||
} catch (error) {
|
||||
console.error("|过车列表|意外的错误:", error);
|
||||
}
|
||||
}, timer.interval);
|
||||
}
|
||||
|
||||
timer.initial = simplePoll(async () => {
|
||||
try {
|
||||
const { code, data } = await carApi.totalPage({ ...timer.queryParams });
|
||||
if (code != 0) throw new Error('code!=0');
|
||||
console.log('过车列表:', data);
|
||||
if (!data || !data.records) return;
|
||||
timer.data.records = data.records;
|
||||
timer.data.total = data.total;
|
||||
console.log('timer.data.total:', timer.data.total);
|
||||
} catch (error) {
|
||||
console.error('|过车列表|意外的错误:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}, timer.interval);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
padding: 4px 6px;
|
||||
background-color: white;
|
||||
box-shadow: 0px 4px 13px -2px rgba(210, 210, 210, 0.5);
|
||||
border-radius: 6px;
|
||||
|
||||
.container-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
span:nth-child(1) {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +1,59 @@
|
||||
<template>
|
||||
<div class="title">
|
||||
<div class="title-text" :class="props.titleDirection == 'left' ? 'justify-start' : 'justify-end'">
|
||||
<span>{{ props.title }}</span>
|
||||
</div>
|
||||
<div class="flex-1 h-full flex items-center justify-end">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<header class="duty-panel-heading">
|
||||
<h2 class="duty-panel-heading__title" :class="{ 'is-right': props.titleDirection === 'right' }">
|
||||
{{ props.title }}
|
||||
</h2>
|
||||
<div v-if="$slots.default" class="duty-panel-heading__actions">
|
||||
<slot />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="comTitle">
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
titleDirection: "left" | "right"
|
||||
}
|
||||
title: string;
|
||||
titleDirection?: 'left' | 'right';
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: "标题",
|
||||
titleDirection: "left"
|
||||
})
|
||||
|
||||
title: '标题',
|
||||
titleDirection: 'left',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 35px;
|
||||
.duty-panel-heading {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
min-height: 52px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&-text {
|
||||
display: flex;
|
||||
padding: 0 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #577cf7;
|
||||
}
|
||||
&__title {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
|
||||
&.is-right {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,155 +1,124 @@
|
||||
<!-- 车辆信息 -->
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<com-title title="过人数据">
|
||||
<div class="flex items-center justify-center cursor-pointer" @click="handleMoreClick">
|
||||
<span>查看更多</span>
|
||||
<el-icon>
|
||||
<ArrowRightBold />
|
||||
</el-icon>
|
||||
</div>
|
||||
</com-title>
|
||||
<div>
|
||||
<el-date-picker v-model="timer.queryParams.swingTimeList" value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="datetimerange" @change="handleChange" :disabledDate="disabledDate" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
<div class="h-[5px]" />
|
||||
<div class="overflow-y-scroll flex-1">
|
||||
<div v-for="v in timer.data.records" :key="v.id"
|
||||
class="container flex items-center gap-2 mb-[5px] overflow-y-scroll">
|
||||
<el-image class="w-[55px] h-[55px] rounded-[2px]" :src="paseSingleImage(v.parseRecordImage)"
|
||||
fit="cover" />
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="container-content">
|
||||
<span>人员</span>
|
||||
<span>{{ v.personName }}</span>
|
||||
</div>
|
||||
<div class="container-content">
|
||||
<span>位置</span>
|
||||
<span>{{ v.channelName }}</span>
|
||||
</div>
|
||||
<div class="container-content">
|
||||
<span>抓拍时间</span>
|
||||
<span>{{ v.swingTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-[5px] flex justify-center items-center">
|
||||
<el-pagination size="small" :page-sizes="[20]" :page-size="20" background layout="prev, pager, next"
|
||||
@size-change="handleSizeChange" @current-change="handleCurrentChange" :total="timer.data.total" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="duty-panel duty-person-records">
|
||||
<com-title title="人员通行">
|
||||
<el-button class="duty-panel__more" text size="small" @click="handleMoreClick">
|
||||
更多<el-icon aria-hidden="true"><ArrowRightBold /></el-icon>
|
||||
</el-button>
|
||||
</com-title>
|
||||
<div class="duty-record-filter">
|
||||
<el-date-picker
|
||||
v-model="timer.queryParams.swingTimeList"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="MM-DD HH:mm"
|
||||
type="datetimerange"
|
||||
size="small"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
range-separator="至"
|
||||
aria-label="筛选人员通行时间"
|
||||
@change="handleChange"
|
||||
:disabled-date="disabledDate"
|
||||
/>
|
||||
</div>
|
||||
<ActivityFeed kind="door" :records="timer.data.records" :loading="loading" empty-text="暂无人员通行记录" />
|
||||
<footer v-if="timer.data.total > timer.queryParams.size" class="duty-record-footer">
|
||||
<el-pagination
|
||||
small
|
||||
:pager-count="5"
|
||||
:page-size="timer.queryParams.size"
|
||||
:current-page="timer.queryParams.current"
|
||||
layout="prev, pager, next"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
:total="timer.data.total"
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="carList">
|
||||
import * as doorApi from "/@/api/door/doorRecord"
|
||||
<script setup lang="ts" name="doorList">
|
||||
import ActivityFeed from './activity-feed.vue';
|
||||
import * as doorApi from '/@/api/door/doorRecord';
|
||||
|
||||
import { simplePoll } from "/@/utils/poll"
|
||||
import { paseSingleImage } from "/@/utils/image"
|
||||
import { simplePoll } from '/@/utils/poll';
|
||||
|
||||
const { push } = useRouter()
|
||||
const loading = ref(true);
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
const comTitle = defineAsyncComponent(() => import('./com-title.vue'));
|
||||
|
||||
const timer = reactive<any>({
|
||||
initial: null,
|
||||
interval: 10 * 1000,
|
||||
data: {
|
||||
records: [],
|
||||
total: 20
|
||||
},
|
||||
queryParams: {
|
||||
swingTimeList: [],
|
||||
current: 1,
|
||||
size: 20
|
||||
}
|
||||
})
|
||||
initial: null,
|
||||
interval: 10 * 1000,
|
||||
data: {
|
||||
records: [],
|
||||
total: 20,
|
||||
},
|
||||
queryParams: {
|
||||
swingTimeList: [],
|
||||
current: 1,
|
||||
size: 20,
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
initListPoll()
|
||||
})
|
||||
initListPoll();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
timer.initial.destroy()
|
||||
timer.initial = null
|
||||
})
|
||||
timer.initial.destroy();
|
||||
timer.initial = null;
|
||||
});
|
||||
|
||||
// 查看更多
|
||||
const handleMoreClick = () => {
|
||||
push({ path: "/door/doorRecord/index" })
|
||||
}
|
||||
push({ path: '/door/doorRecord/index' });
|
||||
};
|
||||
|
||||
const handleSizeChange = (val: number) => {
|
||||
timer.queryParams.current = 1
|
||||
timer.queryParams.size = val
|
||||
timer.initial.executeOnce()
|
||||
}
|
||||
timer.queryParams.current = 1;
|
||||
timer.queryParams.size = val;
|
||||
timer.initial.executeOnce();
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
handleCurrentChange(1)
|
||||
}
|
||||
handleCurrentChange(1);
|
||||
};
|
||||
|
||||
const handleCurrentChange = (val: number) => {
|
||||
timer.queryParams.current = val
|
||||
timer.initial.executeOnce()
|
||||
}
|
||||
timer.queryParams.current = val;
|
||||
timer.initial.executeOnce();
|
||||
};
|
||||
|
||||
// 限制日期选择范围为昨天和今天
|
||||
function disabledDate(time: Date) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
// 只允许选择昨天和今天
|
||||
return time.getTime() < yesterday.getTime() || time.getTime() >= tomorrow.getTime()
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
// 只允许选择昨天和今天
|
||||
return time.getTime() < yesterday.getTime() || time.getTime() >= tomorrow.getTime();
|
||||
}
|
||||
|
||||
// 过人数据
|
||||
const initListPoll = () => {
|
||||
timer.initial = simplePoll(async () => {
|
||||
try {
|
||||
const { code, data } = await doorApi.totalPage({ ...timer.queryParams });
|
||||
if (code != 0) throw new Error("code!=0")
|
||||
console.log("过人数据:", data);
|
||||
if (!data || !data.records) return
|
||||
timer.data.records = data.records
|
||||
timer.data.total = data.total
|
||||
} catch (error) {
|
||||
console.error("|过人数据|意外的错误:", error);
|
||||
}
|
||||
}, timer.interval);
|
||||
}
|
||||
|
||||
|
||||
timer.initial = simplePoll(async () => {
|
||||
try {
|
||||
const { code, data } = await doorApi.totalPage({ ...timer.queryParams });
|
||||
if (code != 0) throw new Error('code!=0');
|
||||
console.log('过人数据:', data);
|
||||
if (!data || !data.records) return;
|
||||
timer.data.records = data.records;
|
||||
timer.data.total = data.total;
|
||||
} catch (error) {
|
||||
console.error('|过人数据|意外的错误:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}, timer.interval);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
padding: 4px 6px;
|
||||
background-color: white;
|
||||
box-shadow: 0px 4px 13px -2px rgba(210, 210, 210, 0.5);
|
||||
border-radius: 6px;
|
||||
|
||||
.container-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
span:nth-child(1) {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,154 +1,176 @@
|
||||
<!-- 排班视图 -->
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<com-title title="基地周值班表">
|
||||
<select-duty v-model="refreshForm.dutyId" autoFirst style="width: 250px;" />
|
||||
</com-title>
|
||||
<el-table v-if="tableHeaderList && tableHeaderList.length" style="padding-top: 10px;" :data="disposeDataList"
|
||||
v-loading="state.loading" border
|
||||
:cell-style="{ ...tableStyle.cellStyle, fontSize: '14px', padding: '8px 0 !important' }"
|
||||
:header-cell-style="{ ...tableStyle.headerCellStyle, fontSize: '12px', padding: '8px 0 !important' }"
|
||||
@sort-change="sortChangeHandle">
|
||||
<el-table-column v-for="(v, i) in tableHeaderList" :width="getWidth(v)" :prop="v.prop" :label="v.label">
|
||||
<el-table-column v-if="v.children && v.children.length" v-for="(v2, i2) in v.children" :prop="v2.prop"
|
||||
:label="v2.label" />
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty class="w-full h-full flex justify-center items-center" v-else description="暂无排班数据" />
|
||||
</div>
|
||||
<section class="duty-panel duty-roster">
|
||||
<com-title title="基地周值班表">
|
||||
<div class="duty-panel__select">
|
||||
<select-duty v-model="refreshForm.dutyId" autoFirst aria-label="选择值班计划" />
|
||||
</div>
|
||||
</com-title>
|
||||
<div class="duty-panel__body duty-roster__body" v-loading="state.loading">
|
||||
<el-table
|
||||
v-if="tableHeaderList.length"
|
||||
class="duty-roster__table"
|
||||
:data="disposeDataList"
|
||||
height="100%"
|
||||
border
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
@sort-change="sortChangeHandle"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="(v, i) in tableHeaderList"
|
||||
:key="v.prop || v.label"
|
||||
:min-width="i === 0 ? 112 : 96"
|
||||
:fixed="i === 0 ? 'left' : false"
|
||||
:prop="v.prop"
|
||||
:label="v.label"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<el-table-column
|
||||
v-if="v.children && v.children.length"
|
||||
v-for="v2 in v.children"
|
||||
:key="v2.prop"
|
||||
:prop="v2.prop"
|
||||
:label="v2.label"
|
||||
min-width="88"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else :image-size="64" description="暂无排班数据" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="dutyTable">
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { BasicTableProps, useTable } from '/@/hooks/table';
|
||||
|
||||
import * as sysDutyApi from "/@/api/sys/sysDuty";
|
||||
import * as sysDutyItemApi from "/@/api/sys/sysDutyItem";
|
||||
import * as sysDutyApi from '/@/api/sys/sysDuty';
|
||||
import * as sysDutyItemApi from '/@/api/sys/sysDutyItem';
|
||||
|
||||
const comTitle = defineAsyncComponent(() => import('./com-title.vue'));
|
||||
|
||||
const tableHeaderList = ref<Array<{
|
||||
prop: string,
|
||||
label: string,
|
||||
children: Array<{
|
||||
prop: string,
|
||||
label: string,
|
||||
}>
|
||||
}>>([])
|
||||
const tableHeaderList = ref<
|
||||
Array<{
|
||||
prop: string;
|
||||
label: string;
|
||||
children: Array<{
|
||||
prop: string;
|
||||
label: string;
|
||||
}>;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const refreshForm = reactive<any>({
|
||||
dutyId: ""
|
||||
})
|
||||
dutyId: '',
|
||||
});
|
||||
|
||||
const detail = reactive<any>({})
|
||||
const detail = reactive<any>({});
|
||||
|
||||
const disposeDataList = ref<any>([])
|
||||
const disposeDataList = ref<any>([]);
|
||||
|
||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
createdIsNeed: false,
|
||||
queryForm: {
|
||||
dutyId: ""
|
||||
},
|
||||
pageList: sysDutyItemApi.fetchList
|
||||
})
|
||||
createdIsNeed: false,
|
||||
queryForm: {
|
||||
dutyId: '',
|
||||
},
|
||||
pageList: sysDutyItemApi.fetchList,
|
||||
});
|
||||
|
||||
// table hook
|
||||
const {
|
||||
getDataList,
|
||||
currentChangeHandle,
|
||||
sizeChangeHandle,
|
||||
sortChangeHandle,
|
||||
downBlobFile,
|
||||
tableStyle
|
||||
} = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state);
|
||||
|
||||
watch(
|
||||
() => refreshForm.dutyId,
|
||||
(newVal, oldValue) => {
|
||||
if (newVal == oldValue) return
|
||||
refresh(newVal)
|
||||
},
|
||||
)
|
||||
|
||||
const getWidth = (v: any) => {
|
||||
if (v.children && v.children.length) {
|
||||
return 10
|
||||
}
|
||||
return 100
|
||||
}
|
||||
() => refreshForm.dutyId,
|
||||
(newVal, oldValue) => {
|
||||
if (newVal == oldValue) return;
|
||||
refresh(newVal);
|
||||
}
|
||||
);
|
||||
|
||||
const refresh = async (id: string) => {
|
||||
state.queryForm.dutyId = id
|
||||
if (!id) return
|
||||
await getsysDutyData(id)
|
||||
await getDataList()
|
||||
const _list: Array<any> = []
|
||||
if (state.dataList && state.dataList.length) {
|
||||
for (const v of state.dataList) {
|
||||
if (!v.rowJson || !v.rowJson.length) continue
|
||||
const _obj: any = {}
|
||||
for (const item of v.rowJson) {
|
||||
_obj[item.key] = item.content
|
||||
}
|
||||
_list.push(_obj)
|
||||
}
|
||||
}
|
||||
disposeDataList.value = _list
|
||||
}
|
||||
state.queryForm.dutyId = id;
|
||||
if (!id) return;
|
||||
await getsysDutyData(id);
|
||||
await getDataList();
|
||||
const _list: Array<any> = [];
|
||||
if (state.dataList && state.dataList.length) {
|
||||
for (const v of state.dataList) {
|
||||
if (!v.rowJson || !v.rowJson.length) continue;
|
||||
const _obj: any = {};
|
||||
for (const item of v.rowJson) {
|
||||
_obj[item.key] = item.content;
|
||||
}
|
||||
_list.push(_obj);
|
||||
}
|
||||
}
|
||||
disposeDataList.value = _list;
|
||||
};
|
||||
|
||||
// 初始化表单数据
|
||||
const getsysDutyData = async (id: string) => {
|
||||
// 获取数据
|
||||
try {
|
||||
const { data } = await sysDutyApi.getObj(id)
|
||||
Object.assign(detail, data)
|
||||
const _tableHeaderList: Array<{
|
||||
prop: string,
|
||||
label: string,
|
||||
children: []
|
||||
}> = []
|
||||
if (data && data.configJson && data.configJson.length) {
|
||||
for (let index = 0; index < data.configJson.length; index++) {
|
||||
const element = data.configJson[index];
|
||||
if (!element) continue
|
||||
const isExist = _tableHeaderList.some(v => v.label == element.mainLabel)
|
||||
if (isExist) continue
|
||||
const entity = {
|
||||
prop: element.key,
|
||||
label: element.mainLabel,
|
||||
children: []
|
||||
}
|
||||
const children = data.configJson.filter((v: any) => v.mainLabel == element.mainLabel) ?? []
|
||||
if (children.length >= 2) {
|
||||
entity.prop = ""
|
||||
entity.children = children.map((v: any) => {
|
||||
return {
|
||||
prop: v.key,
|
||||
label: v.subLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
_tableHeaderList.push(entity as any)
|
||||
}
|
||||
}
|
||||
tableHeaderList.value = _tableHeaderList
|
||||
console.log("tableHeaderList:", tableHeaderList.value);
|
||||
} catch (error) {
|
||||
console.log("|初始化表单数据|意外的错误:", error);
|
||||
}
|
||||
// 获取数据
|
||||
try {
|
||||
const { data } = await sysDutyApi.getObj(id);
|
||||
Object.assign(detail, data);
|
||||
const _tableHeaderList: Array<{
|
||||
prop: string;
|
||||
label: string;
|
||||
children: [];
|
||||
}> = [];
|
||||
if (data && data.configJson && data.configJson.length) {
|
||||
for (let index = 0; index < data.configJson.length; index++) {
|
||||
const element = data.configJson[index];
|
||||
if (!element) continue;
|
||||
const isExist = _tableHeaderList.some((v) => v.label == element.mainLabel);
|
||||
if (isExist) continue;
|
||||
const entity = {
|
||||
prop: element.key,
|
||||
label: element.mainLabel,
|
||||
children: [],
|
||||
};
|
||||
const children = data.configJson.filter((v: any) => v.mainLabel == element.mainLabel) ?? [];
|
||||
if (children.length >= 2) {
|
||||
entity.prop = '';
|
||||
entity.children = children.map((v: any) => {
|
||||
return {
|
||||
prop: v.key,
|
||||
label: v.subLabel,
|
||||
};
|
||||
});
|
||||
}
|
||||
_tableHeaderList.push(entity as any);
|
||||
}
|
||||
}
|
||||
tableHeaderList.value = _tableHeaderList;
|
||||
console.log('tableHeaderList:', tableHeaderList.value);
|
||||
} catch (error) {
|
||||
console.log('|初始化表单数据|意外的错误:', error);
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-cell {
|
||||
padding: 0 !important;
|
||||
background: red !important;
|
||||
<style lang="scss" scoped>
|
||||
.duty-roster__body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-table__cell {
|
||||
:deep(.el-cell) {
|
||||
padding: 0 !important;
|
||||
background: red !important;
|
||||
}
|
||||
.duty-roster__table {
|
||||
--el-table-border-color: var(--el-border-color-lighter);
|
||||
font-size: 13px;
|
||||
|
||||
:deep(.el-table__cell) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
:deep(th.el-table__cell) {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.cell) {
|
||||
padding: 0 10px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,69 +1,99 @@
|
||||
<!-- 视频预览 -->
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col pt-[10px]">
|
||||
<com-title title="监控预览">
|
||||
<select-device-tree v-model="refreshForm.dutyId" autoFirst style="width: 250px;" @change="groupChange" />
|
||||
</com-title>
|
||||
<div class="h-[10px]" />
|
||||
<RtspPluginVideo ref="rtspPluginVideoRef" />
|
||||
</div>
|
||||
<section class="duty-panel duty-video">
|
||||
<com-title title="监控预览">
|
||||
<div class="duty-panel__select">
|
||||
<select-device-tree v-model="refreshForm.dutyId" autoFirst aria-label="选择监控分组" @change="groupChange" />
|
||||
</div>
|
||||
</com-title>
|
||||
<div class="duty-panel__body duty-video__body">
|
||||
<div class="duty-video__frame">
|
||||
<RtspPluginVideo ref="rtspPluginVideoRef" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="videoPay">
|
||||
import * as deviceChannelApi from "/@/api/device/deviceChannel"
|
||||
import * as deviceChannelApi from '/@/api/device/deviceChannel';
|
||||
|
||||
const comTitle = defineAsyncComponent(() => import('./com-title.vue'));
|
||||
|
||||
const refreshForm = reactive<any>({
|
||||
dutyId: null,
|
||||
groupIdList: [],
|
||||
deviceTreeMap: [],
|
||||
deviceChannelCodeList: []
|
||||
})
|
||||
|
||||
const rtspPluginVideoRef = ref<any>(null)
|
||||
dutyId: null,
|
||||
groupIdList: [],
|
||||
deviceTreeMap: [],
|
||||
deviceChannelCodeList: [],
|
||||
});
|
||||
|
||||
const rtspPluginVideoRef = ref<any>(null);
|
||||
let requestSequence = 0;
|
||||
let pageActive = true;
|
||||
onActivated(() => { pageActive = true; });
|
||||
onDeactivated(() => { pageActive = false; ++requestSequence; });
|
||||
onBeforeUnmount(() => { pageActive = false; ++requestSequence; });
|
||||
|
||||
const refreshVideo = async () => {
|
||||
// 查询通道列表
|
||||
try {
|
||||
const _params = {
|
||||
current: 1,
|
||||
size: 64,
|
||||
deviceTreeMap: refreshForm.deviceTreeMap ?? null
|
||||
}
|
||||
let { code, data } = await deviceChannelApi.getChannelPage(_params);
|
||||
if (code != 0) throw new Error("")
|
||||
if (data.records && data.records.length) {
|
||||
const channelList = []
|
||||
for (let index = 0; index < data.records.length; index++) {
|
||||
const element = data.records[index];
|
||||
channelList.push({
|
||||
channelId: element.channelCode,
|
||||
channelName: element.channelName,
|
||||
snum: index,
|
||||
// 默认辅码流
|
||||
streamType: 2
|
||||
})
|
||||
}
|
||||
rtspPluginVideoRef.value?.startReal(channelList)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("分组播放意外的错误error:", error);
|
||||
}
|
||||
}
|
||||
const requestId = ++requestSequence;
|
||||
// 查询通道列表
|
||||
try {
|
||||
const _params = {
|
||||
current: 1,
|
||||
size: 64,
|
||||
deviceTreeMap: refreshForm.deviceTreeMap ?? null,
|
||||
};
|
||||
let { code, data } = await deviceChannelApi.getChannelPage(_params);
|
||||
if (!pageActive || requestId !== requestSequence) return;
|
||||
if (code != 0) throw new Error('');
|
||||
if (data.records && data.records.length) {
|
||||
const channelList = [];
|
||||
for (let index = 0; index < data.records.length; index++) {
|
||||
const element = data.records[index];
|
||||
channelList.push({
|
||||
channelId: element.channelCode,
|
||||
channelName: element.channelName,
|
||||
snum: index,
|
||||
// 默认辅码流
|
||||
streamType: 2,
|
||||
});
|
||||
}
|
||||
rtspPluginVideoRef.value?.startReal(channelList);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('分组播放意外的错误error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const groupChange = (e: any) => {
|
||||
if (!e) return
|
||||
const [groupIds, channelCodes, parentChildGroup] = e
|
||||
if (!groupIds.length && !channelCodes.length) {
|
||||
return
|
||||
}
|
||||
refreshForm.groupIdList = groupIds
|
||||
refreshForm.deviceChannelCodeList = channelCodes
|
||||
refreshForm.deviceTreeMap = parentChildGroup
|
||||
refreshVideo()
|
||||
}
|
||||
const [groupIds, channelCodes, parentChildGroup] = e || [[], [], []];
|
||||
if (!groupIds.length && !channelCodes.length) {
|
||||
++requestSequence;
|
||||
refreshForm.groupIdList = [];
|
||||
refreshForm.deviceChannelCodeList = [];
|
||||
refreshForm.deviceTreeMap = [];
|
||||
rtspPluginVideoRef.value?.destroy?.();
|
||||
return;
|
||||
}
|
||||
refreshForm.groupIdList = groupIds;
|
||||
refreshForm.deviceChannelCodeList = channelCodes;
|
||||
refreshForm.deviceTreeMap = parentChildGroup;
|
||||
refreshVideo();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped>
|
||||
.duty-video__body,
|
||||
.duty-video__frame {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.duty-video__frame {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: #20262e;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,18 @@
|
||||
<!-- 值班类型 -->
|
||||
<!-- 排班工作台 -->
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<el-container>
|
||||
<el-aside class="aside flex flex-col">
|
||||
<army-car-list class="flex-1 overflow-hidden" />
|
||||
<car-list class="flex-1 overflow-hidden" />
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<duty-table />
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<video-pay />
|
||||
</el-main>
|
||||
</el-container>
|
||||
<el-aside class="aside">
|
||||
<door-list />
|
||||
</el-aside>
|
||||
</el-container>
|
||||
</div>
|
||||
|
||||
<div class="layout-padding duty-workbench">
|
||||
<div class="duty-workbench__grid">
|
||||
<aside class="duty-workbench__vehicles" aria-label="车辆通行记录">
|
||||
<army-car-list />
|
||||
<car-list />
|
||||
</aside>
|
||||
<main class="duty-workbench__main" aria-label="值班安排与监控">
|
||||
<duty-table />
|
||||
<video-pay />
|
||||
</main>
|
||||
<door-list class="duty-workbench__people" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="dutyPlan">
|
||||
@@ -34,42 +27,182 @@ const VideoPay = defineAsyncComponent(() => import('./components/video-pay.vue')
|
||||
const stores = useTagsViewRoutes();
|
||||
|
||||
onMounted(() => {
|
||||
const query: any = useRoute().query
|
||||
if (query && query.action == 'login') {
|
||||
nextTick(() => {
|
||||
stores.setCurrenFullscreen(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
const query: any = useRoute().query;
|
||||
if (query && query.action == 'login') {
|
||||
nextTick(() => {
|
||||
stores.setCurrenFullscreen(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stores.setCurrenFullscreen(false)
|
||||
})
|
||||
|
||||
|
||||
stores.setCurrenFullscreen(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.duty-workbench {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 12px !important;
|
||||
background: var(--next-bg-main-color);
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(232px, 0.9fr) minmax(380px, 2.6fr) minmax(248px, 1fr);
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__vehicles,
|
||||
&__main {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__main {
|
||||
grid-template-rows: minmax(0, 0.95fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
:deep(.duty-panel) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
:deep(.duty-panel__body) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.duty-panel__more.el-button) {
|
||||
height: 28px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
margin-left: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.duty-panel__select) {
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(.duty-record-filter) {
|
||||
flex: none;
|
||||
padding: 10px 12px 0;
|
||||
|
||||
.el-date-editor {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.el-range-input {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-range-separator {
|
||||
flex: none;
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.duty-record-footer) {
|
||||
display: flex;
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
padding: 10px 8px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.el-pagination {
|
||||
--el-pagination-button-width: 24px;
|
||||
--el-pagination-button-height: 26px;
|
||||
--el-pagination-font-size: 12px;
|
||||
}
|
||||
|
||||
.el-pager li,
|
||||
.btn-prev,
|
||||
.btn-next {
|
||||
min-width: 24px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(button:focus-visible),
|
||||
:deep(.el-select:focus-within) {
|
||||
outline: 2px solid var(--el-color-primary) !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.aside {
|
||||
width: 18%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@media (max-width: 1280px) {
|
||||
.duty-workbench {
|
||||
overflow: auto;
|
||||
|
||||
&__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: minmax(660px, calc(100vh - 130px)) 560px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&__main {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
&__vehicles {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
&__people {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.duty-workbench {
|
||||
padding: 10px !important;
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
padding: 0 10px;
|
||||
&__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 720px 680px 560px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__vehicles,
|
||||
&__people {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
&__people {
|
||||
grid-row: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -38,9 +38,10 @@
|
||||
</div>
|
||||
</el-row>
|
||||
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle">
|
||||
@sort-change="sortChangeHandle" :row-class-name="carRecordRowClassName">
|
||||
<el-table-column type="index" label="#" width="80" />
|
||||
<el-table-column v-if="columnSetting['alarmList'].visible" min-width="100"
|
||||
:label="columnSetting['alarmList'].label">
|
||||
@@ -63,7 +64,7 @@
|
||||
<el-table-column v-if="columnSetting['carDirect'].visible" prop="carNum" min-width="100"
|
||||
:label="columnSetting['carDirect'].label">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_car_direct" :value="row.carDirect" />
|
||||
<AccessDirectionTag kind="car" :options="dh_car_direct" :value="row.carDirect" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columnSetting['carColor'].visible" prop="enterMode" min-width="100"
|
||||
@@ -120,6 +121,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="reportCar">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { carRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { parseTime } from "/@/utils/formatTime"
|
||||
import { useDict } from '/@/hooks/dict';
|
||||
@@ -249,4 +253,4 @@ function getDictByValue(value: any, tag: any) {
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -45,9 +45,10 @@
|
||||
</right-toolbar>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
<AccessDirectionLegend />
|
||||
<el-table class="access-record-table" :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle">
|
||||
@sort-change="sortChangeHandle" :row-class-name="doorRecordRowClassName">
|
||||
<el-table-column v-if="false" type="selection" width="40" align="center" />
|
||||
<el-table-column type="index" label="#" width="80" />
|
||||
<el-table-column v-if="columnSetting['alarmList'].visible" min-width="100"
|
||||
@@ -137,7 +138,7 @@
|
||||
<el-table-column v-if="columnSetting['enterOrExit'].visible" prop="enterOrExit" min-width="100"
|
||||
:label="columnSetting['enterOrExit'].label">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_enter_or_exit" :value="row.enterOrExit" />
|
||||
<AccessDirectionTag kind="door" :options="dh_enter_or_exit" :value="row.enterOrExit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columnSetting['openResult'].visible" prop="openResult" min-width="100"
|
||||
@@ -174,6 +175,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="reportDoor">
|
||||
import AccessDirectionLegend from '/@/components/AccessDirection/Legend.vue';
|
||||
import AccessDirectionTag from '/@/components/AccessDirection/Tag.vue';
|
||||
import { doorRecordRowClassName } from '/@/utils/accessDirection';
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { useDict } from '/@/hooks/dict';
|
||||
import { parseTime } from "/@/utils/formatTime"
|
||||
@@ -315,4 +319,4 @@ function getDictByValue(value: any, tag: any) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,266 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue';
|
||||
import VideoPlugin from '../src/components/rtsp-plugin-video/video-plugin.vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getSetting: vi.fn() }));
|
||||
vi.mock('../src/components/rtsp-plugin-video/lib/videoPlayer', () => ({}));
|
||||
vi.mock('../src/api/com/comDhSetting', () => ({ getObj: mocks.getSetting }));
|
||||
vi.mock('element-plus', () => ({ ElMessage: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } }));
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const instances: MockPlayer[] = [];
|
||||
class MockPlayer {
|
||||
setting: any;
|
||||
disposed = false;
|
||||
ready = false;
|
||||
closeVideo = vi.fn(() => Promise.resolve());
|
||||
dispose = vi.fn(() => { this.disposed = true; });
|
||||
hide = vi.fn();
|
||||
show = vi.fn();
|
||||
startReal = vi.fn((items) => { this.setting.channelList = items; });
|
||||
startPlayback = vi.fn((items) => { this.setting.channelList = items; });
|
||||
controlPlayback = vi.fn();
|
||||
setTabControlBtn = vi.fn();
|
||||
changeDivision = vi.fn((division) => { this.setting.division = division; });
|
||||
constructor(public options: any) {
|
||||
this.setting = { ...options, hwnd: instances.length + 10, channelList: [] };
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
const wrappers: VueWrapper[] = [];
|
||||
const mountOptions = {
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Yt: true, 'el-pagination': true, 'el-icon': true }, directives: { loading: () => undefined } },
|
||||
};
|
||||
function createPlayer(props = {}) {
|
||||
const wrapper = mount(VideoPlugin, { ...mountOptions, props });
|
||||
wrappers.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
const channels = (name = 'camera-a') => [{ channelId: name, snum: 0, streamType: 2 }];
|
||||
const playback = () => [{ ...channels()[0], startTime: '2026-10-27 08:00:00', endTime: '2026-10-27 09:00:00' }];
|
||||
async function ready(player: MockPlayer) {
|
||||
player.ready = true;
|
||||
player.options.createSuccess({});
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
}
|
||||
async function play(wrapper: VueWrapper, mode = 'real') {
|
||||
const task = mode === 'real' ? wrapper.vm.startReal(channels()) : wrapper.vm.startPlayback(playback());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const player = instances.at(-1)!;
|
||||
if (!player.ready) await ready(player);
|
||||
await task;
|
||||
return player;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: false });
|
||||
(window as any).VideoPlayer = MockPlayer;
|
||||
instances.length = 0;
|
||||
mocks.getSetting.mockReset().mockResolvedValue({ code: 0, data: { dhIccSetting: {} } });
|
||||
});
|
||||
afterEach(() => {
|
||||
wrappers.splice(0).forEach((wrapper) => wrapper.unmount());
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('原生播放器组件的关闭时序', () => {
|
||||
it('正常实时预览和回放仍调用对应 SDK 接口', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const real = await play(wrapper);
|
||||
expect(real.startReal).toHaveBeenCalledWith(channels());
|
||||
const replay = await play(wrapper, 'playback');
|
||||
expect(replay.startPlayback).toHaveBeenCalledWith(playback());
|
||||
wrapper.vm.destroy();
|
||||
expect(real.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(replay.dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('同类型并发初始化只查询和构造一次,只执行最后一次播放请求', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const first = wrapper.vm.startReal(channels('first'));
|
||||
const last = wrapper.vm.startReal(channels('last'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mocks.getSetting).toHaveBeenCalledTimes(1);
|
||||
expect(instances).toHaveLength(1);
|
||||
await ready(instances[0]);
|
||||
await Promise.all([first, last]);
|
||||
expect(instances[0].startReal).toHaveBeenCalledTimes(1);
|
||||
expect(instances[0].startReal).toHaveBeenCalledWith(channels('last'));
|
||||
});
|
||||
|
||||
it('配置请求返回之前卸载,不再构造播放器', async () => {
|
||||
const config = deferred<any>();
|
||||
mocks.getSetting.mockReturnValue(config.promise);
|
||||
const wrapper = createPlayer();
|
||||
const task = wrapper.vm.startReal(channels());
|
||||
wrapper.unmount();
|
||||
config.resolve({ code: 0, data: { dhIccSetting: {} } });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await task;
|
||||
expect(instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('原生创建回包晚于卸载,不恢复已取消的播放', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const task = wrapper.vm.startReal(channels());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const player = instances[0];
|
||||
wrapper.unmount();
|
||||
await ready(player);
|
||||
await task;
|
||||
expect(player.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(player.startReal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('创建成功后的 500ms 延迟也能被卸载取消', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const task = wrapper.vm.startReal(channels());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const player = instances[0];
|
||||
player.options.createSuccess({});
|
||||
wrapper.unmount();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await task;
|
||||
expect(player.startReal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('软关闭会取消等待中的播放,切回浏览器也不会自行恢复', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const task = wrapper.vm.startReal(channels());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const player = instances[0];
|
||||
await wrapper.vm.closeVideo();
|
||||
await ready(player);
|
||||
await task;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(player.startReal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('未 await 的关闭也会在新播放前等待旧 SDK 关闭确认', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const player = await play(wrapper);
|
||||
const closed = deferred();
|
||||
player.closeVideo.mockReturnValue(closed.promise);
|
||||
const closeTask = wrapper.vm.closeVideo();
|
||||
const playTask = wrapper.vm.startReal(channels('next'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(player.startReal).toHaveBeenCalledTimes(1);
|
||||
closed.resolve();
|
||||
await Promise.all([closeTask, playTask]);
|
||||
expect(player.startReal).toHaveBeenLastCalledWith(channels('next'));
|
||||
});
|
||||
|
||||
it('同时关闭多个缓存实例,单个 SDK 不回包也不妨碍销毁其他窗口', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const real = await play(wrapper);
|
||||
const replay = await play(wrapper, 'playback');
|
||||
const closed = deferred();
|
||||
real.closeVideo.mockReturnValue(closed.promise);
|
||||
const closing = wrapper.vm.closeVideo();
|
||||
expect(real.closeVideo).toHaveBeenCalledTimes(1);
|
||||
expect(replay.closeVideo).toHaveBeenCalledTimes(1);
|
||||
wrapper.vm.destroy();
|
||||
expect(real.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(replay.dispose).toHaveBeenCalledTimes(1);
|
||||
closed.resolve();
|
||||
await closing;
|
||||
});
|
||||
|
||||
it('重叠关闭不会重复给同一实例发送关闭指令', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const player = await play(wrapper);
|
||||
const closed = deferred();
|
||||
player.closeVideo.mockReturnValue(closed.promise);
|
||||
const first = wrapper.vm.closeVideo();
|
||||
const second = wrapper.vm.closeVideo();
|
||||
expect(player.closeVideo).toHaveBeenCalledTimes(1);
|
||||
closed.resolve();
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it('两个组件的容器 ID 不重叠,关闭其中一个不会销毁另一个', async () => {
|
||||
const first = createPlayer();
|
||||
const second = createPlayer();
|
||||
const a = await play(first);
|
||||
const b = await play(second);
|
||||
expect(a.options.videoId).not.toEqual(b.options.videoId);
|
||||
first.unmount();
|
||||
expect(a.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(b.dispose).not.toHaveBeenCalled();
|
||||
expect(document.getElementById(b.options.videoId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('配置请求失败时播放 Promise 正常结束,之后可重新初始化', async () => {
|
||||
mocks.getSetting.mockRejectedValueOnce(new Error('configuration unavailable'));
|
||||
const wrapper = createPlayer();
|
||||
await wrapper.vm.startReal(channels());
|
||||
expect(instances).toHaveLength(0);
|
||||
const player = await play(wrapper);
|
||||
expect(player.startReal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SDK 创建失败会清掉失败实例,允许重新打开', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const task = wrapper.vm.startReal(channels());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
instances[0].options.createError({ code: 1001, message: 'not installed' });
|
||||
await task;
|
||||
expect(instances[0].dispose).toHaveBeenCalledTimes(1);
|
||||
const replacement = await play(wrapper);
|
||||
expect(instances).toHaveLength(2);
|
||||
expect(replacement.startReal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('弹窗 active=false 会立即销毁,离场期间的晚到播放调用被忽略', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const player = await play(wrapper);
|
||||
await wrapper.setProps({ active: false });
|
||||
expect(player.dispose).toHaveBeenCalledTimes(1);
|
||||
await wrapper.vm.startReal(channels('late-response'));
|
||||
expect(instances).toHaveLength(1);
|
||||
await wrapper.setProps({ active: true });
|
||||
const replacement = await play(wrapper);
|
||||
expect(instances).toHaveLength(2);
|
||||
expect(replacement.startReal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('销毁后晚到的错误回调不会触发主码流重试', async () => {
|
||||
const wrapper = createPlayer();
|
||||
const player = await play(wrapper);
|
||||
wrapper.vm.destroy();
|
||||
player.options.realError(channels()[0], { code: 408 });
|
||||
expect(player.startReal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('KeepAlive 停用时销毁窗口,重新激活时只重建当前播放', async () => {
|
||||
const visible = ref(true);
|
||||
const host = mount(defineComponent({ render: () => h(KeepAlive, {}, () => visible.value ? h(VideoPlugin) : null) }), mountOptions);
|
||||
wrappers.push(host);
|
||||
const child = host.findComponent(VideoPlugin);
|
||||
const oldPlayer = await play(child);
|
||||
visible.value = false;
|
||||
await nextTick();
|
||||
expect(oldPlayer.dispose).toHaveBeenCalledTimes(1);
|
||||
await child.vm.startReal(channels('inactive'));
|
||||
expect(instances).toHaveLength(1);
|
||||
visible.value = true;
|
||||
await nextTick();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await ready(instances[1]);
|
||||
expect(instances[1].startReal).toHaveBeenCalledWith(channels());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import AutoImport from 'unplugin-auto-import/vite';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), AutoImport({ imports: ['vue', 'vue-router', 'pinia'], dts: false })],
|
||||
resolve: { alias: { '/@': resolve(__dirname, 'src') } },
|
||||
test: { environment: 'jsdom', include: ['tests/**/*.test.{js,ts}'], restoreMocks: true },
|
||||
});
|
||||
Reference in new issue
Block a user