WholeProcessPlatform/frontend/src/views/shiPinJianKong/shiPinJianKongZhuanTi.vue

1572 lines
42 KiB
Vue
Raw Normal View History

2026-03-27 15:52:43 +08:00
<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>
2026-03-27 15:52:43 +08:00
</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 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);
}
});
// 树形数据
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.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));
};
// 递归添加节点及其所有子节点的视频(带数量限制)
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);
};
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>