已添加12个文件
已删除10个文件
已修改1个文件
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // æ°´çµè¡¨çæµï¼é社äºè½æºçæ§æ¥å
¥ï¼ |
| | | import request, { download } from '@/utils/request' |
| | | |
| | | // 设å¤å表-å页æ¥è¯¢ |
| | | export function listMeterDevices(query) { |
| | | return request({ |
| | | url: '/energyMeter/devices', |
| | | method: 'get', |
| | | params: query |
| | | }) |
| | | } |
| | | |
| | | // 设å¤ä¸æé项ï¼ä»
å¯ç¨è®¾å¤ï¼ |
| | | export function listMeterDeviceOptions(query) { |
| | | return request({ |
| | | url: '/energyMeter/devices/options', |
| | | method: 'get', |
| | | params: query |
| | | }) |
| | | } |
| | | |
| | | // éªè¯å¹¶è¯»åç¬¬ä¸æ¹æ¡£æ¡ |
| | | export function verifyMeterDevice(data) { |
| | | return request({ |
| | | url: '/energyMeter/devices/verify', |
| | | method: 'post', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // æ°å¢æ¬å°ç»å® |
| | | export function addMeterDevice(data) { |
| | | return request({ |
| | | url: '/energyMeter/devices', |
| | | method: 'post', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // ä¿®æ¹è®¾å¤æ¬å°å段 / å¯åç¨ |
| | | export function updateMeterDevice(id, data) { |
| | | return request({ |
| | | url: '/energyMeter/devices/' + id, |
| | | method: 'put', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // åå°è®¾å¤æå¨æ¥è¡¨ |
| | | export function collectMeterDevice(id, data) { |
| | | return request({ |
| | | url: '/energyMeter/devices/' + id + '/collect', |
| | | method: 'post', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // æ¹éæå¨æ¥è¡¨ |
| | | export function collectMeterDevices(data) { |
| | | return request({ |
| | | url: '/energyMeter/devices/collect', |
| | | method: 'post', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // 设å¤è¯»æ°/ç¨éæç» |
| | | export function listMeterMeasurements(id, query) { |
| | | return request({ |
| | | url: '/energyMeter/devices/' + id + '/measurements', |
| | | method: 'get', |
| | | params: query |
| | | }) |
| | | } |
| | | |
| | | // ç¨è½æ¦è§ |
| | | export function getMeterOverview(query) { |
| | | return request({ |
| | | url: '/energyMeter/overview', |
| | | method: 'get', |
| | | params: query |
| | | }) |
| | | } |
| | | |
| | | // çµè¡¨å®æ¶çæ¿ï¼ä»æ¥ 15 åéæ²çº¿ + è¿ä¸å¨/ä¸ä¸å¨çµè½è´¨é |
| | | export function getElectricMeterDashboard(query) { |
| | | return request({ |
| | | url: '/energyMeter/electric/dashboard', |
| | | method: 'get', |
| | | params: query |
| | | }) |
| | | } |
| | | |
| | | // ç¬¬ä¸æ¹æ¥å
¥æ¨¡å¼ï¼æ¯å¦æ¼ç¤ºæ°æ®ï¼ |
| | | export function getMeterProviderStatus() { |
| | | return request({ |
| | | url: '/energyMeter/providerStatus', |
| | | method: 'get' |
| | | }) |
| | | } |
| | | |
| | | // è¸æ±½è¡¨æ¼ç¤ºè®¾å¤æå¤©èªå¨çææ°æ®ï¼é»è®¤æ¨å¤©ï¼å¹çï¼å¯é夿§è¡ï¼ |
| | | export function simulateGasMeters(date) { |
| | | return request({ |
| | | url: '/energyMeter/sync/gasSimulate', |
| | | method: 'post', |
| | | params: { date } |
| | | }) |
| | | } |
| | | |
| | | /* ---------------------- éé / 宿¶çæ§ / ç»è®¡åæ ---------------------- */ |
| | | |
| | | // è½èæ°æ®é鿦è§ï¼ä¸ç±»ä»è´¨æ¥å
¥è®¾å¤ãå½éè¿åº¦ä¸æè¿ééæ¹æ¬¡ |
| | | export function getEnergyCollectionOverview() { |
| | | return request({ |
| | | url: '/energyMeter/collection/overview', |
| | | method: 'get' |
| | | }) |
| | | } |
| | | |
| | | // è½è宿¶çæ§ï¼ä¸ç±»ä»è´¨ææ°ç¨éãè¿ 7 æ¥è¶å¿ä¸çµè¡¨å®æ¶æ²çº¿ |
| | | export function getEnergyRealtimeOverview(params) { |
| | | return request({ |
| | | url: '/energyMeter/realtime/overview', |
| | | method: 'get', |
| | | params |
| | | }) |
| | | } |
| | | |
| | | // è½èç»è®¡åæï¼ææ¥/æãæä»è´¨/è®¾å¤æ±æ» |
| | | export function getEnergyReport(params) { |
| | | return request({ |
| | | url: '/energyMeter/report/summary', |
| | | method: 'get', |
| | | params |
| | | }) |
| | | } |
| | | |
| | | // è½èç»è®¡æ¥è¡¨å¯¼åº Excel |
| | | export function exportEnergyReport(params, filename) { |
| | | return download('/energyMeter/report/export', params, filename) |
| | | } |
| | | |
| | | /* ------------------------- 表å
·å°è´¦ï¼æå·¥æè¡¨ï¼ ------------------------- */ |
| | | |
| | | // æ°å¢æå·¥æè¡¨è®¾å¤ï¼æ°´è¡¨/çµè¡¨/æ°è¡¨ï¼ |
| | | export function addManualMeterDevice(data) { |
| | | return request({ |
| | | url: '/energyMeter/devices/manual', |
| | | method: 'post', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // å é¤è®¾å¤ï¼åå§å设å¤ä¸ç¬¬ä¸æ¹æ¥å
¥è®¾å¤ç±å端æç»å¹¶è¿åéè¯¯æ¶æ¯ï¼ |
| | | export function deleteMeterDevice(id) { |
| | | return request({ |
| | | url: '/energyMeter/devices/' + id, |
| | | method: 'delete' |
| | | }) |
| | | } |
| | | |
| | | // æå·¥æè¡¨è®°å½-å页æ¥è¯¢ |
| | | export function listMeterReadings(id, query) { |
| | | return request({ |
| | | url: '/energyMeter/devices/' + id + '/readings', |
| | | method: 'get', |
| | | params: query |
| | | }) |
| | | } |
| | | |
| | | // æå·¥å½è¡¨ï¼è¿åæ¬æç¨éçä¿¡æ¯ï¼ |
| | | export function addMeterReading(id, data) { |
| | | return request({ |
| | | url: '/energyMeter/devices/' + id + '/readings', |
| | | method: 'post', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // å é¤ä¸æ¡æå·¥æè¡¨è®°å½ |
| | | export function deleteMeterReading(id, readingId) { |
| | | return request({ |
| | | url: '/energyMeter/devices/' + id + '/readings/' + readingId, |
| | | method: 'delete' |
| | | }) |
| | | } |
| | |
| | | }, |
| | | ], |
| | | }, |
| | | // è½è管ç |
| | | { |
| | | path: "/energyManagement", |
| | | component: Layout, |
| | | redirect: "/energyManagement/meterLedger", |
| | | name: "EnergyManagement", |
| | | meta: { title: "è½è管ç", icon: "chart" }, |
| | | children: [ |
| | | { |
| | | path: "meterLedger", |
| | | component: () => import("@/views/energyManagement/meterLedger/index"), |
| | | name: "MeterLedger", |
| | | meta: { title: "表å
·å°è´¦", icon: "list" }, |
| | | }, |
| | | { |
| | | path: "dataCollection", |
| | | component: () => import("@/views/energyManagement/collection/index"), |
| | | name: "EnergyDataCollection", |
| | | meta: { title: "è½èæ°æ®éé", icon: "download" }, |
| | | }, |
| | | { |
| | | path: "realtimeMonitor", |
| | | component: () => import("@/views/energyManagement/realtime/index"), |
| | | name: "EnergyRealtimeMonitor", |
| | | meta: { title: "è½è宿¶çæ§", icon: "monitor" }, |
| | | }, |
| | | { |
| | | path: "energyAnalysis", |
| | | component: () => import("@/views/energyManagement/analysis/index"), |
| | | name: "EnergyAnalysis", |
| | | meta: { title: "è½èç»è®¡åæ", icon: "chart" }, |
| | | }, |
| | | { |
| | | path: "waterMeterData", |
| | | redirect: (to) => ({ path: "/energyManagement/realtimeMonitor", query: to.query }), |
| | | hidden: true, |
| | | }, |
| | | { |
| | | path: "electricMeterData", |
| | | redirect: (to) => ({ path: "/energyManagement/realtimeMonitor", query: to.query }), |
| | | hidden: true, |
| | | }, |
| | | { |
| | | path: "gasMeterData", |
| | | redirect: (to) => ({ path: "/energyManagement/realtimeMonitor", query: to.query }), |
| | | hidden: true, |
| | | }, |
| | | ], |
| | | }, |
| | | { |
| | | path: "/user", |
| | | component: Layout, |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container energy-analysis"> |
| | | <div class="page-head"> |
| | | <div class="head-text"> |
| | | <h2>è½èç»è®¡åæ</h2> |
| | | <p>ææ¶æ®µã维度èªå¨æ±æ»æ°´ãçµãè¸æ±½ç¨éï¼çæç»è®¡æ¥è¡¨å¹¶æ¯æå¯¼åºæ¥é
ï¼ä¸ºèè½åææä¾æ°æ®æ¯æã</p> |
| | | </div> |
| | | </div> |
| | | |
| | | <el-alert |
| | | v-if="report.mockMode" |
| | | class="mock-alert" |
| | | type="info" |
| | | :closable="false" |
| | | show-icon |
| | | title="å½å为æ¼ç¤ºæ°æ®æ¨¡å¼ï¼æªé
ç½®ç¬¬ä¸æ¹ææç ï¼ç»è®¡æ°æ®ç±æ¬å°æ¨¡æçæï¼ä»
ç¨äºèè°ä¸é¡µé¢æ¼ç¤ºã" |
| | | /> |
| | | |
| | | <section class="panel filter-panel"> |
| | | <div class="filters"> |
| | | <div class="filter-item"> |
| | | <span class="filter-label">ç»è®¡æ¶æ®µ</span> |
| | | <el-date-picker |
| | | v-model="dateRange" |
| | | type="daterange" |
| | | value-format="YYYY-MM-DD" |
| | | range-separator="è³" |
| | | start-placeholder="å¼å§æ¥æ" |
| | | end-placeholder="ç»ææ¥æ" |
| | | :shortcuts="shortcuts" |
| | | style="width: 260px" |
| | | /> |
| | | </div> |
| | | <div class="filter-item"> |
| | | <span class="filter-label">ç»è®¡ç²åº¦</span> |
| | | <el-radio-group v-model="query.granularity" size="small"> |
| | | <el-radio-button label="DAY">ææ¥</el-radio-button> |
| | | <el-radio-button label="MONTH">ææ</el-radio-button> |
| | | </el-radio-group> |
| | | </div> |
| | | <div class="filter-item"> |
| | | <span class="filter-label">ç»è®¡ç»´åº¦</span> |
| | | <el-radio-group v-model="query.dimension" size="small"> |
| | | <el-radio-button label="MEDIUM">æä»è´¨</el-radio-button> |
| | | <el-radio-button label="DEVICE">æè®¾å¤</el-radio-button> |
| | | </el-radio-group> |
| | | </div> |
| | | <div class="filter-item"> |
| | | <span class="filter-label">ä»è´¨</span> |
| | | <el-select v-model="query.meterType" style="width: 120px"> |
| | | <el-option label="å
¨é¨ä»è´¨" value="ALL" /> |
| | | <el-option label="水表" value="WATER" /> |
| | | <el-option label="çµè¡¨" value="ELECTRIC" /> |
| | | <el-option label="è¸æ±½è¡¨" value="GAS" /> |
| | | </el-select> |
| | | </div> |
| | | <div class="filter-actions"> |
| | | <el-button type="primary" :loading="loading" @click="load">æ¥è¯¢</el-button> |
| | | <el-button :loading="exporting" @click="handleExport">å¯¼åºæ¥è¡¨</el-button> |
| | | </div> |
| | | </div> |
| | | </section> |
| | | |
| | | <div class="summary-grid"> |
| | | <div v-for="item in summaryCards" :key="item.key" class="summary-card" :class="'summary-' + item.key"> |
| | | <span class="summary-label">{{ item.label }}</span> |
| | | <div class="summary-value"> |
| | | <b>{{ formatNumber(item.value) }}</b> |
| | | <i v-if="item.unit">{{ item.unit }}</i> |
| | | </div> |
| | | <span class="summary-sub">{{ item.sub }}</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <section class="panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title">è½èè¶å¿</div> |
| | | <div class="legend"> |
| | | <span><i class="dot water"></i>水 m³</span> |
| | | <span><i class="dot gas"></i>è¸æ±½ m³</span> |
| | | <span><i class="dot electric"></i>çµ åº¦</span> |
| | | </div> |
| | | </div> |
| | | <div ref="trendRef" class="chart chart-trend"></div> |
| | | </section> |
| | | |
| | | <section class="panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title"> |
| | | {{ query.dimension === 'MEDIUM' ? 'å卿ç»è®¡æç»' : 'å设å¤ç»è®¡æç»' }} |
| | | <span class="panel-sub">{{ periodsText }}</span> |
| | | </div> |
| | | <span class="panel-range">æ°æ®è¦ç {{ report.coveredPeriods || 0 }}/{{ report.totalPeriods || 0 }} ä¸ªå¨æ</span> |
| | | </div> |
| | | |
| | | <el-table |
| | | v-if="query.dimension === 'MEDIUM'" |
| | | v-loading="loading" |
| | | :data="report.rows || []" |
| | | border |
| | | stripe |
| | | size="small" |
| | | show-summary |
| | | :summary-method="mediumSummary" |
| | | max-height="460" |
| | | > |
| | | <el-table-column prop="period" label="ç»è®¡å¨æ" min-width="130" fixed /> |
| | | <el-table-column label="水表ç¨éï¼m³ï¼" min-width="140" align="right"> |
| | | <template #default="{ row }">{{ formatNumber(row.water) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="çµè¡¨ç¨éï¼åº¦ï¼" min-width="140" align="right"> |
| | | <template #default="{ row }">{{ formatNumber(row.electric) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="è¸æ±½è¡¨ç¨éï¼m³ï¼" min-width="150" align="right"> |
| | | <template #default="{ row }">{{ formatNumber(row.gas) }}</template> |
| | | </el-table-column> |
| | | <template #empty> |
| | | <el-empty description="æéæ¶æ®µææ ç»è®¡æ°æ®" :image-size="60" /> |
| | | </template> |
| | | </el-table> |
| | | |
| | | <el-table |
| | | v-else |
| | | v-loading="loading" |
| | | :data="report.devices || []" |
| | | border |
| | | stripe |
| | | size="small" |
| | | max-height="460" |
| | | > |
| | | <el-table-column label="设å¤åç§°" min-width="180"> |
| | | <template #default="{ row }"> |
| | | <b class="device-name">{{ row.meterName }}</b> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="meterAddress" label="表å·" min-width="150" /> |
| | | <el-table-column label="ä»è´¨" width="90" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-tag size="small" :type="mediumTagType(row.meterType)" effect="plain">{{ row.meterTypeName }}</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="sourceTypeName" label="ééæ¹å¼" width="110" align="center" /> |
| | | <el-table-column label="æé´ç¨é" width="150" align="right"> |
| | | <template #default="{ row }"> |
| | | <span class="usage-value">{{ formatNumber(row.total) }}</span> |
| | | <span class="usage-unit">{{ displayUnit(row.unit) }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="å ä»è´¨æ¯ä¾" width="130" align="right"> |
| | | <template #default="{ row }">{{ row.share == null ? '--' : Number(row.share).toFixed(1) + '%' }}</template> |
| | | </el-table-column> |
| | | <template #empty> |
| | | <el-empty description="æéæ¶æ®µææ è®¾å¤æ°æ®" :image-size="60" /> |
| | | </template> |
| | | </el-table> |
| | | |
| | | <p class="report-note">{{ report.note || 'ç»è®¡å£å¾ï¼ä»
æ±æ»æ¥ç²åº¦ç¨éï¼è¡¨ç ä¸åä¸ç´¯å ã' }}</p> |
| | | </section> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue' |
| | | import dayjs from 'dayjs' |
| | | import * as echarts from 'echarts' |
| | | import { exportEnergyReport, getEnergyReport } from '@/api/energyManagement/energyMeter' |
| | | |
| | | defineOptions({ name: 'EnergyAnalysis' }) |
| | | |
| | | const COLOR_WATER = '#409eff' |
| | | const COLOR_ELECTRIC = '#e6a23c' |
| | | const COLOR_GAS = '#21a675' |
| | | const UNIT_MAP = { WATER: 'm³', ELECTRIC: '度', GAS: 'm³' } |
| | | |
| | | const loading = ref(false) |
| | | const exporting = ref(false) |
| | | const report = ref({ rows: [], devices: [], totals: [], periods: [] }) |
| | | const dateRange = ref([ |
| | | dayjs().startOf('month').format('YYYY-MM-DD'), |
| | | dayjs().format('YYYY-MM-DD') |
| | | ]) |
| | | const query = reactive({ granularity: 'DAY', dimension: 'MEDIUM', meterType: 'ALL' }) |
| | | |
| | | const trendRef = ref(null) |
| | | let trendChart = null |
| | | |
| | | const shortcuts = [ |
| | | { |
| | | text: 'æ¬æ', |
| | | value: () => [dayjs().startOf('month').toDate(), dayjs().toDate()] |
| | | }, |
| | | { |
| | | text: '䏿', |
| | | value: () => [dayjs().subtract(1, 'month').startOf('month').toDate(), |
| | | dayjs().subtract(1, 'month').endOf('month').toDate()] |
| | | }, |
| | | { |
| | | text: 'è¿ 7 天', |
| | | value: () => [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()] |
| | | }, |
| | | { |
| | | text: 'è¿ 30 天', |
| | | value: () => [dayjs().subtract(29, 'day').toDate(), dayjs().toDate()] |
| | | }, |
| | | { |
| | | text: 'æ¬å¹´', |
| | | value: () => [dayjs().startOf('year').toDate(), dayjs().toDate()] |
| | | } |
| | | ] |
| | | |
| | | const totalsMap = computed(() => { |
| | | const map = {} |
| | | ;(report.value.totals || []).forEach((item) => { |
| | | map[item.meterType] = item |
| | | }) |
| | | return map |
| | | }) |
| | | |
| | | const summaryCards = computed(() => [ |
| | | { |
| | | key: 'water', |
| | | label: '水表å计', |
| | | unit: 'm³', |
| | | value: totalsMap.value.WATER ? totalsMap.value.WATER.total : null, |
| | | sub: deviceCountText('WATER') |
| | | }, |
| | | { |
| | | key: 'electric', |
| | | label: 'çµè¡¨å计', |
| | | unit: '度', |
| | | value: totalsMap.value.ELECTRIC ? totalsMap.value.ELECTRIC.total : null, |
| | | sub: deviceCountText('ELECTRIC') |
| | | }, |
| | | { |
| | | key: 'gas', |
| | | label: 'è¸æ±½è¡¨å计', |
| | | unit: 'm³', |
| | | value: totalsMap.value.GAS ? totalsMap.value.GAS.total : null, |
| | | sub: deviceCountText('GAS') |
| | | }, |
| | | { |
| | | key: 'period', |
| | | label: 'ææ°æ®å¨æ', |
| | | unit: 'ä¸ªå¨æ', |
| | | value: report.value.coveredPeriods || 0, |
| | | sub: `å
± ${report.value.totalPeriods || 0} ä¸ªå¨æ` |
| | | } |
| | | ]) |
| | | |
| | | const periodsText = computed(() => { |
| | | if (!report.value.start) { |
| | | return '' |
| | | } |
| | | return `${report.value.start} ~ ${report.value.end} · å
± ${report.value.totalPeriods || 0} ä¸ªå¨æ` |
| | | }) |
| | | |
| | | onMounted(() => { |
| | | load() |
| | | window.addEventListener('resize', handleResize) |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | window.removeEventListener('resize', handleResize) |
| | | if (trendChart) { |
| | | trendChart.dispose() |
| | | trendChart = null |
| | | } |
| | | }) |
| | | |
| | | async function load() { |
| | | if (!dateRange.value || dateRange.value.length !== 2) { |
| | | return |
| | | } |
| | | loading.value = true |
| | | try { |
| | | const res = await getEnergyReport(buildParams()) |
| | | report.value = res.data || { rows: [], devices: [], totals: [], periods: [] } |
| | | await nextTick() |
| | | renderTrend() |
| | | } catch (e) { |
| | | report.value = { rows: [], devices: [], totals: [], periods: [] } |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | function buildParams() { |
| | | return { |
| | | startDate: dateRange.value[0], |
| | | endDate: dateRange.value[1], |
| | | granularity: query.granularity, |
| | | meterType: query.meterType, |
| | | dimension: query.dimension |
| | | } |
| | | } |
| | | |
| | | async function handleExport() { |
| | | if (!dateRange.value || dateRange.value.length !== 2) { |
| | | return |
| | | } |
| | | exporting.value = true |
| | | try { |
| | | const fileName = `è½èç»è®¡æ¥è¡¨_${dateRange.value[0]}_${dateRange.value[1]}.xlsx` |
| | | await exportEnergyReport(buildParams(), fileName) |
| | | } finally { |
| | | exporting.value = false |
| | | } |
| | | } |
| | | |
| | | function deviceCountText(meterType) { |
| | | const total = totalsMap.value[meterType] |
| | | return total ? `${total.deviceCount || 0} å°è®¾å¤` : 'æªæ¥å
¥è®¾å¤' |
| | | } |
| | | |
| | | function handleResize() { |
| | | trendChart && trendChart.resize() |
| | | } |
| | | |
| | | function renderTrend() { |
| | | if (!trendRef.value) { |
| | | return |
| | | } |
| | | if (!trendChart) { |
| | | trendChart = echarts.init(trendRef.value) |
| | | } |
| | | const rows = report.value.rows || [] |
| | | const hasData = rows.some((row) => row.water != null || row.electric != null || row.gas != null) |
| | | trendChart.setOption( |
| | | { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | valueFormatter: (value) => (value == null ? 'ææ æ°æ®' : Number(value).toFixed(3).replace(/\.?0+$/, '')) |
| | | }, |
| | | grid: { left: 52, right: 56, top: 26, bottom: 30 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: false, |
| | | data: rows.map((row) => row.period), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { |
| | | color: '#909399', |
| | | fontSize: 11, |
| | | hideOverlap: true, |
| | | formatter: (value) => (value && value.length === 10 ? value.slice(5) : value) |
| | | } |
| | | }, |
| | | yAxis: [ |
| | | { |
| | | type: 'value', |
| | | name: 'm³', |
| | | nameTextStyle: { color: '#909399', fontSize: 11 }, |
| | | axisLabel: { color: '#909399', fontSize: 11 }, |
| | | splitLine: { lineStyle: { type: 'dashed', color: '#f0f2f5' } } |
| | | }, |
| | | { |
| | | type: 'value', |
| | | name: '度', |
| | | nameTextStyle: { color: '#909399', fontSize: 11 }, |
| | | axisLabel: { color: '#909399', fontSize: 11 }, |
| | | splitLine: { show: false } |
| | | } |
| | | ], |
| | | series: [ |
| | | lineSeries('æ°´', rows.map((row) => row.water), COLOR_WATER, 0), |
| | | lineSeries('è¸æ±½', rows.map((row) => row.gas), COLOR_GAS, 0), |
| | | lineSeries('çµ', rows.map((row) => row.electric), COLOR_ELECTRIC, 1) |
| | | ], |
| | | graphic: hasData ? [] : [{ type: 'text', left: 'center', top: 'middle', style: { text: 'æéæ¶æ®µææ æ°æ®', fill: '#c0c4cc', fontSize: 13 } }] |
| | | }, |
| | | true |
| | | ) |
| | | trendChart.resize() |
| | | } |
| | | |
| | | function lineSeries(name, data, color, yAxisIndex) { |
| | | return { |
| | | name, |
| | | type: 'line', |
| | | smooth: true, |
| | | symbol: 'circle', |
| | | symbolSize: 4, |
| | | connectNulls: false, |
| | | yAxisIndex, |
| | | data, |
| | | lineStyle: { width: 2, color }, |
| | | itemStyle: { color } |
| | | } |
| | | } |
| | | |
| | | function mediumSummary({ columns }) { |
| | | const water = totalsMap.value.WATER ? totalsMap.value.WATER.total : null |
| | | const electric = totalsMap.value.ELECTRIC ? totalsMap.value.ELECTRIC.total : null |
| | | const gas = totalsMap.value.GAS ? totalsMap.value.GAS.total : null |
| | | return columns.map((column, index) => { |
| | | if (index === 0) { |
| | | return 'å计' |
| | | } |
| | | if (index === 1) { |
| | | return formatNumber(water) |
| | | } |
| | | if (index === 2) { |
| | | return formatNumber(electric) |
| | | } |
| | | if (index === 3) { |
| | | return formatNumber(gas) |
| | | } |
| | | return '' |
| | | }) |
| | | } |
| | | |
| | | function mediumTagType(meterType) { |
| | | return { WATER: 'primary', ELECTRIC: 'warning', GAS: 'success' }[meterType] || 'info' |
| | | } |
| | | |
| | | function displayUnit(unit) { |
| | | if (!unit) { |
| | | return '' |
| | | } |
| | | if (unit === 'm3') { |
| | | return 'm³' |
| | | } |
| | | if (unit === 'kWh') { |
| | | return '度' |
| | | } |
| | | return unit |
| | | } |
| | | |
| | | function formatNumber(value) { |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | const num = Number(value) |
| | | if (Number.isNaN(num)) { |
| | | return String(value) |
| | | } |
| | | return num.toFixed(3).replace(/\.?0+$/, '') |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .energy-analysis { |
| | | padding-bottom: 24px; |
| | | } |
| | | |
| | | .page-head { |
| | | margin-bottom: 14px; |
| | | |
| | | h2 { |
| | | margin: 0 0 4px; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | p { |
| | | margin: 0; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | .mock-alert { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .panel { |
| | | padding: 14px 16px; |
| | | margin-bottom: 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | } |
| | | |
| | | .filter-panel { |
| | | padding: 12px 16px; |
| | | } |
| | | |
| | | .filters { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 14px 20px; |
| | | } |
| | | |
| | | .filter-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .filter-label { |
| | | font-size: 13px; |
| | | color: #606266; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .filter-actions { |
| | | display: flex; |
| | | gap: 8px; |
| | | margin-left: auto; |
| | | } |
| | | |
| | | .summary-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(4, minmax(0, 1fr)); |
| | | gap: 12px; |
| | | margin-bottom: 12px; |
| | | |
| | | @media (max-width: 1100px) { |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | | } |
| | | } |
| | | |
| | | .summary-card { |
| | | padding: 14px 16px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | |
| | | .summary-label { |
| | | font-size: 13px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .summary-value { |
| | | display: flex; |
| | | align-items: baseline; |
| | | gap: 4px; |
| | | margin: 6px 0 2px; |
| | | |
| | | b { |
| | | font-size: 24px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | i { |
| | | font-size: 12px; |
| | | font-style: normal; |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | .summary-sub { |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | &.summary-water { |
| | | border-left: 3px solid #409eff; |
| | | } |
| | | |
| | | &.summary-electric { |
| | | border-left: 3px solid #e6a23c; |
| | | } |
| | | |
| | | &.summary-gas { |
| | | border-left: 3px solid #21a675; |
| | | } |
| | | |
| | | &.summary-period { |
| | | border-left: 3px solid #909399; |
| | | |
| | | .summary-value b { |
| | | font-size: 20px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .panel-head { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .panel-title { |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .panel-sub { |
| | | margin-left: 8px; |
| | | font-size: 12px; |
| | | font-weight: 400; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .panel-range { |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .legend { |
| | | display: flex; |
| | | gap: 14px; |
| | | font-size: 12px; |
| | | color: #606266; |
| | | |
| | | .dot { |
| | | display: inline-block; |
| | | width: 8px; |
| | | height: 8px; |
| | | margin-right: 4px; |
| | | border-radius: 50%; |
| | | |
| | | &.water { |
| | | background: #409eff; |
| | | } |
| | | |
| | | &.electric { |
| | | background: #e6a23c; |
| | | } |
| | | |
| | | &.gas { |
| | | background: #21a675; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .chart { |
| | | width: 100%; |
| | | } |
| | | |
| | | .chart-trend { |
| | | height: 300px; |
| | | } |
| | | |
| | | .device-name { |
| | | font-weight: 500; |
| | | color: #303133; |
| | | } |
| | | |
| | | .usage-value { |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .usage-unit { |
| | | margin-left: 3px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .report-note { |
| | | margin: 12px 0 0; |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container energy-collection"> |
| | | <div class="page-head"> |
| | | <div class="head-text"> |
| | | <h2>è½èæ°æ®éé</h2> |
| | | <p>å¯¹æ¥æºè½æ°´è¡¨ãçµè¡¨ãè¸æ±½è¡¨ï¼æ¯æ¥ 08:00 èªå¨å½éæ°´ãçµãè¸æ±½ç¨éï¼æå·¥æè¡¨è®¾å¤è¯·å¨ã表å
·å°è´¦ãå½è¡¨ã</p> |
| | | </div> |
| | | <div class="head-actions"> |
| | | <el-button :loading="loading" @click="loadAll">å·æ°</el-button> |
| | | <el-button |
| | | type="primary" |
| | | :loading="collecting" |
| | | :disabled="!canCollectAny" |
| | | @click="handleCollectAll" |
| | | > |
| | | ç«å³ééå
¨é¨ |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <el-alert |
| | | v-if="overview.mockMode" |
| | | class="mock-alert" |
| | | type="info" |
| | | :closable="false" |
| | | show-icon |
| | | title="å½å为æ¼ç¤ºæ°æ®æ¨¡å¼ï¼æªé
ç½®ç¬¬ä¸æ¹ææç ï¼ééç»æç±æ¬å°æ¨¡æçæï¼ä»
ç¨äºèè°ä¸é¡µé¢æ¼ç¤ºã" |
| | | /> |
| | | |
| | | <div class="media-grid"> |
| | | <div |
| | | v-for="item in mediaList" |
| | | :key="item.meterType" |
| | | class="media-card" |
| | | :class="'media-' + item.meterType.toLowerCase()" |
| | | > |
| | | <div class="media-head"> |
| | | <span class="media-badge">{{ mediumShort(item.meterType) }}</span> |
| | | <div class="media-title"> |
| | | <h3>{{ item.meterName }}</h3> |
| | | <span>{{ item.deviceCount }} å°è®¾å¤ · {{ item.providerCount }} å°èªå¨ / {{ item.manualCount }} å°æå·¥</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="media-metrics"> |
| | | <div class="metric"> |
| | | <span>å·²å½é设å¤</span> |
| | | <b>{{ item.latestDayMeters }}<i>/{{ item.deviceCount }}</i></b> |
| | | </div> |
| | | <div class="metric"> |
| | | <span>è¿ 7 æ¥è¦ç</span> |
| | | <b>{{ formatRate(item.coverageRate) }}</b> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="media-foot"> |
| | | <span>æè¿ééï¼{{ shortTime(item.lastSuccessAt) }}</span> |
| | | <span>æ°æ®æªè³ï¼{{ item.dataEnd || '--' }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <section class="panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title"> |
| | | 表å
·ééæç» |
| | | <span class="panel-sub">{{ filteredDevices.length }} å°</span> |
| | | </div> |
| | | <div class="filters"> |
| | | <el-select v-model="filters.meterType" placeholder="å
¨é¨ä»è´¨" style="width: 120px" clearable> |
| | | <el-option label="水表" value="WATER" /> |
| | | <el-option label="çµè¡¨" value="ELECTRIC" /> |
| | | <el-option label="è¸æ±½è¡¨" value="GAS" /> |
| | | </el-select> |
| | | <el-select v-model="filters.collectionMode" placeholder="å
¨é¨ééæ¹å¼" style="width: 140px" clearable> |
| | | <el-option label="èªå¨éé" value="AUTO" /> |
| | | <el-option label="æå·¥æè¡¨" value="MANUAL" /> |
| | | </el-select> |
| | | <el-input |
| | | v-model="filters.keyword" |
| | | placeholder="设å¤åç§° / 表å·" |
| | | clearable |
| | | style="width: 200px" |
| | | @keyup.enter="handleQuery" |
| | | /> |
| | | <el-button type="primary" @click="handleQuery">æ¥è¯¢</el-button> |
| | | <el-button @click="handleReset">éç½®</el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <el-table v-loading="loading" :data="pagedDevices" border stripe class="device-table"> |
| | | <el-table-column label="设å¤åç§°" min-width="180"> |
| | | <template #default="{ row }"> |
| | | <div class="device-name"> |
| | | <b>{{ row.displayName || row.providerName || ('è®¾å¤ #' + row.id) }}</b> |
| | | <span v-if="row.providerName && row.providerName !== row.displayName">{{ row.providerName }}</span> |
| | | </div> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="表å·" min-width="150"> |
| | | <template #default="{ row }">{{ row.meterAddress || '--' }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="ä»è´¨" width="88" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-tag size="small" :type="mediumTagType(row.meterType)" effect="plain"> |
| | | {{ mediumShort(row.meterType) }} |
| | | </el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ééæ¹å¼" width="110" align="center"> |
| | | <template #default="{ row }"> |
| | | {{ isAutoRow(row) ? 'èªå¨éé' : 'æå·¥æè¡¨' }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æè¿è¡¨ç " width="120" align="right"> |
| | | <template #default="{ row }"> |
| | | {{ formatNumber(row.latestReading) }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æè¿ç¨é" width="150" align="right"> |
| | | <template #default="{ row }"> |
| | | <span class="usage-value">{{ formatNumber(row.lastDayUsage) }}</span> |
| | | <span class="usage-unit">{{ unitOf(row.meterType) }}</span> |
| | | <div v-if="row.lastDayUsageDate" class="usage-date">{{ row.lastDayUsageDate }}</div> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ééç¶æ" width="120" align="center"> |
| | | <template #default="{ row }"> |
| | | <span :class="['state-dot', stateClass(row)]"></span> |
| | | {{ row.collectStateText || 'å°æªéé' }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æè¿ééæ¶é´" width="170"> |
| | | <template #default="{ row }">{{ row.lastSuccessAt || row.lastTaskTime || '--' }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" width="150" fixed="right" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-button |
| | | v-if="isAutoRow(row)" |
| | | link |
| | | type="primary" |
| | | :loading="collectingId === row.id" |
| | | @click="handleCollect(row)" |
| | | > |
| | | éé |
| | | </el-button> |
| | | <el-button link type="primary" @click="goMonitor(row)">æ¥ççæ§</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | <template #empty> |
| | | <el-empty description="ææ è¡¨å
·æ°æ®" :image-size="60" /> |
| | | </template> |
| | | </el-table> |
| | | |
| | | <div class="pagination"> |
| | | <el-pagination |
| | | v-model:current-page="page.current" |
| | | v-model:page-size="page.size" |
| | | :page-sizes="[10, 20, 50]" |
| | | :total="filteredDevices.length" |
| | | layout="total, sizes, prev, pager, next" |
| | | background |
| | | small |
| | | /> |
| | | </div> |
| | | </section> |
| | | |
| | | <section class="panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title">æè¿ééæ¹æ¬¡</div> |
| | | <div class="panel-extra"> |
| | | <span>å¤±è´¥ä»»å¡ <b class="danger">{{ overview.failedTaskCount || 0 }}</b></span> |
| | | <span>å¾
æ§è¡ä»»å¡ <b>{{ overview.pendingTaskCount || 0 }}</b></span> |
| | | </div> |
| | | </div> |
| | | <el-table :data="overview.runs" border stripe size="small"> |
| | | <el-table-column label="æ¹æ¬¡" min-width="180"> |
| | | <template #default="{ row }">{{ row.runKey || ('æ¹æ¬¡ #' + row.id) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="ç±»å" width="100"> |
| | | <template #default="{ row }">{{ row.triggerTypeName }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="忥èå´" width="200"> |
| | | <template #default="{ row }">{{ row.rangeStart || '--' }} ~ {{ row.rangeEnd || '--' }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="ç¶æ" width="100" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-tag size="small" :type="runTagType(row.status)" effect="light">{{ row.statusText }}</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æå / 空 / 失败 / æ»æ°" width="180" align="center"> |
| | | <template #default="{ row }"> |
| | | {{ row.successTasks }} / {{ row.emptyTasks }} / {{ row.failedTasks }} / {{ row.totalTasks }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="å¼å§æ¶é´" width="170"> |
| | | <template #default="{ row }">{{ row.startedAt || '--' }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="ç»ææ¶é´" width="170"> |
| | | <template #default="{ row }">{{ row.finishedAt || '--' }}</template> |
| | | </el-table-column> |
| | | <template #empty> |
| | | <el-empty description="ææ ééæ¹æ¬¡" :image-size="60" /> |
| | | </template> |
| | | </el-table> |
| | | </section> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' |
| | | import { useRouter } from 'vue-router' |
| | | import dayjs from 'dayjs' |
| | | import { ElMessage } from 'element-plus' |
| | | import { |
| | | collectMeterDevice, |
| | | collectMeterDevices, |
| | | getEnergyCollectionOverview, |
| | | listMeterDevices, |
| | | simulateGasMeters |
| | | } from '@/api/energyManagement/energyMeter' |
| | | |
| | | defineOptions({ name: 'EnergyDataCollection' }) |
| | | |
| | | const router = useRouter() |
| | | |
| | | const UNIT_MAP = { WATER: 'm³', ELECTRIC: '度', GAS: 'm³' } |
| | | const MEDIA_ORDER = { WATER: 0, ELECTRIC: 1, GAS: 2 } |
| | | |
| | | const loading = ref(false) |
| | | const collecting = ref(false) |
| | | const collectingId = ref(null) |
| | | const overview = ref({ media: [], runs: [], mockMode: false }) |
| | | const devices = ref([]) |
| | | |
| | | const filters = reactive({ meterType: '', collectionMode: '', keyword: '' }) |
| | | const applied = reactive({ meterType: '', collectionMode: '', keyword: '' }) |
| | | const page = reactive({ current: 1, size: 20 }) |
| | | |
| | | let reloadTimer = null |
| | | |
| | | const mediaList = computed(() => [...(overview.value.media || [])] |
| | | .sort((a, b) => (MEDIA_ORDER[a.meterType] ?? 9) - (MEDIA_ORDER[b.meterType] ?? 9))) |
| | | |
| | | const filteredDevices = computed(() => devices.value |
| | | .filter((row) => { |
| | | if (applied.meterType && row.meterType !== applied.meterType) { |
| | | return false |
| | | } |
| | | const auto = isAutoRow(row) |
| | | if (applied.collectionMode === 'MANUAL' && auto) { |
| | | return false |
| | | } |
| | | if (applied.collectionMode === 'AUTO' && !auto) { |
| | | return false |
| | | } |
| | | if (applied.keyword) { |
| | | const keyword = applied.keyword.toLowerCase() |
| | | const text = [row.displayName, row.providerName, row.meterAddress].filter(Boolean).join(' ').toLowerCase() |
| | | if (!text.includes(keyword)) { |
| | | return false |
| | | } |
| | | } |
| | | return true |
| | | }) |
| | | .sort((a, b) => (MEDIA_ORDER[a.meterType] ?? 9) - (MEDIA_ORDER[b.meterType] ?? 9))) |
| | | |
| | | const pagedDevices = computed(() => { |
| | | const start = (page.current - 1) * page.size |
| | | return filteredDevices.value.slice(start, start + page.size) |
| | | }) |
| | | |
| | | // åªæçæ£çç¬¬ä¸æ¹æ¥å
¥è®¾å¤èµ°âæ¥è¡¨âæ¥å£ |
| | | const collectableIds = computed(() => devices.value |
| | | .filter((row) => row.sourceType === 'PROVIDER' && row.enabled === 1) |
| | | .map((row) => row.id)) |
| | | |
| | | // è¸æ±½è¡¨æ¼ç¤ºè®¾å¤æèªå¨ééå¤çï¼æå¨å·æ°æ¶éæ°çææ¨å¤©æ°æ® |
| | | const gasSimulatedRows = computed(() => devices.value.filter((row) => isGasSimulated(row))) |
| | | |
| | | const canCollectAny = computed(() => collectableIds.value.length > 0 || gasSimulatedRows.value.length > 0) |
| | | |
| | | function isAutoRow(row) { |
| | | return row.sourceType === 'PROVIDER' || row.simulated === 1 |
| | | } |
| | | |
| | | function isGasSimulated(row) { |
| | | return row.simulated === 1 && row.meterType === 'GAS' |
| | | } |
| | | |
| | | onMounted(loadAll) |
| | | |
| | | onBeforeUnmount(() => { |
| | | if (reloadTimer) { |
| | | clearTimeout(reloadTimer) |
| | | reloadTimer = null |
| | | } |
| | | }) |
| | | |
| | | async function loadAll() { |
| | | loading.value = true |
| | | try { |
| | | const [summary, devicePage] = await Promise.all([ |
| | | getEnergyCollectionOverview(), |
| | | listMeterDevices({ current: 1, size: 500 }) |
| | | ]) |
| | | overview.value = summary.data || { media: [], runs: [] } |
| | | devices.value = (devicePage.data && devicePage.data.records) || [] |
| | | } catch (e) { |
| | | overview.value = { media: [], runs: [], mockMode: false } |
| | | devices.value = [] |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | function handleQuery() { |
| | | applied.meterType = filters.meterType |
| | | applied.collectionMode = filters.collectionMode |
| | | applied.keyword = filters.keyword.trim() |
| | | page.current = 1 |
| | | } |
| | | |
| | | function handleReset() { |
| | | filters.meterType = '' |
| | | filters.collectionMode = '' |
| | | filters.keyword = '' |
| | | handleQuery() |
| | | } |
| | | |
| | | function collectPayload(ids) { |
| | | return { |
| | | meterIds: ids, |
| | | startDate: dayjs().subtract(3, 'day').format('YYYY-MM-DD'), |
| | | endDate: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), |
| | | includeReadings: true |
| | | } |
| | | } |
| | | |
| | | async function handleCollectAll() { |
| | | const providerIds = collectableIds.value |
| | | const gasRows = gasSimulatedRows.value |
| | | if (!providerIds.length && !gasRows.length) { |
| | | ElMessage.warning('å½å没æå¯èªå¨ééç表å
·') |
| | | return |
| | | } |
| | | collecting.value = true |
| | | try { |
| | | const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD') |
| | | if (gasRows.length) { |
| | | await simulateGasMeters(yesterday) |
| | | } |
| | | if (providerIds.length) { |
| | | await collectMeterDevices(collectPayload(providerIds)) |
| | | } |
| | | const parts = [] |
| | | if (providerIds.length) { |
| | | parts.push(`${providerIds.length} å°æ°´/çµè¡¨`) |
| | | } |
| | | if (gasRows.length) { |
| | | parts.push(`${gasRows.length} å°è¸æ±½è¡¨`) |
| | | } |
| | | ElMessage.success(`已触åèªå¨ééï¼${parts.join('ã')}`) |
| | | scheduleReload() |
| | | } catch (e) { |
| | | // é误æç¤ºç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } finally { |
| | | collecting.value = false |
| | | } |
| | | } |
| | | |
| | | async function handleCollect(row) { |
| | | collectingId.value = row.id |
| | | const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD') |
| | | try { |
| | | if (isGasSimulated(row)) { |
| | | // è¸æ±½è¡¨æ¼ç¤ºè®¾å¤ï¼æå¤©èªå¨çææ¨å¤©ç¨éï¼é夿§è¡ä¸ä¼éå¤åå
¥ |
| | | await simulateGasMeters(yesterday) |
| | | ElMessage.success('è¸æ±½è¡¨æ°æ®å·²åæ¥ï¼èªå¨çææ¨æ¥ç¨éï¼') |
| | | } else { |
| | | await collectMeterDevice(row.id, { |
| | | startDate: dayjs().subtract(3, 'day').format('YYYY-MM-DD'), |
| | | endDate: yesterday, |
| | | includeReadings: true |
| | | }) |
| | | ElMessage.success(`ã${row.displayName || row.meterAddress}ãééä»»å¡å·²æäº¤`) |
| | | } |
| | | scheduleReload() |
| | | } catch (e) { |
| | | // é误æç¤ºç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } finally { |
| | | collectingId.value = null |
| | | } |
| | | } |
| | | |
| | | function scheduleReload() { |
| | | if (reloadTimer) { |
| | | clearTimeout(reloadTimer) |
| | | } |
| | | reloadTimer = setTimeout(() => { |
| | | loadAll() |
| | | reloadTimer = null |
| | | }, 4000) |
| | | } |
| | | |
| | | function goMonitor(row) { |
| | | router.push({ path: '/energyManagement/realtimeMonitor', query: { meterId: row.id } }) |
| | | } |
| | | |
| | | function mediumShort(meterType) { |
| | | return { WATER: 'æ°´', ELECTRIC: 'çµ', GAS: 'è¸æ±½' }[meterType] || meterType |
| | | } |
| | | |
| | | function mediumTagType(meterType) { |
| | | return { WATER: 'primary', ELECTRIC: 'warning', GAS: 'success' }[meterType] || 'info' |
| | | } |
| | | |
| | | function unitOf(meterType) { |
| | | return UNIT_MAP[meterType] || '' |
| | | } |
| | | |
| | | function runTagType(status) { |
| | | if (status === 'SUCCESS') { |
| | | return 'success' |
| | | } |
| | | if (status === 'PARTIAL' || status === 'RUNNING') { |
| | | return 'warning' |
| | | } |
| | | if (status === 'FAILED') { |
| | | return 'danger' |
| | | } |
| | | return 'info' |
| | | } |
| | | |
| | | function stateClass(row) { |
| | | if (row.simulated === 1) { |
| | | return 'is-ok' |
| | | } |
| | | if (row.lastTaskStatus === 'FAILED') { |
| | | return 'is-error' |
| | | } |
| | | if (row.sourceType === 'MANUAL') { |
| | | return 'is-manual' |
| | | } |
| | | if (row.lastTaskStatus === 'SUCCESS' || row.lastTaskStatus === 'EMPTY') { |
| | | return 'is-ok' |
| | | } |
| | | return 'is-idle' |
| | | } |
| | | |
| | | function formatRate(value) { |
| | | if (value == null) { |
| | | return '--' |
| | | } |
| | | return `${Number(value).toFixed(1)}%` |
| | | } |
| | | |
| | | function formatNumber(value) { |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | const num = Number(value) |
| | | if (Number.isNaN(num)) { |
| | | return String(value) |
| | | } |
| | | return num.toFixed(3).replace(/\.?0+$/, '') |
| | | } |
| | | |
| | | function shortTime(value) { |
| | | if (!value) { |
| | | return '--' |
| | | } |
| | | return String(value).length > 16 ? String(value).slice(0, 16) : value |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .energy-collection { |
| | | padding-bottom: 24px; |
| | | } |
| | | |
| | | .page-head { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | margin-bottom: 14px; |
| | | |
| | | h2 { |
| | | margin: 0 0 4px; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | p { |
| | | margin: 0; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | .head-actions { |
| | | display: flex; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .mock-alert { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .media-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(3, minmax(0, 1fr)); |
| | | gap: 12px; |
| | | |
| | | @media (max-width: 1100px) { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | } |
| | | |
| | | .media-card { |
| | | padding: 14px 16px 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-top: 3px solid #409eff; |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | |
| | | &.media-water { |
| | | border-top-color: #409eff; |
| | | } |
| | | |
| | | &.media-electric { |
| | | border-top-color: #e6a23c; |
| | | } |
| | | |
| | | &.media-gas { |
| | | border-top-color: #21a675; |
| | | } |
| | | } |
| | | |
| | | .media-head { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .media-badge { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | width: 34px; |
| | | height: 34px; |
| | | flex: 0 0 34px; |
| | | border-radius: 9px; |
| | | background: #eef5ff; |
| | | color: #2f7fd8; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | |
| | | .media-electric & { |
| | | background: #fdf6ec; |
| | | color: #c98a24; |
| | | } |
| | | |
| | | .media-gas & { |
| | | background: #ecf8f2; |
| | | color: #1d8f66; |
| | | } |
| | | } |
| | | |
| | | .media-title { |
| | | flex: 1; |
| | | min-width: 0; |
| | | |
| | | h3 { |
| | | margin: 0 0 3px; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | span { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | .media-metrics { |
| | | display: flex; |
| | | gap: 8px; |
| | | margin: 14px 0 10px; |
| | | |
| | | .metric { |
| | | flex: 1; |
| | | padding: 8px 10px; |
| | | border-radius: 6px; |
| | | background: #f7f9fc; |
| | | |
| | | span { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | b { |
| | | font-size: 18px; |
| | | color: #303133; |
| | | |
| | | i { |
| | | margin-left: 2px; |
| | | font-size: 12px; |
| | | font-style: normal; |
| | | font-weight: 400; |
| | | color: #a8abb2; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .media-foot { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | justify-content: space-between; |
| | | gap: 6px; |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .panel { |
| | | padding: 14px 16px; |
| | | margin-top: 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | } |
| | | |
| | | .panel-head { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 10px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .panel-title { |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .panel-sub { |
| | | margin-left: 6px; |
| | | font-size: 12px; |
| | | font-weight: 400; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .filters { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .panel-extra { |
| | | display: flex; |
| | | gap: 16px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | |
| | | b { |
| | | color: #303133; |
| | | } |
| | | |
| | | .danger { |
| | | color: #f56c6c; |
| | | } |
| | | } |
| | | |
| | | .device-name { |
| | | display: flex; |
| | | flex-direction: column; |
| | | line-height: 1.4; |
| | | |
| | | b { |
| | | font-weight: 500; |
| | | color: #303133; |
| | | } |
| | | |
| | | span { |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | } |
| | | |
| | | .usage-value { |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .usage-unit { |
| | | margin-left: 3px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .usage-date { |
| | | font-size: 11px; |
| | | color: #c0c4cc; |
| | | } |
| | | |
| | | .state-dot { |
| | | display: inline-block; |
| | | width: 6px; |
| | | height: 6px; |
| | | margin-right: 5px; |
| | | border-radius: 50%; |
| | | vertical-align: middle; |
| | | |
| | | &.is-ok { |
| | | background: #21a675; |
| | | } |
| | | |
| | | &.is-error { |
| | | background: #f56c6c; |
| | | } |
| | | |
| | | &.is-manual { |
| | | background: #909399; |
| | | } |
| | | |
| | | &.is-idle { |
| | | background: #e6a23c; |
| | | } |
| | | } |
| | | |
| | | .pagination { |
| | | display: flex; |
| | | justify-content: flex-end; |
| | | padding-top: 12px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="meter-overview"> |
| | | <!-- é¡¶é¨å·¥å
·æ --> |
| | | <el-card shadow="never" class="toolbar-card"> |
| | | <div class="toolbar"> |
| | | <div class="toolbar-item"> |
| | | <span class="label">设å¤èå´</span> |
| | | <el-select v-model="query.scope" style="width: 130px" @change="handleScopeChange"> |
| | | <el-option label="å
¨é¨è®¾å¤" value="ALL" /> |
| | | <el-option label="åå°è®¾å¤" value="SINGLE" /> |
| | | </el-select> |
| | | </div> |
| | | <div v-if="query.scope === 'SINGLE'" class="toolbar-item"> |
| | | <el-select |
| | | v-model="query.meterId" |
| | | filterable |
| | | clearable |
| | | placeholder="æç´¢è®¾å¤åç§°/表å·" |
| | | style="width: 260px" |
| | | @change="loadOverview" |
| | | > |
| | | <el-option |
| | | v-for="item in deviceOptions" |
| | | :key="item.id" |
| | | :label="optionLabel(item)" |
| | | :value="item.id" |
| | | /> |
| | | </el-select> |
| | | </div> |
| | | <div class="toolbar-item meta"> |
| | | æ°æ®æªè³ <b>{{ overview.dataThrough || '--' }}</b> |
| | | <span class="dot">·</span> |
| | | æè¿éé <b>{{ overview.lastSuccessAt || '--' }}</b> |
| | | <span class="dot">·</span> |
| | | {{ overview.scopeLabel || '--' }} |
| | | </div> |
| | | <div class="toolbar-actions"> |
| | | <el-button type="primary" @click="loadAll">æ¥è¯¢</el-button> |
| | | <el-button v-if="canRefresh" :loading="refreshing" @click="handleRefreshData">å·æ°æ°æ®</el-button> |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | | |
| | | <el-alert |
| | | v-if="overview.mockMode" |
| | | class="mock-alert" |
| | | type="info" |
| | | :closable="false" |
| | | show-icon |
| | | title="å½å为æ¼ç¤ºæ°æ®æ¨¡å¼ï¼æªé
ç½®ç¬¬ä¸æ¹ææç ï¼æ¥è¡¨ç»æç±æ¬å°æ¨¡æçæï¼ä»
ç¨äºèè°ä¸é¡µé¢æ¼ç¤ºã" |
| | | /> |
| | | |
| | | <!-- é¡¶é¨ä¸¤åï¼æ¬æç´¯è®¡ / æ¬å¹´ç´¯è®¡ --> |
| | | <el-row :gutter="12" class="top-row"> |
| | | <el-col :xs="24" :lg="12"> |
| | | <div class="panel"> |
| | | <div class="panel-head"> |
| | | <div> |
| | | <div class="panel-title">æ¬æç´¯è®¡{{ usageName }}é</div> |
| | | <div class="panel-range">{{ monthRangeText }}</div> |
| | | </div> |
| | | </div> |
| | | <div class="big-value"> |
| | | <span class="big-number">{{ overview.monthHasData ? formatNumber(overview.monthTotal) : '--' }}</span> |
| | | <span class="big-unit">{{ unit }}</span> |
| | | </div> |
| | | <div ref="monthCompareRef" class="chart chart-sm"></div> |
| | | </div> |
| | | </el-col> |
| | | <el-col :xs="24" :lg="12"> |
| | | <div class="panel"> |
| | | <div class="panel-head"> |
| | | <div> |
| | | <div class="panel-title">æ¬å¹´ç´¯è®¡{{ usageName }}é</div> |
| | | <div class="panel-range">{{ yearRangeText }}</div> |
| | | </div> |
| | | </div> |
| | | <div class="big-value"> |
| | | <span class="big-number">{{ overview.yearHasData ? formatNumber(overview.yearTotal) : '--' }}</span> |
| | | <span class="big-unit">{{ unit }}</span> |
| | | </div> |
| | | <div ref="yearCompareRef" class="chart chart-sm"></div> |
| | | </div> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <!-- æåº¦ç¨æ°´æ°æ® --> |
| | | <div class="panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title">æåº¦{{ usageName }}æ°æ®</div> |
| | | <div class="panel-tools"> |
| | | <span class="label">æ¶é´ï¼</span> |
| | | <el-date-picker |
| | | v-model="query.month" |
| | | type="month" |
| | | value-format="YYYY-MM" |
| | | :clearable="false" |
| | | style="width: 140px" |
| | | @change="handleMonthChange" |
| | | /> |
| | | </div> |
| | | </div> |
| | | <div class="summary-row"> |
| | | <span class="summary-item"> |
| | | 彿æ»{{ usageName }}é |
| | | <b>{{ overview.monthHasData ? formatNumber(overview.monthTotal) : '--' }}{{ unit }}</b> |
| | | </span> |
| | | <span class="summary-item"> |
| | | 䏿æ»{{ usageName }}é |
| | | <b>{{ previousMonthTotal == null ? '--' : formatNumber(previousMonthTotal) + unit }}</b> |
| | | </span> |
| | | </div> |
| | | <div ref="monthDailyRef" class="chart chart-md"></div> |
| | | </div> |
| | | |
| | | <!-- å¹´åº¦ç¨æ°´æ°æ® --> |
| | | <div class="panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title">年度{{ usageName }}æ°æ®</div> |
| | | <div class="panel-tools"> |
| | | <span class="label">æ¶é´ï¼</span> |
| | | <el-date-picker |
| | | v-model="query.year" |
| | | type="year" |
| | | value-format="YYYY" |
| | | :clearable="false" |
| | | style="width: 110px" |
| | | @change="handleYearChange" |
| | | /> |
| | | </div> |
| | | </div> |
| | | <div class="summary-row"> |
| | | <span class="summary-item"> |
| | | æ¬å¹´æ»{{ usageName }}é |
| | | <b>{{ overview.yearHasData ? formatNumber(overview.yearTotal) : '--' }}{{ unit }}</b> |
| | | </span> |
| | | <span class="summary-item"> |
| | | å»å¹´æ»{{ usageName }}é |
| | | <b>{{ previousYearTotal == null ? '--' : formatNumber(previousYearTotal) + unit }}</b> |
| | | </span> |
| | | </div> |
| | | <div ref="yearDailyRef" class="chart chart-md"></div> |
| | | </div> |
| | | |
| | | <el-empty |
| | | v-if="overviewReady && !overview.deviceCount" |
| | | class="empty-tip" |
| | | :description="`å½åè¿æ²¡æ${typeName}设å¤ï¼è¯·å
å°è¡¨å
·å°è´¦æ°å¢`" |
| | | :image-size="70" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' |
| | | import { useRoute } from 'vue-router' |
| | | import * as echarts from 'echarts' |
| | | import dayjs from 'dayjs' |
| | | import { ElMessage } from 'element-plus' |
| | | import { |
| | | collectMeterDevices, |
| | | getMeterOverview, |
| | | listMeterDeviceOptions |
| | | } from '@/api/energyManagement/energyMeter' |
| | | |
| | | const props = defineProps({ |
| | | meterType: { |
| | | type: String, |
| | | required: true |
| | | } |
| | | }) |
| | | |
| | | defineOptions({ name: 'MeterOverview' }) |
| | | |
| | | const route = useRoute() |
| | | |
| | | const unitFallback = { WATER: 'm³', ELECTRIC: '度', GAS: 'm³' } |
| | | const usageNameMap = { WATER: 'ç¨æ°´', ELECTRIC: 'ç¨çµ', GAS: 'ç¨æ°' } |
| | | const COLOR_PREVIOUS = '#2f5f96' |
| | | const COLOR_CURRENT = '#f08c2e' |
| | | |
| | | const query = reactive({ |
| | | scope: 'ALL', |
| | | meterId: null, |
| | | month: dayjs().format('YYYY-MM'), |
| | | year: dayjs().format('YYYY') |
| | | }) |
| | | |
| | | const overview = ref({}) |
| | | const overviewReady = ref(false) |
| | | const deviceOptions = ref([]) |
| | | const unit = ref(unitFallback[props.meterType] || 'm³') |
| | | const refreshing = ref(false) |
| | | |
| | | const monthCompareRef = ref(null) |
| | | const yearCompareRef = ref(null) |
| | | const monthDailyRef = ref(null) |
| | | const yearDailyRef = ref(null) |
| | | const charts = {} |
| | | |
| | | let reloadTimer = null |
| | | |
| | | // è·¯ç±å¸¦å
¥ç设å¤IDï¼ä»è¡¨å
·å°è´¦âæ¥çæ°æ®âè·³è½¬èæ¥ï¼ |
| | | const requestedMeterId = ref(route.query.meterId ? Number(route.query.meterId) : null) |
| | | |
| | | const typeName = computed(() => { |
| | | const map = { WATER: '水表', ELECTRIC: 'çµè¡¨', GAS: 'æ°è¡¨' } |
| | | return map[props.meterType] || '设å¤' |
| | | }) |
| | | |
| | | // æ¯ä¸ªæ°æ®é¡µåªå¤çèªå·±è·¯ç±ä¸ç meterIdï¼é¿å
页ç¾ç¼ååäºç¸ä¸²ç±»å |
| | | const dataPathMap = { |
| | | WATER: '/energyManagement/waterMeterData', |
| | | ELECTRIC: '/energyManagement/electricMeterData', |
| | | GAS: '/energyManagement/gasMeterData' |
| | | } |
| | | const dataPath = computed(() => dataPathMap[props.meterType] || '') |
| | | |
| | | const usageName = computed(() => usageNameMap[props.meterType] || 'ç¨é') |
| | | |
| | | const monthRangeText = computed(() => { |
| | | const month = overview.value.selectedMonth || query.month |
| | | const days = dayjs(`${month}-01`).daysInMonth() |
| | | return `${month}-01 è³ ${month}-${String(days).padStart(2, '0')}` |
| | | }) |
| | | |
| | | const yearRangeText = computed(() => { |
| | | const year = overview.value.selectedYear || query.year |
| | | return `${year}-01 è³ ${year}-12` |
| | | }) |
| | | |
| | | // 䏿/å»å¹´å
¨ç¨åè®¡ï¼æ¥å£è¿åçæ¯éæ¥/éæåå£å¾æ°æ®ï¼è¿éç´æ¥ç´¯å |
| | | const previousMonthTotal = computed(() => sumValues(overview.value.monthSeries, 'previous')) |
| | | const previousYearTotal = computed(() => sumValues(overview.value.yearSeries, 'previous')) |
| | | |
| | | // åªæç¬¬ä¸æ¹æ¥å
¥è®¾å¤æ¯ææ¥è¡¨ï¼æå·¥æè¡¨è®¾å¤æ¥å°ä¼æ¥é |
| | | const refreshableIds = computed(() => { |
| | | const providerIds = deviceOptions.value |
| | | .filter((item) => item.sourceType === 'PROVIDER') |
| | | .map((item) => item.id) |
| | | if (query.scope === 'SINGLE' && query.meterId) { |
| | | return providerIds.filter((id) => id === query.meterId) |
| | | } |
| | | return providerIds |
| | | }) |
| | | |
| | | const canRefresh = computed(() => refreshableIds.value.length > 0) |
| | | |
| | | // è·¯ç± query ä¸ç设å¤å¯è½ä¸å¨å¯ç¨è®¾å¤ä¸æéï¼ä¾å¦å·²åç¨ï¼ï¼è¡¥ä¸æ¡å ä½é¡¹ |
| | | function ensureMeterOption(id) { |
| | | if (!id || deviceOptions.value.some((item) => item.id === id)) { |
| | | return |
| | | } |
| | | deviceOptions.value = [{ id, displayName: `è®¾å¤ #${id}`, meterAddress: '' }, ...deviceOptions.value] |
| | | } |
| | | |
| | | function applyRequestedMeter(id) { |
| | | // ç¼åçéå½å页ç¾ä¹ä¼æ¶å°å
¨å± query ååï¼è¿éå¿
é¡»æè·¯ç±è·¯å¾æ¦æª |
| | | if (!id || route.path !== dataPath.value) { |
| | | return |
| | | } |
| | | requestedMeterId.value = id |
| | | query.scope = 'SINGLE' |
| | | query.meterId = id |
| | | ensureMeterOption(id) |
| | | loadOverview() |
| | | } |
| | | |
| | | onMounted(() => { |
| | | if (requestedMeterId.value && route.path === dataPath.value) { |
| | | query.scope = 'SINGLE' |
| | | query.meterId = requestedMeterId.value |
| | | } |
| | | loadOptions() |
| | | loadOverview() |
| | | window.addEventListener('resize', handleResize) |
| | | }) |
| | | |
| | | // å䏿°æ®é¡µå¨è·¯ç± query ååæ¶åæ¥è®¾å¤èå´ |
| | | watch( |
| | | () => route.query.meterId, |
| | | (value) => { |
| | | if (value && route.path === dataPath.value) { |
| | | applyRequestedMeter(Number(value)) |
| | | } |
| | | } |
| | | ) |
| | | |
| | | onBeforeUnmount(() => { |
| | | window.removeEventListener('resize', handleResize) |
| | | if (reloadTimer) { |
| | | clearTimeout(reloadTimer) |
| | | reloadTimer = null |
| | | } |
| | | Object.keys(charts).forEach((key) => { |
| | | charts[key] && charts[key].dispose() |
| | | delete charts[key] |
| | | }) |
| | | }) |
| | | |
| | | function handleResize() { |
| | | Object.keys(charts).forEach((key) => charts[key] && charts[key].resize()) |
| | | } |
| | | |
| | | function loadAll() { |
| | | loadOptions() |
| | | loadOverview() |
| | | } |
| | | |
| | | async function loadOptions() { |
| | | try { |
| | | const res = await listMeterDeviceOptions({ meterType: props.meterType }) |
| | | deviceOptions.value = res.data || [] |
| | | ensureMeterOption(requestedMeterId.value) |
| | | if (query.scope === 'SINGLE' && !query.meterId && deviceOptions.value.length) { |
| | | query.meterId = deviceOptions.value[0].id |
| | | } |
| | | } catch (e) { |
| | | deviceOptions.value = [] |
| | | } |
| | | } |
| | | |
| | | async function loadOverview() { |
| | | const params = { |
| | | meterType: props.meterType, |
| | | month: query.month, |
| | | year: Number(query.year) |
| | | } |
| | | if (query.scope === 'SINGLE' && query.meterId) { |
| | | params.meterId = query.meterId |
| | | } |
| | | try { |
| | | const res = await getMeterOverview(params) |
| | | overview.value = res.data || {} |
| | | const apiUnit = overview.value.unit |
| | | // åºé仿 kWh åå¨ï¼é¡µé¢ç»ä¸æ¾ç¤ºä¸ºâ度â |
| | | unit.value = apiUnit === 'kWh' ? '度' : (apiUnit === 'm3' ? 'm³' : apiUnit || unitFallback[props.meterType] || 'm³') |
| | | await nextTick() |
| | | renderAll() |
| | | } catch (e) { |
| | | overview.value = {} |
| | | } finally { |
| | | overviewReady.value = true |
| | | } |
| | | } |
| | | |
| | | function handleScopeChange(value) { |
| | | if (value === 'ALL') { |
| | | query.meterId = null |
| | | } else if (!query.meterId && deviceOptions.value.length) { |
| | | query.meterId = deviceOptions.value[0].id |
| | | } |
| | | loadOverview() |
| | | } |
| | | |
| | | function handleMonthChange(value) { |
| | | if (value) { |
| | | query.year = value.slice(0, 4) |
| | | } |
| | | loadOverview() |
| | | } |
| | | |
| | | function handleYearChange(value) { |
| | | if (value) { |
| | | const monthPart = (query.month || dayjs().format('YYYY-MM')).slice(5, 7) |
| | | query.month = `${value}-${monthPart}` |
| | | } |
| | | loadOverview() |
| | | } |
| | | |
| | | // å·æ°æ°æ® = 触åç¬¬ä¸æ¹æ¥è¡¨ï¼æå·¥æè¡¨è®¾å¤ä¸åä¸ |
| | | async function handleRefreshData() { |
| | | const meterIds = refreshableIds.value |
| | | if (!meterIds.length) { |
| | | ElMessage.warning('å½åèå´æ²¡æå¯æ¥è¡¨çç¬¬ä¸æ¹æ¥å
¥è®¾å¤') |
| | | return |
| | | } |
| | | refreshing.value = true |
| | | try { |
| | | await collectMeterDevices({ |
| | | meterIds, |
| | | startDate: dayjs().subtract(3, 'day').format('YYYY-MM-DD'), |
| | | endDate: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), |
| | | includeReadings: true |
| | | }) |
| | | ElMessage.success('æ¥è¡¨ä»»å¡å·²æäº¤ï¼æ£å¨åå°éé') |
| | | if (reloadTimer) { |
| | | clearTimeout(reloadTimer) |
| | | } |
| | | reloadTimer = setTimeout(() => { |
| | | loadOverview() |
| | | loadOptions() |
| | | }, 4000) |
| | | } catch (e) { |
| | | // é误æç¤ºå·²ç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } finally { |
| | | refreshing.value = false |
| | | } |
| | | } |
| | | |
| | | function optionLabel(item) { |
| | | if (!item.meterAddress) { |
| | | return item.displayName |
| | | } |
| | | return `${item.displayName}ï¼${item.meterAddress}ï¼` |
| | | } |
| | | |
| | | /* ------------------------------ å¾è¡¨ ------------------------------ */ |
| | | |
| | | function initChart(refEl, key) { |
| | | if (!refEl) { |
| | | return null |
| | | } |
| | | if (!charts[key]) { |
| | | charts[key] = echarts.init(refEl) |
| | | } |
| | | return charts[key] |
| | | } |
| | | |
| | | function baseGrid() { |
| | | return { left: 46, right: 12, top: 30, bottom: 26, containLabel: false } |
| | | } |
| | | |
| | | function axisY() { |
| | | return { |
| | | type: 'value', |
| | | name: unit.value, |
| | | nameTextStyle: { fontSize: 11, color: '#909399' }, |
| | | axisLabel: { fontSize: 11, color: '#909399' }, |
| | | splitLine: { lineStyle: { type: 'dashed', color: '#ebeef5' } } |
| | | } |
| | | } |
| | | |
| | | function tooltipFormatter(params) { |
| | | const list = Array.isArray(params) ? params : [params] |
| | | const head = list.length ? list[0].axisValueLabel : '' |
| | | const rows = list |
| | | .map((item) => { |
| | | const value = item.value == null ? 'ææ æ°æ®' : `${formatNumber(item.value)} ${unit.value}` |
| | | return `${item.marker}${item.seriesName}ï¼${value}` |
| | | }) |
| | | .join('<br/>') |
| | | return `${head}<br/>${rows}` |
| | | } |
| | | |
| | | function barSeries(name, data, color) { |
| | | return { |
| | | name, |
| | | type: 'bar', |
| | | barMaxWidth: 14, |
| | | barGap: '10%', |
| | | data, |
| | | itemStyle: { color, borderRadius: [2, 2, 0, 0] } |
| | | } |
| | | } |
| | | |
| | | function renderAll() { |
| | | renderMonthCompare() |
| | | renderYearCompare() |
| | | renderMonthDaily() |
| | | renderYearDaily() |
| | | } |
| | | |
| | | // æ¬æ vs ä¸æï¼ææ¥ï¼ |
| | | function renderMonthCompare() { |
| | | const chart = initChart(monthCompareRef.value, 'monthCompare') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | const series = overview.value.monthSeries || [] |
| | | chart.setOption( |
| | | { |
| | | tooltip: { trigger: 'axis', formatter: tooltipFormatter }, |
| | | legend: { |
| | | data: [`䏿${usageName.value}é`, `æ¬æ${usageName.value}é`], |
| | | top: 0, |
| | | right: 8, |
| | | itemWidth: 10, |
| | | itemHeight: 8, |
| | | textStyle: { fontSize: 11, color: '#606266' } |
| | | }, |
| | | grid: baseGrid(), |
| | | xAxis: { |
| | | type: 'category', |
| | | data: series.map((item) => String(Number(item.day ? item.day.slice(8) : 0))), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { fontSize: 10, color: '#909399', interval: series.length > 22 ? 1 : 0 } |
| | | }, |
| | | yAxis: axisY(), |
| | | series: [ |
| | | barSeries(`䏿${usageName.value}é`, series.map((item) => item.previous), COLOR_PREVIOUS), |
| | | barSeries(`æ¬æ${usageName.value}é`, series.map((item) => item.current), COLOR_CURRENT) |
| | | ], |
| | | graphic: overview.value.monthHasData ? [] : emptyGraphic() |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | // æ¬å¹´ vs å»å¹´ï¼ææï¼ |
| | | function renderYearCompare() { |
| | | const chart = initChart(yearCompareRef.value, 'yearCompare') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | const series = overview.value.yearSeries || [] |
| | | chart.setOption( |
| | | { |
| | | tooltip: { trigger: 'axis', formatter: tooltipFormatter }, |
| | | legend: { |
| | | data: [`å»å¹´${usageName.value}é`, `æ¬å¹´${usageName.value}é`], |
| | | top: 0, |
| | | right: 8, |
| | | itemWidth: 10, |
| | | itemHeight: 8, |
| | | textStyle: { fontSize: 11, color: '#606266' } |
| | | }, |
| | | grid: baseGrid(), |
| | | xAxis: { |
| | | type: 'category', |
| | | data: series.map((item) => String(Number(item.month ? item.month.slice(5) : 0))), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { fontSize: 10, color: '#909399' } |
| | | }, |
| | | yAxis: axisY(), |
| | | series: [ |
| | | barSeries(`å»å¹´${usageName.value}é`, series.map((item) => item.previous), COLOR_PREVIOUS), |
| | | barSeries(`æ¬å¹´${usageName.value}é`, series.map((item) => item.current), COLOR_CURRENT) |
| | | ], |
| | | graphic: overview.value.yearHasData ? [] : emptyGraphic() |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | // æåº¦ï¼å½ææ¯æ¥ |
| | | function renderMonthDaily() { |
| | | const chart = initChart(monthDailyRef.value, 'monthDaily') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | const series = overview.value.monthSeries || [] |
| | | chart.setOption( |
| | | { |
| | | tooltip: { trigger: 'axis', formatter: tooltipFormatter }, |
| | | legend: { |
| | | data: [`${usageName.value}é`], |
| | | top: 0, |
| | | right: 8, |
| | | itemWidth: 10, |
| | | itemHeight: 8, |
| | | textStyle: { fontSize: 11, color: '#606266' } |
| | | }, |
| | | grid: { ...baseGrid(), top: 26 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: series.map((item) => String(Number(item.day ? item.day.slice(8) : 0)).padStart(2, '0')), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { fontSize: 10, color: '#909399', interval: series.length > 22 ? 1 : 0 } |
| | | }, |
| | | yAxis: axisY(), |
| | | series: [barSeries(`${usageName.value}é`, series.map((item) => item.current), COLOR_CURRENT)], |
| | | graphic: overview.value.monthHasData ? [] : emptyGraphic() |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | // 年度ï¼å½å¹´æ¯æ |
| | | function renderYearDaily() { |
| | | const chart = initChart(yearDailyRef.value, 'yearDaily') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | const series = overview.value.yearSeries || [] |
| | | chart.setOption( |
| | | { |
| | | tooltip: { trigger: 'axis', formatter: tooltipFormatter }, |
| | | legend: { |
| | | data: [`${usageName.value}é`], |
| | | top: 0, |
| | | right: 8, |
| | | itemWidth: 10, |
| | | itemHeight: 8, |
| | | textStyle: { fontSize: 11, color: '#606266' } |
| | | }, |
| | | grid: { ...baseGrid(), top: 26 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: series.map((item) => (item.month ? `${item.month.slice(5)}æ` : '')), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { fontSize: 11, color: '#909399' } |
| | | }, |
| | | yAxis: axisY(), |
| | | series: [barSeries(`${usageName.value}é`, series.map((item) => item.current), COLOR_CURRENT)], |
| | | graphic: overview.value.yearHasData ? [] : emptyGraphic() |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | function emptyGraphic() { |
| | | return [{ type: 'text', left: 'center', top: 'middle', style: { text: 'ææ æ°æ®', fill: '#c0c4cc', fontSize: 14 } }] |
| | | } |
| | | |
| | | /* ------------------------------ å·¥å
· ------------------------------ */ |
| | | |
| | | function sumValues(series, key) { |
| | | if (!Array.isArray(series) || !series.length) { |
| | | return null |
| | | } |
| | | let sum = 0 |
| | | let hasValue = false |
| | | series.forEach((item) => { |
| | | const value = item ? item[key] : null |
| | | if (value != null && value !== '') { |
| | | sum += Number(value) |
| | | hasValue = true |
| | | } |
| | | }) |
| | | return hasValue ? sum : null |
| | | } |
| | | |
| | | function formatNumber(value) { |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | const num = Number(value) |
| | | if (Number.isNaN(num)) { |
| | | return String(value) |
| | | } |
| | | return num.toFixed(3).replace(/\.?0+$/, '') |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .meter-overview { |
| | | padding-bottom: 8px; |
| | | } |
| | | |
| | | .toolbar-card { |
| | | margin-bottom: 12px; |
| | | |
| | | :deep(.el-card__body) { |
| | | padding: 10px 14px; |
| | | } |
| | | } |
| | | |
| | | .toolbar { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 12px 18px; |
| | | } |
| | | |
| | | .toolbar-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | } |
| | | |
| | | .toolbar-item .label { |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .toolbar-item.meta { |
| | | color: #909399; |
| | | font-size: 12px; |
| | | } |
| | | |
| | | .toolbar-item.meta b { |
| | | color: #303133; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .dot { |
| | | color: #dcdfe6; |
| | | } |
| | | |
| | | .toolbar-actions { |
| | | display: flex; |
| | | gap: 8px; |
| | | margin-left: auto; |
| | | } |
| | | |
| | | .mock-alert { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .top-row { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .panel { |
| | | padding: 14px 16px 8px; |
| | | margin-bottom: 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | } |
| | | |
| | | .panel-head { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .panel-title { |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .panel-range { |
| | | margin-top: 2px; |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .panel-tools { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | } |
| | | |
| | | .big-value { |
| | | margin: 6px 0 0; |
| | | } |
| | | |
| | | .big-number { |
| | | font-size: 24px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .big-unit { |
| | | margin-left: 4px; |
| | | font-size: 13px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .summary-row { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 40px; |
| | | padding: 10px 4px 6px; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | } |
| | | |
| | | .summary-item b { |
| | | margin-left: 6px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .chart { |
| | | width: 100%; |
| | | } |
| | | |
| | | .chart-sm { |
| | | height: 200px; |
| | | } |
| | | |
| | | .chart-md { |
| | | height: 210px; |
| | | } |
| | | |
| | | .empty-tip { |
| | | padding: 16px 0; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container electric-page"> |
| | | <div class="page-head"> |
| | | <div class="page-title"> |
| | | <h2>çµè¡¨æ°æ®</h2> |
| | | <span class="page-desc">仿¥æ²çº¿åçµè½è´¨é卿å¼é¡µé¢æ¶å®æ¶æ¥è¯¢ï¼å端ç¼å 2 åéï¼ï¼æ¬æç´¯è®¡åæ¥ç¨éæ¥èªæ¯å¤© 08:00 ç忥任å¡ã</span> |
| | | </div> |
| | | <div class="page-filters"> |
| | | <el-select v-model="query.scope" style="width: 120px" @change="handleScopeChange"> |
| | | <el-option label="å
¨é¨è®¾å¤" value="ALL" /> |
| | | <el-option label="åå°è®¾å¤" value="SINGLE" /> |
| | | </el-select> |
| | | <el-select |
| | | v-if="query.scope === 'SINGLE'" |
| | | v-model="query.meterId" |
| | | filterable |
| | | clearable |
| | | placeholder="æç´¢è®¾å¤åç§°/表å·" |
| | | style="width: 250px" |
| | | @change="loadAll" |
| | | > |
| | | <el-option |
| | | v-for="item in deviceOptions" |
| | | :key="item.id" |
| | | :label="optionLabel(item)" |
| | | :value="item.id" |
| | | /> |
| | | </el-select> |
| | | <el-date-picker |
| | | v-model="query.month" |
| | | type="month" |
| | | value-format="YYYY-MM" |
| | | :clearable="false" |
| | | style="width: 135px" |
| | | @change="handleMonthChange" |
| | | /> |
| | | <el-button type="primary" :loading="loading" @click="loadAll">æ¥è¯¢</el-button> |
| | | <el-button v-if="canRefresh" :loading="refreshing" @click="handleRefreshData">å·æ°æ°æ®</el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="status-line"> |
| | | <span>æ°æ®æªè³ <b>{{ dataThroughText }}</b></span> |
| | | <span class="dot">·</span> |
| | | <span>æè¿éé <b>{{ overview.lastSuccessAt || '--' }}</b></span> |
| | | <span class="dot">·</span> |
| | | <span>{{ overview.scopeLabel || '--' }}</span> |
| | | </div> |
| | | |
| | | <el-alert |
| | | v-if="overview.mockMode" |
| | | class="mock-alert" |
| | | type="info" |
| | | :closable="false" |
| | | show-icon |
| | | title="å½å为æ¼ç¤ºæ°æ®æ¨¡å¼ï¼æªé
ç½®ç¬¬ä¸æ¹ææç ï¼æ¥è¡¨ç»æç±æ¬å°æ¨¡æçæï¼ä»
ç¨äºèè°ä¸é¡µé¢æ¼ç¤ºã" |
| | | /> |
| | | |
| | | <!-- é¡¶é¨ï¼ä»æ¥ç´¯è®¡ / æ¬æç´¯è®¡ --> |
| | | <div class="top-grid"> |
| | | <section class="panel today-panel" v-loading="dashboardLoading"> |
| | | <header class="panel-head"> |
| | | <div class="head-left"> |
| | | <h3>仿¥ç´¯è®¡ç¨çµ</h3> |
| | | <span class="panel-range">{{ today.rangeStart || '00:00' }} è³ {{ today.rangeEnd || '--' }}</span> |
| | | </div> |
| | | <div class="panel-legend"> |
| | | <span><i class="dot-previous"></i>æ¨æ¥ç¨çµé</span> |
| | | <span><i class="dot-today"></i>仿¥ç¨çµé</span> |
| | | </div> |
| | | </header> |
| | | <div class="metric-line"> |
| | | <span class="metric-value">{{ formatNumber(today.total) }}</span> |
| | | <span class="metric-unit">度</span> |
| | | </div> |
| | | <div class="compare-line"> |
| | | <span> |
| | | è¾æ¨æ¥åæ¶æ®µ |
| | | <em :class="todayCompare.cls">{{ todayCompare.text }}</em> |
| | | </span> |
| | | <span class="sep"></span> |
| | | <span>æ¨æ¥åæ¶æ®µç´¯ç§¯ç¨çµéï¼<b>{{ formatValueUnit(today.yesterdaySamePeriodTotal, '度') }}</b></span> |
| | | </div> |
| | | <div ref="todayChartRef" class="chart chart-today"></div> |
| | | </section> |
| | | |
| | | <section class="panel month-panel" v-loading="overviewLoading"> |
| | | <header class="panel-head"> |
| | | <div class="head-left"> |
| | | <h3>æ¬æç´¯è®¡ç¨çµ</h3> |
| | | <span class="panel-range">{{ monthRangeText }}</span> |
| | | </div> |
| | | <div class="panel-legend"> |
| | | <span><i class="dot-previous-soft"></i>䏿åæ</span> |
| | | <span><i class="dot-today"></i>æ¬æç¨çµé</span> |
| | | </div> |
| | | </header> |
| | | <div class="metric-line"> |
| | | <span class="metric-value">{{ formatNumber(monthTotal) }}</span> |
| | | <span class="metric-unit">度</span> |
| | | </div> |
| | | <div class="compare-line"> |
| | | <span> |
| | | è¾ä¸æåæ |
| | | <em :class="monthCompare.cls">{{ monthCompare.text }}</em> |
| | | </span> |
| | | <span class="sep"></span> |
| | | <span>䏿åæç´¯ç§¯ç¨çµéï¼<b>{{ formatValueUnit(monthPreviousTotal, '度') }}</b></span> |
| | | </div> |
| | | <div ref="monthChartRef" class="chart chart-month"></div> |
| | | </section> |
| | | </div> |
| | | |
| | | <!-- ç¨çµæ°æ® --> |
| | | <section class="panel usage-panel" v-loading="overviewLoading"> |
| | | <header class="panel-head"> |
| | | <div class="head-left"> |
| | | <h3>ç¨çµæ°æ®</h3> |
| | | <div class="tabs"> |
| | | <button |
| | | v-for="tab in usageTabs" |
| | | :key="tab.value" |
| | | type="button" |
| | | :class="{ active: usageTab === tab.value }" |
| | | @click="switchUsageTab(tab.value)" |
| | | > |
| | | {{ tab.label }} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <span class="panel-range">{{ monthRangeText }} · å·²éé {{ collectedDayCount }} 天</span> |
| | | </header> |
| | | <div ref="usageChartRef" class="chart chart-usage"></div> |
| | | <div v-if="collectedDayCount > 0 && collectedDayCount <= 2" class="panel-note"> |
| | | å½ååªééå° {{ collectedDayCount }} å¤©æ°æ®ï¼è¶å¿å¾ä¼éæ¯æ¥ 08:00 忥鿥宿´ã |
| | | </div> |
| | | </section> |
| | | |
| | | <!-- çµè½è´¨é --> |
| | | <section class="panel quality-panel" v-loading="dashboardLoading"> |
| | | <header class="panel-head"> |
| | | <div class="head-left"> |
| | | <h3>çµè½è´¨é</h3> |
| | | <el-tooltip placement="top" effect="dark" raw-content :content="qualityTooltip"> |
| | | <span class="info-dot">?</span> |
| | | </el-tooltip> |
| | | <div class="tabs"> |
| | | <button type="button" :class="{ active: weekOffset === 0 }" @click="switchWeek(0)">è¿ä¸å¨</button> |
| | | <button type="button" :class="{ active: weekOffset === 1 }" @click="switchWeek(1)">ä¸ä¸å¨</button> |
| | | </div> |
| | | </div> |
| | | <span class="panel-range">{{ qualityRangeText }}</span> |
| | | </header> |
| | | |
| | | <div class="quality-summary"> |
| | | <div class="quality-stat"> |
| | | <span class="stat-label">æååç</span> |
| | | <span class="stat-value">{{ formatNumber(latestQuality.activePower) }}<i>kW</i></span> |
| | | </div> |
| | | <div class="quality-stat"> |
| | | <span class="stat-label">æ ååç</span> |
| | | <span class="stat-value">{{ formatNumber(latestQuality.reactivePower) }}<i>kvar</i></span> |
| | | </div> |
| | | <div class="quality-stat"> |
| | | <span class="stat-label">åçå æ°</span> |
| | | <span class="stat-value"> |
| | | {{ powerFactorText }} |
| | | <em v-if="powerFactorLevel" :class="powerFactorLevel.cls">{{ powerFactorLevel.text }}</em> |
| | | </span> |
| | | </div> |
| | | <span class="quality-time">{{ latestQuality.time ? 'æè¿æ°æ® ' + latestQuality.time : 'ææ æ°æ®' }}</span> |
| | | </div> |
| | | |
| | | <div v-if="qualityHasData" ref="qualityChartRef" class="chart chart-quality"></div> |
| | | <div v-else class="quality-empty"> |
| | | æé卿æ 15 åéæ°æ®ï¼è¶å¿å¾ä¼å¨ææ°æ®åèªå¨å±ç¤º |
| | | </div> |
| | | </section> |
| | | |
| | | <el-empty |
| | | v-if="overviewReady && !overview.deviceCount" |
| | | class="empty-tip" |
| | | description="å½åè¿æ²¡æçµè¡¨è®¾å¤ï¼è¯·å
å°è¡¨å
·å°è´¦æ°å¢" |
| | | :image-size="70" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' |
| | | import { useRoute } from 'vue-router' |
| | | import * as echarts from 'echarts' |
| | | import dayjs from 'dayjs' |
| | | import { ElMessage } from 'element-plus' |
| | | import { |
| | | collectMeterDevices, |
| | | getElectricMeterDashboard, |
| | | getMeterOverview, |
| | | listMeterDeviceOptions |
| | | } from '@/api/energyManagement/energyMeter' |
| | | |
| | | defineOptions({ name: 'ElectricMeterData' }) |
| | | |
| | | const route = useRoute() |
| | | |
| | | const COLOR_TODAY = '#f5822c' |
| | | const COLOR_PREVIOUS = '#2f5f96' |
| | | const COLOR_PREVIOUS_SOFT = '#9fb8d6' |
| | | const COLOR_REACTIVE = '#f4b9b0' |
| | | const COLOR_POWER_FACTOR = '#f5822c' |
| | | |
| | | const qualityTooltip = [ |
| | | '<b>æååç</b>ï¼å®é
ååçåçï¼kWï¼ã', |
| | | '<b>æ ååç</b>ï¼çµæºãååå¨å»ºç«ç£åºå ç¨çåçï¼kvarï¼ï¼ä¸ç´æ¥ååã', |
| | | '<b>åçå æ°</b> = æå ÷ â(æå² + æ å²)ï¼è¶æ¥è¿ 1 è¶å¥½ï¼åä½è¯´ææ åå ç¨å¤§ã线路æèé«ã', |
| | | 'æ°æ®æå°æ¶æ±æ»ï¼å°æ¶æªèµ°å®æ¶ï¼æå·²ééç 15 åéæ°æ®æç®å¹³ååçã' |
| | | ].join('<br/>') |
| | | |
| | | const usageTabs = [ |
| | | { label: 'çµé', value: 'energy' }, |
| | | { label: 'åç', value: 'power' } |
| | | ] |
| | | |
| | | const query = reactive({ |
| | | scope: 'ALL', |
| | | meterId: null, |
| | | month: dayjs().format('YYYY-MM'), |
| | | year: dayjs().format('YYYY') |
| | | }) |
| | | |
| | | const overview = ref({}) |
| | | const dashboard = ref({ today: {}, quality: {} }) |
| | | const deviceOptions = ref([]) |
| | | const overviewReady = ref(false) |
| | | const loading = ref(false) |
| | | const overviewLoading = ref(false) |
| | | const dashboardLoading = ref(false) |
| | | const refreshing = ref(false) |
| | | const weekOffset = ref(0) |
| | | const usageTab = ref('energy') |
| | | |
| | | const todayChartRef = ref(null) |
| | | const monthChartRef = ref(null) |
| | | const usageChartRef = ref(null) |
| | | const qualityChartRef = ref(null) |
| | | const charts = {} |
| | | |
| | | let reloadTimer = null |
| | | |
| | | // æ¬é¡µåªå¤ççµè¡¨è·¯ç±ä¸ç meterIdï¼é¿å
页ç¾ç¼ååæ¶å°æ°è¡¨/水表ç设å¤ID |
| | | const DATA_PATH = '/energyManagement/electricMeterData' |
| | | const requestedMeterId = ref( |
| | | route.path === DATA_PATH && route.query.meterId ? Number(route.query.meterId) : null |
| | | ) |
| | | |
| | | const today = computed(() => dashboard.value.today || {}) |
| | | const quality = computed(() => dashboard.value.quality || {}) |
| | | const qualityHasData = computed(() => (quality.value.series || []).length > 0) |
| | | const latestQuality = computed(() => { |
| | | const list = quality.value.series || [] |
| | | for (let i = list.length - 1; i >= 0; i--) { |
| | | const item = list[i] |
| | | if (item && (item.activePower != null || item.powerFactor != null)) { |
| | | return item |
| | | } |
| | | } |
| | | return {} |
| | | }) |
| | | const powerFactorText = computed(() => { |
| | | const value = latestQuality.value.powerFactor |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | return Number(value).toFixed(3) |
| | | }) |
| | | const powerFactorLevel = computed(() => { |
| | | const value = latestQuality.value.powerFactor |
| | | if (value == null || value === '') { |
| | | return null |
| | | } |
| | | const num = Number(value) |
| | | if (!Number.isFinite(num)) { |
| | | return null |
| | | } |
| | | if (num >= 0.9) { |
| | | return { text: 'è¯å¥½', cls: 'pf-good' } |
| | | } |
| | | if (num >= 0.8) { |
| | | return { text: 'ä¸è¬', cls: 'pf-normal' } |
| | | } |
| | | return { text: 'åä½', cls: 'pf-low' } |
| | | }) |
| | | const hasLiveToday = computed(() => |
| | | (today.value.series || []).some((item) => item && item.active != null) |
| | | ) |
| | | const monthSeries = computed(() => { |
| | | const list = overview.value.monthSeries || [] |
| | | const liveDate = dashboard.value.today && dashboard.value.today.date |
| | | const liveTotal = dashboard.value.today && dashboard.value.today.total |
| | | if (!hasLiveToday.value || !liveDate || liveTotal == null) { |
| | | return list |
| | | } |
| | | // 仿¥ 15 åé宿¶æ°æ®æ¯ä¸ä¸æ¬¡è½åºæ´æ°ï¼ç¨å®æ¶å¼è¦çå½å¤©çæ¥ç¨éï¼é¿å
âæ¬æç´¯è®¡ < 仿¥ç´¯è®¡â |
| | | return list.map((item) => |
| | | item.day === liveDate ? { ...item, current: Number(liveTotal) } : item |
| | | ) |
| | | }) |
| | | const monthTotal = computed(() => { |
| | | let total = 0 |
| | | let hasData = false |
| | | monthSeries.value.forEach((item) => { |
| | | if (item.current != null && item.current !== '') { |
| | | total += Number(item.current) |
| | | hasData = true |
| | | } |
| | | }) |
| | | return hasData ? total : null |
| | | }) |
| | | const monthPreviousTotal = computed(() => |
| | | overview.value.monthHasData ? overview.value.monthPreviousTotal : null |
| | | ) |
| | | const collectedDayCount = computed( |
| | | () => monthSeries.value.filter((item) => item.current != null && item.current !== '').length |
| | | ) |
| | | |
| | | const dataThroughText = computed(() => { |
| | | const liveDate = today.value.date |
| | | if (hasLiveToday.value && liveDate && today.value.rangeEnd && today.value.rangeEnd !== '--') { |
| | | return `${liveDate} ${today.value.rangeEnd}` |
| | | } |
| | | return overview.value.dataThrough || '--' |
| | | }) |
| | | |
| | | const monthRangeText = computed(() => { |
| | | const month = overview.value.selectedMonth || query.month |
| | | const days = dayjs(`${month}-01`).daysInMonth() |
| | | const isCurrentMonth = month === dayjs().format('YYYY-MM') |
| | | const end = isCurrentMonth |
| | | ? dayjs().format('MM-DD') |
| | | : `${month.slice(5)}-${String(days).padStart(2, '0')}` |
| | | return `${month.slice(5)}-01 è³ ${end}` |
| | | }) |
| | | |
| | | const qualityRangeText = computed(() => { |
| | | if (!quality.value.weekStart || !quality.value.weekEnd) { |
| | | return weekOffset.value === 1 ? 'ä¸ä¸å¨' : 'è¿ä¸å¨' |
| | | } |
| | | return `${quality.value.weekStart.slice(5)} è³ ${quality.value.weekEnd.slice(5)}` |
| | | }) |
| | | |
| | | const todayCompare = computed(() => buildCompare(today.value.compareRate)) |
| | | const monthCompare = computed(() => { |
| | | const current = Number(monthTotal.value) |
| | | const previous = Number(monthPreviousTotal.value) |
| | | if (monthTotal.value == null || !previous) { |
| | | return { text: '--', cls: '' } |
| | | } |
| | | return buildCompare(((current - previous) / previous) * 100) |
| | | }) |
| | | |
| | | const refreshableIds = computed(() => { |
| | | const providerIds = deviceOptions.value |
| | | .filter((item) => item.sourceType === 'PROVIDER') |
| | | .map((item) => item.id) |
| | | if (query.scope === 'SINGLE' && query.meterId) { |
| | | return providerIds.filter((id) => id === query.meterId) |
| | | } |
| | | return providerIds |
| | | }) |
| | | |
| | | const canRefresh = computed(() => refreshableIds.value.length > 0) |
| | | |
| | | function buildCompare(rate) { |
| | | if (rate == null || rate === '') { |
| | | return { text: '--', cls: '' } |
| | | } |
| | | const num = Number(rate) |
| | | if (Number.isNaN(num)) { |
| | | return { text: '--', cls: '' } |
| | | } |
| | | const arrow = num > 0 ? ' â' : num < 0 ? ' â' : '' |
| | | const cls = num > 0 ? 'up' : num < 0 ? 'down' : '' |
| | | return { text: `${Math.abs(num)}%${arrow}`, cls } |
| | | } |
| | | |
| | | function ensureMeterOption(id) { |
| | | if (!id || deviceOptions.value.some((item) => item.id === id)) { |
| | | return |
| | | } |
| | | deviceOptions.value = [{ id, displayName: `è®¾å¤ #${id}`, meterAddress: '' }, ...deviceOptions.value] |
| | | } |
| | | |
| | | function applyRequestedMeter(id) { |
| | | if (!id || route.path !== DATA_PATH) { |
| | | return |
| | | } |
| | | requestedMeterId.value = id |
| | | query.scope = 'SINGLE' |
| | | query.meterId = id |
| | | ensureMeterOption(id) |
| | | loadAll() |
| | | } |
| | | |
| | | onMounted(() => { |
| | | if (requestedMeterId.value && route.path === DATA_PATH) { |
| | | query.scope = 'SINGLE' |
| | | query.meterId = requestedMeterId.value |
| | | } |
| | | loadAll() |
| | | window.addEventListener('resize', handleResize) |
| | | }) |
| | | |
| | | watch( |
| | | () => route.query.meterId, |
| | | (value) => { |
| | | if (value && route.path === DATA_PATH) { |
| | | applyRequestedMeter(Number(value)) |
| | | } |
| | | } |
| | | ) |
| | | |
| | | onBeforeUnmount(() => { |
| | | window.removeEventListener('resize', handleResize) |
| | | if (reloadTimer) { |
| | | clearTimeout(reloadTimer) |
| | | reloadTimer = null |
| | | } |
| | | Object.keys(charts).forEach((key) => { |
| | | charts[key] && charts[key].dispose() |
| | | delete charts[key] |
| | | }) |
| | | }) |
| | | |
| | | function handleResize() { |
| | | Object.keys(charts).forEach((key) => charts[key] && charts[key].resize()) |
| | | } |
| | | |
| | | async function loadAll() { |
| | | loading.value = true |
| | | try { |
| | | await loadOptions() |
| | | await Promise.all([loadOverview(), loadDashboard()]) |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | async function loadOptions() { |
| | | try { |
| | | const res = await listMeterDeviceOptions({ meterType: 'ELECTRIC' }) |
| | | deviceOptions.value = res.data || [] |
| | | ensureMeterOption(requestedMeterId.value) |
| | | if (query.scope === 'SINGLE' && !query.meterId && deviceOptions.value.length) { |
| | | query.meterId = deviceOptions.value[0].id |
| | | } |
| | | } catch (e) { |
| | | deviceOptions.value = [] |
| | | } |
| | | } |
| | | |
| | | async function loadOverview() { |
| | | const params = { |
| | | meterType: 'ELECTRIC', |
| | | month: query.month, |
| | | year: Number(query.year) |
| | | } |
| | | if (query.scope === 'SINGLE' && query.meterId) { |
| | | params.meterId = query.meterId |
| | | } |
| | | overviewLoading.value = true |
| | | try { |
| | | const res = await getMeterOverview(params) |
| | | overview.value = res.data || {} |
| | | await nextTick() |
| | | renderMonthChart() |
| | | renderUsageChart() |
| | | } catch (e) { |
| | | overview.value = {} |
| | | await nextTick() |
| | | renderMonthChart() |
| | | renderUsageChart() |
| | | } finally { |
| | | overviewLoading.value = false |
| | | overviewReady.value = true |
| | | } |
| | | } |
| | | |
| | | async function loadDashboard() { |
| | | const params = { weekOffset: weekOffset.value } |
| | | if (query.scope === 'SINGLE' && query.meterId) { |
| | | params.meterId = query.meterId |
| | | } |
| | | dashboardLoading.value = true |
| | | try { |
| | | const res = await getElectricMeterDashboard(params) |
| | | dashboard.value = res.data || { today: {}, quality: {} } |
| | | } catch (e) { |
| | | dashboard.value = { today: {}, quality: {} } |
| | | } finally { |
| | | dashboardLoading.value = false |
| | | await nextTick() |
| | | renderTodayChart() |
| | | if (!qualityHasData.value) { |
| | | disposeChart('quality') |
| | | } else { |
| | | renderQualityChart() |
| | | } |
| | | // 仿¥å®æ¶å¼ä¼è¦çæ¬æå½å¤©çæ¥ç¨éï¼è¿é忥巿°æåº¦å¾è¡¨ |
| | | renderMonthChart() |
| | | renderUsageChart() |
| | | } |
| | | } |
| | | |
| | | function handleScopeChange(value) { |
| | | if (value === 'ALL') { |
| | | query.meterId = null |
| | | } else if (!query.meterId && deviceOptions.value.length) { |
| | | query.meterId = deviceOptions.value[0].id |
| | | } |
| | | loadAll() |
| | | } |
| | | |
| | | function handleMonthChange(value) { |
| | | if (value) { |
| | | query.year = value.slice(0, 4) |
| | | } |
| | | loadOverview() |
| | | } |
| | | |
| | | function switchUsageTab(value) { |
| | | usageTab.value = value |
| | | renderUsageChart() |
| | | } |
| | | |
| | | function switchWeek(offset) { |
| | | if (weekOffset.value === offset) { |
| | | return |
| | | } |
| | | weekOffset.value = offset |
| | | loadDashboard() |
| | | } |
| | | |
| | | async function handleRefreshData() { |
| | | const meterIds = refreshableIds.value |
| | | if (!meterIds.length) { |
| | | ElMessage.warning('å½åèå´æ²¡æå¯æ¥è¡¨çç¬¬ä¸æ¹æ¥å
¥è®¾å¤') |
| | | return |
| | | } |
| | | refreshing.value = true |
| | | try { |
| | | await collectMeterDevices({ |
| | | meterIds, |
| | | startDate: dayjs().subtract(3, 'day').format('YYYY-MM-DD'), |
| | | endDate: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), |
| | | includeReadings: true |
| | | }) |
| | | ElMessage.success('æ¥è¡¨ä»»å¡å·²æäº¤ï¼æ£å¨åå°éé') |
| | | if (reloadTimer) { |
| | | clearTimeout(reloadTimer) |
| | | } |
| | | reloadTimer = setTimeout(() => { |
| | | loadAll() |
| | | }, 4000) |
| | | } catch (e) { |
| | | // é误æç¤ºå·²ç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } finally { |
| | | refreshing.value = false |
| | | } |
| | | } |
| | | |
| | | function optionLabel(item) { |
| | | if (!item.meterAddress) { |
| | | return item.displayName |
| | | } |
| | | return `${item.displayName}ï¼${item.meterAddress}ï¼` |
| | | } |
| | | |
| | | /* ------------------------------ å¾è¡¨ ------------------------------ */ |
| | | |
| | | function initChart(refEl, key) { |
| | | if (!refEl) { |
| | | return null |
| | | } |
| | | if (!charts[key]) { |
| | | charts[key] = echarts.init(refEl) |
| | | } |
| | | return charts[key] |
| | | } |
| | | |
| | | function disposeChart(key) { |
| | | if (charts[key]) { |
| | | charts[key].dispose() |
| | | delete charts[key] |
| | | } |
| | | } |
| | | |
| | | function baseGrid() { |
| | | return { left: 46, right: 12, top: 30, bottom: 24, containLabel: false } |
| | | } |
| | | |
| | | function valueAxis(name) { |
| | | return { |
| | | type: 'value', |
| | | name, |
| | | nameTextStyle: { fontSize: 11, color: '#909399' }, |
| | | axisLabel: { fontSize: 10, color: '#909399' }, |
| | | splitLine: { lineStyle: { type: 'dashed', color: '#ebeef5' } } |
| | | } |
| | | } |
| | | |
| | | function emptyGraphic() { |
| | | return [ |
| | | { |
| | | type: 'text', |
| | | left: 'center', |
| | | top: 'middle', |
| | | style: { text: 'ææ æ°æ®', fill: '#c0c4cc', fontSize: 13 } |
| | | } |
| | | ] |
| | | } |
| | | |
| | | function hasAny(points, key) { |
| | | return Array.isArray(points) && points.some((item) => item && item[key] != null) |
| | | } |
| | | |
| | | function barSeries(name, data, color, extra = {}) { |
| | | return { |
| | | name, |
| | | type: 'bar', |
| | | barMaxWidth: 12, |
| | | barGap: '12%', |
| | | data, |
| | | itemStyle: { color, borderRadius: [1, 1, 0, 0] }, |
| | | emphasis: { focus: 'series' }, |
| | | ...extra |
| | | } |
| | | } |
| | | |
| | | function renderTodayChart() { |
| | | const chart = initChart(todayChartRef.value, 'today') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | const series = today.value.series || [] |
| | | const yesterdaySeries = today.value.yesterdaySeries || [] |
| | | const labels = series.map((item) => item.time) |
| | | const hasData = hasAny(series, 'active') || hasAny(yesterdaySeries, 'active') |
| | | chart.setOption( |
| | | { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { type: 'shadow' }, |
| | | formatter: (params) => { |
| | | const list = Array.isArray(params) ? params : [params] |
| | | const head = list.length ? list[0].axisValue : '' |
| | | const rows = list |
| | | .map((item) => { |
| | | const value = item.value == null ? 'ææ æ°æ®' : `${formatNumber(item.value)} 度` |
| | | return `${item.marker}${item.seriesName}ï¼${value}` |
| | | }) |
| | | .join('<br/>') |
| | | return `${head}<br/>${rows}` |
| | | } |
| | | }, |
| | | legend: { show: false }, |
| | | grid: { ...baseGrid(), top: 12, bottom: 22 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: labels, |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { |
| | | fontSize: 10, |
| | | color: '#909399', |
| | | interval: 5, |
| | | formatter: (value) => value |
| | | } |
| | | }, |
| | | yAxis: { ...valueAxis('度'), min: 0 }, |
| | | series: [ |
| | | barSeries( |
| | | 'æ¨æ¥ç¨çµé', |
| | | yesterdaySeries.map((item) => item.active), |
| | | COLOR_PREVIOUS |
| | | ), |
| | | barSeries( |
| | | '仿¥ç¨çµé', |
| | | series.map((item) => item.active), |
| | | COLOR_TODAY |
| | | ) |
| | | ], |
| | | graphic: hasData ? [] : emptyGraphic() |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | function renderMonthChart() { |
| | | const chart = initChart(monthChartRef.value, 'month') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | const series = monthSeries.value |
| | | const hasData = hasAny(series, 'current') || hasAny(series, 'previous') |
| | | chart.setOption( |
| | | { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { type: 'shadow' }, |
| | | formatter: (params) => { |
| | | const list = Array.isArray(params) ? params : [params] |
| | | const head = list.length ? `${query.month}-${String(Number(list[0].axisValue)).padStart(2, '0')}` : '' |
| | | const rows = list |
| | | .map((item) => { |
| | | const value = item.value == null ? 'ææ æ°æ®' : `${formatNumber(item.value)} 度` |
| | | return `${item.marker}${item.seriesName}ï¼${value}` |
| | | }) |
| | | .join('<br/>') |
| | | return `${head}<br/>${rows}` |
| | | } |
| | | }, |
| | | legend: { show: false }, |
| | | grid: { ...baseGrid(), top: 12, bottom: 22 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: series.map((item) => String(Number(item.day ? item.day.slice(8) : 0))), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { |
| | | fontSize: 9, |
| | | color: '#909399', |
| | | interval: 0 |
| | | } |
| | | }, |
| | | yAxis: { ...valueAxis('度'), min: 0 }, |
| | | series: [ |
| | | barSeries( |
| | | '䏿åæ', |
| | | series.map((item) => item.previous), |
| | | COLOR_PREVIOUS_SOFT |
| | | ), |
| | | barSeries( |
| | | 'æ¬æç¨çµé', |
| | | series.map((item) => item.current), |
| | | COLOR_TODAY |
| | | ) |
| | | ], |
| | | graphic: hasData ? [] : emptyGraphic() |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | function renderUsageChart() { |
| | | const chart = initChart(usageChartRef.value, 'usage') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | const series = monthSeries.value |
| | | const isEnergy = usageTab.value === 'energy' |
| | | const hasData = hasAny(series, 'current') |
| | | const toValue = (value) => { |
| | | if (value == null || value === '') { |
| | | return null |
| | | } |
| | | return isEnergy ? Number(value) : Number(value) / 24 |
| | | } |
| | | const unit = isEnergy ? '度' : 'kW' |
| | | |
| | | chart.setOption( |
| | | { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | formatter: (params) => { |
| | | const list = Array.isArray(params) ? params : [params] |
| | | const head = list.length ? `${query.month}-${String(Number(list[0].axisValue)).padStart(2, '0')}` : '' |
| | | const rows = list |
| | | .map((item) => { |
| | | const value = item.value == null ? 'ææ æ°æ®' : `${formatNumber(item.value)} ${unit}` |
| | | return `${item.marker}${item.seriesName}ï¼${value}` |
| | | }) |
| | | .join('<br/>') |
| | | return `${head}<br/>${rows}` |
| | | } |
| | | }, |
| | | legend: { |
| | | data: isEnergy ? ['䏿åæ', 'æ¬æç¨çµé'] : ['䏿æ¥ååç', 'æ¬ææ¥ååç'], |
| | | top: 0, |
| | | right: 0, |
| | | itemWidth: 9, |
| | | itemHeight: 8, |
| | | textStyle: { fontSize: 11, color: '#606266' } |
| | | }, |
| | | grid: { ...baseGrid(), top: 34, bottom: 30, left: 50 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: isEnergy, |
| | | data: series.map((item) => String(Number(item.day ? item.day.slice(8) : 0))), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { fontSize: 10, color: '#909399' } |
| | | }, |
| | | yAxis: { ...valueAxis(unit), min: 0 }, |
| | | series: isEnergy |
| | | ? [ |
| | | barSeries( |
| | | '䏿åæ', |
| | | series.map((item) => toValue(item.previous)), |
| | | COLOR_PREVIOUS_SOFT, |
| | | { barMaxWidth: 14 } |
| | | ), |
| | | barSeries( |
| | | 'æ¬æç¨çµé', |
| | | series.map((item) => toValue(item.current)), |
| | | COLOR_TODAY, |
| | | { barMaxWidth: 14 } |
| | | ) |
| | | ] |
| | | : [ |
| | | { |
| | | name: '䏿æ¥ååç', |
| | | type: 'line', |
| | | smooth: true, |
| | | symbol: 'none', |
| | | data: series.map((item) => toValue(item.previous)), |
| | | lineStyle: { width: 1.5, color: COLOR_PREVIOUS, type: 'dashed' }, |
| | | itemStyle: { color: COLOR_PREVIOUS } |
| | | }, |
| | | { |
| | | name: 'æ¬ææ¥ååç', |
| | | type: 'line', |
| | | smooth: true, |
| | | symbol: 'none', |
| | | data: series.map((item) => toValue(item.current)), |
| | | lineStyle: { width: 2, color: COLOR_TODAY }, |
| | | itemStyle: { color: COLOR_TODAY }, |
| | | areaStyle: { color: 'rgba(245, 130, 44, 0.10)' } |
| | | } |
| | | ], |
| | | graphic: hasData ? [] : emptyGraphic() |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | function renderQualityChart() { |
| | | const chart = initChart(qualityChartRef.value, 'quality') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | const series = quality.value.series || [] |
| | | const hasData = series.length > 0 |
| | | const axisInterval = series.length > 12 ? Math.ceil(series.length / 7) - 1 : 0 |
| | | |
| | | chart.setOption( |
| | | { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { type: 'shadow' }, |
| | | formatter: (params) => { |
| | | const list = Array.isArray(params) ? params : [params] |
| | | const head = list.length ? list[0].axisValue : '' |
| | | const rows = list |
| | | .map((item) => { |
| | | if (item.value == null) { |
| | | return `${item.marker}${item.seriesName}ï¼ææ æ°æ®` |
| | | } |
| | | if (item.seriesName === 'åçå æ°') { |
| | | return `${item.marker}${item.seriesName}ï¼${Number(item.value).toFixed(3)}` |
| | | } |
| | | const unit = item.seriesName === 'æååç' ? 'kW' : 'kvar' |
| | | return `${item.marker}${item.seriesName}ï¼${formatNumber(item.value)} ${unit}` |
| | | }) |
| | | .join('<br/>') |
| | | return `${head}<br/>${rows}` |
| | | } |
| | | }, |
| | | legend: { |
| | | data: ['æååç', 'æ ååç', 'åçå æ°'], |
| | | top: 0, |
| | | right: 0, |
| | | itemWidth: 9, |
| | | itemHeight: 8, |
| | | textStyle: { fontSize: 11, color: '#606266' } |
| | | }, |
| | | grid: { ...baseGrid(), top: 34, right: 52, bottom: 26, left: 50 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: series.map((item) => item.time), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { |
| | | fontSize: 10, |
| | | color: '#909399', |
| | | interval: axisInterval, |
| | | formatter: (value) => value |
| | | } |
| | | }, |
| | | yAxis: [ |
| | | { |
| | | ...valueAxis(''), |
| | | min: 0, |
| | | max: (value) => (value.max <= 0 ? 1 : Math.ceil(value.max * 2) / 2) |
| | | }, |
| | | { |
| | | type: 'value', |
| | | name: '', |
| | | min: 0, |
| | | max: 1, |
| | | interval: 0.2, |
| | | nameTextStyle: { fontSize: 11, color: '#909399' }, |
| | | axisLabel: { fontSize: 10, color: '#909399' }, |
| | | splitLine: { show: false } |
| | | } |
| | | ], |
| | | series: [ |
| | | barSeries( |
| | | 'æååç', |
| | | series.map((item) => item.activePower), |
| | | COLOR_PREVIOUS, |
| | | { barMaxWidth: 8, barGap: '30%' } |
| | | ), |
| | | barSeries( |
| | | 'æ ååç', |
| | | series.map((item) => item.reactivePower), |
| | | COLOR_REACTIVE, |
| | | { barMaxWidth: 8 } |
| | | ), |
| | | { |
| | | name: 'åçå æ°', |
| | | type: 'line', |
| | | yAxisIndex: 1, |
| | | smooth: true, |
| | | symbol: 'none', |
| | | data: series.map((item) => item.powerFactor), |
| | | lineStyle: { width: 1.5, color: COLOR_POWER_FACTOR }, |
| | | itemStyle: { color: COLOR_POWER_FACTOR } |
| | | } |
| | | ], |
| | | graphic: hasData ? [] : emptyGraphic() |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | /* ------------------------------ å·¥å
· ------------------------------ */ |
| | | |
| | | function formatNumber(value) { |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | const num = Number(value) |
| | | if (Number.isNaN(num)) { |
| | | return String(value) |
| | | } |
| | | return num.toFixed(3).replace(/\.?0+$/, '') |
| | | } |
| | | |
| | | function formatValueUnit(value, unit) { |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | return `${formatNumber(value)}${unit}` |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .electric-page { |
| | | padding-bottom: 24px; |
| | | } |
| | | |
| | | .page-head { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: flex-end; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .page-title { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: baseline; |
| | | gap: 10px; |
| | | |
| | | h2 { |
| | | margin: 0; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .page-desc { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | .page-filters { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .status-line { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 6px; |
| | | margin-bottom: 12px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | |
| | | b { |
| | | color: #303133; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .dot { |
| | | color: #dcdfe6; |
| | | } |
| | | } |
| | | |
| | | .mock-alert { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .top-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | | gap: 12px; |
| | | } |
| | | |
| | | .panel { |
| | | padding: 12px 16px 6px; |
| | | margin-bottom: 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 6px; |
| | | background: #fff; |
| | | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); |
| | | } |
| | | |
| | | .panel-head { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .head-left { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .panel-head h3 { |
| | | margin: 0; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | line-height: 22px; |
| | | } |
| | | |
| | | .panel-range { |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .panel-legend { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 12px; |
| | | padding-top: 4px; |
| | | font-size: 11px; |
| | | color: #606266; |
| | | |
| | | i { |
| | | display: inline-block; |
| | | width: 9px; |
| | | height: 8px; |
| | | margin-right: 4px; |
| | | border-radius: 1px; |
| | | vertical-align: middle; |
| | | } |
| | | |
| | | .dot-previous { |
| | | background: #2f5f96; |
| | | } |
| | | |
| | | .dot-previous-soft { |
| | | background: #9fb8d6; |
| | | } |
| | | |
| | | .dot-today { |
| | | background: #f5822c; |
| | | } |
| | | } |
| | | |
| | | .metric-line { |
| | | margin-top: 2px; |
| | | } |
| | | |
| | | .metric-value { |
| | | font-size: 26px; |
| | | font-weight: 600; |
| | | color: #f5822c; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .metric-unit { |
| | | margin-left: 4px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .compare-line { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 8px; |
| | | min-height: 18px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | |
| | | em { |
| | | font-style: normal; |
| | | } |
| | | |
| | | b { |
| | | color: #303133; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .sep { |
| | | width: 1px; |
| | | height: 11px; |
| | | background: #e4e7ed; |
| | | } |
| | | } |
| | | |
| | | .up { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .down { |
| | | color: #67c23a; |
| | | } |
| | | |
| | | .tabs { |
| | | display: inline-flex; |
| | | gap: 6px; |
| | | } |
| | | |
| | | .tabs button { |
| | | padding: 3px 10px; |
| | | border: 1px solid #dcdfe6; |
| | | border-radius: 3px; |
| | | background: #fff; |
| | | color: #606266; |
| | | font-size: 12px; |
| | | line-height: 18px; |
| | | cursor: pointer; |
| | | transition: all 0.15s; |
| | | |
| | | &:hover { |
| | | color: #f5822c; |
| | | border-color: #f6c39b; |
| | | } |
| | | |
| | | &.active { |
| | | color: #f5822c; |
| | | border-color: #f5822c; |
| | | background: #fff7f1; |
| | | } |
| | | } |
| | | |
| | | .info-dot { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | width: 15px; |
| | | height: 15px; |
| | | border-radius: 50%; |
| | | background: #f0f2f5; |
| | | color: #909399; |
| | | font-size: 10px; |
| | | font-weight: 600; |
| | | line-height: 1; |
| | | cursor: help; |
| | | } |
| | | |
| | | .quality-summary { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: flex-end; |
| | | gap: 14px 36px; |
| | | padding: 10px 2px 8px; |
| | | } |
| | | |
| | | .quality-stat { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 2px; |
| | | } |
| | | |
| | | .stat-label { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .stat-value { |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | line-height: 1.3; |
| | | |
| | | i { |
| | | margin-left: 3px; |
| | | font-size: 11px; |
| | | font-style: normal; |
| | | font-weight: 400; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | em { |
| | | margin-left: 6px; |
| | | font-size: 11px; |
| | | font-style: normal; |
| | | font-weight: 400; |
| | | } |
| | | } |
| | | |
| | | .quality-time { |
| | | margin-left: auto; |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .quality-empty { |
| | | padding: 6px 0 22px; |
| | | font-size: 12px; |
| | | text-align: center; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .pf-good { |
| | | color: #67c23a; |
| | | } |
| | | |
| | | .pf-normal { |
| | | color: #e6a23c; |
| | | } |
| | | |
| | | .pf-low { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .panel-note { |
| | | padding: 0 4px 6px; |
| | | font-size: 11px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .chart { |
| | | width: 100%; |
| | | } |
| | | |
| | | .chart-today, |
| | | .chart-month { |
| | | height: 208px; |
| | | } |
| | | |
| | | .chart-usage { |
| | | height: 250px; |
| | | } |
| | | |
| | | .chart-quality { |
| | | height: 230px; |
| | | } |
| | | |
| | | .empty-tip { |
| | | padding: 8px 0 16px; |
| | | } |
| | | |
| | | @media (max-width: 1200px) { |
| | | .top-grid { |
| | | grid-template-columns: minmax(0, 1fr); |
| | | } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container meter-data-page"> |
| | | <div class="page-header"> |
| | | <h2>æ°è¡¨æ°æ®</h2> |
| | | <span class="page-desc"> |
| | | æ°è¡¨ç¨éæ¦è§ä¸è¶å¿ï¼åä½ m³ï¼ï¼æ¯æç¬¬ä¸æ¹æ¥å
¥ä¸æå·¥æè¡¨ï¼è¯·å°ã表å
·å°è´¦ãæ°å¢è®¾å¤ã |
| | | </span> |
| | | </div> |
| | | <MeterOverview meter-type="GAS" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import MeterOverview from '../components/MeterOverview.vue' |
| | | |
| | | defineOptions({ name: 'GasMeterData' }) |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .meter-data-page { |
| | | padding-bottom: 24px; |
| | | } |
| | | |
| | | .page-header { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: baseline; |
| | | gap: 12px; |
| | | margin-bottom: 12px; |
| | | |
| | | h2 { |
| | | margin: 0; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .page-desc { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container meter-data-page"> |
| | | <div class="page-header"> |
| | | <h2>æ°´è¡¨æ°æ®</h2> |
| | | <span class="page-desc">水表ç¨éæ¦è§ä¸è¶å¿ï¼åä½ m³ï¼ï¼è®¾å¤æ°å¢ãç¼è¾ä¸å½è¡¨è¯·å°ã表å
·å°è´¦ãã</span> |
| | | </div> |
| | | <MeterOverview meter-type="WATER" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import MeterOverview from '../components/MeterOverview.vue' |
| | | |
| | | defineOptions({ name: 'WaterMeterData' }) |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .meter-data-page { |
| | | padding-bottom: 24px; |
| | | } |
| | | |
| | | .page-header { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: baseline; |
| | | gap: 12px; |
| | | margin-bottom: 12px; |
| | | |
| | | h2 { |
| | | margin: 0; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .page-desc { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container meter-ledger"> |
| | | <!-- 页头 --> |
| | | <div class="page-head"> |
| | | <div class="page-head-main"> |
| | | <div class="page-title">表å
·å°è´¦</div> |
| | | </div> |
| | | <div class="page-head-actions"> |
| | | <el-button :icon="Refresh" @click="refreshAll">å·æ°</el-button> |
| | | <el-popover |
| | | v-model:visible="addMenuVisible" |
| | | placement="bottom-end" |
| | | :width="264" |
| | | :teleported="false" |
| | | trigger="click" |
| | | popper-class="meter-add-popper" |
| | | > |
| | | <div class="add-menu"> |
| | | <div v-for="group in addGroups" :key="group.type" class="add-group"> |
| | | <div class="add-group-title"> |
| | | <span class="add-dot" :class="group.type.toLowerCase()"></span> |
| | | {{ group.name }} |
| | | </div> |
| | | <button |
| | | v-for="option in group.options" |
| | | :key="option.source" |
| | | type="button" |
| | | class="add-option" |
| | | @click="handleAdd(group.type, option.source)" |
| | | > |
| | | <span class="add-option-label">{{ option.label }}</span> |
| | | <span class="add-option-hint">{{ option.hint }}</span> |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <template #reference> |
| | | <el-button type="primary" :icon="Plus"> |
| | | æ°å¢è®¾å¤ |
| | | <el-icon class="el-icon--right"><ArrowDown /></el-icon> |
| | | </el-button> |
| | | </template> |
| | | </el-popover> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- è½æºç±»å忢 --> |
| | | <div class="type-switch"> |
| | | <div |
| | | v-for="item in meterTypes" |
| | | :key="item.value" |
| | | class="type-card" |
| | | :class="[item.value.toLowerCase(), { active: activeType === item.value }]" |
| | | @click="switchType(item.value)" |
| | | > |
| | | <div class="type-badge">{{ item.label.charAt(0) }}</div> |
| | | <div class="type-info"> |
| | | <div class="type-name">{{ item.label }}</div> |
| | | <div class="type-count"><b>{{ counts[item.value] == null ? '-' : counts[item.value] }}</b> å°</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- çé --> |
| | | <div class="filter-bar"> |
| | | <el-input |
| | | v-model="stateMap[activeType].keyword" |
| | | class="filter-keyword" |
| | | placeholder="æç´¢è®¾å¤åç§° / 表å·" |
| | | clearable |
| | | :prefix-icon="Search" |
| | | @keyup.enter="handleQuery" |
| | | @clear="handleQuery" |
| | | /> |
| | | <el-select |
| | | v-model="stateMap[activeType].collectionMode" |
| | | placeholder="å
¨é¨ééæ¹å¼" |
| | | clearable |
| | | style="width: 150px" |
| | | @change="handleQuery" |
| | | > |
| | | <el-option label="èªå¨éé" value="AUTO" /> |
| | | <el-option label="æå¨æè¡¨" value="MANUAL" /> |
| | | </el-select> |
| | | <el-button type="primary" plain @click="handleQuery">æ¥è¯¢</el-button> |
| | | <el-button text @click="handleReset">éç½®</el-button> |
| | | <div class="filter-summary">å
± <b>{{ stateMap[activeType].total }}</b> å°{{ activeTypeName }}</div> |
| | | </div> |
| | | |
| | | <!-- å表 --> |
| | | <div class="table-card"> |
| | | <el-table :data="devices" v-loading="tableLoading" row-key="id" class="ledger-table"> |
| | | <el-table-column type="index" label="åºå·" width="70" align="center" :index="indexMethod" /> |
| | | <el-table-column label="设å¤åç§°" min-width="210" show-overflow-tooltip> |
| | | <template #default="scope"> |
| | | <div class="name-cell"> |
| | | <span class="device-name">{{ scope.row.displayName || '--' }}</span> |
| | | </div> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ééæ¹å¼" width="110" align="center"> |
| | | <template #default="scope"> |
| | | <span class="collect-mode" :class="isAutoCollect(scope.row) ? 'auto' : 'manual'"> |
| | | <i></i>{{ isAutoCollect(scope.row) ? 'èªå¨éé' : 'æå¨æè¡¨' }} |
| | | </span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="meterAddress" label="表å·" width="160" align="center" class-name="col-mono" show-overflow-tooltip /> |
| | | <el-table-column label="å®è£
ä½ç½®" min-width="180" align="center" show-overflow-tooltip> |
| | | <template #default="scope">{{ scope.row.location || '--' }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="æè¿ç¨é" width="115" align="right"> |
| | | <template #default="scope"> |
| | | <span v-if="scope.row.lastDayUsage != null" class="num"> |
| | | {{ formatNumber(scope.row.lastDayUsage) }}<span class="num-unit">{{ unitText(scope.row.meterType) }}</span> |
| | | </span> |
| | | <span v-else class="device-sub">--</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="䏿¬¡è¯»æ°" width="115" align="right"> |
| | | <template #default="scope"> |
| | | <span v-if="scope.row.previousReading != null" class="num prev"> |
| | | {{ formatNumber(scope.row.previousReading) }}<span class="num-unit">{{ unitText(scope.row.meterType) }}</span> |
| | | </span> |
| | | <span v-else class="device-sub">--</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ææ°è¯»æ°" width="115" align="right"> |
| | | <template #default="scope"> |
| | | <span v-if="scope.row.latestReading != null" class="num"> |
| | | {{ formatNumber(scope.row.latestReading) }}<span class="num-unit">{{ unitText(scope.row.meterType) }}</span> |
| | | </span> |
| | | <span v-else class="device-sub">--</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" width="320" fixed="right" align="center"> |
| | | <template #default="scope"> |
| | | <div class="cell-actions"> |
| | | <el-button link type="primary" @click="goData(scope.row)">æ¥çæ°æ®</el-button> |
| | | <el-button |
| | | v-if="isManualRow(scope.row)" |
| | | link |
| | | type="primary" |
| | | @click="openReadingDialog(scope.row)" |
| | | > |
| | | å½è¡¨ |
| | | </el-button> |
| | | <el-button |
| | | v-else-if="scope.row.simulated !== 1" |
| | | link |
| | | type="primary" |
| | | @click="openCollectDialog(scope.row)" |
| | | > |
| | | æ¥è¡¨ |
| | | </el-button> |
| | | <el-button |
| | | v-if="isManualRow(scope.row)" |
| | | link |
| | | type="primary" |
| | | @click="openReadingsDrawer(scope.row)" |
| | | > |
| | | æè¡¨è®°å½ |
| | | </el-button> |
| | | <el-button link type="primary" @click="openDeviceForm('edit', scope.row)">ç¼è¾</el-button> |
| | | <template v-if="scope.row.deletable === true"> |
| | | <el-button link type="danger" @click="handleDelete(scope.row)">å é¤</el-button> |
| | | </template> |
| | | <el-tooltip v-else :content="deleteTip(scope.row)" placement="top"> |
| | | <span class="btn-disabled"> |
| | | <el-button link type="danger" disabled>å é¤</el-button> |
| | | </span> |
| | | </el-tooltip> |
| | | </div> |
| | | </template> |
| | | </el-table-column> |
| | | <template #empty> |
| | | <div class="empty-box"> |
| | | <el-empty :description="`ææ ${activeTypeName}设å¤`" :image-size="72"> |
| | | <el-button type="primary" plain @click="handleAdd(activeType, 'MANUAL')">æå·¥æè¡¨æ°å¢</el-button> |
| | | </el-empty> |
| | | </div> |
| | | </template> |
| | | </el-table> |
| | | |
| | | <div class="table-footer"> |
| | | <el-pagination |
| | | layout="total, sizes, prev, pager, next, jumper" |
| | | :total="stateMap[activeType].total" |
| | | :current-page="stateMap[activeType].current" |
| | | :page-size="stateMap[activeType].size" |
| | | :page-sizes="[10, 20, 50]" |
| | | background |
| | | @current-change="handlePageChange" |
| | | @size-change="handleSizeChange" |
| | | /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ç¬¬ä¸æ¹æ¥å
¥ï¼éªè¯æ¡£æ¡ â ç»å® --> |
| | | <DeviceFormDialog |
| | | ref="deviceFormRef" |
| | | :default-meter-type="activeType" |
| | | @success="handleDeviceSaved" |
| | | /> |
| | | |
| | | |
| | | <!-- æ°å¢æå·¥æè¡¨è®¾å¤ --> |
| | | <el-dialog v-model="manualDialog.visible" :title="manualDialogTitle" width="560px" append-to-body> |
| | | <el-form ref="manualFormRef" :model="manualDialog.form" :rules="manualRules" label-width="108px"> |
| | | <el-form-item label="è½æºç±»å"> |
| | | <el-tag :type="typeTag(manualDialog.meterType)" size="small" effect="plain"> |
| | | {{ typeText(manualDialog.meterType) }} |
| | | </el-tag> |
| | | <span class="field-tip">ææ°å¢å
¥å£èªå¨å¸¦å
¥ï¼ä¸å¯ä¿®æ¹</span> |
| | | </el-form-item> |
| | | <el-form-item label="表å·" prop="meterAddress"> |
| | | <el-input v-model="manualDialog.form.meterAddress" placeholder="请è¾å
¥è¡¨å·ï¼ä¿çå导é¶" maxlength="64" /> |
| | | </el-form-item> |
| | | <el-form-item label="æ¾ç¤ºåç§°"> |
| | | <el-input v-model="manualDialog.form.displayName" placeholder="ç空æ¶ä½¿ç¨è¡¨å·" maxlength="128" /> |
| | | </el-form-item> |
| | | <el-form-item label="å®è£
ä½ç½®"> |
| | | <el-input v-model="manualDialog.form.location" :placeholder="locationPlaceholder(manualDialog.meterType)" maxlength="255" /> |
| | | </el-form-item> |
| | | <el-form-item label="èµ·å§è¡¨ç " prop="initialReading"> |
| | | <el-input-number |
| | | v-model="manualDialog.form.initialReading" |
| | | :min="0" |
| | | :precision="manualReadingPrecision" |
| | | :step="manualReadingStep" |
| | | controls-position="right" |
| | | style="width: 100%" |
| | | /> |
| | | <div class="field-tip">èµ·å§è¡¨ç ä½ä¸ºåºåè®°å½ï¼ä¸è®¡å
¥ç¨éï¼åä½ {{ unitText(manualDialog.meterType) }}</div> |
| | | </el-form-item> |
| | | <el-form-item label="èµ·å§æ¥æ" prop="initialReadingDate"> |
| | | <el-date-picker |
| | | v-model="manualDialog.form.initialReadingDate" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | :clearable="false" |
| | | :disabled-date="disableAfterToday" |
| | | placeholder="è¯·éæ©èµ·å§æ¥æ" |
| | | style="width: 100%" |
| | | /> |
| | | <div class="field-tip">é»è®¤ä»å¤©ï¼ä¸å¯æäºä»å¤©</div> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | | <el-button @click="manualDialog.visible = false">å æ¶</el-button> |
| | | <el-button type="primary" :loading="manualDialog.submitting" @click="submitManualDevice">ç¡® å®</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- å½è¡¨ --> |
| | | <el-dialog v-model="readingDialog.visible" title="æå·¥å½è¡¨" width="560px" append-to-body> |
| | | <el-form ref="readingFormRef" :model="readingDialog.form" :rules="readingRules" label-width="108px"> |
| | | <el-form-item label="设å¤"> |
| | | <div class="reading-device"> |
| | | <div class="device-name"> |
| | | {{ readingDialog.device ? readingDialog.device.displayName : '--' }} |
| | | </div> |
| | | <div class="device-sub"> |
| | | è¡¨å· {{ readingDialog.device ? readingDialog.device.meterAddress : '--' }} · åä½ |
| | | {{ readingDialog.device ? unitText(readingDialog.device.meterType) : '--' }} |
| | | </div> |
| | | </div> |
| | | </el-form-item> |
| | | <el-form-item label="ä¸æè¡¨ç "> |
| | | <span v-if="readingDialog.previousLoading" class="device-sub">读åä¸â¦</span> |
| | | <span v-else-if="readingDialog.previousReading != null"> |
| | | {{ formatNumber(readingDialog.previousReading) }} |
| | | {{ readingDialog.device ? unitText(readingDialog.device.meterType) : '' }} |
| | | </span> |
| | | <span v-else class="device-sub">æ </span> |
| | | </el-form-item> |
| | | <el-form-item label="æè¡¨æ¥æ" prop="readingDate"> |
| | | <el-date-picker |
| | | v-model="readingDialog.form.readingDate" |
| | | type="date" |
| | | value-format="YYYY-MM-DD" |
| | | :clearable="false" |
| | | :disabled-date="disableReadingDate" |
| | | placeholder="è¯·éæ©æè¡¨æ¥æ" |
| | | style="width: 100%" |
| | | /> |
| | | <div class="field-tip">ä¸å¯æäºä»å¤©ï¼ä¸å¯æ©äºè®¾å¤èµ·å§è¡¨ç æ¥æ</div> |
| | | </el-form-item> |
| | | <el-form-item label="æ¬æ¬¡è¡¨ç " prop="readingValue"> |
| | | <el-input-number |
| | | v-model="readingDialog.form.readingValue" |
| | | :min="0" |
| | | :precision="readingPrecision" |
| | | :step="readingStep" |
| | | controls-position="right" |
| | | style="width: 100%" |
| | | /> |
| | | <div class="field-tip">æ¬æ¬¡è¡¨ç ä¸è½å°äºä¸æè¡¨ç ï¼åä½ {{ unitText(readingDialog.device && readingDialog.device.meterType) }}</div> |
| | | </el-form-item> |
| | | <el-form-item label="夿³¨"> |
| | | <el-input v-model="readingDialog.form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | | <el-button @click="readingDialog.visible = false">å æ¶</el-button> |
| | | <el-button type="primary" :loading="readingDialog.submitting" @click="submitReading">ç¡® å®</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <!-- æè¡¨è®°å½ --> |
| | | <el-drawer |
| | | v-model="readingDrawer.visible" |
| | | direction="rtl" |
| | | size="720px" |
| | | :title="readingDrawerTitle" |
| | | :destroy-on-close="false" |
| | | > |
| | | <div class="readings-panel"> |
| | | <el-table :data="readingDrawer.list" v-loading="readingDrawer.loading" size="small" border> |
| | | <el-table-column label="æè¡¨æ¥æ" width="120"> |
| | | <template #default="scope">{{ readingDate(scope.row) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="表ç " width="130" align="right"> |
| | | <template #default="scope">{{ formatNumber(scope.row.metricValue) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="æ¬æç¨é" width="130" align="right"> |
| | | <template #default="scope"> |
| | | <span v-if="scope.row.usageValue != null">{{ formatNumber(scope.row.usageValue) }}</span> |
| | | <span v-else class="device-sub">--</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="夿³¨" min-width="180" show-overflow-tooltip> |
| | | <template #default="scope">{{ scope.row.remark || '--' }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" width="90" align="center"> |
| | | <template #default="scope"> |
| | | <el-button |
| | | v-if="scope.row.remark !== 'èµ·å§è¡¨ç '" |
| | | link |
| | | type="danger" |
| | | @click="handleDeleteReading(scope.row)" |
| | | > |
| | | å é¤ |
| | | </el-button> |
| | | <span v-else class="device-sub">--</span> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <el-pagination |
| | | class="readings-pagination" |
| | | layout="total, sizes, prev, pager, next" |
| | | :total="readingDrawer.total" |
| | | :current-page="readingDrawer.current" |
| | | :page-size="readingDrawer.size" |
| | | :page-sizes="[10, 20, 50]" |
| | | @current-change="handleReadingPageChange" |
| | | @size-change="handleReadingSizeChange" |
| | | /> |
| | | </div> |
| | | </el-drawer> |
| | | |
| | | <!-- æå¨æ¥è¡¨ï¼ä»
ç¬¬ä¸æ¹æ¥å
¥è®¾å¤ï¼ --> |
| | | <el-dialog v-model="collectDialog.visible" title="æå¨æ¥è¡¨" width="480px" append-to-body> |
| | | <el-form label-width="92px"> |
| | | <el-form-item label="æ¥è¡¨è®¾å¤"> |
| | | <div class="collect-devices"> |
| | | <el-tag v-if="collectDialog.device" size="small" effect="plain"> |
| | | {{ collectDialog.device.displayName }}ï¼{{ collectDialog.device.meterAddress }}ï¼ |
| | | </el-tag> |
| | | </div> |
| | | </el-form-item> |
| | | <el-form-item label="æ¥æèå´"> |
| | | <el-date-picker |
| | | v-model="collectDialog.range" |
| | | type="daterange" |
| | | value-format="YYYY-MM-DD" |
| | | range-separator="è³" |
| | | start-placeholder="å¼å§æ¥æ" |
| | | end-placeholder="ç»ææ¥æ" |
| | | :disabled-date="disableAfterYesterday" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="å
å«ç¤ºå¼"> |
| | | <el-switch v-model="collectDialog.includeReadings" /> |
| | | <span class="field-tip">å
³éååªééæé´ç¨é</span> |
| | | </el-form-item> |
| | | </el-form> |
| | | <div class="dialog-tip"> |
| | | æå¨æ¥è¡¨å¨åå°æ§è¡ï¼å®æåå表ä¼èªå¨å·æ°ï¼é夿¥è¯¢å䏿¶é´èå´ä¸ä¼äº§çéå¤è®°å½ã |
| | | </div> |
| | | <template #footer> |
| | | <el-button @click="collectDialog.visible = false">å æ¶</el-button> |
| | | <el-button type="primary" :loading="collectDialog.submitting" @click="submitCollect">å¼å§æ¥è¡¨</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue' |
| | | import { useRouter } from 'vue-router' |
| | | import dayjs from 'dayjs' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { ArrowDown, Plus, Refresh, Search } from '@element-plus/icons-vue' |
| | | import DeviceFormDialog from '../waterElectricMeter/components/DeviceFormDialog.vue' |
| | | import { |
| | | addManualMeterDevice, |
| | | addMeterReading, |
| | | collectMeterDevice, |
| | | deleteMeterDevice, |
| | | deleteMeterReading, |
| | | listMeterDevices, |
| | | listMeterReadings |
| | | } from '@/api/energyManagement/energyMeter' |
| | | |
| | | defineOptions({ name: 'MeterLedger' }) |
| | | |
| | | const router = useRouter() |
| | | |
| | | // ä¸ç§è½æºç±»åï¼åèªä¿ç䏿¬¡çç鿡件ä¸å页 |
| | | const meterTypes = [ |
| | | { value: 'WATER', label: '水表' }, |
| | | { value: 'ELECTRIC', label: 'çµè¡¨' }, |
| | | { value: 'GAS', label: 'è¸æ±½è¡¨' } |
| | | ] |
| | | |
| | | // æ°å¢å
¥å£æè½æºç±»ååç»ï¼ä¸ç±»è¡¨é½æ¯æç¬¬ä¸æ¹æ¥å
¥ä¸æå·¥æè¡¨ |
| | | const addGroups = [ |
| | | { |
| | | type: 'WATER', |
| | | name: '水表', |
| | | options: [ |
| | | { source: 'PROVIDER', label: 'ç¬¬ä¸æ¹æ¥å
¥', hint: 'èªå¨éé' }, |
| | | { source: 'MANUAL', label: 'æå·¥æè¡¨', hint: 'æå¨å½è¡¨' } |
| | | ] |
| | | }, |
| | | { |
| | | type: 'ELECTRIC', |
| | | name: 'çµè¡¨', |
| | | options: [ |
| | | { source: 'PROVIDER', label: 'ç¬¬ä¸æ¹æ¥å
¥', hint: 'èªå¨éé' }, |
| | | { source: 'MANUAL', label: 'æå·¥æè¡¨', hint: 'æå¨å½è¡¨' } |
| | | ] |
| | | }, |
| | | { |
| | | type: 'GAS', |
| | | name: 'è¸æ±½è¡¨', |
| | | options: [ |
| | | { source: 'PROVIDER', label: 'ç¬¬ä¸æ¹æ¥å
¥', hint: 'èªå¨éé' }, |
| | | { source: 'MANUAL', label: 'æå·¥æè¡¨', hint: 'æå¨å½è¡¨' } |
| | | ] |
| | | } |
| | | ] |
| | | |
| | | const addMenuVisible = ref(false) |
| | | const activeType = ref('WATER') |
| | | const activeTypeName = computed(() => typeText(activeType.value)) |
| | | |
| | | const stateMap = reactive({ |
| | | WATER: { keyword: '', collectionMode: '', current: 1, size: 10, total: 0 }, |
| | | ELECTRIC: { keyword: '', collectionMode: '', current: 1, size: 10, total: 0 }, |
| | | GAS: { keyword: '', collectionMode: '', current: 1, size: 10, total: 0 } |
| | | }) |
| | | |
| | | const counts = reactive({ WATER: null, ELECTRIC: null, GAS: null }) |
| | | const devices = ref([]) |
| | | const tableLoading = ref(false) |
| | | const deviceFormRef = ref(null) |
| | | const manualFormRef = ref(null) |
| | | const readingFormRef = ref(null) |
| | | |
| | | // ç¬¬ä¸æ¹æ¥å
¥ â 跳转è½è宿¶çæ§ï¼å¸¦è®¾å¤IDï¼æåå°è®¾å¤æ¥çï¼ |
| | | const dataRouteMap = { |
| | | WATER: '/energyManagement/realtimeMonitor', |
| | | ELECTRIC: '/energyManagement/realtimeMonitor', |
| | | GAS: '/energyManagement/realtimeMonitor' |
| | | } |
| | | const unitMap = { WATER: 'm³', ELECTRIC: '度', GAS: 'm³' } |
| | | |
| | | const manualDialog = reactive({ |
| | | visible: false, |
| | | submitting: false, |
| | | meterType: 'WATER', |
| | | form: { |
| | | meterAddress: '', |
| | | displayName: '', |
| | | location: '', |
| | | initialReading: 0, |
| | | initialReadingDate: dayjs().format('YYYY-MM-DD') |
| | | } |
| | | }) |
| | | |
| | | const manualRules = { |
| | | meterAddress: [{ required: true, message: '请è¾å
¥è¡¨å·', trigger: 'blur' }], |
| | | initialReading: [{ required: true, message: '请è¾å
¥èµ·å§è¡¨ç ', trigger: 'blur' }], |
| | | initialReadingDate: [{ required: true, message: 'è¯·éæ©èµ·å§æ¥æ', trigger: 'change' }] |
| | | } |
| | | |
| | | const readingDialog = reactive({ |
| | | visible: false, |
| | | submitting: false, |
| | | device: null, |
| | | previousReading: null, |
| | | previousLoading: false, |
| | | form: { |
| | | readingDate: dayjs().format('YYYY-MM-DD'), |
| | | readingValue: 0, |
| | | remark: '' |
| | | } |
| | | }) |
| | | |
| | | const readingRules = { |
| | | readingDate: [{ required: true, message: 'è¯·éæ©æè¡¨æ¥æ', trigger: 'change' }], |
| | | readingValue: [{ required: true, message: '请è¾å
¥æ¬æ¬¡è¡¨ç ', trigger: 'blur' }] |
| | | } |
| | | |
| | | const readingDrawer = reactive({ |
| | | visible: false, |
| | | device: null, |
| | | list: [], |
| | | total: 0, |
| | | current: 1, |
| | | size: 10, |
| | | loading: false |
| | | }) |
| | | |
| | | const readingDrawerTitle = computed(() => { |
| | | const device = readingDrawer.device |
| | | return device ? `æè¡¨è®°å½ · ${device.displayName}` : 'æè¡¨è®°å½' |
| | | }) |
| | | |
| | | const collectDialog = reactive({ |
| | | visible: false, |
| | | submitting: false, |
| | | device: null, |
| | | range: [], |
| | | includeReadings: true |
| | | }) |
| | | |
| | | // å½è¡¨ç²¾åº¦ï¼çµè¡¨ä¸¤ä½ï¼æ°´è¡¨/æ°è¡¨ä¸ä½ |
| | | const readingPrecision = computed(() => (activeType.value === 'ELECTRIC' ? 2 : 3)) |
| | | const readingStep = computed(() => (activeType.value === 'ELECTRIC' ? 0.01 : 0.001)) |
| | | |
| | | // æå·¥æ°å¢æå®é
éæ©çç±»å带å
¥æ é¢ã精度ååä½ï¼é¿å
è·é页ç¾ä¸²ç±»å |
| | | const manualDialogTitle = computed(() => `æ°å¢${typeText(manualDialog.meterType)} · æå·¥æè¡¨`) |
| | | const manualReadingPrecision = computed(() => (manualDialog.meterType === 'ELECTRIC' ? 2 : 3)) |
| | | const manualReadingStep = computed(() => (manualDialog.meterType === 'ELECTRIC' ? 0.01 : 0.001)) |
| | | |
| | | let collectReloadTimer = null |
| | | |
| | | onMounted(() => { |
| | | loadDevices() |
| | | loadCounts() |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | if (collectReloadTimer) { |
| | | clearTimeout(collectReloadTimer) |
| | | collectReloadTimer = null |
| | | } |
| | | }) |
| | | |
| | | /* ------------------------------ å表 ------------------------------ */ |
| | | |
| | | async function loadDevices() { |
| | | const state = stateMap[activeType.value] |
| | | const collectionMode = state.collectionMode |
| | | tableLoading.value = true |
| | | try { |
| | | const res = await listMeterDevices({ |
| | | current: state.current, |
| | | size: state.size, |
| | | meterType: activeType.value, |
| | | keyword: state.keyword || undefined, |
| | | collectionMode: collectionMode || undefined |
| | | }) |
| | | const data = res.data || {} |
| | | devices.value = data.records || [] |
| | | state.total = data.total || 0 |
| | | } catch (e) { |
| | | devices.value = [] |
| | | state.total = 0 |
| | | } finally { |
| | | tableLoading.value = false |
| | | } |
| | | } |
| | | |
| | | // 页ç¾ä¸ç设å¤å°æ°ï¼ä¸åæ¥è¯¢æ¡ä»¶å½±åï¼ |
| | | async function loadCounts() { |
| | | try { |
| | | const results = await Promise.all( |
| | | meterTypes.map((item) => listMeterDevices({ current: 1, size: 1, meterType: item.value })) |
| | | ) |
| | | meterTypes.forEach((item, index) => { |
| | | const data = results[index].data || {} |
| | | counts[item.value] = data.total || 0 |
| | | }) |
| | | } catch (e) { |
| | | // å°æ°å±ç¤ºå¤±è´¥ä¸å½±ååè¡¨ä½¿ç¨ |
| | | } |
| | | } |
| | | |
| | | function handleTabChange() { |
| | | // åæ¢ç±»åæ¶å
æ¸
空ä¸ä¸ç±»åçè¡ï¼é¿å
çå°æ§æ°æ® |
| | | devices.value = [] |
| | | loadDevices() |
| | | } |
| | | |
| | | // ç±»åå¡çç¹å»åæ¢ |
| | | function switchType(value) { |
| | | if (value === activeType.value) { |
| | | return |
| | | } |
| | | activeType.value = value |
| | | handleTabChange() |
| | | } |
| | | |
| | | // 页头巿°ï¼å表ä¸å°æ°ä¸èµ·å·æ° |
| | | function refreshAll() { |
| | | loadDevices() |
| | | loadCounts() |
| | | } |
| | | |
| | | function handleQuery() { |
| | | stateMap[activeType.value].current = 1 |
| | | loadDevices() |
| | | } |
| | | |
| | | function handleReset() { |
| | | const state = stateMap[activeType.value] |
| | | state.keyword = '' |
| | | state.collectionMode = '' |
| | | state.current = 1 |
| | | loadDevices() |
| | | } |
| | | |
| | | function handlePageChange(current) { |
| | | stateMap[activeType.value].current = current |
| | | loadDevices() |
| | | } |
| | | |
| | | function handleSizeChange(size) { |
| | | const state = stateMap[activeType.value] |
| | | state.size = size |
| | | state.current = 1 |
| | | loadDevices() |
| | | } |
| | | |
| | | // 跨页è¿ç»åºå· |
| | | function indexMethod(index) { |
| | | const state = stateMap[activeType.value] |
| | | return (state.current - 1) * state.size + index + 1 |
| | | } |
| | | |
| | | /* ------------------------------ æ°å¢ / ç¼è¾ / å é¤ ------------------------------ */ |
| | | |
| | | function handleAdd(type, source) { |
| | | addMenuVisible.value = false |
| | | // åå°è¦æ°å¢çç±»åï¼ä¿ååè½ç´æ¥å¨å½åå表çå°æ°è®¾å¤ |
| | | if (type && type !== activeType.value) { |
| | | activeType.value = type |
| | | handleTabChange() |
| | | } |
| | | if (source === 'MANUAL') { |
| | | openManualDialog(type) |
| | | return |
| | | } |
| | | openDeviceForm('add', null, type) |
| | | } |
| | | |
| | | function openDeviceForm(mode, row, meterType) { |
| | | deviceFormRef.value && deviceFormRef.value.open(mode, row, meterType) |
| | | } |
| | | |
| | | function handleDeviceSaved() { |
| | | loadDevices() |
| | | loadCounts() |
| | | } |
| | | |
| | | function openManualDialog(meterType) { |
| | | manualDialog.meterType = meterType || activeType.value |
| | | manualDialog.form.meterAddress = '' |
| | | manualDialog.form.displayName = '' |
| | | manualDialog.form.location = '' |
| | | manualDialog.form.initialReading = 0 |
| | | manualDialog.form.initialReadingDate = dayjs().format('YYYY-MM-DD') |
| | | manualDialog.visible = true |
| | | nextTick(() => manualFormRef.value && manualFormRef.value.clearValidate()) |
| | | } |
| | | |
| | | async function submitManualDevice() { |
| | | if (!manualFormRef.value) { |
| | | return |
| | | } |
| | | const valid = await manualFormRef.value.validate().catch(() => false) |
| | | if (!valid) { |
| | | return |
| | | } |
| | | manualDialog.submitting = true |
| | | try { |
| | | await addManualMeterDevice({ |
| | | meterType: manualDialog.meterType, |
| | | meterAddress: manualDialog.form.meterAddress.trim(), |
| | | displayName: manualDialog.form.displayName, |
| | | location: manualDialog.form.location, |
| | | initialReading: manualDialog.form.initialReading, |
| | | initialReadingDate: manualDialog.form.initialReadingDate |
| | | }) |
| | | ElMessage.success('æå·¥æè¡¨è®¾å¤å·²æ°å¢') |
| | | manualDialog.visible = false |
| | | handleDeviceSaved() |
| | | } catch (e) { |
| | | // é误æç¤ºå·²ç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } finally { |
| | | manualDialog.submitting = false |
| | | } |
| | | } |
| | | |
| | | async function handleDelete(row) { |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | `确认å é¤è®¾å¤ã${row.displayName}ãï¼å é¤å该设å¤çæè¡¨è®°å½å°ä¸å¹¶æ¸
é¤ï¼ä¸ä¸å¯æ¢å¤ã`, |
| | | 'å é¤è®¾å¤', |
| | | { type: 'warning', confirmButtonText: 'å é¤', cancelButtonText: 'åæ¶' } |
| | | ) |
| | | } catch (e) { |
| | | return |
| | | } |
| | | try { |
| | | await deleteMeterDevice(row.id) |
| | | ElMessage.success('å 餿å') |
| | | // å é¤å½å页æå䏿¡æ¶åéä¸é¡µï¼é¿å
è½å¨ç©ºé¡µ |
| | | const state = stateMap[activeType.value] |
| | | if (devices.value.length === 1 && state.current > 1) { |
| | | state.current -= 1 |
| | | } |
| | | handleDeviceSaved() |
| | | } catch (e) { |
| | | // é误æç¤ºå·²ç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } |
| | | } |
| | | |
| | | function isAutoCollect(row) { |
| | | return !!row && (row.sourceType === 'PROVIDER' || row.simulated === 1) |
| | | } |
| | | |
| | | function isManualRow(row) { |
| | | return !!row && row.simulated !== 1 && row.sourceType === 'MANUAL' |
| | | } |
| | | |
| | | function deleteTip(row) { |
| | | if (row.isSeed === 1) { |
| | | return 'åå§å设å¤ä¸å
许å é¤' |
| | | } |
| | | return 'ç¬¬ä¸æ¹æ¥å
¥è®¾å¤è¯·ä½¿ç¨åç¨' |
| | | } |
| | | |
| | | /* ------------------------------ å½è¡¨ + æè¡¨è®°å½ ------------------------------ */ |
| | | |
| | | async function openReadingDialog(row) { |
| | | readingDialog.device = row |
| | | readingDialog.previousReading = null |
| | | readingDialog.form.readingDate = dayjs().format('YYYY-MM-DD') |
| | | readingDialog.form.readingValue = 0 |
| | | readingDialog.form.remark = '' |
| | | readingDialog.visible = true |
| | | nextTick(() => readingFormRef.value && readingFormRef.value.clearValidate()) |
| | | readingDialog.previousLoading = true |
| | | try { |
| | | const res = await listMeterReadings(row.id, { current: 1, size: 1 }) |
| | | const rows = (res.data && res.data.records) || [] |
| | | readingDialog.previousReading = rows.length ? rows[0].metricValue : null |
| | | } catch (e) { |
| | | readingDialog.previousReading = null |
| | | } finally { |
| | | readingDialog.previousLoading = false |
| | | } |
| | | } |
| | | |
| | | async function submitReading() { |
| | | if (!readingFormRef.value || !readingDialog.device) { |
| | | return |
| | | } |
| | | const valid = await readingFormRef.value.validate().catch(() => false) |
| | | if (!valid) { |
| | | return |
| | | } |
| | | readingDialog.submitting = true |
| | | try { |
| | | const device = readingDialog.device |
| | | const res = await addMeterReading(device.id, { |
| | | readingDate: readingDialog.form.readingDate, |
| | | readingValue: readingDialog.form.readingValue, |
| | | remark: readingDialog.form.remark |
| | | }) |
| | | const data = res.data || {} |
| | | if (data.usage == null) { |
| | | ElMessage.success('表ç 已记å½ï¼ææ ä¸æè¡¨ç ï¼æ¬æç¨éæ æ³è®¡ç®ï¼') |
| | | } else { |
| | | ElMessage.success(`æ¬æç¨é ${formatNumber(data.usage)} ${unitText(device.meterType)}`) |
| | | } |
| | | readingDialog.visible = false |
| | | handleDeviceSaved() |
| | | if (readingDrawer.visible && readingDrawer.device && readingDrawer.device.id === device.id) { |
| | | loadReadings() |
| | | } |
| | | } catch (e) { |
| | | // é误æç¤ºå·²ç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } finally { |
| | | readingDialog.submitting = false |
| | | } |
| | | } |
| | | |
| | | function openReadingsDrawer(row) { |
| | | readingDrawer.device = row |
| | | readingDrawer.current = 1 |
| | | readingDrawer.list = [] |
| | | readingDrawer.total = 0 |
| | | readingDrawer.visible = true |
| | | loadReadings() |
| | | } |
| | | |
| | | async function loadReadings() { |
| | | if (!readingDrawer.device) { |
| | | return |
| | | } |
| | | readingDrawer.loading = true |
| | | try { |
| | | const res = await listMeterReadings(readingDrawer.device.id, { |
| | | current: readingDrawer.current, |
| | | size: readingDrawer.size |
| | | }) |
| | | const data = res.data || {} |
| | | readingDrawer.list = data.records || [] |
| | | readingDrawer.total = data.total || 0 |
| | | } catch (e) { |
| | | readingDrawer.list = [] |
| | | readingDrawer.total = 0 |
| | | } finally { |
| | | readingDrawer.loading = false |
| | | } |
| | | } |
| | | |
| | | function handleReadingPageChange(current) { |
| | | readingDrawer.current = current |
| | | loadReadings() |
| | | } |
| | | |
| | | function handleReadingSizeChange(size) { |
| | | readingDrawer.size = size |
| | | readingDrawer.current = 1 |
| | | loadReadings() |
| | | } |
| | | |
| | | async function handleDeleteReading(row) { |
| | | const device = readingDrawer.device |
| | | if (!device) { |
| | | return |
| | | } |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | `确认å é¤ ${readingDate(row)} çæè¡¨è®°å½ï¼å é¤åä¼éç®ä¸ä¸æçæ¬æç¨éã`, |
| | | 'å é¤æè¡¨è®°å½', |
| | | { type: 'warning', confirmButtonText: 'å é¤', cancelButtonText: 'åæ¶' } |
| | | ) |
| | | } catch (e) { |
| | | return |
| | | } |
| | | try { |
| | | await deleteMeterReading(device.id, row.id) |
| | | ElMessage.success('å 餿å') |
| | | if (readingDrawer.list.length === 1 && readingDrawer.current > 1) { |
| | | readingDrawer.current -= 1 |
| | | } |
| | | loadReadings() |
| | | handleDeviceSaved() |
| | | } catch (e) { |
| | | // é误æç¤ºå·²ç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } |
| | | } |
| | | |
| | | /* ------------------------------ æ¥è¡¨ï¼ç¬¬ä¸æ¹æ¥å
¥ï¼ ------------------------------ */ |
| | | |
| | | function openCollectDialog(row) { |
| | | if (!row || !row.id) { |
| | | ElMessage.warning('请å
éæ©è®¾å¤') |
| | | return |
| | | } |
| | | collectDialog.device = row |
| | | collectDialog.range = [ |
| | | dayjs().subtract(3, 'day').format('YYYY-MM-DD'), |
| | | dayjs().subtract(1, 'day').format('YYYY-MM-DD') |
| | | ] |
| | | collectDialog.includeReadings = true |
| | | collectDialog.visible = true |
| | | } |
| | | |
| | | async function submitCollect() { |
| | | if (!collectDialog.device) { |
| | | return |
| | | } |
| | | if (!collectDialog.range || collectDialog.range.length !== 2) { |
| | | ElMessage.warning('è¯·éæ©æ¥è¡¨æ¥æèå´') |
| | | return |
| | | } |
| | | collectDialog.submitting = true |
| | | try { |
| | | await collectMeterDevice(collectDialog.device.id, { |
| | | startDate: collectDialog.range[0], |
| | | endDate: collectDialog.range[1], |
| | | includeReadings: collectDialog.includeReadings |
| | | }) |
| | | collectDialog.visible = false |
| | | ElMessage.success('æ¥è¡¨ä»»å¡å·²æäº¤ï¼æ£å¨åå°éé') |
| | | if (collectReloadTimer) { |
| | | clearTimeout(collectReloadTimer) |
| | | } |
| | | collectReloadTimer = setTimeout(() => { |
| | | loadDevices() |
| | | loadCounts() |
| | | collectReloadTimer = null |
| | | }, 4000) |
| | | } catch (e) { |
| | | // é误æç¤ºå·²ç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } finally { |
| | | collectDialog.submitting = false |
| | | } |
| | | } |
| | | |
| | | /* ------------------------------ 跳转 / å·¥å
· ------------------------------ */ |
| | | |
| | | function goData(row) { |
| | | const path = dataRouteMap[row.meterType] |
| | | if (!path) { |
| | | ElMessage.warning('æä¸æ¯æè¯¥ç±»åçè®¾å¤æ°æ®é¡µ') |
| | | return |
| | | } |
| | | router.push({ path, query: { meterId: row.id } }) |
| | | } |
| | | |
| | | function typeText(meterType) { |
| | | const map = { WATER: '水表', ELECTRIC: 'çµè¡¨', GAS: 'è¸æ±½è¡¨' } |
| | | return map[meterType] || meterType || '--' |
| | | } |
| | | |
| | | function typeTag(meterType) { |
| | | if (meterType === 'ELECTRIC') { |
| | | return 'warning' |
| | | } |
| | | if (meterType === 'GAS') { |
| | | return 'success' |
| | | } |
| | | return 'primary' |
| | | } |
| | | |
| | | function unitText(meterType) { |
| | | return unitMap[meterType] || 'm³' |
| | | } |
| | | |
| | | function locationPlaceholder(meterType) { |
| | | if (meterType === 'ELECTRIC') { |
| | | return 'å¦ï¼ä¸å·æ¥¼ä¸å±é
çµé´' |
| | | } |
| | | if (meterType === 'GAS') { |
| | | return 'å¦ï¼ä¸å·æ¥¼ä¸å±è¸æ±½è¡¨ç®±' |
| | | } |
| | | return 'å¦ï¼äºå·æ¥¼ä¸å±æ°´è¡¨äº' |
| | | } |
| | | |
| | | function readingDate(row) { |
| | | const value = row.measureTime || '' |
| | | return value ? value.slice(0, 10) : '--' |
| | | } |
| | | |
| | | function formatNumber(value) { |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | const num = Number(value) |
| | | if (Number.isNaN(num)) { |
| | | return String(value) |
| | | } |
| | | return num.toFixed(3).replace(/\.?0+$/, '') |
| | | } |
| | | |
| | | function disableAfterToday(date) { |
| | | return dayjs(date).isAfter(dayjs(), 'day') |
| | | } |
| | | |
| | | function disableAfterYesterday(date) { |
| | | return dayjs(date).isAfter(dayjs().subtract(1, 'day'), 'day') |
| | | } |
| | | |
| | | function disableReadingDate(date) { |
| | | if (dayjs(date).isAfter(dayjs(), 'day')) { |
| | | return true |
| | | } |
| | | const device = readingDialog.device |
| | | if (device && device.initialReadingDate) { |
| | | return dayjs(date).isBefore(dayjs(device.initialReadingDate), 'day') |
| | | } |
| | | return false |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .meter-ledger { |
| | | padding-bottom: 24px; |
| | | } |
| | | |
| | | /* ---------------- 页头 ---------------- */ |
| | | .page-head { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: flex-end; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | margin-bottom: 14px; |
| | | } |
| | | |
| | | .page-title { |
| | | font-size: 20px; |
| | | font-weight: 600; |
| | | color: #1f2d3d; |
| | | line-height: 1.3; |
| | | } |
| | | |
| | | .page-head-actions { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | /* æ°å¢èåï¼ææ°´è¡¨/çµè¡¨/æ°è¡¨åç» */ |
| | | .add-menu { |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .add-group { |
| | | padding: 6px 2px; |
| | | } |
| | | |
| | | .add-group + .add-group { |
| | | border-top: 1px solid #f0f2f5; |
| | | } |
| | | |
| | | .add-group-title { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | padding: 2px 8px 6px; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .add-dot { |
| | | width: 7px; |
| | | height: 7px; |
| | | border-radius: 50%; |
| | | background: #8c9bb3; |
| | | } |
| | | |
| | | .add-dot.water { |
| | | background: #2f6fe4; |
| | | } |
| | | |
| | | .add-dot.electric { |
| | | background: #ef8a1f; |
| | | } |
| | | |
| | | .add-dot.gas { |
| | | background: #23a26a; |
| | | } |
| | | |
| | | .add-option { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: flex-start; |
| | | gap: 1px; |
| | | width: 100%; |
| | | padding: 6px 8px; |
| | | border: 0; |
| | | border-radius: 6px; |
| | | background: transparent; |
| | | text-align: left; |
| | | cursor: pointer; |
| | | } |
| | | |
| | | .add-option:hover { |
| | | background: #f5f9ff; |
| | | } |
| | | |
| | | .add-option-label { |
| | | font-size: 13px; |
| | | line-height: 1.3; |
| | | color: #303133; |
| | | } |
| | | |
| | | .add-option-hint { |
| | | font-size: 11px; |
| | | line-height: 1.3; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | /* ---------------- ç±»å忢 ---------------- */ |
| | | .type-switch { |
| | | display: grid; |
| | | grid-template-columns: repeat(3, minmax(0, 1fr)); |
| | | gap: 12px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .type-card { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | padding: 14px 16px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 10px; |
| | | background: #fff; |
| | | cursor: pointer; |
| | | transition: all 0.2s; |
| | | |
| | | &:hover { |
| | | border-color: #c6d9f5; |
| | | box-shadow: 0 4px 14px rgba(31, 45, 61, 0.06); |
| | | } |
| | | |
| | | &.active { |
| | | border-color: #3d7eff; |
| | | box-shadow: 0 4px 16px rgba(61, 126, 255, 0.14); |
| | | background: linear-gradient(180deg, #f5f9ff 0%, #ffffff 100%); |
| | | } |
| | | } |
| | | |
| | | .type-badge { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | width: 38px; |
| | | height: 38px; |
| | | border-radius: 10px; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #fff; |
| | | background: #8c9bb3; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .type-card.water .type-badge { |
| | | background: linear-gradient(135deg, #4d9dff 0%, #2f6fe4 100%); |
| | | } |
| | | |
| | | .type-card.electric .type-badge { |
| | | background: linear-gradient(135deg, #ffb45c 0%, #ef8a1f 100%); |
| | | } |
| | | |
| | | .type-card.gas .type-badge { |
| | | background: linear-gradient(135deg, #5fd39a 0%, #23a26a 100%); |
| | | } |
| | | |
| | | .type-info { |
| | | min-width: 0; |
| | | } |
| | | |
| | | .type-name { |
| | | font-size: 14px; |
| | | font-weight: 500; |
| | | color: #1f2d3d; |
| | | } |
| | | |
| | | .type-count { |
| | | margin-top: 2px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | |
| | | b { |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: #1f2d3d; |
| | | margin-right: 2px; |
| | | } |
| | | } |
| | | |
| | | /* ---------------- çé ---------------- */ |
| | | .filter-bar { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 10px; |
| | | padding: 12px 16px; |
| | | margin-bottom: 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 10px; |
| | | background: #fff; |
| | | } |
| | | |
| | | .filter-keyword { |
| | | width: 240px; |
| | | } |
| | | |
| | | .filter-summary { |
| | | margin-left: auto; |
| | | font-size: 13px; |
| | | color: #909399; |
| | | |
| | | b { |
| | | color: #1f2d3d; |
| | | font-weight: 600; |
| | | } |
| | | } |
| | | |
| | | /* ---------------- è¡¨æ ¼ ---------------- */ |
| | | .table-card { |
| | | padding: 6px 16px 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 10px; |
| | | background: #fff; |
| | | } |
| | | |
| | | .ledger-table { |
| | | :deep(.el-table__header th) { |
| | | background: #fafbfc; |
| | | color: #909399; |
| | | font-weight: 500; |
| | | font-size: 12px; |
| | | border-bottom: 1px solid #f0f2f5; |
| | | } |
| | | |
| | | :deep(.el-table__cell) { |
| | | padding: 7px 0; |
| | | } |
| | | |
| | | :deep(.el-table__row > td) { |
| | | border-bottom: 1px solid #f5f7fa; |
| | | } |
| | | |
| | | :deep(.el-table__row:hover > td) { |
| | | background: #f7faff; |
| | | } |
| | | |
| | | /* 表å·çç¼å·åï¼ç宽æ°åãå¼±åé¢è² */ |
| | | :deep(.col-mono .cell) { |
| | | color: #606266; |
| | | font-size: 12px; |
| | | font-variant-numeric: tabular-nums; |
| | | } |
| | | |
| | | :deep(.el-table__inner-wrapper::before) { |
| | | display: none; |
| | | } |
| | | } |
| | | |
| | | .device-cell { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | /* åè¡å±ç¤ºï¼å¾æ + åç§° + æ è®° */ |
| | | .name-cell { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | min-width: 0; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .name-cell .device-name { |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | display: block; |
| | | } |
| | | |
| | | |
| | | .device-text { |
| | | min-width: 0; |
| | | } |
| | | |
| | | .device-name { |
| | | font-size: 13px; |
| | | font-weight: 500; |
| | | color: #1f2d3d; |
| | | line-height: 1.4; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | } |
| | | |
| | | .device-name.sub-line { |
| | | font-weight: 400; |
| | | color: #303133; |
| | | } |
| | | |
| | | .device-sub { |
| | | margin-top: 2px; |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | line-height: 1.4; |
| | | } |
| | | |
| | | .collect-mode { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | gap: 5px; |
| | | font-size: 12px; |
| | | |
| | | i { |
| | | width: 6px; |
| | | height: 6px; |
| | | border-radius: 50%; |
| | | background: #909399; |
| | | } |
| | | |
| | | &.auto { |
| | | color: #2f9e6e; |
| | | |
| | | i { |
| | | background: #2f9e6e; |
| | | } |
| | | } |
| | | |
| | | &.manual { |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | |
| | | .num { |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | color: #1f2d3d; |
| | | font-variant-numeric: tabular-nums; |
| | | } |
| | | |
| | | /* ä¸æè¯»æ°å¼±åä¸çº§ï¼çªåºææ°è¯»æ° */ |
| | | .num.prev { |
| | | font-weight: 500; |
| | | color: #606266; |
| | | } |
| | | |
| | | .num-unit { |
| | | margin-left: 3px; |
| | | font-size: 11px; |
| | | font-weight: 400; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .cell-actions { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | gap: 12px; |
| | | flex-wrap: nowrap; |
| | | white-space: nowrap; |
| | | |
| | | :deep(.el-button) { |
| | | margin-left: 0; |
| | | padding: 0; |
| | | font-size: 12px; |
| | | } |
| | | } |
| | | |
| | | .empty-box { |
| | | padding: 16px 0; |
| | | } |
| | | |
| | | .table-footer { |
| | | display: flex; |
| | | justify-content: flex-end; |
| | | padding-top: 12px; |
| | | } |
| | | |
| | | /* ---------------- å¼¹çªå
çéç¨æ ·å¼ ---------------- */ |
| | | .item-tip { |
| | | margin-left: 4px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .btn-disabled { |
| | | display: inline-block; |
| | | padding: 0 8px; |
| | | } |
| | | |
| | | .field-tip { |
| | | margin-left: 8px; |
| | | font-size: 12px; |
| | | line-height: 1.5; |
| | | color: #909399; |
| | | } |
| | | |
| | | .reading-device { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 2px; |
| | | } |
| | | |
| | | .readings-panel { |
| | | padding: 0 4px 24px; |
| | | } |
| | | |
| | | .readings-pagination { |
| | | margin: 12px 0 0; |
| | | justify-content: flex-end; |
| | | } |
| | | |
| | | .collect-devices { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 6px; |
| | | } |
| | | |
| | | .dialog-tip { |
| | | padding: 8px 12px; |
| | | border-radius: 4px; |
| | | background: #f4f4f5; |
| | | font-size: 12px; |
| | | line-height: 1.6; |
| | | color: #909399; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container energy-realtime"> |
| | | <div class="page-head"> |
| | | <div class="head-text"> |
| | | <h2>è½è宿¶çæ§</h2> |
| | | <p>宿¶å±ç¤ºæ°´ãçµãè¸æ±½æ¶èæ
åµï¼çµè¡¨ä¸º 15 åé宿¶æ°æ®ï¼æ°´è¡¨ / è¸æ±½è¡¨ä¸ºæ¥å»ç»æ°æ®ï¼ä»¥æè¿ä¸ä¸ªææ°æ®çä¸å¡æ¥åç°ã</p> |
| | | </div> |
| | | <div class="head-actions"> |
| | | <span class="update-time">æ´æ°äº {{ generatedAtText }}</span> |
| | | <el-switch v-model="autoRefresh" active-text="èªå¨å·æ°" /> |
| | | <el-button type="primary" :loading="loading" @click="load()">å·æ°</el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="scopedMeter" class="scope-bar"> |
| | | <span>å½åæ¥çåå°è®¾å¤ï¼<b>{{ overview.scopeLabel || ('è®¾å¤ #' + scopedMeter) }}</b></span> |
| | | <el-button link type="primary" @click="clearScope">æ¥çå
¨é¨è¡¨å
·</el-button> |
| | | </div> |
| | | |
| | | <el-alert |
| | | v-if="overview.mockMode" |
| | | class="mock-alert" |
| | | type="info" |
| | | :closable="false" |
| | | show-icon |
| | | title="å½å为æ¼ç¤ºæ°æ®æ¨¡å¼ï¼æªé
ç½®ç¬¬ä¸æ¹ææç ï¼å®æ¶æ°æ®ç±æ¬å°æ¨¡æçæï¼ä»
ç¨äºèè°ä¸é¡µé¢æ¼ç¤ºã" |
| | | /> |
| | | |
| | | <div class="media-grid"> |
| | | <section |
| | | v-for="item in mediaList" |
| | | :key="item.meterType" |
| | | class="media-card" |
| | | :class="'media-' + item.meterType.toLowerCase()" |
| | | > |
| | | <div class="card-head"> |
| | | <span class="media-badge">{{ mediumShort(item.meterType) }}</span> |
| | | <div class="card-title"> |
| | | <h3>{{ item.meterName }}</h3> |
| | | <span>{{ item.deviceCount }} å°è®¾å¤</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="card-value"> |
| | | <span class="num">{{ formatNumber(item.latestUsage) }}</span> |
| | | <span class="unit">{{ displayUnit(item.unit) }}</span> |
| | | <span class="date">{{ item.latestUsageDate ? item.latestUsageDate.slice(5) + ' ç¨é' : 'ææ ç¨é' }}</span> |
| | | </div> |
| | | |
| | | <div class="compare-line"> |
| | | <span :class="['compare', compareClass(item.compareRate)]"> |
| | | {{ compareText(item.compareRate) }} |
| | | </span> |
| | | <span class="compare-label">è¾ä¸ä¸ä¸å¡æ¥</span> |
| | | <span v-if="item.previousUsage != null" class="previous"> |
| | | ä¸ä¸æ¥ {{ formatNumber(item.previousUsage) }} |
| | | </span> |
| | | </div> |
| | | |
| | | <div class="card-stats"> |
| | | <div> |
| | | <span>æ¬æç´¯è®¡</span> |
| | | <b>{{ formatNumber(item.monthTotal) }}<i>{{ displayUnit(item.unit) }}</i></b> |
| | | </div> |
| | | <div> |
| | | <span>è¿ 7 æ¥æ¥å</span> |
| | | <b>{{ formatNumber(item.avgDaily) }}<i>{{ displayUnit(item.unit) }}</i></b> |
| | | </div> |
| | | <div> |
| | | <span>æè¿éé</span> |
| | | <b class="time">{{ shortTime(item.lastSuccessAt) }}</b> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="item.meterType === 'ELECTRIC'" class="electric-extra"> |
| | | <span>å½åæååç <b>{{ formatNumber(item.currentPower) }} kW</b></span> |
| | | <span>åçå æ° <b>{{ formatNumber(item.powerFactor) }}</b></span> |
| | | </div> |
| | | </section> |
| | | </div> |
| | | |
| | | <el-row :gutter="12" class="main-row"> |
| | | <el-col :xs="24" :lg="15"> |
| | | <section class="panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title">è¿ 7 æ¥è½èè¶å¿</div> |
| | | <div class="legend"> |
| | | <span><i class="dot water"></i>水 m³</span> |
| | | <span><i class="dot gas"></i>è¸æ±½ m³</span> |
| | | <span><i class="dot electric"></i>çµ åº¦</span> |
| | | </div> |
| | | </div> |
| | | <div ref="trendRef" class="chart chart-trend"></div> |
| | | </section> |
| | | </el-col> |
| | | <el-col :xs="24" :lg="9"> |
| | | <section class="panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title">çµè¡¨ä»æ¥å®æ¶è´è·</div> |
| | | <span class="panel-range">{{ electricRangeText }}</span> |
| | | </div> |
| | | <template v-if="hasElectricCurve"> |
| | | <div class="electric-stats"> |
| | | <div> |
| | | <span>仿¥ç´¯è®¡ç¨çµ</span> |
| | | <b>{{ formatNumber(overview.electric.todayTotal) }}<i>度</i></b> |
| | | </div> |
| | | <div> |
| | | <span>å½ååç</span> |
| | | <b>{{ formatNumber(overview.electric.currentPower) }}<i>kW</i></b> |
| | | </div> |
| | | <div> |
| | | <span>åçå æ°</span> |
| | | <b>{{ formatNumber(overview.electric.powerFactor) }}</b> |
| | | </div> |
| | | </div> |
| | | <div ref="electricRef" class="chart chart-electric"></div> |
| | | </template> |
| | | <el-empty v-else description="ææ 15 åé宿¶æ°æ®" :image-size="60" /> |
| | | </section> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <section class="panel alerts-panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title">è¿è¡æç¤º</div> |
| | | <span class="panel-range">å
± {{ alerts.length }} æ¡</span> |
| | | </div> |
| | | <div v-if="alerts.length" class="alert-list"> |
| | | <div v-for="(item, index) in alerts" :key="index" :class="['alert-item', item.level]"> |
| | | <span class="alert-dot"></span> |
| | | <span class="alert-text">{{ item.text }}</span> |
| | | </div> |
| | | </div> |
| | | <div v-else class="alert-empty"> |
| | | æ°´ãçµãè¸æ±½ä¸ç±»ä»è´¨ééæ£å¸¸ï¼ææ éè¦å¤ççæç¤ºã |
| | | </div> |
| | | </section> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import * as echarts from 'echarts' |
| | | import { getEnergyRealtimeOverview } from '@/api/energyManagement/energyMeter' |
| | | |
| | | defineOptions({ name: 'EnergyRealtimeMonitor' }) |
| | | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | |
| | | const COLOR_WATER = '#409eff' |
| | | const COLOR_ELECTRIC = '#e6a23c' |
| | | const COLOR_GAS = '#21a675' |
| | | const MEDIA_ORDER = { WATER: 0, ELECTRIC: 1, GAS: 2 } |
| | | |
| | | const loading = ref(false) |
| | | const autoRefresh = ref(true) |
| | | const overview = ref({ media: [], trend: [], alerts: [] }) |
| | | |
| | | const trendRef = ref(null) |
| | | const electricRef = ref(null) |
| | | const charts = {} |
| | | let timer = null |
| | | |
| | | const scopedMeter = computed(() => { |
| | | const value = route.query.meterId |
| | | return value ? Number(value) : null |
| | | }) |
| | | |
| | | const mediaList = computed(() => [...(overview.value.media || [])] |
| | | .sort((a, b) => (MEDIA_ORDER[a.meterType] ?? 9) - (MEDIA_ORDER[b.meterType] ?? 9))) |
| | | |
| | | const alerts = computed(() => overview.value.alerts || []) |
| | | |
| | | const generatedAtText = computed(() => shortTime(overview.value.generatedAt)) |
| | | |
| | | const electricRangeText = computed(() => { |
| | | const electric = overview.value.electric |
| | | if (!electric) { |
| | | return '--' |
| | | } |
| | | return `${electric.rangeStart || '00:00'} ~ ${electric.rangeEnd || '--'}` |
| | | }) |
| | | |
| | | const hasElectricCurve = computed(() => { |
| | | const electric = overview.value.electric |
| | | return Boolean(electric && Array.isArray(electric.series) |
| | | && electric.series.some((item) => item.power != null)) |
| | | }) |
| | | |
| | | onMounted(() => { |
| | | load() |
| | | startTimer() |
| | | window.addEventListener('resize', handleResize) |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | window.removeEventListener('resize', handleResize) |
| | | stopTimer() |
| | | disposeCharts() |
| | | }) |
| | | |
| | | watch(autoRefresh, (value) => { |
| | | if (value) { |
| | | startTimer() |
| | | } else { |
| | | stopTimer() |
| | | } |
| | | }) |
| | | |
| | | watch(() => route.query.meterId, () => { |
| | | load() |
| | | }) |
| | | |
| | | async function load(silent = false) { |
| | | if (!silent) { |
| | | loading.value = true |
| | | } |
| | | const params = {} |
| | | if (scopedMeter.value) { |
| | | params.meterId = scopedMeter.value |
| | | } |
| | | try { |
| | | const res = await getEnergyRealtimeOverview(params) |
| | | overview.value = res.data || { media: [], trend: [], alerts: [] } |
| | | await nextTick() |
| | | renderCharts() |
| | | } catch (e) { |
| | | overview.value = { media: [], trend: [], alerts: [] } |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | function startTimer() { |
| | | stopTimer() |
| | | timer = setInterval(() => load(true), 60 * 1000) |
| | | } |
| | | |
| | | function stopTimer() { |
| | | if (timer) { |
| | | clearInterval(timer) |
| | | timer = null |
| | | } |
| | | } |
| | | |
| | | function handleResize() { |
| | | Object.keys(charts).forEach((key) => charts[key] && charts[key].resize()) |
| | | } |
| | | |
| | | function disposeCharts() { |
| | | Object.keys(charts).forEach((key) => { |
| | | charts[key] && charts[key].dispose() |
| | | delete charts[key] |
| | | }) |
| | | } |
| | | |
| | | function initChart(refEl, key) { |
| | | if (!refEl) { |
| | | return null |
| | | } |
| | | if (!charts[key]) { |
| | | charts[key] = echarts.init(refEl) |
| | | } |
| | | return charts[key] |
| | | } |
| | | |
| | | function renderCharts() { |
| | | renderTrend() |
| | | renderElectric() |
| | | } |
| | | |
| | | function renderTrend() { |
| | | const chart = initChart(trendRef.value, 'trend') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | const trend = overview.value.trend || [] |
| | | const hasData = trend.some((item) => item.water != null || item.electric != null || item.gas != null) |
| | | chart.setOption( |
| | | { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { type: 'line' }, |
| | | valueFormatter: (value) => (value == null ? 'ææ æ°æ®' : Number(value).toFixed(3).replace(/\.?0+$/, '')) |
| | | }, |
| | | grid: { left: 48, right: 52, top: 24, bottom: 28 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: false, |
| | | data: trend.map((item) => item.day.slice(5)), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { color: '#909399', fontSize: 11 } |
| | | }, |
| | | yAxis: [ |
| | | { |
| | | type: 'value', |
| | | name: 'm³', |
| | | nameTextStyle: { color: '#909399', fontSize: 11 }, |
| | | axisLabel: { color: '#909399', fontSize: 11 }, |
| | | splitLine: { lineStyle: { type: 'dashed', color: '#f0f2f5' } } |
| | | }, |
| | | { |
| | | type: 'value', |
| | | name: '度', |
| | | nameTextStyle: { color: '#909399', fontSize: 11 }, |
| | | axisLabel: { color: '#909399', fontSize: 11 }, |
| | | splitLine: { show: false } |
| | | } |
| | | ], |
| | | series: [ |
| | | lineSeries('æ°´', trend.map((item) => item.water), COLOR_WATER, 0), |
| | | lineSeries('è¸æ±½', trend.map((item) => item.gas), COLOR_GAS, 0), |
| | | lineSeries('çµ', trend.map((item) => item.electric), COLOR_ELECTRIC, 1) |
| | | ], |
| | | graphic: hasData ? [] : emptyGraphic() |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | function lineSeries(name, data, color, yAxisIndex) { |
| | | return { |
| | | name, |
| | | type: 'line', |
| | | smooth: true, |
| | | symbol: 'circle', |
| | | symbolSize: 5, |
| | | connectNulls: false, |
| | | yAxisIndex, |
| | | data, |
| | | lineStyle: { width: 2, color }, |
| | | itemStyle: { color }, |
| | | emphasis: { focus: 'series' } |
| | | } |
| | | } |
| | | |
| | | function renderElectric() { |
| | | const electric = overview.value.electric |
| | | const series = electric ? (electric.series || []).filter((item) => item.power != null) : [] |
| | | if (!series.length) { |
| | | if (charts.electric) { |
| | | charts.electric.dispose() |
| | | delete charts.electric |
| | | } |
| | | return |
| | | } |
| | | const chart = initChart(electricRef.value, 'electric') |
| | | if (!chart) { |
| | | return |
| | | } |
| | | chart.setOption( |
| | | { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | formatter: (params) => { |
| | | const item = params && params[0] |
| | | if (!item) { |
| | | return '' |
| | | } |
| | | return `${item.axisValue}<br/>æååçï¼${formatNumber(item.value)} kW` |
| | | } |
| | | }, |
| | | grid: { left: 46, right: 16, top: 20, bottom: 26 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: false, |
| | | data: series.map((item) => item.time), |
| | | axisTick: { show: false }, |
| | | axisLine: { lineStyle: { color: '#e4e7ed' } }, |
| | | axisLabel: { color: '#909399', fontSize: 10, interval: Math.max(Math.floor(series.length / 6), 0) } |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | name: 'kW', |
| | | nameTextStyle: { color: '#909399', fontSize: 11 }, |
| | | axisLabel: { color: '#909399', fontSize: 10 }, |
| | | splitLine: { lineStyle: { type: 'dashed', color: '#f0f2f5' } } |
| | | }, |
| | | series: [ |
| | | { |
| | | type: 'line', |
| | | smooth: true, |
| | | showSymbol: false, |
| | | data: series.map((item) => item.power), |
| | | lineStyle: { width: 2, color: COLOR_ELECTRIC }, |
| | | areaStyle: { |
| | | color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { offset: 0, color: 'rgba(230, 162, 60, 0.28)' }, |
| | | { offset: 1, color: 'rgba(230, 162, 60, 0.02)' } |
| | | ]) |
| | | } |
| | | } |
| | | ] |
| | | }, |
| | | true |
| | | ) |
| | | chart.resize() |
| | | } |
| | | |
| | | function emptyGraphic() { |
| | | return [{ type: 'text', left: 'center', top: 'middle', style: { text: 'ææ æ°æ®', fill: '#c0c4cc', fontSize: 13 } }] |
| | | } |
| | | |
| | | function clearScope() { |
| | | router.replace({ path: '/energyManagement/realtimeMonitor' }) |
| | | } |
| | | |
| | | function mediumShort(meterType) { |
| | | return { WATER: 'æ°´', ELECTRIC: 'çµ', GAS: 'è¸æ±½' }[meterType] || meterType |
| | | } |
| | | |
| | | function displayUnit(unit) { |
| | | if (!unit) { |
| | | return '' |
| | | } |
| | | if (unit === 'm3') { |
| | | return 'm³' |
| | | } |
| | | if (unit === 'kWh') { |
| | | return '度' |
| | | } |
| | | return unit |
| | | } |
| | | |
| | | function compareClass(rate) { |
| | | if (rate == null || Number(rate) === 0) { |
| | | return 'flat' |
| | | } |
| | | return Number(rate) > 0 ? 'up' : 'down' |
| | | } |
| | | |
| | | function compareText(rate) { |
| | | if (rate == null) { |
| | | return 'â' |
| | | } |
| | | const value = Number(rate) |
| | | if (value === 0) { |
| | | return 'æå¹³' |
| | | } |
| | | return `${value > 0 ? 'â' : 'â'} ${Math.abs(value).toFixed(1)}%` |
| | | } |
| | | |
| | | function formatNumber(value) { |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | const num = Number(value) |
| | | if (Number.isNaN(num)) { |
| | | return String(value) |
| | | } |
| | | return num.toFixed(3).replace(/\.?0+$/, '') |
| | | } |
| | | |
| | | function shortTime(value) { |
| | | if (!value) { |
| | | return '--' |
| | | } |
| | | return String(value).length > 16 ? String(value).slice(0, 16) : value |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .energy-realtime { |
| | | padding-bottom: 24px; |
| | | } |
| | | |
| | | .page-head { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | margin-bottom: 14px; |
| | | |
| | | h2 { |
| | | margin: 0 0 4px; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | p { |
| | | margin: 0; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | .head-actions { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .update-time { |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .scope-bar { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | padding: 8px 14px; |
| | | margin-bottom: 12px; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | background: #f4f8ff; |
| | | border: 1px solid #d9ecff; |
| | | border-radius: 6px; |
| | | |
| | | b { |
| | | color: #2f7fd8; |
| | | font-weight: 500; |
| | | } |
| | | } |
| | | |
| | | .mock-alert { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .media-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); |
| | | gap: 12px; |
| | | } |
| | | |
| | | .media-card { |
| | | padding: 14px 16px 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-top: 3px solid #409eff; |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | |
| | | &.media-electric { |
| | | border-top-color: #e6a23c; |
| | | } |
| | | |
| | | &.media-gas { |
| | | border-top-color: #21a675; |
| | | } |
| | | } |
| | | |
| | | .card-head { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .media-badge { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | width: 34px; |
| | | height: 34px; |
| | | flex: 0 0 34px; |
| | | border-radius: 9px; |
| | | background: #eef5ff; |
| | | color: #2f7fd8; |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | |
| | | .media-electric & { |
| | | background: #fdf6ec; |
| | | color: #c98a24; |
| | | } |
| | | |
| | | .media-gas & { |
| | | background: #ecf8f2; |
| | | color: #1d8f66; |
| | | } |
| | | } |
| | | |
| | | .card-title { |
| | | flex: 1; |
| | | min-width: 0; |
| | | |
| | | h3 { |
| | | margin: 0 0 3px; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | span { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | .card-value { |
| | | display: flex; |
| | | align-items: baseline; |
| | | gap: 5px; |
| | | margin: 14px 0 6px; |
| | | |
| | | .num { |
| | | font-size: 30px; |
| | | font-weight: 600; |
| | | line-height: 1.1; |
| | | color: #303133; |
| | | } |
| | | |
| | | .unit { |
| | | font-size: 13px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .date { |
| | | margin-left: auto; |
| | | font-size: 12px; |
| | | color: #c0c4cc; |
| | | } |
| | | } |
| | | |
| | | .compare-line { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | |
| | | .compare { |
| | | font-weight: 600; |
| | | |
| | | &.up { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | &.down { |
| | | color: #21a675; |
| | | } |
| | | |
| | | &.flat { |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | .previous { |
| | | margin-left: auto; |
| | | color: #a8abb2; |
| | | } |
| | | } |
| | | |
| | | .card-stats { |
| | | display: flex; |
| | | gap: 6px; |
| | | margin-top: 12px; |
| | | |
| | | > div { |
| | | flex: 1; |
| | | padding: 7px 9px; |
| | | border-radius: 6px; |
| | | background: #f7f9fc; |
| | | |
| | | span { |
| | | display: block; |
| | | font-size: 11px; |
| | | color: #909399; |
| | | } |
| | | |
| | | b { |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | |
| | | i { |
| | | margin-left: 2px; |
| | | font-size: 11px; |
| | | font-style: normal; |
| | | font-weight: 400; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | &.time { |
| | | font-size: 12px; |
| | | font-weight: 400; |
| | | color: #606266; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .electric-extra { |
| | | display: flex; |
| | | gap: 16px; |
| | | margin-top: 10px; |
| | | padding-top: 9px; |
| | | border-top: 1px dashed #ebeef5; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | |
| | | b { |
| | | color: #303133; |
| | | font-weight: 600; |
| | | } |
| | | } |
| | | |
| | | .main-row { |
| | | margin-top: 12px; |
| | | } |
| | | |
| | | .panel { |
| | | padding: 14px 16px; |
| | | margin-bottom: 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | } |
| | | |
| | | .panel-head { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .panel-title { |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .panel-range { |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .legend { |
| | | display: flex; |
| | | gap: 14px; |
| | | font-size: 12px; |
| | | color: #606266; |
| | | |
| | | .dot { |
| | | display: inline-block; |
| | | width: 8px; |
| | | height: 8px; |
| | | margin-right: 4px; |
| | | border-radius: 50%; |
| | | |
| | | &.water { |
| | | background: #409eff; |
| | | } |
| | | |
| | | &.electric { |
| | | background: #e6a23c; |
| | | } |
| | | |
| | | &.gas { |
| | | background: #21a675; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .electric-stats { |
| | | display: flex; |
| | | gap: 8px; |
| | | margin-bottom: 8px; |
| | | |
| | | > div { |
| | | flex: 1; |
| | | padding: 7px 9px; |
| | | border-radius: 6px; |
| | | background: #f7f9fc; |
| | | |
| | | span { |
| | | display: block; |
| | | font-size: 11px; |
| | | color: #909399; |
| | | } |
| | | |
| | | b { |
| | | font-size: 15px; |
| | | color: #303133; |
| | | |
| | | i { |
| | | margin-left: 2px; |
| | | font-size: 11px; |
| | | font-style: normal; |
| | | font-weight: 400; |
| | | color: #a8abb2; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .chart { |
| | | width: 100%; |
| | | } |
| | | |
| | | .chart-trend { |
| | | height: 300px; |
| | | } |
| | | |
| | | .chart-electric { |
| | | height: 218px; |
| | | } |
| | | |
| | | .alerts-panel { |
| | | margin-top: 0; |
| | | } |
| | | |
| | | .alert-list { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .alert-item { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | gap: 8px; |
| | | padding: 8px 12px; |
| | | border-radius: 6px; |
| | | font-size: 13px; |
| | | background: #fdf6ec; |
| | | color: #8a5a12; |
| | | |
| | | &.info { |
| | | background: #f4f8ff; |
| | | color: #2f5f96; |
| | | } |
| | | |
| | | .alert-dot { |
| | | width: 6px; |
| | | height: 6px; |
| | | margin-top: 6px; |
| | | border-radius: 50%; |
| | | background: currentColor; |
| | | flex: 0 0 6px; |
| | | } |
| | | } |
| | | |
| | | .alert-empty { |
| | | padding: 10px 0; |
| | | font-size: 13px; |
| | | color: #909399; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <el-dialog |
| | | v-model="visible" |
| | | :title="(isEdit ? 'ç¼è¾' : 'æ°å¢') + typeName" |
| | | width="640px" |
| | | append-to-body |
| | | destroy-on-close |
| | | @closed="handleClosed" |
| | | > |
| | | <el-form ref="formRef" :model="form" :rules="rules" label-width="108px" v-loading="submitting"> |
| | | <!-- æ°å¢æ¶è½æºç±»åç±å
¥å£åºå®ï¼ä¸åæä¾æ°´/çµåæ¢ --> |
| | | <el-form-item v-if="!isEdit" label="è½æºç±»å"> |
| | | <el-tag :type="typeTag" size="small" effect="plain">{{ typeName }}</el-tag> |
| | | <span class="type-tip">ææ°å¢å
¥å£èªå¨å¸¦å
¥ï¼ä¸å¯ä¿®æ¹</span> |
| | | </el-form-item> |
| | | |
| | | <el-form-item v-if="!isEdit" label="éä¿¡å°å" prop="meterAddress"> |
| | | <div class="address-row"> |
| | | <el-input |
| | | v-model="form.meterAddress" |
| | | placeholder="请è¾å
¥ç¬¬ä¸æ¹éä¿¡å°åï¼ä¿çå导é¶" |
| | | clearable |
| | | style="flex: 1" |
| | | @keyup.enter="handleVerify" |
| | | /> |
| | | <el-button type="primary" plain :loading="verifying" @click="handleVerify"> |
| | | éªè¯å¹¶è¯»åæ¡£æ¡ |
| | | </el-button> |
| | | </div> |
| | | <div class="field-tip">设å¤ç±»åä¸éä¿¡å°åå
±åç¨äºå¹é
ç¬¬ä¸æ¹æ¡£æ¡ï¼ä¿ååå¿
é¡»éªè¯éè¿ã</div> |
| | | </el-form-item> |
| | | <el-form-item v-else label="éä¿¡å°å"> |
| | | <el-input v-model="form.meterAddress" disabled /> |
| | | <div class="field-tip">éä¿¡å°ååæ´éåç¨æ§ç»å®åéæ°éªè¯ï¼åå²ééè®°å½ä»å½å±æ§è®¾å¤ã</div> |
| | | </el-form-item> |
| | | |
| | | <el-alert |
| | | v-if="archive" |
| | | :type="archive.bindable ? 'success' : 'warning'" |
| | | :closable="false" |
| | | show-icon |
| | | class="archive-alert" |
| | | > |
| | | <template #title>{{ archive.message }}</template> |
| | | <div class="archive-grid"> |
| | | <span>ç¬¬ä¸æ¹IDï¼{{ archive.providerMeterId || '--' }}</span> |
| | | <span>åç§°ï¼{{ archive.providerName || '--' }}</span> |
| | | <span>åºåï¼{{ archive.areaName || '--' }}</span> |
| | | <span>å¨çº¿ç¶æï¼{{ onlineText(archive.onlineStatus) }}</span> |
| | | </div> |
| | | </el-alert> |
| | | <el-alert |
| | | v-else-if="verifyError" |
| | | type="error" |
| | | :title="verifyError" |
| | | :closable="false" |
| | | show-icon |
| | | class="archive-alert" |
| | | /> |
| | | |
| | | <el-form-item label="æ¾ç¤ºåç§°" prop="displayName"> |
| | | <el-input v-model="form.displayName" placeholder="é»è®¤ä½¿ç¨ç¬¬ä¸æ¹åç§°" maxlength="128" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="å®è£
ä½ç½®"> |
| | | <el-input v-model="form.location" maxlength="255" :placeholder="locationPlaceholder" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item v-if="isEdit" label="å¯ç¨ç¶æ"> |
| | | <el-switch |
| | | v-model="form.enabled" |
| | | :active-value="1" |
| | | :inactive-value="0" |
| | | active-text="å¯ç¨" |
| | | inactive-text="åç¨" |
| | | /> |
| | | <div class="field-tip">åç¨ä¸å é¤åå²è®°å½ï¼éæ°å¯ç¨åæè¿åº¦è¡¥éåç¨æé´æ°æ®ã</div> |
| | | </el-form-item> |
| | | </el-form> |
| | | |
| | | <template #footer> |
| | | <el-button @click="visible = false">å æ¶</el-button> |
| | | <el-button |
| | | type="primary" |
| | | :loading="submitting" |
| | | :disabled="!isEdit && !(archive && archive.bindable)" |
| | | @click="handleSubmit" |
| | | > |
| | | ç¡® å® |
| | | </el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, nextTick, reactive, ref } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { addMeterDevice, updateMeterDevice, verifyMeterDevice } from '@/api/energyManagement/energyMeter' |
| | | |
| | | const props = defineProps({ |
| | | defaultMeterType: { |
| | | type: String, |
| | | default: 'WATER' |
| | | } |
| | | }) |
| | | |
| | | const emit = defineEmits(['success']) |
| | | |
| | | const visible = ref(false) |
| | | const isEdit = ref(false) |
| | | const submitting = ref(false) |
| | | const verifying = ref(false) |
| | | const verifyError = ref('') |
| | | const archive = ref(null) |
| | | const formRef = ref(null) |
| | | const currentId = ref(null) |
| | | |
| | | const form = reactive({ |
| | | meterType: 'WATER', |
| | | meterAddress: '', |
| | | displayName: '', |
| | | location: '', |
| | | enabled: 1 |
| | | }) |
| | | |
| | | const typeName = computed(() => { |
| | | const map = { WATER: '水表', ELECTRIC: 'çµè¡¨', GAS: 'æ°è¡¨' } |
| | | return map[form.meterType] || '设å¤' |
| | | }) |
| | | |
| | | const typeTag = computed(() => { |
| | | if (form.meterType === 'ELECTRIC') { |
| | | return 'warning' |
| | | } |
| | | if (form.meterType === 'GAS') { |
| | | return 'success' |
| | | } |
| | | return 'primary' |
| | | }) |
| | | |
| | | const locationPlaceholder = computed(() => { |
| | | if (form.meterType === 'ELECTRIC') { |
| | | return 'å¦ï¼ä¸å·æ¥¼ä¸å±é
çµé´' |
| | | } |
| | | if (form.meterType === 'GAS') { |
| | | return 'å¦ï¼ä¸å·æ¥¼ä¸å±çæ°è¡¨ç®±' |
| | | } |
| | | return 'å¦ï¼ä¸å·æ¥¼ä¸å±æ°´è¡¨äº' |
| | | }) |
| | | |
| | | const rules = { |
| | | meterType: [{ required: true, message: 'è¯·éæ©è½æºç±»å', trigger: 'change' }], |
| | | meterAddress: [{ required: true, message: '请è¾å
¥éä¿¡å°å', trigger: 'blur' }], |
| | | displayName: [{ required: true, message: '请è¾å
¥æ¾ç¤ºåç§°', trigger: 'blur' }] |
| | | } |
| | | |
| | | function open(mode, row, meterType) { |
| | | isEdit.value = mode === 'edit' |
| | | currentId.value = row ? row.id : null |
| | | archive.value = null |
| | | verifyError.value = '' |
| | | if (row) { |
| | | form.meterType = row.meterType |
| | | form.meterAddress = row.meterAddress |
| | | form.displayName = row.displayName |
| | | form.location = row.location || '' |
| | | form.enabled = row.enabled |
| | | } else { |
| | | form.meterType = meterType || props.defaultMeterType || 'WATER' |
| | | form.meterAddress = '' |
| | | form.displayName = '' |
| | | form.location = '' |
| | | form.enabled = 1 |
| | | } |
| | | visible.value = true |
| | | nextTick(() => formRef.value && formRef.value.clearValidate()) |
| | | } |
| | | |
| | | async function handleVerify() { |
| | | if (!form.meterAddress) { |
| | | ElMessage.warning('请å
è¾å
¥éä¿¡å°å') |
| | | return |
| | | } |
| | | verifying.value = true |
| | | verifyError.value = '' |
| | | archive.value = null |
| | | try { |
| | | const res = await verifyMeterDevice({ |
| | | meterType: form.meterType, |
| | | meterAddress: form.meterAddress.trim() |
| | | }) |
| | | archive.value = res.data |
| | | if (res.data && res.data.bindable && !form.displayName) { |
| | | form.displayName = res.data.providerName || form.meterAddress |
| | | } |
| | | } catch (e) { |
| | | verifyError.value = (e && e.message) || 'ç¬¬ä¸æ¹æªæ¾å°è¯¥è¡¨è®¡' |
| | | } finally { |
| | | verifying.value = false |
| | | } |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | if (!formRef.value) { |
| | | return |
| | | } |
| | | const valid = await formRef.value.validate().catch(() => false) |
| | | if (!valid) { |
| | | return |
| | | } |
| | | submitting.value = true |
| | | try { |
| | | if (isEdit.value) { |
| | | await updateMeterDevice(currentId.value, { |
| | | displayName: form.displayName, |
| | | location: form.location, |
| | | enabled: form.enabled |
| | | }) |
| | | ElMessage.success('设å¤ä¿¡æ¯å·²æ´æ°') |
| | | } else { |
| | | await addMeterDevice({ |
| | | meterType: form.meterType, |
| | | meterAddress: form.meterAddress.trim(), |
| | | displayName: form.displayName, |
| | | location: form.location |
| | | }) |
| | | ElMessage.success('ç»å®æåï¼åå²è¡¥éä»»å¡å·²å建') |
| | | } |
| | | visible.value = false |
| | | emit('success') |
| | | } finally { |
| | | submitting.value = false |
| | | } |
| | | } |
| | | |
| | | function handleClosed() { |
| | | archive.value = null |
| | | verifyError.value = '' |
| | | formRef.value && formRef.value.resetFields() |
| | | } |
| | | |
| | | function onlineText(value) { |
| | | if (value === 1) { |
| | | return 'å¨çº¿' |
| | | } |
| | | if (value === 0) { |
| | | return '离线' |
| | | } |
| | | return 'æªç¥' |
| | | } |
| | | |
| | | defineExpose({ open }) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .address-row { |
| | | display: flex; |
| | | gap: 8px; |
| | | width: 100%; |
| | | } |
| | | |
| | | .field-tip { |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | line-height: 1.5; |
| | | color: #909399; |
| | | } |
| | | |
| | | .type-tip { |
| | | margin-left: 8px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .archive-alert { |
| | | margin-bottom: 18px; |
| | | } |
| | | |
| | | .archive-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | | gap: 4px 12px; |
| | | margin-top: 6px; |
| | | font-size: 12px; |
| | | color: #606266; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <el-drawer |
| | | :model-value="modelValue" |
| | | direction="rtl" |
| | | size="620px" |
| | | :title="device ? device.displayName : 'è®¾å¤æ°æ®'" |
| | | :destroy-on-close="false" |
| | | @update:model-value="(val) => emit('update:modelValue', val)" |
| | | @open="handleOpen" |
| | | @closed="handleClosed" |
| | | > |
| | | <div v-if="device" class="side-panel"> |
| | | <div class="panel-head"> |
| | | <div class="panel-title"> |
| | | <el-tag :type="device.meterType === 'WATER' ? 'primary' : 'warning'" effect="plain"> |
| | | {{ device.meterType === 'WATER' ? '水表' : 'çµè¡¨' }} |
| | | </el-tag> |
| | | <span class="address">{{ device.meterAddress }}</span> |
| | | <el-tag :type="device.enabled === 1 ? 'success' : 'info'" size="small"> |
| | | {{ device.enabled === 1 ? 'å¯ç¨' : 'åç¨' }} |
| | | </el-tag> |
| | | </div> |
| | | <el-button type="primary" plain size="small" @click="emit('collect', device)">æ¥ è¡¨</el-button> |
| | | </div> |
| | | |
| | | <el-descriptions :column="2" border size="small" class="panel-desc"> |
| | | <el-descriptions-item label="ç¬¬ä¸æ¹ID">{{ device.providerMeterId || '--' }}</el-descriptions-item> |
| | | <el-descriptions-item label="ç¬¬ä¸æ¹åç§°">{{ device.providerName || '--' }}</el-descriptions-item> |
| | | <el-descriptions-item label="åºå">{{ device.providerAreaName || '--' }}</el-descriptions-item> |
| | | <el-descriptions-item label="å¨çº¿ç¶æ"> |
| | | {{ device.onlineStatus === 1 ? 'å¨çº¿' : device.onlineStatus === 0 ? '离线' : 'æªç¥' }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item label="å®è£
ä½ç½®">{{ device.location || '--' }}</el-descriptions-item> |
| | | <el-descriptions-item label="è¡¥éèµ·ç¹">{{ device.syncStartDate || '--' }}</el-descriptions-item> |
| | | <el-descriptions-item label="æè¿æåéé">{{ device.lastSuccessAt || '--' }}</el-descriptions-item> |
| | | <el-descriptions-item label="æè¿ééç»æ"> |
| | | <el-tag :type="taskTagType(device.lastTaskStatus)" size="small"> |
| | | {{ device.collectStateText || 'å°æªéé' }} |
| | | </el-tag> |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | |
| | | <el-row :gutter="12" class="panel-cards"> |
| | | <el-col :span="12"> |
| | | <div class="mini-card"> |
| | | <div class="mini-label">ææ°è¡¨ç 读æ°</div> |
| | | <div class="mini-value"> |
| | | {{ latestReading != null ? formatNumber(latestReading) : '--' }} |
| | | <span class="mini-unit">{{ unit }}</span> |
| | | </div> |
| | | <div class="mini-sub">{{ latestReadingTime || 'ææ è¯»æ°' }}</div> |
| | | </div> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <div class="mini-card"> |
| | | <div class="mini-label">æè¿ä¸å¤©ç¨é</div> |
| | | <div class="mini-value"> |
| | | {{ device.lastDayUsage != null ? formatNumber(device.lastDayUsage) : '--' }} |
| | | <span class="mini-unit">{{ unit }}</span> |
| | | </div> |
| | | <div class="mini-sub">{{ device.lastDayUsageDate || 'ææ æ°æ®' }}</div> |
| | | </div> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-card shadow="never" class="panel-chart-card"> |
| | | <template #header> |
| | | <div class="card-head"> |
| | | <span>è¿ 14 天ç¨é</span> |
| | | <span class="card-sub">åä½ï¼{{ unit }}</span> |
| | | </div> |
| | | </template> |
| | | <div v-if="trendLoading" v-loading="true" class="chart-placeholder"></div> |
| | | <div v-else-if="!trendData.length" class="chart-placeholder empty">ææ æ°æ®</div> |
| | | <div v-show="trendData.length" ref="trendChartRef" class="chart-placeholder"></div> |
| | | </el-card> |
| | | |
| | | <el-tabs v-model="activeTab" class="panel-tabs" @tab-change="handleTabChange"> |
| | | <el-tab-pane label="ç¨éè®°å½" name="usage"> |
| | | <el-table :data="usageRows" v-loading="usageLoading" size="small" border height="260"> |
| | | <el-table-column label="ä¸å¡æ¥æ" width="110"> |
| | | <template #default="scope">{{ formatDay(scope.row.measureTime) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="ç¨é" width="110" align="right"> |
| | | <template #default="scope">{{ formatNumber(scope.row.metricValue) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="sourceApi" label="æ¥æºæ¥å£" min-width="130" /> |
| | | <el-table-column label="ééæ¶é´" min-width="150"> |
| | | <template #default="scope">{{ scope.row.collectedAt || '--' }}</template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <el-pagination |
| | | class="panel-pagination" |
| | | layout="total, prev, pager, next" |
| | | :total="usageTotal" |
| | | :page-size="pageSize" |
| | | :current-page="usagePage" |
| | | @current-change="handleUsagePageChange" |
| | | /> |
| | | </el-tab-pane> |
| | | <el-tab-pane label="读æ°è®°å½" name="reading"> |
| | | <el-table :data="readingRows" v-loading="readingLoading" size="small" border height="260"> |
| | | <el-table-column label="ç¤ºå¼æ¶é´" min-width="150"> |
| | | <template #default="scope">{{ scope.row.measureTime }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="表ç " width="120" align="right"> |
| | | <template #default="scope">{{ formatNumber(scope.row.metricValue) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="sourceApi" label="æ¥æºæ¥å£" min-width="130" /> |
| | | <el-table-column label="ééæ¶é´" min-width="150"> |
| | | <template #default="scope">{{ scope.row.collectedAt || '--' }}</template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <el-pagination |
| | | class="panel-pagination" |
| | | layout="total, prev, pager, next" |
| | | :total="readingTotal" |
| | | :page-size="pageSize" |
| | | :current-page="readingPage" |
| | | @current-change="handleReadingPageChange" |
| | | /> |
| | | </el-tab-pane> |
| | | </el-tabs> |
| | | </div> |
| | | </el-drawer> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, nextTick, ref } from 'vue' |
| | | import * as echarts from 'echarts' |
| | | import dayjs from 'dayjs' |
| | | import { listMeterMeasurements } from '@/api/energyManagement/energyMeter' |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { |
| | | type: Boolean, |
| | | default: false |
| | | }, |
| | | device: { |
| | | type: Object, |
| | | default: null |
| | | } |
| | | }) |
| | | |
| | | const emit = defineEmits(['update:modelValue', 'collect']) |
| | | |
| | | const activeTab = ref('usage') |
| | | const pageSize = 10 |
| | | const usageRows = ref([]) |
| | | const usageTotal = ref(0) |
| | | const usagePage = ref(1) |
| | | const usageLoading = ref(false) |
| | | const readingRows = ref([]) |
| | | const readingTotal = ref(0) |
| | | const readingPage = ref(1) |
| | | const readingLoading = ref(false) |
| | | const latestReading = ref(null) |
| | | const latestReadingTime = ref('') |
| | | const trendData = ref([]) |
| | | const trendLoading = ref(false) |
| | | const trendChartRef = ref(null) |
| | | let trendChart = null |
| | | |
| | | const unit = computed(() => (props.device && props.device.meterType === 'WATER' ? 'm³' : 'kWh')) |
| | | const usageMetric = computed(() => (props.device && props.device.meterType === 'WATER' ? 'WATER_USAGE' : 'ELECTRIC_USAGE')) |
| | | const readingMetric = computed(() => (props.device && props.device.meterType === 'WATER' ? 'WATER_READING' : 'ELECTRIC_READING')) |
| | | |
| | | function handleOpen() { |
| | | if (!props.device) { |
| | | return |
| | | } |
| | | usagePage.value = 1 |
| | | readingPage.value = 1 |
| | | activeTab.value = 'usage' |
| | | loadUsage() |
| | | loadReading() |
| | | loadTrend() |
| | | } |
| | | |
| | | function handleClosed() { |
| | | if (trendChart) { |
| | | trendChart.dispose() |
| | | trendChart = null |
| | | } |
| | | } |
| | | |
| | | async function loadUsage() { |
| | | if (!props.device) { |
| | | return |
| | | } |
| | | usageLoading.value = true |
| | | try { |
| | | const res = await listMeterMeasurements(props.device.id, { |
| | | metricCode: usageMetric.value, |
| | | granularity: 'D', |
| | | current: usagePage.value, |
| | | size: pageSize |
| | | }) |
| | | usageRows.value = (res.data && res.data.records) || [] |
| | | usageTotal.value = (res.data && res.data.total) || 0 |
| | | } finally { |
| | | usageLoading.value = false |
| | | } |
| | | } |
| | | |
| | | async function loadReading() { |
| | | if (!props.device) { |
| | | return |
| | | } |
| | | readingLoading.value = true |
| | | try { |
| | | const res = await listMeterMeasurements(props.device.id, { |
| | | metricCode: readingMetric.value, |
| | | granularity: 'POINT', |
| | | current: readingPage.value, |
| | | size: pageSize |
| | | }) |
| | | readingRows.value = (res.data && res.data.records) || [] |
| | | readingTotal.value = (res.data && res.data.total) || 0 |
| | | if (readingPage.value === 1 && readingRows.value.length) { |
| | | latestReading.value = readingRows.value[0].metricValue |
| | | latestReadingTime.value = readingRows.value[0].measureTime |
| | | } |
| | | } finally { |
| | | readingLoading.value = false |
| | | } |
| | | } |
| | | |
| | | async function loadTrend() { |
| | | if (!props.device) { |
| | | return |
| | | } |
| | | trendLoading.value = true |
| | | trendData.value = [] |
| | | try { |
| | | const end = dayjs().format('YYYY-MM-DD') |
| | | const start = dayjs().subtract(13, 'day').format('YYYY-MM-DD') |
| | | const res = await listMeterMeasurements(props.device.id, { |
| | | metricCode: usageMetric.value, |
| | | granularity: 'D', |
| | | start, |
| | | end, |
| | | current: 1, |
| | | size: 100 |
| | | }) |
| | | const records = (res.data && res.data.records) || [] |
| | | trendData.value = records |
| | | .slice() |
| | | .sort((a, b) => (a.measureTime > b.measureTime ? 1 : -1)) |
| | | .map((item) => ({ |
| | | day: (item.measureTime || '').slice(5, 10), |
| | | value: item.metricValue |
| | | })) |
| | | await nextTick() |
| | | renderTrend() |
| | | } finally { |
| | | trendLoading.value = false |
| | | } |
| | | } |
| | | |
| | | function renderTrend() { |
| | | if (!trendChartRef.value || !trendData.value.length) { |
| | | return |
| | | } |
| | | if (!trendChart) { |
| | | trendChart = echarts.init(trendChartRef.value) |
| | | } |
| | | const color = props.device && props.device.meterType === 'WATER' ? '#409eff' : '#e6a23c' |
| | | trendChart.setOption( |
| | | { |
| | | grid: { left: 44, right: 16, top: 20, bottom: 30 }, |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | valueFormatter: (value) => (value == null ? 'ææ æ°æ®' : `${formatNumber(value)} ${unit.value}`) |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: trendData.value.map((item) => item.day), |
| | | axisLabel: { fontSize: 11 } |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | name: unit.value, |
| | | nameTextStyle: { fontSize: 11 }, |
| | | splitLine: { lineStyle: { type: 'dashed' } } |
| | | }, |
| | | series: [ |
| | | { |
| | | name: 'ç¨é', |
| | | type: 'bar', |
| | | barMaxWidth: 16, |
| | | data: trendData.value.map((item) => item.value), |
| | | itemStyle: { color, borderRadius: [3, 3, 0, 0] } |
| | | } |
| | | ] |
| | | }, |
| | | true |
| | | ) |
| | | trendChart.resize() |
| | | } |
| | | |
| | | function handleTabChange() { |
| | | if (activeTab.value === 'usage') { |
| | | loadUsage() |
| | | } else { |
| | | loadReading() |
| | | } |
| | | } |
| | | |
| | | function handleUsagePageChange(page) { |
| | | usagePage.value = page |
| | | loadUsage() |
| | | } |
| | | |
| | | function handleReadingPageChange(page) { |
| | | readingPage.value = page |
| | | loadReading() |
| | | } |
| | | |
| | | function formatNumber(value) { |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | const num = Number(value) |
| | | if (Number.isNaN(num)) { |
| | | return String(value) |
| | | } |
| | | return num |
| | | .toFixed(3) |
| | | .replace(/\.?0+$/, '') |
| | | } |
| | | |
| | | function formatDay(value) { |
| | | return value ? String(value).slice(0, 10) : '--' |
| | | } |
| | | |
| | | function taskTagType(status) { |
| | | if (status === 'SUCCESS') { |
| | | return 'success' |
| | | } |
| | | if (status === 'FAILED') { |
| | | return 'danger' |
| | | } |
| | | if (status === 'RETRY' || status === 'EMPTY') { |
| | | return 'warning' |
| | | } |
| | | return 'info' |
| | | } |
| | | |
| | | defineExpose({ reload: handleOpen }) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .side-panel { |
| | | padding: 0 4px 24px; |
| | | } |
| | | |
| | | .panel-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .panel-title { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .panel-title .address { |
| | | font-size: 13px; |
| | | color: #606266; |
| | | } |
| | | |
| | | .panel-desc { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .panel-cards { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .mini-card { |
| | | padding: 10px 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 6px; |
| | | background: #fafcff; |
| | | } |
| | | |
| | | .mini-label { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .mini-value { |
| | | margin: 4px 0 2px; |
| | | font-size: 22px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .mini-unit { |
| | | font-size: 12px; |
| | | font-weight: 400; |
| | | color: #909399; |
| | | } |
| | | |
| | | .mini-sub { |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .panel-chart-card { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .card-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .card-sub { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .chart-placeholder { |
| | | height: 200px; |
| | | width: 100%; |
| | | } |
| | | |
| | | .chart-placeholder.empty { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | color: #909399; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .panel-tabs { |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .panel-pagination { |
| | | margin-top: 8px; |
| | | justify-content: flex-end; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <div class="app-container meter-page"> |
| | | <!-- é¡¶é¨çé --> |
| | | <el-card shadow="never" class="filter-card"> |
| | | <div class="filter-grid"> |
| | | <div class="filter-item"> |
| | | <span class="filter-label">è½æºç±»å</span> |
| | | <el-radio-group v-model="query.meterType" @change="handleTypeChange"> |
| | | <el-radio-button value="WATER">水表</el-radio-button> |
| | | <el-radio-button value="ELECTRIC">çµè¡¨</el-radio-button> |
| | | </el-radio-group> |
| | | </div> |
| | | <div class="filter-item"> |
| | | <span class="filter-label">设å¤èå´</span> |
| | | <el-select v-model="query.scope" style="width: 160px" @change="handleScopeChange"> |
| | | <el-option label="å
¨é¨å·²ç»å®è®¾å¤" value="ALL" /> |
| | | <el-option label="åå°è®¾å¤" value="SINGLE" /> |
| | | </el-select> |
| | | </div> |
| | | <div v-if="query.scope === 'SINGLE'" class="filter-item"> |
| | | <el-select |
| | | v-model="query.meterId" |
| | | filterable |
| | | clearable |
| | | placeholder="æç´¢è®¾å¤åç§°/éä¿¡å°å" |
| | | style="width: 280px" |
| | | @change="loadAll" |
| | | > |
| | | <el-option |
| | | v-for="item in deviceOptions" |
| | | :key="item.id" |
| | | :label="item.displayName + 'ï¼' + item.meterAddress + 'ï¼'" |
| | | :value="item.id" |
| | | /> |
| | | </el-select> |
| | | </div> |
| | | <div class="filter-item"> |
| | | <span class="filter-label">æä»½</span> |
| | | <el-date-picker |
| | | v-model="query.month" |
| | | type="month" |
| | | value-format="YYYY-MM" |
| | | :clearable="false" |
| | | style="width: 140px" |
| | | @change="handleMonthChange" |
| | | /> |
| | | </div> |
| | | <div class="filter-item"> |
| | | <span class="filter-label">年份</span> |
| | | <el-date-picker |
| | | v-model="query.year" |
| | | type="year" |
| | | value-format="YYYY" |
| | | :clearable="false" |
| | | style="width: 110px" |
| | | @change="handleYearChange" |
| | | /> |
| | | </div> |
| | | <div class="filter-actions"> |
| | | <el-button type="primary" @click="loadAll">æ¥è¯¢</el-button> |
| | | <el-button @click="refreshAll">å·æ°æ°æ®</el-button> |
| | | <el-button v-hasPermi="['energyMeter:device:add']" type="success" @click="openDeviceForm('add')"> |
| | | æ°å¢è®¾å¤ |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | | |
| | | <el-alert |
| | | v-if="overview.mockMode" |
| | | class="mock-alert" |
| | | type="info" |
| | | :closable="false" |
| | | show-icon |
| | | title="å½å为æ¼ç¤ºæ°æ®æ¨¡å¼ï¼æªé
ç½®ç¬¬ä¸æ¹ææç ï¼æ¥è¡¨ç»æç±æ¬å°æ¨¡æçæï¼ä»
ç¨äºèè°ä¸é¡µé¢æ¼ç¤ºã" |
| | | /> |
| | | |
| | | <!-- ééç¶ææ¡ --> |
| | | <div class="status-bar"> |
| | | <span class="status-item">æ°æ®æªè³ï¼<b>{{ overview.dataThrough || '--' }}</b></span> |
| | | <span class="status-item">æè¿æåééï¼<b>{{ overview.lastSuccessAt || '--' }}</b></span> |
| | | <span class="status-item"> |
| | | æ°æ®èå´ï¼<b>{{ overview.dataStart || '--' }} ~ {{ overview.dataEnd || '--' }}</b> |
| | | </span> |
| | | <el-tag :type="syncTagType(overview.syncStatus)" size="small">{{ overview.syncStatusText || 'ææ æ°æ®' }}</el-tag> |
| | | <span class="status-item">æ¬æè¦çï¼<b>{{ overview.coverageRate == null ? '--' : overview.coverageRate + '%' }}</b></span> |
| | | <span class="status-item status-scope">{{ overview.scopeLabel || '--' }}</span> |
| | | </div> |
| | | |
| | | <!-- ç»è®¡å¡ç --> |
| | | <el-row :gutter="12" class="stat-row"> |
| | | <el-col :xs="24" :sm="12" :md="6"> |
| | | <div class="stat-card"> |
| | | <div class="stat-label">{{ query.month }} 累计ç¨é</div> |
| | | <div class="stat-value"> |
| | | {{ overview.monthHasData ? formatNumber(overview.monthTotal) : '--' }} |
| | | <span class="stat-unit">{{ unit }}</span> |
| | | </div> |
| | | <div class="stat-sub"> |
| | | å·²éé {{ overview.collectedDays || 0 }}/{{ overview.expectedDays || 0 }} 天 |
| | | </div> |
| | | </div> |
| | | </el-col> |
| | | <el-col :xs="24" :sm="12" :md="6"> |
| | | <div class="stat-card"> |
| | | <div class="stat-label">䏿åæç´¯è®¡</div> |
| | | <div class="stat-value"> |
| | | {{ overview.monthHasData ? formatNumber(overview.monthPreviousTotal) : '--' }} |
| | | <span class="stat-unit">{{ unit }}</span> |
| | | </div> |
| | | <div class="stat-sub"> |
| | | ç¯æ¯ |
| | | <span :class="comparisonClass(overview.monthTotal, overview.monthPreviousTotal)"> |
| | | {{ comparisonText(overview.monthTotal, overview.monthPreviousTotal) }} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | </el-col> |
| | | <el-col :xs="24" :sm="12" :md="6"> |
| | | <div class="stat-card"> |
| | | <div class="stat-label">{{ query.year }} 年累计ç¨é</div> |
| | | <div class="stat-value"> |
| | | {{ overview.yearHasData ? formatNumber(overview.yearTotal) : '--' }} |
| | | <span class="stat-unit">{{ unit }}</span> |
| | | </div> |
| | | <div class="stat-sub">å·²ééæ°æ®å计ï¼ç¼ºå¤±æ¥ä¸æ 0 计</div> |
| | | </div> |
| | | </el-col> |
| | | <el-col :xs="24" :sm="12" :md="6"> |
| | | <div class="stat-card"> |
| | | <div class="stat-label">å»å¹´åæç´¯è®¡</div> |
| | | <div class="stat-value"> |
| | | {{ overview.yearHasData ? formatNumber(overview.yearPreviousTotal) : '--' }} |
| | | <span class="stat-unit">{{ unit }}</span> |
| | | </div> |
| | | <div class="stat-sub"> |
| | | 忝 |
| | | <span :class="comparisonClass(overview.yearTotal, overview.yearPreviousTotal)"> |
| | | {{ comparisonText(overview.yearTotal, overview.yearPreviousTotal) }} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <!-- å¾è¡¨ --> |
| | | <el-row :gutter="12"> |
| | | <el-col :xs="24" :lg="12"> |
| | | <el-card shadow="never" class="chart-card"> |
| | | <template #header> |
| | | <div class="card-head"> |
| | | <span>{{ query.month }} æ¯æ¥ç¨é</span> |
| | | <span class="card-sub">åä½ï¼{{ unit }}</span> |
| | | </div> |
| | | </template> |
| | | <div ref="monthChartRef" class="chart"></div> |
| | | <div class="chart-note"> |
| | | <span>彿ç¨éï¼{{ overview.monthHasData ? formatNumber(overview.monthTotal) : '--' }} {{ unit }}</span> |
| | | <span>䏿ç¨éï¼{{ overview.monthHasData ? formatNumber(overview.monthPreviousTotal) : '--' }} {{ unit }}</span> |
| | | <span>æ¬æè´¹ç¨ï¼{{ overview.monthCost == null ? '--' : formatNumber(overview.monthCost) + ' å
' }}</span> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :xs="24" :lg="12"> |
| | | <el-card shadow="never" class="chart-card"> |
| | | <template #header> |
| | | <div class="card-head"> |
| | | <span>{{ query.year }} å¹´æ¯æç¨é</span> |
| | | <span class="card-sub">åä½ï¼{{ unit }}</span> |
| | | </div> |
| | | </template> |
| | | <div ref="yearChartRef" class="chart"></div> |
| | | <div class="chart-note"> |
| | | <span>æ¬å¹´ç¨éï¼{{ overview.yearHasData ? formatNumber(overview.yearTotal) : '--' }} {{ unit }}</span> |
| | | <span>å»å¹´ç¨éï¼{{ overview.yearHasData ? formatNumber(overview.yearPreviousTotal) : '--' }} {{ unit }}</span> |
| | | <span>æ¬å¹´è´¹ç¨ï¼{{ overview.yearCost == null ? '--' : formatNumber(overview.yearCost) + ' å
' }}</span> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <!-- 设å¤å表 --> |
| | | <el-card shadow="never" class="device-card"> |
| | | <template #header> |
| | | <div class="card-head"> |
| | | <span>ç»å®è®¾å¤</span> |
| | | <div class="table-actions"> |
| | | <el-button |
| | | v-hasPermi="['energyMeter:device:collect']" |
| | | size="small" |
| | | :disabled="!selectedRows.length" |
| | | @click="openCollectDialog(selectedRows)" |
| | | > |
| | | æ¹éæ¥è¡¨ï¼{{ selectedRows.length }}ï¼ |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <el-table |
| | | :data="devices" |
| | | v-loading="tableLoading" |
| | | border |
| | | row-key="id" |
| | | @selection-change="handleSelectionChange" |
| | | > |
| | | <el-table-column type="selection" width="44" reserve-selection /> |
| | | <el-table-column label="设å¤åç§°" min-width="180"> |
| | | <template #default="scope"> |
| | | <div class="device-name">{{ scope.row.displayName }}</div> |
| | | <div class="device-sub">{{ scope.row.providerName || '--' }}</div> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ç±»å" width="80" align="center"> |
| | | <template #default="scope"> |
| | | <el-tag :type="scope.row.meterType === 'WATER' ? 'primary' : 'warning'" size="small" effect="plain"> |
| | | {{ scope.row.meterType === 'WATER' ? '水表' : 'çµè¡¨' }} |
| | | </el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="meterAddress" label="éä¿¡å°å" min-width="130" /> |
| | | <el-table-column prop="providerMeterId" label="ç¬¬ä¸æ¹ID" width="120" /> |
| | | <el-table-column prop="providerAreaName" label="åºå" width="110" /> |
| | | <el-table-column label="ç¶æ" width="110" align="center"> |
| | | <template #default="scope"> |
| | | <el-tag :type="scope.row.enabled === 1 ? 'success' : 'info'" size="small"> |
| | | {{ scope.row.enabled === 1 ? 'å¯ç¨' : 'åç¨' }} |
| | | </el-tag> |
| | | <el-tag |
| | | v-if="scope.row.onlineStatus != null" |
| | | class="online-tag" |
| | | :type="scope.row.onlineStatus === 1 ? 'success' : 'danger'" |
| | | size="small" |
| | | effect="plain" |
| | | > |
| | | {{ scope.row.onlineStatus === 1 ? 'å¨çº¿' : '离线' }} |
| | | </el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æè¿éé" min-width="170"> |
| | | <template #default="scope"> |
| | | <div class="device-sub">{{ scope.row.lastTaskTime || '--' }}</div> |
| | | <el-tag :type="taskTagType(scope.row.lastTaskStatus)" size="small" effect="plain"> |
| | | {{ scope.row.collectStateText || 'å°æªéé' }} |
| | | </el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æè¿ä¸å¤©ç¨é" width="130" align="right"> |
| | | <template #default="scope"> |
| | | <span v-if="scope.row.lastDayUsage != null"> |
| | | {{ formatNumber(scope.row.lastDayUsage) }} {{ scope.row.meterType === 'WATER' ? 'm³' : 'kWh' }} |
| | | </span> |
| | | <span v-else class="device-sub">--</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="ææ°è¯»æ°" width="130" align="right"> |
| | | <template #default="scope"> |
| | | <span v-if="scope.row.latestReading != null"> |
| | | {{ formatNumber(scope.row.latestReading) }} {{ scope.row.meterType === 'WATER' ? 'm³' : 'kWh' }} |
| | | </span> |
| | | <span v-else class="device-sub">--</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="æä½" width="210" fixed="right" align="center"> |
| | | <template #default="scope"> |
| | | <el-button link type="primary" @click="openSidePanel(scope.row)">æ¥ç</el-button> |
| | | <el-button |
| | | v-hasPermi="['energyMeter:device:collect']" |
| | | link |
| | | type="primary" |
| | | @click="openCollectDialog([scope.row])" |
| | | > |
| | | æ¥è¡¨ |
| | | </el-button> |
| | | <el-button |
| | | v-hasPermi="['energyMeter:device:edit']" |
| | | link |
| | | type="primary" |
| | | @click="openDeviceForm('edit', scope.row)" |
| | | > |
| | | ç¼è¾ |
| | | </el-button> |
| | | <el-button v-hasPermi="['energyMeter:device:edit']" link type="danger" @click="toggleEnabled(scope.row)"> |
| | | {{ scope.row.enabled === 1 ? 'åç¨' : 'å¯ç¨' }} |
| | | </el-button> |
| | | </template> |
| | | </el-table-column> |
| | | <template #empty> |
| | | <el-empty description="ææ ç»å®è®¾å¤ï¼è¯·å
æ°å¢" :image-size="80" /> |
| | | </template> |
| | | </el-table> |
| | | |
| | | <el-pagination |
| | | class="device-pagination" |
| | | layout="total, sizes, prev, pager, next, jumper" |
| | | :total="page.total" |
| | | :current-page="page.current" |
| | | :page-size="page.size" |
| | | :page-sizes="[10, 20, 50]" |
| | | @current-change="handlePageChange" |
| | | @size-change="handleSizeChange" |
| | | /> |
| | | </el-card> |
| | | |
| | | <!-- æ°å¢/ç¼è¾ --> |
| | | <DeviceFormDialog ref="deviceFormRef" :default-meter-type="query.meterType" @success="handleDeviceSaved" /> |
| | | |
| | | <!-- å³ä¾§æ°æ®é¢æ¿ --> |
| | | <DeviceSidePanel |
| | | v-model="sidePanelVisible" |
| | | :device="currentDevice" |
| | | @collect="(row) => openCollectDialog([row])" |
| | | /> |
| | | |
| | | |
| | | <!-- æå¨æ¥è¡¨ --> |
| | | <el-dialog v-model="collectDialog.visible" title="æå¨æ¥è¡¨" width="480px" append-to-body> |
| | | <el-form label-width="92px"> |
| | | <el-form-item label="æ¥è¡¨è®¾å¤"> |
| | | <div class="collect-devices"> |
| | | <el-tag v-for="item in collectDialog.devices" :key="item.id" size="small" effect="plain"> |
| | | {{ item.displayName }}ï¼{{ item.meterAddress }}ï¼ |
| | | </el-tag> |
| | | </div> |
| | | </el-form-item> |
| | | <el-form-item label="æ¥æèå´"> |
| | | <el-date-picker |
| | | v-model="collectDialog.range" |
| | | type="daterange" |
| | | value-format="YYYY-MM-DD" |
| | | range-separator="è³" |
| | | start-placeholder="å¼å§æ¥æ" |
| | | end-placeholder="ç»ææ¥æ" |
| | | :disabled-date="disableAfterYesterday" |
| | | style="width: 100%" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="å
å«ç¤ºå¼"> |
| | | <el-switch v-model="collectDialog.includeReadings" /> |
| | | <span class="field-tip">å
³éååªééæé´ç¨é</span> |
| | | </el-form-item> |
| | | </el-form> |
| | | <div class="dialog-tip"> |
| | | æå¨æ¥è¡¨å¨åå°æ§è¡ï¼å®æå页颿°æ®ä¼èªå¨å·æ°ï¼é夿¥è¯¢å䏿¶é´èå´ä¸ä¼äº§çéå¤è®°å½ã |
| | | </div> |
| | | <template #footer> |
| | | <el-button @click="collectDialog.visible = false">å æ¶</el-button> |
| | | <el-button type="primary" :loading="collectDialog.submitting" @click="submitCollect">å¼å§æ¥è¡¨</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue' |
| | | import * as echarts from 'echarts' |
| | | import dayjs from 'dayjs' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import DeviceFormDialog from './components/DeviceFormDialog.vue' |
| | | import DeviceSidePanel from './components/DeviceSidePanel.vue' |
| | | import { |
| | | collectMeterDevice, |
| | | collectMeterDevices, |
| | | getMeterOverview, |
| | | listMeterDeviceOptions, |
| | | listMeterDevices, |
| | | updateMeterDevice |
| | | } from '@/api/energyManagement/energyMeter' |
| | | |
| | | defineOptions({ name: 'WaterElectricMeter' }) |
| | | |
| | | const query = reactive({ |
| | | meterType: 'WATER', |
| | | scope: 'ALL', |
| | | meterId: null, |
| | | month: dayjs().format('YYYY-MM'), |
| | | year: dayjs().format('YYYY') |
| | | }) |
| | | |
| | | const page = reactive({ current: 1, size: 10, total: 0 }) |
| | | const devices = ref([]) |
| | | const deviceOptions = ref([]) |
| | | const selectedRows = ref([]) |
| | | const tableLoading = ref(false) |
| | | const overview = ref({}) |
| | | const currentDevice = ref(null) |
| | | const sidePanelVisible = ref(false) |
| | | const deviceFormRef = ref(null) |
| | | |
| | | const collectDialog = reactive({ |
| | | visible: false, |
| | | devices: [], |
| | | range: [], |
| | | includeReadings: true, |
| | | submitting: false |
| | | }) |
| | | |
| | | const monthChartRef = ref(null) |
| | | const yearChartRef = ref(null) |
| | | let monthChart = null |
| | | let yearChart = null |
| | | let collectReloadTimer = null |
| | | |
| | | const unit = ref('m³') |
| | | |
| | | onMounted(() => { |
| | | loadOptions() |
| | | loadAll() |
| | | window.addEventListener('resize', handleResize) |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | window.removeEventListener('resize', handleResize) |
| | | if (collectReloadTimer) { |
| | | clearTimeout(collectReloadTimer) |
| | | collectReloadTimer = null |
| | | } |
| | | if (monthChart) { |
| | | monthChart.dispose() |
| | | monthChart = null |
| | | } |
| | | if (yearChart) { |
| | | yearChart.dispose() |
| | | yearChart = null |
| | | } |
| | | }) |
| | | |
| | | function handleResize() { |
| | | monthChart && monthChart.resize() |
| | | yearChart && yearChart.resize() |
| | | } |
| | | |
| | | function loadAll() { |
| | | loadOverview() |
| | | loadDevices() |
| | | } |
| | | |
| | | function refreshAll() { |
| | | loadOptions() |
| | | loadAll() |
| | | ElMessage.success('已巿°') |
| | | } |
| | | |
| | | async function loadOverview() { |
| | | const params = { |
| | | meterType: query.meterType, |
| | | month: query.month, |
| | | year: Number(query.year) |
| | | } |
| | | if (query.scope === 'SINGLE' && query.meterId) { |
| | | params.meterId = query.meterId |
| | | } |
| | | try { |
| | | const res = await getMeterOverview(params) |
| | | overview.value = res.data || {} |
| | | unit.value = overview.value.unit === 'm3' ? 'm³' : overview.value.unit || 'm³' |
| | | await nextTick() |
| | | renderMonthChart() |
| | | renderYearChart() |
| | | } catch (e) { |
| | | overview.value = {} |
| | | } |
| | | } |
| | | |
| | | async function loadDevices() { |
| | | tableLoading.value = true |
| | | try { |
| | | const res = await listMeterDevices({ |
| | | current: page.current, |
| | | size: page.size, |
| | | meterType: query.meterType |
| | | }) |
| | | const data = res.data || {} |
| | | devices.value = data.records || [] |
| | | page.total = data.total || 0 |
| | | } finally { |
| | | tableLoading.value = false |
| | | } |
| | | } |
| | | |
| | | async function loadOptions() { |
| | | try { |
| | | const res = await listMeterDeviceOptions({ meterType: query.meterType }) |
| | | deviceOptions.value = res.data || [] |
| | | if (query.scope === 'SINGLE') { |
| | | const exists = deviceOptions.value.some((item) => item.id === query.meterId) |
| | | if (!exists) { |
| | | query.meterId = deviceOptions.value.length ? deviceOptions.value[0].id : null |
| | | } |
| | | } |
| | | } catch (e) { |
| | | deviceOptions.value = [] |
| | | } |
| | | } |
| | | |
| | | function handleTypeChange() { |
| | | query.meterId = null |
| | | query.scope = 'ALL' |
| | | page.current = 1 |
| | | loadOptions() |
| | | loadAll() |
| | | } |
| | | |
| | | function handleScopeChange(value) { |
| | | if (value === 'ALL') { |
| | | query.meterId = null |
| | | } else if (!query.meterId && deviceOptions.value.length) { |
| | | query.meterId = deviceOptions.value[0].id |
| | | } |
| | | loadAll() |
| | | } |
| | | |
| | | function handleMonthChange(value) { |
| | | if (value) { |
| | | query.year = value.slice(0, 4) |
| | | } |
| | | loadAll() |
| | | } |
| | | |
| | | function handleYearChange(value) { |
| | | if (value) { |
| | | const monthPart = (query.month || dayjs().format('YYYY-MM')).slice(5, 7) |
| | | query.month = `${value}-${monthPart}` |
| | | } |
| | | loadAll() |
| | | } |
| | | |
| | | function handlePageChange(current) { |
| | | page.current = current |
| | | loadDevices() |
| | | } |
| | | |
| | | function handleSizeChange(size) { |
| | | page.size = size |
| | | page.current = 1 |
| | | loadDevices() |
| | | } |
| | | |
| | | function handleSelectionChange(rows) { |
| | | selectedRows.value = rows |
| | | } |
| | | |
| | | function openDeviceForm(mode, row) { |
| | | deviceFormRef.value && deviceFormRef.value.open(mode, row) |
| | | } |
| | | |
| | | function handleDeviceSaved() { |
| | | loadOptions() |
| | | loadAll() |
| | | } |
| | | |
| | | function openSidePanel(row) { |
| | | currentDevice.value = row |
| | | sidePanelVisible.value = true |
| | | } |
| | | |
| | | async function toggleEnabled(row) { |
| | | const action = row.enabled === 1 ? 'åç¨' : 'å¯ç¨' |
| | | const tips = |
| | | action === 'åç¨' |
| | | ? 'åç¨åä¸åå䏿¯æ¥èªå¨åæ¥ï¼åå²ééæ°æ®ä¼ä¿çã' |
| | | : 'å¯ç¨åå°æè¿åº¦èªå¨è¡¥éåç¨æé´çæ°æ®ã' |
| | | try { |
| | | await ElMessageBox.confirm(`确认${action}设å¤ã${row.displayName}ãï¼${tips}`, `${action}设å¤`, { |
| | | type: 'warning', |
| | | confirmButtonText: action, |
| | | cancelButtonText: 'åæ¶' |
| | | }) |
| | | } catch (e) { |
| | | return |
| | | } |
| | | try { |
| | | await updateMeterDevice(row.id, { enabled: row.enabled === 1 ? 0 : 1 }) |
| | | ElMessage.success(`${action}æå`) |
| | | loadOptions() |
| | | loadAll() |
| | | } catch (e) { |
| | | // é误æç¤ºå·²ç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } |
| | | } |
| | | |
| | | function openCollectDialog(rows) { |
| | | const list = Array.isArray(rows) ? rows : [rows] |
| | | const valid = list.filter((item) => item && item.id) |
| | | if (!valid.length) { |
| | | ElMessage.warning('请å
éæ©è®¾å¤') |
| | | return |
| | | } |
| | | collectDialog.devices = valid |
| | | collectDialog.range = [ |
| | | dayjs().subtract(3, 'day').format('YYYY-MM-DD'), |
| | | dayjs().subtract(1, 'day').format('YYYY-MM-DD') |
| | | ] |
| | | collectDialog.includeReadings = true |
| | | collectDialog.visible = true |
| | | } |
| | | |
| | | async function submitCollect() { |
| | | if (!collectDialog.range || collectDialog.range.length !== 2) { |
| | | ElMessage.warning('è¯·éæ©æ¥è¡¨æ¥æèå´') |
| | | return |
| | | } |
| | | collectDialog.submitting = true |
| | | try { |
| | | const payload = { |
| | | startDate: collectDialog.range[0], |
| | | endDate: collectDialog.range[1], |
| | | includeReadings: collectDialog.includeReadings |
| | | } |
| | | if (collectDialog.devices.length === 1) { |
| | | await collectMeterDevice(collectDialog.devices[0].id, payload) |
| | | } else { |
| | | await collectMeterDevices({ |
| | | ...payload, |
| | | meterIds: collectDialog.devices.map((item) => item.id) |
| | | }) |
| | | } |
| | | collectDialog.visible = false |
| | | ElMessage.success('æ¥è¡¨ä»»å¡å·²æäº¤ï¼æ£å¨åå°éé') |
| | | if (collectReloadTimer) { |
| | | clearTimeout(collectReloadTimer) |
| | | } |
| | | collectReloadTimer = setTimeout(() => { |
| | | loadAll() |
| | | collectReloadTimer = null |
| | | }, 4000) |
| | | } catch (e) { |
| | | // é误æç¤ºå·²ç±è¯·æ±æ¦æªå¨ç»ä¸å±ç¤º |
| | | } finally { |
| | | collectDialog.submitting = false |
| | | } |
| | | } |
| | | |
| | | /* ------------------------------ å¾è¡¨ ------------------------------ */ |
| | | |
| | | function renderMonthChart() { |
| | | if (!monthChartRef.value) { |
| | | return |
| | | } |
| | | if (!monthChart) { |
| | | monthChart = echarts.init(monthChartRef.value) |
| | | } |
| | | const series = overview.value.monthSeries || [] |
| | | const color = query.meterType === 'WATER' ? '#409eff' : '#e6a23c' |
| | | monthChart.setOption( |
| | | { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | valueFormatter: (value) => (value == null ? 'ææ æ°æ®' : `${formatNumber(value)} ${unit.value}`) |
| | | }, |
| | | legend: { data: ['彿', '䏿åæ'], top: 0, itemWidth: 12, itemHeight: 8 }, |
| | | grid: { left: 48, right: 16, top: 34, bottom: 42 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: series.map((item) => (item.day ? item.day.slice(5) : '')), |
| | | axisLabel: { fontSize: 11, rotate: series.length > 20 ? 45 : 0, interval: series.length > 20 ? 1 : 0 }, |
| | | axisTick: { show: false } |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | name: `ç¨éï¼${unit.value}ï¼`, |
| | | nameTextStyle: { fontSize: 11 }, |
| | | splitLine: { lineStyle: { type: 'dashed' } } |
| | | }, |
| | | series: [ |
| | | { |
| | | name: '彿', |
| | | type: 'bar', |
| | | barMaxWidth: 16, |
| | | data: series.map((item) => item.current), |
| | | itemStyle: { color, borderRadius: [3, 3, 0, 0] } |
| | | }, |
| | | { |
| | | name: '䏿åæ', |
| | | type: 'line', |
| | | smooth: false, |
| | | symbolSize: 4, |
| | | lineStyle: { width: 1.5, color: '#909399' }, |
| | | itemStyle: { color: '#909399' }, |
| | | data: series.map((item) => item.previous) |
| | | } |
| | | ], |
| | | graphic: overview.value.monthHasData |
| | | ? [] |
| | | : [{ type: 'text', left: 'center', top: 'middle', style: { text: 'ææ æ°æ®', fill: '#909399', fontSize: 15 } }] |
| | | }, |
| | | true |
| | | ) |
| | | monthChart.resize() |
| | | } |
| | | |
| | | function renderYearChart() { |
| | | if (!yearChartRef.value) { |
| | | return |
| | | } |
| | | if (!yearChart) { |
| | | yearChart = echarts.init(yearChartRef.value) |
| | | } |
| | | const series = overview.value.yearSeries || [] |
| | | const color = query.meterType === 'WATER' ? '#409eff' : '#e6a23c' |
| | | yearChart.setOption( |
| | | { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | valueFormatter: (value) => (value == null ? 'ææ æ°æ®' : `${formatNumber(value)} ${unit.value}`) |
| | | }, |
| | | legend: { data: ['æ¬å¹´', 'å»å¹´åæ'], top: 0, itemWidth: 12, itemHeight: 8 }, |
| | | grid: { left: 52, right: 16, top: 34, bottom: 32 }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: series.map((item) => (item.month ? item.month.slice(5) + 'æ' : '')), |
| | | axisLabel: { fontSize: 11 }, |
| | | axisTick: { show: false } |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | name: `ç¨éï¼${unit.value}ï¼`, |
| | | nameTextStyle: { fontSize: 11 }, |
| | | splitLine: { lineStyle: { type: 'dashed' } } |
| | | }, |
| | | series: [ |
| | | { |
| | | name: 'æ¬å¹´', |
| | | type: 'bar', |
| | | barMaxWidth: 22, |
| | | data: series.map((item) => item.current), |
| | | itemStyle: { color, borderRadius: [3, 3, 0, 0] } |
| | | }, |
| | | { |
| | | name: 'å»å¹´åæ', |
| | | type: 'line', |
| | | symbolSize: 4, |
| | | lineStyle: { width: 1.5, color: '#909399' }, |
| | | itemStyle: { color: '#909399' }, |
| | | data: series.map((item) => item.previous) |
| | | } |
| | | ], |
| | | graphic: overview.value.yearHasData |
| | | ? [] |
| | | : [{ type: 'text', left: 'center', top: 'middle', style: { text: 'ææ æ°æ®', fill: '#909399', fontSize: 15 } }] |
| | | }, |
| | | true |
| | | ) |
| | | yearChart.resize() |
| | | } |
| | | |
| | | /* ------------------------------ å·¥å
· ------------------------------ */ |
| | | |
| | | function formatNumber(value) { |
| | | if (value == null || value === '') { |
| | | return '--' |
| | | } |
| | | const num = Number(value) |
| | | if (Number.isNaN(num)) { |
| | | return String(value) |
| | | } |
| | | return num.toFixed(3).replace(/\.?0+$/, '') |
| | | } |
| | | |
| | | function comparisonText(current, previous) { |
| | | if (current == null || previous == null || Number(previous) === 0) { |
| | | return '--' |
| | | } |
| | | const diff = ((Number(current) - Number(previous)) / Number(previous)) * 100 |
| | | return `${diff >= 0 ? '+' : ''}${diff.toFixed(1)}%` |
| | | } |
| | | |
| | | function comparisonClass(current, previous) { |
| | | if (current == null || previous == null || Number(previous) === 0) { |
| | | return 'compare-flat' |
| | | } |
| | | return Number(current) >= Number(previous) ? 'compare-up' : 'compare-down' |
| | | } |
| | | |
| | | function syncTagType(status) { |
| | | if (status === 'OK') { |
| | | return 'success' |
| | | } |
| | | if (status === 'PARTIAL') { |
| | | return 'warning' |
| | | } |
| | | if (status === 'STALE') { |
| | | return 'danger' |
| | | } |
| | | return 'info' |
| | | } |
| | | |
| | | function taskTagType(status) { |
| | | if (status === 'SUCCESS') { |
| | | return 'success' |
| | | } |
| | | if (status === 'FAILED') { |
| | | return 'danger' |
| | | } |
| | | if (status === 'RETRY' || status === 'EMPTY') { |
| | | return 'warning' |
| | | } |
| | | return 'info' |
| | | } |
| | | |
| | | function disableAfterYesterday(date) { |
| | | return dayjs(date).isAfter(dayjs().subtract(1, 'day'), 'day') |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .meter-page { |
| | | padding-bottom: 24px; |
| | | } |
| | | |
| | | .filter-card { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .filter-grid { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 16px 20px; |
| | | } |
| | | |
| | | .filter-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .filter-label { |
| | | font-size: 13px; |
| | | color: #606266; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .filter-actions { |
| | | display: flex; |
| | | gap: 8px; |
| | | margin-left: auto; |
| | | } |
| | | |
| | | .mock-alert { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .status-bar { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 16px; |
| | | padding: 10px 14px; |
| | | margin-bottom: 12px; |
| | | border-radius: 6px; |
| | | background: #f5f7fa; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | } |
| | | |
| | | .status-item b { |
| | | color: #303133; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .status-scope { |
| | | margin-left: auto; |
| | | color: #909399; |
| | | } |
| | | |
| | | .stat-row { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .stat-card { |
| | | padding: 16px 18px; |
| | | margin-bottom: 12px; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 8px; |
| | | background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); |
| | | } |
| | | |
| | | .stat-label { |
| | | font-size: 13px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .stat-value { |
| | | margin: 8px 0 4px; |
| | | font-size: 26px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .stat-unit { |
| | | margin-left: 4px; |
| | | font-size: 13px; |
| | | font-weight: 400; |
| | | color: #909399; |
| | | } |
| | | |
| | | .stat-sub { |
| | | font-size: 12px; |
| | | color: #a8abb2; |
| | | } |
| | | |
| | | .compare-up { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .compare-down { |
| | | color: #67c23a; |
| | | } |
| | | |
| | | .compare-flat { |
| | | color: #909399; |
| | | } |
| | | |
| | | .chart-card { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .card-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | font-size: 14px; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .card-sub { |
| | | font-size: 12px; |
| | | font-weight: 400; |
| | | color: #909399; |
| | | } |
| | | |
| | | .chart { |
| | | width: 100%; |
| | | height: 300px; |
| | | } |
| | | |
| | | .chart-note { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 20px; |
| | | padding-top: 8px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .device-card { |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .table-actions { |
| | | display: flex; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .device-name { |
| | | font-size: 13px; |
| | | color: #303133; |
| | | } |
| | | |
| | | .device-sub { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .online-tag { |
| | | margin-left: 6px; |
| | | } |
| | | |
| | | .device-pagination { |
| | | margin-top: 12px; |
| | | justify-content: flex-end; |
| | | } |
| | | |
| | | .collect-devices { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 6px; |
| | | max-height: 96px; |
| | | overflow-y: auto; |
| | | } |
| | | |
| | | .field-tip { |
| | | margin-left: 8px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .dialog-tip { |
| | | padding: 8px 12px; |
| | | border-radius: 4px; |
| | | background: #f4f4f5; |
| | | font-size: 12px; |
| | | line-height: 1.6; |
| | | color: #909399; |
| | | } |
| | | </style> |