This commit is contained in:
dzc committed 2026-10-27 15:06:03 +08:00
1 parent 569ea04236
commit 7ed145bb14
37 files changed
+3191 -994

No files matched your search

+202
View File
@@ -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` |
+4 -1
View File
@@ -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;
+11
View File
@@ -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>
+33
View File
@@ -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
+246 -165
View File
@@ -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;
+160
View File
@@ -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;
}
}
+1
View File
@@ -6,3 +6,4 @@
@import './waves.scss';
@import './dark.scss';
@import './login.scss';
@import './access-record.scss';
+24
View File
@@ -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
View File
@@ -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>
+8 -4
View File
@@ -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>
+10 -5
View File
@@ -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>
+8 -4
View File
@@ -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; } }
}
+28 -7
View File
@@ -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>
+8 -4
View File
@@ -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>
+10 -5
View File
@@ -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>
+105 -147
View File
@@ -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>
+93 -124
View File
@@ -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>
+146 -124
View File
@@ -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>
+182 -49
View File
@@ -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>
+8 -4
View File
@@ -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>
+8 -4
View File
@@ -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>
+266
View File
@@ -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());
});
});
+10
View File
@@ -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 },
});