This commit is contained in:
ch197511161
2025-12-11 01:01:11 +08:00
parent acb0b763db
commit 908b4361ed
9 changed files with 3557 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,290 @@
<template>
<!-- 头部导航栏 -->
<header
class="bg-[#0b1120] p-2 text-gray-300 flex items-center justify-between px-4 border-b border-cyan-500/30 shadow-[0_0_15px_rgba(6,182,212,0.15)] backdrop-blur-md"
>
<div class="flex items-center gap-4">
<!-- 返回按钮 -->
<button
class="flex items-center gap-1 text-cyan-500 hover:text-cyan-300 transition-colors group"
@click="goBack"
>
<el-icon class="group-hover:-translate-x-1 transition-transform"><Back /></el-icon>
<span class="font-mono text-sm">BACK</span>
</button>
<div class="w-[1px] h-6 bg-cyan-500/30 mx-2" />
<!-- 系统标题 -->
<div class="flex items-center gap-2">
<el-icon class="text-cyan-400 text-xl animate-pulse"><Monitor /></el-icon>
<h1
class="text-xl font-bold tracking-wider font-mono text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500"
>
电站设备管理系统 <span class="text-xs text-cyan-500/50 font-normal">/// SYSTEM V4.0</span>
</h1>
</div>
<!-- 面包屑导航 -->
<el-breadcrumb separator="/" class="ml-6 !text-gray-500 hidden md:block tech-breadcrumb">
<template v-for="(item, index) in breadcrumbs" :key="index">
<el-breadcrumb-item
v-if="item.to"
:to="{
path: '/PowerStation',
query: { currentNodeId: item.to.replace('?currentNodeId=', '') },
}"
>
<span
class="text-gray-500 hover:text-cyan-400 cursor-pointer font-mono transition-colors"
>{{ item.text }}</span
>
</el-breadcrumb-item>
<el-breadcrumb-item v-else>
<span class="text-cyan-400 font-mono font-bold">{{ item.text }}</span>
</el-breadcrumb-item>
</template>
</el-breadcrumb>
</div>
<div class="flex items-center gap-6">
<!-- 功能图标区 -->
<div class="flex items-center gap-4">
<!-- 模式切换开关 -->
<el-tooltip content="切换精简/完整模式" effect="dark">
<div
class="flex items-center gap-2 bg-[#0f172a] border border-cyan-500/20 px-3 py-1 rounded-sm"
>
<el-switch
v-model="isFullMode"
class="tech-switch"
size="small"
@change="toggleMode"
style="--el-switch-on-color: #06b6d4; --el-switch-off-color: #334155"
/>
<span class="text-xs font-mono text-cyan-500/80">{{
isFullMode ? '完整加载' : '分批加载'
}}</span>
</div>
</el-tooltip>
<!-- 模型缓存开关 -->
<el-tooltip content="切换模型缓存机制" effect="dark">
<div
class="flex items-center gap-2 bg-[#0f172a] border border-cyan-500/20 px-3 py-1 rounded-sm"
>
<el-switch
v-model="modelCacheEnabled"
class="tech-switch"
size="small"
@change="toggleModelCache"
style="--el-switch-on-color: #06b6d4; --el-switch-off-color: #334155"
/>
<span class="text-xs font-mono text-cyan-500/80">{{
modelCacheEnabled ? '缓存开启' : '缓存关闭'
}}</span>
</div>
</el-tooltip>
<div class="flex items-center gap-3">
<el-tooltip content="通知中心" effect="dark">
<button class="tech-icon-btn">
<el-badge is-dot type="primary" class="tech-badge">
<el-icon :size="18"><Bell /></el-icon>
</el-badge>
</button>
</el-tooltip>
<el-tooltip content="系统设置" effect="dark">
<button class="tech-icon-btn">
<el-icon :size="18"><Setting /></el-icon>
</button>
</el-tooltip>
</div>
</div>
<!-- 用户信息区 -->
<div class="flex items-center gap-3 pl-4 border-l border-cyan-500/30">
<div class="relative">
<el-avatar
:size="32"
src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
class="border border-cyan-500/50"
/>
<div
class="absolute bottom-0 right-0 w-2 h-2 bg-green-500 rounded-full border border-[#0b1120]"
></div>
</div>
<div class="text-sm">
<div class="font-medium text-cyan-100 font-mono">Administrator</div>
<div class="text-xs text-cyan-500/60 font-mono">ROOT_ACCESS</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
// 导入图标
import { Back, Bell, Setting, Monitor } from '@element-plus/icons-vue'
import { computed, ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePowerStationStore } from '~/stores/powerStation'
// 使用路由和store
const router = useRouter()
const route = useRoute()
const store = usePowerStationStore()
// 根据currentNodeId生成面包屑数据
const breadcrumbs = computed(() => {
const nodeId = store.currentNodeId
if (!nodeId) return []
const breadcrumbsList: { text: string; to?: string }[] = []
const nodeIdParts = nodeId.split('~')
if (nodeIdParts.length > 0) {
let currentPath = ''
for (let i = 0; i < nodeIdParts.length; i++) {
const part = nodeIdParts[i]
currentPath = i === 0 ? part : `${currentPath}~${part}`
const displayText = part || `层级 ${i + 1}`
const item: { text: string; to?: string } = {
text: displayText,
}
if (currentPath !== nodeId) {
item.to = `?currentNodeId=${currentPath}`
}
breadcrumbsList.push(item)
}
}
// 使用当前节点ID的第一部分作为面包屑的第一个元素
if (breadcrumbsList.length > 0 && store.currentNodeId) {
const firstPart = store.currentNodeId.split('~')[0]
breadcrumbsList[0].text = firstPart
}
return breadcrumbsList
})
// 计算当前模式状态
const isFullMode = computed(() => route.query.full !== undefined)
// 模型缓存状态
const modelCacheEnabled = ref(store.modelCacheEnabled)
// 同步loadMode到store
watchEffect(() => {
store.loadMode = isFullMode.value
})
// 同步模型缓存状态到store
watchEffect(() => {
store.modelCacheEnabled = modelCacheEnabled.value
})
// 返回上一页函数
const goBack = () => {
router.back()
}
// 切换模式函数
const toggleMode = () => {
const loadMode = !isFullMode.value
const currentNodeId = route.query.currentNodeId || ''
// 同步到store
store.loadMode = loadMode
// 构建新的URL
let newUrl = window.location.origin + window.location.pathname + '?currentNodeId=' + currentNodeId
if (loadMode) {
newUrl += '&full=true'
}
// 刷新页面
window.location.href = newUrl
}
// 切换模型缓存函数
const toggleModelCache = () => {
// 这里可以添加缓存切换的逻辑,比如清除缓存等
console.log('模型缓存状态:', modelCacheEnabled.value ? '开启' : '关闭')
}
</script>
<style scoped>
/* 自定义面包屑样式 */
:deep(.tech-breadcrumb .el-breadcrumb__inner) {
color: #64748b !important;
font-family: monospace;
}
:deep(.tech-breadcrumb .el-breadcrumb__item:last-child .el-breadcrumb__inner) {
color: #22d3ee !important;
font-weight: bold;
text-shadow: 0 0 10px rgba(34, 211, 238, 0.5);
}
/* Tech Icon Button */
.tech-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(34, 211, 238, 0.2);
color: #94a3b8;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.tech-icon-btn:hover {
background: rgba(6, 182, 212, 0.1);
border-color: #22d3ee;
color: #22d3ee;
box-shadow: 0 0 15px rgba(6, 182, 212, 0.4);
transform: translateY(-1px);
}
.tech-icon-btn:active {
transform: translateY(1px);
}
/* Tech Badge */
:deep(.tech-badge .el-badge__content) {
background-color: #ef4444;
border: none;
box-shadow: 0 0 5px #ef4444;
}
/* Tech Switch */
:deep(.tech-switch .el-switch__core) {
background-color: #334155;
border-color: #475569;
}
:deep(.tech-switch.is-checked .el-switch__core) {
background-color: rgba(6, 182, 212, 0.2);
border-color: #06b6d4;
}
:deep(.tech-switch .el-switch__action) {
background-color: #94a3b8;
}
:deep(.tech-switch.is-checked .el-switch__action) {
background-color: #22d3ee;
box-shadow: 0 0 10px #22d3ee;
}
</style>

View File

@@ -0,0 +1,43 @@
<!--
LoadingOverlay.vue
加载遮罩层组件
用于显示加载状态的动画效果
-->
<template>
<div
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center bg-[#0b1120]/80 backdrop-blur-sm z-10"
>
<div class="flex flex-col items-center gap-4">
<!-- 加载动画 -->
<div class="relative">
<div class="absolute inset-0 rounded-full border-2 border-cyan-500/30 animate-ping"></div>
<div class="absolute inset-0 rounded-full border-2 border-cyan-500/50 animate-pulse"></div>
<el-icon class="is-loading text-4xl text-cyan-400 relative z-10"><Loading /></el-icon>
</div>
<!-- 加载文本 -->
<span class="text-cyan-400 font-mono tracking-[0.2em] text-sm animate-pulse">
{{ text || 'SYSTEM LOADING...' }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
/**
* LoadingOverlay 组件
* 显示加载状态的遮罩层,带有动画效果
*/
import { Loading } from '@element-plus/icons-vue';
// ============================================================
// Props 定义
// ============================================================
defineProps<{
/** 是否显示加载状态 */
isLoading: boolean
/** 自定义加载文本 */
text?: string
}>()
</script>

View File

@@ -0,0 +1,174 @@
<!--
ModelToolbar.vue
模型工具栏组件
提供3D视图的各种控制按钮
-->
<template>
<!-- 左侧工具栏导出功能 -->
<div class="absolute top-4 left-4 z-10 flex flex-col gap-2">
<template v-if="showDownload">
<el-tooltip content="导出GLB模型" placement="bottom" effect="dark">
<button class="tech-icon-btn" @click="$emit('exportModel')">
<el-icon><Download /></el-icon>
</button>
</el-tooltip>
<el-tooltip content="导出物体层级关系JSON" placement="bottom" effect="dark">
<button class="tech-icon-btn" @click="$emit('exportHierarchy')">
<el-icon><Document /></el-icon>
</button>
</el-tooltip>
</template>
</div>
<!-- 右侧工具栏视图控制 -->
<div class="absolute top-4 right-4 z-10 flex flex-col gap-2">
<!-- 复位视角 -->
<el-tooltip content="复位视角" placement="left" effect="dark">
<button class="tech-icon-btn" @click="$emit('resetView')">
<el-icon><Refresh /></el-icon>
</button>
</el-tooltip>
<!-- 自动旋转 -->
<el-tooltip content="自动旋转" placement="left" effect="dark">
<button
class="tech-icon-btn"
:class="{ active: autoRotate }"
@click="$emit('toggleAutoRotate')"
>
<el-icon><VideoPlay /></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>
<!-- 光照模式切换 -->
<el-tooltip
:content="lightingMode === 'basic' ? '切换高级照明' : '切换基本照明'"
placement="left"
effect="dark"
>
<button
class="tech-icon-btn"
:class="{ active: lightingMode === 'advanced' }"
@click="$emit('toggleLighting')"
>
<el-icon><Sunny /></el-icon>
</button>
</el-tooltip>
<!-- 双面渲染切换 -->
<el-tooltip
:content="doubleSided ? '关闭双面渲染' : '开启双面渲染'"
placement="left"
effect="dark"
>
<button
class="tech-icon-btn"
:class="{ active: doubleSided }"
@click="$emit('toggleDoubleSide')"
>
<el-icon><View /></el-icon>
</button>
</el-tooltip>
</div>
</template>
<script setup lang="ts">
/**
* ModelToolbar 组件
* 提供3D视图的控制工具栏
*/
import {
Document,
Download,
FullScreen,
Refresh,
Sunny,
VideoPlay,
View,
} from '@element-plus/icons-vue';
// ============================================================
// Props 定义
// ============================================================
defineProps<{
/** 是否自动旋转 */
autoRotate: boolean
/** 是否最大化 */
isMaximized: boolean
/** 光照模式 */
lightingMode: 'basic' | 'advanced'
/** 是否双面渲染 */
doubleSided: boolean
/** 是否显示下载按钮 */
showDownload?: boolean
}>()
// ============================================================
// Emits 定义
// ============================================================
defineEmits<{
/** 复位视角 */
(e: 'resetView'): void
/** 切换自动旋转 */
(e: 'toggleAutoRotate'): void
/** 切换最大化 */
(e: 'toggleMaximize'): void
/** 切换光照模式 */
(e: 'toggleLighting'): void
/** 切换双面渲染 */
(e: 'toggleDoubleSide'): void
/** 导出模型 */
(e: 'exportModel'): void
/** 导出层级关系 */
(e: 'exportHierarchy'): void
}>()
</script>
<style scoped>
/* ============================================================
工具按钮样式
============================================================ */
.tech-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(34, 211, 238, 0.2);
color: #94a3b8;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(4px);
}
.tech-icon-btn:hover {
background: rgba(6, 182, 212, 0.2);
border-color: #22d3ee;
color: #22d3ee;
box-shadow: 0 0 15px rgba(6, 182, 212, 0.4);
transform: scale(1.05);
}
.tech-icon-btn:active {
transform: scale(0.95);
}
.tech-icon-btn.active {
background: rgba(6, 182, 212, 0.2);
border-color: #22d3ee;
color: #22d3ee;
box-shadow: 0 0 10px rgba(6, 182, 212, 0.3);
}
</style>

View File

@@ -0,0 +1,91 @@
<!--
ViewControls.vue
视图控制面板组件
提供标准视图顶视图底视图前视图等的快捷按钮
-->
<template>
<div class="absolute bottom-4 right-4 z-10 pointer-events-none">
<!-- 标准视图控制面板 -->
<div
class="grid grid-cols-3 gap-1 pointer-events-auto bg-slate-900/80 p-2 rounded-lg border border-cyan-500/20 backdrop-blur-sm"
>
<!-- 第一行 -->
<el-tooltip content="顶视图" placement="top" effect="dark">
<button class="view-btn" @click="$emit('setStandardView', 'top')"></button>
</el-tooltip>
<el-tooltip content="前视图" placement="top" effect="dark">
<button class="view-btn" @click="$emit('setStandardView', 'front')"></button>
</el-tooltip>
<el-tooltip content="左视图" placement="bottom" effect="dark">
<button class="view-btn" @click="$emit('setStandardView', 'left')"></button>
</el-tooltip>
<!-- 第二行 -->
<el-tooltip content="底视图" placement="bottom" effect="dark">
<button class="view-btn" @click="$emit('setStandardView', 'bottom')"></button>
</el-tooltip>
<el-tooltip content="后视图" placement="bottom" effect="dark">
<button class="view-btn" @click="$emit('setStandardView', 'back')"></button>
</el-tooltip>
<el-tooltip content="右视图" placement="top" effect="dark">
<button class="view-btn" @click="$emit('setStandardView', 'right')"></button>
</el-tooltip>
<!-- 第三行轴侧视图跨3列 -->
<el-tooltip content="轴侧图" placement="bottom" effect="dark">
<button class="view-btn col-span-3 mt-1" @click="$emit('setStandardView', 'iso')">
轴侧视图
</button>
</el-tooltip>
</div>
</div>
</template>
<script setup lang="ts">
/**
* ViewControls 组件
* 提供标准视图的快捷切换按钮
*/
// ============================================================
// 类型定义
// ============================================================
/** 标准视图类型 */
type StandardViewType = 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso'
// ============================================================
// Emits 定义
// ============================================================
defineEmits<{
/** 设置标准视图 */
(e: 'setStandardView', view: StandardViewType): void
}>()
</script>
<style scoped>
/* ============================================================
视图按钮样式
============================================================ */
.view-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
font-size: 12px;
border-radius: 4px;
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(148, 163, 184, 0.2);
color: #cbd5e1;
cursor: pointer;
transition: all 0.2s;
}
.view-btn:hover {
background: rgba(6, 182, 212, 0.2);
border-color: #22d3ee;
color: #22d3ee;
}
</style>

View File

@@ -0,0 +1,573 @@
<!--
Model3DView.vue
3D模型视图组件
负责加载渲染和交互3D模型
-->
<template>
<div ref="containerRef" class="w-full h-full relative overflow-hidden">
<!-- 加载遮罩 -->
<LoadingOverlay :is-loading="isLoading" />
<!-- 工具栏 -->
<ModelToolbar
:auto-rotate="autoRotate"
:is-maximized="isMaximized || false"
:lighting-mode="lightingMode"
:double-sided="doubleSided"
:show-download="isFullMode"
@reset-view="resetView"
@toggle-auto-rotate="toggleAutoRotate"
@toggle-maximize="emit('toggleMaximize')"
@toggle-lighting="toggleLighting"
@toggle-double-side="toggleDoubleSide"
@export-model="exportModel"
@export-hierarchy="exportHierarchy"
/>
<!-- 视图控制 -->
<ViewControls @set-standard-view="setStandardView" />
</div>
</template>
<script setup lang="ts">
/**
* Model3DView 组件
* 3D模型的主视图组件负责模型的加载、渲染和交互
*/
import * as THREE from 'three'
import { nextTick } from 'vue'
import { useInteraction } from '~/composables/powerStation/useInteraction'
import { useModelManager } from '~/composables/powerStation/useModelManager'
import { useThreeScene } from '~/composables/powerStation/useThreeScene'
import { usePowerStationStore } from '~/stores/powerStation'
import LoadingOverlay from './Model3D/LoadingOverlay.vue'
import ModelToolbar from './Model3D/ModelToolbar.vue'
import ViewControls from './Model3D/ViewControls.vue'
// ============================================================
// Props 和 Emits
// ============================================================
const props = defineProps<{
/** 是否最大化 */
isMaximized?: boolean
}>()
const emit = defineEmits(['toggleMaximize'])
// ============================================================
// 状态和引用
// ============================================================
const route = useRoute()
const store = usePowerStationStore()
// ============================================================
// Three.js 场景系统
// ============================================================
const {
containerRef,
initThree,
getScene,
getCamera,
getRenderer,
getControls,
getComposer,
addAnimateCallback,
addResizeCallback,
onWindowResize,
setStandardView,
} = useThreeScene()
// ============================================================
// 模型管理系统
// ============================================================
const {
modelGroup,
isLoading,
lightingMode,
objectMap,
initModelSystem,
loadModel,
processModel,
fitView,
toggleLighting,
archetypeModel,
findNodeById,
loadModelByFileName,
} = useModelManager(getScene, getCamera, getControls, getRenderer)
// ============================================================
// 交互系统
// ============================================================
const {
initInteraction,
highlightClick,
updatePassSize,
dispose: disposeInteraction,
} = useInteraction(
getScene,
getCamera,
getComposer,
() => containerRef.value,
modelGroup,
() => archetypeModel.value
)
// ============================================================
// 本地状态
// ============================================================
/** 自动旋转状态 */
const autoRotate = ref(false)
/** 双面渲染状态 */
const doubleSided = ref(false)
// ============================================================
// 计算属性
// ============================================================
/** 是否为完整模式 */
const isFullMode = computed(() => store.loadMode)
// ============================================================
// 视图控制方法
// ============================================================
/**
* 切换自动旋转
*/
const toggleAutoRotate = () => {
autoRotate.value = !autoRotate.value
}
/**
* 切换双面渲染
* 使用 shader 方式实现,通过修改材质的 side 属性
*/
const toggleDoubleSide = () => {
doubleSided.value = !doubleSided.value
const scene = getScene()
if (!scene) return
// 遍历场景中所有物体,修改材质的渲染面属性
scene.traverse((child: THREE.Object3D) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((mat: THREE.Material) => {
if (doubleSided.value) {
// 保存原始的 side 值,然后设置为 DoubleSide
if (mat.userData.originalSide === undefined) {
mat.userData.originalSide = mat.side
}
mat.side = THREE.DoubleSide
} else {
// 恢复原始的 side 值
if (mat.userData.originalSide !== undefined) {
mat.side = mat.userData.originalSide
} else {
mat.side = THREE.FrontSide
}
}
mat.needsUpdate = true
})
}
})
}
/**
* 重置视图
*/
const resetView = async () => {
const rootPath = store.currentNodeId || archetypeModel.value?.name || 'root'
await navigateTo({
query: {
...route.query,
currentNodeId: rootPath,
},
})
}
// ============================================================
// 导出功能
// ============================================================
/**
* 导出GLB模型
*/
const exportModel = async () => {
try {
if (!modelGroup.children.length) {
ElMessage.warning('没有可导出的模型')
return
}
const currentModel = modelGroup.children[0]
if (!currentModel) {
ElMessage.warning('未找到当前模型')
return
}
// 克隆模型用于导出
const exportModelClone = currentModel.clone()
// 清理循环引用
cleanCircularReferences(exportModelClone)
// 转换材质为GLTF兼容格式
convertMaterialsToGLTFCompatible(exportModelClone)
// 生成模型名称
const modelName = store.currentNodeId || '模型'
// 创建导出场景
const exportScene = new THREE.Scene()
exportScene.add(exportModelClone)
// 使用GLTFExporter导出
const { GLTFExporter } = await import('three/examples/jsm/exporters/GLTFExporter.js')
const exporter = new GLTFExporter()
exporter.parse(
exportScene,
gltf => {
const glbData = new Blob([gltf], { type: 'model/gltf-binary' })
downloadBlob(glbData, `${modelName}.glb`)
ElMessage.success(`模型 ${modelName}.glb 导出成功`)
},
error => {
console.error('导出失败:', error)
ElMessage.error('模型导出失败')
},
{ binary: true }
)
} catch (error) {
console.error('导出错误:', error)
ElMessage.error('导出过程中发生错误')
}
}
/**
* 导出层级关系JSON
*/
const exportHierarchy = () => {
try {
if (!modelGroup.children.length) {
ElMessage.warning('没有可导出的模型层级')
return
}
if (!archetypeModel.value) {
ElMessage.warning('未找到archetypeModel')
return
}
const modelName = store.currentNodeId || '模型'
const hierarchyData = buildHierarchy(archetypeModel.value)
const jsonData = JSON.stringify(hierarchyData, null, 2)
const blob = new Blob([jsonData], { type: 'application/json' })
downloadBlob(blob, `${modelName}~hierarchy.json`)
ElMessage.success(`物体层级关系 ${modelName}~hierarchy.json 导出成功`)
} catch (error) {
console.error('导出层级关系错误:', error)
ElMessage.error('导出层级关系失败')
}
}
// ============================================================
// 导出辅助函数
// ============================================================
/**
* 清理循环引用
*/
const cleanCircularReferences = (object: THREE.Object3D) => {
object.traverse(child => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach(mat => {
if (mat.userData) {
delete mat.userData.lambertPartner
delete mat.userData.pbrPartner
}
})
}
})
}
/**
* 转换材质为GLTF兼容格式
*/
const convertMaterialsToGLTFCompatible = (object: THREE.Object3D) => {
object.traverse(child => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const newMaterials = materials.map(mat => {
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshBasicMaterial') {
return mat
}
const originalMap = (mat as any).map || null
const useAlpha = originalMap !== null
const standardMat = new THREE.MeshStandardMaterial({
color: (mat as any).color || new THREE.Color(0xaaaaaa),
map: originalMap,
alphaMap: originalMap,
normalMap: (mat as any).normalMap || null,
transparent: useAlpha || (mat as any).transparent || false,
opacity: useAlpha ? 1 : (mat as any).opacity || 1,
side: mat.side || THREE.FrontSide,
metalness: 0.6,
roughness: 0.6,
})
if (mat.userData) {
standardMat.userData = { ...mat.userData }
}
return standardMat
})
mesh.material = Array.isArray(mesh.material) ? newMaterials : newMaterials[0]
}
})
}
/**
* 构建层级结构
*/
const buildHierarchy = (object: THREE.Object3D): any => {
const obj: any = {
id: object.uuid,
name: object.name,
type: object.type,
children: [],
}
object.children.forEach(child => {
obj.children.push(buildHierarchy(child))
})
return obj
}
/**
* 下载Blob文件
*/
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// ============================================================
// 监听器
// ============================================================
// 监听最大化状态变化
watch(
() => props.isMaximized,
() => {
setTimeout(() => {
onWindowResize()
fitView()
}, 350)
}
)
// 路由监听器(核心逻辑入口)
watch(
() => route.query.currentNodeId,
newId => {
const id = newId as string
console.log('路由currentNodeId变化:', id)
// 确保store的currentNodeId与路由参数一致
if (id) {
store.currentNodeId = id
store.selectNode({ id })
} else {
store.currentNodeId = ''
store.selectNode({ id: '' })
}
if (isFullMode.value) {
// 完整模式:处理模型
if (archetypeModel.value) {
processModel(id, true)
}
} else {
// 简化模式:直接加载对应文件
if (id) {
loadModelByFileName(id)
}
}
},
{ immediate: true }
)
// 选择监听器
watch(
() => store.currentNodeId,
async newId => {
console.log('选择监听器触发:', newId)
if (!newId) {
highlightClick(null)
return
}
const currentRouteNodeId = route.query.currentNodeId as string
// 等待模型加载完成
let retryCount = 0
const maxRetries = 5
while (isLoading.value && retryCount < maxRetries) {
console.log(`模型加载中,等待加载完成后再处理高亮 (重试 ${retryCount + 1}/${maxRetries})`)
await nextTick()
await new Promise(resolve => setTimeout(resolve, 100))
retryCount++
}
let obj = objectMap.get(newId)
console.log('直接找到物体:', obj ? obj.name : 'null')
// 如果未找到精确对象,尝试查找可见的祖先
if (!obj && archetypeModel.value) {
console.log('搜索可见祖先...')
let current = findNodeById(archetypeModel.value, newId)
while (current) {
console.log('检查祖先:', current.name, current.userData.id)
if (current.userData.id && objectMap.has(current.userData.id)) {
obj = objectMap.get(current.userData.id)
console.log('找到可见祖先:', obj?.name)
break
}
current = current.parent
}
}
if (obj) {
highlightClick(obj)
} else {
console.warn('未找到ID对应的对象:', newId)
highlightClick(null)
}
if (newId === currentRouteNodeId) {
highlightClick(null)
return
}
},
{ immediate: true }
)
// ============================================================
// 聚焦事件处理
// ============================================================
/**
* 处理聚焦事件
*/
const handleFocusEvent = (nodeId: string) => {
console.log('聚焦事件触发:', nodeId)
let obj = objectMap.get(nodeId)
// 如果未找到精确对象,尝试查找可见的祖先
if (!obj && archetypeModel.value) {
console.log('搜索可见祖先...')
let current = findNodeById(archetypeModel.value, nodeId)
while (current) {
console.log('检查祖先:', current.name, current.userData.id)
if (current.userData.id && objectMap.has(current.userData.id)) {
obj = objectMap.get(current.userData.id)
console.log('找到可见祖先:', obj?.name)
break
}
current = current.parent
}
}
if (obj) {
highlightClick(obj)
fitView(obj, true)
console.log('已聚焦到物体:', obj.name)
} else {
console.warn('未找到ID对应的对象:', nodeId)
}
}
// ============================================================
// 生命周期
// ============================================================
onMounted(() => {
// 1. 初始化Three.js
initThree()
// 2. 初始化模型系统
initModelSystem()
// 3. 初始化交互
initInteraction()
const currentId = route.query.currentNodeId as string
// 4. 根据模式加载模型
if (isFullMode.value) {
loadModel(`/3DModels/${currentId}.glb`, () => {
const currentId =
(route.query.currentNodeId as string) ||
store.currentNodeId ||
archetypeModel.value?.name ||
'root'
processModel(currentId, true)
})
} else {
loadModelByFileName(currentId)
}
// 5. 设置动画循环钩子
addAnimateCallback(() => {
const controls = getControls()
if (controls) {
controls.autoRotate = autoRotate.value
}
})
// 6. 设置尺寸调整钩子
addResizeCallback(() => {
if (containerRef.value) {
const width = containerRef.value.clientWidth
const height = containerRef.value.clientHeight
updatePassSize(width, height)
}
})
// 7. 添加聚焦事件监听器
store.addFocusListener(handleFocusEvent)
})
onUnmounted(() => {
disposeInteraction()
store.removeFocusListener(handleFocusEvent)
})
</script>

View File

@@ -0,0 +1,874 @@
<template>
<div class="h-full flex flex-col overflow-hidden p-4">
<!-- 头部区域 (固定) -->
<div class="flex-none pb-2 border-b border-cyan-500/10 mb-2">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<div class="w-1 h-5 bg-cyan-500 shadow-[0_0_8px_#06b6d4]"></div>
<h3
class="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500 font-mono tracking-wide"
>
属性面板 <span class="text-xs text-cyan-500/50 font-normal">/// PROPERTIES</span>
</h3>
</div>
<div class="flex items-center gap-2">
<el-tag v-if="store.currentNodeId" class="tech-tag font-mono">{{
store.currentNodeId
}}</el-tag>
<span v-else class="text-gray-500 text-sm font-mono">NO SELECTION</span>
<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 overflow-y-auto overflow-x-hidden min-h-0 custom-scrollbar"
v-if="store.currentNodeId"
>
<div class="flex flex-wrap">
<div class="w-[450px]">
<!-- 基本信息 -->
<div class="tech-divider mb-2">
<span class="text-cyan-400 font-mono text-sm">基本信息</span>
<div class="h-[1px] bg-gradient-to-r from-cyan-500/50 to-transparent flex-1 ml-4"></div>
</div>
<el-descriptions :column="1" border size="small" class="tech-descriptions mb-2">
<el-descriptions-item
v-for="(item, index) in properties.basic"
:key="index"
:label="item.label"
>
{{ item.value }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="w-[450px]">
<!-- 技术参数 -->
<div class="tech-divider mb-2">
<span class="text-cyan-400 font-mono text-sm">技术参数</span>
<div class="h-[1px] bg-gradient-to-r from-cyan-500/50 to-transparent flex-1 ml-4"></div>
</div>
<el-descriptions :column="1" border size="small" class="tech-descriptions mb-2">
<el-descriptions-item
v-for="(item, index) in properties.technical"
:key="index"
:label="item.label"
>
{{ item.value }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<!-- 运行状态 -->
<div class="tech-divider mb-2">
<span class="text-cyan-400 font-mono text-sm">状态</span>
<div class="h-[1px] bg-gradient-to-r from-cyan-500/50 to-transparent flex-1 ml-4"></div>
</div>
<div class="flex flex-wrap gap-3">
<div
v-for="(item, index) in properties.status"
:key="index"
class="sm:w-[50%] lg:w-[210px] flex justify-between p-2 bg-[#0f172a] border border-cyan-500/10 rounded cursor-pointer hover:border-cyan-500/50 hover:bg-[#0f172a]/80 transition-all group"
@click="openTableDialog(item)"
>
<span
class="text-sm text-gray-400 font-mono group-hover:text-cyan-400 transition-colors"
>{{ item.label }}</span
>
<div class="flex items-center gap-2">
<el-tag v-if="item.status" :type="item.status" size="small" class="tech-status-tag">{{
item.value
}}</el-tag>
<span v-else class="font-medium text-cyan-300 font-mono">{{ item.value }}</span>
<el-icon
class="text-gray-600 group-hover:text-cyan-400 transition-colors text-xs opacity-0 group-hover:opacity-100"
><List
/></el-icon>
</div>
</div>
</div>
</div>
<!-- 图库功能模块 -->
<div v-if="showGallery" class="mb-6">
<el-divider content-position="left">CAD图库</el-divider>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center items-center h-32">
<el-icon class="is-loading text-2xl text-blue-500">
<Loading />
</el-icon>
<span class="ml-2 text-gray-600">正在加载图片...</span>
</div>
<div v-else>
<!-- 轮播组件 -->
<div class="mb-4"></div>
<!-- 视图模式切换 -->
<div class="flex justify-between items-center mb-3">
<span class="text-sm text-gray-600"> {{ imageList.length }} 张图片</span>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="grid">
<el-icon><Grid /></el-icon>
网格
</el-radio-button>
<el-radio-button value="carousel">
<el-icon><PictureRounded /></el-icon>
走马灯
</el-radio-button>
</el-radio-group>
</div>
<!-- 缩略图区域 -->
<div class="transition-all duration-300">
<!-- 网格视图 -->
<div
v-if="viewMode === 'grid'"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"
>
<div
v-for="(image, index) in imageList"
:key="image.id"
class="relative group cursor-pointer rounded-lg overflow-hidden border-2 transition-all duration-200 hover:border-blue-400"
:class="{ 'border-blue-500 ring-2 ring-blue-200': currentIndex === index }"
@click="openPreview(index)"
>
<el-image
:src="image.thumb"
:alt="image.meta.title"
fit="contain"
class="w-full h-24"
lazy
>
<template #placeholder>
<div class="w-full h-24 flex items-center justify-center bg-gray-200">
<el-icon class="text-xl text-gray-400"><Picture /></el-icon>
</div>
</template>
</el-image>
<!-- 查看详情按钮 -->
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center"
>
<el-button
type="primary"
size="small"
circle
class="opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<el-icon><ZoomIn /></el-icon>
</el-button>
</div>
<div
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-2"
>
<p class="text-white text-xs truncate">{{ image.meta.title }}</p>
</div>
</div>
</div>
<!-- 走马灯视图 -->
<div v-else>
<el-carousel
v-model="currentIndex"
:interval="3000"
arrow="always"
indicator-position="outside"
height="200px"
class="rounded-lg overflow-hidden"
>
<el-carousel-item v-for="(image, index) in imageList" :key="image.id">
<div
class="w-full h-full bg-gray-100/0 flex items-center justify-center cursor-pointer"
@click="openPreview(index)"
@dblclick="showImageInfo(index)"
>
<el-image
:src="image.thumb"
:alt="image.meta.title"
fit="contain"
class="w-full h-full"
lazy
>
<template #placeholder>
<div class="w-full h-full flex items-center justify-center bg-gray-200">
<el-icon class="text-3xl text-gray-400"><Picture /></el-icon>
</div>
</template>
<template #error>
<div class="w-full h-full flex items-center justify-center bg-gray-200">
<el-icon class="text-3xl text-red-400"><Warning /></el-icon>
</div>
</template>
</el-image>
</div>
</el-carousel-item>
</el-carousel>
</div>
</div>
</div>
</div>
<div
v-else
class="h-full flex flex-col items-center justify-center text-gray-400 min-h-[300px]"
>
<el-icon class="text-4xl mb-2"><InfoFilled /></el-icon>
<p>请在左侧模型树或3D视图中选择一个部件查看详情</p>
</div>
<!-- 大图预览组件 -->
<el-image-viewer
v-if="showPreview"
:url-list="previewList"
:initial-index="previewIndex"
@close="showPreview = false"
>
<template #toolbar="{ index }">
<div class="flex items-center gap-2">
<el-button
type="primary"
size="small"
circle
title="下载图片"
@click="downloadImage(index)"
>
<el-icon><Download /></el-icon>
</el-button>
<el-button
type="primary"
size="small"
circle
title="复制链接"
@click="copyImageUrl(index)"
>
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-button
type="primary"
size="small"
circle
title="查看信息"
@click="showImageInfo(index)"
>
<el-icon><InfoFilled /></el-icon>
</el-button>
</div>
</template>
</el-image-viewer>
<!-- 图片信息对话框 -->
<el-dialog v-model="showInfoDialog" title="图片信息" width="400px">
<div v-if="currentImage" class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600">标题:</span>
<span>{{ currentImage.meta.title }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">标签:</span>
<el-tag size="small">{{ currentImage.meta.tag }}</el-tag>
</div>
<div class="flex justify-between">
<span class="text-gray-600">尺寸:</span>
<span>{{ currentImage.meta.dimensions }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">创建时间:</span>
<span>{{ currentImage.meta.createdAt }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">文件大小:</span>
<span>{{ currentImage.meta.size }}</span>
</div>
</div>
</el-dialog>
<!-- 数据表格弹窗 -->
<el-dialog
v-model="showTableDialog"
: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="tableData" 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>
</div>
</template>
<script setup lang="ts">
// 导入必要的Vue API
import { computed, onMounted, ref, watch } from 'vue'
// 导入必要的图标组件
import {
CopyDocument,
Download,
FullScreen,
Grid,
InfoFilled,
List,
Loading,
Picture,
PictureRounded,
Refresh,
Warning,
ZoomIn,
} from '@element-plus/icons-vue'
// 导入Element Plus组件
import { ElMessage } from 'element-plus'
// 导入电站数据状态管理
import { usePowerStationStore } from '~/stores/powerStation'
// 定义组件属性
const props = defineProps<{
isMaximized?: boolean // 是否最大化显示
}>()
// 定义组件事件
const emit = defineEmits(['toggleMaximize'])
// 获取电站状态管理实例
const store = usePowerStationStore()
// 图片数据类型定义
interface ImageMeta {
title: string
tag: string
dimensions: string
createdAt: string
size: string
}
interface ImageItem {
id: string
thumb: string
src: string
meta: ImageMeta
}
interface PropertyItem {
label: string
value: string
unit?: string
status?: string
}
interface PropertiesData {
basic: PropertyItem[]
technical: PropertyItem[]
status: PropertyItem[]
}
// 响应式数据
const loading = ref(false)
const imageList = ref<ImageItem[]>([])
const currentIndex = ref(0)
const viewMode = ref<'grid' | 'carousel'>('carousel')
const showPreview = ref(false)
const previewIndex = ref(0)
const showInfoDialog = ref(false)
const currentImage = ref<ImageItem | null>(null)
const properties = ref<PropertiesData>({ basic: [], technical: [], status: [] })
// 表格弹窗相关
const showTableDialog = ref(false)
const tableData = ref<any[]>([])
// 表格弹窗相关
const currentParamItem = ref<PropertyItem | null>(null)
const openTableDialog = (item: PropertyItem) => {
currentParamItem.value = item
showTableDialog.value = true
generateMockData(item)
}
const generateMockData = (item?: PropertyItem) => {
const targetItem = item || currentParamItem.value
if (!targetItem) return
const label = targetItem.label
const operators = ['系统自动', '操作员A', '操作员B']
// 根据不同的标签生成不同的模拟数据
tableData.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],
}
})
}
// 计算属性
// 计算属性
const showGallery = computed(() => {
// 开发环境下总是显示图库用于测试
if (process.dev) return true
// 检查currentNodeId是否包含CAD相关标识
return store.currentNodeId?.includes('CAD') || false
})
const previewList = computed(() => {
return imageList.value.map(item => item.src)
})
// 异步加载属性数据
const fetchProperties = async (nodeId: string) => {
if (!nodeId) {
console.error('获取属性数据失败: nodeId不能为空')
// 清空属性数据
properties.value = {
basic: [],
technical: [],
status: [],
}
return
}
try {
const response = await $fetch('/api/power-station/properties', {
query: { nodeId },
})
properties.value = response
} catch (error) {
console.error('获取属性数据失败:', error)
// 如果API调用失败使用模拟数据
properties.value = {
basic: [
{ label: '节点ID', value: nodeId },
{ label: '节点名称', value: nodeId.split('~').pop() },
{ label: '节点类型', value: '设备' },
{ label: '所属系统', value: '主系统' },
],
technical: [
{ label: '设计压力', value: '16.5 MPa', unit: 'MPa' },
{ label: '设计温度', value: '545', unit: '°C' },
{ label: '材料', value: 'SA-516 Gr.70' },
{ label: '厚度', value: '25', unit: 'mm' },
],
status: [
{ label: '运行状态', value: '正常运行', status: 'success' },
{ label: '健康度', value: '95%', status: 'success' },
{ label: '维护状态', value: '正常', status: 'success' },
{ label: '报警状态', value: '无报警', status: 'success' },
],
}
}
}
// 异步加载图片数据
const loadImages = async () => {
loading.value = true
try {
// 模拟异步加载
await new Promise(resolve => setTimeout(resolve, 500))
// 构建图片数据
imageList.value = [
{
id: 'cad1',
thumb: '/Cad_Preview/一级过热器出口连接管.png',
src: '/Cad/一级过热器出口连接管.png',
meta: {
title: '一级过热器出口连接管',
tag: '主视图',
dimensions: '1920x1080',
createdAt: '2024-12-04',
size: '2.3MB',
},
},
{
id: 'cad2',
thumb: '/Cad_Preview/一级再热器出口连接管.png',
src: '/Cad/一级再热器出口连接管.png',
meta: {
title: '一级再热器出口连接管',
tag: '侧视图',
dimensions: '1920x1080',
createdAt: '2024-12-04',
size: '1.8MB',
},
},
{
id: 'cad3',
thumb: '/Cad_Preview/二级过热器出口连接管.png',
src: '/Cad/二级过热器出口连接管.png',
meta: {
title: '二级过热器出口连接管',
tag: '俯视图',
dimensions: '1920x1080',
createdAt: '2024-12-04',
size: '3.1MB',
},
},
{
id: 'cad4',
thumb: '/Cad_Preview/一级再热器管排.png',
src: '/Cad/一级再热器管排.png',
meta: {
title: '一级再热器管排',
tag: '管排图',
dimensions: '1920x1080',
createdAt: '2024-12-04',
size: '2.1MB',
},
},
{
id: 'cad5',
thumb: '/Cad_Preview/二级再热器管排.png',
src: '/Cad/二级再热器管排.png',
meta: {
title: '二级再热器管排',
tag: '管排图',
dimensions: '1920x1080',
createdAt: '2024-12-04',
size: '2.5MB',
},
},
{
id: 'cad6',
thumb: '/Cad_Preview/二级过热器管排.png',
src: '/Cad/二级过热器管排.png',
meta: {
title: '二级过热器管排',
tag: '管排图',
dimensions: '1920x1080',
createdAt: '2024-12-04',
size: '2.7MB',
},
},
{
id: 'cad7',
thumb: '/Cad_Preview/一级再热器进口连接管.png',
src: '/Cad/一级再热器进口连接管.png',
meta: {
title: '一级再热器进口连接管',
tag: '进口图',
dimensions: '1920x1080',
createdAt: '2024-12-04',
size: '1.9MB',
},
},
{
id: 'cad8',
thumb: '/Cad_Preview/二级再热器进口连接管.png',
src: '/Cad/二级再热器进口连接管.png',
meta: {
title: '二级再热器进口连接管',
tag: '进口图',
dimensions: '1920x1080',
createdAt: '2024-12-04',
size: '2.2MB',
},
},
]
} catch (error) {
console.error('加载图片失败:', error)
} finally {
loading.value = false
}
}
// 选择图片(当前未使用,保留备用)
// const selectImage = (index: number) => {
// currentIndex.value = index
// }
// 打开预览
const openPreview = (index: number) => {
currentIndex.value = index // 同步更新当前索引
previewIndex.value = index
showPreview.value = true
}
// 下载图片
const downloadImage = (index: number) => {
const image = imageList.value[index]
if (image) {
const link = document.createElement('a')
link.href = image.src
link.download = `${image.meta.title}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
// 复制图片链接
const copyImageUrl = async (index: number) => {
const image = imageList.value[index]
if (image) {
try {
await navigator.clipboard.writeText(window.location.origin + image.src)
ElMessage.success('图片链接已复制到剪贴板')
} catch (error) {
ElMessage.error('复制失败,请手动复制')
}
}
}
// 显示图片信息
const showImageInfo = (index: number) => {
currentImage.value = imageList.value[index]
showInfoDialog.value = true
}
// 监听currentNodeId变化重新加载数据
watch(
() => store.currentNodeId,
newId => {
if (newId) {
// 当选择的节点ID变化时重新获取属性数据
fetchProperties(newId)
// 如果需要显示图库,加载图片
if (showGallery.value) {
loadImages()
}
}
}
)
// 组件挂载时初始化数据
onMounted(() => {
if (store.currentNodeId) {
// 获取初始属性数据
fetchProperties(store.currentNodeId)
// 如果需要显示图库,加载图片
if (showGallery.value) {
loadImages()
}
}
})
</script>
<style scoped>
/* Tech Icon Button */
.tech-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(34, 211, 238, 0.2);
color: #94a3b8;
cursor: pointer;
transition: all 0.3s ease;
}
.tech-icon-btn:hover {
background: rgba(6, 182, 212, 0.1);
border-color: #22d3ee;
color: #22d3ee;
box-shadow: 0 0 10px rgba(6, 182, 212, 0.4);
}
/* Tech Tag */
:deep(.tech-tag) {
background-color: rgba(6, 182, 212, 0.1);
border-color: rgba(6, 182, 212, 0.3);
color: #22d3ee;
}
/* Tech Descriptions */
:deep(.tech-descriptions .el-descriptions__body) {
background-color: transparent;
}
:deep(.tech-descriptions .el-descriptions__label) {
background-color: rgba(15, 23, 42, 0.8) !important;
color: #94a3b8;
font-family: monospace;
border-color: rgba(34, 211, 238, 0.1) !important;
}
:deep(.tech-descriptions .el-descriptions__content) {
background-color: transparent !important;
color: #e2e8f0;
font-family: monospace;
border-color: rgba(34, 211, 238, 0.1) !important;
}
/* Tech Status Tag */
:deep(.tech-status-tag.el-tag--success) {
background-color: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.2);
color: #34d399;
}
/* Tech Button */
:deep(.tech-button) {
background-color: rgba(6, 182, 212, 0.1);
border-color: #06b6d4;
color: #22d3ee;
font-family: monospace;
}
:deep(.tech-button:hover) {
background-color: rgba(6, 182, 212, 0.2);
box-shadow: 0 0 15px rgba(6, 182, 212, 0.3);
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.5);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(34, 211, 238, 0.2);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(34, 211, 238, 0.4);
}
</style>
<style>
/* Styling for the tech dialog modal - Global style to affect element-plus dialog projected to body */
.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;
padding: 15px 20px !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;
padding: 20px !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;
}
/* Tag overrides within dialog */
.tech-dialog-modal .el-tag {
background-color: rgba(15, 23, 42, 0.8) !important;
border-color: rgba(34, 211, 238, 0.3) !important;
}
</style>

View File

@@ -0,0 +1,391 @@
<!--
ModelTreePanel.vue
模型结构树面板组件
显示3D模型的层级结构支持搜索节点选择和聚焦
-->
<template>
<div class="relative">
<div
class="bg-[#0b1120] h-full border-r border-cyan-500/20 flex flex-col transition-all duration-300"
>
<!-- ============================================================
搜索头部
============================================================ -->
<div class="p-4 bg-[#0f172a] border-b border-cyan-500/20">
<div class="flex items-center justify-between mb-3">
<h2 class="text-base font-bold text-cyan-400 flex items-center font-mono tracking-wide">
<el-icon class="mr-2"><Files /></el-icon>
模型结构
</h2>
</div>
<el-input
v-model="filterText"
placeholder="搜索节点..."
prefix-icon="Search"
clearable
class="tech-input"
/>
</div>
<!-- ============================================================
树内容
============================================================ -->
<div
ref="treeContainer"
class="flex-1 overflow-y-auto overflow-x-hidden py-2 custom-scrollbar min-h-0"
>
<el-tree
ref="treeRef"
:data="store.modelHierarchy"
:props="defaultProps"
node-key="id"
:filter-node-method="filterNodeMethod"
highlight-current
default-expand-all
class="tech-tree"
@node-click="handleNodeClick"
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
>
<template #default="{ node }">
<div
:id="'tree-node-' + node.data.id"
class="flex items-center gap-2 text-sm w-full pr-2 group select-none py-1"
@dblclick="handleNodeDblClick(node.data)"
@mousedown.prevent="$event.preventDefault()"
@selectstart.prevent="$event.preventDefault()"
>
<!-- 节点图标 -->
<el-icon v-if="!node.isLeaf" class="text-cyan-500"><Folder /></el-icon>
<el-icon v-else class="text-blue-400"><Document /></el-icon>
<!-- 节点标签 -->
<span
class="truncate flex-1 font-mono text-gray-400 group-hover:text-cyan-300 transition-colors"
:title="node.label"
>
{{ node.label }}
</span>
<!-- 聚焦按钮 -->
<el-tooltip content="聚焦视图" placement="right" :show-after="500" effect="dark">
<el-icon
class="text-cyan-500/50 hover:text-cyan-400 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="handleZoom(node.data)"
>
<ZoomIn />
</el-icon>
</el-tooltip>
</div>
</template>
</el-tree>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* ModelTreePanel 组件
* 显示模型的层级结构树,支持搜索、选择和聚焦功能
*/
import { Document, Files, Folder, ZoomIn } from '@element-plus/icons-vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { usePowerStationStore } from '~/stores/powerStation'
// ============================================================
// 状态和引用
// ============================================================
const store = usePowerStationStore()
const route = useRoute()
/** 过滤文本 */
const filterText = ref('')
/** 树组件引用 */
const treeRef = ref()
/** 树容器引用 */
const treeContainer = ref<HTMLElement>()
/** 是否折叠 */
const isCollapsed = ref(false)
/** 是否为内部选择(来自树本身的选择) */
const isInternalSelection = ref(false)
/** 已展开的节点ID集合 */
const expandedKeys = ref<Set<string>>(new Set())
/** 树组件属性配置 */
const defaultProps = {
value: 'id',
label: 'label',
children: 'children',
}
// ============================================================
// 响应式布局
// ============================================================
/**
* 更新布局状态
* 根据窗口宽度自动折叠
*/
const updateLayout = () => {
if (window.innerWidth < 1200) {
isCollapsed.value = true
} else {
isCollapsed.value = false
}
}
/**
* 切换折叠状态
*/
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
// ============================================================
// 过滤功能
// ============================================================
// 监听过滤文本变化
watch(filterText, val => {
treeRef.value?.filter(val)
})
/**
* 节点过滤方法
* @param value - 过滤值
* @param data - 节点数据
* @returns 是否显示该节点
*/
const filterNodeMethod = (value: string, data: any) => {
if (!value) return true
const searchValue = value.toLowerCase()
const label = data.label?.toLowerCase() || ''
return label.includes(searchValue)
}
// ============================================================
// 节点操作
// ============================================================
/**
* 处理节点点击
* @param data - 节点数据
*/
const handleNodeClick = (data: any) => {
// 更新store选择状态
store.selectNode(data)
// 标记为内部选择
isInternalSelection.value = true
// 移动设备上自动折叠
if (window.innerWidth < 1024) {
isCollapsed.value = true
}
}
/**
* 处理缩放/聚焦操作
* @param data - 节点数据
*/
const handleZoom = (data: any) => {
store.selectNode(data)
store.triggerFocus(data.id)
}
/**
* 处理节点双击
* @param data - 节点数据
*/
const handleNodeDblClick = async (data: any) => {
await navigateTo({
query: {
...route.query,
currentNodeId: data.id,
},
})
}
/**
* 处理节点展开
* @param data - 节点数据
*/
const handleNodeExpand = (data: any) => {
expandedKeys.value.add(data.id)
}
/**
* 处理节点折叠
* @param data - 节点数据
*/
const handleNodeCollapse = (data: any) => {
expandedKeys.value.delete(data.id)
}
// ============================================================
// 辅助函数
// ============================================================
/**
* 查找节点路径
* @param nodes - 节点数组
* @param targetId - 目标ID
* @param path - 当前路径
* @returns 路径数组或null
*/
const findPath = (nodes: any[], targetId: string, path: string[] = []): string[] | null => {
for (const node of nodes) {
if (node.id === targetId) {
return path
}
if (node.children && node.children.length > 0) {
const result = findPath(node.children, targetId, [...path, node.id])
if (result) return result
}
}
return null
}
// ============================================================
// 监听器
// ============================================================
// 监听store选择变化3D视图 -> 树)
watch(
() => store.currentNodeId,
newId => {
if (!treeRef.value) return
// 如果是内部选择,不需要滚动/展开
if (isInternalSelection.value) {
isInternalSelection.value = false
return
}
if (!newId) {
treeRef.value.setCurrentKey(null)
return
}
// 1. 高亮节点
try {
treeRef.value.setCurrentKey(newId)
} catch (e) {
console.warn('设置当前键失败:', e)
}
// 2. 查找路径并展开/滚动
const path = findPath(store.modelHierarchy, newId)
if (path) {
// 更新展开状态
path.forEach(id => expandedKeys.value.add(id))
// 同步树展开状态
path.forEach(id => {
const node = treeRef.value.getNode(id)
if (node) {
node.expanded = true
}
})
// 延迟滚动等待DOM更新
setTimeout(() => {
requestAnimationFrame(() => {
try {
const element = document.getElementById('tree-node-' + newId)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
} catch (e) {
console.warn('树滚动错误:', e)
}
})
}, 300)
}
}
)
// ============================================================
// 生命周期
// ============================================================
onMounted(() => {
updateLayout()
window.addEventListener('resize', updateLayout)
})
onUnmounted(() => {
window.removeEventListener('resize', updateLayout)
})
</script>
<style scoped>
/* ============================================================
输入框样式
============================================================ */
:deep(.tech-input .el-input__wrapper) {
background-color: rgba(15, 23, 42, 0.5);
box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.2) inset;
}
:deep(.tech-input .el-input__inner) {
color: #e2e8f0;
font-family: monospace;
}
:deep(.tech-input .el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #06b6d4 inset;
}
/* ============================================================
树样式
============================================================ */
.tech-tree {
background: transparent;
color: #94a3b8;
}
:deep(.el-tree-node__content) {
background-color: transparent !important;
height: 32px;
}
:deep(.el-tree-node__content:hover) {
background-color: rgba(6, 182, 212, 0.1) !important;
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background-color: rgba(6, 182, 212, 0.15) !important;
border-right: 2px solid #22d3ee;
}
:deep(.el-tree-node.is-current > .el-tree-node__content .truncate) {
color: #22d3ee !important;
font-weight: bold;
}
/* ============================================================
滚动条样式
============================================================ */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.5);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(34, 211, 238, 0.2);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(34, 211, 238, 0.4);
}
</style>

267
app/components/Viewer.vue Normal file
View File

@@ -0,0 +1,267 @@
<!--
* File: \src\components\Viewer.vue
* Project: viewer_for_ruwei
* File Created: Tuesday, 10th September 2024 10:44:34 am
* Author: Sun Jie (j.sun@supreium.com)
* -----
* Last Modified: Tuesday, 10th September 2024 10:44:35 am
* Modified By: Sun Jie (j.sun@supreium.com)
* -----
* Description: 3D 网格查看器组件支持属性可视化切换
* -----
* Copyright (c) 2024 Supreium Co., Ltd , All Rights Reserved.
-->
<template>
<div class="viewer-wrapper">
<!-- 顶部属性切换工具栏 -->
<div class="viewer-toolbar">
<el-radio-group
v-model="selected"
size="small"
class="property-toggle-group"
v-if="properties && properties.length > 0"
>
<el-radio-button
v-for="item in properties"
:key="item"
:value="item"
>
{{ item }}
</el-radio-button>
</el-radio-group>
<!-- 视图控制按钮 -->
<div class="view-controls">
<el-tooltip content="适应视图" placement="bottom">
<button class="control-btn" @click="fitAll">
<el-icon><FullScreen /></el-icon>
</button>
</el-tooltip>
<el-tooltip content="等轴视图" placement="bottom">
<button class="control-btn" @click="viewAt">
<el-icon><View /></el-icon>
</button>
</el-tooltip>
</div>
</div>
<!-- 3D 渲染容器 -->
<div class="viewer-container" ref="renderDiv">
<!-- 加载状态指示器 -->
<div v-if="isLoading" class="loading-overlay">
<el-icon class="is-loading loading-icon"><Loading /></el-icon>
<span class="loading-text">LOADING MODEL...</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FullScreen, Loading, View } from '@element-plus/icons-vue';
import axios from 'axios';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { ViewHelper } from '../utils/viewHelper';
// 定义组件属性,默认使用 /example.json 作为数据源
const props = withDefaults(defineProps<{
dataPath?: string;
}>(), {
dataPath: '/example.json'
});
const renderDiv = ref<HTMLDivElement>();
const resizeListener = ref<Function[]>([]);
const destroyFunctions = ref<Function[]>([]);
const properties = ref<string[]>();
const selected = ref<string>("");
const isLoading = ref(true);
// 窗口尺寸调整回调
const resizeCallback = () => resizeListener.value.forEach((callback) => callback());
// 监听容器尺寸变化
const onDivResize = (currentDiv: HTMLDivElement, callback: ResizeObserverCallback) => {
resizeListener.value.push(callback);
const resizeObserver = new ResizeObserver(callback);
resizeObserver.observe(currentDiv!);
destroyFunctions.value.push(() => resizeObserver?.disconnect());
};
// 初始化视图
const init = async () => {
window.addEventListener("resize", resizeCallback);
await ViewHelper.instance.initView(renderDiv.value!);
onDivResize(renderDiv.value!, () => ViewHelper.instance.viewResize(renderDiv.value!.clientWidth, renderDiv.value!.clientHeight));
};
onMounted(async () => {
await init();
try {
// 加载网格数据
const mesh = (await axios.get(props.dataPath)).data;
// 创建视图对象
ViewHelper.instance.loadMesh(mesh.mesh, mesh.properties);
/**
* 如需过滤属性可使用:
* ViewHelper.instance.loadMesh(mesh.mesh, (mesh.properties as MeshPropertyType[]).filter((item) => item.name === "vonMises" || item.name === "displacement-magnitude"));
*/
// 设置等轴视图并适应
ViewHelper.instance.isometricView();
ViewHelper.instance.fitAll();
// 获取可用属性列表
properties.value = ViewHelper.instance.properties;
// 激活第一个属性,触发 watch
if (properties.value && properties.value.length > 0) {
selected.value = properties.value[0];
}
// 注册清理回调
destroyFunctions.value.push(() => ViewHelper.instance.clear());
} catch (error) {
console.error('加载模型数据失败:', error);
} finally {
isLoading.value = false;
}
});
onUnmounted(() => {
window.removeEventListener("resize", resizeCallback);
destroyFunctions.value.forEach((fun) => fun());
});
// 监听属性选择变化
watch(selected, () => selected.value !== "" && ViewHelper.instance.activeProperty(selected.value))
// 适应视图
const fitAll = () => ViewHelper.instance.fitAll();
// 等轴视图
const viewAt = () => ViewHelper.instance.isometricView();
</script>
<style lang="css" scoped>
/* 查看器容器 */
.viewer-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #0b1120;
border: 1px solid rgba(6, 182, 212, 0.2);
border-radius: 4px;
overflow: hidden;
}
/* 工具栏 */
.viewer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.95) 0%, rgba(11, 17, 32, 0.9) 100%);
border-bottom: 1px solid rgba(6, 182, 212, 0.15);
gap: 16px;
}
/* 视图控制按钮组 */
.view-controls {
display: flex;
gap: 8px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(34, 211, 238, 0.3);
color: #22d3ee;
cursor: pointer;
transition: all 0.3s ease;
}
.control-btn:hover {
background: rgba(6, 182, 212, 0.2);
box-shadow: 0 0 12px rgba(6, 182, 212, 0.4);
transform: scale(1.05);
border-color: #22d3ee;
}
.control-btn:active {
transform: scale(0.95);
}
/* 3D 渲染区域 */
.viewer-container {
flex: 1;
position: relative;
background: radial-gradient(ellipse at center, #0f172a 0%, #0b1120 100%);
}
/* 加载状态 */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(11, 17, 32, 0.9);
backdrop-filter: blur(4px);
z-index: 10;
}
.loading-icon {
font-size: 32px;
color: #22d3ee;
margin-bottom: 12px;
}
.loading-text {
color: rgba(34, 211, 238, 0.8);
font-size: 12px;
letter-spacing: 0.2em;
font-family: monospace;
}
/* 属性切换 Toggle Group 样式 */
:deep(.property-toggle-group .el-radio-button__inner) {
background-color: transparent;
border: 1px solid rgba(34, 211, 238, 0.2);
color: #94a3b8;
font-family: monospace;
font-size: 12px;
transition: all 0.3s ease;
padding: 6px 14px;
}
:deep(.property-toggle-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(.property-toggle-group .el-radio-button:first-child .el-radio-button__inner) {
border-radius: 4px 0 0 4px;
}
:deep(.property-toggle-group .el-radio-button:last-child .el-radio-button__inner) {
border-radius: 0 4px 4px 0;
}
:deep(.property-toggle-group .el-radio-button__inner:hover) {
color: #22d3ee;
background-color: rgba(34, 211, 238, 0.05);
}
/* 处理单个按钮时的圆角 */
:deep(.property-toggle-group .el-radio-button:only-child .el-radio-button__inner) {
border-radius: 4px;
}
</style>