From 80ba6fd4314eaf5fbaf73198d1a61a1323fa4f45 Mon Sep 17 00:00:00 2001
From: yuan <123@>
Date: 星期二, 16 六月 2026 09:54:28 +0800
Subject: [PATCH] feat: 添加能耗数据查询和电表管理功能
---
src/views/energyManagement/meterArchive/index.vue | 124 +++
src/views/energyManagement/energyDataCollection/components/meterFormDia.vue | 114 ++
src/views/energyManagement/collectorArchive/components/formDia.vue | 93 ++
src/views/energyManagement/energyDataCollection/components/formDia.vue | 210 +++++
src/views/energyManagement/energyDataCollection/index.vue | 385 +++++++++
src/views/energyManagement/energyRealTimeMonitor/index.vue | 336 ++++++++
src/views/energyManagement/meterArchive/components/formDia.vue | 114 ++
src/views/energyManagement/collectorArchive/index.vue | 96 ++
src/api/energyManagement/tqdianbiao.js | 58 +
src/components/Pagination/index.vue | 208 ++--
src/api/energyManagement/statisticEle.js | 140 +++
src/views/energyManagement/energyStatistics/index.vue | 426 ++++++++++
12 files changed, 2,200 insertions(+), 104 deletions(-)
diff --git a/src/api/energyManagement/statisticEle.js b/src/api/energyManagement/statisticEle.js
new file mode 100644
index 0000000..289d98a
--- /dev/null
+++ b/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());
+}
+
+/** 瑙f瀽鏃堕棿鏍囪瘑涓哄彲璇绘牸寮� */
+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}瀛e害`;
+ }
+ 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),
+ };
+}
diff --git a/src/api/energyManagement/tqdianbiao.js b/src/api/energyManagement/tqdianbiao.js
new file mode 100644
index 0000000..011bbba
--- /dev/null
+++ b/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" });
+}
+
+// ========== 鐢佃〃妗f ==========
+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 });
+}
diff --git a/src/components/Pagination/index.vue b/src/components/Pagination/index.vue
index 53fbec2..656b061 100644
--- a/src/components/Pagination/index.vue
+++ b/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>
\ No newline at end of file
diff --git a/src/views/energyManagement/collectorArchive/components/formDia.vue b/src/views/energyManagement/collectorArchive/components/formDia.vue
new file mode 100644
index 0000000..0fc196c
--- /dev/null
+++ b/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="閲囬泦鍣ㄦ。妗圛D" 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: "璇疯緭鍏ラ噰闆嗗櫒妗fID", 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>
diff --git a/src/views/energyManagement/collectorArchive/index.vue b/src/views/energyManagement/collectorArchive/index.vue
new file mode 100644
index 0000000..499737f
--- /dev/null
+++ b/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="閲囬泦鍣ㄥ彿/妗fID/澶囨敞"
+ 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: "閲囬泦鍣ㄦ。妗圛D", 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>
diff --git a/src/views/energyManagement/energyDataCollection/components/formDia.vue b/src/views/energyManagement/energyDataCollection/components/formDia.vue
new file mode 100644
index 0000000..a231b7e
--- /dev/null
+++ b/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>
diff --git a/src/views/energyManagement/energyDataCollection/components/meterFormDia.vue b/src/views/energyManagement/energyDataCollection/components/meterFormDia.vue
new file mode 100644
index 0000000..c2f77ec
--- /dev/null
+++ b/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="妗fID">
+ <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>
diff --git a/src/views/energyManagement/energyDataCollection/index.vue b/src/views/energyManagement/energyDataCollection/index.vue
new file mode 100644
index 0000000..36b6b6c
--- /dev/null
+++ b/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">鍚屾灏忔椂鐢甸噺 + 鎵嬪姩鎶勮〃锛坕gnore_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="妗fID" 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>
diff --git a/src/views/energyManagement/energyRealTimeMonitor/index.vue b/src/views/energyManagement/energyRealTimeMonitor/index.vue
new file mode 100644
index 0000000..426e2c7
--- /dev/null
+++ b/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>
diff --git a/src/views/energyManagement/energyStatistics/index.vue b/src/views/energyManagement/energyStatistics/index.vue
new file mode 100644
index 0000000..15889b5
--- /dev/null
+++ b/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">瀛e害</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>
diff --git a/src/views/energyManagement/meterArchive/components/formDia.vue b/src/views/energyManagement/meterArchive/components/formDia.vue
new file mode 100644
index 0000000..c2f77ec
--- /dev/null
+++ b/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="妗fID">
+ <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>
diff --git a/src/views/energyManagement/meterArchive/index.vue b/src/views/energyManagement/meterArchive/index.vue
new file mode 100644
index 0000000..d22b847
--- /dev/null
+++ b/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: "鐢佃〃妗fID", 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>
--
Gitblit v1.9.3