src/views/inventoryManagement/environmentalMonitoring/index.vue
@@ -20,40 +20,117 @@
        <div class="sensor-table__head">
          <span>设备编号</span>
          <span>设备名称</span>
          <span>状态</span>
          <span>电量</span>
          <span>温度</span>
          <span>湿度</span>
          <span>二氧化碳</span>
          <span>光照</span>
          <span>操作</span>
        </div>
        <div v-for="item in deviceRows" :key="item.guid" class="sensor-table__row">
          <span>{{ item.guid }}</span>
          <span>{{ item.name }}</span>
          <span>
            <el-tag
              v-if="item.statusLabel !== '-'"
              :type="item.statusTagType"
              effect="light"
              size="small"
            >
              {{ item.statusLabel }}
            </el-tag>
            <span v-else>{{ item.statusLabel }}</span>
          </span>
          <span>{{ item.battery }}</span>
          <span>{{ item.temperature }}</span>
          <span>{{ item.humidity }}</span>
          <span>{{ item.co2 }}</span>
          <span>{{ item.light }}</span>
          <span class="sensor-table__action">
            <el-button type="primary" link @click="openHistoryDialog(item)">
              查看历史数据
            </el-button>
          </span>
        </div>
        <div v-if="!deviceRows.length" class="sensor-table__empty">暂无环境数据</div>
      </div>
    </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>
      <div class="history-table">
        <div class="history-table__head">
          <span>时间</span>
          <span>温度</span>
          <span>湿度</span>
          <span>二氧化碳</span>
          <span>光照</span>
        </div>
        <div v-for="item in deviceRows" :key="item.guid" class="sensor-table__row">
          <span>{{ item.guid }}</span>
          <span>{{ item.name }}</span>
        <div v-for="item in historyRows" :key="`${item.time}-${item.index}`" class="history-table__row">
          <span>{{ item.time }}</span>
          <span>{{ item.temperature }}</span>
          <span>{{ item.humidity }}</span>
          <span>{{ item.co2 }}</span>
          <span>{{ item.light }}</span>
        </div>
        <div v-if="!deviceRows.length" class="sensor-table__empty">暂无环境数据</div>
        <div v-if="!historyRows.length" class="sensor-table__empty">暂无历史数据</div>
      </div>
    </section>
    </el-dialog>
  </div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import Echarts from "@/components/Echarts/echarts.vue";
import { getEnvironmentalRealData } from "@/api/inventoryManagement/environmentalMonitoring";
import {
  getEnvironmentalHistoryData,
  getEnvironmentalRealData,
} from "@/api/inventoryManagement/environmentalMonitoring";
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);
let pollTimer = null;
const metricConfig = [
  { key: "temperature", label: "温度", color: "#ff7a59" },
  { key: "humidity", label: "湿度", color: "#1ea7fd" },
  { key: "co2", label: "二氧化碳", color: "#12c48b" },
  { key: "light", label: "光照", color: "#8b5cf6" },
  { 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 = {
@@ -88,15 +165,64 @@
  textStyle: { color: "#6c7c96" },
};
const extractNumericValue = (rawValue) => {
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;
};
}
const normalizeMetricObject = (source, index) => {
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 ?? "",
    temperature: 0,
    humidity: 0,
    co2: 0,
@@ -104,38 +230,92 @@
  };
  Object.entries(source || {}).forEach(([key, value]) => {
    const rawText = String(value ?? "");
    if (rawText.includes("℃")) {
      normalized.temperature = extractNumericValue(rawText);
      return;
    }
    if (rawText.includes("%RH")) {
      normalized.humidity = extractNumericValue(rawText);
      return;
    }
    if (rawText.includes("ppm")) {
      normalized.co2 = extractNumericValue(rawText);
      return;
    }
    if (rawText.includes("Lux")) {
      normalized.light = extractNumericValue(rawText);
      return;
    }
    if (key === "temperature") {
      normalized.temperature = extractNumericValue(rawText);
    } else if (key === "humidity") {
      normalized.humidity = extractNumericValue(rawText);
    } else if (key === "co2") {
      normalized.co2 = extractNumericValue(rawText);
    } else if (key === "light") {
      normalized.light = extractNumericValue(rawText);
    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(() => [
  {
@@ -174,22 +354,77 @@
  latestDevices.value.map((item) => ({
    guid: item.guid,
    name: item.name,
    temperature: `${Number(item.temperature || 0).toFixed(2)}℃`,
    humidity: `${Number(item.humidity || 0).toFixed(2)}%RH`,
    co2: `${Number(item.co2 || 0).toFixed(2)}ppm`,
    light: `${Number(item.light || 0).toFixed(2)}Lux`,
    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"),
  }))
);
const fetchRealData = async () => {
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 (error) {
  } 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 openHistoryDialog(row) {
  historyDevice.value = { ...row };
  historyDate.value = formatDateOnly(new Date());
  historyDialogVisible.value = true;
  fetchHistoryData();
}
onMounted(() => {
  fetchRealData();
@@ -228,7 +463,8 @@
  font-weight: 600;
}
.sensor-table {
.sensor-table,
.history-table {
  display: flex;
  flex-direction: column;
  gap: 10px;
@@ -237,28 +473,68 @@
.sensor-table__head,
.sensor-table__row {
  display: grid;
  grid-template-columns: 1.2fr 1fr 1fr 1fr 1fr 1fr;
  grid-template-columns: 1.2fr 1fr 0.8fr 0.8fr 0.9fr 0.9fr 1fr 0.9fr 0.9fr;
  gap: 12px;
  align-items: center;
}
.sensor-table__head {
.history-table__head,
.history-table__row {
  display: grid;
  grid-template-columns: 1.3fr 1fr 1fr 1fr 1fr;
  gap: 12px;
  align-items: center;
}
.sensor-table__head,
.history-table__head {
  padding: 0 6px 10px;
  color: #8393a8;
  font-size: 13px;
}
.sensor-table__row {
.sensor-table__row,
.history-table__row {
  padding: 14px 16px;
  border-radius: 12px;
  background: #f6f9fc;
  color: #1d344f;
}
.sensor-table__action {
  display: flex;
  align-items: center;
}
.sensor-table__empty {
  padding: 32px 0;
  color: #8393a8;
  text-align: center;
}
.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;
}
.history-table {
  margin-top: 16px;
}
@media (max-width: 768px) {
@@ -272,8 +548,19 @@
  }
  .sensor-table__head,
  .sensor-table__row {
  .sensor-table__row,
  .history-table__head,
  .history-table__row {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .history-toolbar {
    display: block;
  }
  .history-toolbar__meta,
  .history-toolbar__filter {
    margin-bottom: 12px;
  }
}
</style>