diff --git a/docs/统计数据口径差异分析.md b/docs/统计数据口径差异分析.md new file mode 100644 index 0000000..125973b --- /dev/null +++ b/docs/统计数据口径差异分析.md @@ -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` | diff --git a/src/api/device/deviceStatistics.ts b/src/api/device/deviceStatistics.ts index 011785e..932495a 100644 --- a/src/api/device/deviceStatistics.ts +++ b/src/api/device/deviceStatistics.ts @@ -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; diff --git a/src/components/AccessDirection/Legend.vue b/src/components/AccessDirection/Legend.vue new file mode 100644 index 0000000..47504ef --- /dev/null +++ b/src/components/AccessDirection/Legend.vue @@ -0,0 +1,11 @@ + diff --git a/src/components/AccessDirection/Tag.vue b/src/components/AccessDirection/Tag.vue new file mode 100644 index 0000000..98ae297 --- /dev/null +++ b/src/components/AccessDirection/Tag.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/rtsp-plugin-video/lib/videoPlayer.js b/src/components/rtsp-plugin-video/lib/videoPlayer.js index 75e9f85..e433455 100644 --- a/src/components/rtsp-plugin-video/lib/videoPlayer.js +++ b/src/components/rtsp-plugin-video/lib/videoPlayer.js @@ -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 diff --git a/src/components/rtsp-plugin-video/video-plugin.vue b/src/components/rtsp-plugin-video/video-plugin.vue index b7c5041..e30eeeb 100644 --- a/src/components/rtsp-plugin-video/video-plugin.vue +++ b/src/components/rtsp-plugin-video/video-plugin.vue @@ -1,9 +1,9 @@ - \ No newline at end of file + diff --git a/src/views/car/components/carTableDialog.vue b/src/views/car/components/carTableDialog.vue index 573543d..6e8ed8f 100644 --- a/src/views/car/components/carTableDialog.vue +++ b/src/views/car/components/carTableDialog.vue @@ -24,9 +24,10 @@ {{ $t('common.resetBtn') }} - + + @sort-change="sortChangeHandle" :row-class-name="carRecordRowClassName"> \ No newline at end of file + diff --git a/src/views/collection/car/components/gate-operation-list.vue b/src/views/collection/car/components/gate-operation-list.vue index cde5104..a5ba375 100644 --- a/src/views/collection/car/components/gate-operation-list.vue +++ b/src/views/collection/car/components/gate-operation-list.vue @@ -22,11 +22,16 @@ 导出 - + + - + + + @@ -46,6 +51,9 @@ + + diff --git a/src/views/device/components/video-dialog.scss b/src/views/device/components/video-dialog.scss new file mode 100644 index 0000000..4b9174e --- /dev/null +++ b/src/views/device/components/video-dialog.scss @@ -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; } } +} diff --git a/src/views/device/deviceGroupPlay/index.vue b/src/views/device/deviceGroupPlay/index.vue index ba7ad7f..298bd99 100644 --- a/src/views/device/deviceGroupPlay/index.vue +++ b/src/views/device/deviceGroupPlay/index.vue @@ -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%; } - \ No newline at end of file + diff --git a/src/views/door/components/doorTableDialog.vue b/src/views/door/components/doorTableDialog.vue index 48a4480..bcc18dc 100644 --- a/src/views/door/components/doorTableDialog.vue +++ b/src/views/door/components/doorTableDialog.vue @@ -31,9 +31,10 @@ {{ $t('common.resetBtn') }} - + + @sort-change="sortChangeHandle" :row-class-name="doorRecordRowClassName"> \ No newline at end of file + diff --git a/src/views/door/doorAlarm/index.vue b/src/views/door/doorAlarm/index.vue index 04f1c98..ef009d6 100644 --- a/src/views/door/doorAlarm/index.vue +++ b/src/views/door/doorAlarm/index.vue @@ -33,9 +33,10 @@ - + + @sort-change="sortChangeHandle" :row-class-name="doorRecordRowClassName"> @@ -130,7 +131,7 @@ @@ -197,6 +198,9 @@ + + diff --git a/src/views/duty/dutyPlan/components/army-car-list.vue b/src/views/duty/dutyPlan/components/army-car-list.vue index 69e7a34..93cd43e 100644 --- a/src/views/duty/dutyPlan/components/army-car-list.vue +++ b/src/views/duty/dutyPlan/components/army-car-list.vue @@ -1,199 +1,196 @@ - \ No newline at end of file + diff --git a/src/views/duty/dutyPlan/components/car-list.vue b/src/views/duty/dutyPlan/components/car-list.vue index 210250c..9fe136d 100644 --- a/src/views/duty/dutyPlan/components/car-list.vue +++ b/src/views/duty/dutyPlan/components/car-list.vue @@ -1,185 +1,143 @@ - - \ No newline at end of file diff --git a/src/views/duty/dutyPlan/components/com-title.vue b/src/views/duty/dutyPlan/components/com-title.vue index 0eb7918..8167361 100644 --- a/src/views/duty/dutyPlan/components/com-title.vue +++ b/src/views/duty/dutyPlan/components/com-title.vue @@ -1,41 +1,59 @@ \ No newline at end of file + diff --git a/src/views/duty/dutyPlan/components/door-list.vue b/src/views/duty/dutyPlan/components/door-list.vue index 51513fd..ff394a6 100644 --- a/src/views/duty/dutyPlan/components/door-list.vue +++ b/src/views/duty/dutyPlan/components/door-list.vue @@ -1,155 +1,124 @@ - - - \ No newline at end of file diff --git a/src/views/duty/dutyPlan/components/duty-table.vue b/src/views/duty/dutyPlan/components/duty-table.vue index 2984913..10d23c1 100644 --- a/src/views/duty/dutyPlan/components/duty-table.vue +++ b/src/views/duty/dutyPlan/components/duty-table.vue @@ -1,154 +1,176 @@ - \ No newline at end of file + diff --git a/src/views/duty/dutyPlan/components/video-pay.vue b/src/views/duty/dutyPlan/components/video-pay.vue index 808d3cb..1488848 100644 --- a/src/views/duty/dutyPlan/components/video-pay.vue +++ b/src/views/duty/dutyPlan/components/video-pay.vue @@ -1,69 +1,99 @@ - \ No newline at end of file + diff --git a/src/views/duty/dutyPlan/index.vue b/src/views/duty/dutyPlan/index.vue index 2af5ffc..90840ba 100644 --- a/src/views/duty/dutyPlan/index.vue +++ b/src/views/duty/dutyPlan/index.vue @@ -1,25 +1,18 @@ - + \ No newline at end of file + diff --git a/src/views/report/car/index.vue b/src/views/report/car/index.vue index 71e5371..ec7edf1 100644 --- a/src/views/report/car/index.vue +++ b/src/views/report/car/index.vue @@ -38,9 +38,10 @@ - + + @sort-change="sortChangeHandle" :row-class-name="carRecordRowClassName"> @@ -63,7 +64,7 @@ - \ No newline at end of file + diff --git a/src/views/report/door/index.vue b/src/views/report/door/index.vue index dd894c3..0cedf0f 100644 --- a/src/views/report/door/index.vue +++ b/src/views/report/door/index.vue @@ -45,9 +45,10 @@ - + + @sort-change="sortChangeHandle" :row-class-name="doorRecordRowClassName"> - \ No newline at end of file + diff --git a/tests/video-plugin.lifecycle.test.ts b/tests/video-plugin.lifecycle.test.ts new file mode 100644 index 0000000..a9c0899 --- /dev/null +++ b/tests/video-plugin.lifecycle.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((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(); + 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()); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b86f679 --- /dev/null +++ b/vitest.config.ts @@ -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 }, +});