| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146 |
- <template>
- <div class="water-page home-page">
- <div class="page-heading">
- <div class="page-title">
- <span class="page-title-icon"><el-icon><HomeFilled /></el-icon></span>
- <div>
- <h2>供水首页工作台</h2>
- <span class="muted">面向值班态的一站式供水运行工作台,仅统计供水设施与供水监测设备</span>
- </div>
- </div>
- <div class="heading-actions">
- <el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
- </div>
- </div>
- <el-alert v-if="loadError" class="load-error" type="error" show-icon :closable="false"
- title="部分数据加载失败" :description="loadError">
- <el-button size="small" type="primary" :loading="loading" @click="loadAll">重试</el-button>
- </el-alert>
- <!-- A 指挥台 -->
- <section class="home-section">
- <div class="metric-grid">
- <div v-for="card in homeKpis" :key="card.key" class="metric">
- <div class="metric-icon"><el-icon><component :is="kpiIcon(card.key)"/></el-icon></div>
- <div class="metric-body">
- <span>{{ card.label }}</span>
- <div class="metric-value">
- <strong :class="card.tone">{{ card.value }}</strong><small>{{ card.unit }}</small>
- </div>
- </div>
- </div>
- </div>
- <div class="workbench-grid">
- <el-card shadow="never" class="water-panel home-panel">
- <template #header>
- <div class="card-title">
- <el-icon><Bell /></el-icon><span>实时报警</span>
- <el-button link type="primary" class="panel-link" @click="goPage('/monitorAlarm/index')">前往报警管理</el-button>
- </div>
- </template>
- <div class="compact-list">
- <button v-for="item in alarmRows" :key="item.id || item.deviceCode" type="button"
- class="compact-item" @click="openRealtimeAlarm(item)">
- <span class="status-dot dot-alarm" />
- <span class="compact-main">
- <b>{{ item.deviceName }}</b>
- <small>{{ item.deviceCode }}</small>
- </span>
- <span class="compact-meta">
- <el-tag size="small" :type="item.displayStatusType">{{ item.displayStatusText }}</el-tag>
- <el-tag size="small" :type="levelType(item.alarmLevel)">{{ item.alarmLevelText }}</el-tag>
- <small>{{ item.alarmTime }}</small>
- </span>
- </button>
- <el-empty v-if="!alarmRows.length" description="当前无实时报警" :image-size="60" />
- </div>
- </el-card>
- <el-card shadow="never" class="water-panel home-panel map-panel">
- <template #header>
- <div class="card-title">
- <el-icon><MapLocation /></el-icon><span>全域态势</span>
- <div class="map-legend">
- <i class="legend-dot" :style="{ background: gisPointStatusColors.alarm }" />报警/异常
- <i class="legend-dot" :style="{ background: gisPointStatusColors.workOrder }" />工单/检修中
- <i class="legend-dot" :style="{ background: gisPointStatusColors.warning }" />预警/欠费
- <i class="legend-dot" :style="{ background: gisPointStatusColors.offline }" />离线
- <i class="legend-dot" :style="{ background: gisPointStatusColors.normal }" />正常
- </div>
- <div class="panel-links">
- <el-button link type="primary" class="panel-link" @click="goPage('/facility/gis')">设施</el-button>
- <el-button link type="primary" class="panel-link" @click="goPage('/device/gis')">设备</el-button>
- <el-button link type="primary" class="panel-link" @click="goPage('/monitorAlarm/map')">报警</el-button>
- </div>
- </div>
- </template>
- <div class="map-stage">
- <GisMapCanvas
- ref="overviewMapRef"
- :points="overviewMapPoints"
- :lines="overviewMapLines"
- cluster-label="供水点位"
- :center="overviewMapCenter"
- :zoom="overviewMapZoom"
- @point-click="openOverviewPoint"
- @line-click="openOverviewPoint"
- @map-click="overviewDetailOpen = false"
- @ready="handleOverviewMapReady"
- @error="overviewMapError = $event"
- />
- <el-alert v-if="overviewMapError" class="map-inline-alert" type="warning" show-icon :closable="false"
- :title="overviewMapError">
- <el-button link type="primary" @click="loadAll">重试</el-button>
- </el-alert>
- <el-empty v-else-if="overviewLoaded && !overviewMapPoints.length && !overviewMapLines.length"
- class="map-inline-empty" description="暂无可展示的全域点位" :image-size="70" />
- </div>
- <el-drawer v-model="overviewDetailOpen" title="点位详情" size="420px" direction="rtl"
- class="detail-drawer" append-to-body>
- <div v-if="overviewDetail" class="overview-detail">
- <section class="detail-hero">
- <h3>{{ overviewDetail.name }}</h3>
- <p>{{ overviewDetail.code }}</p>
- <div class="status-tags">
- <el-tag size="small" :type="gisStatusTagType(overviewDetail.statusKey)">
- {{ overviewDetail.statusText || gisStatusLabel(overviewDetail.statusKey) }}
- </el-tag>
- <el-tag v-if="overviewDetail.meta" size="small" type="info">{{ overviewDetail.meta }}</el-tag>
- </div>
- </section>
- <section class="detail-section">
- <h4>点位信息</h4>
- <dl>
- <dt>名称</dt><dd>{{ overviewDetail.name }}</dd>
- <dt>编码</dt><dd>{{ overviewDetail.code }}</dd>
- <dt>类别</dt><dd>{{ overviewKindLabel(overviewDetail.kind) }}</dd>
- <dt>状态</dt><dd>{{ overviewDetail.statusText || gisStatusLabel(overviewDetail.statusKey) }}</dd>
- <dt>类型 / 报警</dt><dd>{{ overviewDetail.meta }}</dd>
- <dt>经度</dt><dd>{{ overviewDetail.lng }}</dd>
- <dt>纬度</dt><dd>{{ overviewDetail.lat }}</dd>
- </dl>
- </section>
- </div>
- <el-empty v-else description="请点击地图点位查看详情" :image-size="70" />
- </el-drawer>
- </el-card>
- <el-card shadow="never" class="water-panel home-panel">
- <template #header>
- <div class="card-title">
- <el-icon><Tickets /></el-icon><span>工单进展</span>
- <el-button link type="primary" class="panel-link" @click="goPage('/device/maintain')">设备维修派单</el-button>
- </div>
- </template>
- <div class="compact-list">
- <button v-for="item in workOrderRows" :key="item.orderId || item.orderNo" type="button"
- class="compact-item" @click="goPage('/device/maintain', { keyword: item.orderNo })">
- <span class="status-dot" :class="dotByOrderType(item.orderStatusType)" />
- <span class="compact-main">
- <b>{{ item.orderNo }}</b>
- <small>{{ item.deviceName || item.deviceCode }}</small>
- </span>
- <span class="compact-meta">
- <el-tag size="small" :type="item.orderStatusType">{{ item.orderStatusText }}</el-tag>
- </span>
- </button>
- <el-empty v-if="!workOrderRows.length" description="当前暂无供水工单" :image-size="60" />
- </div>
- <div class="order-brief">
- <span>待处理 <b>{{ workOrderStat.pendingCount }}</b></span>
- <span>处理中 <b>{{ workOrderStat.processingCount }}</b></span>
- <span>待验收 <b>{{ workOrderStat.verifyingCount }}</b></span>
- <span>已完成 <b>{{ workOrderStat.finishedCount }}</b></span>
- </div>
- </el-card>
- </div>
- </section>
- <!-- B 异常待办 -->
- <section class="home-section">
- <div class="workbench-grid workbench-grid--equal">
- <el-card shadow="never" class="water-panel home-panel">
- <template #header>
- <div class="card-title">
- <el-icon><Warning /></el-icon><span>异常待办</span>
- <el-button link type="primary" class="panel-link" @click="goPage('/device/abnormal')">查看全部</el-button>
- </div>
- </template>
- <div class="compact-list">
- <button v-for="item in abnormalTodoRows" :key="item.equipmentId" type="button"
- class="compact-item" :class="{ active: selectedDevice && selectedDevice.equipmentId === item.equipmentId }"
- @click="selectAbnormal(item)">
- <span class="status-dot" :class="abnormalDot(item)" />
- <span class="compact-main">
- <b>{{ item.equipmentName || item.equipmentCode }}</b>
- <small>{{ item.equipmentCode }}</small>
- </span>
- <span class="compact-meta">
- <el-tag size="small" :type="abnormalType(item)">{{ item.abnormalReasonText || '设备异常' }}</el-tag>
- </span>
- </button>
- <el-empty v-if="!abnormalTodoRows.length" description="当前无异常设备待办" :image-size="60" />
- </div>
- </el-card>
- <el-card shadow="never" class="water-panel home-panel detail-panel">
- <template #header>
- <div class="card-title"><el-icon><Cpu /></el-icon><span>设备详情</span></div>
- </template>
- <div v-if="selectedDevice" class="device-summary">
- <div class="summary-top">
- <div class="summary-name">{{ selectedDevice.name }}</div>
- <div class="summary-tags">
- <el-tag size="small" :type="Number(selectedDevice.alarmStatus) === 1 ? 'danger' : 'success'">{{ selectedDevice.alarmStatusText }}</el-tag>
- <el-tag size="small" :type="Number(selectedDevice.onlineStatus) === 1 ? 'success' : 'info'">{{ selectedDevice.onlineStatusText }}</el-tag>
- <el-tag v-if="selectedDevice.relatedOrderNo && selectedDevice.relatedOrderNo !== '-'" size="small"
- :type="selectedDevice.workOrderStatusCode != null ? orderType(selectedDevice.workOrderStatusCode) : 'info'">
- {{ selectedDevice.workOrderStatusText }}
- </el-tag>
- </div>
- </div>
- <el-descriptions :column="1" border size="small">
- <el-descriptions-item label="设备编码">{{ selectedDevice.code }}</el-descriptions-item>
- <el-descriptions-item label="设备类型">{{ selectedDevice.typeName }}</el-descriptions-item>
- <el-descriptions-item label="所属区域">{{ selectedDevice.district || '-' }}</el-descriptions-item>
- <el-descriptions-item label="安装位置">{{ selectedDevice.location || '-' }}</el-descriptions-item>
- <el-descriptions-item label="报警等级">{{ selectedDevice.alarmLevelText }}</el-descriptions-item>
- <el-descriptions-item label="异常原因">{{ selectedDevice.abnormalReasonText || '-' }}</el-descriptions-item>
- <el-descriptions-item label="关联工单">
- <el-button v-if="selectedDevice.relatedOrderNo && selectedDevice.relatedOrderNo !== '-'" link type="primary"
- @click="goPage('/device/maintain', { keyword: selectedDevice.relatedOrderNo })">
- {{ selectedDevice.relatedOrderNo }}
- </el-button>
- <span v-else>-</span>
- </el-descriptions-item>
- </el-descriptions>
- </div>
- <el-empty v-else description="从左侧异常待办中选择设备" :image-size="70" />
- </el-card>
- <el-card shadow="never" class="water-panel home-panel">
- <template #header>
- <div class="card-title"><el-icon><Grid /></el-icon><span>快捷入口</span></div>
- </template>
- <div class="quick-grid">
- <button v-for="entry in quickEntries" :key="entry.key" type="button" class="quick-entry"
- @click="goPage(entry.suffix)">
- <el-icon><component :is="entryIcon(entry.key)"/></el-icon>
- <span>{{ entry.label }}</span>
- </button>
- </div>
- </el-card>
- </div>
- </section>
- <!-- C 数据总览 -->
- <section class="home-section">
- <div class="metric-grid">
- <div v-for="card in overviewCards" :key="card.key" class="metric">
- <div class="metric-icon"><el-icon><component :is="overviewIcon(card.key)"/></el-icon></div>
- <div class="metric-body">
- <span>{{ card.label }}</span>
- <div class="metric-value">
- <strong :class="card.tone">{{ card.value }}</strong><small>{{ card.unit }}</small>
- </div>
- </div>
- </div>
- </div>
- <div class="chart-grid">
- <el-card shadow="never" class="water-panel chart-card trend-card">
- <template #header>
- <div class="card-title">
- <el-icon><TrendCharts /></el-icon><span>报警趋势</span>
- <span class="muted">近 {{ trendDays }} 天 · 按四个报警等级分别统计</span>
- </div>
- </template>
- <div ref="trendChartRef" class="chart" />
- </el-card>
- <el-card shadow="never" class="water-panel chart-card">
- <template #header>
- <div class="card-title"><el-icon><PieChart /></el-icon><span>报警分类占比</span></div>
- </template>
- <div ref="warningPieRef" class="chart" />
- </el-card>
- <el-card shadow="never" class="water-panel chart-card">
- <template #header>
- <div class="card-title"><el-icon><DataAnalysis /></el-icon><span>设备分类占比</span></div>
- </template>
- <div ref="devicePieRef" class="chart" />
- </el-card>
- </div>
- </section>
- </div>
- </template>
- <script setup>
- import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
- import { useRouter } from 'vue-router'
- import * as echarts from 'echarts'
- import {
- Bell, BellFilled, Connection, Cpu, DataAnalysis, Finished, Grid, HomeFilled,
- MapLocation, OfficeBuilding, PieChart, Refresh, Tickets, TrendCharts,
- Warning, WarningFilled
- } from '@element-plus/icons-vue'
- import { getAlarmStats, getDeviceGisAllPoints,
- getWaterFacilityFeatures, getWaterFacilityOverview, listAlarmMapPoints,
- listRealtimeAlarms } from '@/api/pipeNetwork/waterSupply'
- import { getAlarmDashboard } from '@/api/alarm'
- import { getEquipmentStatusStatistics, listEquipment } from '@/api/pipeNetwork/pdeviceInform'
- import { listWorkOrder, workOrderStatusCount } from '@/api/pipeNetwork/ops/workOrder'
- import { normalizeGeoJson, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
- import { normalizeDeviceGisPoints } from '@/utils/waterSupplyUiModel'
- import { gisPointStatusColors, gisPointStatusLabels, gisPointStatusTagTypes } from '@/utils/gisMapColors'
- import { buildFacilityMapModel } from '@/utils/waterFacilityGisModel'
- import GisMapCanvas from '@/components/GisMapCanvas.vue'
- import {
- HOME_QUICK_ENTRIES, WATER_DEVICE_ROOT_TYPE_ID, buildHomeGisOverview, buildHomeWorkOrderQuery,
- normalizeAbnormalTodoRows, normalizeAlarmOverviewCards, normalizeAlarmTrend, normalizeDeviceSummary,
- normalizeDeviceStatistics, normalizeHomeKpis, normalizePieRatio, normalizeRealtimeAlarmRows, realtimeAlarmJumpTarget,
- normalizeWorkOrderRows, normalizeWorkOrderStatusStat
- } from '@/utils/waterHomeModel'
- const router = useRouter()
- const loading = ref(true)
- const loadError = ref('')
- const trendDays = 7
- const quickEntries = HOME_QUICK_ENTRIES
- const facilityRows = ref([])
- const ledgerStats = ref({})
- const alarmStats = ref({})
- const alarmDashboard = ref({})
- const alarmRows = ref([])
- const workOrderRows = ref([])
- const workOrderStat = ref({})
- const abnormalTodoRows = ref([])
- const overviewMapPoints = ref([])
- const overviewMapLines = ref([])
- const overviewDeviceRaw = ref([])
- const overviewMapCenter = { lng: 110.3965, lat: 28.4638 }
- const overviewMapZoom = 15
- const overviewMapError = ref('')
- const overviewLoaded = ref(false)
- const overviewDetailOpen = ref(false)
- const overviewDetail = ref(null)
- const overviewMapRef = ref(null)
- const selectedDevice = ref(null)
- const homeKpis = ref([])
- const overviewCards = ref([])
- const trendData = ref({ dates: [], levels: [] })
- const warningRatio = ref([])
- const deviceRatio = ref([])
- const trendChartRef = ref(null)
- const warningPieRef = ref(null)
- const devicePieRef = ref(null)
- let trendChart = null
- let warningPie = null
- let devicePie = null
- const KPI_ICON = {
- facility: OfficeBuilding,
- device: Cpu,
- online: Connection,
- alarm: BellFilled
- }
- const OVERVIEW_ICON = {
- alarmTotal: BellFilled,
- alarmingDevice: WarningFilled,
- verifyingOrder: Tickets,
- onlineRate: TrendCharts
- }
- const ENTRY_ICON = {
- abnormal: WarningFilled,
- alarm: BellFilled,
- audit: Finished,
- map: MapLocation
- }
- function kpiIcon(key) { return KPI_ICON[key] || OfficeBuilding }
- function overviewIcon(key) { return OVERVIEW_ICON[key] || DataAnalysis }
- function entryIcon(key) { return ENTRY_ICON[key] || Grid }
- function levelType(level) {
- const meta = { 1: 'danger', 2: 'warning', 3: 'primary', 4: 'success' }
- return meta[Number(level)] || 'info'
- }
- function orderType(status) {
- const meta = {
- 0: 'danger', 1: 'warning', 2: 'primary', 3: 'warning',
- 4: 'primary', 5: 'success', 6: 'warning', 7: 'info'
- }
- return meta[Number(status)] || 'info'
- }
- function dotByOrderType(type) {
- const map = { danger: 'dot-danger', warning: 'dot-warning', primary: 'dot-primary', success: 'dot-success', info: 'dot-info' }
- return map[type] || 'dot-info'
- }
- function abnormalDot(row) {
- if (Number(row.alarmStatus) === 1) return 'dot-alarm'
- if (Number(row.onlineStatus) === 0) return 'dot-offline'
- return 'dot-warning'
- }
- function abnormalType(row) {
- if (Number(row.alarmStatus) === 1) return 'danger'
- if (Number(row.onlineStatus) === 0) return 'info'
- return 'warning'
- }
- async function settle(fn, fallback = null, onError = null) {
- try {
- return await fn()
- } catch (error) {
- console.warn('供水首页数据片段加载失败', error)
- // 失败上报必须在这里完成:本函数已吞掉异常并返回兜底值,
- // 调用侧再挂 .catch 永远不会触发,“部分数据加载失败”提示会变成死代码。
- onError?.()
- if (fallback !== null) return fallback
- throw error
- }
- }
- async function loadAll() {
- loading.value = true
- loadError.value = ''
- const problems = []
- const recordProblem = label => () => { problems.push(label) }
- const [overview, ledger, alarm, dashboard, realtime, abnormal, orderStat, orders, deviceGis] = await Promise.allSettled([
- settle(() => getWaterFacilityOverview(), [], recordProblem('设施概览')),
- settle(() => getEquipmentStatusStatistics(WATER_DEVICE_ROOT_TYPE_ID), {}, recordProblem('设备统计')),
- settle(() => getAlarmStats(), {}, recordProblem('报警统计')),
- settle(() => getAlarmDashboard({ rootTypeId: WATER_DEVICE_ROOT_TYPE_ID, days: trendDays }), {}, recordProblem('报警态势')),
- settle(() => listRealtimeAlarms(), [], recordProblem('实时报警')),
- // 异常待办 = 当前处于报警中的供水设备;设备状态由 equipment_status 维护
- settle(() => listEquipment({ pageNum: 1, pageSize: 50, rootTypeId: WATER_DEVICE_ROOT_TYPE_ID, alarmStatus: 1 }), { records: [], total: 0 }, recordProblem('异常待办')),
- settle(() => workOrderStatusCount(buildHomeWorkOrderQuery()), { total: 0, counts: [] }, recordProblem('工单统计')),
- settle(() => listWorkOrder(buildHomeWorkOrderQuery({ pageSize: 20 })), { records: [], total: 0 }, recordProblem('工单列表')),
- settle(() => getDeviceGisAllPoints(), { features: [] }, recordProblem('设备点位'))
- ])
- const overviewData = unwrapResponse(overview.value || [])
- facilityRows.value = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
- ledgerStats.value = normalizeDeviceStatistics(unwrapResponse(ledger.value || {}))
- alarmStats.value = unwrapResponse(alarm.value || {})
- alarmDashboard.value = unwrapResponse(dashboard.value || {})
- alarmRows.value = normalizeRealtimeAlarmRows(unwrapResponse(realtime.value || []), 20)
- const abnormalPage = normalizePage(abnormal.value || { records: [], total: 0 })
- abnormalTodoRows.value = normalizeAbnormalTodoRows(abnormalPage.records, 20)
- workOrderStat.value = normalizeWorkOrderStatusStat(unwrapResponse(orderStat.value || { total: 0, counts: [] }))
- const orderList = unwrapResponse(orders.value || { records: [] })
- workOrderRows.value = normalizeWorkOrderRows(orderList?.records || [], 20)
- const gisData = unwrapResponse(deviceGis.value || {})
- overviewDeviceRaw.value = Array.isArray(gisData) ? gisData : (gisData?.features || [])
- await loadOverviewMap(recordProblem)
- homeKpis.value = normalizeHomeKpis({ facilityRows: facilityRows.value, ledgerStats: ledgerStats.value, alarmStats: alarmStats.value })
- overviewCards.value = normalizeAlarmOverviewCards({
- dashboard: alarmDashboard.value,
- deviceStats: ledgerStats.value,
- workOrderStat: workOrderStat.value
- })
- trendData.value = normalizeAlarmTrend(alarmDashboard.value?.trend || [])
- warningRatio.value = normalizePieRatio(alarmDashboard.value?.warningTypeRatio || [], '未分类', 6)
- deviceRatio.value = normalizePieRatio(alarmDashboard.value?.deviceTypeRatio || [], '未分类', 6)
- if (problems.length) loadError.value = problems.join(';')
- loading.value = false
- await nextTick()
- renderCharts()
- }
- function selectAbnormal(row) {
- selectedDevice.value = normalizeDeviceSummary(row)
- }
- async function loadOverviewMap(recordProblem) {
- overviewMapError.value = ''
- const [facilityRes, alarmRes] = await Promise.all([
- settle(() => getWaterFacilityFeatures(), { features: [] }, recordProblem('设施GIS点位')),
- settle(() => listAlarmMapPoints(), [], recordProblem('报警点位'))
- ])
- const facilityModel = buildFacilityMapModel(normalizeGeoJson(facilityRes))
- const overviewModel = buildHomeGisOverview({
- facilityPoints: facilityModel.points,
- facilityLines: facilityModel.lines,
- devicePoints: normalizeDeviceGisPoints(overviewDeviceRaw.value),
- alarmPoints: (Array.isArray(alarmRes) ? alarmRes : [])
- })
- overviewMapPoints.value = overviewModel.points
- overviewMapLines.value = overviewModel.lines
- overviewLoaded.value = true
- await nextTick()
- overviewMapRef.value?.fitBounds()
- }
- function handleOverviewMapReady() {
- nextTick(() => overviewMapRef.value?.fitBounds())
- }
- function openOverviewPoint(point) {
- overviewDetail.value = point || null
- overviewDetailOpen.value = Boolean(point)
- }
- function openRealtimeAlarm(item) {
- const target = realtimeAlarmJumpTarget(item)
- goPage(target.path, target.query)
- }
- function resolveRoutePath(suffix) {
- const matched = router.getRoutes().find(item => item.path.endsWith(suffix))
- return matched ? matched.path : ''
- }
- function goPage(suffix, query = {}) {
- const path = resolveRoutePath(suffix)
- if (!path) {
- console.warn(`未找到供水入口路由: ${suffix}`)
- return
- }
- router.push({ path, query }).catch(() => {})
- }
- function gisStatusLabel(statusKey) {
- return gisPointStatusLabels[statusKey] || '正常'
- }
- function gisStatusTagType(statusKey) {
- return gisPointStatusTagTypes[statusKey] || 'success'
- }
- function overviewKindLabel(kind) {
- return { device: '监测设备', alarm: '报警', facility: '供水设施' }[kind] || '点位'
- }
- function renderCharts() {
- renderTrendChart()
- warningPie?.dispose()
- warningPie = renderPieChart(warningPieRef.value, warningRatio.value, '报警数')
- devicePie?.dispose()
- devicePie = renderPieChart(devicePieRef.value, deviceRatio.value, '设备数')
- }
- function renderTrendChart() {
- if (!trendChartRef.value) return
- trendChart?.dispose()
- trendChart = echarts.init(trendChartRef.value)
- const { dates, levels } = trendData.value
- const palette = ['#f56c6c', '#e6a23c', '#409eff', '#67c23a']
- trendChart.setOption({
- tooltip: { trigger: 'axis' },
- legend: { bottom: 0, icon: 'circle', itemWidth: 9, itemHeight: 9, textStyle: { color: '#475569' } },
- grid: { left: 34, right: 14, top: 18, bottom: 48, containLabel: true },
- xAxis: {
- type: 'category',
- data: dates,
- axisLine: { lineStyle: { color: '#cbd5e1' } },
- axisLabel: { color: '#64748b' }
- },
- yAxis: {
- type: 'value',
- minInterval: 1,
- splitLine: { lineStyle: { color: '#eef2f7' } },
- axisLabel: { color: '#64748b' }
- },
- color: palette,
- series: levels.map((level, index) => ({
- name: level.text || `级${index + 1}`,
- type: 'bar',
- stack: 'alarm',
- barWidth: 26,
- data: level.data,
- itemStyle: { borderRadius: index === levels.length - 1 ? [5, 5, 0, 0] : 0 }
- }))
- })
- }
- function renderPieChart(container, data, unit) {
- if (!container) return null
- const total = data.reduce((sum, item) => sum + item.count, 0)
- const chart = echarts.init(container)
- chart.setOption({
- tooltip: { trigger: 'item', formatter: `{b}:{c} ${unit}({d}%)` },
- legend: { bottom: 0, icon: 'circle', itemWidth: 9, itemHeight: 9, textStyle: { color: '#475569' } },
- title: {
- text: String(total),
- subtext: unit,
- left: 'center',
- top: '32%',
- textStyle: { color: '#1e293b', fontSize: 24, fontWeight: 700 },
- subtextStyle: { color: '#64748b', fontSize: 12 }
- },
- color: ['#2563eb', '#14b8a6', '#f59e0b', '#ef4444', '#8b5cf6', '#64748b'],
- series: [{
- type: 'pie',
- radius: ['48%', '68%'],
- center: ['50%', '43%'],
- data: data.map(item => ({ name: item.name, value: item.count })),
- label: { formatter: '{b}\n{d}%', color: '#475569', lineHeight: 17 },
- itemStyle: { borderColor: '#fff', borderWidth: 3, borderRadius: 8 },
- emphasis: { scaleSize: 7 }
- }]
- })
- return chart
- }
- function handleResize() {
- trendChart?.resize()
- warningPie?.resize()
- devicePie?.resize()
- }
- onMounted(async () => {
- await loadAll()
- window.addEventListener('resize', handleResize)
- })
- onBeforeUnmount(() => {
- window.removeEventListener('resize', handleResize)
- trendChart?.dispose()
- warningPie?.dispose()
- devicePie?.dispose()
- })
- </script>
- <style lang="scss">
- @use './waterSupplyPageTheme.scss';
- </style>
- <style scoped>
- .home-page {
- background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
- }
- .heading-actions {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .page-title-icon {
- display: grid;
- place-items: center;
- width: 42px;
- height: 42px;
- color: #fff;
- background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
- border-radius: 8px;
- box-shadow: 0 8px 16px rgba(37, 99, 235, 0.2);
- }
- .load-error {
- margin-bottom: 14px;
- }
- .home-section {
- margin-bottom: 20px;
- }
- .metric-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 12px;
- margin-bottom: 14px;
- }
- .metric {
- display: flex;
- align-items: center;
- gap: 14px;
- min-width: 0;
- padding: 14px 16px;
- background: #fff;
- border: 1px solid #e1ebf3;
- border-radius: 8px;
- box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
- }
- .metric-icon {
- position: static;
- flex: 0 0 42px;
- display: grid;
- place-items: center;
- width: 42px;
- height: 42px;
- color: #2563eb;
- background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
- border-radius: 8px;
- font-size: 22px;
- }
- .metric-body {
- min-width: 0;
- }
- .metric-body span {
- display: block;
- overflow: hidden;
- margin-bottom: 3px;
- color: #64748b;
- font-size: 13px;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .metric-value {
- display: flex;
- align-items: baseline;
- gap: 5px;
- }
- .metric-value strong {
- color: #1e293b;
- font-size: 24px;
- line-height: 1.2;
- }
- .metric-value strong.danger { color: #dc2626; }
- .metric-value strong.warning { color: #d97706; }
- .metric-value small {
- color: #94a3b8;
- }
- .workbench-grid {
- display: grid;
- grid-template-columns: minmax(240px, 1fr) minmax(0, 3fr) minmax(240px, 1fr);
- gap: 12px;
- align-items: stretch;
- }
- .workbench-grid--equal {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
- .home-panel {
- min-width: 0;
- border: 1px solid #e1ebf3;
- border-radius: 8px;
- box-shadow: 0 5px 16px rgba(28, 75, 110, 0.05);
- }
- .map-panel {
- display: flex;
- flex-direction: column;
- min-height: 0;
- }
- .map-panel :deep(.el-card__body) {
- display: flex;
- flex-direction: column;
- flex: 1;
- min-height: 0;
- }
- .home-panel :deep(.el-card__header) {
- padding: 12px 16px;
- border-bottom: 1px solid #e1ebf3;
- background: #fbfdff;
- }
- .home-panel :deep(.el-card__body) {
- padding: 12px 14px;
- }
- .card-title {
- display: flex;
- align-items: center;
- gap: 8px;
- color: #1e293b;
- font-weight: 600;
- }
- .card-title :deep(.el-icon) {
- color: #2563eb;
- font-size: 18px;
- }
- .card-title .muted {
- margin-left: auto;
- font-size: 12px;
- font-weight: 400;
- }
- .panel-link {
- margin-left: auto;
- }
- .panel-links {
- display: flex;
- align-items: center;
- gap: 2px;
- margin-left: auto;
- }
- .panel-links .panel-link {
- margin-left: 0;
- }
- .legend-dot {
- display: inline-block;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- }
- .compact-list {
- display: flex;
- flex-direction: column;
- gap: 7px;
- min-height: 220px;
- max-height: 360px;
- overflow-y: auto;
- padding-right: 2px;
- }
- .compact-item {
- display: grid;
- grid-template-columns: 10px minmax(0, 1fr) auto;
- gap: 9px;
- align-items: center;
- width: 100%;
- padding: 9px 10px;
- text-align: left;
- background: #fff;
- border: 1px solid #eef2f7;
- border-radius: 8px;
- cursor: pointer;
- transition: all 0.15s ease;
- }
- .compact-item:hover {
- background: #f6fbff;
- border-color: #bfdbfe;
- }
- .compact-item.active {
- background: #eef6ff;
- border-color: #93c5fd;
- }
- .status-dot {
- width: 9px;
- height: 9px;
- border-radius: 50%;
- background: #94a3b8;
- }
- .dot-alarm { background: #dc2626; }
- .dot-offline { background: #94a3b8; }
- .dot-normal { background: #16a34a; }
- .dot-warning { background: #d97706; }
- .dot-danger { background: #dc2626; }
- .dot-primary { background: #2563eb; }
- .dot-success { background: #16a34a; }
- .dot-info { background: #94a3b8; }
- .dot-workorder { background: #2563eb; }
- .compact-main {
- display: block;
- min-width: 0;
- }
- .compact-main b,
- .compact-main small {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .compact-main b {
- color: #1e293b;
- font-size: 13px;
- font-weight: 600;
- }
- .compact-main small {
- margin-top: 2px;
- color: #94a3b8;
- font-size: 12px;
- }
- .compact-meta {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 3px;
- min-width: 0;
- }
- .compact-meta small {
- color: #94a3b8;
- font-size: 11px;
- white-space: nowrap;
- }
- .map-stage {
- position: relative;
- flex: 1;
- min-height: 0;
- height: auto;
- overflow: hidden;
- border: 1px solid #e1ebf3;
- border-radius: 8px;
- background: #eef6ff;
- }
- .map-stage :deep(.gis-map-canvas) {
- width: 100%;
- height: 100%;
- min-height: 0;
- }
- .map-inline-alert {
- position: absolute;
- left: 16px;
- bottom: 16px;
- z-index: 3;
- width: 340px;
- }
- .map-inline-empty {
- position: absolute;
- left: 50%;
- top: 50%;
- z-index: 2;
- transform: translate(-50%, -50%);
- }
- .map-legend {
- display: flex;
- align-items: center;
- gap: 7px;
- margin-left: auto;
- color: #64748b;
- font-size: 12px;
- }
- .overview-detail {
- min-height: 240px;
- }
- .detail-hero h3,
- .detail-section h4 {
- margin: 0 0 8px;
- color: #1c4b78;
- }
- .detail-hero p {
- margin: 0 0 10px;
- color: #678197;
- font-size: 13px;
- }
- .status-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- }
- .detail-section {
- margin-top: 18px;
- padding-top: 12px;
- border-top: 1px solid #e5eef7;
- }
- .detail-section dl {
- display: grid;
- grid-template-columns: 92px minmax(0, 1fr);
- gap: 7px 10px;
- margin: 0;
- font-size: 13px;
- }
- .detail-section dt {
- color: #71889c;
- }
- .detail-section dd {
- margin: 0;
- color: #2b4c67;
- overflow-wrap: anywhere;
- }
- .order-brief {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 6px;
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid #eef2f7;
- }
- .order-brief span {
- color: #64748b;
- font-size: 12px;
- }
- .order-brief b {
- margin-left: 3px;
- color: #17324d;
- font-size: 14px;
- }
- .device-summary {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- .detail-panel :deep(.el-card__body) {
- display: flex;
- flex-direction: column;
- }
- .detail-panel .device-summary {
- flex: 1;
- }
- .summary-top {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 10px;
- flex-wrap: wrap;
- }
- .summary-name {
- color: #17324d;
- font-size: 16px;
- font-weight: 700;
- }
- .summary-tags {
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 6px;
- }
- .detail-panel :deep(.el-descriptions__label) {
- width: 86px;
- color: #64748b;
- background: #f8fafc;
- white-space: nowrap;
- }
- .quick-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
- }
- .quick-entry {
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
- padding: 14px 12px;
- color: #1e293b;
- background: #fff;
- border: 1px solid #e1ebf3;
- border-radius: 8px;
- cursor: pointer;
- transition: all 0.15s ease;
- }
- .quick-entry:hover {
- background: #f6fbff;
- border-color: #bfdbfe;
- }
- .quick-entry :deep(.el-icon) {
- flex: 0 0 20px;
- color: #2563eb;
- font-size: 20px;
- }
- .quick-entry span {
- overflow: hidden;
- font-size: 13px;
- font-weight: 600;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .chart-grid {
- display: grid;
- grid-template-columns: 1.5fr 1fr 1fr;
- gap: 12px;
- }
- .chart-card {
- border: 1px solid #e1ebf3;
- border-radius: 8px;
- box-shadow: 0 5px 16px rgba(28, 75, 110, 0.05);
- }
- .chart-card .chart {
- height: 260px;
- }
- .chart-card :deep(.el-card__header) {
- padding: 12px 16px;
- border-bottom: 1px solid #e1ebf3;
- background: #fbfdff;
- }
- .chart-card :deep(.el-card__body) {
- padding: 10px 12px;
- }
- @media (max-width: 1200px) {
- .metric-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- .workbench-grid {
- grid-template-columns: 1fr;
- }
- .chart-grid {
- grid-template-columns: 1fr 1fr;
- }
- .trend-card {
- grid-column: 1 / -1;
- }
- }
- @media (max-width: 768px) {
- .page-heading,
- .heading-actions {
- align-items: stretch;
- flex-direction: column;
- }
- .metric-grid,
- .chart-grid {
- grid-template-columns: 1fr;
- }
- .trend-card {
- grid-column: auto;
- }
- .map-legend {
- display: none;
- }
- .panel-links {
- display: none;
- }
- .quick-grid {
- grid-template-columns: 1fr;
- }
- }
- </style>
|