init3
This commit is contained in:
854
app/components/PowerStation/DataChartsSection.vue
Normal file
854
app/components/PowerStation/DataChartsSection.vue
Normal 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>
|
||||||
290
app/components/PowerStation/HeaderNavigation.vue
Normal file
290
app/components/PowerStation/HeaderNavigation.vue
Normal 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>
|
||||||
43
app/components/PowerStation/Model3D/LoadingOverlay.vue
Normal file
43
app/components/PowerStation/Model3D/LoadingOverlay.vue
Normal 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>
|
||||||
174
app/components/PowerStation/Model3D/ModelToolbar.vue
Normal file
174
app/components/PowerStation/Model3D/ModelToolbar.vue
Normal 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>
|
||||||
91
app/components/PowerStation/Model3D/ViewControls.vue
Normal file
91
app/components/PowerStation/Model3D/ViewControls.vue
Normal 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>
|
||||||
573
app/components/PowerStation/Model3DView.vue
Normal file
573
app/components/PowerStation/Model3DView.vue
Normal 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>
|
||||||
874
app/components/PowerStation/ModelPropertiesPanel.vue
Normal file
874
app/components/PowerStation/ModelPropertiesPanel.vue
Normal 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>
|
||||||
391
app/components/PowerStation/ModelTreePanel.vue
Normal file
391
app/components/PowerStation/ModelTreePanel.vue
Normal 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
267
app/components/Viewer.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user