zhouhao 2 недель назад
Сommit
f3e13a6032

+ 13 - 0
.gitignore

@@ -0,0 +1,13 @@
+__pycache__/
+*.py[cod]
+.DS_Store
+__MACOSX/
+.venv/
+frontend/node_modules/
+frontend/dist/
+.env
+.env.*
+!.env.example
+*.bak
+数据库.md
+web 账号.txt

+ 83 - 0
README.md

@@ -0,0 +1,83 @@
+# 压缩机故障预测波形工作台
+
+当前版本实现整体浏览效果,包含标注保存和登录鉴权:
+
+- 查询 `point_name`、`measurement_type`、`minTime`、`maxTime`
+- 三行时间点选择器,三种测量类型共享按 `sample_time` 排序的时间点索引
+- 时间窗口按时间点数量选择,支持 `+/-1`
+- 压力、位移、加速度分图和归一化合并显示
+- 后端抽样,保留周期边界和键相点
+- `second_value >= 30`、每转 8 个脉冲、长脉冲为 0 度的周期识别
+- 双击曲线查看压力-角度图和压力-体积功图,双击空白处标注 / 取消标注
+- 登录鉴权(内置账号),所有数据接口需登录后访问
+
+## 启动后端
+
+在项目根目录执行:
+
+```bash
+python3 -m pip install -r backend/requirements.txt
+uvicorn app.main:app --app-dir backend --reload --port 8000
+```
+
+后端默认读取项目根目录的 `数据库.md`。也可以使用环境变量覆盖:
+
+```bash
+DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=root DB_PASSWORD='...' \
+DB_NAME=compressorsensor uvicorn app.main:app --app-dir backend --reload --port 8000
+```
+
+没有可用 MySQL 时,默认 `DEMO_MODE=auto` 会自动显示演示数据;强制演示数据可以使用:
+
+```bash
+DEMO_MODE=always uvicorn app.main:app --app-dir backend --reload --port 8000
+```
+
+## 启动前端
+
+另开一个终端,在 `frontend` 目录执行:
+
+```bash
+npm install
+npm run dev
+```
+
+浏览器访问 `http://localhost:5173`。
+
+生产构建:
+
+```bash
+npm run build
+```
+
+## 主要接口
+
+- `POST /api/login` —— 登录,返回 token(账号/密码见下)
+- `POST /api/logout` —— 登出
+- `GET /api/health`
+- `GET /api/query-options`
+- `GET /api/time-points`
+- `POST /api/wave-window`
+- `GET /api/wave-files/{wave_file_id}/periods/{period_number}`
+- `GET /api/annotation-config` —— 返回标注宽度 `annotationWidth`(后端 `ANNOTATION_WIDTH` 配置,默认 10)
+- `GET /api/annotations?wave_file_ids=1,2,3` —— 查询指定波形文件的标注索引
+- `POST /api/annotations` —— 创建标注(`wave_file_id`、`label`、`period_start/end`、`sample_index_start/end`)
+- `DELETE /api/annotations/{id}` —— 删除标注
+
+除 `POST /api/login` 外,所有接口都需要在请求头携带 `Authorization: Bearer <token>`。
+
+## 登录账号
+
+内置账号(可用环境变量 `AUTH_USER` / `AUTH_PASSWORD` 覆盖):
+
+- 账号:`aaabbb`
+- 密码:`Aa*147258&cd`
+
+token 有效期默认 12 小时(`AUTH_TOKEN_TTL_SECONDS` 可配),服务重启后需重新登录。
+
+## 标注说明
+
+- 标注只在波形图上进行:双击曲线查看功图,双击空白处标注 / 取消标注。
+- 标注宽度由后端配置 `ANNOTATION_WIDTH`(默认 10,即 10 个周期为一段)。
+- 标注块不重叠:计算宽度块时会在点击周期所在的空闲区间内居中取宽度。
+- 标注数据只存索引,不存波形数据,表结构见 `wave_annotation`(`backend/app/db.py` 内建表语句,服务启动时自动建表)。

+ 1 - 0
backend/app/__init__.py

@@ -0,0 +1 @@
+"""HTTP API for the compressor waveform review tool."""

+ 1 - 0
backend/app/algorithms/__init__.py

@@ -0,0 +1 @@
+"""Waveform analysis algorithms."""

+ 193 - 0
backend/app/algorithms/cycles.py

@@ -0,0 +1,193 @@
+from dataclasses import dataclass
+
+import numpy as np
+
+
+TRIGGER_THRESHOLD = 30.0
+PULSES_PER_REVOLUTION = 8
+
+
+@dataclass(frozen=True)
+class DetectedCycle:
+    number: int
+    start_offset: int
+    end_offset: int
+    angle: np.ndarray
+    signal: np.ndarray
+    trigger_offsets: tuple[int, ...]
+
+
+def _trigger_runs(trigger: np.ndarray) -> list[tuple[int, int, int]]:
+    """Return start offset, end offset and length for each high trigger run."""
+    values = np.asarray(trigger, dtype=float)
+    high = np.nan_to_num(values, nan=-np.inf) >= TRIGGER_THRESHOLD
+    changes = np.diff(np.r_[False, high, False].astype(np.int8))
+    starts = np.flatnonzero(changes == 1)
+    ends = np.flatnonzero(changes == -1) - 1
+    return [
+        (int(start), int(end), int(end - start + 1))
+        for start, end in zip(starts, ends)
+    ]
+
+
+def detect_cycles(samples: np.ndarray) -> tuple[list[DetectedCycle], dict[str, float | int]]:
+    """Detect complete crankshaft cycles from a wave_sample array.
+
+    The trigger contract intentionally mirrors showPV: second_value >= 30 is
+    high, the longer high run marks 0 degrees, and eight pulses make one turn.
+    Offsets are array offsets rather than sample_index values so the function
+    also works when a file has a non-zero or sparse sample_index column.
+    """
+    values = np.asarray(samples, dtype=float)
+    if values.ndim != 2 or values.shape[1] < 3 or not len(values):
+        return [], {
+            "triggerRunCount": 0,
+            "zeroMarkerCount": 0,
+            "completeCycleCount": 0,
+        }
+
+    runs = _trigger_runs(values[:, 2])
+    diagnostics: dict[str, float | int] = {
+        "triggerRunCount": len(runs),
+        "zeroMarkerCount": 0,
+        "completeCycleCount": 0,
+    }
+    if len(runs) < PULSES_PER_REVOLUTION + 1:
+        return [], diagnostics
+
+    lengths = np.asarray([run[2] for run in runs], dtype=float)
+    ordinary_length = float(np.median(lengths))
+    long_limit = ordinary_length + max(5.0, ordinary_length * 0.45)
+    zero_runs = [run for run in runs if run[2] >= long_limit]
+    diagnostics.update(
+        ordinaryTriggerLength=ordinary_length,
+        zeroMarkerThreshold=long_limit,
+        zeroMarkerCount=len(zero_runs),
+    )
+    if len(zero_runs) < 2:
+        return [], diagnostics
+
+    cycles: list[DetectedCycle] = []
+    signal = values[:, 1]
+    for cycle_number, (zero_run, next_zero_run) in enumerate(
+        zip(zero_runs, zero_runs[1:]),
+        start=1,
+    ):
+        start = zero_run[0]
+        end = next_zero_run[0]
+        cycle_runs = [run for run in runs if start <= run[0] <= end]
+        if len(cycle_runs) != PULSES_PER_REVOLUTION + 1 or end <= start:
+            continue
+
+        angle = np.full(end - start, np.nan, dtype=float)
+        for pulse_index in range(PULSES_PER_REVOLUTION):
+            segment_start = cycle_runs[pulse_index][0]
+            segment_end = cycle_runs[pulse_index + 1][0]
+            local_start = segment_start - start
+            local_end = segment_end - start
+            if local_end <= local_start:
+                continue
+            angle[local_start:local_end] = np.linspace(
+                pulse_index * 45.0,
+                (pulse_index + 1) * 45.0,
+                local_end - local_start,
+                endpoint=False,
+            )
+
+        cycles.append(
+            DetectedCycle(
+                number=cycle_number,
+                start_offset=start,
+                end_offset=end,
+                angle=angle,
+                signal=signal[start:end].copy(),
+                trigger_offsets=tuple(run[0] for run in cycle_runs[:-1]),
+            )
+        )
+
+    diagnostics["completeCycleCount"] = len(cycles)
+    return cycles, diagnostics
+
+
+def build_angle_vector(sample_count: int, cycles: list[DetectedCycle]) -> np.ndarray:
+    """Build a sparse full-file crank-angle vector (0-180 degrees).
+
+    Mirrors showPV's display angle: the 0-360 keyphasor angle is folded so that
+    the piston stroke position reads 0 -> 180 -> 0 across a full revolution.
+    """
+    angle = np.full(sample_count, np.nan, dtype=float)
+    for cycle in cycles:
+        full = cycle.angle
+        angle[cycle.start_offset : cycle.end_offset] = np.where(
+            full <= 180.0,
+            full,
+            360.0 - full,
+        )
+    return angle
+
+
+def downsample_indices(
+    values: np.ndarray,
+    target_count: int,
+    required_indices: set[int] | None = None,
+) -> np.ndarray:
+    """Min/max downsample while retaining requested period boundaries.
+
+    Each bucket contributes its first/last/min/max points. Required offsets are
+    added afterwards, so trigger boundaries survive even when the overview has
+    far fewer pixels than the raw waveform.
+    """
+    values = np.asarray(values, dtype=float)
+    count = len(values)
+    if count == 0:
+        return np.empty(0, dtype=np.int64)
+
+    target_count = max(int(target_count), 16)
+    required = {
+        int(index)
+        for index in (required_indices or set())
+        if 0 <= int(index) < count
+    }
+    if count <= target_count:
+        return np.arange(count, dtype=np.int64)
+
+    bucket_count = max(1, target_count // 4)
+    edges = np.linspace(0, count, bucket_count + 1, dtype=np.int64)
+    selected = set(required)
+    for bucket_index in range(bucket_count):
+        start = int(edges[bucket_index])
+        end = int(edges[bucket_index + 1])
+        if end <= start:
+            continue
+        bucket = values[start:end]
+        finite = np.isfinite(bucket)
+        candidates = {start, end - 1}
+        if finite.any():
+            finite_values = bucket.copy()
+            finite_values[~finite] = np.nan
+            candidates.add(start + int(np.nanargmin(finite_values)))
+            candidates.add(start + int(np.nanargmax(finite_values)))
+        selected.update(candidates)
+
+    if len(selected) > target_count:
+        required_sorted = sorted(required)
+        remaining = sorted(selected.difference(required))
+        slots = max(0, target_count - len(required_sorted))
+        if slots:
+            positions = np.linspace(0, len(remaining) - 1, slots, dtype=np.int64)
+            selected = set(required_sorted).union(remaining[int(position)] for position in positions)
+        else:
+            # Keep every period boundary even if it exceeds the requested budget.
+            selected = set(required_sorted)
+    elif len(selected) < target_count:
+        missing = np.setdiff1d(
+            np.arange(count, dtype=np.int64),
+            np.asarray(sorted(selected), dtype=np.int64),
+            assume_unique=True,
+        )
+        slots = min(target_count - len(selected), len(missing))
+        if slots:
+            positions = np.linspace(0, len(missing) - 1, slots, dtype=np.int64)
+            selected.update(int(missing[int(position)]) for position in positions)
+
+    return np.asarray(sorted(selected), dtype=np.int64)

+ 35 - 0
backend/app/auth.py

@@ -0,0 +1,35 @@
+import secrets
+import time
+from typing import Optional
+
+from .config import settings
+
+# 内存 token 存储:token -> 过期时间(monotonic 秒)。服务重启后失效,需重新登录。
+_tokens: dict[str, float] = {}
+
+
+def create_token() -> str:
+    token = secrets.token_hex(32)
+    _tokens[token] = time.monotonic() + settings.auth_token_ttl_seconds
+    return token
+
+
+def validate_token(token: Optional[str]) -> bool:
+    if not token:
+        return False
+    expires = _tokens.get(token)
+    if expires is None:
+        return False
+    if time.monotonic() > expires:
+        _tokens.pop(token, None)
+        return False
+    return True
+
+
+def revoke_token(token: Optional[str]) -> None:
+    if token:
+        _tokens.pop(token, None)
+
+
+def verify_credentials(username: str, password: str) -> bool:
+    return username == settings.auth_user and password == settings.auth_password

+ 45 - 0
backend/app/config.py

@@ -0,0 +1,45 @@
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+
+def _database_file_values() -> dict[str, str]:
+    """Read the project's existing database note without copying credentials."""
+    path = Path(__file__).resolve().parents[2] / "数据库.md"
+    if not path.exists():
+        return {}
+    values: dict[str, str] = {}
+    for line in path.read_text(encoding="utf-8").splitlines():
+        if ":" in line:
+            key, value = line.split(":", 1)
+        elif "=" in line:
+            key, value = line.split("=", 1)
+        else:
+            continue
+        values[key.strip().lower()] = value.strip()
+    return values
+
+
+_database_values = _database_file_values()
+
+
+@dataclass(frozen=True)
+class Settings:
+    # Keep credentials outside the source tree. DEMO_MODE defaults to "never":
+    # a database failure raises an error instead of silently serving demo data.
+    # Use DEMO_MODE=always for demo data, or DEMO_MODE=auto for fallback.
+    db_host: str = os.getenv("DB_HOST", _database_values.get("host", "127.0.0.1"))
+    db_port: int = int(os.getenv("DB_PORT", _database_values.get("port", "3306")))
+    db_user: str = os.getenv("DB_USER", _database_values.get("user", "root"))
+    db_password: str = os.getenv("DB_PASSWORD", _database_values.get("password", ""))
+    db_name: str = os.getenv("DB_NAME", _database_values.get("db_name", "compressorsensor"))
+    db_connect_timeout: int = int(os.getenv("DB_CONNECT_TIMEOUT", "3"))
+    demo_mode: str = os.getenv("DEMO_MODE", "never").strip().lower()
+    cors_origin: str = os.getenv("CORS_ORIGIN", "http://localhost:5173")
+    annotation_width: int = int(os.getenv("ANNOTATION_WIDTH", "10"))
+    auth_user: str = os.getenv("AUTH_USER", "aaabbb")
+    auth_password: str = os.getenv("AUTH_PASSWORD", "Aa*147258&cd")
+    auth_token_ttl_seconds: int = int(os.getenv("AUTH_TOKEN_TTL_SECONDS", "43200"))
+
+
+settings = Settings()

+ 42 - 0
backend/app/db.py

@@ -0,0 +1,42 @@
+import pymysql
+
+from .config import settings
+
+
+def get_connection():
+    return pymysql.connect(
+        host=settings.db_host,
+        port=settings.db_port,
+        user=settings.db_user,
+        password=settings.db_password,
+        database=settings.db_name,
+        charset="utf8mb4",
+        cursorclass=pymysql.cursors.DictCursor,
+        connect_timeout=settings.db_connect_timeout,
+        read_timeout=30,
+        write_timeout=30,
+        autocommit=True,
+    )
+
+
+_ANNOTATION_TABLE_DDL = """
+CREATE TABLE IF NOT EXISTS wave_annotation (
+    id                 BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '标注ID',
+    wave_file_id       BIGINT UNSIGNED NOT NULL COMMENT '所属波形文件ID(wave_file.id)',
+    label              VARCHAR(8)      NOT NULL COMMENT '样本类型:正常 / 异常',
+    period_start       INT             NOT NULL COMMENT '起始周期编号',
+    period_end         INT             NOT NULL COMMENT '结束周期编号(含)',
+    sample_index_start INT             NOT NULL COMMENT '起始采样点索引',
+    sample_index_end   INT             NOT NULL COMMENT '结束采样点索引(含)',
+    created_at         DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at         DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (id),
+    KEY idx_wave_file (wave_file_id)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '波形标注索引表';
+"""
+
+
+def ensure_annotation_table() -> None:
+    with get_connection() as connection:
+        with connection.cursor() as cursor:
+            cursor.execute(_ANNOTATION_TABLE_DDL)

+ 195 - 0
backend/app/main.py

@@ -0,0 +1,195 @@
+from typing import Any
+
+from fastapi import Depends, FastAPI, Header, HTTPException, Query
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel, Field
+
+from .auth import create_token, revoke_token, validate_token, verify_credentials
+from .config import settings
+from .db import ensure_annotation_table
+from .services.data_service import data_service
+
+
+class TimePointInput(BaseModel):
+    index: int = Field(ge=0)
+    sampleTime: str
+    files: dict[str, Any]
+
+
+class WaveWindowInput(BaseModel):
+    pointName: str
+    measurementTypes: list[str]
+    points: list[TimePointInput]
+    maxPoints: int = Field(default=200000, ge=256, le=200000)
+    noSampling: bool = False
+
+
+class AnnotationInput(BaseModel):
+    wave_file_id: int = Field(gt=0)
+    label: str = Field(min_length=1, max_length=8)
+    period_start: int = Field(gt=0)
+    period_end: int = Field(gt=0)
+    sample_index_start: int = Field(ge=0)
+    sample_index_end: int = Field(ge=0)
+
+
+class AnnotationQueryInput(BaseModel):
+    wave_file_ids: list[int] = Field(default=[])
+
+
+class LoginInput(BaseModel):
+    username: str = Field(min_length=1)
+    password: str = Field(min_length=1)
+
+
+app = FastAPI(
+    title="压缩机故障预测波形服务",
+    version="0.1.0",
+)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=[settings.cors_origin, "http://127.0.0.1:5173"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+def require_auth(authorization: str | None = Header(default=None)) -> str:
+    token = (authorization or "").removeprefix("Bearer ").strip()
+    if not validate_token(token):
+        raise HTTPException(status_code=401, detail="未登录或登录已过期")
+    return token
+
+
+@app.on_event("startup")
+def startup() -> None:
+    try:
+        ensure_annotation_table()
+    except Exception:
+        # 数据库不可达时跳过建表,演示模式仍可正常启动。
+        pass
+
+
+@app.post("/api/login")
+def login(payload: LoginInput) -> dict[str, Any]:
+    if not verify_credentials(payload.username, payload.password):
+        raise HTTPException(status_code=401, detail="用户名或密码错误")
+    return {"token": create_token(), "username": payload.username}
+
+
+@app.post("/api/logout")
+def logout(authorization: str | None = Header(default=None)) -> dict[str, Any]:
+    token = (authorization or "").removeprefix("Bearer ").strip()
+    revoke_token(token)
+    return {"ok": True}
+
+
+@app.get("/api/health")
+def health(_auth: str = Depends(require_auth)) -> dict[str, Any]:
+    return data_service.health()
+
+
+@app.get("/api/query-options")
+def query_options(_auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return data_service.query_options()
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.get("/api/time-points")
+def time_points(
+    point_name: str = Query(min_length=1),
+    measurement_types: list[str] = Query(default=[]),
+    min_time: str | None = None,
+    max_time: str | None = None,
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        return data_service.time_points(point_name, measurement_types, min_time, max_time)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.post("/api/wave-window")
+def wave_window(payload: WaveWindowInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return data_service.wave_window(
+            payload.pointName,
+            payload.measurementTypes,
+            [point.model_dump() if hasattr(point, "model_dump") else point.dict() for point in payload.points],
+            payload.maxPoints,
+            payload.noSampling,
+        )
+    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/wave-files/{wave_file_id}/periods/{period_number}")
+def period_detail(
+    wave_file_id: int,
+    period_number: int,
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        return data_service.period_detail(wave_file_id, period_number)
+    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/annotation-config")
+def annotation_config(_auth: str = Depends(require_auth)) -> dict[str, Any]:
+    return data_service.annotation_config()
+
+
+@app.get("/api/annotations")
+def list_annotations(
+    wave_file_ids: list[int] = Query(default=[]),
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        return data_service.list_annotations(wave_file_ids)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.post("/api/annotations/query")
+def query_annotations(
+    payload: AnnotationQueryInput,
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        return data_service.list_annotations(payload.wave_file_ids)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.post("/api/annotations")
+def create_annotation(payload: AnnotationInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return data_service.create_annotation(payload.model_dump())
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.delete("/api/annotations/{annotation_id}")
+def delete_annotation(annotation_id: int, _auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return data_service.delete_annotation(annotation_id)
+    except ValueError as error:
+        raise HTTPException(status_code=404, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error

+ 1 - 0
backend/app/services/__init__.py

@@ -0,0 +1 @@
+"""Application services."""

Разница между файлами не показана из-за своего большого размера
+ 1038 - 0
backend/app/services/data_service.py


+ 4 - 0
backend/requirements.txt

@@ -0,0 +1,4 @@
+fastapi>=0.115,<1
+numpy>=2.0,<3
+PyMySQL>=1.1,<2
+uvicorn[standard]>=0.30,<1

+ 13 - 0
frontend/index.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="#0f1d2b" />
+    <title>压缩机故障预测 | 波形标注工作台</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

Разница между файлами не показана из-за своего большого размера
+ 1735 - 0
frontend/package-lock.json


+ 22 - 0
frontend/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "compressor-wave-review",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc --noEmit && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "element-plus": "^2.9.8",
+    "echarts": "^5.6.0",
+    "vue": "^3.5.13"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.2.1",
+    "typescript": "^5.7.3",
+    "vite": "^6.1.0",
+    "vue-tsc": "^2.2.0"
+  }
+}

+ 660 - 0
frontend/src/App.vue

@@ -0,0 +1,660 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+import { ElMessageBox } from 'element-plus'
+import { createAnnotation, deleteAnnotation, fetchAnnotationConfig, fetchAnnotationsByIds, fetchPeriodDetail, fetchQueryOptions, fetchTimePoints, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout } from './api'
+import PeriodModal from './components/PeriodModal.vue'
+import TimePointStrip from './components/TimePointStrip.vue'
+import WaveChart from './components/WaveChart.vue'
+import { MEASUREMENT_TYPES, type Annotation, type AnnotationLabel, type Cycle, type MeasurementType, type PeriodDetail, type QueryOption, type TimePoint, type QueryOptionsResponse, type WaveWindowResponse } from './types'
+
+const queryMeta = ref<QueryOptionsResponse | null>(null)
+const selectedPointName = ref('')
+const selectedTypes = ref<MeasurementType[]>(['压力'])
+const minTime = ref('')
+const maxTime = ref('')
+const windowSize = ref(4)
+const maxPoints = ref(200000)
+const noSampling = ref(false)
+const timePoints = ref<TimePoint[]>([])
+const startIndex = ref(0)
+const waveData = ref<WaveWindowResponse | null>(null)
+const chartMode = ref<'split' | 'merge'>('split')
+const queryLoading = ref(false)
+const timePointsLoading = ref(false)
+const waveLoading = ref(false)
+const errorMessage = ref('')
+const initialized = ref(false)
+const periodDetail = ref<PeriodDetail | null>(null)
+const periodVisible = ref(false)
+const periodLoading = ref(false)
+const annotationWidth = ref(10)
+const annotations = ref<Annotation[]>([])
+const annotationLoading = ref(false)
+const loggedIn = ref(false)
+const loginLoading = ref(false)
+const loginError = ref('')
+const loginForm = reactive({ username: '', password: '' })
+let waveDebounce: ReturnType<typeof setTimeout> | undefined
+let queryDebounce: ReturnType<typeof setTimeout> | undefined
+let waveRequestId = 0
+
+const pointNames = computed(() => queryMeta.value?.pointNames ?? [])
+const selectedOptionRows = computed<QueryOption[]>(() => (
+  queryMeta.value?.options.filter((row) => (
+    row.pointName === selectedPointName.value && selectedTypes.value.includes(row.measurementType)
+  )) ?? []
+))
+const selectedWindowPoints = computed(() => timePoints.value.slice(startIndex.value, startIndex.value + windowSize.value))
+const endIndex = computed(() => Math.min(timePoints.value.length, startIndex.value + windowSize.value))
+const currentCycles = computed(() => waveData.value?.cycles ?? [])
+const currentSource = computed(() => waveData.value?.source ?? timePointsSource.value)
+const timePointsSource = ref<'database' | 'demo'>('demo')
+const availableTypeCount = computed(() => selectedOptionRows.value.reduce((total, row) => total + row.fileCount, 0))
+const annotatedFileIds = computed(() => [...new Set(annotations.value.map((item) => item.waveFileId))])
+
+function toInputTime(value: string) {
+  if (!value) return ''
+  return value.replace('T', ' ').slice(0, 19)
+}
+
+function toApiTime(value: string) {
+  return value ? value.replace('T', ' ') + (value.length === 16 ? ':00' : '') : undefined
+}
+
+function boundsForSelection() {
+  const rows = selectedOptionRows.value
+  if (!rows.length) return null
+  const min = rows.reduce((latest, row) => (row.minTime > latest ? row.minTime : latest), rows[0].minTime)
+  const max = rows.reduce((earliest, row) => (row.maxTime < earliest ? row.maxTime : earliest), rows[0].maxTime)
+  return { min: toInputTime(min), max: toInputTime(max) }
+}
+
+function syncTimeBounds(force = false) {
+  const bounds = boundsForSelection()
+  if (!bounds) return
+  if (force || !minTime.value || minTime.value < bounds.min || minTime.value > bounds.max) minTime.value = bounds.min
+  if (force || !maxTime.value || maxTime.value > bounds.max || maxTime.value < bounds.min) maxTime.value = bounds.max
+  if (minTime.value > maxTime.value) {
+    minTime.value = bounds.min
+    maxTime.value = bounds.max
+  }
+}
+
+function formatDateTime(value: string | undefined) {
+  if (!value) return '未选择'
+  return value.replace('T', ' ').slice(0, 16)
+}
+
+function statusText() {
+  if (timePointsLoading.value) return '正在读取时间点'
+  if (waveLoading.value) return '正在整理波形'
+  if (!timePoints.value.length) return '等待查询'
+  return '窗口已就绪'
+}
+
+async function loadOptions() {
+  queryLoading.value = true
+  errorMessage.value = ''
+  try {
+    const result = await fetchQueryOptions()
+    queryMeta.value = result
+    selectedPointName.value = result.pointNames[0] ?? ''
+    syncTimeBounds(true)
+    initialized.value = true
+    await loadTimePoints()
+  } catch (error) {
+    errorMessage.value = error instanceof Error ? error.message : '查询条件读取失败'
+  } finally {
+    queryLoading.value = false
+  }
+}
+
+function firstRunningIndex(): number {
+  const index = timePoints.value.findIndex((point) =>
+    selectedTypes.value.some((type) => (point.files[type]?.rpm ?? 0) > 0),
+  )
+  return index >= 0 ? index : 0
+}
+
+async function loadTimePoints() {
+  if (!selectedPointName.value || !selectedTypes.value.length) return
+  timePointsLoading.value = true
+  waveData.value = null
+  errorMessage.value = ''
+  try {
+    const result = await fetchTimePoints({
+      pointName: selectedPointName.value,
+      measurementTypes: selectedTypes.value,
+      minTime: toApiTime(minTime.value),
+      maxTime: toApiTime(maxTime.value),
+    })
+    timePoints.value = result.points
+    timePointsSource.value = result.source
+    startIndex.value = Math.max(
+      0,
+      Math.min(firstRunningIndex(), Math.max(0, timePoints.value.length - windowSize.value)),
+    )
+    void loadAnnotations()
+    await loadWaveWindowNow()
+  } catch (error) {
+    timePoints.value = []
+    errorMessage.value = error instanceof Error ? error.message : '时间点读取失败'
+  } finally {
+    timePointsLoading.value = false
+  }
+}
+
+async function loadWaveWindowNow() {
+  const points = selectedWindowPoints.value
+  if (!points.length || !selectedPointName.value) {
+    waveData.value = null
+    return
+  }
+  const requestId = ++waveRequestId
+  waveLoading.value = true
+  try {
+    const result = await fetchWaveWindow({
+      pointName: selectedPointName.value,
+      measurementTypes: selectedTypes.value,
+      points,
+      maxPoints: maxPoints.value,
+      noSampling: noSampling.value,
+    })
+    if (requestId === waveRequestId) {
+      waveData.value = result
+    }
+  } catch (error) {
+    if (requestId === waveRequestId) errorMessage.value = error instanceof Error ? error.message : '波形读取失败'
+  } finally {
+    if (requestId === waveRequestId) waveLoading.value = false
+  }
+}
+
+function scheduleWaveWindow() {
+  if (waveDebounce) clearTimeout(waveDebounce)
+  waveDebounce = setTimeout(() => void loadWaveWindowNow(), 140)
+}
+
+function scheduleTimePoints() {
+  if (!initialized.value) return
+  if (queryDebounce) clearTimeout(queryDebounce)
+  queryDebounce = setTimeout(() => void loadTimePoints(), 180)
+}
+
+function onPointChange() {
+  syncTimeBounds(true)
+  scheduleTimePoints()
+}
+
+function onTypesChange() {
+  if (!selectedTypes.value.length) {
+    selectedTypes.value = ['压力']
+    return
+  }
+  syncTimeBounds(true)
+  scheduleTimePoints()
+}
+
+function onTimeChange() {
+  if (minTime.value > maxTime.value) {
+    errorMessage.value = '开始时间不能晚于结束时间'
+    return
+  }
+  scheduleTimePoints()
+}
+
+function onStartIndexChange(value: number) {
+  startIndex.value = Math.max(0, Math.min(value, Math.max(0, timePoints.value.length - windowSize.value)))
+  scheduleWaveWindow()
+}
+
+function onWindowSizeChange(value: number) {
+  windowSize.value = Math.max(1, Math.min(200, Math.round(value || 1)))
+  startIndex.value = Math.min(startIndex.value, Math.max(0, timePoints.value.length - windowSize.value))
+  scheduleWaveWindow()
+}
+
+function resetWindow() {
+  startIndex.value = 0
+  scheduleWaveWindow()
+}
+
+function pageWindow(direction: -1 | 1) {
+  const next = startIndex.value + direction * windowSize.value
+  startIndex.value = Math.max(0, Math.min(next, Math.max(0, timePoints.value.length - windowSize.value)))
+  scheduleWaveWindow()
+}
+
+function refresh() {
+  void loadOptions()
+}
+
+async function openPeriod(cycle: Cycle) {
+  periodVisible.value = true
+  periodLoading.value = true
+  periodDetail.value = null
+  try {
+    periodDetail.value = await fetchPeriodDetail(cycle.waveFileId, cycle.periodNo)
+  } catch (error) {
+    errorMessage.value = error instanceof Error ? error.message : '周期详情读取失败'
+    periodVisible.value = false
+  } finally {
+    periodLoading.value = false
+  }
+}
+
+function closePeriod() {
+  periodVisible.value = false
+}
+
+async function loadAnnotationConfig() {
+  try {
+    const result = await fetchAnnotationConfig()
+    annotationWidth.value = result.annotationWidth
+  } catch {
+    annotationWidth.value = 10
+  }
+}
+
+async function loadAnnotations() {
+  const ids = [...new Set(
+    timePoints.value.flatMap((point) => (
+      selectedTypes.value
+        .map((type) => point.files[type]?.id)
+        .filter((id): id is number => id != null)
+    )),
+  )]
+  if (!ids.length) {
+    annotations.value = []
+    return
+  }
+  try {
+    const result = await fetchAnnotationsByIds(ids)
+    annotations.value = result.annotations
+  } catch {
+    annotations.value = []
+  }
+}
+
+type AnnotationBlock = {
+  waveFileId: number
+  periodStart: number
+  periodEnd: number
+  sampleIndexStart: number
+  sampleIndexEnd: number
+}
+
+function annotationBlock(cycle: Cycle): AnnotationBlock | null {
+  const width = Math.max(1, annotationWidth.value)
+  const fileCycles = (waveData.value?.cycles ?? []).filter((item) => item.waveFileId === cycle.waveFileId)
+  const maxPeriod = Math.max(1, ...fileCycles.map((item) => item.periodNo))
+  let leftBound = 1
+  let rightBound = maxPeriod
+  const existing = annotations.value
+    .filter((item) => item.waveFileId === cycle.waveFileId)
+    .sort((a, b) => a.periodStart - b.periodStart)
+  for (const item of existing) {
+    if (item.periodEnd < cycle.periodNo) {
+      leftBound = Math.max(leftBound, item.periodEnd + 1)
+    } else if (item.periodStart > cycle.periodNo) {
+      rightBound = Math.min(rightBound, item.periodStart - 1)
+    } else {
+      return null
+    }
+  }
+  if (leftBound > rightBound) return null
+  let start = Math.max(leftBound, cycle.periodNo - Math.floor((width - 1) / 2))
+  let end = Math.min(rightBound, start + width - 1)
+  start = Math.max(leftBound, end - width + 1)
+  const startCycle = fileCycles.find((item) => item.periodNo === start)
+  const endCycle = fileCycles.find((item) => item.periodNo === end)
+  return {
+    waveFileId: cycle.waveFileId,
+    periodStart: start,
+    periodEnd: end,
+    sampleIndexStart: startCycle?.startSampleIndex ?? cycle.startSampleIndex,
+    sampleIndexEnd: endCycle?.endSampleIndex ?? cycle.endSampleIndex,
+  }
+}
+
+async function chooseLabel(block: AnnotationBlock): Promise<AnnotationLabel | null> {
+  try {
+    await ElMessageBox.confirm(
+      `将在周期 ${block.periodStart} — ${block.periodEnd} 创建标注,请选择样本类型`,
+      '创建标注',
+      {
+        confirmButtonText: '正常样本',
+        cancelButtonText: '异常样本',
+        distinguishCancelAndClose: true,
+        type: 'info',
+        closeOnClickModal: false,
+      },
+    )
+    return '正常'
+  } catch (action) {
+    if (action === 'cancel') return '异常'
+    return null
+  }
+}
+
+async function onAnnotationToggle(cycle: Cycle) {
+  if (annotationLoading.value) return
+  const existing = annotations.value.find((item) => (
+    item.waveFileId === cycle.waveFileId && cycle.periodNo >= item.periodStart && cycle.periodNo <= item.periodEnd
+  ))
+  if (existing) {
+    try {
+      await ElMessageBox.confirm(
+        `取消该标注(${existing.label},周期 ${existing.periodStart} — ${existing.periodEnd})?`,
+        '取消标注',
+        { confirmButtonText: '取消标注', cancelButtonText: '保留', type: 'warning', closeOnClickModal: false },
+      )
+    } catch {
+      return
+    }
+    annotationLoading.value = true
+    try {
+      await deleteAnnotation(existing.id)
+      await loadAnnotations()
+    } catch (error) {
+      errorMessage.value = error instanceof Error ? error.message : '标注取消失败'
+    } finally {
+      annotationLoading.value = false
+    }
+    return
+  }
+  const block = annotationBlock(cycle)
+  if (!block) return
+  const label = await chooseLabel(block)
+  if (!label) return
+  annotationLoading.value = true
+  try {
+    await createAnnotation({
+      wave_file_id: block.waveFileId,
+      label,
+      period_start: block.periodStart,
+      period_end: block.periodEnd,
+      sample_index_start: block.sampleIndexStart,
+      sample_index_end: block.sampleIndexEnd,
+    })
+    await loadAnnotations()
+  } catch (error) {
+    errorMessage.value = error instanceof Error ? error.message : '标注创建失败'
+  } finally {
+    annotationLoading.value = false
+  }
+}
+
+watch(selectedPointName, () => {
+  if (initialized.value) onPointChange()
+})
+watch(selectedTypes, () => {
+  if (initialized.value) onTypesChange()
+}, { deep: true })
+watch([minTime, maxTime], () => {
+  if (initialized.value) onTimeChange()
+})
+watch(windowSize, () => {
+  if (initialized.value) onWindowSizeChange(windowSize.value)
+})
+watch(noSampling, () => {
+  if (initialized.value) loadWaveWindowNow()
+})
+
+async function handleLogin() {
+  if (!loginForm.username || !loginForm.password) {
+    loginError.value = '请输入账号和密码'
+    return
+  }
+  loginLoading.value = true
+  loginError.value = ''
+  try {
+    await apiLogin(loginForm.username, loginForm.password)
+    loggedIn.value = true
+    loginForm.password = ''
+    await loadOptions()
+    void loadAnnotationConfig()
+  } catch (error) {
+    loginError.value = error instanceof Error ? error.message : '登录失败'
+  } finally {
+    loginLoading.value = false
+  }
+}
+
+async function handleLogout() {
+  await apiLogout()
+  loggedIn.value = false
+  waveData.value = null
+  timePoints.value = []
+  annotations.value = []
+}
+
+function onAuthExpired() {
+  loggedIn.value = false
+  waveData.value = null
+  timePoints.value = []
+  annotations.value = []
+}
+
+onMounted(() => {
+  window.addEventListener('auth-expired', onAuthExpired)
+  loggedIn.value = getToken() != null
+  if (loggedIn.value) {
+    void loadOptions()
+    void loadAnnotationConfig()
+  }
+})
+onBeforeUnmount(() => {
+  window.removeEventListener('auth-expired', onAuthExpired)
+  if (waveDebounce) clearTimeout(waveDebounce)
+  if (queryDebounce) clearTimeout(queryDebounce)
+})
+</script>
+
+<template>
+  <div v-if="!loggedIn" class="login-shell">
+    <form class="login-card" @submit.prevent="handleLogin">
+      <div class="login-brand">
+        <div class="brand-mark"><span></span><span></span><span></span></div>
+        <div>
+          <div class="brand-kicker">COMPRESSOR / WAVE LAB</div>
+          <h1>故障预测波形工作台</h1>
+        </div>
+      </div>
+      <p class="login-hint">请登录后继续使用</p>
+      <el-input v-model="loginForm.username" class="login-input" size="large" placeholder="账号" clearable @input="loginError = ''" />
+      <el-input v-model="loginForm.password" class="login-input" size="large" type="password" show-password placeholder="密码" @keyup.enter="handleLogin" @input="loginError = ''" />
+      <el-alert v-if="loginError" class="login-error" type="error" :closable="false" show-icon :title="loginError" />
+      <el-button class="login-button" type="primary" size="large" :loading="loginLoading" native-type="submit">登 录</el-button>
+    </form>
+  </div>
+  <div v-else class="app-shell">
+    <header class="app-header">
+      <div class="brand-lockup">
+        <div class="brand-mark"><span></span><span></span><span></span></div>
+        <div>
+          <div class="brand-kicker">COMPRESSOR / WAVE LAB</div>
+          <h1>故障预测波形工作台</h1>
+        </div>
+      </div>
+      <div class="header-meta">
+        <span class="live-indicator"><i></i> 浏览模式</span>
+        <span class="header-date">{{ selectedPointName || '未选择采样点' }}</span>
+        <el-button class="header-refresh" type="primary" plain :loading="queryLoading" @click="refresh">刷新</el-button>
+        <el-button class="header-logout" plain @click="handleLogout">退出登录</el-button>
+      </div>
+    </header>
+
+    <main class="workspace">
+      <section class="query-panel panel">
+        <div class="panel-heading compact-heading">
+          <div>
+            <h2>查询条件</h2>
+          </div>
+          <el-tag class="connection-badge" :type="currentSource === 'database' ? 'success' : 'warning'" effect="plain">
+            {{ currentSource === 'database' ? '数据库已连接' : '演示数据' }}
+          </el-tag>
+        </div>
+        <div class="query-grid">
+          <label class="field field-point">
+            <span class="field-label">机组与部位</span>
+            <el-select
+              v-model="selectedPointName"
+              class="query-control"
+              size="large"
+              filterable
+              :disabled="queryLoading"
+              placeholder="输入机组、气缸或部位关键词"
+              filter-placeholder="搜索采样点"
+            >
+              <el-option v-for="pointName in pointNames" :key="pointName" :label="pointName" :value="pointName" />
+            </el-select>
+          </label>
+          <div class="field field-types">
+            <span class="field-label">数据名称</span>
+            <el-select
+              v-model="selectedTypes"
+              class="query-control"
+              size="large"
+              multiple
+              collapse-tags
+              collapse-tags-tooltip
+              :max-collapse-tags="2"
+              placeholder="选择数据名称"
+            >
+              <el-option v-for="type in MEASUREMENT_TYPES" :key="type" :label="type" :value="type" />
+            </el-select>
+          </div>
+          <label class="field">
+            <span class="field-label">开始时间</span>
+            <el-date-picker
+              v-model="minTime"
+              class="query-control"
+              size="large"
+              type="datetime"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              format="YYYY-MM-DD HH:mm:ss"
+              :disabled="queryLoading"
+              :disabled-date="(date: Date) => date < new Date(`${boundsForSelection()?.min ?? '1970-01-01 00:00:00'}`) || date > new Date(`${boundsForSelection()?.max ?? '2999-12-31 23:59:59'}`)"
+              placeholder="选择开始时间"
+            />
+          </label>
+          <label class="field">
+            <span class="field-label">结束时间</span>
+            <el-date-picker
+              v-model="maxTime"
+              class="query-control"
+              size="large"
+              type="datetime"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              format="YYYY-MM-DD HH:mm:ss"
+              :disabled="queryLoading"
+              :disabled-date="(date: Date) => date < new Date(`${boundsForSelection()?.min ?? '1970-01-01 00:00:00'}`) || date > new Date(`${boundsForSelection()?.max ?? '2999-12-31 23:59:59'}`)"
+              placeholder="选择结束时间"
+            />
+          </label>
+          <label class="field window-field">
+            <span class="field-label">时间窗口 <em>文件数量</em></span>
+            <el-input-number
+              v-model="windowSize"
+              class="query-control window-number"
+              size="large"
+              :min="1"
+              :max="200"
+              controls-position="right"
+              :disabled="queryLoading"
+            />
+          </label>
+          <el-button class="query-action query-button" type="primary" size="large" :loading="timePointsLoading" :disabled="!selectedPointName" @click="loadTimePoints">
+            查询时间点
+          </el-button>
+        </div>
+        <el-alert v-if="errorMessage" class="data-alert" type="error" :closable="false" show-icon :title="errorMessage" />
+      </section>
+
+      <section class="selection-panel panel">
+        <TimePointStrip
+          :points="timePoints"
+          :measurement-types="selectedTypes"
+          :start-index="startIndex"
+          :window-size="windowSize"
+          :loading="timePointsLoading"
+          :min-time="minTime"
+          :max-time="maxTime"
+          :annotated-file-ids="annotatedFileIds"
+          @update:start-index="onStartIndexChange"
+        />
+        <div class="selection-controls">
+          <div class="selection-readout">
+            <span class="selection-accent"></span>
+            <span>当前窗口覆盖 <strong>{{ selectedWindowPoints.length }}</strong> 个时间点:{{ selectedWindowPoints.map((p) => p.sampleTime.replace('T', ' ')).join('、') }}</span>
+          </div>
+          <div class="selection-actions">
+            <el-button class="ghost-button" plain :disabled="!startIndex" @click="resetWindow">回到起点</el-button>
+            <el-button class="ghost-button" plain :disabled="startIndex <= 0" @click="pageWindow(-1)">上一页</el-button>
+            <el-button class="ghost-button" plain :disabled="startIndex >= timePoints.length - windowSize" @click="pageWindow(1)">下一页</el-button>
+          </div>
+        </div>
+      </section>
+
+      <section class="chart-panel panel">
+        <div class="panel-heading chart-heading">
+          <div>
+            <h2>波形预览</h2>
+          </div>
+          <div class="chart-actions">
+            <span class="annotation-width">标度宽度:<strong>{{ annotationWidth }}</strong></span>
+            <el-radio-group v-model="chartMode" class="mode-switch" size="small">
+              <el-radio-button label="split">分图显示</el-radio-button>
+              <el-radio-button label="merge">归一合并</el-radio-button>
+            </el-radio-group>
+            <el-switch
+              v-model="noSampling"
+              class="sampling-switch"
+              size="small"
+              active-text="不采样"
+              inactive-text="采样"
+              :disabled="waveLoading"
+            />
+            <div class="chart-status"><i :class="{ busy: waveLoading }"></i>{{ statusText() }}</div>
+          </div>
+        </div>
+        <WaveChart
+          :data="waveData"
+          :points="selectedWindowPoints"
+          :mode="chartMode"
+          :loading="waveLoading"
+          :annotations="annotations"
+          @period-dblclick="openPeriod"
+          @annotation-toggle="onAnnotationToggle"
+        />
+      </section>
+
+       <aside class="insight-panel panel">
+        <div class="panel-heading compact-heading">
+          <div><h2>当前窗口</h2></div>
+          <span class="readout-number">{{ String(selectedWindowPoints.length).padStart(2, '0') }}</span>
+        </div>
+        <div class="readout-grid">
+          <div class="readout-card">
+            <span>时间跨度</span>
+            <div class="readout-value">
+              <strong>{{ formatDateTime(selectedWindowPoints[0]?.sampleTime) }}</strong>
+              <small>至 {{ formatDateTime(selectedWindowPoints[selectedWindowPoints.length - 1]?.sampleTime) }}</small>
+            </div>
+          </div>
+          <div class="readout-card"><span>检测周期</span><strong>{{ currentCycles.length }}</strong></div>
+          <div class="readout-card"><span>选择范围</span><strong>#{{ startIndex + 1 }} — #{{ endIndex }}</strong></div>
+          <div class="readout-card"><span>显示策略</span><strong>MIN / MAX</strong></div>
+        </div>
+        <div class="data-footprint">
+          <div><span>数据名称</span><strong>{{ selectedTypes.join(' / ') }}</strong></div>
+          <div><span>可用文件</span><strong>{{ availableTypeCount.toLocaleString() }}</strong></div>
+          <div><span>抽样上限</span><strong>{{ maxPoints.toLocaleString() }} 点</strong></div>
+        </div>
+      </aside>
+    </main>
+
+    <PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" @close="closePeriod" />
+  </div>
+</template>

+ 135 - 0
frontend/src/api.ts

@@ -0,0 +1,135 @@
+import type {
+  Annotation,
+  AnnotationConfigResponse,
+  AnnotationLabel,
+  AnnotationListResponse,
+  MeasurementType,
+  PeriodDetail,
+  QueryOptionsResponse,
+  TimePoint,
+  TimePointsResponse,
+  WaveWindowResponse,
+} from './types'
+
+const apiBase = import.meta.env.VITE_API_BASE ?? ''
+const TOKEN_KEY = 'auth_token'
+
+export function getToken(): string | null {
+  return localStorage.getItem(TOKEN_KEY)
+}
+
+export function setToken(token: string | null) {
+  if (token) localStorage.setItem(TOKEN_KEY, token)
+  else localStorage.removeItem(TOKEN_KEY)
+}
+
+async function request<T>(path: string, init?: RequestInit): Promise<T> {
+  const token = getToken()
+  const response = await fetch(`${apiBase}${path}`, {
+    ...init,
+    headers: {
+      'Content-Type': 'application/json',
+      ...(token ? { Authorization: `Bearer ${token}` } : {}),
+      ...(init?.headers ?? {}),
+    },
+  })
+  if (response.status === 401 && path !== '/api/login') {
+    setToken(null)
+    window.dispatchEvent(new CustomEvent('auth-expired'))
+  }
+  if (!response.ok) {
+    const body = await response.json().catch(() => null)
+    throw new Error(body?.detail ?? `请求失败(${response.status})`)
+  }
+  return response.json() as Promise<T>
+}
+
+export async function login(username: string, password: string) {
+  const result = await request<{ token: string; username: string }>('/api/login', {
+    method: 'POST',
+    body: JSON.stringify({ username, password }),
+  })
+  setToken(result.token)
+  return result
+}
+
+export async function logout() {
+  try {
+    await request<{ ok: boolean }>('/api/logout', { method: 'POST' })
+  } finally {
+    setToken(null)
+  }
+}
+
+export function fetchQueryOptions() {
+  return request<QueryOptionsResponse>('/api/query-options')
+}
+
+export function fetchTimePoints(params: {
+  pointName: string
+  measurementTypes: MeasurementType[]
+  minTime?: string
+  maxTime?: string
+}) {
+  const search = new URLSearchParams({ point_name: params.pointName })
+  params.measurementTypes.forEach((value) => search.append('measurement_types', value))
+  if (params.minTime) search.set('min_time', params.minTime)
+  if (params.maxTime) search.set('max_time', params.maxTime)
+  return request<TimePointsResponse>(`/api/time-points?${search.toString()}`)
+}
+
+export function fetchWaveWindow(params: {
+  pointName: string
+  measurementTypes: MeasurementType[]
+  points: TimePoint[]
+  maxPoints: number
+  noSampling: boolean
+}) {
+  return request<WaveWindowResponse>('/api/wave-window', {
+    method: 'POST',
+    body: JSON.stringify(params),
+  })
+}
+
+export function fetchPeriodDetail(waveFileId: number, periodNo: number) {
+  return request<PeriodDetail>(`/api/wave-files/${waveFileId}/periods/${periodNo}`)
+}
+
+export function fetchAnnotationConfig() {
+  return request<AnnotationConfigResponse>('/api/annotation-config')
+}
+
+export function fetchAnnotations(waveFileIds: number[]) {
+  if (!waveFileIds.length) return Promise.resolve({ source: 'demo' as const, annotations: [] as Annotation[], notice: null })
+  const search = new URLSearchParams()
+  waveFileIds.forEach((id) => search.append('wave_file_ids', String(id)))
+  return request<AnnotationListResponse>(`/api/annotations?${search.toString()}`)
+}
+
+export function fetchAnnotationsByIds(waveFileIds: number[]) {
+  if (!waveFileIds.length) return Promise.resolve({ source: 'demo' as const, annotations: [] as Annotation[], notice: null })
+  return request<AnnotationListResponse>('/api/annotations/query', {
+    method: 'POST',
+    body: JSON.stringify({ wave_file_ids: waveFileIds }),
+  })
+}
+
+export function createAnnotation(payload: {
+  wave_file_id: number
+  label: AnnotationLabel
+  period_start: number
+  period_end: number
+  sample_index_start: number
+  sample_index_end: number
+}) {
+  return request<Annotation & { source: string; notice: string | null }>('/api/annotations', {
+    method: 'POST',
+    body: JSON.stringify(payload),
+  })
+}
+
+export function deleteAnnotation(id: number) {
+  return request<{ deleted: number; source: string; notice: string | null }>(`/api/annotations/${id}`, {
+    method: 'DELETE',
+  })
+}

+ 143 - 0
frontend/src/components/PeriodModal.vue

@@ -0,0 +1,143 @@
+<script setup lang="ts">
+import * as echarts from 'echarts'
+import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import type { PeriodDetail } from '../types'
+
+const props = defineProps<{
+  detail: PeriodDetail | null
+  visible: boolean
+  loading?: boolean
+}>()
+
+const emit = defineEmits<{
+  close: []
+}>()
+
+const chartElement = ref<HTMLDivElement | null>(null)
+const chartMode = ref<'angle' | 'volume'>('volume')
+let chart: echarts.ECharts | undefined
+
+function render() {
+  if (!chart || !props.detail) return
+  const detail = props.detail
+  const volumeMode = chartMode.value === 'volume' && detail.volume
+
+  const series: echarts.SeriesOption[] = detail.phases.map((phase, phaseIndex) => {
+    const isLast = phaseIndex === detail.phases.length - 1
+    const data: Array<{ value: [number, number]; idx: number }> = []
+    detail.angles360.forEach((angle360, index) => {
+      const x = volumeMode ? detail.volume?.[index] : detail.angles[index]
+      if (x == null || !Number.isFinite(x)) return
+      const inPhase = isLast ? angle360 >= phase.start : angle360 >= phase.start && angle360 < phase.end
+      if (inPhase) data.push({ value: [x, detail.pressure[index]], idx: index })
+    })
+    return {
+      name: phase.name,
+      type: 'line' as const,
+      showSymbol: false,
+      lineStyle: { color: phase.color, width: 1.6 },
+      itemStyle: { color: phase.color },
+      data,
+    }
+  })
+
+  chart.setOption({
+    animation: false,
+    grid: { left: 64, right: 28, top: 38, bottom: 52 },
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: '#162b3c',
+      borderWidth: 0,
+      textStyle: { color: '#fff', fontSize: 11 },
+      formatter: (params: any[]) => {
+        const item = params?.[0]
+        if (!item) return ''
+        const index = item.data?.idx ?? 0
+        const angle = detail.angles[index]
+        const pressure = detail.pressure[index]
+        const volume = detail.volume?.[index]
+        const phase = detail.phases.find((p) => detail.angles360[index] >= p.start && detail.angles360[index] < p.end)
+        return [
+          `曲轴角度:${angle?.toFixed(1)}°${volumeMode ? `(0-360:${detail.angles360[index]?.toFixed(1)}°)` : ''}`,
+          `压力原始值:${pressure?.toPrecision(8)}`,
+          volumeMode ? `体积:${volume?.toFixed(4)} L` : '',
+          phase ? `<span style="color:${phase.color}">●</span> ${phase.name}` : '',
+        ].filter(Boolean).join('<br/>')
+      },
+    },
+    legend: { top: 0, left: 70, itemWidth: 16, itemHeight: 7, textStyle: { color: '#60717b', fontSize: 11 } },
+    xAxis: {
+      type: 'value',
+      name: volumeMode ? '气缸体积 (L)' : '曲轴角度 (°)',
+      nameLocation: 'middle',
+      nameGap: 30,
+      min: volumeMode ? undefined : 0,
+      max: volumeMode ? undefined : 180,
+      axisLine: { lineStyle: { color: '#9eabb1' } },
+      axisLabel: { color: '#71808a' },
+    },
+    yAxis: {
+      type: 'value',
+      name: '压力原始值',
+      nameLocation: 'middle',
+      nameGap: 45,
+      nameTextStyle: { color: '#e4572e' },
+      axisLine: { show: true, lineStyle: { color: '#e4572e' } },
+      axisLabel: { color: '#71808a' },
+      splitLine: { lineStyle: { color: '#edf1f2' } },
+    },
+    series,
+  }, true)
+  chart.resize()
+}
+
+async function ensureChart() {
+  if (!props.visible) {
+    chart?.dispose()
+    chart = undefined
+    return
+  }
+  await nextTick()
+  if (!chartElement.value) return
+  if (!chart) chart = echarts.init(chartElement.value, undefined, { renderer: 'canvas' })
+  render()
+}
+
+watch(() => [props.detail, props.visible, chartMode.value], () => void ensureChart(), { deep: true })
+
+onMounted(() => {
+  void ensureChart()
+})
+
+onBeforeUnmount(() => chart?.dispose())
+</script>
+
+<template>
+  <Teleport to="body">
+    <div v-if="visible" class="modal-backdrop" @click.self="emit('close')">
+      <section class="period-modal" role="dialog" aria-modal="true">
+        <header class="period-modal-head">
+          <div>
+            <h2>周期功图</h2>
+            <p>{{ detail?.waveFile.pointName }} · wave_file #{{ detail?.waveFile.id }} · {{ detail?.waveFile.sampleTime }}</p>
+          </div>
+          <button class="icon-button" type="button" aria-label="关闭" @click="emit('close')">×</button>
+        </header>
+        <div v-if="loading" class="modal-loading">正在生成角度与体积数据…</div>
+        <template v-else-if="detail">
+          <div class="period-stat-row">
+            <div><span>采样范围</span><strong>{{ detail.period.startSampleIndex }} — {{ detail.period.endSampleIndex }}</strong></div>
+            <div><span>周期采样数</span><strong>{{ detail.period.sampleCount.toLocaleString() }}</strong></div>
+            <div><span>键相点</span><strong>{{ detail.period.triggerSampleIndices.length }} 个</strong></div>
+            <div v-if="detail.volumeInfo"><span>缸径 / 余隙</span><strong>{{ detail.volumeInfo.boreMm }} mm / {{ detail.volumeInfo.clearanceVolumeL }} L</strong></div>
+          </div>
+          <div class="modal-tabs">
+            <button :class="{ active: chartMode === 'volume' }" type="button" :disabled="!detail.volume" @click="chartMode = 'volume'">压力-体积功图</button>
+            <button :class="{ active: chartMode === 'angle' }" type="button" @click="chartMode = 'angle'">压力-角度图</button>
+          </div>
+          <div ref="chartElement" class="period-chart"></div>
+        </template>
+      </section>
+    </div>
+  </Teleport>
+</template>

+ 225 - 0
frontend/src/components/TimePointStrip.vue

@@ -0,0 +1,225 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import type { MeasurementType, TimePoint } from '../types'
+
+const props = defineProps<{
+  points: TimePoint[]
+  measurementTypes: MeasurementType[]
+  startIndex: number
+  windowSize: number
+  loading?: boolean
+  minTime?: string
+  maxTime?: string
+  annotatedFileIds?: number[]
+}>()
+
+const emit = defineEmits<{
+  'update:startIndex': [value: number]
+}>()
+
+const canvas = ref<HTMLCanvasElement | null>(null)
+const canvasHost = ref<HTMLElement | null>(null)
+let resizeObserver: ResizeObserver | undefined
+
+const maxStart = computed(() => Math.max(0, props.points.length - props.windowSize))
+const endIndex = computed(() => Math.min(props.points.length, props.startIndex + props.windowSize))
+
+function displayDate(value: string | undefined) {
+  if (!value) return '--'
+  return value.replace('T', ' ').slice(0, 10)
+}
+
+function draw() {
+  const element = canvas.value
+  const host = canvasHost.value
+  if (!element || !host) return
+  const width = Math.max(host.clientWidth, 320)
+  const height = 210
+  const ratio = window.devicePixelRatio || 1
+  element.width = width * ratio
+  element.height = height * ratio
+  element.style.width = `${width}px`
+  element.style.height = `${height}px`
+  const context = element.getContext('2d')
+  if (!context) return
+  context.setTransform(ratio, 0, 0, ratio, 0, 0)
+  context.clearRect(0, 0, width, height)
+
+  const left = 70
+  const right = 20
+  const available = Math.max(width - left - right, 1)
+  const rowGap = 39
+  const rows = props.measurementTypes
+  const rowY = (index: number) => 32 + index * rowGap
+  const annotationY = 32 + rows.length * rowGap
+  const xAt = (index: number) => left + (props.points.length <= 1 ? 0 : index / (props.points.length - 1)) * available
+  const selectedLeft = xAt(props.startIndex)
+  const selectedRight = xAt(Math.min(props.points.length - 1, Math.max(props.startIndex, endIndex.value - 1)))
+  const selectedWidth = Math.max(selectedRight - selectedLeft, 8)
+
+  context.fillStyle = '#f4f6f7'
+  context.fillRect(selectedLeft, 10, selectedWidth, 172)
+  context.strokeStyle = '#dbe2e6'
+  context.lineWidth = 1
+  context.strokeRect(selectedLeft + 0.5, 10.5, selectedWidth - 1, 171)
+
+  rows.forEach((measurementType, rowIndex) => {
+    const y = rowY(rowIndex)
+    context.strokeStyle = '#d7e0e4'
+    context.setLineDash([2, 5])
+    context.beginPath()
+    context.moveTo(left, y)
+    context.lineTo(width - right, y)
+    context.stroke()
+    context.setLineDash([])
+    context.fillStyle = '#52616b'
+    context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
+    context.fillText(measurementType, 10, y + 4)
+
+    let lastX = -Infinity
+    props.points.forEach((point, pointIndex) => {
+      const fileInfo = point.files[measurementType]
+      if (!fileInfo) return
+      const x = xAt(pointIndex)
+      if (x - lastX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
+      lastX = x
+      const running = fileInfo.rpm > 0
+      context.fillStyle = running ? '#2e7d32' : '#c0c4cc'
+      context.beginPath()
+      context.arc(x, y, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
+      context.fill()
+    })
+  })
+
+  context.strokeStyle = '#d7e0e4'
+  context.setLineDash([2, 5])
+  context.beginPath()
+  context.moveTo(left, annotationY)
+  context.lineTo(width - right, annotationY)
+  context.stroke()
+  context.setLineDash([])
+  context.fillStyle = '#52616b'
+  context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
+  context.fillText('标注', 10, annotationY + 4)
+  const annotatedSet = new Set(props.annotatedFileIds ?? [])
+  const isAnnotated = (point: TimePoint) => props.measurementTypes.some((type) => {
+    const id = point.files[type]?.id
+    return id != null && annotatedSet.has(id)
+  })
+  let lastGrayX = -Infinity
+  props.points.forEach((point, pointIndex) => {
+    if (isAnnotated(point)) return
+    const hasFile = props.measurementTypes.some((type) => point.files[type])
+    if (!hasFile) return
+    const x = xAt(pointIndex)
+    if (x - lastGrayX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
+    lastGrayX = x
+    context.fillStyle = '#c0c4cc'
+    context.beginPath()
+    context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3 : 2.5, 0, Math.PI * 2)
+    context.fill()
+  })
+  props.points.forEach((point, pointIndex) => {
+    if (!isAnnotated(point)) return
+    const x = xAt(pointIndex)
+    context.fillStyle = '#e05252'
+    context.beginPath()
+    context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
+    context.fill()
+  })
+
+  context.fillStyle = '#788892'
+  context.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'
+  const tickCount = Math.min(6, Math.max(2, Math.floor(width / 180)))
+  for (let tick = 0; tick <= tickCount; tick += 1) {
+    const index = Math.min(props.points.length - 1, Math.round((props.points.length - 1) * tick / tickCount))
+    const x = xAt(index)
+    context.strokeStyle = '#d6dfe3'
+    context.beginPath()
+    context.moveTo(x, 180)
+    context.lineTo(x, 186)
+    context.stroke()
+    const label = displayDate(props.points[index]?.sampleTime)
+    context.textAlign = tick === 0 ? 'left' : tick === tickCount ? 'right' : 'center'
+    context.fillText(label, x, 202)
+  }
+  context.textAlign = 'left'
+  if (!props.points.length) {
+    context.fillStyle = '#8a989f'
+    context.fillText('暂无时间点', left, 70)
+  }
+}
+
+function selectFromPointer(event: PointerEvent) {
+  if (!canvas.value || !props.points.length) return
+  const rect = canvas.value.getBoundingClientRect()
+  const left = 70
+  const right = 20
+  const usable = Math.max(rect.width - left - right, 1)
+  const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left - left) / usable))
+  const pointIndex = Math.round(ratio * (props.points.length - 1))
+  const value = Math.max(0, Math.min(maxStart.value, pointIndex - Math.floor(props.windowSize / 2)))
+  emit('update:startIndex', value)
+}
+
+function updateSlider(value: number | number[]) {
+  emit('update:startIndex', Number(Array.isArray(value) ? value[0] : value))
+}
+
+watch(
+  () => [props.points, props.measurementTypes, props.startIndex, props.windowSize, props.annotatedFileIds],
+  () => nextTick(draw),
+  { deep: true },
+)
+
+onMounted(() => {
+  resizeObserver = new ResizeObserver(draw)
+  if (canvasHost.value) resizeObserver.observe(canvasHost.value)
+  draw()
+})
+
+onBeforeUnmount(() => resizeObserver?.disconnect())
+</script>
+
+<template>
+  <section class="time-strip" aria-label="时间点选择区">
+    <div class="time-strip-head">
+      <div>
+        <h2>时间点选择</h2>
+      </div>
+      <div class="time-summary">
+        <span class="time-legend"><i class="legend-running"></i>运转</span>
+        <span class="time-legend"><i class="legend-stopped"></i>停机</span>
+        <span class="summary-divider">/</span>
+        <span class="mono">{{ points.length.toLocaleString() }}</span> 个时间点
+        <span class="summary-divider">/</span>
+        当前 {{ points.length ? `${startIndex + 1} — ${endIndex}` : '—' }}
+      </div>
+    </div>
+    <div ref="canvasHost" class="timeline-canvas-host" :class="{ 'is-loading': loading }">
+      <canvas ref="canvas" @pointerdown="selectFromPointer" />
+      <div v-if="loading" class="canvas-loading">正在读取时间点…</div>
+    </div>
+    <div class="time-strip-foot">
+      <div class="time-foot-item">
+        <span class="foot-label">起点</span>
+        <strong>{{ displayDate(minTime) }}</strong>
+      </div>
+      <div class="window-track">
+        <el-slider
+          :model-value="startIndex"
+          :min="0"
+          :max="maxStart"
+          :disabled="!points.length || maxStart === 0"
+          :show-tooltip="false"
+          aria-label="时间窗口位置"
+          @update:model-value="updateSlider"
+        />
+      </div>
+      <div class="time-foot-item align-right">
+        <span class="foot-label">终点</span>
+        <strong>{{ displayDate(maxTime) }}</strong>
+      </div>
+    </div>
+  </section>
+</template>

+ 557 - 0
frontend/src/components/WaveChart.vue

@@ -0,0 +1,557 @@
+<script setup lang="ts">
+import * as echarts from 'echarts'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import type { Annotation, Cycle, MeasurementType, TimePoint, WaveWindowResponse } from '../types'
+
+const props = defineProps<{
+  data: WaveWindowResponse | null
+  points: TimePoint[]
+  mode: 'split' | 'merge'
+  loading?: boolean
+  annotations?: Annotation[]
+}>()
+
+const emit = defineEmits<{
+  periodDblclick: [cycle: Cycle]
+  annotationToggle: [cycle: Cycle]
+}>()
+
+const chartElement = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | undefined
+
+const colors: Record<MeasurementType | '角度' | '合并信号' | 'second_value' | '体积', string> = {
+  压力: '#e05252',
+  位移: '#287f9e',
+  加速度: '#7656a5',
+  角度: '#c58b24',
+  合并信号: '#287f9e',
+  second_value: '#f56c6c',
+  体积: '#4d9e6f',
+}
+
+const plottedPointCount = computed(() => (
+  (props.data?.series.reduce((total, series) => total + series.data.length, 0) ?? 0)
+  + (props.data?.secondSeries.data.length ?? 0)
+))
+
+const hasPlottableData = computed(() => plottedPointCount.value > 0)
+
+const secondValueStatus = computed(() => {
+  const series = props.data?.secondSeries
+  if (!series) return ''
+  return `周期数据:有效 ${series.finiteCount.toLocaleString()} 点 / 非零 ${series.nonZeroCount.toLocaleString()} 点`
+})
+
+function formatTime(value: string | undefined) {
+  return value?.replace('T', ' ').slice(11, 19) ?? ''
+}
+
+function minMaxOf(values: number[]): [number, number] {
+  let min = Infinity
+  let max = -Infinity
+  for (let i = 0; i < values.length; i += 1) {
+    const value = values[i]
+    if (value < min) min = value
+    if (value > max) max = value
+  }
+  return [min, max]
+}
+
+function pointAxisLabel(value: number) {
+  const index = Math.round(value)
+  if (Math.abs(value - index) > 0.04 || !props.points[index]) return ''
+  return `${index + 1}\n${formatTime(props.points[index].sampleTime)}`
+}
+
+function periodAreas(cycles: Cycle[]): any[] {
+  return cycles.map((cycle, index) => [
+    {
+      xAxis: cycle.startX,
+      itemStyle: {
+        color: index % 2 === 0 ? 'rgba(213, 155, 43, 0.075)' : 'rgba(15, 29, 43, 0.09)',
+      },
+    },
+    { xAxis: cycle.endX },
+  ])
+}
+
+function triggerLines(xs: number[]) {
+  return xs.map((x) => ({
+    xAxis: x,
+    lineStyle: { color: '#d59b2b', width: 1, type: 'dotted' as const, opacity: 0.65 },
+    label: { show: false },
+  }))
+}
+
+function annotationXRange(annotation: Annotation): { start: number; end: number } | null {
+  const matching = (props.data?.cycles ?? []).filter(
+    (cycle) => cycle.waveFileId === annotation.waveFileId
+      && cycle.periodNo >= annotation.periodStart
+      && cycle.periodNo <= annotation.periodEnd,
+  )
+  if (!matching.length) return null
+  const start = Math.min(...matching.map((cycle) => cycle.startX))
+  const end = Math.max(...matching.map((cycle) => cycle.endX))
+  return { start, end }
+}
+
+function annotationAreas(annotations: Annotation[]): any[] {
+  const areas: any[] = []
+  annotations.forEach((annotation) => {
+    const range = annotationXRange(annotation)
+    if (!range) return
+    const color = annotation.label === '异常' ? 'rgba(229, 57, 46, 0.22)' : 'rgba(46, 125, 50, 0.18)'
+    areas.push([
+      { xAxis: range.start, itemStyle: { color } },
+      { xAxis: range.end },
+    ])
+  })
+  return areas
+}
+
+function buildAnnotationOverlaySeries(annotations: Annotation[]): echarts.SeriesOption[] {
+  const data = props.data
+  if (!data) return []
+  const gridCount = props.mode === 'merge' ? 1 : data.measurementTypes.length + 3
+  const areas = annotationAreas(annotations)
+  return Array.from({ length: gridCount }, (_, index) => ({
+    id: `annotation-overlay-${index}`,
+    type: 'line' as const,
+    xAxisIndex: index,
+    yAxisIndex: index,
+    silent: true,
+    data: [],
+    markArea: { silent: true, data: areas },
+  }))
+}
+
+function initialZoom(data: WaveWindowResponse): { start: number; end: number } {
+  const sampleCount = data.files[0]?.sampleCount ?? 65536
+  const start = data.cycles.length > 0 ? (data.cycles[0].startX / data.xMax) * 100 : 0
+  const end = (() => {
+    if (data.cycles.length > 10) return (data.cycles[9].endX / data.xMax) * 100
+    if (data.cycles.length > 0) return 100
+    return Math.min(100, (25600 / sampleCount / data.xMax) * 100)
+  })()
+  return { start, end }
+}
+
+function readCurrentZoom(data: WaveWindowResponse): { start: number; end: number } {
+  if (chart) {
+    const option = chart.getOption() as any
+    const slider = option?.dataZoom?.[1] ?? option?.dataZoom?.[0]
+    if (slider && slider.start != null && slider.end != null) {
+      return { start: slider.start, end: slider.end }
+    }
+  }
+  return initialZoom(data)
+}
+
+const xMax = computed(() => props.data?.xMax ?? 1)
+
+const annotationSegments = computed(() => {
+  return (props.annotations ?? [])
+    .map((annotation) => {
+      const range = annotationXRange(annotation)
+      return range ? { annotation, ...range } : null
+    })
+    .filter((item): item is { annotation: Annotation; start: number; end: number } => item !== null)
+    .sort((a, b) => a.start - b.start)
+})
+
+const activeAnnotationIndex = ref(0)
+
+function focusAnnotation(index: number) {
+  const segment = annotationSegments.value[index]
+  if (!segment || !chart || !props.data) return
+  activeAnnotationIndex.value = index
+  const start = Math.max(0, (segment.start / xMax.value) * 100)
+  const end = Math.min(100, (segment.end / xMax.value) * 100)
+  chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start, end })
+  chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start, end })
+}
+
+watch(annotationSegments, () => {
+  if (activeAnnotationIndex.value >= annotationSegments.value.length) {
+    activeAnnotationIndex.value = Math.max(0, annotationSegments.value.length - 1)
+  }
+})
+
+function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
+  const data = props.data
+  if (!data) return { animation: false }
+  const measurementTypes = data.measurementTypes
+  const periodBackground = periodAreas(data.cycles)
+  const types = props.mode === 'merge' ? ['合并信号'] : [...measurementTypes, 'second_value', '角度', '体积']
+  const gridCount = types.length
+  const chartHeight = chartElement.value?.clientHeight || 640
+  const topInset = 32
+  const bottomInset = 68
+  const rowGap = props.mode === 'merge' ? 0 : 14
+  const rowHeight = props.mode === 'merge'
+    ? Math.max(260, chartHeight - topInset - bottomInset)
+    : Math.max(76, Math.floor((chartHeight - topInset - bottomInset - rowGap * (gridCount - 1)) / gridCount))
+  const grid = Array.from({ length: gridCount }, (_, index) => ({
+    left: 70,
+    right: 22,
+    top: topInset + index * (rowHeight + rowGap),
+    height: rowHeight,
+    containLabel: false,
+  }))
+  if (props.mode === 'merge') {
+    grid[0] = { left: 70, right: 22, top: topInset, height: rowHeight, containLabel: false }
+  }
+  const xAxes = grid.map((_, index) => ({
+    type: 'value' as const,
+    min: data.xMin,
+    max: data.xMax,
+    gridIndex: index,
+    axisLine: { lineStyle: { color: '#b9c6cc' } },
+    axisTick: { show: index === gridCount - 1 },
+    axisLabel: {
+      show: index === gridCount - 1,
+      color: '#71808a',
+      fontSize: 11,
+      formatter: pointAxisLabel,
+    },
+    boundaryGap: false,
+    splitLine: { show: false },
+  }))
+  const yAxes = types.map((type, index) => ({
+    type: 'value' as const,
+    gridIndex: index,
+    name: type === 'second_value' ? '周期数据' : type,
+    nameLocation: 'middle' as const,
+    nameGap: 48,
+    nameTextStyle: { color: colors[type as MeasurementType | '角度' | '合并信号' | 'second_value' | '体积'], fontWeight: 600 },
+    axisLine: { show: true, lineStyle: { color: colors[type as MeasurementType | '角度' | '合并信号' | 'second_value' | '体积'] } },
+    axisLabel: { color: '#71808a', fontSize: 11 },
+    splitLine: { show: true, lineStyle: { color: '#e7edf0', width: 1 } },
+    min: type === '角度' ? 0 : undefined,
+    max: type === '角度' ? 180 : undefined,
+  }))
+  const series: echarts.SeriesOption[] = []
+  if (props.mode === 'merge') {
+    const normalised = measurementTypes.map((type) => {
+      const source = data.series.find((item) => item.measurementType === type)
+      const values = source?.data ?? []
+      const finiteValues = values.map((item) => item.rawValue).filter(Number.isFinite)
+      const [safeMin, safeMax] = finiteValues.length ? minMaxOf(finiteValues) : [0, 1]
+      const span = safeMax - safeMin || 1
+      return {
+        name: type,
+        type: 'line' as const,
+        z: 10,
+        showSymbol: false,
+        connectNulls: false,
+        sampling: 'lttb' as const,
+        lineStyle: { width: 2.5, color: colors[type], cap: 'round' as const, join: 'round' as const },
+        itemStyle: { color: colors[type] },
+        data: values
+          .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
+          .map((item) => [item.x, (item.rawValue - safeMin) / span] as [number, number]),
+        markArea: { silent: true, data: periodBackground },
+      }
+    })
+    series.push(...normalised)
+    const secondValues = data.secondSeries.data
+    const secondFiniteValues = secondValues.map((item) => item.rawValue).filter(Number.isFinite)
+    const [secondMin, secondMax] = secondFiniteValues.length ? minMaxOf(secondFiniteValues) : [0, 1]
+    const secondSpan = secondMax - secondMin || 1
+    series.push({
+      name: '周期数据',
+      type: 'line',
+      xAxisIndex: 0,
+      yAxisIndex: 0,
+      showSymbol: false,
+      connectNulls: false,
+      lineStyle: { width: 3, color: colors.second_value, cap: 'round' as const, join: 'round' as const },
+      areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
+      data: secondValues
+        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
+        .map((item) => [item.x, (item.rawValue - secondMin) / secondSpan] as [number, number]),
+    })
+    series.push({
+      name: '角度',
+      type: 'line',
+      xAxisIndex: 0,
+      yAxisIndex: 0,
+      showSymbol: false,
+      lineStyle: { width: 1.5, type: 'dashed', color: colors['角度'], opacity: 0.8 },
+      data: data.angleSeries.data
+        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.angle))
+        .map((item) => [item.x, item.angle / 180] as [number, number]),
+      markLine: { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) },
+    })
+    const volumeValues = data.volumeSeries.data
+    const volumeFinite = volumeValues.map((item) => item.volume).filter(Number.isFinite)
+    const [volumeMin, volumeMax] = volumeFinite.length ? minMaxOf(volumeFinite) : [0, 1]
+    const volumeSpan = volumeMax - volumeMin || 1
+    series.push({
+      name: '体积',
+      type: 'line',
+      xAxisIndex: 0,
+      yAxisIndex: 0,
+      showSymbol: false,
+      lineStyle: { width: 1.5, color: colors['体积'], opacity: 0.9 },
+      data: volumeValues
+        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
+        .map((item) => [item.x, (item.volume - volumeMin) / volumeSpan] as [number, number]),
+    })
+  } else {
+    const secondIndex = measurementTypes.length
+    const angleIndex = secondIndex + 1
+    const volumeIndex = secondIndex + 2
+    series.push({
+      name: '周期数据',
+      type: 'line',
+      xAxisIndex: secondIndex,
+      yAxisIndex: secondIndex,
+      showSymbol: false,
+      connectNulls: false,
+      lineStyle: { width: 3, color: colors.second_value, cap: 'round', join: 'round' },
+      areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
+      itemStyle: { color: colors.second_value },
+      data: data.secondSeries.data
+        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
+        .map((item) => [item.x, item.rawValue] as [number, number]),
+      markArea: { silent: true, data: periodBackground },
+    })
+    series.push({
+      name: '角度',
+      type: 'line',
+      xAxisIndex: angleIndex,
+      yAxisIndex: angleIndex,
+      showSymbol: false,
+      lineStyle: { width: 2, color: colors['角度'] },
+      itemStyle: { color: colors['角度'] },
+      data: data.angleSeries.data
+        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.angle))
+        .map((item) => [item.x, item.angle] as [number, number]),
+      markArea: { silent: true, data: periodBackground },
+      markLine: { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) },
+    })
+    series.push({
+      name: '体积',
+      type: 'line',
+      xAxisIndex: volumeIndex,
+      yAxisIndex: volumeIndex,
+      showSymbol: false,
+      lineStyle: { width: 2, color: colors['体积'] },
+      itemStyle: { color: colors['体积'] },
+      data: data.volumeSeries.data
+        .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
+        .map((item) => [item.x, item.volume] as [number, number]),
+      markArea: { silent: true, data: periodBackground },
+    })
+    measurementTypes.forEach((type, index) => {
+      const source = data.series.find((item) => item.measurementType === type)
+      series.push({
+        name: type,
+        type: 'line',
+        xAxisIndex: index,
+        yAxisIndex: index,
+        showSymbol: false,
+        connectNulls: false,
+        sampling: 'lttb' as const,
+        lineStyle: { width: 2.5, color: colors[type], cap: 'round', join: 'round' },
+        itemStyle: { color: colors[type] },
+        data: source?.data
+          .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
+          .map((item) => [item.x, item.rawValue] as [number, number]) ?? [],
+        markArea: { silent: true, data: periodBackground },
+        markLine: index === 0 ? { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) } : undefined,
+      })
+    })
+  }
+
+  const zoom = zoomMode === 'keep' ? readCurrentZoom(data) : initialZoom(data)
+  series.push(...buildAnnotationOverlaySeries(props.annotations ?? []))
+
+  return {
+    animation: false,
+    color: measurementTypes.map((type) => colors[type]),
+    grid,
+    xAxis: xAxes,
+    yAxis: yAxes,
+    series,
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'cross', snap: false },
+      backgroundColor: '#162b3c',
+      borderWidth: 0,
+      textStyle: { color: '#f7fafb', fontSize: 11 },
+      formatter: (params: any[]) => {
+        if (!params?.length) return ''
+        const first = params[0]
+        const lines = [`<strong>窗口位置:${Number(first.value?.[0] ?? 0).toFixed(4)}</strong>`]
+        params.forEach((item) => {
+          const value = item.value?.[1]
+          if (value !== undefined && value !== null) {
+            lines.push(`<span style="color:${item.color}">●</span> ${item.seriesName}: ${Number(value).toPrecision(7)}`)
+          }
+        })
+        return lines.join('<br/>')
+      },
+    },
+    legend: {
+      data: [...measurementTypes, '周期数据', '角度', '体积'],
+      top: 0,
+      left: 70,
+      itemWidth: 16,
+      itemHeight: 7,
+      textStyle: { color: '#60717b', fontSize: 11 },
+    },
+    dataZoom: [
+      { type: 'inside', xAxisIndex: xAxes.map((_, index) => index), zoomOnMouseWheel: true, moveOnMouseMove: true, start: zoom.start, end: zoom.end },
+      {
+        type: 'slider',
+        xAxisIndex: xAxes.map((_, index) => index),
+        bottom: 8,
+        height: 36,
+        borderColor: '#d9e2e5',
+        backgroundColor: '#f2f5f6',
+        fillerColor: 'rgba(31, 122, 140, 0.16)',
+        handleStyle: { color: '#1f7a8c' },
+        textStyle: { color: '#71808a', fontSize: 10 },
+        start: zoom.start,
+        end: zoom.end,
+      },
+    ],
+    graphic: props.mode === 'merge' ? [
+      {
+        type: 'text',
+        left: 70,
+        top: 31,
+        style: { text: '归一化值', fill: '#8b9aa2', fontSize: 11 },
+      },
+    ] : [],
+  }
+}
+
+function onDoubleClick(params: any) {
+  if (!props.data || !chart || !props.data.cycles.length) return
+  if (params.componentType !== 'series') return
+  const offsetX = params?.event?.offsetX
+  if (typeof offsetX !== 'number') return
+  const coordinate = chart.convertFromPixel({ gridIndex: 0 }, [offsetX, params.event.offsetY ?? 0]) as number[]
+  const x = coordinate?.[0]
+  if (typeof x !== 'number') return
+  const cycle = props.data.cycles.find((item) => x >= item.startX && x <= item.endX)
+  if (cycle) emit('periodDblclick', cycle)
+}
+
+function onBlankDoubleClick(event: any) {
+  if (!props.data || !chart || !props.data.cycles.length) return
+  if (event.target) return
+  const px = event.offsetX
+  const py = event.offsetY
+  if (typeof px !== 'number' || typeof py !== 'number') return
+  const gridCount = props.mode === 'merge' ? 1 : props.data.measurementTypes.length + 3
+  let inGrid = false
+  for (let index = 0; index < gridCount; index += 1) {
+    if (chart.containPixel({ gridIndex: index }, [px, py])) {
+      inGrid = true
+      break
+    }
+  }
+  if (!inGrid) return
+  const coordinate = chart.convertFromPixel({ gridIndex: 0 }, [px, py]) as number[]
+  const x = coordinate?.[0]
+  if (typeof x !== 'number') return
+  const cycle = props.data.cycles.find((item) => x >= item.startX && x <= item.endX)
+  if (cycle) emit('annotationToggle', cycle)
+}
+
+function renderFull(zoomMode: 'initial' | 'keep' = 'initial') {
+  if (!chart) return
+  chart.setOption(buildOption(zoomMode), true)
+  chart.resize()
+}
+
+function updateAnnotationOverlay() {
+  if (!chart || !props.data) return
+  chart.setOption({ series: buildAnnotationOverlaySeries(props.annotations ?? []) }, false)
+}
+
+function resize() {
+  chart?.resize()
+}
+
+watch(
+  () => props.data,
+  () => nextTick(() => renderFull('initial')),
+  { deep: true },
+)
+watch(
+  () => props.mode,
+  () => nextTick(() => renderFull('keep')),
+)
+watch(
+  () => props.annotations,
+  () => nextTick(updateAnnotationOverlay),
+  { deep: true },
+)
+
+onMounted(() => {
+  if (!chartElement.value) return
+  chart = echarts.init(chartElement.value, undefined, { renderer: 'canvas' })
+  chart.on('dblclick', onDoubleClick)
+  chart.getZr().on('dblclick', onBlankDoubleClick)
+  window.addEventListener('resize', resize)
+  renderFull('initial')
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', resize)
+  chart?.dispose()
+})
+</script>
+
+<template>
+  <div class="wave-chart-shell">
+    <div class="chart-toolbar-note">
+      <span class="chart-dot"></span>
+      <span>双击曲线查看功图,双击空白处标注 / 取消标注</span>
+      <span class="chart-separator"></span>
+      <span>滚轮缩放,底部滑轨横向浏览</span>
+      <span class="chart-point-count">已加载 {{ plottedPointCount.toLocaleString() }} 点<span v-if="secondValueStatus"> · {{ secondValueStatus }}</span></span>
+    </div>
+    <div ref="chartElement" class="wave-chart" :class="{ 'is-merge': mode === 'merge' }"></div>
+    <div v-if="annotations?.length" class="annotation-nav">
+      <div class="annotation-nav-head">
+        <span class="annotation-nav-title">标注导航</span>
+        <div class="annotation-nav-controls">
+          <el-button class="ghost-button" size="small" plain :disabled="activeAnnotationIndex <= 0" @click="focusAnnotation(activeAnnotationIndex - 1)">上一标注</el-button>
+          <span class="annotation-nav-counter">{{ annotationSegments.length ? `${activeAnnotationIndex + 1} / ${annotationSegments.length}` : '0 / 0' }}</span>
+          <el-button class="ghost-button" size="small" plain :disabled="activeAnnotationIndex >= annotationSegments.length - 1" @click="focusAnnotation(activeAnnotationIndex + 1)">下一标注</el-button>
+        </div>
+      </div>
+      <div class="annotation-nav-track">
+        <div
+          v-for="(segment, index) in annotationSegments"
+          :key="segment.annotation.id"
+          class="annotation-nav-seg"
+          :class="{
+            normal: segment.annotation.label === '正常',
+            abnormal: segment.annotation.label === '异常',
+            active: index === activeAnnotationIndex,
+          }"
+          :style="{
+            left: `${(segment.start / xMax) * 100}%`,
+            width: `${Math.max(((segment.end - segment.start) / xMax) * 100, 0.4)}%`,
+          }"
+          :title="`${segment.annotation.label} · 周期 ${segment.annotation.periodStart}—${segment.annotation.periodEnd} · 点 ${segment.annotation.sampleIndexStart}—${segment.annotation.sampleIndexEnd}`"
+          @click="focusAnnotation(index)"
+        />
+      </div>
+      <div class="annotation-nav-legend">
+        <span><i class="annotation-swatch normal"></i>正常样本</span>
+        <span><i class="annotation-swatch abnormal"></i>异常样本</span>
+      </div>
+    </div>
+    <div v-if="loading" class="chart-loading">正在整理波形数据…</div>
+    <div v-else-if="data && !hasPlottableData" class="chart-empty-hint">当前窗口没有可绘制的波形数据,请检查时间点和数据名称选择。</div>
+    <div v-if="data && !data.cycles.length" class="chart-empty-hint">当前窗口未检测到完整周期,仍可查看原始波形。</div>
+  </div>
+</template>

+ 1 - 0
frontend/src/env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 7 - 0
frontend/src/main.ts

@@ -0,0 +1,7 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import App from './App.vue'
+import './styles.css'
+
+createApp(App).use(ElementPlus).mount('#app')

+ 264 - 0
frontend/src/styles.css

@@ -0,0 +1,264 @@
+:root {
+  --el-color-primary: #409eff;
+  --el-color-primary-light-3: #79bbff;
+  --el-color-primary-light-5: #a0cfff;
+  --el-color-primary-light-7: #c6e2ff;
+  --el-color-primary-light-9: #ecf5ff;
+  --el-color-primary-dark-2: #337ecc;
+  --el-border-radius-base: 4px;
+  color: #303133;
+  background: #f2f3f5;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  min-width: 320px;
+  min-height: 100vh;
+  color: #303133;
+  background: #f2f3f5;
+}
+
+button, input, select { font: inherit; }
+button { cursor: pointer; }
+button:disabled, input:disabled, select:disabled { cursor: not-allowed; }
+h1, h2, p { margin: 0; }
+
+.app-shell { min-height: 100vh; background: #f2f3f5; }
+
+.login-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: linear-gradient(160deg, #304156 0%, #1d2b3a 100%); }
+.login-card { width: min(420px, 100%); display: flex; flex-direction: column; gap: 14px; padding: 34px 32px 30px; background: #fff; border-radius: 8px; box-shadow: 0 18px 48px rgba(0, 0, 0, .28); }
+.login-brand { display: flex; align-items: center; gap: 13px; margin-bottom: 6px; }
+.login-brand .brand-mark { flex-shrink: 0; }
+.login-brand h1 { color: #303133; }
+.login-brand .brand-kicker { color: #909399; }
+.login-hint { color: #909399; font-size: 13px; }
+.login-input { width: 100%; }
+.login-error { margin-top: 2px; }
+.login-button.el-button { width: 100%; height: 42px; margin: 6px 0 0; font-size: 15px; letter-spacing: .2em; }
+.header-logout.el-button { height: 32px; margin: 0; color: #d9e2ec; border-color: #7aa7d4; background: transparent; }
+.header-logout.el-button:hover { color: #fff; border-color: #f56c6c; background: rgba(245, 108, 108, .18); }
+
+.app-header {
+  min-height: 72px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 24px;
+  padding: 14px clamp(18px, 4vw, 60px);
+  color: #fff;
+  background: #304156;
+  border-bottom: 1px solid #253447;
+}
+
+.brand-lockup, .header-meta, .panel-heading, .chart-actions, .selection-controls, .time-strip-head, .time-strip-foot, .app-footer, .period-modal-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.brand-lockup { justify-content: flex-start; gap: 13px; }
+.brand-mark { width: 38px; height: 38px; display: flex; align-items: flex-end; gap: 3px; padding: 7px; background: #409eff; border-radius: 4px; }
+.brand-mark span { display: block; width: 5px; background: #fff; border-radius: 1px; }
+.brand-mark span:nth-child(1) { height: 11px; }
+.brand-mark span:nth-child(2) { height: 20px; }
+.brand-mark span:nth-child(3) { height: 26px; }
+.brand-kicker { margin-bottom: 3px; color: #a9c9e8; font-size: 10px; font-weight: 600; letter-spacing: .1em; }
+h1 { font-size: 18px; line-height: 1.35; font-weight: 600; }
+h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
+.header-meta { justify-content: flex-end; gap: 16px; color: #d9e2ec; font-size: 13px; }
+.live-indicator { display: inline-flex; align-items: center; gap: 7px; color: #cbd7e4; font-size: 12px; }
+.live-indicator i, .chart-status i { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: #67c23a; }
+.header-date { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.header-refresh.el-button { min-width: 68px; height: 32px; margin: 0; color: #fff; border-color: #7aa7d4; background: transparent; }
+.header-refresh.el-button:hover { color: #fff; border-color: #409eff; background: rgba(64, 158, 255, .18); }
+.spinning { animation: spin .8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.workspace {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 292px;
+  gap: 16px;
+  width: min(1600px, calc(100% - 32px));
+  margin: 20px auto 0;
+}
+
+.panel { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; box-shadow: 0 2px 12px rgba(0, 0, 0, .04); }
+.query-panel { grid-column: 1; grid-row: 1; }
+.selection-panel { grid-column: 1; grid-row: 2; }
+.chart-panel { grid-column: 1 / -1; grid-row: 3; }
+.insight-panel { grid-column: 2; grid-row: 1 / span 2; align-self: stretch; }
+
+.query-panel { padding: 22px 24px 18px; }
+.panel-heading { align-items: flex-start; gap: 16px; }
+.compact-heading { margin-bottom: 18px; }
+.connection-badge.el-tag { height: 28px; padding: 0 10px; font-size: 12px; }
+
+.query-grid {
+  display: grid;
+  grid-template-columns: minmax(220px, 1.7fr) minmax(260px, 2fr) minmax(180px, 1.25fr) minmax(180px, 1.25fr) 170px 126px;
+  align-items: end;
+  gap: 16px 12px;
+}
+
+.field { min-width: 0; }
+.window-field .field-label { white-space: nowrap; }
+.field-label { display: flex; align-items: baseline; gap: 7px; margin-bottom: 8px; color: #606266; font-size: 14px; font-weight: 500; line-height: 20px; }
+.field-label em { color: #909399; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 11px; font-style: normal; font-weight: 400; }
+.query-control { width: 100%; }
+.query-control.el-select, .query-control.el-date-editor { height: 40px; }
+.query-control.el-input-number { width: 100%; }
+.query-control .el-input__wrapper, .query-control.el-input-number { min-height: 40px; border-radius: 4px; }
+.query-control .el-input__inner, .query-control .el-select__placeholder, .query-control .el-select__selected-item, .query-control .el-date-editor__placeholder { font-size: 14px; }
+.query-control .el-tag { font-size: 13px; }
+.window-number .el-input__inner { text-align: center; }
+.query-button.el-button { width: 100%; height: 40px; margin: 0; border-radius: 4px; font-size: 14px; }
+.data-alert { margin-top: 16px; }
+
+.selection-panel { padding: 20px 24px 18px; }
+.time-strip { min-width: 0; }
+.time-strip-head { align-items: flex-start; margin-bottom: 12px; }
+.time-summary { padding-top: 4px; color: #909399; font-size: 13px; }
+.time-legend { display: inline-flex; align-items: center; gap: 4px; margin-right: 10px; color: #606266; font-size: 12px; }
+.time-legend i { width: 9px; height: 9px; display: inline-block; border-radius: 50%; }
+.legend-running { background: #2e7d32; }
+.legend-stopped { background: #c0c4cc; }
+.time-summary .mono, .readout-number { color: #303133; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: 500; }
+.summary-divider { padding: 0 8px; color: #dcdfe6; }
+.timeline-canvas-host { position: relative; width: 100%; min-height: 210px; overflow: hidden; border: 1px solid #ebeef5; border-radius: 4px; background: #fff; }
+.timeline-canvas-host canvas { display: block; width: 100%; height: 210px; cursor: crosshair; }
+.timeline-canvas-host.is-loading { opacity: .7; }
+.canvas-loading, .chart-loading, .modal-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #909399; background: rgba(255, 255, 255, .82); font-size: 13px; }
+.time-strip-foot { gap: 16px; margin-top: 12px; }
+.time-foot-item { display: flex; flex-direction: column; gap: 3px; min-width: 100px; }
+.time-foot-item.align-right { text-align: right; }
+.foot-label { color: #909399; font-size: 12px; }
+.time-foot-item strong { color: #606266; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; font-weight: 500; }
+.window-track { flex: 1; padding: 0 6px; }
+.window-track .el-slider { width: 100%; }
+.selection-controls { gap: 12px; margin-top: 14px; }
+.selection-readout { display: flex; align-items: flex-start; gap: 8px; color: #606266; font-size: 14px; flex: 1; min-width: 0; }
+.selection-readout > span:last-child { line-height: 1.7; word-break: break-all; }
+.selection-actions { display: flex; gap: 8px; flex-shrink: 0; }
+.selection-readout strong { color: #303133; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: 500; }
+.selection-accent { width: 7px; height: 7px; background: #409eff; border-radius: 50%; }
+.ghost-button.el-button { height: 32px; padding: 0 14px; font-size: 13px; }
+
+.chart-panel { min-height: 500px; padding: 22px 24px 15px; }
+.chart-heading { margin-bottom: 10px; }
+.chart-actions { gap: 15px; }
+.mode-switch.el-radio-group { display: inline-flex; }
+.mode-switch .el-radio-button__inner { font-size: 13px; }
+.annotation-width { display: inline-flex; align-items: baseline; gap: 5px; color: #909399; font-size: 13px; white-space: nowrap; }
+.annotation-width strong { color: #409eff; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 15px; font-weight: 600; }
+.sampling-switch { --el-switch-on-color: #409eff; }
+.chart-status { display: flex; align-items: center; gap: 8px; color: #909399; font-size: 13px; white-space: nowrap; }
+.chart-status i { width: 7px; height: 7px; }
+.chart-status i.busy { background: #e6a23c; animation: pulse 1s ease-in-out infinite alternate; }
+@keyframes pulse { to { opacity: .35; } }
+.wave-chart-shell { position: relative; }
+.chart-toolbar-note { display: flex; align-items: center; gap: 8px; height: 28px; color: #909399; font-size: 12px; }
+.chart-dot { width: 7px; height: 7px; border-radius: 50%; background: #409eff; }
+.chart-separator { width: 1px; height: 14px; margin: 0 3px; background: #dcdfe6; }
+.chart-point-count { margin-left: auto; color: #606266; font-weight: 500; }
+.wave-chart { width: 100%; height: 680px; }
+.wave-chart.is-merge { height: 560px; }
+.chart-empty-hint { margin-top: 7px; color: #e6a23c; font-size: 12px; }
+
+.annotation-nav { margin-top: 12px; padding: 10px 12px 12px; border: 1px solid #ebeef5; border-radius: 4px; background: #fafbfc; }
+.annotation-nav-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
+.annotation-nav-title { color: #606266; font-size: 13px; font-weight: 600; }
+.annotation-nav-controls { display: flex; align-items: center; gap: 8px; }
+.annotation-nav-controls .el-button { height: 28px; padding: 0 12px; font-size: 12px; }
+.annotation-nav-counter { color: #606266; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
+.annotation-nav-track { position: relative; height: 16px; border-radius: 3px; background: #eef1f3; overflow: hidden; }
+.annotation-nav-seg { position: absolute; top: 2px; bottom: 2px; border-radius: 2px; cursor: pointer; }
+.annotation-nav-seg.normal { background: #67c23a; opacity: .75; }
+.annotation-nav-seg.abnormal { background: #f56c6c; opacity: .85; }
+.annotation-nav-seg.active { outline: 2px solid #409eff; outline-offset: 1px; }
+.annotation-nav-seg:hover { opacity: 1; }
+.annotation-nav-legend { display: flex; gap: 12px; margin-top: 8px; color: #909399; font-size: 12px; }
+.annotation-nav-legend span { display: inline-flex; align-items: center; gap: 5px; }
+.annotation-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
+.annotation-swatch.normal { background: #67c23a; }
+.annotation-swatch.abnormal { background: #f56c6c; }
+
+.insight-panel { min-height: 0; padding: 16px; color: #303133; background: #fff; border-color: #ebeef5; }
+.insight-panel .compact-heading { margin-bottom: 10px; }
+.readout-number { color: #409eff; font-size: 23px; }
+.readout-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
+.readout-card { min-height: 46px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 12px; border: 1px solid #ebeef5; border-radius: 4px; background: #fff; }
+.readout-card span, .data-footprint span { color: #909399; font-size: 12px; }
+.readout-value { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
+.readout-card strong { margin: 0; color: #303133; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; font-weight: 500; text-align: right; }
+.readout-card small { margin: 0; color: #909399; font-size: 10px; white-space: nowrap; }
+.data-footprint { display: grid; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid #ebeef5; }
+.data-footprint div { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
+.data-footprint strong { max-width: 170px; overflow: hidden; color: #606266; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; font-weight: 500; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
+
+.app-footer { width: min(1600px, calc(100% - 32px)); margin: 0 auto; padding: 17px 2px 22px; color: #909399; font-size: 11px; }
+.app-footer b { padding: 0 5px; color: #409eff; font-weight: 400; }
+
+.modal-backdrop { position: fixed; z-index: 20; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(0, 0, 0, .45); backdrop-filter: blur(2px); }
+.period-modal { position: relative; width: min(960px, 100%); min-height: 500px; overflow: hidden; background: #fff; border-radius: 4px; box-shadow: 0 12px 32px rgba(0, 0, 0, .16); }
+.period-modal-head { align-items: flex-start; padding: 22px 25px 18px; color: #303133; background: #f5f7fa; border-bottom: 1px solid #ebeef5; }
+.period-modal-head .eyebrow { display: block; margin-bottom: 7px; color: #409eff; }
+.period-modal-head h2 { font-size: 21px; }
+.period-modal-head p { margin-top: 7px; color: #909399; font-size: 12px; }
+.icon-button { width: 29px; height: 29px; border: 0; color: #909399; background: transparent; font-size: 20px; line-height: 1; }
+.icon-button:hover { color: #409eff; }
+.period-stat-row { display: flex; flex-wrap: wrap; gap: 0; border-bottom: 1px solid #ebeef5; }
+.period-stat-row > div { flex: 1 1 140px; min-height: 68px; display: flex; flex-direction: column; justify-content: center; gap: 5px; padding: 9px 18px; border-right: 1px solid #ebeef5; }
+.period-stat-row span { color: #909399; font-size: 11px; }
+.period-stat-row strong { color: #606266; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; font-weight: 500; }
+.modal-tabs { display: flex; gap: 18px; padding: 17px 25px 0; }
+.modal-tabs button { border: 0; border-bottom: 2px solid transparent; padding: 0 0 8px; color: #909399; background: transparent; font-size: 13px; }
+.modal-tabs button.active { color: #409eff; border-bottom-color: #409eff; }
+.period-chart { width: 100%; height: 340px; }
+.modal-loading { position: absolute; z-index: 2; top: 100px; bottom: 0; background: rgba(255, 255, 255, .82); }
+
+@media (max-width: 1250px) {
+  .workspace { grid-template-columns: minmax(0, 1fr) 250px; }
+  .query-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+  .field-point { grid-column: span 2; }
+  .field-types { grid-column: span 1; }
+  .query-action { grid-column: span 1; }
+}
+
+@media (max-width: 930px) {
+  .workspace { display: flex; flex-direction: column; width: min(100% - 20px, 760px); }
+  .insight-panel { order: 4; }
+  .query-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .field-point, .field-types, .query-action { grid-column: span 2; }
+  .insight-panel { min-height: auto; }
+  .readout-grid { grid-template-columns: repeat(2, 1fr); margin: 14px -16px 0; }
+  .chart-heading { align-items: flex-start; flex-direction: column; }
+}
+
+@media (max-width: 620px) {
+  .app-header { align-items: flex-start; flex-direction: column; gap: 13px; padding: 15px; }
+  .header-meta { width: 100%; justify-content: space-between; gap: 8px; }
+  .header-date { max-width: 150px; }
+  .workspace { width: calc(100% - 16px); margin-top: 10px; gap: 10px; }
+  .query-panel, .selection-panel, .chart-panel { padding: 17px 14px 14px; }
+  .query-grid { grid-template-columns: 1fr; gap: 12px; }
+  .field-point, .field-types, .query-action { grid-column: span 1; }
+  .time-summary { font-size: 12px; text-align: right; }
+  .selection-controls { align-items: flex-start; flex-direction: column; }
+  .chart-actions { width: 100%; align-items: flex-start; flex-direction: column; gap: 10px; }
+  .chart-status { align-self: flex-end; }
+  .wave-chart { height: 600px; }
+  .wave-chart.is-merge { height: 520px; }
+  .chart-toolbar-note { font-size: 11px; }
+  .chart-point-count { display: none; }
+  .readout-grid { grid-template-columns: 1fr; }
+  .app-footer { width: calc(100% - 20px); align-items: flex-start; flex-direction: column; gap: 8px; line-height: 1.4; }
+  .modal-backdrop { align-items: flex-end; padding: 0; }
+  .period-modal { max-height: 92vh; overflow: auto; }
+  .period-stat-row > div { flex-basis: 50%; border-bottom: 1px solid #ebeef5; }
+  .period-chart { height: 300px; }
+}

+ 200 - 0
frontend/src/types.ts

@@ -0,0 +1,200 @@
+export const MEASUREMENT_TYPES = ['压力', '位移', '加速度'] as const
+export type MeasurementType = (typeof MEASUREMENT_TYPES)[number]
+
+export type QueryOption = {
+  pointName: string
+  measurementType: MeasurementType
+  minTime: string
+  maxTime: string
+  fileCount: number
+}
+
+export type FileInfo = {
+  id: number
+  sampleCount: number
+  sampleFrequencyHz: number
+  rpm: number
+}
+
+export type TimePoint = {
+  index: number
+  sampleTime: string
+  files: Partial<Record<MeasurementType, FileInfo>>
+}
+
+export type QueryOptionsResponse = {
+  source: 'database' | 'demo'
+  measurementTypes: MeasurementType[]
+  pointNames: string[]
+  options: QueryOption[]
+  notice: string | null
+}
+
+export type TimePointsResponse = {
+  source: 'database' | 'demo'
+  pointName: string
+  measurementTypes: MeasurementType[]
+  total: number
+  points: TimePoint[]
+  notice: string | null
+}
+
+export type WaveValue = {
+  value: [number, number]
+  x: number
+  rawValue: number
+  sampleIndex: number
+  waveFileId: number
+  sampleTime: string
+  secondValue: number | null
+}
+
+export type WaveSeries = {
+  measurementType: MeasurementType
+  color: string
+  data: WaveValue[]
+}
+
+export type Cycle = {
+  id: string
+  waveFileId: number
+  periodNo: number
+  pointIndex: number
+  sampleTime: string
+  startX: number
+  endX: number
+  startSampleIndex: number
+  endSampleIndex: number
+  sourceType: MeasurementType
+}
+
+export type WaveWindowResponse = {
+  source: 'database' | 'demo'
+  notice: string | null
+  pointName: string
+  measurementTypes: MeasurementType[]
+  points: TimePoint[]
+  xMin: number
+  xMax: number
+  series: WaveSeries[]
+  secondSeries: {
+    name: string
+    color: string
+    sourceMeasurementType: MeasurementType
+    data: Array<{
+      value: [number, number]
+      x: number
+      rawValue: number
+      sampleIndex: number
+      waveFileId: number
+      sampleTime: string
+    }>
+    finiteCount: number
+    nonZeroCount: number
+    min: number | null
+    max: number | null
+  }
+  angleSeries: {
+    color: string
+    data: Array<{
+      value: [number, number]
+      x: number
+      angle: number
+      sampleIndex: number
+      waveFileId: number
+      sampleTime: string
+    }>
+  }
+  volumeSeries: {
+    color: string
+    data: Array<{
+      value: [number, number]
+      x: number
+      volume: number
+      sampleIndex: number
+      waveFileId: number
+      sampleTime: string
+    }>
+    info: {
+      cylinder: string
+      boreMm: number
+      clearanceVolumeL: number
+      minVolumeL: number | null
+      maxVolumeL: number | null
+    } | null
+  }
+  cycles: Cycle[]
+  triggerXs: number[]
+  files: Array<{
+    id: number
+    pointIndex: number
+    sampleTime: string
+    measurementType: MeasurementType
+    sampleCount: number
+    sampleFrequencyHz: number
+  }>
+  diagnostics: Array<Record<string, string | number>>
+}
+
+export type PeriodDetail = {
+  source: 'database' | 'demo'
+  notice: string | null
+  waveFile: {
+    id: number
+    pointName: string
+    measurementType: MeasurementType
+    sampleTime: string
+    sampleFrequencyHz: number
+    sampleCount: number
+  }
+  period: {
+    periodNo: number
+    startSampleIndex: number
+    endSampleIndex: number
+    sampleCount: number
+    triggerSampleIndices: number[]
+  }
+  angles: number[]
+  angles360: number[]
+  pressure: number[]
+  volume: number[] | null
+  volumeInfo: {
+    cylinder: string
+    boreMm: number
+    clearanceVolumeL: number
+    minVolumeL: number
+    maxVolumeL: number
+  } | null
+  phases: Array<{
+    name: string
+    color: string
+    start: number
+    end: number
+  }>
+  diagnostics: Record<string, number>
+}
+
+export const ANNOTATION_LABELS = ['正常', '异常'] as const
+export type AnnotationLabel = (typeof ANNOTATION_LABELS)[number]
+
+export type Annotation = {
+  id: number
+  waveFileId: number
+  label: AnnotationLabel
+  periodStart: number
+  periodEnd: number
+  sampleIndexStart: number
+  sampleIndexEnd: number
+}
+
+export type AnnotationConfigResponse = {
+  source: 'database' | 'demo'
+  annotationWidth: number
+  notice: string | null
+}
+
+export type AnnotationListResponse = {
+  source: 'database' | 'demo'
+  annotations: Annotation[]
+  notice: string | null
+}

+ 16 - 0
frontend/tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "strict": true,
+    "skipLibCheck": true,
+    "jsx": "preserve",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "esModuleInterop": true,
+    "lib": ["ES2022", "DOM", "DOM.Iterable"]
+  },
+  "include": ["src/**/*.ts", "src/**/*.vue"]
+}

+ 13 - 0
frontend/vite.config.ts

@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    port: 5173,
+    allowedHosts: ['2frps.roadbyway.com'],
+    proxy: {
+      '/api': 'http://127.0.0.1:8000',
+    },
+  },
+})

+ 196 - 0
部署.md

@@ -0,0 +1,196 @@
+# 部署与启动说明
+
+本文档说明「故障预测波形工作台」前后端的部署与启动方式。
+
+## 一、环境要求
+
+| 组件 | 版本要求 |
+| --- | --- |
+| Python | 3.10+(建议 3.11 / 3.12 / 3.13) |
+| Node.js | 18+(建议 20+) |
+| npm | 随 Node.js 自带 |
+| MySQL | 5.7+ / 8.x(已建好 `compressorsensor` 库,含 `wave_file`、`wave_sample` 表) |
+
+## 二、目录结构
+
+```
+Compressor/
+├── backend/
+│   ├── requirements.txt      # Python 依赖
+│   └── app/
+│       ├── main.py           # FastAPI 入口与全部接口
+│       ├── config.py         # 配置(读环境变量 / 数据库.md)
+│       ├── db.py             # 数据库连接 + wave_annotation 建表语句
+│       ├── auth.py           # 登录 token(进程内存存储)
+│       ├── services/         # 数据查询与标注服务
+│       └── algorithms/       # 周期识别等算法
+├── frontend/
+│   ├── package.json
+│   ├── vite.config.ts        # 开发端口 5173,/api 代理到后端
+│   └── src/                  # Vue3 + ElementPlus + ECharts
+├── 数据库.md                 # 数据库连接信息(不入库、不提交敏感信息)
+└── README.md
+```
+
+## 三、数据库准备
+
+1. 确认 MySQL 中已有 `compressorsensor` 数据库及 `wave_file`、`wave_sample` 表。
+2. 数据库连接信息放在项目根目录 `数据库.md`,格式如下(键名支持 `host` / `port` / `user` / `password` / `db_name`):
+
+   ```
+   host : 127.0.0.1
+   port : 3306
+   user : root
+   password : 你的密码
+   db_name : compressorsensor
+   ```
+
+3. 标注索引表 `wave_annotation` 无需手动创建:后端启动时会自动执行 `CREATE TABLE IF NOT EXISTS`(见 `backend/app/db.py`)。
+
+## 四、后端部署
+
+### 4.1 安装依赖
+
+```bash
+cd Compressor
+python3 -m pip install -r backend/requirements.txt
+```
+
+### 4.2 开发模式启动
+
+```bash
+cd Compressor
+uvicorn app.main:app --app-dir backend --reload --port 8000
+```
+
+- `--reload` 修改代码自动重启(仅开发用)。
+- 后端默认监听 `127.0.0.1:8000`。
+
+### 4.3 生产模式启动
+
+```bash
+cd Compressor
+uvicorn app.main:app --app-dir backend --host 0.0.0.0 --port 8000
+```
+
+> **注意**:登录 token 保存在进程内存中(`backend/app/auth.py`)。因此生产环境**请使用单进程启动**(不要加 `--workers N`,或显式 `--workers 1`),否则多进程各自持有独立 token 表,登录后请求可能被其他进程判定为未登录。若需要多进程/多实例,请改用 Redis 等共享存储(当前未实现)。
+
+### 4.4 用环境变量覆盖数据库连接
+
+不修改 `数据库.md` 也可以直接用环境变量覆盖:
+
+```bash
+DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=root DB_PASSWORD='...' \
+DB_NAME=compressorsensor \
+uvicorn app.main:app --app-dir backend --host 0.0.0.0 --port 8000
+```
+
+## 五、前端部署
+
+### 5.1 安装依赖
+
+```bash
+cd Compressor/frontend
+npm install
+```
+
+### 5.2 开发模式启动
+
+```bash
+cd Compressor/frontend
+npm run dev
+```
+
+浏览器访问 `http://localhost:5173`。开发服务器把 `/api` 代理到 `http://127.0.0.1:8000`(见 `vite.config.ts`),因此本地开发无需额外配置。
+
+### 5.3 生产构建
+
+```bash
+cd Compressor/frontend
+npm run build
+```
+
+产物在 `frontend/dist/`,是纯静态文件。
+
+### 5.4 生产部署(推荐:nginx 托管 + 反向代理)
+
+把 `dist/` 部署到 nginx,并将 `/api` 反向代理到后端,前后端同源、无需处理 CORS:
+
+```nginx
+server {
+    listen 80;
+    server_name your-domain.com;
+
+    root /path/to/Compressor/frontend/dist;
+    index index.html;
+
+    # SPA 路由回退(当前为单页应用,一般可省略)
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    # 后端接口反向代理
+    location /api/ {
+        proxy_pass http://127.0.0.1:8000;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+    }
+}
+```
+
+### 5.5 生产部署(备选:前端独立域名直连后端)
+
+前端构建时可指定后端地址(跨域直连):
+
+```bash
+VITE_API_BASE=http://backend-host:8000 npm run build
+```
+
+此时后端需放行该来源,启动后端时设置:
+
+```bash
+CORS_ORIGIN=http://your-frontend-domain \
+uvicorn app.main:app --app-dir backend --host 0.0.0.0 --port 8000
+```
+
+## 六、环境变量一览
+
+后端相关:
+
+| 环境变量 | 默认值 | 说明 |
+| --- | --- | --- |
+| `DB_HOST` | `数据库.md` 的 `host`,否则 `127.0.0.1` | MySQL 地址 |
+| `DB_PORT` | `数据库.md` 的 `port`,否则 `3306` | MySQL 端口 |
+| `DB_USER` | `数据库.md` 的 `user`,否则 `root` | MySQL 用户 |
+| `DB_PASSWORD` | `数据库.md` 的 `password` | MySQL 密码 |
+| `DB_NAME` | `数据库.md` 的 `db_name`,否则 `compressorsensor` | 数据库名 |
+| `DB_CONNECT_TIMEOUT` | `3` | 连接超时(秒) |
+| `DEMO_MODE` | `never` | `never` 数据库失败即报错;`always` 强制演示数据;`auto` 数据库失败时回退演示数据 |
+| `CORS_ORIGIN` | `http://localhost:5173` | 允许的跨域来源 |
+| `ANNOTATION_WIDTH` | `10` | 标注宽度(几个周期为一段) |
+| `AUTH_USER` | `aaabbb` | 登录账号 |
+| `AUTH_PASSWORD` | `Aa*147258&cd` | 登录密码 |
+| `AUTH_TOKEN_TTL_SECONDS` | `43200` | token 有效期(秒,默认 12 小时) |
+
+前端相关:
+
+| 环境变量 | 默认值 | 说明 |
+| --- | --- | --- |
+| `VITE_API_BASE` | 空(走相对路径,配合 nginx 反代) | 前端请求后端的基础地址,如 `http://backend-host:8000` |
+
+## 七、登录账号
+
+内置账号(可用 `AUTH_USER` / `AUTH_PASSWORD` 环境变量覆盖):
+
+- 账号:`aaabbb`
+- 密码:`Aa*147258&cd`
+
+除 `POST /api/login` 外,所有接口都需要在请求头携带 `Authorization: Bearer <token>`。token 默认 12 小时有效,服务重启后失效,需重新登录。
+
+## 八、注意事项
+
+1. **敏感信息**:`数据库.md` 含数据库密码,已被 `.gitignore` 忽略,请勿提交到版本库。
+2. **自动建表**:`wave_annotation` 标注表由后端启动时自动创建,无需手动建表。
+3. **token 存储**:token 存于进程内存,生产请单进程运行(见 4.3)。
+4. **演示模式**:无数据库时可用 `DEMO_MODE=always` 启动,前端与标注功能均可体验(标注走内存,不落库)。
+5. **验证部署**:登录后访问 `GET /api/health`(需带 token)返回 `{"status": "ok"}` 即表示后端正常。