|
|
@@ -1,17 +1,23 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { computed, ref } from 'vue'
|
|
|
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
|
|
|
+import { fetchIndicator, type IndicatorSide } from '../api/indicator'
|
|
|
+import { fetchRecentSeries } from '../api/series'
|
|
|
import { useAlarmsStore } from '../stores/alarms'
|
|
|
import { useChartsStore } from '../stores/charts'
|
|
|
+import type { ChartSeries } from '../types'
|
|
|
import { deviceLabel } from '../utils/device'
|
|
|
import { buildChartSeries } from '../utils/chart'
|
|
|
import { levelColor } from '../utils/level'
|
|
|
+import { toMs } from '../utils/time'
|
|
|
import FaultChart from './FaultChart.vue'
|
|
|
import FaultChartModal from './FaultChartModal.vue'
|
|
|
+import IndicatorChart from './IndicatorChart.vue'
|
|
|
|
|
|
const store = useAlarmsStore()
|
|
|
const charts = useChartsStore()
|
|
|
const detailOpen = ref(false)
|
|
|
+const activeTab = ref<'realtime' | 'fault'>('realtime')
|
|
|
|
|
|
const info = computed<Record<string, unknown>>(() => store.selected?.alarm_info ?? {})
|
|
|
const isVibration = computed(() => store.selected?.alarm_type === '振动故障')
|
|
|
@@ -71,50 +77,215 @@ const smallChart = computed(() => {
|
|
|
downsampleMax: 10000,
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+/* ---------------- 实时数据(数据库最后 6 小时,每分钟刷新) ---------------- */
|
|
|
+const RT_INTERVAL = 60000
|
|
|
+const rtMs = ref<number[]>([])
|
|
|
+const rtOil = ref<(number | null)[]>([])
|
|
|
+const rtCoupling = ref<(number | null)[]>([])
|
|
|
+const rtChain = ref<(number | null)[]>([])
|
|
|
+const rtLoading = ref(false)
|
|
|
+const rtError = ref('')
|
|
|
+let rtTimer: ReturnType<typeof setInterval> | undefined
|
|
|
+
|
|
|
+const RT_COLORS = { oil: '#4da3ff', coupling: '#ffb74d', chain: '#81c784' }
|
|
|
+
|
|
|
+function buildRt(
|
|
|
+ ms: number[],
|
|
|
+ values: (number | null)[],
|
|
|
+ name: string,
|
|
|
+ unit: string,
|
|
|
+ color: string,
|
|
|
+): ChartSeries {
|
|
|
+ const data = ms.map((t, i) => [t, values[i] ?? null] as [number, number | null])
|
|
|
+ return { name: `${name}(${unit})`, data, color }
|
|
|
+}
|
|
|
+
|
|
|
+const rtSeries = computed<ChartSeries[]>(() => [
|
|
|
+ buildRt(rtMs.value, rtOil.value, '润滑油压力', 'MPa', RT_COLORS.oil),
|
|
|
+ buildRt(rtMs.value, rtCoupling.value, '联轴器端', 'mm/s', RT_COLORS.coupling),
|
|
|
+ buildRt(rtMs.value, rtChain.value, '链轮端', 'mm/s', RT_COLORS.chain),
|
|
|
+])
|
|
|
+const rtXMin = computed(() => (rtMs.value.length ? rtMs.value[0] : 0))
|
|
|
+const rtXMax = computed(() => (rtMs.value.length ? rtMs.value[rtMs.value.length - 1] : 1))
|
|
|
+
|
|
|
+async function loadRealtime(): Promise<void> {
|
|
|
+ if (!store.deviceCode) return
|
|
|
+ rtLoading.value = true
|
|
|
+ rtError.value = ''
|
|
|
+ try {
|
|
|
+ const data = await fetchRecentSeries(store.deviceCode, 6)
|
|
|
+ rtMs.value = data.times.map(toMs)
|
|
|
+ rtOil.value = data.oil
|
|
|
+ rtCoupling.value = data.coupling
|
|
|
+ rtChain.value = data.chain
|
|
|
+ } catch (err) {
|
|
|
+ rtError.value = '实时数据加载失败:' + (err as Error).message
|
|
|
+ } finally {
|
|
|
+ rtLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ---------------- 示功图(P-V,按缸切换,随实时一起刷新) ---------------- */
|
|
|
+const cylinders = ['一', '二', '三', '四', '五', '六']
|
|
|
+const cylinder = ref('一')
|
|
|
+const indicator = ref<IndicatorSide[]>([])
|
|
|
+const indLoading = ref(false)
|
|
|
+const indError = ref('')
|
|
|
+
|
|
|
+async function loadIndicator(): Promise<void> {
|
|
|
+ if (!store.deviceCode) return
|
|
|
+ indLoading.value = true
|
|
|
+ indError.value = ''
|
|
|
+ try {
|
|
|
+ const data = await fetchIndicator(store.deviceCode, cylinder.value)
|
|
|
+ indicator.value = data.sides
|
|
|
+ } catch (err) {
|
|
|
+ indError.value = '功图加载失败:' + (err as Error).message
|
|
|
+ } finally {
|
|
|
+ indLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function selectCylinder(value: string): void {
|
|
|
+ if (value === cylinder.value) return
|
|
|
+ cylinder.value = value
|
|
|
+ loadIndicator()
|
|
|
+}
|
|
|
+
|
|
|
+function stopRealtime(): void {
|
|
|
+ if (rtTimer) {
|
|
|
+ clearInterval(rtTimer)
|
|
|
+ rtTimer = undefined
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function refreshRealtime(): void {
|
|
|
+ loadRealtime()
|
|
|
+ loadIndicator()
|
|
|
+}
|
|
|
+
|
|
|
+function startRealtime(): void {
|
|
|
+ stopRealtime()
|
|
|
+ refreshRealtime()
|
|
|
+ rtTimer = setInterval(refreshRealtime, RT_INTERVAL)
|
|
|
+}
|
|
|
+
|
|
|
+watch(() => store.deviceCode, () => startRealtime())
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => store.selected?.id,
|
|
|
+ (id) => {
|
|
|
+ activeTab.value = id ? 'fault' : 'realtime'
|
|
|
+ },
|
|
|
+)
|
|
|
+
|
|
|
+onMounted(startRealtime)
|
|
|
+
|
|
|
+onBeforeUnmount(stopRealtime)
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
- <section v-if="store.selected" class="glass card detail-card">
|
|
|
- <div class="detail-head">
|
|
|
- <h3 class="card-title">故障详情</h3>
|
|
|
- <span class="stage" :style="{ background: stageColor }">{{ stage }}</span>
|
|
|
- </div>
|
|
|
- <div class="detail">
|
|
|
- <div class="pair">
|
|
|
- <div class="cell"><span class="label">机组:</span><span class="value">{{ machine }}</span></div>
|
|
|
- <div class="cell"><span class="label">有效运行小时数:</span><span class="value">{{ hours }}</span></div>
|
|
|
- </div>
|
|
|
- <div class="dline"><span class="label">时间:</span><span class="value wrap">{{ timeRange }}</span></div>
|
|
|
- <div class="pair">
|
|
|
- <div class="cell"><span class="label">{{ baselineLabel }}:</span><span class="value">{{ baseline }}</span></div>
|
|
|
- <div class="cell"><span class="label">最大连续:</span><span class="value">{{ consecutive }}</span></div>
|
|
|
- </div>
|
|
|
- <div class="dline"><span class="label">触发依据:</span><span class="value wrap">{{ basis }}</span></div>
|
|
|
- <div class="dline"><span class="label">模型预测:</span><span class="value wrap">{{ modelPrediction }}</span></div>
|
|
|
- </div>
|
|
|
+ <section class="glass card detail-card">
|
|
|
+ <a-tabs v-model:activeKey="activeTab" size="small" class="detail-tabs">
|
|
|
+ <a-tab-pane key="realtime" tab="实时数据">
|
|
|
+ <div class="rt-wrap">
|
|
|
+ <FaultChart
|
|
|
+ :series="rtSeries"
|
|
|
+ :fault-start="0"
|
|
|
+ :fault-end="0"
|
|
|
+ :x-min="rtXMin"
|
|
|
+ :x-max="rtXMax"
|
|
|
+ :grid-height="90"
|
|
|
+ :ref-lines="[]"
|
|
|
+ :show-y-axis="true"
|
|
|
+ :dark="true"
|
|
|
+ :per-series-legend="true"
|
|
|
+ :inside-zoom="true"
|
|
|
+ :grid-divider="true"
|
|
|
+ />
|
|
|
+ <div v-if="rtLoading" class="rt-loading">
|
|
|
+ <a-spin size="small" />
|
|
|
+ <span class="rt-loading-text">加载中</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="rtError" class="rt-error">{{ rtError }}</div>
|
|
|
|
|
|
- <div class="chart-block">
|
|
|
- <div v-if="charts.loading" class="chart-loading">
|
|
|
- <a-spin size="small" />
|
|
|
- <span class="chart-loading-text">加载中</span>
|
|
|
- </div>
|
|
|
- <div v-else-if="charts.error" class="chart-error">{{ charts.error }}</div>
|
|
|
- <FaultChart
|
|
|
- v-else-if="smallChart"
|
|
|
- :series="smallChart.series"
|
|
|
- :fault-start="charts.faultStart"
|
|
|
- :fault-end="charts.faultEnd"
|
|
|
- :height="isVibration ? 190 : 110"
|
|
|
- :y-min="smallChart.yMin"
|
|
|
- :y-max="smallChart.yMax"
|
|
|
- :unit="smallChart.unit"
|
|
|
- :ref-lines="smallChart.refLines"
|
|
|
- :show-y-axis="false"
|
|
|
- :dark="true"
|
|
|
- />
|
|
|
- </div>
|
|
|
+ <div class="indicator-block">
|
|
|
+ <div class="cyl-tabs">
|
|
|
+ <span
|
|
|
+ v-for="item in cylinders"
|
|
|
+ :key="item"
|
|
|
+ class="cyl"
|
|
|
+ :class="{ active: item === cylinder }"
|
|
|
+ @click="selectCylinder(item)"
|
|
|
+ >{{ item }}缸</span>
|
|
|
+ </div>
|
|
|
+ <div class="ind-wrap">
|
|
|
+ <template v-if="indicator.length">
|
|
|
+ <div v-for="side in indicator" :key="side.side" class="ind-item">
|
|
|
+ <div class="ind-title">
|
|
|
+ {{ side.side }} · {{ side.sample_time?.slice(0, 16) }} · {{ side.rpm?.toFixed(0) }} rpm
|
|
|
+ </div>
|
|
|
+ <IndicatorChart
|
|
|
+ :pressure="side.pressure"
|
|
|
+ :volume="side.volume"
|
|
|
+ :angle="side.angle"
|
|
|
+ :height="170"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <div v-else-if="!indLoading" class="ind-empty">无运行功图</div>
|
|
|
+ <div v-if="indLoading" class="ind-loading">
|
|
|
+ <a-spin size="small" />
|
|
|
+ <span class="rt-loading-text">加载中</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="indError" class="rt-error">{{ indError }}</div>
|
|
|
+ </div>
|
|
|
+ </a-tab-pane>
|
|
|
|
|
|
- <div class="card-foot">
|
|
|
+ <a-tab-pane v-if="store.selected" key="fault" tab="故障详情">
|
|
|
+ <div class="detail">
|
|
|
+ <div class="pair">
|
|
|
+ <div class="cell"><span class="label">机组:</span><span class="value">{{ machine }}</span></div>
|
|
|
+ <div class="cell"><span class="label">有效运行小时数:</span><span class="value">{{ hours }}</span></div>
|
|
|
+ </div>
|
|
|
+ <div class="dline"><span class="label">时间:</span><span class="value wrap">{{ timeRange }}</span></div>
|
|
|
+ <div class="pair">
|
|
|
+ <div class="cell"><span class="label">{{ baselineLabel }}:</span><span class="value">{{ baseline }}</span></div>
|
|
|
+ <div class="cell"><span class="label">最大连续:</span><span class="value">{{ consecutive }}</span></div>
|
|
|
+ </div>
|
|
|
+ <div class="dline"><span class="label">触发依据:</span><span class="value wrap">{{ basis }}</span></div>
|
|
|
+ <div class="dline"><span class="label">模型预测:</span><span class="value wrap">{{ modelPrediction }}</span></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="chart-block">
|
|
|
+ <div v-if="charts.loading" class="chart-loading">
|
|
|
+ <a-spin size="small" />
|
|
|
+ <span class="chart-loading-text">加载中</span>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="charts.error" class="chart-error">{{ charts.error }}</div>
|
|
|
+ <FaultChart
|
|
|
+ v-else-if="smallChart"
|
|
|
+ :series="smallChart.series"
|
|
|
+ :fault-start="charts.faultStart"
|
|
|
+ :fault-end="charts.faultEnd"
|
|
|
+ :height="isVibration ? 190 : 110"
|
|
|
+ :y-min="smallChart.yMin"
|
|
|
+ :y-max="smallChart.yMax"
|
|
|
+ :unit="smallChart.unit"
|
|
|
+ :ref-lines="smallChart.refLines"
|
|
|
+ :show-y-axis="false"
|
|
|
+ :dark="true"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </a-tab-pane>
|
|
|
+ </a-tabs>
|
|
|
+
|
|
|
+ <div v-if="store.selected && activeTab === 'fault'" class="card-foot">
|
|
|
+ <span class="stage" :style="{ background: stageColor }">{{ stage }}</span>
|
|
|
+ <span class="foot-spacer" />
|
|
|
<a-button type="link" size="small" @click="detailOpen = true">详细内容</a-button>
|
|
|
<a-button type="link" size="small" @click="store.clearSelected()">关闭</a-button>
|
|
|
</div>
|
|
|
@@ -128,19 +299,6 @@ const smallChart = computed(() => {
|
|
|
pointer-events: auto;
|
|
|
padding: 8px 10px;
|
|
|
}
|
|
|
-.detail-head {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: space-between;
|
|
|
- margin-bottom: 4px;
|
|
|
-}
|
|
|
-.card-title {
|
|
|
- font-size: 14px;
|
|
|
- color: #fff;
|
|
|
- font-weight: 600;
|
|
|
- letter-spacing: 0.5px;
|
|
|
- margin: 0;
|
|
|
-}
|
|
|
.stage {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
@@ -152,6 +310,13 @@ const smallChart = computed(() => {
|
|
|
padding: 0 8px;
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
+.detail-tabs :deep(.ant-tabs-nav) {
|
|
|
+ margin-bottom: 6px;
|
|
|
+}
|
|
|
+.detail-tabs :deep(.ant-tabs-tab) {
|
|
|
+ font-size: 13px;
|
|
|
+ padding: 4px 0;
|
|
|
+}
|
|
|
.detail {
|
|
|
font-size: 12px;
|
|
|
}
|
|
|
@@ -205,14 +370,98 @@ const smallChart = computed(() => {
|
|
|
font-size: 12px;
|
|
|
padding: 8px 0;
|
|
|
}
|
|
|
+.rt-wrap {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+.rt-loading {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 6px;
|
|
|
+ background: rgba(0, 0, 0, 0.35);
|
|
|
+ border-radius: 6px;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+.rt-loading-text {
|
|
|
+ color: #ffffff;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.rt-loading :deep(.ant-spin-dot) {
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.rt-error {
|
|
|
+ color: #ff4d4f;
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 8px 0;
|
|
|
+}
|
|
|
+.indicator-block {
|
|
|
+ margin-top: 8px;
|
|
|
+ padding-top: 6px;
|
|
|
+ border-top: 1px solid #333a45;
|
|
|
+}
|
|
|
+.cyl-tabs {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+.cyl {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #8a929c;
|
|
|
+ cursor: pointer;
|
|
|
+ padding-bottom: 3px;
|
|
|
+ border-bottom: 2px solid transparent;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+.cyl:hover {
|
|
|
+ color: #c3c9d1;
|
|
|
+}
|
|
|
+.cyl.active {
|
|
|
+ color: #ffffff;
|
|
|
+ border-bottom-color: #ffffff;
|
|
|
+}
|
|
|
+.ind-wrap {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+.ind-item {
|
|
|
+ margin-bottom: 6px;
|
|
|
+}
|
|
|
+.ind-title {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #aaaaaa;
|
|
|
+ line-height: 16px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.ind-empty {
|
|
|
+ color: #aaaaaa;
|
|
|
+ font-size: 12px;
|
|
|
+ text-align: center;
|
|
|
+ padding: 24px 0;
|
|
|
+}
|
|
|
+.ind-loading {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 6px;
|
|
|
+ background: rgba(0, 0, 0, 0.35);
|
|
|
+ border-radius: 6px;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
.card-foot {
|
|
|
display: flex;
|
|
|
- justify-content: flex-end;
|
|
|
+ align-items: center;
|
|
|
gap: 8px;
|
|
|
margin-top: 4px;
|
|
|
padding-top: 4px;
|
|
|
border-top: 1px solid #333a45;
|
|
|
}
|
|
|
+.foot-spacer {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
.card-foot :deep(.ant-btn) {
|
|
|
font-size: 12px;
|
|
|
}
|