Commit 6ca52b5b authored by luwei's avatar luwei

Initial commit

parents
Pipeline #361 canceled with stages
# Node.js dependencies
node_modules/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# OS generated files
.DS_Store
Thumbs.db
# Python dependencies
__pycache__/
*.py[cod]
*.pyo
*.pyd
venv/
# IDE settings
.vscode/
.idea/
*.sublime-workspace
# Environment variables
.env
# Build outputs
dist/
build/
*.pyc
# Database
*.sqlite3
*.db
# Miscellaneous
*.bak
*.swp
*.tmp
*.temp
from fastapi import APIRouter, File, Form, HTTPException, Query, Response, UploadFile
from pydantic import BaseModel, Field
from app.services.data_management_service import DataManagementService
from app.utils.response import success_response
router = APIRouter()
service = DataManagementService()
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
@router.get('/files')
def get_files(
category_id: str = Query(default=''),
filename: str = Query(default=''),
):
return success_response(data=service.list_files(category_id=category_id, filename=filename.strip()))
@router.post('/files/upload')
async def upload_file(
file: UploadFile = File(...),
category_id: str = Form(default=''),
):
try:
content = await file.read()
if not content:
raise ValueError('上传文件为空')
file_meta = service.upload_file(
filename=file.filename or 'unknown.xlsx',
content=content,
category_id=category_id,
)
return success_response(data=file_meta, message='文件上传成功')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get('/files/template')
def download_template():
content = service.get_excel_template_bytes()
return Response(
content=content,
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=data_template.xlsx',
},
)
@router.delete('/files/{file_id}')
def delete_file(file_id: str):
try:
service.delete_file(file_id=file_id)
return success_response(data=True, message='文件删除成功')
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get('/files/{file_id}/records')
def get_file_records(file_id: str, limit: int = Query(default=500, ge=1, le=5000)):
try:
result = service.get_file_records(file_id=file_id, limit=limit)
return success_response(data=result)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
import os
from dataclasses import dataclass
def _read_env(name: str, default: str) -> str:
return os.getenv(name, default).strip()
@dataclass(frozen=True)
class Settings:
mysql_host: str = _read_env('MYSQL_HOST', '39.107.252.11')
mysql_port: int = int(_read_env('MYSQL_PORT', '3366'))
mysql_user: str = _read_env('MYSQL_USER', 'root')
mysql_password: str = _read_env('MYSQL_PASSWORD', 'RCX8WRu07M')
mysql_database: str = _read_env('MYSQL_DATABASE', 'thermal_control_system')
settings = Settings()
\ No newline at end of file
from contextlib import contextmanager
from typing import Generator
from sqlalchemy import create_engine, text
from sqlalchemy.engine import URL
from sqlalchemy.orm import Session, declarative_base, sessionmaker
from app.config import settings
Base = declarative_base()
def _build_mysql_url(database: str | None = None) -> URL:
return URL.create(
drivername='mysql+pymysql',
username=settings.mysql_user,
password=settings.mysql_password,
host=settings.mysql_host,
port=settings.mysql_port,
database=database,
)
engine = create_engine(
_build_mysql_url(settings.mysql_database),
pool_pre_ping=True,
pool_recycle=3600,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
@contextmanager
def db_session() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
finally:
session.close()
def init_database() -> None:
admin_engine = create_engine(_build_mysql_url(), pool_pre_ping=True)
try:
with admin_engine.begin() as connection:
connection.execute(
text(
f"CREATE DATABASE IF NOT EXISTS `{settings.mysql_database}` "
'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
)
)
finally:
admin_engine.dispose()
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS categories (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '分类名称',
type ENUM('data_file', 'data_package') NOT NULL COMMENT '分类类型',
parent_id BIGINT DEFAULT NULL COMMENT '父分类ID',
sort_order INT DEFAULT 0 COMMENT '排序',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_type (type),
INDEX idx_parent (parent_id)
) COMMENT='数据分类表'
"""
)
)
from app.models import data_management # noqa: F401
Base.metadata.create_all(bind=engine)
\ No newline at end of file
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.data_management import router as data_management_router
from app.database import init_database
from app.utils.response import error_response, success_response
def create_app() -> FastAPI:
app = FastAPI(
title='Thermal Control System API',
version='0.1.0',
)
app.add_middleware(
CORSMiddleware,
allow_origins=['*'],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
@app.exception_handler(HTTPException)
async def http_exception_handler(_, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content=error_response(message=str(exc.detail), code=exc.status_code),
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(_, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content=error_response(message='参数校验失败', code=422, data=exc.errors()),
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(_, exc: Exception):
return JSONResponse(
status_code=500,
content=error_response(message=f'服务器异常: {exc}', code=500),
)
@app.get('/api/health')
async def health_check():
return success_response(data={'status': 'ok'}, message='服务正常')
@app.on_event('startup')
def startup_event():
init_database()
app.include_router(data_management_router, prefix='/api/data', tags=['数据管理'])
return app
app = create_app()
\ No newline at end of file
from app.models.data_management import Category, DataFile
__all__ = ['Category', 'DataFile']
from sqlalchemy import BIGINT, TIMESTAMP, Enum, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Category(Base):
__tablename__ = 'categories'
__table_args__ = (
Index('idx_type', 'type'),
Index('idx_parent', 'parent_id'),
{'mysql_comment': '数据分类表'},
)
id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment='分类名称')
type: Mapped[str] = mapped_column(
Enum('data_file', 'data_package', name='category_type_enum'),
nullable=False,
comment='分类类型',
)
parent_id: Mapped[int | None] = mapped_column(BIGINT, nullable=True, comment='父分类ID')
sort_order: 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 DataFile(Base):
__tablename__ = 'data_files'
__table_args__ = (
Index('idx_file_category', 'category_id'),
{'mysql_comment': '数据文件表'},
)
id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
filename: Mapped[str] = mapped_column(String(255), nullable=False, comment='原始文件名')
stored_name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, comment='存储文件名')
category_id: Mapped[int | None] = mapped_column(BIGINT, nullable=True, comment='分类ID')
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'))
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
import io
from datetime import datetime
from pathlib import Path
from typing import Any
import xlrd
from openpyxl import Workbook, load_workbook
from sqlalchemy import func
from app.database import db_session
from app.models import Category, DataFile
class DataManagementService:
def __init__(self) -> None:
self._base_dir = Path(__file__).resolve().parents[3]
self._upload_dir = self._base_dir / 'uploads'
self._data_dir = self._upload_dir / 'data'
self._upload_dir.mkdir(parents=True, exist_ok=True)
self._data_dir.mkdir(parents=True, exist_ok=True)
def get_category_tree(self) -> list[dict[str, Any]]:
with db_session() as session:
rows = (
session.query(Category)
.filter(Category.type == 'data_file')
.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_file',
parent_id=None,
sort_order=0,
)
session.add(category)
session.commit()
session.refresh(category)
return {
'id': category.id,
'name': category.name,
'parent_id': category.parent_id,
'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('分类名称不能为空')
category_db_id = self._parse_int_id(category_id, '分类ID')
with db_session() as session:
category = (
session.query(Category)
.filter(Category.id == category_db_id, Category.type == 'data_file')
.first()
)
if not category:
raise ValueError('分类不存在')
category.name = name
session.commit()
session.refresh(category)
return {
'id': category.id,
'name': category.name,
'parent_id': category.parent_id,
'type': category.type,
'sort_order': category.sort_order,
}
def delete_category(self, category_id: str) -> None:
if category_id in {'', 'all'}:
raise ValueError('分类ID不能为空')
category_db_id = self._parse_int_id(category_id, '分类ID')
with db_session() as session:
category = (
session.query(Category)
.filter(Category.id == category_db_id, Category.type == 'data_file')
.first()
)
if not category:
raise ValueError('分类不存在')
child_count = (
session.query(func.count(Category.id))
.filter(Category.parent_id == category_db_id, Category.type == 'data_file')
.scalar()
)
if child_count and child_count > 0:
raise ValueError('请先删除子分类')
file_count = (
session.query(func.count(DataFile.id))
.filter(DataFile.category_id == category_db_id)
.scalar()
)
if file_count and file_count > 0:
raise ValueError('该分类下仍有文件,无法删除')
session.delete(category)
session.commit()
def list_files(self, category_id: str = 'all', filename: str = '') -> list[dict[str, Any]]:
with db_session() as session:
query = session.query(DataFile)
if category_id not in {'', 'all', None}:
root_id = self._parse_int_id(category_id, '分类ID')
query = query.filter(DataFile.category_id == root_id)
if filename:
query = query.filter(DataFile.filename.like(f'%{filename}%'))
rows = query.order_by(DataFile.uploaded_at.desc(), DataFile.id.desc()).all()
return [
{
'id': item.id,
'filename': item.filename,
'stored_name': item.stored_name,
'category_id': item.category_id,
'uploaded_at': item.uploaded_at.strftime('%Y-%m-%d %H:%M:%S') if item.uploaded_at else '',
'data_count': item.data_count,
}
for item in rows
]
def upload_file(self, filename: str, content: bytes, category_id: str = 'all') -> dict[str, Any]:
suffix = Path(filename).suffix.lower()
if suffix not in {'.xlsx', '.xls'}:
raise ValueError('仅支持 Excel 文件(.xlsx/.xls)')
category_db_id: int | None
if category_id in {'', 'all', None}:
category_db_id = None
else:
category_db_id = self._parse_int_id(category_id, '分类ID')
timestamp = datetime.now().strftime('%Y%m%d%H%M%S%f')
stored_name = f'{timestamp}_{Path(filename).name}'
target_path = self._data_dir / stored_name
with target_path.open('wb') as file:
file.write(content)
_, total_count = self._read_records(target_path, limit=None)
try:
with db_session() as session:
if category_db_id is not None:
category_exists = (
session.query(Category)
.filter(Category.id == category_db_id, Category.type == 'data_file')
.first()
)
if not category_exists:
raise ValueError('上传分类不存在')
file_meta = DataFile(
filename=filename,
stored_name=stored_name,
category_id=category_db_id,
data_count=total_count,
)
session.add(file_meta)
session.commit()
session.refresh(file_meta)
return {
'id': file_meta.id,
'filename': file_meta.filename,
'stored_name': file_meta.stored_name,
'category_id': file_meta.category_id,
'uploaded_at': file_meta.uploaded_at.strftime('%Y-%m-%d %H:%M:%S') if file_meta.uploaded_at else '',
'data_count': file_meta.data_count,
}
except Exception:
if target_path.exists():
target_path.unlink()
raise
def delete_file(self, file_id: str) -> None:
file_db_id = self._parse_int_id(file_id, '文件ID')
with db_session() as session:
matched = session.query(DataFile).filter(DataFile.id == file_db_id).first()
if not matched:
raise ValueError('文件不存在')
target_path = self._data_dir / matched.stored_name
if target_path.exists():
target_path.unlink()
session.delete(matched)
session.commit()
def get_file_records(self, file_id: str, limit: int = 500) -> 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._data_dir / file_meta.stored_name
if not target_path.exists():
raise ValueError('文件已丢失,请重新上传')
records, total_count = self._read_records(target_path, limit=limit)
return {
'records': records,
'count': total_count,
}
def _read_records(self, file_path: Path, limit: int | None = None) -> tuple[list[dict[str, Any]], int]:
rows = self._read_excel_rows(file_path)
if not rows:
return [], 0
first_row = [item.strip() for item in rows[0]]
has_header = any(self._normalize_header(item) in {'time', 'current', 'voltage', 'temperature'} for item in first_row)
if has_header:
header = first_row
data_rows = rows[1:]
else:
header = ['time', 'current', 'voltage', 'temperature']
data_rows = rows
index_map = self._build_index_map(header)
total_count = len(data_rows)
if limit is None or limit < 0:
selected_rows = data_rows
else:
selected_rows = data_rows[:limit]
records: list[dict[str, Any]] = []
for row in selected_rows:
records.append(
{
'time': self._read_value(row, index_map['time']),
'current': self._to_float(self._read_value(row, index_map['current'])),
'voltage': self._to_float(self._read_value(row, index_map['voltage'])),
'temperature': self._to_float(self._read_value(row, index_map['temperature'])),
}
)
return records, total_count
def _build_index_map(self, header: list[str]) -> dict[str, int]:
index_map = {
'time': 0,
'current': 1,
'voltage': 2,
'temperature': 3,
}
for index, name in enumerate(header):
normalized = self._normalize_header(name)
if normalized == 'time':
index_map['time'] = index
elif normalized == 'current':
index_map['current'] = index
elif normalized == 'voltage':
index_map['voltage'] = index
elif normalized == 'temperature':
index_map['temperature'] = index
return index_map
def _normalize_header(self, name: str) -> str:
key = name.strip().lower()
if key in {'time', '时间', 'timestamp'}:
return 'time'
if key in {'current', '电流'}:
return 'current'
if key in {'voltage', '电压'}:
return 'voltage'
if key in {'temperature', '温度'}:
return 'temperature'
return key
def _read_value(self, row: list[str], index: int) -> str:
if index < len(row):
return row[index].strip()
return ''
def _read_excel_rows(self, file_path: Path) -> list[list[str]]:
suffix = file_path.suffix.lower()
if suffix == '.xlsx':
return self._read_xlsx_rows(file_path)
if suffix == '.xls':
return self._read_xls_rows(file_path)
return []
def _read_xlsx_rows(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]
if any(cell != '' for cell in cells):
rows.append(cells)
return rows
finally:
workbook.close()
def _read_xls_rows(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)]
if any(cell != '' for cell in cells):
rows.append(cells)
return rows
def _cell_to_text(self, value: Any) -> str:
if value is None:
return ''
if isinstance(value, datetime):
return value.strftime('%Y-%m-%d %H:%M:%S')
return str(value).strip()
def _to_float(self, value: str) -> float | None:
if value == '':
return None
try:
return float(value)
except ValueError:
return None
def _parse_int_id(self, value: str, field_name: str) -> int:
try:
return int(value)
except (TypeError, ValueError) as error:
raise ValueError(f'{field_name}格式错误') from error
def get_excel_template_bytes(self) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = '数据模板'
sheet.append(['时间', '电流', '电压', '温度'])
sheet.append(['2026-04-16 10:00:00', 1.2, 220.5, 36.8])
output = io.BytesIO()
workbook.save(output)
workbook.close()
return output.getvalue()
from typing import Any
def success_response(data: Any = None, message: str = 'success', code: int = 0) -> dict[str, Any]:
return {
'code': code,
'message': message,
'data': data,
}
def error_response(message: str = 'error', code: int = 1, data: Any = None) -> dict[str, Any]:
return {
'code': code,
'message': message,
'data': data,
}
fastapi==0.116.1
uvicorn[standard]==0.35.0
python-multipart==0.0.20
pydantic==2.11.7
SQLAlchemy==2.0.43
PyMySQL==1.1.2
openpyxl==3.1.5
xlrd==2.0.1
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>热实验温度控制系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^5.0.4"
},
"devDependencies": {
"axios": "^1.12.2",
"element-plus": "^2.11.3",
"@vitejs/plugin-vue": "^6.0.5",
"sass-embedded": "^1.99.0",
"vite": "^8.0.3",
"vite-plugin-vue-devtools": "^8.1.1"
},
"engines": {
"sass": "^1.93.2",
"node": "^20.19.0 || >=22.12.0"
}
}
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const tabs = [
{ label: '数据管理', path: '/data-management' },
{ label: '实时监控', path: '/realtime-monitor' },
{ label: '历史数据', path: '/history-data' },
{ label: '模型训练', path: '/model-training' },
{ label: '模型列表', path: '/model-list' },
{ label: '模型评估', path: '/model-evaluation' },
{ label: '数据包管理', path: '/package-management' },
]
const activeTab = computed(() => {
const matched = tabs.find((item) => route.path.startsWith(item.path))
return matched?.path ?? '/data-management'
})
const handleTabChange = (path) => {
router.push(path)
}
</script>
<template>
<div class="app-shell">
<header class="top-header">
<div class="project-title">热实验温度控制系统</div>
<el-tabs class="top-tabs" :model-value="activeTab" @tab-change="handleTabChange">
<el-tab-pane
v-for="tab in tabs"
:key="tab.path"
:label="tab.label"
:name="tab.path"
/>
</el-tabs>
</header>
<main class="app-main">
<router-view />
</main>
</div>
</template>
<style lang="scss" scoped>
.app-shell {
min-height: 100vh;
background:
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 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
height: 72px;
padding: 0 20px;
backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.84);
position: sticky;
top: 0;
z-index: 20;
}
.project-title {
flex: 0 0 auto;
font-size: 22px;
letter-spacing: 1px;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
}
.top-tabs {
flex: 1;
min-width: 0;
:deep(.el-tabs__header) {
margin-bottom: 0;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
:deep(.el-tabs__item) {
color: #334155;
font-weight: 500;
}
:deep(.el-tabs__item.is-active) {
color: #0f766e;
}
:deep(.el-tabs__active-bar) {
background-color: #0f766e;
height: 3px;
border-radius: 999px;
}
}
.app-main {
height: calc(100vh - 72px);
padding: 16px 20px 20px;
}
@media (max-width: 980px) {
.top-header {
flex-direction: column;
align-items: flex-start;
height: auto;
padding: 12px;
}
.project-title {
font-size: 18px;
}
.top-tabs {
width: 100%;
}
.app-main {
height: auto;
min-height: calc(100vh - 120px);
padding: 12px;
}
}
</style>
import request from '@/utils/request'
export function getCategoryTree() {
return request.get('/data/categories')
}
export function createCategory(payload) {
return request.post('/data/categories', payload)
}
export function updateCategory(categoryId, payload) {
return request.put(`/data/categories/${categoryId}`, payload)
}
export function deleteCategory(categoryId) {
return request.delete(`/data/categories/${categoryId}`)
}
export function getDataFiles(params) {
return request.get('/data/files', { params })
}
export function uploadDataFile(formData) {
return request.post('/data/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
export function deleteDataFile(fileId) {
return request.delete(`/data/files/${fileId}`)
}
export function getFileRecords(fileId, params) {
return request.get(`/data/files/${fileId}/records`, { params })
}
export function downloadDataTemplate() {
return request.get('/data/files/template', {
responseType: 'blob',
})
}
<script setup>
defineProps({
title: {
type: String,
default: '页面建设中',
},
})
</script>
<template>
<section class="uc-page">
<div class="uc-card">
<h2>{{ title }}</h2>
<p>该模块正在建设中,可先使用“数据管理”完成数据上传与查看。</p>
</div>
</section>
</template>
<style lang="scss" scoped>
.uc-page {
height: 100%;
display: grid;
place-items: center;
}
.uc-card {
width: min(640px, 100%);
border-radius: 18px;
padding: 38px 30px;
background: linear-gradient(135deg, #ffffff, #f2fbf8);
border: 1px solid rgba(15, 118, 110, 0.2);
box-shadow: 0 12px 30px rgba(2, 8, 23, 0.1);
h2 {
margin: 0 0 10px;
font-size: 28px;
color: #0f172a;
}
p {
margin: 0;
color: #334155;
font-size: 15px;
}
}
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './styles/global.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/data-management',
},
{
path: '/data-management',
name: 'data-management',
component: () => import('@/views/DataManagement/index.vue'),
},
{
path: '/realtime-monitor',
name: 'realtime-monitor',
component: () => import('@/components/common/UnderConstruction.vue'),
props: { title: '实时监控' },
},
{
path: '/history-data',
name: 'history-data',
component: () => import('@/components/common/UnderConstruction.vue'),
props: { title: '历史数据' },
},
{
path: '/model-training',
name: 'model-training',
component: () => import('@/components/common/UnderConstruction.vue'),
props: { title: '模型训练' },
},
{
path: '/model-list',
name: 'model-list',
component: () => import('@/components/common/UnderConstruction.vue'),
props: { title: '模型列表' },
},
{
path: '/model-evaluation',
name: 'model-evaluation',
component: () => import('@/components/common/UnderConstruction.vue'),
props: { title: '模型评估' },
},
{
path: '/package-management',
name: 'package-management',
component: () => import('@/components/common/UnderConstruction.vue'),
props: { title: '数据包管理' },
},
],
})
export default router
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap');
:root {
--page-text: #0f172a;
--page-muted: #64748b;
--brand: #0f766e;
--brand-soft: #14b8a6;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
padding: 0;
width: 100%;
min-height: 100%;
}
body {
font-family: 'Poppins', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
color: var(--page-text);
}
import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: '/api',
timeout: 20000,
})
request.interceptors.response.use(
(response) => {
if (response.config.responseType === 'blob') {
return response.data
}
const payload = response.data
if (typeof payload?.code !== 'undefined') {
if (payload.code === 0) {
return payload.data
}
ElMessage.error(payload.message || '请求失败')
return Promise.reject(new Error(payload.message || '请求失败'))
}
return payload
},
(error) => {
const message = error?.response?.data?.message || error.message || '网络异常'
ElMessage.error(message)
return Promise.reject(error)
},
)
export default request
\ No newline at end of file
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps({
records: {
type: Array,
default: () => [],
},
})
const canvasRef = ref(null)
const numericPoints = computed(() => {
return props.records
.map((item, idx) => ({
x: idx,
current: Number(item.current),
voltage: Number(item.voltage),
temperature: Number(item.temperature),
}))
.filter((item) => {
return (
Number.isFinite(item.current) ||
Number.isFinite(item.voltage) ||
Number.isFinite(item.temperature)
)
})
})
const draw = () => {
const canvas = canvasRef.value
if (!canvas) {
return
}
const dpr = window.devicePixelRatio || 1
const width = canvas.clientWidth
const height = canvas.clientHeight
canvas.width = width * dpr
canvas.height = height * dpr
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, height)
if (numericPoints.value.length < 2) {
ctx.fillStyle = '#64748b'
ctx.font = '14px Poppins, Noto Sans SC, sans-serif'
ctx.fillText('暂无足够数据生成曲线', 20, height / 2)
return
}
const padding = { top: 18, right: 20, bottom: 32, left: 44 }
const innerWidth = width - padding.left - padding.right
const innerHeight = height - padding.top - padding.bottom
const allValues = numericPoints.value
.flatMap((item) => [item.current, item.voltage, item.temperature])
.filter((value) => Number.isFinite(value))
const minValue = Math.min(...allValues)
const maxValue = Math.max(...allValues)
const diff = maxValue - minValue || 1
ctx.strokeStyle = 'rgba(15, 23, 42, 0.14)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(padding.left, padding.top)
ctx.lineTo(padding.left, height - padding.bottom)
ctx.lineTo(width - padding.right, height - padding.bottom)
ctx.stroke()
const drawLine = (key, color) => {
ctx.beginPath()
let started = false
numericPoints.value.forEach((point, index) => {
const value = point[key]
if (!Number.isFinite(value)) {
return
}
const x = padding.left + (index / (numericPoints.value.length - 1)) * innerWidth
const y = padding.top + ((maxValue - value) / diff) * innerHeight
if (!started) {
ctx.moveTo(x, y)
started = true
} else {
ctx.lineTo(x, y)
}
})
if (started) {
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.stroke()
}
}
drawLine('current', '#0ea5e9')
drawLine('voltage', '#f97316')
drawLine('temperature', '#ef4444')
}
onMounted(async () => {
await nextTick()
draw()
})
watch(
() => props.records,
async () => {
await nextTick()
draw()
},
{ deep: true },
)
</script>
<template>
<div class="curve-box">
<div class="legend-row">
<span class="legend-item"><i class="dot dot-current" />电流</span>
<span class="legend-item"><i class="dot dot-voltage" />电压</span>
<span class="legend-item"><i class="dot dot-temperature" />温度</span>
</div>
<canvas ref="canvasRef" class="curve-canvas" />
</div>
</template>
<style lang="scss" scoped>
.curve-box {
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.legend-row {
display: flex;
gap: 16px;
color: #334155;
font-size: 13px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
}
.dot-current {
background: #0ea5e9;
}
.dot-voltage {
background: #f97316;
}
.dot-temperature {
background: #ef4444;
}
.curve-canvas {
width: 100%;
flex: 1;
min-height: 280px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: linear-gradient(180deg, #ffffff, #f8fafc);
}
</style>
<script setup>
import { Delete, Edit } from '@element-plus/icons-vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createCategory,
deleteCategory,
deleteDataFile,
downloadDataTemplate,
getCategoryTree,
getDataFiles,
getFileRecords,
updateCategory,
uploadDataFile,
} from '@/api/dataManagement'
import DataCurve from './components/DataCurve.vue'
const categoryTree = ref([])
const selectedCategory = ref('')
const searchForm = reactive({
filename: '',
})
const fileList = ref([])
const loadingFiles = ref(false)
const currentFile = ref(null)
const recordList = ref([])
const contentLoading = ref(false)
const contentMode = ref('table')
const categoryDialogVisible = ref(false)
const categoryDialogMode = ref('create')
const editingCategoryId = ref('')
const categoryForm = reactive({
name: '',
})
const layoutRef = ref(null)
const leftPaneWidth = ref(16)
const middlePaneWidth = ref(38)
const dragState = ref(null)
const layoutStorageKey = 'data-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(() => {
const value = 100 - leftPaneWidth.value - middlePaneWidth.value
return Number(value.toFixed(2))
})
const categoryDialogTitle = computed(() => {
return categoryDialogMode.value === 'create' ? '新增分类' : '编辑分类'
})
const normalizeCategoryTree = (treeData) => {
const source = Array.isArray(treeData) ? treeData : []
const oneLevel =
source.length === 1 && String(source[0]?.id) === 'all' ? source[0].children || [] : source
return oneLevel.map((item) => ({
id: item.id,
name: item.name,
parent_id: item.parent_id,
children: [],
}))
}
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 stopDragging = () => {
document.removeEventListener('mousemove', handleDragging)
document.removeEventListener('mouseup', stopDragging)
dragState.value = null
saveLayoutCache()
}
const handleDragging = (event) => {
if (!dragState.value) {
return
}
const delta = ((event.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 startDragging = (target, event) => {
if (window.innerWidth <= 980 || !layoutRef.value) {
return
}
dragState.value = {
target,
startX: event.clientX,
startLeft: leftPaneWidth.value,
startMiddle: middlePaneWidth.value,
layoutWidth: layoutRef.value.clientWidth,
}
document.addEventListener('mousemove', handleDragging)
document.addEventListener('mouseup', stopDragging)
}
const loadCategoryTree = async () => {
const treeData = await getCategoryTree()
categoryTree.value = normalizeCategoryTree(treeData)
if (!categoryTree.value.some((item) => item.id === selectedCategory.value)) {
selectedCategory.value = ''
}
}
const loadFileList = async () => {
loadingFiles.value = true
try {
const data = await getDataFiles({
category_id: selectedCategory.value || '',
filename: searchForm.filename.trim(),
})
fileList.value = data
} finally {
loadingFiles.value = false
}
}
const handleCategoryClick = async (node) => {
selectedCategory.value = node.id
currentFile.value = null
recordList.value = []
await loadFileList()
}
const openCreateCategoryDialog = () => {
categoryDialogMode.value = 'create'
editingCategoryId.value = ''
categoryForm.name = ''
categoryDialogVisible.value = true
}
const openEditCategoryDialog = (node) => {
categoryDialogMode.value = 'edit'
editingCategoryId.value = node.id
categoryForm.name = node.name
categoryDialogVisible.value = true
}
const submitCategory = async () => {
const categoryName = categoryForm.name.trim()
if (!categoryName) {
ElMessage.warning('请输入分类名称')
return
}
if (categoryDialogMode.value === 'create') {
await createCategory({
name: categoryName,
parent_id: null,
})
} else {
await updateCategory(editingCategoryId.value, {
name: categoryName,
})
}
categoryDialogVisible.value = false
ElMessage.success('分类保存成功')
await loadCategoryTree()
}
const handleDeleteCategory = async (node) => {
try {
await ElMessageBox.confirm(`确定删除分类“${node.name}”吗?`, '提示', {
type: 'warning',
})
await deleteCategory(node.id)
ElMessage.success('分类已删除')
if (selectedCategory.value === node.id) {
selectedCategory.value = ''
}
await loadCategoryTree()
await loadFileList()
} catch {
// 用户取消删除时不提示错误
}
}
const handleSearch = async () => {
await loadFileList()
}
const handleReset = async () => {
searchForm.filename = ''
await loadFileList()
}
const beforeUploadExcel = (file) => {
const lowerName = file.name.toLowerCase()
const valid = lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')
if (!valid) {
ElMessage.warning('仅支持 Excel 文件(.xlsx/.xls)')
}
return valid
}
const handleUpload = async (uploadRequest) => {
const formData = new FormData()
formData.append('file', uploadRequest.file)
formData.append('category_id', selectedCategory.value ? String(selectedCategory.value) : '')
try {
await uploadDataFile(formData)
ElMessage.success('上传成功')
await loadFileList()
uploadRequest.onSuccess()
} catch (error) {
uploadRequest.onError(error)
}
}
const handleDownloadTemplate = async () => {
const blob = await downloadDataTemplate()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'data_template.xlsx'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleViewFile = async (row) => {
currentFile.value = row
contentLoading.value = true
try {
const result = await getFileRecords(row.id, { limit: 500 })
recordList.value = result.records
} finally {
contentLoading.value = false
}
}
const handleDeleteFile = async (row) => {
try {
await ElMessageBox.confirm(`确定删除文件“${row.filename}”吗?`, '提示', {
type: 'warning',
})
await deleteDataFile(row.id)
ElMessage.success('文件已删除')
if (currentFile.value?.id === row.id) {
currentFile.value = null
recordList.value = []
}
await loadFileList()
} catch {
// 用户取消删除时不提示错误
}
}
onMounted(async () => {
loadLayoutCache()
await loadCategoryTree()
await loadFileList()
})
onBeforeUnmount(() => {
stopDragging()
})
</script>
<template>
<section class="data-page">
<div ref="layoutRef" class="data-layout">
<el-card class="pane-card" shadow="hover" :style="{ width: `${leftPaneWidth}%` }">
<template #header>
<div class="pane-header">
<div>
<span>分类树</span>
</div>
<el-button type="primary" plain size="small" @click="openCreateCategoryDialog">
新增
</el-button>
</div>
</template>
<el-tree
:data="categoryTree"
node-key="id"
highlight-current
:current-node-key="selectedCategory || undefined"
:expand-on-click-node="false"
@node-click="handleCategoryClick"
>
<template #default="{ data }">
<div class="tree-node">
<span class="tree-name">{{ data.name }}</span>
<span class="tree-actions">
<el-button link type="primary" :icon="Edit" @click.stop="openEditCategoryDialog(data)" />
<el-button link type="danger" :icon="Delete" @click.stop="handleDeleteCategory(data)" />
</span>
</div>
</template>
</el-tree>
</el-card>
<div class="pane-divider" @mousedown="(event) => startDragging('left', event)" />
<el-card class="pane-card" shadow="hover" :style="{ width: `${middlePaneWidth}%` }">
<template #header>
<div class="pane-header">
<span>文件列表</span>
</div>
</template>
<el-form :inline="true" class="search-form">
<el-form-item>
<el-input v-model="searchForm.filename" placeholder="文件名" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="info" plain @click="handleDownloadTemplate">下载模板</el-button>
<el-upload
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUploadExcel"
accept=".xlsx,.xls"
>
<el-button type="success">上传Excel</el-button>
</el-upload>
</el-form-item>
</el-form>
<el-table :data="fileList" border stripe v-loading="loadingFiles" height="calc(100vh - 270px)">
<el-table-column prop="filename" label="文件名" min-width="160" />
<el-table-column prop="uploaded_at" label="上传时间" min-width="170" />
<el-table-column prop="data_count" label="数据量" width="90" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewFile(row)">查看</el-button>
<el-button link type="danger" @click="handleDeleteFile(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="pane-divider" @mousedown="(event) => startDragging('middle', event)" />
<el-card class="pane-card" shadow="hover" :style="{ width: `${rightPaneWidth}%` }">
<template #header>
<div class="pane-header">
<span>
文件内容
<span v-if="currentFile" class="file-title">- {{ currentFile.filename }}</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="contentLoading">
<el-empty v-if="!currentFile" description="请选择并查看一个文件" />
<el-table
v-else-if="contentMode === 'table'"
:data="recordList"
border
stripe
height="calc(100vh - 270px)"
>
<el-table-column prop="time" label="时间" min-width="140" />
<el-table-column prop="current" label="电流" min-width="100" />
<el-table-column prop="voltage" label="电压" min-width="100" />
<el-table-column prop="temperature" label="温度" min-width="100" />
</el-table>
<DataCurve v-else :records="recordList" />
</div>
</el-card>
</div>
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="420px">
<el-form label-position="top">
<el-form-item label="分类名称" required>
<el-input v-model="categoryForm.name" maxlength="100" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitCategory">保存</el-button>
</template>
</el-dialog>
</section>
</template>
<style lang="scss" scoped>
.data-page {
height: 100%;
}
.data-layout {
height: 100%;
display: flex;
align-items: stretch;
gap: 0;
}
.pane-divider {
width: 8px;
flex: 0 0 8px;
position: relative;
cursor: col-resize;
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 3px;
width: 2px;
background: rgba(148, 163, 184, 0.4);
}
}
.pane-card {
height: 100%;
min-width: 0;
border: 1px solid rgba(15, 23, 42, 0.08);
:deep(.el-card__body) {
height: calc(100% - 56px);
display: flex;
flex-direction: column;
gap: 10px;
}
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-weight: 600;
color: #0f172a;
}
.pane-tip {
margin-left: 6px;
font-size: 12px;
font-weight: 400;
color: #64748b;
}
.file-title {
font-size: 13px;
color: #475569;
margin-left: 6px;
}
.tree-node {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.tree-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-form {
margin-bottom: 4px;
}
.content-wrap {
flex: 1;
min-height: 0;
}
@media (max-width: 980px) {
.data-layout {
display: block;
overflow-y: auto;
}
.pane-divider {
display: none;
}
.pane-card {
width: 100% !important;
min-height: 420px;
margin-bottom: 12px;
}
}
</style>
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
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