Files
DianZhanDemo/app/components/PowerStation/DataChartsSection.vue
ch197511161 908b4361ed init3
2025-12-11 01:01:11 +08:00

855 lines
26 KiB
Vue
Raw Permalink 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="h-full flex flex-col relative overflow-hidden p-1 group">
<!-- Tech Border/Background effects -->
<div class="absolute inset-0 border border-cyan-500/20 pointer-events-none z-0"></div>
<!-- Corner Accents -->
<div
class="absolute top-0 left-0 w-4 h-4 border-t-2 border-l-2 border-cyan-500 z-0 opacity-60 group-hover:opacity-100 transition-opacity"
></div>
<div
class="absolute top-0 right-0 w-4 h-4 border-t-2 border-r-2 border-cyan-500 z-0 opacity-60 group-hover:opacity-100 transition-opacity"
></div>
<div
class="absolute bottom-0 left-0 w-4 h-4 border-b-2 border-l-2 border-cyan-500 z-0 opacity-60 group-hover:opacity-100 transition-opacity"
></div>
<div
class="absolute bottom-0 right-0 w-4 h-4 border-b-2 border-r-2 border-cyan-500 z-0 opacity-60 group-hover:opacity-100 transition-opacity"
></div>
<div class="relative z-10 flex flex-col h-full p-4">
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-3">
<div
class="w-1 h-6 bg-gradient-to-b from-cyan-400 to-blue-600 shadow-[0_0_10px_rgba(34,211,238,0.5)]"
></div>
<h3
class="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-100 to-blue-200 tracking-wider font-mono"
>
数据趋势分析
<span class="text-xs text-cyan-500/50 ml-2 font-normal">/// TREND ANALYSIS</span>
</h3>
</div>
<div class="flex items-center gap-4">
<el-radio-group
v-model="chartType"
size="small"
@change="handleTypeChange"
class="tech-radio-group"
>
<el-radio-button value="stress">应力</el-radio-button>
<el-radio-button value="temp">温度</el-radio-button>
<el-radio-button value="load">载荷</el-radio-button>
<el-radio-button value="displacement">位移</el-radio-button>
</el-radio-group>
<div class="flex items-center border-l border-cyan-500/20 pl-4">
<!-- 3D View Button -->
<div
class="flex justify-center items-center px-4 py-1 mr-2 bg-[#0f172a] border border-cyan-500/10 rounded cursor-pointer hover:border-cyan-500/50 hover:bg-[#0f172a]/80 transition-all group"
@click="showViewer = true"
>
<span
class="text-sm text-cyan-400 font-mono group-hover:text-cyan-300 transition-colors"
>数据模型</span
>
</div>
<el-tooltip content="查看详细数据" placement="bottom" effect="dark">
<button class="tech-icon-btn" @click="showTableDialog = true">
<el-icon><List /></el-icon>
</button>
</el-tooltip>
<el-tooltip :content="isMaximized ? '还原' : '最大化'" placement="left" effect="dark">
<button class="tech-icon-btn" @click="emit('toggleMaximize')">
<el-icon><FullScreen /></el-icon>
</button>
</el-tooltip>
</div>
</div>
</div>
<div
class="flex-1 h-full min-h-0 relative bg-[#0f172a]/40 border border-cyan-500/10 rounded backdrop-blur-sm shadow-inner"
>
<div
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center bg-[#0b1120]/80 z-10 backdrop-blur-sm"
>
<div class="flex flex-col items-center gap-3">
<el-icon class="is-loading text-cyan-400 text-3xl"><Loading /></el-icon>
<span class="text-cyan-400/80 text-xs tracking-[0.2em] font-mono">LOADING DATA...</span>
</div>
</div>
<client-only>
<v-chart class="chart" :option="option" autoresize />
</client-only>
</div>
</div>
<!-- 数据表格弹窗 -->
<el-dialog
v-model="showTableDialog"
:title="`${currentMetricName} - 详细数据查询`"
width="1200px"
append-to-body
class="tech-dialog-modal"
>
<div class="mb-4 p-4 bg-[#0f172a] border border-cyan-500/20 rounded">
<div class="flex flex-wrap gap-4 items-end">
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500">时间范围</span>
<el-date-picker
v-model="query.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
size="small"
value-format="YYYY-MM-DD"
/>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500">数值范围 ({{ currentUnit }})</span>
<div class="flex items-center gap-2">
<el-input-number
v-model="query.minValue"
size="small"
:placeholder="'Min'"
:controls="false"
class="!w-24"
/>
<span class="text-gray-400">-</span>
<el-input-number
v-model="query.maxValue"
size="small"
:placeholder="'Max'"
:controls="false"
class="!w-24"
/>
</div>
</div>
<el-button type="primary" size="small" @click="handleQuery">
<el-icon class="mr-1"><Search /></el-icon> 查询
</el-button>
<el-button size="small" @click="resetQuery">重置</el-button>
</div>
</div>
<el-table :data="filteredTableData" height="600" stripe style="width: 100%">
<el-table-column prop="date" label="时间" width="180" sortable />
<el-table-column prop="value" :label="`数值 (${currentUnit})`" sortable>
<template #default="scope">
<span :class="getValueClass(scope.row.value)">{{ scope.row.value }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.value)" size="small">
{{ getStatusText(scope.row.value) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- 运行状态记录弹窗 (Moved from ModelPropertiesPanel) -->
<el-dialog
v-model="showRunStateDialog"
:title="currentParamItem ? `${currentParamItem.label} - 历史记录` : '运行状态记录'"
width="900px"
append-to-body
class="tech-dialog-modal"
>
<div class="mb-4 flex flex-wrap gap-4 items-center">
<div class="text-sm text-cyan-400 font-mono">
<span class="mr-2">当前节点:</span>
<el-tag size="small" class="tech-tag">{{ store.currentNodeId || 'Unknown' }}</el-tag>
</div>
<div class="flex-1"></div>
<el-button
size="small"
type="primary"
class="tech-button"
@click="() => generateMockData()"
>
<el-icon class="mr-1"><Refresh /></el-icon> 刷新数据
</el-button>
</div>
<el-table :data="runStateTableData" height="500" stripe style="width: 100%">
<el-table-column prop="time" label="记录时间" width="200" />
<el-table-column prop="parameter" label="监测参数" width="180" />
<el-table-column prop="value" label="监测数值" />
<el-table-column prop="status" label="状态判定" width="120">
<template #default="scope">
<el-tag :type="scope.row.statusType" size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="operator" label="记录人" width="120" />
</el-table>
</el-dialog>
<!-- 3D Viewer 弹窗 (Implicitly moved for 3D Button) -->
<el-dialog
v-model="showViewer"
title="3D数据查看"
width="1000px"
top="5vh"
append-to-body
class="tech-dialog-modal"
destroy-on-close
>
<div
class="h-[700px] w-full border border-gray-700/50 rounded overflow-hidden relative bg-[#0b1120]"
>
<Viewer v-if="showViewer" />
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { FullScreen, List, Loading, Refresh, Search } from '@element-plus/icons-vue'
import { LineChart } from 'echarts/charts'
import {
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components'
import { graphic, use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { computed, onMounted, ref, watch } from 'vue'
import VChart from 'vue-echarts'
import Viewer from '~/components/Viewer.vue'
import { usePowerStationStore } from '~/stores/powerStation'
// 注册 ECharts 组件
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
DataZoomComponent,
])
// 定义组件属性
defineProps<{
isMaximized?: boolean // 是否最大化显示
}>()
// 定义组件事件
const emit = defineEmits(['toggleMaximize'])
// 状态管理
const store = usePowerStationStore()
const chartType = ref('stress') // 图表类型
const showTableDialog = ref(false)
const showRunStateDialog = ref(false) // Renamed from showTableDialog to avoid conflict
const showViewer = ref(false)
// 运行状态表格相关
const runStateTableData = ref<any[]>([])
const currentParamItem = ref<any>(null)
const generateMockData = (item?: any) => {
const targetItem = item || currentParamItem.value || { label: '未命名参数' }
// if (!targetItem) return // Allow running without item for demo
const label = targetItem.label
const operators = ['系统自动', '操作员A', '操作员B']
// 根据不同的标签生成不同的模拟数据
runStateTableData.value = Array.from({ length: 20 }).map((_, i) => {
let value = ''
let statusObj = { label: '正常', type: 'success' }
// 基于标签的简单的模拟逻辑
if (label.includes('温度')) {
const val = 50 + Math.random() * 20
value = val.toFixed(1) + ' °C'
if (val > 68) statusObj = { label: '高温警告', type: 'warning' }
} else if (label.includes('压力')) {
const val = 10 + Math.random() * 5
value = val.toFixed(2) + ' MPa'
if (val > 14.5) statusObj = { label: '高压', type: 'warning' }
} else if (label.includes('健康')) {
// 健康度
const val = 90 + Math.random() * 10
value = Math.min(100, val).toFixed(1) + '%'
if (val < 92) statusObj = { label: '需关注', type: 'warning' }
} else if (label.includes('状态') || label.includes('运行')) {
const states = ['正常运行', '以正常运行', '低负荷运行']
value = states[Math.floor(Math.random() * states.length)] || '正常运行'
// 偶尔出个异常
if (Math.random() > 0.9) {
value = '停机维护'
statusObj = { label: '停机', type: 'info' }
}
} else if (label.includes('报警')) {
const subStatus = Math.random()
if (subStatus > 0.8) {
value = '轻微报警'
statusObj = { label: '报警', type: 'warning' }
} else {
value = '无报警'
}
} else {
// 默认回退
value = (Math.random() * 100).toFixed(2)
}
// 随机插入一些异常
if (Math.random() > 0.95 && statusObj.type === 'success') {
statusObj = { label: '异常', type: 'danger' }
}
return {
time: new Date(Date.now() - i * 1000 * 60 * 30).toLocaleString(),
parameter: label,
value: value,
status: statusObj.label,
// @ts-ignore - Element Plus tag type
statusType: statusObj.type || 'success',
operator: operators[i % operators.length],
}
})
}
// 数据定义
interface ChartDataPoint {
date: string
value: number
}
const rawData = ref<ChartDataPoint[]>([]) // 原始数据
const chartData = ref({
dates: [] as string[],
values: [] as number[],
unit: '',
name: '',
})
// 查询状态
const query = ref({
dateRange: [] as string[],
minValue: undefined as number | undefined,
maxValue: undefined as number | undefined,
})
// 图表加载状态
const isLoading = ref(false)
// 计算属性
const currentUnit = computed(() => chartData.value.unit)
const currentMetricName = computed(() => chartData.value.name)
// 过滤后的表格数据
const filteredTableData = computed(() => {
let data = [...rawData.value]
// 日期过滤
if (query.value.dateRange && query.value.dateRange.length === 2) {
const start = new Date(query.value.dateRange[0]).getTime()
const end = new Date(query.value.dateRange[1]).getTime() + 86400000 // 包含结束当天
data = data.filter(item => {
const itemTime = new Date(item.date).getTime()
return itemTime >= start && itemTime < end
})
}
// 数值过滤
if (query.value.minValue !== undefined) {
data = data.filter(item => item.value >= query.value.minValue!)
}
if (query.value.maxValue !== undefined) {
data = data.filter(item => item.value <= query.value.maxValue!)
}
return data
})
// 辅助函数:获取状态样式
const getStatusType = (val: number) => {
// 这里只是简单的mock逻辑实际应根据不同指标的阈值判断
if (chartType.value === 'stress' && val > 140) return 'danger'
if (chartType.value === 'temp' && val > 575) return 'warning'
if (chartType.value === 'load' && val > 4500) return 'warning'
if (chartType.value === 'displacement' && val > 40) return 'warning'
return 'success'
}
const getStatusText = (val: number) => {
const type = getStatusType(val)
if (type === 'danger') return '告警'
if (type === 'warning') return '注意'
return '正常'
}
const getValueClass = (val: number) => {
const type = getStatusType(val)
if (type === 'danger') return 'text-red-400 font-bold'
if (type === 'warning') return 'text-orange-400'
return 'text-slate-300'
}
// 查询处理
const handleQuery = () => {
// 实际上computed会自动处理这里可以加一些loading效果或者后端查询逻辑
}
const resetQuery = () => {
query.value = {
dateRange: [],
minValue: undefined,
maxValue: undefined,
}
}
// 获取图表数据函数
const fetchChartData = async (type: string) => {
if (!store.currentNodeId) {
// 清空图表数据
chartData.value = {
dates: [],
values: [],
unit: '',
name: '',
}
rawData.value = []
return
}
isLoading.value = true
try {
// 尝试调用API (这里假设API会根据type返回对应数据)
// 实际项目中可能需要根据type调整API路径或参数
const response = await $fetch('/api/power-station/chart-data', {
params: {
nodeId: store.currentNodeId,
type: type,
},
}).catch(() => null) // 捕获错误以便使用Mock数据
if (response && response.success && response.data) {
const data = response.data as any
chartData.value = {
...data,
dates: (data.dates || []).map((d: any) => String(d)),
}
// 同步 rawData
rawData.value = chartData.value.dates.map((date: string, index: number) => ({
date,
value: chartData.value.values[index],
}))
} else {
throw new Error('Use mock data')
}
} catch (error) {
// 使用模拟数据
const dates = []
const values = []
const rawPoints: ChartDataPoint[] = []
const now = new Date()
let unit = ''
let name = ''
let baseValue = 0
let range = 0
switch (type) {
case 'stress':
unit = 'MPa'
name = '历史应力 (Stress)'
baseValue = 100
range = 50
break
case 'temp':
unit = '°C'
name = '历史温度 (Temp)'
baseValue = 560
range = 20
break
case 'load':
unit = 'N'
name = '实时载荷 (Load)'
baseValue = 3000
range = 2000 // 3000-5000
break
case 'displacement':
unit = 'mm'
name = '实时位移 (Displacement)'
baseValue = 10
range = 40 // 10-50
break
}
for (let i = 0; i < 30; i++) {
const date = new Date(now.getTime() - (29 - i) * 24 * 60 * 60 * 1000)
const dateStr = date.toISOString().split('T')[0]
const val = Math.floor(Math.random() * range) + baseValue
dates.push(dateStr)
values.push(val)
rawPoints.push({ date: dateStr, value: val })
}
chartData.value = {
dates,
values,
unit,
name,
}
rawData.value = rawPoints
} finally {
isLoading.value = false
}
}
// 处理图表类型变更
const handleTypeChange = (val: string | number | boolean | undefined) => {
if (store.currentNodeId && typeof val === 'string') {
resetQuery() // 切换类型时重置查询条件
fetchChartData(val)
}
}
// 监听currentNodeId变化重新计算图表数据
watch(
() => store.currentNodeId,
newId => {
if (newId) {
// 当选择的节点ID变化时重新获取图表数据
fetchChartData(chartType.value)
}
}
)
// 计算图表配置选项
const option = computed(() => {
const data = chartData.value
// 颜色配置
const colors = {
stress: { main: '#06b6d4', area: ['rgba(6,182,212,0.5)', 'rgba(6,182,212,0.01)'] }, // Cyan
temp: { main: '#f43f5e', area: ['rgba(244,63,94,0.5)', 'rgba(244,63,94,0.01)'] }, // Rose
load: { main: '#f59e0b', area: ['rgba(245,158,11,0.5)', 'rgba(245,158,11,0.01)'] }, // Amber
displacement: { main: '#10b981', area: ['rgba(16,185,129,0.5)', 'rgba(16,185,129,0.01)'] }, // Emerald
}
const currentColors = colors[chartType.value as keyof typeof colors] || colors.stress
return {
backgroundColor: 'transparent',
title: {
text: data.name || '历史数据',
left: 'center',
textStyle: {
color: '#e2e8f0',
fontSize: 16,
fontWeight: 'normal',
fontFamily: 'monospace',
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: '#06b6d4',
textStyle: {
color: '#e2e8f0',
},
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#0891b2',
},
},
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
containLabel: true,
borderColor: '#1e293b',
},
xAxis: {
type: 'category',
boundaryGap: false,
data: data.dates,
axisLine: {
lineStyle: {
color: '#334155',
},
},
axisLabel: {
color: '#94a3b8',
fontFamily: 'monospace',
},
splitLine: {
show: true,
lineStyle: {
color: 'rgba(51, 65, 85, 0.3)',
type: 'dashed',
},
},
},
yAxis: {
type: 'value',
name: data.unit,
nameTextStyle: {
color: '#94a3b8',
align: 'right',
padding: [0, 10, 0, 0],
},
axisLine: {
show: true,
lineStyle: {
color: '#334155',
},
},
axisLabel: {
color: '#94a3b8',
fontFamily: 'monospace',
},
splitLine: {
lineStyle: {
color: 'rgba(51, 65, 85, 0.3)',
type: 'dashed',
},
},
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
{
start: 0,
end: 100,
backgroundColor: 'rgba(15, 23, 42, 0.5)',
dataBackground: {
lineStyle: { color: '#0891b2' },
areaStyle: { color: '#0891b2', opacity: 0.2 },
},
selectedDataBackground: {
lineStyle: { color: '#22d3ee' },
areaStyle: { color: '#22d3ee', opacity: 0.4 },
},
fillerColor: 'rgba(34, 211, 238, 0.1)',
handleStyle: {
color: '#22d3ee',
borderColor: '#0891b2',
},
textStyle: {
color: '#94a3b8',
},
},
],
series: [
{
name: data.name,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
showSymbol: false,
data: data.values,
lineStyle: {
width: 3,
color: currentColors.main,
shadowColor: currentColors.main,
shadowBlur: 10,
},
areaStyle: {
opacity: 0.8,
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: currentColors.area[0],
},
{
offset: 1,
color: currentColors.area[1],
},
]),
},
itemStyle: {
color: currentColors.main,
borderColor: '#fff',
borderWidth: 2,
},
},
],
}
})
// 组件挂载时初始化数据
onMounted(() => {
if (store.currentNodeId) {
fetchChartData(chartType.value)
}
})
</script>
<style scoped>
.chart {
height: 100%;
width: 100%;
}
/* Tech Radio Group Styles */
:deep(.tech-radio-group .el-radio-button__inner) {
background-color: transparent;
border: 1px solid rgba(34, 211, 238, 0.2);
color: #94a3b8;
font-family: monospace;
transition: all 0.3s ease;
padding: 6px 16px;
}
:deep(.tech-radio-group .el-radio-button__original-radio:checked + .el-radio-button__inner) {
background-color: rgba(6, 182, 212, 0.15);
border-color: #06b6d4;
color: #22d3ee;
box-shadow: 0 0 10px rgba(6, 182, 212, 0.3);
}
:deep(.tech-radio-group .el-radio-button:first-child .el-radio-button__inner) {
border-radius: 4px 0 0 4px;
}
:deep(.tech-radio-group .el-radio-button:last-child .el-radio-button__inner) {
border-radius: 0 4px 4px 0;
}
:deep(.tech-radio-group .el-radio-button__inner:hover) {
color: #22d3ee;
background-color: rgba(34, 211, 238, 0.05);
}
/* Tech Icon Button */
.tech-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: transparent;
border: 1px solid rgba(34, 211, 238, 0.3);
color: #22d3ee;
cursor: pointer;
transition: all 0.3s ease;
}
.tech-icon-btn:hover {
background: rgba(6, 182, 212, 0.2);
box-shadow: 0 0 10px rgba(6, 182, 212, 0.4);
transform: scale(1.05);
border-color: #22d3ee;
}
.tech-icon-btn:active {
transform: scale(0.95);
}
</style>
<style>
/* Global overrides for the tech dialog */
.tech-dialog-modal {
background-color: #0b1120 !important;
border: 1px solid #06b6d4 !important;
box-shadow: 0 0 20px rgba(6, 182, 212, 0.2) !important;
}
.tech-dialog-modal .el-dialog__header {
border-bottom: 1px solid rgba(34, 211, 238, 0.1);
margin-right: 0 !important;
}
.tech-dialog-modal .el-dialog__title {
color: #22d3ee !important;
font-family: monospace;
font-weight: bold;
}
.tech-dialog-modal .el-dialog__body {
background-color: #0b1120 !important;
color: #94a3b8 !important;
}
.tech-dialog-modal .el-dialog__close {
color: #22d3ee !important;
}
/* Table overrides inside dialog */
.tech-dialog-modal .el-table {
background-color: transparent !important;
--el-table-tr-bg-color: transparent !important;
--el-table-header-bg-color: rgba(15, 23, 42, 0.8) !important;
--el-table-row-hover-bg-color: rgba(6, 182, 212, 0.1) !important;
--el-table-border-color: rgba(34, 211, 238, 0.1) !important;
color: #94a3b8 !important;
}
.tech-dialog-modal .el-table th.el-table__cell {
background-color: rgba(15, 23, 42, 0.8) !important;
color: #22d3ee !important;
border-bottom: 1px solid rgba(34, 211, 238, 0.2) !important;
}
.tech-dialog-modal .el-table td.el-table__cell {
border-bottom: 1px solid rgba(34, 211, 238, 0.1) !important;
}
.tech-dialog-modal .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background-color: rgba(30, 41, 59, 0.3) !important;
}
/* Input/Select overrides */
.tech-dialog-modal .el-input__wrapper,
.tech-dialog-modal .el-date-editor {
background-color: rgba(15, 23, 42, 0.5) !important;
box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.2) inset !important;
}
.tech-dialog-modal .el-input__inner {
color: #e2e8f0 !important;
}
.tech-dialog-modal .el-range-input {
color: #e2e8f0 !important;
}
.tech-dialog-modal .el-range-separator {
color: #94a3b8 !important;
}
/* Tag overrides */
.tech-dialog-modal .el-tag {
background-color: rgba(15, 23, 42, 0.8) !important;
border-color: rgba(34, 211, 238, 0.3) !important;
}
.tech-dialog-modal .el-tag--success {
color: #34d399 !important;
background-color: rgba(16, 185, 129, 0.1) !important;
border-color: rgba(16, 185, 129, 0.2) !important;
}
.tech-dialog-modal .el-tag--warning {
color: #fbbf24 !important;
background-color: rgba(245, 158, 11, 0.1) !important;
border-color: rgba(245, 158, 11, 0.2) !important;
}
.tech-dialog-modal .el-tag--danger {
color: #f87171 !important;
background-color: rgba(239, 68, 68, 0.1) !important;
border-color: rgba(239, 68, 68, 0.2) !important;
}
</style>