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

新增故障标签预览与手册生成:无登录曲线页、PKS标签图表、Word导出;补交故障汇总表重命名

18922397810 9 часов назад
Родитель
Сommit
79dd73ee37

+ 4 - 0
.gitignore

@@ -24,3 +24,7 @@ models/datas/hourly/
 models/datas/pressure/
 models/datas/vibration/
 models/datas/artifacts/
+# 生成的分析图表与演示构建临时文件
+总结文档/figures/
+.chart-data-*/
+.ppt-build/

BIN
7,8,9号压缩机故障汇总.xlsx


+ 19 - 0
backend/app/main.py

@@ -9,6 +9,7 @@ from .config import settings
 from .db import ensure_annotation_table
 from .services.data_service import data_service
 from .services.pretrain_service import pretrain_service
+from .services.tag_manual_service import tag_manual_service
 
 
 class TimePointInput(BaseModel):
@@ -124,6 +125,24 @@ def health(_auth: str = Depends(require_auth)) -> dict[str, Any]:
     return data_service.health()
 
 
+@app.get("/api/tag-manual/labels")
+def tag_manual_labels() -> dict[str, Any]:
+    try:
+        return tag_manual_service.labels()
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.get("/api/tag-manual/series")
+def tag_manual_series(key: str = Query(min_length=1)) -> dict[str, Any]:
+    try:
+        return tag_manual_service.series(key)
+    except KeyError as error:
+        raise HTTPException(status_code=404, detail="标签不存在") from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
 @app.get("/api/query-options")
 def query_options(_auth: str = Depends(require_auth)) -> dict[str, Any]:
     try:

+ 619 - 0
backend/app/services/tag_manual_service.py

@@ -0,0 +1,619 @@
+from __future__ import annotations
+
+import csv
+import math
+import re
+from bisect import bisect_left, bisect_right
+from datetime import datetime, timedelta
+from pathlib import Path
+from statistics import median
+from typing import Any
+
+from ..db import get_connection
+
+ROOT = Path(__file__).resolve().parents[3]
+PRESSURE_CSV = ROOT / "models" / "datas" / "reference" / "润滑油压力小时阶段段.csv"
+VIBRATION_CSV = ROOT / "models" / "datas" / "reference" / "振动异常段.csv"
+
+PRESSURE_FIELDS = (
+    "机组", "周期编号", "压力阶段", "开始小时", "结束小时", "持续自然小时数",
+    "有效运行小时数", "周期基线油压", "正常波动带", "阶段开始小时油压",
+    "阶段结束小时油压", "阶段最低小时油压", "阶段开始相对周期基线变化",
+    "阶段结束相对周期基线变化", "阶段最大连续下降有效小时数", "阶段低报警小时数",
+    "阶段低低报警小时数", "是否形成确认等级", "阶段触发依据",
+)
+VIBRATION_FIELDS = (
+    "机组", "周期编号", "振动阶段", "开始小时", "结束小时", "持续自然小时数",
+    "有效运行小时数", "近期基线振动", "正常波动带", "阶段开始小时振动",
+    "阶段结束小时振动", "阶段最大振动值", "阶段开始相对近期基线变化",
+    "阶段结束相对近期基线变化", "阶段最大连续异常有效小时数", "阶段高报警小时数",
+    "阶段高高报警小时数", "是否形成确认等级", "阶段触发依据", "阶段综合评分",
+    "持续时间得分", "平均振动得分", "最大振动值得分", "峰值偏离得分",
+    "最强异常侧", "最强侧近期峰值基线", "最强侧近期稳健尺度", "阶段最大峰值/基线比例",
+)
+PERCENT_FIELDS = {
+    "阶段开始相对周期基线变化", "阶段结束相对周期基线变化",
+    "阶段开始相对近期基线变化", "阶段结束相对近期基线变化",
+}
+LONG_FIELDS = {"阶段触发依据"}
+FIELD_LABELS = {
+    "机组": "机组",
+    "周期编号": "周期编号",
+    "压力阶段": "阶段",
+    "振动阶段": "阶段",
+    "开始小时": "开始",
+    "结束小时": "结束",
+    "持续自然小时数": "自然小时",
+    "有效运行小时数": "有效运行小时",
+    "周期基线油压": "周期基线",
+    "近期基线振动": "近期基线",
+    "正常波动带": "正常波动带",
+    "阶段开始小时油压": "开始油压",
+    "阶段结束小时油压": "结束油压",
+    "阶段最低小时油压": "最低油压",
+    "阶段开始小时振动": "开始振动",
+    "阶段结束小时振动": "结束振动",
+    "阶段最大振动值": "最大振动",
+    "阶段开始相对周期基线变化": "开始相对基线",
+    "阶段结束相对周期基线变化": "结束相对基线",
+    "阶段开始相对近期基线变化": "开始相对基线",
+    "阶段结束相对近期基线变化": "结束相对基线",
+    "阶段最大连续下降有效小时数": "最大连续下降",
+    "阶段最大连续异常有效小时数": "最大连续异常",
+    "阶段低报警小时数": "低报小时",
+    "阶段低低报警小时数": "低低报小时",
+    "阶段高报警小时数": "高报小时",
+    "阶段高高报警小时数": "高高报小时",
+    "是否形成确认等级": "确认等级",
+    "阶段触发依据": "触发依据",
+    "阶段综合评分": "综合评分",
+    "持续时间得分": "持续时间得分",
+    "平均振动得分": "平均振动得分",
+    "最大振动值得分": "最大振动得分",
+    "峰值偏离得分": "峰值偏离得分",
+    "最强异常侧": "最强异常侧",
+    "最强侧近期峰值基线": "最强侧峰值基线",
+    "最强侧近期稳健尺度": "最强侧稳健尺度",
+    "阶段最大峰值/基线比例": "峰值/基线",
+}
+PALETTE = {
+    "oil": "#1f6feb",
+    "coupling": "#e8890c",
+    "chain": "#2e9e5b",
+}
+BAND_RED = "rgba(229,57,53,0.13)"
+
+
+def _to_float(value: Any) -> float | None:
+    if value is None:
+        return None
+    text = str(value).strip()
+    if not text or text.lower() == "nan":
+        return None
+    try:
+        number = float(text)
+    except ValueError:
+        return None
+    return number if math.isfinite(number) else None
+
+
+def _parse_time(value: str) -> datetime:
+    return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S")
+
+
+def _fmt_time(value: datetime) -> str:
+    return value.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _device_code(unit: str) -> str:
+    match = re.search(r"(\d+)", unit)
+    return f"{match.group(1)}#" if match else unit
+
+
+def _field_value(key: str, value: str) -> str:
+    number = _to_float(value)
+    if number is not None and key in PERCENT_FIELDS:
+        return f"{number * 100:.1f}%"
+    return value
+
+
+def _fields(row: dict[str, str], names: tuple[str, ...]) -> list[dict[str, Any]]:
+    items: list[dict[str, Any]] = []
+    for name in names:
+        value = (row.get(name) or "").strip()
+        items.append({
+            "key": name,
+            "label": FIELD_LABELS.get(name, name),
+            "value": _field_value(name, value) if value else "—",
+            "span": 2 if name in LONG_FIELDS else 1,
+        })
+    return items
+
+
+def _read_rows(path: Path, kind: str, stage_key: str) -> list[dict[str, Any]]:
+    rows: list[dict[str, Any]] = []
+    with path.open(encoding="utf-8-sig", newline="") as handle:
+        for index, row in enumerate(csv.DictReader(handle)):
+            unit = (row.get("机组") or "").strip()
+            start = (row.get("开始小时") or "").strip()
+            end = (row.get("结束小时") or "").strip()
+            cycle = (row.get("周期编号") or "").strip()
+            stage = (row.get(stage_key) or "").strip()
+            kind_label = "润滑油压力" if kind == "pressure" else "振动"
+            cycle_short = cycle.split("-", 1)[1] if cycle.startswith(f"{unit}-") else cycle
+            baseline = _to_float(
+                row.get("周期基线油压") if kind == "pressure" else row.get("近期基线振动")
+            )
+            rows.append({
+                "key": f"{kind}:{index}",
+                "kind": kind,
+                "kindLabel": kind_label,
+                "device": unit,
+                "deviceCode": _device_code(unit),
+                "cycle": cycle,
+                "stage": stage,
+                "start": start,
+                "end": end,
+                "side": (row.get("最强异常侧") or "").strip(),
+                "baseline": baseline,
+                "title": f"{unit}-{kind_label}-{cycle_short}-{start[:13]}",
+                "fields": _fields(row, PRESSURE_FIELDS if kind == "pressure" else VIBRATION_FIELDS),
+            })
+    return rows
+
+
+def _labels() -> list[dict[str, Any]]:
+    return _read_rows(PRESSURE_CSV, "pressure", "压力阶段") + _read_rows(
+        VIBRATION_CSV, "vibration", "振动阶段"
+    )
+
+
+def _summary(labels: list[dict[str, Any]]) -> dict[str, Any]:
+    def count(key: str) -> dict[str, int]:
+        values: dict[str, int] = {}
+        for label in labels:
+            name = str(label[key])
+            values[name] = values.get(name, 0) + 1
+        return dict(sorted(values.items()))
+
+    return {
+        "total": len(labels),
+        "byKind": count("kindLabel"),
+        "byDevice": count("device"),
+        "byStage": count("stage"),
+    }
+
+
+def _step_for(hours: float) -> int:
+    if hours <= 24:
+        return 5
+    if hours <= 72:
+        return 30
+    return 60
+
+
+def _query_rows(device: str, start: datetime, end: datetime, step: int) -> list[dict[str, Any]]:
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT MIN(sample_time) AS t,
+                       AVG(NULLIF(YSJ_5, 0)) AS oil,
+                       AVG(NULLIF(YSJ_10, 0)) AS coupling,
+                       AVG(NULLIF(YSJ_11, 0)) AS chain
+                FROM pks_long_sample
+                WHERE device_code = %s
+                  AND sample_time >= %s
+                  AND sample_time <= %s
+                  AND YSJ_41 > 0
+                GROUP BY FLOOR(UNIX_TIMESTAMP(sample_time) / %s)
+                ORDER BY t
+                """,
+                (device, start, end, step),
+            )
+            return list(cursor.fetchall())
+
+
+def _padded(values: list[float]) -> tuple[float, float]:
+    if not values:
+        return 0.0, 1.0
+    low, high = min(values), max(values)
+    pad = (high - low) * 0.08 or abs(high) * 0.08 or 1.0
+    return low - pad, high + pad
+
+
+def _series_points(values: list[float | None], convert: Any = None) -> list[list[Any]]:
+    points: list[list[Any]] = []
+    for index, value in enumerate(values):
+        if value is None:
+            points.append([index, None])
+        else:
+            points.append([index, convert(value) if convert else value])
+    return points
+
+
+def _subplot(
+    title: str,
+    unit: str,
+    series: list[dict[str, Any]],
+    band: str,
+    ref_lines: list[dict[str, Any]],
+    y_min: float,
+    y_max: float,
+) -> dict[str, Any]:
+    return {
+        "title": title,
+        "unit": unit,
+        "band": band,
+        "refLines": ref_lines,
+        "yMin": round(y_min, 4),
+        "yMax": round(y_max, 4),
+        "series": series,
+    }
+
+
+def _running_seconds(rows: list[dict[str, Any]], start: datetime, end: datetime) -> tuple[float, float]:
+    before = [row for row in rows if row["t"] <= start]
+    after = [row for row in rows if row["t"] >= end]
+    before_seconds = 0.0
+    for previous, current in zip(before, before[1:]):
+        delta = (current["t"] - previous["t"]).total_seconds()
+        if 0 < delta <= 180:
+            before_seconds += delta
+    after_seconds = 0.0
+    for previous, current in zip(after, after[1:]):
+        delta = (current["t"] - previous["t"]).total_seconds()
+        if 0 < delta <= 180:
+            after_seconds += delta
+    return before_seconds, after_seconds
+
+
+def _coverage_bounds(
+    rows: list[dict[str, Any]],
+    start: datetime,
+    end: datetime,
+    hours: float = 6,
+) -> tuple[datetime, datetime]:
+    needed = hours * 3600
+    before = [row for row in rows if row["t"] <= start]
+    range_start = start
+    total = 0.0
+    for index in range(len(before) - 1, -1, -1):
+        moment = before[index]["t"]
+        if index < len(before) - 1:
+            delta = (before[index + 1]["t"] - moment).total_seconds()
+            if 0 < delta <= 180:
+                total += delta
+        range_start = moment
+        if total >= needed:
+            break
+
+    after = [row for row in rows if row["t"] >= end]
+    range_end = end
+    total = 0.0
+    for index, row in enumerate(after):
+        if index > 0:
+            delta = (row["t"] - after[index - 1]["t"]).total_seconds()
+            if 0 < delta <= 180:
+                total += delta
+        range_end = row["t"]
+        if total >= needed:
+            break
+    return range_start, range_end
+
+
+def _build_chart(label: dict[str, Any], all_labels: list[dict[str, Any]]) -> dict[str, Any]:
+    start = _parse_time(label["start"])
+    end = _parse_time(label["end"]) + timedelta(hours=1)
+    range_start = start - timedelta(hours=6)
+    range_end = end + timedelta(hours=6)
+    run_hours = (end - start).total_seconds() / 3600 + 12
+    step = _step_for(run_hours)
+    rows = _query_rows(label["deviceCode"], range_start, range_end, step)
+
+    if rows:
+        before_seconds, after_seconds = _running_seconds(rows, start, end)
+        if before_seconds < 5.9 * 3600 or after_seconds < 5.9 * 3600:
+            coarse = _query_rows(
+                label["deviceCode"],
+                start - timedelta(days=30),
+                end + timedelta(days=30),
+                60,
+            )
+            if coarse:
+                covered_start, covered_end = _coverage_bounds(coarse, start, end)
+                range_start = min(range_start, covered_start)
+                range_end = max(range_end, covered_end)
+                rows = _query_rows(label["deviceCode"], range_start, range_end, step)
+
+    if not rows:
+        return {
+            "key": label["key"],
+            "title": label["title"],
+            "subtitle": f"故障段 {_fmt_time(start)} ~ {_fmt_time(end)} | 无运行数据",
+            "stage": label["stage"],
+            "kind": label["kind"],
+            "x": {"max": 0, "times": [], "gaps": [], "shutdowns": []},
+            "fault": {"start": 0, "end": 0},
+            "yellowBands": [],
+            "subplots": [],
+            "empty": True,
+        }
+
+    gap_seconds = max(90, step * 3)
+    gap_count = 0
+    last_time = None
+    for row in rows:
+        moment = row["t"]
+        if last_time is not None and (moment - last_time).total_seconds() > gap_seconds:
+            gap_count += 1
+        last_time = moment
+    gap_slots = max(2, round(len(rows) / 160))
+    if gap_count:
+        gap_slots = min(gap_slots, max(2, round(len(rows) * 0.15 / gap_count)))
+
+    display: list[dict[str, Any] | None] = []
+    gap_indexes: list[int] = []
+    shutdowns: list[dict[str, Any]] = []
+    last_time = None
+    settle_until = None
+    for row in rows:
+        moment = row["t"]
+        if last_time is not None and (moment - last_time).total_seconds() > gap_seconds:
+            left_index = len(display)
+            display.extend([None] * gap_slots)
+            right_index = len(display) - 1
+            gap_indexes.append((left_index + right_index) // 2)
+            shutdowns.append({
+                "shutdown": last_time.strftime("%Y-%m-%d %H:%M:%S"),
+                "startup": moment.strftime("%Y-%m-%d %H:%M:%S"),
+                "startIndex": left_index,
+                "endIndex": right_index,
+            })
+            settle_until = moment + timedelta(minutes=20)
+        last_time = moment
+        if settle_until is not None:
+            if moment < settle_until and not (start <= moment <= end):
+                continue
+            settle_until = None
+        display.append({
+            "t": moment,
+            "oil": _to_float(row["oil"]),
+            "coupling": _to_float(row["coupling"]),
+            "chain": _to_float(row["chain"]),
+        })
+
+    times = [item["t"].strftime("%Y-%m-%d %H:%M:%S") if item else None for item in display]
+    oil = [item["oil"] if item else None for item in display]
+    coupling = [item["coupling"] if item else None for item in display]
+    chain = [item["chain"] if item else None for item in display]
+
+    fault_start_index = 0
+    fault_end_index = len(display) - 1
+    seen_start = False
+    for index, item in enumerate(display):
+        if item is None:
+            continue
+        if not seen_start and item["t"] >= start:
+            fault_start_index = index
+            seen_start = True
+        if item["t"] <= end:
+            fault_end_index = index
+    if not seen_start:
+        fault_start_index = 0
+
+    real_times: list[datetime] = []
+    real_indexes: list[int] = []
+    for index, item in enumerate(display):
+        if item is not None:
+            real_times.append(item["t"])
+            real_indexes.append(index)
+
+    intervals: list[tuple[datetime, datetime]] = []
+    for other in all_labels:
+        if other["key"] == label["key"]:
+            continue
+        other_start = _parse_time(other["start"])
+        other_end = _parse_time(other["end"]) + timedelta(hours=1)
+        clip_start = max(other_start, range_start)
+        clip_end = min(other_end, range_end)
+        if clip_start >= clip_end:
+            continue
+        if clip_end <= start or clip_start >= end:
+            intervals.append((clip_start, clip_end))
+            continue
+        if clip_start < start:
+            intervals.append((clip_start, start))
+        if clip_end > end:
+            intervals.append((end, clip_end))
+
+    merged: list[list[datetime]] = []
+    for interval_start, interval_end in sorted(intervals):
+        if not merged or interval_start > merged[-1][1]:
+            merged.append([interval_start, interval_end])
+        else:
+            merged[-1][1] = max(merged[-1][1], interval_end)
+
+    yellow_bands: list[dict[str, int]] = []
+    for interval_start, interval_end in merged:
+        left = bisect_left(real_times, interval_start)
+        right = bisect_right(real_times, interval_end) - 1
+        if left >= len(real_indexes) or right < 0 or left > right:
+            continue
+        start_index = real_indexes[left]
+        end_index = real_indexes[right]
+        if end_index > start_index:
+            yellow_bands.append({"start": start_index, "end": end_index})
+
+    oil_values = [value for value in oil if value is not None]
+    vibration_values = [value for value in coupling + chain if value is not None]
+    oil_baseline = label["baseline"] if label["baseline"] and label["baseline"] > 0 else (
+        median(oil_values) if oil_values else None
+    )
+    vibration_baseline = label["baseline"] if label["baseline"] and label["baseline"] > 0 else (
+        median(vibration_values) if vibration_values else None
+    )
+
+    if label["kind"] == "pressure":
+        relative = [
+            None if value is None or not oil_baseline else (value / oil_baseline - 1) * 100
+            for value in oil
+        ]
+        relative_values = [value for value in relative if value is not None]
+        rel_low, rel_high = _padded(relative_values)
+        oil_low, oil_high = _padded(oil_values)
+        vibration_low, vibration_high = _padded(vibration_values)
+        subplots = [
+            _subplot(
+                "润滑油压力 · 相对周期基线", "%",
+                [{"name": "润滑油压力", "color": PALETTE["oil"], "data": _series_points(relative)}],
+                BAND_RED,
+                [
+                    {"value": 0, "label": "基线"},
+                    {"value": -5, "label": "-5%"},
+                    {"value": -10, "label": "-10%"},
+                    {"value": -20, "label": "-20%"},
+                ],
+                min(-25.0, rel_low - 2),
+                5.0,
+            ),
+            _subplot(
+                "润滑油压力 · 原值", "MPa",
+                [{"name": "润滑油压力", "color": PALETTE["oil"], "data": _series_points(oil)}],
+                BAND_RED,
+                [{"value": oil_baseline, "label": "周期基线"}] if oil_baseline else [],
+                oil_low,
+                oil_high,
+            ),
+            _subplot(
+                "联轴器端振动 · 原值", "mm/s",
+                [{"name": "联轴器端", "color": PALETTE["coupling"], "data": _series_points(coupling)}],
+                "",
+                [{"value": vibration_baseline, "label": "近期基线"}] if vibration_baseline else [],
+                vibration_low,
+                vibration_high,
+            ),
+            _subplot(
+                "链轮端振动 · 原值", "mm/s",
+                [{"name": "链轮端", "color": PALETTE["chain"], "data": _series_points(chain)}],
+                "",
+                [{"value": vibration_baseline, "label": "近期基线"}] if vibration_baseline else [],
+                vibration_low,
+                vibration_high,
+            ),
+        ]
+    else:
+        relative_coupling = [
+            None if value is None or not vibration_baseline else value / vibration_baseline
+            for value in coupling
+        ]
+        relative_chain = [
+            None if value is None or not vibration_baseline else value / vibration_baseline
+            for value in chain
+        ]
+        relative_values = [
+            value for value in relative_coupling + relative_chain if value is not None
+        ]
+        _, rel_high = _padded(relative_values)
+        oil_low, oil_high = _padded(oil_values)
+        vibration_low, vibration_high = _padded(vibration_values)
+        relative_max = max(2.0, rel_high + 0.1)
+        subplots = [
+            _subplot(
+                "联轴器端振动 · 相对近期基线", "×",
+                [{
+                    "name": "联轴器端", "color": PALETTE["coupling"],
+                    "data": _series_points(relative_coupling),
+                }],
+                BAND_RED,
+                [{"value": 1, "label": "基线"}],
+                0,
+                relative_max,
+            ),
+            _subplot(
+                "联轴器端振动 · 原值", "mm/s",
+                [{"name": "联轴器端", "color": PALETTE["coupling"], "data": _series_points(coupling)}],
+                BAND_RED,
+                [{"value": vibration_baseline, "label": "近期基线"}] if vibration_baseline else [],
+                vibration_low,
+                vibration_high,
+            ),
+            _subplot(
+                "链轮端振动 · 相对近期基线", "×",
+                [{
+                    "name": "链轮端", "color": PALETTE["chain"],
+                    "data": _series_points(relative_chain),
+                }],
+                BAND_RED,
+                [{"value": 1, "label": "基线"}],
+                0,
+                relative_max,
+            ),
+            _subplot(
+                "链轮端振动 · 原值", "mm/s",
+                [{"name": "链轮端", "color": PALETTE["chain"], "data": _series_points(chain)}],
+                BAND_RED,
+                [{"value": vibration_baseline, "label": "近期基线"}] if vibration_baseline else [],
+                vibration_low,
+                vibration_high,
+            ),
+            _subplot(
+                "润滑油压力 · 原值", "MPa",
+                [{"name": "润滑油压力", "color": PALETTE["oil"], "data": _series_points(oil)}],
+                "",
+                [{"value": oil_baseline, "label": "周期基线"}] if oil_baseline else [],
+                oil_low,
+                oil_high,
+            ),
+        ]
+
+    shutdown_note = ""
+    if shutdowns:
+        shown = shutdowns[:3]
+        text = ";".join(
+            f"关机时间 {item['shutdown'][5:16]},开机时间 {item['startup'][5:16]}"
+            for item in shown
+        )
+        if len(shutdowns) > len(shown):
+            text += f";等 {len(shutdowns)} 段"
+        shutdown_note = f" | {text}"
+
+    yellow_note = " | 淡黄色为其他故障段重合时间" if yellow_bands else ""
+
+    return {
+        "key": label["key"],
+        "title": label["title"],
+        "subtitle": (
+            f"故障段 {_fmt_time(start)} ~ {_fmt_time(end)} | "
+            f"展示 {_fmt_time(range_start)} ~ {_fmt_time(range_end)}"
+            f"{shutdown_note}{yellow_note}"
+        ),
+        "stage": label["stage"],
+        "kind": label["kind"],
+        "x": {
+            "max": len(display) - 1,
+            "times": times,
+            "gaps": gap_indexes,
+            "shutdowns": shutdowns,
+        },
+        "fault": {"start": fault_start_index, "end": fault_end_index},
+        "yellowBands": yellow_bands,
+        "subplots": subplots,
+        "empty": False,
+    }
+
+
+class TagManualService:
+    def labels(self) -> dict[str, Any]:
+        items = _labels()
+        return {"items": items, "summary": _summary(items)}
+
+    def series(self, key: str) -> dict[str, Any]:
+        labels = _labels()
+        for label in labels:
+            if label["key"] == key:
+                return _build_chart(label, labels)
+        raise KeyError(key)
+
+
+tag_manual_service = TagManualService()

+ 665 - 0
build_tag_manual.py

@@ -0,0 +1,665 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+from collections import Counter, defaultdict
+from datetime import date
+from pathlib import Path
+from urllib.parse import quote
+from urllib.request import urlopen
+
+from PIL import Image
+from docx import Document
+from docx.enum.section import WD_ORIENT
+from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
+from docx.oxml import OxmlElement
+from docx.oxml.ns import qn
+from docx.shared import Cm, Pt, RGBColor
+
+ROOT = Path(__file__).resolve().parent
+OUT_DIR = ROOT / "总结文档"
+FIG_DIR = OUT_DIR / "figures"
+DEFAULT_DOCX = OUT_DIR / "组态数据高质量演化过程故障标签手册.docx"
+BASE_URL = "http://127.0.0.1:5173/preview.html"
+API_URL = "http://127.0.0.1:8000/api/tag-manual/labels"
+CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+
+BODY_FONT = "宋体"
+HEAD_FONT = "黑体"
+BODY_SIZE = 12.0
+TABLE_SIZE = 9.0
+DEVICE_ORDER = {"7号机": 7, "8号机": 8, "9号机": 9}
+STAGE_ORDER = {"轻微": 0, "异常": 1, "严重异常": 2}
+KIND_ORDER = {"pressure": 0, "vibration": 1}
+KIND_LABEL = {"pressure": "润滑油压力", "vibration": "振动"}
+
+
+def set_run_font(run, name: str = BODY_FONT, size: float = BODY_SIZE, bold: bool = False) -> None:
+    run.font.name = name
+    run.font.size = Pt(size)
+    run.font.bold = bold
+    run.font.color.rgb = RGBColor(0, 0, 0)
+    run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
+
+
+def set_style_font(style, name: str, size: float, bold: bool) -> None:
+    style.font.name = name
+    style.font.size = Pt(size)
+    style.font.bold = bold
+    style.font.color.rgb = RGBColor(0, 0, 0)
+    style.element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
+
+
+def configure_document(doc: Document) -> None:
+    section = doc.sections[0]
+    section.orientation = WD_ORIENT.LANDSCAPE
+    section.page_width = Cm(29.7)
+    section.page_height = Cm(21.0)
+    section.top_margin = Cm(2.0)
+    section.bottom_margin = Cm(2.0)
+    section.left_margin = Cm(2.0)
+    section.right_margin = Cm(2.0)
+
+    normal = doc.styles["Normal"]
+    set_style_font(normal, BODY_FONT, BODY_SIZE, False)
+    normal.paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
+    normal.paragraph_format.space_before = Pt(0)
+    normal.paragraph_format.space_after = Pt(0)
+
+    heading1 = doc.styles["Heading 1"]
+    set_style_font(heading1, HEAD_FONT, 16, True)
+    heading1.paragraph_format.space_before = Pt(18)
+    heading1.paragraph_format.space_after = Pt(10)
+    heading1.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
+    heading1.paragraph_format.keep_with_next = True
+
+    heading2 = doc.styles["Heading 2"]
+    set_style_font(heading2, HEAD_FONT, 14, True)
+    heading2.paragraph_format.space_before = Pt(12)
+    heading2.paragraph_format.space_after = Pt(6)
+    heading2.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
+    heading2.paragraph_format.keep_with_next = True
+
+    add_footer(doc)
+
+
+def add_footer(doc: Document) -> None:
+    section = doc.sections[0]
+    footer = section.footer
+    paragraph = footer.paragraphs[0]
+    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = paragraph.add_run("组态数据高质量演化过程故障标签手册    第 ")
+    set_run_font(run, BODY_FONT, 9)
+    field = OxmlElement("w:fldSimple")
+    field.set(qn("w:instr"), "PAGE")
+    paragraph._p.append(field)
+    tail = paragraph.add_run(" 页")
+    set_run_font(tail, BODY_FONT, 9)
+
+
+def body_paragraph(doc: Document, text: str, bold_prefix: str = "") -> None:
+    paragraph = doc.add_paragraph()
+    paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
+    paragraph.paragraph_format.space_before = Pt(0)
+    paragraph.paragraph_format.space_after = Pt(6)
+    if bold_prefix and text.startswith(bold_prefix):
+        first = paragraph.add_run(bold_prefix)
+        set_run_font(first, BODY_FONT, BODY_SIZE, True)
+        rest = paragraph.add_run(text[len(bold_prefix):])
+        set_run_font(rest, BODY_FONT, BODY_SIZE)
+    else:
+        run = paragraph.add_run(text)
+        set_run_font(run, BODY_FONT, BODY_SIZE)
+
+
+def set_cell_margins(cell, left: int = 60, right: int = 60, top: int = 30, bottom: int = 30) -> None:
+    tc_pr = cell._tc.get_or_add_tcPr()
+    mar = tc_pr.first_child_found_in("w:tcMar")
+    if mar is None:
+        mar = OxmlElement("w:tcMar")
+        tc_pr.append(mar)
+    for key, value in (("w:left", left), ("w:right", right), ("w:top", top), ("w:bottom", bottom)):
+        node = mar.find(qn(key))
+        if node is None:
+            node = OxmlElement(key)
+            mar.append(node)
+        node.set(qn("w:w"), str(value))
+        node.set(qn("w:type"), "dxa")
+
+
+def fix_table_layout(table, widths: list[float]) -> None:
+    tbl_pr = table._tbl.tblPr
+    layout = OxmlElement("w:tblLayout")
+    layout.set(qn("w:type"), "fixed")
+    tbl_pr.append(layout)
+    tbl_w = tbl_pr.find(qn("w:tblW"))
+    if tbl_w is None:
+        tbl_w = OxmlElement("w:tblW")
+        tbl_pr.append(tbl_w)
+    tbl_w.set(qn("w:w"), str(int(sum(widths) * 567)))
+    tbl_w.set(qn("w:type"), "dxa")
+    for index, column in enumerate(table._tbl.tblGrid.gridCol_lst):
+        column.set(qn("w:w"), str(int(widths[index] * 567)))
+
+
+def no_split(row) -> None:
+    tr_pr = row._tr.get_or_add_trPr()
+    cant = OxmlElement("w:cantSplit")
+    tr_pr.append(cant)
+
+
+def repeat_header(row) -> None:
+    tr_pr = row._tr.get_or_add_trPr()
+    header = OxmlElement("w:tblHeader")
+    tr_pr.append(header)
+
+
+def add_table(
+    doc: Document,
+    headers: list[str],
+    rows: list[list[str]],
+    widths: list[float],
+    font_size: float = TABLE_SIZE,
+    align_center: bool = False,
+) -> None:
+    table = doc.add_table(rows=1, cols=len(headers))
+    table.style = "Table Grid"
+    table.alignment = WD_TABLE_ALIGNMENT.CENTER
+    table.autofit = False
+    fix_table_layout(table, widths)
+    header = table.rows[0]
+    repeat_header(header)
+    no_split(header)
+    for index, text in enumerate(headers):
+        cell = header.cells[index]
+        cell.width = Cm(widths[index])
+        set_cell_margins(cell)
+        cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
+        paragraph = cell.paragraphs[0]
+        paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
+        run = paragraph.add_run(text)
+        set_run_font(run, BODY_FONT, font_size, True)
+    for values in rows:
+        row = table.add_row()
+        no_split(row)
+        for index, text in enumerate(values):
+            cell = row.cells[index]
+            cell.width = Cm(widths[index])
+            set_cell_margins(cell)
+            cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
+            paragraph = cell.paragraphs[0]
+            paragraph.alignment = (
+                WD_ALIGN_PARAGRAPH.CENTER if align_center or index > 1 else WD_ALIGN_PARAGRAPH.LEFT
+            )
+            paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
+            run = paragraph.add_run(str(text))
+            set_run_font(run, BODY_FONT, font_size)
+    doc.add_paragraph()
+
+
+def add_attr_table(doc: Document, fields: list[dict]) -> None:
+    widths = [2.0, 6.5, 2.0, 6.5, 2.0, 6.5]
+    table = doc.add_table(rows=0, cols=6)
+    table.style = "Table Grid"
+    table.alignment = WD_TABLE_ALIGNMENT.CENTER
+    table.autofit = False
+    fix_table_layout(table, widths)
+
+    row = None
+    pair = 0
+
+    def new_row():
+        nonlocal row, pair
+        row = table.add_row()
+        no_split(row)
+        pair = 0
+        for index, width in enumerate(widths):
+            cell = row.cells[index]
+            cell.width = Cm(width)
+            set_cell_margins(cell)
+            cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
+
+    for field in fields:
+        label = str(field.get("label") or field.get("key") or "")
+        value = str(field.get("value") or "—")
+        span = int(field.get("span") or 1)
+        if span >= 2:
+            if row is not None and pair > 0:
+                row = None
+            new_row()
+            label_cell = row.cells[0]
+            value_cell = row.cells[1].merge(row.cells[5])
+            for cell, text, bold in ((label_cell, label, True), (value_cell, value, False)):
+                cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
+                paragraph = cell.paragraphs[0]
+                paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
+                run = paragraph.add_run(text)
+                set_run_font(run, BODY_FONT, TABLE_SIZE, bold)
+            row = None
+            pair = 0
+            continue
+
+        if row is None or pair >= 3:
+            new_row()
+        label_cell = row.cells[pair * 2]
+        value_cell = row.cells[pair * 2 + 1]
+        for cell, text, bold in ((label_cell, label, True), (value_cell, value, False)):
+            cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
+            paragraph = cell.paragraphs[0]
+            paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
+            run = paragraph.add_run(text)
+            set_run_font(run, BODY_FONT, TABLE_SIZE, bold)
+        pair += 1
+
+    doc.add_paragraph()
+
+
+def load_labels() -> list[dict]:
+    with urlopen(API_URL, timeout=20) as response:
+        payload = json.load(response)
+    return payload["items"]
+
+
+def fetch_ready() -> None:
+    for name, url in (
+        ("后端服务", "http://127.0.0.1:8000/api/tag-manual/labels"),
+        ("前端预览", "http://127.0.0.1:5173/preview.html"),
+    ):
+        try:
+            with urlopen(url, timeout=5):
+                pass
+        except Exception as error:
+            raise SystemExit(
+                f"{name}未启动或不可访问:{error}\n"
+                "请先启动:uvicorn app.main:app --app-dir backend --port 8000\n"
+                "并启动:cd frontend && npm run dev"
+            ) from error
+
+
+def screenshot(label: dict, profile_dir: Path, force: bool = False) -> Path:
+    FIG_DIR.mkdir(parents=True, exist_ok=True)
+    jpg_path = FIG_DIR / f"{label['key'].replace(':', '_')}.jpg"
+    if jpg_path.exists() and not force:
+        return jpg_path
+    png_path = FIG_DIR / f"{label['key'].replace(':', '_')}.png"
+    url = f"{BASE_URL}?key={quote(label['key'])}&chart=1"
+    command = [
+        CHROME,
+        "--headless=new",
+        "--disable-gpu",
+        "--hide-scrollbars",
+        "--window-size=1800,900",
+        "--force-device-scale-factor=2",
+        "--virtual-time-budget=20000",
+        "--run-all-compositor-stages-before-draw",
+        f"--user-data-dir={profile_dir}",
+        f"--screenshot={png_path}",
+        url,
+    ]
+    for attempt in range(2):
+        subprocess.run(
+            command,
+            check=True,
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+            timeout=120,
+        )
+        if png_path.exists() and png_path.stat().st_size > 5000:
+            break
+        time.sleep(1)
+    if not png_path.exists() or png_path.stat().st_size <= 5000:
+        raise RuntimeError(f"截图失败:{label['key']}")
+    image = Image.open(png_path).convert("RGB")
+    image = image.resize((2200, 1100), Image.LANCZOS)
+    image.save(jpg_path, quality=90, optimize=True)
+    png_path.unlink(missing_ok=True)
+    return jpg_path
+
+
+def cycle_name(label: dict) -> str:
+    cycle = label.get("cycle") or ""
+    unit = label.get("device") or ""
+    prefix = f"{unit}-"
+    return cycle[len(prefix):] if cycle.startswith(prefix) else cycle
+
+
+def sort_key(label: dict) -> tuple:
+    unit = label.get("device") or ""
+    return (
+        DEVICE_ORDER.get(unit, 99),
+        KIND_ORDER.get(label.get("kind"), 9),
+        label.get("start") or "",
+    )
+
+
+def add_cover(doc: Document, labels: list[dict]) -> None:
+    for _ in range(4):
+        doc.add_paragraph()
+    paragraph = doc.add_paragraph()
+    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = paragraph.add_run("组态数据高质量演化过程")
+    set_run_font(run, HEAD_FONT, 26, True)
+    paragraph = doc.add_paragraph()
+    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = paragraph.add_run("故障标签手册")
+    set_run_font(run, HEAD_FONT, 26, True)
+
+    doc.add_paragraph()
+    paragraph = doc.add_paragraph()
+    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = paragraph.add_run("润滑油压力故障 · 振动故障")
+    set_run_font(run, BODY_FONT, 16, False)
+
+    for _ in range(6):
+        doc.add_paragraph()
+    paragraph = doc.add_paragraph()
+    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = paragraph.add_run(f"共 {len(labels)} 条演化标签")
+    set_run_font(run, BODY_FONT, 12, False)
+    paragraph = doc.add_paragraph()
+    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = paragraph.add_run(f"{date.today().year} 年 {date.today().month} 月")
+    set_run_font(run, BODY_FONT, 12, False)
+    doc.add_page_break()
+
+
+def add_reading_guide(doc: Document) -> None:
+    doc.add_heading("文档阅读说明", level=1)
+    body_paragraph(
+        doc,
+        "用途:本手册汇总设备运行过程中由规则扫描形成的润滑油压力、振动两类演化标签,"
+        "逐条给出标签属性、演化阶段和标签前后运行曲线,用于复核、诊断和维修决策辅助。",
+        "用途:",
+    )
+    body_paragraph(
+        doc,
+        "阅读方法:先阅读标签数量总结,了解总体分布和演化过程;再按章节、机组或阶段查阅详情。"
+        "在 Word 中打开导航窗格,可通过一级、二级标题直接跳转到对应章节或标签。",
+        "阅读方法:",
+    )
+    body_paragraph(
+        doc,
+        "排序方式:详情部分分为润滑油压力、振动两章,章内按机组和开始时间排序;"
+        "同一标签的所有属性集中在一张表中,图表位于属性表下方。",
+        "排序方式:",
+    )
+    body_paragraph(
+        doc,
+        "数据口径:标签来自演化阶段统计记录;曲线来自设备运行监测数据,只使用运行样本,"
+        "停机、坏点和无效值不参与曲线绘制。标签是规则扫描结果,不等同于物理故障结论。",
+        "数据口径:",
+    )
+
+    doc.add_heading("图形解释", level=2)
+    rows = [
+        ["红色背景", "当前标签对应的故障阶段,画在当前类型子图上。"],
+        ["淡黄色背景", "总表中其他故障标签与当前展示窗口重合的时间段;与红色故障段重叠的部分不重复标注。"],
+        ["竖向虚线", "停机边界。两条虚线为一组,左侧为关机,右侧为开机。"],
+        ["标题下备注", "故障段时间、图表展示时间,以及各停机段的关机时间和开机时间。"],
+        ["相对基线子图", "润滑油压力按周期基线计算百分比;振动按近期基线计算倍数;虚线为基线。"],
+        ["原值子图", "测点实际值,用于核对曲线形态和数值范围。"],
+        ["右上角角标", "标签阶段:轻微、异常或严重异常。"],
+        ["底部滑块", "时间轴缩放控件,默认显示全部时间范围。"],
+        ["子图", "每个子图只画一条曲线,所有子图共用同一时间轴。"],
+    ]
+    add_table(doc, ["图形元素", "含义"], rows, [5.0, 20.5], font_size=9.5)
+
+    body_paragraph(
+        doc,
+        "展示范围:故障段前后各补充 6 小时有效运行数据。若期间发生停机,会继续向更早或更晚的"
+        "运行数据取数,停机段压缩为空档并用竖向虚线标出。停机后 20 分钟的启动瞬态在故障段外"
+        "不展示,故障段内的数据不剔除。",
+        "展示范围:",
+    )
+    body_paragraph(
+        doc,
+        "本版为正文定稿版,仅保留一条示例曲线。其余标签的曲线图待图表调整完成后统一补充。",
+        "图表说明:",
+    )
+
+
+def add_summary(doc: Document, labels: list[dict]) -> None:
+    doc.add_heading("一、标签数量总结", level=1)
+    body_paragraph(
+        doc,
+        f"本手册共收录 {len(labels)} 条演化标签,覆盖润滑油压力故障和振动故障两类,"
+        "按机组、阶段和周期编号统计如下。",
+    )
+
+    kind_counts = Counter(label["kindLabel"] for label in labels)
+    device_kind = Counter((label["device"], label["kindLabel"]) for label in labels)
+    stage_kind = Counter((label["stage"], label["kindLabel"]) for label in labels)
+    device_stage = Counter((label["device"], label["stage"]) for label in labels)
+
+    doc.add_heading("1.1 按类型统计", level=2)
+    rows = [
+        [name, str(kind_counts.get(name, 0)), f"{kind_counts.get(name, 0) / len(labels) * 100:.1f}%"]
+        for name in ("润滑油压力", "振动")
+    ]
+    add_table(doc, ["类型", "数量", "占比"], rows, [7.0, 5.0, 5.0], align_center=True)
+
+    doc.add_heading("1.2 按机组统计", level=2)
+    rows = []
+    for device in ("7号机", "8号机", "9号机"):
+        pressure = device_kind.get((device, "润滑油压力"), 0)
+        vibration = device_kind.get((device, "振动"), 0)
+        rows.append([device, str(pressure), str(vibration), str(pressure + vibration)])
+    rows.append([
+        "合计",
+        str(kind_counts.get("润滑油压力", 0)),
+        str(kind_counts.get("振动", 0)),
+        str(len(labels)),
+    ])
+    add_table(doc, ["机组", "润滑油压力", "振动", "合计"], rows, [6.0, 6.0, 6.0, 6.0], align_center=True)
+
+    doc.add_heading("1.3 按阶段统计", level=2)
+    rows = []
+    for stage in ("轻微", "异常", "严重异常"):
+        pressure = stage_kind.get((stage, "润滑油压力"), 0)
+        vibration = stage_kind.get((stage, "振动"), 0)
+        rows.append([stage, str(pressure), str(vibration), str(pressure + vibration)])
+    rows.append([
+        "合计",
+        str(kind_counts.get("润滑油压力", 0)),
+        str(kind_counts.get("振动", 0)),
+        str(len(labels)),
+    ])
+    add_table(doc, ["阶段", "润滑油压力", "振动", "合计"], rows, [6.0, 6.0, 6.0, 6.0], align_center=True)
+
+    doc.add_heading("1.4 按机组和阶段统计", level=2)
+    rows = []
+    for device in ("7号机", "8号机", "9号机"):
+        values = [device_stage.get((device, stage), 0) for stage in ("轻微", "异常", "严重异常")]
+        rows.append([device, *[str(value) for value in values], str(sum(values))])
+    total_stage = Counter(label["stage"] for label in labels)
+    rows.append([
+        "合计",
+        *[str(total_stage.get(stage, 0)) for stage in ("轻微", "异常", "严重异常")],
+        str(len(labels)),
+    ])
+    add_table(
+        doc,
+        ["机组", "轻微", "异常", "严重异常", "合计"],
+        rows,
+        [6.0, 4.5, 4.5, 5.0, 5.0],
+        align_center=True,
+    )
+
+    doc.add_heading("1.5 按周期编号统计", level=2)
+    grouped: dict[tuple, list[dict]] = defaultdict(list)
+    for label in labels:
+        grouped[(label["device"], label["kind"], cycle_name(label))].append(label)
+    rows = []
+    for (device, kind, cycle), items in sorted(
+        grouped.items(),
+        key=lambda item: (
+            DEVICE_ORDER.get(item[0][0], 99),
+            KIND_ORDER.get(item[0][1], 9),
+            item[0][2],
+        ),
+    ):
+        stages = Counter(item["stage"] for item in items)
+        starts = min(item["start"] for item in items)
+        ends = max(item["end"] for item in items)
+        rows.append([
+            device,
+            KIND_LABEL.get(kind, kind),
+            cycle,
+            str(stages.get("轻微", 0)),
+            str(stages.get("异常", 0)),
+            str(stages.get("严重异常", 0)),
+            str(len(items)),
+            starts[:16],
+            ends[:16],
+        ])
+    add_table(
+        doc,
+        ["机组", "类型", "周期编号", "轻微", "异常", "严重异常", "合计", "起始", "结束"],
+        rows,
+        [2.2, 2.4, 3.0, 1.4, 1.4, 1.8, 1.4, 4.8, 4.8],
+        font_size=8.0,
+        align_center=True,
+    )
+
+    doc.add_heading("1.6 总体演化过程", level=2)
+    pressure = kind_counts.get("润滑油压力", 0)
+    vibration = kind_counts.get("振动", 0)
+    slight = total_stage.get("轻微", 0)
+    abnormal = total_stage.get("异常", 0)
+    severe = total_stage.get("严重异常", 0)
+    min_start = min(label["start"] for label in labels)
+    max_end = max(label["end"] for label in labels)
+    body_paragraph(
+        doc,
+        f"标签时间覆盖 {min_start[:10]} 至 {max_end[:10]}。润滑油压力 {pressure} 条,"
+        f"占 {pressure / len(labels) * 100:.1f}%;振动 {vibration} 条,占 "
+        f"{vibration / len(labels) * 100:.1f}%。",
+    )
+    body_paragraph(
+        doc,
+        f"按阶段看,轻微 {slight} 条、异常 {abnormal} 条、严重异常 {severe} 条。"
+        "多数标签处于轻微阶段,异常和严重异常数量较少,符合演化过程由轻到重逐步发展的特点。",
+    )
+    body_paragraph(
+        doc,
+        "润滑油压力标签以轻微阶段为主,异常和严重异常主要出现在持续下降过程中;"
+        "振动标签在轻微、异常和严重异常三个阶段均有分布,说明振动异常既可能表现为短时冲击,"
+        "也可能持续发展。",
+    )
+    body_paragraph(
+        doc,
+        "阅读详情时,应结合周期基线、相对变化、连续有效小时数、报警小时数和阶段触发依据,"
+        "判断标签是单次波动还是持续演化,并结合现场维修记录进行确认。",
+    )
+    rows = [
+        ["轻微", "相对基线偏离达到轻微门槛,持续时间较短或尚未形成连续确认。"],
+        ["异常", "相对基线偏离扩大,连续有效小时数增加,形成异常阶段。"],
+        ["严重异常", "达到严重门槛或触发低低报、高高报,应优先复核。"],
+    ]
+    add_table(doc, ["阶段", "含义"], rows, [4.0, 21.5], font_size=9.5)
+
+
+def add_detail_chapters(
+    doc: Document,
+    labels: list[dict],
+    profile_dir: Path,
+    force: bool,
+    limit: int,
+    single_image: str,
+) -> None:
+    chapters = (
+        ("pressure", "二、润滑油压力故障标签详情", "润滑油压力"),
+        ("vibration", "三、振动故障标签详情", "振动"),
+    )
+    for chapter_index, (kind, title, kind_label) in enumerate(chapters):
+        items = sorted(
+            [label for label in labels if label.get("kind") == kind],
+            key=sort_key,
+        )
+        if limit:
+            items = items[:limit]
+        doc.add_page_break()
+        doc.add_heading(title, level=1)
+        stage_counts = Counter(item.get("stage") for item in items)
+        body_paragraph(
+            doc,
+            f"本章共 {len(items)} 条{kind_label}故障标签,其中轻微 {stage_counts.get('轻微', 0)} 条、"
+            f"异常 {stage_counts.get('异常', 0)} 条、严重异常 {stage_counts.get('严重异常', 0)} 条。"
+            "章内按机组和开始时间排列;每个二级标题对应一条标签,标题下方为属性表。"
+            "本版仅在一条标签下放置示例曲线,其余曲线待统一补充。",
+        )
+        for index, label in enumerate(items, start=1):
+            print(f"[{kind} {index}/{len(items)}] {label['title']}")
+            if index > 1:
+                doc.add_page_break()
+            heading = (
+                f"{label['device']}-{label['kindLabel']}-{cycle_name(label)}-{label['start'][:16]}"
+            )
+            doc.add_heading(heading, level=2)
+            add_attr_table(doc, label.get("fields") or [])
+            if single_image and label["key"] == single_image:
+                image_path = screenshot(label, profile_dir, force=force)
+                picture_paragraph = doc.add_paragraph()
+                picture_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+                picture_paragraph.paragraph_format.space_before = Pt(4)
+                picture_paragraph.paragraph_format.space_after = Pt(2)
+                picture_paragraph.paragraph_format.keep_with_next = True
+                run = picture_paragraph.add_run()
+                run.add_picture(str(image_path), width=Cm(24.0))
+                caption = doc.add_paragraph()
+                caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
+                caption.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
+                caption.paragraph_format.space_after = Pt(6)
+                run = caption.add_run("图:标签前后运行曲线(红色为故障段,竖向虚线为关机/开机边界)")
+                set_run_font(run, BODY_FONT, 9.0)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--limit", type=int, default=0, help="只生成前 N 条,用于冒烟测试")
+    parser.add_argument("--output", type=Path, default=DEFAULT_DOCX)
+    parser.add_argument("--force", action="store_true", help="强制重新截图")
+    parser.add_argument(
+        "--single-image",
+        default="pressure:44",
+        help="只插入该标签的曲线图;传空字符串表示全部插入",
+    )
+    args = parser.parse_args()
+
+    fetch_ready()
+    labels = load_labels()
+    print(f"标签总数:{len(labels)}")
+
+    args.output.parent.mkdir(parents=True, exist_ok=True)
+    profile_dir = Path(tempfile.mkdtemp(prefix="tag_manual_chrome_"))
+    try:
+        doc = Document()
+        configure_document(doc)
+        add_cover(doc, labels)
+        add_reading_guide(doc)
+        add_summary(doc, labels)
+        add_detail_chapters(
+            doc,
+            labels,
+            profile_dir,
+            args.force,
+            args.limit,
+            args.single_image,
+        )
+        doc.save(args.output)
+        print(f"已生成:{args.output}")
+        print(f"截图目录:{FIG_DIR}")
+    finally:
+        shutil.rmtree(profile_dir, ignore_errors=True)
+
+
+if __name__ == "__main__":
+    main()

+ 13 - 0
frontend/preview.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="theme-color" content="#f5f7fa" />
+    <title>故障标签波形预览</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/preview/main.ts"></script>
+  </body>
+</html>

+ 151 - 0
frontend/src/preview/PreviewApp.vue

@@ -0,0 +1,151 @@
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue'
+
+import TagChart from './TagChart.vue'
+
+interface LabelItem {
+  key: string
+  kind: string
+  kindLabel: string
+  device: string
+  cycle: string
+  stage: string
+  start: string
+  end: string
+  side: string
+  title: string
+}
+
+interface Summary {
+  total: number
+  byKind: Record<string, number>
+  byDevice: Record<string, number>
+  byStage: Record<string, number>
+}
+
+const params = new URLSearchParams(window.location.search)
+const chartOnly = params.get('chart') === '1'
+const labels = ref<LabelItem[]>([])
+const summary = ref<Summary | null>(null)
+const kind = ref(params.get('kind') ?? 'all')
+const device = ref(params.get('device') ?? 'all')
+const stage = ref(params.get('stage') ?? 'all')
+const selected = ref(params.get('key') ?? '')
+const loading = ref(true)
+const error = ref('')
+
+const devices = computed(() => Array.from(new Set(labels.value.map((item) => item.device))))
+const stages = computed(() => Array.from(new Set(labels.value.map((item) => item.stage))))
+const filtered = computed(() =>
+  labels.value.filter((item) => {
+    if (kind.value !== 'all' && item.kind !== kind.value) return false
+    if (device.value !== 'all' && item.device !== device.value) return false
+    if (stage.value !== 'all' && item.stage !== stage.value) return false
+    return true
+  }),
+)
+
+watch(
+  filtered,
+  (list) => {
+    if (chartOnly) return
+    if (!list.some((item) => item.key === selected.value)) {
+      selected.value = list[0]?.key ?? ''
+    }
+  },
+  { immediate: true },
+)
+
+async function loadLabels(): Promise<void> {
+  try {
+    const response = await fetch('/api/tag-manual/labels')
+    if (!response.ok) throw new Error(`HTTP ${response.status}`)
+    const data = await response.json()
+    labels.value = data.items ?? []
+    summary.value = data.summary ?? null
+    if (!selected.value && labels.value.length) {
+      selected.value = filtered.value[0]?.key ?? labels.value[0].key
+    }
+  } catch (err) {
+    error.value = `标签加载失败:${(err as Error).message}`
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(loadLabels)
+</script>
+
+<template>
+  <div v-if="chartOnly" class="tag-page chart-only">
+    <TagChart v-if="selected" :series-key="selected" />
+    <div v-else class="empty-state">未指定标签</div>
+  </div>
+
+  <div v-else class="tag-page">
+    <aside class="sidebar">
+      <header class="sidebar-head">
+        <h1>故障标签波形预览</h1>
+        <p>选择标签查看演化曲线</p>
+      </header>
+
+      <div v-if="summary" class="summary">
+        <span>共 {{ summary.total }} 条</span>
+        <span
+          v-for="(value, name) in summary.byKind"
+          :key="name"
+        >{{ name }} {{ value }}</span>
+      </div>
+
+      <div class="filters">
+        <label>
+          <span>类型</span>
+          <select v-model="kind">
+            <option value="all">全部</option>
+            <option value="pressure">润滑油压力</option>
+            <option value="vibration">振动</option>
+          </select>
+        </label>
+        <label>
+          <span>机组</span>
+          <select v-model="device">
+            <option value="all">全部</option>
+            <option v-for="item in devices" :key="item" :value="item">{{ item }}</option>
+          </select>
+        </label>
+        <label>
+          <span>阶段</span>
+          <select v-model="stage">
+            <option value="all">全部</option>
+            <option v-for="item in stages" :key="item" :value="item">{{ item }}</option>
+          </select>
+        </label>
+      </div>
+
+      <div class="label-list">
+        <button
+          v-for="item in filtered"
+          :key="item.key"
+          class="label-item"
+          :class="{ active: item.key === selected }"
+          type="button"
+          @click="selected = item.key"
+        >
+          <span class="label-title">{{ item.title }}</span>
+          <span class="label-meta">
+            <em :class="`stage-${item.stage}`">{{ item.stage }}</em>
+            <span v-if="item.side">{{ item.side }}</span>
+            <span>{{ item.end.slice(5, 16) }}</span>
+          </span>
+        </button>
+        <div v-if="!filtered.length && !loading" class="empty-list">无匹配标签</div>
+      </div>
+    </aside>
+
+    <main class="chart-pane">
+      <TagChart v-if="selected" :series-key="selected" />
+      <div v-else-if="loading" class="empty-state">加载中</div>
+      <div v-else class="empty-state">{{ error || '无匹配标签' }}</div>
+    </main>
+  </div>
+</template>

+ 379 - 0
frontend/src/preview/TagChart.vue

@@ -0,0 +1,379 @@
+<script setup lang="ts">
+import * as echarts from 'echarts'
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
+
+interface RefLine {
+  value: number
+  label?: string
+}
+
+interface SeriesDef {
+  name: string
+  color: string
+  data: [number, number | null][]
+}
+
+interface Subplot {
+  title: string
+  unit: string
+  band: string
+  refLines: RefLine[]
+  yMin: number
+  yMax: number
+  series: SeriesDef[]
+}
+
+interface ChartData {
+  key: string
+  title: string
+  subtitle: string
+  stage: string
+  kind: string
+  x: {
+    max: number
+    times: (string | null)[]
+    gaps: number[]
+    shutdowns: { shutdown: string; startup: string; startIndex: number; endIndex: number }[]
+  }
+  fault: {
+    start: number
+    end: number
+  }
+  yellowBands: { start: number; end: number }[]
+  subplots: Subplot[]
+  empty: boolean
+}
+
+const props = defineProps<{ seriesKey: string }>()
+
+const el = ref<HTMLElement | null>(null)
+const loading = ref(false)
+const error = ref('')
+
+let chart: echarts.ECharts | null = null
+let observer: ResizeObserver | null = null
+let current: ChartData | null = null
+
+function timeAt(index: number): string | null {
+  const times = current?.x.times ?? []
+  if (!times.length) return null
+  const safe = Math.max(0, Math.min(times.length - 1, Math.round(index)))
+  if (times[safe]) return times[safe]
+  for (let offset = 1; offset < 400; offset += 1) {
+    if (times[safe - offset]) return times[safe - offset]
+    if (times[safe + offset]) return times[safe + offset]
+  }
+  return null
+}
+
+function formatTick(value: number): string {
+  const time = timeAt(Number(value))
+  return time ? time.slice(5, 16) : ''
+}
+
+function formatNumber(value: number, unit: string): string {
+  if (unit === '%') return `${value.toFixed(1)}%`
+  if (unit === '×') return `${value.toFixed(2)}×`
+  return `${value.toFixed(2)} ${unit}`
+}
+
+function buildOption(data: ChartData): Record<string, unknown> {
+  const height = el.value?.clientHeight ?? 900
+  const count = data.subplots.length
+  const top = 92
+  const bottom = 80
+  const gap = 34
+  const rowHeight = count
+    ? Math.max(120, (height - top - bottom - gap * (count - 1)) / count)
+    : 200
+  const gridTop = (index: number): number => top + index * (rowHeight + gap)
+  const last = count - 1
+  const flatUnits: string[] = []
+
+  const titles: Record<string, unknown>[] = [
+    {
+      text: data.title,
+      left: 24,
+      top: 10,
+      textStyle: { fontSize: 15, fontWeight: 600, color: '#1f2329' },
+    },
+    {
+      text: data.subtitle,
+      left: 24,
+      top: 38,
+      textStyle: { fontSize: 13, color: '#5b6472' },
+    },
+  ]
+  data.subplots.forEach((subplot, index) => {
+    titles.push({
+      text: `${subplot.title}(${subplot.unit})`,
+      left: 78,
+      top: gridTop(index) - 24,
+      textStyle: { fontSize: 12, fontWeight: 600, color: '#303744' },
+    })
+  })
+  const stageStyle: Record<string, { color: string; background: string }> = {
+    '轻微': { color: '#b26a00', background: '#fff1e0' },
+    '异常': { color: '#c62828', background: '#ffe8e8' },
+    '严重异常': { color: '#8e0000', background: '#ffd6d6' },
+  }
+  const style = stageStyle[data.stage] ?? { color: '#5b6472', background: '#eef1f5' }
+  titles.push({
+    text: data.stage || '未分级',
+    right: 24,
+    top: 10,
+    textStyle: { fontSize: 12, fontWeight: 600, color: style.color },
+    backgroundColor: style.background,
+    padding: [4, 10],
+    borderRadius: 11,
+  })
+
+  const legends: Record<string, unknown>[] = data.subplots.map((subplot, index) => ({
+    data: subplot.series.map((item) => item.name),
+    top: gridTop(index) - 26,
+    right: 36,
+    itemWidth: 12,
+    itemHeight: 8,
+    selectedMode: false,
+    textStyle: { fontSize: 10, color: '#5b6472' },
+  }))
+
+  const grids: Record<string, unknown>[] = data.subplots.map((_, index) => ({
+    left: 78,
+    right: 36,
+    top: gridTop(index),
+    height: rowHeight,
+    containLabel: false,
+  }))
+
+  const xAxis: Record<string, unknown>[] = data.subplots.map((_, index) => ({
+    type: 'value',
+    gridIndex: index,
+    min: 0,
+    max: Math.max(1, data.x.max),
+    axisLine: { show: index === last, lineStyle: { color: '#d0d5dd' } },
+    axisTick: { show: index === last },
+    axisLabel: {
+      show: index === last,
+      fontSize: 10,
+      color: '#6b7280',
+      hideOverlap: true,
+      formatter: formatTick,
+    },
+    splitLine: { show: true, lineStyle: { color: 'rgba(0,0,0,0.035)' } },
+    axisPointer: { label: { show: false } },
+  }))
+
+  const yAxis: Record<string, unknown>[] = data.subplots.map((subplot, index) => ({
+    type: 'value',
+    gridIndex: index,
+    min: subplot.yMin,
+    max: subplot.yMax,
+    splitNumber: 4,
+    axisLine: { show: false },
+    axisTick: { show: false },
+    axisLabel: {
+      fontSize: 10,
+      color: '#6b7280',
+      formatter: (value: number) => Number(value).toFixed(2),
+    },
+    splitLine: { show: true, lineStyle: { color: '#eef1f5' } },
+  }))
+
+  const series: Record<string, unknown>[] = []
+  data.subplots.forEach((subplot, gridIndex) => {
+    subplot.series.forEach((item, seriesIndex) => {
+      flatUnits.push(subplot.unit)
+      const markLines: Record<string, unknown>[] = []
+      if (seriesIndex === 0) {
+        data.x.shutdowns.forEach((gap) => {
+          markLines.push({
+            xAxis: gap.startIndex,
+            lineStyle: { type: 'dashed', color: '#9aa4b2', width: 1 },
+            label: { show: false },
+          })
+          markLines.push({
+            xAxis: gap.endIndex,
+            lineStyle: { type: 'dashed', color: '#9aa4b2', width: 1 },
+            label: { show: false },
+          })
+        })
+        subplot.refLines.forEach((line) => {
+          markLines.push({
+            yAxis: line.value,
+            lineStyle: { type: 'dashed', color: '#aab2bd', width: 0.9 },
+            label: line.label
+              ? {
+                  show: true,
+                  formatter: line.label,
+                  position: 'insideEndTop',
+                  fontSize: 9,
+                  color: '#8a94a6',
+                }
+              : { show: false },
+          })
+        })
+      }
+      const areas: Record<string, unknown>[][] = []
+      if (subplot.band) {
+        areas.push([
+          { xAxis: data.fault.start, itemStyle: { color: subplot.band } },
+          { xAxis: data.fault.end },
+        ])
+      }
+      data.yellowBands.forEach((band) => {
+        areas.push([
+          { xAxis: band.start, itemStyle: { color: 'rgba(255,193,7,0.10)' } },
+          { xAxis: band.end },
+        ])
+      })
+      series.push({
+        name: item.name,
+        type: 'line',
+        xAxisIndex: gridIndex,
+        yAxisIndex: gridIndex,
+        data: item.data,
+        showSymbol: false,
+        symbol: 'none',
+        animation: false,
+        connectNulls: false,
+        lineStyle: { width: 1.2, color: item.color },
+        itemStyle: { color: item.color },
+        emphasis: { disabled: true },
+        markArea: seriesIndex === 0 && areas.length
+          ? {
+              silent: true,
+              itemStyle: { borderWidth: 0 },
+              data: areas,
+            }
+          : undefined,
+        markLine: seriesIndex === 0 && markLines.length
+          ? { silent: true, symbol: 'none', data: markLines }
+          : undefined,
+      })
+    })
+  })
+
+  return {
+    animation: false,
+    backgroundColor: '#ffffff',
+    textStyle: {
+      fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
+    },
+    title: titles,
+    legend: legends,
+    grid: grids,
+    xAxis,
+    yAxis,
+    series,
+    axisPointer: { link: [{ xAxisIndex: 'all' }] },
+    dataZoom: [
+      {
+        type: 'inside',
+        xAxisIndex: data.subplots.map((_, index) => index),
+        start: 0,
+        end: 100,
+      },
+      {
+        type: 'slider',
+        xAxisIndex: data.subplots.map((_, index) => index),
+        start: 0,
+        end: 100,
+        bottom: 6,
+        height: 14,
+        borderColor: '#e3e8ef',
+        fillerColor: 'rgba(31,111,235,0.12)',
+        handleStyle: { color: '#1f6feb' },
+        textStyle: { fontSize: 9, color: '#8a94a6' },
+        showDetail: false,
+      },
+    ],
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'line', lineStyle: { color: '#b8c0cc' } },
+      backgroundColor: 'rgba(255,255,255,0.97)',
+      borderColor: '#dde2e8',
+      textStyle: { fontSize: 11, color: '#303744' },
+      formatter: (params: unknown) => {
+        const items = Array.isArray(params) ? params : [params]
+        const first = items[0] as { value?: [number, number | null] } | undefined
+        const time = first?.value ? timeAt(first.value[0]) : null
+        if (!time) return ''
+        const lines = items.map((raw) => {
+          const item = raw as {
+            marker?: string
+            seriesName?: string
+            seriesIndex?: number
+            value?: [number, number | null]
+          }
+          const value = item.value?.[1]
+          const unit = flatUnits[item.seriesIndex ?? 0] ?? ''
+          const text = value === null || value === undefined
+            ? '-'
+            : formatNumber(Number(value), unit)
+          return `${item.marker ?? ''}${item.seriesName ?? ''}: ${text}`
+        })
+        return [time, ...lines].join('<br/>')
+      },
+    },
+    graphic: data.empty
+      ? [{
+          type: 'text',
+          left: 'center',
+          top: 'middle',
+          style: { text: '该时间段无运行数据', fontSize: 14, fill: '#8a94a6' },
+        }]
+      : undefined,
+  }
+}
+
+function render(): void {
+  if (!chart || !current) return
+  chart.setOption(buildOption(current), { notMerge: true })
+  ;(window as unknown as { __tagChartReady?: boolean }).__tagChartReady = true
+}
+
+async function load(): Promise<void> {
+  loading.value = true
+  error.value = ''
+  try {
+    const response = await fetch(
+      `/api/tag-manual/series?key=${encodeURIComponent(props.seriesKey)}`,
+    )
+    if (!response.ok) throw new Error(`HTTP ${response.status}`)
+    current = (await response.json()) as ChartData
+    document.title = current.title
+    render()
+  } catch (err) {
+    error.value = `曲线加载失败:${(err as Error).message}`
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(() => {
+  if (!el.value) return
+  chart = echarts.init(el.value)
+  observer = new ResizeObserver(() => {
+    chart?.resize()
+    render()
+  })
+  observer.observe(el.value)
+  load()
+})
+
+onBeforeUnmount(() => {
+  observer?.disconnect()
+  observer = null
+  chart?.dispose()
+  chart = null
+})
+
+watch(() => props.seriesKey, load)
+</script>
+
+<template>
+  <div ref="el" class="tag-chart" />
+  <div v-if="loading" class="chart-overlay">加载中</div>
+  <div v-else-if="error" class="chart-overlay">{{ error }}</div>
+</template>

+ 6 - 0
frontend/src/preview/main.ts

@@ -0,0 +1,6 @@
+import { createApp } from 'vue'
+
+import PreviewApp from './PreviewApp.vue'
+import './preview.css'
+
+createApp(PreviewApp).mount('#app')

+ 207 - 0
frontend/src/preview/preview.css

@@ -0,0 +1,207 @@
+:root {
+  font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
+  color: #1f2329;
+  background: #f5f7fa;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+  margin: 0;
+  min-height: 100%;
+  height: 100%;
+}
+
+.tag-page {
+  display: flex;
+  height: 100vh;
+  overflow: hidden;
+  background: #f5f7fa;
+}
+
+.tag-page.chart-only {
+  display: block;
+  background: #ffffff;
+}
+
+.tag-page.chart-only .tag-chart {
+  width: 100vw;
+  height: 100vh;
+}
+
+.sidebar {
+  width: 320px;
+  min-width: 320px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  border-right: 1px solid #e3e8ef;
+  background: #ffffff;
+}
+
+.sidebar-head {
+  padding: 18px 18px 10px;
+}
+
+.sidebar-head h1 {
+  margin: 0;
+  font-size: 17px;
+  font-weight: 600;
+}
+
+.sidebar-head p {
+  margin: 6px 0 0;
+  font-size: 12px;
+  color: #7a8494;
+}
+
+.summary {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  padding: 0 18px 12px;
+}
+
+.summary span {
+  padding: 3px 8px;
+  border-radius: 999px;
+  background: #eef3fb;
+  color: #35507a;
+  font-size: 11px;
+}
+
+.filters {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+  padding: 0 18px 12px;
+  border-bottom: 1px solid #eef1f5;
+}
+
+.filters label {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  font-size: 11px;
+  color: #7a8494;
+}
+
+.filters label:first-child {
+  grid-column: 1 / -1;
+}
+
+.filters select {
+  height: 30px;
+  padding: 0 8px;
+  border: 1px solid #dde3ea;
+  border-radius: 6px;
+  background: #ffffff;
+  color: #303744;
+  font-size: 12px;
+  outline: none;
+}
+
+.filters select:focus {
+  border-color: #6b9cf5;
+}
+
+.label-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px;
+}
+
+.label-item {
+  display: flex;
+  flex-direction: column;
+  gap: 5px;
+  width: 100%;
+  padding: 9px 10px;
+  border: 1px solid transparent;
+  border-radius: 8px;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+}
+
+.label-item:hover {
+  background: #f3f6fb;
+}
+
+.label-item.active {
+  border-color: #bcd2fb;
+  background: #eef4ff;
+}
+
+.label-title {
+  font-size: 12px;
+  color: #303744;
+  line-height: 1.35;
+}
+
+.label-meta {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 10px;
+  color: #8a94a6;
+}
+
+.label-meta em {
+  font-style: normal;
+  padding: 1px 6px;
+  border-radius: 999px;
+  background: #eef1f5;
+  color: #5b6472;
+}
+
+.label-meta em.stage-异常 {
+  background: #fff1e6;
+  color: #c26a00;
+}
+
+.label-meta em.stage-严重异常 {
+  background: #ffe9e9;
+  color: #c62828;
+}
+
+.chart-pane {
+  position: relative;
+  flex: 1;
+  min-width: 0;
+  height: 100%;
+  background: #ffffff;
+}
+
+.tag-chart {
+  width: 100%;
+  height: 100%;
+}
+
+.chart-overlay {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  padding: 8px 14px;
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.94);
+  color: #6b7280;
+  font-size: 12px;
+  box-shadow: 0 4px 16px rgba(20, 34, 56, 0.08);
+}
+
+.empty-state,
+.empty-list {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  min-height: 120px;
+  color: #8a94a6;
+  font-size: 12px;
+}

+ 10 - 0
frontend/vite.config.ts

@@ -1,9 +1,19 @@
+import { resolve } from 'node:path'
+
 import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
 
 export default defineConfig(({ mode }) => ({
   base: mode === 'production' ? '/query/' : '/',
   plugins: [vue()],
+  build: {
+    rollupOptions: {
+      input: {
+        main: resolve(__dirname, 'index.html'),
+        preview: resolve(__dirname, 'preview.html'),
+      },
+    },
+  },
   server: {
     port: 5173,
     allowedHosts: ['2frps.roadbyway.com'],

BIN
副本7,8,9号压缩机故障汇总.xlsx


BIN
总结文档/组态数据高质量演化过程故障标签手册.docx