570 lines
17 KiB
Vue
570 lines
17 KiB
Vue
<template>
|
||
<div class="default-main manage-process" :style="{ height: pageHeight.height }">
|
||
<DeviceTree @node-click="nodeClick" @init="nodeClick" @deviceTypeChange=""></DeviceTree>
|
||
<div class="manage-process-right">
|
||
<div class="process-flow-section">
|
||
<el-descriptions title="数据链路">
|
||
|
||
</el-descriptions>
|
||
<div class="process-flow-wrap">
|
||
<div class="process-flow">
|
||
<template v-for="(node, index) in flowNodes" :key="node.label">
|
||
<div class="process-step">
|
||
<div class="process-node" :class="`process-node--${getNodeState(index)}`">
|
||
<div class="process-node__icon" :style="{ color: getNodeColor(index) }">
|
||
<el-icon v-if="node.isEl"><component :is="node.icon" /></el-icon>
|
||
<component v-else :is="node.icon" :color="getNodeColor(index)" />
|
||
</div>
|
||
<div class="process-node__name">{{ node.label }}</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="index < flowNodes.length - 1"
|
||
class="process-bridge"
|
||
:class="`process-bridge--${getLineState(index)}`"
|
||
>
|
||
<svg class="process-bridge__svg" viewBox="0 0 120 16" preserveAspectRatio="none">
|
||
<line class="process-bridge__bg" x1="0" y1="8" x2="104" y2="8" />
|
||
<line class="process-bridge__active" x1="0" y1="8" x2="104" y2="8" />
|
||
<line
|
||
v-if="getLineState(index) === 'flowing'"
|
||
class="process-bridge__flow"
|
||
x1="0"
|
||
y1="8"
|
||
x2="104"
|
||
y2="8"
|
||
/>
|
||
<polygon class="process-bridge__head" points="106,4 116,8 106,12" />
|
||
</svg>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="process-list-section">
|
||
<el-descriptions title="数据列表" />
|
||
<div class="process-list-body">
|
||
<el-table :data="processTableData" border stripe height="100%">
|
||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||
<el-table-column prop="name" label="节点" min-width="100" align="center" />
|
||
<el-table-column prop="link" label="上游链路" min-width="140" align="center" />
|
||
<el-table-column prop="status" label="连接状态" min-width="100" align="center">
|
||
<template #default="{ row }">
|
||
<span :class="`process-table-status process-table-status--${row.state}`">
|
||
{{ row.status }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<script setup lang="ts">
|
||
import { h, watch, onUnmounted, ref, computed, defineComponent } from 'vue'
|
||
import { Monitor, Coin } from '@element-plus/icons-vue'
|
||
import { mainHeight } from '@/utils/layout'
|
||
import DeviceTree from '@/components/tree/govern/deviceTree.vue'
|
||
|
||
defineOptions({
|
||
name: 'process'
|
||
})
|
||
|
||
const pageHeight = mainHeight(20)
|
||
|
||
/** 连接状态:null-初始,0-终端绿/终端↔前置,1-前置绿/前置↔MQTT,2-MQTT绿/MQTT↔数据库,3-数据库绿/数据库↔Web,4-全部完成 */
|
||
const linkStatus = ref<number | null>(2)
|
||
|
||
const COLOR = {
|
||
pending: '#94a3b8',
|
||
success: '#009f05',
|
||
error: '#e05252'
|
||
}
|
||
|
||
const createProcessIcon = (render: (color: string) => ReturnType<typeof h>[]) =>
|
||
defineComponent({
|
||
props: {
|
||
color: { type: String, default: COLOR.pending }
|
||
},
|
||
setup(props) {
|
||
return () =>
|
||
h(
|
||
'svg',
|
||
{
|
||
viewBox: '0 0 48 48',
|
||
fill: 'none',
|
||
xmlns: 'http://www.w3.org/2000/svg',
|
||
class: 'process-node__svg'
|
||
},
|
||
render(props.color)
|
||
)
|
||
}
|
||
})
|
||
|
||
/** 终端:采集设备 */
|
||
const IconTerminal = createProcessIcon(c => [
|
||
h('rect', { x: 8, y: 10, width: 32, height: 22, rx: 3, stroke: c, 'stroke-width': 2.2 }),
|
||
h('rect', { x: 14, y: 15, width: 20, height: 12, rx: 1.5, stroke: c, 'stroke-width': 1.5, opacity: 0.45 }),
|
||
h('path', { d: 'M4 36h40', stroke: c, 'stroke-width': 2.2, 'stroke-linecap': 'round' }),
|
||
h('path', { d: 'M22 32v4', stroke: c, 'stroke-width': 2.2, 'stroke-linecap': 'round' }),
|
||
h('circle', { cx: 38, cy: 8, r: 3, fill: c, opacity: 0.85 })
|
||
])
|
||
|
||
/** 前置:双机服务器 */
|
||
const IconFrontEnd = createProcessIcon(c => [
|
||
h('rect', { x: 10, y: 8, width: 28, height: 13, rx: 2.5, stroke: c, 'stroke-width': 2.2, fill: 'rgba(148,163,184,0.08)' }),
|
||
h('circle', { cx: 16, cy: 14.5, r: 2, fill: c }),
|
||
h('line', { x1: 22, y1: 14.5, x2: 34, y2: 14.5, stroke: c, 'stroke-width': 1.5, opacity: 0.5 }),
|
||
h('rect', { x: 10, y: 27, width: 28, height: 13, rx: 2.5, stroke: c, 'stroke-width': 2.2, fill: 'rgba(148,163,184,0.08)' }),
|
||
h('circle', { cx: 16, cy: 33.5, r: 2, fill: c }),
|
||
h('line', { x1: 22, y1: 33.5, x2: 34, y2: 33.5, stroke: c, 'stroke-width': 1.5, opacity: 0.5 })
|
||
])
|
||
|
||
/** MQTT:云 + 节点 */
|
||
const IconMqtt = createProcessIcon(c => [
|
||
h('circle', { cx: 24, cy: 26, r: 2.5, fill: c }),
|
||
h('circle', { cx: 12, cy: 16, r: 2, fill: c, opacity: 0.75 }),
|
||
h('circle', { cx: 36, cy: 16, r: 2, fill: c, opacity: 0.75 }),
|
||
h('circle', { cx: 12, cy: 36, r: 2, fill: c, opacity: 0.75 }),
|
||
h('circle', { cx: 36, cy: 36, r: 2, fill: c, opacity: 0.75 }),
|
||
h('line', { x1: 24, y1: 26, x2: 12, y2: 16, stroke: c, 'stroke-width': 1.4, opacity: 0.55 }),
|
||
h('line', { x1: 24, y1: 26, x2: 36, y2: 16, stroke: c, 'stroke-width': 1.4, opacity: 0.55 }),
|
||
h('line', { x1: 24, y1: 26, x2: 12, y2: 36, stroke: c, 'stroke-width': 1.4, opacity: 0.55 }),
|
||
h('line', { x1: 24, y1: 26, x2: 36, y2: 36, stroke: c, 'stroke-width': 1.4, opacity: 0.55 }),
|
||
h('path', {
|
||
d: 'M15 24c0-4.5 3.5-7.5 9-7.5s9 3 9 7.5c3.5 0 6 2.5 6 6s-2.5 6-6 6H15c-3.5 0-6-2.5-6-6s2.5-6 6-6z',
|
||
stroke: c,
|
||
'stroke-width': 2,
|
||
fill: '#fff'
|
||
}),
|
||
h('text', { x: 24, y: 27.5, 'text-anchor': 'middle', fill: c, 'font-size': 7.5, 'font-weight': 'bold' }, 'MQTT')
|
||
])
|
||
|
||
const flowNodes = [
|
||
{ label: '终端', icon: IconTerminal },
|
||
{ label: '前置', icon: IconFrontEnd },
|
||
{ label: 'MQTT', icon: IconMqtt },
|
||
{ label: '数据库', icon: Coin, isEl: true },
|
||
{ label: 'Web', icon: Monitor, isEl: true }
|
||
]
|
||
|
||
/** 全部完成状态值 */
|
||
const COMPLETE_STATUS = 4
|
||
/** 连线数量 */
|
||
const LINE_COUNT = flowNodes.length - 1
|
||
|
||
const createErrorArray = <T,>(length: number, value: T) => Array.from({ length }, () => value)
|
||
|
||
type NodeState = 'pending' | 'success' | 'error'
|
||
type LineState = 'idle' | 'flowing' | 'success' | 'error'
|
||
|
||
const lineErrors = ref(createErrorArray(LINE_COUNT, false))
|
||
const nodeErrors = ref(createErrorArray(flowNodes.length, false))
|
||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const clearTimeoutTimer = () => {
|
||
if (timeoutTimer) {
|
||
clearTimeout(timeoutTimer)
|
||
timeoutTimer = null
|
||
}
|
||
}
|
||
|
||
const resetErrorsFrom = (fromIndex: number) => {
|
||
for (let i = fromIndex; i < lineErrors.value.length; i++) {
|
||
lineErrors.value[i] = false
|
||
}
|
||
for (let i = fromIndex + 1; i < nodeErrors.value.length; i++) {
|
||
nodeErrors.value[i] = false
|
||
}
|
||
}
|
||
|
||
const resetAllErrors = () => {
|
||
lineErrors.value = createErrorArray(LINE_COUNT, false)
|
||
nodeErrors.value = createErrorArray(flowNodes.length, false)
|
||
}
|
||
|
||
const startTimeout = (status: number | null) => {
|
||
clearTimeoutTimer()
|
||
if (status === null || status >= COMPLETE_STATUS) return
|
||
|
||
const capturedStatus = status
|
||
timeoutTimer = setTimeout(() => {
|
||
if (linkStatus.value !== capturedStatus) return
|
||
lineErrors.value[capturedStatus] = true
|
||
nodeErrors.value[capturedStatus + 1] = true
|
||
}, 5000)
|
||
}
|
||
|
||
watch(
|
||
linkStatus,
|
||
(val, oldVal) => {
|
||
const prev = oldVal ?? null
|
||
if (val !== null && (prev === null || val > prev)) {
|
||
resetErrorsFrom(val)
|
||
} else if (val !== null && prev !== null && val < prev) {
|
||
resetAllErrors()
|
||
} else if (val === null) {
|
||
resetAllErrors()
|
||
}
|
||
startTimeout(val)
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
onUnmounted(clearTimeoutTimer)
|
||
|
||
const getNodeState = (index: number): NodeState => {
|
||
if (nodeErrors.value[index]) return 'error'
|
||
if (linkStatus.value === null) return 'pending'
|
||
if (linkStatus.value >= COMPLETE_STATUS) return 'success'
|
||
if (index <= linkStatus.value) return 'success'
|
||
return 'pending'
|
||
}
|
||
|
||
const getLineState = (index: number): LineState => {
|
||
if (lineErrors.value[index]) return 'error'
|
||
if (linkStatus.value === null) return 'idle'
|
||
if (linkStatus.value >= COMPLETE_STATUS) return 'success'
|
||
if (index < linkStatus.value) return 'success'
|
||
if (index === linkStatus.value) return 'flowing'
|
||
return 'idle'
|
||
}
|
||
|
||
const getNodeColor = (index: number) => COLOR[getNodeState(index)]
|
||
|
||
const getNodeStatusText = (index: number) => {
|
||
const state = getNodeState(index)
|
||
if (state === 'error') return '连接失败'
|
||
if (state === 'success') return '连接成功'
|
||
if (linkStatus.value !== null && linkStatus.value < COMPLETE_STATUS && index === linkStatus.value + 1) {
|
||
return '连接中'
|
||
}
|
||
return '待连接'
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
const processTableData = computed(() =>
|
||
flowNodes.map((node, index) => ({
|
||
name: node.label,
|
||
link: index === 0 ? '-' : `${flowNodes[index - 1].label} → ${node.label}`,
|
||
status: getNodeStatusText(index),
|
||
state: getNodeState(index)
|
||
}))
|
||
)
|
||
|
||
const nodeClick = async (e: anyObj) => {
|
||
console.log('点击设备树节点')
|
||
}
|
||
</script>
|
||
<style scoped lang="scss">
|
||
$green: #009f05;
|
||
$green-light: #e8f7e9;
|
||
$red: #e05252;
|
||
$red-light: #fdeeee;
|
||
$gray: #cbd5e1;
|
||
$gray-text: #94a3b8;
|
||
$node-min: 110px;
|
||
$node-max: 120px;
|
||
$bridge-min: 60px;
|
||
|
||
.manage-process {
|
||
display: flex;
|
||
height: 100%;
|
||
|
||
&-right {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
height: 100%;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
padding: 10px 10px 10px 0;
|
||
|
||
:deep(.el-descriptions__header) {
|
||
height: 36px;
|
||
margin-bottom: 7px;
|
||
display: flex;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.process-flow-section {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.process-list-section {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.process-list-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.process-table-status {
|
||
&--success {
|
||
color: $green;
|
||
}
|
||
|
||
&--error {
|
||
color: $red;
|
||
}
|
||
|
||
&--pending {
|
||
color: $gray-text;
|
||
}
|
||
}
|
||
|
||
.process-flow-wrap {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
padding-bottom: 8px;
|
||
padding: 0 10%;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.process-flow {
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
min-width: min(100%, calc(#{$node-min} * 5 + #{$bridge-min} * 4));
|
||
min-height: $node-min;
|
||
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.process-step {
|
||
flex: 1 1 $node-min;
|
||
min-width: $node-min;
|
||
max-width: $node-max;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.process-node {
|
||
position: relative;
|
||
width: 100%;
|
||
min-width: $node-min;
|
||
min-height: $node-min;
|
||
max-width: $node-max;
|
||
aspect-ratio: 1;
|
||
height: auto;
|
||
padding: clamp(8px, 8%, 12px);
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
border-radius: 16px;
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||
|
||
&__index {
|
||
position: absolute;
|
||
top: 8px;
|
||
left: 10px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: #cbd5e1;
|
||
font-variant-numeric: tabular-nums;
|
||
transition: color 0.3s;
|
||
}
|
||
|
||
&__icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: clamp(52px, 48%, 60px);
|
||
height: clamp(52px, 48%, 60px);
|
||
border-radius: 12px;
|
||
background: #fff;
|
||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||
|
||
.el-icon {
|
||
font-size: clamp(34px, 32%, 40px);
|
||
}
|
||
|
||
.process-node__svg {
|
||
width: clamp(34px, 32%, 40px);
|
||
height: clamp(34px, 32%, 40px);
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
&__name {
|
||
margin-top: 8px;
|
||
font-size: clamp(13px, 12%, 15px);
|
||
font-weight: 600;
|
||
color: #334155;
|
||
letter-spacing: 1px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
&__status {
|
||
margin-top: 4px;
|
||
font-size: clamp(12px, 11%, 13px);
|
||
color: $gray-text;
|
||
transition: color 0.3s;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
&--success {
|
||
background: linear-gradient(160deg, #fff 0%, $green-light 100%);
|
||
border-color: rgba(0, 159, 5, 0.25);
|
||
box-shadow: 0 4px 16px rgba(0, 159, 5, 0.1);
|
||
|
||
.process-node__index {
|
||
color: rgba(0, 159, 5, 0.45);
|
||
}
|
||
|
||
.process-node__icon {
|
||
background: #fff;
|
||
box-shadow: 0 0 0 2px rgba(0, 159, 5, 0.15);
|
||
}
|
||
|
||
.process-node__status {
|
||
color: $green;
|
||
}
|
||
}
|
||
|
||
&--error {
|
||
background: linear-gradient(160deg, #fff 0%, $red-light 100%);
|
||
border-color: rgba(224, 82, 82, 0.3);
|
||
box-shadow: 0 4px 16px rgba(224, 82, 82, 0.1);
|
||
|
||
.process-node__index {
|
||
color: rgba(224, 82, 82, 0.45);
|
||
}
|
||
|
||
.process-node__icon {
|
||
box-shadow: 0 0 0 2px rgba(224, 82, 82, 0.15);
|
||
}
|
||
|
||
.process-node__status {
|
||
color: $red;
|
||
}
|
||
}
|
||
}
|
||
|
||
.process-bridge {
|
||
flex: 1 1 0;
|
||
min-width: $bridge-min;
|
||
height: 25px;
|
||
padding: 0 2px;
|
||
align-self: center;
|
||
|
||
&__svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: visible;
|
||
display: block;
|
||
}
|
||
|
||
&__bg {
|
||
stroke: #e8edf3;
|
||
stroke-width: 5;
|
||
stroke-linecap: round;
|
||
transition: stroke 0.4s;
|
||
}
|
||
|
||
&__active {
|
||
stroke: transparent;
|
||
stroke-width: 3;
|
||
stroke-linecap: round;
|
||
transition: stroke 0.4s;
|
||
}
|
||
|
||
&__flow {
|
||
stroke: $green;
|
||
stroke-width: 3;
|
||
stroke-linecap: round;
|
||
stroke-dasharray: 5 9;
|
||
animation: bridge-dash 0.85s linear infinite;
|
||
}
|
||
|
||
&__head {
|
||
fill: #cbd5e1;
|
||
transition: fill 0.4s;
|
||
}
|
||
|
||
&--success {
|
||
.process-bridge__active {
|
||
stroke: $green;
|
||
filter: drop-shadow(0 0 3px rgba(0, 159, 5, 0.35));
|
||
}
|
||
|
||
.process-bridge__head {
|
||
fill: $green;
|
||
}
|
||
}
|
||
|
||
&--error {
|
||
.process-bridge__active {
|
||
stroke: $red;
|
||
filter: drop-shadow(0 0 3px rgba(224, 82, 82, 0.35));
|
||
}
|
||
|
||
.process-bridge__head {
|
||
fill: $red;
|
||
}
|
||
}
|
||
|
||
&--flowing {
|
||
.process-bridge__active {
|
||
stroke: #dce3ec;
|
||
stroke-width: 2;
|
||
}
|
||
|
||
.process-bridge__head {
|
||
fill: $green;
|
||
animation: head-pulse 1.2s ease-in-out infinite;
|
||
}
|
||
}
|
||
|
||
&--idle {
|
||
.process-bridge__active {
|
||
stroke: transparent;
|
||
}
|
||
|
||
.process-bridge__head {
|
||
fill: #cbd5e1;
|
||
}
|
||
}
|
||
}
|
||
|
||
@keyframes bridge-dash {
|
||
to {
|
||
stroke-dashoffset: -14;
|
||
}
|
||
}
|
||
|
||
@keyframes head-pulse {
|
||
0%,
|
||
100% {
|
||
opacity: 0.45;
|
||
transform: translateX(0);
|
||
}
|
||
|
||
50% {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
</style>
|