Procházet zdrojové kódy

3d:故障详情卡片改为 实时数据/故障详情 双 tab;实时数据新增多子图趋势(共享x轴/dataZoom/白色分割)与示功图 P-V(按缸切换、4阶段配色、进气/膨胀/压缩/排气图例);新增 /api/recent-series、/api/indicator 接口;扩展区隐藏滚动条

18922397810 před 2 týdny
rodič
revize
ac88090ae8

+ 2 - 0
3d/README.md

@@ -101,6 +101,8 @@ APP_MODE=prod AUTH_USER=admin AUTH_PASSWORD='***' bash 3d/backend/run_prod.sh --
 | GET | `/api/alarms` | 是 | 故障列表(筛选/分页,倒序) |
 | GET | `/api/alarm-filters` | 是 | 故障列表筛选项 |
 | GET | `/api/pks-series` | 是 | 故障曲线原始序列(YSJ_5/10/11) |
+| GET | `/api/recent-series` | 是 | 数据库最新 N 小时实时序列(默认 6h,供实时数据 tab) |
+| GET | `/api/indicator` | 是 | 示功图 P-V(按机组+缸,取最近运行周期,盖侧/轴侧) |
 | GET | `/api/scene-positions` | 是 | 场景位置列表 |
 | POST/PUT/DELETE | `/api/scene-positions[/id]` | 是 | 场景位置增/改/删 |
 | GET | `/api/fault-part-map` | 是 | 故障↔3D 部件对照表 |

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

@@ -9,7 +9,7 @@
 
 需登录接口(Bearer token):
     /api/models、/api/alarms、/api/alarm-filters、/api/pks-series、
-    /api/scene-positions、/api/fault-part-map
+    /api/recent-series、/api/indicator、/api/scene-positions、/api/fault-part-map
 """
 
 from typing import Any
@@ -21,7 +21,7 @@ from pydantic import BaseModel, Field
 
 from auth import create_token, require_auth, revoke_token, verify_credentials
 from config import APP_MODE, AUTH_PASSWORD, AUTH_USER, FRONTEND_DIST, IS_TEST
-from routers import alarms, fault_map, files, scenes, series
+from routers import alarms, fault_map, files, indicator, scenes, series
 
 
 class LoginInput(BaseModel):
@@ -65,6 +65,7 @@ app.include_router(alarms.router, dependencies=[Depends(require_auth)])
 app.include_router(fault_map.router, dependencies=[Depends(require_auth)])
 app.include_router(scenes.router, dependencies=[Depends(require_auth)])
 app.include_router(series.router, dependencies=[Depends(require_auth)])
+app.include_router(indicator.router, dependencies=[Depends(require_auth)])
 app.include_router(files.router)
 
 

+ 1 - 0
3d/backend/requirements.txt

@@ -2,3 +2,4 @@ fastapi>=0.115,<1
 uvicorn[standard]>=0.30,<1
 pydantic>=2.0,<3
 PyMySQL>=1.1,<2
+numpy>=1.26,<3

+ 20 - 0
3d/backend/routers/indicator.py

@@ -0,0 +1,20 @@
+"""压缩机示功图(P-V)接口。"""
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Query
+
+from services import indicator_service
+
+router = APIRouter()
+
+
+@router.get("/api/indicator")
+def indicator(
+    device_code: str = Query(...),
+    cylinder: str = Query(...),
+) -> dict[str, Any]:
+    try:
+        return indicator_service.query_indicator(device_code, cylinder)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error

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

@@ -16,3 +16,11 @@ def pks_series(
     end: str = Query(...),
 ) -> dict[str, Any]:
     return series_service.query_series(device_code, start, end)
+
+
+@router.get("/api/recent-series")
+def recent_series(
+    device_code: str = Query(...),
+    hours: int = Query(6, ge=1, le=72),
+) -> dict[str, Any]:
+    return series_service.query_recent_series(device_code, hours)

+ 107 - 0
3d/backend/services/indicator_service.py

@@ -0,0 +1,107 @@
+"""压缩机示功图(P-V)。
+
+读取波库中该测点**最近一次运行**(``rpm>0``)的 ``wave_file`` 首个周期
+(``wave_sample_one``),按缸径/行程/连杆/余隙把曲柄转角换算为容积(L),
+返回盖侧 / 轴侧两副压力功图(P-V)。
+
+首周期本身即一个完整冲程,样本按时间顺序对应曲柄角 0→360°,直接成闭环,
+无需额外相位旋转(与 backend/app/services/data_service.py 的示功图一致)。
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import numpy as np
+
+from db import connect
+
+CYLINDERS = ("一", "二", "三", "四", "五", "六")
+SIDES = ("盖侧", "轴侧")
+CYLINDER_BORE_MM = {"一": 360.0, "二": 490.0, "三": 390.0, "四": 490.0, "五": 390.0, "六": 490.0}
+PISTON_STROKE_MM = 148.0
+CONNECTING_ROD_LENGTH_MM = 460.0
+CLEARANCE_VOLUME_L_BY_BORE = {490.0: 1.51, 390.0: 0.74, 360.0: 0.62}
+MIN_SAMPLES = 60
+
+
+def unit_number(device_code: str) -> str | None:
+    match = re.search(r"([789])", device_code or "")
+    return match.group(1) if match else None
+
+
+def volume_from_angle(cylinder: str, count: int) -> np.ndarray:
+    bore = CYLINDER_BORE_MM[cylinder]
+    clearance = CLEARANCE_VOLUME_L_BY_BORE[bore]
+    crank_radius = PISTON_STROKE_MM / 2.0
+    rod = CONNECTING_ROD_LENGTH_MM
+    theta = np.linspace(0.0, 2.0 * np.pi, count, endpoint=False)
+    travel = (
+        crank_radius * (1.0 - np.cos(theta))
+        + rod
+        - np.sqrt(rod**2 - (crank_radius * np.sin(theta)) ** 2)
+    )
+    piston_area = np.pi * (bore / 2.0) ** 2
+    return clearance + piston_area * travel / 1_000_000.0
+
+
+def _side(unit: str, cylinder: str, side: str) -> dict[str, Any] | None:
+    point = f"{unit}号机组{cylinder}缸压力{side}"
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT id, sample_time, rpm FROM wave_file
+                WHERE point_name = %s AND measurement_type = '压力' AND rpm > 0
+                ORDER BY sample_time DESC
+                LIMIT 1
+                """,
+                (point,),
+            )
+            row = cursor.fetchone()
+            if not row:
+                return None
+            file_id = int(row["id"])
+            cursor.execute(
+                "SELECT signal_value FROM wave_sample_one WHERE wave_file_id = %s ORDER BY sample_index",
+                (file_id,),
+            )
+            values = [
+                float(item["signal_value"])
+                for item in cursor.fetchall()
+                if item["signal_value"] is not None
+            ]
+    finally:
+        connection.close()
+
+    if len(values) < MIN_SAMPLES:
+        return None
+    pressure = np.asarray(values, dtype=float)
+    volume = volume_from_angle(cylinder, len(pressure))
+    angle = np.linspace(0.0, 360.0, len(pressure), endpoint=False)
+    sample_time = row["sample_time"]
+    return {
+        "side": side,
+        "point_name": point,
+        "wave_file_id": file_id,
+        "sample_time": sample_time.strftime("%Y-%m-%d %H:%M:%S") if sample_time else None,
+        "rpm": float(row["rpm"]) if row["rpm"] is not None else None,
+        "pressure": [round(float(value), 4) for value in pressure.tolist()],
+        "volume": [round(float(value), 5) for value in volume.tolist()],
+        "angle": [round(float(value), 2) for value in angle.tolist()],
+    }
+
+
+def query_indicator(device_code: str, cylinder: str) -> dict[str, Any]:
+    unit = unit_number(device_code)
+    if unit is None:
+        raise ValueError(f"不支持的机组:{device_code}")
+    if cylinder not in CYLINDERS:
+        raise ValueError(f"不支持的缸号:{cylinder}")
+    sides = [item for item in (_side(unit, cylinder, side) for side in SIDES) if item]
+    return {"device_code": device_code, "unit": unit, "cylinder": cylinder, "sides": sides}
+
+
+__all__ = ["CYLINDERS", "SIDES", "query_indicator"]

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

@@ -22,6 +22,30 @@ def _num(value: Any) -> float | None:
     return number
 
 
+def query_recent_series(device_code: str, hours: int = 6) -> dict[str, Any]:
+    """数据库中最新的 ``hours`` 小时实时数据(以该设备最新采样时间为终点)。"""
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                "SELECT MAX(sample_time) AS latest FROM pks_long_sample WHERE device_code = %s",
+                (device_code,),
+            )
+            row = cursor.fetchone()
+    finally:
+        connection.close()
+
+    latest = row["latest"] if row else None
+    if latest is None:
+        return {
+            "device_code": device_code, "start": None, "end": None, "count": 0,
+            "times": [], "oil": [], "coupling": [], "chain": [],
+        }
+    fmt = "%Y-%m-%d %H:%M:%S"
+    start = (latest - timedelta(hours=hours)).strftime(fmt)
+    return query_series(device_code, start, latest.strftime(fmt))
+
+
 def query_series(device_code: str, start: str, end: str) -> dict[str, Any]:
     connection = connect()
     try:

+ 29 - 0
3d/frontend/src/api/indicator.ts

@@ -0,0 +1,29 @@
+import { http } from './http'
+
+export interface IndicatorSide {
+  side: string
+  point_name: string
+  wave_file_id: number
+  sample_time: string | null
+  rpm: number | null
+  pressure: number[]
+  volume: number[]
+  angle: number[]
+}
+
+export interface IndicatorResponse {
+  device_code: string
+  unit: string
+  cylinder: string
+  sides: IndicatorSide[]
+}
+
+export async function fetchIndicator(
+  deviceCode: string,
+  cylinder: string,
+): Promise<IndicatorResponse> {
+  const res = await http.get<IndicatorResponse>('/api/indicator', {
+    params: { device_code: deviceCode, cylinder },
+  })
+  return res.data
+}

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

@@ -28,3 +28,10 @@ export async function fetchPksSeries(
   onProgress?.(100)
   return res.data
 }
+
+export async function fetchRecentSeries(deviceCode: string, hours = 6): Promise<PksSeries> {
+  const res = await http.get<PksSeries>('/api/recent-series', {
+    params: { device_code: deviceCode, hours },
+  })
+  return res.data
+}

+ 303 - 54
3d/frontend/src/components/AlarmDetailCard.vue

@@ -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;
 }

+ 120 - 41
3d/frontend/src/components/FaultChart.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import * as echarts from 'echarts'
-import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 
 import type { ChartSeries } from '../types'
 
@@ -15,17 +15,61 @@ const props = withDefaults(
     faultStart: number
     faultEnd: number
     height?: number
-    yMin: number
-    yMax: number
+    yMin?: number
+    yMax?: number
     unit?: string
     refLines?: RefLine[]
     showYAxis?: boolean
     dark?: boolean
     zoom?: boolean
+    xMin?: number
+    xMax?: number
+    showLegend?: boolean
+    showAxisUnit?: boolean
+    lineColor?: string
+    insideZoom?: boolean
+    perSeriesLegend?: boolean
+    gridHeight?: number
+    gridDivider?: boolean
   }>(),
-  { height: 180, unit: '', refLines: () => [], showYAxis: true, dark: true, zoom: false },
+  {
+    height: 180,
+    unit: '',
+    refLines: () => [],
+    showYAxis: true,
+    dark: true,
+    zoom: false,
+    showLegend: false,
+    showAxisUnit: true,
+    lineColor: '',
+    insideZoom: false,
+    perSeriesLegend: false,
+    gridDivider: false,
+  },
 )
 
+function layout() {
+  const n = props.series.length
+  const perSeries = props.perSeriesLegend
+  const hasLegend = props.showLegend || n > 1
+  const xLabelH = 18
+  const sliderH = 16
+  const topPad = 6
+  const band = perSeries ? 14 : hasLegend ? 18 : 0
+  const gap = perSeries ? 18 : 8
+  const bottom = props.zoom ? sliderH + xLabelH + 10 : xLabelH + 8
+  const totalH = props.gridHeight
+    ? topPad + (perSeries ? band * n : band) + gap * (n - 1) + props.gridHeight * n + bottom
+    : props.height
+  const usable = perSeries
+    ? totalH - topPad - bottom - band * n - gap * (n - 1)
+    : totalH - topPad - band - bottom - gap * (n - 1)
+  const gridH = props.gridHeight ?? Math.max(20, usable / n)
+  return { n, totalH, perSeries, hasLegend, topPad, band, gap, bottom, gridH, xLabelH, sliderH }
+}
+
+const containerHeight = computed(() => layout().totalH)
+
 const el = ref<HTMLElement | null>(null)
 let chart: echarts.ECharts | null = null
 let observer: ResizeObserver | null = null
@@ -77,18 +121,13 @@ function buildDataZoom(
 }
 
 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 { n, perSeries, hasLegend, gridH, sliderH, topPad, band, gap } = layout()
+  const topOf = (i: number): number =>
+    perSeries ? topPad + i * (band + gridH + gap) : topPad + band + i * (gridH + gap)
+  const gridTop = (i: number): number => (perSeries ? topOf(i) + band : topOf(i))
   const indexes = props.series.map((_, i) => i)
-  const [tMin, tMax] = seriesExtent()
+  const [tMin, tMax] =
+    props.xMin != null && props.xMax != null ? [props.xMin, props.xMax] : seriesExtent()
 
   const axisColor = props.dark ? '#aaaaaa' : '#666666'
   const lineColor = props.dark ? '#555555' : '#cccccc'
@@ -100,6 +139,8 @@ function buildOption(): Record<string, unknown> {
   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 baseColor = props.lineColor || BLUE
+  const customColors = props.series.some((s) => s.color)
 
   const markLines = props.refLines.map((line) => ({
     yAxis: line.value,
@@ -119,7 +160,7 @@ function buildOption(): Record<string, unknown> {
     grids.push({
       left: 4,
       right: 8,
-      top: top + i * (gridH + gap),
+      top: gridTop(i),
       height: gridH,
       containLabel: props.showYAxis,
     })
@@ -138,15 +179,17 @@ function buildOption(): Record<string, unknown> {
       type: 'value',
       gridIndex: i,
       show: props.showYAxis,
-      min: props.yMin,
-      max: props.yMax,
+      scale: true,
+      min: s.yMin ?? props.yMin,
+      max: s.yMax ?? props.yMax,
       splitNumber: 4,
       axisLine: { show: false },
       axisTick: { show: false },
       axisLabel: {
         fontSize: 9,
         color: axisColor,
-        formatter: (value: number) => `${Number(value).toFixed(2)}${props.unit}`,
+        formatter: (value: number) =>
+          props.showAxisUnit ? `${Number(value).toFixed(2)}${props.unit}` : Number(value).toFixed(2),
       },
       splitLine: { show: false },
     })
@@ -160,7 +203,8 @@ function buildOption(): Record<string, unknown> {
       symbol: 'none',
       animation: false,
       sampling: 'none',
-      lineStyle: { width: props.dark ? 1.5 : 1 },
+      lineStyle: { width: props.dark ? 1.5 : 1, ...(s.color ? { color: s.color } : {}) },
+      itemStyle: s.color ? { color: s.color } : undefined,
       emphasis: { disabled: true },
       markLine: { silent: true, symbol: 'none', data: markLines },
       markArea: {
@@ -171,40 +215,71 @@ function buildOption(): Record<string, unknown> {
     })
   })
 
+  const width = chart ? chart.getWidth() : el.value?.clientWidth ?? 0
+  const graphic: Record<string, unknown>[] = []
+  if (props.gridDivider && width > 0) {
+    for (let i = 0; i < n - 1; i += 1) {
+      const y = gridTop(i) + gridH + gap / 2
+      graphic.push({
+        type: 'line',
+        z: 10,
+        silent: true,
+        shape: { x1: 4, y1: y, x2: width - 8, y2: y },
+        style: { stroke: '#ffffff', lineWidth: 1 },
+      })
+    }
+  }
+
   return {
     animation: false,
     backgroundColor: 'transparent',
+    graphic: graphic.length ? graphic : undefined,
     textStyle: {
       fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
       fontWeight: 'normal',
     },
-    legend: hasLegend
-      ? {
-          top: 0,
+    legend: perSeries
+      ? props.series.map((s, i) => ({
+          top: topOf(i) + 1,
           left: 'center',
-          itemWidth: 12,
+          itemWidth: 14,
           itemHeight: 8,
           textStyle: { fontSize: 10, color: legendColor },
-          data: props.series.map((s) => s.name),
-        }
-      : undefined,
+          data: [s.name],
+        }))
+      : 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 },
-    },
+    visualMap: customColors
+      ? undefined
+      : {
+          show: false,
+          dimension: 0,
+          seriesIndex: indexes,
+          pieces: [
+            { lt: props.faultStart, color: baseColor },
+            { gte: props.faultStart, lte: props.faultEnd, color: faultLine },
+            { gt: props.faultEnd, color: baseColor },
+          ],
+          outOfRange: { color: baseColor },
+        },
     axisPointer: { link: [{ xAxisIndex: 'all' }] },
-    dataZoom: props.zoom ? buildDataZoom(indexes, sliderH, axisColor, tMin, tMax) : undefined,
+    dataZoom: props.insideZoom
+      ? [{ type: 'inside', xAxisIndex: indexes, startValue: tMin, endValue: tMax }]
+      : props.zoom
+        ? buildDataZoom(indexes, sliderH, axisColor, tMin, tMax)
+        : undefined,
     tooltip: {
       trigger: 'axis',
       axisPointer: { type: 'line' },
@@ -253,11 +328,15 @@ onBeforeUnmount(() => {
   chart = null
 })
 
-watch(() => [props.series, props.yMin, props.yMax, props.refLines], render, { deep: true })
+watch(
+  () => [props.series, props.yMin, props.yMax, props.refLines, props.xMin, props.xMax],
+  render,
+  { deep: true },
+)
 </script>
 
 <template>
-  <div ref="el" class="fault-chart" :style="{ height: `${height}px` }" />
+  <div ref="el" class="fault-chart" :style="{ height: `${containerHeight}px` }" />
 </template>
 
 <style scoped>

+ 139 - 0
3d/frontend/src/components/IndicatorChart.vue

@@ -0,0 +1,139 @@
+<script setup lang="ts">
+import * as echarts from 'echarts'
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
+
+const props = withDefaults(
+  defineProps<{
+    pressure: number[]
+    volume: number[]
+    angle: number[]
+    height?: number
+  }>(),
+  { height: 170 },
+)
+
+// 曲柄角度 4 阶段(配色为根目录 frontend/src/components/WaveChart.vue 的加亮版)。
+const PHASES = [
+  { start: 0, end: 120, color: '#ab47bc', name: '进气' },
+  { start: 120, end: 195, color: '#ef5350', name: '膨胀' },
+  { start: 195, end: 270, color: '#42a5f5', name: '压缩' },
+  { start: 270, end: 360, color: '#66bb6a', name: '排气' },
+]
+
+const el = ref<HTMLElement | null>(null)
+let chart: echarts.ECharts | null = null
+let observer: ResizeObserver | null = null
+
+function buildOption(): Record<string, unknown> {
+  // 按曲柄角顺时针排序,保证同一阶段为连续折线(避免跨圈连成一条直线)。
+  const order = props.volume
+    .map((_, index) => index)
+    .sort((a, b) => (props.angle[a] ?? 0) - (props.angle[b] ?? 0))
+  const series = PHASES.map((phase) => {
+    const data: [number, number][] = []
+    for (const index of order) {
+      const angle = props.angle[index]
+      if (angle === undefined || angle < phase.start || angle >= phase.end) continue
+      data.push([props.volume[index], props.pressure[index]])
+    }
+    return {
+      type: 'line',
+      name: phase.name,
+      data,
+      showSymbol: false,
+      symbol: 'none',
+      animation: false,
+      sampling: 'none',
+      lineStyle: { width: 1.6, color: phase.color },
+      itemStyle: { color: phase.color },
+    }
+  }).filter((item) => item.data.length >= 2)
+
+  return {
+    animation: false,
+    backgroundColor: 'transparent',
+    textStyle: {
+      fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
+      fontWeight: 'normal',
+    },
+    legend: {
+      top: 0,
+      left: 'center',
+      itemWidth: 12,
+      itemHeight: 8,
+      itemGap: 10,
+      textStyle: { fontSize: 9, color: '#aaaaaa' },
+      data: series.map((item) => item.name),
+    },
+    grid: { left: 46, right: 12, top: 24, bottom: 26, containLabel: false },
+    xAxis: {
+      type: 'value',
+      name: 'V/L',
+      nameTextStyle: { color: '#aaaaaa', fontSize: 9 },
+      axisLine: { lineStyle: { color: '#555555' } },
+      axisTick: { show: false },
+      axisLabel: { color: '#aaaaaa', fontSize: 9 },
+      splitLine: { show: false },
+    },
+    yAxis: {
+      type: 'value',
+      scale: true,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        color: '#aaaaaa',
+        fontSize: 9,
+        formatter: (value: number) => Number(value).toFixed(2),
+      },
+      splitLine: { show: false },
+    },
+    series,
+    tooltip: {
+      trigger: 'item',
+      backgroundColor: 'rgba(0,0,0,0.8)',
+      textStyle: { fontSize: 11, color: '#ffffff' },
+      formatter: (params: unknown) => {
+        const item = params as { value?: [number, number]; seriesName?: string } | undefined
+        const value = item?.value
+        if (!value) return ''
+        return `${item?.seriesName ?? ''}<br/>V ${Number(value[0]).toFixed(3)} L<br/>P ${Number(value[1]).toFixed(3)} MPa`
+      },
+    },
+  }
+}
+
+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.pressure, props.volume, props.angle], render, { deep: true })
+</script>
+
+<template>
+  <div ref="el" class="indicator-chart" :style="{ height: `${height}px` }" />
+</template>
+
+<style scoped>
+.indicator-chart {
+  width: 100%;
+}
+</style>

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

@@ -40,9 +40,15 @@ body {
   flex-direction: column;
   gap: 12px;
   overflow-y: auto;
+  scrollbar-width: none;
+  -ms-overflow-style: none;
   pointer-events: none;
   z-index: 8;
 }
+.ext-panel::-webkit-scrollbar {
+  width: 0;
+  height: 0;
+}
 .ext-panel > * {
   pointer-events: auto;
   flex: 0 0 auto;

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

@@ -40,6 +40,9 @@ export interface FaultMapRow {
 export interface ChartSeries {
   name: string
   data: [number, number | null][]
+  color?: string
+  yMin?: number
+  yMax?: number
 }
 
 export interface ScenePosition {