Просмотр исходного кода

Add abnormal/no-cycle status filters and pinned normal reference to time points

18922397810 1 неделя назад
Родитель
Сommit
110f529518
6 измененных файлов с 111 добавлено и 18 удалено
  1. 2 0
      backend/app/main.py
  2. 48 4
      backend/app/services/data_service.py
  3. 41 10
      frontend/src/App.vue
  4. 2 0
      frontend/src/api.ts
  5. 17 4
      frontend/src/styles.css
  6. 1 0
      frontend/src/types.ts

+ 2 - 0
backend/app/main.py

@@ -118,6 +118,7 @@ def time_points(
     max_time: str | None = None,
     max_time: str | None = None,
     include_stopped: bool = False,
     include_stopped: bool = False,
     min_status: int | None = Query(default=None, ge=0),
     min_status: int | None = Query(default=None, ge=0),
+    status_filter: list[str] = Query(default=[]),
     _auth: str = Depends(require_auth),
     _auth: str = Depends(require_auth),
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     try:
     try:
@@ -128,6 +129,7 @@ def time_points(
             max_time,
             max_time,
             include_stopped,
             include_stopped,
             min_status,
             min_status,
+            status_filter,
         )
         )
     except ValueError as error:
     except ValueError as error:
         raise HTTPException(status_code=400, detail=str(error)) from error
         raise HTTPException(status_code=400, detail=str(error)) from error

+ 48 - 4
backend/app/services/data_service.py

@@ -215,10 +215,15 @@ class DataService:
         max_time: str | None,
         max_time: str | None,
         include_stopped: bool = False,
         include_stopped: bool = False,
         min_status: int | None = None,
         min_status: int | None = None,
+        status_filter: list[str] | None = None,
     ) -> dict[str, Any]:
     ) -> dict[str, Any]:
         if not point_name.strip():
         if not point_name.strip():
             raise ValueError("机组与部位不能为空")
             raise ValueError("机组与部位不能为空")
         types = _validate_measurement_types(measurement_types)
         types = _validate_measurement_types(measurement_types)
+        status_filters = {value for value in (status_filter or [])}
+        unknown = status_filters - {"abnormal", "no_cycle"}
+        if unknown:
+            raise ValueError(f"不支持的状态筛选:{'、'.join(sorted(unknown))}")
         start = _parse_time(min_time)
         start = _parse_time(min_time)
         end = _parse_time(max_time)
         end = _parse_time(max_time)
         if start and end and start > end:
         if start and end and start > end:
@@ -233,9 +238,15 @@ class DataService:
             params: list[Any] = [point_name, *types]
             params: list[Any] = [point_name, *types]
             if not include_stopped:
             if not include_stopped:
                 clauses.append("rpm > 0")
                 clauses.append("rpm > 0")
-            if min_status is not None and min_status > 0:
+            if min_status is not None and min_status > 0 and "no_cycle" not in status_filters:
                 clauses.append("tspluse_status >= %s")
                 clauses.append("tspluse_status >= %s")
                 params.append(min_status)
                 params.append(min_status)
+            if "abnormal" in status_filters and "no_cycle" in status_filters:
+                clauses.append("(tspluse_status > 0 OR tspluse_status = -1)")
+            elif "abnormal" in status_filters:
+                clauses.append("tspluse_status > 0")
+            elif "no_cycle" in status_filters:
+                clauses.append("tspluse_status = -1")
             if start:
             if start:
                 clauses.append("sample_time >= %s")
                 clauses.append("sample_time >= %s")
                 params.append(start)
                 params.append(start)
@@ -255,18 +266,51 @@ class DataService:
                         params,
                         params,
                     )
                     )
                     rows = cursor.fetchall()
                     rows = cursor.fetchall()
-            return self._group_time_points(rows)
+            reference_rows: list[dict[str, Any]] = []
+            if status_filters and rows:
+                reference_where = [
+                    "point_name = %s",
+                    "measurement_type = '压力'",
+                    "tspluse_status = 0",
+                ]
+                reference_params: list[Any] = [point_name]
+                if not include_stopped:
+                    reference_where.append("rpm > 0")
+                if start:
+                    reference_where.append("sample_time >= %s")
+                    reference_params.append(start)
+                if end:
+                    reference_where.append("sample_time <= %s")
+                    reference_params.append(end)
+                reference_params.append(rows[0]["sample_time"])
+                with get_connection() as connection:
+                    with connection.cursor() as cursor:
+                        cursor.execute(
+                            f"""
+                            SELECT id, point_name, measurement_type, sample_time,
+                                   sample_count, sample_frequency_hz, rpm, tspluse_status
+                            FROM wave_file
+                            WHERE {' AND '.join(reference_where)}
+                            ORDER BY ABS(TIMESTAMPDIFF(SECOND, sample_time, %s)) ASC
+                            LIMIT 1
+                            """,
+                            reference_params,
+                        )
+                        reference_rows = cursor.fetchall()
+            return self._group_time_points(rows), self._group_time_points(reference_rows)
 
 
         def demo_query():
         def demo_query():
-            return self._demo_time_points(point_name, types, start, end)
+            return self._demo_time_points(point_name, types, start, end), []
 
 
-        points, source = self._run_with_fallback(database_query, demo_query)
+        result, source = self._run_with_fallback(database_query, demo_query)
+        points, reference = result
         return {
         return {
             "source": source,
             "source": source,
             "pointName": point_name,
             "pointName": point_name,
             "measurementTypes": types,
             "measurementTypes": types,
             "total": len(points),
             "total": len(points),
             "points": points,
             "points": points,
+            "referencePoints": reference,
             "notice": self._source_notice(source),
             "notice": self._source_notice(source),
         }
         }
 
 

+ 41 - 10
frontend/src/App.vue

@@ -17,9 +17,12 @@ const windowSize = ref(4)
 const maxPoints = ref(200000)
 const maxPoints = ref(200000)
 const noSampling = ref(false)
 const noSampling = ref(false)
 const includeStopped = ref(false)
 const includeStopped = ref(false)
+const abnormalOnly = ref(false)
+const noCycleOnly = ref(false)
 const minStatus = ref<number | null>(null)
 const minStatus = ref<number | null>(null)
 const ruler = ref<{ min: number; max: number } | null>(null)
 const ruler = ref<{ min: number; max: number } | null>(null)
 const timePoints = ref<TimePoint[]>([])
 const timePoints = ref<TimePoint[]>([])
+const referencePoints = ref<TimePoint[]>([])
 const startIndex = ref(0)
 const startIndex = ref(0)
 const waveData = ref<WaveWindowResponse | null>(null)
 const waveData = ref<WaveWindowResponse | null>(null)
 const chartMode = ref<'split' | 'merge'>('split')
 const chartMode = ref<'split' | 'merge'>('split')
@@ -56,8 +59,20 @@ const selectedOptionRows = computed<QueryOption[]>(() => (
     row.pointName === selectedPointName.value && selectedTypes.value.includes(row.measurementType)
     row.pointName === selectedPointName.value && selectedTypes.value.includes(row.measurementType)
   )) ?? []
   )) ?? []
 ))
 ))
-const selectedWindowPoints = computed(() => timePoints.value.slice(startIndex.value, startIndex.value + windowSize.value))
-const endIndex = computed(() => Math.min(timePoints.value.length, startIndex.value + windowSize.value))
+const selectedWindowPoints = computed(() => {
+  const slice = timePoints.value.slice(startIndex.value, startIndex.value + stripWindowSize.value)
+  return hasReference.value ? [...referencePoints.value, ...slice] : slice
+})
+const hasReference = computed(() => referencePoints.value.length > 0)
+const stripWindowSize = computed(() => Math.max(0, windowSize.value - (hasReference.value ? 1 : 0)))
+const maxStart = computed(() => Math.max(0, timePoints.value.length - stripWindowSize.value))
+const endIndex = computed(() => Math.min(timePoints.value.length, startIndex.value + stripWindowSize.value))
+const statusFilter = computed(() => {
+  const list: string[] = []
+  if (abnormalOnly.value) list.push('abnormal')
+  if (noCycleOnly.value) list.push('no_cycle')
+  return list
+})
 const currentCycles = computed(() => waveData.value?.cycles ?? [])
 const currentCycles = computed(() => waveData.value?.cycles ?? [])
 const currentSource = computed(() => waveData.value?.source ?? timePointsSource.value)
 const currentSource = computed(() => waveData.value?.source ?? timePointsSource.value)
 const timePointsSource = ref<'database' | 'demo'>('demo')
 const timePointsSource = ref<'database' | 'demo'>('demo')
@@ -148,18 +163,21 @@ async function loadTimePoints() {
       minTime: toApiTime(minTime.value),
       minTime: toApiTime(minTime.value),
       maxTime: toApiTime(maxTime.value),
       maxTime: toApiTime(maxTime.value),
       includeStopped: includeStopped.value,
       includeStopped: includeStopped.value,
-      minStatus: minStatus.value,
+      minStatus: noCycleOnly.value ? null : minStatus.value,
+      statusFilter: statusFilter.value,
     })
     })
     timePoints.value = result.points
     timePoints.value = result.points
+    referencePoints.value = result.referencePoints ?? []
     timePointsSource.value = result.source
     timePointsSource.value = result.source
     startIndex.value = Math.max(
     startIndex.value = Math.max(
       0,
       0,
-      Math.min(firstRunningIndex(), Math.max(0, timePoints.value.length - windowSize.value)),
+      Math.min(firstRunningIndex(), Math.max(0, timePoints.value.length - stripWindowSize.value)),
     )
     )
     chartDirty.value = true
     chartDirty.value = true
     void loadAnnotations()
     void loadAnnotations()
   } catch (error) {
   } catch (error) {
     timePoints.value = []
     timePoints.value = []
+    referencePoints.value = []
     errorMessage.value = error instanceof Error ? error.message : '时间点读取失败'
     errorMessage.value = error instanceof Error ? error.message : '时间点读取失败'
   } finally {
   } finally {
     timePointsLoading.value = false
     timePointsLoading.value = false
@@ -227,13 +245,13 @@ function onTimeChange() {
 }
 }
 
 
 function onStartIndexChange(value: number) {
 function onStartIndexChange(value: number) {
-  startIndex.value = Math.max(0, Math.min(value, Math.max(0, timePoints.value.length - windowSize.value)))
+  startIndex.value = Math.max(0, Math.min(value, maxStart.value))
   markChartDirty()
   markChartDirty()
 }
 }
 
 
 function onWindowSizeChange(value: number) {
 function onWindowSizeChange(value: number) {
   windowSize.value = Math.max(1, Math.min(200, Math.round(value || 1)))
   windowSize.value = Math.max(1, Math.min(200, Math.round(value || 1)))
-  startIndex.value = Math.min(startIndex.value, Math.max(0, timePoints.value.length - windowSize.value))
+  startIndex.value = Math.min(startIndex.value, maxStart.value)
   markChartDirty()
   markChartDirty()
 }
 }
 
 
@@ -243,8 +261,8 @@ function resetWindow() {
 }
 }
 
 
 function pageWindow(direction: -1 | 1) {
 function pageWindow(direction: -1 | 1) {
-  const next = startIndex.value + direction * windowSize.value
-  startIndex.value = Math.max(0, Math.min(next, Math.max(0, timePoints.value.length - windowSize.value)))
+  const next = startIndex.value + direction * stripWindowSize.value
+  startIndex.value = Math.max(0, Math.min(next, maxStart.value))
   markChartDirty()
   markChartDirty()
 }
 }
 
 
@@ -444,6 +462,12 @@ watch(noSampling, () => {
 watch(includeStopped, () => {
 watch(includeStopped, () => {
   if (initialized.value) scheduleTimePoints()
   if (initialized.value) scheduleTimePoints()
 })
 })
+watch(abnormalOnly, () => {
+  if (initialized.value) scheduleTimePoints()
+})
+watch(noCycleOnly, () => {
+  if (initialized.value) scheduleTimePoints()
+})
 watch(minStatus, () => {
 watch(minStatus, () => {
   if (initialized.value) scheduleTimePoints()
   if (initialized.value) scheduleTimePoints()
 })
 })
@@ -674,9 +698,11 @@ onBeforeUnmount(() => {
             <label class="field field-check">
             <label class="field field-check">
               <span class="field-label">运行状态</span>
               <span class="field-label">运行状态</span>
               <el-checkbox v-model="includeStopped" class="query-checkbox" size="large">停机</el-checkbox>
               <el-checkbox v-model="includeStopped" class="query-checkbox" size="large">停机</el-checkbox>
+              <el-checkbox v-model="abnormalOnly" class="query-checkbox" size="large">异常</el-checkbox>
+              <el-checkbox v-model="noCycleOnly" class="query-checkbox" size="large">无周期</el-checkbox>
             </label>
             </label>
             <label class="field field-status">
             <label class="field field-status">
-              <span class="field-label">质心距离 <em>异常级别 ≥ 输入值</em></span>
+              <span class="field-label">质心距离</span>
               <el-input-number
               <el-input-number
                 v-model="minStatus"
                 v-model="minStatus"
                 class="query-control"
                 class="query-control"
@@ -686,8 +712,10 @@ onBeforeUnmount(() => {
                 :step="1"
                 :step="1"
                 :step-strictly="true"
                 :step-strictly="true"
                 controls-position="right"
                 controls-position="right"
+                :disabled="queryLoading || noCycleOnly"
                 clearable
                 clearable
                 placeholder="可空"
                 placeholder="可空"
+                :title="noCycleOnly ? '勾选无周期时忽略质心距离' : undefined"
               />
               />
             </label>
             </label>
           </div>
           </div>
@@ -700,7 +728,7 @@ onBeforeUnmount(() => {
           :points="timePoints"
           :points="timePoints"
           :measurement-types="selectedTypes"
           :measurement-types="selectedTypes"
           :start-index="startIndex"
           :start-index="startIndex"
-          :window-size="windowSize"
+          :window-size="stripWindowSize"
           :loading="timePointsLoading"
           :loading="timePointsLoading"
           :min-time="minTime"
           :min-time="minTime"
           :max-time="maxTime"
           :max-time="maxTime"
@@ -712,6 +740,9 @@ onBeforeUnmount(() => {
           <div class="selection-readout">
           <div class="selection-readout">
             <span class="selection-accent"></span>
             <span class="selection-accent"></span>
             <span>当前窗口覆盖 <strong>{{ selectedWindowPoints.length }}</strong> 个时间点</span>
             <span>当前窗口覆盖 <strong>{{ selectedWindowPoints.length }}</strong> 个时间点</span>
+            <span v-if="hasReference" class="reference-badge" title="始终保留一个 status=0 的正常数据用于对比,固定不随翻页变化">
+              正常对比:{{ formatDateTime(referencePoints[0]?.sampleTime) }}
+            </span>
           </div>
           </div>
           <div class="selection-actions">
           <div class="selection-actions">
             <el-button class="ghost-button" plain :disabled="!startIndex" @click="resetWindow">回到起点</el-button>
             <el-button class="ghost-button" plain :disabled="!startIndex" @click="resetWindow">回到起点</el-button>

+ 2 - 0
frontend/src/api.ts

@@ -73,6 +73,7 @@ export function fetchTimePoints(params: {
   maxTime?: string
   maxTime?: string
   includeStopped?: boolean
   includeStopped?: boolean
   minStatus?: number | null
   minStatus?: number | null
+  statusFilter?: string[]
 }) {
 }) {
   const search = new URLSearchParams({ point_name: params.pointName })
   const search = new URLSearchParams({ point_name: params.pointName })
   params.measurementTypes.forEach((value) => search.append('measurement_types', value))
   params.measurementTypes.forEach((value) => search.append('measurement_types', value))
@@ -80,6 +81,7 @@ export function fetchTimePoints(params: {
   if (params.maxTime) search.set('max_time', params.maxTime)
   if (params.maxTime) search.set('max_time', params.maxTime)
   if (params.includeStopped) search.set('include_stopped', 'true')
   if (params.includeStopped) search.set('include_stopped', 'true')
   if (params.minStatus) search.set('min_status', String(params.minStatus))
   if (params.minStatus) search.set('min_status', String(params.minStatus))
+  ;(params.statusFilter ?? []).forEach((value) => search.append('status_filter', value))
   return request<TimePointsResponse>(`/api/time-points?${search.toString()}`)
   return request<TimePointsResponse>(`/api/time-points?${search.toString()}`)
 }
 }
 
 

+ 17 - 4
frontend/src/styles.css

@@ -117,12 +117,15 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .query-control .el-tag { font-size: 13px; }
 .query-control .el-tag { font-size: 13px; }
 .window-number .el-input__inner { text-align: center; }
 .window-number .el-input__inner { text-align: center; }
 .query-button.el-button { width: 100%; height: 40px; margin: 0; border-radius: 4px; font-size: 14px; }
 .query-button.el-button { width: 100%; height: 40px; margin: 0; border-radius: 4px; font-size: 14px; }
-.query-row-2 { display: flex; align-items: flex-end; gap: 16px; grid-column: 1 / -1; }
+.query-row-2 { display: flex; align-items: flex-start; gap: 16px; grid-column: 1 / -1; }
+.query-row-2 .field-check,
+.query-row-2 .field-status { min-height: 68px; }
 .query-row-2 .field-check .field-label,
 .query-row-2 .field-check .field-label,
 .query-row-2 .field-status .field-label { white-space: nowrap; }
 .query-row-2 .field-status .field-label { white-space: nowrap; }
-.query-checkbox.el-checkbox { height: 40px; margin-right: 0; }
+.query-checkbox.el-checkbox { height: 40px; margin-right: 20px; display: inline-flex; align-items: center; }
+.query-checkbox.el-checkbox:last-child { margin-right: 0; }
 .query-checkbox .el-checkbox__label { font-size: 14px; color: #303133; }
 .query-checkbox .el-checkbox__label { font-size: 14px; color: #303133; }
-.field-status { width: 240px; max-width: 100%; }
+.field-status { width: 120px; max-width: 100%; }
 .data-alert { margin-top: 16px; }
 .data-alert { margin-top: 16px; }
 
 
 .selection-panel { padding: 20px 24px 18px; }
 .selection-panel { padding: 20px 24px 18px; }
@@ -152,7 +155,17 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .selection-actions { display: flex; gap: 8px; flex-shrink: 0; }
 .selection-actions { display: flex; gap: 8px; flex-shrink: 0; }
 .selection-readout strong { color: #303133; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: 500; }
 .selection-readout strong { color: #303133; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: 500; }
 .selection-accent { width: 7px; height: 7px; background: #409eff; border-radius: 50%; }
 .selection-accent { width: 7px; height: 7px; background: #409eff; border-radius: 50%; }
-.jump-select { width: 220px; }
+.reference-badge {
+  flex-shrink: 0;
+  padding: 1px 8px;
+  border: 1px solid #4d9e6f;
+  border-radius: 10px;
+  color: #2e7d32;
+  background: #ecf7f1;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.jump-select { width: 280px; }
 .jump-select .el-select__wrapper { min-height: 32px; }
 .jump-select .el-select__wrapper { min-height: 32px; }
 .ghost-button.chart-query { font-weight: 500; }
 .ghost-button.chart-query { font-weight: 500; }
 .ghost-button.chart-query.is-dirty {
 .ghost-button.chart-query.is-dirty {

+ 1 - 0
frontend/src/types.ts

@@ -44,6 +44,7 @@ export type TimePointsResponse = {
   measurementTypes: MeasurementType[]
   measurementTypes: MeasurementType[]
   total: number
   total: number
   points: TimePoint[]
   points: TimePoint[]
+  referencePoints?: TimePoint[]
   notice: string | null
   notice: string | null
 }
 }