Commit b599ac8f authored by luwei's avatar luwei

数据管理功能

parent 6ca52b5b
Pipeline #362 failed with stages
from contextlib import contextmanager
from typing import Generator
from sqlalchemy import create_engine, text
from sqlalchemy import create_engine
from sqlalchemy.engine import URL
from sqlalchemy.orm import Session, declarative_base, sessionmaker
......@@ -38,40 +38,3 @@ def db_session() -> Generator[Session, None, None]:
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
......@@ -4,7 +4,6 @@ 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
......@@ -47,10 +46,6 @@ def create_app() -> FastAPI:
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
......
......@@ -39,6 +39,7 @@ class DataFile(Base):
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='存储文件名')
file_path: Mapped[str] = mapped_column(String(255), nullable=False, 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'))
......
import csv
import io
from datetime import datetime
from pathlib import Path
......@@ -13,9 +14,10 @@ from app.models import Category, DataFile
class DataManagementService:
def __init__(self) -> None:
self._base_dir = Path(__file__).resolve().parents[3]
self._base_dir = Path(__file__).resolve().parents[2]
self._upload_dir = self._base_dir / 'uploads'
self._data_dir = self._upload_dir / 'data'
self._legacy_data_dir = Path(__file__).resolve().parents[3] / 'uploads' / 'data'
self._upload_dir.mkdir(parents=True, exist_ok=True)
self._data_dir.mkdir(parents=True, exist_ok=True)
......@@ -146,6 +148,7 @@ class DataManagementService:
'id': item.id,
'filename': item.filename,
'stored_name': item.stored_name,
'file_path': item.file_path,
'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,
......@@ -155,38 +158,39 @@ class DataManagementService:
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)')
if suffix not in {'.xlsx', '.xls', '.csv'}:
raise ValueError('仅支持数据文件(.xlsx/.xls/.csv)')
category_db_id: int | None
if category_id in {'', 'all', None}:
category_db_id = None
else:
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('上传分类不存在')
timestamp = datetime.now().strftime('%Y%m%d%H%M%S%f')
stored_name = f'{timestamp}_{Path(filename).name}'
safe_category_name = self._sanitize_filename(category.name)
safe_original_name = self._sanitize_filename(Path(filename).stem)
stored_name = f'{safe_category_name}_{timestamp}_{safe_original_name}{suffix}'
target_path = self._data_dir / stored_name
db_file_path = f'/app/uploads/data/{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('上传分类不存在')
_, total_count = self._read_records(target_path, limit=None)
file_meta = DataFile(
filename=filename,
stored_name=stored_name,
file_path=db_file_path,
category_id=category_db_id,
data_count=total_count,
)
......@@ -198,6 +202,7 @@ class DataManagementService:
'id': file_meta.id,
'filename': file_meta.filename,
'stored_name': file_meta.stored_name,
'file_path': file_meta.file_path,
'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,
......@@ -215,7 +220,7 @@ class DataManagementService:
if not matched:
raise ValueError('文件不存在')
target_path = self._data_dir / matched.stored_name
target_path = self._resolve_local_file_path(matched.file_path, matched.stored_name)
if target_path.exists():
target_path.unlink()
......@@ -231,7 +236,7 @@ class DataManagementService:
if not file_meta:
raise ValueError('文件不存在')
target_path = self._data_dir / file_meta.stored_name
target_path = self._resolve_local_file_path(file_meta.file_path, file_meta.stored_name)
if not target_path.exists():
raise ValueError('文件已丢失,请重新上传')
......@@ -248,13 +253,16 @@ class DataManagementService:
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)
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 = rows[1:]
else:
header = ['time', 'current', 'voltage', 'temperature']
header = ['time', 'current', 'voltage', 'set_temperature', 'actual_temperature']
data_rows = rows
index_map = self._build_index_map(header)
......@@ -272,7 +280,8 @@ class DataManagementService:
'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'])),
'set_temperature': self._to_float(self._read_value(row, index_map['set_temperature'])),
'actual_temperature': self._to_float(self._read_value(row, index_map['actual_temperature'])),
}
)
......@@ -283,7 +292,8 @@ class DataManagementService:
'time': 0,
'current': 1,
'voltage': 2,
'temperature': 3,
'set_temperature': 3,
'actual_temperature': 4,
}
for index, name in enumerate(header):
......@@ -294,8 +304,10 @@ class DataManagementService:
index_map['current'] = index
elif normalized == 'voltage':
index_map['voltage'] = index
elif normalized == 'temperature':
index_map['temperature'] = index
elif normalized == 'set_temperature':
index_map['set_temperature'] = index
elif normalized == 'actual_temperature':
index_map['actual_temperature'] = index
return index_map
......@@ -307,10 +319,35 @@ class DataManagementService:
return 'current'
if key in {'voltage', '电压'}:
return 'voltage'
if key in {'temperature', '温度'}:
return 'temperature'
if key in {'set_temperature', '设定温度', 'set temperature'}:
return 'set_temperature'
if key in {'actual_temperature', '实际温度', 'actual temperature', 'temperature', '温度'}:
return 'actual_temperature'
return key
def _sanitize_filename(self, value: str) -> str:
cleaned = ''.join(char if char.isalnum() or char in {'_', '-', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '升', '温', '恒', '降', '实', '验'} else '_' for char in value.strip())
compacted = '_'.join(part for part in cleaned.split('_') if part)
return compacted or 'file'
def _resolve_local_file_path(self, db_file_path: str, stored_name: str) -> Path:
def _with_legacy_fallback(name: str) -> Path:
current_path = self._data_dir / name
if current_path.exists():
return current_path
legacy_path = self._legacy_data_dir / name
if legacy_path.exists():
return legacy_path
return current_path
if db_file_path:
normalized = db_file_path.replace('\\', '/')
suffix = normalized.split('/app/uploads/data/', 1)
if len(suffix) == 2 and suffix[1]:
return _with_legacy_fallback(Path(suffix[1]).name)
return _with_legacy_fallback(Path(normalized).name)
return _with_legacy_fallback(stored_name)
def _read_value(self, row: list[str], index: int) -> str:
if index < len(row):
return row[index].strip()
......@@ -322,6 +359,8 @@ class DataManagementService:
return self._read_xlsx_rows(file_path)
if suffix == '.xls':
return self._read_xls_rows(file_path)
if suffix == '.csv':
return self._read_csv_rows(file_path)
return []
def _read_xlsx_rows(self, file_path: Path) -> list[list[str]]:
......@@ -347,6 +386,24 @@ class DataManagementService:
rows.append(cells)
return rows
def _read_csv_rows(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]
if any(cell != '' for cell in cells):
rows.append(cells)
return rows
except UnicodeDecodeError:
continue
raise ValueError('CSV 文件编码无法识别,请保存为 UTF-8 或 GBK 后重试')
def _cell_to_text(self, value: Any) -> str:
if value is None:
return ''
......@@ -372,8 +429,8 @@ class DataManagementService:
workbook = Workbook()
sheet = workbook.active
sheet.title = '数据模板'
sheet.append(['时间', '电流', '电压', '温度'])
sheet.append(['2026-04-16 10:00:00', 1.2, 220.5, 36.8])
sheet.append(['时间', '电流', '电压', '设定温度', '实际温度'])
sheet.append(['2026/1/22 16:38:01', 1.2, 220.5, 37.0, 36.8])
output = io.BytesIO()
workbook.save(output)
......
......@@ -8,6 +8,8 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"echarts": "^6.0.0",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^5.0.4"
......@@ -500,7 +502,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
......@@ -1939,6 +1940,22 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/electron-to-chromium": {
"version": "1.5.338",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.338.tgz",
......@@ -4067,6 +4084,21 @@
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}
......@@ -10,14 +10,15 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"echarts": "^6.0.0",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"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"
......
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
records: {
......@@ -8,124 +9,221 @@ const props = defineProps({
},
})
const canvasRef = ref(null)
const chartRef = ref(null)
let chartInstance = null
const numericPoints = computed(() => {
return props.records
const chartData = computed(() => {
const source = Array.isArray(props.records) ? props.records : []
return source
.map((item, idx) => ({
x: idx,
idx,
time: item.time || `第${idx + 1}条`,
current: Number(item.current),
voltage: Number(item.voltage),
temperature: Number(item.temperature),
actual_temperature: Number(item.actual_temperature),
}))
.filter((item) => {
return (
Number.isFinite(item.current) ||
Number.isFinite(item.voltage) ||
Number.isFinite(item.temperature)
Number.isFinite(item.actual_temperature)
)
})
})
const draw = () => {
const canvas = canvasRef.value
if (!canvas) {
return
const stats = computed(() => {
const temps = chartData.value
.map((item) => item.actual_temperature)
.filter((value) => Number.isFinite(value))
return {
count: chartData.value.length,
max: temps.length ? Math.max(...temps) : 0,
min: temps.length ? Math.min(...temps) : 0,
}
})
const dpr = window.devicePixelRatio || 1
const width = canvas.clientWidth
const height = canvas.clientHeight
canvas.width = width * dpr
canvas.height = height * dpr
const formatValue = (value) => {
return Number.isFinite(value) ? Number(value).toFixed(2) : '--'
}
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, height)
const getSeriesData = (key) => {
return chartData.value.map((item) => (Number.isFinite(item[key]) ? item[key] : null))
}
if (numericPoints.value.length < 2) {
ctx.fillStyle = '#64748b'
ctx.font = '14px Poppins, Noto Sans SC, sans-serif'
ctx.fillText('暂无足够数据生成曲线', 20, height / 2)
const renderChart = () => {
if (!chartRef.value) {
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
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value)
}
const x = padding.left + (index / (numericPoints.value.length - 1)) * innerWidth
const y = padding.top + ((maxValue - value) / diff) * innerHeight
const hasData = chartData.value.length > 0
if (!started) {
ctx.moveTo(x, y)
started = true
} else {
ctx.lineTo(x, y)
chartInstance.setOption(
{
animation: true,
color: ['#409EFF', '#67C23A', '#FF6B6B'],
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.96)',
borderColor: '#e2e8f0',
borderWidth: 1,
textStyle: {
color: '#334155',
},
extraCssText: 'box-shadow: 0 10px 30px rgba(15,23,42,0.16); border-radius: 12px;',
formatter(params) {
if (!params?.length) {
return ''
}
})
if (started) {
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.stroke()
}
}
const lines = [`<div style="margin-bottom:6px;font-weight:600;">${params[0].axisValue}</div>`]
params.forEach((item) => {
lines.push(
`<div style="display:flex;align-items:center;gap:6px;min-width:160px;justify-content:space-between;">
<span>${item.marker}${item.seriesName}</span>
<strong>${formatValue(item.data)}</strong>
</div>`,
)
})
return lines.join('')
},
},
legend: {
bottom: 0,
itemWidth: 18,
itemHeight: 10,
textStyle: {
color: '#475569',
},
data: ['电流(A)', '电压(V)', '温度(℃)'],
},
grid: {
top: 24,
left: 24,
right: 24,
bottom: 56,
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: chartData.value.map((item) => item.time),
axisLabel: {
color: '#64748b',
rotate: 35,
},
axisLine: {
lineStyle: {
color: '#94a3b8',
},
},
},
yAxis: {
type: 'value',
axisLabel: {
color: '#64748b',
},
splitLine: {
lineStyle: {
type: 'dashed',
color: 'rgba(148, 163, 184, 0.45)',
},
},
},
series: [
{
name: '电流(A)',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 7,
data: getSeriesData('current'),
},
{
name: '电压(V)',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 7,
data: getSeriesData('voltage'),
},
{
name: '温度(℃)',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 7,
data: getSeriesData('actual_temperature'),
},
],
graphic: hasData
? []
: [
{
type: 'text',
left: 'center',
top: 'middle',
style: {
text: '暂无足够数据生成曲线',
fill: '#64748b',
fontSize: 14,
},
},
],
},
true,
)
}
drawLine('current', '#0ea5e9')
drawLine('voltage', '#f97316')
drawLine('temperature', '#ef4444')
const resizeChart = () => {
chartInstance?.resize()
}
onMounted(async () => {
await nextTick()
draw()
renderChart()
window.addEventListener('resize', resizeChart)
})
watch(
() => props.records,
async () => {
await nextTick()
draw()
renderChart()
},
{ deep: true },
)
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
chartInstance?.dispose()
chartInstance = null
})
</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 class="stats-row">
<div class="stat-card">
<div class="stat-label">总条数</div>
<div class="stat-value">{{ stats.count }}</div>
</div>
<div class="stat-card">
<div class="stat-label">最高温度</div>
<div class="stat-value">{{ formatValue(stats.max) }} °C</div>
</div>
<div class="stat-card">
<div class="stat-label">最低温度</div>
<div class="stat-value">{{ formatValue(stats.min) }} °C</div>
</div>
<canvas ref="canvasRef" class="curve-canvas" />
</div>
<div ref="chartRef" class="echarts-box" />
</div>
</template>
......@@ -134,46 +232,38 @@ watch(
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.legend-row {
display: flex;
gap: 16px;
color: #334155;
font-size: 13px;
gap: 14px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
.stats-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
.stat-card {
padding: 14px 16px;
border-radius: 14px;
background: #f3f6fb;
}
.dot-current {
background: #0ea5e9;
}
.dot-voltage {
background: #f97316;
.stat-label {
font-size: 13px;
color: #64748b;
margin-bottom: 8px;
}
.dot-temperature {
background: #ef4444;
.stat-value {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.curve-canvas {
width: 100%;
.echarts-box {
flex: 1;
min-height: 280px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.12);
min-height: 360px;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.1);
background: linear-gradient(180deg, #ffffff, #f8fafc);
}
</style>
<script setup>
import { Delete, Edit } from '@element-plus/icons-vue'
import { Delete, Edit, Folder, FolderOpened, Upload } from '@element-plus/icons-vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
......@@ -16,7 +16,7 @@ import {
import DataCurve from './components/DataCurve.vue'
const categoryTree = ref([])
const selectedCategory = ref('')
const selectedCategory = ref('all')
const searchForm = reactive({
filename: '',
......@@ -37,10 +37,17 @@ const categoryForm = reactive({
name: '',
})
const uploadDialogVisible = ref(false)
const uploadForm = reactive({
categoryId: '',
})
const layoutRef = ref(null)
const leftPaneWidth = ref(16)
const middlePaneWidth = ref(38)
const dragState = ref(null)
const pendingDragX = ref(null)
let dragFrameId = 0
const layoutStorageKey = 'data-management-layout-v1'
const MIN_LEFT = 12
......@@ -58,17 +65,33 @@ const categoryDialogTitle = computed(() => {
return categoryDialogMode.value === 'create' ? '新增分类' : '编辑分类'
})
const flatCategoryOptions = computed(() => {
const root = categoryTree.value.find((item) => String(item.id) === 'all')
return (root?.children || []).map((item) => ({
label: item.name,
value: String(item.id),
}))
})
const normalizeCategoryTree = (treeData) => {
const source = Array.isArray(treeData) ? treeData : []
const oneLevel =
source.length === 1 && String(source[0]?.id) === 'all' ? source[0].children || [] : source
const oneLevel = source.filter((item) => String(item?.id) !== 'all')
return oneLevel.map((item) => ({
const children = oneLevel.map((item) => ({
id: item.id,
name: item.name,
parent_id: item.parent_id,
children: [],
}))
return [
{
id: 'all',
name: '全部',
parent_id: null,
children,
},
]
}
const setPaneWidths = (nextLeft, nextMiddle, target = 'left') => {
......@@ -121,11 +144,48 @@ const saveLayoutCache = () => {
)
}
const applyDragPosition = (clientX) => {
if (!dragState.value) {
return
}
const delta = ((clientX - dragState.value.startX) / dragState.value.layoutWidth) * 100
if (dragState.value.target === 'left') {
setPaneWidths(dragState.value.startLeft + delta, dragState.value.startMiddle, 'left')
} else {
setPaneWidths(dragState.value.startLeft, dragState.value.startMiddle + delta, 'middle')
}
}
const flushDragging = () => {
dragFrameId = 0
if (pendingDragX.value === null) {
return
}
applyDragPosition(pendingDragX.value)
}
const stopDragging = () => {
document.removeEventListener('mousemove', handleDragging)
document.removeEventListener('mouseup', stopDragging)
dragState.value = null
window.removeEventListener('mousemove', handleDragging)
window.removeEventListener('mouseup', stopDragging)
window.removeEventListener('mouseleave', stopDragging)
window.removeEventListener('blur', stopDragging)
document.body.style.userSelect = ''
document.body.style.cursor = ''
if (dragFrameId) {
cancelAnimationFrame(dragFrameId)
dragFrameId = 0
}
pendingDragX.value = null
if (dragState.value) {
saveLayoutCache()
}
dragState.value = null
}
const handleDragging = (event) => {
......@@ -133,12 +193,10 @@ const handleDragging = (event) => {
return
}
const delta = ((event.clientX - dragState.value.startX) / dragState.value.layoutWidth) * 100
pendingDragX.value = event.clientX
if (dragState.value.target === 'left') {
setPaneWidths(dragState.value.startLeft + delta, dragState.value.startMiddle, 'left')
} else {
setPaneWidths(dragState.value.startLeft, dragState.value.startMiddle + delta, 'middle')
if (!dragFrameId) {
dragFrameId = requestAnimationFrame(flushDragging)
}
}
......@@ -147,6 +205,9 @@ const startDragging = (target, event) => {
return
}
event.preventDefault()
stopDragging()
dragState.value = {
target,
startX: event.clientX,
......@@ -155,16 +216,26 @@ const startDragging = (target, event) => {
layoutWidth: layoutRef.value.clientWidth,
}
document.addEventListener('mousemove', handleDragging)
document.addEventListener('mouseup', stopDragging)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
window.addEventListener('mousemove', handleDragging)
window.addEventListener('mouseup', stopDragging)
window.addEventListener('mouseleave', stopDragging)
window.addEventListener('blur', stopDragging)
}
const loadCategoryTree = async () => {
const treeData = await getCategoryTree()
categoryTree.value = normalizeCategoryTree(treeData)
if (!categoryTree.value.some((item) => item.id === selectedCategory.value)) {
selectedCategory.value = ''
const allIds = [
...categoryTree.value.map((item) => String(item.id)),
...categoryTree.value.flatMap((item) => (item.children || []).map((child) => String(child.id))),
]
if (!allIds.includes(String(selectedCategory.value))) {
selectedCategory.value = 'all'
}
}
......@@ -172,7 +243,7 @@ const loadFileList = async () => {
loadingFiles.value = true
try {
const data = await getDataFiles({
category_id: selectedCategory.value || '',
category_id: selectedCategory.value && selectedCategory.value !== 'all' ? selectedCategory.value : '',
filename: searchForm.filename.trim(),
})
fileList.value = data
......@@ -233,7 +304,7 @@ const handleDeleteCategory = async (node) => {
await deleteCategory(node.id)
ElMessage.success('分类已删除')
if (selectedCategory.value === node.id) {
selectedCategory.value = ''
selectedCategory.value = 'all'
}
await loadCategoryTree()
await loadFileList()
......@@ -251,22 +322,40 @@ const handleReset = async () => {
await loadFileList()
}
const openUploadDialog = () => {
uploadForm.categoryId = selectedCategory.value && selectedCategory.value !== 'all' ? String(selectedCategory.value) : ''
uploadDialogVisible.value = true
}
const beforeUploadExcel = (file) => {
if (!uploadForm.categoryId) {
ElMessage.warning('请先在弹窗中选择分类')
return false
}
const lowerName = file.name.toLowerCase()
const valid = lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')
const valid = lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls') || lowerName.endsWith('.csv')
if (!valid) {
ElMessage.warning('仅支持 Excel 文件(.xlsx/.xls)')
ElMessage.warning('仅支持数据文件(.xlsx/.xls/.csv)')
}
return valid
}
const handleUpload = async (uploadRequest) => {
if (!uploadForm.categoryId) {
ElMessage.warning('请先选择分类后再上传')
uploadRequest.onError(new Error('未选择分类'))
return
}
const formData = new FormData()
formData.append('file', uploadRequest.file)
formData.append('category_id', selectedCategory.value ? String(selectedCategory.value) : '')
formData.append('category_id', String(uploadForm.categoryId))
try {
await uploadDataFile(formData)
selectedCategory.value = String(uploadForm.categoryId)
uploadDialogVisible.value = false
ElMessage.success('上传成功')
await loadFileList()
uploadRequest.onSuccess()
......@@ -319,6 +408,7 @@ const handleDeleteFile = async (row) => {
onMounted(async () => {
loadLayoutCache()
await loadCategoryTree()
selectedCategory.value = selectedCategory.value || 'all'
await loadFileList()
})
......@@ -328,7 +418,7 @@ onBeforeUnmount(() => {
</script>
<template>
<section class="data-page">
<section class="data-page" :class="{ dragging: !!dragState }">
<div ref="layoutRef" class="data-layout">
<el-card class="pane-card" shadow="hover" :style="{ width: `${leftPaneWidth}%` }">
<template #header>
......@@ -346,14 +436,20 @@ onBeforeUnmount(() => {
:data="categoryTree"
node-key="id"
highlight-current
default-expand-all
:current-node-key="selectedCategory || undefined"
:expand-on-click-node="false"
@node-click="handleCategoryClick"
>
<template #default="{ data }">
<div class="tree-node">
<div class="tree-main">
<el-icon class="tree-icon">
<component :is="data.id === 'all' ? FolderOpened : Folder" />
</el-icon>
<span class="tree-name">{{ data.name }}</span>
<span class="tree-actions">
</div>
<span v-if="data.id !== 'all'" 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>
......@@ -362,7 +458,7 @@ onBeforeUnmount(() => {
</el-tree>
</el-card>
<div class="pane-divider" @mousedown="(event) => startDragging('left', event)" />
<div class="pane-divider" @mousedown.prevent="(event) => startDragging('left', event)" />
<el-card class="pane-card" shadow="hover" :style="{ width: `${middlePaneWidth}%` }">
<template #header>
......@@ -379,14 +475,7 @@ onBeforeUnmount(() => {
<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-button type="success" :icon="Upload" @click="openUploadDialog">上传文件</el-button>
</el-form-item>
</el-form>
......@@ -403,7 +492,7 @@ onBeforeUnmount(() => {
</el-table>
</el-card>
<div class="pane-divider" @mousedown="(event) => startDragging('middle', event)" />
<div class="pane-divider" @mousedown.prevent="(event) => startDragging('middle', event)" />
<el-card class="pane-card" shadow="hover" :style="{ width: `${rightPaneWidth}%` }">
<template #header>
......@@ -432,7 +521,8 @@ onBeforeUnmount(() => {
<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-column prop="set_temperature" label="设定温度" min-width="100" />
<el-table-column prop="actual_temperature" label="实际温度" min-width="100" />
</el-table>
<DataCurve v-else :records="recordList" />
......@@ -440,6 +530,42 @@ onBeforeUnmount(() => {
</el-card>
</div>
<el-dialog v-model="uploadDialogVisible" title="上传数据文件" width="460px">
<el-form label-position="top">
<el-form-item label="选择分类" required>
<el-select v-model="uploadForm.categoryId" placeholder="请选择要上传到的分类" style="width: 100%">
<el-option
v-for="item in flatCategoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="上传文件" required>
<el-upload
class="upload-panel"
drag
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUploadExcel"
accept=".xlsx,.xls,.csv"
>
<el-icon class="upload-panel__icon"><Upload /></el-icon>
<div class="el-upload__text">拖拽文件到此处,或点击选择 Excel / CSV 文件</div>
<template #tip>
<div class="el-upload__tip">支持 .xlsx / .xls / .csv,上传前请先选择分类</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="420px">
<el-form label-position="top">
<el-form-item label="分类名称" required>
......@@ -458,6 +584,11 @@ onBeforeUnmount(() => {
<style lang="scss" scoped>
.data-page {
height: 100%;
&.dragging {
user-select: none;
cursor: col-resize;
}
}
.data-layout {
......@@ -468,19 +599,25 @@ onBeforeUnmount(() => {
}
.pane-divider {
width: 8px;
flex: 0 0 8px;
width: 12px;
flex: 0 0 12px;
position: relative;
cursor: col-resize;
user-select: none;
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 3px;
left: 5px;
width: 2px;
background: rgba(148, 163, 184, 0.4);
transition: background 0.2s ease;
}
&:hover::before {
background: rgba(59, 130, 246, 0.7);
}
}
......@@ -519,12 +656,40 @@ onBeforeUnmount(() => {
margin-left: 6px;
}
:deep(.el-tree) {
--el-tree-node-hover-bg-color: #f8fbff;
}
:deep(.el-tree-node__content) {
height: 40px;
border-radius: 10px;
margin-bottom: 4px;
}
:deep(.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content) {
background: linear-gradient(90deg, rgba(59, 130, 246, 0.14), rgba(14, 165, 233, 0.08));
}
.tree-node {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-right: 4px;
}
.tree-main {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.tree-icon {
font-size: 16px;
color: #eab308;
flex: 0 0 auto;
}
.tree-name {
......@@ -532,6 +697,21 @@ onBeforeUnmount(() => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1e293b;
}
.tree-actions {
opacity: 0.8;
}
.upload-panel {
width: 100%;
}
.upload-panel__icon {
font-size: 28px;
color: #409eff;
margin-bottom: 8px;
}
.search-form {
......
CREATE DATABASE IF NOT EXISTS thermal_control_system
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE thermal_control_system;
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 NOT NULL DEFAULT 0 COMMENT '排序',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_type (type),
INDEX idx_parent (parent_id)
) COMMENT='数据分类表';
CREATE TABLE IF NOT EXISTS data_files (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
filename VARCHAR(255) NOT NULL COMMENT '原始文件名',
stored_name VARCHAR(255) NOT NULL UNIQUE COMMENT '存储文件名',
file_path VARCHAR(500) NOT NULL COMMENT '文件路径',
category_id BIGINT DEFAULT NULL COMMENT '分类ID',
data_count INT NOT NULL DEFAULT 0 COMMENT '数据条数',
uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
remark TEXT NULL COMMENT '备注',
INDEX idx_file_category (category_id)
) COMMENT='数据文件表';
ALTER TABLE data_files
ADD COLUMN IF NOT EXISTS file_path VARCHAR(500) NOT NULL COMMENT '文件路径';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment