1
0

2 Commits 9fd5c67c55 ... df423b1808

Autor SHA1 Nachricht Datum
  18922397810 df423b1808 标注:统一标签表、算法标签导入与人工标注 vor 6 Tagen
  18922397810 f7a87d8ad1 周期功图:7/8/9号机按厂家偏移校准各缸相位 vor 6 Tagen

+ 51 - 0
backend/app/db.py

@@ -40,3 +40,54 @@ def ensure_annotation_table() -> None:
     with get_connection() as connection:
         with connection.cursor() as cursor:
             cursor.execute(_ANNOTATION_TABLE_DDL)
+
+
+_LABEL_TABLE_DDL = """
+CREATE TABLE IF NOT EXISTS compressor_label (
+    id                BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '标签ID',
+    source_type       VARCHAR(32)     NOT NULL COMMENT 'vibration/oil_pressure/temperature/pressure_wave/pks_manual',
+    origin            VARCHAR(16)     NOT NULL DEFAULT 'manual' COMMENT 'algorithm/manual/imported',
+    target_type       VARCHAR(16)     NOT NULL COMMENT 'wave_file/pks',
+    target_id         BIGINT UNSIGNED NULL COMMENT 'wave_file.id',
+    target_key        VARCHAR(200)    NOT NULL COMMENT '通道+目标标识',
+    channel_key       VARCHAR(200)    NOT NULL COMMENT '子图通道键',
+    segment_key       VARCHAR(64)     NULL COMMENT '算法原始段分组键',
+    unit              VARCHAR(16)     NOT NULL COMMENT '机组号,如 7',
+    device_code       VARCHAR(64)     NULL COMMENT '如 7#',
+    device_part       VARCHAR(200)    NULL COMMENT '如 7号机组一缸 / 7号机',
+    device_point      VARCHAR(200)    NULL COMMENT 'wave 点位或 PKS 指标名',
+    metric_key        VARCHAR(64)     NULL COMMENT 'PKS 列名,如 YSJ_11',
+    metric_label      VARCHAR(200)    NULL COMMENT 'PKS 指标中文名',
+    start_time        DATETIME        NOT NULL COMMENT '标签段起点',
+    end_time          DATETIME        NOT NULL COMMENT '标签段终点(不含)',
+    algorithm_start   DATETIME        NULL COMMENT '算法原始段起点',
+    algorithm_end     DATETIME        NULL COMMENT '算法原始段终点',
+    label_type        VARCHAR(32)     NOT NULL COMMENT '轻微/异常/严重异常',
+    label_subtype     VARCHAR(64)     NULL COMMENT '振动侧/温度部位/方向等',
+    severity          TINYINT         NULL COMMENT '1轻微/2异常/3严重异常',
+    confirm_status    VARCHAR(16)     NOT NULL DEFAULT '未确认' COMMENT '未确认/已确认/人工否决',
+    usable_for_train  TINYINT(1)      NOT NULL DEFAULT 1,
+    source_file       VARCHAR(500)    NULL,
+    source_row        VARCHAR(64)     NULL COMMENT '周期编号等',
+    algorithm_version VARCHAR(64)     NULL,
+    features_json     MEDIUMTEXT      NULL COMMENT '算法原始属性',
+    note              VARCHAR(1000)   NULL,
+    created_by        VARCHAR(64)     NULL,
+    updated_by        VARCHAR(64)     NULL,
+    created_at        DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at        DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    deleted_at        DATETIME        NULL,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_segment (segment_key),
+    KEY idx_target_time (target_key, start_time, end_time),
+    KEY idx_channel_time (channel_key, start_time),
+    KEY idx_unit_time (unit, start_time),
+    KEY idx_source (source_type, origin)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '统一训练标签表';
+"""
+
+
+def ensure_label_table() -> None:
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            cursor.execute(_LABEL_TABLE_DDL)

+ 57 - 26
backend/app/main.py

@@ -6,9 +6,9 @@ from pydantic import BaseModel, Field
 
 from .auth import create_token, revoke_token, validate_token, verify_credentials
 from .config import settings
-from .db import ensure_annotation_table
+from .db import ensure_annotation_table, ensure_label_table
 from .services.data_service import data_service
-from .services.pretrain_service import pretrain_service
+from .services.label_service import create_label, delete_label, query_labels, update_label
 from .services.tag_manual_service import tag_manual_service
 from .services.temperature_transition_service import temperature_transition_service
 
@@ -64,13 +64,21 @@ class PressureAngleQueryInput(BaseModel):
 
 
 
-class PretrainRecordInput(BaseModel):
-    id: int = Field(gt=0)
-    point_name: str = Field(min_length=1)
-    measurement_type: str = Field(min_length=1)
-    rpm: float | None = None
-    sample_time: str = Field(min_length=1)
-    file_name: str = Field(default="")
+class LabelInput(BaseModel):
+    target_type: str = Field(min_length=1)
+    target_id: int | None = None
+    channel_key: str = Field(min_length=1)
+    unit: str = Field(min_length=1)
+    device_code: str | None = None
+    device_part: str | None = None
+    device_point: str | None = None
+    metric_key: str | None = None
+    metric_label: str | None = None
+    start_time: str = Field(min_length=1)
+    end_time: str = Field(min_length=1)
+    label_type: str = Field(min_length=1)
+    label_subtype: str | None = None
+    note: str | None = None
 
 
 class LoginInput(BaseModel):
@@ -102,6 +110,7 @@ def require_auth(authorization: str | None = Header(default=None)) -> str:
 def startup() -> None:
     try:
         ensure_annotation_table()
+        ensure_label_table()
     except Exception:
         # 数据库不可达时跳过建表,演示模式仍可正常启动。
         pass
@@ -168,7 +177,6 @@ def query_options(_auth: str = Depends(require_auth)) -> dict[str, Any]:
         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
     except Exception as error:
@@ -378,39 +386,62 @@ def delete_annotation(annotation_id: int, _auth: str = Depends(require_auth)) ->
         raise HTTPException(status_code=503, detail=str(error)) from error
 
 
-@app.get("/api/pretrain-records")
-def pretrain_records_status(
-    ids: list[int] = Query(default=[]),
+@app.get("/api/labels")
+def list_labels(
+    target_type: str | None = None,
+    unit: str | None = None,
+    device_code: str | None = None,
+    metric_keys: list[str] = Query(default=[]),
+    channel_keys: list[str] = Query(default=[]),
+    wave_file_ids: list[int] = Query(default=[]),
+    start: str | None = None,
+    end: str | None = None,
     _auth: str = Depends(require_auth),
 ) -> dict[str, Any]:
     try:
-        return {"recorded": pretrain_service.recorded_ids(ids)}
+        return query_labels(
+            target_type=target_type,
+            unit=unit,
+            device_code=device_code,
+            metric_keys=metric_keys,
+            channel_keys=channel_keys,
+            wave_file_ids=wave_file_ids,
+            start=start,
+            end=end,
+        )
     except Exception as error:
         raise HTTPException(status_code=503, detail=str(error)) from error
 
 
-@app.post("/api/pretrain-records")
-def create_pretrain_record(
-    payload: PretrainRecordInput,
+@app.post("/api/labels")
+def create_label_endpoint(payload: LabelInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return create_label(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.put("/api/labels/{label_id}")
+def update_label_endpoint(
+    label_id: int,
+    payload: LabelInput,
     _auth: str = Depends(require_auth),
 ) -> dict[str, Any]:
     try:
-        recorded, already = pretrain_service.record(payload.model_dump())
+        return update_label(label_id, 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
-    return {"recorded": recorded, "already": already}
 
 
-@app.delete("/api/pretrain-records/{file_id}")
-def delete_pretrain_record(file_id: int, _auth: str = Depends(require_auth)) -> dict[str, Any]:
+@app.delete("/api/labels/{label_id}")
+def delete_label_endpoint(label_id: int, _auth: str = Depends(require_auth)) -> dict[str, Any]:
     try:
-        deleted = pretrain_service.remove(file_id)
+        return delete_label(label_id)
     except ValueError as error:
-        raise HTTPException(status_code=400, detail=str(error)) from error
+        raise HTTPException(status_code=404, detail=str(error)) from error
     except Exception as error:
         raise HTTPException(status_code=503, detail=str(error)) from error
-    if not deleted:
-        raise HTTPException(status_code=404, detail="CSV 中不存在该记录")
-    return {"deleted": deleted}

+ 17 - 3
backend/app/services/data_service.py

@@ -106,7 +106,20 @@ CYLINDER_PHASE_DEGREE = {
     "五缸": 240.0,
     "六缸": 120.0,
 }
-CYLINDER_PHASE_OVERRIDE = {"二缸": 24.0, "三缸": 146.0, "四缸": 147.0, "五缸": 265.0, "六缸": 265.0}
+UNIT_PHASE_OVERRIDE = {
+    "7": {
+        "一缸": 141.0, "二缸": 160.0, "三缸": 280.0,
+        "四缸": 280.0, "五缸": 40.0, "六缸": 40.0,
+    },
+    "8": {
+        "一缸": 8.0, "二缸": 23.0, "三缸": 138.0,
+        "四缸": 145.0, "五缸": 262.0, "六缸": 262.0,
+    },
+    "9": {
+        "一缸": 16.0, "二缸": 27.0, "三缸": 153.0,
+        "四缸": 153.0, "五缸": 269.0, "六缸": 269.0,
+    },
+}
 PHASE_ROTATION_UNITS = frozenset({"7", "8", "9"})
 
 DEMO_POINT_NAME = "7号机组一缸压力盖侧"
@@ -159,8 +172,9 @@ def _phase_degrees(point_name: str, keyphase_info: Any = None) -> float:
     if unit not in PHASE_ROTATION_UNITS:
         return 0.0
     cylinder = _point_cylinder(point_name or "")
-    if cylinder in CYLINDER_PHASE_OVERRIDE:
-        return CYLINDER_PHASE_OVERRIDE[cylinder]
+    unit_override = UNIT_PHASE_OVERRIDE.get(unit)
+    if unit_override and cylinder in unit_override:
+        return unit_override[cylinder]
     angle = _keyphase_angle(keyphase_info)
     if angle is not None:
         return angle

+ 351 - 0
backend/app/services/label_service.py

@@ -0,0 +1,351 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from datetime import datetime
+from typing import Any
+
+from ..db import get_connection
+
+LEVELS = ("轻微", "异常", "严重异常")
+SEVERITY_BY_LEVEL = {"轻微": 1, "异常": 2, "严重异常": 3}
+
+_SELECT_FIELDS = """
+    id, source_type, origin, target_type, target_id, target_key, channel_key,
+    unit, device_code, device_part, device_point, metric_key, metric_label,
+    start_time, end_time, algorithm_start, algorithm_end, label_type, label_subtype,
+    severity, confirm_status, usable_for_train, source_file, source_row,
+    algorithm_version, features_json, note
+"""
+
+
+def _time_string(value: Any) -> str:
+    if isinstance(value, datetime):
+        return value.strftime("%Y-%m-%d %H:%M:%S")
+    return str(value or "")
+
+
+def _parse_time(value: str) -> datetime:
+    text = str(value or "").strip().replace("T", " ")
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
+        try:
+            return datetime.strptime(text[:19] if fmt.endswith("%S") else text[:16], fmt)
+        except ValueError:
+            continue
+    raise ValueError("时间格式不正确,应为 YYYY-MM-DD HH:MM:SS")
+
+
+def _snap_hour(value: datetime) -> datetime:
+    return value.replace(minute=0, second=0, microsecond=0)
+
+
+def _row_key(row: dict[str, Any]) -> str:
+    if row.get("target_type") == "pks":
+        unit = str(row.get("unit") or "")
+        metric = str(row.get("metric_key") or "")
+        suffix = metric.split("_")[-1] if "_" in metric else metric
+        return f"YSJ{unit}_{suffix}" if unit and suffix else metric
+    return str(row.get("device_point") or "")
+
+
+def _row_dict(row: dict[str, Any]) -> dict[str, Any]:
+    features: Any = None
+    raw = row.get("features_json")
+    if raw:
+        try:
+            features = json.loads(raw)
+        except (TypeError, ValueError):
+            features = raw
+    return {
+        "id": int(row["id"]),
+        "sourceType": row.get("source_type") or "",
+        "origin": row.get("origin") or "",
+        "targetType": row.get("target_type") or "",
+        "targetId": int(row["target_id"]) if row.get("target_id") is not None else None,
+        "targetKey": row.get("target_key") or "",
+        "channelKey": row.get("channel_key") or "",
+        "unit": row.get("unit") or "",
+        "deviceCode": row.get("device_code") or "",
+        "devicePart": row.get("device_part") or "",
+        "devicePoint": row.get("device_point") or "",
+        "metricKey": row.get("metric_key") or "",
+        "metricLabel": row.get("metric_label") or "",
+        "startTime": _time_string(row.get("start_time")),
+        "endTime": _time_string(row.get("end_time")),
+        "labelType": row.get("label_type") or "",
+        "labelSubtype": row.get("label_subtype"),
+        "severity": int(row["severity"]) if row.get("severity") is not None else None,
+        "confirmStatus": row.get("confirm_status") or "",
+        "usableForTrain": bool(row.get("usable_for_train")),
+        "sourceRow": row.get("source_row") or "",
+        "features": features,
+        "note": row.get("note"),
+        "rowKey": _row_key(row),
+    }
+
+
+def _fetch_label(cursor: Any, label_id: int) -> dict[str, Any]:
+    cursor.execute(f"SELECT {_SELECT_FIELDS} FROM compressor_label WHERE id = %s", (label_id,))
+    row = cursor.fetchone()
+    if not row:
+        raise ValueError("标注不存在")
+    return _row_dict(row)
+
+
+def query_labels(
+    target_type: str | None = None,
+    unit: str | None = None,
+    device_code: str | None = None,
+    metric_keys: list[str] | None = None,
+    channel_keys: list[str] | None = None,
+    wave_file_ids: list[int] | None = None,
+    start: str | None = None,
+    end: str | None = None,
+) -> dict[str, Any]:
+    clauses = ["deleted_at IS NULL"]
+    params: list[Any] = []
+    if target_type:
+        clauses.append("target_type = %s")
+        params.append(target_type)
+    if unit:
+        clauses.append("unit = %s")
+        params.append(unit)
+    if device_code:
+        clauses.append("device_code = %s")
+        params.append(device_code)
+    if metric_keys:
+        placeholders = ", ".join(["%s"] * len(metric_keys))
+        clauses.append(f"metric_key IN ({placeholders})")
+        params.extend(metric_keys)
+    if channel_keys:
+        placeholders = ", ".join(["%s"] * len(channel_keys))
+        clauses.append(f"channel_key IN ({placeholders})")
+        params.extend(channel_keys)
+    if wave_file_ids:
+        placeholders = ", ".join(["%s"] * len(wave_file_ids))
+        clauses.append(f"target_id IN ({placeholders})")
+        params.extend(wave_file_ids)
+    if start:
+        clauses.append("end_time > %s")
+        params.append(start)
+    if end:
+        clauses.append("start_time < %s")
+        params.append(end)
+
+    sql = f"""
+        SELECT {_SELECT_FIELDS}
+        FROM compressor_label
+        WHERE {' AND '.join(clauses)}
+        ORDER BY channel_key ASC, start_time ASC, id ASC
+    """
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            cursor.execute(sql, params)
+            rows = cursor.fetchall()
+    return {
+        "source": "database",
+        "labels": [_row_dict(row) for row in rows],
+        "notice": None,
+    }
+
+
+def _insert_row(cursor: Any, row: dict[str, Any]) -> int:
+    fields = (
+        "source_type", "origin", "target_type", "target_id", "target_key", "channel_key",
+        "segment_key", "unit", "device_code", "device_part", "device_point", "metric_key",
+        "metric_label", "start_time", "end_time", "algorithm_start", "algorithm_end",
+        "label_type", "label_subtype", "severity", "confirm_status", "usable_for_train",
+        "source_file", "source_row", "algorithm_version", "features_json", "note",
+    )
+    placeholders = ", ".join(["%s"] * len(fields))
+    cursor.execute(
+        f"INSERT INTO compressor_label ({', '.join(fields)}) VALUES ({placeholders})",
+        tuple(row.get(field) for field in fields),
+    )
+    return int(cursor.lastrowid)
+
+
+def _trim_algorithm_row(cursor: Any, row: dict[str, Any], start: datetime, end: datetime) -> None:
+    row_start = _parse_time(_time_string(row["start_time"]))
+    row_end = _parse_time(_time_string(row["end_time"]))
+    if row_end <= start or row_start >= end:
+        return
+    keep_before = row_start < start
+    keep_after = row_end > end
+    if keep_before and keep_after:
+        cursor.execute(
+            "UPDATE compressor_label SET end_time = %s WHERE id = %s",
+            (_time_string(start), row["id"]),
+        )
+        clone = dict(row)
+        clone["start_time"] = _time_string(end)
+        clone["end_time"] = _time_string(row_end)
+        clone["algorithm_start"] = row.get("algorithm_start") or row["start_time"]
+        clone["algorithm_end"] = row.get("algorithm_end") or row["end_time"]
+        base = str(row.get("segment_key") or row["id"])
+        clone["segment_key"] = hashlib.md5(f"{base}|split|{_time_string(end)}".encode("utf-8")).hexdigest()
+        clone["origin"] = "algorithm"
+        _insert_row(cursor, clone)
+    elif keep_before:
+        cursor.execute(
+            "UPDATE compressor_label SET end_time = %s WHERE id = %s",
+            (_time_string(start), row["id"]),
+        )
+    elif keep_after:
+        cursor.execute(
+            "UPDATE compressor_label SET start_time = %s WHERE id = %s",
+            (_time_string(end), row["id"]),
+        )
+    else:
+        cursor.execute(
+            "UPDATE compressor_label SET deleted_at = NOW() WHERE id = %s",
+            (row["id"],),
+        )
+
+
+def _apply_manual(
+    cursor: Any,
+    payload: dict[str, Any],
+    exclude_id: int | None = None,
+    carry: dict[str, Any] | None = None,
+) -> int:
+    label_type = str(payload.get("label_type") or "").strip()
+    if label_type not in LEVELS:
+        raise ValueError("标签类型只能是 轻微 / 异常 / 严重异常")
+    start = _snap_hour(_parse_time(payload["start_time"]))
+    end = _snap_hour(_parse_time(payload["end_time"]))
+    if end <= start:
+        raise ValueError("结束时间必须大于开始时间")
+    channel_key = str(payload.get("channel_key") or "").strip()
+    if not channel_key:
+        raise ValueError("channel_key 不能为空")
+
+    cursor.execute(
+        f"""
+        SELECT {_SELECT_FIELDS}
+        FROM compressor_label
+        WHERE channel_key = %s
+          AND deleted_at IS NULL
+          AND start_time <= %s
+          AND end_time >= %s
+          AND (%s IS NULL OR id <> %s)
+        """,
+        (channel_key, _time_string(end), _time_string(start), exclude_id, exclude_id),
+    )
+    rows = cursor.fetchall()
+
+    for row in rows:
+        if row["origin"] == "manual" and row["label_type"] != label_type:
+            raise ValueError("同一子图内不同类型不能重叠或相邻,请先修改或删除相邻标注")
+
+    same = [row for row in rows if row["label_type"] == label_type]
+    merged_start = min([start] + [_parse_time(_time_string(row["start_time"])) for row in same])
+    merged_end = max([end] + [_parse_time(_time_string(row["end_time"])) for row in same])
+
+    for row in rows:
+        if row["label_type"] != label_type and row["origin"] != "manual":
+            _trim_algorithm_row(cursor, row, merged_start, merged_end)
+
+    same_ids = [row["id"] for row in same]
+    if same_ids:
+        placeholders = ", ".join(["%s"] * len(same_ids))
+        cursor.execute(
+            f"UPDATE compressor_label SET deleted_at = NOW() WHERE id IN ({placeholders})",
+            same_ids,
+        )
+
+    carry = carry or {}
+    source_type = "pks_manual" if payload.get("target_type") == "pks" else "wave_manual"
+    new_row = {
+        "source_type": source_type,
+        "origin": "manual",
+        "target_type": payload.get("target_type") or ("pks" if payload.get("metric_key") else "wave_file"),
+        "target_id": payload.get("target_id"),
+        "target_key": channel_key,
+        "channel_key": channel_key,
+        "segment_key": None,
+        "unit": str(payload.get("unit") or carry.get("unit") or ""),
+        "device_code": payload.get("device_code") or carry.get("device_code"),
+        "device_part": payload.get("device_part") or carry.get("device_part"),
+        "device_point": payload.get("device_point") or carry.get("device_point"),
+        "metric_key": payload.get("metric_key") or carry.get("metric_key"),
+        "metric_label": payload.get("metric_label") or carry.get("metric_label"),
+        "start_time": _time_string(merged_start),
+        "end_time": _time_string(merged_end),
+        "algorithm_start": carry.get("algorithm_start"),
+        "algorithm_end": carry.get("algorithm_end"),
+        "label_type": label_type,
+        "label_subtype": payload.get("label_subtype") or carry.get("label_subtype"),
+        "severity": SEVERITY_BY_LEVEL.get(label_type),
+        "confirm_status": "人工确认",
+        "usable_for_train": 1,
+        "source_file": carry.get("source_file"),
+        "source_row": carry.get("source_row"),
+        "algorithm_version": carry.get("algorithm_version"),
+        "features_json": carry.get("features_json"),
+        "note": payload.get("note"),
+    }
+    return _insert_row(cursor, new_row)
+
+
+def create_label(payload: dict[str, Any]) -> dict[str, Any]:
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            connection.begin()
+            try:
+                label_id = _apply_manual(cursor, payload)
+                result = _fetch_label(cursor, label_id)
+                connection.commit()
+                return result
+            except Exception:
+                connection.rollback()
+                raise
+
+
+def update_label(label_id: int, payload: dict[str, Any]) -> dict[str, Any]:
+    if label_id <= 0:
+        raise ValueError("标注 id 必须为正整数")
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            connection.begin()
+            try:
+                cursor.execute(
+                    f"SELECT {_SELECT_FIELDS} FROM compressor_label WHERE id = %s AND deleted_at IS NULL",
+                    (label_id,),
+                )
+                old = cursor.fetchone()
+                if not old:
+                    raise ValueError("标注不存在")
+                new_id = _apply_manual(cursor, payload, exclude_id=label_id, carry=old)
+                result = _fetch_label(cursor, new_id)
+                if old["origin"] == "manual":
+                    cursor.execute(
+                        "UPDATE compressor_label SET deleted_at = NOW() WHERE id = %s",
+                        (label_id,),
+                    )
+                else:
+                    _trim_algorithm_row(
+                        cursor,
+                        old,
+                        _parse_time(result["startTime"]),
+                        _parse_time(result["endTime"]),
+                    )
+                connection.commit()
+                return result
+            except Exception:
+                connection.rollback()
+                raise
+
+
+def delete_label(label_id: int) -> dict[str, Any]:
+    if label_id <= 0:
+        raise ValueError("标注 id 必须为正整数")
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                "UPDATE compressor_label SET deleted_at = NOW() WHERE id = %s AND deleted_at IS NULL",
+                (label_id,),
+            )
+            if cursor.rowcount == 0:
+                raise ValueError("标注不存在")
+    return {"deleted": label_id}

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

@@ -1,111 +0,0 @@
-import csv
-import threading
-from pathlib import Path
-
-_PRETRAIN_CSV_NAME = "预训练之文件头.csv"
-_CSV_COLUMNS = ["id", "point_name", "measurement_type", "rpm", "sample_time", "file_name"]
-
-
-class PretrainService:
-    """Append selected wave_file rows to the pre-training CSV (deduplicated by id)."""
-
-    def __init__(self) -> None:
-        self._csv_path = Path(__file__).resolve().parents[2] / _PRETRAIN_CSV_NAME
-        self._lock = threading.Lock()
-
-    @property
-    def csv_path(self) -> Path:
-        return self._csv_path
-
-    def _read_records(self) -> list[dict[str, str]]:
-        if not self._csv_path.exists():
-            return []
-        with self._csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
-            reader = csv.DictReader(handle)
-            return [dict(row) for row in reader]
-
-    def _write_records(self, records: list[dict[str, str]]) -> None:
-        with self._csv_path.open("w", encoding="utf-8-sig", newline="") as handle:
-            writer = csv.DictWriter(handle, fieldnames=_CSV_COLUMNS)
-            writer.writeheader()
-            writer.writerows(records)
-
-    def recorded_ids(self, ids: list[int]) -> list[int]:
-        wanted = {int(value) for value in ids if value}
-        if not wanted:
-            return []
-        with self._lock:
-            records = self._read_records()
-        existing: set[int] = set()
-        for row in records:
-            raw = str(row.get("id") or "").strip()
-            if raw.isdigit():
-                existing.add(int(raw))
-        return sorted(existing & wanted)
-
-    def recorded_counts(self) -> dict[str, int]:
-        """每个 point_name 在预训练 CSV 中的记录数量。"""
-        with self._lock:
-            records = self._read_records()
-        counts: dict[str, int] = {}
-        for row in records:
-            name = str(row.get("point_name") or "").strip()
-            if name:
-                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:
-            file_id = int(row["id"])
-        except (TypeError, ValueError) as error:
-            raise ValueError("id 必须为正整数") from error
-        if file_id <= 0:
-            raise ValueError("id 必须为正整数")
-        with self._lock:
-            records = self._read_records()
-            existing: set[int] = set()
-            for item in records:
-                raw = str(item.get("id") or "").strip()
-                if raw.isdigit():
-                    existing.add(int(raw))
-            if file_id in existing:
-                return False, True
-            records.append(row)
-            self._write_records(records)
-        return True, False
-
-    def remove(self, file_id: int) -> bool:
-        if file_id <= 0:
-            raise ValueError("id 必须为正整数")
-        with self._lock:
-            records = self._read_records()
-            remaining = [
-                row
-                for row in records
-                if not str(row.get("id") or "").strip().isdigit()
-                or int(str(row.get("id") or "").strip()) != file_id
-            ]
-            if len(remaining) == len(records):
-                return False
-            self._write_records(remaining)
-        return True
-
-
-pretrain_service = PretrainService()

+ 246 - 0
backend/import_compressor_labels.py

@@ -0,0 +1,246 @@
+"""Import algorithm stage labels into compressor_label.
+
+Phase 2: algorithm labels are written as segment records. Manual rows are
+never overwritten by a re-import.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import json
+import re
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any
+
+from app.db import ensure_label_table, get_connection
+
+ROOT = Path(__file__).resolve().parents[1]
+VIBRATION_CSV = ROOT / "models" / "datas" / "reference" / "振动异常段.csv"
+OIL_CSV = ROOT / "models" / "datas" / "reference" / "润滑油压力小时阶段段.csv"
+TEMPERATURE_CSV = ROOT / "_tmp_temperature" / "out" / "温度异常段.csv"
+
+SEVERITY = {"轻微": 1, "异常": 2, "严重异常": 3}
+VIBRATION_SIDE_TO_METRIC = {"联轴器端": "YSJ_10", "链轮端": "YSJ_11"}
+
+INSERT_SQL = """
+INSERT INTO compressor_label (
+    source_type, origin, target_type, target_id, target_key, channel_key, segment_key,
+    unit, device_code, device_part, device_point, metric_key, metric_label,
+    start_time, end_time, algorithm_start, algorithm_end,
+    label_type, label_subtype, severity, confirm_status, usable_for_train,
+    source_file, source_row, algorithm_version, features_json
+) VALUES (
+    %s, %s, %s, %s, %s, %s, %s,
+    %s, %s, %s, %s, %s, %s,
+    %s, %s, %s, %s,
+    %s, %s, %s, %s, %s,
+    %s, %s, %s, %s
+)
+ON DUPLICATE KEY UPDATE
+    label_type = IF(compressor_label.origin = 'manual', compressor_label.label_type, VALUES(label_type)),
+    label_subtype = IF(compressor_label.origin = 'manual', compressor_label.label_subtype, VALUES(label_subtype)),
+    severity = IF(compressor_label.origin = 'manual', compressor_label.severity, VALUES(severity)),
+    confirm_status = IF(compressor_label.origin = 'manual', compressor_label.confirm_status, VALUES(confirm_status)),
+    usable_for_train = IF(compressor_label.origin = 'manual', compressor_label.usable_for_train, VALUES(usable_for_train)),
+    features_json = IF(compressor_label.origin = 'manual', compressor_label.features_json, VALUES(features_json)),
+    updated_at = CURRENT_TIMESTAMP
+"""
+
+
+def read_csv(path: Path) -> list[dict[str, str]]:
+    if not path.exists():
+        print(f"跳过:文件不存在 {path}")
+        return []
+    with path.open("r", encoding="utf-8-sig", newline="") as handle:
+        return list(csv.DictReader(handle))
+
+
+def parse_unit(raw: str) -> str | None:
+    match = re.match(r"\s*(\d+)\s*号机", raw or "")
+    return match.group(1) if match else None
+
+
+def parse_time(raw: str) -> datetime | None:
+    text = (raw or "").strip()
+    if not text:
+        return None
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
+        try:
+            return datetime.strptime(text, fmt)
+        except ValueError:
+            continue
+    return None
+
+
+def time_text(value: datetime) -> str:
+    return value.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def metric_label(site_points: dict[str, str], unit: str, metric: str) -> str:
+    return site_points.get(f"YSJ{unit}_{metric.split('_')[-1]}", metric)
+
+
+def confirm_status(row: dict[str, str]) -> str:
+    return "已确认" if (row.get("是否形成确认等级") or "").strip() == "是" else "未确认"
+
+
+def make_segment(
+    *,
+    source_type: str,
+    unit: str,
+    metric: str,
+    start: datetime,
+    end: datetime,
+    label_type: str,
+    label_subtype: str | None,
+    row: dict[str, str],
+    source_file: str,
+    site_points: dict[str, str],
+) -> tuple[Any, ...]:
+    device_code = f"{unit}#"
+    target_key = f"pks:{unit}:{device_code}:{metric}"
+    source_row = (row.get("周期编号") or "").strip()
+    segment_key = hashlib.md5(
+        f"{source_type}|{unit}|{metric}|{source_row}|{time_text(start)}|{time_text(end)}".encode("utf-8")
+    ).hexdigest()
+    label = metric_label(site_points, unit, metric)
+    return (
+        source_type,
+        "algorithm",
+        "pks",
+        None,
+        target_key,
+        target_key,
+        segment_key,
+        unit,
+        device_code,
+        (row.get("机组") or "").strip(),
+        label,
+        metric,
+        label,
+        time_text(start),
+        time_text(end),
+        time_text(start),
+        time_text(end),
+        label_type,
+        label_subtype,
+        SEVERITY.get(label_type),
+        confirm_status(row),
+        1,
+        source_file,
+        source_row,
+        "csv-v1",
+        json.dumps(row, ensure_ascii=False),
+    )
+
+
+def build_rows(site_points: dict[str, str]) -> list[tuple[Any, ...]]:
+    rows: list[tuple[Any, ...]] = []
+
+    for row in read_csv(VIBRATION_CSV):
+        unit = parse_unit(row.get("机组", ""))
+        start = parse_time(row.get("开始小时", ""))
+        end = parse_time(row.get("结束小时", ""))
+        side = (row.get("最强异常侧") or "").strip()
+        metric = VIBRATION_SIDE_TO_METRIC.get(side)
+        if not unit or not start or not end or not metric:
+            continue
+        rows.append(
+            make_segment(
+                source_type="vibration",
+                unit=unit,
+                metric=metric,
+                start=start,
+                end=end + timedelta(hours=1),
+                label_type=(row.get("振动阶段") or "").strip(),
+                label_subtype=side or None,
+                row=row,
+                source_file=str(VIBRATION_CSV.relative_to(ROOT)),
+                site_points=site_points,
+            )
+        )
+
+    for row in read_csv(OIL_CSV):
+        unit = parse_unit(row.get("机组", ""))
+        start = parse_time(row.get("开始小时", ""))
+        end = parse_time(row.get("结束小时", ""))
+        if not unit or not start or not end:
+            continue
+        metric = "YSJ_5" if unit in {"7", "8", "9"} else "YSJ_1"
+        rows.append(
+            make_segment(
+                source_type="oil_pressure",
+                unit=unit,
+                metric=metric,
+                start=start,
+                end=end + timedelta(hours=1),
+                label_type=(row.get("压力阶段") or "").strip(),
+                label_subtype="润滑油压力",
+                row=row,
+                source_file=str(OIL_CSV.relative_to(ROOT)),
+                site_points=site_points,
+            )
+        )
+
+    for row in read_csv(TEMPERATURE_CSV):
+        unit = parse_unit(row.get("机组", ""))
+        start = parse_time(row.get("开始小时", ""))
+        end = parse_time(row.get("结束小时", ""))
+        metric = (row.get("阶段点位") or "").strip()
+        if not unit or not start or not end or not re.fullmatch(r"YSJ_\d+", metric):
+            continue
+        rows.append(
+            make_segment(
+                source_type="temperature",
+                unit=unit,
+                metric=metric,
+                start=start,
+                end=end + timedelta(hours=1),
+                label_type=(row.get("温度阶段") or "").strip(),
+                label_subtype=(row.get("阶段部位") or row.get("特征类型") or "").strip() or None,
+                row=row,
+                source_file=str(TEMPERATURE_CSV.relative_to(ROOT)),
+                site_points=site_points,
+            )
+        )
+
+    return [row for row in rows if row[17] in SEVERITY]
+
+
+def load_site_points() -> dict[str, str]:
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            cursor.execute("SELECT ItemName, ItemDescription FROM site_point")
+            return {str(row["ItemName"]): str(row["ItemDescription"] or "") for row in cursor.fetchall()}
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="导入算法阶段标签到 compressor_label")
+    parser.add_argument("--dry-run", action="store_true", help="只统计,不写库")
+    args = parser.parse_args()
+
+    ensure_label_table()
+    site_points = load_site_points()
+    rows = build_rows(site_points)
+    counts: dict[str, int] = {}
+    for row in rows:
+        counts[row[0]] = counts.get(row[0], 0) + 1
+    print(f"待导入 {len(rows)} 条:{counts}")
+    if args.dry_run:
+        return
+
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            cursor.executemany(INSERT_SQL, rows)
+            cursor.execute(
+                "SELECT source_type, COUNT(*) AS n FROM compressor_label "
+                "WHERE origin = 'algorithm' GROUP BY source_type ORDER BY source_type"
+            )
+            print("库内算法标签:", {row["source_type"]: int(row["n"]) for row in cursor.fetchall()})
+
+
+if __name__ == "__main__":
+    main()

+ 417 - 207
frontend/src/App.vue

@@ -1,14 +1,14 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
-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 { ElMessage, ElMessageBox } from 'element-plus'
+import { createLabel, deleteLabel, fetchFaults, fetchLabels, fetchPeriodDetail, fetchQueryOptions, fetchSitePoints, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, updateLabel } from './api'
 import PeriodModal from './components/PeriodModal.vue'
 import AlarmMonitor from './components/AlarmMonitor.vue'
 import Model2 from './components/Model2.vue'
 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 Fault, type MeasurementType, type PeriodDetail, type QueryOption, type SitePoint, type TimePoint, type QueryOptionsResponse, type WaveWindowFile, type WaveWindowResponse } from './types'
+import { DEVICE_POINT_TO_TYPE, DEVICE_POINTS, PRIMARY_DEVICE_POINT, type Cycle, type DevicePoint, type Fault, type MeasurementType, type PeriodDetail, type QueryOption, type SitePoint, type TimePoint, type QueryOptionsResponse, type WaveLabel, type WaveWindowFile, type WaveWindowResponse } from './types'
 
 const DEFAULT_DEVICE_PART = '7号机组一缸'
 const queryMeta = ref<QueryOptionsResponse | null>(null)
@@ -45,16 +45,130 @@ const initialized = ref(false)
 const periodDetail = ref<PeriodDetail | null>(null)
 const periodVisible = ref(false)
 const periodLoading = ref(false)
-const annotationWidth = ref(10)
-const annotations = ref<Annotation[]>([])
-const annotationLoading = ref(false)
+const labels = ref<WaveLabel[]>([])
+const labelDialogVisible = ref(false)
+const labelSaving = ref(false)
+const LABEL_SEVERITY: Record<string, number> = { 轻微: 1, 异常: 2, 严重异常: 3 }
+const labelForm = reactive({
+  id: null as number | null,
+  rowKey: '',
+  labelType: '异常',
+  startTime: '',
+  endTime: '',
+  note: '',
+  features: null as Record<string, unknown> | string | null,
+  targetType: 'pks' as 'wave_file' | 'pks',
+  channelKey: '',
+  unit: '',
+  deviceCode: '' as string | null,
+  devicePart: '' as string | null,
+  devicePoint: '' as string | null,
+  metricKey: '' as string | null,
+  metricLabel: '' as string | null,
+  origin: 'manual',
+})
+
+const HOUR_FEATURE_KEYS = new Set([
+  '持续自然小时数', '有效运行小时数',
+  '阶段低报警小时数', '阶段低低报警小时数',
+  '阶段高报警小时数', '阶段高高报警小时数',
+])
+
+const LABEL_FEATURE_NAMES: Record<string, string> = {
+  机组: '机组',
+  批次: '批次',
+  周期编号: '周期编号',
+  级: '级',
+  振动阶段: '振动阶段',
+  压力阶段: '压力阶段',
+  温度阶段: '温度阶段',
+  阶段部位: '部位',
+  阶段点位: '点位',
+  开始小时: '开始',
+  结束小时: '结束',
+  持续自然小时数: '持续',
+  有效运行小时数: '有效',
+  周期基线油压: '周期基线',
+  近期基线振动: '近期基线',
+  正常波动带: '波动带',
+  阶段开始小时油压: '开始油压',
+  阶段结束小时油压: '结束油压',
+  阶段最低小时油压: '最低油压',
+  阶段开始小时振动: '开始振动',
+  阶段结束小时振动: '结束振动',
+  阶段最大振动值: '最大振动',
+  阶段开始相对周期基线变化: '开始相对基线',
+  阶段结束相对周期基线变化: '结束相对基线',
+  阶段开始相对近期基线变化: '开始相对基线',
+  阶段结束相对近期基线变化: '结束相对基线',
+  阶段最大连续下降有效小时数: '最大连续下降',
+  阶段最大连续异常有效小时数: '最大连续异常',
+  阶段低报警小时数: '低报警',
+  阶段低低报警小时数: '低低报警',
+  阶段高报警小时数: '高报警',
+  阶段高高报警小时数: '高高报警',
+  是否形成确认等级: '确认等级',
+  阶段触发依据: '触发依据',
+  阶段综合评分: '综合评分',
+  持续时间得分: '持续得分',
+  平均振动得分: '平均得分',
+  最大振动值得分: '最大得分',
+  峰值偏离得分: '峰值偏离',
+  最强异常侧: '最强侧',
+  最强侧近期峰值基线: '最强侧基线',
+  最强侧近期稳健尺度: '稳健尺度',
+  '阶段最大峰值/基线比例': '峰值/基线',
+  峰值时刻: '峰值时刻',
+  峰值缸: '峰值缸',
+  最高温度: '最高温度',
+  最大自身偏离: '自身偏离',
+  最大级差: '最大级差',
+  最大级差基线: '级差基线',
+  最大级差变化: '级差变化',
+  最大同级偏离: '同级偏离',
+  最大同级偏低: '同级偏低',
+  最大稳健z: '稳健z',
+  最大同级相对1h: '同级相对1h',
+  最大同级相对3h: '同级相对3h',
+  最大同级相对6h: '同级相对6h',
+  首次短时触发: '首次短时',
+  首次持续触发: '首次持续',
+  阶段最高报警阈值: '高报警阈值',
+  阶段高高报警阈值: '高高报警阈值',
+  方向: '方向',
+  特征类型: '特征类型',
+  置信度: '置信度',
+  高频波动倍数: '高频倍数',
+  高频波动z: '高频z',
+  高频标准差: '高频标准差',
+  高频基线标准差: '高频基线标准差',
+  高频波动缸: '高频波动缸',
+  去趋势残差标准差: '去趋势残差',
+  每小时反复升降次数: '反复升降次数',
+  关联通道异常: '关联通道异常',
+}
+
+const labelFeatureItems = computed(() => {
+  const features = labelForm.features
+  if (!features || typeof features === 'string') return []
+  return Object.entries(features).map(([key, value]) => {
+    const empty = value === null || value === undefined || value === ''
+    let text = empty ? '—' : String(value)
+    if (!empty && HOUR_FEATURE_KEYS.has(key)) text += ' 小时'
+    if (!empty && key === '模型预测参考') text = text.replace(/^模型预测参考:?/, '')
+    return {
+      key,
+      label: LABEL_FEATURE_NAMES[key] ?? key,
+      value: text,
+      full: key === '阶段触发依据' || key === '模型预测参考' || key === '关联通道异常',
+    }
+  })
+})
 const loggedIn = ref(false)
 const loginLoading = ref(false)
 const loginError = ref('')
 const loginForm = reactive({ username: '', password: '' })
 const waveChartRef = ref<InstanceType<typeof WaveChart> | null>(null)
-const recordedFileIds = ref<number[]>([])
-const recordingFileId = ref<number | null>(null)
 let queryDebounce: ReturnType<typeof setTimeout> | undefined
 let waveRequestId = 0
 
@@ -106,12 +220,11 @@ const windowSiteRows = computed(() => {
   }))
 })
 
-const devicePointCounts = computed<Record<string, { pre: number | null; ab: number | null }>>(() => {
-  const map: Record<string, { pre: number | null; ab: number | null }> = {}
+const devicePointCounts = computed<Record<string, { ab: number | null }>>(() => {
+  const map: Record<string, { ab: number | 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,
     }
   }
@@ -119,11 +232,8 @@ const devicePointCounts = computed<Record<string, { pre: number | null; ab: numb
 })
 
 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 ? `${point} (${inner.join(' · ')})` : point
+  const ab = devicePointCounts.value[point]?.ab ?? null
+  return ab != null ? `${point} (${ab})` : point
 }
 const selectedOptionRows = computed<QueryOption[]>(() => (
   queryMeta.value?.options.filter((row) => (
@@ -151,7 +261,6 @@ const currentCycles = computed(() => waveData.value?.cycles ?? [])
 const currentSource = computed(() => waveData.value?.source ?? timePointsSource.value)
 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
@@ -359,7 +468,7 @@ async function loadTimePoints() {
       Math.min(firstRunningIndex(), Math.max(0, timePoints.value.length - stripWindowSize.value)),
     )
     chartDirty.value = true
-    void loadAnnotations()
+    void loadLabels()
     void loadFaults()
   } catch (error) {
     timePoints.value = []
@@ -576,144 +685,248 @@ function closePeriod() {
   periodVisible.value = false
 }
 
-async function loadAnnotationConfig() {
-  try {
-    const result = await fetchAnnotationConfig()
-    annotationWidth.value = result.annotationWidth
-  } catch {
-    annotationWidth.value = 10
-  }
+function addMinutes(value: string, minutes: number): string | undefined {
+  const date = new Date(value.replace(' ', 'T'))
+  if (Number.isNaN(date.getTime())) return undefined
+  date.setMinutes(date.getMinutes() + minutes)
+  const pad = (part: number) => String(part).padStart(2, '0')
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
 }
 
-async function loadAnnotations() {
-  const ids = [...new Set(
-    timePoints.value.flatMap((point) => (
-      selectedDevicePoints.value
-        .map((devicePoint) => point.files[devicePoint]?.id)
-        .filter((id): id is number => id != null)
-    )),
-  )]
-  if (!ids.length) {
-    annotations.value = []
+function metricKeyOfSitePoint(itemName: string): string {
+  return itemName.replace(/^YSJ\d+_/, 'YSJ_')
+}
+
+async function loadLabels() {
+  if (!timePoints.value.length) {
+    labels.value = []
+    return
+  }
+  const start = timePoints.value[0]?.sampleTime
+  const last = timePoints.value[timePoints.value.length - 1]?.sampleTime
+  const end = last ? addMinutes(last, 15) : undefined
+  const metricKeys = isPksMode.value ? selectedSitePoints.value.map(metricKeyOfSitePoint) : []
+  const waveChannelKeys = selectedDevicePoints.value.map((devicePoint) => (
+    `wave:${unitNumber.value || selectedDevicePart.value}:${selectedDevicePart.value}:${devicePoint}`
+  ))
+  if (!metricKeys.length && !waveChannelKeys.length) {
+    labels.value = []
     return
   }
   try {
-    const result = await fetchAnnotationsByIds(ids)
-    annotations.value = result.annotations
+    const requests = []
+    if (metricKeys.length) {
+      requests.push(fetchLabels({
+        unit: unitNumber.value ?? undefined,
+        deviceCode: unitNumber.value ? `${unitNumber.value}#` : undefined,
+        metricKeys,
+        start,
+        end,
+      }))
+    }
+    if (waveChannelKeys.length) {
+      requests.push(fetchLabels({ channelKeys: waveChannelKeys, start, end }))
+    }
+    const results = await Promise.all(requests)
+    labels.value = results.flatMap((result) => result.labels)
   } catch {
-    annotations.value = []
+    labels.value = []
   }
 }
 
-type AnnotationBlock = {
-  waveFileId: number
-  periodStart: number
-  periodEnd: number
-  sampleIndexStart: number
-  sampleIndexEnd: number
+function toHourText(value: string): string {
+  const date = new Date(value.replace(' ', 'T'))
+  if (Number.isNaN(date.getTime())) return value
+  date.setMinutes(0, 0, 0)
+  const pad = (part: number) => String(part).padStart(2, '0')
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:00:00`
 }
 
-function annotationBlock(cycle: Cycle): AnnotationBlock | null {
-  const width = Math.max(1, annotationWidth.value)
-  const fileCycles = (waveData.value?.cycles ?? []).filter((item) => item.waveFileId === cycle.waveFileId)
-  const maxPeriod = Math.max(1, ...fileCycles.map((item) => item.periodNo))
-  let leftBound = 1
-  let rightBound = maxPeriod
-  const existing = annotations.value
-    .filter((item) => item.waveFileId === cycle.waveFileId)
-    .sort((a, b) => a.periodStart - b.periodStart)
-  for (const item of existing) {
-    if (item.periodEnd < cycle.periodNo) {
-      leftBound = Math.max(leftBound, item.periodEnd + 1)
-    } else if (item.periodStart > cycle.periodNo) {
-      rightBound = Math.min(rightBound, item.periodStart - 1)
-    } else {
-      return null
+function labelTargetFor(rowKey: string) {
+  const isSite = isPksMode.value && selectedSitePoints.value.includes(rowKey)
+  if (isSite) {
+    const unit = unitNumber.value ?? ''
+    const deviceCode = unit ? `${unit}#` : ''
+    const metricKey = metricKeyOfSitePoint(rowKey)
+    const description = siteDescription(rowKey)
+    return {
+      targetType: 'pks' as const,
+      channelKey: `pks:${unit}:${deviceCode}:${metricKey}`,
+      unit,
+      deviceCode,
+      devicePart: `${unit}号机`,
+      devicePoint: description,
+      metricKey,
+      metricLabel: description,
     }
   }
-  if (leftBound > rightBound) return null
-  let start = Math.max(leftBound, cycle.periodNo - Math.floor((width - 1) / 2))
-  let end = Math.min(rightBound, start + width - 1)
-  start = Math.max(leftBound, end - width + 1)
-  const startCycle = fileCycles.find((item) => item.periodNo === start)
-  const endCycle = fileCycles.find((item) => item.periodNo === end)
+  const unit = unitNumber.value ?? ''
+  const devicePart = selectedDevicePart.value
+  return {
+    targetType: 'wave_file' as const,
+    channelKey: `wave:${unit || devicePart}:${devicePart}:${rowKey}`,
+    unit,
+    deviceCode: unit ? `${unit}#` : null,
+    devicePart,
+    devicePoint: rowKey,
+    metricKey: null,
+    metricLabel: rowKey,
+  }
+}
+
+function labelVisibleAt(rowKey: string, at: string): WaveLabel | undefined {
+  const target = new Date(at.replace(' ', 'T')).getTime()
+  const candidates = labels.value.filter((label) => (
+    label.rowKey === rowKey
+    && new Date(label.startTime.replace(' ', 'T')).getTime() <= target
+    && target < new Date(label.endTime.replace(' ', 'T')).getTime()
+  ))
+  candidates.sort((a, b) => {
+    const manualDiff = Number(b.origin === 'manual') - Number(a.origin === 'manual')
+    if (manualDiff) return manualDiff
+    return (LABEL_SEVERITY[b.labelType] ?? 0) - (LABEL_SEVERITY[a.labelType] ?? 0)
+  })
+  return candidates[0]
+}
+
+function labelInputFromForm() {
   return {
-    waveFileId: cycle.waveFileId,
-    periodStart: start,
-    periodEnd: end,
-    sampleIndexStart: startCycle?.startSampleIndex ?? cycle.startSampleIndex,
-    sampleIndexEnd: endCycle?.endSampleIndex ?? cycle.endSampleIndex,
+    target_type: labelForm.targetType,
+    target_id: null,
+    channel_key: labelForm.channelKey,
+    unit: labelForm.unit,
+    device_code: labelForm.deviceCode,
+    device_part: labelForm.devicePart,
+    device_point: labelForm.devicePoint,
+    metric_key: labelForm.metricKey,
+    metric_label: labelForm.metricLabel,
+    start_time: toHourText(labelForm.startTime),
+    end_time: toHourText(labelForm.endTime),
+    label_type: labelForm.labelType,
+    label_subtype: null,
+    note: labelForm.note || null,
   }
 }
 
-async function chooseLabel(block: AnnotationBlock): Promise<AnnotationLabel | null> {
+async function onLabelSingle(payload: { rowKey: string; startTime: string; endTime: string }) {
+  const target = labelTargetFor(payload.rowKey)
   try {
-    await ElMessageBox.confirm(
-      `将在周期 ${block.periodStart} — ${block.periodEnd} 创建标注,请选择样本类型`,
-      '创建标注',
-      {
-        confirmButtonText: '正常样本',
-        cancelButtonText: '异常样本',
-        distinguishCancelAndClose: true,
-        type: 'info',
-        closeOnClickModal: false,
-      },
-    )
-    return '正常'
-  } catch (action) {
-    if (action === 'cancel') return '异常'
-    return null
+    await createLabel({
+      target_type: target.targetType,
+      target_id: null,
+      channel_key: target.channelKey,
+      unit: target.unit,
+      device_code: target.deviceCode,
+      device_part: target.devicePart,
+      device_point: target.devicePoint,
+      metric_key: target.metricKey,
+      metric_label: target.metricLabel,
+      start_time: toHourText(payload.startTime),
+      end_time: toHourText(payload.endTime),
+      label_type: '异常',
+      label_subtype: null,
+      note: null,
+    })
+    await loadLabels()
+  } catch (error) {
+    ElMessage.error(error instanceof Error ? error.message : '标注失败')
   }
 }
 
-async function onAnnotationToggle(cycle: Cycle) {
-  if (annotationLoading.value) return
-  const existing = annotations.value.find((item) => (
-    item.waveFileId === cycle.waveFileId && cycle.periodNo >= item.periodStart && cycle.periodNo <= item.periodEnd
-  ))
-  if (existing) {
-    try {
-      await ElMessageBox.confirm(
-        `取消该标注(${existing.label},周期 ${existing.periodStart} — ${existing.periodEnd})?`,
-        '取消标注',
-        { confirmButtonText: '取消标注', cancelButtonText: '保留', type: 'warning', closeOnClickModal: false },
-      )
-    } catch {
-      return
-    }
-    annotationLoading.value = true
-    try {
-      await deleteAnnotation(existing.id)
-      await loadAnnotations()
-    } catch (error) {
-      errorMessage.value = error instanceof Error ? error.message : '标注取消失败'
-    } finally {
-      annotationLoading.value = false
-    }
+function onLabelDouble(payload: { rowKey: string; startTime: string; endTime: string; label?: WaveLabel }) {
+  const target = labelTargetFor(payload.rowKey)
+  labelForm.id = payload.label?.id ?? null
+  labelForm.rowKey = payload.rowKey
+  labelForm.labelType = payload.label?.labelType ?? '异常'
+  labelForm.startTime = payload.label?.startTime ?? toHourText(payload.startTime)
+  labelForm.endTime = payload.label?.endTime ?? toHourText(payload.endTime)
+  labelForm.note = payload.label?.note ?? ''
+  labelForm.features = payload.label?.features ?? null
+  labelForm.origin = payload.label?.origin ?? 'manual'
+  labelForm.targetType = target.targetType
+  labelForm.channelKey = target.channelKey
+  labelForm.unit = target.unit
+  labelForm.deviceCode = target.deviceCode
+  labelForm.devicePart = target.devicePart
+  labelForm.devicePoint = target.devicePoint
+  labelForm.metricKey = target.metricKey
+  labelForm.metricLabel = target.metricLabel
+  labelDialogVisible.value = true
+}
+
+async function saveLabel() {
+  if (labelSaving.value) return
+  if (!labelForm.startTime || !labelForm.endTime) {
+    ElMessage.warning('请设置开始和结束时间')
     return
   }
-  const block = annotationBlock(cycle)
-  if (!block) return
-  const label = await chooseLabel(block)
-  if (!label) return
-  annotationLoading.value = true
+  if (toHourText(labelForm.startTime) >= toHourText(labelForm.endTime)) {
+    ElMessage.warning('结束时间必须大于开始时间')
+    return
+  }
+  labelSaving.value = true
   try {
-    await createAnnotation({
-      wave_file_id: block.waveFileId,
-      label,
-      period_start: block.periodStart,
-      period_end: block.periodEnd,
-      sample_index_start: block.sampleIndexStart,
-      sample_index_end: block.sampleIndexEnd,
+    const payload = labelInputFromForm()
+    if (labelForm.id != null) await updateLabel(labelForm.id, payload)
+    else await createLabel(payload)
+    labelDialogVisible.value = false
+    await loadLabels()
+    ElMessage.success('标注已保存')
+  } catch (error) {
+    ElMessage.error(error instanceof Error ? error.message : '保存失败')
+  } finally {
+    labelSaving.value = false
+  }
+}
+
+async function removeLabel() {
+  if (labelForm.id == null || labelSaving.value) return
+  try {
+    await ElMessageBox.confirm('删除这条标注?', '删除标注', {
+      confirmButtonText: '删除',
+      cancelButtonText: '保留',
+      type: 'warning',
     })
-    await loadAnnotations()
+  } catch {
+    return
+  }
+  labelSaving.value = true
+  try {
+    await deleteLabel(labelForm.id)
+    labelDialogVisible.value = false
+    await loadLabels()
+    ElMessage.success('已删除')
   } catch (error) {
-    errorMessage.value = error instanceof Error ? error.message : '标注创建失败'
+    ElMessage.error(error instanceof Error ? error.message : '删除失败')
   } finally {
-    annotationLoading.value = false
+    labelSaving.value = false
   }
 }
 
+const timelineLabelLevels = computed<(string | null)[]>(() => {
+  const points = timePoints.value
+  if (!points.length) return []
+  if (!labels.value.length) return points.map(() => null)
+  const ranges = labels.value.map((label) => ({
+    start: new Date(label.startTime.replace(' ', 'T')).getTime(),
+    end: new Date(label.endTime.replace(' ', 'T')).getTime(),
+    level: label.labelType,
+    severity: LABEL_SEVERITY[label.labelType] ?? 0,
+  }))
+  return points.map((point) => {
+    const target = new Date(point.sampleTime.replace(' ', 'T')).getTime()
+    let best: string | null = null
+    let bestSeverity = 0
+    for (const range of ranges) {
+      if (range.start <= target && target < range.end && range.severity > bestSeverity) {
+        best = range.level
+        bestSeverity = range.severity
+      }
+    }
+    return best
+  })
+})
+
 watch(selectedDevicePart, async (current, previous) => {
   if (!initialized.value) return
   if (previous && previous !== current) rememberPart(previous)
@@ -761,61 +974,10 @@ watch(selectedSitePoints, (points) => {
   if (isPksMode.value) scheduleTimePoints()
   if (points.length) fileListPoint.value = SITE_TAB_LABEL
 })
-watch(waveData, () => void refreshPretrainStatus(), { deep: false })
-
-async function refreshPretrainStatus() {
-  const ids = waveData.value?.files.map((file) => file.id) ?? []
-  if (!ids.length) {
-    recordedFileIds.value = []
-    return
-  }
-  try {
-    const result = await fetchPretrainStatus(ids)
-    recordedFileIds.value = result.recorded
-  } catch {
-    recordedFileIds.value = []
-  }
-}
-
 function locateFile(file: WaveWindowFile) {
   waveChartRef.value?.locateFile(file.pointIndex)
 }
 
-async function recordFile(file: WaveWindowFile) {
-  if (recordedFileIds.value.includes(file.id) || recordingFileId.value != null) return
-  recordingFileId.value = file.id
-  try {
-    const result = await recordPretrain({
-      id: file.id,
-      point_name: file.pointName,
-      measurement_type: file.measurementType,
-      rpm: file.rpm,
-      sample_time: file.sampleTime,
-      file_name: file.fileName,
-    })
-    if (result.recorded || result.already) {
-      recordedFileIds.value = [...new Set([...recordedFileIds.value, file.id])]
-    }
-  } catch (error) {
-    errorMessage.value = error instanceof Error ? error.message : '记录失败'
-  } finally {
-    recordingFileId.value = null
-  }
-}
-
-async function deleteRecordedFile(file: WaveWindowFile) {
-  if (!recordedFileIds.value.includes(file.id) || recordingFileId.value != null) return
-  recordingFileId.value = file.id
-  try {
-    await deletePretrain(file.id)
-    recordedFileIds.value = recordedFileIds.value.filter((id) => id !== file.id)
-  } catch (error) {
-    errorMessage.value = error instanceof Error ? error.message : '删除失败'
-  } finally {
-    recordingFileId.value = null
-  }
-}
-
 async function handleLogin() {
   if (!loginForm.username || !loginForm.password) {
     loginError.value = '请输入账号和密码'
@@ -829,7 +991,6 @@ async function handleLogin() {
     loginForm.password = ''
     await loadOptions()
     void loadTspluseRuler()
-    void loadAnnotationConfig()
   } catch (error) {
     loginError.value = error instanceof Error ? error.message : '登录失败'
   } finally {
@@ -842,14 +1003,14 @@ async function handleLogout() {
   loggedIn.value = false
   waveData.value = null
   timePoints.value = []
-  annotations.value = []
+  labels.value = []
 }
 
 function onAuthExpired() {
   loggedIn.value = false
   waveData.value = null
   timePoints.value = []
-  annotations.value = []
+  labels.value = []
 }
 
 onMounted(() => {
@@ -858,7 +1019,6 @@ onMounted(() => {
   if (loggedIn.value) {
     void loadOptions()
     void loadTspluseRuler()
-    void loadAnnotationConfig()
   }
 })
 onBeforeUnmount(() => {
@@ -951,9 +1111,9 @@ onBeforeUnmount(() => {
                     :value="point"
                   >
                     <span>{{ point }}</span>
-                    <template v-if="devicePointCounts[point]?.pre !== null || devicePointCounts[point]?.ab !== null">
+                    <template v-if="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 class="abnormal-count">{{ devicePointCounts[point]?.ab }}</span>)
                       </span>
                     </template>
                   </el-option>
@@ -975,9 +1135,9 @@ onBeforeUnmount(() => {
                   :value="point"
                 >
                   <span>{{ point }}</span>
-                  <template v-if="devicePointCounts[point]?.pre !== null || devicePointCounts[point]?.ab !== null">
+                  <template v-if="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 class="abnormal-count">{{ devicePointCounts[point]?.ab }}</span>)
                     </span>
                   </template>
                 </el-option>
@@ -1087,7 +1247,7 @@ onBeforeUnmount(() => {
           :loading="timePointsLoading"
           :min-time="minTime"
           :max-time="maxTime"
-          :annotated-file-ids="annotatedFileIds"
+          :label-levels="timelineLabelLevels"
           :faults="faults"
           :ruler="ruler"
           :site-points="isPksMode ? selectedSitePoints : []"
@@ -1184,24 +1344,6 @@ onBeforeUnmount(() => {
                 <td class="file-name" :title="file.fileName">{{ file.fileName || '—' }}</td>
                 <td class="actions">
                   <el-button class="file-action" size="small" plain :disabled="!waveData" @click="locateFile(file)">定位</el-button>
-                  <el-button
-                    v-if="!recordedFileIds.includes(file.id)"
-                    class="file-action"
-                    size="small"
-                    type="primary"
-                    plain
-                    :loading="recordingFileId === file.id"
-                    @click="recordFile(file)"
-                  >记录</el-button>
-                  <el-button
-                    v-else
-                    class="file-action"
-                    size="small"
-                    type="danger"
-                    plain
-                    :loading="recordingFileId === file.id"
-                    @click="deleteRecordedFile(file)"
-                  >删除</el-button>
                 </td>
               </tr>
             </tbody>
@@ -1216,7 +1358,6 @@ onBeforeUnmount(() => {
             <h2>波形预览</h2>
           </div>
           <div class="chart-actions">
-            <span class="annotation-width">标度宽度:<strong>{{ annotationWidth }}</strong></span>
             <el-radio-group v-model="chartMode" class="mode-switch" size="small">
               <el-radio-button label="split">分图显示</el-radio-button>
               <el-radio-button label="normalized">归一化分图</el-radio-button>
@@ -1238,10 +1379,11 @@ onBeforeUnmount(() => {
           :points="selectedWindowPoints"
           :mode="chartMode"
           :loading="waveLoading"
-          :annotations="annotations"
+          :labels="labels"
           :site-descriptions="siteDescriptionMap"
           @period-dblclick="openPeriod"
-          @annotation-toggle="onAnnotationToggle"
+          @label-single="onLabelSingle"
+          @label-double="onLabelDouble"
         />
       </section>
 
@@ -1279,5 +1421,73 @@ onBeforeUnmount(() => {
     </el-tabs>
 
     <PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" @close="closePeriod" />
+
+    <el-dialog
+      v-model="labelDialogVisible"
+      title="标注"
+      width="1000px"
+      top="5vh"
+      append-to-body
+      class="label-dialog"
+      :close-on-click-modal="false"
+    >
+      <div class="label-form">
+        <div class="label-form-grid">
+          <div class="label-field">
+            <span class="label-field-name">点位</span>
+            <span class="label-field-value" :title="`${labelForm.devicePart} · ${labelForm.devicePoint}`">{{ labelForm.devicePart }} · {{ labelForm.devicePoint }}</span>
+          </div>
+          <div class="label-field">
+            <span class="label-field-name">故障级别</span>
+            <el-radio-group v-model="labelForm.labelType" size="small">
+              <el-radio-button label="轻微" />
+              <el-radio-button label="异常" />
+              <el-radio-button label="严重异常" />
+            </el-radio-group>
+          </div>
+          <div class="label-field">
+            <span class="label-field-name">开始</span>
+            <el-date-picker
+              v-model="labelForm.startTime"
+              type="datetime"
+              format="YYYY-MM-DD HH:00"
+              value-format="YYYY-MM-DD HH:00:00"
+              placeholder="按整点"
+            />
+          </div>
+          <div class="label-field">
+            <span class="label-field-name">结束</span>
+            <el-date-picker
+              v-model="labelForm.endTime"
+              type="datetime"
+              format="YYYY-MM-DD HH:00"
+              value-format="YYYY-MM-DD HH:00:00"
+              placeholder="按整点"
+            />
+          </div>
+          <div class="label-field label-field-wide">
+            <span class="label-field-name">备注</span>
+            <el-input v-model="labelForm.note" placeholder="可选" />
+          </div>
+        </div>
+
+        <div class="label-info-section">
+          <div class="label-info-title">算法属性</div>
+          <div v-if="labelFeatureItems.length" class="label-info-grid">
+            <template v-for="item in labelFeatureItems" :key="item.key">
+              <div class="label-info-label" :class="{ full: item.full }">{{ item.label }}</div>
+              <div class="label-info-value" :class="{ full: item.full }">{{ item.value }}</div>
+            </template>
+          </div>
+          <pre v-else-if="labelForm.features" class="label-info-raw">{{ labelForm.features }}</pre>
+          <div v-else class="label-info-empty">无算法属性</div>
+        </div>
+      </div>
+      <template #footer>
+        <el-button v-if="labelForm.id != null" type="danger" plain :loading="labelSaving" @click="removeLabel">删除</el-button>
+        <el-button @click="labelDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="labelSaving" @click="saveLabel">保存</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>

+ 49 - 17
frontend/src/api.ts

@@ -8,6 +8,7 @@ import type {
   AnnotationListResponse,
   DevicePoint,
   FaultsResponse,
+  LabelListResponse,
   PeriodDetail,
   PressureAngleQueryResponse,
   PressureAngleSyncResponse,
@@ -17,6 +18,7 @@ import type {
   TimePoint,
   TimePointsResponse,
   TspluseRulerResponse,
+  WaveLabel,
   WaveWindowResponse,
 } from './types'
 
@@ -214,31 +216,61 @@ export function deleteAnnotation(id: number) {
   })
 }
 
-export type PretrainRecordInput = {
-  id: number
-  point_name: string
-  measurement_type: string
-  rpm: number
-  sample_time: string
-  file_name: string
+export function fetchLabels(params: {
+  unit?: string
+  deviceCode?: string
+  metricKeys?: string[]
+  channelKeys?: string[]
+  waveFileIds?: number[]
+  start?: string
+  end?: string
+}) {
+  const search = new URLSearchParams()
+  if (params.unit) search.set('unit', params.unit)
+  if (params.deviceCode) search.set('device_code', params.deviceCode)
+  params.metricKeys?.forEach((key) => search.append('metric_keys', key))
+  params.channelKeys?.forEach((key) => search.append('channel_keys', key))
+  params.waveFileIds?.forEach((id) => search.append('wave_file_ids', String(id)))
+  if (params.start) search.set('start', params.start)
+  if (params.end) search.set('end', params.end)
+  return request<LabelListResponse>(`/api/labels?${search.toString()}`)
 }
 
-export function fetchPretrainStatus(ids: number[]) {
-  if (!ids.length) return Promise.resolve({ recorded: [] as number[] })
-  const search = new URLSearchParams()
-  ids.forEach((id) => search.append('ids', String(id)))
-  return request<{ recorded: number[] }>(`/api/pretrain-records?${search.toString()}`)
+export type LabelInput = {
+  target_type: 'wave_file' | 'pks'
+  target_id?: number | null
+  channel_key: string
+  unit: string
+  device_code?: string | null
+  device_part?: string | null
+  device_point?: string | null
+  metric_key?: string | null
+  metric_label?: string | null
+  start_time: string
+  end_time: string
+  label_type: string
+  label_subtype?: string | null
+  note?: string | null
 }
 
-export function recordPretrain(row: PretrainRecordInput) {
-  return request<{ recorded: boolean; already: boolean }>('/api/pretrain-records', {
+export function createLabel(payload: LabelInput) {
+  return request<WaveLabel>('/api/labels', {
     method: 'POST',
-    body: JSON.stringify(row),
+    body: JSON.stringify(payload),
+  })
+}
+
+export function updateLabel(id: number, payload: LabelInput) {
+  return request<WaveLabel>(`/api/labels/${id}`, {
+    method: 'PUT',
+    body: JSON.stringify(payload),
   })
 }
 
-export function deletePretrain(id: number) {
-  return request<{ deleted: boolean }>(`/api/pretrain-records/${id}`, {
+export function deleteLabel(id: number) {
+  return request<{ deleted: number }>(`/api/labels/${id}`, {
     method: 'DELETE',
   })
 }
+
+

+ 20 - 23
frontend/src/components/TimePointStrip.vue

@@ -13,7 +13,7 @@ const props = defineProps<{
   loading?: boolean
   minTime?: string
   maxTime?: string
-  annotatedFileIds?: number[]
+  labelLevels?: (string | null)[]
   faults?: Fault[]
   ruler?: { min: number; max: number } | null
   sitePoints?: string[]
@@ -141,30 +141,26 @@ function draw() {
   context.fillStyle = '#52616b'
   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.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.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
-    lastGrayX = x
-    context.fillStyle = '#c0c4cc'
-    context.beginPath()
-    context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3 : 2.5, 0, Math.PI * 2)
-    context.fill()
-  })
+  const labelColors: Record<string, string> = {
+    轻微: '#d4a017',
+    异常: '#e8792b',
+    严重异常: '#d64545',
+  }
+  let lastDotX = -Infinity
   props.points.forEach((point, pointIndex) => {
-    if (!isAnnotated(point)) return
+    const level = props.labelLevels?.[pointIndex] ?? null
+    if (!level) {
+      const hasData = props.devicePoints.some((devicePoint) => point.files[devicePoint])
+        || Object.keys(point.siteValues ?? {}).length > 0
+      if (!hasData) return
+    }
     const x = xAt(pointIndex)
-    context.fillStyle = '#e05252'
+    if (x - lastDotX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
+    lastDotX = x
+    context.fillStyle = level ? (labelColors[level] ?? '#e05252') : '#c0c4cc'
+    const radius = pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5
     context.beginPath()
-    context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
+    context.arc(x, annotationY, radius, 0, Math.PI * 2)
     context.fill()
   })
 
@@ -254,7 +250,7 @@ function updateSlider(value: number | number[]) {
 }
 
 watch(
-  () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.annotatedFileIds, props.faults, props.ruler, props.sitePoints],
+  () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.labelLevels, props.faults, props.ruler, props.sitePoints],
   () => nextTick(draw),
   { deep: true },
 )
@@ -278,6 +274,7 @@ onBeforeUnmount(() => resizeObserver?.disconnect())
         <span class="time-legend"><i class="legend-running"></i>运转</span>
         <span class="time-legend"><i class="legend-stopped"></i>停机</span>
         <span class="time-legend"><i class="legend-fault"></i>故障</span>
+        <span class="time-legend"><i class="legend-label"></i>标注</span>
         <span class="summary-divider">/</span>
         <span class="mono">{{ points.length.toLocaleString() }}</span> 个时间点
         <span class="summary-divider">/</span>

+ 209 - 117
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, TimePoint, WaveWindowResponse } from '../types'
+import type { Cycle, TimePoint, WaveLabel, WaveWindowResponse } from '../types'
 import { SITE_POINT_COLORS } from '../types'
 import { fixedAxisRange } from '../utils/axis'
 
@@ -10,13 +10,14 @@ const props = defineProps<{
   points: TimePoint[]
   mode: 'split' | 'normalized'
   loading?: boolean
-  annotations?: Annotation[]
+  labels?: WaveLabel[]
   siteDescriptions?: Record<string, string>
 }>()
 
 const emit = defineEmits<{
   periodDblclick: [cycle: Cycle]
-  annotationToggle: [cycle: Cycle]
+  labelSingle: [payload: { rowKey: string; startTime: string; endTime: string }]
+  labelDouble: [payload: { rowKey: string; startTime: string; endTime: string; label?: WaveLabel }]
 }>()
 
 const chartElement = ref<HTMLDivElement | null>(null)
@@ -75,9 +76,21 @@ function siteScaleRuleFor(description: string | undefined): SiteScaleRule | null
 
 const SPLIT_ROW_HEIGHT = 120
 const SPLIT_ROW_GAP = 14
+const LABEL_LANE_HEIGHT = 18
 const CHART_TOP_INSET = 32
 const CHART_BOTTOM_INSET = 68
 const EMPTY_CHART_HEIGHT = 480
+const LABEL_LEVEL_COLORS: Record<string, string> = {
+  轻微: '#d4a017',
+  异常: '#e8792b',
+  严重异常: '#d64545',
+}
+const LABEL_LEVEL_TEXT: Record<string, string> = {
+  轻微: '轻微',
+  异常: '异常',
+  严重异常: '严重',
+}
+
 
 function buildRowMetas(data: WaveWindowResponse): RowMeta[] {
   const metas: RowMeta[] = data.devicePoints.map((name) => ({ name, kind: 'device' as const, siteSeq: -1 }))
@@ -94,7 +107,8 @@ function buildRowMetas(data: WaveWindowResponse): RowMeta[] {
 
 function splitTotalHeight(rowCount: number) {
   if (rowCount <= 0) return EMPTY_CHART_HEIGHT
-  return CHART_TOP_INSET + CHART_BOTTOM_INSET + rowCount * SPLIT_ROW_HEIGHT + (rowCount - 1) * SPLIT_ROW_GAP
+  const rowBlock = LABEL_LANE_HEIGHT + SPLIT_ROW_HEIGHT
+  return CHART_TOP_INSET + CHART_BOTTOM_INSET + rowCount * rowBlock + (rowCount - 1) * SPLIT_ROW_GAP
 }
 
 function niceAxisExtent(values: number[]): [number, number] {
@@ -225,49 +239,6 @@ function triggerLines(xs: number[]) {
   }))
 }
 
-function annotationXRange(annotation: Annotation): { start: number; end: number } | null {
-  const matching = (props.data?.cycles ?? []).filter(
-    (cycle) => cycle.waveFileId === annotation.waveFileId
-      && cycle.periodNo >= annotation.periodStart
-      && cycle.periodNo <= annotation.periodEnd,
-  )
-  if (!matching.length) return null
-  const start = Math.min(...matching.map((cycle) => cycle.startX))
-  const end = Math.max(...matching.map((cycle) => cycle.endX))
-  return { start, end }
-}
-
-function annotationAreas(annotations: Annotation[]): any[] {
-  const areas: any[] = []
-  annotations.forEach((annotation) => {
-    const range = annotationXRange(annotation)
-    if (!range) return
-    const color = annotation.label === '异常' ? 'rgba(229, 57, 46, 0.22)' : 'rgba(46, 125, 50, 0.18)'
-    areas.push([
-      { xAxis: range.start, itemStyle: { color } },
-      { xAxis: range.end },
-    ])
-  })
-  return areas
-}
-
-function buildAnnotationOverlaySeries(annotations: Annotation[]): echarts.SeriesOption[] {
-  const data = props.data
-  if (!data) return []
-  const gridCount = buildRowMetas(data).length
-  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 },
-  }))
-}
-
 function initialZoom(data: WaveWindowResponse): { start: number; end: number } {
   // 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
@@ -290,28 +261,6 @@ function readCurrentZoom(data: WaveWindowResponse): { start: number; end: number
 
 const xMax = computed(() => props.data?.xMax ?? 1)
 
-const annotationSegments = computed(() => {
-  return (props.annotations ?? [])
-    .map((annotation) => {
-      const range = annotationXRange(annotation)
-      return range ? { annotation, ...range } : null
-    })
-    .filter((item): item is { annotation: Annotation; start: number; end: number } => item !== null)
-    .sort((a, b) => a.start - b.start)
-})
-
-const activeAnnotationIndex = ref(0)
-
-function focusAnnotation(index: number) {
-  const segment = annotationSegments.value[index]
-  if (!segment || !chart || !props.data) return
-  activeAnnotationIndex.value = index
-  const start = Math.max(0, (segment.start / xMax.value) * 100)
-  const end = Math.min(100, (segment.end / xMax.value) * 100)
-  chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start, end })
-  chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start, end })
-}
-
 function locateFile(pointIndex: number) {
   if (!chart || !props.data) return
   const start = Math.max(0, (pointIndex / xMax.value) * 100)
@@ -322,11 +271,187 @@ function locateFile(pointIndex: number) {
 
 defineExpose({ locateFile })
 
-watch(annotationSegments, () => {
-  if (activeAnnotationIndex.value >= annotationSegments.value.length) {
-    activeAnnotationIndex.value = Math.max(0, annotationSegments.value.length - 1)
+function pointTimeMs(value: string): number {
+  return new Date(value.replace(' ', 'T')).getTime()
+}
+
+function lowerBoundTime(times: number[], target: number): number {
+  let low = 0
+  let high = times.length
+  while (low < high) {
+    const middle = (low + high) >> 1
+    if (times[middle] < target) low = middle + 1
+    else high = middle
   }
-})
+  return low
+}
+
+type LaneSegment = { start: number; end: number; labelType: string }
+
+function laneSegmentsForRow(rowKey: string): LaneSegment[] {
+  const rowLabels = (props.labels ?? []).filter((label) => label.rowKey === rowKey)
+  if (!rowLabels.length || !props.points.length) return []
+  const times = props.points.map((point) => pointTimeMs(point.sampleTime))
+  const segments: LaneSegment[] = []
+  rowLabels.forEach((label) => {
+    const startMs = pointTimeMs(label.startTime)
+    const endMs = pointTimeMs(label.endTime)
+    if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return
+    const start = lowerBoundTime(times, startMs)
+    const end = lowerBoundTime(times, endMs)
+    if (end <= start) return
+    segments.push({ start, end, labelType: label.labelType })
+  })
+  return segments
+}
+
+function formatHour(date: Date): string {
+  const pad = (part: number) => String(part).padStart(2, '0')
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:00:00`
+}
+
+function hourRangeAt(gridIndex: number, offsetX: number): { startTime: string; endTime: string; startMs: number } | null {
+  if (!chart || !props.points.length) return null
+  const value = Number(chart.convertFromPixel({ xAxisIndex: gridIndex }, offsetX))
+  if (!Number.isFinite(value)) return null
+  const index = Math.max(0, Math.min(props.points.length - 1, Math.floor(value)))
+  const point = props.points[index]
+  if (!point) return null
+  const date = new Date(point.sampleTime.replace(' ', 'T'))
+  if (Number.isNaN(date.getTime())) return null
+  date.setMinutes(0, 0, 0)
+  const end = new Date(date.getTime())
+  end.setHours(end.getHours() + 1)
+  return { startTime: formatHour(date), endTime: formatHour(end), startMs: date.getTime() }
+}
+
+function existingLabelAt(rowKey: string, startMs: number): WaveLabel | undefined {
+  const candidates = (props.labels ?? []).filter((label) => {
+    if (label.rowKey !== rowKey) return false
+    const start = pointTimeMs(label.startTime)
+    const end = pointTimeMs(label.endTime)
+    return Number.isFinite(start) && Number.isFinite(end) && start <= startMs && startMs < end
+  })
+  candidates.sort((a, b) => Number(b.origin === 'manual') - Number(a.origin === 'manual'))
+  return candidates[0]
+}
+
+let laneClickTimer: ReturnType<typeof setTimeout> | undefined
+let laneClickKey = ''
+
+function handleLaneClick(rowKey: string, gridIndex: number, event: any) {
+  const offsetX = event?.offsetX
+  if (typeof offsetX !== 'number') return
+  const range = hourRangeAt(gridIndex, offsetX)
+  if (!range) return
+  const existing = existingLabelAt(rowKey, range.startMs)
+  const key = `${rowKey}|${range.startTime}`
+  if (laneClickTimer && laneClickKey === key) {
+    clearTimeout(laneClickTimer)
+    laneClickTimer = undefined
+    laneClickKey = ''
+    emit('labelDouble', { rowKey, startTime: range.startTime, endTime: range.endTime, label: existing })
+    return
+  }
+  if (laneClickTimer) {
+    clearTimeout(laneClickTimer)
+    laneClickTimer = undefined
+  }
+  laneClickKey = key
+  laneClickTimer = setTimeout(() => {
+    laneClickTimer = undefined
+    laneClickKey = ''
+    if (existing) return
+    emit('labelSingle', { rowKey, startTime: range.startTime, endTime: range.endTime })
+  }, 240)
+}
+
+let labelMeasureContext: CanvasRenderingContext2D | null = null
+
+function measureLabelText(text: string): number {
+  if (!labelMeasureContext) {
+    labelMeasureContext = document.createElement('canvas').getContext('2d')
+  }
+  if (!labelMeasureContext) return text.length * 9
+  labelMeasureContext.font = '600 9px -apple-system, BlinkMacSystemFont, sans-serif'
+  return labelMeasureContext.measureText(text).width
+}
+
+function updateLanes() {
+  if (!chart || !props.data) return
+  const data = props.data
+  const graphics: any[] = []
+  const metas = buildRowMetas(data)
+  metas.forEach((meta, index) => {
+    if (meta.kind !== 'device' && meta.kind !== 'site') return
+    const laneTop = CHART_TOP_INSET + index * (SPLIT_ROW_HEIGHT + SPLIT_ROW_GAP + LABEL_LANE_HEIGHT)
+    const gridModel = (chart as any)?.getModel()?.getComponent('grid', index) as any
+    const gridRect = gridModel?.coordinateSystem?.getRect?.()
+    const fallbackLeft = Number(chart?.convertToPixel({ xAxisIndex: index }, data.xMin))
+    const fallbackRight = Number(chart?.convertToPixel({ xAxisIndex: index }, data.xMax))
+    const clipLeft = gridRect ? Number(gridRect.x) : Math.min(fallbackLeft, fallbackRight)
+    const clipRight = gridRect ? Number(gridRect.x + gridRect.width) : Math.max(fallbackLeft, fallbackRight)
+    const laneWidth = Math.max(clipRight - clipLeft, 1)
+    graphics.push({
+      type: 'rect',
+      left: clipLeft,
+      top: laneTop,
+      shape: { x: 0, y: 0, width: laneWidth, height: LABEL_LANE_HEIGHT },
+      style: { fill: '#eef2f4' },
+      cursor: 'pointer',
+      silent: false,
+      onclick: (event: any) => handleLaneClick(meta.name, index, event),
+    })
+    graphics.push({
+      type: 'text',
+      left: 8,
+      top: laneTop + 3,
+      style: { text: '标注', fill: '#8a989f', fontSize: 10 },
+      silent: true,
+    })
+    laneSegmentsForRow(meta.name).forEach((segment) => {
+      const color = LABEL_LEVEL_COLORS[segment.labelType] ?? '#8a989f'
+      const text = LABEL_LEVEL_TEXT[segment.labelType] ?? segment.labelType
+      for (let slot = segment.start; slot < segment.end; slot += 1) {
+        const left = Number(chart?.convertToPixel({ xAxisIndex: index }, slot))
+        const right = Number(chart?.convertToPixel({ xAxisIndex: index }, slot + 1))
+        if (!Number.isFinite(left) || !Number.isFinite(right)) continue
+        const x = Math.max(Math.min(left, right), clipLeft)
+        const xEnd = Math.min(Math.max(left, right), clipRight)
+        const width = xEnd - x
+        if (width <= 0.5) continue
+        const rectX = x + 0.5
+        const rectWidth = Math.max(width - 1, 1)
+        graphics.push({
+          type: 'rect',
+          left: rectX,
+          top: laneTop + 1,
+          shape: { x: 0, y: 0, width: rectWidth, height: LABEL_LANE_HEIGHT - 2 },
+          style: { fill: color },
+          cursor: 'pointer',
+          onclick: (event: any) => handleLaneClick(meta.name, index, event),
+        })
+        if (rectWidth >= 10) {
+          const textWidth = measureLabelText(text)
+          graphics.push({
+            type: 'text',
+            left: rectX + Math.max((rectWidth - textWidth) / 2, 0),
+            top: laneTop + 4,
+            style: {
+              text,
+              fill: '#ffffff',
+              fontSize: 9,
+              fontWeight: 600,
+            },
+            cursor: 'pointer',
+            onclick: (event: any) => handleLaneClick(meta.name, index, event),
+          })
+        }
+      }
+    })
+  })
+  chart.setOption({ graphic: graphics } as any, { replaceMerge: ['graphic'] } as any)
+}
 
 function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
   const data = props.data
@@ -348,7 +473,7 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
   const grid = Array.from({ length: gridCount }, (_, index) => ({
     left: 70,
     right: 22,
-    top: topInset + index * (rowHeight + rowGap),
+    top: topInset + index * (rowHeight + rowGap + LABEL_LANE_HEIGHT) + LABEL_LANE_HEIGHT,
     height: rowHeight,
     containLabel: false,
   }))
@@ -536,7 +661,6 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
   })
 
   const zoom = zoomMode === 'keep' ? readCurrentZoom(data) : initialZoom(data)
-  series.push(...buildAnnotationOverlaySeries(props.annotations ?? []))
 
   return {
     animation: false,
@@ -764,7 +888,7 @@ function onBlankDoubleClick(event: any) {
   const x = coordinate?.[0]
   if (typeof x !== 'number') return
   const cycle = props.data.cycles.find((item) => x >= item.startX && x <= item.endX)
-  if (cycle) emit('annotationToggle', cycle)
+  if (cycle) emit('periodDblclick', cycle)
 }
 
 function renderFull(zoomMode: 'initial' | 'keep' = 'initial') {
@@ -779,16 +903,13 @@ function renderFull(zoomMode: 'initial' | 'keep' = 'initial') {
   }
   chart.resize()
   drawPvPreviews()
-}
-
-function updateAnnotationOverlay() {
-  if (!chart || !props.data) return
-  chart.setOption({ series: buildAnnotationOverlaySeries(props.annotations ?? []) }, false)
+  updateLanes()
 }
 
 function resize() {
   chart?.resize()
   drawPvPreviews()
+  updateLanes()
 }
 
 watch(
@@ -801,8 +922,8 @@ watch(
   () => nextTick(() => renderFull('keep')),
 )
 watch(
-  () => props.annotations,
-  () => nextTick(updateAnnotationOverlay),
+  () => props.labels,
+  () => nextTick(updateLanes),
   { deep: true },
 )
 
@@ -810,7 +931,10 @@ onMounted(() => {
   if (!chartElement.value) return
   chart = echarts.init(chartElement.value, undefined, { renderer: 'canvas' })
   chart.on('dblclick', onDoubleClick)
-  chart.on('dataZoom', drawPvPreviews)
+  chart.on('dataZoom', () => {
+    drawPvPreviews()
+    updateLanes()
+  })
   chart.getZr().on('dblclick', onBlankDoubleClick)
   window.addEventListener('resize', resize)
   renderFull('initial')
@@ -827,7 +951,7 @@ onBeforeUnmount(() => {
   <div class="wave-chart-shell">
     <div class="chart-toolbar-note">
       <span class="chart-dot"></span>
-      <span>双击曲线查看功图,双击空白处标注 / 取消标注</span>
+      <span>单击标注行按整点标注,双击修改;双击图表区域查看功图</span>
       <span class="chart-separator"></span>
       <span>滚轮缩放,底部滑轨横向浏览</span>
       <span class="chart-point-count">已加载 {{ plottedPointCount.toLocaleString() }} 点<span v-if="secondValueStatus"> · {{ secondValueStatus }}</span></span>
@@ -844,38 +968,6 @@ onBeforeUnmount(() => {
       </div>
     </div>
     <div ref="chartElement" class="wave-chart" :style="{ height: `${layoutHeightPx}px` }"></div>
-    <div v-if="annotations?.length" class="annotation-nav">
-      <div class="annotation-nav-head">
-        <span class="annotation-nav-title">标注导航</span>
-        <div class="annotation-nav-controls">
-          <el-button class="ghost-button" size="small" plain :disabled="activeAnnotationIndex <= 0" @click="focusAnnotation(activeAnnotationIndex - 1)">上一标注</el-button>
-          <span class="annotation-nav-counter">{{ annotationSegments.length ? `${activeAnnotationIndex + 1} / ${annotationSegments.length}` : '0 / 0' }}</span>
-          <el-button class="ghost-button" size="small" plain :disabled="activeAnnotationIndex >= annotationSegments.length - 1" @click="focusAnnotation(activeAnnotationIndex + 1)">下一标注</el-button>
-        </div>
-      </div>
-      <div class="annotation-nav-track">
-        <div
-          v-for="(segment, index) in annotationSegments"
-          :key="segment.annotation.id"
-          class="annotation-nav-seg"
-          :class="{
-            normal: segment.annotation.label === '正常',
-            abnormal: segment.annotation.label === '异常',
-            active: index === activeAnnotationIndex,
-          }"
-          :style="{
-            left: `${(segment.start / xMax) * 100}%`,
-            width: `${Math.max(((segment.end - segment.start) / xMax) * 100, 0.4)}%`,
-          }"
-          :title="`${segment.annotation.label} · 周期 ${segment.annotation.periodStart}—${segment.annotation.periodEnd} · 点 ${segment.annotation.sampleIndexStart}—${segment.annotation.sampleIndexEnd}`"
-          @click="focusAnnotation(index)"
-        />
-      </div>
-      <div class="annotation-nav-legend">
-        <span><i class="annotation-swatch normal"></i>正常样本</span>
-        <span><i class="annotation-swatch abnormal"></i>异常样本</span>
-      </div>
-    </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>

+ 22 - 0
frontend/src/styles.css

@@ -176,6 +176,7 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .legend-running { background: #2e7d32; }
 .legend-stopped { background: #c0c4cc; }
 .legend-fault { background: #f5c400; width: 3px; height: 12px; border-radius: 1px; }
+.legend-label { background: linear-gradient(90deg, #d4a017 0 33%, #e8792b 33% 66%, #d64545 66% 100%); }
 .time-summary .mono, .readout-number { color: #303133; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: 500; }
 .summary-divider { padding: 0 8px; color: #dcdfe6; }
 .timeline-canvas-host { position: relative; width: 100%; min-height: 210px; overflow: hidden; border: 1px solid #ebeef5; border-radius: 4px; background: #fff; }
@@ -354,3 +355,24 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
   .period-chart-grid { grid-template-columns: 1fr; padding: 10px 12px 14px; }
   .period-chart { height: clamp(360px, 62vh, 560px); }
 }
+
+.label-dialog .el-dialog__body { max-height: 620px; overflow-y: auto; }
+.label-form { display: flex; flex-direction: column; gap: 16px; }
+.label-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 24px; }
+.label-field { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.label-field-wide { grid-column: 1 / -1; }
+.label-field-name { width: 64px; flex: 0 0 64px; color: #606266; font-size: 13px; font-weight: 600; }
+.label-field-value { color: #303133; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.label-field .el-date-editor,
+.label-field .el-input { flex: 1; min-width: 0; }
+.label-info-section { margin-top: 4px; }
+.label-info-title { color: #606266; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
+.label-info-grid { display: grid; grid-template-columns: 120px 1fr 96px 1fr 96px 1fr; border-top: 1px solid #f0f0f0; border-left: 1px solid #f0f0f0; font-size: 12px; }
+.label-info-label,
+.label-info-value { padding: 6px 8px; border-right: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0; min-width: 0; word-break: break-all; }
+.label-info-label { color: #666666; background: #fafafa; }
+.label-info-value { color: #1a1a1a; }
+.label-info-label.full { grid-column: 1; }
+.label-info-value.full { grid-column: 2 / -1; }
+.label-info-empty { color: #909399; font-size: 12px; padding: 18px 0; text-align: center; border: 1px solid #f0f0f0; border-radius: 4px; }
+.label-info-raw { margin: 0; max-height: 320px; overflow: auto; padding: 10px 12px; border: 1px solid #f0f0f0; border-radius: 4px; background: #fafafa; font-size: 11px; line-height: 1.55; white-space: pre-wrap; word-break: break-all; }

+ 20 - 1
frontend/src/types.ts

@@ -71,7 +71,6 @@ export type QueryOptionsResponse = {
   devicePoints: DevicePoint[]
   devicePointToType: Record<DevicePoint, MeasurementType>
   options: QueryOption[]
-  pretrainCounts?: Record<string, number>
   abnormalCounts?: Record<string, number>
   notice: string | null
 }
@@ -367,3 +366,23 @@ export type AnnotationListResponse = {
   annotations: Annotation[]
   notice: string | null
 }
+
+export type WaveLabel = {
+  id: number
+  rowKey: string
+  startTime: string
+  endTime: string
+  labelType: string
+  labelSubtype: string | null
+  origin: string
+  sourceType: string
+  confirmStatus: string
+  note?: string | null
+  features?: Record<string, unknown> | string | null
+}
+
+export type LabelListResponse = {
+  source: 'database' | 'demo'
+  labels: WaveLabel[]
+  notice: string | null
+}

+ 17 - 3
viewer/backend/services/indicator_service.py

@@ -28,7 +28,20 @@ PISTON_ROD_DIAMETER_MM = 78.0
 CLEARANCE_VOLUME_L_BY_BORE = {490.0: 1.51, 390.0: 0.74, 360.0: 0.62}
 # 7/8/9 号机各缸相位角(月度报告/沁水压缩机参数20250102.docx)。
 CYLINDER_PHASE_DEGREE = {"一": 0.0, "二": 0.0, "三": 120.0, "四": 240.0, "五": 240.0, "六": 120.0}
-CYLINDER_PHASE_OVERRIDE = {"二": 24.0, "三": 146.0, "四": 147.0, "五": 265.0, "六": 265.0}
+UNIT_PHASE_OVERRIDE = {
+    "7": {
+        "一": 141.0, "二": 160.0, "三": 280.0,
+        "四": 280.0, "五": 40.0, "六": 40.0,
+    },
+    "8": {
+        "一": 8.0, "二": 23.0, "三": 138.0,
+        "四": 145.0, "五": 262.0, "六": 262.0,
+    },
+    "9": {
+        "一": 16.0, "二": 27.0, "三": 153.0,
+        "四": 153.0, "五": 269.0, "六": 269.0,
+    },
+}
 PHASE_ROTATION_UNITS = frozenset({"7", "8", "9"})
 PULSES_PER_REVOLUTION = 8
 MIN_SAMPLES = 60
@@ -77,8 +90,9 @@ def keyphase_angle(keyphase_info) -> float | None:
 def phase_degrees(unit: str, cylinder: str, keyphase_info=None) -> float:
     if unit not in PHASE_ROTATION_UNITS:
         return 0.0
-    if cylinder in CYLINDER_PHASE_OVERRIDE:
-        return CYLINDER_PHASE_OVERRIDE[cylinder]
+    unit_override = UNIT_PHASE_OVERRIDE.get(unit)
+    if unit_override and cylinder in unit_override:
+        return unit_override[cylinder]
     angle = keyphase_angle(keyphase_info)
     if angle is not None:
         return angle

+ 315 - 0
标注功能重构方案.md

@@ -0,0 +1,315 @@
+# 标注功能重构方案(细化版)
+
+> 状态:设计稿,未改代码。确认后再进入实施。
+> 本版调整:标注改为“段记录 + 15 分钟色块显示”;PKS 单击整小时、连续小时合并;双击弹窗改类型/看算法属性/改起止/删除;不设正常段;时间轴汇总所有子图。
+
+## 1. 目标与范围
+
+### 目标
+
+- 标注对象:**wave_file 传感器数据** 和 **PKS 数据**。
+- 显示单位:**15 分钟色块**。
+- 存储单位:**标签段**(起止时间对齐 15 分钟;wave 与 PKS 人工标注均按整小时)。
+- 算法标签(振动/油压/温度)进入统一表,页面可看、可改。
+- 人工标签与算法标签共存,人工优先。
+- 后续可导出给训练。
+
+### 不做
+
+- 不改模型算法。
+- 不改 `tag-manual`。
+- 不改 `compressor_alarm`。
+- 暂不删除 `wave_annotation`,最后迁移。
+- **不做“正常”段**:正常样本由后续训练算法生成。
+
+### 取消
+
+“预训练记录”整套:前端记录/删除按钮、`/api/pretrain-records`、`pretrain_service.py`、`backend/预训练之文件头.csv`、`pretrainCounts`。
+
+---
+
+## 2. 核心模型
+
+### 2.1 目标与通道
+
+```
+标注目标 = 目标类型 + 通道 + 标签段(start_time, end_time)
+```
+
+| 目标类型 | 通道 channel_key | target_key |
+|---|---|---|
+| `wave_file` | `wave:{device_part}|{device_point}` | `wave:{wave_file_id}` |
+| `pks` | `pks:{unit}:{device_code}:{metric_key}` | 同 channel_key |
+
+- wave:15 分钟一个文件,槽与文件一一对应。
+- PKS:每个全场点位(如 `YSJ_11`)一个独立通道、独立标注行。
+- 不同子图 = 不同通道 = 独立标签记录,单独操作。
+
+### 2.2 段记录与 15 分钟显示
+
+- 一条标签记录 = 一个连续时间段 `[start_time, end_time)`。
+- `start_time/end_time` 对齐 15 分钟;PKS 人工标注默认整小时。
+- 页面按 15 分钟切成色块显示;一条段记录可覆盖多个色块。
+- 相邻同类型段 **合并为一条记录**;不同类型重叠时拒绝并提示。
+
+### 2.3 标签级别
+
+只保留异常级别,与现有算法一致:
+
+```
+轻微 / 异常 / 严重异常
+```
+
+`label_subtype` 存振动侧、温度部位、方向等;`features_json` 存算法原始属性。
+
+---
+
+## 3. 页面交互
+
+### 3.1 标注行
+
+每个子图上方一行,高度约一行文本(18–20px):
+
+```
+┌──────────── 子图标题 ────────────┐
+│ 异常 │ [异常][异常][ ][轻微][轻微] │  ← 左侧显示当前设置的类
+├──────────────────────────────────┤
+│            波形子图               │
+└──────────────────────────────────┘
+```
+
+- 每格 = 15 分钟。
+- 有标签:按级别着色,块内显示类型文本,单行、超出隐藏。
+- 无标签:浅灰空块。
+- 无数据(停机/缺文件):空白且不可点。
+- 颜色:轻微=黄,异常=橙,严重异常=红。
+- 标注行左侧标题只显示“当前被设置的类”,不显示复杂信息。
+
+### 3.2 单击
+
+- 单击空白 15 分钟块:新增标注,默认类型 `异常`(可改)。
+- **wave 与 PKS 一致**:单击任一块 → 自动选中所在**整小时**(4 个块)。
+- 连续小时再次单击 → 若类型相同,**合并成一条记录**。
+- 同一通道内,不同类型不允许重叠或相邻;不同通道互不影响。
+
+### 3.3 双击弹窗
+
+双击任意块打开弹窗,内容:
+
+1. 类型选择:轻微 / 异常 / 严重异常。
+2. 起止时间:可编辑,**按小时设置**(wave 与 PKS 都默认整小时)。
+3. 算法原始属性:以只读 JSON 展示,例如:
+
+```
+机组、批次、周期编号、压力阶段、开始小时、结束小时、
+持续自然小时数、有效运行小时数、周期基线油压、正常波动带、
+阶段开始小时油压、阶段结束小时油压、阶段最低小时油压、
+阶段开始/结束相对周期基线变化、阶段最大连续下降有效小时数、
+阶段低报警小时数、阶段低低报警小时数、是否形成确认等级、阶段触发依据
+```
+
+4. 备注(可编辑)。
+5. **删除按钮**:删除整条段记录。
+
+保存后:
+- 改类型 → 更新段记录。
+- 改起止 → 重新计算合并/拆分。
+- 人工修改后 `origin=manual`,算法重复导入不覆盖。
+
+### 3.4 展示
+
+- 波形半透明色块保持现状,覆盖整个标签段。
+- 时间轴“标注”行:**汇总所有子图**;任一道有标签即着色,颜色取最高级别。
+- 时间轴 tooltip 只简单表达“有标注”,不列通道明细。
+- 增加颜色图例。
+
+---
+
+## 4. 数据表
+
+```sql
+CREATE TABLE IF NOT EXISTS compressor_label (
+    id                 BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    source_type        VARCHAR(32)  NOT NULL COMMENT 'vibration/oil_pressure/temperature/pressure_wave/pks_manual',
+    origin             VARCHAR(16)  NOT NULL DEFAULT 'manual' COMMENT 'algorithm/manual/imported',
+    target_type        VARCHAR(16)  NOT NULL COMMENT 'wave_file/pks',
+    target_id          BIGINT UNSIGNED NULL COMMENT 'wave_file.id',
+    target_key         VARCHAR(200) NOT NULL COMMENT 'wave:{id} / pks:{unit}:{code}:{metric}',
+    channel_key        VARCHAR(200) NOT NULL COMMENT '子图通道键',
+    segment_key        VARCHAR(128) NULL COMMENT '算法原始段分组键',
+    unit               VARCHAR(16)  NOT NULL,
+    device_code        VARCHAR(64)  NULL,
+    device_part        VARCHAR(200) NULL,
+    device_point       VARCHAR(200) NULL COMMENT 'wave 点位',
+    metric_key         VARCHAR(64)  NULL COMMENT 'PKS: YSJ_11',
+    metric_label       VARCHAR(200) NULL COMMENT 'PKS: 链轮端振动',
+    start_time         DATETIME     NOT NULL COMMENT '标签段起点,15min/小时对齐',
+    end_time           DATETIME     NOT NULL COMMENT '标签段终点(不含)',
+    algorithm_start    DATETIME     NULL COMMENT '算法原始段起点',
+    algorithm_end      DATETIME     NULL COMMENT '算法原始段终点',
+    label_type         VARCHAR(32)  NOT NULL COMMENT '轻微/异常/严重异常',
+    label_subtype      VARCHAR(64)  NULL,
+    severity           TINYINT      NULL,
+    confirm_status     VARCHAR(16)  NOT NULL DEFAULT '未确认',
+    usable_for_train   TINYINT(1)   NOT NULL DEFAULT 1,
+    source_file        VARCHAR(500) NULL,
+    source_row         VARCHAR(64)  NULL,
+    algorithm_version  VARCHAR(64)  NULL,
+    features_json      MEDIUMTEXT   NULL COMMENT '算法原始属性',
+    note               VARCHAR(1000) NULL,
+    created_by         VARCHAR(64)  NULL,
+    updated_by         VARCHAR(64)  NULL,
+    created_at         DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at         DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    deleted_at         DATETIME     NULL,
+    PRIMARY KEY (id),
+    KEY idx_target_time (target_key, start_time, end_time),
+    KEY idx_channel_time (channel_key, start_time),
+    KEY idx_segment (segment_key),
+    KEY idx_unit_time (unit, start_time),
+    KEY idx_source (source_type, origin)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '统一训练标签表';
+```
+
+说明:
+
+- 不做 `(target_key, slot_start)` 唯一键,因为要支持跨多槽的段和合并。
+- 段重叠/相邻的合并与冲突在服务层处理。
+- 同通道同时间段只保留一条有效记录。
+- 算法段导入时也按段存,显示时展开为 15 分钟块。
+
+### 合并/冲突规则
+
+```
+新增或修改时,取同 target_key(同一子图通道)下与 [start,end) 重叠或相邻的记录:
+- 类型相同(重叠或相邻) -> 合并为一条,start=min, end=max
+- 类型不同(重叠或相邻) -> 拒绝,提示先修改/删除相邻记录
+- 不同 target_key/通道互不影响,各自独立
+```
+
+### 算法导入覆盖
+
+```
+不存在            -> 插入
+origin=manual     -> 跳过
+origin=algorithm  -> 更新
+```
+
+---
+
+## 5. 算法标签接入
+
+算法不动,新增转存程序:
+
+```
+读取 CSV -> 生成段记录 -> UPSERT compressor_label
+```
+
+| 来源 | source_type | metric_key | label_type | 时间 |
+|---|---|---|---|---|
+| 振动异常段.csv | vibration | 链轮端→YSJ_11,联轴器端→YSJ_10 | 振动阶段 | 开始/结束小时 |
+| 润滑油压力小时阶段段.csv | oil_pressure | 7/8/9 号机→YSJ_5 | 压力阶段 | 开始/结束小时 |
+| 温度异常段.csv | temperature | 阶段点位 YSJ_n → YSJ{unit}_n | 温度阶段 | 开始/结束小时 |
+
+字段映射:
+
+- `label_subtype`:振动侧 / 温度部位 / 方向 / 特征类型。
+- `confirm_status`:由“是否形成确认等级”映射。
+- `source_row`:周期编号。
+- `features_json`:整行原样 JSON。
+- `segment_key`:来源 + 周期编号 + 起止时间。
+
+正常样本不导入、不生成。
+
+---
+
+## 6. 接口
+
+```
+GET    /api/labels?target_type=&unit=&device_code=&metric_key=&start=&end=
+GET    /api/labels?wave_file_ids=...
+POST   /api/labels              新增(自动合并)
+PUT    /api/labels/{id}         改类型/起止/备注(自动合并或拆分)
+DELETE /api/labels/{id}         删除段
+```
+
+返回:
+
+```json
+{
+  "id": 12,
+  "targetType": "pks",
+  "targetKey": "pks:7:7#:YSJ_11",
+  "startTime": "2026-02-10T10:00:00",
+  "endTime": "2026-02-10T12:00:00",
+  "labelType": "异常",
+  "labelSubtype": "链轮端",
+  "origin": "algorithm",
+  "confirmStatus": "已确认",
+  "features": { "...": "算法原始属性" }
+}
+```
+
+---
+
+## 7. 时间轴
+
+- 汇总所有子图/通道。
+- 每个时间槽:任一道有标签 → 标记。
+- 颜色取该槽内最高级别(严重异常 > 异常 > 轻微)。
+- tooltip 只显示“有标注”,不列通道明细。
+- 语义不变,只改数据来源和着色逻辑。
+
+---
+
+## 8. 分期
+
+### Phase 0:确认本设计
+
+### Phase 1:清理预训练记录
+前端按钮/状态、后端接口/service/CSV/`pretrainCounts`。不动 `wave_annotation`。
+
+### Phase 2:新表 + 算法标签只读
+建表、转存三张 CSV、查询接口、标注行只读展示。
+
+### Phase 3:人工标注
+单击新增/整小时选择/连续合并;双击弹窗改类型、起止、看算法 JSON、删除;先 wave 后 PKS。
+
+### Phase 4:训练导出与迁移(暂缓,用户指示先不做)
+导出 `usable_for_train=1`;迁移 `wave_annotation`;下线老逻辑。
+
+---
+
+## 8.1 测试记录(2026-11-08)
+
+使用 Playwright + Chrome 对 Phase 1–3 做了界面回归:
+
+- Phase 1:窗口文件表只剩“定位”,无“记录/删除”;页面无“预训练”;`/api/pretrain-records` 返回 404。
+- Phase 2:算法标签在子图标注行按 15 分钟块显示;时间轴“标注”行汇总着色。
+- Phase 3:
+  - 单击空白整点块 → POST 创建 `异常`,成功。
+  - 双击已有标签 → 弹窗,类型、起止、点位、备注正常回显。
+  - 改类型保存 → PUT,弹窗关闭,重开类型正确。
+  - 改起止时间 + 备注保存 → PUT,重开值正确。
+  - 删除 → 二次确认 → DELETE,弹窗关闭。
+  - 同类型相邻单击 → 合并为一条段记录。
+  - 不同类型相邻保存 → 400,界面提示“同一子图内不同类型不能重叠或相邻”。
+  - 双击算法标签 → 显示只读算法属性 JSON(机组、周期编号、压力阶段、基线油压、触发依据等)。
+- 测试产生的临时人工标签已清理,库内保留 182 条算法标签。
+
+---
+
+## 9. 已确认
+
+1. wave 单击也选中整小时。
+2. 同一通道内,不同类型不允许重叠或相邻并存;不同通道各自独立。
+3. 时间轴 tooltip 只表达“有标注”,不显示通道明细。
+
+---
+
+## 10. 注意
+
+- 段记录跨多槽,显示时展开;不要按槽存重复行。
+- 人工修改与算法导入要隔离,避免覆盖。
+- 新旧标注并存期只显示一套,避免重复。
+- ECharts 标注行需随缩放重绘,事件绑定在 graphic 上。

+ 51 - 0
角度偏移记录.md

@@ -0,0 +1,51 @@
+# 压缩机各缸角度偏移记录
+
+用途:记录参考软件/厂家给出的各缸角度偏移(`键相8-0-x` 中的 x),以及代码当前实际使用的显示相位。
+
+## 9号机(厂家提供,已按顺序 1–6 缸)
+
+厂家图片给出的偏移:`8-0-16`、`8-0-27`、`8-0-153`、`8-0-153`、`8-0-269`、`8-0-269`。
+
+| 缸 | 厂家偏移角 | 文档相位 | 文件名键相 | 之前参考0°校准值 | 当前实际使用 |
+|---|---|---|---|---|---|
+| 1缸 | 16° | 0° | 0° | 0° | **16°** |
+| 2缸 | 27° | 0° | 0° | 24° | **27°** |
+| 3缸 | 153° | 120° | 135° | 146° | **153°** |
+| 4缸 | 153° | 240° | 135° | 147° | **153°** |
+| 5缸 | 269° | 240° | 270° | 265° | **269°** |
+| 6缸 | 269° | 120° | 265° | 265° | **269°** |
+
+说明:
+
+- “厂家偏移角”以本表为准,代码里 9 号机 1–6 缸直接使用该值。
+- “文档相位”来自 `月度报告/沁水压缩机参数20250102.docx`。
+- “文件名键相”来自原始采集文件名 `键相8-0-x`。
+- “之前参考0°校准值”是按参考软件 0° 压力读数反推的值,仅作对照;现已改为厂家值。
+
+## 7号机(厂家提供,已按顺序 1–6 缸)
+
+厂家图片给出的偏移:`8-0-141`、`8-0-160`、`8-0-280`、`8-0-280`、`8-0-40`、`8-0-40`。
+
+| 缸 | 厂家偏移角 | 文档相位 | 文件名键相 | 当前实际使用 |
+|---|---|---|---|---|
+| 1缸 | 141° | 0° | 135° | **141°** |
+| 2缸 | 160° | 0° | 135° | **160°** |
+| 3缸 | 280° | 120° | 270° | **280°** |
+| 4缸 | 280° | 240° | 270° | **280°** |
+| 5缸 | 40° | 240° | 45° | **40°** |
+| 6缸 | 40° | 120° | 盖45°/轴135° | **40°** |
+
+说明:7 号机 6 缸的盖侧/轴侧文件名键相不一致,代码统一按厂家偏移 40° 处理。
+
+## 8号机(厂家提供,已按顺序 1–6 缸)
+
+厂家图片给出的偏移:`8-0-8`、`8-0-23`、`8-0-138`、`8-0-145`、`8-0-262`、`8-0-262`。
+
+| 缸 | 厂家偏移角 | 文档相位 | 文件名键相 | 当前实际使用 |
+|---|---|---|---|---|
+| 1缸 | 8° | 0° | 0° | **8°** |
+| 2缸 | 23° | 0° | 0° | **23°** |
+| 3缸 | 138° | 120° | 135° | **138°** |
+| 4缸 | 145° | 240° | 135° | **145°** |
+| 5缸 | 262° | 240° | 270° | **262°** |
+| 6缸 | 262° | 120° | 270° | **262°** |