Browse Source

能耗监测、数据概览功能模块完成

fanghuisheng 5 hours ago
parent
commit
b64fb3d611
26 changed files with 4078 additions and 2 deletions
  1. 89 0
      src/api/business/ems/overview.js
  2. 56 0
      src/api/business/ems/survey.js
  3. 223 0
      src/components/oa-multi-picker/index.vue
  4. 1 1
      src/components/oa-scroll/index.vue
  5. 183 0
      src/components/oa-switch/index.vue
  6. 2 0
      src/main.js
  7. 42 0
      src/pages.json
  8. 103 0
      src/pages/business/ems/overview/components/buildingRankChart.vue
  9. 83 0
      src/pages/business/ems/overview/components/categoryPieChart.vue
  10. 134 0
      src/pages/business/ems/overview/components/deviceBarChart.vue
  11. 90 0
      src/pages/business/ems/overview/components/itemPieChart.vue
  12. 242 0
      src/pages/business/ems/overview/components/kpiGrid.vue
  13. 159 0
      src/pages/business/ems/overview/components/projectIntro.vue
  14. 201 0
      src/pages/business/ems/overview/components/trendChartFullscreen.vue
  15. 169 0
      src/pages/business/ems/overview/components/trendLineChart.vue
  16. 96 0
      src/pages/business/ems/overview/components/unitGaugeChart.vue
  17. 467 0
      src/pages/business/ems/overview/index.vue
  18. 203 0
      src/pages/business/ems/survey/components/chartFullscreen.vue
  19. 172 0
      src/pages/business/ems/survey/components/deviceInfo.vue
  20. 217 0
      src/pages/business/ems/survey/components/historyChart.vue
  21. 277 0
      src/pages/business/ems/survey/components/metricChartTab.vue
  22. 165 0
      src/pages/business/ems/survey/components/realtimeData.vue
  23. 214 0
      src/pages/business/ems/survey/components/usageChart.vue
  24. 151 0
      src/pages/business/ems/survey/details.vue
  25. 328 0
      src/pages/business/ems/survey/index.vue
  26. 11 1
      src/plugins/time.plugins.js

+ 89 - 0
src/api/business/ems/overview.js

@@ -0,0 +1,89 @@
+import { request } from "@/utils/request";
+
+/**
+ * 项目概览信息
+ */
+export function getEmsProjectOverview(params) {
+  return request({
+    url: "/service-ems/overview/project",
+    method: "GET",
+    params,
+  });
+}
+
+/**
+ * 能源类型条目列表
+ */
+export function getEmsOverviewItem(params) {
+  return request({
+    url: "/service-ems/overview/item",
+    method: "GET",
+    params,
+  });
+}
+
+/**
+ * 能耗(用量、等值树、标准煤、碳排放)
+ */
+export function getEmsClassificationEnergy(params) {
+  return request({
+    url: "/service-ems/overview/classification-energy",
+    method: "GET",
+    params,
+  });
+}
+
+/**
+ * 综合能耗(综合统计、碳减排、单位指标、分类占比)
+ */
+export function getEmsOverviewTop(params) {
+  return request({
+    url: "/service-ems/overview/top",
+    method: "GET",
+    params,
+  });
+}
+
+/**
+ * 分项能耗占比
+ */
+export function getEmsItemRatio(params) {
+  return request({
+    url: "/service-ems/overview/item-ratio",
+    method: "GET",
+    params,
+  });
+}
+
+/**
+ * 建筑能耗排名
+ */
+export function getEmsBuildingRanking(params) {
+  return request({
+    url: "/service-ems/overview/building-ranking",
+    method: "GET",
+    params,
+  });
+}
+
+/**
+ * 能耗趋势
+ */
+export function getEmsEnergyTrend(params) {
+  return request({
+    url: "/service-ems/overview/energy-trend",
+    method: "GET",
+    params,
+  });
+}
+
+/**
+ * 设备产品列表(设备信息统计)
+ */
+export function getEmsProductPage(param) {
+  return request({
+    url: "/service-iot/dmpProductInfo/page",
+    method: "POST",
+    data: param,
+  });
+}

+ 56 - 0
src/api/business/ems/survey.js

@@ -0,0 +1,56 @@
+import { request } from "@/utils/request";
+
+/**
+ * 产品信息分页
+ */
+export function getEmsProductPage(param) {
+  return request({
+    url: "/service-iot/dmpProductInfo/page",
+    method: "POST",
+    data: param,
+  });
+}
+
+/**
+ * 设备分页列表
+ */
+export function getEmsDevicePage(param) {
+  return request({
+    url: "/service-iot/dmpDeviceInfo/page",
+    method: "POST",
+    data: param,
+  });
+}
+
+/**
+ * 设备属性列表
+ */
+export function getEmsProductAttribute(param) {
+  return request({
+    url: "/service-iot/dmpProductAttribute/page",
+    method: "POST",
+    data: param,
+  });
+}
+
+/**
+ * 设备实时数据
+ */
+export function getEmsLastMetrics(param) {
+  return request({
+    url: "/service-tsdb/dataQuery/last",
+    method: "POST",
+    data: param,
+  });
+}
+
+/**
+ * 设备历史数据
+ */
+export function getEmsHistoryMetrics(param) {
+  return request({
+    url: "/service-tsdb/dataQuery/history",
+    method: "POST",
+    data: param,
+  });
+}

+ 223 - 0
src/components/oa-multi-picker/index.vue

@@ -0,0 +1,223 @@
+<template>
+  <u-popup :show="show" mode="bottom" :closeOnClickOverlay="closeOnClickOverlay" @close="handleClose">
+    <view class="oa-multi-picker">
+      <u-toolbar
+        :title="title"
+        :cancelText="cancelText"
+        :confirmText="confirmText"
+        :cancelColor="cancelColor"
+        :confirmColor="confirmColor"
+        @cancel="handleCancel"
+        @confirm="handleConfirm"
+      />
+      <scroll-view scroll-y class="oa-multi-picker__scroll" :style="scrollStyle">
+        <view
+          v-for="item in options"
+          :key="getItemValue(item)"
+          class="oa-multi-picker__item u-line-1"
+          :style="{ height: itemHeightPx, lineHeight: itemHeightPx }"
+          @click="toggleItem(item)"
+        >
+          <view class="oa-multi-picker__label">
+            <text
+              class="oa-multi-picker__text"
+              :class="{ 'oa-multi-picker__text--selected': isSelected(item) }"
+              :style="isSelected(item) ? { color: confirmColor } : {}"
+            >{{ getItemText(item) }}</text>
+            <view v-if="isSelected(item)" class="oa-multi-picker__check" :style="{ borderColor: confirmColor }"></view>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+  </u-popup>
+</template>
+
+<script setup>
+import { computed, ref, watch } from "vue";
+
+const emit = defineEmits(["close", "cancel", "confirm", "update:modelValue"]);
+
+const props = defineProps({
+  show: {
+    type: Boolean,
+    default: false,
+  },
+  columns: {
+    type: Array,
+    default: () => [],
+  },
+  modelValue: {
+    type: Array,
+    default: () => [],
+  },
+  title: {
+    type: String,
+    default: "请选择",
+  },
+  keyName: {
+    type: String,
+    default: "text",
+  },
+  valueName: {
+    type: String,
+    default: "",
+  },
+  cancelText: {
+    type: String,
+    default: "取消",
+  },
+  confirmText: {
+    type: String,
+    default: "确定",
+  },
+  cancelColor: {
+    type: String,
+    default: "#909193",
+  },
+  confirmColor: {
+    type: String,
+    default: "#3c9cff",
+  },
+  itemHeight: {
+    type: [String, Number],
+    default: 44,
+  },
+  visibleItemCount: {
+    type: [String, Number],
+    default: 6,
+  },
+  closeOnClickOverlay: {
+    type: Boolean,
+    default: true,
+  },
+  minSelect: {
+    type: Number,
+    default: 1,
+  },
+});
+
+const tempValues = ref([]);
+
+const options = computed(() => props.columns[0] || []);
+
+const itemHeightPx = computed(() => `${Number(props.itemHeight)}px`);
+
+const scrollStyle = computed(() => ({
+  maxHeight: `${Number(props.visibleItemCount) * Number(props.itemHeight)}px`,
+}));
+
+function getItemValue(item) {
+  if (item && typeof item === "object") {
+    const valueKey = props.valueName || props.keyName;
+    return item[valueKey];
+  }
+  return item;
+}
+
+function getItemText(item) {
+  if (item && typeof item === "object") {
+    return item[props.keyName];
+  }
+  return item;
+}
+
+function isSelected(item) {
+  return tempValues.value.includes(getItemValue(item));
+}
+
+function toggleItem(item) {
+  const value = getItemValue(item);
+  const index = tempValues.value.indexOf(value);
+  if (index > -1) {
+    tempValues.value.splice(index, 1);
+    return;
+  }
+  tempValues.value.push(value);
+}
+
+function getSelectedItems() {
+  return options.value.filter((item) => tempValues.value.includes(getItemValue(item)));
+}
+
+function handleClose() {
+  if (!props.closeOnClickOverlay) return;
+  emit("close");
+}
+
+function handleCancel() {
+  emit("cancel");
+}
+
+function handleConfirm() {
+  if (tempValues.value.length < props.minSelect) {
+    uni.showToast({
+      title: props.minSelect > 1 ? `请至少选择${props.minSelect}项` : "请至少选择一项",
+      icon: "none",
+    });
+    return;
+  }
+
+  const selectedItems = getSelectedItems();
+  emit("update:modelValue", [...tempValues.value]);
+  emit("confirm", {
+    value: selectedItems,
+    values: props.columns,
+  });
+}
+
+watch(
+  () => props.show,
+  (visible) => {
+    if (visible) {
+      tempValues.value = [...props.modelValue];
+    }
+  }
+);
+</script>
+
+<style lang="scss" scoped>
+.oa-multi-picker {
+  background-color: #fff;
+
+  &__scroll {
+    width: 100%;
+  }
+
+  &__item {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 0 20px;
+    text-align: center;
+  }
+
+  &__label {
+    display: inline-flex;
+    align-items: center;
+    max-width: 100%;
+  }
+
+  &__text {
+    font-size: 16px;
+    color: #303133;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    &--selected {
+      font-weight: 500;
+    }
+  }
+
+  &__check {
+    flex-shrink: 0;
+    width: 5px;
+    height: 10px;
+    margin-left: 8px;
+    margin-bottom: 2px;
+    border-style: solid;
+    border-width: 0 2px 2px 0;
+    transform: rotate(45deg);
+  }
+}
+</style>

+ 1 - 1
src/components/oa-scroll/index.vue

@@ -40,7 +40,7 @@
     </slot>
     <slot name="default"> </slot>
     <slot name="bottomLoading">
-      <div class="bottoBox">
+      <div class="bottoBox" :style="{ backgroundColor: refresherBackground }">
         <span
           v-show="total != 0 && refresherLoad && refresherLoadTitle"
           :style="{

+ 183 - 0
src/components/oa-switch/index.vue

@@ -0,0 +1,183 @@
+<template>
+  <view class="oa-switch flex plr10" :style="containerStyle">
+    <view
+      class="oa-switch__item flex mtb-auto ptb10 nav"
+      v-for="(item, index) in items"
+      :key="item.key ?? index"
+      @click="handleItemClick(item, index)"
+      style="overflow-x: auto"
+    >
+      <view
+        class="oa-switch__chip radius p5 mr10"
+        :style="{
+          color: item.active ? themeColor : disabledColor,
+          backgroundColor: `${item.active ? themeColor : disabledColor}30`,
+        }"
+      >
+        <span class="flex">
+          <span class="mr5">{{ item.label }}</span>
+          <u-icon name="arrow-down" :color="disabledColor" size="12"></u-icon>
+        </span>
+      </view>
+    </view>
+    <view
+      v-if="showReset"
+      class="oa-switch__reset ml-auto mtb-auto pl10 nav"
+      @click="handleReset"
+      :style="{ color: themeColor }"
+    >
+      {{ resetText }}
+    </view>
+  </view>
+
+  <u-picker
+    :show="picker.show"
+    :columns="picker.columns"
+    :keyName="picker.keyName"
+    :title="picker.title"
+    :visibleItemCount="picker.visibleItemCount"
+    :closeOnClickOverlay="true"
+    @close="closePicker"
+    @cancel="closePicker"
+    @confirm="confirmPicker"
+  ></u-picker>
+
+  <u-action-sheet
+    :actions="sheet.actions"
+    :show="sheet.show"
+    cancelText="取消"
+    :round="10"
+    :wrapMaxHeight="'50vh'"
+    :closeOnClickOverlay="true"
+    :safeAreaInsetBottom="true"
+    @close="closeSheet"
+    @select="confirmSheet"
+  ></u-action-sheet>
+</template>
+
+<script setup>
+import { computed, getCurrentInstance, reactive, toRefs } from "vue";
+
+const { proxy } = getCurrentInstance();
+const emit = defineEmits(["click", "change", "reset"]);
+
+const props = defineProps({
+  /** 筛选项:label/active 控制展示,type 控制弹层类型,options 为选项数据 */
+  items: {
+    type: Array,
+    default: () => [],
+  },
+  showReset: {
+    type: Boolean,
+    default: true,
+  },
+  resetText: {
+    type: String,
+    default: "重置",
+  },
+  activeColor: {
+    type: String,
+    default: "",
+  },
+  disabledColor: {
+    type: String,
+    default: "#999999",
+  },
+  minHeight: {
+    type: String,
+    default: "50px",
+  },
+});
+
+const { items, showReset, resetText, activeColor, disabledColor, minHeight } = toRefs(props);
+
+const themeColor = computed(() => activeColor.value || proxy.$settingStore.themeColor.color);
+
+const containerStyle = computed(() => ({
+  minHeight: minHeight.value,
+  lineHeight: "20px",
+}));
+
+const picker = reactive({
+  show: false,
+  columns: [],
+  keyName: "name",
+  title: "请选择",
+  visibleItemCount: "6",
+  currentItem: null,
+});
+
+const sheet = reactive({
+  show: false,
+  actions: [],
+  currentItem: null,
+});
+
+function handleItemClick(item, index) {
+  if (item.type === "picker" && item.options?.length) {
+    picker.currentItem = item;
+    picker.columns = [item.options];
+    picker.keyName = item.pickerKeyName || "name";
+    picker.title = item.pickerTitle || "请选择";
+    picker.visibleItemCount = item.pickerVisibleItemCount || "6";
+    picker.show = true;
+    return;
+  }
+
+  if (item.type === "sheet" && item.options?.length) {
+    sheet.currentItem = item;
+    sheet.actions = item.options;
+    sheet.show = true;
+    return;
+  }
+
+  emit("click", item, index);
+}
+
+function closePicker() {
+  picker.show = false;
+  picker.currentItem = null;
+}
+
+function confirmPicker(e) {
+  const item = picker.currentItem;
+  closePicker();
+  if (!item) return;
+  emit("change", {
+    key: item.key,
+    item,
+    type: "picker",
+    value: e.value[0],
+    raw: e,
+  });
+}
+
+function closeSheet() {
+  sheet.show = false;
+  sheet.currentItem = null;
+}
+
+function confirmSheet(e) {
+  const item = sheet.currentItem;
+  closeSheet();
+  if (!item) return;
+  emit("change", {
+    key: item.key,
+    item,
+    type: "sheet",
+    value: e,
+    raw: e,
+  });
+}
+
+function handleReset() {
+  emit("reset");
+}
+</script>
+
+<style lang="scss" scoped>
+.oa-switch {
+  align-items: center;
+  padding-bottom: 4px;
+}
+</style>

+ 2 - 0
src/main.js

@@ -23,6 +23,7 @@ import oaTtsAudio from "@/components/oa-ttsAudio/index"
 import oaWeather from "@/components/oa-weather/index"
 import oaSteps from "@/components/oa-steps/index"
 import oaChatSSEClient from "@/components/oa-chatSSEClient/index"
+import oaSwitch from "@/components/oa-switch/index"
 
 // import hideHead from "./utils/hideHead.js";
 
@@ -46,6 +47,7 @@ export function createApp() {
   app.component('oa-weather', oaWeather)
   app.component('oa-steps', oaSteps)
   app.component('oa-chatSSEClient', oaChatSSEClient)
+  app.component('oa-switch', oaSwitch)
 
 
   // 挂载全局json导出

+ 42 - 0
src/pages.json

@@ -1113,6 +1113,48 @@
                 }
             ]
         },
+        {
+            "name": "能源管理系统",
+            "root": "pages/business/ems/",
+            "pages": [
+                {
+                    "path": "overview/index",
+                    "style": {
+                        "navigationBarTitleText": "能耗概览",
+                        "enablePullDownRefresh": false,
+                        "navigationStyle": "custom",
+                        "app-plus": {
+                            "bounce": "none",
+                            "titleNView": false
+                        }
+                    }
+                },
+                {
+                    "path": "survey/index",
+                    "style": {
+                        "navigationBarTitleText": "设备监控",
+                        "enablePullDownRefresh": false,
+                        "navigationStyle": "custom",
+                        "app-plus": {
+                            "bounce": "none",
+                            "titleNView": false
+                        }
+                    }
+                },
+                {
+                    "path": "survey/details",
+                    "style": {
+                        "navigationBarTitleText": "设备详情",
+                        "enablePullDownRefresh": false,
+                        "navigationStyle": "custom",
+                        "app-plus": {
+                            "bounce": "none",
+                            "titleNView": false
+                        }
+                    }
+                }
+            ]
+        },
         {
             "name": "ai",
             "root": "pages/business/ai/",

+ 103 - 0
src/pages/business/ems/overview/components/buildingRankChart.vue

@@ -0,0 +1,103 @@
+<template>
+  <view class="overview-chart">
+    <l-echart ref="chartRef" class="overview-chart__echart" :style="{ height: chartHeight }"></l-echart>
+  </view>
+</template>
+
+<script setup>
+import * as echarts from "echarts";
+import { ref, watch, nextTick, computed } from "vue";
+
+const props = defineProps({
+  chartData: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const chartRef = ref(null);
+
+const chartHeight = computed(() => {
+  const count = Math.max(props.chartData.length, 1);
+  return `${Math.min(80 + count * 56, 320)}px`;
+});
+
+function buildOption() {
+  const data = props.chartData.length ? props.chartData : [{ name: "A栋", value: 0 }];
+  const names = data.map((item) => item.name);
+  const values = data.map((item) => item.value);
+  const maxValue = Math.max(...values, 1);
+
+  return {
+    grid: {
+      left: "3%",
+      right: "14%",
+      top: "4%",
+      bottom: "4%",
+      containLabel: true,
+    },
+    xAxis: {
+      type: "value",
+      max: maxValue,
+      show: false,
+    },
+    yAxis: {
+      type: "category",
+      data: names,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        color: "#666",
+        fontSize: 11,
+      },
+    },
+    series: [
+      {
+        type: "bar",
+        data: values.map((value) => ({
+          value,
+          itemStyle: {
+            color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
+              { offset: 0, color: "#6eb5ff" },
+              { offset: 1, color: "#4a90e2" },
+            ]),
+            borderRadius: [0, 4, 4, 0],
+          },
+        })),
+        barWidth: 14,
+        label: {
+          show: true,
+          position: "right",
+          color: "#666",
+          fontSize: 11,
+        },
+        showBackground: true,
+        backgroundStyle: {
+          color: "#f0f2f5",
+          borderRadius: [0, 4, 4, 0],
+        },
+      },
+    ],
+  };
+}
+
+function renderChart() {
+  nextTick(() => {
+    if (!chartRef.value) return;
+    chartRef.value.init(echarts, (instance) => {
+      instance.setOption(buildOption(), true);
+    });
+  });
+}
+
+watch(() => props.chartData, renderChart, { deep: true, immediate: true });
+</script>
+
+<style lang="scss" scoped>
+.overview-chart {
+  &__echart {
+    width: 100%;
+    min-height: 140px;
+  }
+}
+</style>

+ 83 - 0
src/pages/business/ems/overview/components/categoryPieChart.vue

@@ -0,0 +1,83 @@
+<template>
+  <view class="overview-chart">
+    <l-echart ref="chartRef" class="overview-chart__echart"></l-echart>
+  </view>
+</template>
+
+<script setup>
+import * as echarts from "echarts";
+import { ref, watch, nextTick } from "vue";
+
+const props = defineProps({
+  chartData: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const chartRef = ref(null);
+
+function buildOption() {
+  const data = props.chartData.length
+    ? props.chartData
+    : [{ name: "电", value: 0, color: "#40b883" }];
+
+  return {
+    color: data.map((item) => item.color),
+    tooltip: {
+      trigger: "item",
+      formatter: "{b}: {c} ({d}%)",
+    },
+    legend: {
+      bottom: 0,
+      left: "center",
+      icon: "circle",
+      itemWidth: 8,
+      itemHeight: 8,
+      textStyle: { fontSize: 11, color: "#666" },
+    },
+    series: [
+      {
+        type: "pie",
+        radius: ["42%", "68%"],
+        center: ["50%", "45%"],
+        avoidLabelOverlap: true,
+        label: {
+          show: true,
+          formatter: "{b}\n{d}%",
+          fontSize: 11,
+          color: "#666",
+        },
+        labelLine: {
+          length: 12,
+          length2: 8,
+        },
+        data: data.map((item) => ({
+          name: item.name,
+          value: item.value,
+        })),
+      },
+    ],
+  };
+}
+
+function renderChart() {
+  nextTick(() => {
+    if (!chartRef.value) return;
+    chartRef.value.init(echarts, (instance) => {
+      instance.setOption(buildOption(), true);
+    });
+  });
+}
+
+watch(() => props.chartData, renderChart, { deep: true, immediate: true });
+</script>
+
+<style lang="scss" scoped>
+.overview-chart {
+  &__echart {
+    width: 100%;
+    height: 480rpx;
+  }
+}
+</style>

+ 134 - 0
src/pages/business/ems/overview/components/deviceBarChart.vue

@@ -0,0 +1,134 @@
+<template>
+  <view class="overview-chart">
+    <l-echart ref="chartRef" class="overview-chart__echart" :style="{ height: chartHeight }"></l-echart>
+  </view>
+</template>
+
+<script setup>
+import * as echarts from "echarts";
+import { ref, watch, nextTick, computed } from "vue";
+
+const props = defineProps({
+  chartData: {
+    type: Array,
+    default: () => [],
+  },
+  colors: {
+    type: Array,
+    default: () => ["#40b883", "#4a90e2", "#6797ed", "#9978fa", "#4ecee2", "#ffbb62", "#ff7d2e"],
+  },
+});
+
+const chartRef = ref(null);
+const ROW_HEIGHT = 44;
+
+const chartHeight = computed(() => {
+  const count = Math.max(props.chartData.length, 1);
+  return `${Math.max(160, count * ROW_HEIGHT + 24)}px`;
+});
+
+function getSortedData() {
+  const data = props.chartData.length ? [...props.chartData] : [{ name: "暂无数据", value: 0 }];
+  return data.sort((a, b) => (Number(a.value) || 0) - (Number(b.value) || 0));
+}
+
+function buildOption() {
+  const sortedData = getSortedData();
+  const names = sortedData.map((item) => item.name);
+  const values = sortedData.map((item) => Number(item.value) || 0);
+  const totalValue = values.reduce((sum, val) => sum + val, 0) || Math.max(...values, 1);
+
+  return {
+    grid: {
+      left: 8,
+      right: 16,
+      top: 8,
+      bottom: 8,
+      containLabel: true,
+    },
+    xAxis: {
+      type: "value",
+      show: false,
+      max: totalValue,
+    },
+    yAxis: [
+      {
+        type: "category",
+        data: names,
+        inverse: true,
+        axisLine: { show: false },
+        axisTick: { show: false },
+        axisLabel: {
+          color: "#666",
+          fontSize: 11,
+          width: 96,
+          overflow: "truncate",
+          margin: 10,
+        },
+      },
+      {
+        type: "category",
+        data: values,
+        inverse: true,
+        axisLine: { show: false },
+        axisTick: { show: false },
+        axisLabel: {
+          color: "#666",
+          fontSize: 11,
+          margin: 12,
+          formatter: (value) => value,
+        },
+      },
+    ],
+    series: [
+      {
+        type: "bar",
+        data: values.map(() => totalValue),
+        barWidth: 12,
+        barGap: "-100%",
+        barCategoryGap: "40%",
+        silent: true,
+        z: 0,
+        itemStyle: {
+          color: "#f0f2f5",
+          borderRadius: 6,
+        },
+      },
+      {
+        type: "bar",
+        data: values.map((value, index) => ({
+          value,
+          itemStyle: {
+            color: props.colors[index % props.colors.length],
+            borderRadius: 6,
+          },
+        })),
+        barWidth: 12,
+        barCategoryGap: "40%",
+        z: 1,
+      },
+    ],
+  };
+}
+
+function renderChart() {
+  nextTick(() => {
+    if (!chartRef.value) return;
+    chartRef.value.init(echarts, (instance) => {
+      instance.setOption(buildOption(), true);
+      setTimeout(() => instance.resize(), 80);
+    });
+  });
+}
+
+watch(() => props.chartData, renderChart, { deep: true, immediate: true });
+</script>
+
+<style lang="scss" scoped>
+.overview-chart {
+  &__echart {
+    width: 100%;
+    min-height: 160px;
+  }
+}
+</style>

+ 90 - 0
src/pages/business/ems/overview/components/itemPieChart.vue

@@ -0,0 +1,90 @@
+<template>
+  <view class="overview-chart">
+    <l-echart ref="chartRef" class="overview-chart__echart"></l-echart>
+  </view>
+</template>
+
+<script setup>
+import * as echarts from "echarts";
+import { ref, watch, nextTick } from "vue";
+
+const props = defineProps({
+  chartData: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const chartRef = ref(null);
+
+const DEFAULT_DATA = [
+  { name: "照明插座系统用电", value: 0, color: "#4a90e2" },
+  { name: "空调系统用电", value: 0, color: "#40b883" },
+  { name: "动力系统用电", value: 0, color: "#f5c542" },
+  { name: "特殊系统用电", value: 0, color: "#ef6b6b" },
+];
+
+function buildOption() {
+  const data = props.chartData.length ? props.chartData : DEFAULT_DATA;
+
+  return {
+    color: data.map((item) => item.color),
+    tooltip: {
+      trigger: "item",
+      formatter: "{b}: {c} ({d}%)",
+    },
+    legend: {
+      bottom: 0,
+      left: "center",
+      width: "92%",
+      icon: "circle",
+      itemWidth: 8,
+      itemHeight: 8,
+      itemGap: 10,
+      textStyle: { fontSize: 10, color: "#666" },
+    },
+    series: [
+      {
+        type: "pie",
+        radius: ["38%", "58%"],
+        center: ["50%", "42%"],
+        avoidLabelOverlap: true,
+        label: {
+          show: true,
+          formatter: "{b}\n{d}%",
+          fontSize: 10,
+          color: "#666",
+        },
+        labelLine: {
+          length: 10,
+          length2: 6,
+        },
+        data: data.map((item) => ({
+          name: item.name,
+          value: item.value,
+        })),
+      },
+    ],
+  };
+}
+
+function renderChart() {
+  nextTick(() => {
+    if (!chartRef.value) return;
+    chartRef.value.init(echarts, (instance) => {
+      instance.setOption(buildOption(), true);
+    });
+  });
+}
+
+watch(() => props.chartData, renderChart, { deep: true, immediate: true });
+</script>
+
+<style lang="scss" scoped>
+.overview-chart {
+  &__echart {
+    width: 100%;
+    height: 520rpx;
+  }
+}
+</style>

+ 242 - 0
src/pages/business/ems/overview/components/kpiGrid.vue

@@ -0,0 +1,242 @@
+<template>
+  <view class="kpi-grid">
+    <view class="kpi-card bg-white radius" v-for="(item, index) in list" :key="index">
+      <view class="kpi-card__header flex">
+        <view class="kpi-card__icon" :style="{ backgroundColor: `${item.color}18` }">
+          <text class="kpi-card__icon-text" :style="{ color: item.color }">{{ item.icon }}</text>
+        </view>
+        <text class="kpi-card__title">{{ item.title }}</text>
+      </view>
+      <view class="kpi-card__value">
+        <text class="kpi-card__num">{{ item.value }}</text>
+        <text class="kpi-card__unit">{{ item.unit }}</text>
+      </view>
+      <view class="kpi-card__compare flex" v-if="item.showCompare">
+        <view class="kpi-card__compare-item">
+          <text class="kpi-card__compare-label">环比</text>
+          <text :class="item.mom >= 0 ? 'kpi-card__up' : 'kpi-card__down'">{{ formatRate(item.mom) }}</text>
+        </view>
+        <view class="kpi-card__compare-item">
+          <text class="kpi-card__compare-label">同比</text>
+          <text :class="item.yoy >= 0 ? 'kpi-card__up' : 'kpi-card__down'">{{ formatRate(item.yoy) }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { reactive, computed, watch, toRefs } from "vue";
+import { getEmsClassificationEnergy, getEmsOverviewTop } from "@/api/business/ems/overview.js";
+
+const PIE_COLORS = ["#4fe3c3", "#6797ed", "#9978fa", "#4ecee2", "#ffbb62", "#ff7d2e", "#dc76ee", "#56d853"];
+
+const emit = defineEmits(["update:unitIndex", "update:categoryData"]);
+
+const props = defineProps({
+  dateType: {
+    type: String,
+    default: "1",
+  },
+  energyType: {
+    type: String,
+    default: "1",
+  },
+  unitIndex: {
+    type: Number,
+    default: 0,
+  },
+  categoryData: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const DEFAULT_KPI = [
+  { title: "能耗用量", value: "0.0", unit: "kWh", icon: "⚡", color: "#4a90e2", showCompare: true, mom: 0, yoy: 0 },
+  { title: "等值树", value: "0.0", unit: "棵", icon: "🌳", color: "#40b883", showCompare: true, mom: 0, yoy: 0 },
+  { title: "标准煤", value: "0.0", unit: "kgce", icon: "◉", color: "#f5a623", showCompare: true, mom: 0, yoy: 0 },
+  { title: "碳排放", value: "0.0", unit: "kgce", icon: "☁", color: "#7b61ff", showCompare: true, mom: 0, yoy: 0 },
+  { title: "综合能耗统计", value: "0.0", unit: "kgce", icon: "◎", color: "#9b59b6", showCompare: false, mom: 0, yoy: 0 },
+  { title: "折算碳减排量", value: "0.0", unit: "kg", icon: "♻", color: "#ef6b6b", showCompare: false, mom: 0, yoy: 0 },
+];
+
+const state = reactive({
+  list: DEFAULT_KPI.map((item) => ({ ...item })),
+});
+
+const { list } = toRefs(state);
+
+const usageUnit = computed(() => (String(props.energyType) === "1" ? "kWh" : "m³"));
+
+function formatRate(value) {
+  const num = Number(value) || 0;
+  return `${num >= 0 ? "+" : ""}${num.toFixed(1)}%`;
+}
+
+function toFixedNum(val, fallback = 0) {
+  const num = Number(val);
+  return Number.isFinite(num) ? Number(num.toFixed(1)) : fallback;
+}
+
+function getQueryParams() {
+  return {
+    dateType: props.dateType,
+    energyType: props.energyType,
+  };
+}
+
+function applyClassificationEnergy(data) {
+  if (!data) return;
+
+  state.list[0].value = toFixedNum(data.consume);
+  state.list[0].unit = usageUnit.value;
+  state.list[0].mom = toFixedNum(data.sequentialCon);
+  state.list[0].yoy = toFixedNum(data.pariPassCon);
+
+  state.list[1].value = toFixedNum(data.plantTree);
+  state.list[1].mom = toFixedNum(data.sequentialPlantTree);
+  state.list[1].yoy = toFixedNum(data.pariPassPlantTree);
+
+  state.list[2].value = toFixedNum(data.coalAmount);
+  state.list[2].mom = toFixedNum(data.sequentialCoal);
+  state.list[2].yoy = toFixedNum(data.pariPassCoal);
+
+  state.list[3].value = toFixedNum(data.co2Amount);
+  state.list[3].mom = toFixedNum(data.sequentialCo2);
+  state.list[3].yoy = toFixedNum(data.pariPassCo2);
+}
+
+function applyOverviewTopKpi(data) {
+  if (!data) return;
+
+  state.list[4].value = toFixedNum(data.coalTotal);
+  state.list[5].value = toFixedNum(data.co2Total);
+
+  emit("update:unitIndex", toFixedNum(data.unitCoal));
+
+  if (Array.isArray(data.ratioList) && data.ratioList.length) {
+    emit(
+      "update:categoryData",
+      data.ratioList.map((item, index) => ({
+        name: item.name,
+        value: toFixedNum(item.consume),
+        color: PIE_COLORS[index % PIE_COLORS.length],
+      }))
+    );
+  }
+}
+
+function fetchClassificationEnergy() {
+  return getEmsClassificationEnergy(getQueryParams())
+    .then((requset) => {
+      if (requset.status === "SUCCESS") {
+        applyClassificationEnergy(requset.data);
+      }
+    })
+    .catch(() => {});
+}
+
+function fetchOverviewTop() {
+  return getEmsOverviewTop({ dateType: props.dateType })
+    .then((requset) => {
+      if (requset.status === "SUCCESS" && requset.data) {
+        applyOverviewTopKpi(requset.data);
+      }
+    })
+    .catch(() => {});
+}
+
+function refresh() {
+  return Promise.all([fetchClassificationEnergy(), fetchOverviewTop()]);
+}
+
+watch(
+  () => [props.dateType, props.energyType],
+  () => {
+    refresh();
+  },
+  { immediate: true }
+);
+
+defineExpose({
+  refresh,
+});
+</script>
+
+<style lang="scss" scoped>
+.kpi-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 10px;
+  margin-bottom: 10px;
+}
+
+.kpi-card {
+  padding: 12px;
+
+  &__header {
+    align-items: center;
+    margin-bottom: 8px;
+  }
+
+  &__icon {
+    width: 28px;
+    height: 28px;
+    border-radius: 8px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 6px;
+    flex-shrink: 0;
+  }
+
+  &__icon-text {
+    font-size: 14px;
+    line-height: 1;
+  }
+
+  &__title {
+    font-size: 12px;
+    color: #666;
+    line-height: 18px;
+  }
+
+  &__value {
+    margin-bottom: 6px;
+  }
+
+  &__num {
+    font-size: 20px;
+    font-weight: 600;
+    color: #333;
+  }
+
+  &__unit {
+    margin-left: 4px;
+    font-size: 11px;
+    color: #999;
+  }
+
+  &__compare {
+    gap: 12px;
+  }
+
+  &__compare-item {
+    font-size: 10px;
+    color: #999;
+  }
+
+  &__compare-label {
+    margin-right: 4px;
+  }
+
+  &__up {
+    color: #ef4444;
+  }
+
+  &__down {
+    color: #16a34a;
+  }
+}
+</style>

+ 159 - 0
src/pages/business/ems/overview/components/projectIntro.vue

@@ -0,0 +1,159 @@
+<template>
+  <view class="project-intro bg-white radius mb10">
+    <u-collapse :border="false" :value="[]">
+      <u-collapse-item :name="collapseName" :border="false">
+        <template #title>
+          <text class="project-intro__title text-ellipsis">{{ projectTitle }}</text>
+        </template>
+        <view class="project-intro__content">
+          <image
+            v-if="projectImage"
+            class="project-intro__image"
+            :src="projectImage"
+            mode="aspectFill"
+          ></image>
+          <view class="project-intro__detail" v-for="(item, index) in projectContent" :key="index">
+            <text class="project-intro__label">[{{ item.label }}]</text>
+            <text class="project-intro__value">{{ item.value }}</text>
+          </view>
+        </view>
+      </u-collapse-item>
+    </u-collapse>
+  </view>
+</template>
+
+<script setup>
+import { reactive, computed, toRefs, onMounted } from "vue";
+import { getEmsProjectOverview } from "@/api/business/ems/overview.js";
+
+const FIELD_CONFIG = [
+  { key: "area", label: "建筑面积", formatter: (v) => (v != null && v !== "" ? `${v}㎡` : "") },
+  { key: "commonArea", label: "公共区域面积", formatter: (v) => (v != null && v !== "" ? `${v}㎡` : "") },
+  { key: "airConditionedArea", label: "空调面积", formatter: (v) => (v != null && v !== "" ? `${v}㎡` : "") },
+  { key: "residentPopulation", label: "常驻人数" },
+  {
+    key: "region",
+    label: "项目区域",
+    getValue: (d) => {
+      const parts = [d.provinceName, d.cityName, d.districtName].filter(Boolean);
+      return parts.length ? parts.join(" ") : d.provinceCode || d.cityCode || d.districtCode || "";
+    },
+  },
+  { key: "address", label: "详细地址" },
+  { key: "typeName", label: "项目类型" },
+  { key: "introduction", label: "项目简介" },
+  { key: "platformName", label: "平台名称" },
+  {
+    key: "areaCoal",
+    label: "单位能耗",
+    formatter: (v) => (v != null && v !== "" && v !== undefined ? String(v) : ""),
+  },
+];
+
+const collapseName = "project-intro";
+
+const state = reactive({
+  projectInfo: {},
+});
+
+const { projectInfo } = toRefs(state);
+
+function getValueByKey(obj, key) {
+  if (!key.includes(".")) return obj[key];
+  return key.split(".").reduce((o, k) => (o && o[k]), obj);
+}
+
+const projectTitle = computed(() => {
+  const d = state.projectInfo || {};
+  return d.name || d.abbreviation || "项目概览";
+});
+
+const projectImage = computed(() => state.projectInfo?.imageUrl || state.projectInfo?.image || "");
+
+const projectContent = computed(() => {
+  const d = state.projectInfo || {};
+  const content = [];
+
+  FIELD_CONFIG.forEach((config) => {
+    let value;
+    if (config.getValue) {
+      value = config.getValue(d);
+    } else {
+      value = getValueByKey(d, config.key);
+      if (config.formatter) value = config.formatter(value, d);
+    }
+    if (value != null && value !== "") {
+      content.push({ label: config.label, value: String(value) });
+    }
+  });
+
+  return content.length ? content : [{ label: "项目名称", value: "暂无数据" }];
+});
+
+function fetchProjectOverview() {
+  return getEmsProjectOverview()
+    .then((requset) => {
+      if (requset?.data) {
+        state.projectInfo = requset.data;
+      }
+    })
+    .catch(() => {});
+}
+
+function refresh() {
+  return fetchProjectOverview();
+}
+
+onMounted(() => {
+  refresh();
+});
+
+defineExpose({
+  refresh,
+});
+</script>
+
+<style lang="scss" scoped>
+.project-intro {
+  overflow: hidden;
+
+  &__title {
+    flex: 1;
+    font-size: 14px;
+    font-weight: 600;
+    color: #333;
+    padding-right: 8px;
+  }
+
+  &__content {
+    padding: 0 4px 8px;
+  }
+
+  &__image {
+    width: 100%;
+    height: 150px;
+    border-radius: 6px;
+    margin-bottom: 12px;
+  }
+
+  &__detail {
+    display: flex;
+    align-items: flex-start;
+    margin-bottom: 8px;
+    font-size: 12px;
+    line-height: 20px;
+  }
+
+  &__label {
+    flex-shrink: 0;
+    color: #666;
+    margin-right: 4px;
+  }
+
+  &__value {
+    flex: 1;
+    color: #333;
+    word-break: break-all;
+  }
+}
+</style>

+ 201 - 0
src/pages/business/ems/overview/components/trendChartFullscreen.vue

@@ -0,0 +1,201 @@
+<template>
+  <view v-if="show" class="chart-fullscreen" @touchmove.stop.prevent>
+    <view class="chart-fullscreen__inner" :class="{ 'chart-fullscreen__inner--landscape': needRotate }">
+      <view class="chart-fullscreen__header flex">
+        <view class="chart-fullscreen__close flex" @click="handleClose">
+          <u-icon name="close" size="20" color="#333"></u-icon>
+        </view>
+        <text class="chart-fullscreen__title">{{ title || "能耗用电趋势" }}</text>
+        <view class="chart-fullscreen__placeholder"></view>
+      </view>
+      <view class="chart-fullscreen__content">
+        <trend-line-chart
+          :key="chartKey"
+          :fullscreen="true"
+          :x-data="xData"
+          :current-data="currentData"
+          :compare-data="compareData"
+        ></trend-line-chart>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref, watch, onUnmounted } from "vue";
+import trendLineChart from "./trendLineChart.vue";
+
+const emit = defineEmits(["close"]);
+const props = defineProps({
+  show: {
+    type: Boolean,
+    default: false,
+  },
+  title: {
+    type: String,
+    default: "能耗用电趋势",
+  },
+  xData: {
+    type: Array,
+    default: () => [],
+  },
+  currentData: {
+    type: Array,
+    default: () => [],
+  },
+  compareData: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const needRotate = ref(false);
+const chartKey = ref(0);
+
+function updateRotateState() {
+  const info = uni.getSystemInfoSync();
+  //#ifdef APP-PLUS
+  needRotate.value = false;
+  //#endif
+  //#ifndef APP-PLUS
+  needRotate.value = info.windowWidth < info.windowHeight;
+  //#endif
+}
+
+function refreshChart() {
+  chartKey.value += 1;
+}
+
+function lockLandscape() {
+  //#ifdef APP-PLUS
+  plus.screen.unlockOrientation();
+  plus.screen.lockOrientation("landscape-primary");
+  //#endif
+  //#ifdef H5
+  if (screen.orientation?.lock) {
+    screen.orientation.lock("landscape").catch(() => {});
+  }
+  //#endif
+}
+
+function unlockLandscape() {
+  //#ifdef APP-PLUS
+  plus.screen.unlockOrientation();
+  plus.screen.lockOrientation("portrait-primary");
+  //#endif
+  //#ifdef H5
+  if (screen.orientation?.unlock) {
+    screen.orientation.unlock();
+  }
+  //#endif
+}
+
+function handleOrientationChange() {
+  if (!props.show) return;
+  updateRotateState();
+  setTimeout(refreshChart, 200);
+}
+
+function handleClose() {
+  unlockLandscape();
+  emit("close");
+}
+
+watch(
+  () => props.show,
+  (val) => {
+    if (val) {
+      updateRotateState();
+      lockLandscape();
+      refreshChart();
+      setTimeout(() => {
+        updateRotateState();
+        refreshChart();
+      }, 350);
+      //#ifdef H5
+      window.addEventListener("orientationchange", handleOrientationChange);
+      window.addEventListener("resize", handleOrientationChange);
+      //#endif
+    } else {
+      unlockLandscape();
+      //#ifdef H5
+      window.removeEventListener("orientationchange", handleOrientationChange);
+      window.removeEventListener("resize", handleOrientationChange);
+      //#endif
+    }
+  }
+);
+
+onUnmounted(() => {
+  unlockLandscape();
+  //#ifdef H5
+  window.removeEventListener("orientationchange", handleOrientationChange);
+  window.removeEventListener("resize", handleOrientationChange);
+  //#endif
+});
+</script>
+
+<style lang="scss" scoped>
+.chart-fullscreen {
+  position: fixed;
+  inset: 0;
+  z-index: 10090;
+  background: #000;
+  overflow: hidden;
+
+  &__inner {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    background: #fff;
+
+    &--landscape {
+      position: fixed;
+      top: 0;
+      left: 100vw;
+      width: 100vh;
+      height: 100vw;
+      transform: rotate(90deg);
+      transform-origin: top left;
+    }
+  }
+
+  &__header {
+    align-items: center;
+    justify-content: space-between;
+    height: 44px;
+    padding: 0 12px;
+    border-bottom: 1px solid #f0f0f0;
+    flex-shrink: 0;
+  }
+
+  &__close {
+    width: 44px;
+    height: 44px;
+    align-items: center;
+    justify-content: center;
+  }
+
+  &__title {
+    flex: 1;
+    text-align: center;
+    font-size: 15px;
+    color: #333;
+    font-weight: 600;
+  }
+
+  &__placeholder {
+    width: 44px;
+  }
+
+  &__content {
+    flex: 1;
+    min-height: 0;
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+}
+</style>

+ 169 - 0
src/pages/business/ems/overview/components/trendLineChart.vue

@@ -0,0 +1,169 @@
+<template>
+  <view class="overview-chart" :class="{ 'overview-chart--fullscreen': fullscreen }">
+    <l-echart
+      ref="chartRef"
+      class="overview-chart__echart"
+      :class="{ 'overview-chart__echart--fullscreen': fullscreen }"
+    ></l-echart>
+  </view>
+</template>
+
+<script setup>
+import * as echarts from "echarts";
+import { ref, watch, nextTick } from "vue";
+
+const props = defineProps({
+  xData: {
+    type: Array,
+    default: () => [],
+  },
+  currentData: {
+    type: Array,
+    default: () => [],
+  },
+  compareData: {
+    type: Array,
+    default: () => [],
+  },
+  fullscreen: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const chartRef = ref(null);
+
+function buildOption() {
+  const xData = props.xData.length ? props.xData : ["暂无数据"];
+
+  return {
+    color: ["#f5a623", "#40b883"],
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(255, 255, 255, 0.9)",
+    },
+    legend: {
+      bottom: 0,
+      left: "center",
+      icon: "rect",
+      itemWidth: 12,
+      itemHeight: 8,
+      data: ["本期", "同比"],
+      textStyle: { fontSize: 11, color: "#666" },
+    },
+    grid: {
+      left: props.fullscreen ? "6%" : "4%",
+      right: props.fullscreen ? "6%" : "4%",
+      top: props.fullscreen ? "12%" : "14%",
+      bottom: props.fullscreen ? "14%" : "16%",
+      containLabel: true,
+    },
+    dataZoom: [
+      {
+        type: "slider",
+        show: xData.length > 7,
+        xAxisIndex: 0,
+        height: 18,
+        bottom: props.fullscreen ? 24 : 28,
+        borderColor: "transparent",
+        backgroundColor: "#f5f6f7",
+        fillerColor: "rgba(74, 144, 226, 0.15)",
+        handleSize: "80%",
+      },
+    ],
+    xAxis: {
+      type: "category",
+      boundaryGap: false,
+      data: xData,
+      axisLine: { lineStyle: { color: "#e5e5e5" } },
+      axisLabel: {
+        color: "#999",
+        fontSize: props.fullscreen ? 11 : 10,
+        rotate: xData.length > 10 ? -35 : 0,
+      },
+    },
+    yAxis: {
+      type: "value",
+      name: "kWh",
+      nameTextStyle: { color: "#999", fontSize: 10 },
+      splitLine: {
+        lineStyle: { type: "dashed", color: "#eee" },
+      },
+      axisLabel: { color: "#999", fontSize: 10 },
+    },
+    series: [
+      {
+        name: "本期",
+        type: "line",
+        smooth: true,
+        symbol: "circle",
+        symbolSize: 5,
+        data: props.currentData,
+      },
+      {
+        name: "同比",
+        type: "line",
+        smooth: true,
+        symbol: "circle",
+        symbolSize: 5,
+        data: props.compareData,
+      },
+    ],
+  };
+}
+
+function resizeChart(instance) {
+  if (!instance) return;
+  setTimeout(() => instance.resize(), 80);
+  setTimeout(() => instance.resize(), 300);
+}
+
+function renderChart() {
+  nextTick(() => {
+    if (!chartRef.value) return;
+    chartRef.value.init(echarts, (instance) => {
+      instance.setOption(buildOption(), true);
+      if (props.fullscreen) {
+        resizeChart(instance);
+      }
+    });
+  });
+}
+
+watch(
+  () => [props.xData, props.currentData, props.compareData, props.fullscreen],
+  renderChart,
+  { deep: true, immediate: true }
+);
+</script>
+
+<style lang="scss" scoped>
+.overview-chart {
+  &--fullscreen {
+    flex: 1;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    box-sizing: border-box;
+
+    :deep(.lime-echart),
+    :deep(.lime-echart__canvas) {
+      width: 100% !important;
+      height: 100% !important;
+    }
+  }
+
+  &__echart {
+    width: 100%;
+    height: 520rpx;
+
+    &--fullscreen {
+      flex: 1;
+      width: 100%;
+      height: 100% !important;
+      min-height: 0;
+    }
+  }
+}
+</style>

+ 96 - 0
src/pages/business/ems/overview/components/unitGaugeChart.vue

@@ -0,0 +1,96 @@
+<template>
+  <view class="overview-chart">
+    <l-echart ref="chartRef" class="overview-chart__echart"></l-echart>
+  </view>
+</template>
+
+<script setup>
+import * as echarts from "echarts";
+import { ref, watch, nextTick } from "vue";
+
+const props = defineProps({
+  value: {
+    type: Number,
+    default: 0,
+  },
+  max: {
+    type: Number,
+    default: 100,
+  },
+});
+
+const chartRef = ref(null);
+
+function buildOption() {
+  return {
+    series: [
+      {
+        type: "gauge",
+        startAngle: 200,
+        endAngle: -20,
+        min: 0,
+        max: props.max,
+        splitNumber: 5,
+        radius: "90%",
+        center: ["50%", "62%"],
+        axisLine: {
+          lineStyle: {
+            width: 14,
+            color: [
+              [0.3, "#40b883"],
+              [0.7, "#4a90e2"],
+              [1, "#f5a623"],
+            ],
+          },
+        },
+        pointer: {
+          icon: "rect",
+          length: "55%",
+          width: 4,
+          itemStyle: { color: "#4a90e2" },
+        },
+        axisTick: { show: false },
+        splitLine: {
+          length: 8,
+          lineStyle: { color: "#ddd", width: 1 },
+        },
+        axisLabel: {
+          color: "#999",
+          fontSize: 10,
+          distance: 14,
+        },
+        detail: {
+          valueAnimation: true,
+          formatter: `{value}\nkgce/m².a`,
+          color: "#333",
+          fontSize: 14,
+          lineHeight: 20,
+          offsetCenter: [0, "30%"],
+        },
+        title: { show: false },
+        data: [{ value: props.value }],
+      },
+    ],
+  };
+}
+
+function renderChart() {
+  nextTick(() => {
+    if (!chartRef.value) return;
+    chartRef.value.init(echarts, (instance) => {
+      instance.setOption(buildOption(), true);
+    });
+  });
+}
+
+watch(() => [props.value, props.max], renderChart, { immediate: true });
+</script>
+
+<style lang="scss" scoped>
+.overview-chart {
+  &__echart {
+    width: 100%;
+    height: 420rpx;
+  }
+}
+</style>

+ 467 - 0
src/pages/business/ems/overview/index.vue

@@ -0,0 +1,467 @@
+<template>
+  <u-sticky class="shadow-default" bgColor="#fff" style="top: 0">
+    <u-navbar :titleStyle="{ color: '#000' }" :autoBack="true" title="能耗概览" :placeholder="true" :safeAreaInsetTop="true" bgColor="#fff">
+      <template #left>
+        <view class="u-navbar__content__left__item">
+          <u-icon name="arrow-left" size="20" color="#000"></u-icon>
+        </view>
+      </template>
+    </u-navbar>
+
+    <oa-switch :items="filterItems" @change="handleFilterChange" @reset="resetSwitch" />
+  </u-sticky>
+
+  <oa-scroll
+    customClass="overview-container scroll-height"
+    :isSticky="true"
+    :refresherLoad="false"
+    :refresherEnabled="true"
+    :refresherEnabledTitle="false"
+    :refresherDefaultStyle="'none'"
+    :refresherThreshold="44"
+    :refresherBackground="'#eef3fb'"
+    :customStyle="{
+      //#ifdef APP-PLUS || MP-WEIXIN
+      height: `calc(100vh - (44px + 50px + ${proxy.$settingStore.StatusBarHeight}))`,
+      //#endif
+      //#ifdef H5
+      height: 'calc(100vh - (44px + 50px))',
+      //#endif
+    }"
+    @refresh="init"
+    :data-theme="'theme-' + proxy.$settingStore.themeColor.name"
+  >
+    <template #default>
+      <view class="overview-page p10">
+        <project-intro ref="projectIntroRef"></project-intro>
+
+        <kpi-grid
+          ref="kpiGridRef"
+          :date-type="form.dateType"
+          :energy-type="form.energyType"
+          v-model:unit-index="unitIndex"
+          v-model:category-data="categoryData"
+        ></kpi-grid>
+
+        <view class="chart-card bg-white radius mb10">
+          <view class="chart-card__title">分类能耗占比</view>
+          <category-pie-chart :chart-data="categoryData"></category-pie-chart>
+        </view>
+
+        <view class="chart-card bg-white radius mb10">
+          <view class="chart-card__title">单位综合能耗指标</view>
+          <unit-gauge-chart :value="unitIndex" :max="100"></unit-gauge-chart>
+        </view>
+
+        <view class="chart-card bg-white radius mb10">
+          <view class="chart-card__title">设备信息</view>
+          <device-bar-chart :chart-data="deviceData"></device-bar-chart>
+        </view>
+
+        <view class="chart-card bg-white radius mb10">
+          <view class="chart-card__title">建筑能耗排名</view>
+          <building-rank-chart :chart-data="buildingData"></building-rank-chart>
+        </view>
+
+        <view class="chart-card bg-white radius mb10">
+          <view class="chart-card__title">分项能耗占比</view>
+          <item-pie-chart :chart-data="itemData"></item-pie-chart>
+        </view>
+
+        <view class="chart-card bg-white radius mb10">
+          <view class="chart-card__header flex">
+            <view class="chart-card__title">能耗用电趋势</view>
+            <view class="chart-wrap__fullscreen flex" @click="openTrendFullscreen">
+              <view class="chart-wrap__fullscreen-icon"></view>
+              <text class="chart-wrap__fullscreen-text">横屏</text>
+            </view>
+          </view>
+          <trend-line-chart :x-data="trendXData" :current-data="trendCurrent" :compare-data="trendCompare"></trend-line-chart>
+        </view>
+      </view>
+    </template>
+  </oa-scroll>
+
+  <trend-chart-fullscreen
+    :show="trendFullscreen.show"
+    :x-data="trendXData"
+    :current-data="trendCurrent"
+    :compare-data="trendCompare"
+    @close="closeTrendFullscreen"
+  ></trend-chart-fullscreen>
+</template>
+
+<script setup>
+/*----------------------------------依赖引入-----------------------------------*/
+import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
+import { getCurrentInstance, reactive, toRefs, computed, ref } from "vue";
+/*----------------------------------接口引入-----------------------------------*/
+import {
+  getEmsOverviewItem,
+  getEmsItemRatio,
+  getEmsBuildingRanking,
+  getEmsEnergyTrend,
+  getEmsProductPage,
+} from "@/api/business/ems/overview.js";
+/*----------------------------------组件引入-----------------------------------*/
+import projectIntro from "./components/projectIntro.vue";
+import kpiGrid from "./components/kpiGrid.vue";
+import categoryPieChart from "./components/categoryPieChart.vue";
+import unitGaugeChart from "./components/unitGaugeChart.vue";
+import deviceBarChart from "./components/deviceBarChart.vue";
+import buildingRankChart from "./components/buildingRankChart.vue";
+import itemPieChart from "./components/itemPieChart.vue";
+import trendLineChart from "./components/trendLineChart.vue";
+import trendChartFullscreen from "./components/trendChartFullscreen.vue";
+
+const { proxy } = getCurrentInstance();
+const kpiGridRef = ref(null);
+const projectIntroRef = ref(null);
+
+const ITEM_COLORS = ["#4a90e2", "#40b883", "#f5c542", "#ef6b6b"];
+
+const TIME_LIST = [
+  { name: "日", value: "1" },
+  { name: "月", value: "2" },
+  { name: "年", value: "3" },
+];
+
+const state = reactive({
+  form: {
+    dateType: "1",
+    energyType: "1",
+  },
+  energyTypeList: [],
+  categoryData: [{ name: "电", value: 0, color: "#40b883" }],
+  unitIndex: 0,
+  deviceData: [],
+  buildingData: [],
+  itemData: [],
+  trendXData: [],
+  trendCurrent: [],
+  trendCompare: [],
+  trendFullscreen: {
+    show: false,
+  },
+});
+
+const { form, categoryData, unitIndex, deviceData, buildingData, itemData, trendXData, trendCurrent, trendCompare, trendFullscreen } = toRefs(state);
+
+/*----------------------------------计算属性-----------------------------------*/
+/** 当前选中的时间维度文案 */
+const timeLabel = computed(() => {
+  const current = TIME_LIST.find((item) => item.value === state.form.dateType);
+  return current ? current.name : "日";
+});
+
+/** 当前选中的能源类型文案 */
+const energyTypeLabel = computed(() => {
+  const current = state.energyTypeList.find((item) => String(item.energyType) === String(state.form.energyType));
+  return current ? current.name : "电";
+});
+
+const filterItems = computed(() => [
+  {
+    key: "time",
+    label: timeLabel.value,
+    active: state.form.dateType !== "1",
+    type: "sheet",
+    options: TIME_LIST,
+  },
+  {
+    key: "energy",
+    label: energyTypeLabel.value,
+    active: !!state.form.energyType,
+    type: "sheet",
+    options: state.energyTypeList.map((item) => ({
+      name: item.name,
+      value: String(item.energyType),
+    })),
+  },
+]);
+
+function handleFilterChange({ key, value }) {
+  if (key === "time") {
+    state.form.dateType = value.value;
+    resetEnergyTrend();
+  } else if (key === "energy") {
+    state.form.energyType = value.value;
+  }
+  loadOverviewData();
+}
+
+/*----------------------------------工具方法-----------------------------------*/
+/** 数值保留一位小数 */
+function toFixedNum(val, fallback = 0) {
+  const num = Number(val);
+  return Number.isFinite(num) ? Number(num.toFixed(1)) : fallback;
+}
+
+/** 按日/月/年生成趋势图时间轴 */
+function buildTrendTimeline() {
+  const now = proxy.$dayjs();
+
+  if (state.form.dateType === "1") {
+    return Array.from({ length: 24 }, (_, index) => ({
+      time: index,
+      label: `${index}点`,
+    }));
+  }
+
+  if (state.form.dateType === "2") {
+    const daysInMonth = now.daysInMonth();
+    return Array.from({ length: daysInMonth }, (_, index) => {
+      const day = index + 1;
+      return {
+        time: day,
+        label: now.date(day).format("YYYY-MM-DD"),
+      };
+    });
+  }
+
+  return Array.from({ length: 12 }, (_, index) => {
+    const month = index + 1;
+    return {
+      time: month,
+      label: now.month(month - 1).date(1).format("YYYY-MM-DD"),
+    };
+  });
+}
+
+/** 重置趋势图为空数据 */
+function resetEnergyTrend() {
+  const timeline = buildTrendTimeline();
+  state.trendXData = timeline.map((item) => item.label);
+  state.trendCurrent = timeline.map(() => 0);
+  state.trendCompare = timeline.map(() => 0);
+}
+
+/*----------------------------------数据获取方法-----------------------------------*/
+/** 获取能源类型列表 */
+function fetchOverviewItem() {
+  return getEmsOverviewItem()
+    .then((requset) => {
+      if (requset.status === "SUCCESS" && requset.data?.length) {
+        state.energyTypeList = requset.data;
+        const exists = requset.data.some((item) => String(item.energyType) === String(state.form.energyType));
+        if (!exists) {
+          state.form.energyType = String(requset.data[0].energyType);
+        }
+      }
+    })
+    .catch(() => {});
+}
+
+/** 获取分项能耗占比 */
+function fetchItemRatio() {
+  return getEmsItemRatio({
+    dateType: state.form.dateType,
+    energyType: state.form.energyType,
+  })
+    .then((requset) => {
+      if (requset.status === "SUCCESS" && Array.isArray(requset.data) && requset.data.length) {
+        state.itemData = requset.data.map((item, index) => ({
+          name: item.name,
+          value: toFixedNum((Number(item.total) || 0) * (Number(item.consume) || 0)),
+          color: ITEM_COLORS[index % ITEM_COLORS.length],
+        }));
+      } else {
+        state.itemData = [];
+      }
+    })
+    .catch(() => {
+      state.itemData = [];
+    });
+}
+
+/** 获取建筑能耗排名 */
+function fetchBuildingRanking() {
+  return getEmsBuildingRanking({
+    dateType: state.form.dateType,
+    energyType: state.form.energyType,
+  })
+    .then((requset) => {
+      if (requset.status === "SUCCESS" && Array.isArray(requset.data) && requset.data.length) {
+        state.buildingData = requset.data.map((item) => ({
+          name: item.name,
+          value: toFixedNum(item.value),
+        }));
+      } else {
+        state.buildingData = [];
+      }
+    })
+    .catch(() => {
+      state.buildingData = [];
+    });
+}
+
+/** 获取能耗趋势 */
+function fetchEnergyTrend() {
+  return getEmsEnergyTrend({
+    dateType: state.form.dateType,
+    energyType: state.form.energyType,
+  })
+    .then((requset) => {
+      const list = requset.status === "SUCCESS" ? requset.data : [];
+      const timeline = buildTrendTimeline();
+      const dataMap = {};
+
+      (list || []).forEach((item) => {
+        const key = Number(item.time);
+        if (Number.isFinite(key)) {
+          dataMap[key] = item;
+        }
+      });
+
+      state.trendXData = timeline.map((item) => item.label);
+      state.trendCurrent = timeline.map((item) => {
+        const row = dataMap[item.time];
+        return row ? toFixedNum(row.thisValue) : 0;
+      });
+      state.trendCompare = timeline.map((item) => {
+        const row = dataMap[item.time];
+        return row ? toFixedNum(row.oldValue) : 0;
+      });
+    })
+    .catch(() => {
+      resetEnergyTrend();
+    });
+}
+
+/** 获取设备信息统计 */
+function fetchDeviceInfo() {
+  return getEmsProductPage({ current: 1, size: 1000 })
+    .then((requset) => {
+      if (requset.status === "SUCCESS") {
+        const list = (requset.data?.records || [])
+          .map((item) => ({
+            name: item.productName || "未知设备",
+            value: Number(item.deviceCount ?? item.deviceNum ?? 0) || 0,
+          }))
+          .filter((item) => item.name);
+        state.deviceData = list.length ? list : [];
+      }
+    })
+    .catch(() => {});
+}
+
+/** 加载除 KPI 外的概览图表数据 */
+function loadOverviewData() {
+  fetchItemRatio();
+  fetchBuildingRanking();
+  fetchEnergyTrend();
+  fetchDeviceInfo();
+}
+
+/** 页面初始化 / 下拉刷新 */
+function init() {
+  projectIntroRef.value?.refresh();
+  fetchOverviewItem().finally(() => {
+    kpiGridRef.value?.refresh();
+    loadOverviewData();
+  });
+}
+
+/*----------------------------------交互方法-----------------------------------*/
+/** 重置日/月/年与能源类型筛选 */
+function resetSwitch() {
+  state.form = {
+    dateType: "1",
+    energyType: state.energyTypeList[0] ? String(state.energyTypeList[0].energyType) : "1",
+  };
+  resetEnergyTrend();
+  loadOverviewData();
+}
+
+/** 打开趋势图横屏全屏 */
+function openTrendFullscreen() {
+  state.trendFullscreen.show = true;
+}
+
+/** 关闭趋势图横屏全屏 */
+function closeTrendFullscreen() {
+  state.trendFullscreen.show = false;
+}
+
+/*----------------------------------生命周期-----------------------------------*/
+onShow(() => {
+  proxy.$settingStore.systemThemeColor([1]);
+});
+
+onLoad(() => {
+  resetEnergyTrend(); // 预置空趋势数据,避免首屏图表空白
+  init();
+});
+
+onUnload(() => {
+  closeTrendFullscreen(); // 离开页面时关闭全屏
+});
+</script>
+
+<style lang="scss" scoped>
+.overview-container {
+  background-color: #eef3fb;
+}
+
+.overview-page {
+  padding-bottom: 20px;
+}
+
+.chart-card {
+  padding: 12px 12px 4px;
+  overflow: hidden;
+
+  &__header {
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 4px;
+  }
+
+  &__title {
+    font-size: 14px;
+    font-weight: 600;
+    color: #559aff;
+    line-height: 22px;
+
+    &::before {
+      content: "丨 ";
+      color: #559aff;
+    }
+  }
+}
+
+.chart-wrap {
+  &__fullscreen {
+    align-items: center;
+    padding: 4px 8px;
+    border: 1px solid #eee;
+    border-radius: 14px;
+    flex-shrink: 0;
+  }
+
+  &__fullscreen-icon {
+    width: 12px;
+    height: 12px;
+    border: 1.5px solid #666;
+    border-radius: 1px;
+    position: relative;
+
+    &::before {
+      content: "";
+      position: absolute;
+      top: -3px;
+      right: -3px;
+      width: 5px;
+      height: 5px;
+      border-top: 1.5px solid #666;
+      border-right: 1.5px solid #666;
+    }
+  }
+
+  &__fullscreen-text {
+    margin-left: 4px;
+    font-size: 12px;
+    color: #666;
+  }
+}
+</style>

+ 203 - 0
src/pages/business/ems/survey/components/chartFullscreen.vue

@@ -0,0 +1,203 @@
+<template>
+  <view v-if="show" class="chart-fullscreen" @touchmove.stop.prevent>
+    <view class="chart-fullscreen__inner" :class="{ 'chart-fullscreen__inner--landscape': needRotate }">
+      <view class="chart-fullscreen__header flex">
+        <view class="chart-fullscreen__close flex" @click="handleClose">
+          <u-icon name="close" size="20" color="#333"></u-icon>
+        </view>
+        <text class="chart-fullscreen__title">{{ metricName || "图表全屏" }}</text>
+        <view class="chart-fullscreen__placeholder"></view>
+      </view>
+      <view class="chart-fullscreen__content">
+        <history-chart
+          v-if="chartType === 'history'"
+          :key="chartKey"
+          :fullscreen="true"
+          :series-list="seriesList"
+        ></history-chart>
+        <usage-chart v-else :key="chartKey" :fullscreen="true" :series-list="seriesList"></usage-chart>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref, watch, onUnmounted } from "vue";
+import historyChart from "./historyChart.vue";
+import usageChart from "./usageChart.vue";
+
+const emit = defineEmits(["close"]);
+const props = defineProps({
+  show: {
+    type: Boolean,
+    default: false,
+  },
+  chartType: {
+    type: String,
+    default: "history",
+  },
+  metricName: {
+    type: String,
+    default: "",
+  },
+  chartData: {
+    type: Array,
+    default: () => [],
+  },
+  seriesList: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const needRotate = ref(false);
+const chartKey = ref(0);
+
+function updateRotateState() {
+  const info = uni.getSystemInfoSync();
+  //#ifdef APP-PLUS
+  needRotate.value = false;
+  //#endif
+  //#ifndef APP-PLUS
+  needRotate.value = info.windowWidth < info.windowHeight;
+  //#endif
+}
+
+function refreshChart() {
+  chartKey.value += 1;
+}
+
+function lockLandscape() {
+  //#ifdef APP-PLUS
+  plus.screen.unlockOrientation();
+  plus.screen.lockOrientation("landscape-primary");
+  //#endif
+  //#ifdef H5
+  if (screen.orientation?.lock) {
+    screen.orientation.lock("landscape").catch(() => {});
+  }
+  //#endif
+}
+
+function unlockLandscape() {
+  //#ifdef APP-PLUS
+  plus.screen.unlockOrientation();
+  plus.screen.lockOrientation("portrait-primary");
+  //#endif
+  //#ifdef H5
+  if (screen.orientation?.unlock) {
+    screen.orientation.unlock();
+  }
+  //#endif
+}
+
+function handleOrientationChange() {
+  if (!props.show) return;
+  updateRotateState();
+  setTimeout(refreshChart, 200);
+}
+
+function handleClose() {
+  unlockLandscape();
+  emit("close");
+}
+
+watch(
+  () => props.show,
+  (val) => {
+    if (val) {
+      updateRotateState();
+      lockLandscape();
+      refreshChart();
+      setTimeout(() => {
+        updateRotateState();
+        refreshChart();
+      }, 350);
+      //#ifdef H5
+      window.addEventListener("orientationchange", handleOrientationChange);
+      window.addEventListener("resize", handleOrientationChange);
+      //#endif
+    } else {
+      unlockLandscape();
+      //#ifdef H5
+      window.removeEventListener("orientationchange", handleOrientationChange);
+      window.removeEventListener("resize", handleOrientationChange);
+      //#endif
+    }
+  }
+);
+
+onUnmounted(() => {
+  unlockLandscape();
+  //#ifdef H5
+  window.removeEventListener("orientationchange", handleOrientationChange);
+  window.removeEventListener("resize", handleOrientationChange);
+  //#endif
+});
+</script>
+
+<style lang="scss" scoped>
+.chart-fullscreen {
+  position: fixed;
+  inset: 0;
+  z-index: 10090;
+  background: #000;
+  overflow: hidden;
+
+  &__inner {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    background: #fff;
+
+    // 竖屏设备强制横屏铺满:长边为宽、短边为高
+    &--landscape {
+      position: fixed;
+      top: 0;
+      left: 100vw;
+      width: 100vh;
+      height: 100vw;
+      transform: rotate(90deg);
+      transform-origin: top left;
+    }
+  }
+
+  &__header {
+    align-items: center;
+    justify-content: space-between;
+    height: 44px;
+    padding: 0 12px;
+    border-bottom: 1px solid #f0f0f0;
+    flex-shrink: 0;
+  }
+
+  &__close {
+    width: 44px;
+    height: 44px;
+    align-items: center;
+    justify-content: center;
+  }
+
+  &__title {
+    flex: 1;
+    text-align: center;
+    font-size: 15px;
+    color: #333;
+    font-weight: 600;
+  }
+
+  &__placeholder {
+    width: 44px;
+  }
+
+  &__content {
+    flex: 1;
+    min-height: 0;
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+}
+</style>

+ 172 - 0
src/pages/business/ems/survey/components/deviceInfo.vue

@@ -0,0 +1,172 @@
+<template>
+  <view class="device-info p10">
+    <u-loading-page :loading="loading" fontSize="16" style="z-index: 99"></u-loading-page>
+
+    <view class="info-card bg-white radius mb10">
+      <view class="info-card__row flex">
+        <text class="info-card__label">设备名称</text>
+        <text class="info-card__value">{{ detail.deviceName || "--" }}</text>
+      </view>
+      <view class="info-card__row flex">
+        <text class="info-card__label">设备编码</text>
+        <text class="info-card__value info-card__value--sub">{{ detail.deviceId || "--" }}</text>
+      </view>
+    </view>
+
+    <view class="info-card bg-white radius mb10">
+      <view class="info-card__row flex">
+        <text class="info-card__label">监测位置</text>
+        <text class="info-card__value">{{ detail.installAddress || "--" }}</text>
+      </view>
+    </view>
+
+    <view class="info-card bg-white radius mb10">
+      <view class="info-card__row flex">
+        <text class="info-card__label">最近更新时间</text>
+        <text class="info-card__value">{{ updateTimeText }}</text>
+      </view>
+      <view class="info-card__row flex">
+        <text class="info-card__label">通讯状态</text>
+        <view class="info-card__value flex">
+          <view class="status-dot" :class="detail.deviceStatus == 1 ? 'status-dot--online' : 'status-dot--offline'"></view>
+          <text>{{ statusText }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { getCurrentInstance, reactive, toRefs, computed, watch } from "vue";
+import { getEmsDevicePage } from "@/api/business/ems/survey.js";
+
+const { proxy } = getCurrentInstance();
+const emit = defineEmits(["loaded"]);
+
+const props = defineProps({
+  productId: {
+    type: String,
+    default: "",
+  },
+  deviceId: {
+    type: String,
+    default: "",
+  },
+});
+
+const state = reactive({
+  loading: false,
+  detail: {},
+});
+
+const { loading, detail } = toRefs(state);
+
+const updateTimeText = computed(() => {
+  return proxy.$time.formatDateTime(
+    state.detail.updatedTime || state.detail.lastOnlineTime || state.detail.createdTime
+  );
+});
+
+const statusText = computed(() => {
+  return state.detail.deviceStatus == 1 ? "在线" : "离线";
+});
+
+function fetchDetail() {
+  if (!props.deviceId) return;
+
+  state.loading = true;
+  getEmsDevicePage({
+    deviceId: props.deviceId,
+    productId: props.productId || undefined,
+    categoryType: 3,
+    current: 1,
+    size: 1,
+  })
+    .then((requset) => {
+      if (requset.status === "SUCCESS" && requset.data.records?.length) {
+        state.detail = {
+          ...requset.data.records[0],
+          typeImg: requset.data.records[0].typeImg || "/static/images/device/1.png",
+        };
+        emit("loaded", state.detail);
+      }
+      state.loading = false;
+    })
+    .catch(() => {
+      state.loading = false;
+    });
+}
+
+function refresh() {
+  fetchDetail();
+}
+
+watch(
+  () => [props.productId, props.deviceId],
+  () => {
+    fetchDetail();
+  },
+  { immediate: true }
+);
+
+defineExpose({
+  refresh,
+  detail,
+});
+</script>
+
+<style lang="scss" scoped>
+.info-card {
+  padding: 4px 15px;
+
+  &__row {
+    align-items: flex-start;
+    padding: 12px 0;
+    border-bottom: 1px solid #f5f6f7;
+
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  &__label {
+    flex-shrink: 0;
+    font-size: 14px;
+    color: #909399;
+    line-height: 22px;
+    white-space: nowrap;
+  }
+
+  &__value {
+    flex: 1;
+    min-width: 0;
+    margin-left: 10px;
+    font-size: 14px;
+    color: #333;
+    line-height: 22px;
+    text-align: right;
+    word-break: break-all;
+    justify-content: flex-end;
+
+    &--sub {
+      color: #666;
+    }
+  }
+}
+
+.status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  margin: 7px 6px 0 0;
+  flex-shrink: 0;
+
+  &--online {
+    background-color: #16bf00;
+  }
+
+  &--offline {
+    background-color: #ef4444;
+  }
+}
+</style>

+ 217 - 0
src/pages/business/ems/survey/components/historyChart.vue

@@ -0,0 +1,217 @@
+<template>
+  <view class="chart-panel bg-white radius" :class="{ 'chart-panel--fullscreen': fullscreen }">
+    <l-echart ref="chartRef" class="chart-panel__echart" :class="{ 'chart-panel__echart--fullscreen': fullscreen }"></l-echart>
+  </view>
+</template>
+
+<script setup>
+import * as echarts from "echarts";
+import { ref, watch, nextTick } from "vue";
+
+const CHART_COLORS = ["#4a90e2", "#40b883", "#f5a623", "#ef4444", "#9b59b6", "#1abc9c"];
+
+const props = defineProps({
+  metricName: {
+    type: String,
+    default: "",
+  },
+  chartData: {
+    type: Array,
+    default: () => [],
+  },
+  seriesList: {
+    type: Array,
+    default: () => [],
+  },
+  fullscreen: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const chartRef = ref(null);
+let chartInstance;
+
+function getSeriesList() {
+  if (props.seriesList.length) return props.seriesList;
+  if (props.chartData.length) {
+    return [{ name: props.metricName || "暂无数据", data: props.chartData }];
+  }
+  return [];
+}
+
+function buildOption() {
+  const seriesList = getSeriesList();
+  if (!seriesList.length) {
+    return {
+      title: {
+        text: "暂无数据",
+        left: "center",
+        top: "center",
+        textStyle: { color: "#999", fontSize: 14, fontWeight: "normal" },
+      },
+    };
+  }
+
+  const labelOrder = [];
+  const labelSeen = new Set();
+  seriesList.forEach((series) => {
+    (series.data || []).forEach((point) => {
+      if (!labelSeen.has(point.label)) {
+        labelSeen.add(point.label);
+        labelOrder.push({
+          label: point.label,
+          timestamp: point.timestamp || 0,
+        });
+      }
+    });
+  });
+  labelOrder.sort((a, b) => a.timestamp - b.timestamp);
+  const xData = labelOrder.map((item) => item.label);
+
+  const lineSeries = seriesList.map((series, index) => ({
+    name: series.name,
+    type: "line",
+    smooth: true,
+    symbol: "circle",
+    symbolSize: 4,
+    connectNulls: true,
+    data: xData.map((label) => {
+      const point = (series.data || []).find((item) => item.label === label);
+      return point ? point.value : null;
+    }),
+    itemStyle: {
+      color: CHART_COLORS[index % CHART_COLORS.length],
+    },
+  }));
+
+  return {
+    color: CHART_COLORS,
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(255, 255, 255, 0.9)",
+      borderColor: "rgba(0, 0, 0, 0.08)",
+    },
+    legend: {
+      type: "scroll",
+      top: 10,
+      left: "center",
+      data: seriesList.map((item) => item.name),
+      icon: "circle",
+      itemWidth: 8,
+      itemHeight: 8,
+      textStyle: {
+        fontSize: 12,
+        color: "#666",
+      },
+    },
+    grid: {
+      left: props.fullscreen ? "8%" : "12%",
+      right: props.fullscreen ? "8%" : "12%",
+      top: props.fullscreen ? 48 : 56,
+      bottom: props.fullscreen ? 36 : 60,
+    },
+    dataZoom: [
+      {
+        type: "slider",
+        show: xData.length > 4,
+        xAxisIndex: 0,
+        height: 18,
+        bottom: 10,
+        borderColor: "transparent",
+        backgroundColor: "#f5f6f7",
+        fillerColor: "rgba(74, 144, 226, 0.15)",
+        handleSize: "80%",
+      },
+    ],
+    xAxis: {
+      type: "category",
+      boundaryGap: false,
+      data: xData,
+      axisLine: {
+        lineStyle: { color: "#e5e5e5" },
+      },
+      axisLabel: {
+        color: "#999",
+        fontSize: 11,
+      },
+    },
+    yAxis: {
+      type: "value",
+      splitLine: {
+        lineStyle: {
+          type: "dashed",
+          color: "#eee",
+        },
+      },
+      axisLabel: {
+        color: "#999",
+        fontSize: 11,
+      },
+    },
+    series: lineSeries,
+  };
+}
+
+function resizeChart(instance) {
+  if (!instance) return;
+  setTimeout(() => instance.resize(), 80);
+  setTimeout(() => instance.resize(), 300);
+}
+
+function renderChart() {
+  nextTick(() => {
+    if (!chartRef.value) return;
+    chartRef.value.init(echarts, (instance) => {
+      chartInstance = instance;
+      instance.setOption(buildOption(), true);
+      if (props.fullscreen) {
+        resizeChart(instance);
+      }
+    });
+  });
+}
+
+watch(
+  () => [props.metricName, props.chartData, props.seriesList, props.fullscreen],
+  () => {
+    renderChart();
+  },
+  { deep: true, immediate: true }
+);
+</script>
+
+<style lang="scss" scoped>
+.chart-panel {
+  padding: 10px 0 0;
+
+  &--fullscreen {
+    flex: 1;
+    width: 100%;
+    height: 100%;
+    padding: 0;
+    border-radius: 0;
+    display: flex;
+    flex-direction: column;
+    box-sizing: border-box;
+
+    :deep(.lime-echart),
+    :deep(.lime-echart__canvas) {
+      width: 100% !important;
+      height: 100% !important;
+    }
+  }
+
+  &__echart {
+    width: 100%;
+    height: 520rpx;
+
+    &--fullscreen {
+      flex: 1;
+      width: 100%;
+      height: 100% !important;
+      min-height: 0;
+    }
+  }
+}
+</style>

+ 277 - 0
src/pages/business/ems/survey/components/metricChartTab.vue

@@ -0,0 +1,277 @@
+<template>
+  <view class="metric-chart p10">
+    <view class="metric-select bg-white radius flex" @click="openMetricPicker">
+      <text class="metric-select__text text-ellipsis">{{ metricLabel }}</text>
+      <u-icon name="arrow-down" color="#999" size="14"></u-icon>
+    </view>
+    <view class="chart-wrap bg-white radius">
+      <view class="chart-wrap__toolbar flex">
+        <view class="chart-wrap__fullscreen flex" @click="openChartFullscreen">
+          <view class="chart-wrap__fullscreen-icon"></view>
+          <text class="chart-wrap__fullscreen-text">横屏</text>
+        </view>
+      </view>
+      <history-chart v-if="chartType === 'history'" :series-list="chartSeries"></history-chart>
+      <usage-chart v-else :series-list="chartSeries"></usage-chart>
+    </view>
+
+    <oa-multi-picker
+      :show="metricPicker.show"
+      :columns="[metrics]"
+      :modelValue="selectedValues"
+      keyName="name"
+      valueName="value"
+      title="请选择参数"
+      visibleItemCount="6"
+      :closeOnClickOverlay="true"
+      :confirmColor="proxy.$settingStore.themeColor.color"
+      @close="closeMetricPicker"
+      @cancel="closeMetricPicker"
+      @confirm="confirmMetricPicker"
+    ></oa-multi-picker>
+
+    <chart-fullscreen
+      :show="fullscreen.show"
+      :chart-type="chartType"
+      :metric-name="fullscreen.metricName"
+      :chart-data="[]"
+      :series-list="chartSeries"
+      @close="closeChartFullscreen"
+    ></chart-fullscreen>
+  </view>
+</template>
+
+<script setup>
+import { getCurrentInstance, reactive, toRefs, computed } from "vue";
+import { getEmsProductAttribute, getEmsHistoryMetrics } from "@/api/business/ems/survey.js";
+import historyChart from "./historyChart.vue";
+import usageChart from "./usageChart.vue";
+import chartFullscreen from "./chartFullscreen.vue";
+import oaMultiPicker from "@/components/oa-multi-picker/index.vue";
+
+const { proxy } = getCurrentInstance();
+
+const USAGE_METRIC_NAME = "累计正向有功电能";
+
+const props = defineProps({
+  chartType: {
+    type: String,
+    default: "history",
+    validator: (value) => ["history", "usage"].includes(value),
+  },
+  productId: {
+    type: String,
+    default: "",
+  },
+  deviceUuid: {
+    type: String,
+    default: "",
+  },
+});
+
+const state = reactive({
+  attributesLoaded: false,
+  metrics: [],
+  selectedValues: [],
+  chartSeries: [],
+  metricPicker: {
+    show: false,
+  },
+  fullscreen: {
+    show: false,
+    metricName: "",
+  },
+});
+
+const { metrics, selectedValues, chartSeries, metricPicker, fullscreen } = toRefs(state);
+
+const metricLabel = computed(() => {
+  if (!state.selectedValues.length) return "请选择参数";
+  const names = state.metrics.filter((item) => state.selectedValues.includes(item.value)).map((item) => item.name);
+  return names.length ? names.join("、") : "请选择参数";
+});
+
+function parseMetricItems(response, metricCode) {
+  if (response?.status !== "SUCCESS" || !response.data?.length) return [];
+  const metricsList = response.data[0]?.metrics || [];
+  const metricData =
+    metricsList.find((item) => item.metric === metricCode) ||
+    metricsList.find((item) => item.metric?.toLowerCase() === metricCode?.toLowerCase());
+  return metricData?.metricItems || [];
+}
+
+function buildChartData(metricItems) {
+  if (!metricItems.length) return [];
+
+  return metricItems.map((item) => {
+    const value = Number(item.value) || 0;
+    const time = proxy.$dayjs(Number(item.timestamp));
+    return {
+      label: time.isValid() ? time.format("HH:mm") : "--",
+      timestamp: Number(item.timestamp) || 0,
+      value: Number(value.toFixed(2)),
+    };
+  });
+}
+
+function buildChartSeries(response, selectedMetrics) {
+  return selectedMetrics.map((metric) => ({
+    name: metric.name,
+    data: buildChartData(parseMetricItems(response, metric.value)),
+  }));
+}
+
+function fetchChart() {
+  if (!state.selectedValues.length || !props.deviceUuid) {
+    state.chartSeries = [];
+    return;
+  }
+
+  const selectedMetrics = state.metrics.filter((item) => state.selectedValues.includes(item.value));
+  const now = proxy.$dayjs();
+  const startTime = `${now.format("YYYY-MM-DD")} 00:00:00`;
+  const endTime = now.format("YYYY-MM-DD HH:mm:ss");
+
+  getEmsHistoryMetrics({
+    startTime,
+    endTime,
+    deviceuuid: [props.deviceUuid],
+    metrics: state.selectedValues,
+  })
+    .then((requset) => {
+      state.chartSeries = buildChartSeries(requset, selectedMetrics);
+    })
+    .catch(() => {
+      state.chartSeries = [];
+    });
+}
+
+function resolveMetrics(metricList) {
+  if (props.chartType !== "usage") {
+    return metricList;
+  }
+  return metricList.filter((item) => item.name === USAGE_METRIC_NAME);
+}
+
+function ensureAttributesLoaded() {
+  if (state.attributesLoaded || !props.productId) {
+    return Promise.resolve();
+  }
+
+  return getEmsProductAttribute({
+    current: 1,
+    size: 100,
+    attributeName: "",
+    productId: props.productId,
+  })
+    .then((requset) => {
+      if (requset.status === "SUCCESS" && requset.data.records?.length) {
+        const metricList = resolveMetrics(
+          requset.data.records.map((item) => ({
+            name: item.attributeName,
+            value: item.attributeCode.toLowerCase(),
+          }))
+        );
+        state.metrics = metricList;
+        if (!state.selectedValues.length && metricList.length) {
+          state.selectedValues = [metricList[0].value];
+        }
+      }
+      state.attributesLoaded = true;
+    })
+    .catch(() => {});
+}
+
+function openMetricPicker() {
+  state.metricPicker.show = true;
+}
+
+function closeMetricPicker() {
+  state.metricPicker.show = false;
+}
+
+function confirmMetricPicker(e) {
+  const values = e.value.map((item) => item.value);
+  state.selectedValues = props.chartType === "usage" ? values.filter((value) => state.metrics.some((item) => item.value === value)).slice(0, 1) : values;
+  closeMetricPicker();
+  fetchChart();
+}
+
+function openChartFullscreen() {
+  state.fullscreen.metricName = state.chartSeries.map((item) => item.name).join("、");
+  state.fullscreen.show = true;
+}
+
+function closeChartFullscreen() {
+  state.fullscreen.show = false;
+}
+
+function refresh(force = false) {
+  return ensureAttributesLoaded().then(() => {
+    if (force || !state.chartSeries.length) {
+      fetchChart();
+    }
+  });
+}
+
+defineExpose({
+  refresh,
+  closeChartFullscreen,
+});
+</script>
+
+<style lang="scss" scoped>
+.metric-select {
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 15px;
+  margin-bottom: 10px;
+  border: 1px solid #f0f0f0;
+
+  &__text {
+    flex: 1;
+    min-width: 0;
+    font-size: 14px;
+    color: #333;
+  }
+}
+
+.chart-wrap {
+  &__toolbar {
+    justify-content: flex-end;
+    padding: 10px 10px 0;
+  }
+
+  &__fullscreen {
+    align-items: center;
+    padding: 4px 8px;
+    border: 1px solid #eee;
+    border-radius: 14px;
+  }
+
+  &__fullscreen-icon {
+    width: 12px;
+    height: 12px;
+    border: 1.5px solid #666;
+    border-radius: 1px;
+    position: relative;
+
+    &::before {
+      content: "";
+      position: absolute;
+      top: -3px;
+      right: -3px;
+      width: 5px;
+      height: 5px;
+      border-top: 1.5px solid #666;
+      border-right: 1.5px solid #666;
+    }
+  }
+
+  &__fullscreen-text {
+    margin-left: 4px;
+    font-size: 12px;
+    color: #666;
+  }
+}
+</style>

+ 165 - 0
src/pages/business/ems/survey/components/realtimeData.vue

@@ -0,0 +1,165 @@
+<template>
+  <view class="realtime-data p10">
+    <u-loading-page :loading="loading" fontSize="16" style="z-index: 99"></u-loading-page>
+    <view v-if="list.length" class="info-card bg-white radius mb10">
+      <view class="info-card__row flex" v-for="item in list" :key="item.attributeCode">
+        <text class="info-card__label">{{ item.attributeName }}</text>
+        <text class="info-card__value">{{ formatRealtimeValue(item) }}</text>
+      </view>
+    </view>
+    <view v-else-if="!loading" class="info-card bg-white radius mb10">
+      <view class="info-card__row flex">
+        <text class="info-card__value info-card__value--empty">暂无实时数据</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { getCurrentInstance, reactive, toRefs } from "vue";
+import { getEmsProductAttribute, getEmsLastMetrics } from "@/api/business/ems/survey.js";
+
+const { proxy } = getCurrentInstance();
+
+const props = defineProps({
+  productId: {
+    type: String,
+    default: "",
+  },
+  deviceUuid: {
+    type: String,
+    default: "",
+  },
+});
+
+const state = reactive({
+  loading: false,
+  list: [],
+});
+
+const { loading, list } = toRefs(state);
+
+function formatRealtimeValue(item) {
+  const value = item.value;
+  if (item.attributeDict?.length && (Number.isFinite(value) || (value !== null && value !== undefined && value !== ""))) {
+    const mapped = proxy.$common.mapping("name", "value", value, item.attributeDict);
+    return mapped || "-";
+  }
+  if (Number.isFinite(value)) {
+    const formatted = Number.isInteger(value) ? value : Number(value.toFixed(2));
+    return item.attributeUnit ? `${formatted} ${item.attributeUnit}` : formatted;
+  }
+  if (value !== null && value !== undefined && value !== "") {
+    return item.attributeUnit ? `${value} ${item.attributeUnit}` : value;
+  }
+  return "-";
+}
+
+function fetchData() {
+  if (!props.productId) return;
+
+  state.loading = true;
+  state.list = [];
+
+  getEmsProductAttribute({
+    current: 1,
+    size: 100,
+    attributeName: "",
+    productId: props.productId,
+  })
+    .then((requset) => {
+      if (requset.status !== "SUCCESS" || !requset.data.records?.length) {
+        state.loading = false;
+        return;
+      }
+
+      const attributeList = requset.data.records.map((item) => ({
+        attributeName: item.attributeName,
+        attributeCode: item.attributeCode,
+        attributeUnit: item.attributeUnit || "",
+        attributeDict: item.attributeDict ? JSON.parse(item.attributeDict) : [],
+        value: null,
+      }));
+
+      if (!props.deviceUuid) {
+        state.list = attributeList;
+        state.loading = false;
+        return;
+      }
+
+      const metrics = attributeList.map((item) => item.attributeCode.toLowerCase());
+      getEmsLastMetrics({
+        deviceuuid: [props.deviceUuid],
+        metrics,
+      })
+        .then((lastRequset) => {
+          if (lastRequset.status === "SUCCESS" && lastRequset.data?.length) {
+            const metricsValue = lastRequset.data[0].metrics || {};
+            attributeList.forEach((item) => {
+              const key = item.attributeCode.toLowerCase();
+              if (Object.prototype.hasOwnProperty.call(metricsValue, key)) {
+                item.value = metricsValue[key];
+              }
+            });
+          }
+          state.list = attributeList;
+          state.loading = false;
+        })
+        .catch(() => {
+          state.list = attributeList;
+          state.loading = false;
+        });
+    })
+    .catch(() => {
+      state.loading = false;
+    });
+}
+
+function refresh() {
+  fetchData();
+}
+
+defineExpose({
+  refresh,
+});
+</script>
+
+<style lang="scss" scoped>
+.info-card {
+  padding: 4px 15px;
+
+  &__row {
+    align-items: flex-start;
+    padding: 12px 0;
+    border-bottom: 1px solid #f5f6f7;
+
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  &__label {
+    flex-shrink: 0;
+    font-size: 14px;
+    color: #909399;
+    line-height: 22px;
+    white-space: nowrap;
+  }
+
+  &__value {
+    flex: 1;
+    min-width: 0;
+    margin-left: 10px;
+    font-size: 14px;
+    color: #333;
+    line-height: 22px;
+    text-align: right;
+    word-break: break-all;
+
+    &--empty {
+      text-align: center;
+      margin-left: 0;
+    }
+  }
+}
+</style>

+ 214 - 0
src/pages/business/ems/survey/components/usageChart.vue

@@ -0,0 +1,214 @@
+<template>
+  <view class="chart-panel bg-white radius" :class="{ 'chart-panel--fullscreen': fullscreen }">
+    <l-echart ref="chartRef" class="chart-panel__echart" :class="{ 'chart-panel__echart--fullscreen': fullscreen }"></l-echart>
+  </view>
+</template>
+
+<script setup>
+import * as echarts from "echarts";
+import { ref, watch, nextTick } from "vue";
+
+const CHART_COLORS = ["#40b883", "#4a90e2", "#f5a623", "#ef4444", "#9b59b6", "#1abc9c"];
+
+const props = defineProps({
+  metricName: {
+    type: String,
+    default: "",
+  },
+  chartData: {
+    type: Array,
+    default: () => [],
+  },
+  seriesList: {
+    type: Array,
+    default: () => [],
+  },
+  fullscreen: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const chartRef = ref(null);
+
+function getSeriesList() {
+  if (props.seriesList.length) return props.seriesList;
+  if (props.chartData.length) {
+    return [{ name: props.metricName || "暂无数据", data: props.chartData }];
+  }
+  return [];
+}
+
+function buildOption() {
+  const seriesList = getSeriesList();
+  if (!seriesList.length) {
+    return {
+      title: {
+        text: "暂无数据",
+        left: "center",
+        top: "center",
+        textStyle: { color: "#999", fontSize: 14, fontWeight: "normal" },
+      },
+    };
+  }
+
+  const labelOrder = [];
+  const labelSeen = new Set();
+  seriesList.forEach((series) => {
+    (series.data || []).forEach((point) => {
+      if (!labelSeen.has(point.label)) {
+        labelSeen.add(point.label);
+        labelOrder.push({
+          label: point.label,
+          timestamp: point.timestamp || 0,
+        });
+      }
+    });
+  });
+  labelOrder.sort((a, b) => a.timestamp - b.timestamp);
+  const xData = labelOrder.map((item) => item.label);
+  const barWidth = seriesList.length > 1 ? Math.max(8, 16 / seriesList.length) : 16;
+
+  const barSeries = seriesList.map((series, index) => ({
+    name: series.name,
+    type: "bar",
+    barWidth,
+    data: xData.map((label) => {
+      const point = (series.data || []).find((item) => item.label === label);
+      return point ? point.value : null;
+    }),
+    itemStyle: {
+      borderRadius: [4, 4, 0, 0],
+      color: CHART_COLORS[index % CHART_COLORS.length],
+    },
+  }));
+
+  return {
+    color: CHART_COLORS,
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(255, 255, 255, 0.9)",
+      borderColor: "rgba(0, 0, 0, 0.08)",
+    },
+    legend: {
+      type: "scroll",
+      top: 10,
+      left: "center",
+      data: seriesList.map((item) => item.name),
+      icon: "rect",
+      itemWidth: 12,
+      itemHeight: 8,
+      textStyle: {
+        fontSize: 12,
+        color: "#666",
+      },
+    },
+    grid: {
+      left: props.fullscreen ? "8%" : "10%",
+      right: props.fullscreen ? "6%" : "6%",
+      top: props.fullscreen ? 48 : 56,
+      bottom: props.fullscreen ? 36 : 60,
+    },
+    dataZoom: [
+      {
+        type: "slider",
+        show: xData.length > 4,
+        xAxisIndex: 0,
+        height: 18,
+        bottom: 10,
+        borderColor: "transparent",
+        backgroundColor: "#f5f6f7",
+        fillerColor: "rgba(64, 184, 131, 0.15)",
+        handleSize: "80%",
+      },
+    ],
+    xAxis: {
+      type: "category",
+      data: xData,
+      axisLine: {
+        lineStyle: { color: "#e5e5e5" },
+      },
+      axisLabel: {
+        color: "#999",
+        fontSize: 11,
+      },
+    },
+    yAxis: {
+      type: "value",
+      min: 0,
+      splitLine: {
+        lineStyle: {
+          type: "dashed",
+          color: "#eee",
+        },
+      },
+      axisLabel: {
+        color: "#999",
+        fontSize: 11,
+      },
+    },
+    series: barSeries,
+  };
+}
+
+function resizeChart(instance) {
+  if (!instance) return;
+  setTimeout(() => instance.resize(), 80);
+  setTimeout(() => instance.resize(), 300);
+}
+
+function renderChart() {
+  nextTick(() => {
+    if (!chartRef.value) return;
+    chartRef.value.init(echarts, (instance) => {
+      instance.setOption(buildOption(), true);
+      if (props.fullscreen) {
+        resizeChart(instance);
+      }
+    });
+  });
+}
+
+watch(
+  () => [props.metricName, props.chartData, props.seriesList, props.fullscreen],
+  () => {
+    renderChart();
+  },
+  { deep: true, immediate: true }
+);
+</script>
+
+<style lang="scss" scoped>
+.chart-panel {
+  padding: 10px 0 0;
+
+  &--fullscreen {
+    flex: 1;
+    width: 100%;
+    height: 100%;
+    padding: 0;
+    border-radius: 0;
+    display: flex;
+    flex-direction: column;
+    box-sizing: border-box;
+
+    :deep(.lime-echart),
+    :deep(.lime-echart__canvas) {
+      width: 100% !important;
+      height: 100% !important;
+    }
+  }
+
+  &__echart {
+    width: 100%;
+    height: 520rpx;
+
+    &--fullscreen {
+      flex: 1;
+      width: 100%;
+      height: 100% !important;
+      min-height: 0;
+    }
+  }
+}
+</style>

+ 151 - 0
src/pages/business/ems/survey/details.vue

@@ -0,0 +1,151 @@
+<template>
+  <u-sticky class="shadow-default" bgColor="#fff" style="top: 0">
+    <u-navbar :titleStyle="{ color: '#000' }" :autoBack="true" title="设备详情" :placeholder="true" :safeAreaInsetTop="true" bgColor="#fff">
+      <template #left>
+        <view class="u-navbar__content__left__item">
+          <u-icon name="arrow-left" size="20" color="#000"></u-icon>
+        </view>
+      </template>
+    </u-navbar>
+
+    <u-tabs
+      :list="tabsList"
+      :current="tabsCurrent"
+      @click="tabsClick"
+      :lineColor="proxy.$settingStore.themeColor.color"
+      :activeStyle="{ color: '#333', fontSize: '14px', fontWeight: 'bold' }"
+      :inactiveStyle="{ color: '#909399', fontSize: '14px' }"
+      :scrollable="false"
+    ></u-tabs>
+  </u-sticky>
+
+  <oa-scroll
+    customClass="details-container scroll-height"
+    :isSticky="true"
+    :refresherLoad="false"
+    :refresherEnabled="true"
+    :refresherEnabledTitle="false"
+    :refresherDefaultStyle="'none'"
+    :refresherThreshold="44"
+    :refresherBackground="'#f5f6f7'"
+    :customStyle="{
+      //#ifdef APP-PLUS || MP-WEIXIN
+      height: `calc(100vh - (44px + 44px + ${proxy.$settingStore.StatusBarHeight}))`,
+      //#endif
+      //#ifdef H5
+      height: 'calc(100vh - (44px + 44px))',
+      //#endif
+    }"
+    @refresh="init"
+    :data-theme="'theme-' + proxy.$settingStore.themeColor.name"
+  >
+    <template #default>
+      <device-info
+        v-show="tabsCurrent === 0"
+        ref="deviceInfoRef"
+        :product-id="query.productId"
+        :device-id="query.deviceId"
+        @loaded="handleDeviceLoaded"
+      />
+
+      <realtime-data
+        v-show="tabsCurrent === 1"
+        ref="realtimeDataRef"
+        :product-id="query.productId"
+        :device-uuid="detail.deviceUuid"
+      />
+
+      <metric-chart-tab
+        v-show="tabsCurrent === 2"
+        ref="historyChartRef"
+        chart-type="history"
+        :product-id="query.productId"
+        :device-uuid="detail.deviceUuid"
+      />
+
+      <metric-chart-tab
+        v-show="tabsCurrent === 3"
+        ref="usageChartRef"
+        chart-type="usage"
+        :product-id="query.productId"
+        :device-uuid="detail.deviceUuid"
+      />
+    </template>
+  </oa-scroll>
+</template>
+
+<script setup>
+/*----------------------------------依赖引入-----------------------------------*/
+import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
+import { getCurrentInstance, reactive, toRefs, ref } from "vue";
+/*----------------------------------组件引入-----------------------------------*/
+import deviceInfo from "./components/deviceInfo.vue";
+import realtimeData from "./components/realtimeData.vue";
+import metricChartTab from "./components/metricChartTab.vue";
+/*----------------------------------变量声明-----------------------------------*/
+const { proxy } = getCurrentInstance();
+const deviceInfoRef = ref(null);
+const realtimeDataRef = ref(null);
+const historyChartRef = ref(null);
+const usageChartRef = ref(null);
+
+const state = reactive({
+  tabsList: [{ name: "设备信息" }, { name: "实时数据" }, { name: "历史曲线" }, { name: "用量分析" }],
+  tabsCurrent: 0,
+  detail: {},
+  query: {
+    productId: "",
+    deviceId: "",
+  },
+});
+
+const { tabsList, tabsCurrent, detail, query } = toRefs(state);
+
+function loadTabData(index, force = false) {
+  if (index === 1) {
+    realtimeDataRef.value?.refresh();
+    return;
+  }
+  if (index === 2) {
+    historyChartRef.value?.refresh(force);
+    return;
+  }
+  if (index === 3) {
+    usageChartRef.value?.refresh(force);
+  }
+}
+
+function handleDeviceLoaded(deviceDetail) {
+  state.detail = deviceDetail;
+}
+
+function init() {
+  deviceInfoRef.value?.refresh();
+  loadTabData(state.tabsCurrent, true);
+}
+
+function tabsClick(e) {
+  state.tabsCurrent = e.index;
+  loadTabData(e.index);
+}
+
+onShow(() => {
+  proxy.$settingStore.systemThemeColor([1]);
+});
+
+onLoad((options) => {
+  state.query.productId = options.productId || "";
+  state.query.deviceId = options.deviceId || "";
+});
+
+onUnload(() => {
+  historyChartRef.value?.closeChartFullscreen();
+  usageChartRef.value?.closeChartFullscreen();
+});
+</script>
+
+<style lang="scss" scoped>
+.details-container {
+  background-color: #f5f6f7;
+}
+</style>

+ 328 - 0
src/pages/business/ems/survey/index.vue

@@ -0,0 +1,328 @@
+<template>
+  <u-sticky class="shadow-default" bgColor="#fff" style="top: 0">
+    <u-navbar :titleStyle="{ color: '#000' }" :autoBack="true" title="设备监控" :placeholder="true" :safeAreaInsetTop="true"
+      bgColor="#fff">
+      <template #left>
+        <view class="u-navbar__content__left__item">
+          <u-icon name="arrow-left" size="20" color="#000"></u-icon>
+        </view>
+      </template>
+    </u-navbar>
+
+    <view class="survey-header__search plr10">
+      <u--input style="width: 100%" v-model="form.deviceName" placeholder="搜索设备" prefixIcon="search"
+        prefixIconStyle="font-size: 22px;color: #909399" customStyle="height:35px;background-color:#f5f6fa;"
+        @confirm="init()" clearable></u--input>
+    </view>
+
+    <oa-switch :items="filterItems" @change="handleFilterChange" @reset="resetSwitch" />
+  </u-sticky>
+
+  <oa-scroll customClass="survey-container scroll-height" :isSticky="true" :pageSize="params.size" :total="total"
+    :customStyle="{
+      //#ifdef APP-PLUS || MP-WEIXIN
+      height: `calc(100vh - (44px + 35px + 50px + ${proxy.$settingStore.StatusBarHeight}))`,
+      //#endif
+      //#ifdef H5
+      height: 'calc(100vh - (44px + 35px + 50px))',
+      //#endif
+    }" :refresherLoad="true" :refresherEnabled="true" :refresherDefaultStyle="'none'" :refresherThreshold="44"
+    :refresherBackground="'#eef3fb'" @load="load" @refresh="refresh"
+    :data-theme="'theme-' + proxy.$settingStore.themeColor.name">
+    <template #default>
+      <u-loading-page :loading="loading" fontSize="16" style="z-index: 99"></u-loading-page>
+
+      <view class="device-list p10">
+        <view class="device-card bg-white radius mb10 p15" v-for="(item, index) in dataList" :key="index"
+          @click="handleToDevice(item)">
+          <view class="device-card__header flex">
+            <image class="device-card__icon" :src="item.typeImg || '/static/images/device/1.png'" mode="aspectFill">
+            </image>
+            <view class="device-card__title text-ellipsis">{{ item.deviceName || "-" }}</view>
+          </view>
+
+          <view class="device-card__tags flex mt10">
+            <view class="device-card__tag device-card__tag--product">{{ proxy.$common.mapping('name', 'value',
+              item.productId, productOptions) || "未知产品" }}</view>
+            <view class="device-card__tag"
+              :class="item.deviceStatus == 1 ? 'device-card__tag--online' : 'device-card__tag--offline'">
+              {{ item.deviceStatus == 1 ? "在线" : "离线" }}
+            </view>
+          </view>
+
+          <view class="device-card__info mt10 p10 radius">
+            <view class="device-card__info-row font12">
+              <text class="device-card__info-label">设备编码:</text>
+              <text class="device-card__info-value">{{ item.deviceId || "-" }}</text>
+            </view>
+            <view class="device-card__info-row font12 mt5">
+              <text class="device-card__info-label">最近更新时间:</text>
+              <text class="device-card__info-value">{{ ((item.updatedTime || item.lastOnlineTime || item.createdTime) ||
+                "-").replace("T", " ").split(".")[0] }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </template>
+  </oa-scroll>
+</template>
+
+<script setup>
+/*----------------------------------依赖引入-----------------------------------*/
+import { onLoad, onShow } from "@dcloudio/uni-app";
+import { getCurrentInstance, reactive, toRefs, computed } from "vue";
+/*----------------------------------接口引入-----------------------------------*/
+import { getEmsDevicePage, getEmsProductPage } from "@/api/business/ems/survey.js";
+/*----------------------------------store引入-----------------------------------*/
+import { commonStores } from "@/store/modules/index";
+
+const { proxy } = getCurrentInstance();
+const commonStore = commonStores();
+
+const state = reactive({
+  loading: false,
+  dataList: [],
+  total: 0,
+  productOptions: [{ name: "全部产品", value: "" }],
+  params: {
+    deviceName: "",
+    productId: "",
+    productName: "",
+    categoryType: 3,
+    deviceStatus: undefined,
+    size: 20,
+    current: 1,
+  },
+  form: {
+    deviceName: "",
+    systemName: "全部系统",
+    productName: "全部产品",
+    deviceStatus: "",
+  },
+});
+
+const { loading, dataList, total, params, form, productOptions } = toRefs(state);
+
+const filterItems = computed(() => [
+  {
+    key: "system",
+    label: state.form.systemName || "全部系统",
+    active: state.form.systemName !== "全部系统",
+    type: "picker",
+    options: [
+      { name: "全部系统", value: "" },
+      { name: "配电系统", value: "配电系统" },
+      { name: "给水系统", value: "给水系统" },
+      { name: "空调系统", value: "空调系统" },
+      { name: "电力系统", value: "电力系统" },
+      { name: "环境系统", value: "环境系统" },
+      { name: "照明系统", value: "照明系统" },
+      { name: "安防系统", value: "安防系统" },
+    ],
+    pickerTitle: "请选择系统",
+  },
+  {
+    key: "product",
+    label: state.form.productName || "全部产品",
+    active: state.form.productName !== "全部产品",
+    type: "picker",
+    options: state.productOptions,
+    pickerTitle: "请选择产品",
+  },
+  {
+    key: "status",
+    label: state.form.deviceStatus === "1" ? "在线设备" : state.form.deviceStatus === "2" ? "离线设备" : "全部设备",
+    active: !!state.form.deviceStatus,
+    type: "sheet",
+    options: [
+      { name: "全部设备", value: "" },
+      { name: "在线设备", value: "1" },
+      { name: "离线设备", value: "2" },
+    ],
+  },
+]);
+
+function handleFilterChange({ key, value }) {
+  if (key === "system") {
+    state.form.systemName = value.name;
+    state.params.productName = value.value;
+    state.params.productId = "";
+    state.form.productName = "全部产品";
+    fetchDeviceList();
+    return;
+  }
+
+  if (key === "product") {
+    state.form.productName = value.name;
+    state.params.productId = value.value || "";
+    if (value.value) {
+      state.params.productName = "";
+      state.form.systemName = "全部系统";
+    }
+    fetchDeviceList();
+    return;
+  }
+
+  if (key === "status") {
+    state.form.deviceStatus = value.value;
+    state.params.deviceStatus = value.value || undefined;
+    fetchDeviceList();
+  }
+}
+
+function fetchProductList() {
+  getEmsProductPage({ current: 1, size: 1000, productName: "" })
+    .then((requset) => {
+      if (requset.status === "SUCCESS") {
+        const list = (requset.data?.records || []).map((item) => ({
+          name: item.productName,
+          value: item.id,
+        }));
+        state.productOptions = [{ name: "全部产品", value: "" }, ...list];
+      }
+    })
+    .catch(() => { });
+}
+
+function fetchDeviceList() {
+  state.loading = true;
+  state.params.deviceName = state.form.deviceName;
+  getEmsDevicePage(state.params)
+    .then((requset) => {
+      if (requset.status === "SUCCESS") {
+        state.dataList = (requset.data.records || []).map((item) => ({
+          ...item,
+          typeImg: item.typeImg || "/static/images/device/1.png",
+        }));
+        state.total = requset.data.total || 0;
+      }
+      state.loading = false;
+    })
+    .catch(() => {
+      state.loading = false;
+    });
+}
+
+function init() {
+  state.params.current = 1;
+  state.params.size = 20;
+  fetchDeviceList();
+}
+
+function handleToDevice(item) {
+  commonStore.deviceManageData = {
+    ...item,
+    createdTime: item.createdTime ? item.createdTime.replace("T", " ") : "",
+    productName: item.productName || state.params.productName,
+    typeImg: item.typeImg || commonStore.deviceManageData?.typeImg || "/static/images/device/1.png",
+  };
+  proxy.$tab.navigateTo(`/pages/business/ems/survey/details?productId=${item.productId}&deviceId=${item.deviceId}`);
+}
+
+function load() {
+  state.params.size += 10;
+  fetchDeviceList();
+}
+
+function refresh() {
+  init();
+}
+
+function resetSwitch() {
+  state.form = {
+    deviceName: "",
+    systemName: "全部系统",
+    productName: "全部产品",
+    deviceStatus: "",
+  };
+  state.params.deviceName = "";
+  state.params.productId = "";
+  state.params.productName = "";
+  state.params.deviceStatus = undefined;
+  init();
+}
+
+onShow(() => {
+  proxy.$settingStore.systemThemeColor([1]);
+});
+
+onLoad(() => {
+  fetchProductList();
+  init();
+});
+</script>
+
+<style lang="scss" scoped>
+.survey-header {
+  &__search {
+    margin-top: 4px;
+    padding-bottom: 4px;
+  }
+}
+
+.device-list {
+  min-height: 100%;
+  background-color: #eef3fb;
+}
+
+.device-card {
+  box-shadow: 0 2px 8px rgba(74, 144, 226, 0.08);
+
+  &__header {
+    align-items: center;
+  }
+
+  &__icon {
+    width: 28px;
+    height: 28px;
+    border-radius: 6px;
+    flex-shrink: 0;
+    margin-right: 10px;
+  }
+
+  &__title {
+    flex: 1;
+    font-size: 16px;
+    font-weight: 600;
+    color: #1a1a1a;
+  }
+
+  &__tags {
+    gap: 8px;
+  }
+
+  &__tag {
+    font-size: 12px;
+    padding: 2px 10px;
+    border-radius: 4px;
+    line-height: 20px;
+
+    &--product {
+      color: #4a90e2;
+      background-color: rgba(74, 144, 226, 0.12);
+    }
+
+    &--online {
+      color: #16a34a;
+      background-color: rgba(22, 163, 74, 0.12);
+    }
+
+    &--offline {
+      color: #ef4444;
+      background-color: rgba(239, 68, 68, 0.12);
+    }
+  }
+
+  &__info {
+    background-color: #f3f6fb;
+  }
+
+  &__info-label {
+    color: #8c8c8c;
+  }
+
+  &__info-value {
+    color: #666666;
+  }
+}
+</style>

+ 11 - 1
src/plugins/time.plugins.js

@@ -213,5 +213,15 @@ export default {
             startTime: start,
             endTime: end,
         };
-    }
+    },
+    /**
+     * @格式化接口返回的时间(T 替换为空格,去除毫秒)
+     * @param {string|number|Date|null|undefined} time
+     * @param {string} placeholder 空值占位符
+     * @returns {string}
+     */
+    formatDateTime(time, placeholder = "--") {
+        if (time === null || time === undefined || time === "") return placeholder;
+        return String(time).replace("T", " ").split(".")[0];
+    },
 }