Commit 3faede64 authored by luwei's avatar luwei

功能修改

parent 478c4876
...@@ -100,6 +100,20 @@ def delete_file(file_id: str): ...@@ -100,6 +100,20 @@ def delete_file(file_id: str):
raise HTTPException(status_code=400, detail=str(error)) from error raise HTTPException(status_code=400, detail=str(error)) from error
@router.get('/quality-config')
def get_quality_config():
return success_response(data=service.get_quality_config())
@router.get('/files/{file_id}/quality')
def get_file_quality(file_id: str):
try:
result = service.assess_file_quality(file_id=file_id)
return success_response(data=result)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get('/files/{file_id}/records') @router.get('/files/{file_id}/records')
def get_file_records(file_id: str, limit: int = Query(default=500, ge=1, le=5000)): def get_file_records(file_id: str, limit: int = Query(default=500, ge=1, le=5000)):
try: try:
......
...@@ -73,18 +73,30 @@ class PackageCreateRequest(BaseModel): ...@@ -73,18 +73,30 @@ class PackageCreateRequest(BaseModel):
remark: str | None = Field(default=None) remark: str | None = Field(default=None)
file_ids: list[int] = Field(default_factory=list) file_ids: list[int] = Field(default_factory=list)
clean_rules: CleanRules | None = Field(default=None) clean_rules: CleanRules | None = Field(default=None)
row_start: int | None = Field(default=None, ge=1)
row_end: int | None = Field(default=None, ge=1)
class PreviewRequest(BaseModel): class PreviewRequest(BaseModel):
file_ids: list[int] = Field(default_factory=list) file_ids: list[int] = Field(default_factory=list)
clean_rules: CleanRules | None = Field(default=None) clean_rules: CleanRules | None = Field(default=None)
row_start: int | None = Field(default=None, ge=1)
row_end: int | None = Field(default=None, ge=1)
@router.post('/preview') @router.post('/preview')
def preview_package(request: PreviewRequest, limit: int = Query(default=300, ge=1, le=2000)): def preview_package(request: PreviewRequest, limit: int = Query(default=300, ge=1, le=2000)):
try: try:
if request.row_start and request.row_end and request.row_start > request.row_end:
raise ValueError('起始行不能大于结束行')
clean_rules = request.clean_rules.model_dump() if request.clean_rules else None clean_rules = request.clean_rules.model_dump() if request.clean_rules else None
result = service.preview_records(file_ids=request.file_ids, limit=limit, clean_rules=clean_rules) result = service.preview_records(
file_ids=request.file_ids,
limit=limit,
clean_rules=clean_rules,
row_start=request.row_start,
row_end=request.row_end,
)
return success_response(data=result) return success_response(data=result)
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error raise HTTPException(status_code=400, detail=str(error)) from error
...@@ -101,6 +113,8 @@ def list_packages( ...@@ -101,6 +113,8 @@ def list_packages(
@router.post('') @router.post('')
def create_package(request: PackageCreateRequest): def create_package(request: PackageCreateRequest):
try: try:
if request.row_start and request.row_end and request.row_start > request.row_end:
raise ValueError('起始行不能大于结束行')
category_id = None if request.category_id in (None, '', 'all') else str(request.category_id) category_id = None if request.category_id in (None, '', 'all') else str(request.category_id)
clean_rules = request.clean_rules.model_dump() if request.clean_rules else None clean_rules = request.clean_rules.model_dump() if request.clean_rules else None
pkg = service.create_package( pkg = service.create_package(
...@@ -109,6 +123,8 @@ def create_package(request: PackageCreateRequest): ...@@ -109,6 +123,8 @@ def create_package(request: PackageCreateRequest):
remark=request.remark, remark=request.remark,
file_ids=request.file_ids, file_ids=request.file_ids,
clean_rules=clean_rules, clean_rules=clean_rules,
row_start=request.row_start,
row_end=request.row_end,
) )
return success_response(data=pkg, message='数据包创建成功') return success_response(data=pkg, message='数据包创建成功')
except ValueError as error: except ValueError as error:
......
from app.models.data_management import Category, DataFile, DataPackage, DataPackageFile from app.models.data_management import Category, DataFile, DataPackage, DataPackageFile, DataQualityConfig
from app.models.eval_management import EvalRecord from app.models.eval_management import EvalRecord
from app.models.train_management import SavedModel, TrainTask from app.models.train_management import SavedModel, TrainTask
__all__ = ['Category', 'DataFile', 'DataPackage', 'DataPackageFile', 'TrainTask', 'SavedModel', 'EvalRecord'] __all__ = ['Category', 'DataFile', 'DataPackage', 'DataPackageFile', 'DataQualityConfig', 'TrainTask', 'SavedModel', 'EvalRecord']
from sqlalchemy import BIGINT, TIMESTAMP, Enum, Index, Integer, JSON, String, Text, text from sqlalchemy import BIGINT, TIMESTAMP, Double, Enum, Index, Integer, JSON, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base from app.database import Base
...@@ -59,6 +59,8 @@ class DataPackage(Base): ...@@ -59,6 +59,8 @@ class DataPackage(Base):
remark: Mapped[str | None] = mapped_column(Text, nullable=True, comment='备注') remark: Mapped[str | None] = mapped_column(Text, nullable=True, comment='备注')
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='数据条数')
clean_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment='野值清洗规则') clean_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment='野值清洗规则')
row_start: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='数据行起始行号(1-based, 含)')
row_end: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='数据行结束行号(1-based, 含)')
created_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP')) created_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
updated_at: Mapped[str] = mapped_column( updated_at: Mapped[str] = mapped_column(
TIMESTAMP, TIMESTAMP,
...@@ -79,3 +81,24 @@ class DataPackageFile(Base): ...@@ -79,3 +81,24 @@ class DataPackageFile(Base):
package_id: Mapped[int] = mapped_column(BIGINT, nullable=False, comment='数据包ID') package_id: Mapped[int] = mapped_column(BIGINT, nullable=False, comment='数据包ID')
file_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='排序') sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text('0'), comment='排序')
class DataQualityConfig(Base):
__tablename__ = 'data_quality_config'
__table_args__ = (
{'mysql_comment': '数据质量准确性配置表'},
)
id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
field_name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, comment='字段名')
label: Mapped[str] = mapped_column(String(100), nullable=False, comment='显示名称')
unit: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("''"), comment='单位')
min_value: Mapped[float | None] = mapped_column(Double, nullable=True, comment='合规最小值')
max_value: Mapped[float | None] = mapped_column(Double, nullable=True, 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'),
)
...@@ -9,7 +9,7 @@ from openpyxl import Workbook, load_workbook ...@@ -9,7 +9,7 @@ from openpyxl import Workbook, load_workbook
from sqlalchemy import func from sqlalchemy import func
from app.database import db_session from app.database import db_session
from app.models import Category, DataFile from app.models import Category, DataFile, DataQualityConfig
class DataManagementService: class DataManagementService:
...@@ -436,3 +436,186 @@ class DataManagementService: ...@@ -436,3 +436,186 @@ class DataManagementService:
workbook.save(output) workbook.save(output)
workbook.close() workbook.close()
return output.getvalue() return output.getvalue()
# ── quality ───────────────────────────────────────────────────────────────
def get_quality_config(self) -> list[dict[str, Any]]:
with db_session() as session:
rows = session.query(DataQualityConfig).order_by(DataQualityConfig.id.asc()).all()
return [
{
'id': item.id,
'field_name': item.field_name,
'label': item.label,
'unit': item.unit,
'min_value': item.min_value,
'max_value': item.max_value,
}
for item in rows
]
def assess_file_quality(self, file_id: str) -> dict[str, Any]:
file_db_id = self._parse_int_id(file_id, '文件ID')
with db_session() as session:
file_meta = session.query(DataFile).filter(DataFile.id == file_db_id).first()
if not file_meta:
raise ValueError('文件不存在')
target_path = self._resolve_local_file_path(file_meta.file_path, file_meta.stored_name)
if not target_path.exists():
raise ValueError('文件已丢失,请重新上传')
quality_config = self._get_quality_config_map()
all_rows = self._read_all_excel_rows(target_path)
if not all_rows:
return self._empty_quality_result()
first_row = [item.strip() for item in all_rows[0]]
has_header = any(
self._normalize_header(item) in {'time', 'current', 'voltage', 'set_temperature', 'actual_temperature'}
for item in first_row
)
if has_header:
header = first_row
data_rows = all_rows[1:]
else:
header = ['time', 'current', 'voltage', 'set_temperature', 'actual_temperature']
data_rows = all_rows
index_map = self._build_index_map(header)
total = len(data_rows)
if total == 0:
return self._empty_quality_result()
measurement_fields = ['current', 'voltage', 'set_temperature', 'actual_temperature']
complete_count = 0
continuous_count = 0
accurate_count = 0
has_measurement_count = 0
for row in data_rows:
field_values: dict[str, float | None] = {}
for field in measurement_fields:
val_str = self._read_value(row, index_map[field])
field_values[field] = self._to_float(val_str)
any_present = any(v is not None for v in field_values.values())
all_present = all(v is not None for v in field_values.values())
if any_present:
continuous_count += 1
has_measurement_count += 1
in_range = True
for field, val in field_values.items():
if val is None:
continue
cfg = quality_config.get(field, {})
min_val = cfg.get('min_value')
max_val = cfg.get('max_value')
if min_val is not None and val < min_val:
in_range = False
break
if max_val is not None and val > max_val:
in_range = False
break
if in_range:
accurate_count += 1
if all_present:
complete_count += 1
completeness = round(complete_count / total * 100, 2) if total > 0 else 0.0
continuity = round(continuous_count / total * 100, 2) if total > 0 else 0.0
accuracy = round(accurate_count / has_measurement_count * 100, 2) if has_measurement_count > 0 else 0.0
return {
'total': total,
'complete_count': complete_count,
'continuous_count': continuous_count,
'accurate_count': accurate_count,
'has_measurement_count': has_measurement_count,
'completeness': completeness,
'continuity': continuity,
'accuracy': accuracy,
'quality_config': list(quality_config.values()),
}
def _get_quality_config_map(self) -> dict[str, dict[str, Any]]:
with db_session() as session:
rows = session.query(DataQualityConfig).all()
return {
item.field_name: {
'field_name': item.field_name,
'label': item.label,
'unit': item.unit,
'min_value': item.min_value,
'max_value': item.max_value,
}
for item in rows
}
@staticmethod
def _empty_quality_result() -> dict[str, Any]:
return {
'total': 0,
'complete_count': 0,
'continuous_count': 0,
'accurate_count': 0,
'has_measurement_count': 0,
'completeness': 0.0,
'continuity': 0.0,
'accuracy': 0.0,
'quality_config': [],
}
def _read_all_excel_rows(self, file_path: Path) -> list[list[str]]:
"""Read all rows including fully-empty rows (for continuity check)."""
suffix = file_path.suffix.lower()
if suffix == '.xlsx':
return self._read_xlsx_rows_all(file_path)
if suffix == '.xls':
return self._read_xls_rows_all(file_path)
if suffix == '.csv':
return self._read_csv_rows_all(file_path)
return []
def _read_xlsx_rows_all(self, file_path: Path) -> list[list[str]]:
workbook = load_workbook(file_path, data_only=True, read_only=True)
try:
sheet = workbook.active
rows: list[list[str]] = []
for row in sheet.iter_rows(values_only=True):
cells = [self._cell_to_text(cell) for cell in row]
rows.append(cells)
return rows
finally:
workbook.close()
def _read_xls_rows_all(self, file_path: Path) -> list[list[str]]:
workbook = xlrd.open_workbook(file_path)
sheet = workbook.sheet_by_index(0)
rows: list[list[str]] = []
for row_index in range(sheet.nrows):
cells = [self._cell_to_text(sheet.cell_value(row_index, col_index)) for col_index in range(sheet.ncols)]
rows.append(cells)
return rows
def _read_csv_rows_all(self, file_path: Path) -> list[list[str]]:
encodings = ['utf-8-sig', 'utf-8', 'gbk', 'gb2312']
for encoding in encodings:
try:
with file_path.open('r', encoding=encoding, newline='') as file:
reader = csv.reader(file)
rows: list[list[str]] = []
for row in reader:
cells = [self._cell_to_text(cell) for cell in row]
rows.append(cells)
return rows
except UnicodeDecodeError:
continue
raise ValueError('CSV 文件编码无法识别,请保存为 UTF-8 或 GBK 后重试')
...@@ -105,6 +105,8 @@ class EvalService: ...@@ -105,6 +105,8 @@ class EvalService:
def _load_package_records(self, package_id: int) -> list[dict[str, Any]]: def _load_package_records(self, package_id: int) -> list[dict[str, Any]]:
with db_session() as session: with db_session() as session:
pkg = session.query(DataPackage).filter(DataPackage.id == package_id).first()
clean_rules = pkg.clean_rules if pkg else None
pf_rows = ( pf_rows = (
session.query(DataPackageFile) session.query(DataPackageFile)
.filter(DataPackageFile.package_id == package_id) .filter(DataPackageFile.package_id == package_id)
...@@ -126,8 +128,45 @@ class EvalService: ...@@ -126,8 +128,45 @@ class EvalService:
recs, _ = self._dm._read_records(path, limit=None) recs, _ = self._dm._read_records(path, limit=None)
all_records.extend(recs) all_records.extend(recs)
if clean_rules and clean_rules.get('enabled'):
all_records = self._apply_clean_rules(all_records, clean_rules)
return all_records return all_records
@staticmethod
def _apply_clean_rules(records: list[dict[str, Any]], clean_rules: dict) -> list[dict[str, Any]]:
c_min = clean_rules.get('current_min')
c_max = clean_rules.get('current_max')
v_min = clean_rules.get('voltage_min')
v_max = clean_rules.get('voltage_max')
t_min = clean_rules.get('temperature_min')
t_max = clean_rules.get('temperature_max')
result: list[dict[str, Any]] = []
for r in records:
current = r.get('current')
voltage = r.get('voltage')
temp = r.get('actual_temperature')
if current is not None:
if c_min is not None and current < c_min:
continue
if c_max is not None and current > c_max:
continue
if voltage is not None:
if v_min is not None and voltage < v_min:
continue
if v_max is not None and voltage > v_max:
continue
if temp is not None:
if t_min is not None and temp < t_min:
continue
if t_max is not None and temp > t_max:
continue
result.append(r)
return result
@staticmethod @staticmethod
def _record_to_dict(row: EvalRecord, include_chart: bool = True) -> dict[str, Any]: def _record_to_dict(row: EvalRecord, include_chart: bool = True) -> dict[str, Any]:
d: dict[str, Any] = { d: dict[str, Any] = {
......
...@@ -154,11 +154,15 @@ class PackageManagementService: ...@@ -154,11 +154,15 @@ class PackageManagementService:
remark: str | None, remark: str | None,
file_ids: list[int], file_ids: list[int],
clean_rules: dict | None = None, clean_rules: dict | None = None,
row_start: int | None = None,
row_end: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not name: if not name:
raise ValueError('数据包名称不能为空') raise ValueError('数据包名称不能为空')
if not file_ids: if not file_ids:
raise ValueError('请至少选择一个数据文件') raise ValueError('请至少选择一个数据文件')
if row_start is not None and row_end is not None and row_start > row_end:
raise ValueError('起始行不能大于结束行')
cat_db_id: int | None = None cat_db_id: int | None = None
if category_id and str(category_id).strip().lower() not in {'', 'all', 'none', 'null'}: if category_id and str(category_id).strip().lower() not in {'', 'all', 'none', 'null'}:
...@@ -180,8 +184,13 @@ class PackageManagementService: ...@@ -180,8 +184,13 @@ class PackageManagementService:
file_map = {f.id: f for f in files} file_map = {f.id: f for f in files}
if clean_rules and clean_rules.get('enabled'): needs_full_merge = bool((clean_rules and clean_rules.get('enabled')) or row_start is not None or row_end is not None)
result = self._merge_records(file_ids, file_map, limit=None, clean_rules=clean_rules) if needs_full_merge:
result = self._merge_records(
file_ids, file_map, limit=None,
clean_rules=clean_rules,
row_start=row_start, row_end=row_end,
)
total_count = result['count'] total_count = result['count']
stored_rules: dict | None = clean_rules stored_rules: dict | None = clean_rules
else: else:
...@@ -194,6 +203,8 @@ class PackageManagementService: ...@@ -194,6 +203,8 @@ class PackageManagementService:
remark=remark, remark=remark,
data_count=total_count, data_count=total_count,
clean_rules=stored_rules, clean_rules=stored_rules,
row_start=row_start,
row_end=row_end,
) )
session.add(pkg) session.add(pkg)
session.flush() session.flush()
...@@ -225,6 +236,8 @@ class PackageManagementService: ...@@ -225,6 +236,8 @@ class PackageManagementService:
raise ValueError('数据包不存在') raise ValueError('数据包不存在')
stored_clean_rules = pkg.clean_rules stored_clean_rules = pkg.clean_rules
stored_row_start = pkg.row_start
stored_row_end = pkg.row_end
pf_rows = ( pf_rows = (
session.query(DataPackageFile) session.query(DataPackageFile)
...@@ -236,13 +249,19 @@ class PackageManagementService: ...@@ -236,13 +249,19 @@ class PackageManagementService:
files = session.query(DataFile).filter(DataFile.id.in_(file_ids)).all() files = session.query(DataFile).filter(DataFile.id.in_(file_ids)).all()
file_map = {f.id: f for f in files} file_map = {f.id: f for f in files}
return self._merge_records(file_ids, file_map, limit, clean_rules=stored_clean_rules) return self._merge_records(
file_ids, file_map, limit,
clean_rules=stored_clean_rules,
row_start=stored_row_start, row_end=stored_row_end,
)
def preview_records( def preview_records(
self, self,
file_ids: list[int], file_ids: list[int],
limit: int = 300, limit: int = 300,
clean_rules: dict | None = None, clean_rules: dict | None = None,
row_start: int | None = None,
row_end: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not file_ids: if not file_ids:
return {'records': [], 'count': 0} return {'records': [], 'count': 0}
...@@ -251,7 +270,11 @@ class PackageManagementService: ...@@ -251,7 +270,11 @@ class PackageManagementService:
files = session.query(DataFile).filter(DataFile.id.in_(file_ids)).all() files = session.query(DataFile).filter(DataFile.id.in_(file_ids)).all()
file_map = {f.id: f for f in files} file_map = {f.id: f for f in files}
return self._merge_records(file_ids, file_map, limit, clean_rules=clean_rules) return self._merge_records(
file_ids, file_map, limit,
clean_rules=clean_rules,
row_start=row_start, row_end=row_end,
)
# ── helpers ────────────────────────────────────────────────────────────── # ── helpers ──────────────────────────────────────────────────────────────
...@@ -261,8 +284,12 @@ class PackageManagementService: ...@@ -261,8 +284,12 @@ class PackageManagementService:
file_map: dict[int, Any], file_map: dict[int, Any],
limit: int | None, limit: int | None,
clean_rules: dict | None = None, clean_rules: dict | None = None,
row_start: int | None = None,
row_end: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
use_filter = bool(clean_rules and clean_rules.get('enabled')) use_filter = bool(clean_rules and clean_rules.get('enabled'))
use_range = row_start is not None or row_end is not None
need_full_read = use_filter or use_range
all_records: list[dict[str, Any]] = [] all_records: list[dict[str, Any]] = []
total_count = 0 total_count = 0
remaining = limit remaining = limit
...@@ -272,21 +299,27 @@ class PackageManagementService: ...@@ -272,21 +299,27 @@ class PackageManagementService:
continue continue
file_meta = file_map[fid] file_meta = file_map[fid]
path = self._dm._resolve_local_file_path(file_meta.file_path, file_meta.stored_name) path = self._dm._resolve_local_file_path(file_meta.file_path, file_meta.stored_name)
if use_filter: if need_full_read:
# Read all rows so filter can be applied correctly
records, count = self._dm._read_records(path, limit=None) records, count = self._dm._read_records(path, limit=None)
else: else:
read_limit = (remaining if remaining is not None and remaining > 0 else 0) if remaining is not None else None read_limit = (remaining if remaining is not None and remaining > 0 else 0) if remaining is not None else None
records, count = self._dm._read_records(path, limit=read_limit) records, count = self._dm._read_records(path, limit=read_limit)
total_count += count total_count += count
all_records.extend(records) all_records.extend(records)
if not use_filter and remaining is not None: if not need_full_read and remaining is not None:
remaining -= len(records) remaining -= len(records)
if remaining <= 0: if remaining <= 0:
break break
if use_filter: if use_filter:
all_records = self._apply_clean_rules(all_records, clean_rules) all_records = self._apply_clean_rules(all_records, clean_rules)
if use_range:
# row_start/row_end are 1-based and inclusive
start_idx = (row_start - 1) if row_start and row_start >= 1 else 0
end_idx = row_end if row_end else len(all_records)
all_records = all_records[start_idx:end_idx]
total_count = len(all_records) total_count = len(all_records)
if limit is not None and limit > 0: if limit is not None and limit > 0:
...@@ -334,6 +367,8 @@ class PackageManagementService: ...@@ -334,6 +367,8 @@ class PackageManagementService:
'category_id': pkg.category_id, 'category_id': pkg.category_id,
'remark': pkg.remark, 'remark': pkg.remark,
'data_count': pkg.data_count, 'data_count': pkg.data_count,
'row_start': pkg.row_start,
'row_end': pkg.row_end,
'created_at': pkg.created_at.strftime('%Y-%m-%d %H:%M:%S') if pkg.created_at else '', '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 '', 'updated_at': pkg.updated_at.strftime('%Y-%m-%d %H:%M:%S') if pkg.updated_at else '',
} }
......
.data-page[data-v-7089f2ac]{background:var(--bg-page);height:100%}.data-page.dragging[data-v-7089f2ac]{-webkit-user-select:none;user-select:none;cursor:col-resize}.data-layout[data-v-7089f2ac]{background:var(--bg-white);border:1px solid var(--border-color);height:100%;box-shadow:var(--shadow-card);align-items:stretch;gap:0;display:flex}.pane-divider[data-v-7089f2ac]{background:var(--border-color);cursor:col-resize;flex-shrink:0;width:4px;transition:background .15s;position:relative}.pane-divider[data-v-7089f2ac]:after{content:"";position:absolute;inset:0 -2px}.pane-divider[data-v-7089f2ac]:hover,.dragging .pane-divider[data-v-7089f2ac]{background:var(--primary-border)}.pane-card[data-v-7089f2ac]{border:none;border-right:1px solid var(--border-color);background:var(--bg-white);border-radius:0;min-width:0;height:100%;box-shadow:none!important}.pane-card[data-v-7089f2ac] .el-card__header{border-bottom:1px solid var(--border-color);background:#f7f8fa;align-items:center;height:44px;padding:0 16px;display:flex}.pane-card[data-v-7089f2ac] .el-card__body{flex-direction:column;gap:10px;height:calc(100% - 44px);padding:12px 16px;display:flex}.pane-card--tree[data-v-7089f2ac] .el-card__body{padding:8px 0;overflow-y:auto}.pane-header[data-v-7089f2ac]{width:100%;color:var(--text-primary);justify-content:space-between;align-items:center;font-size:14px;font-weight:600;display:flex}.file-title[data-v-7089f2ac]{color:var(--text-secondary);margin-left:4px;font-size:13px;font-weight:400}.tree-node[data-v-7089f2ac]{justify-content:space-between;align-items:center;gap:8px;width:100%;padding-right:4px;display:flex}.tree-main[data-v-7089f2ac]{align-items:center;gap:6px;min-width:0;display:inline-flex}.tree-icon[data-v-7089f2ac]{color:var(--primary);flex:none;font-size:14px}.tree-name[data-v-7089f2ac]{color:var(--text-primary);text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.tree-actions[data-v-7089f2ac]{flex-shrink:0;display:none}.tree-actions .el-button[data-v-7089f2ac]{color:var(--text-tertiary);padding:0 3px;font-size:13px}.tree-actions .el-button[data-v-7089f2ac]:hover{color:var(--primary)}.tree-node:hover .tree-actions[data-v-7089f2ac]{display:inline-flex}.upload-panel[data-v-7089f2ac]{width:100%}.upload-panel__icon[data-v-7089f2ac]{color:var(--primary);margin-bottom:8px;font-size:28px}.search-form[data-v-7089f2ac]{flex-shrink:0;margin-bottom:4px}.content-wrap[data-v-7089f2ac]{flex:1;min-height:0}@media (width<=980px){.data-layout[data-v-7089f2ac]{display:block;overflow-y:auto}.pane-divider[data-v-7089f2ac]{display:none}.pane-card[data-v-7089f2ac]{border-right:none;border-bottom:1px solid var(--border-color);min-height:420px;margin-bottom:12px;width:100%!important}}
import{n as e,r as t,u as n}from"./es-DCOtnflc.js";import{$ as r,A as i,B as a,D as o,Ht as s,I as c,N as l,Q as u,R as d,V as f,Vt as p,X as m,_ as h,a as g,at as _,d as v,f as y,ht as b,m as x,p as S,st as C,t as w,u as T,v as E}from"./_plugin-vue_export-helper-D1RKUtCV.js";import{t as D}from"./request-D8DwihSV.js";import{t as O}from"./echarts-B4btcaVd.js";function k(){return D.get(`/eval/packages`)}function A(){return D.get(`/eval/models`)}function j(e){return D.post(`/eval/run`,e)}function M(){return D.get(`/eval/records`)}function N(e){return D.get(`/eval/records/${e}`)}function P(e){return D.delete(`/eval/records/${e}`)}var F={__name:`EvalChart`,props:{chartData:{type:Array,default:()=>[]},height:{type:String,default:`360px`}},setup(e){let t=e,n=C(null),r=null,a=T(()=>Array.isArray(t.chartData)?t.chartData.filter(e=>e!=null):[]),s=T(()=>a.value.map(e=>e.time||String(e.index??``))),u=T(()=>a.value.map(e=>e.actual??null)),d=T(()=>a.value.map(e=>e.predicted??null)),f=()=>{if(!n.value)return;r||=O(n.value);let e=a.value.length>0;r.setOption({animation:!1,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(e){if(!e?.length)return``;let t=[`<div style="margin-bottom:6px;font-weight:600;font-size:12px;">${e[0].axisValue}</div>`];return e.forEach(e=>{let n=e.data==null?`--`:Number(e.data).toFixed(4);t.push(`<div style="display:flex;align-items:center;gap:8px;min-width:180px;justify-content:space-between;">
<span>${e.marker}${e.seriesName}</span>
<strong>${n} ℃</strong>
</div>`)}),t.join(``)}},legend:{bottom:4,itemWidth:20,itemHeight:10,textStyle:{color:`#475569`,fontSize:12},data:[`实际温度 (℃)`,`预测温度 (℃)`]},grid:{top:16,left:16,right:20,bottom:52,containLabel:!0},xAxis:{type:`category`,boundaryGap:!1,data:s.value,axisLabel:{color:`#64748b`,fontSize:11,rotate:35,interval:Math.max(0,Math.floor(s.value.length/12)-1),hideOverlap:!0},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:!1,symbol:`none`,lineStyle:{width:2,type:`solid`},data:u.value},{name:`预测温度 (℃)`,type:`line`,smooth:!1,symbol:`none`,lineStyle:{width:2,type:`dashed`},data:d.value}],graphic:e?[]:[{type:`text`,left:`center`,top:`middle`,style:{text:`暂无评估数据`,fill:`#94a3b8`,fontSize:14}}],dataZoom:e?[{type:`inside`,start:0,end:100},{type:`slider`,start:0,end:100,height:20,bottom:28}]:[]},!0)},h=()=>r?.resize();return m(()=>t.chartData,async()=>{await o(),f()},{deep:!0}),l(async()=>{await o(),f(),window.addEventListener(`resize`,h)}),i(()=>{window.removeEventListener(`resize`,h),r?.dispose(),r=null}),(e,r)=>(c(),x(`div`,{ref_key:`chartRef`,ref:n,style:p({width:`100%`,height:t.height})},null,4))}},ee={class:`eval-page`},te={key:0,class:`eval-loading`},ne={class:`metrics-row`},re={class:`metric-chip`},ie={class:`metric-value`},ae={class:`metric-chip`},oe={class:`metric-value`},I={class:`metric-chip`},L={class:`metric-value`},R={class:`metric-chip`},z={class:`metric-value`},B={class:`metric-chip`},V={class:`metric-value`},H={class:`card-header-row`},U={class:`metric-cell`},W={class:`metric-cell`},G={style:{"min-height":`120px`}},K={class:`metrics-row`,style:{"margin-bottom":`12px`}},se={class:`metric-chip`},ce={class:`metric-value`},le={class:`metric-chip`},ue={class:`metric-value`},de={class:`metric-chip`},fe={class:`metric-value`},q={class:`metric-chip`},pe={class:`metric-value`},J=w({__name:`index`,setup(i){let o=C([]),p=C([]),m=_({model_id:``,package_id:``}),w=C(!1),T=C(null),D=C([]),O=C(!1),J=C(!1),Y=C(null),X=C(!1),Z=async()=>{let[e,t]=await Promise.all([k(),A()]);o.value=e,p.value=t},Q=async()=>{O.value=!0;try{D.value=await M()}finally{O.value=!1}},me=async()=>{if(!m.model_id){t.warning(`请选择模型`);return}if(!m.package_id){t.warning(`请选择数据包`);return}w.value=!0,T.value=null;try{T.value=await j({model_id:m.model_id,package_id:m.package_id}),t.success(`评估完成`),await Q()}catch(e){t.error(e?.message||`评估失败`)}finally{w.value=!1}},he=async e=>{X.value=!0,J.value=!0,Y.value=null;try{Y.value=await N(e.id)}finally{X.value=!1}},ge=async n=>{try{await e.confirm(`确定删除模型"${n.model_name}"在"${n.package_name}"上的评估记录吗?`,`提示`,{type:`warning`}),await P(n.id),t.success(`已删除`),T.value?.id===n.id&&(T.value=null),await Q()}catch{}},$=e=>e==null?`-`:Number(e).toFixed(5);return l(async()=>{await Promise.all([Z(),Q()])}),(e,t)=>{let i=a(`el-option`),l=a(`el-select`),_=a(`el-form-item`),C=a(`el-button`),k=a(`el-form`),A=a(`el-icon`),j=a(`el-card`),M=a(`el-table-column`),N=a(`el-table`),P=a(`el-dialog`),Z=f(`loading`);return c(),x(`div`,ee,[E(j,{class:`config-card`,shadow:`hover`},{header:u(()=>[...t[4]||=[v(`span`,{class:`card-title`},`评估配置`,-1)]]),default:u(()=>[E(k,{model:m,inline:``,class:`eval-form`},{default:u(()=>[E(_,{label:`选择模型`,required:``},{default:u(()=>[E(l,{modelValue:m.model_id,"onUpdate:modelValue":t[0]||=e=>m.model_id=e,placeholder:`请选择已保存模型`,filterable:``,style:{width:`260px`}},{default:u(()=>[(c(!0),x(g,null,d(p.value,e=>(c(),y(i,{key:e.id,label:`${e.model_name}(训练包:${e.package_name})`,value:e.id},null,8,[`label`,`value`]))),128))]),_:1},8,[`modelValue`])]),_:1}),E(_,{label:`选择数据包`,required:``},{default:u(()=>[E(l,{modelValue:m.package_id,"onUpdate:modelValue":t[1]||=e=>m.package_id=e,placeholder:`请选择评估数据包`,filterable:``,style:{width:`240px`}},{default:u(()=>[(c(!0),x(g,null,d(o.value,e=>(c(),y(i,{key:e.id,label:`${e.name}${e.data_count} 条)`,value:e.id},null,8,[`label`,`value`]))),128))]),_:1},8,[`modelValue`])]),_:1}),E(_,null,{default:u(()=>[E(C,{type:`primary`,loading:w.value,onClick:me},{default:u(()=>[...t[5]||=[h(` 开始评估 `,-1)]]),_:1},8,[`loading`])]),_:1})]),_:1},8,[`model`]),w.value?(c(),x(`div`,te,[E(A,{class:`is-loading`,style:{"font-size":`24px`,color:`#409EFF`}},{default:u(()=>[E(b(n))]),_:1}),t[6]||=v(`span`,null,`正在推理,请稍候…`,-1)])):T.value?(c(),x(g,{key:1},[v(`div`,ne,[v(`div`,re,[t[7]||=v(`span`,{class:`metric-label`},`评估样本`,-1),v(`span`,ie,s(T.value.total_count)+` 条`,1)]),v(`div`,ae,[t[8]||=v(`span`,{class:`metric-label`},`MAE`,-1),v(`span`,oe,s($(T.value.mae))+` ℃`,1)]),v(`div`,I,[t[9]||=v(`span`,{class:`metric-label`},`RMSE`,-1),v(`span`,L,s($(T.value.rmse))+` ℃`,1)]),v(`div`,R,[t[10]||=v(`span`,{class:`metric-label`},`模型`,-1),v(`span`,z,s(T.value.model_name),1)]),v(`div`,B,[t[11]||=v(`span`,{class:`metric-label`},`数据包`,-1),v(`span`,V,s(T.value.package_name),1)])]),E(F,{"chart-data":T.value.chart_data,height:`340px`},null,8,[`chart-data`])],64)):S(``,!0)]),_:1}),E(j,{class:`records-card`,shadow:`hover`},{header:u(()=>[v(`div`,H,[t[13]||=v(`span`,{class:`card-title`},`评估记录`,-1),E(C,{icon:b(n),size:`small`,plain:``,loading:O.value,onClick:Q},{default:u(()=>[...t[12]||=[h(` 刷新 `,-1)]]),_:1},8,[`icon`,`loading`])])]),default:u(()=>[r((c(),y(N,{data:D.value,border:``,stripe:``,height:`calc(100vh - 580px)`},{default:u(()=>[E(M,{prop:`model_name`,label:`模型名称`,"min-width":`140`,"show-overflow-tooltip":``}),E(M,{prop:`package_name`,label:`评估数据包`,"min-width":`130`,"show-overflow-tooltip":``}),E(M,{label:`样本数`,width:`90`,align:`center`},{default:u(({row:e})=>[h(s(e.total_count),1)]),_:1}),E(M,{label:`MAE (℃)`,width:`110`,align:`center`},{default:u(({row:e})=>[v(`span`,U,s($(e.mae)),1)]),_:1}),E(M,{label:`RMSE (℃)`,width:`110`,align:`center`},{default:u(({row:e})=>[v(`span`,W,s($(e.rmse)),1)]),_:1}),E(M,{prop:`created_at`,label:`评估时间`,width:`165`}),E(M,{label:`操作`,width:`120`,fixed:`right`,align:`center`},{default:u(({row:e})=>[E(C,{link:``,type:`primary`,onClick:t=>he(e)},{default:u(()=>[...t[14]||=[h(`查看`,-1)]]),_:1},8,[`onClick`]),E(C,{link:``,type:`danger`,onClick:t=>ge(e)},{default:u(()=>[...t[15]||=[h(`删除`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[Z,O.value]])]),_:1}),E(P,{modelValue:J.value,"onUpdate:modelValue":t[3]||=e=>J.value=e,title:Y.value?`${Y.value.model_name}${Y.value.package_name}`:`评估详情`,width:`860px`,"destroy-on-close":``},{footer:u(()=>[E(C,{onClick:t[2]||=e=>J.value=!1},{default:u(()=>[...t[20]||=[h(`关闭`,-1)]]),_:1})]),default:u(()=>[r((c(),x(`div`,G,[Y.value?(c(),x(g,{key:0},[v(`div`,K,[v(`div`,se,[t[16]||=v(`span`,{class:`metric-label`},`样本数`,-1),v(`span`,ce,s(Y.value.total_count),1)]),v(`div`,le,[t[17]||=v(`span`,{class:`metric-label`},`MAE`,-1),v(`span`,ue,s($(Y.value.mae))+` ℃`,1)]),v(`div`,de,[t[18]||=v(`span`,{class:`metric-label`},`RMSE`,-1),v(`span`,fe,s($(Y.value.rmse))+` ℃`,1)]),v(`div`,q,[t[19]||=v(`span`,{class:`metric-label`},`评估时间`,-1),v(`span`,pe,s(Y.value.created_at),1)])]),E(F,{"chart-data":Y.value.chart_data||[],height:`380px`},null,8,[`chart-data`])],64)):S(``,!0)])),[[Z,X.value]])]),_:1},8,[`modelValue`,`title`])])}}},[[`__scopeId`,`data-v-ec2828d6`]]);export{J as default};
\ No newline at end of file
import{n as e,r as t,u as n}from"./es-DCOtnflc.js";import{$ as r,B as i,Ht as a,I as o,N as s,Q as c,V as l,_ as u,d,f,ht as p,m,p as h,st as g,t as _,v}from"./_plugin-vue_export-helper-D1RKUtCV.js";import{a as y,r as b}from"./trainManagement-TFX-G3Jl.js";var x={class:`model-list-page`},S={class:`card-header-row`},C={class:`params-text`},w={class:`loss-text`},T=_({__name:`index`,setup(_){let T=g([]),E=g(!1),D=async()=>{E.value=!0;try{T.value=await y()}finally{E.value=!1}},O=async n=>{try{await e.confirm(`确定删除模型"${n.model_name}"吗?删除后无法恢复。`,`提示`,{type:`warning`}),await b(n.id),t.success(`模型已删除`),await D()}catch{}},k=e=>e?[`seq=${e.seq_len}`,`hidden=${e.hidden_size}`,`layers=${e.num_layers}`,`epochs=${e.epochs}`,`lr=${e.learning_rate}`].join(` / `):`-`,A=e=>e.train_loss==null?`-`:`训练 ${Number(e.train_loss).toFixed(5)}`+(e.val_loss==null?``:` / 验证 ${Number(e.val_loss).toFixed(5)}`);return s(D),(e,t)=>{let s=i(`el-button`),g=i(`el-table-column`),_=i(`el-tooltip`),y=i(`el-table`),b=i(`el-empty`),j=i(`el-card`),M=l(`loading`);return o(),m(`div`,x,[v(j,{shadow:`hover`,class:`list-card`},{header:c(()=>[d(`div`,S,[t[1]||=d(`span`,{class:`card-title`},`已保存模型`,-1),v(s,{icon:p(n),size:`small`,plain:``,loading:E.value,onClick:D},{default:c(()=>[...t[0]||=[u(` 刷新 `,-1)]]),_:1},8,[`icon`,`loading`])])]),default:c(()=>[r((o(),f(y,{data:T.value,border:``,stripe:``,height:`calc(100vh - 160px)`},{default:c(()=>[v(g,{type:`index`,width:`55`,label:`#`,align:`center`}),v(g,{prop:`model_name`,label:`模型名称`,"min-width":`150`,"show-overflow-tooltip":``}),v(g,{prop:`package_name`,label:`训练数据包`,"min-width":`140`,"show-overflow-tooltip":``}),v(g,{label:`LSTM 参数`,"min-width":`280`,"show-overflow-tooltip":``},{default:c(({row:e})=>[v(_,{content:k(e.params),placement:`top`},{default:c(()=>[d(`span`,C,a(k(e.params)),1)]),_:2},1032,[`content`])]),_:1}),v(g,{label:`训练损失`,"min-width":`190`,"show-overflow-tooltip":``},{default:c(({row:e})=>[d(`span`,w,a(A(e)),1)]),_:1}),v(g,{prop:`created_at`,label:`保存时间`,width:`170`}),v(g,{label:`操作`,width:`90`,fixed:`right`,align:`center`},{default:c(({row:e})=>[v(s,{link:``,type:`danger`,onClick:t=>O(e)},{default:c(()=>[...t[2]||=[u(`删除`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[M,E.value]]),!E.value&&T.value.length===0?(o(),f(b,{key:0,description:`暂无已保存的模型,请先在模型训练页面完成训练并保存`,style:{padding:`40px 0`}})):h(``,!0)]),_:1})])}}},[[`__scopeId`,`data-v-c204d828`]]);export{T as default};
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import{t as e}from"./request-D8DwihSV.js";function t(){return e.get(`/train/packages`)}function n(){return e.get(`/train/tasks`)}function r(t){return e.post(`/train/tasks`,t)}function i(t){return e.post(`/train/tasks/${t}/cancel`)}function a(t){return e.post(`/train/tasks/${t}/restart`)}function o(t){return e.delete(`/train/tasks/${t}`)}function s(t){return e.post(`/train/tasks/${t}/save`)}function c(){return e.get(`/train/models`)}function l(t){return e.delete(`/train/models/${t}`)}export{c as a,a as c,o as i,s as l,r as n,t as o,l as r,n as s,i as t};
\ No newline at end of file
...@@ -5,9 +5,9 @@ ...@@ -5,9 +5,9 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>热实验温度控制系统</title> <title>热实验温度控制系统</title>
<script type="module" crossorigin src="/assets/index-CvdqK_rU.js"></script> <script type="module" crossorigin src="/assets/index-8e_Vyf7m.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-D1RKUtCV.js"> <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-D1RKUtCV.js">
<link rel="modulepreload" crossorigin href="/assets/es-DCOtnflc.js"> <link rel="modulepreload" crossorigin href="/assets/es-XoFJL7_H.js">
<link rel="stylesheet" crossorigin href="/assets/index-pfQxyObg.css"> <link rel="stylesheet" crossorigin href="/assets/index-pfQxyObg.css">
</head> </head>
<body> <body>
......
...@@ -41,3 +41,11 @@ export function downloadDataTemplate() { ...@@ -41,3 +41,11 @@ export function downloadDataTemplate() {
responseType: 'blob', responseType: 'blob',
}) })
} }
export function getFileQuality(fileId) {
return request.get(`/data/files/${fileId}/quality`)
}
export function getQualityConfig() {
return request.get('/data/quality-config')
}
<script setup> <script setup>
import { Delete, Edit, Folder, FolderOpened, Upload } from '@element-plus/icons-vue' import { Delete, Document, Edit, Folder, FolderOpened, Upload } from '@element-plus/icons-vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
downloadDataTemplate, downloadDataTemplate,
getCategoryTree, getCategoryTree,
getDataFiles, getDataFiles,
getFileQuality,
getFileRecords, getFileRecords,
updateCategory, updateCategory,
uploadDataFile, uploadDataFile,
...@@ -42,6 +43,32 @@ const uploadForm = reactive({ ...@@ -42,6 +43,32 @@ const uploadForm = reactive({
categoryId: '', categoryId: '',
}) })
const qualityDialogVisible = ref(false)
const qualityLoading = ref(false)
const qualityFile = ref(null)
const qualityResult = ref(null)
const qualityColorClass = (percent) => {
if (percent >= 90) return 'quality-good'
if (percent >= 70) return 'quality-warn'
return 'quality-bad'
}
const handleQualityCheck = async (row) => {
qualityFile.value = row
qualityResult.value = null
qualityDialogVisible.value = true
qualityLoading.value = true
try {
qualityResult.value = await getFileQuality(row.id)
} catch {
ElMessage.error('质量评估失败')
qualityDialogVisible.value = false
} finally {
qualityLoading.value = false
}
}
const layoutRef = ref(null) const layoutRef = ref(null)
const leftPaneWidth = ref(16) const leftPaneWidth = ref(16)
const middlePaneWidth = ref(38) const middlePaneWidth = ref(38)
...@@ -483,9 +510,10 @@ onBeforeUnmount(() => { ...@@ -483,9 +510,10 @@ onBeforeUnmount(() => {
<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" />
<el-table-column label="操作" width="150" fixed="right"> <el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="handleViewFile(row)">查看</el-button> <el-button link type="primary" @click="handleViewFile(row)">查看</el-button>
<el-button link type="success" @click="handleQualityCheck(row)">质量判定</el-button>
<el-button link type="danger" @click="handleDeleteFile(row)">删除</el-button> <el-button link type="danger" @click="handleDeleteFile(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
...@@ -578,6 +606,99 @@ onBeforeUnmount(() => { ...@@ -578,6 +606,99 @@ onBeforeUnmount(() => {
<el-button type="primary" @click="submitCategory">保存</el-button> <el-button type="primary" @click="submitCategory">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 质量判定对话框 -->
<el-dialog
v-model="qualityDialogVisible"
title="数据质量判定"
width="500px"
destroy-on-close
>
<div v-if="qualityFile" class="quality-filename">
<el-icon><Document /></el-icon>
{{ qualityFile.filename }}
</div>
<div v-loading="qualityLoading" class="quality-body">
<template v-if="qualityResult && !qualityLoading">
<div class="quality-total">{{ qualityResult.total }} 条数据行</div>
<!-- 完整性 -->
<div class="quality-item">
<div class="quality-item__header">
<span class="quality-item__label">完整性</span>
<span class="quality-item__count">{{ qualityResult.complete_count }} / {{ qualityResult.total }} 条字段完整</span>
<span class="quality-item__pct" :class="qualityColorClass(qualityResult.completeness)">
{{ qualityResult.completeness }}%
</span>
</div>
<el-progress
:percentage="qualityResult.completeness"
:color="qualityResult.completeness >= 90 ? '#67c23a' : qualityResult.completeness >= 70 ? '#e6a23c' : '#f56c6c'"
:stroke-width="10"
:show-text="false"
/>
<div class="quality-item__desc">每条数据行的电流、电压、设定温度、实际温度均有值</div>
</div>
<!-- 连续性 -->
<div class="quality-item">
<div class="quality-item__header">
<span class="quality-item__label">连续性</span>
<span class="quality-item__count">{{ qualityResult.continuous_count }} / {{ qualityResult.total }} 条有效行</span>
<span class="quality-item__pct" :class="qualityColorClass(qualityResult.continuity)">
{{ qualityResult.continuity }}%
</span>
</div>
<el-progress
:percentage="qualityResult.continuity"
:color="qualityResult.continuity >= 90 ? '#67c23a' : qualityResult.continuity >= 70 ? '#e6a23c' : '#f56c6c'"
:stroke-width="10"
:show-text="false"
/>
<div class="quality-item__desc">非断点行(至少有一个测量值的行)占比,断点为所有测量字段均为空的行</div>
</div>
<!-- 准确性 -->
<div class="quality-item">
<div class="quality-item__header">
<span class="quality-item__label">准确性</span>
<span class="quality-item__count">{{ qualityResult.accurate_count }} / {{ qualityResult.has_measurement_count }} 条在范围内</span>
<span class="quality-item__pct" :class="qualityColorClass(qualityResult.accuracy)">
{{ qualityResult.accuracy }}%
</span>
</div>
<el-progress
:percentage="qualityResult.accuracy"
:color="qualityResult.accuracy >= 90 ? '#67c23a' : qualityResult.accuracy >= 70 ? '#e6a23c' : '#f56c6c'"
:stroke-width="10"
:show-text="false"
/>
<div class="quality-item__desc">
所有非空测量值均在配置范围内的行占比
<span v-if="qualityResult.quality_config && qualityResult.quality_config.length">
(当前配置:
<span v-for="(cfg, idx) in qualityResult.quality_config" :key="cfg.field_name">
<template v-if="cfg.min_value !== null || cfg.max_value !== null">
{{ cfg.label }}
<template v-if="cfg.min_value !== null">{{ cfg.min_value }}{{ cfg.unit }}</template>
<template v-if="cfg.max_value !== null">{{ cfg.max_value }}{{ cfg.unit }}</template>
<template v-if="idx < qualityResult.quality_config.length - 1"></template>
</template>
</span>
</span>
<span v-else class="quality-no-config">(未配置准确性范围,请在 data_quality_config 表中设置)</span>
</div>
</div>
</template>
<el-empty v-else-if="!qualityLoading" description="暂无质量数据" :image-size="60" />
</div>
<template #footer>
<el-button @click="qualityDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</section> </section>
</template> </template>
...@@ -760,4 +881,71 @@ onBeforeUnmount(() => { ...@@ -760,4 +881,71 @@ onBeforeUnmount(() => {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
} }
// quality dialog
.quality-filename {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #475569;
margin-bottom: 16px;
word-break: break-all;
}
.quality-total {
font-size: 13px;
color: #64748b;
margin-bottom: 12px;
}
.quality-body {
min-height: 120px;
}
.quality-item {
margin-bottom: 20px;
&__header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
}
&__label {
font-size: 14px;
font-weight: 600;
color: #0f172a;
min-width: 48px;
}
&__count {
font-size: 12px;
color: #64748b;
flex: 1;
}
&__pct {
font-size: 18px;
font-weight: 700;
min-width: 56px;
text-align: right;
&.quality-good { color: #67c23a; }
&.quality-warn { color: #e6a23c; }
&.quality-bad { color: #f56c6c; }
}
&__desc {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
line-height: 1.5;
}
}
.quality-no-config {
color: #e6a23c;
}
</style> </style>
...@@ -3,6 +3,7 @@ import { ArrowLeft } from '@element-plus/icons-vue' ...@@ -3,6 +3,7 @@ import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { createPackage, getAllDataFiles, getPkgCategoryTree, previewPackage } from '@/api/packageManagement' import { createPackage, getAllDataFiles, getPkgCategoryTree, previewPackage } from '@/api/packageManagement'
import { getQualityConfig } from '@/api/dataManagement'
import DataCurve from '@/views/DataManagement/components/DataCurve.vue' import DataCurve from '@/views/DataManagement/components/DataCurve.vue'
const emit = defineEmits(['cancel', 'saved']) const emit = defineEmits(['cancel', 'saved'])
...@@ -56,6 +57,56 @@ const cleanRules = reactive({ ...@@ -56,6 +57,56 @@ const cleanRules = reactive({
temperature_max: null, temperature_max: null,
}) })
const applyQualityConfigToCleanRules = (configs) => {
const map = {}
for (const cfg of configs) {
map[cfg.field_name] = cfg
}
if (map.current) {
if (cleanRules.current_min === null) cleanRules.current_min = map.current.min_value ?? null
if (cleanRules.current_max === null) cleanRules.current_max = map.current.max_value ?? null
}
if (map.voltage) {
if (cleanRules.voltage_min === null) cleanRules.voltage_min = map.voltage.min_value ?? null
if (cleanRules.voltage_max === null) cleanRules.voltage_max = map.voltage.max_value ?? null
}
// temperature: take broader range of set_temperature and actual_temperature
const setTMin = map.set_temperature?.min_value ?? null
const setTMax = map.set_temperature?.max_value ?? null
const actTMin = map.actual_temperature?.min_value ?? null
const actTMax = map.actual_temperature?.max_value ?? null
if (cleanRules.temperature_min === null) {
const candidates = [setTMin, actTMin].filter((v) => v !== null)
cleanRules.temperature_min = candidates.length ? Math.min(...candidates) : null
}
if (cleanRules.temperature_max === null) {
const candidates = [setTMax, actTMax].filter((v) => v !== null)
cleanRules.temperature_max = candidates.length ? Math.max(...candidates) : null
}
}
watch(
() => cleanRules.enabled,
async (enabled) => {
if (!enabled) return
// Only auto-fill if all fields are still null (user hasn't typed yet)
const allNull =
cleanRules.current_min === null &&
cleanRules.current_max === null &&
cleanRules.voltage_min === null &&
cleanRules.voltage_max === null &&
cleanRules.temperature_min === null &&
cleanRules.temperature_max === null
if (!allNull) return
try {
const configs = await getQualityConfig()
applyQualityConfigToCleanRules(configs)
} catch {
// ignore: quality config may not be configured yet
}
},
)
const cleanRulesPayload = computed(() => { const cleanRulesPayload = computed(() => {
if (!cleanRules.enabled) return null if (!cleanRules.enabled) return null
return { return {
...@@ -69,7 +120,31 @@ const cleanRulesPayload = computed(() => { ...@@ -69,7 +120,31 @@ const cleanRulesPayload = computed(() => {
} }
}) })
// ── preview ─────────────────────────────────────────────────────────────────── // ── row range ─────────────────────────────────────────────────────────────────
const rowRange = reactive({
enabled: false,
start: null,
end: null,
})
const rowRangeError = computed(() => {
if (!rowRange.enabled) return ''
const s = rowRange.start
const e = rowRange.end
if (s !== null && s < 1) return '起始行不能小于 1'
if (e !== null && e < 1) return '结束行不能小于 1'
if (s !== null && e !== null && s > e) return '起始行不能大于结束行'
return ''
})
const rowRangePayload = computed(() => {
if (!rowRange.enabled) return { row_start: null, row_end: null }
return {
row_start: rowRange.start ?? null,
row_end: rowRange.end ?? null,
}
})
const previewLoading = ref(false) const previewLoading = ref(false)
const previewRecords = ref([]) const previewRecords = ref([])
const previewTotal = ref(0) const previewTotal = ref(0)
...@@ -87,7 +162,11 @@ const triggerPreview = () => { ...@@ -87,7 +162,11 @@ const triggerPreview = () => {
previewLoading.value = true previewLoading.value = true
try { try {
const result = await previewPackage( const result = await previewPackage(
{ file_ids: selectedFileIds.value, clean_rules: cleanRulesPayload.value }, {
file_ids: selectedFileIds.value,
clean_rules: cleanRulesPayload.value,
...rowRangePayload.value,
},
{ limit: 300 }, { limit: 300 },
) )
previewRecords.value = result.records previewRecords.value = result.records
...@@ -100,6 +179,7 @@ const triggerPreview = () => { ...@@ -100,6 +179,7 @@ const triggerPreview = () => {
watch(selectedFileIds, triggerPreview, { deep: true }) watch(selectedFileIds, triggerPreview, { deep: true })
watch(cleanRules, triggerPreview, { deep: true }) watch(cleanRules, triggerPreview, { deep: true })
watch(rowRange, triggerPreview, { deep: true })
// ── save ────────────────────────────────────────────────────────────────────── // ── save ──────────────────────────────────────────────────────────────────────
const saving = ref(false) const saving = ref(false)
...@@ -113,6 +193,10 @@ const handleGenerate = async () => { ...@@ -113,6 +193,10 @@ const handleGenerate = async () => {
ElMessage.warning('请至少选择一个数据文件') ElMessage.warning('请至少选择一个数据文件')
return return
} }
if (rowRangeError.value) {
ElMessage.warning(rowRangeError.value)
return
}
saving.value = true saving.value = true
try { try {
...@@ -122,6 +206,7 @@ const handleGenerate = async () => { ...@@ -122,6 +206,7 @@ const handleGenerate = async () => {
remark: form.remark.trim() || null, remark: form.remark.trim() || null,
file_ids: selectedFileIds.value, file_ids: selectedFileIds.value,
clean_rules: cleanRulesPayload.value, clean_rules: cleanRulesPayload.value,
...rowRangePayload.value,
}) })
ElMessage.success('数据包创建成功') ElMessage.success('数据包创建成功')
emit('saved') emit('saved')
...@@ -255,6 +340,34 @@ onMounted(async () => { ...@@ -255,6 +340,34 @@ onMounted(async () => {
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="数据行范围">
<div class="row-range-wrap">
<el-checkbox v-model="rowRange.enabled">启用行范围截取</el-checkbox>
<div v-if="rowRange.enabled" class="row-range-inputs">
<el-input-number
v-model="rowRange.start"
:controls="false"
:value-on-clear="null"
:min="1"
:precision="0"
placeholder="起始行"
class="range-input"
/>
<span class="range-sep">~</span>
<el-input-number
v-model="rowRange.end"
:controls="false"
:value-on-clear="null"
:min="1"
:precision="0"
placeholder="结束行"
class="range-input"
/>
<span class="range-unit">行(含两端)</span>
<div v-if="rowRangeError" class="row-range-error">{{ rowRangeError }}</div>
</div>
</div>
</el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input <el-input
v-model="form.remark" v-model="form.remark"
...@@ -450,4 +563,28 @@ onMounted(async () => { ...@@ -450,4 +563,28 @@ onMounted(async () => {
font-size: 13px; font-size: 13px;
color: #94a3b8; color: #94a3b8;
} }
.row-range-wrap {
width: 100%;
}
.row-range-inputs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.range-unit {
font-size: 12px;
color: #94a3b8;
}
.row-range-error {
width: 100%;
font-size: 12px;
color: #f56c6c;
margin-top: 2px;
}
</style> </style>
\ No newline at end of file
...@@ -45,6 +45,12 @@ CREATE TABLE IF NOT EXISTS data_packages ( ...@@ -45,6 +45,12 @@ CREATE TABLE IF NOT EXISTS data_packages (
ALTER TABLE data_packages ALTER TABLE data_packages
ADD COLUMN IF NOT EXISTS clean_rules JSON NULL COMMENT '野值清洗规则'; ADD COLUMN IF NOT EXISTS clean_rules JSON NULL COMMENT '野值清洗规则';
ALTER TABLE data_packages
ADD COLUMN IF NOT EXISTS row_start INT NULL COMMENT '数据行起始行号(1-based, 含)';
ALTER TABLE data_packages
ADD COLUMN IF NOT EXISTS row_end INT NULL COMMENT '数据行结束行号(1-based, 含)';
CREATE TABLE IF NOT EXISTS data_package_files ( CREATE TABLE IF NOT EXISTS data_package_files (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
package_id BIGINT NOT NULL COMMENT '数据包ID', package_id BIGINT NOT NULL COMMENT '数据包ID',
...@@ -59,3 +65,21 @@ ALTER TABLE train_tasks ...@@ -59,3 +65,21 @@ ALTER TABLE train_tasks
ALTER TABLE saved_models ALTER TABLE saved_models
ADD COLUMN IF NOT EXISTS test_loss FLOAT NULL COMMENT '测试集损失'; ADD COLUMN IF NOT EXISTS test_loss FLOAT NULL COMMENT '测试集损失';
CREATE TABLE IF NOT EXISTS data_quality_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
field_name VARCHAR(50) NOT NULL COMMENT '字段名: current/voltage/set_temperature/actual_temperature',
label VARCHAR(100) NOT NULL COMMENT '显示名称',
unit VARCHAR(20) NOT NULL DEFAULT '' COMMENT '单位',
min_value DOUBLE DEFAULT NULL COMMENT '合规最小值',
max_value DOUBLE DEFAULT NULL COMMENT '合规最大值',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_field_name (field_name)
) COMMENT='数据质量准确性配置表';
INSERT IGNORE INTO data_quality_config (field_name, label, unit) VALUES
('current', '电流', 'A'),
('voltage', '电压', 'V'),
('set_temperature', '设定温度', '℃'),
('actual_temperature', '实际温度', '℃');
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