2026-03-27 15:52:43 +08:00
|
|
|
|
<template>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
<div class="shiPinJianKongZhuanTi-page">
|
|
|
|
|
|
<a-tabs
|
|
|
|
|
|
v-model:activeKey="activeTab"
|
2026-05-19 18:56:33 +08:00
|
|
|
|
:tabBarGutter="32"
|
2026-05-18 09:03:20 +08:00
|
|
|
|
class="custom-tabs"
|
2026-05-19 18:56:33 +08:00
|
|
|
|
@change="handleTabChange"
|
2026-05-18 09:03:20 +08:00
|
|
|
|
>
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<template #rightExtra>
|
|
|
|
|
|
<a-button type="default" class="mr-2" @click="handleAction">
|
|
|
|
|
|
<template #icon><i class="iconfont icon-fenbu mr-1" /></template>
|
|
|
|
|
|
查看分布
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</a-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<a-tab-pane v-for="tab in tabs" :key="tab.key">
|
|
|
|
|
|
<template #tab> {{ tab.title }}({{ tab.data?.count_sttpCode || 0 }}) </template>
|
|
|
|
|
|
</a-tab-pane>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</a-tabs>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="tabs-content">
|
|
|
|
|
|
<!-- 顶部标题和图标选择区域 -->
|
|
|
|
|
|
<div class="content-header">
|
|
|
|
|
|
<div class="header-left">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<span class="title"> <a-divider type="vertical"></a-divider> 监控列表</span>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="header-right">
|
|
|
|
|
|
<a-radio-group v-model:value="viewLayout" button-style="solid">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<a-radio-button v-if="monitorType === 'live'" value="1">
|
|
|
|
|
|
<i class="iconfont icon-view1"></i>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="4">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<i class="iconfont icon-view4"></i>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="6">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<i class="iconfont icon-view6"></i>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="9">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<i class="iconfont icon-view9"></i>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</a-radio-button>
|
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 主体内容区域 -->
|
|
|
|
|
|
<div class="content-body">
|
|
|
|
|
|
<!-- 左侧监控列表 -->
|
|
|
|
|
|
<div class="left-panel">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<!-- Radio切换:实时视频/录像 -->
|
|
|
|
|
|
<div class="monitor-radio">
|
|
|
|
|
|
<a-radio-group
|
|
|
|
|
|
v-model:value="monitorType"
|
|
|
|
|
|
button-style="solid"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
@change="handleMonitorTypeChange"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-radio-button value="live">实时视频</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="record">录像</a-radio-button>
|
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
|
</div>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 查询和状态筛选 -->
|
|
|
|
|
|
<div class="search-bar">
|
|
|
|
|
|
<a-input-search
|
|
|
|
|
|
v-model:value="searchText"
|
2026-05-19 18:56:33 +08:00
|
|
|
|
placeholder="输入关键字搜索"
|
2026-05-18 09:03:20 +08:00
|
|
|
|
size="small"
|
2026-05-19 18:56:33 +08:00
|
|
|
|
:style="{ flex: monitorType === 'record' ? '1' : '0 1 auto' }"
|
|
|
|
|
|
allow-clear
|
2026-05-18 09:03:20 +08:00
|
|
|
|
/>
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<div v-if="monitorType === 'live'" class="status-filter">
|
|
|
|
|
|
<a-tooltip title="在线">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="status-btn"
|
|
|
|
|
|
:class="{ active: statusFilter === 'online' }"
|
|
|
|
|
|
@click="statusFilter = 'online'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="iconfont icon-jiankongFill __online"></i>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-tooltip>
|
|
|
|
|
|
<a-tooltip title="离线">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="status-btn"
|
|
|
|
|
|
:class="{ active: statusFilter === 'offline' }"
|
|
|
|
|
|
@click="statusFilter = 'offline'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="iconfont icon-jiankongFill"></i>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-tooltip>
|
|
|
|
|
|
</div>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 树形结构 -->
|
|
|
|
|
|
<div class="tree-container">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<a-spin :spinning="treeLoading" wrapperClassName="tree-spin">
|
|
|
|
|
|
<!-- 空状态显示 -->
|
|
|
|
|
|
<div v-if="!treeData || treeData.length === 0" class="empty-tree">
|
|
|
|
|
|
<a-empty description="暂无数据" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 树形组件 -->
|
|
|
|
|
|
<a-tree
|
|
|
|
|
|
v-else
|
|
|
|
|
|
v-model:expandedKeys="expandedKeys"
|
|
|
|
|
|
v-model:checkedKeys="checkedKeys"
|
|
|
|
|
|
:tree-data="treeData"
|
|
|
|
|
|
:field-names="{ name: 'name', key: 'key', children: 'children' }"
|
|
|
|
|
|
checkable
|
|
|
|
|
|
block-node
|
|
|
|
|
|
:show-icon="monitorType === 'live'"
|
|
|
|
|
|
@check="handleTreeSelect"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #title="{ name, status, children }">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<!-- 只在实时视频模式且最后一层(没有children)显示在线/离线图标 -->
|
|
|
|
|
|
<template
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
monitorType === 'live' && (!children || children.length === 0)
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i
|
|
|
|
|
|
v-if="status === 1"
|
|
|
|
|
|
class="iconfont icon-jiankongFill __online tree-status-icon"
|
|
|
|
|
|
></i>
|
|
|
|
|
|
<i
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="iconfont icon-jiankongFill tree-status-icon offline"
|
|
|
|
|
|
></i>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
{{ name }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-tree>
|
|
|
|
|
|
</a-spin>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 中间视频内容区域 -->
|
|
|
|
|
|
<div class="center-panel">
|
|
|
|
|
|
<div class="video-grid" :class="`layout-${viewLayout}`">
|
|
|
|
|
|
<div v-for="index in parseInt(viewLayout)" :key="index" class="video-item">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<LiveVideoBox
|
|
|
|
|
|
:video-data="videoList[index - 1]"
|
|
|
|
|
|
:monitor-type="monitorType"
|
|
|
|
|
|
height="100%"
|
|
|
|
|
|
@close="(nodeKey) => removeVideo(index - 1, nodeKey)"
|
|
|
|
|
|
/>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 右侧回放面板(仅录像模式显示) -->
|
|
|
|
|
|
<div v-if="monitorType === 'record'" class="right-panel">
|
|
|
|
|
|
<div class="replay-header">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<span class="replay-title"><a-divider type="vertical" />回放列表</span>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 时间选择 -->
|
|
|
|
|
|
<div class="date-picker">
|
|
|
|
|
|
<a-range-picker
|
|
|
|
|
|
v-model:value="dateRange"
|
|
|
|
|
|
format="YYYY-MM-DD"
|
|
|
|
|
|
style="width: 100%"
|
2026-05-19 18:56:33 +08:00
|
|
|
|
:presets="DateSetting.RangeButton.days"
|
2026-05-18 09:03:20 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 录像列表 -->
|
|
|
|
|
|
<div class="record-list">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<a-spin :spinning="recordLoading" wrapperClassName="record-spin">
|
|
|
|
|
|
<!-- 空状态显示 -->
|
|
|
|
|
|
<div v-if="!recordList || recordList.length === 0" class="empty-record">
|
|
|
|
|
|
<a-empty />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 列表 -->
|
|
|
|
|
|
<div v-else class="record-items">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="record-item"
|
|
|
|
|
|
v-for="(item, index) in recordList"
|
|
|
|
|
|
:key="index"
|
|
|
|
|
|
@click="handlePlayRecord(item)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="record-image-wrapper">
|
|
|
|
|
|
<a-image :src="item.imgPath" :preview="false" alt="" />
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</div>
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<div class="sp_btn"><i class="icon iconfont icon-play"></i></div>
|
|
|
|
|
|
<a-tooltip placement="top">
|
|
|
|
|
|
<template #title>
|
|
|
|
|
|
<div>{{ item.name }}</div>
|
|
|
|
|
|
<div>{{ dayjs(item.time).format("YYYY-MM-DD HH") }}</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<div class="record-info">
|
|
|
|
|
|
<div class="record-name">{{ item.name }}</div>
|
|
|
|
|
|
<div class="record-time">
|
|
|
|
|
|
{{ dayjs(item.time).format("YYYY-MM-DD HH") }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-spin>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-27 15:52:43 +08:00
|
|
|
|
</div>
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
import { ref, reactive, onMounted, watch } from "vue";
|
|
|
|
|
|
import { message } from "ant-design-vue";
|
|
|
|
|
|
import {
|
|
|
|
|
|
baseMsstbprpt,
|
|
|
|
|
|
envVdTreeGetKendoListCust,
|
|
|
|
|
|
envVdRunDataGetKendoList,
|
|
|
|
|
|
} from "@/api/shiPinJianKong";
|
2026-05-18 09:03:20 +08:00
|
|
|
|
import type { Dayjs } from "dayjs";
|
|
|
|
|
|
import dayjs from "dayjs";
|
2026-05-19 18:56:33 +08:00
|
|
|
|
import { DateSetting } from "@/utils/enumeration";
|
|
|
|
|
|
import LiveVideoBox from "./components/LiveVideoBox.vue";
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
// Tab相关
|
2026-05-19 18:56:33 +08:00
|
|
|
|
const tabs = ref<any[]>([]);
|
|
|
|
|
|
const activeTab = ref<string>("");
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 视图布局选择(1/4/6/9宫格)
|
2026-05-19 18:56:33 +08:00
|
|
|
|
const viewLayout = ref<"1" | "4" | "6" | "9">("4");
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 监控类型(实时视频/录像)
|
2026-05-19 18:56:33 +08:00
|
|
|
|
const monitorType = ref<"live" | "record">("live");
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 搜索文本
|
|
|
|
|
|
const searchText = ref("");
|
|
|
|
|
|
|
|
|
|
|
|
// 状态筛选(在线/离线)
|
|
|
|
|
|
const statusFilter = ref<"online" | "offline">("online");
|
|
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
// 视频列表
|
|
|
|
|
|
const videoList = ref<any[]>(Array(9).fill(null));
|
|
|
|
|
|
|
|
|
|
|
|
// 移除视频
|
|
|
|
|
|
const removeVideo = (index: number, nodeKey?: string) => {
|
|
|
|
|
|
const videoData = videoList.value[index];
|
|
|
|
|
|
|
|
|
|
|
|
// 如果传入了 nodeKey,优先使用;否则从 videoData 中获取
|
|
|
|
|
|
const keyToRemove = nodeKey || videoData?.key;
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有关联的节点key,则取消勾选
|
|
|
|
|
|
if (keyToRemove) {
|
|
|
|
|
|
// 从 checkedKeys 中移除该节点的 key(使用 filter 创建新数组以触发响应式)
|
|
|
|
|
|
checkedKeys.value = checkedKeys.value.filter((key) => key !== keyToRemove);
|
|
|
|
|
|
|
|
|
|
|
|
// 检查并移除不再需要的父节点
|
|
|
|
|
|
removeUnnecessaryParentKeys(keyToRemove);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 移除视频
|
|
|
|
|
|
videoList.value[index] = null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 添加视频到列表
|
|
|
|
|
|
const addVideo = (videoData: any) => {
|
|
|
|
|
|
const maxCount = parseInt(viewLayout.value);
|
|
|
|
|
|
// 找到第一个空位
|
|
|
|
|
|
const emptyIndex = videoList.value.findIndex(
|
|
|
|
|
|
(v, i) => i < maxCount && (v === null || v === undefined)
|
|
|
|
|
|
);
|
|
|
|
|
|
if (emptyIndex !== -1) {
|
|
|
|
|
|
videoList.value[emptyIndex] = videoData;
|
|
|
|
|
|
} else if (videoList.value.filter((v, i) => i < maxCount).length < maxCount) {
|
|
|
|
|
|
videoList.value.push(videoData);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 如果满了,替换第一个
|
|
|
|
|
|
videoList.value[0] = videoData;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 监听布局变化,调整视频列表长度
|
|
|
|
|
|
watch(viewLayout, (newLayout) => {
|
|
|
|
|
|
const maxCount = parseInt(newLayout);
|
|
|
|
|
|
// 确保数组长度足够
|
|
|
|
|
|
while (videoList.value.length < maxCount) {
|
|
|
|
|
|
videoList.value.push(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-18 09:03:20 +08:00
|
|
|
|
// 树形数据
|
2026-05-19 18:56:33 +08:00
|
|
|
|
const expandedKeys = ref<string[]>([]);
|
|
|
|
|
|
const checkedKeys = ref<string[]>([]);
|
|
|
|
|
|
const treeData = ref<any[]>([]);
|
|
|
|
|
|
const originalTreeData = ref<any[]>([]); // 保存原始数据
|
|
|
|
|
|
const treeLoading = ref(false);
|
|
|
|
|
|
let currentRequestId = 0; // 用于追踪最新的请求ID
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 日期范围(默认前7天)
|
|
|
|
|
|
const dateRange = ref<[Dayjs, Dayjs]>([dayjs().subtract(7, "day"), dayjs()]);
|
|
|
|
|
|
|
|
|
|
|
|
// 录像列表
|
|
|
|
|
|
const recordLoading = ref(false);
|
2026-05-19 18:56:33 +08:00
|
|
|
|
const recordList = ref<any[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
// 加载录像列表
|
|
|
|
|
|
const loadRecordList = async () => {
|
|
|
|
|
|
// 获取当前选中的节点keys
|
|
|
|
|
|
if (!checkedKeys.value || checkedKeys.value.length === 0) {
|
|
|
|
|
|
recordList.value = [];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
recordLoading.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 将选中的key转换为原始的id
|
|
|
|
|
|
const originalIds: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 递归查找原始id
|
|
|
|
|
|
const findOriginalId = (nodes: any[], targetKey: string): string | null => {
|
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
|
if (node.key === targetKey) {
|
|
|
|
|
|
// 如果节点有原始id,使用原始id,否则使用name
|
|
|
|
|
|
return node.id || node.stcd || node.name;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
|
|
|
|
const found = findOriginalId(node.children, targetKey);
|
|
|
|
|
|
if (found) return found;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 从原始数据中获取对应的原始id
|
|
|
|
|
|
for (const key of checkedKeys.value) {
|
|
|
|
|
|
const originalId = findOriginalId(originalTreeData.value, key);
|
|
|
|
|
|
if (originalId) {
|
|
|
|
|
|
originalIds.push(originalId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const params = {
|
|
|
|
|
|
filter: {
|
|
|
|
|
|
logic: "and",
|
|
|
|
|
|
filters: [
|
|
|
|
|
|
{
|
|
|
|
|
|
field: "stcd",
|
|
|
|
|
|
operator: "in",
|
|
|
|
|
|
value: originalIds,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
field: "tm",
|
|
|
|
|
|
operator: "gte",
|
|
|
|
|
|
dataType: "date",
|
|
|
|
|
|
value: dateRange.value[0].format("YYYY-MM-DD 00:00:00"),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
field: "tm",
|
|
|
|
|
|
operator: "lte",
|
|
|
|
|
|
dataType: "date",
|
|
|
|
|
|
value: dateRange.value[1].format("YYYY-MM-DD 23:59:59"),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
sort: [{ field: "tm", dir: "desc" }],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const res = await envVdRunDataGetKendoList(params);
|
|
|
|
|
|
|
|
|
|
|
|
if (res?.data?.data) {
|
|
|
|
|
|
recordList.value = res.data.data.map((item: any) => ({
|
|
|
|
|
|
name: item.name || item.stnm || "录像文件",
|
|
|
|
|
|
time: item.tm ? dayjs(item.tm).format("YYYY-MM-DD HH:mm:ss") : "",
|
|
|
|
|
|
...item,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有录像数据,默认播放第一个
|
|
|
|
|
|
if (recordList.value.length > 0) {
|
|
|
|
|
|
const firstRecord = recordList.value[0];
|
|
|
|
|
|
// 使用 flpth 字段作为视频路径
|
|
|
|
|
|
const videoPath = firstRecord.flpth || firstRecord.url || firstRecord.videoUrl;
|
|
|
|
|
|
if (videoPath) {
|
|
|
|
|
|
// 清空之前的视频
|
|
|
|
|
|
videoList.value = Array(9).fill(null);
|
|
|
|
|
|
// 添加第一个录像到中间区域
|
|
|
|
|
|
addVideo({
|
|
|
|
|
|
flnm: firstRecord.name,
|
|
|
|
|
|
stnm: firstRecord.stnm,
|
|
|
|
|
|
url: videoPath,
|
|
|
|
|
|
...firstRecord,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
recordList.value = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
recordList.value = [];
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
recordLoading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 处理按钮点击
|
|
|
|
|
|
const handleAction = () => {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
// TODO: 实现查看分布功能
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 播放录像
|
|
|
|
|
|
const handlePlayRecord = (record: any) => {
|
|
|
|
|
|
// 使用 flpth 字段作为视频路径
|
|
|
|
|
|
const videoPath = record.flpth || record.url || record.videoUrl;
|
|
|
|
|
|
if (videoPath) {
|
|
|
|
|
|
const maxCount = parseInt(viewLayout.value);
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否已经添加过该录像
|
|
|
|
|
|
const existingIndex = videoList.value.findIndex((v) => v && v.flnm === record.flnm);
|
|
|
|
|
|
|
|
|
|
|
|
if (existingIndex !== -1) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查当前已播放的视频数量
|
|
|
|
|
|
const currentVideoCount = videoList.value.filter((v, i) => i < maxCount && v !== null)
|
|
|
|
|
|
.length;
|
|
|
|
|
|
|
|
|
|
|
|
if (currentVideoCount >= maxCount) {
|
|
|
|
|
|
message.warning(`视频数量已达上限(${maxCount}),请关闭已打开的视频或调整宫格布局`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 添加到空闲位置
|
|
|
|
|
|
addVideo({
|
|
|
|
|
|
flnm: record.name,
|
|
|
|
|
|
stnm: record.stnm,
|
|
|
|
|
|
url: videoPath,
|
|
|
|
|
|
...record,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const init = async () => {
|
|
|
|
|
|
const params = {
|
|
|
|
|
|
filter: {
|
|
|
|
|
|
logic: "and",
|
|
|
|
|
|
filters: [
|
|
|
|
|
|
{
|
|
|
|
|
|
field: "sttpFullPath",
|
|
|
|
|
|
operator: "contains",
|
|
|
|
|
|
dataType: "string",
|
|
|
|
|
|
value: "VD,VD_",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
group: [
|
|
|
|
|
|
{ dir: "asc", field: "sttpCode" },
|
|
|
|
|
|
{ dir: "asc", field: "sttpName" },
|
|
|
|
|
|
],
|
|
|
|
|
|
groupResultFlat: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let res = await baseMsstbprpt(params);
|
|
|
|
|
|
|
|
|
|
|
|
if (res?.data?.data) {
|
|
|
|
|
|
const _list: any[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 合并生态流量
|
|
|
|
|
|
res.data.data.forEach((el: any) => {
|
|
|
|
|
|
if (el?.sttpCode == "VD_EQ" || el?.sttpCode == "VD_EQS") {
|
|
|
|
|
|
const found = _list.find((item: any) => Array.isArray(item.sttpCode));
|
|
|
|
|
|
|
|
|
|
|
|
if (found) {
|
|
|
|
|
|
found.count_sttpCode += el.count_sttpCode;
|
|
|
|
|
|
found.count_sttpName += el.count_sttpName;
|
|
|
|
|
|
found.sttpCode.push(el?.sttpCode);
|
|
|
|
|
|
found.sttpCode_value.push(el?.sttpCode);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_list.push({
|
|
|
|
|
|
...el,
|
|
|
|
|
|
sttpCode: [el?.sttpCode],
|
|
|
|
|
|
sttpCode_value: [el?.sttpCode],
|
|
|
|
|
|
sttpName: "生态流量",
|
|
|
|
|
|
sttpName_value: "生态流量",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (el?.sttpCode != "VD_FPRD") {
|
|
|
|
|
|
_list.push(el);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const list =
|
|
|
|
|
|
_list.sort((a: any, b: any) => b?.count_sttpCode - a?.count_sttpCode) ?? [];
|
|
|
|
|
|
const totalCount = list.reduce((sum, item) => sum + item.count_sttpCode, 0);
|
|
|
|
|
|
|
|
|
|
|
|
const allObject = {
|
|
|
|
|
|
sttpCode: ["all"],
|
|
|
|
|
|
sttpCode_value: ["all"],
|
|
|
|
|
|
count_sttpCode: totalCount,
|
|
|
|
|
|
count_sttpName: "全部",
|
|
|
|
|
|
sttpName: "全部",
|
|
|
|
|
|
sttpName_value: "全部",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
list.unshift(allObject);
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为tabs格式
|
|
|
|
|
|
tabs.value = list.map((item: any, index: number) => ({
|
|
|
|
|
|
key: String(index),
|
|
|
|
|
|
title: item.sttpName || item.sttpName_value,
|
|
|
|
|
|
data: item,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
// 设置默认选中的tab
|
|
|
|
|
|
if (tabs.value.length > 0) {
|
|
|
|
|
|
activeTab.value = tabs.value[0].key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化后加载第一个tab的树形数据
|
|
|
|
|
|
if (tabs.value.length > 0) {
|
|
|
|
|
|
initTree();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化树形数据
|
|
|
|
|
|
const initTree = async () => {
|
|
|
|
|
|
// 生成新的请求ID
|
|
|
|
|
|
const requestId = ++currentRequestId;
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前选中tab的数据
|
|
|
|
|
|
const currentTab = tabs.value.find((tab) => tab.key === activeTab.value);
|
|
|
|
|
|
if (!currentTab) return;
|
|
|
|
|
|
|
|
|
|
|
|
const sttpCodeValue = currentTab.data?.sttpCode_value || [];
|
|
|
|
|
|
|
|
|
|
|
|
// 构建筛选条件
|
|
|
|
|
|
const filters: any[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 录像模式不传status,只传sttpCode
|
|
|
|
|
|
if (monitorType.value === "live") {
|
|
|
|
|
|
// 实时视频模式:添加status筛选
|
|
|
|
|
|
const statusValue = statusFilter.value === "online" ? 1 : 0;
|
|
|
|
|
|
filters.push({
|
|
|
|
|
|
field: "status",
|
|
|
|
|
|
operator: "eq",
|
|
|
|
|
|
dataType: "string",
|
|
|
|
|
|
value: statusValue,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果不是"全部"tab,才添加sttpCode筛选
|
|
|
|
|
|
if (sttpCodeValue[0] !== "all") {
|
|
|
|
|
|
filters.push({
|
|
|
|
|
|
field: "sttpCode",
|
|
|
|
|
|
operator: "in",
|
|
|
|
|
|
dataType: "string",
|
|
|
|
|
|
value: [sttpCodeValue],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const params = {
|
|
|
|
|
|
filter: {
|
|
|
|
|
|
logic: "and",
|
|
|
|
|
|
filters,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 清空视频列表和勾选状态
|
|
|
|
|
|
videoList.value = Array(9).fill(null);
|
|
|
|
|
|
checkedKeys.value = [];
|
|
|
|
|
|
expandedKeys.value = [];
|
|
|
|
|
|
|
|
|
|
|
|
treeLoading.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await envVdTreeGetKendoListCust(params);
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否是最新的请求,如果不是则忽略结果
|
|
|
|
|
|
if (requestId !== currentRequestId) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (res?.data?.data) {
|
|
|
|
|
|
// 为每个节点生成唯一的key
|
|
|
|
|
|
let keyCounter = 0;
|
|
|
|
|
|
const generateKey = (nodes: any[]) => {
|
|
|
|
|
|
nodes.forEach((node) => {
|
|
|
|
|
|
node.key = `key_${keyCounter++}`;
|
|
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
|
|
|
|
generateKey(node.children);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
generateKey(res.data.data);
|
|
|
|
|
|
treeData.value = res.data.data;
|
|
|
|
|
|
originalTreeData.value = JSON.parse(JSON.stringify(res.data.data)); // 深拷贝保存原始数据
|
|
|
|
|
|
|
|
|
|
|
|
// 只展开第一条数据的所有子节点
|
|
|
|
|
|
const getFirstBranchKeys = (nodes: any[]): string[] => {
|
|
|
|
|
|
let keys: string[] = [];
|
|
|
|
|
|
if (nodes.length > 0) {
|
|
|
|
|
|
const firstNode = nodes[0];
|
|
|
|
|
|
if (firstNode.children && firstNode.children.length > 0) {
|
|
|
|
|
|
keys.push(firstNode.key);
|
|
|
|
|
|
keys = keys.concat(getFirstBranchKeys(firstNode.children));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return keys;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
expandedKeys.value = getFirstBranchKeys(treeData.value);
|
|
|
|
|
|
|
|
|
|
|
|
// 默认选中第一条叶子节点
|
|
|
|
|
|
const getFirstLeafKey = (nodes: any[]): string | null => {
|
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
|
if (!node.children || node.children.length === 0) {
|
|
|
|
|
|
return node.key;
|
|
|
|
|
|
}
|
|
|
|
|
|
const leafKey = getFirstLeafKey(node.children);
|
|
|
|
|
|
if (leafKey) return leafKey;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const firstLeafKey = getFirstLeafKey(treeData.value);
|
|
|
|
|
|
if (firstLeafKey) {
|
|
|
|
|
|
// 设置默认勾选,并添加所有父节点
|
|
|
|
|
|
checkedKeys.value = [firstLeafKey];
|
|
|
|
|
|
addParentKeys(firstLeafKey); // 添加父节点
|
|
|
|
|
|
|
|
|
|
|
|
// 默认勾选后,需要手动添加视频
|
|
|
|
|
|
if (monitorType.value === "live") {
|
|
|
|
|
|
// 查找对应的节点数据
|
|
|
|
|
|
const findNodeByKey = (nodes: any[], key: string): any => {
|
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
|
if (node.key === key) return node;
|
|
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
|
|
|
|
const found = findNodeByKey(node.children, key);
|
|
|
|
|
|
if (found) return found;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const firstLeafNode = findNodeByKey(treeData.value, firstLeafKey);
|
|
|
|
|
|
if (firstLeafNode) {
|
|
|
|
|
|
const videoUrl =
|
|
|
|
|
|
firstLeafNode.url || firstLeafNode.videoUrl || firstLeafNode.playUrl;
|
|
|
|
|
|
if (videoUrl) {
|
|
|
|
|
|
addVideo({
|
|
|
|
|
|
flnm: firstLeafNode.name,
|
|
|
|
|
|
stnm: firstLeafNode.stnm,
|
|
|
|
|
|
url: videoUrl,
|
|
|
|
|
|
...firstLeafNode,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// 静默处理错误
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
treeLoading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Tab切换处理
|
|
|
|
|
|
const handleTabChange = (key: string) => {
|
|
|
|
|
|
activeTab.value = key;
|
|
|
|
|
|
initTree();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 状态筛选变化时重新加载树
|
|
|
|
|
|
watch(statusFilter, () => {
|
|
|
|
|
|
initTree();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 监控类型切换处理
|
|
|
|
|
|
const handleMonitorTypeChange = () => {
|
|
|
|
|
|
// 切换到录像模式时,设置viewLayout为4,隐藏1宫格
|
|
|
|
|
|
if (monitorType.value === "record") {
|
|
|
|
|
|
viewLayout.value = "4";
|
|
|
|
|
|
// 清空中间视频
|
|
|
|
|
|
videoList.value = Array(9).fill(null);
|
|
|
|
|
|
checkedKeys.value = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换到实时视频时,恢复在线默认状态
|
|
|
|
|
|
if (monitorType.value === "live") {
|
|
|
|
|
|
statusFilter.value = "online";
|
|
|
|
|
|
// 如果当前是1宫格,保持不变;否则设置为4
|
|
|
|
|
|
if (!["4", "6", "9"].includes(viewLayout.value)) {
|
|
|
|
|
|
viewLayout.value = "4";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清空搜索
|
|
|
|
|
|
searchText.value = "";
|
|
|
|
|
|
// 重新加载树
|
|
|
|
|
|
initTree();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 树节点勾选处理
|
|
|
|
|
|
const handleTreeSelect = (checkedKeysValue: any[], info: any) => {
|
|
|
|
|
|
// 只在实时视频模式下响应勾选
|
|
|
|
|
|
if (monitorType.value !== "live") return;
|
|
|
|
|
|
|
|
|
|
|
|
const nodeData = info.node;
|
|
|
|
|
|
|
|
|
|
|
|
// 判断是勾选还是取消勾选
|
|
|
|
|
|
const isChecked = checkedKeysValue.includes(nodeData.key);
|
|
|
|
|
|
|
|
|
|
|
|
if (isChecked) {
|
|
|
|
|
|
// 勾选:添加视频
|
|
|
|
|
|
if (!nodeData.children || nodeData.children.length === 0) {
|
|
|
|
|
|
// 叶子节点:直接添加(需检查数量限制)
|
|
|
|
|
|
const videoUrl = nodeData.url || nodeData.videoUrl || nodeData.playUrl;
|
|
|
|
|
|
if (videoUrl) {
|
|
|
|
|
|
// 检查是否超出布局限制
|
|
|
|
|
|
const currentVideoCount = videoList.value.filter(
|
|
|
|
|
|
(v, i) => i < parseInt(viewLayout.value) && v !== null
|
|
|
|
|
|
).length;
|
|
|
|
|
|
const maxCount = parseInt(viewLayout.value);
|
|
|
|
|
|
|
|
|
|
|
|
if (currentVideoCount < maxCount) {
|
|
|
|
|
|
addVideo({
|
|
|
|
|
|
flnm: nodeData.name,
|
|
|
|
|
|
stnm: nodeData.stnm,
|
|
|
|
|
|
url: videoUrl,
|
|
|
|
|
|
...nodeData,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 自动添加所有父节点到 checkedKeys
|
|
|
|
|
|
addParentKeys(nodeData.key);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 超出限制,使用 setTimeout 延迟取消勾选,避免与 Tree 组件的自动勾选冲突
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
// 使用 filter 创建新数组以触发响应式更新
|
|
|
|
|
|
checkedKeys.value = checkedKeys.value.filter((key) => key !== nodeData.key);
|
|
|
|
|
|
}, 50);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 父节点:递归查找所有叶子节点并添加视频(需检查数量限制)
|
|
|
|
|
|
addVideosFromNodeWithLimit(nodeData);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 取消勾选:移除视频
|
|
|
|
|
|
if (!nodeData.children || nodeData.children.length === 0) {
|
|
|
|
|
|
// 叶子节点:直接移除
|
|
|
|
|
|
removeVideoByNodeKey(nodeData.key);
|
|
|
|
|
|
|
|
|
|
|
|
// 检查并移除不再需要的父节点
|
|
|
|
|
|
removeUnnecessaryParentKeys(nodeData.key);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 父节点:递归查找所有叶子节点并移除视频
|
|
|
|
|
|
removeVideosFromNode(nodeData);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 添加节点的所有父节点到 checkedKeys(只有当父节点下所有子节点都被勾选时才添加)
|
|
|
|
|
|
const addParentKeys = (childKey: string) => {
|
|
|
|
|
|
const parentKeys = getParentKeys(childKey);
|
|
|
|
|
|
|
|
|
|
|
|
parentKeys.forEach((parentKey) => {
|
|
|
|
|
|
// 查找父节点
|
|
|
|
|
|
const findNode = (nodes: any[], key: string): any => {
|
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
|
if (node.key === key) return node;
|
|
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
|
|
|
|
const found = findNode(node.children, key);
|
|
|
|
|
|
if (found) return found;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const parentNode = findNode(treeData.value, parentKey);
|
|
|
|
|
|
if (parentNode) {
|
|
|
|
|
|
// 检查父节点下的所有子节点是否都被勾选
|
|
|
|
|
|
const allChildrenChecked = areAllChildrenChecked(parentNode);
|
|
|
|
|
|
if (allChildrenChecked && !checkedKeys.value.includes(parentKey)) {
|
|
|
|
|
|
// 所有子节点都被勾选,添加父节点
|
|
|
|
|
|
checkedKeys.value = [...checkedKeys.value, parentKey];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 获取节点的所有父节点 key
|
|
|
|
|
|
const getParentKeys = (childKey: string): string[] => {
|
|
|
|
|
|
const parentKeys: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
const findParents = (
|
|
|
|
|
|
nodes: any[],
|
|
|
|
|
|
targetKey: string,
|
|
|
|
|
|
parents: string[] = []
|
|
|
|
|
|
): boolean => {
|
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
|
if (node.key === targetKey) {
|
|
|
|
|
|
parentKeys.push(...parents);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
|
|
|
|
if (findParents(node.children, targetKey, [...parents, node.key])) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
findParents(treeData.value, childKey);
|
|
|
|
|
|
return parentKeys;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 检查并移除不再需要的父节点
|
|
|
|
|
|
const removeUnnecessaryParentKeys = (childKey: string) => {
|
|
|
|
|
|
const parentKeys = getParentKeys(childKey);
|
|
|
|
|
|
|
|
|
|
|
|
parentKeys.forEach((parentKey) => {
|
|
|
|
|
|
// 查找父节点
|
|
|
|
|
|
const findNode = (nodes: any[], key: string): any => {
|
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
|
if (node.key === key) return node;
|
|
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
|
|
|
|
const found = findNode(node.children, key);
|
|
|
|
|
|
if (found) return found;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const parentNode = findNode(treeData.value, parentKey);
|
|
|
|
|
|
if (parentNode && checkedKeys.value.includes(parentKey)) {
|
|
|
|
|
|
// 检查父节点下是否所有直接子节点都被勾选
|
|
|
|
|
|
const allDirectChildrenChecked = areAllDirectChildrenChecked(parentNode);
|
|
|
|
|
|
|
|
|
|
|
|
if (!allDirectChildrenChecked) {
|
|
|
|
|
|
// 不是所有直接子节点都被勾选,移除父节点
|
|
|
|
|
|
checkedKeys.value = checkedKeys.value.filter((key) => key !== parentKey);
|
|
|
|
|
|
|
|
|
|
|
|
// 递归检查父节点的父节点
|
|
|
|
|
|
removeUnnecessaryParentKeys(parentKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 检查父节点下是否所有直接子节点都被勾选
|
|
|
|
|
|
const areAllDirectChildrenChecked = (parentNode: any): boolean => {
|
|
|
|
|
|
if (!parentNode.children || parentNode.children.length === 0) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查所有直接子节点是否都在 checkedKeys 中
|
|
|
|
|
|
return parentNode.children.every((child: any) => checkedKeys.value.includes(child.key));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 检查父节点下的所有子节点是否都被勾选
|
|
|
|
|
|
const areAllChildrenChecked = (parentNode: any): boolean => {
|
|
|
|
|
|
if (!parentNode.children || parentNode.children.length === 0) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取所有叶子节点的 key
|
|
|
|
|
|
const getLeafKeys = (node: any): string[] => {
|
|
|
|
|
|
let keys: string[] = [];
|
|
|
|
|
|
if (!node.children || node.children.length === 0) {
|
|
|
|
|
|
return [node.key];
|
|
|
|
|
|
}
|
|
|
|
|
|
node.children.forEach((child: any) => {
|
|
|
|
|
|
keys = keys.concat(getLeafKeys(child));
|
|
|
|
|
|
});
|
|
|
|
|
|
return keys;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const allLeafKeys = getLeafKeys(parentNode);
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否所有叶子节点都在 checkedKeys 中
|
|
|
|
|
|
return allLeafKeys.every((key: string) => checkedKeys.value.includes(key));
|
2026-05-18 09:03:20 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
// 递归添加节点及其所有子节点的视频(带数量限制)
|
|
|
|
|
|
const addVideosFromNodeWithLimit = (node: any) => {
|
|
|
|
|
|
const maxCount = parseInt(viewLayout.value);
|
|
|
|
|
|
|
|
|
|
|
|
// 先移除父节点本身的 key(因为我们只关心子节点)
|
|
|
|
|
|
checkedKeys.value = checkedKeys.value.filter((key) => key !== node.key);
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前已添加的视频数量
|
|
|
|
|
|
const getCurrentVideoCount = () => {
|
|
|
|
|
|
return videoList.value.filter((v, i) => i < maxCount && v !== null).length;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 收集所有叶子节点
|
|
|
|
|
|
const collectLeafNodes = (n: any): any[] => {
|
|
|
|
|
|
let leaves: any[] = [];
|
|
|
|
|
|
if (!n.children || n.children.length === 0) {
|
|
|
|
|
|
const videoUrl = n.url || n.videoUrl || n.playUrl;
|
|
|
|
|
|
if (videoUrl) {
|
|
|
|
|
|
leaves.push(n);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
n.children.forEach((child: any) => {
|
|
|
|
|
|
leaves = leaves.concat(collectLeafNodes(child));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return leaves;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const allLeafNodes = collectLeafNodes(node);
|
|
|
|
|
|
const currentCount = getCurrentVideoCount();
|
|
|
|
|
|
const remainingSlots = maxCount - currentCount;
|
|
|
|
|
|
|
|
|
|
|
|
if (remainingSlots <= 0) {
|
|
|
|
|
|
// 没有剩余位置,延迟取消所有相关节点的勾选
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
cancelNodeCheck(node);
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 只添加前 remainingSlots 个叶子节点
|
|
|
|
|
|
const nodesToAdd = allLeafNodes.slice(0, remainingSlots);
|
|
|
|
|
|
const nodesToCancel = allLeafNodes.slice(remainingSlots);
|
|
|
|
|
|
|
|
|
|
|
|
// 添加允许的视频并勾选对应节点
|
|
|
|
|
|
nodesToAdd.forEach((leafNode: any) => {
|
|
|
|
|
|
addVideo({
|
|
|
|
|
|
flnm: leafNode.name,
|
|
|
|
|
|
stnm: leafNode.stnm,
|
|
|
|
|
|
url: leafNode.url || leafNode.videoUrl || leafNode.playUrl,
|
|
|
|
|
|
...leafNode,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 将子节点 key 添加到 checkedKeys(不添加父节点)
|
|
|
|
|
|
if (!checkedKeys.value.includes(leafNode.key)) {
|
|
|
|
|
|
checkedKeys.value = [...checkedKeys.value, leafNode.key];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 先取消超出限制的节点的勾选
|
|
|
|
|
|
if (nodesToCancel.length > 0) {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
cancelNodesCheck(nodesToCancel);
|
|
|
|
|
|
|
|
|
|
|
|
// 取消后再为所有添加的子节点添加父节点
|
|
|
|
|
|
nodesToAdd.forEach((leafNode: any) => {
|
|
|
|
|
|
addParentKeys(leafNode.key);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, 50);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 没有超出限制,直接添加父节点
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
nodesToAdd.forEach((leafNode: any) => {
|
|
|
|
|
|
addParentKeys(leafNode.key);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, 50);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 取消单个节点的勾选
|
|
|
|
|
|
const cancelNodeCheck = (node: any) => {
|
|
|
|
|
|
// 使用 filter 创建新数组以触发响应式更新
|
|
|
|
|
|
checkedKeys.value = checkedKeys.value.filter((key) => key !== node.key);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 取消多个节点的勾选
|
|
|
|
|
|
const cancelNodesCheck = (nodes: any[]) => {
|
|
|
|
|
|
nodes.forEach((node: any) => {
|
|
|
|
|
|
cancelNodeCheck(node);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 递归移除节点及其所有子节点的视频
|
|
|
|
|
|
const removeVideosFromNode = (node: any) => {
|
|
|
|
|
|
if (!node.children || node.children.length === 0) {
|
|
|
|
|
|
// 叶子节点:移除视频
|
|
|
|
|
|
removeVideoByNodeKey(node.key);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 有子节点:递归处理所有子节点
|
|
|
|
|
|
node.children.forEach((child: any) => {
|
|
|
|
|
|
removeVideosFromNode(child);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 根据节点key移除视频
|
|
|
|
|
|
const removeVideoByNodeKey = (nodeKey: string) => {
|
|
|
|
|
|
const index = videoList.value.findIndex((video) => video && video.key === nodeKey);
|
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
|
videoList.value[index] = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 监听选中节点、日期范围和监控类型变化,加载录像列表
|
|
|
|
|
|
watch(
|
|
|
|
|
|
[checkedKeys, dateRange, monitorType],
|
|
|
|
|
|
([newCheckedKeys, newDateRange, newMonitorType]) => {
|
|
|
|
|
|
if (newMonitorType === "record") {
|
|
|
|
|
|
loadRecordList();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
recordList.value = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 监听搜索文本变化,实时过滤
|
|
|
|
|
|
watch(searchText, (newValue) => {
|
|
|
|
|
|
filterTree(newValue);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤树形数据
|
|
|
|
|
|
const filterTree = (keyword: string) => {
|
|
|
|
|
|
if (!keyword || keyword.trim() === "") {
|
|
|
|
|
|
// 清空搜索时恢复原始数据
|
|
|
|
|
|
treeData.value = JSON.parse(JSON.stringify(originalTreeData.value));
|
|
|
|
|
|
|
|
|
|
|
|
// 重新展开第一条
|
|
|
|
|
|
const getFirstBranchKeys = (nodes: any[]): string[] => {
|
|
|
|
|
|
let keys: string[] = [];
|
|
|
|
|
|
if (nodes.length > 0) {
|
|
|
|
|
|
const firstNode = nodes[0];
|
|
|
|
|
|
if (firstNode.children && firstNode.children.length > 0) {
|
|
|
|
|
|
keys.push(firstNode.key);
|
|
|
|
|
|
keys = keys.concat(getFirstBranchKeys(firstNode.children));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return keys;
|
|
|
|
|
|
};
|
|
|
|
|
|
expandedKeys.value = getFirstBranchKeys(treeData.value);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const lowerKeyword = keyword.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
// 递归过滤树节点
|
|
|
|
|
|
const filterNodes = (nodes: any[]): any[] => {
|
|
|
|
|
|
const filtered: any[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
nodes.forEach((node) => {
|
|
|
|
|
|
const nodeName = (node.name || "").toLowerCase();
|
|
|
|
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (hasChildren) {
|
|
|
|
|
|
// 如果有子节点,先过滤子节点
|
|
|
|
|
|
const filteredChildren = filterNodes(node.children);
|
|
|
|
|
|
|
|
|
|
|
|
// 如果父节点名称匹配或子节点有匹配项,则保留该节点
|
|
|
|
|
|
if (nodeName.includes(lowerKeyword) || filteredChildren.length > 0) {
|
|
|
|
|
|
filtered.push({
|
|
|
|
|
|
...node,
|
|
|
|
|
|
children: filteredChildren.length > 0 ? filteredChildren : node.children,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 叶子节点,直接判断是否匹配
|
|
|
|
|
|
if (nodeName.includes(lowerKeyword)) {
|
|
|
|
|
|
filtered.push({ ...node });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return filtered;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
treeData.value = filterNodes(originalTreeData.value);
|
|
|
|
|
|
|
|
|
|
|
|
// 搜索时展开所有匹配的节点
|
|
|
|
|
|
const getAllKeys = (nodes: any[]): string[] => {
|
|
|
|
|
|
let keys: string[] = [];
|
|
|
|
|
|
nodes.forEach((node) => {
|
|
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
|
|
|
|
keys.push(node.key);
|
|
|
|
|
|
keys = keys.concat(getAllKeys(node.children));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return keys;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
expandedKeys.value = getAllKeys(treeData.value);
|
2026-05-18 09:03:20 +08:00
|
|
|
|
};
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
init();
|
|
|
|
|
|
});
|
2026-05-18 09:03:20 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.shiPinJianKongZhuanTi-page {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
position: fixed;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
top: 60px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
z-index: 900;
|
|
|
|
|
|
pointer-events: all;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
padding-top: 2px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
background-color: #ffffff;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.custom-tabs {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 48px;
|
|
|
|
|
|
background-color: #ffffff;
|
|
|
|
|
|
z-index: 6666;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
:deep(.ant-tabs-nav) {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
height: 38px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
margin-bottom: 0;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
padding-left: 10px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
.ant-tabs-tab {
|
|
|
|
|
|
padding: 12px 0;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
.ant-tabs-ink-bar {
|
|
|
|
|
|
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAYAAACXU8ZrAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABZ0RVh0Q3JlYXRpb24gVGltZQAxMS8wMy8yMrqNQAoAAAAcdEVYdFNvZnR3YXJlAEFkb2JlIEZpcmV3b3JrcyBDUzbovLKMAAAAOklEQVQImWNgQAchkx3QhZhQeEFTExj+MexnCJoyH1mYEUUBwz8kScYFDOtyEhGKMBSgKmTErQChEAA6FRM7O0rIOgAAAABJRU5ErkJggg==")
|
|
|
|
|
|
no-repeat center bottom;
|
|
|
|
|
|
border-bottom: 2px solid #005293;
|
|
|
|
|
|
height: 7px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
.ant-tabs-tab-active {
|
|
|
|
|
|
.ant-tabs-tab-btn {
|
|
|
|
|
|
color: #2f6b98 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tabs-content {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
height: calc(100% - 114px);
|
|
|
|
|
|
background-color: #ffffff;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
overflow: hidden;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
padding: 16px 26px 16px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 顶部标题和图标选择区域
|
|
|
|
|
|
.content-header {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 42px;
|
|
|
|
|
|
line-height: 42px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
background-color: #fff;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
border: 1px solid #f0f0f0;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
.header-left {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
width: 300px;
|
|
|
|
|
|
height: 42px;
|
|
|
|
|
|
padding: 0 12px;
|
|
|
|
|
|
border-right: 1px solid #f0f0f0;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
.title {
|
|
|
|
|
|
font-size: 16px;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: rgba(0, 0, 0, 0.85);
|
|
|
|
|
|
:deep(.ant-divider) {
|
|
|
|
|
|
margin-right: 4px;
|
|
|
|
|
|
height: 1.3em !important;
|
|
|
|
|
|
border-left: 3px solid rgb(47, 107, 152) !important;
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-right {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
height: 42px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-right: 12px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
:deep(.ant-radio-group) {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin-top: 1px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
border-radius: 0px;
|
|
|
|
|
|
gap: 8px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
.ant-radio-button-wrapper {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
padding: 0 !important;
|
|
|
|
|
|
border: none !important;
|
|
|
|
|
|
padding-left: 6px !important;
|
|
|
|
|
|
border-radius: 0px;
|
|
|
|
|
|
&::before {
|
|
|
|
|
|
width: 0px;
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
.anticon {
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 主体内容区域
|
|
|
|
|
|
.content-body {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 左侧面板
|
|
|
|
|
|
.left-panel {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
width: 300px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
background-color: #fff;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
border-left: 1px solid #f0f0f0;
|
|
|
|
|
|
border-right: 1px solid #f0f0f0;
|
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
flex-shrink: 0;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
padding: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
.monitor-radio {
|
|
|
|
|
|
margin-bottom: 8px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
:deep(.ant-radio-group) {
|
|
|
|
|
|
width: 100%;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
.ant-radio-button-wrapper {
|
|
|
|
|
|
width: 50%;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
line-height: 32px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.search-bar {
|
|
|
|
|
|
display: flex;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
gap: 4px;
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.ant-input-wrapper) {
|
|
|
|
|
|
input {
|
|
|
|
|
|
height: 30px;
|
|
|
|
|
|
padding-left: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.ant-input-group-addon button {
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
.status-filter {
|
|
|
|
|
|
flex-shrink: 0;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
|
|
|
|
|
|
.status-btn {
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
padding: 4px 6px;
|
|
|
|
|
|
background-color: #fff;
|
|
|
|
|
|
border: 1px solid #d9d9d9;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
|
|
|
|
.iconfont {
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
color: #8c8c8c;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.iconfont.__online {
|
|
|
|
|
|
color: rgb(0, 160, 80);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.active {
|
|
|
|
|
|
background-color: #fff;
|
|
|
|
|
|
border-color: rgb(47, 107, 152) !important;
|
|
|
|
|
|
|
|
|
|
|
|
.iconfont:not(.__online) {
|
|
|
|
|
|
color: #595959;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&::after {
|
|
|
|
|
|
content: "";
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: -3px;
|
|
|
|
|
|
right: 0px;
|
|
|
|
|
|
width: 15px;
|
|
|
|
|
|
height: 15px;
|
|
|
|
|
|
background-image: url(@/assets/icons/fishxz.7410ec88.svg);
|
|
|
|
|
|
background-size: contain;
|
|
|
|
|
|
background-repeat: no-repeat;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tree-container {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow-y: auto;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
.tree-spin {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
:deep(.ant-spin-container) {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.empty-tree {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
color: red;
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
:deep(.ant-tree) {
|
|
|
|
|
|
.ant-tree-node-content-wrapper {
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
background-color: #f5f5f5;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-19 18:56:33 +08:00
|
|
|
|
.ant-tree-indent-unit {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
.ant-tree-node-selected {
|
|
|
|
|
|
.ant-tree-node-content-wrapper {
|
|
|
|
|
|
background-color: #e6f7ff;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 下拉箭头样式
|
|
|
|
|
|
.ant-tree-switcher {
|
|
|
|
|
|
line-height: 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.ant-tree-checkbox {
|
|
|
|
|
|
margin-top: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tree-status-icon {
|
|
|
|
|
|
margin-right: 4px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
|
|
|
|
|
|
&.__online {
|
|
|
|
|
|
color: rgb(0, 160, 80);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.offline {
|
|
|
|
|
|
color: rgb(141, 141, 141);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 中间视频区域
|
|
|
|
|
|
.center-panel {
|
|
|
|
|
|
flex: 1;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
padding: 20px 12px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
border-right: 1px solid #f0f0f0;
|
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
.video-grid {
|
|
|
|
|
|
display: grid;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
gap: 15px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
height: 100%;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
grid-auto-rows: 1fr;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
&.layout-1 {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
grid-template-rows: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.layout-4 {
|
|
|
|
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
|
|
|
grid-template-rows: repeat(2, 1fr);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.layout-6 {
|
|
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
|
|
grid-template-rows: repeat(2, 1fr);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.layout-9 {
|
|
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
|
|
grid-template-rows: repeat(3, 1fr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.video-item {
|
|
|
|
|
|
background-color: #000;
|
|
|
|
|
|
overflow: hidden;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
min-height: 0;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
.video-placeholder {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
|
|
|
|
|
|
.video-icon {
|
|
|
|
|
|
font-size: 48px;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.video-label {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 右侧回放面板
|
|
|
|
|
|
.right-panel {
|
|
|
|
|
|
width: 280px;
|
|
|
|
|
|
background-color: #fff;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
border-right: 1px solid #f0f0f0;
|
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
|
|
|
|
|
|
.replay-header {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
padding: 8px;
|
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
.replay-title {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: rgba(0, 0, 0, 0.85);
|
|
|
|
|
|
:deep(.ant-divider) {
|
|
|
|
|
|
margin-right: 6px;
|
|
|
|
|
|
height: 1.2em !important;
|
|
|
|
|
|
border-left: 3px solid rgb(47, 107, 152) !important;
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.date-picker {
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-list {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow-y: auto;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
padding: 0 8px 18px;
|
|
|
|
|
|
.record-spin {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
:deep(.ant-spin-container) {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.empty-record {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-items {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
.record-item {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
padding: 0 6px;
|
|
|
|
|
|
position: relative;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
transition: background-color 0.2s;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
cursor: pointer;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
background-color: #f5f5f5;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
.sp_btn {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-image-wrapper {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 134px;
|
|
|
|
|
|
border: 1px solid #d9d9d9;
|
|
|
|
|
|
border-bottom: none;
|
|
|
|
|
|
padding: 6px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.ant-image) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
|
|
|
|
|
|
img {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.sp_btn {
|
|
|
|
|
|
opacity: 0.65;
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
width: 40px;
|
|
|
|
|
|
height: 40px;
|
|
|
|
|
|
border-radius: 40px;
|
|
|
|
|
|
top: 50%;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
margin-left: -20px;
|
|
|
|
|
|
margin-top: -40px;
|
|
|
|
|
|
background-color: rgb(12, 51, 88);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
i {
|
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
|
}
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-info {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
height: 50px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
border: 1px solid #d9d9d9;
|
|
|
|
|
|
border-top: none;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
font-size: 14px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
|
|
|
|
|
|
.record-name {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
padding: 0 8px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-time {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
font-size: 14px;
|
2026-05-18 09:03:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|