18922397810 преди 1 седмица
родител
ревизия
295e568c89

+ 10 - 8
backend/app/main.py

@@ -18,8 +18,8 @@ class TimePointInput(BaseModel):
 
 
 class WaveWindowInput(BaseModel):
-    pointName: str
-    measurementTypes: list[str]
+    devicePart: str = Field(min_length=1)
+    devicePoints: list[str]
     points: list[TimePointInput]
     maxPoints: int = Field(default=200000, ge=256, le=200000)
     noSampling: bool = False
@@ -105,6 +105,8 @@ def health(_auth: str = Depends(require_auth)) -> dict[str, Any]:
 def query_options(_auth: str = Depends(require_auth)) -> dict[str, Any]:
     try:
         result = data_service.query_options()
+        # Counts are keyed by the full point_name so the UI can scope them to
+        # the currently selected device_part.
         result["pretrainCounts"] = pretrain_service.recorded_counts()
         result["abnormalCounts"] = data_service.abnormal_counts()
         return result
@@ -114,8 +116,8 @@ def query_options(_auth: str = Depends(require_auth)) -> dict[str, Any]:
 
 @app.get("/api/time-points")
 def time_points(
-    point_name: str = Query(min_length=1),
-    measurement_types: list[str] = Query(default=[]),
+    device_part: str = Query(min_length=1),
+    device_points: list[str] = Query(default=[]),
     min_time: str | None = None,
     max_time: str | None = None,
     include_stopped: bool = False,
@@ -125,8 +127,8 @@ def time_points(
 ) -> dict[str, Any]:
     try:
         return data_service.time_points(
-            point_name,
-            measurement_types,
+            device_part,
+            device_points,
             min_time,
             max_time,
             include_stopped,
@@ -151,8 +153,8 @@ def tspluse_ruler(_auth: str = Depends(require_auth)) -> dict[str, Any]:
 def wave_window(payload: WaveWindowInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
     try:
         return data_service.wave_window(
-            payload.pointName,
-            payload.measurementTypes,
+            payload.devicePart,
+            payload.devicePoints,
             [point.model_dump() if hasattr(point, "model_dump") else point.dict() for point in payload.points],
             payload.maxPoints,
             payload.noSampling,

Файловите разлики са ограничени, защото са твърде много
+ 444 - 337
backend/app/services/data_service.py


+ 16 - 0
backend/app/services/pretrain_service.py

@@ -54,6 +54,22 @@ class PretrainService:
                 counts[name] = counts.get(name, 0) + 1
         return counts
 
+    def device_point_counts(self) -> dict[str, int]:
+        """每个 device_point 在预训练 CSV 中的记录数量(按 point_name 后缀剥离)。"""
+        with self._lock:
+            records = self._read_records()
+        suffixes = ["压力盖侧", "压力轴侧", "活塞杆沉降", "十字头振动", "自由端振动", "驱动端振动"]
+        counts: dict[str, int] = {}
+        for row in records:
+            name = str(row.get("point_name") or "").strip()
+            if not name:
+                continue
+            for suffix in suffixes:
+                if name.endswith(suffix):
+                    counts[suffix] = counts.get(suffix, 0) + 1
+                    break
+        return counts
+
     def record(self, record: dict[str, object]) -> tuple[bool, bool]:
         row = {column: str(record.get(column) or "") for column in _CSV_COLUMNS}
         try:

+ 107 - 58
frontend/src/App.vue

@@ -7,11 +7,12 @@ import { fixedAxisRange } from './utils/axis'
 import TimePointStrip from './components/TimePointStrip.vue'
 import WaveChart from './components/WaveChart.vue'
 import { STOPPED_COLOR, statusGradientColor } from './statusColor'
-import { MEASUREMENT_TYPES, type Annotation, type AnnotationLabel, type Cycle, type MeasurementType, type PeriodDetail, type QueryOption, type TimePoint, type QueryOptionsResponse, type WaveWindowFile, type WaveWindowResponse } from './types'
+import { DEVICE_POINT_TO_TYPE, DEVICE_POINTS, PRIMARY_DEVICE_POINT, type Annotation, type AnnotationLabel, type Cycle, type DevicePoint, type MeasurementType, type PeriodDetail, type QueryOption, type TimePoint, type QueryOptionsResponse, type WaveWindowFile, type WaveWindowResponse } from './types'
 
 const queryMeta = ref<QueryOptionsResponse | null>(null)
-const selectedPointName = ref('')
-const selectedTypes = ref<MeasurementType[]>(['压力'])
+const selectedDevicePart = ref('')
+const selectedDevicePoints = ref<DevicePoint[]>([PRIMARY_DEVICE_POINT])
+const fileListPoint = ref<DevicePoint>(PRIMARY_DEVICE_POINT)
 const minTime = ref('')
 const maxTime = ref('')
 const windowSize = ref(4)
@@ -50,29 +51,31 @@ const recordingFileId = ref<number | null>(null)
 let queryDebounce: ReturnType<typeof setTimeout> | undefined
 let waveRequestId = 0
 
-const pointNames = computed(() => queryMeta.value?.pointNames ?? [])
+const deviceParts = computed(() => queryMeta.value?.deviceParts ?? [])
+const devicePoints = computed(() => queryMeta.value?.devicePoints ?? DEVICE_POINTS)
 
-const pointCounts = computed<Record<string, { pre: number | null; ab: number | null }>>(() => {
+const devicePointCounts = computed<Record<string, { pre: number | null; ab: number | null }>>(() => {
   const map: Record<string, { pre: number | null; ab: number | null }> = {}
-  for (const name of pointNames.value) {
-    map[name] = {
-      pre: queryMeta.value?.pretrainCounts?.[name] ?? null,
-      ab: queryMeta.value?.abnormalCounts?.[name] ?? null,
+  for (const point of devicePoints.value) {
+    const pointName = `${selectedDevicePart.value}${point}`
+    map[point] = {
+      pre: queryMeta.value?.pretrainCounts?.[pointName] ?? null,
+      ab: queryMeta.value?.abnormalCounts?.[pointName] ?? null,
     }
   }
   return map
 })
 
-function pointLabel(pointName: string): string {
-  const { pre, ab } = pointCounts.value[pointName] ?? { pre: null, ab: null }
+function devicePointLabel(point: DevicePoint): string {
+  const { pre, ab } = devicePointCounts.value[point] ?? { pre: null, ab: null }
   const inner: string[] = []
   if (pre != null) inner.push(String(pre))
   if (ab != null) inner.push(String(ab))
-  return inner.length ? `${pointName} (${inner.join(' · ')})` : pointName
+  return inner.length ? `${point} (${inner.join(' · ')})` : point
 }
 const selectedOptionRows = computed<QueryOption[]>(() => (
   queryMeta.value?.options.filter((row) => (
-    row.pointName === selectedPointName.value && selectedTypes.value.includes(row.measurementType)
+    row.devicePart === selectedDevicePart.value && selectedDevicePoints.value.includes(row.devicePoint)
   )) ?? []
 ))
 const selectedWindowPoints = computed(() => {
@@ -99,6 +102,15 @@ const currentSource = computed(() => waveData.value?.source ?? timePointsSource.
 const timePointsSource = ref<'database' | 'demo'>('demo')
 const availableTypeCount = computed(() => selectedOptionRows.value.reduce((total, row) => total + row.fileCount, 0))
 const annotatedFileIds = computed(() => [...new Set(annotations.value.map((item) => item.waveFileId))])
+const windowFiles = computed(() => (waveData.value?.files ?? []).filter((file) => file.devicePoint === fileListPoint.value))
+const primaryDevicePoint = computed(() => (
+  waveData.value?.primaryPoint
+  ?? (selectedDevicePoints.value.includes('压力盖侧')
+    ? '压力盖侧'
+    : selectedDevicePoints.value.includes('压力轴侧')
+      ? '压力轴侧'
+      : selectedDevicePoints.value[0])
+))
 
 function toInputTime(value: string) {
   if (!value) return ''
@@ -112,8 +124,10 @@ function toApiTime(value: string) {
 function boundsForSelection() {
   const rows = selectedOptionRows.value
   if (!rows.length) return null
-  const min = rows.reduce((latest, row) => (row.minTime > latest ? row.minTime : latest), rows[0].minTime)
-  const max = rows.reduce((earliest, row) => (row.maxTime < earliest ? row.maxTime : earliest), rows[0].maxTime)
+  const unionRows = rows.filter((row) => row.devicePoint === PRIMARY_DEVICE_POINT || row.devicePoint === '压力轴侧')
+  const effective = unionRows.length ? unionRows : rows
+  const min = effective.reduce((latest, row) => (row.minTime > latest ? row.minTime : latest), effective[0].minTime)
+  const max = effective.reduce((earliest, row) => (row.maxTime < earliest ? row.maxTime : earliest), effective[0].maxTime)
   return { min: toInputTime(min), max: toInputTime(max) }
 }
 
@@ -146,7 +160,7 @@ async function loadOptions() {
   try {
     const result = await fetchQueryOptions()
     queryMeta.value = result
-    selectedPointName.value = result.pointNames[0] ?? ''
+    selectedDevicePart.value = result.deviceParts[0] ?? ''
     syncTimeBounds(true)
     initialized.value = true
     await loadTimePoints()
@@ -167,20 +181,20 @@ async function loadTspluseRuler() {
 
 function firstRunningIndex(): number {
   const index = timePoints.value.findIndex((point) =>
-    selectedTypes.value.some((type) => (point.files[type]?.rpm ?? 0) > 0),
+    selectedDevicePoints.value.some((pointName) => (point.files[pointName]?.rpm ?? 0) > 0),
   )
   return index >= 0 ? index : 0
 }
 
 async function loadTimePoints() {
-  if (!selectedPointName.value || !selectedTypes.value.length) return
+  if (!selectedDevicePart.value || !selectedDevicePoints.value.length) return
   timePointsLoading.value = true
   waveData.value = null
   errorMessage.value = ''
   try {
     const result = await fetchTimePoints({
-      pointName: selectedPointName.value,
-      measurementTypes: selectedTypes.value,
+      devicePart: selectedDevicePart.value,
+      devicePoints: selectedDevicePoints.value,
       minTime: toApiTime(minTime.value),
       maxTime: toApiTime(maxTime.value),
       includeStopped: includeStopped.value,
@@ -207,7 +221,7 @@ async function loadTimePoints() {
 
 async function loadWaveWindowNow() {
   const points = selectedWindowPoints.value
-  if (!points.length || !selectedPointName.value) {
+  if (!points.length || !selectedDevicePart.value) {
     waveData.value = null
     chartDirty.value = false
     return
@@ -216,8 +230,8 @@ async function loadWaveWindowNow() {
   waveLoading.value = true
   try {
     const result = await fetchWaveWindow({
-      pointName: selectedPointName.value,
-      measurementTypes: selectedTypes.value,
+      devicePart: selectedDevicePart.value,
+      devicePoints: selectedDevicePoints.value,
       points,
       maxPoints: maxPoints.value,
       noSampling: noSampling.value,
@@ -244,14 +258,15 @@ function scheduleTimePoints() {
   queryDebounce = setTimeout(() => void loadTimePoints(), 180)
 }
 
-function onPointChange() {
+function onDevicePartChange() {
   syncTimeBounds(true)
   scheduleTimePoints()
 }
 
-function onTypesChange() {
-  if (!selectedTypes.value.length) {
-    selectedTypes.value = ['压力']
+function onDevicePointsChange() {
+  const points = selectedDevicePoints.value
+  if (!points.length) {
+    selectedDevicePoints.value = [PRIMARY_DEVICE_POINT]
     return
   }
   syncTimeBounds(true)
@@ -292,12 +307,30 @@ function jumpToIndex(index: number) {
   onStartIndexChange(Number(index))
 }
 
+type JumpSegment = { text: string; color?: string }
+
 const jumpOptions = computed(() => timePoints.value.map((point, index) => {
-  const file = point.files['压力']
   const text = point.sampleTime.replace('T', ' ')
-  const label = file && file.status !== undefined ? `${text} (${file.status})` : text
-  const color = file ? (file.rpm <= 0 ? STOPPED_COLOR : statusGradientColor(file.status, ruler.value)) : undefined
-  return { value: index, label, color }
+  const segments: JumpSegment[] = [{ text }]
+  const statuses: JumpSegment[] = []
+  for (const devicePoint of selectedDevicePoints.value) {
+    const file = point.files[devicePoint]
+    if (!file || file.status === undefined) continue
+    const color = file.rpm <= 0 ? STOPPED_COLOR : statusGradientColor(file.status, ruler.value)
+    statuses.push({ text: String(file.status), color })
+  }
+  if (statuses.length) {
+    segments.push({ text: ' (' })
+    statuses.forEach((segment, i) => {
+      if (i > 0) segments.push({ text: '·', color: '#8b9aa2' })
+      segments.push(segment)
+    })
+    segments.push({ text: ')' })
+  }
+  const label = statuses.length
+    ? `${text} (${statuses.map((segment) => segment.text).join('·')})`
+    : text
+  return { value: index, segments, label }
 }))
 
 function sampleTimeStyle(file: WaveWindowFile) {
@@ -340,8 +373,8 @@ async function loadAnnotationConfig() {
 async function loadAnnotations() {
   const ids = [...new Set(
     timePoints.value.flatMap((point) => (
-      selectedTypes.value
-        .map((type) => point.files[type]?.id)
+      selectedDevicePoints.value
+        .map((devicePoint) => point.files[devicePoint]?.id)
         .filter((id): id is number => id != null)
     )),
   )]
@@ -466,11 +499,11 @@ async function onAnnotationToggle(cycle: Cycle) {
   }
 }
 
-watch(selectedPointName, () => {
-  if (initialized.value) onPointChange()
+watch(selectedDevicePart, () => {
+  if (initialized.value) onDevicePartChange()
 })
-watch(selectedTypes, () => {
-  if (initialized.value) onTypesChange()
+watch(selectedDevicePoints, () => {
+  if (initialized.value) onDevicePointsChange()
 }, { deep: true })
 watch([minTime, maxTime], () => {
   if (initialized.value) onTimeChange()
@@ -633,7 +666,7 @@ onBeforeUnmount(() => {
       </div>
       <div class="header-meta">
         <span class="live-indicator"><i></i> 浏览模式</span>
-        <span class="header-date">{{ selectedPointName || '未选择采样点' }}</span>
+        <span class="header-date">{{ selectedDevicePart || '未选择机组与部位' }}</span>
         <el-button class="header-refresh" type="primary" plain :loading="queryLoading" @click="refresh">刷新</el-button>
         <el-button class="header-logout" plain @click="handleLogout">退出登录</el-button>
       </div>
@@ -653,37 +686,42 @@ onBeforeUnmount(() => {
           <label class="field field-point">
             <span class="field-label">机组与部位</span>
             <el-select
-              v-model="selectedPointName"
+              v-model="selectedDevicePart"
               class="query-control"
               size="large"
               filterable
               :disabled="queryLoading"
               placeholder="输入机组、气缸或部位关键词"
-              filter-placeholder="搜索采样点"
+              filter-placeholder="搜索机组与部位"
             >
-              <el-option v-for="pointName in pointNames" :key="pointName" :label="pointLabel(pointName)" :value="pointName">
-                <span>{{ pointName }}</span>
-                <template v-if="pointCounts[pointName]?.pre !== null || pointCounts[pointName]?.ab !== null">
-                  <span class="point-counts">
-                    (<span v-if="pointCounts[pointName]?.pre !== null">{{ pointCounts[pointName]?.pre }}</span><template v-if="pointCounts[pointName]?.pre !== null && pointCounts[pointName]?.ab !== null"> · </template><span v-if="pointCounts[pointName]?.ab !== null" class="abnormal-count">{{ pointCounts[pointName]?.ab }}</span>)
-                  </span>
-                </template>
-              </el-option>
+              <el-option v-for="devicePart in deviceParts" :key="devicePart" :label="devicePart" :value="devicePart" />
             </el-select>
           </label>
           <div class="field field-types">
-            <span class="field-label">数据名称</span>
+            <span class="field-label">测试点位</span>
             <el-select
-              v-model="selectedTypes"
+              v-model="selectedDevicePoints"
               class="query-control"
               size="large"
               multiple
               collapse-tags
               collapse-tags-tooltip
               :max-collapse-tags="2"
-              placeholder="选择数据名称"
+              placeholder="选择测试点位"
             >
-              <el-option v-for="type in MEASUREMENT_TYPES" :key="type" :label="type" :value="type" />
+              <el-option
+                v-for="point in devicePoints"
+                :key="point"
+                :label="devicePointLabel(point)"
+                :value="point"
+              >
+                <span>{{ point }}</span>
+                <template v-if="devicePointCounts[point]?.pre !== null || devicePointCounts[point]?.ab !== null">
+                  <span class="point-counts">
+                    (<span v-if="devicePointCounts[point]?.pre !== null">{{ devicePointCounts[point]?.pre }}</span><template v-if="devicePointCounts[point]?.pre !== null && devicePointCounts[point]?.ab !== null"> · </template><span v-if="devicePointCounts[point]?.ab !== null" class="abnormal-count">{{ devicePointCounts[point]?.ab }}</span>)
+                  </span>
+                </template>
+              </el-option>
             </el-select>
           </div>
           <label class="field">
@@ -726,7 +764,7 @@ onBeforeUnmount(() => {
               :disabled="queryLoading"
             />
           </label>
-          <el-button class="query-action query-button" type="primary" size="large" :loading="timePointsLoading" :disabled="!selectedPointName" @click="loadTimePoints">
+          <el-button class="query-action query-button" type="primary" size="large" :loading="timePointsLoading" :disabled="!selectedDevicePart" @click="loadTimePoints">
             查询时间点
           </el-button>
           <div class="query-row-2">
@@ -765,7 +803,8 @@ onBeforeUnmount(() => {
       <section class="selection-panel panel">
         <TimePointStrip
           :points="timePoints"
-          :measurement-types="selectedTypes"
+          :device-points="selectedDevicePoints"
+          :point-to-type="DEVICE_POINT_TO_TYPE"
           :start-index="startIndex"
           :window-size="stripWindowSize"
           :loading="timePointsLoading"
@@ -798,7 +837,10 @@ onBeforeUnmount(() => {
               @update:model-value="jumpToIndex"
             >
               <template #default="{ item }">
-                <span :style="item.color ? { color: item.color } : undefined">{{ item.label }}</span>
+                <template v-for="(segment, segmentIndex) in item.segments" :key="segmentIndex">
+                  <span v-if="segment.color" :style="{ color: segment.color }">{{ segment.text }}</span>
+                  <span v-else>{{ segment.text }}</span>
+                </template>
               </template>
             </el-select-v2>
             <el-button
@@ -813,13 +855,19 @@ onBeforeUnmount(() => {
           </div>
         </div>
         <div v-if="waveData?.files.length" class="window-files">
-          <div class="window-files-title">当前窗口 wave_file 记录</div>
+          <div class="window-files-title">
+            <span>当前窗口 wave_file 记录</span>
+            <el-radio-group v-model="fileListPoint" class="file-point-radio" size="small">
+              <el-radio-button v-for="point in devicePoints" :key="point" :value="point">{{ point }}</el-radio-button>
+            </el-radio-group>
+          </div>
           <div class="window-files-scroll">
             <table class="window-files-table">
             <thead>
               <tr>
                 <th>id</th>
                 <th>point_name</th>
+                <th>测试点位</th>
                 <th>measurement_type</th>
                 <th>rpm</th>
                 <th>sample_time</th>
@@ -829,9 +877,10 @@ onBeforeUnmount(() => {
               </tr>
             </thead>
             <tbody>
-              <tr v-for="file in waveData.files" :key="file.id">
+              <tr v-for="file in windowFiles" :key="file.id">
                 <td class="mono">{{ file.id }}</td>
                 <td>{{ file.pointName }}</td>
+                <td>{{ file.devicePoint }}</td>
                 <td>{{ file.measurementType }}</td>
                 <td class="mono">{{ file.rpm }}</td>
                 <td class="mono" :style="sampleTimeStyle(file)">{{ file.sampleTime.replace('T', ' ') }}</td>
@@ -917,7 +966,7 @@ onBeforeUnmount(() => {
           <div class="readout-card"><span>显示策略</span><strong>MIN / MAX</strong></div>
         </div>
         <div class="data-footprint">
-          <div><span>数据名称</span><strong>{{ selectedTypes.join(' / ') }}</strong></div>
+          <div><span>测试点位</span><strong>{{ selectedDevicePoints.join(' / ') }}</strong></div>
           <div><span>可用文件</span><strong>{{ availableTypeCount.toLocaleString() }}</strong></div>
           <div><span>抽样上限</span><strong>{{ maxPoints.toLocaleString() }} 点</strong></div>
         </div>

+ 7 - 7
frontend/src/api.ts

@@ -3,7 +3,7 @@ import type {
   AnnotationConfigResponse,
   AnnotationLabel,
   AnnotationListResponse,
-  MeasurementType,
+  DevicePoint,
   PeriodDetail,
   QueryOptionsResponse,
   TimePoint,
@@ -67,16 +67,16 @@ export function fetchQueryOptions() {
 }
 
 export function fetchTimePoints(params: {
-  pointName: string
-  measurementTypes: MeasurementType[]
+  devicePart: string
+  devicePoints: DevicePoint[]
   minTime?: string
   maxTime?: string
   includeStopped?: boolean
   minStatus?: number | null
   statusFilter?: string[]
 }) {
-  const search = new URLSearchParams({ point_name: params.pointName })
-  params.measurementTypes.forEach((value) => search.append('measurement_types', value))
+  const search = new URLSearchParams({ device_part: params.devicePart })
+  params.devicePoints.forEach((value) => search.append('device_points', value))
   if (params.minTime) search.set('min_time', params.minTime)
   if (params.maxTime) search.set('max_time', params.maxTime)
   if (params.includeStopped) search.set('include_stopped', 'true')
@@ -90,8 +90,8 @@ export function fetchTspluseRuler() {
 }
 
 export function fetchWaveWindow(params: {
-  pointName: string
-  measurementTypes: MeasurementType[]
+  devicePart: string
+  devicePoints: DevicePoint[]
   points: TimePoint[]
   maxPoints: number
   noSampling: boolean

+ 14 - 13
frontend/src/components/TimePointStrip.vue

@@ -1,11 +1,12 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
-import type { MeasurementType, TimePoint } from '../types'
+import type { DevicePoint, MeasurementType, TimePoint } from '../types'
 import { RUNNING_COLOR, STOPPED_COLOR, statusGradientColor } from '../statusColor'
 
 const props = defineProps<{
   points: TimePoint[]
-  measurementTypes: MeasurementType[]
+  devicePoints: DevicePoint[]
+  pointToType: Record<DevicePoint, MeasurementType>
   startIndex: number
   windowSize: number
   loading?: boolean
@@ -31,10 +32,10 @@ function displayDate(value: string | undefined) {
   return value.replace('T', ' ').slice(0, 10)
 }
 
-function pointColor(measurementType: MeasurementType, fileInfo: { rpm: number; status?: number }): string {
+function pointColor(devicePoint: DevicePoint, fileInfo: { rpm: number; status?: number }): string {
   const running = fileInfo.rpm > 0
   if (!running) return STOPPED_COLOR
-  if (measurementType === '压力') return statusGradientColor(fileInfo.status, props.ruler ?? null)
+  if (props.pointToType[devicePoint] === '压力') return statusGradientColor(fileInfo.status, props.ruler ?? null)
   return RUNNING_COLOR
 }
 
@@ -58,7 +59,7 @@ function draw() {
   const right = 20
   const available = Math.max(width - left - right, 1)
   const rowGap = 39
-  const rows = props.measurementTypes
+  const rows = props.devicePoints
   const rowY = (index: number) => 32 + index * rowGap
   const annotationY = 32 + rows.length * rowGap
   const xAt = (index: number) => left + (props.points.length <= 1 ? 0 : index / (props.points.length - 1)) * available
@@ -72,7 +73,7 @@ function draw() {
   context.lineWidth = 1
   context.strokeRect(selectedLeft + 0.5, 10.5, selectedWidth - 1, 171)
 
-  rows.forEach((measurementType, rowIndex) => {
+  rows.forEach((devicePoint, rowIndex) => {
     const y = rowY(rowIndex)
     context.strokeStyle = '#d7e0e4'
     context.setLineDash([2, 5])
@@ -83,16 +84,16 @@ function draw() {
     context.setLineDash([])
     context.fillStyle = '#52616b'
     context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
-    context.fillText(measurementType, 10, y + 4)
+    context.fillText(devicePoint, 10, y + 4)
 
     let lastX = -Infinity
     props.points.forEach((point, pointIndex) => {
-      const fileInfo = point.files[measurementType]
+      const fileInfo = point.files[devicePoint]
       if (!fileInfo) return
       const x = xAt(pointIndex)
       if (x - lastX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
       lastX = x
-      context.fillStyle = pointColor(measurementType, fileInfo)
+      context.fillStyle = pointColor(devicePoint, fileInfo)
       context.beginPath()
       context.arc(x, y, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
       context.fill()
@@ -110,14 +111,14 @@ function draw() {
   context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
   context.fillText('标注', 10, annotationY + 4)
   const annotatedSet = new Set(props.annotatedFileIds ?? [])
-  const isAnnotated = (point: TimePoint) => props.measurementTypes.some((type) => {
-    const id = point.files[type]?.id
+  const isAnnotated = (point: TimePoint) => props.devicePoints.some((devicePoint) => {
+    const id = point.files[devicePoint]?.id
     return id != null && annotatedSet.has(id)
   })
   let lastGrayX = -Infinity
   props.points.forEach((point, pointIndex) => {
     if (isAnnotated(point)) return
-    const hasFile = props.measurementTypes.some((type) => point.files[type])
+    const hasFile = props.devicePoints.some((devicePoint) => point.files[devicePoint])
     if (!hasFile) return
     const x = xAt(pointIndex)
     if (x - lastGrayX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
@@ -175,7 +176,7 @@ function updateSlider(value: number | number[]) {
 }
 
 watch(
-  () => [props.points, props.measurementTypes, props.startIndex, props.windowSize, props.annotatedFileIds, props.ruler],
+  () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.annotatedFileIds, props.ruler],
   () => nextTick(draw),
   { deep: true },
 )

+ 279 - 210
frontend/src/components/WaveChart.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import * as echarts from 'echarts'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
-import type { Annotation, Cycle, MeasurementType, TimePoint, WaveWindowResponse } from '../types'
+import type { Annotation, Cycle, TimePoint, WaveWindowResponse } from '../types'
 import { fixedAxisRange } from '../utils/axis'
 
 const props = defineProps<{
@@ -19,17 +19,17 @@ const emit = defineEmits<{
 
 const chartElement = ref<HTMLDivElement | null>(null)
 let chart: echarts.ECharts | undefined
-const pvCanvas = ref<HTMLCanvasElement | null>(null)
 const pvShell = ref<HTMLDivElement | null>(null)
+const pvCanvases = ref<(HTMLCanvasElement | null)[]>([])
+const pvRowEls = ref<(HTMLElement | null)[]>([])
 let pvFrame: number | undefined
 
-const colors: Record<MeasurementType | '角度' | '合并信号' | 'second_value' | '体积', string> = {
+const colors: Record<string, string> = {
   压力: '#e05252',
   位移: '#287f9e',
   加速度: '#7656a5',
-  角度: '#c58b24',
   合并信号: '#287f9e',
-  second_value: '#f56c6c',
+  周期数据: '#f56c6c',
   体积: '#4d9e6f',
 }
 
@@ -56,11 +56,18 @@ function niceAxisExtent(values: number[]): [number, number] {
   return [Math.floor(min / interval) * interval, Math.ceil(max / interval) * interval]
 }
 
-function fixedYAxis(type: string) {
+function fixedYAxis(devicePoint: string) {
   if (props.mode === 'merge') return null
-  if (type !== '压力' && type !== '位移' && type !== '加速度') return null
-  const series = props.data?.series.find((item) => item.measurementType === type)
-  return fixedAxisRange(type, series?.min, series?.max)
+  const series = props.data?.series.find((item) => item.devicePoint === devicePoint)
+  if (!series) return null
+  return fixedAxisRange(series.measurementType, series.min, series.max)
+}
+
+function splitExtraCount(data: WaveWindowResponse): number {
+  let count = 0
+  if (data.secondSeries.data.length) count += 1
+  if (data.volumeSeries.data.length || data.volumeSeries.info) count += 1
+  return count
 }
 
 const plottedPointCount = computed(() => (
@@ -76,8 +83,21 @@ const secondValueStatus = computed(() => {
   return `周期数据:有效 ${series.finiteCount.toLocaleString()} 点 / 非零 ${series.nonZeroCount.toLocaleString()} 点`
 })
 
-function formatTime(value: string | undefined) {
-  return value?.replace('T', ' ').slice(11, 19) ?? ''
+const pvPointRows = computed(() => (
+  (props.data?.series
+    .filter((series) => series.measurementType === '压力')
+    .map((series) => ({
+      devicePoint: series.devicePoint,
+      title: `${series.devicePoint} · 压力-体积功图 · 当前可视周期`,
+    })) ?? [])
+))
+
+function setPvCanvas(el: unknown, index: number) {
+  pvCanvases.value[index] = el as HTMLCanvasElement
+}
+
+function setPvRow(el: unknown, index: number) {
+  pvRowEls.value[index] = el as HTMLElement
 }
 
 function minMaxOf(values: number[]): [number, number] {
@@ -95,8 +115,8 @@ function pointAxisLabel(value: number) {
   const index = Math.round(value)
   if (Math.abs(value - index) > 0.04 || !props.points[index]) return ''
   const point = props.points[index]
-  const sourceType = props.data?.secondSeries.sourceMeasurementType
-  const fileId = sourceType ? point.files[sourceType]?.id : undefined
+  const sourceDevicePoint = props.data?.secondSeries.sourceDevicePoint
+  const fileId = sourceDevicePoint ? point.files[sourceDevicePoint]?.id : undefined
   const time = point.sampleTime.replace('T', ' ').slice(0, 19)
   return `${fileId ?? ''}\n${time}`
 }
@@ -150,13 +170,14 @@ function annotationAreas(annotations: Annotation[]): any[] {
 function buildAnnotationOverlaySeries(annotations: Annotation[]): echarts.SeriesOption[] {
   const data = props.data
   if (!data) return []
-  const gridCount = props.mode === 'merge' ? 1 : data.measurementTypes.length + 2
+  const gridCount = props.mode === 'merge' ? 1 : data.devicePoints.length + splitExtraCount(data)
   const areas = annotationAreas(annotations)
   return Array.from({ length: gridCount }, (_, index) => ({
     id: `annotation-overlay-${index}`,
     type: 'line' as const,
     xAxisIndex: index,
     yAxisIndex: index,
+    z: -10,
     silent: true,
     data: [],
     markArea: { silent: true, data: areas },
@@ -164,14 +185,12 @@ function buildAnnotationOverlaySeries(annotations: Annotation[]): echarts.Series
 }
 
 function initialZoom(data: WaveWindowResponse): { start: number; end: number } {
-  const sampleCount = data.files[0]?.sampleCount ?? 65536
-  const start = data.cycles.length > 0 ? (data.cycles[0].startX / data.xMax) * 100 : 0
-  const end = (() => {
-    if (data.cycles.length > 10) return (data.cycles[9].endX / data.xMax) * 100
-    if (data.cycles.length > 0) return 100
-    return Math.min(100, (25600 / sampleCount / data.xMax) * 100)
-  })()
-  return { start, end }
+  // A point series can occupy a later exact-timestamp slot than the primary
+  // series. Starting at the first cycle would hide that series and its PV row
+  // while axis tooltips still report its values. Always start with the full
+  // selected window; users can zoom in after all selected points are visible.
+  void data
+  return { start: 0, end: 100 }
 }
 
 function readCurrentZoom(data: WaveWindowResponse): { start: number; end: number } {
@@ -228,10 +247,19 @@ watch(annotationSegments, () => {
 function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
   const data = props.data
   if (!data) return { animation: false }
-  const measurementTypes = data.measurementTypes
-  const periodBackground = periodAreas(data.cycles)
-  const types = props.mode === 'merge' ? ['合并信号'] : [...measurementTypes, 'second_value', '体积']
-  const gridCount = types.length
+  const devicePoints = data.devicePoints
+  const primaryCycles = data.cycles.filter((cycle) => cycle.devicePoint === data.primaryPoint)
+  const backgroundCycles = data.cycles.filter((cycle) => cycle.background).length
+    ? data.cycles.filter((cycle) => cycle.background)
+    : primaryCycles
+  const periodBackground = periodAreas(backgroundCycles)
+  const showSecond = data.secondSeries.data.length > 0
+  const showVolume = data.volumeSeries.data.length > 0 || data.volumeSeries.info != null
+  const extraRows: string[] = []
+  if (showSecond) extraRows.push('周期数据')
+  if (showVolume) extraRows.push('体积')
+  const rows = props.mode === 'merge' ? ['合并信号'] : [...devicePoints, ...extraRows]
+  const gridCount = rows.length
   const chartHeight = chartElement.value?.clientHeight || 640
   const topInset = 32
   const bottomInset = 68
@@ -265,123 +293,109 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
     boundaryGap: false,
     splitLine: { show: false },
   }))
-  const yAxes = types.map((type, index) => {
-    const fixed = fixedYAxis(type)
+  const yAxes = rows.map((rowName, index) => {
+    const isDevicePoint = (devicePoints as readonly string[]).includes(rowName)
+    const seriesColor = isDevicePoint
+      ? (data.series.find((series) => series.devicePoint === rowName)?.color ?? '#60717b')
+      : (colors[rowName] ?? '#60717b')
+    const fixed = isDevicePoint ? fixedYAxis(rowName) : null
+    const isDisplacement = isDevicePoint
+      ? data.series.find((series) => series.devicePoint === rowName)?.measurementType === '位移'
+      : false
     return {
       type: 'value' as const,
       gridIndex: index,
-      name: type === 'second_value' ? '周期数据' : type,
+      name: rowName,
       nameLocation: 'middle' as const,
       nameGap: 48,
-      nameTextStyle: { color: colors[type as MeasurementType | '角度' | '合并信号' | 'second_value' | '体积'], fontWeight: 600 },
-      axisLine: { show: true, lineStyle: { color: colors[type as MeasurementType | '角度' | '合并信号' | 'second_value' | '体积'] } },
+      nameTextStyle: { color: seriesColor, fontWeight: 600 },
+      axisLine: { show: true, lineStyle: { color: seriesColor } },
       axisLabel: { color: '#71808a', fontSize: 11 },
       splitLine: { show: true, lineStyle: { color: '#e7edf0', width: 1 } },
-      scale: type === '位移',
+      scale: isDisplacement,
       min: fixed?.min,
       max: fixed?.max,
       interval: fixed?.interval,
     }
   })
   const series: echarts.SeriesOption[] = []
+  const deviceColor = (devicePoint: string) => data.series.find((series) => series.devicePoint === devicePoint)?.color ?? colors[data.series.find((series) => series.devicePoint === devicePoint)?.measurementType ?? '加速度']
   if (props.mode === 'merge') {
-    const normalised = measurementTypes.map((type) => {
-      const source = data.series.find((item) => item.measurementType === type)
+    devicePoints.forEach((devicePoint) => {
+      const source = data.series.find((series) => series.devicePoint === devicePoint)
       const values = source?.data ?? []
       const finiteValues = values.map((item) => item.rawValue).filter(Number.isFinite)
       const [safeMin, safeMax] = finiteValues.length ? minMaxOf(finiteValues) : [0, 1]
       const span = safeMax - safeMin || 1
-      return {
-        name: type,
+      const color = source?.color ?? colors[source?.measurementType ?? '加速度']
+      series.push({
+        name: devicePoint,
         type: 'line' as const,
         z: 10,
+        xAxisIndex: 0,
+        yAxisIndex: 0,
         showSymbol: false,
         connectNulls: false,
         sampling: 'lttb' as const,
-        lineStyle: { width: 2.5, color: colors[type], cap: 'round' as const, join: 'round' as const },
-        itemStyle: { color: colors[type] },
+        lineStyle: { width: 2.5, color, cap: 'round' as const, join: 'round' as const },
+        itemStyle: { color },
         data: values
           .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
           .map((item) => [item.x, (item.rawValue - safeMin) / span] as [number, number]),
         markArea: { silent: true, data: periodBackground },
-      }
-    })
-    series.push(...normalised)
-    const secondValues = data.secondSeries.data
-    const secondFiniteValues = secondValues.map((item) => item.rawValue).filter(Number.isFinite)
-    const [secondMin, secondMax] = secondFiniteValues.length ? minMaxOf(secondFiniteValues) : [0, 1]
-    const secondSpan = secondMax - secondMin || 1
-    series.push({
-      name: '周期数据',
-      type: 'line',
-      xAxisIndex: 0,
-      yAxisIndex: 0,
-      showSymbol: false,
-      connectNulls: false,
-      lineStyle: { width: 3, color: colors.second_value, cap: 'round' as const, join: 'round' as const },
-      areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
-      data: secondValues
-        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
-        .map((item) => [item.x, (item.rawValue - secondMin) / secondSpan] as [number, number]),
-    })
-    const volumeValues = data.volumeSeries.data
-    const volumeFinite = volumeValues.map((item) => item.volume).filter(Number.isFinite)
-    const [volumeMin, volumeMax] = volumeFinite.length ? minMaxOf(volumeFinite) : [0, 1]
-    const volumeSpan = volumeMax - volumeMin || 1
-    series.push({
-      name: '体积',
-      type: 'line',
-      xAxisIndex: 0,
-      yAxisIndex: 0,
-      showSymbol: false,
-      lineStyle: { width: 1.5, color: colors['体积'], opacity: 0.9 },
-      data: volumeValues
-        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
-        .map((item) => [item.x, (item.volume - volumeMin) / volumeSpan] as [number, number]),
+      })
     })
+    if (showSecond) {
+      const secondValues = data.secondSeries.data
+      const secondFiniteValues = secondValues.map((item) => item.rawValue).filter(Number.isFinite)
+      const [secondMin, secondMax] = secondFiniteValues.length ? minMaxOf(secondFiniteValues) : [0, 1]
+      const secondSpan = secondMax - secondMin || 1
+      series.push({
+        name: '周期数据',
+        type: 'line',
+        xAxisIndex: 0,
+        yAxisIndex: 0,
+        showSymbol: false,
+        connectNulls: false,
+        lineStyle: { width: 3, color: colors['周期数据'], cap: 'round' as const, join: 'round' as const },
+        areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
+        data: secondValues
+          .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
+          .map((item) => [item.x, (item.rawValue - secondMin) / secondSpan] as [number, number]),
+      })
+    }
+    if (showVolume) {
+      const volumeValues = data.volumeSeries.data
+      const volumeFinite = volumeValues.map((item) => item.volume).filter(Number.isFinite)
+      const [volumeMin, volumeMax] = volumeFinite.length ? minMaxOf(volumeFinite) : [0, 1]
+      const volumeSpan = volumeMax - volumeMin || 1
+      series.push({
+        name: '体积',
+        type: 'line',
+        xAxisIndex: 0,
+        yAxisIndex: 0,
+        showSymbol: false,
+        lineStyle: { width: 1.5, color: colors['体积'], opacity: 0.9 },
+        data: volumeValues
+          .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
+          .map((item) => [item.x, (item.volume - volumeMin) / volumeSpan] as [number, number]),
+      })
+    }
   } else {
-    const secondIndex = measurementTypes.length
-    const volumeIndex = secondIndex + 1
-    series.push({
-      name: '周期数据',
-      type: 'line',
-      xAxisIndex: secondIndex,
-      yAxisIndex: secondIndex,
-      showSymbol: false,
-      connectNulls: false,
-      lineStyle: { width: 3, color: colors.second_value, cap: 'round', join: 'round' },
-      areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
-      itemStyle: { color: colors.second_value },
-      data: data.secondSeries.data
-        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
-        .map((item) => [item.x, item.rawValue] as [number, number]),
-      markArea: { silent: true, data: periodBackground },
-    })
-    series.push({
-      name: '体积',
-      type: 'line',
-      xAxisIndex: volumeIndex,
-      yAxisIndex: volumeIndex,
-      showSymbol: false,
-      lineStyle: { width: 2, color: colors['体积'] },
-      itemStyle: { color: colors['体积'] },
-      data: data.volumeSeries.data
-        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
-        .map((item) => [item.x, item.volume] as [number, number]),
-      markArea: { silent: true, data: periodBackground },
-    })
-    measurementTypes.forEach((type, index) => {
-      const source = data.series.find((item) => item.measurementType === type)
+    devicePoints.forEach((devicePoint, index) => {
+      const source = data.series.find((series) => series.devicePoint === devicePoint)
+      const color = source?.color ?? colors[source?.measurementType ?? '加速度']
       series.push({
-        name: type,
+        name: devicePoint,
         type: 'line',
+        z: 10,
         xAxisIndex: index,
         yAxisIndex: index,
         showSymbol: false,
         connectNulls: false,
         sampling: 'lttb' as const,
-        lineStyle: { width: 2.5, color: colors[type], cap: 'round', join: 'round' },
-        itemStyle: { color: colors[type] },
+        lineStyle: { width: 2.5, color, cap: 'round', join: 'round' },
+        itemStyle: { color },
         data: source?.data
           .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
           .map((item) => [item.x, item.rawValue] as [number, number]) ?? [],
@@ -389,6 +403,42 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
         markLine: index === 0 ? { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) } : undefined,
       })
     })
+    if (showSecond) {
+      const secondIndex = devicePoints.length
+      series.push({
+        name: '周期数据',
+        type: 'line',
+        z: 10,
+        xAxisIndex: secondIndex,
+        yAxisIndex: secondIndex,
+        showSymbol: false,
+        connectNulls: false,
+        lineStyle: { width: 3, color: colors['周期数据'], cap: 'round', join: 'round' },
+        areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
+        itemStyle: { color: colors['周期数据'] },
+        data: data.secondSeries.data
+          .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
+          .map((item) => [item.x, item.rawValue] as [number, number]),
+        markArea: { silent: true, data: periodBackground },
+      })
+    }
+    if (showVolume) {
+      const volumeIndex = devicePoints.length + (showSecond ? 1 : 0)
+      series.push({
+        name: '体积',
+        type: 'line',
+        z: 10,
+        xAxisIndex: volumeIndex,
+        yAxisIndex: volumeIndex,
+        showSymbol: false,
+        lineStyle: { width: 2, color: colors['体积'] },
+        itemStyle: { color: colors['体积'] },
+        data: data.volumeSeries.data
+          .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
+          .map((item) => [item.x, item.volume] as [number, number]),
+        markArea: { silent: true, data: periodBackground },
+      })
+    }
   }
 
   const zoom = zoomMode === 'keep' ? readCurrentZoom(data) : initialZoom(data)
@@ -396,7 +446,7 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
 
   return {
     animation: false,
-    color: measurementTypes.map((type) => colors[type]),
+    color: devicePoints.map(deviceColor),
     grid,
     xAxis: xAxes,
     yAxis: yAxes,
@@ -421,7 +471,7 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
       },
     },
     legend: {
-      data: [...measurementTypes, '周期数据', '体积'],
+      data: [...devicePoints, ...extraRows],
       top: 0,
       left: 70,
       itemWidth: 16,
@@ -459,118 +509,123 @@ function drawPvPreviews() {
   if (pvFrame != null) cancelAnimationFrame(pvFrame)
   pvFrame = requestAnimationFrame(() => {
     pvFrame = undefined
-    const canvas = pvCanvas.value
-    const shell = pvShell.value
     const data = props.data
-    if (!canvas || !shell || !data || !chart) return
-    const height = 132
-    const pressureSeries = data.series.find((series) => series.measurementType === '压力')
-    const pressure = pressureSeries?.data ?? []
-    const xPixel = (value: number) => Number(chart?.convertToPixel({ xAxisIndex: 0 }, value))
+    if (!chart || !data) return
     const zoom = readCurrentZoom(data)
     const visibleMin = data.xMin + (data.xMax - data.xMin) * zoom.start / 100
     const visibleMax = data.xMin + (data.xMax - data.xMin) * zoom.end / 100
+    const xPixel = (value: number) => Number(chart?.convertToPixel({ xAxisIndex: 0 }, value))
     const chartLeft = xPixel(visibleMin)
     const chartRight = xPixel(visibleMax)
     if (!Number.isFinite(chartLeft) || !Number.isFinite(chartRight)) return
     const plotLeft = Math.min(chartLeft, chartRight)
     const plotRight = Math.max(chartLeft, chartRight)
     const width = Math.max(plotRight - plotLeft, 1)
-    shell.style.marginLeft = `${plotLeft}px`
-    shell.style.width = `${width}px`
     const ratio = Math.min(window.devicePixelRatio || 1, 2)
-    canvas.width = width * ratio
-    canvas.height = height * ratio
-    canvas.style.width = `${width}px`
-    canvas.style.height = `${height}px`
-    const context = canvas.getContext('2d')
-    if (!context) return
-    context.setTransform(ratio, 0, 0, ratio, 0, 0)
-    context.clearRect(0, 0, width, height)
-    context.fillStyle = '#f8fafb'
-    context.fillRect(0, 0, width, height)
-    if (!pressure.length || !data.cycles.length) return
-    const localX = (value: number) => xPixel(value) - plotLeft
-    const groups = new Map<number, typeof pressure>()
-    pressure.forEach((point) => {
-      if (point.volume == null || !Number.isFinite(point.volume) || !Number.isFinite(point.rawValue)) return
-      const list = groups.get(point.waveFileId) ?? []
-      list.push(point)
-      groups.set(point.waveFileId, list)
-    })
-    const lowerBound = (points: typeof pressure, sampleIndex: number) => {
-      let low = 0
-      let high = points.length
-      while (low < high) {
-        const middle = (low + high) >> 1
-        if (points[middle].sampleIndex < sampleIndex) low = middle + 1
-        else high = middle
+
+    pvPointRows.value.forEach((row, rowIndex) => {
+      const canvas = pvCanvases.value[rowIndex]
+      const rowEl = pvRowEls.value[rowIndex]
+      if (!canvas || !rowEl) return
+      const series = data.series.find((series) => series.devicePoint === row.devicePoint)
+      const pressure = series?.data ?? []
+      const pointCycles = data.cycles.filter((cycle) => cycle.devicePoint === row.devicePoint)
+      rowEl.style.marginLeft = `${plotLeft}px`
+      rowEl.style.width = `${width}px`
+      const height = 132
+      canvas.width = width * ratio
+      canvas.height = height * ratio
+      canvas.style.width = `${width}px`
+      canvas.style.height = `${height}px`
+      const context = canvas.getContext('2d')
+      if (!context) return
+      context.setTransform(ratio, 0, 0, ratio, 0, 0)
+      context.clearRect(0, 0, width, height)
+      context.fillStyle = '#f8fafb'
+      context.fillRect(0, 0, width, height)
+      if (!pressure.length || !pointCycles.length) return
+      const localX = (value: number) => xPixel(value) - plotLeft
+      const groups = new Map<number, typeof pressure>()
+      pressure.forEach((point) => {
+        if (point.volume == null || !Number.isFinite(point.volume) || !Number.isFinite(point.rawValue)) return
+        const list = groups.get(point.waveFileId) ?? []
+        list.push(point)
+        groups.set(point.waveFileId, list)
+      })
+      const lowerBound = (points: typeof pressure, sampleIndex: number) => {
+        let low = 0
+        let high = points.length
+        while (low < high) {
+          const middle = (low + high) >> 1
+          if (points[middle].sampleIndex < sampleIndex) low = middle + 1
+          else high = middle
+        }
+        return low
       }
-      return low
-    }
-    context.font = '10px -apple-system, BlinkMacSystemFont, sans-serif'
-    context.textAlign = 'center'
-    data.cycles.forEach((cycle, cycleIndex) => {
-      const left = localX(cycle.startX)
-      const right = localX(cycle.endX)
-      const cycleWidth = Math.abs(right - left)
-      if (!Number.isFinite(left) || !Number.isFinite(right)) return
-      const cellLeft = Math.min(left, right)
-      const cellRight = Math.max(left, right)
-      const clippedLeft = Math.max(0, cellLeft)
-      const clippedRight = Math.min(width, cellRight)
-      if (clippedRight <= clippedLeft) return
-      context.fillStyle = cycleIndex % 2 === 0 ? 'rgba(213, 155, 43, .075)' : 'rgba(15, 29, 43, .09)'
-      context.fillRect(clippedLeft, 0, clippedRight - clippedLeft, height)
-      context.strokeStyle = 'rgba(213, 155, 43, .25)'
-      context.strokeRect(cellLeft + .5, .5, Math.max(0, cycleWidth - 1), height - 1)
-      if (cycleWidth < 100) return
-      const filePoints = groups.get(cycle.waveFileId) ?? []
-      const start = lowerBound(filePoints, cycle.startSampleIndex)
-      const end = lowerBound(filePoints, cycle.endSampleIndex + 1)
-      const points = filePoints.slice(start, end)
-      if (points.length < 2) return
-      const volumes = points.map((point) => point.volume as number)
-      const pressures = points.map((point) => point.rawValue)
-      const [minVolume, maxVolume] = niceAxisExtent(volumes)
-      const fixedPressure = fixedAxisRange('压力', pressureSeries?.min, pressureSeries?.max)
-      const [minPressure, maxPressure] = fixedPressure ? [fixedPressure.min, fixedPressure.max] : niceAxisExtent(pressures)
-      const volumeSpan = maxVolume - minVolume || 1
-      const pressureSpan = maxPressure - minPressure || 1
-      const frameWidth = Math.min(cycleWidth, height * modalChartWidth / modalChartHeight)
-      const frameHeight = frameWidth * modalChartHeight / modalChartWidth
-      const frameLeft = cellLeft + (cycleWidth - frameWidth) / 2
-      const frameTop = (height - frameHeight) / 2
-      const graphLeft = frameLeft + frameWidth * 64 / modalChartWidth
-      const graphRight = frameLeft + frameWidth * (modalChartWidth - 28) / modalChartWidth
-      const graphTop = frameTop + frameHeight * 38 / modalChartHeight
-      const graphBottom = frameTop + frameHeight * (modalChartHeight - 52) / modalChartHeight
-      const graphHeight = graphBottom - graphTop
-      context.save()
-      context.beginPath()
-      context.rect(cellLeft, 0, cycleWidth, height)
-      context.clip()
-      context.lineWidth = 1
-      pvPhaseColors.forEach((phase) => {
-        const phasePoints = points.filter((point) => (
-          point.angle360 != null
-          && point.angle360 >= phase.start
-          && point.angle360 < phase.end
-        ))
-        if (phasePoints.length < 2) return
-        context.strokeStyle = phase.color
+      context.font = '10px -apple-system, BlinkMacSystemFont, sans-serif'
+      context.textAlign = 'center'
+      pointCycles.forEach((cycle, cycleIndex) => {
+        const left = localX(cycle.startX)
+        const right = localX(cycle.endX)
+        const cycleWidth = Math.abs(right - left)
+        if (!Number.isFinite(left) || !Number.isFinite(right)) return
+        const cellLeft = Math.min(left, right)
+        const cellRight = Math.max(left, right)
+        const clippedLeft = Math.max(0, cellLeft)
+        const clippedRight = Math.min(width, cellRight)
+        if (clippedRight <= clippedLeft) return
+        context.fillStyle = cycleIndex % 2 === 0 ? 'rgba(213, 155, 43, .075)' : 'rgba(15, 29, 43, .09)'
+        context.fillRect(clippedLeft, 0, clippedRight - clippedLeft, height)
+        context.strokeStyle = 'rgba(213, 155, 43, .25)'
+        context.strokeRect(cellLeft + .5, .5, Math.max(0, cycleWidth - 1), height - 1)
+        if (cycleWidth < 100) return
+        const filePoints = groups.get(cycle.waveFileId) ?? []
+        const start = lowerBound(filePoints, cycle.startSampleIndex)
+        const end = lowerBound(filePoints, cycle.endSampleIndex + 1)
+        const points = filePoints.slice(start, end)
+        if (points.length < 2) return
+        const volumes = points.map((point) => point.volume as number)
+        const pressures = points.map((point) => point.rawValue)
+        const [minVolume, maxVolume] = niceAxisExtent(volumes)
+        const fixedPressure = fixedAxisRange('压力', series?.min, series?.max)
+        const [minPressure, maxPressure] = fixedPressure ? [fixedPressure.min, fixedPressure.max] : niceAxisExtent(pressures)
+        const volumeSpan = maxVolume - minVolume || 1
+        const pressureSpan = maxPressure - minPressure || 1
+        const frameWidth = Math.min(cycleWidth, height * modalChartWidth / modalChartHeight)
+        const frameHeight = frameWidth * modalChartHeight / modalChartWidth
+        const frameLeft = cellLeft + (cycleWidth - frameWidth) / 2
+        const frameTop = (height - frameHeight) / 2
+        const graphLeft = frameLeft + frameWidth * 64 / modalChartWidth
+        const graphRight = frameLeft + frameWidth * (modalChartWidth - 28) / modalChartWidth
+        const graphTop = frameTop + frameHeight * 38 / modalChartHeight
+        const graphBottom = frameTop + frameHeight * (modalChartHeight - 52) / modalChartHeight
+        const graphHeight = graphBottom - graphTop
+        context.save()
         context.beginPath()
-        phasePoints.forEach((point, index) => {
-          const x = graphLeft + (((point.volume as number) - minVolume) / volumeSpan) * (graphRight - graphLeft)
-          const y = graphBottom - ((point.rawValue - minPressure) / pressureSpan) * graphHeight
-          if (index === 0) context.moveTo(x, y)
-          else context.lineTo(x, y)
+        context.rect(cellLeft, 0, cycleWidth, height)
+        context.clip()
+        context.lineWidth = 1
+        pvPhaseColors.forEach((phase) => {
+          const phasePoints = points.filter((point) => (
+            point.angle360 != null
+            && point.angle360 >= phase.start
+            && point.angle360 < phase.end
+          ))
+          if (phasePoints.length < 2) return
+          context.strokeStyle = phase.color
+          context.beginPath()
+          phasePoints.forEach((point, index) => {
+            const x = graphLeft + (((point.volume as number) - minVolume) / volumeSpan) * (graphRight - graphLeft)
+            const y = graphBottom - ((point.rawValue - minPressure) / pressureSpan) * graphHeight
+            if (index === 0) context.moveTo(x, y)
+            else context.lineTo(x, y)
+          })
+          context.stroke()
         })
-        context.stroke()
+        context.restore()
+        context.fillStyle = '#788892'
+        context.fillText(`P-V ${cycle.periodNo}`, (left + right) / 2, 12)
       })
-      context.restore()
-      context.fillStyle = '#788892'
-      context.fillText(`P-V ${cycle.periodNo}`, (left + right) / 2, 12)
     })
   })
 }
@@ -593,7 +648,7 @@ function onBlankDoubleClick(event: any) {
   const px = event.offsetX
   const py = event.offsetY
   if (typeof px !== 'number' || typeof py !== 'number') return
-  const gridCount = props.mode === 'merge' ? 1 : props.data.measurementTypes.length + 3
+  const gridCount = props.mode === 'merge' ? 1 : props.data.devicePoints.length + splitExtraCount(props.data) + 1
   let inGrid = false
   for (let index = 0; index < gridCount; index += 1) {
     if (chart.containPixel({ gridIndex: index }, [px, py])) {
@@ -612,6 +667,13 @@ function onBlankDoubleClick(event: any) {
 function renderFull(zoomMode: 'initial' | 'keep' = 'initial') {
   if (!chart) return
   chart.setOption(buildOption(zoomMode), true)
+  if (zoomMode === 'initial') {
+    // ECharts can retain the previous slider state even with a replaced
+    // option. Reset both zoom components so later exact-timestamp slots are
+    // not clipped after adding another device point.
+    chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: 0, end: 100 })
+    chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 })
+  }
   chart.resize()
   drawPvPreviews()
 }
@@ -668,8 +730,15 @@ onBeforeUnmount(() => {
       <span class="chart-point-count">已加载 {{ plottedPointCount.toLocaleString() }} 点<span v-if="secondValueStatus"> · {{ secondValueStatus }}</span></span>
     </div>
     <div ref="pvShell" class="pv-preview-shell">
-      <div class="pv-preview-title">压力-体积功图 · 当前可视周期</div>
-      <canvas ref="pvCanvas" class="pv-preview-canvas" aria-label="压力-体积功图缩略预览"></canvas>
+      <div
+        v-for="(row, rowIndex) in pvPointRows"
+        :key="row.devicePoint"
+        :ref="(el) => setPvRow(el, rowIndex)"
+        class="pv-preview-row"
+      >
+        <div class="pv-preview-title">{{ row.title }}</div>
+        <canvas :ref="(el) => setPvCanvas(el, rowIndex)" class="pv-preview-canvas" :aria-label="row.title"></canvas>
+      </div>
     </div>
     <div ref="chartElement" class="wave-chart" :class="{ 'is-merge': mode === 'merge' }"></div>
     <div v-if="annotations?.length" class="annotation-nav">
@@ -706,7 +775,7 @@ onBeforeUnmount(() => {
     </div>
     <div v-if="loading" class="chart-loading">正在整理波形数据…</div>
     <div v-else-if="!data" class="chart-empty-hint">请点击「查询图表」加载波形数据。</div>
-    <div v-else-if="data && !hasPlottableData" class="chart-empty-hint">当前窗口没有可绘制的波形数据,请检查时间点和数据名称选择。</div>
+    <div v-else-if="data && !hasPlottableData" class="chart-empty-hint">当前窗口没有可绘制的波形数据,请检查时间点和测试点位选择。</div>
     <div v-if="data && !data.cycles.length" class="chart-empty-hint">当前窗口未检测到完整周期,仍可查看原始波形。</div>
     <div v-if="data?.firstCycleNotice" class="chart-empty-hint">{{ data.firstCycleNotice }}</div>
   </div>

+ 5 - 3
frontend/src/styles.css

@@ -181,7 +181,8 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .ghost-button.el-button { height: 32px; padding: 0 14px; font-size: 13px; }
 
 .window-files { margin-top: 12px; border: 1px solid #ebeef5; border-radius: 4px; overflow: hidden; background: #fff; }
-.window-files-title { padding: 8px 12px; color: #606266; font-size: 12px; font-weight: 600; background: #f5f7fa; border-bottom: 1px solid #ebeef5; }
+.window-files-title { display: flex; align-items: center; gap: 10px; padding: 8px 12px; color: #606266; font-size: 12px; font-weight: 600; background: #f5f7fa; border-bottom: 1px solid #ebeef5; }
+.file-point-radio { margin-left: auto; }
 .window-files-table { width: 100%; border-collapse: collapse; font-size: 12px; }
 .window-files-table th, .window-files-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid #f0f2f5; white-space: nowrap; }
 .window-files-table th { color: #909399; font-weight: 500; background: #fafbfc; }
@@ -213,7 +214,8 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .chart-point-count { margin-left: auto; color: #606266; font-weight: 500; }
 .wave-chart { width: 100%; height: 680px; }
 .wave-chart.is-merge { height: 560px; }
-.pv-preview-shell { position: relative; width: 100%; height: 132px; overflow: hidden; border: 1px solid #ebeef5; border-radius: 4px; background: #f8fafb; }
+.pv-preview-shell { display: flex; flex-direction: column; gap: 6px; width: 100%; padding: 4px 0; }
+.pv-preview-row { position: relative; width: 100%; height: 132px; overflow: hidden; border: 1px solid #ebeef5; border-radius: 4px; background: #f8fafb; }
 .pv-preview-title { position: absolute; z-index: 1; top: 3px; left: 10px; color: #8b9aa2; font-size: 11px; pointer-events: none; }
 .pv-preview-canvas { display: block; width: 100%; height: 132px; }
 .chart-empty-hint { margin-top: 7px; color: #e6a23c; font-size: 12px; }
@@ -302,7 +304,7 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
   .chart-status { align-self: flex-end; }
   .wave-chart { height: 600px; }
   .wave-chart.is-merge { height: 520px; }
-  .pv-preview-shell, .pv-preview-canvas { height: 118px; }
+  .pv-preview-row, .pv-preview-canvas { height: 118px; }
   .chart-toolbar-note { font-size: 11px; }
   .chart-point-count { display: none; }
   .readout-grid { grid-template-columns: 1fr; }

+ 32 - 7
frontend/src/types.ts

@@ -1,8 +1,23 @@
 export const MEASUREMENT_TYPES = ['压力', '位移', '加速度'] as const
 export type MeasurementType = (typeof MEASUREMENT_TYPES)[number]
 
+export const DEVICE_POINTS = ['压力盖侧', '压力轴侧', '活塞杆沉降', '十字头振动', '自由端振动', '驱动端振动'] as const
+export type DevicePoint = (typeof DEVICE_POINTS)[number]
+
+export const DEVICE_POINT_TO_TYPE: Record<DevicePoint, MeasurementType> = {
+  压力盖侧: '压力',
+  压力轴侧: '压力',
+  活塞杆沉降: '位移',
+  十字头振动: '加速度',
+  自由端振动: '加速度',
+  驱动端振动: '加速度',
+}
+
+export const PRIMARY_DEVICE_POINT: DevicePoint = '压力盖侧'
+
 export type QueryOption = {
-  pointName: string
+  devicePart: string
+  devicePoint: DevicePoint
   measurementType: MeasurementType
   minTime: string
   maxTime: string
@@ -11,6 +26,8 @@ export type QueryOption = {
 
 export type FileInfo = {
   id: number
+  devicePoint?: DevicePoint
+  measurementType?: MeasurementType
   sampleCount: number
   sampleFrequencyHz: number
   rpm: number
@@ -20,7 +37,7 @@ export type FileInfo = {
 export type TimePoint = {
   index: number
   sampleTime: string
-  files: Partial<Record<MeasurementType, FileInfo>>
+  files: Partial<Record<DevicePoint, FileInfo>>
 }
 
 export type TspluseRulerResponse = {
@@ -32,7 +49,9 @@ export type TspluseRulerResponse = {
 export type QueryOptionsResponse = {
   source: 'database' | 'demo'
   measurementTypes: MeasurementType[]
-  pointNames: string[]
+  deviceParts: string[]
+  devicePoints: DevicePoint[]
+  devicePointToType: Record<DevicePoint, MeasurementType>
   options: QueryOption[]
   pretrainCounts?: Record<string, number>
   abnormalCounts?: Record<string, number>
@@ -41,8 +60,8 @@ export type QueryOptionsResponse = {
 
 export type TimePointsResponse = {
   source: 'database' | 'demo'
-  pointName: string
-  measurementTypes: MeasurementType[]
+  devicePart: string
+  devicePoints: DevicePoint[]
   total: number
   points: TimePoint[]
   referencePoints?: TimePoint[]
@@ -62,6 +81,7 @@ export type WaveValue = {
 }
 
 export type WaveSeries = {
+  devicePoint: DevicePoint
   measurementType: MeasurementType
   color: string
   data: WaveValue[]
@@ -80,12 +100,15 @@ export type Cycle = {
   startSampleIndex: number
   endSampleIndex: number
   sourceType: MeasurementType
+  devicePoint: DevicePoint
+  background?: boolean
 }
 
 export type WaveWindowFile = {
   id: number
   pointIndex: number
   sampleTime: string
+  devicePoint: DevicePoint
   measurementType: MeasurementType
   sampleCount: number
   sampleFrequencyHz: number
@@ -100,8 +123,9 @@ export type WaveWindowFile = {
 export type WaveWindowResponse = {
   source: 'database' | 'demo'
   notice: string | null
-  pointName: string
-  measurementTypes: MeasurementType[]
+  devicePart: string
+  devicePoints: DevicePoint[]
+  primaryPoint: DevicePoint
   points: TimePoint[]
   xMin: number
   xMax: number
@@ -110,6 +134,7 @@ export type WaveWindowResponse = {
     name: string
     color: string
     sourceMeasurementType: MeasurementType
+    sourceDevicePoint: DevicePoint
     data: Array<{
       value: [number, number]
       x: number

+ 3 - 4
frontend/src/utils/axis.ts

@@ -27,10 +27,9 @@ export function fixedAxisRange(
     return { min, max, interval }
   }
   const raw = (max - min) / 6
-  const magnitude = 10 ** Math.floor(Math.log10(Math.max(raw, 1)))
-  const fraction = raw / magnitude
-  const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10
-  const interval = Math.max(1, niceFraction * magnitude)
+  const interval = type === '压力'
+    ? Math.max(1, niceTickInterval(Math.max(raw, 1), 1))
+    : niceTickInterval(max - min, 6)
   min = Math.floor(min / interval) * interval
   max = Math.ceil(max / interval) * interval
   return { min, max, interval }