Explorar el Código

3d:故障曲线(详情卡内嵌相对曲线+min/max降采样;白底弹窗含相对/原值切换与dataZoom);告警曲线接口(running-only、0值断开、±2h、故障区间+1h);取消部件悬停提示

18922397810 hace 2 semanas
padre
commit
35f4ae3a71

+ 2 - 1
3d/backend/main.py

@@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import FileResponse
 
 from config import FRONTEND_DIST
-from routers import alarms, files, scenes, state
+from routers import alarms, files, scenes, series, state
 
 app = FastAPI(title="压缩机 3D 部件查看服务", version="0.1.0")
 app.add_middleware(
@@ -26,6 +26,7 @@ app.add_middleware(
 app.include_router(alarms.router)
 app.include_router(files.router)
 app.include_router(scenes.router)
+app.include_router(series.router)
 app.include_router(state.router)
 
 

+ 18 - 0
3d/backend/routers/series.py

@@ -0,0 +1,18 @@
+"""PKS 原始采样曲线。"""
+
+from typing import Any
+
+from fastapi import APIRouter, Query
+
+from services import series_service
+
+router = APIRouter()
+
+
+@router.get("/api/pks-series")
+def pks_series(
+    device_code: str = Query(...),
+    start: str = Query(...),
+    end: str = Query(...),
+) -> dict[str, Any]:
+    return series_service.query_series(device_code, start, end)

+ 71 - 0
3d/backend/services/series_service.py

@@ -0,0 +1,71 @@
+"""PKS 原始采样曲线:按设备与时间窗口读取 YSJ_5 / YSJ_10 / YSJ_11。
+
+只取运行样本(``YSJ_41 > 0``);停机造成的长时间空档插入空点,让曲线断开。
+"""
+
+from datetime import timedelta
+from typing import Any
+
+from db import connect
+
+GAP = timedelta(seconds=60)
+
+
+def _num(value: Any) -> float | None:
+    """0(传感器掉点/停机残留)视为无效,置空以断开曲线。"""
+    try:
+        number = float(value) if value is not None else None
+    except (TypeError, ValueError):
+        return None
+    if number is None or number <= 0:
+        return None
+    return number
+
+
+def query_series(device_code: str, start: str, end: str) -> dict[str, Any]:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT sample_time, YSJ_5, YSJ_10, YSJ_11
+                FROM pks_long_sample
+                WHERE device_code = %s AND sample_time >= %s AND sample_time <= %s
+                  AND YSJ_41 > 0
+                ORDER BY sample_time
+                """,
+                (device_code, start, end),
+            )
+            rows = cursor.fetchall()
+    finally:
+        connection.close()
+
+    times: list[str] = []
+    oil: list[float | None] = []
+    coupling: list[float | None] = []
+    chain: list[float | None] = []
+    previous = None
+    for row in rows:
+        moment = row["sample_time"]
+        if previous is not None and (moment - previous) > GAP:
+            middle = previous + (moment - previous) / 2
+            times.append(middle.strftime("%Y-%m-%d %H:%M:%S"))
+            oil.append(None)
+            coupling.append(None)
+            chain.append(None)
+        times.append(moment.strftime("%Y-%m-%d %H:%M:%S"))
+        oil.append(_num(row["YSJ_5"]))
+        coupling.append(_num(row["YSJ_10"]))
+        chain.append(_num(row["YSJ_11"]))
+        previous = moment
+
+    return {
+        "device_code": device_code,
+        "start": start,
+        "end": end,
+        "count": len(times),
+        "times": times,
+        "oil": oil,
+        "coupling": coupling,
+        "chain": chain,
+    }

+ 23 - 0
3d/frontend/package-lock.json

@@ -10,6 +10,7 @@
       "dependencies": {
         "ant-design-vue": "^4.2.6",
         "axios": "^1.7.9",
+        "echarts": "^5.6.0",
         "pinia": "^2.3.0",
         "three": "^0.186.0",
         "vue": "^3.5.13"
@@ -1298,6 +1299,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
+      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
     "node_modules/entities": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -1885,6 +1895,11 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+    },
     "node_modules/typescript": {
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2060,6 +2075,14 @@
       "dependencies": {
         "loose-envify": "^1.0.0"
       }
+    },
+    "node_modules/zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
+      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
     }
   }
 }

+ 1 - 0
3d/frontend/package.json

@@ -11,6 +11,7 @@
   "dependencies": {
     "ant-design-vue": "^4.2.6",
     "axios": "^1.7.9",
+    "echarts": "^5.6.0",
     "pinia": "^2.3.0",
     "three": "^0.186.0",
     "vue": "^3.5.13"

+ 30 - 0
3d/frontend/src/api/series.ts

@@ -0,0 +1,30 @@
+import { http } from './http'
+
+export interface PksSeries {
+  device_code: string
+  start: string
+  end: string
+  count: number
+  times: string[]
+  oil: (number | null)[]
+  coupling: (number | null)[]
+  chain: (number | null)[]
+}
+
+export async function fetchPksSeries(
+  deviceCode: string,
+  start: string,
+  end: string,
+  onProgress?: (percent: number) => void,
+): Promise<PksSeries> {
+  const res = await http.get<PksSeries>('/api/pks-series', {
+    params: { device_code: deviceCode, start, end },
+    onDownloadProgress: (event) => {
+      if (onProgress && event.total) {
+        onProgress(Math.round((event.loaded / event.total) * 100))
+      }
+    },
+  })
+  onProgress?.(100)
+  return res.data
+}

+ 83 - 8
3d/frontend/src/components/AlarmDetailCard.vue

@@ -1,11 +1,18 @@
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, ref } from 'vue'
 
 import { useAlarmsStore } from '../stores/alarms'
+import { useChartsStore } from '../stores/charts'
 import { deviceLabel } from '../utils/device'
+import { buildChartSeries } from '../utils/chart'
 import { levelColor } from '../utils/level'
+import FaultChart from './FaultChart.vue'
+import FaultChartModal from './FaultChartModal.vue'
 
 const store = useAlarmsStore()
+const charts = useChartsStore()
+const detailOpen = ref(false)
+
 const info = computed<Record<string, unknown>>(() => store.selected?.alarm_info ?? {})
 const isVibration = computed(() => store.selected?.alarm_type === '振动故障')
 
@@ -17,8 +24,9 @@ function pick(...keys: string[]): string {
   return '—'
 }
 
-function fmt(dt: string): string {
-  return dt ? dt.replace('T', ' ').slice(0, 16) : ''
+function num2(value: string): string {
+  const n = Number(value)
+  return Number.isFinite(n) ? n.toFixed(2) : value
 }
 
 const stage = computed(() => (isVibration.value ? pick('振动阶段') : pick('压力阶段')))
@@ -26,22 +34,39 @@ const stageColor = computed(() => levelColor(store.selected?.alarm_level ?? null
 const machine = computed(
   () => String(info.value['机组'] ?? deviceLabel(store.selected?.device_code ?? '')),
 )
-const hours = computed(() => pick('有效运行小时数'))
+const hours = computed(() => num2(pick('有效运行小时数')))
 const timeRange = computed(() => {
   const start = pick('开始小时')
   const end = pick('结束小时')
   if (start !== '—' || end !== '—') {
-    return `${start === '—' ? start : fmt(start)}~${end === '—' ? end : fmt(end)}`
+    return `${start === '—' ? start : start.slice(0, 16)}~${end === '—' ? end : end.slice(0, 16)}`
   }
   const row = store.selected
-  return row ? `${fmt(row.alarm_time_start)}~${fmt(row.alarm_time_end)}` : '—'
+  return row ? `${row.alarm_time_start.slice(0, 16)}~${row.alarm_time_end.slice(0, 16)}` : '—'
 })
 const baselineLabel = computed(() => (isVibration.value ? '近期基线' : '周期基线'))
-const baseline = computed(() => (isVibration.value ? pick('近期基线振动') : pick('周期基线油压')))
+const baseline = computed(() =>
+  num2(isVibration.value ? pick('近期基线振动') : pick('周期基线油压')),
+)
 const consecutive = computed(() =>
-  isVibration.value ? pick('阶段最大连续异常有效小时数') : pick('阶段最大连续下降有效小时数'),
+  num2(isVibration.value ? pick('阶段最大连续异常有效小时数') : pick('阶段最大连续下降有效小时数')),
 )
 const basis = computed(() => pick('阶段触发依据'))
+
+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,
+  })
+})
 </script>
 
 <template>
@@ -63,6 +88,33 @@ const basis = computed(() => pick('阶段触发依据'))
       <div class="dline"><span class="label">触发依据:</span><span class="value wrap">{{ basis }}</span></div>
       <div class="dline"><span class="label">模型预测:</span><span class="value">—</span></div>
     </div>
+
+    <div class="chart-block">
+      <div v-if="charts.loading" class="chart-loading">
+        <a-progress :percent="charts.progress" size="small" stroke-color="#4da3ff" />
+      </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="card-foot">
+      <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>
 
@@ -125,4 +177,27 @@ const basis = computed(() => pick('阶段触发依据'))
 .dline .value.wrap {
   word-break: break-all;
 }
+.chart-block {
+  margin-top: 6px;
+  padding: 4px 0;
+}
+.chart-loading {
+  padding: 12px 4px 6px;
+}
+.chart-error {
+  color: #ff4d4f;
+  font-size: 12px;
+  padding: 8px 0;
+}
+.card-foot {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  margin-top: 4px;
+  padding-top: 4px;
+  border-top: 1px solid #333a45;
+}
+.card-foot :deep(.ant-btn) {
+  font-size: 12px;
+}
 </style>

+ 254 - 0
3d/frontend/src/components/FaultChart.vue

@@ -0,0 +1,254 @@
+<script setup lang="ts">
+import * as echarts from 'echarts'
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
+
+import type { ChartSeries } from '../types'
+
+interface RefLine {
+  value: number
+  label?: string
+}
+
+const props = withDefaults(
+  defineProps<{
+    series: ChartSeries[]
+    faultStart: number
+    faultEnd: number
+    height?: number
+    yMin: number
+    yMax: number
+    unit?: string
+    refLines?: RefLine[]
+    showYAxis?: boolean
+    dark?: boolean
+    zoom?: boolean
+  }>(),
+  { height: 180, unit: '', refLines: () => [], showYAxis: true, dark: true, zoom: false },
+)
+
+const el = ref<HTMLElement | null>(null)
+let chart: echarts.ECharts | null = null
+let observer: ResizeObserver | null = null
+
+const BLUE = '#4da3ff'
+const RED = '#E53935'
+const HOUR = 3600 * 1000
+const PAD = 2 * HOUR
+
+function pad2(value: number): string {
+  return String(value).padStart(2, '0')
+}
+
+function buildDataZoom(
+  indexes: number[],
+  sliderH: number,
+  axisColor: string,
+): Record<string, unknown>[] {
+  const startValue = props.faultStart - PAD
+  const endValue = props.faultEnd + PAD
+  return [
+    { type: 'inside', xAxisIndex: indexes, startValue, endValue },
+    {
+      type: 'slider',
+      xAxisIndex: indexes,
+      startValue,
+      endValue,
+      bottom: 4,
+      height: sliderH,
+      borderColor: props.dark ? '#333a45' : '#dddddd',
+      fillerColor: 'rgba(77,163,255,0.2)',
+      handleStyle: { color: BLUE },
+      textStyle: { color: axisColor, fontSize: 9 },
+      showDetail: false,
+    },
+  ]
+}
+
+function buildOption(): Record<string, unknown> {
+  const n = props.series.length
+  const totalH = props.height
+  const hasLegend = n > 1
+  const legendH = hasLegend ? 18 : 0
+  const xLabelH = 18
+  const sliderH = 16
+  const top = legendH + 6
+  const bottom = props.zoom ? sliderH + xLabelH + 10 : xLabelH + 8
+  const gap = 8
+  const gridH = Math.max(24, (totalH - top - bottom - gap * (n - 1)) / n)
+  const indexes = props.series.map((_, i) => i)
+
+  const axisColor = props.dark ? '#aaaaaa' : '#666666'
+  const lineColor = props.dark ? '#555555' : '#cccccc'
+  const legendColor = props.dark ? '#d6dbe2' : '#333333'
+  const refColor = props.dark ? 'rgba(170,170,170,0.7)' : 'rgba(120,120,120,0.8)'
+  const refText = props.dark ? '#aaaaaa' : '#666666'
+  const tooltipBg = props.dark ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.95)'
+  const tooltipText = props.dark ? '#ffffff' : '#333333'
+  const faultLine = props.dark ? '#ff5252' : RED
+  const faultFill = props.dark ? 'rgba(229,57,53,0.22)' : 'rgba(229,57,53,0.10)'
+  const faultBorder = props.dark ? 'rgba(229,57,53,0.55)' : 'transparent'
+
+  const markLines = props.refLines.map((line) => ({
+    yAxis: line.value,
+    lineStyle: { color: refColor, type: 'dashed', width: 1 },
+    label: line.label
+      ? { formatter: line.label, position: 'insideEndTop', color: refText, fontSize: 9 }
+      : { show: false },
+  }))
+
+  const grids: Record<string, unknown>[] = []
+  const xAxis: Record<string, unknown>[] = []
+  const yAxis: Record<string, unknown>[] = []
+  const series: Record<string, unknown>[] = []
+
+  props.series.forEach((s, i) => {
+    const last = i === n - 1
+    grids.push({
+      left: 4,
+      right: 8,
+      top: top + i * (gridH + gap),
+      height: gridH,
+      containLabel: props.showYAxis,
+    })
+    xAxis.push({
+      type: 'time',
+      gridIndex: i,
+      min: props.faultStart - PAD,
+      max: props.faultEnd + PAD,
+      axisLine: { show: last, lineStyle: { color: lineColor } },
+      axisTick: { show: last },
+      axisLabel: { show: last, fontSize: 9, color: axisColor, hideOverlap: false, interval: 'auto' },
+      splitLine: { show: false },
+      axisPointer: { label: { show: false } },
+    })
+    yAxis.push({
+      type: 'value',
+      gridIndex: i,
+      show: props.showYAxis,
+      min: props.yMin,
+      max: props.yMax,
+      splitNumber: 4,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        fontSize: 9,
+        color: axisColor,
+        formatter: (value: number) => `${Number(value).toFixed(2)}${props.unit}`,
+      },
+      splitLine: { show: false },
+    })
+    series.push({
+      name: s.name,
+      type: 'line',
+      xAxisIndex: i,
+      yAxisIndex: i,
+      data: s.data,
+      showSymbol: false,
+      symbol: 'none',
+      animation: false,
+      sampling: 'none',
+      lineStyle: { width: props.dark ? 1.5 : 1 },
+      emphasis: { disabled: true },
+      markLine: { silent: true, symbol: 'none', data: markLines },
+      markArea: {
+        silent: true,
+        itemStyle: { color: faultFill, borderColor: faultBorder, borderWidth: 1 },
+        data: [[{ xAxis: props.faultStart }, { xAxis: props.faultEnd }]],
+      },
+    })
+  })
+
+  return {
+    animation: false,
+    backgroundColor: 'transparent',
+    textStyle: {
+      fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
+      fontWeight: 'normal',
+    },
+    legend: hasLegend
+      ? {
+          top: 0,
+          left: 'center',
+          itemWidth: 12,
+          itemHeight: 8,
+          textStyle: { fontSize: 10, color: legendColor },
+          data: props.series.map((s) => s.name),
+        }
+      : undefined,
+    grid: grids,
+    xAxis,
+    yAxis,
+    series,
+    visualMap: {
+      show: false,
+      dimension: 0,
+      seriesIndex: indexes,
+      pieces: [
+        { lt: props.faultStart, color: BLUE },
+        { gte: props.faultStart, lte: props.faultEnd, color: faultLine },
+        { gt: props.faultEnd, color: BLUE },
+      ],
+      outOfRange: { color: BLUE },
+    },
+    axisPointer: { link: [{ xAxisIndex: 'all' }] },
+    dataZoom: props.zoom ? buildDataZoom(indexes, sliderH, axisColor) : undefined,
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'line' },
+      backgroundColor: tooltipBg,
+      textStyle: { fontSize: 11, color: tooltipText },
+      formatter: (params: unknown) => {
+        const items = Array.isArray(params) ? params : [params]
+        if (!items.length) return ''
+        const first = items[0] as { axisValue: number }
+        const date = new Date(first.axisValue)
+        const head =
+          `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
+          `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
+        const lines = items.map((raw) => {
+          const item = raw as { marker: string; seriesName: string; value: [number, number | null] }
+          const value = item.value?.[1]
+          const text = value === null || value === undefined ? '-' : Number(value).toFixed(2)
+          return `${item.marker}${item.seriesName}: ${text}${props.unit}`
+        })
+        return [head, ...lines].join('<br/>')
+      },
+    },
+  }
+}
+
+function render(): void {
+  if (!chart) return
+  chart.setOption(buildOption() as unknown as echarts.EChartsOption, { notMerge: true })
+}
+
+onMounted(() => {
+  if (!el.value) return
+  chart = echarts.init(el.value)
+  render()
+  observer = new ResizeObserver(() => {
+    chart?.resize()
+    render()
+  })
+  observer.observe(el.value)
+})
+
+onBeforeUnmount(() => {
+  observer?.disconnect()
+  observer = null
+  chart?.dispose()
+  chart = null
+})
+
+watch(() => [props.series, props.yMin, props.yMax, props.refLines], render, { deep: true })
+</script>
+
+<template>
+  <div ref="el" class="fault-chart" :style="{ height: `${height}px` }" />
+</template>
+
+<style scoped>
+.fault-chart {
+  width: 100%;
+}
+</style>

+ 100 - 0
3d/frontend/src/components/FaultChartModal.vue

@@ -0,0 +1,100 @@
+<script setup lang="ts">
+import { theme } from 'ant-design-vue'
+import { computed, ref } from 'vue'
+
+import { useAlarmsStore } from '../stores/alarms'
+import { useChartsStore } from '../stores/charts'
+import { buildChartSeries } from '../utils/chart'
+import FaultChart from './FaultChart.vue'
+
+defineProps<{ open: boolean }>()
+const emit = defineEmits<{ (event: 'update:open', value: boolean): void }>()
+
+const alarms = useAlarmsStore()
+const charts = useChartsStore()
+const mode = ref<'relative' | 'absolute'>('relative')
+
+const isVibration = computed(() => alarms.selected?.alarm_type === '振动故障')
+const info = computed<Record<string, unknown>>(() => alarms.selected?.alarm_info ?? {})
+
+const built = computed(() => {
+  if (!charts.loaded || !alarms.selected) return null
+  return buildChartSeries({
+    ms: charts.ms,
+    oil: charts.oil,
+    coupling: charts.coupling,
+    chain: charts.chain,
+    isVibration: isVibration.value,
+    mode: mode.value,
+    baselineOil: Number(info.value['周期基线油压']),
+    baselineVib: Number(info.value['近期基线振动']),
+  })
+})
+
+function close(): void {
+  emit('update:open', false)
+}
+</script>
+
+<template>
+  <a-modal
+    :open="open"
+    wrap-class-name="fault-chart-modal"
+    :footer="null"
+    width="900px"
+    title="故障曲线"
+    @cancel="close"
+  >
+    <a-config-provider :theme="{ algorithm: theme.defaultAlgorithm }">
+      <div class="modal-head">
+        <a-radio-group v-model:value="mode" size="small" button-style="solid">
+          <a-radio-button value="relative">相对基线</a-radio-button>
+          <a-radio-button value="absolute">原值</a-radio-button>
+        </a-radio-group>
+      </div>
+      <div v-if="charts.loading" class="modal-loading">
+        <div class="modal-loading-text">曲线数据加载中…</div>
+        <a-progress :percent="charts.progress" size="small" stroke-color="#4da3ff" />
+      </div>
+      <div v-else-if="charts.error" class="modal-error">{{ charts.error }}</div>
+      <FaultChart
+        v-else-if="built"
+        :series="built.series"
+        :fault-start="charts.faultStart"
+        :fault-end="charts.faultEnd"
+        :height="isVibration ? 520 : 360"
+        :y-min="built.yMin"
+        :y-max="built.yMax"
+        :unit="built.unit"
+        :ref-lines="built.refLines"
+        :show-y-axis="true"
+        :dark="false"
+        :zoom="true"
+      />
+    </a-config-provider>
+  </a-modal>
+</template>
+
+<style scoped>
+.modal-head {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: 8px;
+}
+.modal-head :deep(.ant-radio-button-wrapper) {
+  font-size: 12px;
+}
+.modal-loading {
+  padding: 40px 4px;
+}
+.modal-loading-text {
+  color: #666666;
+  font-size: 12px;
+  margin-bottom: 8px;
+}
+.modal-error {
+  color: #ff4d4f;
+  font-size: 12px;
+  padding: 30px 0;
+}
+</style>

+ 2 - 28
3d/frontend/src/components/ViewerCanvas.vue

@@ -1,34 +1,19 @@
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { ref } from 'vue'
 
 import { useThreeViewer } from '../composables/useThreeViewer'
 import { useModelStore } from '../stores/model'
-import { useViewerStore } from '../stores/viewer'
 
 const container = ref<HTMLElement | null>(null)
 const modelStore = useModelStore()
-const viewerStore = useViewerStore()
 
 useThreeViewer(container)
-
-const tooltipStyle = computed(() => ({
-  left: `${viewerStore.tooltip.x + 14}px`,
-  top: `${viewerStore.tooltip.y + 14}px`,
-}))
-
-function onMove(e: MouseEvent): void {
-  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
-  viewerStore.moveTooltip(e.clientX - rect.left, e.clientY - rect.top)
-}
 </script>
 
 <template>
-  <div class="stage" @mousemove="onMove">
+  <div class="stage">
     <div ref="container" class="canvas-host" />
     <div v-if="modelStore.loadingText" class="loading-overlay">{{ modelStore.loadingText }}</div>
-    <div v-if="viewerStore.tooltip.visible" class="tooltip" :style="tooltipStyle">
-      {{ viewerStore.hoveredLabel }}
-    </div>
     <slot />
   </div>
 </template>
@@ -56,15 +41,4 @@ function onMove(e: MouseEvent): void {
   font-size: 14px;
   z-index: 5;
 }
-.tooltip {
-  position: absolute;
-  pointer-events: none;
-  background: rgba(0, 0, 0, 0.75);
-  color: #fff;
-  padding: 4px 8px;
-  border-radius: 5px;
-  font-size: 12px;
-  white-space: nowrap;
-  z-index: 20;
-}
 </style>

+ 5 - 1
3d/frontend/src/stores/alarms.ts

@@ -66,9 +66,13 @@ export const useAlarmsStore = defineStore('alarms', () => {
     selectedId.value = selectedId.value === row.id ? null : row.id
   }
 
+  function clearSelected(): void {
+    selectedId.value = null
+  }
+
   return {
     devices, alarmTypes, deviceCode, alarmType, page, pageSize, total, items,
     loading, selectedId, selected,
-    loadFilters, load, setDevice, setType, setPage, select,
+    loadFilters, load, setDevice, setType, setPage, select, clearSelected,
   }
 })

+ 69 - 0
3d/frontend/src/stores/charts.ts

@@ -0,0 +1,69 @@
+import { defineStore } from 'pinia'
+import { ref, watch } from 'vue'
+
+import { fetchPksSeries } from '../api/series'
+import { shiftHours, toMs } from '../utils/time'
+import { useAlarmsStore } from './alarms'
+
+const HOUR = 3600 * 1000
+
+export const useChartsStore = defineStore('charts', () => {
+  const alarms = useAlarmsStore()
+
+  const ms = ref<number[]>([])
+  const oil = ref<(number | null)[]>([])
+  const coupling = ref<(number | null)[]>([])
+  const chain = ref<(number | null)[]>([])
+  const faultStart = ref(0)
+  const faultEnd = ref(0)
+  const loading = ref(false)
+  const progress = ref(0)
+  const error = ref('')
+  const loaded = ref(false)
+
+  function clear(): void {
+    ms.value = []
+    oil.value = []
+    coupling.value = []
+    chain.value = []
+    faultStart.value = 0
+    faultEnd.value = 0
+    loaded.value = false
+  }
+
+  async function load(): Promise<void> {
+    const selected = alarms.selected
+    clear()
+    error.value = ''
+    if (!selected) return
+    loading.value = true
+    progress.value = 0
+    try {
+      const start = shiftHours(selected.alarm_time_start, -2)
+      // alarm_time_end 是末个小时(闭区间),故障边界为其 +1h,再向前后各留 2h
+      const end = shiftHours(selected.alarm_time_end, 3)
+      const data = await fetchPksSeries(selected.device_code, start, end, (percent) => {
+        progress.value = percent
+      })
+      ms.value = data.times.map(toMs)
+      oil.value = data.oil
+      coupling.value = data.coupling
+      chain.value = data.chain
+      faultStart.value = toMs(selected.alarm_time_start)
+      faultEnd.value = toMs(selected.alarm_time_end) + HOUR
+      loaded.value = true
+    } catch (err) {
+      error.value = '曲线数据加载失败:' + (err as Error).message
+      clear()
+    } finally {
+      loading.value = false
+    }
+  }
+
+  watch(() => alarms.selected?.id, load, { immediate: true })
+
+  return {
+    ms, oil, coupling, chain, faultStart, faultEnd,
+    loading, progress, error, loaded, load,
+  }
+})

+ 24 - 0
3d/frontend/src/styles/index.css

@@ -118,3 +118,27 @@ body {
 .part-query-modal .ant-modal-body {
   color: #1a1a1a;
 }
+
+/* 故障曲线弹窗:白底黑字 */
+.fault-chart-modal .ant-modal-content {
+  background: #fff;
+  color: #1a1a1a;
+}
+.fault-chart-modal .ant-modal-header {
+  background: #fff;
+  border-bottom: none;
+  margin-bottom: 8px;
+}
+.fault-chart-modal .ant-modal-title {
+  color: #111;
+  font-weight: 600;
+}
+.fault-chart-modal .ant-modal-close {
+  color: #888;
+}
+.fault-chart-modal .ant-modal-close:hover {
+  color: #222;
+}
+.fault-chart-modal .ant-modal-body {
+  color: #1a1a1a;
+}

+ 5 - 0
3d/frontend/src/types/index.ts

@@ -27,6 +27,11 @@ export interface SavedPartState {
 
 export type SavedState = Record<string, SavedPartState>
 
+export interface ChartSeries {
+  name: string
+  data: [number, number | null][]
+}
+
 export interface ScenePosition {
   id: number
   name: string

+ 107 - 0
3d/frontend/src/utils/chart.ts

@@ -0,0 +1,107 @@
+import type { ChartSeries } from '../types'
+import { minMaxDownsample } from './downsample'
+
+export interface RefLine {
+  value: number
+  label?: string
+}
+
+export interface ChartBuildInput {
+  ms: number[]
+  oil: (number | null)[]
+  coupling: (number | null)[]
+  chain: (number | null)[]
+  isVibration: boolean
+  mode: 'relative' | 'absolute'
+  baselineOil: number
+  baselineVib: number
+  downsampleMax?: number
+}
+
+export interface ChartBuildResult {
+  series: ChartSeries[]
+  yMin: number
+  yMax: number
+  unit: string
+  refLines: RefLine[]
+}
+
+function minMax(arrays: (number | null)[][]): { min: number; max: number } {
+  let lo = Infinity
+  let hi = -Infinity
+  arrays.forEach((arr) =>
+    arr.forEach((value) => {
+      if (value === null || value === undefined) return
+      if (value < lo) lo = value
+      if (value > hi) hi = value
+    }),
+  )
+  if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { min: 0, max: 1 }
+  const padding = (hi - lo) * 0.08 || Math.abs(hi) * 0.08 || 1
+  return { min: lo - padding, max: hi + padding }
+}
+
+export function buildChartSeries(input: ChartBuildInput): ChartBuildResult {
+  const { ms, isVibration, mode, baselineOil, baselineVib, downsampleMax } = input
+
+  const finish = (pairs: [number, number | null][]): [number, number | null][] =>
+    downsampleMax ? minMaxDownsample(pairs, downsampleMax) : pairs
+
+  if (isVibration) {
+    const relative = mode === 'relative' && Number.isFinite(baselineVib) && baselineVib > 0
+    const make = (arr: (number | null)[]): [number, number | null][] =>
+      finish(
+        ms.map((t, i) => {
+          const v = arr[i]
+          return [t, v === null ? null : relative ? v / baselineVib : v] as [number, number | null]
+        }),
+      )
+    const series: ChartSeries[] = [
+      { name: '联轴器端', data: make(input.coupling) },
+      { name: '链轮端', data: make(input.chain) },
+    ]
+    if (relative) {
+      return { series, yMin: 0, yMax: 2, unit: '×', refLines: [{ value: 1, label: '基线' }] }
+    }
+    const range = minMax([input.coupling, input.chain])
+    return {
+      series,
+      yMin: range.min,
+      yMax: range.max,
+      unit: 'mm/s',
+      refLines: Number.isFinite(baselineVib) ? [{ value: baselineVib, label: '基线' }] : [],
+    }
+  }
+
+  const relative = mode === 'relative' && Number.isFinite(baselineOil) && baselineOil > 0
+  const data = finish(
+    ms.map((t, i) => {
+      const v = input.oil[i]
+      const out = v === null ? null : relative ? (v / baselineOil - 1) * 100 : v
+      return [t, out] as [number, number | null]
+    }),
+  )
+  const series: ChartSeries[] = [{ name: '润滑油压力', data }]
+  if (relative) {
+    return {
+      series,
+      yMin: -25,
+      yMax: 5,
+      unit: '%',
+      refLines: [
+        { value: 0, label: '基线' },
+        { value: -5 },
+        { value: -10 },
+        { value: -20 },
+      ],
+    }
+  }
+  const range = minMax([input.oil])
+  return {
+    series,
+    yMin: range.min,
+    yMax: range.max,
+    unit: 'MPa',
+    refLines: Number.isFinite(baselineOil) ? [{ value: baselineOil, label: '基线' }] : [],
+  }
+}

+ 46 - 0
3d/frontend/src/utils/downsample.ts

@@ -0,0 +1,46 @@
+/**
+ * 保持特征的降采样:按桶取每桶的首/尾、最小、最大点,并保留空值断点。
+ * 总点数不超过 maxPoints;实际点数不足时原样返回。
+ */
+export function minMaxDownsample(
+  data: [number, number | null][],
+  maxPoints: number,
+): [number, number | null][] {
+  const n = data.length
+  if (maxPoints <= 0 || n <= maxPoints) return data
+
+  const buckets = Math.max(1, Math.floor(maxPoints / 5))
+  const size = n / buckets
+  const out: [number, number | null][] = []
+
+  for (let b = 0; b < buckets; b++) {
+    const start = Math.floor(b * size)
+    const end = Math.min(n, Math.floor((b + 1) * size))
+    if (start >= end) continue
+    let minIndex = -1
+    let maxIndex = -1
+    let nullIndex = -1
+    let minValue = Infinity
+    let maxValue = -Infinity
+    for (let i = start; i < end; i++) {
+      const value = data[i][1]
+      if (value === null || value === undefined) {
+        if (nullIndex < 0) nullIndex = i
+        continue
+      }
+      if (value < minValue) { minValue = value; minIndex = i }
+      if (value > maxValue) { maxValue = value; maxIndex = i }
+    }
+    const indexes = [start, minIndex, maxIndex, nullIndex, end - 1]
+      .filter((i) => i >= 0)
+      .sort((a, b2) => a - b2)
+    let previous = -1
+    for (const i of indexes) {
+      if (i !== previous) {
+        out.push(data[i])
+        previous = i
+      }
+    }
+  }
+  return out
+}

+ 20 - 0
3d/frontend/src/utils/time.ts

@@ -0,0 +1,20 @@
+export function pad2(value: number): string {
+  return String(value).padStart(2, '0')
+}
+
+export function formatDateTime(date: Date): string {
+  return (
+    `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
+    `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
+  )
+}
+
+export function toMs(value: string): number {
+  return new Date(value.replace(' ', 'T')).getTime()
+}
+
+export function shiftHours(value: string, hours: number): string {
+  const date = new Date(value.replace(' ', 'T'))
+  date.setHours(date.getHours() + hours)
+  return formatDateTime(date)
+}