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
from typing import Any
from sqlalchemy import func
from app.database import db_session
from app.models import Category, DataFile, DataPackage, DataPackageFile
from app.services.data_management_service import DataManagementService
class PackageManagementService:
def __init__(self) -> None:
self._dm = DataManagementService()
# ── categories ──────────────────────────────────────────────────────────
def get_category_tree(self) -> list[dict[str, Any]]:
with db_session() as session:
rows = (
session.query(Category)
.filter(Category.type == 'data_package')
.filter(Category.parent_id.is_(None))
.order_by(Category.sort_order.asc(), Category.id.asc())
.all()
)
return [
{
'id': item.id,
'name': item.name,
'parent_id': None,
'children': [],
}
for item in rows
]
def create_category(self, name: str, parent_id: str = 'all') -> dict[str, Any]:
if not name:
raise ValueError('分类名称不能为空')
if str(parent_id).strip().lower() not in {'', 'all', 'none', 'null'}:
raise ValueError('当前仅支持一级分类')
with db_session() as session:
category = Category(
name=name,
type='data_package',
parent_id=None,
sort_order=0,
)
session.add(category)
session.commit()
session.refresh(category)
return {
'id': category.id,
'name': category.name,
'parent_id': None,
'type': category.type,
'sort_order': category.sort_order,
}
def update_category(self, category_id: str, name: str) -> dict[str, Any]:
if category_id in {'', 'all'}:
raise ValueError('分类ID不能为空')
if not name:
raise ValueError('分类名称不能为空')
db_id = self._parse_int_id(category_id, '分类ID')
with db_session() as session:
category = (
session.query(Category)
.filter(Category.id == db_id, Category.type == 'data_package')
.first()
)
if not category:
raise ValueError('分类不存在')
category.name = name
session.commit()
session.refresh(category)
return {
'id': category.id,
'name': category.name,
'parent_id': None,
'type': category.type,
'sort_order': category.sort_order,
}
def delete_category(self, category_id: str) -> None:
if category_id in {'', 'all'}:
raise ValueError('分类ID不能为空')
db_id = self._parse_int_id(category_id, '分类ID')
with db_session() as session:
category = (
session.query(Category)
.filter(Category.id == db_id, Category.type == 'data_package')
.first()
)
if not category:
raise ValueError('分类不存在')
pkg_count = (
session.query(func.count(DataPackage.id))
.filter(DataPackage.category_id == db_id)
.scalar()
)
if pkg_count and pkg_count > 0:
raise ValueError('该分类下仍有数据包,无法删除')
session.delete(category)
session.commit()
# ── data files (for selection) ───────────────────────────────────────────
def list_all_data_files(self) -> list[dict[str, Any]]:
with db_session() as session:
rows = (
session.query(DataFile, Category)
.outerjoin(Category, DataFile.category_id == Category.id)
.order_by(Category.name.asc(), DataFile.uploaded_at.desc(), DataFile.id.desc())
.all()
)
return [
{
'id': f.id,
'filename': f.filename,
'category_id': f.category_id,
'category_name': c.name if c else '',
'data_count': f.data_count,
'uploaded_at': f.uploaded_at.strftime('%Y-%m-%d %H:%M:%S') if f.uploaded_at else '',
}
for f, c in rows
]
# ── packages ─────────────────────────────────────────────────────────────
def list_packages(self, category_id: str = '', name: str = '') -> list[dict[str, Any]]:
with db_session() as session:
query = session.query(DataPackage)
if category_id not in ('', 'all', None):
db_id = self._parse_int_id(category_id, '分类ID')
query = query.filter(DataPackage.category_id == db_id)
if name:
query = query.filter(DataPackage.name.like(f'%{name}%'))
rows = query.order_by(DataPackage.created_at.desc(), DataPackage.id.desc()).all()
return [self._pkg_to_dict(item) for item in rows]
def create_package(
self,
name: str,
category_id: str | None,
remark: str | None,
file_ids: list[int],
) -> dict[str, Any]:
if not name:
raise ValueError('数据包名称不能为空')
if not file_ids:
raise ValueError('请至少选择一个数据文件')
cat_db_id: int | None = None
if category_id and str(category_id).strip().lower() not in {'', 'all', 'none', 'null'}:
cat_db_id = self._parse_int_id(str(category_id), '分类ID')
with db_session() as session:
if cat_db_id is not None:
cat = (
session.query(Category)
.filter(Category.id == cat_db_id, Category.type == 'data_package')
.first()
)
if not cat:
raise ValueError('数据包分类不存在')
files = session.query(DataFile).filter(DataFile.id.in_(file_ids)).all()
if len(files) != len(file_ids):
raise ValueError('部分数据文件不存在')
total_count = sum(f.data_count for f in files)
pkg = DataPackage(
name=name,
category_id=cat_db_id,
remark=remark,
data_count=total_count,
)
session.add(pkg)
session.flush()
for idx, fid in enumerate(file_ids):
pf = DataPackageFile(package_id=pkg.id, file_id=fid, sort_order=idx)
session.add(pf)
session.commit()
session.refresh(pkg)
return self._pkg_to_dict(pkg)
def delete_package(self, package_id: str) -> None:
db_id = self._parse_int_id(package_id, '数据包ID')
with db_session() as session:
pkg = session.query(DataPackage).filter(DataPackage.id == db_id).first()
if not pkg:
raise ValueError('数据包不存在')
session.query(DataPackageFile).filter(DataPackageFile.package_id == db_id).delete()
session.delete(pkg)
session.commit()
def get_package_records(self, package_id: str, limit: int = 500) -> dict[str, Any]:
db_id = self._parse_int_id(package_id, '数据包ID')
with db_session() as session:
pkg = session.query(DataPackage).filter(DataPackage.id == db_id).first()
if not pkg:
raise ValueError('数据包不存在')
pf_rows = (
session.query(DataPackageFile)
.filter(DataPackageFile.package_id == db_id)
.order_by(DataPackageFile.sort_order.asc())
.all()
)
file_ids = [pf.file_id for pf in pf_rows]
files = session.query(DataFile).filter(DataFile.id.in_(file_ids)).all()
file_map = {f.id: f for f in files}
return self._merge_records(file_ids, file_map, limit)
def preview_records(self, file_ids: list[int], limit: int = 300) -> dict[str, Any]:
if not file_ids:
return {'records': [], 'count': 0}
with db_session() as session:
files = session.query(DataFile).filter(DataFile.id.in_(file_ids)).all()
file_map = {f.id: f for f in files}
return self._merge_records(file_ids, file_map, limit)
# ── helpers ──────────────────────────────────────────────────────────────
def _merge_records(
self,
file_ids: list[int],
file_map: dict[int, Any],
limit: int,
) -> dict[str, Any]:
all_records: list[dict[str, Any]] = []
total_count = 0
remaining = limit
for fid in file_ids:
if fid not in file_map:
continue
file_meta = file_map[fid]
path = self._dm._resolve_local_file_path(file_meta.file_path, file_meta.stored_name)
records, count = self._dm._read_records(path, limit=remaining if remaining > 0 else 0)
total_count += count
all_records.extend(records)
remaining -= len(records)
if remaining <= 0:
break
return {'records': all_records, 'count': total_count}
def _pkg_to_dict(self, pkg: DataPackage) -> dict[str, Any]:
return {
'id': pkg.id,
'name': pkg.name,
'category_id': pkg.category_id,
'remark': pkg.remark,
'data_count': pkg.data_count,
'created_at': pkg.created_at.strftime('%Y-%m-%d %H:%M:%S') if pkg.created_at else '',
'updated_at': pkg.updated_at.strftime('%Y-%m-%d %H:%M:%S') if pkg.updated_at else '',
}
@staticmethod
def _parse_int_id(value: str, label: str = 'ID') -> int:
try:
return int(value)
except (ValueError, TypeError) as exc:
raise ValueError(f'{label}格式错误') from exc
"""Training task management service.
Background training runs in a daemon thread. A threading.Event per task_id
provides cancellation support. Progress is persisted to DB at most every 2 %.
"""
from __future__ import annotations
import threading
from pathlib import Path
from typing import Any
from app.database import db_session
from app.ml.lstm_trainer import train_lstm
from app.models import DataFile, DataPackage, DataPackageFile
from app.models.train_management import SavedModel, TrainTask
from app.services.data_management_service import DataManagementService
# ── module-level cancel registry ─────────────────────────────────────────────
_cancel_events: dict[int, threading.Event] = {}
_registry_lock = threading.Lock()
class TrainService:
def __init__(self) -> None:
self._dm = DataManagementService()
self._models_dir = Path(__file__).resolve().parents[2] / 'saved_models'
self._models_dir.mkdir(parents=True, exist_ok=True)
# ── packages (for dropdown) ──────────────────────────────────────────────
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]
# ── tasks ────────────────────────────────────────────────────────────────
def list_tasks(self) -> list[dict[str, Any]]:
with db_session() as session:
tasks = session.query(TrainTask).order_by(TrainTask.created_at.desc()).all()
return [self._task_to_dict(t) for t in tasks]
def get_task(self, task_id: int) -> dict[str, Any]:
with db_session() as session:
task = session.query(TrainTask).filter(TrainTask.id == task_id).first()
if not task:
raise ValueError('训练任务不存在')
return self._task_to_dict(task)
def create_task(self, model_name: str, package_id: int, params: dict) -> dict[str, Any]:
with db_session() as session:
pkg = session.query(DataPackage).filter(DataPackage.id == package_id).first()
if not pkg:
raise ValueError('数据包不存在')
task = TrainTask(
model_name=model_name,
package_id=package_id,
package_name=pkg.name,
params=params,
status='pending',
progress=0,
)
session.add(task)
session.commit()
session.refresh(task)
task_dict = self._task_to_dict(task)
self._launch_thread(task_dict['id'], package_id, params)
return task_dict
def cancel_task(self, task_id: int) -> None:
with _registry_lock:
event = _cancel_events.get(task_id)
if event:
event.set()
else:
# Task may still be in 'pending' state (thread not started yet)
with db_session() as session:
task = session.query(TrainTask).filter(TrainTask.id == task_id).first()
if task and task.status in ('pending', 'running'):
task.status = 'cancelled'
session.commit()
def restart_task(self, task_id: int) -> dict[str, Any]:
with db_session() as session:
task = session.query(TrainTask).filter(TrainTask.id == task_id).first()
if not task:
raise ValueError('任务不存在')
if task.status in ('pending', 'running'):
raise ValueError('任务仍在进行中,请先取消')
new_task = TrainTask(
model_name=task.model_name,
package_id=task.package_id,
package_name=task.package_name,
params=task.params,
status='pending',
progress=0,
)
session.add(new_task)
session.commit()
session.refresh(new_task)
task_dict = self._task_to_dict(new_task)
self._launch_thread(task_dict['id'], task_dict['package_id'], task_dict['params'])
return task_dict
def delete_task(self, task_id: int) -> None:
with db_session() as session:
task = session.query(TrainTask).filter(TrainTask.id == task_id).first()
if not task:
raise ValueError('任务不存在')
if task.status in ('pending', 'running'):
raise ValueError('请先取消正在进行的任务')
# Remove model file if exists (and model not saved to saved_models table)
model_path = self._models_dir / f'task_{task_id}.pt'
if model_path.exists() and not task.is_saved:
model_path.unlink(missing_ok=True)
session.delete(task)
session.commit()
def save_model(self, task_id: int) -> dict[str, Any]:
with db_session() as session:
task = session.query(TrainTask).filter(TrainTask.id == task_id).first()
if not task:
raise ValueError('任务不存在')
if task.status != 'completed':
raise ValueError('只能保存已完成的训练任务')
if task.is_saved:
raise ValueError('模型已保存')
model_path = self._models_dir / f'task_{task_id}.pt'
if not model_path.exists():
raise ValueError('模型文件不存在,可能已被清除,请重新训练')
saved = SavedModel(
task_id=task_id,
model_name=task.model_name,
package_id=task.package_id,
package_name=task.package_name,
params=task.params,
file_path=str(model_path),
train_loss=task.train_loss,
val_loss=task.val_loss,
)
session.add(saved)
task.is_saved = 1
session.commit()
session.refresh(saved)
return self._saved_to_dict(saved)
# ── saved models ─────────────────────────────────────────────────────────
def list_saved_models(self) -> list[dict[str, Any]]:
with db_session() as session:
models = session.query(SavedModel).order_by(SavedModel.created_at.desc()).all()
return [self._saved_to_dict(m) for m in models]
def delete_saved_model(self, model_id: int) -> None:
with db_session() as session:
model = session.query(SavedModel).filter(SavedModel.id == model_id).first()
if not model:
raise ValueError('模型不存在')
# Reset is_saved on the originating task if it still exists
task = session.query(TrainTask).filter(TrainTask.id == model.task_id).first()
if task:
task.is_saved = 0
# Delete the .pt file only if nothing else references it
model_path = Path(model.file_path)
if model_path.exists():
model_path.unlink(missing_ok=True)
session.delete(model)
session.commit()
# ── private helpers ───────────────────────────────────────────────────────
def _launch_thread(self, task_id: int, package_id: int, params: dict) -> None:
cancel_event = threading.Event()
with _registry_lock:
_cancel_events[task_id] = cancel_event
thread = threading.Thread(
target=self._training_worker,
args=(task_id, package_id, params, cancel_event),
daemon=True,
name=f'train-task-{task_id}',
)
thread.start()
def _training_worker(
self,
task_id: int,
package_id: int,
params: dict,
cancel_event: threading.Event,
) -> None:
try:
self._update_task(task_id, status='running', progress=0)
records = self._load_package_records(package_id)
if not records:
raise ValueError('数据包没有有效数据,请检查关联文件')
save_path = self._models_dir / f'task_{task_id}.pt'
last_pct = [0]
def on_progress(pct: int, train_loss: float, val_loss: float | None) -> None:
if cancel_event.is_set():
return
# Throttle: persist at most every 2 % to reduce DB writes
if pct - last_pct[0] >= 2 or pct == 100:
last_pct[0] = pct
self._update_task(
task_id,
progress=pct,
train_loss=round(float(train_loss), 6),
val_loss=round(float(val_loss), 6) if val_loss is not None else None,
)
result = train_lstm(
records=records,
params=params,
save_path=save_path,
on_progress=on_progress,
cancel_event=cancel_event,
)
self._update_task(
task_id,
status='completed',
progress=100,
train_loss=result['train_loss'],
val_loss=result.get('val_loss'),
)
except InterruptedError:
self._update_task(task_id, status='cancelled')
except Exception as exc:
self._update_task(task_id, status='failed', error_msg=str(exc)[:2000])
finally:
with _registry_lock:
_cancel_events.pop(task_id, None)
def _update_task(self, task_id: int, **kwargs: Any) -> None:
with db_session() as session:
task = session.query(TrainTask).filter(TrainTask.id == task_id).first()
if not task:
return
for key, value in kwargs.items():
setattr(task, key, value)
session.commit()
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)
records, _ = self._dm._read_records(path, limit=None)
all_records.extend(records)
return all_records
@staticmethod
def _task_to_dict(task: TrainTask) -> dict[str, Any]:
return {
'id': task.id,
'model_name': task.model_name,
'package_id': task.package_id,
'package_name': task.package_name,
'params': task.params,
'status': task.status,
'progress': task.progress,
'train_loss': task.train_loss,
'val_loss': task.val_loss,
'error_msg': task.error_msg,
'is_saved': bool(task.is_saved),
'created_at': task.created_at.strftime('%Y-%m-%d %H:%M:%S') if task.created_at else '',
}
@staticmethod
def _saved_to_dict(model: SavedModel) -> dict[str, Any]:
return {
'id': model.id,
'task_id': model.task_id,
'model_name': model.model_name,
'package_id': model.package_id,
'package_name': model.package_name,
'params': model.params,
'train_loss': model.train_loss,
'val_loss': model.val_loss,
'created_at': model.created_at.strftime('%Y-%m-%d %H:%M:%S') if model.created_at else '',
}
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__nav-wrap) {
height: 52px;
}
:deep(.el-tabs__item) { :deep(.el-tabs__item) {
color: #334155; height: 52px;
line-height: 52px;
padding: 0 16px;
font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary);
transition: color 0.15s;
&:hover {
color: var(--primary);
background: var(--primary-light);
} }
:deep(.el-tabs__item.is-active) { &.is-active {
color: #0f766e; 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, reactive, ref } from 'vue'
import {
deleteEvalRecord,
getEvalModels,
getEvalPackages,
getEvalRecord,
getEvalRecords,
runEvaluation,
} from '@/api/evalManagement'
import EvalChart from './components/EvalChart.vue'
// ── form ──────────────────────────────────────────────────────────────────────
const packages = ref([])
const models = ref([])
const form = reactive({ model_id: '', package_id: '' })
const evaluating = ref(false)
// Current evaluation result (shown directly above the records table)
const currentResult = ref(null) // { model_name, package_name, mae, rmse, chart_data }
// ── records ───────────────────────────────────────────────────────────────────
const records = ref([])
const loadingRecords = ref(false)
// ── dialog for historical view ────────────────────────────────────────────────
const dialogVisible = ref(false)
const dialogRecord = ref(null)
const dialogLoading = ref(false)
// ── actions ───────────────────────────────────────────────────────────────────
const loadDropdowns = async () => {
const [pkgs, mdls] = await Promise.all([getEvalPackages(), getEvalModels()])
packages.value = pkgs
models.value = mdls
}
const loadRecords = async () => {
loadingRecords.value = true
try {
records.value = await getEvalRecords()
} finally {
loadingRecords.value = false
}
}
const handleEvaluate = async () => {
if (!form.model_id) { ElMessage.warning('请选择模型'); return }
if (!form.package_id) { ElMessage.warning('请选择数据包'); return }
evaluating.value = true
currentResult.value = null
try {
const result = await runEvaluation({ model_id: form.model_id, package_id: form.package_id })
currentResult.value = result
ElMessage.success('评估完成')
await loadRecords()
} catch (e) {
ElMessage.error(e?.message || '评估失败')
} finally {
evaluating.value = false
}
}
const handleView = async (row) => {
dialogLoading.value = true
dialogVisible.value = true
dialogRecord.value = null
try {
dialogRecord.value = await getEvalRecord(row.id)
} finally {
dialogLoading.value = false
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
`确定删除模型"${row.model_name}"在"${row.package_name}"上的评估记录吗?`,
'提示',
{ type: 'warning' },
)
await deleteEvalRecord(row.id)
ElMessage.success('已删除')
if (currentResult.value?.id === row.id) currentResult.value = null
await loadRecords()
} catch {
// user cancelled
}
}
const fmtMetric = (v) => (v != null ? Number(v).toFixed(5) : '-')
onMounted(async () => {
await Promise.all([loadDropdowns(), loadRecords()])
})
</script>
<template>
<div class="eval-page">
<!-- ── top: config ──────────────────────────────────────────────────── -->
<el-card class="config-card" shadow="hover">
<template #header>
<span class="card-title">评估配置</span>
</template>
<el-form :model="form" inline class="eval-form">
<el-form-item label="选择模型" required>
<el-select
v-model="form.model_id"
placeholder="请选择已保存模型"
filterable
style="width: 260px"
>
<el-option
v-for="m in models"
:key="m.id"
:label="`${m.model_name}(训练包:${m.package_name})`"
:value="m.id"
/>
</el-select>
</el-form-item>
<el-form-item label="选择数据包" required>
<el-select
v-model="form.package_id"
placeholder="请选择评估数据包"
filterable
style="width: 240px"
>
<el-option
v-for="p in packages"
:key="p.id"
:label="`${p.name}(${p.data_count} 条)`"
:value="p.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="evaluating" @click="handleEvaluate">
开始评估
</el-button>
</el-form-item>
</el-form>
<!-- result metrics + chart -->
<template v-if="evaluating">
<div class="eval-loading">
<el-icon class="is-loading" style="font-size:24px;color:#409EFF"><Refresh /></el-icon>
<span>正在推理,请稍候…</span>
</div>
</template>
<template v-else-if="currentResult">
<div class="metrics-row">
<div class="metric-chip">
<span class="metric-label">评估样本</span>
<span class="metric-value">{{ currentResult.total_count }}</span>
</div>
<div class="metric-chip">
<span class="metric-label">MAE</span>
<span class="metric-value">{{ fmtMetric(currentResult.mae) }}</span>
</div>
<div class="metric-chip">
<span class="metric-label">RMSE</span>
<span class="metric-value">{{ fmtMetric(currentResult.rmse) }}</span>
</div>
<div class="metric-chip">
<span class="metric-label">模型</span>
<span class="metric-value">{{ currentResult.model_name }}</span>
</div>
<div class="metric-chip">
<span class="metric-label">数据包</span>
<span class="metric-value">{{ currentResult.package_name }}</span>
</div>
</div>
<EvalChart :chart-data="currentResult.chart_data" height="340px" />
</template>
</el-card>
<!-- ── bottom: records table ────────────────────────────────────────── -->
<el-card class="records-card" shadow="hover">
<template #header>
<div class="card-header-row">
<span class="card-title">评估记录</span>
<el-button :icon="Refresh" size="small" plain :loading="loadingRecords" @click="loadRecords">
刷新
</el-button>
</div>
</template>
<el-table
:data="records"
v-loading="loadingRecords"
border
stripe
height="calc(100vh - 580px)"
>
<el-table-column prop="model_name" label="模型名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="package_name" label="评估数据包" min-width="130" show-overflow-tooltip />
<el-table-column label="样本数" width="90" align="center">
<template #default="{ row }">{{ row.total_count }}</template>
</el-table-column>
<el-table-column label="MAE (℃)" width="110" align="center">
<template #default="{ row }">
<span class="metric-cell">{{ fmtMetric(row.mae) }}</span>
</template>
</el-table-column>
<el-table-column label="RMSE (℃)" width="110" align="center">
<template #default="{ row }">
<span class="metric-cell">{{ fmtMetric(row.rmse) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="评估时间" width="165" />
<el-table-column label="操作" width="120" fixed="right" align="center">
<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>
<!-- ── historical view dialog ───────────────────────────────────────── -->
<el-dialog
v-model="dialogVisible"
:title="dialogRecord ? `${dialogRecord.model_name} — ${dialogRecord.package_name}` : '评估详情'"
width="860px"
destroy-on-close
>
<div v-loading="dialogLoading" style="min-height: 120px">
<template v-if="dialogRecord">
<div class="metrics-row" style="margin-bottom: 12px">
<div class="metric-chip">
<span class="metric-label">样本数</span>
<span class="metric-value">{{ dialogRecord.total_count }}</span>
</div>
<div class="metric-chip">
<span class="metric-label">MAE</span>
<span class="metric-value">{{ fmtMetric(dialogRecord.mae) }}</span>
</div>
<div class="metric-chip">
<span class="metric-label">RMSE</span>
<span class="metric-value">{{ fmtMetric(dialogRecord.rmse) }}</span>
</div>
<div class="metric-chip">
<span class="metric-label">评估时间</span>
<span class="metric-value">{{ dialogRecord.created_at }}</span>
</div>
</div>
<EvalChart :chart-data="dialogRecord.chart_data || []" height="380px" />
</template>
</div>
<template #footer>
<el-button @click="dialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.eval-page {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.config-card,
.records-card {
flex-shrink: 0;
:deep(.el-card__header) {
padding: 12px 20px;
border-bottom: 1px solid #e8edf3;
}
:deep(.el-card__body) {
padding: 16px 20px;
}
}
.records-card {
flex: 1;
min-height: 200px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.card-header-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.eval-form {
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
}
.eval-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 24px 0 8px;
color: #64748b;
font-size: 14px;
}
.metrics-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 16px 0 12px;
}
.metric-chip {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 16px;
min-width: 100px;
}
.metric-label {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.metric-value {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
.metric-cell {
font-size: 13px;
color: #0f766e;
font-weight: 500;
}
</style>
<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>
<script setup>
import { Refresh } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import {
cancelTrainTask,
createTrainTask,
deleteTrainTask,
getTrainPackages,
getTrainTasks,
restartTrainTask,
saveTrainModel,
} from '@/api/trainManagement'
// ── form ──────────────────────────────────────────────────────────────────────
const form = reactive({
model_name: '',
package_id: '',
params: {
seq_len: 20,
hidden_size: 64,
num_layers: 2,
epochs: 50,
batch_size: 32,
learning_rate: 0.001,
train_ratio: 0.8,
},
})
const packages = ref([])
const submitting = ref(false)
// ── tasks table ───────────────────────────────────────────────────────────────
const tasks = ref([])
const loadingTasks = ref(false)
let pollTimer = null
const hasActiveTasks = computed(() =>
tasks.value.some((t) => t.status === 'pending' || t.status === 'running'),
)
const startPolling = () => {
if (pollTimer) return
pollTimer = setInterval(loadTasks, 3000)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
const loadTasks = async () => {
try {
tasks.value = await getTrainTasks()
if (hasActiveTasks.value) {
startPolling()
} else {
stopPolling()
}
} catch {
// ignore poll errors silently
}
}
const handleStartTraining = async () => {
if (!form.model_name.trim()) {
ElMessage.warning('请输入模型名称')
return
}
if (!form.package_id) {
ElMessage.warning('请选择数据包')
return
}
submitting.value = true
try {
await createTrainTask({
model_name: form.model_name.trim(),
package_id: form.package_id,
params: { ...form.params },
})
ElMessage.success('训练任务已提交')
form.model_name = ''
await loadTasks()
} finally {
submitting.value = false
}
}
const handleCancel = async (task) => {
try {
await cancelTrainTask(task.id)
ElMessage.success('已发送取消请求')
await loadTasks()
} catch (e) {
ElMessage.error(e?.message || '取消失败')
}
}
const handleRestart = async (task) => {
try {
await restartTrainTask(task.id)
ElMessage.success('重新训练已启动')
await loadTasks()
} catch (e) {
ElMessage.error(e?.message || '重启失败')
}
}
const handleSave = async (task) => {
try {
await saveTrainModel(task.id)
ElMessage.success('模型已保存,可在模型列表中查看')
await loadTasks()
} catch (e) {
ElMessage.error(e?.message || '保存失败')
}
}
const handleDelete = async (task) => {
try {
await ElMessageBox.confirm(`确定删除训练任务"${task.model_name}"吗?`, '提示', {
type: 'warning',
})
await deleteTrainTask(task.id)
ElMessage.success('已删除')
await loadTasks()
} catch {
// user cancelled
}
}
// ── display helpers ───────────────────────────────────────────────────────────
const STATUS_MAP = {
pending: { type: 'info', text: '等待中' },
running: { type: 'warning', text: '训练中' },
completed: { type: 'success', text: '已完成' },
failed: { type: 'danger', text: '失败' },
cancelled: { type: '', text: '已取消' },
}
const getStatusTag = (status) => STATUS_MAP[status] || { type: 'info', text: status }
const formatParams = (params) => {
if (!params) return '-'
return [
`序列长度 ${params.seq_len}`,
`隐藏层 ${params.hidden_size}`,
`层数 ${params.num_layers}`,
`轮数 ${params.epochs}`,
`批次 ${params.batch_size}`,
`学习率 ${params.learning_rate}`,
`训练比 ${params.train_ratio}`,
].join(' / ')
}
const formatLoss = (task) => {
if (task.train_loss == null) return '-'
const train = `训练: ${Number(task.train_loss).toFixed(5)}`
const val = task.val_loss != null ? ` / 验证: ${Number(task.val_loss).toFixed(5)}` : ''
return train + val
}
onMounted(async () => {
loadingTasks.value = true
try {
await Promise.all([
getTrainPackages().then((d) => (packages.value = d)),
loadTasks(),
])
} finally {
loadingTasks.value = false
}
})
onBeforeUnmount(stopPolling)
</script>
<template>
<div class="train-page">
<!-- ── config card ──────────────────────────────────────────────────── -->
<el-card class="config-card" shadow="hover">
<template #header>
<span class="card-title">训练配置</span>
</template>
<el-form :model="form" label-position="top" class="train-form">
<!-- row 1: package + model name -->
<div class="form-row">
<el-form-item label="选择数据包" required class="form-item-wide">
<el-select
v-model="form.package_id"
placeholder="请选择数据包"
filterable
style="width: 100%"
>
<el-option
v-for="pkg in packages"
:key="pkg.id"
:label="`${pkg.name}(${pkg.data_count} 条)`"
:value="pkg.id"
/>
</el-select>
</el-form-item>
<el-form-item label="模型名称" required class="form-item-wide">
<el-input
v-model="form.model_name"
placeholder="请输入模型名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
</div>
<!-- row 2: LSTM params -->
<div class="params-section">
<span class="params-label">LSTM 超参数</span>
<div class="params-grid">
<el-form-item label="序列长度">
<el-input-number
v-model="form.params.seq_len"
:min="5"
:max="500"
:step="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="隐藏层大小">
<el-input-number
v-model="form.params.hidden_size"
:min="8"
:max="1024"
:step="8"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="LSTM 层数">
<el-input-number
v-model="form.params.num_layers"
:min="1"
:max="8"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="训练轮数 (Epochs)">
<el-input-number
v-model="form.params.epochs"
:min="1"
:max="2000"
:step="10"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="批次大小 (Batch)">
<el-input-number
v-model="form.params.batch_size"
:min="1"
:max="512"
:step="8"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="学习率">
<el-input-number
v-model="form.params.learning_rate"
:min="0.00001"
:max="1"
:step="0.0001"
:precision="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="训练集比例">
<el-input-number
v-model="form.params.train_ratio"
:min="0.5"
:max="0.99"
:step="0.05"
:precision="2"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</div>
</div>
<!-- action -->
<div class="form-action">
<el-button
type="primary"
size="large"
:loading="submitting"
@click="handleStartTraining"
>
开始训练
</el-button>
</div>
</el-form>
</el-card>
<!-- ── tasks table ──────────────────────────────────────────────────── -->
<el-card class="tasks-card" shadow="hover">
<template #header>
<div class="card-header-row">
<span class="card-title">训练记录</span>
<el-button
:icon="Refresh"
size="small"
plain
:loading="loadingTasks"
@click="loadTasks"
>
刷新
</el-button>
</div>
</template>
<el-table
:data="tasks"
v-loading="loadingTasks"
border
stripe
:height="tableHeight"
>
<el-table-column prop="model_name" label="模型名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="package_name" label="数据包" min-width="130" show-overflow-tooltip />
<el-table-column label="参数" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-tooltip :content="formatParams(row.params)" placement="top">
<span class="params-cell">{{ formatParams(row.params) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getStatusTag(row.status).type" size="small">
{{ getStatusTag(row.status).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="150" align="center">
<template #default="{ row }">
<template v-if="row.status === 'running'">
<el-progress
:percentage="row.progress"
:stroke-width="6"
:show-text="true"
style="width: 120px"
/>
</template>
<template v-else-if="row.status === 'completed'">
<el-progress
:percentage="100"
status="success"
:stroke-width="6"
style="width: 120px"
/>
</template>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="损失" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.status === 'failed'" class="err-text">
{{ row.error_msg || '未知错误' }}
</span>
<span v-else>{{ formatLoss(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160" />
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<!-- running / pending -->
<template v-if="row.status === 'running' || row.status === 'pending'">
<el-button link type="warning" @click="handleCancel(row)">取消</el-button>
</template>
<!-- completed -->
<template v-else-if="row.status === 'completed'">
<el-button
v-if="!row.is_saved"
link
type="primary"
@click="handleSave(row)"
>
保存模型
</el-button>
<el-tag v-else type="success" size="small" style="margin-right: 6px">已保存</el-tag>
<el-button link type="info" @click="handleRestart(row)">重新训练</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
<!-- failed / cancelled -->
<template v-else>
<el-button link type="info" @click="handleRestart(row)">重新训练</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script>
// tableHeight is a non-reactive calculation; compute once
const tableHeight = 'calc(100vh - 520px)'
export default {}
</script>
<style lang="scss" scoped>
.train-page {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
background: var(--bg-page);
}
.config-card,
.tasks-card {
flex-shrink: 0;
border: 1px solid var(--border-color);
box-shadow: var(--shadow-card) !important;
border-radius: 4px;
: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;
}
}
.tasks-card {
flex: 1;
min-height: 0;
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%;
}
.train-form {
.form-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.form-item-wide {
flex: 1;
min-width: 220px;
:deep(.el-form-item__content) {
display: block;
}
}
}
.params-section {
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-bottom: 8px;
}
.params-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
}
.params-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px 24px;
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.form-action {
border-top: 1px solid var(--border-color);
padding-top: 16px;
text-align: right;
}
.params-cell {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.muted {
color: var(--text-tertiary);
}
.err-text {
color: #F53F3F;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
</style>
<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