Files
DianZhanDemo/app/components/PowerStation/DataChartsSection.vue

855 lines
26 KiB
Vue
Raw Permalink Normal View History

2025-12-11 01:01:11 +08:00
<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>