Files
admin-govern/src/views/govern/manage/process/index.vue

570 lines
17 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="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-前置绿/前置↔MQTT2-MQTT绿/MQTT↔数据库3-数据库绿/数据库↔Web4-全部完成 */
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>