修改项目树问题 绘制稳态治理分析页面

This commit is contained in:
guanj
2026-06-04 09:08:37 +08:00
parent c2805d7e9e
commit 4f32f84132
42 changed files with 3368 additions and 2287 deletions

View File

@@ -0,0 +1,569 @@
<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>