feat: 视频播放器

This commit is contained in:
zhanghao 2026-06-29 17:13:58 +08:00
parent 6dfa2be64e
commit 5be800e579
12 changed files with 6 additions and 775 deletions

View File

@ -1,103 +0,0 @@
import { GroupNodeModel, GroupNode } from '@logicflow/extension'
import { recursiveFilter } from "@/utils/convertNodeRedToLogicFlow";
class CustomGroupModel extends GroupNodeModel {
initNodeData(data) {
super.initNodeData(data);
this.children = new Set(data.children || [])
this.zIndex = 0;
this.width = data.w + 80; // 增加内边距
this.height = data.h + 80;
this.x = data.x;
this.y = data.y;
setTimeout(() => {
this.updateSize()
const nodes = this.graphModel.nodes;
const arr = recursiveFilter(nodes, this.id)
arr.forEach((item) => {
item.updateSize()
})
}, 0)
}
setAttributes() {
this.text.editable = false;
}
getTextStyle() {
const style = super.getTextStyle();
style.fill = this.properties.style?.color || '#333';
style.fontSize = 16;
return style;
}
updateSize() {
const children = []
this.children.forEach((id) => {
children.push(this.graphModel.getNodeModelById(id))
});
if (children.length === 0) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
children.forEach((node) => {
minX = Math.min(minX, node.x - node.width / 2);
maxX = Math.max(maxX, node.x + node.width / 2);
minY = Math.min(minY, node.y - node.height / 2);
maxY = Math.max(maxY, node.y + node.height / 2);
});
this.width = maxX - minX + 80; // 增加内边距
this.height = maxY - minY + 80;
this.x = (minX + maxX) / 2;
this.y = (minY + maxY) / 2;
this.setTextCoordinate(this.width, this.height, this.x, this.y)
}
setTextCoordinate(width, height, x, y) {
const strategies = {
"n": () => {
this.text.x = x
this.text.y = y - height / 2 + 20
},
"ne": () => {
this.text.x = x + width / 2 - 20
this.text.y = y - height / 2 + 20
},
"sw": () => {
this.text.x = x - width / 2 + 20
this.text.y = y + height / 2 - 20
},
"s": () => {
this.text.x = x
this.text.y = y + height / 2 - 20
},
"se": () => {
this.text.x = x + width / 2 - 20
this.text.y = y + height / 2 - 20
},
"default": () => {
this.text.x = x - width / 2 + 20
this.text.y = y - height / 2 + 20
}
};
const labelPosition = this.properties.style?.['label-position'] || 'default'
const action = strategies[labelPosition]
action();
}
removeChild(id) {
this.children.delete(id) // 现在可安全调用
}
}
// 导出方法注册
export function registerCustomGroup(lf) {
lf.register({
type: "customGroup",
view: GroupNode,
model: CustomGroupModel
})
}

View File

@ -1,238 +0,0 @@
<template>
<div class="custom_element" :class="props.properties.state">
<div class="custom_element__header">
<div class="custom_element__title">
{{ props.text || "--" }}
</div>
<el-button v-if="showDetailTypeList.includes(props.properties.type)" v-popover="popoverRef" class="custom_element__btn" link @click="openPop"
>详情</el-button
>
</div>
<div class="nodeType">节点类型: {{ props.properties.type }}</div>
<div class="nodeState">
<div>状态{{ stateMap[props.properties.state] }}</div>
</div>
<el-popover
ref="popoverRef"
virtual-triggering
persistent
placement="right-start"
:visible="popoverVisible"
popper-class="popover__container"
>
<template #default>
<div class="popover__header">
<div>{{ props.text || "--" }}</div>
<div class="closeBtn" @click="popoverVisible = !popoverVisible">
<el-icon><CircleClose /></el-icon>
</div>
</div>
<div class="popover__context">
<div class="container">
<div class="title">输入参数</div>
<el-input
v-if="getTypeByParams(props.properties.input) === 0"
v-model="props.properties.input"
:readonly="true"
:autosize="{ minRows: 2, maxRows: 6 }"
type="textarea"
/>
<div v-if="getTypeByParams(props.properties.input) === 2">
<el-image
style="width: 200px; height: 100px"
:src="getUrlByParams(props.properties.input)"
:zoom-rate="2"
:max-scale="7"
:min-scale="0.2"
:preview-src-list="[getUrlByParams(props.properties.input)]"
show-progress
:initial-index="4"
fit="cover"
/>
</div>
</div>
<div class="container">
<div class="title">输出参数</div>
<el-input
v-if="getTypeByParams(props.properties.output) === 0"
v-model="props.properties.output"
:readonly="true"
:autosize="{ minRows: 2, maxRows: 6 }"
type="textarea"
/>
<div v-if="getTypeByParams(props.properties.output) === 2" class="imageContainer">
<el-image
v-for="item in getUrlByParams(props.properties.output)"
style="width: 60px; height: 60px"
:src="item"
:preview-src-list="getUrlByParams(props.properties.output)"
show-progress
fit="fill"
/>
</div>
<div v-if="getTypeByParams(props.properties.output) === 4" class="videoContainer">
<IPlayer v-for="item in getUrlByParams(props.properties.output)" :videoUrl="item" />
</div>
</div>
</div>
</template>
</el-popover>
</div>
</template>
<script setup>
import { watch } from "vue";
import { CircleClose } from '@element-plus/icons-vue'
import IPlayer from './IPlayer/index.vue'
const props = defineProps({
model: Object,
graphModel: Object,
properties: Object,
text: String,
});
const stateMap = {
success: "已执行",
unexecuted: "未执行",
running: "执行中",
failed: "异常",
paused: "暂停",
stopped: "停止"
};
const showDetailTypeList = ['inject', 'startCamera', 'getImage', 'stopCamera', 'process', 'end', 'stopVideo']
const popoverVisible = ref(false);
const popoverRef = ref();
const openPop = (e) => {
popoverVisible.value = true;
};
const getTypeByParams = (str) => {
let type = 0;
try {
const data = JSON.parse(str);
type = data?.type || 0;
} catch {
type = 0;
}
return type;
};
const propertiesTypeMap = {
'2': 'imageUrl',
'4': 'videoUrl',
'default': ''
}
const getUrlByParams = (str) => {
let url = [];
try {
const data = JSON.parse(str);
url = data?.[propertiesTypeMap[data?.type || 'default']] || 0;
} catch {
url = [];
}
return url;
};
watch(
() => props.properties.nowTime,
() => {
popoverVisible.value = false;
}
);
</script>
<style lang="scss" scoped>
.custom_element {
width: 100%;
height: 100%;
padding: 10px;
border-radius: 10px;
box-sizing: border-box;
color: #fff;
&__header {
display: flex;
justify-content: space-between;
}
&__title {
font-size: 20px;
}
&__btn {
color: skyblue;
}
}
.running, .paused, .stopped {
background-color: #8bbf86;
}
.unexecuted {
background-color: #ddd;
}
.success {
background-color: green;
}
.failed {
background-color: rgb(128, 41, 0);
}
.nodeType {
margin: 8px 0;
}
.nodeState {
margin: 8px 0;
}
</style>
<style lang="scss">
.popover__container {
width: 300px !important;
.popover__header {
display: flex;
justify-content: space-between;
.closeBtn {
cursor: pointer;
}
}
.popover__context {
.container {
display: flex;
max-height: 200px;
overflow-y: auto;
margin-bottom: 10px;
.title {
width: 80px;
}
.imageContainer {
width: 260px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: stretch;
max-height: 600px;
overflow-y: auto;
}
.videoContainer {
width: 260px;
max-height: 600px;
overflow-y: auto;
}
}
}
}
</style>

View File

@ -1,134 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from '@logicflow/core';
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h } from 'vue';
import ElementPlus from 'element-plus'
// 导入 Vue 组件
import CustomFlow from './customFlow.vue';
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class CustomFlowHtmlNode extends HtmlNode {
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.isMounted = false;
// 创建 元素的渲染函数
this.r = h(CustomFlow, {
model: props.model,
graphModel: props.graphModel,
properties: {
...props.model.getProperties(),
},
text: props.model.text.value,
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
setHtml(rootEl) {
if (!this.isMounted) {
this.isMounted = true;
const node = document.createElement('div');
node.style.width = '100%'
node.style.height = '100%'
rootEl.appendChild(node);
this.app.use(ElementPlus) // 关键单独注册ElementPlus
this.app.mount(node);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class CustomFlowHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
// 仅允许从底部锚点连出
this.sourceRules.push({
message: '只能从输出锚点连线',
validate: (sourceNode, targetNode, sourceAnchor) =>
// sourceAnchor.name === 'right'
sourceAnchor.properties.connectionType === 'source'
});
// 仅允许连接到顶部锚点
this.targetRules.push({
message: '只能连接到输入锚点',
validate: (sourceNode, targetNode, targetAnchor) =>
// targetAnchor.name === 'left'
targetAnchor.properties.connectionType === 'target'
});
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
this.width = 200;
this.height = 100;
this.text.editable = false;
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this, x = _a.x, y = _a.y, width = _a.width, height = _a.height;
return [
// { x: x, y: y - height / 2, id: "".concat(this.id, "_0") },
{ x: x + width / 2, y: y, name: 'left', id: "".concat(this.id, "_1"), properties: { connectionType: 'target' } },
// { x: x, y: y + height / 2, id: "".concat(this.id, "_2") },
{ x: x - width / 2, y: y, name: 'right', id: "".concat(this.id, "_3"), properties: { connectionType: 'source' } },
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = 'none';
style.hover.stroke = 'none';
return style;
}
}
// 导出方法注册
export function registerCustomNode(lf) {
lf.register({
type: "CustomNode",
view: CustomFlowHtmlNode,
model: CustomFlowHtmlModel
})
}

View File

@ -1,294 +0,0 @@
<template>
<div class="flow_container">
<div class="btn_container">
<div class="instState">实例状态<span :class="instStatus">{{ stateMap[instStatus]}}</span></div>
<el-button key=" translateCenter" type="primary" @click="() => lfRef.translateCenter()">居中</el-button>
<el-button key="fitView" type="primary" @click="() => lfRef.fitView()">适应屏幕</el-button>
<el-button v-if="['RUNNING','PAUSED'].includes(instStatus)" type="primary" @click="stopFlow">停止</el-button>
<el-button v-if="instStatus === 'RUNNING'" type="primary" @click="pauseFlow">暂停</el-button>
<el-button v-if="instStatus === 'PAUSED'" type="primary" @click="restartFlow">恢复</el-button>
</div>
<div ref="containerRef" class="containerRef"></div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import LogicFlow from "@logicflow/core";
import { Group } from "@logicflow/extension";
import "@logicflow/core/es/index.css";
import { convertNodeRedToLogicFlow, recursiveFilter, changeOtherNodeState } from '@/utils/convertNodeRedToLogicFlow'
import { registerCustomNode } from "./customFlowNode";
import { registerCustomGroup } from "./CustomGroup";
import { getLogicFlow } from '@/api/test/log'
import { pauseInst, resumeInst, stopInst } from '@/api/device/terminal'
import { ElMessage } from 'element-plus'
const containerRef = ref(null);
const lfRef = ref(null);
//
const queryParams = new URLSearchParams(window.location.search);
const instId = queryParams.get('instId') || '';
const taskId = queryParams.get('taskId') || '';
const itemId = queryParams.get('itemId') || '03e0842c2fd7ab7575636a473aeefc89';
const data = {
nodes: [
{
id: "custom-node-1",
text: "node-1",
type: "CustomNode",
x: 100,
y: 100,
},
{
id: 'custom-node-2',
type: 'CustomNode',
x: 300,
y: 300,
text: 'node-2',
},
],
//
edges: [
{
type: 'polyline',
sourceNodeId: 'custom-node-1',
targetNodeId: 'custom-node-2',
},
],
};
const dataNodes = ref([])
const loadNodeRedData = async () => {
const {nodes, edges} = await convertNodeRedToLogicFlow(itemId)
lfRef.value.render({
nodes,
edges
})
dataNodes.value = nodes
setTimeout(() => {
lfRef.value.fitView()
}, 20)
getLog()
}
const getLog = async () => {
const res = await getLogicFlow({
instId,
taskId,
itemId
})
if (res.code === 200) {
const arr = res.data.filter(item => {
return item.nodeType
})
const map = new Map()
arr.forEach(item => {
if (['inject', 'start'].includes(item.nodeType)) { // , 'end'
map.set(item.nodeId, {
state: 'success',
nodeType: item.nodeType,
input: item.params
})
} else {
let obj
if (map.has(item.nodeId)) {
obj = map.get(item.nodeId)
} else {
obj = {
nodeType: item.nodeType,
}
}
if (item.paramsType === 'INPUT') {
obj.state = item.instStatus.toLowerCase(),
obj.input = item.instStatus === 'FAILED' ? item.params : item.message
} else {
obj.state = item.instStatus.toLowerCase() === 'running' ? 'success' : item.instStatus.toLowerCase(),
obj.output = item.instStatus === 'FAILED' ? item.message : item.params
}
map.set(item.nodeId, obj)
}
});
for (const [key, value] of map) {
lfRef.value.setProperties(key, value)
}
changeOtherNodeState(dataNodes.value, lfRef.value)
if (arr[arr.length - 1].nodeType !== 'end' && arr[arr.length - 1].instStatus !== 'FAILED') {
setTimeout(() => {
getLog()
}, 2000)
}
}
}
const stopFlow = async () => {
const res = await stopInst(instId)
if (res.code === 200) {
ElMessage.success('停止成功')
getInstDetail()
} else {
ElMessage.error(res.msg)
}
}
const pauseFlow = async () => {
const res = await pauseInst(instId)
if (res.code === 200) {
ElMessage.success('暂停成功')
getInstDetail()
} else {
ElMessage.error(res.msg)
}
}
const restartFlow = async () => {
const res = await resumeInst(instId)
if (res.code === 200) {
ElMessage.success('恢复成功')
getInstDetail()
} else {
ElMessage.error(res.msg)
}
}
const instStatus = ref('RUNNING')
const stateMap = {
SUCCESS: "已完成",
RUNNING: "执行中",
FAILED: "失败",
PAUSED: "已暂停",
STOPPED: "已终止"
};
const getInstDetail = async () => {
const res = await getLogicFlow({
instId,
taskId,
itemId
})
if (res.code === 200) {
const item = res.data[res.data.length - 1]
instStatus.value = item.instStatus
}
}
onMounted(() => {
if (containerRef.value) {
const lf = new LogicFlow({
container: containerRef.value,
isSilentMode: true,
adjustNodePosition: true,
stopZoomGraph: false,
stopScrollGraph: false,
stopMoveGraph: false,
autoExpand: true,
adjustEdgeStartAndEnd: true,
allowRotate: false,
edgeTextEdit: false,
keyboard: {
enabled: true,
},
partial: true,
background: {
color: "#FFFFFF",
},
grid: true,
edgeTextDraggable: false,
nodeTextEdit: false, //false
textEdit: false, //
style: {
inputText: {
background: "black",
color: "white",
},
},
idGenerator(type) {
return type + "_" + Math.random();
},
plugins: [Group],
group: {
foldable: true, //
foldSize: 30, //
},
});
//
registerCustomNode(lf)
registerCustomGroup(lf)
// data
lf.render(data);
lf.on('node:dragstart', (data, e) => {
lfRef.value.setProperties(data.data.id, {
nowTime: new Date().getTime()
})
})
//
lf.on("node:mousemove", ({ data, e }) => {
const { nodes } = lf.getGraphData();
const arr = recursiveFilter(nodes, data.id)
arr.forEach(item => {
lf.getNodeModelById(item.id).updateSize()
})
});
//
lf.on("blank:drop", () => {
getLog()
});
lfRef.value = lf;
}
loadNodeRedData()
getInstDetail()
});
</script>
<style lang="scss" scoped>
.flow_container {
width: 100%;
height: 100%;
position: relative;
.btn_container {
height: 60px;
display: flex;
align-items: center;
justify-content: end;
position: absolute;
right: 10px;
top: 0;
z-index: 2;
.instState {
margin-right: 12px;
font-size: 16px;
font-weight: bold;
.RUNNING, .PAUSED, .STOPPED {
color: #8bbf86;
}
.SUCCESS {
color: green;
}
.FAILED {
color: rgb(128, 41, 0);
}
}
}
:deep(.containerRef) {
width: 100%;
height: 100%;
.lf-graph {
width: 100% !important;
height: 100% !important;
}
}
}
</style>

View File

@ -54,7 +54,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus";
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
import IPlayer from "@/components/IPlayer/index.vue";
const props = defineProps({
model: Object,

View File

@ -50,7 +50,7 @@ import { ref, onMounted, onUnmounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import { emitter } from "@/utils/eventBus";
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
import IPlayer from "@/components/IPlayer/index.vue";
const props = defineProps({
model: Object,

View File

@ -37,7 +37,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus";
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
import IPlayer from "@/components/IPlayer/index.vue";
const props = defineProps({
model: Object,

View File

@ -53,7 +53,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus";
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
import IPlayer from "@/components/IPlayer/index.vue";
const props = defineProps({
model: Object,

View File

@ -37,7 +37,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus";
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
import IPlayer from "@/components/IPlayer/index.vue";
const props = defineProps({
model: Object,

View File

@ -38,7 +38,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css";
import { emitter } from "@/utils/eventBus";
import IPlayer from "@/views/device/register/components/LogicFlow/IPlayer/index.vue";
import IPlayer from "@/components/IPlayer/index.vue";
const props = defineProps({
model: Object,