| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469 |
- <script setup lang="ts">
- 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 === '振动故障')
- function pick(...keys: string[]): string {
- for (const key of keys) {
- const value = info.value[key]
- if (value !== undefined && value !== null && value !== '') return String(value)
- }
- return '—'
- }
- function num2(value: string): string {
- const n = Number(value)
- return Number.isFinite(n) ? n.toFixed(2) : value
- }
- const stage = computed(() => (isVibration.value ? pick('振动阶段') : pick('压力阶段')))
- const stageColor = computed(() => levelColor(store.selected?.alarm_level ?? null))
- const machine = computed(
- () => String(info.value['机组'] ?? deviceLabel(store.selected?.device_code ?? '')),
- )
- const hours = computed(() => num2(pick('有效运行小时数')))
- const timeRange = computed(() => {
- const start = pick('开始小时')
- const end = pick('结束小时')
- if (start !== '—' || end !== '—') {
- return `${start === '—' ? start : start.slice(0, 16)}~${end === '—' ? end : end.slice(0, 16)}`
- }
- const row = store.selected
- return row ? `${row.alarm_time_start.slice(0, 16)}~${row.alarm_time_end.slice(0, 16)}` : '—'
- })
- const baselineLabel = computed(() => (isVibration.value ? '近期基线' : '周期基线'))
- const baseline = computed(() =>
- num2(isVibration.value ? pick('近期基线振动') : pick('周期基线油压')),
- )
- const consecutive = computed(() =>
- num2(isVibration.value ? pick('阶段最大连续异常有效小时数') : pick('阶段最大连续下降有效小时数')),
- )
- const basis = computed(() => pick('阶段触发依据'))
- const modelPrediction = computed(() => {
- const raw = pick('模型预测参考')
- return raw === '—' ? raw : raw.replace(/^模型预测参考:?/, '')
- })
- const smallChart = computed(() => {
- if (!charts.loaded || !store.selected) return null
- return buildChartSeries({
- ms: charts.ms,
- oil: charts.oil,
- coupling: charts.coupling,
- chain: charts.chain,
- isVibration: isVibration.value,
- mode: 'relative',
- baselineOil: Number(info.value['周期基线油压']),
- baselineVib: Number(info.value['近期基线振动']),
- 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 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"
- :freeze-y="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="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>
- <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>
- <FaultChartModal v-model:open="detailOpen" />
- </section>
- </template>
- <style scoped>
- .detail-card {
- pointer-events: auto;
- padding: 8px 10px;
- }
- .stage {
- display: inline-flex;
- align-items: center;
- height: 18px;
- line-height: 18px;
- font-size: 12px;
- color: #fff;
- border-radius: 3px;
- 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;
- }
- .pair {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
- }
- .cell,
- .dline {
- display: flex;
- min-width: 0;
- line-height: 20px;
- }
- .label {
- color: #aaaaaa;
- flex: 0 0 auto;
- }
- .value {
- color: #fff;
- min-width: 0;
- }
- .cell .value {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .dline .value.wrap {
- word-break: break-all;
- }
- .chart-block {
- margin-top: 6px;
- padding: 4px 0;
- }
- .chart-loading {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 12px 4px 6px;
- }
- .chart-loading-text {
- color: #aaaaaa;
- font-size: 12px;
- }
- .chart-loading :deep(.ant-spin-dot) {
- font-size: 12px;
- }
- .chart-error {
- color: #ff4d4f;
- 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;
- 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;
- }
- </style>
|