yuan
3 天以前 80ba6fd4314eaf5fbaf73198d1a61a1323fa4f45
feat: 添加能耗数据查询和电表管理功能
已添加11个文件
已修改1个文件
2304 ■■■■■ 文件已修改
src/api/energyManagement/statisticEle.js 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/energyManagement/tqdianbiao.js 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Pagination/index.vue 208 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/collectorArchive/components/formDia.vue 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/collectorArchive/index.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyDataCollection/components/formDia.vue 210 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyDataCollection/components/meterFormDia.vue 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyDataCollection/index.vue 385 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyRealTimeMonitor/index.vue 336 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyStatistics/index.vue 426 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/meterArchive/components/formDia.vue 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/meterArchive/index.vue 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/energyManagement/statisticEle.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,140 @@
import request from "@/utils/request";
/** èƒ½è€—数据列表查询 */
export function listStatisticEle(query) {
  return request({
    url: "/statisticEle/list",
    method: "get",
    params: query,
  });
}
/** èƒ½è€—汇总统计 */
export function summaryStatisticEle(query) {
  return request({
    url: "/statisticEle/summary",
    method: "get",
    params: query,
  });
}
/** æ˜¨æ—¥ç”¨ç”µé‡æ±‡æ€» */
export function getYesterdaySummary() {
  return request({
    url: "/statisticEle/yesterday",
    method: "get",
  });
}
/** èŽ·å–æ˜¨å¤©æ—¥æœŸ YYYYMMDD */
export function getYesterdayDayTime() {
  const d = new Date();
  d.setDate(d.getDate() - 1);
  return formatDayTime(d);
}
/** èŽ·å–æ˜¨å¤©æ—¥æœŸ YYYY-MM-DD */
export function getYesterdayDayPicker() {
  const d = new Date();
  d.setDate(d.getDate() - 1);
  return formatDayPicker(d);
}
/** åŒæ­¥çŠ¶æ€ */
export function getSyncStatus() {
  return request({
    url: "/statisticEle/syncStatus",
    method: "get",
  });
}
/** æ ¼å¼åŒ–时间为小时维度 YYYYMMDDHH */
export function formatHourTime(date) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, "0");
  const d = String(date.getDate()).padStart(2, "0");
  const h = String(date.getHours()).padStart(2, "0");
  return `${y}${m}${d}${h}`;
}
/** æ ¼å¼åŒ–时间为天维度 YYYYMMDD */
export function formatDayTime(date) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, "0");
  const d = String(date.getDate()).padStart(2, "0");
  return `${y}${m}${d}`;
}
/** å¤©ç»´åº¦è½¬æ—¥æœŸé€‰æ‹©å™¨æ ¼å¼ YYYY-MM-DD */
export function formatDayPicker(date) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, "0");
  const d = String(date.getDate()).padStart(2, "0");
  return `${y}-${m}-${d}`;
}
/** æ ¼å¼åŒ–时间为月维度 YYYYMM */
export function formatMonthTime(date) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, "0");
  return `${y}${m}`;
}
/** æ ¼å¼åŒ–时间为年维度 YYYY */
export function formatYearTime(date) {
  return String(date.getFullYear());
}
/** è§£æžæ—¶é—´æ ‡è¯†ä¸ºå¯è¯»æ ¼å¼ */
export function parseTimeKey(timeKey, dimension) {
  if (!timeKey) return "-";
  if (dimension === "hour" && timeKey.length >= 10) {
    return `${timeKey.slice(0, 4)}-${timeKey.slice(4, 6)}-${timeKey.slice(6, 8)} ${timeKey.slice(8, 10)}:00`;
  }
  if ((dimension === "manual" || dimension === "minute") && timeKey.length >= 12) {
    return `${timeKey.slice(0, 4)}-${timeKey.slice(4, 6)}-${timeKey.slice(6, 8)} ${timeKey.slice(8, 10)}:${timeKey.slice(10, 12)}`;
  }
  if (dimension === "day" && timeKey.length >= 8) {
    return `${timeKey.slice(0, 4)}-${timeKey.slice(4, 6)}-${timeKey.slice(6, 8)}`;
  }
  if (dimension === "month" && timeKey.length >= 6) {
    return `${timeKey.slice(0, 4)}-${timeKey.slice(4, 6)}`;
  }
  if (dimension === "quarter" && timeKey.includes("Q")) {
    const [y, q] = timeKey.split("Q");
    return `${y}å¹´ ç¬¬${q}季度`;
  }
  if (dimension === "year" && timeKey.length >= 4) {
    return `${timeKey.slice(0, 4)}å¹´`;
  }
  return timeKey;
}
/** æ ¼å¼åŒ–时间为分钟维度 YYYYMMDDHHmm */
export function formatMinuteTime(date) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, "0");
  const d = String(date.getDate()).padStart(2, "0");
  const h = String(date.getHours()).padStart(2, "0");
  const min = String(date.getMinutes()).padStart(2, "0");
  return `${y}${m}${d}${h}${min}`;
}
/** æ—¶é—´èŒƒå›´è½¬æŸ¥è¯¢ key(支持分钟精度) */
export function formatRangeStart(date) {
  return formatMinuteTime(date);
}
export function formatRangeEnd(date) {
  return formatMinuteTime(date);
}
/** èŽ·å–æœ€è¿‘ N å°æ—¶çš„æ—¶é—´èŒƒå›´ */
export function getRecentHourRange(hours = 24) {
  const end = new Date();
  const start = new Date(end.getTime() - hours * 3600000);
  return {
    startTime: formatMinuteTime(start),
    endTime: formatMinuteTime(end),
  };
}
src/api/energyManagement/tqdianbiao.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
import request from "@/utils/request";
// ========== é‡‡é›†å™¨æ¡£æ¡ˆ ==========
export function collectorListPage(query) {
  return request({ url: "/tqdianbiao/collector/listPage", method: "get", params: query });
}
export function collectorListAll() {
  return request({ url: "/tqdianbiao/collector/listAll", method: "get" });
}
export function collectorAdd(data) {
  return request({ url: "/tqdianbiao/collector/add", method: "post", data });
}
export function collectorUpdate(data) {
  return request({ url: "/tqdianbiao/collector/update", method: "post", data });
}
export function collectorDelete(ids) {
  return request({ url: "/tqdianbiao/collector/delete", method: "delete", data: ids });
}
export function collectorSync() {
  return request({ url: "/tqdianbiao/collector/sync", method: "post" });
}
// ========== ç”µè¡¨æ¡£æ¡ˆ ==========
export function meterListPage(query) {
  return request({ url: "/tqdianbiao/meter/listPage", method: "get", params: query });
}
export function meterListAll() {
  return request({ url: "/tqdianbiao/meter/listAll", method: "get" });
}
export function meterAdd(data) {
  return request({ url: "/tqdianbiao/meter/add", method: "post", data });
}
export function meterUpdate(data) {
  return request({ url: "/tqdianbiao/meter/update", method: "post", data });
}
export function meterDelete(ids) {
  return request({ url: "/tqdianbiao/meter/delete", method: "delete", data: ids });
}
export function meterSync() {
  return request({ url: "/tqdianbiao/meter/sync", method: "post" });
}
// ========== ç”µé‡è®°å½•(小时/天手动录入) ==========
export function eleRecordListPage(query) {
  return request({ url: "/tqdianbiao/eleRecord/listPage", method: "get", params: query });
}
export function eleRecordAdd(data) {
  return request({ url: "/tqdianbiao/eleRecord/add", method: "post", data });
}
export function eleRecordUpdate(data) {
  return request({ url: "/tqdianbiao/eleRecord/update", method: "post", data });
}
export function eleRecordDelete(ids) {
  return request({ url: "/tqdianbiao/eleRecord/delete", method: "delete", data: ids });
}
export function eleRecordPrevReading(params) {
  return request({ url: "/tqdianbiao/eleRecord/prevReading", method: "get", params });
}
src/components/Pagination/index.vue
@@ -1,105 +1,105 @@
<template>
  <div :class="{ 'hidden': hidden }" class="pagination-container">
    <el-pagination
      :background="background"
      v-model:current-page="currentPage"
      v-model:page-size="pageSize"
      :layout="layout"
      :page-sizes="pageSizes"
      :pager-count="pagerCount"
      :total="total"
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
    />
  </div>
</template>
<script setup>
import { scrollTo } from '@/utils/scroll-to'
const props = defineProps({
  total: {
    required: true,
    type: Number
  },
  page: {
    type: Number,
    default: 1
  },
  limit: {
    type: Number,
    default: 20
  },
  pageSizes: {
    type: Array,
    default() {
      return [10, 20, 30, 50]
    }
  },
  // ç§»åŠ¨ç«¯é¡µç æŒ‰é’®çš„æ•°é‡ç«¯é»˜è®¤å€¼5
  pagerCount: {
    type: Number,
    default: document.body.clientWidth < 992 ? 5 : 7
  },
  layout: {
    type: String,
    default: 'total, sizes, prev, pager, next, jumper'
  },
  background: {
    type: Boolean,
    default: true
  },
  autoScroll: {
    type: Boolean,
    default: true
  },
  hidden: {
    type: Boolean,
    default: false
  }
})
const emit = defineEmits()
const currentPage = computed({
  get() {
    return props.page
  },
  set(val) {
    emit('update:page', val)
  }
})
const pageSize = computed({
  get() {
    return props.limit
  },
  set(val){
    emit('update:limit', val)
  }
})
function handleSizeChange(val) {
  if (currentPage.value * val > props.total) {
    currentPage.value = 1
  }
  emit('pagination', { page: currentPage.value, limit: val })
  if (props.autoScroll) {
    scrollTo(0, 800)
  }
}
function handleCurrentChange(val) {
  emit('pagination', { page: val, limit: pageSize.value })
  if (props.autoScroll) {
    scrollTo(0, 800)
  }
}
</script>
<style scoped>
.pagination-container {
  background: #fff;
}
.pagination-container.hidden {
  display: none;
}
<template>
  <div :class="{ 'hidden': hidden }" class="pagination-container">
    <el-pagination
      :background="background"
      v-model:current-page="currentPage"
      v-model:page-size="pageSize"
      :layout="layout"
      :page-sizes="pageSizes"
      :pager-count="pagerCount"
      :total="total"
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
    />
  </div>
</template>
<script setup>
import { scrollTo } from '@/utils/scroll-to'
const props = defineProps({
  total: {
    required: true,
    type: Number
  },
  page: {
    type: Number,
    default: 1
  },
  limit: {
    type: Number,
    default: 20
  },
  pageSizes: {
    type: Array,
    default() {
      return [10, 20, 30, 50, 100, 200, 500]
    }
  },
  // ç§»åŠ¨ç«¯é¡µç æŒ‰é’®çš„æ•°é‡ç«¯é»˜è®¤å€¼5
  pagerCount: {
    type: Number,
    default: document.body.clientWidth < 992 ? 5 : 7
  },
  layout: {
    type: String,
    default: 'total, sizes, prev, pager, next, jumper'
  },
  background: {
    type: Boolean,
    default: true
  },
  autoScroll: {
    type: Boolean,
    default: true
  },
  hidden: {
    type: Boolean,
    default: false
  }
})
const emit = defineEmits()
const currentPage = computed({
  get() {
    return props.page
  },
  set(val) {
    emit('update:page', val)
  }
})
const pageSize = computed({
  get() {
    return props.limit
  },
  set(val){
    emit('update:limit', val)
  }
})
function handleSizeChange(val) {
  if (currentPage.value * val > props.total) {
    currentPage.value = 1
  }
  emit('pagination', { page: currentPage.value, limit: val })
  if (props.autoScroll) {
    scrollTo(0, 800)
  }
}
function handleCurrentChange(val) {
  emit('pagination', { page: val, limit: pageSize.value })
  if (props.autoScroll) {
    scrollTo(0, 800)
  }
}
</script>
<style scoped>
.pagination-container {
  background: #fff;
}
.pagination-container.hidden {
  display: none;
}
</style>
src/views/energyManagement/collectorArchive/components/formDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
<template>
  <el-dialog v-model="visible" :title="title" width="600px" @close="closeDia">
    <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
      <el-form-item label="采集器档案ID" prop="collectorId">
        <el-input v-model="form.collectorId" placeholder="平台 cid" :disabled="operationType === 'edit'" />
      </el-form-item>
      <el-form-item label="采集器号" prop="collectorNo">
        <el-input v-model="form.collectorNo" placeholder="采集器号" />
      </el-form-item>
      <el-form-item label="在线状态" prop="online">
        <el-switch v-model="form.online" active-text="在线" inactive-text="离线" />
      </el-form-item>
      <el-form-item label="信号值" prop="csq">
        <el-input-number v-model="form.csq" :min="0" :max="31" style="width: 100%" />
      </el-form-item>
      <el-form-item label="上线时间" prop="connectTime">
        <el-input v-model="form.connectTime" placeholder="如 2026-06-15 10:00:00" />
      </el-form-item>
      <el-form-item label="掉线时间" prop="disconnectTime">
        <el-input v-model="form.disconnectTime" placeholder="如 2026-06-15 10:00:00" />
      </el-form-item>
      <el-form-item label="备注" prop="description">
        <el-input v-model="form.description" type="textarea" :rows="2" />
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { collectorAdd, collectorUpdate } from "@/api/energyManagement/tqdianbiao.js";
const emit = defineEmits(["close"]);
const visible = ref(false);
const submitting = ref(false);
const operationType = ref("add");
const title = ref("");
const formRef = ref(null);
const defaultForm = () => ({
  id: null,
  collectorId: "",
  collectorNo: "",
  online: true,
  csq: null,
  connectTime: "",
  disconnectTime: "",
  description: "",
});
const form = reactive(defaultForm());
const rules = {
  collectorId: [{ required: true, message: "请输入采集器档案ID", trigger: "blur" }],
  collectorNo: [{ required: true, message: "请输入采集器号", trigger: "blur" }],
};
function open(type, row) {
  operationType.value = type;
  title.value = type === "add" ? "新增采集器" : "编辑采集器";
  Object.assign(form, defaultForm(), type === "edit" ? { ...row } : {});
  visible.value = true;
}
function closeDia() {
  emit("close");
}
async function submit() {
  await formRef.value.validate();
  submitting.value = true;
  try {
    if (operationType.value === "add") {
      await collectorAdd(form);
      ElMessage.success("新增成功");
    } else {
      await collectorUpdate(form);
      ElMessage.success("修改成功");
    }
    visible.value = false;
    emit("close");
  } finally {
    submitting.value = false;
  }
}
defineExpose({ open });
</script>
src/views/energyManagement/collectorArchive/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
<template>
  <div class="app-container">
    <div class="search_form">
      <div>
        <span class="search_title">关键词:</span>
        <el-input
          v-model="searchForm.keyword"
          style="width: 240px"
          placeholder="采集器号/档案ID/备注"
          clearable
          @keyup.enter="handleQuery"
        />
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px">搜索</el-button>
      </div>
      <div>
        <el-button type="success" :loading="syncing" @click="handleSync">同步采集器</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :tableLoading="tableLoading"
        @pagination="pagination"
      >
        <template #online="{ row }">
          <el-tag :type="row.online ? 'success' : 'danger'" size="small">
            {{ row.online ? "在线" : "离线" }}
          </el-tag>
        </template>
      </PIMTable>
    </div>
  </div>
</template>
<script setup>
import { onMounted, reactive, ref, toRefs } from "vue";
import { ElMessage } from "element-plus";
import { collectorListPage, collectorSync } from "@/api/energyManagement/tqdianbiao.js";
const tableLoading = ref(false);
const syncing = ref(false);
const tableData = ref([]);
const data = reactive({ searchForm: { keyword: "" } });
const { searchForm } = toRefs(data);
const page = reactive({ current: 1, size: 10, total: 0 });
const tableColumn = ref([
  { label: "采集器档案ID", prop: "collectorId", width: 130 },
  { label: "采集器号", prop: "collectorNo", width: 140 },
  { label: "在线状态", prop: "online", dataType: "slot", slot: "online", width: 100 },
  { label: "信号值", prop: "csq", width: 80 },
  { label: "上线时间", prop: "connectTime", minWidth: 160 },
  { label: "掉线时间", prop: "disconnectTime", minWidth: 160 },
  { label: "备注", prop: "description", minWidth: 120 },
  { label: "同步时间", prop: "syncTime", minWidth: 160 },
]);
function handleQuery() {
  page.current = 1;
  getList();
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
}
async function getList() {
  tableLoading.value = true;
  try {
    const res = await collectorListPage({ ...searchForm.value, current: page.current, size: page.size });
    tableData.value = res.data.records;
    page.total = res.data.total;
  } finally {
    tableLoading.value = false;
  }
}
async function handleSync() {
  syncing.value = true;
  try {
    const res = await collectorSync();
    ElMessage.success(res.msg || "同步成功");
    getList();
  } finally {
    syncing.value = false;
  }
}
onMounted(() => getList());
</script>
src/views/energyManagement/energyDataCollection/components/formDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,210 @@
<template>
  <el-dialog v-model="visible" :title="title" width="560px" @close="closeDia">
    <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
      <el-form-item label="电表" prop="meterId">
        <el-select
          v-model="form.meterId"
          placeholder="请选择电表"
          filterable
          style="width: 100%"
          :disabled="operationType === 'edit'"
          @change="handleMeterChange"
        >
          <el-option
            v-for="item in meterList"
            :key="item.meterId"
            :label="`${item.meterName || item.address} (ID:${item.meterId})`"
            :value="item.meterId"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="统计时间" prop="recordTime">
        <el-date-picker
          v-model="form.recordTime"
          type="datetime"
          placeholder="精确到分钟"
          format="YYYY-MM-DD HH:mm"
          value-format="YYYY-MM-DD HH:mm:00"
          style="width: 100%"
          @change="handleTimeChange"
        />
      </el-form-item>
      <el-form-item label="倍率">
        <el-input-number v-model="form.ratio" :min="1" style="width: 100%" @change="calcConsumption" />
      </el-form-item>
      <el-form-item label="上次电量" prop="prevReading">
        <el-input-number
          v-model="form.prevReading"
          :min="0"
          :precision="4"
          style="width: 100%"
          @change="calcConsumption"
        />
      </el-form-item>
      <el-form-item label="本次电量" prop="currReading">
        <el-input-number
          v-model="form.currReading"
          :min="0"
          :precision="4"
          style="width: 100%"
          @change="calcConsumption"
        />
      </el-form-item>
      <el-form-item label="本次用电量">
        <el-input :model-value="consumptionDisplay" disabled>
          <template #suffix>kWh</template>
        </el-input>
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { eleRecordAdd, eleRecordUpdate, meterListAll, eleRecordPrevReading } from "@/api/energyManagement/tqdianbiao.js";
import { formatMinuteTime } from "@/api/energyManagement/statisticEle.js";
const emit = defineEmits(["close"]);
const visible = ref(false);
const submitting = ref(false);
const operationType = ref("add");
const title = ref("");
const formRef = ref(null);
const meterList = ref([]);
const defaultForm = () => ({
  id: null,
  dimension: "manual",
  meterId: null,
  recordTime: "",
  prevReading: null,
  currReading: null,
  totalConsumption: null,
  ratio: 1,
});
const form = reactive(defaultForm());
const rules = {
  meterId: [{ required: true, message: "请选择电表", trigger: "change" }],
  recordTime: [{ required: true, message: "请选择时间", trigger: "change" }],
  prevReading: [{ required: true, message: "请输入上次电量", trigger: "blur" }],
  currReading: [{ required: true, message: "请输入本次电量", trigger: "blur" }],
};
const consumptionDisplay = computed(() => {
  if (form.totalConsumption == null) return "-";
  return Number(form.totalConsumption).toFixed(4);
});
async function loadMeters() {
  const res = await meterListAll();
  meterList.value = res.data || [];
}
function recordTimeFromRow(row) {
  if (row.timeKey?.length >= 12) {
    const k = row.timeKey;
    return `${k.slice(0, 4)}-${k.slice(4, 6)}-${k.slice(6, 8)} ${k.slice(8, 10)}:${k.slice(10, 12)}:00`;
  }
  if (row.timeKey?.length >= 10) {
    const k = row.timeKey;
    return `${k.slice(0, 4)}-${k.slice(4, 6)}-${k.slice(6, 8)} ${k.slice(8, 10)}:00:00`;
  }
  return "";
}
function calcConsumption() {
  if (form.prevReading == null || form.currReading == null || form.ratio == null) {
    form.totalConsumption = null;
    return;
  }
  const diff = form.currReading - form.prevReading;
  form.totalConsumption = Number((diff * form.ratio).toFixed(4));
}
function handleMeterChange(meterId) {
  const meter = meterList.value.find((m) => m.meterId === meterId);
  if (meter?.rate) {
    form.ratio = meter.rate;
  }
  calcConsumption();
  if (form.recordTime) {
    loadPrevReading();
  }
}
function handleTimeChange() {
  loadPrevReading();
}
async function loadPrevReading() {
  if (!form.meterId || !form.recordTime) return;
  const timeKey = formatMinuteTime(new Date(form.recordTime));
  const res = await eleRecordPrevReading({ meterId: form.meterId, timeKey });
  if (res.data != null) {
    form.prevReading = Number(res.data);
    calcConsumption();
  }
}
function open(type, row) {
  operationType.value = type;
  title.value = type === "add" ? "手动抄表录入" : "编辑抄表记录";
  Object.assign(form, defaultForm());
  if (type === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      meterId: row.meterId,
      recordTime: recordTimeFromRow(row),
      prevReading: row.prevReading != null ? Number(row.prevReading) : null,
      currReading: row.currReading != null ? Number(row.currReading) : null,
      totalConsumption: row.totalConsumption != null ? Number(row.totalConsumption) : null,
      ratio: row.ratio || 1,
    });
  }
  loadMeters();
  visible.value = true;
}
function closeDia() {
  emit("close");
}
async function submit() {
  await formRef.value.validate();
  calcConsumption();
  submitting.value = true;
  try {
    const payload = {
      id: form.id,
      dimension: "manual",
      meterId: form.meterId,
      timeKey: formatMinuteTime(new Date(form.recordTime)),
      prevReading: form.prevReading,
      currReading: form.currReading,
      totalConsumption: form.totalConsumption,
      ratio: form.ratio,
      readingMethod: "manual",
    };
    if (operationType.value === "add") {
      await eleRecordAdd(payload);
      ElMessage.success("录入成功");
    } else {
      await eleRecordUpdate(payload);
      ElMessage.success("修改成功");
    }
    visible.value = false;
    emit("close");
  } finally {
    submitting.value = false;
  }
}
defineExpose({ open });
</script>
src/views/energyManagement/energyDataCollection/components/meterFormDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,114 @@
<template>
  <el-dialog v-model="visible" :title="title" width="520px" @close="closeDia">
    <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
      <el-form-item label="电表名称" prop="meterName">
        <el-input v-model="form.meterName" placeholder="请输入电表名称" />
      </el-form-item>
      <el-form-item label="表地址" prop="address">
        <el-input v-model="form.address" placeholder="请输入表地址" />
      </el-form-item>
      <el-form-item label="备注">
        <el-input v-model="form.description" type="textarea" :rows="2" placeholder="备注" />
      </el-form-item>
      <el-form-item label="继电器状态" prop="relayState">
        <el-select v-model="form.relayState" style="width: 100%">
          <el-option label="合闸" value="1" />
          <el-option label="拉闸" value="0" />
        </el-select>
      </el-form-item>
      <el-form-item v-if="showRate" label="倍率">
        <el-input-number v-model="form.rate" :min="1" style="width: 100%" />
      </el-form-item>
      <el-form-item v-if="operationType === 'edit' && form.source === 'sync'" label="档案ID">
        <el-input :model-value="form.meterId" disabled />
      </el-form-item>
      <el-form-item v-if="operationType === 'edit' && form.source === 'sync'">
        <el-text type="info" size="small">同步电表仅可修改名称、表地址、备注、继电器状态(不同步到能源平台)</el-text>
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { meterAdd, meterUpdate } from "@/api/energyManagement/tqdianbiao.js";
const emit = defineEmits(["close"]);
const visible = ref(false);
const submitting = ref(false);
const operationType = ref("add");
const title = ref("");
const formRef = ref(null);
const defaultForm = () => ({
  id: null,
  meterId: null,
  meterName: "",
  address: "",
  description: "",
  relayState: "1",
  rate: 1,
  source: "manual",
});
const form = reactive(defaultForm());
const showRate = computed(() => operationType.value === "edit" && form.source === "manual");
const rules = {
  address: [{ required: true, message: "请输入表地址", trigger: "blur" }],
  relayState: [{ required: true, message: "请选择继电器状态", trigger: "change" }],
};
function open(type, row) {
  operationType.value = type;
  title.value = type === "add" ? "新增电表" : "编辑电表";
  Object.assign(form, defaultForm());
  if (type === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      meterId: row.meterId,
      meterName: row.meterName || row.address || "",
      address: row.address || "",
      description: row.description || "",
      relayState: row.relayState || "1",
      rate: row.rate || 1,
      source: row.source || "sync",
    });
  }
  visible.value = true;
}
function closeDia() {
  emit("close");
}
async function submit() {
  await formRef.value.validate();
  submitting.value = true;
  try {
    const payload = { ...form };
    if (!payload.meterName) {
      payload.meterName = payload.address;
    }
    if (operationType.value === "add") {
      await meterAdd(payload);
      ElMessage.success("新增成功");
    } else {
      await meterUpdate(payload);
      ElMessage.success("修改成功");
    }
    visible.value = false;
    emit("close");
  } finally {
    submitting.value = false;
  }
}
defineExpose({ open });
</script>
src/views/energyManagement/energyDataCollection/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,385 @@
<template>
  <div class="app-container">
    <el-card class="sync-card">
      <el-row :gutter="16">
        <el-col :span="6">
          <div class="sync-item">
            <div class="sync-label">电表数量</div>
            <div class="sync-value">{{ syncStatus.meterCount ?? 0 }}</div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="sync-item">
            <div class="sync-label">采集器在线</div>
            <div class="sync-value online">{{ syncStatus.onlineCollectorCount ?? 0 }} / {{ syncStatus.collectorCount ?? 0 }}</div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="sync-item">
            <div class="sync-label">小时数据同步</div>
            <div class="sync-value small">{{ lastHourSync }}</div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="sync-item">
            <div class="sync-label">电量记录数</div>
            <div class="sync-value">{{ syncStatus.recordCountByDimension?.hour ?? 0 }}</div>
          </div>
        </el-col>
      </el-row>
    </el-card>
    <el-tabs v-model="activeTab">
      <el-tab-pane label="电量数据" name="record">
        <el-card shadow="never">
          <template #header>
            <div class="card-header">
              <span>能耗数据采集</span>
              <span class="desc">同步小时电量 + æ‰‹åŠ¨æŠ„è¡¨ï¼ˆignore_radio=1)</span>
            </div>
          </template>
          <el-form :inline="true" class="search-form">
            <el-form-item label="时间范围">
              <el-date-picker
                v-model="hourRange"
                type="datetimerange"
                range-separator="至"
                start-placeholder="开始时间"
                end-placeholder="结束时间"
                value-format="YYYY-MM-DD HH:mm:ss"
                :default-time="defaultTime"
              />
            </el-form-item>
            <el-form-item>
              <el-button type="primary" :loading="loading" @click="handleRefresh">
                <el-icon><Refresh /></el-icon>
                åˆ·æ–°
              </el-button>
              <el-button type="primary" @click="openRecordForm('add')">手动抄表</el-button>
              <el-button type="danger" plain :disabled="!selectedRows.length" @click="handleDelete">删除</el-button>
            </el-form-item>
          </el-form>
          <el-row :gutter="16" class="stat-row">
            <el-col :span="8">
              <div class="stat-item">
                <div class="stat-label">记录条数</div>
                <div class="stat-value">{{ tableData.length }}</div>
              </div>
            </el-col>
            <el-col :span="8">
              <div class="stat-item">
                <div class="stat-label">总用电量(kWh)</div>
                <div class="stat-value">{{ totalConsumption }}</div>
              </div>
            </el-col>
            <el-col :span="8">
              <div class="stat-item">
                <div class="stat-label">涉及电表</div>
                <div class="stat-value">{{ meterCount }}</div>
              </div>
            </el-col>
          </el-row>
          <el-table
            v-loading="loading"
            :data="pagedData"
            border
            stripe
            height="calc(100vh - 480px)"
            @selection-change="handleSelectionChange"
          >
            <el-table-column type="selection" width="50" />
            <el-table-column label="时间" min-width="150">
              <template #default="{ row }">
                {{ formatRecordTime(row) }}
              </template>
            </el-table-column>
            <el-table-column prop="meterName" label="电表名称" min-width="120" show-overflow-tooltip />
            <el-table-column prop="meterId" label="电表ID" width="100" />
            <el-table-column prop="address" label="表地址" min-width="110" show-overflow-tooltip />
            <el-table-column prop="prevReading" label="上次电量" width="100" />
            <el-table-column prop="currReading" label="本次电量" width="100" />
            <el-table-column prop="ratio" label="倍率" width="70" />
            <el-table-column prop="totalConsumption" label="本次用电量(kWh)" width="130" />
            <el-table-column label="抄表方式" width="90">
              <template #default="{ row }">
                <el-tag :type="row.readingMethod === 'manual' ? 'warning' : 'success'" size="small">
                  {{ row.readingMethod === "manual" ? "手动" : "同步" }}
                </el-tag>
              </template>
            </el-table-column>
            <el-table-column label="操作" width="80" fixed="right">
              <template #default="{ row }">
                <el-button
                  v-if="row.readingMethod === 'manual'"
                  link
                  type="primary"
                  @click="openRecordForm('edit', row)"
                >编辑</el-button>
              </template>
            </el-table-column>
          </el-table>
          <pagination
            v-show="tableData.length > 0"
            :total="tableData.length"
            :page="page.current"
            :limit="page.size"
            :page-sizes="[50, 100, 200, 500]"
            @pagination="handlePagination"
          />
        </el-card>
      </el-tab-pane>
      <el-tab-pane label="电表管理" name="meter">
        <el-card shadow="never">
          <div class="meter-toolbar">
            <el-input
              v-model="meterKeyword"
              placeholder="搜索电表名称/地址/备注"
              clearable
              style="width: 240px"
              @keyup.enter="loadMeters"
            />
            <el-button type="primary" @click="loadMeters">搜索</el-button>
            <el-button type="success" :loading="meterSyncing" @click="handleMeterSync">同步电表</el-button>
            <el-button type="primary" @click="openMeterForm('add')">新增电表</el-button>
          </div>
          <el-table v-loading="meterLoading" :data="meterTableData" border stripe height="calc(100vh - 420px)">
            <el-table-column label="电表名称" min-width="120" show-overflow-tooltip>
              <template #default="{ row }">{{ row.meterName || row.address || "-" }}</template>
            </el-table-column>
            <el-table-column prop="meterId" label="档案ID" width="110" />
            <el-table-column prop="address" label="表地址" min-width="120" />
            <el-table-column prop="rate" label="倍率" width="70" />
            <el-table-column label="来源" width="80">
              <template #default="{ row }">
                <el-tag :type="row.source === 'manual' ? 'warning' : 'info'" size="small">
                  {{ row.source === "manual" ? "手动" : "同步" }}
                </el-tag>
              </template>
            </el-table-column>
            <el-table-column label="继电器" width="80">
              <template #default="{ row }">
                <el-tag :type="row.relayState === '1' ? 'success' : 'danger'" size="small">
                  {{ row.relayState === "1" ? "合闸" : "拉闸" }}
                </el-tag>
              </template>
            </el-table-column>
            <el-table-column prop="description" label="备注" min-width="100" show-overflow-tooltip />
            <el-table-column label="操作" width="140" fixed="right">
              <template #default="{ row }">
                <el-button link type="primary" @click="openMeterForm('edit', row)">编辑</el-button>
                <el-button v-if="row.source === 'manual'" link type="danger" @click="handleMeterDelete(row)">删除</el-button>
              </template>
            </el-table-column>
          </el-table>
          <pagination
            v-show="meterPage.total > 0"
            :total="meterPage.total"
            :page="meterPage.current"
            :limit="meterPage.size"
            :page-sizes="[50, 100, 200, 500]"
            @pagination="handleMeterPagination"
          />
        </el-card>
      </el-tab-pane>
    </el-tabs>
    <form-dia ref="formDiaRef" @close="handleRefresh" />
    <meter-form-dia ref="meterFormDiaRef" @close="loadMeters" />
  </div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDia from "./components/formDia.vue";
import MeterFormDia from "./components/meterFormDia.vue";
import { eleRecordDelete, meterListPage, meterSync, meterDelete } from "@/api/energyManagement/tqdianbiao.js";
import {
  listStatisticEle,
  getSyncStatus,
  formatMinuteTime,
  parseTimeKey,
  getRecentHourRange,
} from "@/api/energyManagement/statisticEle.js";
const activeTab = ref("record");
const loading = ref(false);
const tableData = ref([]);
const syncStatus = ref({});
const selectedRows = ref([]);
const formDiaRef = ref(null);
const meterFormDiaRef = ref(null);
const page = reactive({ current: 1, size: 500 });
const defaultTime = [
  new Date(2000, 0, 1, 0, 0, 0),
  new Date(2000, 0, 1, 23, 59, 59),
];
const hourRange = ref([]);
const meterLoading = ref(false);
const meterSyncing = ref(false);
const meterKeyword = ref("");
const meterTableData = ref([]);
const meterPage = reactive({ current: 1, size: 500, total: 0 });
const lastHourSync = computed(() => syncStatus.value.lastSyncTimeByType?.hour || "-");
const totalConsumption = computed(() => {
  return tableData.value.reduce((sum, item) => sum + (item.totalConsumption || 0), 0).toFixed(2);
});
const meterCount = computed(() => new Set(tableData.value.map((item) => item.meterId)).size);
const pagedData = computed(() => {
  const start = (page.current - 1) * page.size;
  return tableData.value.slice(start, start + page.size);
});
function formatRecordTime(row) {
  const dim = row.readingMethod === "manual" ? "manual" : "hour";
  if (row.timeKey?.length === 12) return parseTimeKey(row.timeKey, "manual");
  return parseTimeKey(row.timeKey, dim);
}
function initDefaultRange() {
  const now = new Date();
  const start = new Date(now.getTime() - 7 * 86400000);
  hourRange.value = [
    `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, "0")}-${String(start.getDate()).padStart(2, "0")} 00:00:00`,
    `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} 23:59:59`,
  ];
}
function buildTimeParams() {
  if (!hourRange.value || hourRange.value.length !== 2) {
    return { ...getRecentHourRange(24 * 7), ignoreRadio: 1 };
  }
  return {
    startTime: formatMinuteTime(new Date(hourRange.value[0])),
    endTime: formatMinuteTime(new Date(hourRange.value[1])),
    ignoreRadio: 1,
  };
}
async function loadSyncStatus() {
  const res = await getSyncStatus();
  syncStatus.value = res.data || {};
}
async function fetchData() {
  loading.value = true;
  try {
    const params = { dimension: "hour", ...buildTimeParams() };
    const res = await listStatisticEle(params);
    tableData.value = res.data || [];
    page.current = 1;
  } finally {
    loading.value = false;
  }
}
async function loadMeters() {
  meterLoading.value = true;
  try {
    const res = await meterListPage({
      keyword: meterKeyword.value,
      current: meterPage.current,
      size: meterPage.size,
    });
    meterTableData.value = res.data.records || [];
    meterPage.total = res.data.total || 0;
  } finally {
    meterLoading.value = false;
  }
}
function handleSelectionChange(rows) {
  selectedRows.value = rows;
}
function openRecordForm(type, row) {
  formDiaRef.value.open(type, row);
}
function openMeterForm(type, row) {
  meterFormDiaRef.value.open(type, row);
}
function handleDelete() {
  ElMessageBox.confirm("确认删除选中的电量记录?", "提示", { type: "warning" })
    .then(async () => {
      await eleRecordDelete(selectedRows.value.map((r) => r.id));
      ElMessage.success("删除成功");
      handleRefresh();
    })
    .catch(() => {});
}
function handleMeterDelete(row) {
  ElMessageBox.confirm(`确认删除电表「${row.meterName}」?`, "提示", { type: "warning" })
    .then(async () => {
      await meterDelete([row.id]);
      ElMessage.success("删除成功");
      loadMeters();
    })
    .catch(() => {});
}
async function handleMeterSync() {
  meterSyncing.value = true;
  try {
    const res = await meterSync();
    ElMessage.success(res.msg || "同步成功");
    loadMeters();
    loadSyncStatus();
  } finally {
    meterSyncing.value = false;
  }
}
function handleRefresh() {
  loadSyncStatus();
  fetchData();
}
function handlePagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
}
function handleMeterPagination(obj) {
  meterPage.current = obj.page;
  meterPage.size = obj.limit;
  loadMeters();
}
onMounted(() => {
  initDefaultRange();
  handleRefresh();
  loadMeters();
});
</script>
<style scoped>
.sync-card { margin-bottom: 16px; }
.sync-item { text-align: center; padding: 8px 0; }
.sync-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
.sync-value { font-size: 20px; font-weight: 600; }
.sync-value.online { color: #67c23a; }
.sync-value.small { font-size: 13px; font-weight: 500; }
.card-header { display: flex; align-items: center; gap: 12px; }
.card-header .desc { font-size: 13px; color: #909399; }
.search-form { margin-bottom: 16px; }
.stat-row { margin-bottom: 16px; }
.stat-item { background: #f5f7fa; border-radius: 8px; padding: 16px; text-align: center; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
.stat-value { font-size: 24px; font-weight: 600; }
.meter-toolbar { display: flex; gap: 10px; margin-bottom: 16px; align-items: center; }
</style>
src/views/energyManagement/energyRealTimeMonitor/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,336 @@
<template>
  <div class="app-container">
    <div class="page-header">
      <div>
        <h2>能耗实时监控</h2>
        <p class="subtitle">展示本地已同步的用电数据(定时任务每小时同步)</p>
      </div>
      <div class="header-actions">
        <el-tag :type="autoRefresh ? 'success' : 'info'" size="small">
          {{ autoRefresh ? "自动刷新中" : "已暂停" }}
        </el-tag>
        <span class="update-time">更新:{{ lastUpdateTime }}</span>
        <el-switch v-model="autoRefresh" active-text="自动刷新" @change="toggleAutoRefresh" />
        <el-button type="primary" :loading="loading" @click="loadData">
          <el-icon><Refresh /></el-icon>
          ç«‹å³åˆ·æ–°
        </el-button>
      </div>
    </div>
    <el-card class="yesterday-banner" v-loading="yesterdayLoading" shadow="hover">
      <div class="yesterday-row">
        <div>
          <div class="yesterday-title">昨日总用电({{ getYesterdayDayPicker() }})</div>
          <div class="yesterday-value">{{ yesterdaySummary.totalConsumption ?? 0 }} <span>kWh</span></div>
        </div>
        <div class="yesterday-stats">
          <span>平均 {{ yesterdaySummary.avgConsumption ?? 0 }} kWh</span>
          <span>最大 {{ yesterdaySummary.maxConsumption ?? 0 }} kWh</span>
          <span>最小 {{ yesterdaySummary.minConsumption ?? 0 }} kWh</span>
        </div>
      </div>
    </el-card>
    <el-row :gutter="16" class="monitor-cards">
      <el-col :span="8">
        <el-card shadow="hover">
          <div class="monitor-card">
            <div class="monitor-title">当前小时用电</div>
            <div class="monitor-value">
              {{ currentHourConsumption }}
              <span class="unit">kWh</span>
            </div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="8">
        <el-card shadow="hover">
          <div class="monitor-card">
            <div class="monitor-title">近24小时累计</div>
            <div class="monitor-value">
              {{ totalConsumption }}
              <span class="unit">kWh</span>
            </div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="8">
        <el-card shadow="hover">
          <div class="monitor-card">
            <div class="monitor-title">平均小时用电</div>
            <div class="monitor-value">
              {{ avgConsumption }}
              <span class="unit">kWh</span>
            </div>
          </div>
        </el-card>
      </el-col>
    </el-row>
    <el-card class="chart-card">
      <template #header>
        <div class="card-header">
          <span>近24小时用电趋势</span>
          <el-radio-group v-model="chartType" size="small" @change="renderChart">
            <el-radio-button value="line">折线图</el-radio-button>
            <el-radio-button value="bar">柱状图</el-radio-button>
          </el-radio-group>
        </div>
      </template>
      <div ref="chartRef" class="chart-container"></div>
    </el-card>
    <el-card class="table-card">
      <template #header>
        <span>实时采集明细</span>
      </template>
      <el-table v-loading="loading" :data="records" border stripe max-height="320">
        <el-table-column label="时间" min-width="160">
          <template #default="{ row }">
            {{ parseTimeKey(row.timeKey, "hour") }}
          </template>
        </el-table-column>
        <el-table-column prop="meterId" label="电表ID" width="100" />
        <el-table-column prop="totalConsumption" label="用电量(kWh)" width="120" />
        <el-table-column prop="startTime" label="开始时间" min-width="160" />
        <el-table-column prop="endTime" label="结束时间" min-width="160" />
        <el-table-column prop="endReading" label="当前读数" min-width="160" show-overflow-tooltip />
      </el-table>
    </el-card>
  </div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import * as echarts from "echarts";
import {
  summaryStatisticEle,
  getYesterdaySummary,
  getYesterdayDayPicker,
  parseTimeKey,
  getRecentHourRange,
} from "@/api/energyManagement/statisticEle.js";
const loading = ref(false);
const yesterdayLoading = ref(false);
const yesterdaySummary = ref({});
const autoRefresh = ref(true);
const lastUpdateTime = ref("-");
const chartType = ref("line");
const records = ref([]);
const chartRecords = ref([]);
const chartRef = ref(null);
let chartInstance = null;
let refreshTimer = null;
const totalConsumption = computed(() => {
  const total = chartRecords.value.reduce(
    (sum, item) => sum + (item.totalConsumption || 0),
    0
  );
  return total.toFixed(2);
});
const avgConsumption = computed(() => {
  if (!chartRecords.value.length) return "0.00";
  return (Number(totalConsumption.value) / chartRecords.value.length).toFixed(2);
});
const currentHourConsumption = computed(() => {
  if (!chartRecords.value.length) return "0.00";
  const latest = chartRecords.value[chartRecords.value.length - 1];
  return (latest.totalConsumption || 0).toFixed(2);
});
async function loadYesterday() {
  yesterdayLoading.value = true;
  try {
    const res = await getYesterdaySummary();
    yesterdaySummary.value = res.data || {};
  } finally {
    yesterdayLoading.value = false;
  }
}
async function loadData() {
  loading.value = true;
  try {
    const { startTime, endTime } = getRecentHourRange(24);
    const res = await summaryStatisticEle({
      dimension: "hour",
      startTime,
      endTime,
    });
    records.value = res.data?.records || [];
    chartRecords.value = res.data?.chartRecords || [];
    lastUpdateTime.value = new Date().toLocaleString();
    renderChart();
  } finally {
    loading.value = false;
  }
}
function renderChart() {
  if (!chartRef.value) return;
  if (!chartInstance) {
    chartInstance = echarts.init(chartRef.value);
  }
  const labels = chartRecords.value.map((item) => parseTimeKey(item.timeKey, "hour"));
  const values = chartRecords.value.map((item) => item.totalConsumption || 0);
  chartInstance.setOption({
    tooltip: { trigger: "axis" },
    grid: { left: 50, right: 30, top: 30, bottom: 60 },
    xAxis: {
      type: "category",
      data: labels,
      axisLabel: { rotate: 35, fontSize: 11 },
    },
    yAxis: {
      type: "value",
      name: "kWh",
    },
    series: [
      {
        name: "用电量",
        type: chartType.value,
        data: values,
        smooth: true,
        areaStyle: chartType.value === "line" ? { opacity: 0.15 } : undefined,
        itemStyle: { color: "#409EFF" },
        barMaxWidth: 40,
      },
    ],
  });
}
function startAutoRefresh() {
  stopAutoRefresh();
  refreshTimer = setInterval(loadData, 60 * 1000);
}
function stopAutoRefresh() {
  if (refreshTimer) {
    clearInterval(refreshTimer);
    refreshTimer = null;
  }
}
function toggleAutoRefresh(val) {
  if (val) {
    startAutoRefresh();
  } else {
    stopAutoRefresh();
  }
}
function handleResize() {
  chartInstance?.resize();
}
onMounted(() => {
  loadYesterday();
  loadData();
  startAutoRefresh();
  window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
  stopAutoRefresh();
  window.removeEventListener("resize", handleResize);
  chartInstance?.dispose();
  chartInstance = null;
});
</script>
<style scoped>
.page-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 16px;
}
.page-header h2 {
  margin: 0 0 4px;
  font-size: 20px;
}
.subtitle {
  margin: 0;
  color: #909399;
  font-size: 13px;
}
.header-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}
.update-time {
  font-size: 13px;
  color: #909399;
}
.yesterday-banner {
  margin-bottom: 16px;
}
.yesterday-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.yesterday-title {
  font-size: 14px;
  color: #909399;
  margin-bottom: 6px;
}
.yesterday-value {
  font-size: 32px;
  font-weight: 600;
  color: #409eff;
}
.yesterday-value span {
  font-size: 14px;
  font-weight: 400;
  color: #909399;
}
.yesterday-stats {
  display: flex;
  gap: 20px;
  font-size: 13px;
  color: #606266;
}
.monitor-cards {
  margin-bottom: 16px;
}
.monitor-card {
  text-align: center;
  padding: 8px 0;
}
.monitor-title {
  color: #909399;
  font-size: 14px;
  margin-bottom: 12px;
}
.monitor-value {
  font-size: 32px;
  font-weight: 600;
  color: #303133;
}
.monitor-value .unit {
  font-size: 14px;
  font-weight: 400;
  color: #909399;
  margin-left: 4px;
}
.chart-card {
  margin-bottom: 16px;
}
.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.chart-container {
  width: 100%;
  height: 400px;
}
</style>
src/views/energyManagement/energyStatistics/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,426 @@
<template>
  <div class="app-container">
    <!-- æ˜¨æ—¥ç”¨ç”µå¿«è§ˆ -->
    <el-card class="yesterday-card" v-loading="yesterdayLoading">
      <div class="yesterday-header">
        <div>
          <h3>昨日用电量</h3>
          <p class="sub">{{ yesterdayLabel }}</p>
        </div>
        <el-button type="primary" link @click="viewYesterdayDetail">查看昨日明细</el-button>
      </div>
      <el-row :gutter="16">
        <el-col :span="6">
          <div class="metric-box highlight">
            <div class="metric-label">总用电量</div>
            <div class="metric-value">{{ yesterdaySummary.totalConsumption ?? 0 }} <span>kWh</span></div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="metric-box">
            <div class="metric-label">平均用电量</div>
            <div class="metric-value">{{ yesterdaySummary.avgConsumption ?? 0 }} <span>kWh</span></div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="metric-box">
            <div class="metric-label">最大用电量</div>
            <div class="metric-value">{{ yesterdaySummary.maxConsumption ?? 0 }} <span>kWh</span></div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="metric-box">
            <div class="metric-label">最小用电量</div>
            <div class="metric-value">{{ yesterdaySummary.minConsumption ?? 0 }} <span>kWh</span></div>
          </div>
        </el-col>
      </el-row>
    </el-card>
    <el-card>
      <template #header>
        <div class="card-header">
          <span>能耗统计分析</span>
          <span class="desc">按天、月、季度、年汇总统计(由小时数据累积计算)</span>
        </div>
      </template>
      <el-form :inline="true" class="search-form">
        <el-form-item label="统计维度">
          <el-radio-group v-model="queryForm.dimension" @change="handleDimensionChange">
            <el-radio-button value="day">天</el-radio-button>
            <el-radio-button value="month">月</el-radio-button>
            <el-radio-button value="quarter">季度</el-radio-button>
            <el-radio-button value="year">å¹´</el-radio-button>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="时间范围">
          <el-date-picker
            v-if="queryForm.dimension === 'day'"
            v-model="dayRange"
            type="daterange"
            range-separator="至"
            value-format="YYYY-MM-DD"
          />
          <el-date-picker
            v-else-if="queryForm.dimension === 'month'"
            v-model="monthRange"
            type="monthrange"
            range-separator="至"
            value-format="YYYY-MM"
          />
          <el-date-picker
            v-else-if="queryForm.dimension === 'quarter'"
            v-model="quarterRange"
            type="daterange"
            range-separator="至"
            value-format="YYYY-MM-DD"
          />
          <el-date-picker
            v-else
            v-model="yearRange"
            type="yearrange"
            range-separator="至"
            value-format="YYYY"
          />
        </el-form-item>
        <el-form-item>
          <el-button @click="setYesterday">昨日</el-button>
          <el-button @click="setLast7Days">近7天</el-button>
          <el-button type="primary" :loading="loading" @click="handleQuery">查询</el-button>
          <el-button @click="handleExport">导出</el-button>
        </el-form-item>
      </el-form>
      <el-row :gutter="16" class="summary-row">
        <el-col :span="6">
          <div class="summary-card total">
            <div class="label">总用电量</div>
            <div class="value">{{ summary.totalConsumption ?? 0 }} <span>kWh</span></div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="summary-card">
            <div class="label">平均用电量</div>
            <div class="value">{{ summary.avgConsumption ?? 0 }} <span>kWh</span></div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="summary-card">
            <div class="label">最大用电量</div>
            <div class="value">{{ summary.maxConsumption ?? 0 }} <span>kWh</span></div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="summary-card">
            <div class="label">最小用电量</div>
            <div class="value">{{ summary.minConsumption ?? 0 }} <span>kWh</span></div>
          </div>
        </el-col>
      </el-row>
      <div class="chart-toolbar">
        <span>趋势图</span>
        <el-radio-group v-model="chartType" size="small" @change="renderChart">
          <el-radio-button value="line">折线图</el-radio-button>
          <el-radio-button value="bar">柱状图</el-radio-button>
        </el-radio-group>
      </div>
      <div ref="chartRef" class="chart-container"></div>
      <div class="detail-title">用电明细</div>
      <el-table v-loading="loading" :data="detailRecords" border stripe max-height="360">
        <el-table-column label="时间" min-width="150">
          <template #default="{ row }">
            {{ parseTimeKey(row.timeKey, queryForm.dimension) }}
          </template>
        </el-table-column>
        <el-table-column prop="meterId" label="电表ID" width="100" />
        <el-table-column prop="address" label="表地址" min-width="120" show-overflow-tooltip />
        <el-table-column prop="collectorNo" label="采集器号" min-width="120" show-overflow-tooltip />
        <el-table-column prop="totalConsumption" label="总电量(kWh)" width="120" />
        <el-table-column prop="peakConsumption" label="å³°(kWh)" width="100" />
        <el-table-column prop="flatConsumption" label="å¹³(kWh)" width="100" />
        <el-table-column prop="valleyConsumption" label="è°·(kWh)" width="100" />
        <el-table-column prop="startTime" label="开始时间" min-width="150" />
        <el-table-column prop="endTime" label="结束时间" min-width="150" />
      </el-table>
    </el-card>
  </div>
</template>
<script setup>
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { ElMessageBox } from "element-plus";
import * as echarts from "echarts";
import {
  summaryStatisticEle,
  getYesterdaySummary,
  formatDayPicker,
  formatDayTime,
  formatMonthTime,
  getYesterdayDayPicker,
  parseTimeKey,
} from "@/api/energyManagement/statisticEle.js";
const { proxy } = getCurrentInstance();
const loading = ref(false);
const yesterdayLoading = ref(false);
const chartRef = ref(null);
let chartInstance = null;
const queryForm = reactive({ dimension: "day" });
const chartType = ref("bar");
const summary = ref({});
const chartRecords = ref([]);
const detailRecords = ref([]);
const yesterdaySummary = ref({});
const dayRange = ref([]);
const monthRange = ref([]);
const quarterRange = ref([]);
const yearRange = ref([]);
const yesterdayLabel = computed(() => getYesterdayDayPicker());
function initDefaultRange() {
  const yesterday = getYesterdayDayPicker();
  dayRange.value = [yesterday, yesterday];
  const now = new Date();
  const weekAgo = new Date(now.getTime() - 7 * 86400000);
  quarterRange.value = [formatDayPicker(weekAgo), formatDayPicker(now)];
  monthRange.value = [
    formatMonthTime(now).replace(/(\d{4})(\d{2})/, "$1-$2"),
    formatMonthTime(now).replace(/(\d{4})(\d{2})/, "$1-$2"),
  ];
  yearRange.value = [String(now.getFullYear()), String(now.getFullYear())];
}
function buildTimeParams() {
  const dim = queryForm.dimension;
  if (dim === "day") {
    return {
      startTime: dayRange.value[0].replace(/-/g, ""),
      endTime: dayRange.value[1].replace(/-/g, ""),
    };
  }
  if (dim === "month") {
    return {
      startTime: monthRange.value[0].replace(/-/g, ""),
      endTime: monthRange.value[1].replace(/-/g, ""),
    };
  }
  if (dim === "quarter") {
    return {
      startTime: quarterRange.value[0].replace(/-/g, ""),
      endTime: quarterRange.value[1].replace(/-/g, ""),
    };
  }
  return {
    startTime: yearRange.value[0],
    endTime: yearRange.value[1],
  };
}
async function loadYesterday() {
  yesterdayLoading.value = true;
  try {
    const res = await getYesterdaySummary();
    yesterdaySummary.value = res.data || {};
  } finally {
    yesterdayLoading.value = false;
  }
}
async function handleQuery() {
  loading.value = true;
  try {
    const params = { dimension: queryForm.dimension, ...buildTimeParams() };
    const res = await summaryStatisticEle(params);
    summary.value = res.data || {};
    chartRecords.value = res.data?.chartRecords || [];
    detailRecords.value = res.data?.records || [];
    renderChart();
  } finally {
    loading.value = false;
  }
}
function renderChart() {
  if (!chartRef.value) return;
  if (!chartInstance) {
    chartInstance = echarts.init(chartRef.value);
  }
  const labels = chartRecords.value.map((item) =>
    parseTimeKey(item.timeKey, queryForm.dimension)
  );
  const values = chartRecords.value.map((item) => item.totalConsumption || 0);
  chartInstance.setOption({
    tooltip: { trigger: "axis" },
    grid: { left: 50, right: 20, top: 30, bottom: 50 },
    xAxis: { type: "category", data: labels, axisLabel: { rotate: 30, fontSize: 11 } },
    yAxis: { type: "value", name: "kWh" },
    series: [
      {
        name: "总用电量",
        type: chartType.value,
        data: values,
        smooth: chartType.value === "line",
        areaStyle: chartType.value === "line" ? { opacity: 0.12 } : undefined,
        itemStyle: { color: "#409EFF" },
        barMaxWidth: 40,
      },
    ],
  });
}
function setYesterday() {
  queryForm.dimension = "day";
  const yesterday = getYesterdayDayPicker();
  dayRange.value = [yesterday, yesterday];
  handleQuery();
}
function setLast7Days() {
  queryForm.dimension = "day";
  const now = new Date();
  const weekAgo = new Date(now.getTime() - 6 * 86400000);
  dayRange.value = [formatDayPicker(weekAgo), formatDayPicker(now)];
  handleQuery();
}
function viewYesterdayDetail() {
  setYesterday();
}
function handleDimensionChange() {
  handleQuery();
}
function handleExport() {
  ElMessageBox.confirm("确认导出当前统计报表?", "导出", { type: "warning" })
    .then(() => {
      proxy.download("/statisticEle/export", {
        dimension: queryForm.dimension,
        ...buildTimeParams(),
      }, `能耗统计_${queryForm.dimension}.xlsx`);
    })
    .catch(() => {});
}
function handleResize() {
  chartInstance?.resize();
}
onMounted(() => {
  initDefaultRange();
  loadYesterday();
  handleQuery();
  window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
  window.removeEventListener("resize", handleResize);
  chartInstance?.dispose();
  chartInstance = null;
});
</script>
<style scoped>
.yesterday-card {
  margin-bottom: 16px;
}
.yesterday-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 16px;
}
.yesterday-header h3 {
  margin: 0 0 4px;
  font-size: 18px;
}
.yesterday-header .sub {
  margin: 0;
  color: #909399;
  font-size: 13px;
}
.metric-box {
  background: #f5f7fa;
  border-radius: 8px;
  padding: 16px;
  text-align: center;
}
.metric-box.highlight {
  background: linear-gradient(135deg, #409eff22, #409eff11);
}
.metric-label {
  font-size: 13px;
  color: #909399;
  margin-bottom: 8px;
}
.metric-value {
  font-size: 24px;
  font-weight: 600;
}
.metric-value span {
  font-size: 13px;
  font-weight: 400;
  color: #909399;
}
.card-header {
  display: flex;
  align-items: center;
  gap: 12px;
}
.card-header .desc {
  font-size: 13px;
  color: #909399;
}
.search-form {
  margin-bottom: 16px;
}
.summary-row {
  margin-bottom: 16px;
}
.summary-card {
  background: #f5f7fa;
  border-radius: 8px;
  padding: 20px;
  text-align: center;
}
.summary-card.total {
  background: linear-gradient(135deg, #409eff22, #409eff11);
}
.summary-card .label {
  font-size: 13px;
  color: #909399;
  margin-bottom: 8px;
}
.summary-card .value {
  font-size: 26px;
  font-weight: 600;
}
.summary-card .value span {
  font-size: 13px;
  font-weight: 400;
  color: #909399;
}
.chart-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
  font-weight: 500;
}
.chart-container {
  width: 100%;
  height: 380px;
  margin-bottom: 20px;
}
.detail-title {
  font-weight: 500;
  margin-bottom: 10px;
}
</style>
src/views/energyManagement/meterArchive/components/formDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,114 @@
<template>
  <el-dialog v-model="visible" :title="title" width="520px" @close="closeDia">
    <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
      <el-form-item label="电表名称" prop="meterName">
        <el-input v-model="form.meterName" placeholder="请输入电表名称" />
      </el-form-item>
      <el-form-item label="表地址" prop="address">
        <el-input v-model="form.address" placeholder="请输入表地址" />
      </el-form-item>
      <el-form-item label="备注">
        <el-input v-model="form.description" type="textarea" :rows="2" placeholder="备注" />
      </el-form-item>
      <el-form-item label="继电器状态" prop="relayState">
        <el-select v-model="form.relayState" style="width: 100%">
          <el-option label="合闸" value="1" />
          <el-option label="拉闸" value="0" />
        </el-select>
      </el-form-item>
      <el-form-item v-if="showRate" label="倍率">
        <el-input-number v-model="form.rate" :min="1" style="width: 100%" />
      </el-form-item>
      <el-form-item v-if="operationType === 'edit' && form.source === 'sync'" label="档案ID">
        <el-input :model-value="form.meterId" disabled />
      </el-form-item>
      <el-form-item v-if="operationType === 'edit' && form.source === 'sync'">
        <el-text type="info" size="small">同步电表仅可修改名称、表地址、备注、继电器状态(不同步到能源平台)</el-text>
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { meterAdd, meterUpdate } from "@/api/energyManagement/tqdianbiao.js";
const emit = defineEmits(["close"]);
const visible = ref(false);
const submitting = ref(false);
const operationType = ref("add");
const title = ref("");
const formRef = ref(null);
const defaultForm = () => ({
  id: null,
  meterId: null,
  meterName: "",
  address: "",
  description: "",
  relayState: "1",
  rate: 1,
  source: "manual",
});
const form = reactive(defaultForm());
const showRate = computed(() => operationType.value === "edit" && form.source === "manual");
const rules = {
  address: [{ required: true, message: "请输入表地址", trigger: "blur" }],
  relayState: [{ required: true, message: "请选择继电器状态", trigger: "change" }],
};
function open(type, row) {
  operationType.value = type;
  title.value = type === "add" ? "新增电表" : "编辑电表";
  Object.assign(form, defaultForm());
  if (type === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      meterId: row.meterId,
      meterName: row.meterName || row.address || "",
      address: row.address || "",
      description: row.description || "",
      relayState: row.relayState || "1",
      rate: row.rate || 1,
      source: row.source || "sync",
    });
  }
  visible.value = true;
}
function closeDia() {
  emit("close");
}
async function submit() {
  await formRef.value.validate();
  submitting.value = true;
  try {
    const payload = { ...form };
    if (!payload.meterName) {
      payload.meterName = payload.address;
    }
    if (operationType.value === "add") {
      await meterAdd(payload);
      ElMessage.success("新增成功");
    } else {
      await meterUpdate(payload);
      ElMessage.success("修改成功");
    }
    visible.value = false;
    emit("close");
  } finally {
    submitting.value = false;
  }
}
defineExpose({ open });
</script>
src/views/energyManagement/meterArchive/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
<template>
  <div class="app-container">
    <div class="search_form">
      <div>
        <span class="search_title">关键词:</span>
        <el-input
          v-model="searchForm.keyword"
          style="width: 240px"
          placeholder="电表名称/表地址/备注"
          clearable
          @keyup.enter="handleQuery"
        />
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px">搜索</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openForm('add')">新增电表</el-button>
        <el-button type="success" :loading="syncing" @click="handleSync">同步电表</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :tableLoading="tableLoading"
        @pagination="pagination"
      >
        <template #source="{ row }">
          <el-tag :type="row.source === 'manual' ? 'warning' : 'info'" size="small">
            {{ row.source === "manual" ? "手动" : "同步" }}
          </el-tag>
        </template>
        <template #relayState="{ row }">
          <el-tag :type="row.relayState === '1' ? 'success' : 'danger'" size="small">
            {{ row.relayState === "1" ? "合闸" : row.relayState === "0" ? "拉闸" : "未知" }}
          </el-tag>
        </template>
        <template #operate="{ row }">
          <el-button link type="primary" @click="openForm('edit', row)">编辑</el-button>
          <el-button v-if="row.source === 'manual'" link type="danger" @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
    </div>
    <form-dia ref="formDiaRef" @close="getList" />
  </div>
</template>
<script setup>
import { onMounted, reactive, ref, toRefs } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDia from "./components/formDia.vue";
import { meterListPage, meterSync, meterDelete } from "@/api/energyManagement/tqdianbiao.js";
const tableLoading = ref(false);
const syncing = ref(false);
const tableData = ref([]);
const formDiaRef = ref(null);
const data = reactive({ searchForm: { keyword: "" } });
const { searchForm } = toRefs(data);
const page = reactive({ current: 1, size: 10, total: 0 });
const tableColumn = ref([
  { label: "电表名称", prop: "meterName", minWidth: 120 },
  { label: "电表档案ID", prop: "meterId", width: 120 },
  { label: "表地址", prop: "address", minWidth: 120 },
  { label: "倍率", prop: "rate", width: 70 },
  { label: "来源", prop: "source", dataType: "slot", slot: "source", width: 80 },
  { label: "继电器", prop: "relayState", dataType: "slot", slot: "relayState", width: 90 },
  { label: "备注", prop: "description", minWidth: 100 },
  { label: "同步时间", prop: "syncTime", minWidth: 160 },
  { label: "操作", prop: "operate", dataType: "slot", slot: "operate", width: 120, fixed: "right" },
]);
function openForm(type, row) {
  formDiaRef.value.open(type, row);
}
function handleQuery() {
  page.current = 1;
  getList();
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
}
async function getList() {
  tableLoading.value = true;
  try {
    const res = await meterListPage({ ...searchForm.value, current: page.current, size: page.size });
    tableData.value = res.data.records;
    page.total = res.data.total;
  } finally {
    tableLoading.value = false;
  }
}
async function handleSync() {
  syncing.value = true;
  try {
    const res = await meterSync();
    ElMessage.success(res.msg || "同步成功");
    getList();
  } finally {
    syncing.value = false;
  }
}
function handleDelete(row) {
  ElMessageBox.confirm(`确认删除电表「${row.meterName || row.address}」?`, "提示", { type: "warning" })
    .then(async () => {
      await meterDelete([row.id]);
      ElMessage.success("删除成功");
      getList();
    })
    .catch(() => {});
}
onMounted(() => getList());
</script>