Spaces:
Sleeping
Sleeping
| <html lang="zh-CN" class="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>智能预测性维护仿真系统 | Predictive Maintenance Sim</title> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| tailwind.config = { | |
| darkMode: 'class', | |
| theme: { | |
| extend: { | |
| colors: { | |
| gray: { | |
| 900: '#111827', | |
| 800: '#1f2937', | |
| 700: '#374151', | |
| }, | |
| blue: { | |
| 600: '#2563eb', | |
| 500: '#3b82f6', | |
| } | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <!-- Vue 3 --> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <!-- ECharts --> | |
| <script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script> | |
| <!-- Font Awesome --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> | |
| <style> | |
| [v-cloak] { display: none; } | |
| .glass-panel { | |
| background: rgba(31, 41, 55, 0.7); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(75, 85, 99, 0.4); | |
| } | |
| .sensor-value { | |
| font-family: 'Courier New', monospace; | |
| font-weight: bold; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-900 text-gray-100 min-h-screen font-sans"> | |
| <div id="app" v-cloak class="p-6 max-w-[1600px] mx-auto"> | |
| <!-- Header --> | |
| <header class="mb-8 flex justify-between items-center border-b border-gray-700 pb-4"> | |
| <div> | |
| <h1 class="text-3xl font-bold text-blue-500"> | |
| <i class="fa-solid fa-industry mr-2"></i>智能预测性维护仿真系统 | |
| </h1> | |
| <p class="text-gray-400 mt-1">Industrial IoT Predictive Maintenance Dashboard (IIoT)</p> | |
| </div> | |
| <div class="flex gap-4"> | |
| <button @click="exportData" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg border border-gray-600 transition flex items-center gap-2"> | |
| <i class="fa-solid fa-download"></i> 导出日志 | |
| </button> | |
| <div class="px-4 py-2 bg-gray-800 rounded-lg border border-gray-700"> | |
| <span class="text-gray-400">System Time:</span> | |
| <span class="text-green-400 font-mono ml-2">[[ timestamp ]]</span> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- KPI Cards --> | |
| <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"> | |
| <div class="glass-panel p-6 rounded-xl relative overflow-hidden"> | |
| <div class="absolute right-4 top-4 text-blue-500/20 text-6xl"><i class="fa-solid fa-heart-pulse"></i></div> | |
| <h3 class="text-gray-400 text-sm uppercase tracking-wider">车间平均健康度</h3> | |
| <div class="text-3xl font-bold mt-2" :class="avgHealth > 80 ? 'text-green-400' : 'text-yellow-400'"> | |
| [[ avgHealth ]]% | |
| </div> | |
| </div> | |
| <div class="glass-panel p-6 rounded-xl relative overflow-hidden"> | |
| <div class="absolute right-4 top-4 text-red-500/20 text-6xl"><i class="fa-solid fa-triangle-exclamation"></i></div> | |
| <h3 class="text-gray-400 text-sm uppercase tracking-wider">风险警报</h3> | |
| <div class="text-3xl font-bold mt-2 text-white"> | |
| [[ riskLevel ]] | |
| </div> | |
| </div> | |
| <div class="glass-panel p-6 rounded-xl relative overflow-hidden"> | |
| <div class="absolute right-4 top-4 text-green-500/20 text-6xl"><i class="fa-solid fa-money-bill-wave"></i></div> | |
| <h3 class="text-gray-400 text-sm uppercase tracking-wider">累计维护成本</h3> | |
| <div class="text-3xl font-bold mt-2 text-green-400"> | |
| $[[ totalCost ]] | |
| </div> | |
| </div> | |
| <div class="glass-panel p-6 rounded-xl relative overflow-hidden"> | |
| <div class="absolute right-4 top-4 text-purple-500/20 text-6xl"><i class="fa-solid fa-clock"></i></div> | |
| <h3 class="text-gray-400 text-sm uppercase tracking-wider">总运行小时数</h3> | |
| <div class="text-3xl font-bold mt-2 text-purple-400"> | |
| [[ totalRunHours ]] h | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main Content Grid --> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| <!-- Equipment List & Controls --> | |
| <div class="lg:col-span-2 space-y-6"> | |
| <!-- Fleet Table --> | |
| <div class="glass-panel rounded-xl overflow-hidden"> | |
| <div class="p-4 bg-gray-800/50 border-b border-gray-700 flex justify-between items-center"> | |
| <h2 class="text-xl font-semibold"><i class="fa-solid fa-list-ul mr-2 text-blue-500"></i>设备状态监控</h2> | |
| </div> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full text-left"> | |
| <thead class="bg-gray-800 text-gray-400 text-sm"> | |
| <tr> | |
| <th class="p-4">ID / 名称</th> | |
| <th class="p-4">类型</th> | |
| <th class="p-4">状态</th> | |
| <th class="p-4 text-right">健康度</th> | |
| <th class="p-4 text-right">温度 (°C)</th> | |
| <th class="p-4 text-right">振动 (mm/s)</th> | |
| <th class="p-4 text-right">RUL预测 (h)</th> | |
| <th class="p-4 text-center">操作</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-gray-700"> | |
| <tr v-for="eq in fleet" :key="eq.id" | |
| class="hover:bg-gray-800/50 transition cursor-pointer" | |
| :class="{'bg-blue-900/20': selectedEqId === eq.id}" | |
| @click="selectEquipment(eq)"> | |
| <td class="p-4"> | |
| <div class="font-bold text-white">[[ eq.id ]]</div> | |
| <div class="text-xs text-gray-500">[[ eq.name ]]</div> | |
| </td> | |
| <td class="p-4 text-gray-300">[[ eq.type ]]</td> | |
| <td class="p-4"> | |
| <span class="px-2 py-1 rounded text-xs font-bold" | |
| :class="{ | |
| 'bg-green-900 text-green-400': eq.status === 'Running', | |
| 'bg-yellow-900 text-yellow-400': eq.status === 'Warning', | |
| 'bg-red-900 text-red-400': eq.status === 'Critical', | |
| 'bg-gray-700 text-gray-300': eq.status === 'Stopped' | |
| }"> | |
| [[ eq.status ]] | |
| </span> | |
| </td> | |
| <td class="p-4 text-right"> | |
| <div class="flex items-center justify-end gap-2"> | |
| <div class="w-16 bg-gray-700 h-2 rounded-full overflow-hidden"> | |
| <div class="h-full transition-all duration-500" | |
| :class="eq.health < 50 ? 'bg-red-500' : (eq.health < 80 ? 'bg-yellow-500' : 'bg-green-500')" | |
| :style="{width: eq.health + '%'}"></div> | |
| </div> | |
| <span class="font-mono text-sm">[[ eq.health ]]%</span> | |
| </div> | |
| </td> | |
| <td class="p-4 text-right font-mono" :class="{'text-red-400': eq.sensors.temp > 70}"> | |
| [[ eq.sensors.temp ]] | |
| </td> | |
| <td class="p-4 text-right font-mono" :class="{'text-red-400': eq.sensors.vibration > 1.5}"> | |
| [[ eq.sensors.vibration ]] | |
| </td> | |
| <td class="p-4 text-right font-mono text-blue-300"> | |
| [[ eq.rul ]] | |
| </td> | |
| <td class="p-4 text-center"> | |
| <button @click.stop="performMaintenance(eq.id)" | |
| class="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded transition" | |
| :disabled="eq.health > 95" | |
| :class="{'opacity-50 cursor-not-allowed': eq.health > 95}"> | |
| 维护 | |
| </button> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Live Chart --> | |
| <div class="glass-panel rounded-xl p-4 h-[400px]"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-semibold text-gray-200"> | |
| <i class="fa-solid fa-chart-line mr-2 text-blue-500"></i> | |
| 实时传感器遥测 ([[ selectedEq ? selectedEq.name : 'All' ]]) | |
| </h3> | |
| <div class="flex gap-2 text-xs"> | |
| <span class="flex items-center gap-1"><div class="w-3 h-3 bg-red-500 rounded-full"></div> 温度</span> | |
| <span class="flex items-center gap-1"><div class="w-3 h-3 bg-yellow-400 rounded-full"></div> 振动</span> | |
| </div> | |
| </div> | |
| <div id="sensorChart" class="w-full h-[320px]"></div> | |
| </div> | |
| </div> | |
| <!-- Side Panel: Analytics & Details --> | |
| <div class="space-y-6"> | |
| <!-- RUL Gauge --> | |
| <div class="glass-panel rounded-xl p-6 flex flex-col items-center justify-center"> | |
| <h3 class="text-gray-400 text-sm mb-4">剩余寿命预测 (RUL)</h3> | |
| <div id="rulGauge" class="w-full h-[250px]"></div> | |
| <p class="text-center text-xs text-gray-500 mt-2">Based on exponential decay model</p> | |
| </div> | |
| <!-- System Tools: Upload --> | |
| <div class="glass-panel rounded-xl p-4"> | |
| <h3 class="text-lg font-semibold mb-3 border-b border-gray-700 pb-2"> | |
| <i class="fa-solid fa-screwdriver-wrench mr-2 text-blue-500"></i>系统工具 | |
| </h3> | |
| <div class="space-y-3"> | |
| <div> | |
| <label class="block text-xs text-gray-400 mb-1">固件/配置上传 (支持大文件)</label> | |
| <div class="flex gap-2"> | |
| <input type="file" ref="fileInput" class="block w-full text-xs text-gray-400 | |
| file:mr-2 file:py-2 file:px-4 | |
| file:rounded-full file:border-0 | |
| file:text-xs file:font-semibold | |
| file:bg-blue-900/30 file:text-blue-400 | |
| hover:file:bg-blue-900/50 cursor-pointer" | |
| @change="handleFileSelect" | |
| > | |
| <button @click="uploadFile" :disabled="!selectedFile || uploadStatus === 'uploading'" | |
| class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded transition disabled:opacity-50 text-xs whitespace-nowrap"> | |
| <i class="fa-solid fa-upload" :class="{'fa-spin': uploadStatus === 'uploading'}"></i> | |
| </button> | |
| </div> | |
| <div v-if="uploadStatus" class="mt-2 text-xs" :class="{ | |
| 'text-yellow-400': uploadStatus === 'uploading', | |
| 'text-green-400': uploadStatus === 'success', | |
| 'text-red-400': uploadStatus === 'error' | |
| }"> | |
| [[ uploadMessage ]] | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Event Log --> | |
| <div class="glass-panel rounded-xl p-4 flex-1 h-[400px] overflow-hidden flex flex-col"> | |
| <h3 class="text-lg font-semibold mb-3 border-b border-gray-700 pb-2"> | |
| <i class="fa-solid fa-clipboard-list mr-2 text-blue-500"></i>系统日志 | |
| </h3> | |
| <div class="overflow-y-auto space-y-2 pr-2 text-sm flex-1"> | |
| <div v-for="(log, i) in systemLogs" :key="i" class="p-2 rounded bg-gray-800/50 border border-gray-700 flex justify-between"> | |
| <span :class="log.type === 'alert' ? 'text-red-400' : 'text-gray-300'"> | |
| <i :class="log.icon"></i> [[ log.message ]] | |
| </span> | |
| <span class="text-gray-600 text-xs">[[ log.time ]]</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, computed, onMounted, onUnmounted } = Vue; | |
| createApp({ | |
| delimiters: ['[[', ']]'], | |
| setup() { | |
| const fleet = ref([]); | |
| const timestamp = ref('--:--:--'); | |
| const riskLevel = ref('Low'); | |
| const totalCost = ref(0); | |
| const selectedEqId = ref(null); | |
| const systemLogs = ref([]); | |
| // Upload logic | |
| const fileInput = ref(null); | |
| const selectedFile = ref(null); | |
| const uploadStatus = ref(''); // '', 'uploading', 'success', 'error' | |
| const uploadMessage = ref(''); | |
| const handleFileSelect = (event) => { | |
| selectedFile.value = event.target.files[0]; | |
| uploadStatus.value = ''; | |
| uploadMessage.value = ''; | |
| }; | |
| const uploadFile = async () => { | |
| if (!selectedFile.value) return; | |
| const formData = new FormData(); | |
| formData.append('file', selectedFile.value); | |
| uploadStatus.value = 'uploading'; | |
| uploadMessage.value = 'Uploading...'; | |
| try { | |
| const res = await fetch('/api/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await res.json(); | |
| if (res.ok && data.success) { | |
| uploadStatus.value = 'success'; | |
| uploadMessage.value = data.message; | |
| addLog(`文件上传成功: ${selectedFile.value.name}`, 'success'); | |
| selectedFile.value = null; | |
| if (fileInput.value) fileInput.value.value = ''; | |
| } else { | |
| throw new Error(data.message || 'Upload failed'); | |
| } | |
| } catch (e) { | |
| uploadStatus.value = 'error'; | |
| uploadMessage.value = `Error: ${e.message}`; | |
| addLog(`文件上传失败: ${selectedFile.value ? selectedFile.value.name : ''}`, 'alert'); | |
| } | |
| }; | |
| // Charts | |
| let chartInstance = null; | |
| let gaugeInstance = null; | |
| const chartData = { | |
| time: [], | |
| temp: [], | |
| vib: [] | |
| }; | |
| const selectedEq = computed(() => { | |
| return fleet.value.find(e => e.id === selectedEqId.value) || fleet.value[0]; | |
| }); | |
| const avgHealth = computed(() => { | |
| if (fleet.value.length === 0) return 0; | |
| const sum = fleet.value.reduce((acc, curr) => acc + curr.health, 0); | |
| return Math.round(sum / fleet.value.length); | |
| }); | |
| const totalRunHours = computed(() => { | |
| return fleet.value.reduce((acc, curr) => acc + curr.run_hours, 0).toFixed(1); | |
| }); | |
| const addLog = (msg, type='info') => { | |
| const icons = { | |
| 'info': 'fa-solid fa-info-circle', | |
| 'alert': 'fa-solid fa-triangle-exclamation', | |
| 'success': 'fa-solid fa-check-circle' | |
| }; | |
| systemLogs.value.unshift({ | |
| message: msg, | |
| type: type, | |
| icon: icons[type], | |
| time: new Date().toLocaleTimeString() | |
| }); | |
| if (systemLogs.value.length > 20) systemLogs.value.pop(); | |
| }; | |
| const initCharts = () => { | |
| chartInstance = echarts.init(document.getElementById('sensorChart')); | |
| gaugeInstance = echarts.init(document.getElementById('rulGauge')); | |
| const option = { | |
| backgroundColor: 'transparent', | |
| grid: { left: 40, right: 20, top: 20, bottom: 20 }, | |
| xAxis: { | |
| type: 'category', | |
| data: [], | |
| axisLine: { lineStyle: { color: '#4B5563' } } | |
| }, | |
| yAxis: [ | |
| { type: 'value', name: 'Temp', min: 0, max: 100, axisLine: { lineStyle: { color: '#EF4444' } }, splitLine: { show: false } }, | |
| { type: 'value', name: 'Vib', min: 0, max: 5, axisLine: { lineStyle: { color: '#FBBF24' } }, splitLine: { lineStyle: { color: '#374151', type: 'dashed' } } } | |
| ], | |
| series: [ | |
| { name: 'Temp', type: 'line', data: [], smooth: true, showSymbol: false, itemStyle: { color: '#EF4444' } }, | |
| { name: 'Vib', type: 'line', yAxisIndex: 1, data: [], smooth: true, showSymbol: false, itemStyle: { color: '#FBBF24' } } | |
| ] | |
| }; | |
| chartInstance.setOption(option); | |
| const gaugeOption = { | |
| series: [{ | |
| type: 'gauge', | |
| startAngle: 180, endAngle: 0, | |
| min: 0, max: 2000, | |
| splitNumber: 5, | |
| itemStyle: { color: '#3B82F6' }, | |
| progress: { show: true, width: 18 }, | |
| pointer: { show: false }, | |
| axisLine: { lineStyle: { width: 18 } }, | |
| axisTick: { show: false }, | |
| splitLine: { length: 15, lineStyle: { width: 2, color: '#999' } }, | |
| axisLabel: { distance: 25, color: '#999', fontSize: 10 }, | |
| detail: { valueAnimation: true, fontSize: 30, offsetCenter: [0, '30%'], color: 'auto', formatter: '{value} h' }, | |
| data: [{ value: 1000 }] | |
| }] | |
| }; | |
| gaugeInstance.setOption(gaugeOption); | |
| }; | |
| const updateData = async () => { | |
| try { | |
| const res = await fetch('/api/status'); | |
| const data = await res.json(); | |
| // Smart merge to prevent UI flicker | |
| if (fleet.value.length === 0) { | |
| fleet.value = data.fleet; | |
| selectedEqId.value = data.fleet[0].id; | |
| } else { | |
| data.fleet.forEach((newItem, idx) => { | |
| Object.assign(fleet.value[idx], newItem); | |
| }); | |
| } | |
| timestamp.value = data.timestamp; | |
| totalCost.value = data.metrics.total_maintenance_cost; | |
| riskLevel.value = data.metrics.fleet_risk_level; | |
| // Check for alerts | |
| fleet.value.forEach(eq => { | |
| if (eq.status === 'Critical' && Math.random() < 0.1) { | |
| addLog(`${eq.name} 状态危急! 建议立即停机维护`, 'alert'); | |
| } | |
| }); | |
| // Update Charts | |
| if (selectedEq.value) { | |
| const now = new Date().toLocaleTimeString(); | |
| chartData.time.push(now); | |
| chartData.temp.push(selectedEq.value.sensors.temp); | |
| chartData.vib.push(selectedEq.value.sensors.vibration); | |
| if (chartData.time.length > 20) { | |
| chartData.time.shift(); | |
| chartData.temp.shift(); | |
| chartData.vib.shift(); | |
| } | |
| chartInstance.setOption({ | |
| xAxis: { data: chartData.time }, | |
| series: [ | |
| { data: chartData.temp }, | |
| { data: chartData.vib } | |
| ] | |
| }); | |
| gaugeInstance.setOption({ | |
| series: [{ data: [{ value: selectedEq.value.rul }] }] | |
| }); | |
| } | |
| } catch (e) { | |
| console.error("Fetch error", e); | |
| } | |
| }; | |
| const selectEquipment = (eq) => { | |
| selectedEqId.value = eq.id; | |
| // Reset chart data for visual cleanliness | |
| chartData.time = []; | |
| chartData.temp = []; | |
| chartData.vib = []; | |
| }; | |
| const performMaintenance = async (id) => { | |
| try { | |
| const res = await fetch('/api/maintain', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({id}) | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| addLog(`维护完成: ${id}`, 'success'); | |
| updateData(); // Immediate refresh | |
| } | |
| } catch (e) { | |
| addLog('维护请求失败', 'alert'); | |
| } | |
| }; | |
| const exportData = () => { | |
| window.open('/api/export', '_blank'); | |
| }; | |
| onMounted(() => { | |
| initCharts(); | |
| updateData(); | |
| setInterval(updateData, 1000); // 1s polling for real-time feel | |
| window.addEventListener('resize', () => { | |
| chartInstance && chartInstance.resize(); | |
| gaugeInstance && gaugeInstance.resize(); | |
| }); | |
| }); | |
| return { | |
| fleet, timestamp, riskLevel, totalCost, avgHealth, totalRunHours, | |
| selectedEqId, selectedEq, systemLogs, | |
| selectEquipment, performMaintenance, exportData, | |
| fileInput, selectedFile, uploadStatus, uploadMessage, handleFileSelect, uploadFile | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |