ソースを参照

标注:按小时删除、算法标签导入页与时间轴选择条修复

18922397810 4 日 前
親
コミット
7765878640

+ 48 - 1
backend/app/main.py

@@ -8,7 +8,8 @@ from .auth import create_token, revoke_token, validate_token, verify_credentials
 from .config import settings
 from .db import ensure_annotation_table, ensure_label_table
 from .services.data_service import data_service
-from .services.label_service import create_label, delete_label, query_labels, update_label
+from .services.label_import_service import file_status, job_status, start_import
+from .services.label_service import create_label, delete_label, delete_label_range, query_labels, update_label
 from .services.tag_manual_service import tag_manual_service
 from .services.temperature_transition_service import temperature_transition_service
 
@@ -81,6 +82,11 @@ class LabelInput(BaseModel):
     note: str | None = None
 
 
+class LabelRangeInput(BaseModel):
+    start_time: str = Field(min_length=1)
+    end_time: str = Field(min_length=1)
+
+
 class LoginInput(BaseModel):
     username: str = Field(min_length=1)
     password: str = Field(min_length=1)
@@ -445,3 +451,44 @@ def delete_label_endpoint(label_id: int, _auth: str = Depends(require_auth)) ->
         raise HTTPException(status_code=404, detail=str(error)) from error
     except Exception as error:
         raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.post("/api/labels/{label_id}/delete-range")
+def delete_label_range_endpoint(
+    label_id: int,
+    payload: LabelRangeInput,
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        return delete_label_range(label_id, payload.start_time, payload.end_time)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.get("/api/label-import/status")
+def label_import_status(_auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return {"files": file_status(), "job": job_status()}
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.post("/api/label-import")
+def label_import_start(_auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        start_import()
+    except ValueError as error:
+        raise HTTPException(status_code=409, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+    return {"started": True}
+
+
+@app.get("/api/label-import/progress")
+def label_import_progress(_auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return job_status()
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error

+ 366 - 0
backend/app/services/label_import_service.py

@@ -0,0 +1,366 @@
+from __future__ import annotations
+
+import copy
+import csv
+import hashlib
+import json
+import re
+import threading
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Callable
+
+from ..db import ensure_label_table, get_connection
+
+ROOT = Path(__file__).resolve().parents[3]
+
+LABEL_FILES = (
+    {
+        "key": "vibration",
+        "label": "振动异常段",
+        "relativePath": "models/datas/reference/振动异常段.csv",
+    },
+    {
+        "key": "oil_pressure",
+        "label": "润滑油压力小时阶段段",
+        "relativePath": "models/datas/reference/润滑油压力小时阶段段.csv",
+    },
+    {
+        "key": "temperature",
+        "label": "温度异常段",
+        "relativePath": "_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
+"""
+
+ProgressCallback = Callable[[int, str], None]
+
+
+def _now() -> str:
+    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def read_csv(path: Path) -> list[dict[str, str]]:
+    if not path.exists():
+        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_for_file(source_type: str, path: Path, site_points: dict[str, str]) -> list[tuple[Any, ...]]:
+    rows: list[tuple[Any, ...]] = []
+    source_file = str(path.relative_to(ROOT))
+    for row in read_csv(path):
+        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
+        if source_type == "vibration":
+            side = (row.get("最强异常侧") or "").strip()
+            metric = VIBRATION_SIDE_TO_METRIC.get(side)
+            if not metric:
+                continue
+            rows.append(
+                make_segment(
+                    source_type=source_type,
+                    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=source_file,
+                    site_points=site_points,
+                )
+            )
+        elif source_type == "oil_pressure":
+            metric = "YSJ_5" if unit in {"7", "8", "9"} else "YSJ_1"
+            rows.append(
+                make_segment(
+                    source_type=source_type,
+                    unit=unit,
+                    metric=metric,
+                    start=start,
+                    end=end + timedelta(hours=1),
+                    label_type=(row.get("压力阶段") or "").strip(),
+                    label_subtype="润滑油压力",
+                    row=row,
+                    source_file=source_file,
+                    site_points=site_points,
+                )
+            )
+        elif source_type == "temperature":
+            metric = (row.get("阶段点位") or "").strip()
+            if not re.fullmatch(r"YSJ_\d+", metric):
+                continue
+            rows.append(
+                make_segment(
+                    source_type=source_type,
+                    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=source_file,
+                    site_points=site_points,
+                )
+            )
+    return [row for row in rows if row[17] in SEVERITY]
+
+
+def build_rows(site_points: dict[str, str]) -> list[tuple[Any, ...]]:
+    rows: list[tuple[Any, ...]] = []
+    for item in LABEL_FILES:
+        path = ROOT / item["relativePath"]
+        rows.extend(build_rows_for_file(item["key"], path, site_points))
+    return rows
+
+
+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 file_status() -> list[dict[str, Any]]:
+    files: list[dict[str, Any]] = []
+    for item in LABEL_FILES:
+        path = ROOT / item["relativePath"]
+        exists = path.exists()
+        files.append(
+            {
+                "key": item["key"],
+                "label": item["label"],
+                "relativePath": item["relativePath"],
+                "exists": exists,
+                "size": path.stat().st_size if exists else None,
+                "modifiedAt": (
+                    datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
+                    if exists
+                    else None
+                ),
+            }
+        )
+    return files
+
+
+def run_import(progress: ProgressCallback | None = None) -> dict[str, Any]:
+    started = time.monotonic()
+    ensure_label_table()
+    if progress:
+        progress(5, "准备")
+    site_points = load_site_points()
+    rows: list[tuple[Any, ...]] = []
+    by_source: dict[str, int] = {}
+    skipped: list[str] = []
+    for index, item in enumerate(LABEL_FILES):
+        path = ROOT / item["relativePath"]
+        if progress:
+            progress(15 + index * 18, f"读取{item['label']}")
+        if not path.exists():
+            skipped.append(item["relativePath"])
+            continue
+        file_rows = build_rows_for_file(item["key"], path, site_points)
+        by_source[item["key"]] = len(file_rows)
+        rows.extend(file_rows)
+    if progress:
+        progress(75, "写入数据库")
+    if rows:
+        with get_connection() as connection:
+            with connection.cursor() as cursor:
+                cursor.executemany(INSERT_SQL, rows)
+    if progress:
+        progress(90, "统计结果")
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                "SELECT COUNT(*) AS n FROM compressor_label WHERE origin = 'algorithm' AND deleted_at IS NULL"
+            )
+            active = int(cursor.fetchone()["n"])
+    duration_ms = int((time.monotonic() - started) * 1000)
+    if progress:
+        progress(100, "完成")
+    return {
+        "total": len(rows),
+        "bySource": by_source,
+        "skippedFiles": skipped,
+        "activeLabels": active,
+        "durationMs": duration_ms,
+    }
+
+
+_job_lock = threading.Lock()
+_job_state: dict[str, Any] = {
+    "running": False,
+    "progress": 0,
+    "stage": "空闲",
+    "message": "",
+    "startedAt": None,
+    "finishedAt": None,
+    "result": None,
+    "error": None,
+}
+
+
+def job_status() -> dict[str, Any]:
+    with _job_lock:
+        return copy.deepcopy(_job_state)
+
+
+def _update_job(**kwargs: Any) -> None:
+    with _job_lock:
+        _job_state.update(kwargs)
+
+
+def _progress_callback(progress: int, stage: str) -> None:
+    _update_job(progress=progress, stage=stage)
+
+
+def _run_job() -> None:
+    try:
+        result = run_import(progress=_progress_callback)
+        _update_job(
+            running=False,
+            progress=100,
+            stage="完成",
+            result=result,
+            finishedAt=_now(),
+            error=None,
+        )
+    except Exception as error:  # noqa: BLE001
+        _update_job(
+            running=False,
+            stage="失败",
+            error=str(error),
+            finishedAt=_now(),
+        )
+
+
+def start_import() -> None:
+    with _job_lock:
+        if _job_state["running"]:
+            raise ValueError("导入正在进行中")
+        _job_state.update(
+            {
+                "running": True,
+                "progress": 0,
+                "stage": "准备",
+                "message": "",
+                "startedAt": _now(),
+                "finishedAt": None,
+                "result": None,
+                "error": None,
+            }
+        )
+    threading.Thread(target=_run_job, daemon=True).start()

+ 71 - 1
backend/app/services/label_service.py

@@ -11,7 +11,7 @@ LEVELS = ("轻微", "异常", "严重异常")
 SEVERITY_BY_LEVEL = {"轻微": 1, "异常": 2, "严重异常": 3}
 
 _SELECT_FIELDS = """
-    id, source_type, origin, target_type, target_id, target_key, channel_key,
+    id, 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,
@@ -165,6 +165,20 @@ def _insert_row(cursor: Any, row: dict[str, Any]) -> int:
     return int(cursor.lastrowid)
 
 
+def _clone_row_with_range(cursor: Any, row: dict[str, Any], start: datetime, end: datetime) -> int:
+    clone = dict(row)
+    clone["start_time"] = _time_string(start)
+    clone["end_time"] = _time_string(end)
+    if row.get("origin") == "algorithm":
+        base = str(row.get("segment_key") or row.get("id"))
+        clone["segment_key"] = hashlib.md5(
+            f"{base}|range|{_time_string(start)}|{_time_string(end)}".encode("utf-8")
+        ).hexdigest()
+    else:
+        clone["segment_key"] = None
+    return _insert_row(cursor, clone)
+
+
 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"]))
@@ -349,3 +363,59 @@ def delete_label(label_id: int) -> dict[str, Any]:
             if cursor.rowcount == 0:
                 raise ValueError("标注不存在")
     return {"deleted": label_id}
+
+
+def delete_label_range(label_id: int, start: str, end: str) -> dict[str, Any]:
+    if label_id <= 0:
+        raise ValueError("标注 id 必须为正整数")
+    cut_start = _snap_hour(_parse_time(start))
+    cut_end = _snap_hour(_parse_time(end))
+    if cut_end <= cut_start:
+        raise ValueError("结束时间必须大于开始时间")
+    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,),
+                )
+                row = cursor.fetchone()
+                if not row:
+                    raise ValueError("标注不存在")
+                row_start = _parse_time(_time_string(row["start_time"]))
+                row_end = _parse_time(_time_string(row["end_time"]))
+                lo = max(row_start, cut_start)
+                hi = min(row_end, cut_end)
+                if hi <= lo:
+                    raise ValueError("删除区间不在该标注内")
+                cursor.execute(
+                    "UPDATE compressor_label SET deleted_at = NOW() WHERE id = %s",
+                    (label_id,),
+                )
+                if lo <= row_start and hi >= row_end:
+                    connection.commit()
+                    return {
+                        "deleted": label_id,
+                        "deletedRange": [_time_string(lo), _time_string(hi)],
+                        "beforeId": None,
+                        "afterId": None,
+                        "whole": True,
+                    }
+                before_id = None
+                after_id = None
+                if row_start < lo:
+                    before_id = _clone_row_with_range(cursor, row, row_start, lo)
+                if hi < row_end:
+                    after_id = _clone_row_with_range(cursor, row, hi, row_end)
+                connection.commit()
+                return {
+                    "deleted": label_id,
+                    "deletedRange": [_time_string(lo), _time_string(hi)],
+                    "beforeId": before_id,
+                    "afterId": after_id,
+                    "whole": False,
+                }
+            except Exception:
+                connection.rollback()
+                raise

+ 13 - 227
backend/import_compressor_labels.py

@@ -1,220 +1,10 @@
-"""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.
-"""
+"""Import algorithm stage labels into compressor_label (CLI wrapper)."""
 
 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()}
+from app.services.label_import_service import build_rows, load_site_points, run_import
 
 
 def main() -> None:
@@ -222,24 +12,20 @@ def main() -> None:
     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:
+        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}")
         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()})
+    result = run_import()
+    print(f"导入 {result['total']} 条:{result['bySource']}")
+    if result["skippedFiles"]:
+        print("跳过文件:", result["skippedFiles"])
+    print("库内有效算法标签:", result["activeLabels"])
 
 
 if __name__ == "__main__":

+ 45 - 4
frontend/src/App.vue

@@ -1,9 +1,10 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 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 { createLabel, deleteLabel, deleteLabelRange, 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 LabelImport from './components/LabelImport.vue'
 import Model2 from './components/Model2.vue'
 import TimePointStrip from './components/TimePointStrip.vue'
 import WaveChart from './components/WaveChart.vue'
@@ -55,6 +56,8 @@ const labelForm = reactive({
   labelType: '异常',
   startTime: '',
   endTime: '',
+  hourStart: '',
+  hourEnd: '',
   note: '',
   features: null as Record<string, unknown> | string | null,
   targetType: 'pks' as 'wave_file' | 'pks',
@@ -840,6 +843,8 @@ function onLabelDouble(payload: { rowKey: string; startTime: string; endTime: st
   labelForm.labelType = payload.label?.labelType ?? '异常'
   labelForm.startTime = payload.label?.startTime ?? toHourText(payload.startTime)
   labelForm.endTime = payload.label?.endTime ?? toHourText(payload.endTime)
+  labelForm.hourStart = toHourText(payload.startTime)
+  labelForm.hourEnd = toHourText(payload.endTime)
   labelForm.note = payload.label?.note ?? ''
   labelForm.features = payload.label?.features ?? null
   labelForm.origin = payload.label?.origin ?? 'manual'
@@ -882,8 +887,8 @@ async function saveLabel() {
 async function removeLabel() {
   if (labelForm.id == null || labelSaving.value) return
   try {
-    await ElMessageBox.confirm('删除这条标注?', '删除标注', {
-      confirmButtonText: '删除',
+    await ElMessageBox.confirm('删除整段标注?', '删除整段', {
+      confirmButtonText: '删除整段',
       cancelButtonText: '保留',
       type: 'warning',
     })
@@ -903,6 +908,34 @@ async function removeLabel() {
   }
 }
 
+async function removeLabelHour() {
+  if (labelForm.id == null || labelSaving.value) return
+  try {
+    await ElMessageBox.confirm(
+      `删除 ${labelForm.hourStart} — ${labelForm.hourEnd} 这一小时?其余时段保留。`,
+      '删除该小时',
+      {
+        confirmButtonText: '删除该小时',
+        cancelButtonText: '保留',
+        type: 'warning',
+      },
+    )
+  } catch {
+    return
+  }
+  labelSaving.value = true
+  try {
+    await deleteLabelRange(labelForm.id, labelForm.hourStart, labelForm.hourEnd)
+    labelDialogVisible.value = false
+    await loadLabels()
+    ElMessage.success('已删除该小时')
+  } catch (error) {
+    ElMessage.error(error instanceof Error ? error.message : '删除失败')
+  } finally {
+    labelSaving.value = false
+  }
+}
+
 const timelineLabelLevels = computed<(string | null)[]>(() => {
   const points = timePoints.value
   if (!points.length) return []
@@ -1418,6 +1451,9 @@ onBeforeUnmount(() => {
       <el-tab-pane label="告警监控" name="alarm">
         <AlarmMonitor />
       </el-tab-pane>
+      <el-tab-pane label="标签导入" name="label-import">
+        <LabelImport />
+      </el-tab-pane>
     </el-tabs>
 
     <PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" @close="closePeriod" />
@@ -1469,6 +1505,11 @@ onBeforeUnmount(() => {
             <span class="label-field-name">备注</span>
             <el-input v-model="labelForm.note" placeholder="可选" />
           </div>
+          <div v-if="labelForm.id != null" class="label-field label-field-wide">
+            <span class="label-field-name">当前小时</span>
+            <span class="label-field-value">{{ labelForm.hourStart }} — {{ labelForm.hourEnd }}</span>
+            <el-button class="label-hour-delete" type="danger" plain size="small" :loading="labelSaving" @click="removeLabelHour">删除该小时</el-button>
+          </div>
         </div>
 
         <div class="label-info-section">
@@ -1484,7 +1525,7 @@ onBeforeUnmount(() => {
         </div>
       </div>
       <template #footer>
-        <el-button v-if="labelForm.id != null" type="danger" plain :loading="labelSaving" @click="removeLabel">删除</el-button>
+        <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>

+ 57 - 0
frontend/src/api.ts

@@ -273,4 +273,61 @@ export function deleteLabel(id: number) {
   })
 }
 
+export function deleteLabelRange(id: number, startTime: string, endTime: string) {
+  return request<{
+    deleted: number
+    deletedRange: [string, string]
+    beforeId: number | null
+    afterId: number | null
+    whole: boolean
+  }>(`/api/labels/${id}/delete-range`, {
+    method: 'POST',
+    body: JSON.stringify({ start_time: startTime, end_time: endTime }),
+  })
+}
 
+
+export type LabelImportFile = {
+  key: string
+  label: string
+  relativePath: string
+  exists: boolean
+  size: number | null
+  modifiedAt: string | null
+}
+
+export type LabelImportResult = {
+  total: number
+  bySource: Record<string, number>
+  skippedFiles: string[]
+  activeLabels: number
+  durationMs: number
+}
+
+export type LabelImportJob = {
+  running: boolean
+  progress: number
+  stage: string
+  message: string
+  startedAt: string | null
+  finishedAt: string | null
+  result: LabelImportResult | null
+  error: string | null
+}
+
+export type LabelImportStatus = {
+  files: LabelImportFile[]
+  job: LabelImportJob
+}
+
+export function fetchLabelImportStatus() {
+  return request<LabelImportStatus>('/api/label-import/status')
+}
+
+export function startLabelImport() {
+  return request<{ started: boolean }>('/api/label-import', { method: 'POST' })
+}
+
+export function fetchLabelImportProgress() {
+  return request<LabelImportJob>('/api/label-import/progress')
+}

+ 163 - 0
frontend/src/components/LabelImport.vue

@@ -0,0 +1,163 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import {
+  fetchLabelImportProgress,
+  fetchLabelImportStatus,
+  startLabelImport,
+  type LabelImportFile,
+  type LabelImportJob,
+} from '../api'
+
+const files = ref<LabelImportFile[]>([])
+const job = ref<LabelImportJob | null>(null)
+const loading = ref(false)
+const starting = ref(false)
+let pollTimer: ReturnType<typeof setInterval> | undefined
+
+const hasAnyFile = computed(() => files.value.some((file) => file.exists))
+const progressStatus = computed<'success' | 'exception' | undefined>(() => {
+  if (job.value?.error) return 'exception'
+  if (job.value?.result) return 'success'
+  return undefined
+})
+const busy = computed(() => starting.value || Boolean(job.value?.running))
+
+function formatSize(size: number | null): string {
+  if (size == null) return '—'
+  if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
+  if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`
+  return `${size} B`
+}
+
+function stopPolling() {
+  if (pollTimer) {
+    clearInterval(pollTimer)
+    pollTimer = undefined
+  }
+}
+
+async function pollOnce() {
+  try {
+    const result = await fetchLabelImportProgress()
+    job.value = result
+    if (!result.running) {
+      stopPolling()
+      starting.value = false
+      if (result.error) ElMessage.error(result.error)
+      else if (result.result) ElMessage.success('标签更新完成')
+    }
+  } catch (error) {
+    stopPolling()
+    starting.value = false
+    ElMessage.error(error instanceof Error ? error.message : '进度读取失败')
+  }
+}
+
+function startPolling() {
+  stopPolling()
+  pollTimer = setInterval(pollOnce, 500)
+}
+
+async function refresh() {
+  loading.value = true
+  try {
+    const result = await fetchLabelImportStatus()
+    files.value = result.files
+    job.value = result.job
+    if (result.job.running) startPolling()
+  } catch (error) {
+    ElMessage.error(error instanceof Error ? error.message : '文件状态读取失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+async function start() {
+  if (busy.value || !hasAnyFile.value) return
+  starting.value = true
+  try {
+    await startLabelImport()
+    await pollOnce()
+    startPolling()
+  } catch (error) {
+    starting.value = false
+    ElMessage.error(error instanceof Error ? error.message : '启动失败')
+  }
+}
+
+onMounted(refresh)
+onBeforeUnmount(stopPolling)
+</script>
+
+<template>
+  <div class="label-import-page">
+    <section class="panel label-import-panel">
+      <div class="panel-heading compact-heading">
+        <div>
+          <h2>算法标签导入</h2>
+          <p class="label-import-note">相对路径以项目根目录为基准,部署后无需修改。</p>
+        </div>
+        <el-button class="label-import-refresh" type="primary" plain :loading="loading" @click="refresh">刷新文件状态</el-button>
+      </div>
+
+      <div class="label-import-body">
+        <table class="label-import-table">
+          <thead>
+            <tr>
+              <th>文件</th>
+              <th>相对路径</th>
+              <th>状态</th>
+              <th>大小</th>
+              <th>修改时间</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="file in files" :key="file.key">
+              <td>{{ file.label }}</td>
+              <td class="label-import-path">{{ file.relativePath }}</td>
+              <td>
+                <el-tag :type="file.exists ? 'success' : 'danger'" size="small">
+                  {{ file.exists ? '存在' : '缺失' }}
+                </el-tag>
+              </td>
+              <td class="label-import-mono">{{ formatSize(file.size) }}</td>
+              <td class="label-import-mono">{{ file.modifiedAt ?? '—' }}</td>
+            </tr>
+          </tbody>
+        </table>
+
+        <div class="label-import-actions">
+          <el-button class="label-import-update" type="primary" size="large" :loading="busy" :disabled="!hasAnyFile" @click="start">
+            更新标签
+          </el-button>
+          <span v-if="!hasAnyFile && files.length" class="label-import-hint">
+            未找到可导入的文件,请检查相对路径。
+          </span>
+        </div>
+
+        <div v-if="job && (job.running || job.result || job.error)" class="label-import-progress">
+          <el-progress :percentage="job.progress" :status="progressStatus" :stroke-width="14" />
+          <div class="label-import-stage">
+            {{ job.stage }}<span v-if="job.running">…</span>
+          </div>
+          <div v-if="job.error" class="label-import-error">{{ job.error }}</div>
+          <div v-if="job.result" class="label-import-result">
+            <div>处理行数:<strong>{{ job.result.total }}</strong></div>
+            <div>有效算法标签:<strong>{{ job.result.activeLabels }}</strong></div>
+            <div>耗时:{{ job.result.durationMs }} ms</div>
+            <div>
+              来源:
+              <span v-for="(count, key) in job.result.bySource" :key="key" class="label-import-source">
+                {{ key }} {{ count }}
+              </span>
+            </div>
+            <div v-if="job.result.skippedFiles.length" class="label-import-skip">
+              跳过文件:{{ job.result.skippedFiles.join('、') }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+  </div>
+</template>

+ 4 - 2
frontend/src/components/TimePointStrip.vue

@@ -85,11 +85,13 @@ function draw() {
   const selectedRight = xAt(Math.min(props.points.length - 1, Math.max(props.startIndex, endIndex.value - 1)))
   const selectedWidth = Math.max(selectedRight - selectedLeft, 8)
 
+  const selectionTop = 10
+  const selectionHeight = Math.max(tickY - selectionTop, 0)
   context.fillStyle = '#f4f6f7'
-  context.fillRect(selectedLeft, 10, selectedWidth, 172)
+  context.fillRect(selectedLeft, selectionTop, selectedWidth, selectionHeight)
   context.strokeStyle = '#dbe2e6'
   context.lineWidth = 1
-  context.strokeRect(selectedLeft + 0.5, 10.5, selectedWidth - 1, 171)
+  context.strokeRect(selectedLeft + 0.5, selectionTop + 0.5, selectedWidth - 1, Math.max(selectionHeight - 1, 0))
 
   rows.forEach((rowName, rowIndex) => {
     const y = rowY(rowIndex)

+ 23 - 1
frontend/src/styles.css

@@ -362,7 +362,8 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .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-value { flex: 1; min-width: 0; color: #303133; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.label-hour-delete { margin-left: auto; flex: 0 0 auto; }
 .label-field .el-date-editor,
 .label-field .el-input { flex: 1; min-width: 0; }
 .label-info-section { margin-top: 4px; }
@@ -376,3 +377,24 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .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; }
+
+.label-import-page { display: flex; flex-direction: column; gap: 16px; margin: 20px 0 0; }
+.label-import-panel { padding: 22px 24px 18px; }
+.label-import-note { margin: 6px 0 0; color: #909399; font-size: 12px; }
+.label-import-body { display: flex; flex-direction: column; gap: 16px; }
+.label-import-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+.label-import-table th,
+.label-import-table td { padding: 10px 12px; border-bottom: 1px solid #ebeef5; text-align: left; vertical-align: middle; }
+.label-import-table th { color: #606266; background: #fafbfc; font-weight: 600; }
+.label-import-path,
+.label-import-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; color: #303133; word-break: break-all; }
+.label-import-refresh.el-button { height: 32px; padding: 0 14px; font-size: 13px; border-radius: 4px; }
+.label-import-update.el-button { height: 40px; min-width: 120px; border-radius: 4px; font-size: 14px; }
+.label-import-actions { display: flex; align-items: center; gap: 12px; }
+.label-import-hint { color: #909399; font-size: 12px; }
+.label-import-progress { padding: 16px; border: 1px solid #ebeef5; border-radius: 4px; background: #fafbfc; display: flex; flex-direction: column; gap: 10px; }
+.label-import-stage { color: #606266; font-size: 13px; }
+.label-import-result { display: flex; flex-wrap: wrap; gap: 8px 24px; color: #303133; font-size: 13px; }
+.label-import-source { margin-right: 10px; }
+.label-import-error { color: #f56c6c; font-size: 13px; }
+.label-import-skip { color: #e6a23c; }