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

875 lines
27 KiB
Vue
Raw Normal View History

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