Bladeren bron

新增告警监控:告警列表/扫描接口与润滑油压力低算法,前端标签页与告警组件,补充pks device_code索引

zhouhao 2 weken geleden
bovenliggende
commit
76bab55d34

+ 4 - 0
backend/add_pks_device_code_time_index.sql

@@ -0,0 +1,4 @@
+-- Run once during a maintenance window before enabling device_code-based PKS scans.
+-- The oil-pressure alarm queries one compressor within a 24-hour time range.
+CREATE INDEX idx_pks_device_code_sample_time
+    ON pks_long_sample (device_code, sample_time);

+ 30 - 0
backend/app/main.py

@@ -41,6 +41,13 @@ class AnnotationQueryInput(BaseModel):
     wave_file_ids: list[int] = Field(default=[])
     wave_file_ids: list[int] = Field(default=[])
 
 
 
 
+class AlarmScanInput(BaseModel):
+    forecast_time: str = Field(min_length=1)
+    device_code: str = Field(min_length=1)
+    alarm_type: str = Field(min_length=1)
+    hours: float = Field(gt=0)
+
+
 class PretrainRecordInput(BaseModel):
 class PretrainRecordInput(BaseModel):
     id: int = Field(gt=0)
     id: int = Field(gt=0)
     point_name: str = Field(min_length=1)
     point_name: str = Field(min_length=1)
@@ -179,6 +186,29 @@ def tspluse_ruler(_auth: str = Depends(require_auth)) -> dict[str, Any]:
         raise HTTPException(status_code=503, detail=str(error)) from error
         raise HTTPException(status_code=503, detail=str(error)) from error
 
 
 
 
+@app.get("/api/alarms")
+def list_alarms(
+    current_time: str | None = None,
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        return data_service.list_alarms(current_time)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.post("/api/alarm-scan")
+def alarm_scan(payload: AlarmScanInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return data_service.scan_alarm(payload.model_dump())
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
 @app.post("/api/wave-window")
 @app.post("/api/wave-window")
 def wave_window(payload: WaveWindowInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
 def wave_window(payload: WaveWindowInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
     try:
     try:

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

@@ -45,6 +45,10 @@ PHASES = (
 )
 )
 ANNOTATION_LABELS = ("正常", "异常")
 ANNOTATION_LABELS = ("正常", "异常")
 
 
+OIL_PRESSURE_ALARM_TYPE = "润滑油压力低"
+OIL_PRESSURE_DEVICE_PART = "润滑油"
+OIL_PRESSURE_DEVICE_POINT = "压力"
+
 # PKS 全场点位:机组号 -> pks_long_sample.import_batch_id
 # PKS 全场点位:机组号 -> pks_long_sample.import_batch_id
 # 7号机=30、8号机=31、9号机=32。
 # 7号机=30、8号机=31、9号机=32。
 UNIT_BATCH = {"7": 30, "8": 31, "9": 32}
 UNIT_BATCH = {"7": 30, "8": 31, "9": 32}
@@ -164,6 +168,19 @@ def _annotation_dict(row: dict[str, Any]) -> dict[str, Any]:
     }
     }
 
 
 
 
+def _alarm_dict(row: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "id": int(row["id"]),
+        "deviceCode": row["device_code"] or "",
+        "devicePart": row["device_part"] or "",
+        "devicePoint": row["device_point"] or "",
+        "alarmType": row["alarm_type"] or "",
+        "alarmDes": row["alarm_des"] or "",
+        "alarmTimeStart": _time_string(row["alarm_time_start"]),
+        "alarmTimeEnd": _time_string(row["alarm_time_end"]),
+    }
+
+
 def _validate_device_points(
 def _validate_device_points(
     values: list[str] | tuple[str, ...] | None,
     values: list[str] | tuple[str, ...] | None,
     *,
     *,
@@ -730,6 +747,235 @@ class DataService:
             "notice": self._source_notice(source),
             "notice": self._source_notice(source),
         }
         }
 
 
+    def list_alarms(self, current_time: str | None = None) -> dict[str, Any]:
+        """列出在给定时刻命中的告警。
+
+        命中条件为 ``alarm_time_start <= 当前时间 <= alarm_time_end``,
+        即告警时间段覆盖“告警当前时间”。
+        """
+        moment = _parse_time(current_time) or datetime.now()
+
+        def database_query():
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    cursor.execute(
+                        """
+                        SELECT id, device_code, device_part, device_point, alarm_type,
+                               alarm_des, alarm_time_start, alarm_time_end
+                        FROM compressor_alarm
+                        WHERE alarm_time_start <= %s AND alarm_time_end >= %s
+                        ORDER BY alarm_time_start DESC, id DESC
+                        """,
+                        (moment, moment),
+                    )
+                    rows = cursor.fetchall()
+            return [_alarm_dict(row) for row in rows]
+
+        result, source = self._run_with_fallback(database_query, lambda: [])
+        return {
+            "source": source,
+            "currentTime": _time_string(moment),
+            "alarms": result,
+            "notice": self._source_notice(source),
+        }
+
+    def scan_alarm(self, payload: dict[str, Any]) -> dict[str, Any]:
+        """执行告警算法并按原有协议写入 compressor_alarm。"""
+        device_code = str(payload.get("device_code") or "").strip()
+        alarm_type = str(payload.get("alarm_type") or "").strip()
+        start = _parse_time(payload.get("forecast_time"))
+        try:
+            hours = float(payload.get("hours") or 0)
+        except (TypeError, ValueError):
+            raise ValueError("告警时长必须是数字") from None
+        if not device_code:
+            raise ValueError("压缩机不能为空")
+        if not alarm_type:
+            raise ValueError("算法不能为空")
+        if start is None:
+            raise ValueError("预警时间不能为空")
+        if hours <= 0:
+            raise ValueError("告警时长必须大于 0")
+
+        if alarm_type == OIL_PRESSURE_ALARM_TYPE:
+            hours = 24.0
+            analysis = self._analyze_oil_pressure_alarm(device_code, start)
+            if analysis is None:
+                return {
+                    "source": "database",
+                    "notice": None,
+                    "action": "no_alarm",
+                    "id": 0,
+                }
+            device_part = OIL_PRESSURE_DEVICE_PART
+            device_point = OIL_PRESSURE_DEVICE_POINT
+            alarm_des = analysis
+            status = 1
+        else:
+            # Keep the existing endpoint contract for algorithms not yet implemented.
+            device_part = "测试组件"
+            device_point = "测试组件"
+            alarm_des = "测试数据"
+            status = 0
+        end = start + timedelta(hours=hours)
+
+        def database_query():
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    cursor.execute(
+                        """
+                        SELECT id FROM compressor_alarm
+                        WHERE device_code = %s AND device_part = %s
+                          AND device_point = %s AND alarm_type = %s
+                          AND alarm_time_start <= %s AND alarm_time_end >= %s
+                        ORDER BY id ASC
+                        LIMIT 1
+                        """,
+                        (device_code, device_part, device_point, alarm_type, start, start),
+                    )
+                    row = cursor.fetchone()
+                    if row is not None:
+                        cursor.execute(
+                            """
+                            UPDATE compressor_alarm
+                            SET alarm_time_end = %s, alarm_des = %s, status = %s
+                            WHERE id = %s
+                            """,
+                            (end, alarm_des, status, row["id"]),
+                        )
+                        return {"action": "updated", "id": int(row["id"])}
+                    cursor.execute(
+                        """
+                        INSERT INTO compressor_alarm
+                            (device_code, device_part, device_point, alarm_type,
+                             alarm_des, alarm_time_start, alarm_time_end, status)
+                        VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+                        """,
+                            (device_code, device_part, device_point, alarm_type, alarm_des, start, end, status),
+                    )
+                    return {"action": "inserted", "id": int(cursor.lastrowid)}
+
+        result, source = self._run_with_fallback(
+            database_query,
+            lambda: {"action": "demo", "id": 0},
+        )
+        return {"source": source, "notice": self._source_notice(source), **result}
+
+    @staticmethod
+    def _alarm_limit(item: dict[str, Any], alarm_type: str) -> float | None:
+        for index in range(1, 5):
+            if str(item.get(f"AlarmType{index}") or "").strip() == alarm_type:
+                return _safe_float(item.get(f"AlarmLimit{index}"))
+        return None
+
+    def _analyze_oil_pressure_alarm(self, device_code: str, end: datetime) -> str | None:
+        """Analyze YSJ_5 in the 24 hours before ``end``.
+
+        A low-pressure alarm requires either a configured low/low-low threshold
+        breach or a sustained decline of at least 10% from the 24-hour median.
+        The trend rule requires three consecutive non-rising valid hours or a
+        negative six-hour linear trend. Direct low-low threshold breaches remain
+        immediately reportable.
+        """
+        unit = device_code.rstrip("#").strip()
+        if unit not in UNIT_BATCH:
+            raise ValueError("压缩机必须是 7#、8# 或 9#")
+        start = end - timedelta(hours=24)
+
+        with get_connection() as connection:
+            with connection.cursor() as cursor:
+                cursor.execute(
+                    """
+                    SELECT AlarmType1, AlarmType2, AlarmType3, AlarmType4,
+                           AlarmLimit1, AlarmLimit2, AlarmLimit3, AlarmLimit4
+                    FROM site_point
+                    WHERE ItemName = %s
+                    """,
+                    (f"YSJ{unit}_5",),
+                )
+                config = cursor.fetchone()
+                if config is None:
+                    raise ValueError(f"未找到 {device_code} 的润滑油压力报警配置")
+                low = self._alarm_limit(config, "PVLow")
+                low_low = self._alarm_limit(config, "PVLowLow")
+                if low is None or low_low is None:
+                    raise ValueError(f"{device_code} 缺少润滑油压力低压报警阈值")
+
+                cursor.execute(
+                    """
+                    SELECT FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
+                           COUNT(*) AS samples,
+                           SUM(CASE WHEN YSJ_41 > 0 THEN 1 ELSE 0 END) AS running_samples,
+                           AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg
+                    FROM pks_long_sample
+                    WHERE device_code = %s
+                      AND sample_time >= %s AND sample_time < %s
+                    GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
+                    ORDER BY hour_start
+                    """,
+                    (device_code, start, end),
+                )
+                rows = []
+                for row in cursor.fetchall():
+                    running = int(row["running_samples"] or 0)
+                    value = _safe_float(row["oil_avg"])
+                    if running / 720 >= 0.80 and value is not None:
+                        rows.append({"time": row["hour_start"], "value": value, "running": running})
+
+        if not rows:
+            return None
+        values = [row["value"] for row in rows]
+        baseline = float(np.median(values))
+        current = rows[-1]["value"]
+        relative_drop = (baseline - current) / baseline if baseline else 0.0
+        low_low_rows = [row for row in rows if row["value"] <= low_low]
+        low_rows = [row for row in rows if row["value"] <= low]
+        consecutive_low = 0
+        for row in reversed(rows):
+            if row["value"] <= low:
+                consecutive_low += 1
+            else:
+                break
+        consecutive_low_low = 0
+        for row in reversed(rows):
+            if row["value"] <= low_low:
+                consecutive_low_low += 1
+            else:
+                break
+        consecutive_decline = 1
+        for index in range(len(rows) - 1, 0, -1):
+            if rows[index]["time"] - rows[index - 1]["time"] != timedelta(hours=1):
+                break
+            if rows[index]["value"] > rows[index - 1]["value"]:
+                break
+            consecutive_decline += 1
+        trend_values = [row["value"] for row in rows[-6:]]
+        trend = float(np.polyfit(range(len(trend_values)), trend_values, 1)[0]) if len(trend_values) >= 3 else None
+        downward = consecutive_decline >= 3 or (trend is not None and trend < -0.0005)
+        evolution_alarm = relative_drop >= 0.10 and downward
+
+        if not low_low_rows and not low_rows and not evolution_alarm:
+            return None
+        reasons = [
+            f"前24小时有效运行数据{len(rows)}小时",
+            f"当前小时油压{current:.3f},24小时基线{baseline:.3f},相对下降{relative_drop:.1%}",
+        ]
+        if consecutive_low_low:
+            reasons.append(f"连续{consecutive_low_low}小时低于低低报警阈值{low_low:.3f}")
+        elif low_low_rows:
+            reasons.append(f"低低报警区间出现{len(low_low_rows)}小时")
+        elif consecutive_low:
+            reasons.append(f"连续{consecutive_low}小时低于低报警阈值{low:.3f}")
+        else:
+            reasons.append(f"低报警区间出现{len(low_rows)}小时")
+        if evolution_alarm:
+            reasons.append(
+                f"相对24小时基线下降至少10%,连续下降{consecutive_decline}小时,"
+                f"近6小时斜率{trend:.6f}" if trend is not None
+                else f"相对24小时基线下降至少10%,连续下降{consecutive_decline}小时"
+            )
+        return ";".join(reasons)
+
     @staticmethod
     @staticmethod
     def _group_time_points(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
     def _group_time_points(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
         grouped: OrderedDict[Any, dict[str, Any]] = OrderedDict()
         grouped: OrderedDict[Any, dict[str, Any]] = OrderedDict()

+ 19 - 8
frontend/src/App.vue

@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { ElMessageBox } from 'element-plus'
 import { ElMessageBox } from 'element-plus'
 import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConfig, fetchAnnotationsByIds, fetchFaults, fetchPeriodDetail, fetchPretrainStatus, fetchQueryOptions, fetchSitePoints, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, recordPretrain } from './api'
 import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConfig, fetchAnnotationsByIds, fetchFaults, fetchPeriodDetail, fetchPretrainStatus, fetchQueryOptions, fetchSitePoints, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, recordPretrain } from './api'
 import PeriodModal from './components/PeriodModal.vue'
 import PeriodModal from './components/PeriodModal.vue'
+import AlarmMonitor from './components/AlarmMonitor.vue'
 import { fixedAxisRange } from './utils/axis'
 import { fixedAxisRange } from './utils/axis'
 import TimePointStrip from './components/TimePointStrip.vue'
 import TimePointStrip from './components/TimePointStrip.vue'
 import WaveChart from './components/WaveChart.vue'
 import WaveChart from './components/WaveChart.vue'
@@ -34,6 +35,7 @@ const faults = ref<Fault[]>([])
 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')
+const activeTab = ref<'model1' | 'alarm'>('model1')
 const chartDirty = ref(false)
 const chartDirty = ref(false)
 const queryLoading = ref(false)
 const queryLoading = ref(false)
 const timePointsLoading = ref(false)
 const timePointsLoading = ref(false)
@@ -900,22 +902,26 @@ onBeforeUnmount(() => {
         </div>
         </div>
       </div>
       </div>
       <div class="header-meta">
       <div class="header-meta">
-        <span class="live-indicator"><i></i> 浏览模式</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>
         <el-button class="header-logout" plain @click="handleLogout">退出登录</el-button>
       </div>
       </div>
     </header>
     </header>
 
 
-    <main class="workspace">
+    <el-tabs v-model="activeTab" class="workspace-tabs">
+      <el-tab-pane label="模型1" name="model1">
+        <main class="workspace">
       <section class="query-panel panel">
       <section class="query-panel panel">
         <div class="panel-heading compact-heading">
         <div class="panel-heading compact-heading">
           <div>
           <div>
             <h2>查询条件</h2>
             <h2>查询条件</h2>
           </div>
           </div>
-          <el-tag class="connection-badge" :type="currentSource === 'database' ? 'success' : 'warning'" effect="plain">
-            {{ currentSource === 'database' ? '数据库已连接' : '演示数据' }}
-          </el-tag>
+          <div class="heading-meta">
+            <span class="live-indicator"><i></i> 浏览模式</span>
+            <span class="header-date">{{ selectedDevicePart || '未选择机组与部位' }}</span>
+            <el-button class="header-refresh" type="primary" plain :loading="queryLoading" @click="refresh">刷新</el-button>
+            <el-tag class="connection-badge" :type="currentSource === 'database' ? 'success' : 'warning'" effect="plain">
+              {{ currentSource === 'database' ? '数据库已连接' : '演示数据' }}
+            </el-tag>
+          </div>
         </div>
         </div>
         <div class="query-grid">
         <div class="query-grid">
           <label class="field field-point">
           <label class="field field-point">
@@ -1274,7 +1280,12 @@ onBeforeUnmount(() => {
           <div><span>抽样上限</span><strong>{{ maxPoints.toLocaleString() }} 点</strong></div>
           <div><span>抽样上限</span><strong>{{ maxPoints.toLocaleString() }} 点</strong></div>
         </div>
         </div>
       </aside>
       </aside>
-    </main>
+        </main>
+      </el-tab-pane>
+      <el-tab-pane label="告警监控" name="alarm">
+        <AlarmMonitor />
+      </el-tab-pane>
+    </el-tabs>
 
 
     <PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" :pressure-axis="pressureAxis" @close="closePeriod" />
     <PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" :pressure-axis="pressureAxis" @close="closePeriod" />
   </div>
   </div>

+ 21 - 0
frontend/src/api.ts

@@ -1,4 +1,6 @@
 import type {
 import type {
+  AlarmListResponse,
+  AlarmScanResponse,
   Annotation,
   Annotation,
   AnnotationConfigResponse,
   AnnotationConfigResponse,
   AnnotationLabel,
   AnnotationLabel,
@@ -105,6 +107,25 @@ export function fetchFaults(params: { devicePart: string; minTime?: string; maxT
   return request<FaultsResponse>(`/api/faults?${search.toString()}`)
   return request<FaultsResponse>(`/api/faults?${search.toString()}`)
 }
 }
 
 
+export function fetchAlarms(currentTime?: string) {
+  const search = new URLSearchParams()
+  if (currentTime) search.set('current_time', currentTime)
+  const query = search.toString()
+  return request<AlarmListResponse>(`/api/alarms${query ? `?${query}` : ''}`)
+}
+
+export function runAlarmScan(payload: {
+  forecast_time: string
+  device_code: string
+  alarm_type: string
+  hours: number
+}) {
+  return request<AlarmScanResponse>('/api/alarm-scan', {
+    method: 'POST',
+    body: JSON.stringify(payload),
+  })
+}
+
 export function fetchWaveWindow(params: {
 export function fetchWaveWindow(params: {
   devicePart: string
   devicePart: string
   devicePoints: DevicePoint[]
   devicePoints: DevicePoint[]

+ 176 - 0
frontend/src/components/AlarmMonitor.vue

@@ -0,0 +1,176 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import { fetchAlarms, runAlarmScan } from '../api'
+import type { Alarm } from '../types'
+
+function pad(value: number) {
+  return String(value).padStart(2, '0')
+}
+
+function formatNow() {
+  const now = new Date()
+  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
+}
+
+const currentTime = ref(formatNow())
+const alarms = ref<Alarm[]>([])
+const alarmsLoading = ref(false)
+const alarmsError = ref('')
+const alarmSource = ref<'database' | 'demo'>('demo')
+
+async function loadAlarms() {
+  alarmsLoading.value = true
+  alarmsError.value = ''
+  try {
+    const result = await fetchAlarms(currentTime.value || undefined)
+    alarms.value = result.alarms
+    alarmSource.value = result.source
+  } catch (error) {
+    alarms.value = []
+    alarmsError.value = error instanceof Error ? error.message : '告警结果读取失败'
+  } finally {
+    alarmsLoading.value = false
+  }
+}
+
+const compressorOptions = [
+  { label: '7号机组', value: '7#' },
+  { label: '8号机组', value: '8#' },
+  { label: '9号机组', value: '9#' },
+]
+const algorithmOptions = ['润滑油压力低', '十字头故障', '气阀故障', '活塞故障']
+
+const forecastTime = ref(formatNow())
+const deviceCode = ref('7#')
+const alarmType = ref('润滑油压力低')
+const hours = ref(24)
+const scanLoading = ref(false)
+const scanMessage = ref('')
+const scanError = ref('')
+
+async function handleScan() {
+  if (!forecastTime.value) {
+    scanError.value = '请选择预警时间'
+    return
+  }
+  scanLoading.value = true
+  scanMessage.value = ''
+  scanError.value = ''
+  try {
+    const result = await runAlarmScan({
+      forecast_time: forecastTime.value,
+      device_code: deviceCode.value,
+      alarm_type: alarmType.value,
+      hours: hours.value,
+    })
+    scanMessage.value = result.action === 'inserted'
+      ? '扫描完成:新增告警'
+      : result.action === 'updated'
+        ? '扫描完成:更新告警'
+        : result.action === 'no_alarm'
+          ? '扫描完成:前24小时未发现润滑油压力异常'
+          : '扫描完成:演示模式未写库'
+    currentTime.value = forecastTime.value
+    await loadAlarms()
+  } catch (error) {
+    scanError.value = error instanceof Error ? error.message : '告警扫描失败'
+  } finally {
+    scanLoading.value = false
+  }
+}
+
+onMounted(() => {
+  void loadAlarms()
+})
+</script>
+
+<template>
+  <div class="alarm-page">
+    <section class="panel alarm-results">
+      <div class="panel-heading compact-heading">
+        <div>
+          <h2>告警结果</h2>
+        </div>
+        <div class="alarm-query">
+          <span class="field-label">告警当前时间</span>
+          <el-date-picker
+            v-model="currentTime"
+            class="query-control"
+            size="large"
+            type="datetime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            format="YYYY-MM-DD HH:mm:ss"
+            :disabled="alarmsLoading"
+            placeholder="选择告警当前时间"
+          />
+          <el-button type="primary" size="large" :loading="alarmsLoading" @click="loadAlarms">查询</el-button>
+        </div>
+      </div>
+      <el-alert v-if="alarmsError" class="data-alert" type="error" :closable="false" show-icon :title="alarmsError" />
+      <el-table v-loading="alarmsLoading" class="alarm-table" :data="alarms" border stripe>
+        <el-table-column prop="deviceCode" label="压缩机号" min-width="110" />
+        <el-table-column prop="devicePart" label="设备部分" min-width="120" />
+        <el-table-column prop="devicePoint" label="点位" min-width="120" />
+        <el-table-column prop="alarmType" label="告警类型" min-width="120" />
+        <el-table-column prop="alarmTimeStart" label="开始时间" min-width="170" />
+        <el-table-column prop="alarmTimeEnd" label="结束时间" min-width="170" />
+        <el-table-column prop="alarmDes" label="告警描述" min-width="150" />
+        <template #empty>当前时间没有命中告警</template>
+      </el-table>
+    </section>
+
+    <section class="panel alarm-algorithm">
+      <div class="panel-heading compact-heading">
+        <div>
+          <h2>告警算法</h2>
+        </div>
+        <el-tag class="connection-badge" :type="alarmSource === 'database' ? 'success' : 'warning'" effect="plain">
+          {{ alarmSource === 'database' ? '数据库已连接' : '演示数据' }}
+        </el-tag>
+      </div>
+      <div class="alarm-form">
+        <label class="field">
+          <span class="field-label">预警时间</span>
+          <el-date-picker
+            v-model="forecastTime"
+            class="query-control"
+            size="large"
+            type="datetime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            format="YYYY-MM-DD HH:mm:ss"
+            placeholder="选择预警时间"
+          />
+        </label>
+        <label class="field">
+          <span class="field-label">压缩机</span>
+          <el-select v-model="deviceCode" class="query-control" size="large">
+            <el-option v-for="item in compressorOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </label>
+        <label class="field">
+          <span class="field-label">算法</span>
+          <el-select v-model="alarmType" class="query-control" size="large">
+            <el-option v-for="item in algorithmOptions" :key="item" :label="item" :value="item" />
+          </el-select>
+        </label>
+        <label class="field field-hours">
+          <span class="field-label">分析窗口</span>
+          <div class="hours-row">
+            <el-input-number
+              v-model="hours"
+              class="query-control"
+              size="large"
+              :min="24"
+              :max="24"
+              controls-position="right"
+            />
+            <span class="unit-text">前24小时</span>
+          </div>
+        </label>
+        <el-button class="alarm-scan-button" type="primary" size="large" :loading="scanLoading" @click="handleScan">扫描</el-button>
+      </div>
+      <el-alert v-if="scanError" class="data-alert" type="error" :closable="false" show-icon :title="scanError" />
+      <el-alert v-else-if="scanMessage" class="data-alert" type="success" :closable="false" show-icon :title="scanMessage" />
+    </section>
+  </div>
+</template>

+ 25 - 0
frontend/src/styles.css

@@ -87,6 +87,31 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
   margin: 20px auto 0;
   margin: 20px auto 0;
 }
 }
 
 
+.workspace-tabs { width: min(1600px, calc(100% - 32px)); margin: 0 auto; }
+.workspace-tabs > .el-tabs__header { margin: 18px 0 0; }
+.workspace-tabs .workspace { width: 100%; margin: 20px 0 0; }
+.heading-meta { display: flex; align-items: center; gap: 14px; }
+.heading-meta .live-indicator { color: #606266; }
+.heading-meta .header-date { color: #606266; }
+.heading-meta .header-refresh.el-button { height: 32px; margin: 0; color: #409eff; border-color: #a0cfff; background: transparent; }
+.heading-meta .header-refresh.el-button:hover { color: #fff; border-color: #409eff; background: #409eff; }
+.tab-empty-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 360px; margin: 20px 0 0; padding: 40px; color: #909399; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; box-shadow: 0 2px 12px rgba(0, 0, 0, .04); }
+.tab-empty-panel h2 { color: #303133; }
+
+.alarm-page { display: flex; flex-direction: column; gap: 16px; margin: 20px 0 0; }
+.alarm-results, .alarm-algorithm { padding: 22px 24px 18px; }
+.alarm-query { display: flex; align-items: center; gap: 12px; }
+.alarm-query .query-control { width: 220px; }
+.alarm-table { margin-top: 14px; }
+.alarm-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 18px; margin-top: 6px; }
+.alarm-form .field { display: flex; flex-direction: column; }
+.alarm-form .query-control { width: 226px; }
+.alarm-form .field-hours { flex-direction: column; }
+.alarm-form .hours-row { display: flex; align-items: center; gap: 8px; }
+.alarm-form .field-hours .query-control { width: 150px; }
+.alarm-form .unit-text { color: #909399; font-size: 13px; }
+.alarm-scan-button { min-width: 116px; }
+
 .panel { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; box-shadow: 0 2px 12px rgba(0, 0, 0, .04); }
 .panel { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; box-shadow: 0 2px 12px rgba(0, 0, 0, .04); }
 .query-panel { grid-column: 1; grid-row: 1; }
 .query-panel { grid-column: 1; grid-row: 1; }
 .selection-panel { grid-column: 1; grid-row: 2; }
 .selection-panel { grid-column: 1; grid-row: 2; }

+ 25 - 0
frontend/src/types.ts

@@ -100,6 +100,31 @@ export type FaultsResponse = {
   notice: string | null
   notice: string | null
 }
 }
 
 
+export type Alarm = {
+  id: number
+  deviceCode: string
+  devicePart: string
+  devicePoint: string
+  alarmType: string
+  alarmDes: string
+  alarmTimeStart: string
+  alarmTimeEnd: string
+}
+
+export type AlarmListResponse = {
+  source: 'database' | 'demo'
+  currentTime: string
+  alarms: Alarm[]
+  notice: string | null
+}
+
+export type AlarmScanResponse = {
+  source: 'database' | 'demo'
+  notice: string | null
+  action: 'inserted' | 'updated' | 'no_alarm' | 'demo'
+  id: number
+}
+
 export type WaveValue = {
 export type WaveValue = {
   value: [number, number]
   value: [number, number]
   x: number
   x: number