<template>
|
<div class="environment-monitoring-page">
|
<section class="chart-panel">
|
<h3 class="panel-title">环境实时柱状图</h3>
|
<Echarts
|
:series="barSeries"
|
:x-axis="xAxis"
|
:y-axis="yAxis"
|
:tooltip="tooltip"
|
:grid="grid"
|
:legend="legend"
|
:options="chartTheme"
|
:chart-style="chartStyle"
|
/>
|
</section>
|
|
<section class="table-panel">
|
<h3 class="panel-title">设备环境数据列表</h3>
|
<el-table :data="deviceRows"
|
border
|
stripe
|
empty-text="暂无环境数据"
|
show-overflow-tooltip>
|
<el-table-column label="设备编号" prop="guid" min-width="160" show-overflow-tooltip />
|
<el-table-column label="设备名称" prop="name" min-width="120" show-overflow-tooltip />
|
<el-table-column label="状态" prop="statusLabel" width="80" align="center">
|
<template #default="{ row }">
|
<el-tag v-if="row.statusLabel !== '-'"
|
:type="row.statusTagType"
|
effect="light"
|
size="small">
|
{{ row.statusLabel }}
|
</el-tag>
|
<span v-else>-</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="电量" prop="battery" width="80" align="center" />
|
<el-table-column label="温度" prop="temperature" width="90" align="center" />
|
<el-table-column label="湿度" prop="humidity" width="90" align="center" />
|
<el-table-column label="二氧化碳" prop="co2" width="100" align="center" />
|
<el-table-column label="光照" prop="light" width="90" align="center" />
|
<el-table-column label="存放位置" prop="storageLocation" min-width="120" show-overflow-tooltip />
|
<el-table-column label="附件" width="100" align="center">
|
<template #default="{ row }">
|
<el-button v-if="row.commonFileListVO && row.commonFileListVO.length"
|
type="primary"
|
link
|
@click="openFileDialog(row)">
|
查看附件
|
</el-button>
|
<span v-else>-</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="操作" width="130" align="center" fixed="right">
|
<template #default="{ row }">
|
<el-button type="primary" link @click="openHistoryDialog(row)">
|
查看历史数据
|
</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</section>
|
|
<el-dialog
|
v-model="historyDialogVisible"
|
title="查看历史数据"
|
width="900px"
|
append-to-body
|
destroy-on-close
|
>
|
<div class="history-toolbar">
|
<div class="history-toolbar__meta">
|
<span>设备编号:{{ historyDevice.guid || "-" }}</span>
|
<span>设备名称:{{ historyDevice.name || "-" }}</span>
|
</div>
|
<div class="history-toolbar__filter">
|
<el-date-picker
|
v-model="historyDate"
|
type="date"
|
value-format="YYYY-MM-DD"
|
placeholder="请选择日期"
|
:clearable="false"
|
:disabled-date="disabledHistoryDate"
|
@change="handleHistoryDateChange"
|
/>
|
<el-button type="primary" :loading="historyLoading" @click="fetchHistoryData">
|
查询
|
</el-button>
|
</div>
|
</div>
|
|
<el-table :data="historyRows"
|
v-loading="historyLoading"
|
border
|
stripe
|
empty-text="暂无历史数据"
|
show-overflow-tooltip>
|
<el-table-column label="时间" prop="time" min-width="160" show-overflow-tooltip />
|
<el-table-column label="温度" prop="temperature" width="100" align="center" />
|
<el-table-column label="湿度" prop="humidity" width="100" align="center" />
|
<el-table-column label="二氧化碳" prop="co2" width="100" align="center" />
|
<el-table-column label="光照" prop="light" width="100" align="center" />
|
</el-table>
|
</el-dialog>
|
|
<el-dialog
|
v-model="fileDialogVisible"
|
title="查看附件"
|
width="600px"
|
append-to-body
|
destroy-on-close
|
>
|
<div class="file-dialog__meta">
|
<span>设备编号:{{ fileDevice.guid || "-" }}</span>
|
<span>设备名称:{{ fileDevice.name || "-" }}</span>
|
</div>
|
<el-table :data="fileList"
|
border
|
stripe
|
empty-text="暂无附件"
|
show-overflow-tooltip>
|
<el-table-column label="附件名称" prop="originalFilename" min-width="200" show-overflow-tooltip />
|
<el-table-column label="操作" width="120" align="center">
|
<template #default="{ row }">
|
<el-button type="primary" link @click="downloadAttachment(row)">
|
下载
|
</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, ref } from "vue";
|
import Echarts from "@/components/Echarts/echarts.vue";
|
import {
|
getEnvironmentalHistoryData,
|
getEnvironmentalRealData,
|
} from "@/api/inventoryManagement/environmentalMonitoring";
|
|
const { proxy } = getCurrentInstance();
|
const POLL_INTERVAL = import.meta.env.DEV ? 73000 : 30000;
|
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000;
|
|
const latestDevices = ref([]);
|
const historyDialogVisible = ref(false);
|
const historyDevice = ref({});
|
const historyDate = ref(formatDateOnly(new Date()));
|
const historyList = ref([]);
|
const historyLoading = ref(false);
|
const fileDialogVisible = ref(false);
|
const fileDevice = ref({});
|
const fileList = ref([]);
|
|
let pollTimer = null;
|
|
const metricConfig = [
|
{ key: "temperature", label: "温度", color: "#ff7a59", unit: "℃" },
|
{ key: "humidity", label: "湿度", color: "#1ea7fd", unit: "%RH" },
|
{ key: "co2", label: "二氧化碳", color: "#12c48b", unit: "ppm" },
|
{ key: "light", label: "光照", color: "#8b5cf6", unit: "Lux" },
|
];
|
|
const chartTheme = {
|
backgroundColor: "transparent",
|
textStyle: { color: "#6c7c96" },
|
};
|
|
const chartStyle = {
|
width: "100%",
|
height: "360px",
|
};
|
|
const grid = {
|
left: "4%",
|
right: "4%",
|
top: "16%",
|
bottom: "10%",
|
containLabel: true,
|
};
|
|
const tooltip = {
|
trigger: "axis",
|
axisPointer: { type: "shadow" },
|
backgroundColor: "rgba(12, 20, 34, 0.88)",
|
borderColor: "rgba(126, 164, 255, 0.18)",
|
textStyle: { color: "#e8edf7" },
|
};
|
|
const legend = {
|
top: 0,
|
right: 0,
|
textStyle: { color: "#6c7c96" },
|
};
|
|
function pad2(value) {
|
return String(value).padStart(2, "0");
|
}
|
|
function formatDateOnly(date) {
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
}
|
|
function buildServerDayTimestamp(dateString, endOfDay = false) {
|
const [year, month, day] = String(dateString || "").split("-").map(Number);
|
if (!year || !month || !day) {
|
return NaN;
|
}
|
|
const hour = endOfDay ? 23 : 0;
|
const minute = endOfDay ? 59 : 0;
|
const second = endOfDay ? 59 : 0;
|
const shanghaiOffsetHours = 8;
|
|
return Math.floor(
|
Date.UTC(year, month - 1, day, hour - shanghaiOffsetHours, minute, second) / 1000
|
);
|
}
|
|
function extractNumericValue(rawValue) {
|
const matched = String(rawValue ?? "").match(/-?\d+(\.\d+)?/);
|
return matched ? Number(matched[0]) : 0;
|
}
|
|
function formatMetricValue(value, unit) {
|
return `${Number(value || 0).toFixed(2)}${unit}`;
|
}
|
|
function resolveMetricKey(key, rawText) {
|
if (rawText.includes("℃")) {
|
return "temperature";
|
}
|
if (rawText.includes("%RH")) {
|
return "humidity";
|
}
|
if (rawText.includes("ppm")) {
|
return "co2";
|
}
|
if (rawText.includes("Lux")) {
|
return "light";
|
}
|
if (["temperature", "humidity", "co2", "light"].includes(key)) {
|
return key;
|
}
|
return "";
|
}
|
|
function normalizeMetricObject(source, index) {
|
const normalized = {
|
guid: source.guid || source.deviceGuid || source.deviceNo || `GUID-${index + 1}`,
|
name: source.deviceName || source.name || `设备${index + 1}`,
|
status: source.status || source.deviceStatus || "",
|
battery: source.battery ?? source.deviceBattery ?? "",
|
storageLocation: source.storageLocation || source.location || "",
|
commonFileListVO: source.commonFileListVO || [],
|
temperature: 0,
|
humidity: 0,
|
co2: 0,
|
light: 0,
|
};
|
|
Object.entries(source || {}).forEach(([key, value]) => {
|
const metricKey = resolveMetricKey(key, String(value ?? ""));
|
if (metricKey) {
|
normalized[metricKey] = extractNumericValue(value);
|
}
|
});
|
|
return normalized;
|
}
|
|
function formatStatusValue(value) {
|
if (value === "offline") {
|
return "离线";
|
}
|
if (value === "error") {
|
return "异常";
|
}
|
return value || "-";
|
}
|
|
function resolveStatusTagType(value) {
|
if (value === "offline") {
|
return "info";
|
}
|
if (value === "error") {
|
return "danger";
|
}
|
if (value) {
|
return "success";
|
}
|
return "";
|
}
|
|
function formatBatteryValue(value) {
|
if (value === "" || value === null || value === undefined) {
|
return "-";
|
}
|
|
const numericValue = Number(value);
|
if (Number.isFinite(numericValue)) {
|
return `${numericValue}%`;
|
}
|
|
return String(value);
|
}
|
|
function resolveHistoryTimeLabel(source, index) {
|
return (
|
source.collectTime ||
|
source.collectionTime ||
|
source.time ||
|
source.createTime ||
|
source.recordTime ||
|
source.ts ||
|
`第${index + 1}条`
|
);
|
}
|
|
function normalizeHistoryObject(source, index) {
|
const normalized = {
|
index,
|
time: resolveHistoryTimeLabel(source, index),
|
temperature: 0,
|
humidity: 0,
|
co2: 0,
|
light: 0,
|
};
|
|
Object.entries(source || {}).forEach(([key, value]) => {
|
const metricKey = resolveMetricKey(key, String(value ?? ""));
|
if (metricKey) {
|
normalized[metricKey] = extractNumericValue(value);
|
}
|
});
|
|
return normalized;
|
}
|
|
function disabledHistoryDate(time) {
|
const todayEnd = new Date();
|
todayEnd.setHours(23, 59, 59, 999);
|
|
const minDate = new Date(todayEnd.getTime() - (TEN_DAYS_MS - 1));
|
minDate.setHours(0, 0, 0, 0);
|
|
return time.getTime() > todayEnd.getTime() || time.getTime() < minDate.getTime();
|
}
|
|
const xAxis = computed(() => [
|
{
|
type: "category",
|
data: latestDevices.value.map((item) => item.name),
|
axisLine: { lineStyle: { color: "rgba(79, 110, 148, 0.55)" } },
|
axisLabel: { color: "#6c7c96", rotate: 0 },
|
axisTick: { show: false },
|
},
|
]);
|
|
const yAxis = [
|
{
|
type: "value",
|
axisLine: { show: false },
|
axisTick: { show: false },
|
splitLine: { lineStyle: { color: "rgba(110, 131, 160, 0.12)" } },
|
axisLabel: { color: "#6c7c96" },
|
},
|
];
|
|
const barSeries = computed(() =>
|
metricConfig.map((item) => ({
|
name: item.label,
|
type: "bar",
|
barMaxWidth: 28,
|
itemStyle: {
|
color: item.color,
|
borderRadius: [8, 8, 0, 0],
|
},
|
data: latestDevices.value.map((device) => Number(device[item.key] || 0)),
|
}))
|
);
|
|
const deviceRows = computed(() =>
|
latestDevices.value.map((item) => ({
|
guid: item.guid,
|
name: item.name,
|
statusLabel: formatStatusValue(item.status),
|
statusTagType: resolveStatusTagType(item.status),
|
battery: formatBatteryValue(item.battery),
|
temperature: formatMetricValue(item.temperature, "℃"),
|
humidity: formatMetricValue(item.humidity, "%RH"),
|
co2: formatMetricValue(item.co2, "ppm"),
|
light: formatMetricValue(item.light, "Lux"),
|
storageLocation: item.storageLocation || "-",
|
commonFileListVO: item.commonFileListVO || [],
|
}))
|
);
|
|
const historyRows = computed(() =>
|
historyList.value.map((item) => ({
|
index: item.index,
|
time: item.time,
|
temperature: formatMetricValue(item.temperature, "℃"),
|
humidity: formatMetricValue(item.humidity, "%RH"),
|
co2: formatMetricValue(item.co2, "ppm"),
|
light: formatMetricValue(item.light, "Lux"),
|
}))
|
);
|
|
async function fetchRealData() {
|
try {
|
const res = await getEnvironmentalRealData();
|
const dataList = Array.isArray(res?.data) ? res.data : [];
|
latestDevices.value = dataList.map((item, index) => normalizeMetricObject(item, index));
|
} catch {
|
latestDevices.value = [];
|
}
|
}
|
|
async function fetchHistoryData() {
|
if (!historyDevice.value.guid || !historyDate.value) {
|
historyList.value = [];
|
return;
|
}
|
|
const startTime = buildServerDayTimestamp(historyDate.value, false);
|
const endTime = buildServerDayTimestamp(historyDate.value, true);
|
|
if (Number.isNaN(startTime) || Number.isNaN(endTime)) {
|
historyList.value = [];
|
return;
|
}
|
|
historyLoading.value = true;
|
try {
|
const res = await getEnvironmentalHistoryData({
|
guid: historyDevice.value.guid,
|
startTime,
|
endTime,
|
});
|
const dataList = Array.isArray(res?.data) ? res.data : [];
|
historyList.value = dataList.map((item, index) => normalizeHistoryObject(item, index));
|
} catch {
|
historyList.value = [];
|
} finally {
|
historyLoading.value = false;
|
}
|
}
|
|
function handleHistoryDateChange() {
|
fetchHistoryData();
|
}
|
|
function openFileDialog(row) {
|
fileDevice.value = row;
|
fileList.value = row.commonFileListVO || [];
|
fileDialogVisible.value = true;
|
}
|
|
function downloadAttachment(file) {
|
if (!file.url) {
|
proxy.$modal.msgWarning("附件链接不存在");
|
return;
|
}
|
proxy.$download.byUrl(file.url, file.originalFilename || file.fileName);
|
}
|
|
function openHistoryDialog(row) {
|
historyDevice.value = { ...row };
|
historyDate.value = formatDateOnly(new Date());
|
historyDialogVisible.value = true;
|
fetchHistoryData();
|
}
|
|
onMounted(() => {
|
fetchRealData();
|
pollTimer = window.setInterval(fetchRealData, POLL_INTERVAL);
|
});
|
|
onBeforeUnmount(() => {
|
if (pollTimer) {
|
window.clearInterval(pollTimer);
|
pollTimer = null;
|
}
|
});
|
</script>
|
|
<style scoped lang="scss">
|
.environment-monitoring-page {
|
min-height: 100%;
|
padding: 20px;
|
}
|
|
.chart-panel,
|
.table-panel {
|
padding: 20px;
|
border-radius: 16px;
|
background: #fff;
|
}
|
|
.table-panel {
|
margin-top: 20px;
|
}
|
|
.panel-title {
|
margin: 0 0 16px;
|
color: #1d344f;
|
font-size: 18px;
|
font-weight: 600;
|
}
|
|
.history-toolbar {
|
display: flex;
|
justify-content: space-between;
|
gap: 16px;
|
align-items: center;
|
margin-bottom: 16px;
|
}
|
|
.history-toolbar__meta {
|
display: flex;
|
gap: 20px;
|
color: #1d344f;
|
flex-wrap: wrap;
|
}
|
|
.history-toolbar__filter {
|
display: flex;
|
gap: 12px;
|
align-items: center;
|
}
|
|
.file-dialog__meta {
|
display: flex;
|
gap: 20px;
|
color: #1d344f;
|
margin-bottom: 16px;
|
flex-wrap: wrap;
|
}
|
|
@media (max-width: 768px) {
|
.environment-monitoring-page {
|
padding: 12px;
|
}
|
|
.chart-panel,
|
.table-panel {
|
padding: 16px;
|
}
|
|
.history-toolbar {
|
display: block;
|
}
|
|
.history-toolbar__meta,
|
.history-toolbar__filter {
|
margin-bottom: 12px;
|
}
|
}
|
</style>
|