Procházet zdrojové kódy

首个周期模式下叠加全场点位(PKS)数据:点位下拉、每周期一点曲线、时间点行、文件列表合并表

18922397810 před 1 týdnem
rodič
revize
0f76397889

+ 16 - 0
backend/app/main.py

@@ -15,6 +15,7 @@ class TimePointInput(BaseModel):
     index: int = Field(ge=0)
     sampleTime: str
     files: dict[str, Any]
+    siteValues: dict[str, float | None] = Field(default={})
 
 
 class WaveWindowInput(BaseModel):
@@ -24,6 +25,7 @@ class WaveWindowInput(BaseModel):
     maxPoints: int = Field(default=200000, ge=256, le=200000)
     noSampling: bool = False
     firstCycleOnly: bool = False
+    sitePoints: list[str] = Field(default=[])
 
 
 class AnnotationInput(BaseModel):
@@ -123,6 +125,7 @@ def time_points(
     include_stopped: bool = False,
     min_status: int | None = Query(default=None, ge=0),
     status_filter: list[str] = Query(default=[]),
+    site_points: list[str] = Query(default=[]),
     _auth: str = Depends(require_auth),
 ) -> dict[str, Any]:
     try:
@@ -134,6 +137,7 @@ def time_points(
             include_stopped,
             min_status,
             status_filter,
+            site_points,
         )
     except ValueError as error:
         raise HTTPException(status_code=400, detail=str(error)) from error
@@ -141,6 +145,17 @@ def time_points(
         raise HTTPException(status_code=503, detail=str(error)) from error
 
 
+@app.get("/api/site-points")
+def site_points(
+    device_part: str = Query(min_length=1),
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        return data_service.site_points(device_part)
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
 @app.get("/api/tspluse-ruler")
 def tspluse_ruler(_auth: str = Depends(require_auth)) -> dict[str, Any]:
     try:
@@ -159,6 +174,7 @@ def wave_window(payload: WaveWindowInput, _auth: str = Depends(require_auth)) ->
             payload.maxPoints,
             payload.noSampling,
             payload.firstCycleOnly,
+            payload.sitePoints,
         )
     except ValueError as error:
         raise HTTPException(status_code=400, detail=str(error)) from error

+ 131 - 0
backend/app/services/data_service.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import re
 from collections import OrderedDict
 from datetime import datetime, timedelta
 from functools import lru_cache
@@ -43,6 +44,11 @@ PHASES = (
 )
 ANNOTATION_LABELS = ("正常", "异常")
 
+# PKS 全场点位:机组号 -> pks_long_sample.import_batch_id
+# 7号机=30、8号机=31、9号机=32。
+UNIT_BATCH = {"7": 30, "8": 31, "9": 32}
+_SITE_POINT_PATTERN = re.compile(r"^YSJ([789])_([1-9]|1[0-9]|2[0-9]|3[0-9]|4[0-1])$")
+
 # Extra samples fetched past cycle_end so detect_cycles can see the zero marker
 # that closes the first cycle (cycle_end is exclusive, the marker sits at it).
 FIRST_CYCLE_PAD = 64
@@ -132,6 +138,20 @@ def _primary_device_point(points: list[str]) -> str:
     return points[0]
 
 
+def _unit_number(device_part: str) -> str | None:
+    """从 机组与部位 提取机组号(7/8/9),非 7/8/9 机组返回 None。"""
+    match = re.match(r"^([789])号机组", device_part.strip())
+    return match.group(1) if match else None
+
+
+def _site_point_column(item_name: str, unit: str | None) -> str | None:
+    """校验全场点位名称并映射到 pks_long_sample 的列名(如 YSJ7_3 -> YSJ_3)。"""
+    match = _SITE_POINT_PATTERN.match(item_name)
+    if not match or match.group(1) != unit:
+        return None
+    return f"YSJ_{match.group(2)}"
+
+
 class DataService:
     # 数据库失败后的重试冷却时间(秒)。超过该时间后自动重连数据库,
     # 避免一次网络抖动就把服务永久锁死在演示数据模式。
@@ -267,6 +287,98 @@ class DataService:
         result["source"] = source
         return result
 
+    def site_points(self, device_part: str) -> dict[str, Any]:
+        """机组(7/8/9)的全场点位记录(site_point 表中 YSJ{机组号}_1..41)。"""
+
+        unit = _unit_number(device_part)
+
+        def database_query():
+            if unit is None:
+                return []
+            allowed = {f"YSJ{unit}_{n}" for n in range(1, 42)}
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    cursor.execute(
+                        "SELECT ItemName, ItemDescription FROM site_point WHERE ItemName LIKE %s",
+                        (f"YSJ{unit}\\_%",),
+                    )
+                    rows = cursor.fetchall()
+            items = [
+                {"itemName": row["ItemName"], "itemDescription": row["ItemDescription"] or ""}
+                for row in rows
+                if row["ItemName"] in allowed
+            ]
+            items.sort(key=lambda item: int(item["itemName"].rsplit("_", 1)[1]))
+            return items
+
+        def demo_query():
+            return []
+
+        items, source = self._run_with_fallback(database_query, demo_query)
+        return {
+            "source": source,
+            "unit": unit,
+            "items": items,
+            "notice": self._source_notice(source),
+        }
+
+    @staticmethod
+    def _attach_site_values(points: list[dict[str, Any]], unit: str, site_points: list[str]) -> None:
+        """把 pks_long_sample 的最近邻值挂到每个时间点的 siteValues 上。
+
+        pks 数据 5 秒一条、wave_file 15 分钟一条。按用户口径做分钟/5秒级对齐:
+        把 wave 采样时刻四舍五入到最近的 5 秒格点,再用一次 ``sample_time IN (...)``
+        精确取数(结果行数 = 时间点数),避免把整段 pks 拉出来。
+        """
+        columns = [
+            (item_name, column)
+            for item_name in site_points
+            if (column := _site_point_column(item_name, unit)) is not None
+        ]
+        if not columns or not points:
+            for point in points:
+                point.setdefault("siteValues", {})
+            return
+        rounded: list[datetime] = []
+        for point in points:
+            timestamp = datetime.strptime(point["sampleTime"], "%Y-%m-%d %H:%M:%S")
+            rounded.append(datetime.fromtimestamp(round(timestamp.timestamp() / 5.0) * 5))
+        placeholders = ", ".join(["%s"] * len(rounded))
+        for item_name, column in columns:
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    cursor.execute(
+                        f"SELECT sample_time, `{column}` AS value FROM pks_long_sample "
+                        f"WHERE import_batch_id = %s AND sample_time IN ({placeholders})",
+                        (UNIT_BATCH[unit], *rounded),
+                    )
+                    rows = cursor.fetchall()
+            by_time = {row["sample_time"]: row["value"] for row in rows}
+            for index, target_time in enumerate(rounded):
+                value = by_time.get(target_time)
+                points[index].setdefault("siteValues", {})[item_name] = (
+                    float(value) if value is not None else None
+                )
+
+    @staticmethod
+    def _build_site_series(site_points: list[str], points: list[dict[str, Any]]) -> dict[str, Any]:
+        """把时间点上的 siteValues 整理成每周期一个点的曲线系列。"""
+        series = []
+        for item_name in site_points:
+            data = []
+            for index, point in enumerate(points):
+                value = (point.get("siteValues") or {}).get(item_name)
+                if value is not None:
+                    data.append(
+                        {
+                            "value": [index, float(value)],
+                            "x": index,
+                            "sampleTime": point["sampleTime"],
+                        },
+                    )
+            series.append({"itemName": item_name, "data": data})
+        return {"points": site_points, "series": series}
+
     def time_points(
         self,
         device_part: str,
@@ -276,6 +388,7 @@ class DataService:
         include_stopped: bool = False,
         min_status: int | None = None,
         status_filter: list[str] | None = None,
+        site_points: list[str] | None = None,
     ) -> dict[str, Any]:
         if not device_part.strip():
             raise ValueError("机组与部位不能为空")
@@ -367,6 +480,15 @@ class DataService:
 
         result, source = self._run_with_fallback(database_query, demo_query)
         points, reference = result
+        # 全场点位(PKS)数据是辅助层:任意失败都静默跳过,不影响主查询。
+        try:
+            unit = _unit_number(device_part)
+            if unit and site_points:
+                self._attach_site_values(points, unit, site_points)
+        except Exception:
+            pass
+        for point in points:
+            point.setdefault("siteValues", {})
         return {
             "source": source,
             "devicePart": device_part,
@@ -416,6 +538,7 @@ class DataService:
         max_points: int,
         no_sampling: bool = False,
         first_cycle_only: bool = False,
+        site_points: list[str] | None = None,
     ) -> dict[str, Any]:
         selected_points = _validate_device_points(device_points)
         if not points:
@@ -459,6 +582,14 @@ class DataService:
             )
 
         result, source = self._run_with_fallback(database_query, demo_query)
+        # 全场点位(PKS)系列:仅首个周期模式叠加,每周期一个点。
+        if first_cycle_only and site_points:
+            try:
+                result["siteSeries"] = self._build_site_series(site_points, result["points"])
+            except Exception:
+                result["siteSeries"] = {"points": [], "series": []}
+        else:
+            result["siteSeries"] = {"points": [], "series": []}
         result["source"] = source
         result["notice"] = self._source_notice(source)
         return result

+ 142 - 22
frontend/src/App.vue

@@ -1,18 +1,18 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { ElMessageBox } from 'element-plus'
-import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConfig, fetchAnnotationsByIds, fetchPeriodDetail, fetchPretrainStatus, fetchQueryOptions, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, recordPretrain } from './api'
+import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConfig, fetchAnnotationsByIds, fetchPeriodDetail, fetchPretrainStatus, fetchQueryOptions, fetchSitePoints, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, recordPretrain } from './api'
 import PeriodModal from './components/PeriodModal.vue'
 import { fixedAxisRange } from './utils/axis'
 import TimePointStrip from './components/TimePointStrip.vue'
 import WaveChart from './components/WaveChart.vue'
 import { STOPPED_COLOR, statusGradientColor } from './statusColor'
-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'
+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 SitePoint, type TimePoint, type QueryOptionsResponse, type WaveWindowFile, type WaveWindowResponse } from './types'
 
 const queryMeta = ref<QueryOptionsResponse | null>(null)
 const selectedDevicePart = ref('')
 const selectedDevicePoints = ref<DevicePoint[]>([PRIMARY_DEVICE_POINT])
-const fileListPoint = ref<DevicePoint>(PRIMARY_DEVICE_POINT)
+const fileListPoint = ref<DevicePoint | string>(PRIMARY_DEVICE_POINT)
 const minTime = ref('')
 const maxTime = ref('')
 const selectedTimeRangePoint = ref<DevicePoint | ''>('')
@@ -24,6 +24,8 @@ const abnormalOnly = ref(false)
 const noCycleOnly = ref(false)
 const minStatus = ref<number | null>(null)
 const firstCycleOnly = ref(false)
+const sitePointOptions = ref<SitePoint[]>([])
+const selectedSitePoints = ref<string[]>([])
 const ruler = ref<{ min: number; max: number } | null>(null)
 const timePoints = ref<TimePoint[]>([])
 const referencePoints = ref<TimePoint[]>([])
@@ -55,6 +57,49 @@ let waveRequestId = 0
 const deviceParts = computed(() => queryMeta.value?.deviceParts ?? [])
 const devicePoints = computed(() => queryMeta.value?.devicePoints ?? DEVICE_POINTS)
 
+const unitNumber = computed(() => {
+  const match = /^([789])号机组/.exec(selectedDevicePart.value)
+  return match ? match[1] : null
+})
+const isPksMode = computed(() => firstCycleOnly.value && !!unitNumber.value)
+const pointModel = computed({
+  get: () => (isPksMode.value
+    ? [...selectedDevicePoints.value as unknown as string[], ...selectedSitePoints.value]
+    : selectedDevicePoints.value as unknown as string[]),
+  set: (value: string[]) => {
+    const wave = value.filter((item) => (DEVICE_POINTS as readonly string[]).includes(item)) as DevicePoint[]
+    const site = value.filter((item) => !(DEVICE_POINTS as readonly string[]).includes(item))
+    selectedDevicePoints.value = wave
+    selectedSitePoints.value = site
+  },
+})
+const sitePointLabel = (point: SitePoint) => `${point.itemName} ${point.itemDescription}`.trim()
+const SITE_TAB_LABEL = '全场点位'
+const siteDescriptionMap = computed<Record<string, string>>(() => {
+  const map: Record<string, string> = {}
+  for (const item of sitePointOptions.value) map[item.itemName] = item.itemDescription
+  return map
+})
+const siteDescription = (itemName: string) => siteDescriptionMap.value[itemName] || itemName
+const fileTabs = computed(() => {
+  if (!isPksMode.value) return selectedDevicePoints.value as unknown as string[]
+  return [
+    ...selectedDevicePoints.value as unknown as string[],
+    ...(selectedSitePoints.value.length ? [SITE_TAB_LABEL] : []),
+  ]
+})
+const isSiteTab = computed(() => fileListPoint.value === SITE_TAB_LABEL)
+const windowSiteRows = computed(() => {
+  if (!isSiteTab.value) return []
+  return (waveData.value?.points ?? []).map((point, index) => ({
+    index,
+    sampleTime: point.sampleTime,
+    values: Object.fromEntries(
+      selectedSitePoints.value.map((itemName) => [itemName, point.siteValues?.[itemName] ?? null]),
+    ),
+  }))
+})
+
 const devicePointCounts = computed<Record<string, { pre: number | null; ab: number | null }>>(() => {
   const map: Record<string, { pre: number | null; ab: number | null }> = {}
   for (const point of devicePoints.value) {
@@ -176,6 +221,19 @@ async function loadOptions() {
   }
 }
 
+async function loadSitePoints() {
+  if (!isPksMode.value || !selectedDevicePart.value) {
+    sitePointOptions.value = []
+    return
+  }
+  try {
+    const result = await fetchSitePoints(selectedDevicePart.value)
+    sitePointOptions.value = result.items
+  } catch {
+    sitePointOptions.value = []
+  }
+}
+
 async function loadTspluseRuler() {
   try {
     ruler.value = await fetchTspluseRuler()
@@ -205,6 +263,7 @@ async function loadTimePoints() {
       includeStopped: includeStopped.value,
       minStatus: noCycleOnly.value ? null : minStatus.value,
       statusFilter: statusFilter.value,
+      sitePoints: isPksMode.value ? selectedSitePoints.value : [],
     })
     timePoints.value = result.points
     referencePoints.value = result.referencePoints ?? []
@@ -241,6 +300,7 @@ async function loadWaveWindowNow() {
       maxPoints: maxPoints.value,
       noSampling: noSampling.value,
       firstCycleOnly: firstCycleOnly.value,
+      sitePoints: isPksMode.value ? selectedSitePoints.value : [],
     })
     if (requestId === waveRequestId) {
       waveData.value = result
@@ -279,6 +339,7 @@ function onTimeRangeChange(value: DevicePoint | '') {
 
 function onDevicePartChange() {
   selectedTimeRangePoint.value = ''
+  selectedSitePoints.value = []
   syncTimeBounds(true)
   scheduleTimePoints()
 }
@@ -552,6 +613,19 @@ watch(firstCycleOnly, () => {
   markChartDirty()
   void loadWaveWindowNow()
 })
+watch([firstCycleOnly, unitNumber], async () => {
+  if (!initialized.value) return
+  if (isPksMode.value) {
+    await loadSitePoints()
+    if (selectedSitePoints.value.length) fileListPoint.value = SITE_TAB_LABEL
+  } else {
+    sitePointOptions.value = []
+  }
+})
+watch(selectedSitePoints, (points) => {
+  if (initialized.value && isPksMode.value) scheduleTimePoints()
+  if (points.length) fileListPoint.value = SITE_TAB_LABEL
+})
 watch(waveData, () => void refreshPretrainStatus(), { deep: false })
 
 async function refreshPretrainStatus() {
@@ -720,7 +794,7 @@ onBeforeUnmount(() => {
           <div class="field field-types">
             <span class="field-label">测试点位</span>
             <el-select
-              v-model="selectedDevicePoints"
+              v-model="pointModel"
               class="query-control"
               size="large"
               multiple
@@ -729,19 +803,46 @@ onBeforeUnmount(() => {
               :max-collapse-tags="2"
               placeholder="选择测试点位"
             >
-              <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>
+              <template v-if="isPksMode">
+                <el-option-group label="波形点位">
+                  <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-option-group>
+                <el-option-group label="全场点位 (PKS)">
+                  <el-option
+                    v-for="point in sitePointOptions"
+                    :key="point.itemName"
+                    :value="point.itemName"
+                    :label="sitePointLabel(point)"
+                  />
+                </el-option-group>
+              </template>
+              <template v-else>
+                <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>
+              </template>
             </el-select>
           </div>
           <label class="field">
@@ -855,6 +956,8 @@ onBeforeUnmount(() => {
           :max-time="maxTime"
           :annotated-file-ids="annotatedFileIds"
           :ruler="ruler"
+          :site-points="isPksMode ? selectedSitePoints : []"
+          :site-descriptions="siteDescriptionMap"
           @update:start-index="onStartIndexChange"
         />
         <div class="selection-controls">
@@ -897,15 +1000,31 @@ onBeforeUnmount(() => {
             >查询图表</el-button>
           </div>
         </div>
-        <div v-if="waveData?.files.length" class="window-files">
+        <div v-if="(waveData?.files.length || windowSiteRows.length)" class="window-files">
           <div class="window-files-title">
-            <span>当前窗口 wave_file 记录</span>
+            <span>{{ isSiteTab ? '当前窗口 全场点位记录' : '当前窗口 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-button v-for="point in fileTabs" :key="point" :value="point">{{ point }}</el-radio-button>
             </el-radio-group>
           </div>
           <div class="window-files-scroll">
-            <table class="window-files-table">
+            <table v-if="isSiteTab" class="window-files-table">
+              <thead>
+                <tr>
+                  <th>#</th>
+                  <th>sample_time</th>
+                  <th v-for="itemName in selectedSitePoints" :key="itemName" :title="itemName">{{ siteDescription(itemName) }}</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="row in windowSiteRows" :key="row.index">
+                  <td class="mono">{{ row.index + 1 }}</td>
+                  <td class="mono">{{ row.sampleTime }}</td>
+                  <td v-for="itemName in selectedSitePoints" :key="itemName" class="mono">{{ row.values[itemName] ?? '—' }}</td>
+                </tr>
+              </tbody>
+            </table>
+            <table v-else class="window-files-table">
             <thead>
               <tr>
                 <th>id</th>
@@ -986,6 +1105,7 @@ onBeforeUnmount(() => {
           :mode="chartMode"
           :loading="waveLoading"
           :annotations="annotations"
+          :site-descriptions="siteDescriptionMap"
           @period-dblclick="openPeriod"
           @annotation-toggle="onAnnotationToggle"
         />
@@ -1009,7 +1129,7 @@ onBeforeUnmount(() => {
           <div class="readout-card"><span>显示策略</span><strong>MIN / MAX</strong></div>
         </div>
         <div class="data-footprint">
-          <div><span>测试点位</span><strong>{{ selectedDevicePoints.join(' / ') }}</strong></div>
+          <div><span>测试点位</span><strong>{{ [...selectedDevicePoints, ...(isPksMode ? selectedSitePoints : [])].join(' / ') || '未选择' }}</strong></div>
           <div><span>可用文件</span><strong>{{ availableTypeCount.toLocaleString() }}</strong></div>
           <div><span>抽样上限</span><strong>{{ maxPoints.toLocaleString() }} 点</strong></div>
         </div>

+ 9 - 0
frontend/src/api.ts

@@ -6,6 +6,8 @@ import type {
   DevicePoint,
   PeriodDetail,
   QueryOptionsResponse,
+  SitePoint,
+  SitePointsResponse,
   TimePoint,
   TimePointsResponse,
   TspluseRulerResponse,
@@ -66,6 +68,10 @@ export function fetchQueryOptions() {
   return request<QueryOptionsResponse>('/api/query-options')
 }
 
+export function fetchSitePoints(devicePart: string) {
+  return request<SitePointsResponse>(`/api/site-points?device_part=${encodeURIComponent(devicePart)}`)
+}
+
 export function fetchTimePoints(params: {
   devicePart: string
   devicePoints: DevicePoint[]
@@ -74,6 +80,7 @@ export function fetchTimePoints(params: {
   includeStopped?: boolean
   minStatus?: number | null
   statusFilter?: string[]
+  sitePoints?: string[]
 }) {
   const search = new URLSearchParams({ device_part: params.devicePart })
   params.devicePoints.forEach((value) => search.append('device_points', value))
@@ -82,6 +89,7 @@ export function fetchTimePoints(params: {
   if (params.includeStopped) search.set('include_stopped', 'true')
   if (params.minStatus) search.set('min_status', String(params.minStatus))
   ;(params.statusFilter ?? []).forEach((value) => search.append('status_filter', value))
+  ;(params.sitePoints ?? []).forEach((value) => search.append('site_points', value))
   return request<TimePointsResponse>(`/api/time-points?${search.toString()}`)
 }
 
@@ -96,6 +104,7 @@ export function fetchWaveWindow(params: {
   maxPoints: number
   noSampling: boolean
   firstCycleOnly?: boolean
+  sitePoints?: string[]
 }) {
   return request<WaveWindowResponse>('/api/wave-window', {
     method: 'POST',

+ 40 - 16
frontend/src/components/TimePointStrip.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import type { DevicePoint, MeasurementType, TimePoint } from '../types'
+import { SITE_POINT_COLORS } from '../types'
 import { RUNNING_COLOR, STOPPED_COLOR, statusGradientColor } from '../statusColor'
 
 const props = defineProps<{
@@ -14,6 +15,8 @@ const props = defineProps<{
   maxTime?: string
   annotatedFileIds?: number[]
   ruler?: { min: number; max: number } | null
+  sitePoints?: string[]
+  siteDescriptions?: Record<string, string>
 }>()
 
 const emit = defineEmits<{
@@ -24,6 +27,12 @@ const canvas = ref<HTMLCanvasElement | null>(null)
 const canvasHost = ref<HTMLElement | null>(null)
 let resizeObserver: ResizeObserver | undefined
 
+let drawLeft = 70
+
+function rowLabel(name: string) {
+  return props.siteDescriptions?.[name] || name
+}
+
 const maxStart = computed(() => Math.max(0, props.points.length - props.windowSize))
 const endIndex = computed(() => Math.min(props.points.length, props.startIndex + props.windowSize))
 
@@ -44,7 +53,12 @@ function draw() {
   const host = canvasHost.value
   if (!element || !host) return
   const width = Math.max(host.clientWidth, 320)
-  const height = 210
+  const topY = 32
+  const rowGap = 39
+  const rows = [...props.devicePoints, ...(props.sitePoints ?? [])]
+  const annotationY = topY + rows.length * rowGap
+  const tickY = annotationY + 109
+  const height = tickY + 30
   const ratio = window.devicePixelRatio || 1
   element.width = width * ratio
   element.height = height * ratio
@@ -55,13 +69,16 @@ function draw() {
   context.setTransform(ratio, 0, 0, ratio, 0, 0)
   context.clearRect(0, 0, width, height)
 
-  const left = 70
+  context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
+  let maxLabelWidth = 70
+  for (const name of rows) {
+    maxLabelWidth = Math.max(maxLabelWidth, Math.ceil(context.measureText(rowLabel(name)).width) + 14)
+  }
+  drawLeft = maxLabelWidth
+  const left = drawLeft
   const right = 20
   const available = Math.max(width - left - right, 1)
-  const rowGap = 39
-  const rows = props.devicePoints
-  const rowY = (index: number) => 32 + index * rowGap
-  const annotationY = 32 + rows.length * rowGap
+  const rowY = (index: number) => topY + index * rowGap
   const xAt = (index: number) => left + (props.points.length <= 1 ? 0 : index / (props.points.length - 1)) * available
   const selectedLeft = xAt(props.startIndex)
   const selectedRight = xAt(Math.min(props.points.length - 1, Math.max(props.startIndex, endIndex.value - 1)))
@@ -73,7 +90,7 @@ function draw() {
   context.lineWidth = 1
   context.strokeRect(selectedLeft + 0.5, 10.5, selectedWidth - 1, 171)
 
-  rows.forEach((devicePoint, rowIndex) => {
+  rows.forEach((rowName, rowIndex) => {
     const y = rowY(rowIndex)
     context.strokeStyle = '#d7e0e4'
     context.setLineDash([2, 5])
@@ -84,16 +101,23 @@ function draw() {
     context.setLineDash([])
     context.fillStyle = '#52616b'
     context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
-    context.fillText(devicePoint, 10, y + 4)
+    context.fillText(rowLabel(rowName), 10, y + 4)
 
+    const isSiteRow = rowIndex >= props.devicePoints.length
+    const siteIndex = rowIndex - props.devicePoints.length
     let lastX = -Infinity
     props.points.forEach((point, pointIndex) => {
-      const fileInfo = point.files[devicePoint]
-      if (!fileInfo) return
+      if (isSiteRow) {
+        if (point.siteValues?.[rowName] == null) return
+      } else if (!point.files[rowName as DevicePoint]) {
+        return
+      }
       const x = xAt(pointIndex)
       if (x - lastX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
       lastX = x
-      context.fillStyle = pointColor(devicePoint, fileInfo)
+      context.fillStyle = isSiteRow
+        ? SITE_POINT_COLORS[siteIndex % SITE_POINT_COLORS.length]
+        : pointColor(rowName as DevicePoint, point.files[rowName as DevicePoint]!)
       context.beginPath()
       context.arc(x, y, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
       context.fill()
@@ -145,12 +169,12 @@ function draw() {
     const x = xAt(index)
     context.strokeStyle = '#d6dfe3'
     context.beginPath()
-    context.moveTo(x, 180)
-    context.lineTo(x, 186)
+    context.moveTo(x, tickY)
+    context.lineTo(x, tickY + 6)
     context.stroke()
     const label = displayDate(props.points[index]?.sampleTime)
     context.textAlign = tick === 0 ? 'left' : tick === tickCount ? 'right' : 'center'
-    context.fillText(label, x, 202)
+    context.fillText(label, x, tickY + 22)
   }
   context.textAlign = 'left'
   if (!props.points.length) {
@@ -162,7 +186,7 @@ function draw() {
 function selectFromPointer(event: PointerEvent) {
   if (!canvas.value || !props.points.length) return
   const rect = canvas.value.getBoundingClientRect()
-  const left = 70
+  const left = drawLeft
   const right = 20
   const usable = Math.max(rect.width - left - right, 1)
   const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left - left) / usable))
@@ -176,7 +200,7 @@ function updateSlider(value: number | number[]) {
 }
 
 watch(
-  () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.annotatedFileIds, props.ruler],
+  () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.annotatedFileIds, props.ruler, props.sitePoints],
   () => nextTick(draw),
   { deep: true },
 )

+ 58 - 4
frontend/src/components/WaveChart.vue

@@ -2,6 +2,7 @@
 import * as echarts from 'echarts'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import type { Annotation, Cycle, TimePoint, WaveWindowResponse } from '../types'
+import { SITE_POINT_COLORS } from '../types'
 import { fixedAxisRange } from '../utils/axis'
 
 const props = defineProps<{
@@ -10,6 +11,7 @@ const props = defineProps<{
   mode: 'split' | 'merge'
   loading?: boolean
   annotations?: Annotation[]
+  siteDescriptions?: Record<string, string>
 }>()
 
 const emit = defineEmits<{
@@ -31,6 +33,7 @@ const colors: Record<string, string> = {
   合并信号: '#287f9e',
   周期数据: '#f56c6c',
   体积: '#4d9e6f',
+  全场点位: '#8d6e63',
 }
 
 const pvPhaseColors = [
@@ -67,12 +70,14 @@ 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
+  if (data.siteSeries?.series.some((series) => series.data.length)) count += 1
   return count
 }
 
 const plottedPointCount = computed(() => (
   (props.data?.series.reduce((total, series) => total + series.data.length, 0) ?? 0)
   + (props.data?.secondSeries.data.length ?? 0)
+  + (props.data?.siteSeries?.series.reduce((total, series) => total + series.data.length, 0) ?? 0)
 ))
 
 const hasPlottableData = computed(() => plottedPointCount.value > 0)
@@ -255,7 +260,10 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
   const periodBackground = periodAreas(backgroundCycles)
   const showSecond = data.secondSeries.data.length > 0
   const showVolume = data.volumeSeries.data.length > 0 || data.volumeSeries.info != null
+  const siteSeries = data.siteSeries?.series.filter((series) => series.data.length) ?? []
+  const showSite = siteSeries.length > 0
   const extraRows: string[] = []
+  if (showSite) extraRows.push('全场点位')
   if (showSecond) extraRows.push('周期数据')
   if (showVolume) extraRows.push('体积')
   const rows = props.mode === 'merge' ? ['合并信号'] : [...devicePoints, ...extraRows]
@@ -381,6 +389,28 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
           .map((item) => [item.x, (item.volume - volumeMin) / volumeSpan] as [number, number]),
       })
     }
+    if (showSite) {
+      const siteFinite = siteSeries.flatMap((series) => series.data.map((item) => item.value[1])).filter(Number.isFinite)
+      const [siteMin, siteMax] = siteFinite.length ? minMaxOf(siteFinite) : [0, 1]
+      const siteSpan = siteMax - siteMin || 1
+      siteSeries.forEach((siteItem, index) => {
+        const color = SITE_POINT_COLORS[index % SITE_POINT_COLORS.length]
+        series.push({
+          name: siteItem.itemName,
+          type: 'line',
+          xAxisIndex: 0,
+          yAxisIndex: 0,
+          showSymbol: true,
+          symbol: 'circle',
+          symbolSize: 6,
+          lineStyle: { width: 1.5, color, opacity: 0.8 },
+          itemStyle: { color },
+          data: siteItem.data
+            .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.value[1]))
+            .map((item) => [item.x, (item.value[1] - siteMin) / siteSpan] as [number, number]),
+        })
+      })
+    }
   } else {
     devicePoints.forEach((devicePoint, index) => {
       const source = data.series.find((series) => series.devicePoint === devicePoint)
@@ -403,8 +433,30 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
         markLine: index === 0 ? { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) } : undefined,
       })
     })
+    if (showSite) {
+      const siteIndex = devicePoints.length
+      siteSeries.forEach((siteItem, index) => {
+        const color = SITE_POINT_COLORS[index % SITE_POINT_COLORS.length]
+        series.push({
+          name: siteItem.itemName,
+          type: 'line',
+          z: 10,
+          xAxisIndex: siteIndex,
+          yAxisIndex: siteIndex,
+          showSymbol: true,
+          symbol: 'circle',
+          symbolSize: 6,
+          lineStyle: { width: 1.5, color, opacity: 0.8 },
+          itemStyle: { color },
+          data: siteItem.data
+            .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.value[1]))
+            .map((item) => [item.x, item.value[1]] as [number, number]),
+          markArea: { silent: true, data: periodBackground },
+        })
+      })
+    }
     if (showSecond) {
-      const secondIndex = devicePoints.length
+      const secondIndex = devicePoints.length + (showSite ? 1 : 0)
       series.push({
         name: '周期数据',
         type: 'line',
@@ -423,7 +475,7 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
       })
     }
     if (showVolume) {
-      const volumeIndex = devicePoints.length + (showSecond ? 1 : 0)
+      const volumeIndex = devicePoints.length + (showSite ? 1 : 0) + (showSecond ? 1 : 0)
       series.push({
         name: '体积',
         type: 'line',
@@ -464,14 +516,16 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
         params.forEach((item) => {
           const value = item.value?.[1]
           if (value !== undefined && value !== null) {
-            lines.push(`<span style="color:${item.color}">●</span> ${item.seriesName}: ${Number(value).toPrecision(7)}`)
+            const seriesName = props.siteDescriptions?.[item.seriesName] || item.seriesName
+            lines.push(`<span style="color:${item.color}">●</span> ${seriesName}: ${Number(value).toPrecision(7)}`)
           }
         })
         return lines.join('<br/>')
       },
     },
     legend: {
-      data: [...devicePoints, ...extraRows],
+      data: [...devicePoints, ...extraRows, ...siteSeries.map((series) => series.itemName)],
+      formatter: (name: string) => props.siteDescriptions?.[name] || name,
       top: 0,
       left: 70,
       itemWidth: 16,

+ 22 - 0
frontend/src/types.ts

@@ -15,6 +15,8 @@ export const DEVICE_POINT_TO_TYPE: Record<DevicePoint, MeasurementType> = {
 
 export const PRIMARY_DEVICE_POINT: DevicePoint = '压力盖侧'
 
+export const SITE_POINT_COLORS = ['#0f9d58', '#ff7043', '#5c6bc0', '#ffb300', '#26a69a', '#ec407a', '#9ccc65', '#8d6e63', '#d45a7c', '#4d9e6f']
+
 export type QueryOption = {
   devicePart: string
   devicePoint: DevicePoint
@@ -38,6 +40,19 @@ export type TimePoint = {
   index: number
   sampleTime: string
   files: Partial<Record<DevicePoint, FileInfo>>
+  siteValues?: Record<string, number | null>
+}
+
+export type SitePoint = {
+  itemName: string
+  itemDescription: string
+}
+
+export type SitePointsResponse = {
+  source: 'database' | 'demo'
+  unit: string | null
+  items: SitePoint[]
+  notice: string | null
 }
 
 export type TspluseRulerResponse = {
@@ -183,6 +198,13 @@ export type WaveWindowResponse = {
   diagnostics: Array<Record<string, string | number>>
   firstCycleMode?: boolean
   firstCycleNotice?: string | null
+  siteSeries: {
+    points: string[]
+    series: Array<{
+      itemName: string
+      data: Array<{ value: [number, number]; x: number; sampleTime: string }>
+    }>
+  }
 }
 
 export type PeriodDetail = {