温度看板搭建示例

This commit is contained in:
“hsc”
2026-08-28 10:21:06 +08:00
parent 2194ca4d34
commit 43f1dde4e2
11 changed files with 1276 additions and 82 deletions
-1
View File
@@ -1 +0,0 @@
import{r as e,a as r,o as t,c as o,b as s,w as n,F as i,d as l,e as a,t as c,f as u,g as d}from"./vendor.3763ac43.js";!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver((e=>{for(const t of e)if("childList"===t.type)for(const e of t.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)})).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerpolicy&&(r.referrerPolicy=e.referrerpolicy),"use-credentials"===e.crossorigin?r.credentials="include":"anonymous"===e.crossorigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();const f=l("img",{alt:"Vue logo",src:"/assets/logo.03d6d6da.png"},null,-1),p=u({setup(l){const u=e("Hello Element Plus via Vite");return(e,l)=>{const d=r("el-button");return t(),o(i,null,[f,s(d,{type:"primary"},{default:n((()=>[a(c(u.value),1)])),_:1})],64)}}});p.use(d),p.mount("#app");
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

-39
View File
File diff suppressed because one or more lines are too long
+2 -20
View File
@@ -1,4 +1,3 @@
<<<<<<< HEAD
<!DOCTYPE html>
<html lang="zh-CN">
<head>
@@ -6,7 +5,8 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>设备管理系统</title>
<script type="module" crossorigin src="/assets/index-BhGvW7tT.js"></script>
<script type="module" crossorigin src="/assets/index-1sWe50d-.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
<link rel="stylesheet" crossorigin href="/assets/index-BpBnxyAz.css">
</head>
<body>
@@ -14,21 +14,3 @@
<!-- Vite 入口,指向 src/main.js,不要删除 -->
</body>
</html>
=======
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Element Plus Demo</title>
<script type="module" crossorigin src="/assets/index.a712a2e4.js"></script>
<link rel="modulepreload" href="/assets/vendor.3763ac43.js">
<link rel="stylesheet" href="/assets/index.a7868d2c.css">
</head>
<body>
<div id="app"></div>
<!-- 引入 Vite 的核心模块入口 -->
</body>
</html>
>>>>>>> ef029340f2f8ddaab8f06229c6a867fadacd9a3c
+983
View File
File diff suppressed because it is too large Load Diff
+15 -9
View File
@@ -1,43 +1,49 @@
{
"hash": "57bb8c15",
"hash": "a6f0f194",
"configHash": "147f2811",
"lockfileHash": "fb7e8ff9",
"browserHash": "fba38d4b",
"lockfileHash": "77198382",
"browserHash": "0cd29ca6",
"optimized": {
"@element-plus/icons-vue": {
"src": "../../@element-plus/icons-vue/dist/index.js",
"file": "@element-plus_icons-vue.js",
"fileHash": "d1f71359",
"fileHash": "9553abbf",
"needsInterop": false
},
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "3e018dd2",
"needsInterop": false
},
"echarts": {
"src": "../../echarts/index.js",
"file": "echarts.js",
"fileHash": "34e45839",
"fileHash": "275a5510",
"needsInterop": false
},
"element-plus": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus.js",
"fileHash": "8047ae48",
"fileHash": "cc57a904",
"needsInterop": false
},
"pinia": {
"src": "../../pinia/dist/pinia.js",
"file": "pinia.js",
"fileHash": "fa8e7c2a",
"fileHash": "0a5304b8",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.js",
"file": "vue-router.js",
"fileHash": "25bd4614",
"fileHash": "0ef5b6fe",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "9a318764",
"fileHash": "52e89e85",
"needsInterop": false
}
},
+15
View File
@@ -0,0 +1,15 @@
import request from '@/utils/request'
// 设备看板(温湿度)相关接口
// 获取所有环境箱最新温湿度(卡片轮询用)
export function getRealtime() {
return request.get('/inspection/dashboard/realtime')
}
// 获取指定环境箱最近 N 分钟的温湿度曲线
export function getCurve(deviceCode, minutes = 5) {
return request.get('/inspection/dashboard/curve', {
params: { deviceCode, minutes }
})
}
+7 -6
View File
@@ -18,13 +18,14 @@ request.interceptors.request.use(config => {
request.interceptors.response.use(
response => {
const res = response.data
// 假设后端统一返回 { code, data, message }
if (res.code !== 0) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
// 后端统一返回 { Code, Msg, Data }(兼容小写字段)
const code = res.Code ?? res.code
if (code !== 0) {
ElMessage.error(res.Msg || res.message || '请求失败')
return Promise.reject(new Error(res.Msg || res.message))
}
// 直接返回 data,页面拿到的就是干净数据,不用每次再 .data
return res.data
// 直接返回 Data,页面拿到的就是干净数据,不用每次再 .data
return res.Data ?? res.data
},
error => {
ElMessage.error(error.message || '网络错误')
@@ -1,14 +1,255 @@
<template>
<div class="page-container">
<el-card>
<div class="page-container temp-box-page">
<!-- 顶部选中环境箱的实时温湿度曲线 -->
<el-card class="chart-card">
<template #header>
<span>温度箱看板</span>
<div class="chart-header">
<span class="title">
实时曲线
<el-tag v-if="selected" size="small" type="primary">{{ selected.DeviceCode }}</el-tag>
<el-tag v-if="selected && isAlarm(selected)" size="small" type="danger">报警</el-tag>
</span>
<span class="update-time">卡片数据更新{{ updateTime || '--' }}</span>
</div>
</template>
<el-empty description="温度箱看板 - 功能开发中" />
<div ref="chartRef" class="chart-box"></div>
<el-empty v-if="!selected" description="点击下方卡片选择一台环境箱查看曲线" :image-size="60" />
</el-card>
<!-- 下方100 台环境箱卡片 -->
<el-card class="grid-card">
<template #header>
<div class="chart-header">
<span class="title">环境箱状态{{ devices.length }} </span>
<span class="update-time">
报警 {{ alarmCount }}
</span>
</div>
</template>
<div v-loading="loading && devices.length === 0" class="device-grid">
<div
v-for="d in devices"
:key="d.DeviceCode"
class="device-card"
:class="{ 'is-alarm': isAlarm(d), 'is-selected': d.DeviceCode === selectedCode }"
@click="selectDevice(d)"
>
<div class="device-card__head">
<span class="device-code">{{ d.DeviceCode }}</span>
<el-tag :type="isAlarm(d) ? 'danger' : 'success'" size="small">
{{ d.Status }}
</el-tag>
</div>
<div class="device-card__body">
<div class="value temp">{{ d.DeviceTemperature }}<small></small></div>
<div class="value hum">{{ d.DeviceHumidity }}<small>%</small></div>
</div>
<div class="device-card__foot">
<span v-if="isAlarm(d)" class="alarm-text">{{ d.AlarmInfo }}</span>
<span class="data-time">{{ fmtTime(d.CreateTime) }}</span>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
// TODO: 实现温度箱看板功能
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getRealtime, getCurve } from '@/api/dashboard'
// ---------- 状态 ----------
const devices = ref([]) // 100 台设备最新值
const selectedCode = ref('') // 当前选中的设备
const updateTime = ref('')
const loading = ref(false)
const chartRef = ref(null)
let chart = null
let realtimeTimer = null
let curveTimer = null
const selected = computed(() => devices.value.find(d => d.DeviceCode === selectedCode.value) || null)
const alarmCount = computed(() => devices.value.filter(isAlarm).length)
// ---------- 工具 ----------
function isAlarm(d) {
return d.Status === '报警' || !!d.AlarmInfo
}
function fmtTime(str) {
if (!str) return '--'
// 后端返回 ISO 时间,截取 时:分:秒
return str.indexOf('T') > -1 ? str.slice(11, 19) : str
}
// ---------- 曲线 ----------
function initChart() {
if (!chartRef.value) return
chart = echarts.init(chartRef.value)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['温度(℃)', '湿度(%)'] },
grid: { left: 50, right: 50, top: 40, bottom: 30 },
xAxis: { type: 'category', boundaryGap: false, data: [] },
yAxis: [
{ type: 'value', name: '℃' },
{ type: 'value', name: '%' }
],
series: [
{ name: '温度(℃)', type: 'line', showSymbol: false, smooth: true, data: [], itemStyle: { color: '#f56c6c' } },
{ name: '湿度(%)', type: 'line', showSymbol: false, smooth: true, yAxisIndex: 1, data: [], itemStyle: { color: '#409eff' } }
]
})
window.addEventListener('resize', resizeChart)
}
function resizeChart() {
chart && chart.resize()
}
async function fetchCurve() {
if (!selectedCode.value) return
try {
const list = await getCurve(selectedCode.value, 5)
const times = list.map(d => fmtTime(d.CreateTime))
chart && chart.setOption({
xAxis: { data: times },
series: [
{ data: list.map(d => d.DeviceTemperature) },
{ data: list.map(d => d.DeviceHumidity) }
]
})
} catch (e) { /* 轮询失败忽略,等下一轮 */ }
}
// ---------- 卡片 ----------
async function fetchRealtime() {
try {
loading.value = true
const list = await getRealtime()
devices.value = list || []
updateTime.value = new Date().toLocaleTimeString()
// 默认选中第一台
if (!selectedCode.value && devices.value.length) {
selectedCode.value = devices.value[0].DeviceCode
}
} catch (e) { /* 后端未启动时静默等待下一轮 */ }
finally {
loading.value = false
}
}
function selectDevice(d) {
if (selectedCode.value === d.DeviceCode) return
selectedCode.value = d.DeviceCode
fetchCurve() // 切换后立刻刷一次曲线
}
// ---------- 生命周期 ----------
onMounted(async () => {
await nextTick()
initChart()
fetchRealtime()
fetchCurve()
realtimeTimer = setInterval(fetchRealtime, 5000) // 卡片每 5 秒刷新
curveTimer = setInterval(fetchCurve, 2000) // 曲线每 2 秒刷新
})
onUnmounted(() => {
clearInterval(realtimeTimer)
clearInterval(curveTimer)
window.removeEventListener('resize', resizeChart)
chart && chart.dispose()
})
</script>
<style scoped>
.temp-box-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.update-time {
color: #909399;
font-size: 13px;
}
.chart-box {
height: 260px;
}
/* 卡片网格 */
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
max-height: 520px;
overflow-y: auto;
}
.device-card {
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 10px 12px;
cursor: pointer;
transition: all 0.2s;
background: #fff;
}
.device-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.device-card.is-selected {
border-color: #409eff;
background: #ecf5ff;
}
.device-card.is-alarm {
border-color: #f56c6c;
background: #fef0f0;
}
.device-card__head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.device-code {
font-weight: 600;
font-size: 14px;
}
.device-card__body {
display: flex;
gap: 18px;
margin: 4px 0;
}
.value {
font-size: 22px;
font-weight: 700;
}
.value small {
font-size: 12px;
font-weight: 400;
margin-left: 2px;
color: #909399;
}
.value.temp { color: #f56c6c; }
.value.hum { color: #409eff; }
.device-card__foot {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #909399;
}
.alarm-text {
color: #f56c6c;
}
</style>
+8 -1
View File
@@ -11,6 +11,13 @@ export default defineConfig({
},
server: {
port: 3000,
open: true
open: true,
// 开发环境代理:/api 请求转发到后端服务,避免跨域
proxy: {
'/api': {
target: 'http://localhost:5287',
changeOrigin: true
}
}
}
})