gongchunyi
2 天以前 e1673170d6a44bc67f978b815d15df67114377bb
feat: 能耗管理
已添加12个文件
已删除10个文件
已修改1个文件
10518 ■■■■■ 文件已修改
src/api/energyManagement/energyMeter.js 187 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/index.js 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/analysis/index.vue 654 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/collection/index.vue 744 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyArea/index.vue 511 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyPeriodTime/index.vue 462 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyPower/components/formDia.vue 228 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyPower/index.vue 322 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/energyTrends/index.vue 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/meterData/components/MeterOverview.vue 786 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/meterData/electric/index.vue 1298 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/meterData/gas/index.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/meterData/water/index.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/meterLedger/index.vue 1426 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/realtime/index.vue 847 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/waterElectricMeter/components/DeviceFormDialog.vue 287 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/waterElectricMeter/components/DeviceSidePanel.vue 453 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/waterElectricMeter/index.vue 998 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/waterManagement/components/formDia.vue 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/waterManagement/components/waterBillForm.vue 203 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/waterManagement/index.vue 329 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/waterManagement/waterBill.vue 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/energyManagement/waterManagement/waterTrends.vue 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/energyManagement/energyMeter.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,187 @@
// æ°´ç”µè¡¨ç›‘测(集社云能源监控接入)
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'
  })
}
src/router/index.js
@@ -97,6 +97,55 @@
      },
    ],
  },
  // èƒ½è€—管理
  {
    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,
src/views/energyManagement/analysis/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,654 @@
<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>
src/views/energyManagement/collection/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,744 @@
<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>
src/views/energyManagement/energyArea/index.vue
ÎļþÒÑɾ³ý
src/views/energyManagement/energyPeriodTime/index.vue
ÎļþÒÑɾ³ý
src/views/energyManagement/energyPower/components/formDia.vue
ÎļþÒÑɾ³ý
src/views/energyManagement/energyPower/index.vue
ÎļþÒÑɾ³ý
src/views/energyManagement/energyTrends/index.vue
ÎļþÒÑɾ³ý
src/views/energyManagement/meterData/components/MeterOverview.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,786 @@
<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>
src/views/energyManagement/meterData/electric/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1298 @@
<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>
src/views/energyManagement/meterData/gas/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
<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>
src/views/energyManagement/meterData/water/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
<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>
src/views/energyManagement/meterLedger/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1426 @@
<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>
src/views/energyManagement/realtime/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,847 @@
<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>
src/views/energyManagement/waterElectricMeter/components/DeviceFormDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,287 @@
<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>
src/views/energyManagement/waterElectricMeter/components/DeviceSidePanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,453 @@
<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>
src/views/energyManagement/waterElectricMeter/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,998 @@
<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>
src/views/energyManagement/waterManagement/components/formDia.vue
ÎļþÒÑɾ³ý
src/views/energyManagement/waterManagement/components/waterBillForm.vue
ÎļþÒÑɾ³ý
src/views/energyManagement/waterManagement/index.vue
ÎļþÒÑɾ³ý
src/views/energyManagement/waterManagement/waterBill.vue
ÎļþÒÑɾ³ý
src/views/energyManagement/waterManagement/waterTrends.vue
ÎļþÒÑɾ³ý