WholeProcessPlatform/frontend/src/views/shiPinJianKong/shiPinJianKongZhuanTi.vue
2026-07-14 16:08:10 +08:00

1643 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="shiPinJianKongZhuanTi-page">
<a-tabs
v-model:activeKey="activeTab"
:tabBarGutter="32"
class="custom-tabs"
@change="handleTabChange"
>
<template #rightExtra>
<a-button type="default" class="mr-2" @click="handleAction">
<template #icon><i class="iconfont icon-fenbu mr-1" /></template>
查看分布
</a-button>
</template>
<a-tab-pane v-for="tab in tabs" :key="tab.key">
<template #tab> {{ tab.title }}({{ tab.data?.count_sttpCode || 0 }}) </template>
</a-tab-pane>
</a-tabs>
<div class="tabs-content">
<!-- 顶部标题和图标选择区域 -->
<div class="content-header">
<div class="header-left">
<span class="title"> <a-divider type="vertical"></a-divider> 监控列表</span>
</div>
<div class="header-right">
<a-radio-group v-model:value="viewLayout" button-style="solid">
<a-radio-button v-if="monitorType === 'live'" value="1">
<i class="iconfont icon-view1"></i>
</a-radio-button>
<a-radio-button value="4">
<i class="iconfont icon-view4"></i>
</a-radio-button>
<a-radio-button value="6">
<i class="iconfont icon-view6"></i>
</a-radio-button>
<a-radio-button value="9">
<i class="iconfont icon-view9"></i>
</a-radio-button>
</a-radio-group>
</div>
</div>
<!-- 主体内容区域 -->
<div class="content-body">
<!-- 左侧监控列表 -->
<div class="left-panel">
<!-- 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>
<!-- 查询和状态筛选 -->
<div class="search-bar">
<a-input-search
v-model:value="searchText"
placeholder="输入关键字搜索"
size="small"
:style="{ flex: monitorType === 'record' ? '1' : '0 1 auto' }"
allow-clear
/>
<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>
</div>
<!-- 树形结构 -->
<div class="tree-container">
<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>
</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">
<LiveVideoBox
:video-data="videoList[index - 1]"
:monitor-type="monitorType"
height="100%"
@close="(nodeKey) => removeVideo(index - 1, nodeKey)"
/>
</div>
</div>
</div>
<!-- 右侧回放面板(仅录像模式显示) -->
<div v-if="monitorType === 'record'" class="right-panel">
<div class="replay-header">
<span class="replay-title"><a-divider type="vertical" />回放列表</span>
</div>
<!-- 时间选择 -->
<div class="date-picker">
<a-range-picker
v-model:value="dateRange"
format="YYYY-MM-DD"
style="width: 100%"
:presets="DateSetting.RangeButton.days"
/>
</div>
<!-- 录像列表 -->
<div class="record-list">
<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="" />
</div>
<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>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from "vue";
import { message } from "ant-design-vue";
import {
baseMsstbprpt,
envVdTreeGetKendoListCust,
envVdRunDataGetKendoList,
} from "@/api/shiPinJianKong";
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import { DateSetting } from "@/utils/enumeration";
import LiveVideoBox from "./components/LiveVideoBox.vue";
// Tab相关
const tabs = ref<any[]>([]);
const activeTab = ref<string>("");
// 视图布局选择1/4/6/9宫格
const viewLayout = ref<"1" | "4" | "6" | "9">("4");
// 监控类型(实时视频/录像)
const monitorType = ref<"live" | "record">("live");
// 搜索文本
const searchText = ref("");
// 状态筛选(在线/离线)
const statusFilter = ref<"online" | "offline">("online");
// 视频列表
const videoList = ref<any[]>(Array(9).fill(null));
// 等待队列(存放超出布局限制的视频)
const pendingVideoQueue = ref<any[]>([]);
// 移除视频
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;
// 尝试从队列补位
fillEmptySlot(index);
};
// 填充空位(从队列中取第一个视频)
const fillEmptySlot = (emptyIndex: number) => {
if (pendingVideoQueue.value.length > 0) {
const nextVideo = pendingVideoQueue.value.shift();
if (nextVideo) {
videoList.value[emptyIndex] = nextVideo;
}
}
};
// 添加视频到列表
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 {
// 如果满了,加入等待队列
if (!pendingVideoQueue.value.find((v) => v.key === videoData.key)) {
pendingVideoQueue.value.push(videoData);
}
}
};
// 监听布局变化,调整视频列表长度
watch(viewLayout, (newLayout, oldLayout) => {
const newMaxCount = parseInt(newLayout);
const oldMaxCount = oldLayout ? parseInt(oldLayout) : newMaxCount;
// 布局减小时,超出部分移入队列
if (newMaxCount < oldMaxCount) {
for (let i = newMaxCount; i < oldMaxCount && i < videoList.value.length; i++) {
const video = videoList.value[i];
if (video && !pendingVideoQueue.value.find((v) => v.key === video.key)) {
pendingVideoQueue.value.push(video);
}
videoList.value[i] = null;
}
}
// 确保数组长度足够
while (videoList.value.length < newMaxCount) {
videoList.value.push(null);
}
// 布局增大时,从队列填充新空位
if (newMaxCount > oldMaxCount) {
const availableSlots = newMaxCount - oldMaxCount;
for (let i = 0; i < availableSlots && pendingVideoQueue.value.length > 0; i++) {
const emptyIndex = videoList.value.findIndex(
(v, i) => i < newMaxCount && (v === null || v === undefined)
);
if (emptyIndex !== -1) {
const nextVideo = pendingVideoQueue.value.shift();
if (nextVideo) {
videoList.value[emptyIndex] = nextVideo;
}
}
}
}
});
// 树形数据
const expandedKeys = ref<string[]>([]);
const checkedKeys = ref<string[]>([]);
const treeData = ref<any[]>([]);
const originalTreeData = ref<any[]>([]); // 保存原始数据
const treeLoading = ref(false);
let currentRequestId = 0; // 用于追踪最新的请求ID
// 日期范围默认前7天
const dateRange = ref<[Dayjs, Dayjs]>([dayjs().subtract(7, "day"), dayjs()]);
// 录像列表
const recordLoading = ref(false);
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;
}
};
// 处理按钮点击
const handleAction = () => {
// 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.url === videoPath);
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 = [];
pendingVideoQueue.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 = [];
pendingVideoQueue.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 {
// 超出限制,加入等待队列(保持勾选状态)
if (!pendingVideoQueue.value.find((v) => v.key === nodeData.key)) {
pendingVideoQueue.value.push({
flnm: nodeData.name,
stnm: nodeData.stnm,
url: videoUrl,
...nodeData,
});
}
}
} else {
}
} else {
// 父节点:递归查找所有叶子节点并添加视频(需检查数量限制)
addVideosFromNodeWithLimit(nodeData);
}
} else {
// 取消勾选:移除视频
if (!nodeData.children || nodeData.children.length === 0) {
// 先检查是否在队列中
const queueIndex = pendingVideoQueue.value.findIndex(
(v) => v.key === nodeData.key
);
if (queueIndex !== -1) {
// 从队列中移除
pendingVideoQueue.value.splice(queueIndex, 1);
removeUnnecessaryParentKeys(nodeData.key);
return;
}
// 叶子节点:直接移除
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));
};
// 递归添加节点及其所有子节点的视频(带数量限制)
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) {
// 没有剩余位置,全部加入队列
allLeafNodes.forEach((leafNode: any) => {
if (!pendingVideoQueue.value.find((v) => v.key === leafNode.key)) {
pendingVideoQueue.value.push({
flnm: leafNode.name,
stnm: leafNode.stnm,
url: leafNode.url || leafNode.videoUrl || leafNode.playUrl,
...leafNode,
});
}
});
return;
}
// 只添加前 remainingSlots 个叶子节点
const nodesToAdd = allLeafNodes.slice(0, remainingSlots);
const nodesToQueue = 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 (nodesToQueue.length > 0) {
nodesToQueue.forEach((leafNode: any) => {
if (!pendingVideoQueue.value.find((v) => v.key === leafNode.key)) {
pendingVideoQueue.value.push({
flnm: leafNode.name,
stnm: leafNode.stnm,
url: leafNode.url || leafNode.videoUrl || leafNode.playUrl,
...leafNode,
});
}
});
}
// 添加父节点
setTimeout(() => {
nodesToAdd.forEach((leafNode: any) => {
addParentKeys(leafNode.key);
});
}, 50);
};
// 递归移除节点及其所有子节点的视频
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 queueIndex = pendingVideoQueue.value.findIndex(
(v) => v.key === nodeKey
);
if (queueIndex !== -1) {
pendingVideoQueue.value.splice(queueIndex, 1);
return;
}
const index = videoList.value.findIndex((video) => video && video.key === nodeKey);
if (index !== -1) {
videoList.value[index] = null;
// 尝试从队列补位
fillEmptySlot(index);
}
};
// 监听选中节点、日期范围和监控类型变化,加载录像列表
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);
};
onMounted(() => {
init();
});
</script>
<style scoped lang="scss">
.shiPinJianKongZhuanTi-page {
position: fixed;
left: 0;
top: 60px;
z-index: 900;
pointer-events: all;
width: 100%;
height: 100%;
padding-top: 2px;
background-color: #ffffff;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
}
.custom-tabs {
width: 100%;
height: 48px;
background-color: #ffffff;
z-index: 6666;
:deep(.ant-tabs-nav) {
height: 38px;
margin-bottom: 0;
padding-left: 10px;
.ant-tabs-tab {
padding: 12px 0;
}
.ant-tabs-ink-bar {
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAYAAACXU8ZrAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABZ0RVh0Q3JlYXRpb24gVGltZQAxMS8wMy8yMrqNQAoAAAAcdEVYdFNvZnR3YXJlAEFkb2JlIEZpcmV3b3JrcyBDUzbovLKMAAAAOklEQVQImWNgQAchkx3QhZhQeEFTExj+MexnCJoyH1mYEUUBwz8kScYFDOtyEhGKMBSgKmTErQChEAA6FRM7O0rIOgAAAABJRU5ErkJggg==")
no-repeat center bottom;
border-bottom: 2px solid #005293;
height: 7px;
}
.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: #2f6b98 !important;
}
}
}
}
.tabs-content {
height: calc(100% - 114px);
background-color: #ffffff;
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px 26px 16px;
}
// 顶部标题和图标选择区域
.content-header {
width: 100%;
height: 42px;
line-height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
border: 1px solid #f0f0f0;
.header-left {
width: 300px;
height: 42px;
padding: 0 12px;
border-right: 1px solid #f0f0f0;
.title {
font-size: 16px;
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;
}
}
}
.header-right {
height: 42px;
display: flex;
align-items: center;
margin-right: 12px;
:deep(.ant-radio-group) {
width: 100%;
margin-top: 1px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 0px;
gap: 8px;
.ant-radio-button-wrapper {
width: 28px;
height: 28px;
padding: 0 !important;
border: none !important;
padding-left: 6px !important;
border-radius: 0px;
&::before {
width: 0px;
}
.anticon {
font-size: 16px;
}
}
}
}
}
// 主体内容区域
.content-body {
flex: 1;
display: flex;
overflow: hidden;
}
// 左侧面板
.left-panel {
width: 300px;
background-color: #fff;
border-left: 1px solid #f0f0f0;
border-right: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
flex-shrink: 0;
padding: 12px;
.monitor-radio {
margin-bottom: 8px;
:deep(.ant-radio-group) {
width: 100%;
.ant-radio-button-wrapper {
width: 50%;
height: 32px;
line-height: 32px;
text-align: center;
}
}
}
.search-bar {
display: flex;
gap: 4px;
:deep(.ant-input-wrapper) {
input {
height: 30px;
padding-left: 4px;
}
.ant-input-group-addon button {
width: 32px;
height: 32px;
}
}
.status-filter {
flex-shrink: 0;
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;
}
}
}
}
}
.tree-container {
flex: 1;
overflow-y: auto;
.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;
}
:deep(.ant-tree) {
.ant-tree-node-content-wrapper {
&:hover {
background-color: #f5f5f5;
}
}
.ant-tree-indent-unit {
width: 8px;
}
.ant-tree-node-selected {
.ant-tree-node-content-wrapper {
background-color: #e6f7ff;
}
}
// 下拉箭头样式
.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);
}
}
}
}
}
// 中间视频区域
.center-panel {
flex: 1;
padding: 20px 12px;
overflow: hidden;
border-right: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
.video-grid {
display: grid;
gap: 15px;
height: 100%;
grid-auto-rows: 1fr;
&.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;
min-height: 0;
.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;
border-right: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
flex-shrink: 0;
.replay-header {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
.replay-title {
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;
}
}
}
.date-picker {
padding: 12px;
}
.record-list {
flex: 1;
overflow-y: auto;
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;
}
.record-item {
padding: 0 6px;
position: relative;
transition: background-color 0.2s;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
.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;
}
}
.record-info {
height: 50px;
text-align: center;
border: 1px solid #d9d9d9;
border-top: none;
flex: 1;
min-width: 0;
font-size: 14px;
.record-name {
padding: 0 8px;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-time {
font-size: 14px;
}
}
}
}
}
</style>