Commit 4d970f15 authored by luwei's avatar luwei

功能开发

parent b599ac8f
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.services.eval_service import EvalService
from app.utils.response import success_response
router = APIRouter()
service = EvalService()
class EvalRequest(BaseModel):
model_id: int
package_id: int
@router.get('/packages')
def list_packages():
return success_response(data=service.list_packages())
@router.get('/models')
def list_models():
return success_response(data=service.list_saved_models())
@router.get('/records')
def list_records():
return success_response(data=service.list_records())
@router.get('/records/{record_id}')
def get_record(record_id: int):
try:
return success_response(data=service.get_record(record_id))
except ValueError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
@router.delete('/records/{record_id}')
def delete_record(record_id: int):
try:
service.delete_record(record_id)
return success_response(data=True, message='评估记录已删除')
except ValueError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
@router.post('/run')
def run_evaluation(request: EvalRequest):
try:
result = service.evaluate(model_id=request.model_id, package_id=request.package_id)
return success_response(data=result, message='评估完成')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.services.package_management_service import PackageManagementService
from app.utils.response import success_response
router = APIRouter()
service = PackageManagementService()
class CategoryCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
parent_id: str | int | None = Field(default='all')
class CategoryUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
@router.get('/categories')
def get_categories():
return success_response(data=service.get_category_tree())
@router.post('/categories')
def create_category(request: CategoryCreateRequest):
try:
parent_id = 'all' if request.parent_id is None else str(request.parent_id)
category = service.create_category(name=request.name.strip(), parent_id=parent_id)
return success_response(data=category, message='分类创建成功')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put('/categories/{category_id}')
def update_category(category_id: str, request: CategoryUpdateRequest):
try:
category = service.update_category(category_id=category_id, name=request.name.strip())
return success_response(data=category, message='分类更新成功')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.delete('/categories/{category_id}')
def delete_category(category_id: str):
try:
service.delete_category(category_id=category_id)
return success_response(data=True, message='分类删除成功')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
# Must be declared before /{package_id} routes to avoid path conflict
@router.get('/data-files')
def list_all_data_files():
return success_response(data=service.list_all_data_files())
class PackageCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
category_id: str | int | None = Field(default=None)
remark: str | None = Field(default=None)
file_ids: list[int] = Field(default_factory=list)
class PreviewRequest(BaseModel):
file_ids: list[int] = Field(default_factory=list)
@router.post('/preview')
def preview_package(request: PreviewRequest, limit: int = Query(default=300, ge=1, le=2000)):
try:
result = service.preview_records(file_ids=request.file_ids, limit=limit)
return success_response(data=result)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get('')
def list_packages(
category_id: str = Query(default=''),
name: str = Query(default=''),
):
return success_response(data=service.list_packages(category_id=category_id, name=name.strip()))
@router.post('')
def create_package(request: PackageCreateRequest):
try:
category_id = None if request.category_id in (None, '', 'all') else str(request.category_id)
pkg = service.create_package(
name=request.name.strip(),
category_id=category_id,
remark=request.remark,
file_ids=request.file_ids,
)
return success_response(data=pkg, message='数据包创建成功')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get('/{package_id}/records')
def get_package_records(package_id: str, limit: int = Query(default=500, ge=1, le=5000)):
try:
result = service.get_package_records(package_id=package_id, limit=limit)
return success_response(data=result)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.delete('/{package_id}')
def delete_package(package_id: str):
try:
service.delete_package(package_id=package_id)
return success_response(data=True, message='数据包删除成功')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.services.train_service import TrainService
from app.utils.response import success_response
router = APIRouter()
service = TrainService()
# ── request schemas ───────────────────────────────────────────────────────────
class LSTMParams(BaseModel):
seq_len: int = Field(default=20, ge=5, le=500)
hidden_size: int = Field(default=64, ge=8, le=1024)
num_layers: int = Field(default=2, ge=1, le=8)
epochs: int = Field(default=50, ge=1, le=2000)
batch_size: int = Field(default=32, ge=1, le=512)
learning_rate: float = Field(default=0.001, gt=0, le=1)
train_ratio: float = Field(default=0.8, ge=0.5, le=0.99)
class CreateTaskRequest(BaseModel):
model_name: str = Field(min_length=1, max_length=255)
package_id: int
params: LSTMParams = Field(default_factory=LSTMParams)
# ── packages ──────────────────────────────────────────────────────────────────
@router.get('/packages')
def list_packages():
return success_response(data=service.list_packages())
# ── tasks ─────────────────────────────────────────────────────────────────────
@router.get('/tasks')
def list_tasks():
return success_response(data=service.list_tasks())
@router.get('/tasks/{task_id}')
def get_task(task_id: int):
try:
return success_response(data=service.get_task(task_id))
except ValueError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
@router.post('/tasks')
def create_task(request: CreateTaskRequest):
try:
task = service.create_task(
model_name=request.model_name.strip(),
package_id=request.package_id,
params=request.params.model_dump(),
)
return success_response(data=task, message='训练任务已启动')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.post('/tasks/{task_id}/cancel')
def cancel_task(task_id: int):
try:
service.cancel_task(task_id)
return success_response(data=True, message='取消请求已发送')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.post('/tasks/{task_id}/restart')
def restart_task(task_id: int):
try:
task = service.restart_task(task_id)
return success_response(data=task, message='重新训练已启动')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.delete('/tasks/{task_id}')
def delete_task(task_id: int):
try:
service.delete_task(task_id)
return success_response(data=True, message='任务已删除')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.post('/tasks/{task_id}/save')
def save_model(task_id: int):
try:
model = service.save_model(task_id)
return success_response(data=model, message='模型已保存')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
# ── saved models ──────────────────────────────────────────────────────────────
@router.get('/models')
def list_models():
return success_response(data=service.list_saved_models())
@router.delete('/models/{model_id}')
def delete_model(model_id: int):
try:
service.delete_saved_model(model_id)
return success_response(data=True, message='模型已删除')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
...@@ -4,10 +4,20 @@ from fastapi.middleware.cors import CORSMiddleware ...@@ -4,10 +4,20 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api.data_management import router as data_management_router from app.api.data_management import router as data_management_router
from app.api.eval_management import router as eval_management_router
from app.api.package_management import router as package_management_router
from app.api.train_management import router as train_management_router
from app.database import Base, engine
from app.models import Category, DataFile, DataPackage, DataPackageFile # noqa: F401
from app.models.eval_management import EvalRecord # noqa: F401
from app.models.train_management import SavedModel, TrainTask # noqa: F401
from app.utils.response import error_response, success_response from app.utils.response import error_response, success_response
def create_app() -> FastAPI: def create_app() -> FastAPI:
# Auto-create any missing tables (safe: uses CREATE TABLE IF NOT EXISTS internally)
Base.metadata.create_all(bind=engine)
app = FastAPI( app = FastAPI(
title='Thermal Control System API', title='Thermal Control System API',
version='0.1.0', version='0.1.0',
...@@ -47,6 +57,9 @@ def create_app() -> FastAPI: ...@@ -47,6 +57,9 @@ def create_app() -> FastAPI:
return success_response(data={'status': 'ok'}, message='服务正常') return success_response(data={'status': 'ok'}, message='服务正常')
app.include_router(data_management_router, prefix='/api/data', tags=['数据管理']) app.include_router(data_management_router, prefix='/api/data', tags=['数据管理'])
app.include_router(package_management_router, prefix='/api/packages', tags=['数据包管理'])
app.include_router(train_management_router, prefix='/api/train', tags=['模型训练'])
app.include_router(eval_management_router, prefix='/api/eval', tags=['模型评估'])
return app return app
......
"""LSTM inference — predict actual_temperature for every data point in a dataset.
Requires the same PyTorch environment used for training.
"""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any
import numpy as np
from app.ml.lstm_trainer import (
FEATURE_COLS,
TARGET_IDX,
_LSTMModel,
_check_torch,
_extract_features,
)
# Maximum number of chart points stored in DB (subsample if dataset is larger)
CHART_MAX_POINTS = 1500
def predict_lstm(
records: list[dict[str, Any]],
model_path: Path,
) -> dict[str, Any]:
"""
Run inference on *records* using the saved model at *model_path*.
Returns a dict::
{
'total_count': int, # total prediction points
'mae': float,
'rmse': float,
'chart_data': [ # sampled up to CHART_MAX_POINTS
{'index': int, 'time': str, 'actual': float, 'predicted': float},
...
],
}
"""
_check_torch()
import torch
checkpoint = torch.load(model_path, map_location='cpu', weights_only=False)
seq_len: int = int(checkpoint.get('seq_len', 20))
hidden_size: int = int(checkpoint.get('hidden_size', 64))
num_layers: int = int(checkpoint.get('num_layers', 2))
input_size: int = int(checkpoint.get('input_size', len(FEATURE_COLS)))
data_min = np.array(checkpoint['data_min'], dtype=np.float32)
data_max = np.array(checkpoint['data_max'], dtype=np.float32)
data_range = data_max - data_min
data_range[data_range == 0] = 1.0
# ── prepare data ─────────────────────────────────────────────────────────
raw = _extract_features(records)
if len(raw) < seq_len + 1:
raise ValueError(
f'数据量不足:需至少 {seq_len + 1} 条有效记录,当前仅 {len(raw)} 条。'
)
data_norm = (raw - data_min) / data_range
# Build all sequences at once → (N-seq_len, seq_len, features)
n = len(data_norm)
indices = np.arange(n - seq_len)
X = np.stack([data_norm[i : i + seq_len] for i in indices], axis=0).astype(np.float32)
# ── model inference ───────────────────────────────────────────────────────
model = _LSTMModel(input_size, hidden_size, num_layers)
model.load_state_dict(checkpoint['model_state'])
model.eval()
with torch.no_grad():
X_t = torch.tensor(X)
# Batch to avoid OOM on very large datasets
batch_sz = 512
preds_norm: list[float] = []
for start in range(0, len(X_t), batch_sz):
out = model(X_t[start : start + batch_sz])
preds_norm.extend(out.numpy().tolist())
preds_norm_arr = np.array(preds_norm, dtype=np.float32)
# Denormalize
t_min = float(data_min[TARGET_IDX])
t_range = float(data_range[TARGET_IDX])
preds_real = preds_norm_arr * t_range + t_min
actuals_real = raw[seq_len:, TARGET_IDX]
# ── metrics ───────────────────────────────────────────────────────────────
errors = preds_real - actuals_real
mae = float(np.mean(np.abs(errors)))
rmse = float(math.sqrt(float(np.mean(errors ** 2))))
# ── build result points ───────────────────────────────────────────────────
times = [str(r.get('time', '')) for r in records]
total = len(preds_real)
# Subsample for chart storage
if total <= CHART_MAX_POINTS:
sample_idx = list(range(total))
else:
step = total / CHART_MAX_POINTS
sample_idx = [int(i * step) for i in range(CHART_MAX_POINTS)]
chart_data = [
{
'index': seq_len + i,
'time': times[seq_len + i] if (seq_len + i) < len(times) else str(seq_len + i),
'actual': round(float(actuals_real[i]), 4),
'predicted': round(float(preds_real[i]), 4),
}
for i in sample_idx
]
return {
'total_count': total,
'mae': round(mae, 6),
'rmse': round(rmse, 6),
'chart_data': chart_data,
}
"""LSTM temperature forecasting trainer.
Uses PyTorch if available. If not installed, raises a descriptive RuntimeError
so the train service can mark the task as failed with a helpful message.
"""
from __future__ import annotations
import threading
from pathlib import Path
from typing import Callable
import numpy as np
# ── optional torch import ────────────────────────────────────────────────────
_TORCH_AVAILABLE = False
try:
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
_TORCH_AVAILABLE = True
class _LSTMModel(nn.Module):
def __init__(self, input_size: int, hidden_size: int, num_layers: int) -> None:
super().__init__()
dropout = 0.2 if num_layers > 1 else 0.0
self.lstm = nn.LSTM(
input_size,
hidden_size,
num_layers,
batch_first=True,
dropout=dropout,
)
self.fc = nn.Linear(hidden_size, 1)
def forward(self, x: 'torch.Tensor') -> 'torch.Tensor':
out, _ = self.lstm(x)
return self.fc(out[:, -1, :]).squeeze(-1)
except ImportError:
pass # _TORCH_AVAILABLE stays False
# ── constants ────────────────────────────────────────────────────────────────
FEATURE_COLS = ['current', 'voltage', 'set_temperature', 'actual_temperature']
TARGET_COL = 'actual_temperature'
TARGET_IDX = FEATURE_COLS.index(TARGET_COL)
def _check_torch() -> None:
if not _TORCH_AVAILABLE:
raise RuntimeError(
'PyTorch 未安装,请执行:\n'
' pip install torch --index-url https://download.pytorch.org/whl/cpu\n'
'安装后重启后端服务。'
)
# ── data helpers ─────────────────────────────────────────────────────────────
def _extract_features(records: list[dict]) -> np.ndarray:
"""Convert records list → (N, 4) float32 array; rows with parse errors skipped."""
rows: list[list[float]] = []
for r in records:
try:
row = [float(r.get(col) or 0) for col in FEATURE_COLS]
if any(v != v for v in row): # NaN guard
continue
rows.append(row)
except (TypeError, ValueError):
continue
return np.array(rows, dtype=np.float32)
def _make_sequences(data: np.ndarray, seq_len: int) -> tuple[np.ndarray, np.ndarray]:
"""Return (X, y): X.shape=(N, seq_len, features), y.shape=(N,)."""
xs: list[np.ndarray] = []
ys: list[float] = []
for i in range(len(data) - seq_len):
xs.append(data[i : i + seq_len])
ys.append(data[i + seq_len, TARGET_IDX])
return np.array(xs, dtype=np.float32), np.array(ys, dtype=np.float32)
# ── public training entry point ───────────────────────────────────────────────
def train_lstm(
records: list[dict],
params: dict,
save_path: Path,
on_progress: Callable[[int, float, float | None], None],
cancel_event: threading.Event,
) -> dict[str, float | None]:
"""
Train an LSTM model on *records* and persist it to *save_path*.
Args:
records: list of dicts with keys in FEATURE_COLS.
params: hyper-parameter dict (seq_len, hidden_size, num_layers,
epochs, batch_size, learning_rate, train_ratio).
save_path: destination .pt file.
on_progress: callback(pct, train_loss, val_loss) called after each epoch.
cancel_event: when set, training stops with InterruptedError.
Returns:
{'train_loss': float, 'val_loss': float|None}
"""
_check_torch()
seq_len = max(1, int(params.get('seq_len', 20)))
hidden_size = max(1, int(params.get('hidden_size', 64)))
num_layers = max(1, int(params.get('num_layers', 2)))
epochs = max(1, int(params.get('epochs', 50)))
batch_size = max(1, int(params.get('batch_size', 32)))
lr = float(params.get('learning_rate', 0.001))
train_ratio = min(0.99, max(0.5, float(params.get('train_ratio', 0.8))))
# ── data preparation ────────────────────────────────────────────────────
data = _extract_features(records)
min_required = seq_len + 10
if len(data) < min_required:
raise ValueError(
f'有效数据量不足:需至少 {min_required} 条,当前仅 {len(data)} 条。'
'请检查数据包内容或减小序列长度。'
)
# min-max normalisation per feature
data_min = data.min(axis=0)
data_max = data.max(axis=0)
data_range = data_max - data_min
data_range[data_range == 0] = 1.0
data_norm = (data - data_min) / data_range
X, y = _make_sequences(data_norm, seq_len)
n_train = max(1, int(len(X) * train_ratio))
X_train, y_train = X[:n_train], y[:n_train]
X_val, y_val = X[n_train:], y[n_train:]
device = torch.device('cpu')
X_train_t = torch.tensor(X_train).to(device)
y_train_t = torch.tensor(y_train).to(device)
has_val = len(X_val) > 0
if has_val:
X_val_t = torch.tensor(X_val).to(device)
y_val_t = torch.tensor(y_val).to(device)
train_loader = DataLoader(
TensorDataset(X_train_t, y_train_t),
batch_size=batch_size,
shuffle=True,
)
# ── model ────────────────────────────────────────────────────────────────
input_size = len(FEATURE_COLS)
model = _LSTMModel(input_size, hidden_size, num_layers).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
criterion = nn.MSELoss()
train_loss = 0.0
val_loss: float | None = None
for epoch in range(epochs):
if cancel_event.is_set():
raise InterruptedError('训练已取消')
# ── train step ───────────────────────────────────────────────────────
model.train()
epoch_loss = 0.0
for xb, yb in train_loader:
optimizer.zero_grad()
pred = model(xb)
loss = criterion(pred, yb)
loss.backward()
optimizer.step()
epoch_loss += loss.item() * len(xb)
train_loss = epoch_loss / len(X_train)
# ── val step ─────────────────────────────────────────────────────────
if has_val:
model.eval()
with torch.no_grad():
val_pred = model(X_val_t)
val_loss = criterion(val_pred, y_val_t).item()
pct = int((epoch + 1) / epochs * 100)
on_progress(pct, train_loss, val_loss)
# ── persist ──────────────────────────────────────────────────────────────
save_path.parent.mkdir(parents=True, exist_ok=True)
torch.save(
{
'model_state': model.state_dict(),
'params': params,
'data_min': data_min.tolist(),
'data_max': data_max.tolist(),
'feature_cols': FEATURE_COLS,
'target_col': TARGET_COL,
'input_size': input_size,
'hidden_size': hidden_size,
'num_layers': num_layers,
'seq_len': seq_len,
},
save_path,
)
return {
'train_loss': round(float(train_loss), 6),
'val_loss': round(float(val_loss), 6) if val_loss is not None else None,
}
from app.models.data_management import Category, DataFile from app.models.data_management import Category, DataFile, DataPackage, DataPackageFile
from app.models.eval_management import EvalRecord
from app.models.train_management import SavedModel, TrainTask
__all__ = ['Category', 'DataFile'] __all__ = ['Category', 'DataFile', 'DataPackage', 'DataPackageFile', 'TrainTask', 'SavedModel', 'EvalRecord']
...@@ -44,3 +44,37 @@ class DataFile(Base): ...@@ -44,3 +44,37 @@ class DataFile(Base):
data_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text('0'), comment='数据条数') data_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text('0'), comment='数据条数')
uploaded_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP')) uploaded_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
remark: Mapped[str | None] = mapped_column(Text, nullable=True) remark: Mapped[str | None] = mapped_column(Text, nullable=True)
class DataPackage(Base):
__tablename__ = 'data_packages'
__table_args__ = (
Index('idx_pkg_category', 'category_id'),
{'mysql_comment': '数据包表'},
)
id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, comment='数据包名称')
category_id: Mapped[int | None] = mapped_column(BIGINT, nullable=True, comment='分类ID(data_package类型)')
remark: Mapped[str | None] = mapped_column(Text, nullable=True, comment='备注')
data_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text('0'), comment='数据条数')
created_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
updated_at: Mapped[str] = mapped_column(
TIMESTAMP,
nullable=False,
server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
)
class DataPackageFile(Base):
__tablename__ = 'data_package_files'
__table_args__ = (
Index('idx_dpf_package', 'package_id'),
Index('idx_dpf_file', 'file_id'),
{'mysql_comment': '数据包文件关联表'},
)
id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
package_id: Mapped[int] = mapped_column(BIGINT, nullable=False, comment='数据包ID')
file_id: Mapped[int] = mapped_column(BIGINT, nullable=False, comment='数据文件ID')
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text('0'), comment='排序')
from sqlalchemy import BIGINT, FLOAT, TIMESTAMP, Index, Integer, JSON, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class EvalRecord(Base):
__tablename__ = 'eval_records'
__table_args__ = (
Index('idx_eval_model', 'model_id'),
Index('idx_eval_package', 'package_id'),
{'mysql_comment': '模型评估记录表'},
)
id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
model_id: Mapped[int] = mapped_column(BIGINT, nullable=False, comment='已保存模型ID')
model_name: Mapped[str] = mapped_column(String(255), nullable=False, comment='模型名称')
package_id: Mapped[int] = mapped_column(BIGINT, nullable=False, comment='数据包ID')
package_name: Mapped[str] = mapped_column(String(255), nullable=False, comment='数据包名称')
total_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text('0'), comment='评估数据点总数')
mae: Mapped[float | None] = mapped_column(FLOAT, nullable=True, comment='平均绝对误差')
rmse: Mapped[float | None] = mapped_column(FLOAT, nullable=True, comment='均方根误差')
# Store up to ~2000 sampled points for chart rendering
chart_data: Mapped[list | None] = mapped_column(JSON, nullable=True, comment='图表数据(采样)')
created_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
from sqlalchemy import BIGINT, FLOAT, TIMESTAMP, Enum, Index, Integer, JSON, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TrainTask(Base):
__tablename__ = 'train_tasks'
__table_args__ = (
Index('idx_task_status', 'status'),
{'mysql_comment': '模型训练任务表'},
)
id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
model_name: Mapped[str] = mapped_column(String(255), nullable=False, comment='模型名称')
package_id: Mapped[int] = mapped_column(BIGINT, nullable=False, comment='数据包ID')
package_name: Mapped[str] = mapped_column(String(255), nullable=False, comment='数据包名称')
params: Mapped[dict] = mapped_column(JSON, nullable=False, comment='LSTM超参数')
status: Mapped[str] = mapped_column(
Enum('pending', 'running', 'completed', 'failed', 'cancelled', name='train_status_enum'),
nullable=False,
server_default=text("'pending'"),
comment='训练状态',
)
progress: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text('0'), comment='进度 0-100')
train_loss: Mapped[float | None] = mapped_column(FLOAT, nullable=True, comment='训练损失')
val_loss: Mapped[float | None] = mapped_column(FLOAT, nullable=True, comment='验证损失')
error_msg: Mapped[str | None] = mapped_column(Text, nullable=True, comment='错误信息')
is_saved: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text('0'), comment='是否已保存为模型')
created_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
updated_at: Mapped[str] = mapped_column(
TIMESTAMP,
nullable=False,
server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
)
class SavedModel(Base):
__tablename__ = 'saved_models'
__table_args__ = (
Index('idx_saved_task', 'task_id'),
{'mysql_comment': '已保存模型表'},
)
id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(BIGINT, nullable=False, comment='来源训练任务ID')
model_name: Mapped[str] = mapped_column(String(255), nullable=False, comment='模型名称')
package_id: Mapped[int] = mapped_column(BIGINT, nullable=False, comment='数据包ID')
package_name: Mapped[str] = mapped_column(String(255), nullable=False, comment='数据包名称')
params: Mapped[dict] = mapped_column(JSON, nullable=False, comment='LSTM超参数')
file_path: Mapped[str] = mapped_column(String(500), nullable=False, comment='模型文件路径')
train_loss: Mapped[float | None] = mapped_column(FLOAT, nullable=True)
val_loss: Mapped[float | None] = mapped_column(FLOAT, nullable=True)
created_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
from __future__ import annotations
from pathlib import Path
from typing import Any
from app.database import db_session
from app.ml.lstm_predictor import predict_lstm
from app.models import DataFile, DataPackage, DataPackageFile
from app.models.eval_management import EvalRecord
from app.models.train_management import SavedModel
from app.services.data_management_service import DataManagementService
class EvalService:
def __init__(self) -> None:
self._dm = DataManagementService()
# ── dropdown data ─────────────────────────────────────────────────────────
def list_packages(self) -> list[dict[str, Any]]:
with db_session() as session:
rows = session.query(DataPackage).order_by(DataPackage.created_at.desc()).all()
return [{'id': p.id, 'name': p.name, 'data_count': p.data_count} for p in rows]
def list_saved_models(self) -> list[dict[str, Any]]:
with db_session() as session:
rows = session.query(SavedModel).order_by(SavedModel.created_at.desc()).all()
return [{'id': m.id, 'model_name': m.model_name, 'package_name': m.package_name} for m in rows]
# ── evaluate ──────────────────────────────────────────────────────────────
def evaluate(self, model_id: int, package_id: int) -> dict[str, Any]:
with db_session() as session:
model = session.query(SavedModel).filter(SavedModel.id == model_id).first()
if not model:
raise ValueError('模型不存在')
pkg = session.query(DataPackage).filter(DataPackage.id == package_id).first()
if not pkg:
raise ValueError('数据包不存在')
model_path = Path(model.file_path)
model_name = model.model_name
package_name = pkg.name
if not model_path.exists():
raise ValueError('模型文件不存在,请重新训练并保存模型')
records = self._load_package_records(package_id)
if not records:
raise ValueError('数据包中没有有效数据')
result = predict_lstm(records=records, model_path=model_path)
with db_session() as session:
record = EvalRecord(
model_id=model_id,
model_name=model_name,
package_id=package_id,
package_name=package_name,
total_count=result['total_count'],
mae=result['mae'],
rmse=result['rmse'],
chart_data=result['chart_data'],
)
session.add(record)
session.commit()
session.refresh(record)
return self._record_to_dict(record)
# ── list / get ────────────────────────────────────────────────────────────
def list_records(self) -> list[dict[str, Any]]:
with db_session() as session:
rows = (
session.query(EvalRecord)
.order_by(EvalRecord.created_at.desc())
.all()
)
return [self._record_to_dict(r, include_chart=False) for r in rows]
def get_record(self, record_id: int) -> dict[str, Any]:
with db_session() as session:
row = session.query(EvalRecord).filter(EvalRecord.id == record_id).first()
if not row:
raise ValueError('评估记录不存在')
return self._record_to_dict(row, include_chart=True)
def delete_record(self, record_id: int) -> None:
with db_session() as session:
row = session.query(EvalRecord).filter(EvalRecord.id == record_id).first()
if not row:
raise ValueError('评估记录不存在')
session.delete(row)
session.commit()
# ── helpers ───────────────────────────────────────────────────────────────
def _load_package_records(self, package_id: int) -> list[dict[str, Any]]:
with db_session() as session:
pf_rows = (
session.query(DataPackageFile)
.filter(DataPackageFile.package_id == package_id)
.order_by(DataPackageFile.sort_order.asc())
.all()
)
file_ids = [pf.file_id for pf in pf_rows]
if not file_ids:
return []
files = session.query(DataFile).filter(DataFile.id.in_(file_ids)).all()
file_map = {f.id: f for f in files}
all_records: list[dict[str, Any]] = []
for fid in file_ids:
if fid not in file_map:
continue
fmeta = file_map[fid]
path = self._dm._resolve_local_file_path(fmeta.file_path, fmeta.stored_name)
recs, _ = self._dm._read_records(path, limit=None)
all_records.extend(recs)
return all_records
@staticmethod
def _record_to_dict(row: EvalRecord, include_chart: bool = True) -> dict[str, Any]:
d: dict[str, Any] = {
'id': row.id,
'model_id': row.model_id,
'model_name': row.model_name,
'package_id': row.package_id,
'package_name': row.package_name,
'total_count': row.total_count,
'mae': row.mae,
'rmse': row.rmse,
'created_at': row.created_at.strftime('%Y-%m-%d %H:%M:%S') if row.created_at else '',
}
if include_chart:
d['chart_data'] = row.chart_data or []
return d
This diff is collapsed.
This diff is collapsed.
import torch
print(torch.cuda.is_available())
\ No newline at end of file
...@@ -6,3 +6,6 @@ SQLAlchemy==2.0.43 ...@@ -6,3 +6,6 @@ SQLAlchemy==2.0.43
PyMySQL==1.1.2 PyMySQL==1.1.2
openpyxl==3.1.5 openpyxl==3.1.5
xlrd==2.0.1 xlrd==2.0.1
numpy>=1.24.0
# PyTorch (CPU): pip install torch --index-url https://download.pytorch.org/whl/cpu
# PyTorch (CUDA): pip install torch --index-url https://download.pytorch.org/whl/cu121
...@@ -7,12 +7,14 @@ const router = useRouter() ...@@ -7,12 +7,14 @@ const router = useRouter()
const tabs = [ const tabs = [
{ label: '数据管理', path: '/data-management' }, { label: '数据管理', path: '/data-management' },
{ label: '实时监控', path: '/realtime-monitor' }, { label: '数据包管理', path: '/package-management' },
{ label: '历史数据', path: '/history-data' },
{ label: '模型训练', path: '/model-training' }, { label: '模型训练', path: '/model-training' },
{ label: '模型列表', path: '/model-list' }, { label: '模型列表', path: '/model-list' },
{ label: '模型评估', path: '/model-evaluation' }, { label: '模型评估', path: '/model-evaluation' },
{ label: '数据包管理', path: '/package-management' }, { label: '实时监控', path: '/realtime-monitor' },
{ label: '历史数据', path: '/history-data' },
] ]
const activeTab = computed(() => { const activeTab = computed(() => {
...@@ -29,6 +31,7 @@ const handleTabChange = (path) => { ...@@ -29,6 +31,7 @@ const handleTabChange = (path) => {
<div class="app-shell"> <div class="app-shell">
<header class="top-header"> <header class="top-header">
<div class="project-title">热实验温度控制系统</div> <div class="project-title">热实验温度控制系统</div>
<div class="header-divider"></div>
<el-tabs class="top-tabs" :model-value="activeTab" @tab-change="handleTabChange"> <el-tabs class="top-tabs" :model-value="activeTab" @tab-change="handleTabChange">
<el-tab-pane <el-tab-pane
v-for="tab in tabs" v-for="tab in tabs"
...@@ -48,34 +51,37 @@ const handleTabChange = (path) => { ...@@ -48,34 +51,37 @@ const handleTabChange = (path) => {
<style lang="scss" scoped> <style lang="scss" scoped>
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
background: background: var(--bg-page);
radial-gradient(circle at 8% 12%, rgba(14, 165, 233, 0.22), transparent 38%),
radial-gradient(circle at 96% 8%, rgba(34, 197, 94, 0.2), transparent 36%),
#f2f8fb;
} }
.top-header { .top-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 0;
gap: 20px; height: 52px;
height: 72px; padding: 0 24px;
padding: 0 20px; background: #ffffff;
backdrop-filter: blur(8px); border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.84);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; z-index: 20;
} }
.header-divider {
width: 1px;
height: 18px;
background: var(--border-color);
margin: 0 20px 0 4px;
flex-shrink: 0;
}
.project-title { .project-title {
flex: 0 0 auto; flex: 0 0 auto;
font-size: 22px; font-size: 15px;
letter-spacing: 1px;
font-weight: 700; font-weight: 700;
color: #0f172a; color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.5px;
} }
.top-tabs { .top-tabs {
...@@ -90,25 +96,40 @@ const handleTabChange = (path) => { ...@@ -90,25 +96,40 @@ const handleTabChange = (path) => {
display: none; display: none;
} }
:deep(.el-tabs__item) { :deep(.el-tabs__nav-wrap) {
color: #334155; height: 52px;
font-weight: 500;
} }
:deep(.el-tabs__item.is-active) { :deep(.el-tabs__item) {
color: #0f766e; height: 52px;
line-height: 52px;
padding: 0 16px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
transition: color 0.15s;
&:hover {
color: var(--primary);
background: var(--primary-light);
}
&.is-active {
color: var(--primary);
font-weight: 600;
}
} }
:deep(.el-tabs__active-bar) { :deep(.el-tabs__active-bar) {
background-color: #0f766e; background-color: var(--primary);
height: 3px; height: 2px;
border-radius: 999px; border-radius: 0;
bottom: 0;
} }
} }
.app-main { .app-main {
height: calc(100vh - 72px); height: calc(100vh - 52px);
padding: 16px 20px 20px;
} }
@media (max-width: 980px) { @media (max-width: 980px) {
...@@ -116,11 +137,11 @@ const handleTabChange = (path) => { ...@@ -116,11 +137,11 @@ const handleTabChange = (path) => {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
height: auto; height: auto;
padding: 12px; padding: 10px 16px;
} }
.project-title { .header-divider {
font-size: 18px; display: none;
} }
.top-tabs { .top-tabs {
...@@ -129,8 +150,7 @@ const handleTabChange = (path) => { ...@@ -129,8 +150,7 @@ const handleTabChange = (path) => {
.app-main { .app-main {
height: auto; height: auto;
min-height: calc(100vh - 120px); min-height: calc(100vh - 100px);
padding: 12px;
} }
} }
</style> </style>
import request from '@/utils/request'
export function getEvalPackages() {
return request.get('/eval/packages')
}
export function getEvalModels() {
return request.get('/eval/models')
}
export function runEvaluation(payload) {
return request.post('/eval/run', payload)
}
export function getEvalRecords() {
return request.get('/eval/records')
}
export function getEvalRecord(recordId) {
return request.get(`/eval/records/${recordId}`)
}
export function deleteEvalRecord(recordId) {
return request.delete(`/eval/records/${recordId}`)
}
import request from '@/utils/request'
// ── categories ──────────────────────────────────────────────────────────────
export function getPkgCategoryTree() {
return request.get('/packages/categories')
}
export function createPkgCategory(payload) {
return request.post('/packages/categories', payload)
}
export function updatePkgCategory(categoryId, payload) {
return request.put(`/packages/categories/${categoryId}`, payload)
}
export function deletePkgCategory(categoryId) {
return request.delete(`/packages/categories/${categoryId}`)
}
// ── data files (for selection) ───────────────────────────────────────────────
export function getAllDataFiles() {
return request.get('/packages/data-files')
}
// ── packages ─────────────────────────────────────────────────────────────────
export function getPackages(params) {
return request.get('/packages', { params })
}
export function createPackage(payload) {
return request.post('/packages', payload)
}
export function deletePackage(packageId) {
return request.delete(`/packages/${packageId}`)
}
export function getPackageRecords(packageId, params) {
return request.get(`/packages/${packageId}/records`, { params })
}
export function previewPackage(payload, params) {
return request.post('/packages/preview', payload, { params })
}
import request from '@/utils/request'
export function getTrainPackages() {
return request.get('/train/packages')
}
export function getTrainTasks() {
return request.get('/train/tasks')
}
export function getTrainTask(taskId) {
return request.get(`/train/tasks/${taskId}`)
}
export function createTrainTask(payload) {
return request.post('/train/tasks', payload)
}
export function cancelTrainTask(taskId) {
return request.post(`/train/tasks/${taskId}/cancel`)
}
export function restartTrainTask(taskId) {
return request.post(`/train/tasks/${taskId}/restart`)
}
export function deleteTrainTask(taskId) {
return request.delete(`/train/tasks/${taskId}`)
}
export function saveTrainModel(taskId) {
return request.post(`/train/tasks/${taskId}/save`)
}
export function getSavedModels() {
return request.get('/train/models')
}
export function deleteSavedModel(modelId) {
return request.delete(`/train/models/${modelId}`)
}
...@@ -21,26 +21,30 @@ defineProps({ ...@@ -21,26 +21,30 @@ defineProps({
height: 100%; height: 100%;
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--bg-page);
} }
.uc-card { .uc-card {
width: min(640px, 100%); width: min(480px, 100%);
border-radius: 18px; border-radius: 4px;
padding: 38px 30px; padding: 40px 32px;
background: linear-gradient(135deg, #ffffff, #f2fbf8); background: var(--bg-white);
border: 1px solid rgba(15, 118, 110, 0.2); border: 1px solid var(--border-color);
box-shadow: 0 12px 30px rgba(2, 8, 23, 0.1); box-shadow: var(--shadow-card);
text-align: center;
h2 { h2 {
margin: 0 0 10px; margin: 0 0 12px;
font-size: 28px; font-size: 16px;
color: #0f172a; font-weight: 600;
color: var(--text-primary);
} }
p { p {
margin: 0; margin: 0;
color: #334155; color: var(--text-secondary);
font-size: 15px; font-size: 13px;
line-height: 1.6;
} }
} }
</style> </style>
...@@ -27,26 +27,22 @@ const router = createRouter({ ...@@ -27,26 +27,22 @@ const router = createRouter({
{ {
path: '/model-training', path: '/model-training',
name: 'model-training', name: 'model-training',
component: () => import('@/components/common/UnderConstruction.vue'), component: () => import('@/views/ModelTraining/index.vue'),
props: { title: '模型训练' },
}, },
{ {
path: '/model-list', path: '/model-list',
name: 'model-list', name: 'model-list',
component: () => import('@/components/common/UnderConstruction.vue'), component: () => import('@/views/ModelList/index.vue'),
props: { title: '模型列表' },
}, },
{ {
path: '/model-evaluation', path: '/model-evaluation',
name: 'model-evaluation', name: 'model-evaluation',
component: () => import('@/components/common/UnderConstruction.vue'), component: () => import('@/views/ModelEvaluation/index.vue'),
props: { title: '模型评估' },
}, },
{ {
path: '/package-management', path: '/package-management',
name: 'package-management', name: 'package-management',
component: () => import('@/components/common/UnderConstruction.vue'), component: () => import('@/views/PackageManagement/index.vue'),
props: { title: '数据包管理' },
}, },
], ],
}) })
......
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap');
:root { :root {
--page-text: #0f172a; --primary: #165DFF;
--page-muted: #64748b; --primary-hover: #4080FF;
--brand: #0f766e; --primary-light: #E8F3FF;
--brand-soft: #14b8a6; --primary-border: #BEDAFF;
--text-primary: #1D2129;
--text-secondary: #4E5969;
--text-tertiary: #86909C;
--bg-page: #F2F3F5;
--bg-white: #FFFFFF;
--border-color: #E5E6EB;
--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.08);
--shadow-card-hover: 0 2px 8px rgba(0, 0, 0, 0.12);
} }
* { * {
...@@ -21,6 +27,147 @@ body, ...@@ -21,6 +27,147 @@ body,
} }
body { body {
font-family: 'Poppins', 'Noto Sans SC', 'Microsoft YaHei', sans-serif; font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
color: var(--page-text); font-size: 13px;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* ── Element Plus global overrides ──────────────────────────────────────── */
/* Table header */
.el-table th.el-table__cell {
background-color: #F7F8FA !important;
color: var(--text-primary) !important;
font-weight: 600 !important;
font-size: 13px !important;
border-bottom: 1px solid var(--border-color) !important;
}
/* Table row hover */
.el-table__row:hover > td.el-table__cell {
background-color: var(--primary-light) !important;
}
/* Table selected row */
.el-table__body tr.current-row > td.el-table__cell {
background-color: var(--primary-light) !important;
}
/* Table body font */
.el-table td.el-table__cell {
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
/* Card */
.el-card {
--el-card-border-color: var(--border-color);
border-radius: 4px;
box-shadow: var(--shadow-card) !important;
}
/* Button sizes */
.el-button {
font-size: 13px;
font-weight: 500;
}
/* Primary button */
.el-button--primary {
--el-button-bg-color: var(--primary);
--el-button-border-color: var(--primary);
--el-button-hover-bg-color: var(--primary-hover);
--el-button-hover-border-color: var(--primary-hover);
--el-button-active-bg-color: #0E4FD9;
--el-button-active-border-color: #0E4FD9;
}
/* Primary outlined button */
.el-button--primary.is-plain {
--el-button-bg-color: transparent;
--el-button-text-color: var(--primary);
--el-button-border-color: var(--primary);
--el-button-hover-bg-color: var(--primary-light);
--el-button-hover-border-color: var(--primary);
--el-button-hover-text-color: var(--primary);
}
/* Input */
.el-input__wrapper {
font-size: 13px;
}
/* Tag */
.el-tag {
font-size: 12px;
}
/* Empty description */
.el-empty__description p {
font-size: 13px;
color: var(--text-tertiary);
}
/* Tree node */
.el-tree-node__content {
height: 32px;
font-size: 13px;
}
.el-tree-node.is-current > .el-tree-node__content {
background-color: var(--primary-light) !important;
color: var(--primary) !important;
font-weight: 600;
border-left: 3px solid var(--primary);
}
.el-tree-node__content:hover {
background-color: #F2F3F5 !important;
}
/* Radio button (outlined style) */
.el-radio-button__inner {
font-size: 13px;
font-weight: 500;
}
.el-radio-button:first-child .el-radio-button__inner {
border-left: 1px solid var(--border-color);
}
.el-radio-button__original-radio:checked + .el-radio-button__inner {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
box-shadow: none !important;
}
/* Dialog */
.el-dialog__header {
font-size: 14px;
font-weight: 600;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
/* Tabs (navigation) */
.el-tabs__item {
font-size: 14px !important;
font-weight: 500 !important;
color: var(--text-secondary) !important;
}
.el-tabs__item.is-active {
color: var(--primary) !important;
font-weight: 600 !important;
}
.el-tabs__active-bar {
background-color: var(--primary) !important;
height: 2px !important;
}
.el-tabs__item:hover {
color: var(--primary) !important;
} }
...@@ -420,7 +420,7 @@ onBeforeUnmount(() => { ...@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
<template> <template>
<section class="data-page" :class="{ dragging: !!dragState }"> <section class="data-page" :class="{ dragging: !!dragState }">
<div ref="layoutRef" class="data-layout"> <div ref="layoutRef" class="data-layout">
<el-card class="pane-card" shadow="hover" :style="{ width: `${leftPaneWidth}%` }"> <el-card class="pane-card pane-card--tree" shadow="hover" :style="{ width: `${leftPaneWidth}%` }">
<template #header> <template #header>
<div class="pane-header"> <div class="pane-header">
<div> <div>
...@@ -475,11 +475,11 @@ onBeforeUnmount(() => { ...@@ -475,11 +475,11 @@ onBeforeUnmount(() => {
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
<el-button type="info" plain @click="handleDownloadTemplate">下载模板</el-button> <el-button type="info" plain @click="handleDownloadTemplate">下载模板</el-button>
<el-button type="success" :icon="Upload" @click="openUploadDialog">上传文件</el-button> <el-button type="primary" :icon="Upload" @click="openUploadDialog">上传文件</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-table :data="fileList" border stripe v-loading="loadingFiles" height="calc(100vh - 270px)"> <el-table :data="fileList" border stripe v-loading="loadingFiles" height="calc(100vh - 250px)">
<el-table-column prop="filename" label="文件名" min-width="160" /> <el-table-column prop="filename" label="文件名" min-width="160" />
<el-table-column prop="uploaded_at" label="上传时间" min-width="170" /> <el-table-column prop="uploaded_at" label="上传时间" min-width="170" />
<el-table-column prop="data_count" label="数据量" width="90" /> <el-table-column prop="data_count" label="数据量" width="90" />
...@@ -516,7 +516,7 @@ onBeforeUnmount(() => { ...@@ -516,7 +516,7 @@ onBeforeUnmount(() => {
:data="recordList" :data="recordList"
border border
stripe stripe
height="calc(100vh - 270px)" height="calc(100vh - 250px)"
> >
<el-table-column prop="time" label="时间" min-width="140" /> <el-table-column prop="time" label="时间" min-width="140" />
<el-table-column prop="current" label="电流" min-width="100" /> <el-table-column prop="current" label="电流" min-width="100" />
...@@ -584,6 +584,7 @@ onBeforeUnmount(() => { ...@@ -584,6 +584,7 @@ onBeforeUnmount(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.data-page { .data-page {
height: 100%; height: 100%;
background: var(--bg-page);
&.dragging { &.dragging {
user-select: none; user-select: none;
...@@ -596,41 +597,62 @@ onBeforeUnmount(() => { ...@@ -596,41 +597,62 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 0; gap: 0;
background: var(--bg-white);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-card);
} }
.pane-divider { .pane-divider {
width: 12px; width: 4px;
flex: 0 0 12px; flex-shrink: 0;
position: relative; background: var(--border-color);
cursor: col-resize; cursor: col-resize;
user-select: none; transition: background 0.15s;
position: relative;
&::before { &::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; inset: 0 -2px;
bottom: 0;
left: 5px;
width: 2px;
background: rgba(148, 163, 184, 0.4);
transition: background 0.2s ease;
} }
&:hover::before { &:hover,
background: rgba(59, 130, 246, 0.7); .dragging & {
background: var(--primary-border);
} }
} }
.pane-card { .pane-card {
height: 100%; height: 100%;
min-width: 0; min-width: 0;
border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 0;
border: none;
border-right: 1px solid var(--border-color);
box-shadow: none !important;
background: var(--bg-white);
:deep(.el-card__header) {
padding: 0 16px;
height: 44px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color);
background: #F7F8FA;
}
:deep(.el-card__body) { :deep(.el-card__body) {
height: calc(100% - 56px); height: calc(100% - 44px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
padding: 12px 16px;
}
&--tree {
:deep(.el-card__body) {
padding: 8px 0;
overflow-y: auto;
}
} }
} }
...@@ -638,36 +660,17 @@ onBeforeUnmount(() => { ...@@ -638,36 +660,17 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; width: 100%;
font-size: 14px;
font-weight: 600; font-weight: 600;
color: #0f172a; color: var(--text-primary);
}
.pane-tip {
margin-left: 6px;
font-size: 12px;
font-weight: 400;
color: #64748b;
} }
.file-title { .file-title {
font-size: 13px; font-size: 13px;
color: #475569; color: var(--text-secondary);
margin-left: 6px; font-weight: 400;
} margin-left: 4px;
:deep(.el-tree) {
--el-tree-node-hover-bg-color: #f8fbff;
}
:deep(.el-tree-node__content) {
height: 40px;
border-radius: 10px;
margin-bottom: 4px;
}
:deep(.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content) {
background: linear-gradient(90deg, rgba(59, 130, 246, 0.14), rgba(14, 165, 233, 0.08));
} }
.tree-node { .tree-node {
...@@ -675,33 +678,48 @@ onBeforeUnmount(() => { ...@@ -675,33 +678,48 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 8px;
padding-right: 4px; padding-right: 4px;
} }
.tree-main { .tree-main {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
min-width: 0; min-width: 0;
} }
.tree-icon { .tree-icon {
font-size: 16px; font-size: 14px;
color: #eab308; color: var(--primary);
flex: 0 0 auto; flex: 0 0 auto;
} }
.tree-name { .tree-name {
max-width: 120px; font-size: 13px;
color: var(--text-primary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: #1e293b;
} }
.tree-actions { .tree-actions {
opacity: 0.8; display: none;
flex-shrink: 0;
.el-button {
padding: 0 3px;
font-size: 13px;
color: var(--text-tertiary);
&:hover {
color: var(--primary);
}
}
}
.tree-node:hover .tree-actions {
display: inline-flex;
} }
.upload-panel { .upload-panel {
...@@ -710,12 +728,13 @@ onBeforeUnmount(() => { ...@@ -710,12 +728,13 @@ onBeforeUnmount(() => {
.upload-panel__icon { .upload-panel__icon {
font-size: 28px; font-size: 28px;
color: #409eff; color: var(--primary);
margin-bottom: 8px; margin-bottom: 8px;
} }
.search-form { .search-form {
margin-bottom: 4px; margin-bottom: 4px;
flex-shrink: 0;
} }
.content-wrap { .content-wrap {
...@@ -737,6 +756,8 @@ onBeforeUnmount(() => { ...@@ -737,6 +756,8 @@ onBeforeUnmount(() => {
width: 100% !important; width: 100% !important;
min-height: 420px; min-height: 420px;
margin-bottom: 12px; margin-bottom: 12px;
border-right: none;
border-bottom: 1px solid var(--border-color);
} }
} }
</style> </style>
<script setup>
import * as echarts from 'echarts'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
chartData: {
type: Array,
default: () => [],
},
height: {
type: String,
default: '360px',
},
})
const chartRef = ref(null)
let chartInstance = null
const validData = computed(() =>
Array.isArray(props.chartData) ? props.chartData.filter((d) => d != null) : [],
)
const xLabels = computed(() => validData.value.map((d) => d.time || String(d.index ?? '')))
const actualSeries = computed(() => validData.value.map((d) => d.actual ?? null))
const predictedSeries = computed(() => validData.value.map((d) => d.predicted ?? null))
const renderChart = () => {
if (!chartRef.value) return
if (!chartInstance) chartInstance = echarts.init(chartRef.value)
const hasData = validData.value.length > 0
chartInstance.setOption(
{
animation: false,
color: ['#409EFF', '#F56C6C'],
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.96)',
borderColor: '#e2e8f0',
borderWidth: 1,
textStyle: { color: '#334155' },
extraCssText: 'box-shadow: 0 8px 24px rgba(15,23,42,0.14); border-radius: 10px;',
formatter(params) {
if (!params?.length) return ''
const lines = [
`<div style="margin-bottom:6px;font-weight:600;font-size:12px;">${params[0].axisValue}</div>`,
]
params.forEach((item) => {
const val = item.data != null ? Number(item.data).toFixed(4) : '--'
lines.push(
`<div style="display:flex;align-items:center;gap:8px;min-width:180px;justify-content:space-between;">
<span>${item.marker}${item.seriesName}</span>
<strong>${val} ℃</strong>
</div>`,
)
})
return lines.join('')
},
},
legend: {
bottom: 4,
itemWidth: 20,
itemHeight: 10,
textStyle: { color: '#475569', fontSize: 12 },
data: ['实际温度 (℃)', '预测温度 (℃)'],
},
grid: { top: 16, left: 16, right: 20, bottom: 52, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: xLabels.value,
axisLabel: {
color: '#64748b',
fontSize: 11,
rotate: 35,
interval: Math.max(0, Math.floor(xLabels.value.length / 12) - 1),
hideOverlap: true,
},
axisLine: { lineStyle: { color: '#cbd5e1' } },
},
yAxis: {
type: 'value',
name: '温度 (℃)',
nameTextStyle: { color: '#64748b', fontSize: 11 },
axisLabel: { color: '#64748b', fontSize: 11 },
splitLine: { lineStyle: { type: 'dashed', color: 'rgba(148,163,184,0.4)' } },
},
series: [
{
name: '实际温度 (℃)',
type: 'line',
smooth: false,
symbol: 'none',
lineStyle: { width: 2, type: 'solid' },
data: actualSeries.value,
},
{
name: '预测温度 (℃)',
type: 'line',
smooth: false,
symbol: 'none',
lineStyle: { width: 2, type: 'dashed' },
data: predictedSeries.value,
},
],
graphic: hasData
? []
: [
{
type: 'text',
left: 'center',
top: 'middle',
style: { text: '暂无评估数据', fill: '#94a3b8', fontSize: 14 },
},
],
dataZoom: hasData
? [
{ type: 'inside', start: 0, end: 100 },
{ type: 'slider', start: 0, end: 100, height: 20, bottom: 28 },
]
: [],
},
true,
)
}
const resizeChart = () => chartInstance?.resize()
watch(() => props.chartData, async () => { await nextTick(); renderChart() }, { deep: true })
onMounted(async () => {
await nextTick()
renderChart()
window.addEventListener('resize', resizeChart)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
chartInstance?.dispose()
chartInstance = null
})
</script>
<template>
<div ref="chartRef" :style="{ width: '100%', height: props.height }" />
</template>
<script setup>
import { Refresh } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import { deleteSavedModel, getSavedModels } from '@/api/trainManagement'
const models = ref([])
const loading = ref(false)
const loadModels = async () => {
loading.value = true
try {
models.value = await getSavedModels()
} finally {
loading.value = false
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(`确定删除模型"${row.model_name}"吗?删除后无法恢复。`, '提示', {
type: 'warning',
})
await deleteSavedModel(row.id)
ElMessage.success('模型已删除')
await loadModels()
} catch {
// user cancelled
}
}
const formatParams = (params) => {
if (!params) return '-'
return [
`seq=${params.seq_len}`,
`hidden=${params.hidden_size}`,
`layers=${params.num_layers}`,
`epochs=${params.epochs}`,
`lr=${params.learning_rate}`,
].join(' / ')
}
const formatLoss = (row) => {
if (row.train_loss == null) return '-'
const train = `训练 ${Number(row.train_loss).toFixed(5)}`
const val = row.val_loss != null ? ` / 验证 ${Number(row.val_loss).toFixed(5)}` : ''
return train + val
}
onMounted(loadModels)
</script>
<template>
<div class="model-list-page">
<el-card shadow="hover" class="list-card">
<template #header>
<div class="card-header-row">
<span class="card-title">已保存模型</span>
<el-button :icon="Refresh" size="small" plain :loading="loading" @click="loadModels">
刷新
</el-button>
</div>
</template>
<el-table :data="models" v-loading="loading" border stripe height="calc(100vh - 160px)">
<el-table-column type="index" width="55" label="#" align="center" />
<el-table-column prop="model_name" label="模型名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="package_name" label="训练数据包" min-width="140" show-overflow-tooltip />
<el-table-column label="LSTM 参数" min-width="280" show-overflow-tooltip>
<template #default="{ row }">
<el-tooltip :content="formatParams(row.params)" placement="top">
<span class="params-text">{{ formatParams(row.params) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="训练损失" min-width="190" show-overflow-tooltip>
<template #default="{ row }">
<span class="loss-text">{{ formatLoss(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="保存时间" width="170" />
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!loading && models.length === 0"
description="暂无已保存的模型,请先在模型训练页面完成训练并保存"
style="padding: 40px 0"
/>
</el-card>
</div>
</template>
<style lang="scss" scoped>
.model-list-page {
height: 100%;
overflow: hidden;
background: var(--bg-page);
}
.list-card {
height: 100%;
overflow: hidden;
border-radius: 4px;
border: 1px solid var(--border-color);
box-shadow: var(--shadow-card) !important;
:deep(.el-card__header) {
padding: 0 20px;
height: 44px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color);
background: #F7F8FA;
}
:deep(.el-card__body) {
padding: 16px 20px;
height: calc(100% - 44px);
overflow: hidden;
}
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.card-header-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.params-text,
.loss-text {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.loss-text {
color: var(--primary);
font-weight: 500;
}
</style>
This diff is collapsed.
<script setup>
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { createPackage, getAllDataFiles, getPkgCategoryTree, previewPackage } from '@/api/packageManagement'
import DataCurve from '@/views/DataManagement/components/DataCurve.vue'
const emit = defineEmits(['cancel', 'saved'])
// ── data files ────────────────────────────────────────────────────────────────
const allFiles = ref([])
const selectedFileIds = ref([])
const fileTableRef = ref(null)
const fileSearchText = ref('')
const filteredFiles = computed(() => {
const kw = fileSearchText.value.trim().toLowerCase()
if (!kw) return allFiles.value
return allFiles.value.filter(
(f) =>
f.filename.toLowerCase().includes(kw) ||
(f.category_name || '').toLowerCase().includes(kw),
)
})
const handleFileSelectionChange = (rows) => {
selectedFileIds.value = rows.map((r) => r.id)
}
// ── categories ────────────────────────────────────────────────────────────────
const categoryOptions = ref([])
const loadCategories = async () => {
const data = await getPkgCategoryTree()
const source = Array.isArray(data) ? data : []
categoryOptions.value = source
.filter((item) => String(item?.id) !== 'all')
.map((item) => ({ label: item.name, value: String(item.id) }))
}
// ── form ──────────────────────────────────────────────────────────────────────
const form = reactive({
categoryId: '',
name: '',
remark: '',
})
// ── preview ───────────────────────────────────────────────────────────────────
const previewLoading = ref(false)
const previewRecords = ref([])
const previewTotal = ref(0)
const previewMode = ref('table')
let previewDebounceTimer = null
const triggerPreview = () => {
clearTimeout(previewDebounceTimer)
if (!selectedFileIds.value.length) {
previewRecords.value = []
previewTotal.value = 0
return
}
previewDebounceTimer = setTimeout(async () => {
previewLoading.value = true
try {
const result = await previewPackage({ file_ids: selectedFileIds.value }, { limit: 300 })
previewRecords.value = result.records
previewTotal.value = result.count
} finally {
previewLoading.value = false
}
}, 600)
}
watch(selectedFileIds, triggerPreview, { deep: true })
// ── save ──────────────────────────────────────────────────────────────────────
const saving = ref(false)
const handleGenerate = async () => {
if (!form.name.trim()) {
ElMessage.warning('请输入数据包名称')
return
}
if (!selectedFileIds.value.length) {
ElMessage.warning('请至少选择一个数据文件')
return
}
saving.value = true
try {
await createPackage({
name: form.name.trim(),
category_id: form.categoryId || null,
remark: form.remark.trim() || null,
file_ids: selectedFileIds.value,
})
ElMessage.success('数据包创建成功')
emit('saved')
} finally {
saving.value = false
}
}
// ── init ──────────────────────────────────────────────────────────────────────
onMounted(async () => {
const [filesData] = await Promise.all([getAllDataFiles(), loadCategories()])
allFiles.value = filesData
})
</script>
<template>
<div class="add-pkg-page">
<!-- header -->
<div class="add-pkg-header">
<el-button :icon="ArrowLeft" plain @click="emit('cancel')">返回列表</el-button>
<span class="add-pkg-title">新增数据包</span>
</div>
<!-- top: file selection -->
<div class="section-card file-section">
<div class="section-title">
选择数据文件
<span class="section-hint">已选 {{ selectedFileIds.length }} 个文件</span>
</div>
<div class="file-search-bar">
<el-input
v-model="fileSearchText"
placeholder="按文件名或分类搜索"
clearable
style="width: 280px"
/>
</div>
<el-table
ref="fileTableRef"
:data="filteredFiles"
border
stripe
height="220"
@selection-change="handleFileSelectionChange"
>
<el-table-column type="selection" width="46" />
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip />
<el-table-column prop="category_name" label="所属分类" width="130" show-overflow-tooltip />
<el-table-column prop="data_count" label="数据量" width="80" align="center" />
<el-table-column prop="uploaded_at" label="上传时间" width="160" />
</el-table>
</div>
<!-- bottom: form + preview -->
<div class="section-bottom">
<!-- left: form -->
<div class="section-card form-section">
<div class="section-title">生成数据包</div>
<el-form label-position="top" class="pkg-form">
<el-form-item label="数据包分类">
<el-select v-model="form.categoryId" placeholder="请选择(可选)" clearable style="width: 100%">
<el-option
v-for="item in categoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="数据包名称" required>
<el-input v-model="form.name" maxlength="100" show-word-limit placeholder="请输入数据包名称" />
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="备注(可选)"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="saving"
style="width: 100%"
@click="handleGenerate"
>
生成数据包
</el-button>
</el-form-item>
</el-form>
</div>
<!-- right: preview -->
<div class="section-card preview-section">
<div class="section-title">
生成结果预览
<span v-if="previewTotal" class="section-hint">{{ previewTotal }} 条(展示前 300 条)</span>
<el-radio-group v-model="previewMode" size="small" style="margin-left: auto">
<el-radio-button value="table">表格</el-radio-button>
<el-radio-button value="curve">曲线</el-radio-button>
</el-radio-group>
</div>
<div class="preview-content" v-loading="previewLoading">
<el-empty
v-if="!previewLoading && !previewRecords.length"
description="请在上方选择数据文件,预览将自动更新"
:image-size="80"
/>
<el-table
v-else-if="previewMode === 'table'"
:data="previewRecords"
border
stripe
height="100%"
>
<el-table-column prop="time" label="时间" min-width="140" />
<el-table-column prop="current" label="电流" min-width="80" />
<el-table-column prop="voltage" label="电压" min-width="80" />
<el-table-column prop="set_temperature" label="设定温度" min-width="100" />
<el-table-column prop="actual_temperature" label="实际温度" min-width="100" />
</el-table>
<DataCurve v-else :records="previewRecords" />
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.add-pkg-page {
height: 100%;
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.add-pkg-header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 20px;
background: rgba(255, 255, 255, 0.9);
border-bottom: 1px solid #e8edf3;
flex-shrink: 0;
}
.add-pkg-title {
font-size: 16px;
font-weight: 600;
color: #0f172a;
}
.section-card {
background: #fff;
border: 1px solid #e8edf3;
border-radius: 8px;
padding: 16px;
overflow: hidden;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.section-hint {
font-size: 12px;
font-weight: 400;
color: #64748b;
}
.file-search-bar {
margin-bottom: 10px;
}
.file-section {
flex-shrink: 0;
margin: 12px 12px 0;
}
.section-bottom {
flex: 1;
display: flex;
gap: 12px;
padding: 12px;
overflow: hidden;
min-height: 0;
}
.form-section {
width: 320px;
flex-shrink: 0;
overflow-y: auto;
}
.preview-section {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
.section-title {
flex-shrink: 0;
}
}
.preview-content {
flex: 1;
overflow: hidden;
min-height: 0;
}
.pkg-form {
:deep(.el-form-item) {
margin-bottom: 16px;
}
}
</style>
<script setup>
import { Delete, Edit, Folder, FolderOpened } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref, computed } from 'vue'
import { createPkgCategory, deletePkgCategory, getPkgCategoryTree, updatePkgCategory } from '@/api/packageManagement'
const props = defineProps({
modelValue: {
type: [String, Number],
default: 'all',
},
style: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['update:modelValue', 'select'])
const categoryTree = ref([])
const dialogVisible = ref(false)
const dialogMode = ref('create')
const editingId = ref('')
const form = reactive({ name: '' })
const dialogTitle = computed(() => (dialogMode.value === 'create' ? '新增分类' : '编辑分类'))
const normalizeCategoryTree = (treeData) => {
const source = Array.isArray(treeData) ? treeData : []
const children = source
.filter((item) => String(item?.id) !== 'all')
.map((item) => ({ id: item.id, name: item.name, parent_id: item.parent_id, children: [] }))
return [{ id: 'all', name: '全部', parent_id: null, children }]
}
const loadCategoryTree = async () => {
const data = await getPkgCategoryTree()
categoryTree.value = normalizeCategoryTree(data)
const allIds = [
...categoryTree.value.map((item) => String(item.id)),
...categoryTree.value.flatMap((item) => (item.children || []).map((c) => String(c.id))),
]
if (!allIds.includes(String(props.modelValue))) {
emit('update:modelValue', 'all')
emit('select', 'all')
}
}
const handleNodeClick = (node) => {
emit('update:modelValue', node.id)
emit('select', node.id)
}
const openCreate = () => {
dialogMode.value = 'create'
editingId.value = ''
form.name = ''
dialogVisible.value = true
}
const openEdit = (node) => {
dialogMode.value = 'edit'
editingId.value = node.id
form.name = node.name
dialogVisible.value = true
}
const submitCategory = async () => {
const name = form.name.trim()
if (!name) {
ElMessage.warning('请输入分类名称')
return
}
if (dialogMode.value === 'create') {
await createPkgCategory({ name, parent_id: null })
} else {
await updatePkgCategory(editingId.value, { name })
}
dialogVisible.value = false
ElMessage.success('分类保存成功')
await loadCategoryTree()
}
const handleDelete = async (node) => {
try {
await ElMessageBox.confirm(`确定删除分类"${node.name}"吗?`, '提示', { type: 'warning' })
await deletePkgCategory(node.id)
ElMessage.success('分类已删除')
if (String(props.modelValue) === String(node.id)) {
emit('update:modelValue', 'all')
emit('select', 'all')
}
await loadCategoryTree()
} catch {
// user cancelled
}
}
onMounted(loadCategoryTree)
</script>
<template>
<el-card class="pane-card" shadow="hover" :style="props.style">
<template #header>
<div class="pane-header">
<span>分类树</span>
<el-button type="primary" plain size="small" @click="openCreate">新增</el-button>
</div>
</template>
<el-tree
:data="categoryTree"
node-key="id"
highlight-current
default-expand-all
:current-node-key="modelValue || undefined"
:expand-on-click-node="false"
@node-click="handleNodeClick"
>
<template #default="{ data }">
<div class="tree-node">
<div class="tree-main">
<el-icon class="tree-icon">
<component :is="data.id === 'all' ? FolderOpened : Folder" />
</el-icon>
<span class="tree-name">{{ data.name }}</span>
</div>
<span v-if="data.id !== 'all'" class="tree-actions">
<el-button link type="primary" :icon="Edit" @click.stop="openEdit(data)" />
<el-button link type="danger" :icon="Delete" @click.stop="handleDelete(data)" />
</span>
</div>
</template>
</el-tree>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="420px">
<el-form label-position="top">
<el-form-item label="分类名称" required>
<el-input v-model="form.name" maxlength="100" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitCategory">保存</el-button>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.pane-card {
height: 100%;
overflow: hidden;
border-radius: 0;
border: none;
border-right: 1px solid var(--border-color);
box-shadow: none !important;
display: flex;
flex-direction: column;
background: var(--bg-white);
:deep(.el-card__header) {
padding: 0 16px;
height: 44px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color);
background: #F7F8FA;
}
:deep(.el-card__body) {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
padding-right: 4px;
}
.tree-main {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.tree-icon {
color: var(--primary);
flex-shrink: 0;
font-size: 14px;
}
.tree-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.tree-actions {
display: none;
flex-shrink: 0;
gap: 0;
.el-button {
padding: 0 3px;
font-size: 13px;
color: var(--text-tertiary);
&:hover {
color: var(--primary);
}
}
}
.tree-node:hover .tree-actions {
display: inline-flex;
}
</style>
<script setup>
import { ref, watch } from 'vue'
import { getPackageRecords } from '@/api/packageManagement'
import DataCurve from '@/views/DataManagement/components/DataCurve.vue'
const props = defineProps({
package: {
type: Object,
default: null,
},
style: {
type: Object,
default: () => ({}),
},
})
const loading = ref(false)
const records = ref([])
const contentMode = ref('table')
const loadRecords = async (pkg) => {
if (!pkg) {
records.value = []
return
}
loading.value = true
try {
const result = await getPackageRecords(pkg.id, { limit: 500 })
records.value = result.records
} finally {
loading.value = false
}
}
watch(() => props.package, (pkg) => {
contentMode.value = 'table'
loadRecords(pkg)
}, { immediate: true })
</script>
<template>
<el-card class="pane-card" shadow="hover" :style="props.style">
<template #header>
<div class="pane-header">
<span>
数据包内容
<span v-if="props.package" class="pkg-title">{{ props.package.name }}</span>
</span>
<el-radio-group v-model="contentMode" size="small">
<el-radio-button value="table">表格</el-radio-button>
<el-radio-button value="curve">曲线</el-radio-button>
</el-radio-group>
</div>
</template>
<div class="content-wrap" v-loading="loading">
<el-empty v-if="!props.package" description="请选择一个数据包查看" />
<el-table
v-else-if="contentMode === 'table'"
:data="records"
border
stripe
height="calc(100vh - 250px)"
>
<el-table-column prop="time" label="时间" min-width="140" />
<el-table-column prop="current" label="电流" min-width="90" />
<el-table-column prop="voltage" label="电压" min-width="90" />
<el-table-column prop="set_temperature" label="设定温度" min-width="100" />
<el-table-column prop="actual_temperature" label="实际温度" min-width="100" />
</el-table>
<DataCurve v-else :records="records" />
</div>
</el-card>
</template>
<style lang="scss" scoped>
.pane-card {
height: 100%;
overflow: hidden;
border-radius: 0;
border: none;
box-shadow: none !important;
display: flex;
flex-direction: column;
background: var(--bg-white);
:deep(.el-card__header) {
padding: 0 16px;
height: 44px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color);
background: #F7F8FA;
}
:deep(.el-card__body) {
flex: 1;
overflow: hidden;
padding: 0;
}
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.pkg-title {
font-weight: 400;
color: var(--text-secondary);
font-size: 13px;
margin-left: 4px;
}
.content-wrap {
height: 100%;
padding: 0 16px 12px;
}
:deep(.el-radio-group) {
.el-radio-button__inner {
background: transparent;
color: var(--text-secondary);
border-color: var(--border-color);
font-size: 13px;
padding: 4px 12px;
}
.el-radio-button__original-radio:checked + .el-radio-button__inner {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: #fff;
box-shadow: none !important;
}
}
</style>
<script setup>
import { Plus, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref, watch } from 'vue'
import { deletePackage, getPackages } from '@/api/packageManagement'
const props = defineProps({
categoryId: {
type: [String, Number],
default: 'all',
},
refreshKey: {
type: Number,
default: 0,
},
style: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['view', 'add'])
const loading = ref(false)
const packageList = ref([])
const searchName = ref('')
const currentPackage = ref(null)
const loadPackages = async () => {
loading.value = true
try {
const data = await getPackages({
category_id:
props.categoryId && String(props.categoryId) !== 'all' ? String(props.categoryId) : '',
name: searchName.value.trim(),
})
packageList.value = data
} finally {
loading.value = false
}
}
const handleSearch = () => loadPackages()
const handleReset = () => {
searchName.value = ''
loadPackages()
}
const handleView = (row) => {
currentPackage.value = row
emit('view', row)
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(`确定删除数据包"${row.name}"吗?`, '提示', { type: 'warning' })
await deletePackage(row.id)
ElMessage.success('数据包已删除')
if (currentPackage.value?.id === row.id) {
currentPackage.value = null
}
await loadPackages()
} catch {
// user cancelled
}
}
watch(() => props.categoryId, loadPackages)
watch(() => props.refreshKey, () => {
currentPackage.value = null
loadPackages()
})
onMounted(loadPackages)
</script>
<template>
<el-card class="pane-card" shadow="hover" :style="props.style">
<template #header>
<div class="pane-header">
<span>数据包列表</span>
<el-button type="primary" :icon="Plus" size="small" @click="emit('add')">新增数据包</el-button>
</div>
</template>
<div class="search-bar">
<el-input
v-model="searchName"
placeholder="数据包名称"
clearable
:prefix-icon="Search"
@keyup.enter="handleSearch"
@clear="handleReset"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<el-table
:data="packageList"
v-loading="loading"
border
stripe
highlight-current-row
height="calc(100vh - 265px)"
:row-class-name="({ row }) => (currentPackage?.id === row.id ? 'current-row' : '')"
>
<el-table-column prop="name" label="数据包名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" min-width="160" />
<el-table-column prop="data_count" label="数据量" width="80" align="center" />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">查看</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<style lang="scss" scoped>
.pane-card {
height: 100%;
overflow: hidden;
border-radius: 0;
border: none;
border-right: 1px solid var(--border-color);
box-shadow: none !important;
display: flex;
flex-direction: column;
background: var(--bg-white);
:deep(.el-card__header) {
padding: 0 16px;
height: 44px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color);
background: #F7F8FA;
}
:deep(.el-card__body) {
flex: 1;
overflow: hidden;
padding: 12px 16px;
}
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.search-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
.el-input {
flex: 1;
}
}
:deep(.el-table .current-row > td) {
background: var(--primary-light) !important;
}
:deep(.el-table .el-button.is-link) {
font-size: 13px;
padding: 0 4px;
height: auto;
line-height: 1;
}
</style>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import PkgCategoryTree from './components/PkgCategoryTree.vue'
import PkgList from './components/PkgList.vue'
import PkgDetail from './components/PkgDetail.vue'
import AddPackage from './components/AddPackage.vue'
// ── view state ────────────────────────────────────────────────────────────────
const currentView = ref('list') // 'list' | 'add'
const selectedCategoryId = ref('all')
const selectedPackage = ref(null)
const refreshKey = ref(0)
const handleCategorySelect = (categoryId) => {
selectedCategoryId.value = categoryId
selectedPackage.value = null
}
const handleViewPackage = (pkg) => {
selectedPackage.value = pkg
}
const handleAddPackage = () => {
currentView.value = 'add'
}
const handlePackageSaved = () => {
currentView.value = 'list'
refreshKey.value++
selectedPackage.value = null
}
const handleAddCancel = () => {
currentView.value = 'list'
}
// ── drag-resize layout ────────────────────────────────────────────────────────
const layoutRef = ref(null)
const leftPaneWidth = ref(16)
const middlePaneWidth = ref(38)
const dragState = ref(null)
const pendingDragX = ref(null)
let dragFrameId = 0
const layoutStorageKey = 'pkg-management-layout-v1'
const MIN_LEFT = 12
const MAX_LEFT = 24
const MIN_MIDDLE = 30
const MAX_MIDDLE = 56
const MIN_RIGHT = 24
const rightPaneWidth = computed(() =>
Number((100 - leftPaneWidth.value - middlePaneWidth.value).toFixed(2)),
)
const setPaneWidths = (nextLeft, nextMiddle, target = 'left') => {
let left = Math.min(Math.max(nextLeft, MIN_LEFT), MAX_LEFT)
let middle = Math.min(Math.max(nextMiddle, MIN_MIDDLE), MAX_MIDDLE)
if (100 - left - middle < MIN_RIGHT) {
if (target === 'left') {
left = 100 - middle - MIN_RIGHT
if (left < MIN_LEFT) {
left = MIN_LEFT
middle = 100 - left - MIN_RIGHT
}
} else {
middle = 100 - left - MIN_RIGHT
if (middle < MIN_MIDDLE) {
middle = MIN_MIDDLE
left = 100 - middle - MIN_RIGHT
}
}
}
leftPaneWidth.value = Number(left.toFixed(2))
middlePaneWidth.value = Number(middle.toFixed(2))
}
const loadLayoutCache = () => {
const raw = localStorage.getItem(layoutStorageKey)
if (!raw) return
try {
const parsed = JSON.parse(raw)
if (typeof parsed.left === 'number' && typeof parsed.middle === 'number') {
setPaneWidths(parsed.left, parsed.middle, 'middle')
}
} catch {
localStorage.removeItem(layoutStorageKey)
}
}
const saveLayoutCache = () => {
localStorage.setItem(
layoutStorageKey,
JSON.stringify({ left: leftPaneWidth.value, middle: middlePaneWidth.value }),
)
}
const applyDragPosition = (clientX) => {
if (!dragState.value) return
const delta = ((clientX - dragState.value.startX) / dragState.value.layoutWidth) * 100
if (dragState.value.target === 'left') {
setPaneWidths(dragState.value.startLeft + delta, dragState.value.startMiddle, 'left')
} else {
setPaneWidths(dragState.value.startLeft, dragState.value.startMiddle + delta, 'middle')
}
}
const flushDragging = () => {
dragFrameId = 0
if (pendingDragX.value === null) return
applyDragPosition(pendingDragX.value)
}
const stopDragging = () => {
window.removeEventListener('mousemove', handleDragging)
window.removeEventListener('mouseup', stopDragging)
window.removeEventListener('mouseleave', stopDragging)
window.removeEventListener('blur', stopDragging)
document.body.style.userSelect = ''
document.body.style.cursor = ''
if (dragFrameId) {
cancelAnimationFrame(dragFrameId)
dragFrameId = 0
}
pendingDragX.value = null
if (dragState.value) saveLayoutCache()
dragState.value = null
}
const handleDragging = (event) => {
if (!dragState.value) return
pendingDragX.value = event.clientX
if (!dragFrameId) {
dragFrameId = requestAnimationFrame(flushDragging)
}
}
const startDragging = (target, event) => {
if (window.innerWidth <= 980 || !layoutRef.value) return
event.preventDefault()
stopDragging()
dragState.value = {
target,
startX: event.clientX,
startLeft: leftPaneWidth.value,
startMiddle: middlePaneWidth.value,
layoutWidth: layoutRef.value.clientWidth,
}
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
window.addEventListener('mousemove', handleDragging)
window.addEventListener('mouseup', stopDragging)
window.addEventListener('mouseleave', stopDragging)
window.addEventListener('blur', stopDragging)
}
onMounted(loadLayoutCache)
onBeforeUnmount(stopDragging)
</script>
<template>
<section class="pkg-page" :class="{ dragging: !!dragState }">
<!-- add package sub-page -->
<AddPackage v-if="currentView === 'add'" @saved="handlePackageSaved" @cancel="handleAddCancel" />
<!-- list view: three-pane layout -->
<div v-else ref="layoutRef" class="pkg-layout">
<PkgCategoryTree
:model-value="selectedCategoryId"
:style="{ width: `${leftPaneWidth}%` }"
@update:model-value="(v) => (selectedCategoryId = v)"
@select="handleCategorySelect"
/>
<div class="pane-divider" @mousedown.prevent="(e) => startDragging('left', e)" />
<PkgList
:category-id="selectedCategoryId"
:refresh-key="refreshKey"
:style="{ width: `${middlePaneWidth}%` }"
@view="handleViewPackage"
@add="handleAddPackage"
/>
<div class="pane-divider" @mousedown.prevent="(e) => startDragging('middle', e)" />
<PkgDetail
:package="selectedPackage"
:style="{ width: `${rightPaneWidth}%` }"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
.pkg-page {
height: 100%;
background: var(--bg-page);
&.dragging {
user-select: none;
cursor: col-resize;
}
}
.pkg-layout {
height: 100%;
display: flex;
align-items: stretch;
gap: 0;
background: var(--bg-white);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-card);
}
.pane-divider {
width: 4px;
flex-shrink: 0;
background: var(--border-color);
cursor: col-resize;
transition: background 0.15s;
position: relative;
&::after {
content: '';
position: absolute;
inset: 0 -2px;
}
&:hover,
.dragging & {
background: var(--primary-border);
}
}
</style>
...@@ -30,3 +30,23 @@ CREATE TABLE IF NOT EXISTS data_files ( ...@@ -30,3 +30,23 @@ CREATE TABLE IF NOT EXISTS data_files (
ALTER TABLE data_files ALTER TABLE data_files
ADD COLUMN IF NOT EXISTS file_path VARCHAR(500) NOT NULL COMMENT '文件路径'; ADD COLUMN IF NOT EXISTS file_path VARCHAR(500) NOT NULL COMMENT '文件路径';
CREATE TABLE IF NOT EXISTS data_packages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL COMMENT '数据包名称',
category_id BIGINT DEFAULT NULL COMMENT '分类ID(data_package类型)',
remark TEXT NULL COMMENT '备注',
data_count INT NOT NULL DEFAULT 0 COMMENT '数据条数(所有关联文件合计)',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_pkg_category (category_id)
) COMMENT='数据包表';
CREATE TABLE IF NOT EXISTS data_package_files (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
package_id BIGINT NOT NULL COMMENT '数据包ID',
file_id BIGINT NOT NULL COMMENT '数据文件ID',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
INDEX idx_dpf_package (package_id),
INDEX idx_dpf_file (file_id)
) COMMENT='数据包文件关联表';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment