Commit b599ac8f authored by luwei's avatar luwei

数据管理功能

parent 6ca52b5b
Pipeline #362 failed with stages
from contextlib import contextmanager from contextlib import contextmanager
from typing import Generator from typing import Generator
from sqlalchemy import create_engine, text from sqlalchemy import create_engine
from sqlalchemy.engine import URL from sqlalchemy.engine import URL
from sqlalchemy.orm import Session, declarative_base, sessionmaker from sqlalchemy.orm import Session, declarative_base, sessionmaker
...@@ -38,40 +38,3 @@ def db_session() -> Generator[Session, None, None]: ...@@ -38,40 +38,3 @@ def db_session() -> Generator[Session, None, None]:
yield session yield session
finally: finally:
session.close() 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 ...@@ -4,7 +4,6 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api.data_management import router as data_management_router from app.api.data_management import router as data_management_router
from app.database import init_database
from app.utils.response import error_response, success_response from app.utils.response import error_response, success_response
...@@ -47,10 +46,6 @@ def create_app() -> FastAPI: ...@@ -47,10 +46,6 @@ def create_app() -> FastAPI:
async def health_check(): async def health_check():
return success_response(data={'status': 'ok'}, message='服务正常') 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=['数据管理']) app.include_router(data_management_router, prefix='/api/data', tags=['数据管理'])
return app return app
......
...@@ -39,6 +39,7 @@ class DataFile(Base): ...@@ -39,6 +39,7 @@ class DataFile(Base):
id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
filename: Mapped[str] = mapped_column(String(255), nullable=False, comment='原始文件名') filename: Mapped[str] = mapped_column(String(255), nullable=False, comment='原始文件名')
stored_name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, 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') 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='数据条数') data_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text('0'), comment='数据条数')
uploaded_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP')) uploaded_at: Mapped[str] = mapped_column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP'))
......
import csv
import io import io
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
...@@ -13,9 +14,10 @@ from app.models import Category, DataFile ...@@ -13,9 +14,10 @@ from app.models import Category, DataFile
class DataManagementService: class DataManagementService:
def __init__(self) -> None: 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._upload_dir = self._base_dir / 'uploads'
self._data_dir = self._upload_dir / 'data' 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._upload_dir.mkdir(parents=True, exist_ok=True)
self._data_dir.mkdir(parents=True, exist_ok=True) self._data_dir.mkdir(parents=True, exist_ok=True)
...@@ -146,6 +148,7 @@ class DataManagementService: ...@@ -146,6 +148,7 @@ class DataManagementService:
'id': item.id, 'id': item.id,
'filename': item.filename, 'filename': item.filename,
'stored_name': item.stored_name, 'stored_name': item.stored_name,
'file_path': item.file_path,
'category_id': item.category_id, 'category_id': item.category_id,
'uploaded_at': item.uploaded_at.strftime('%Y-%m-%d %H:%M:%S') if item.uploaded_at else '', 'uploaded_at': item.uploaded_at.strftime('%Y-%m-%d %H:%M:%S') if item.uploaded_at else '',
'data_count': item.data_count, 'data_count': item.data_count,
...@@ -155,38 +158,39 @@ class DataManagementService: ...@@ -155,38 +158,39 @@ class DataManagementService:
def upload_file(self, filename: str, content: bytes, category_id: str = 'all') -> dict[str, Any]: def upload_file(self, filename: str, content: bytes, category_id: str = 'all') -> dict[str, Any]:
suffix = Path(filename).suffix.lower() suffix = Path(filename).suffix.lower()
if suffix not in {'.xlsx', '.xls'}: if suffix not in {'.xlsx', '.xls', '.csv'}:
raise ValueError('仅支持 Excel 文件(.xlsx/.xls)') raise ValueError('仅支持数据文件(.xlsx/.xls/.csv)')
category_db_id: int | None
if category_id in {'', 'all', None}: if category_id in {'', 'all', None}:
category_db_id = None raise ValueError('请先选择分类后再上传文件')
else:
category_db_id = self._parse_int_id(category_id, '分类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('上传分类不存在')
timestamp = datetime.now().strftime('%Y%m%d%H%M%S%f') 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 target_path = self._data_dir / stored_name
db_file_path = f'/app/uploads/data/{stored_name}'
with target_path.open('wb') as file: with target_path.open('wb') as file:
file.write(content) file.write(content)
_, total_count = self._read_records(target_path, limit=None)
try: try:
with db_session() as session: _, total_count = self._read_records(target_path, limit=None)
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( file_meta = DataFile(
filename=filename, filename=filename,
stored_name=stored_name, stored_name=stored_name,
file_path=db_file_path,
category_id=category_db_id, category_id=category_db_id,
data_count=total_count, data_count=total_count,
) )
...@@ -198,6 +202,7 @@ class DataManagementService: ...@@ -198,6 +202,7 @@ class DataManagementService:
'id': file_meta.id, 'id': file_meta.id,
'filename': file_meta.filename, 'filename': file_meta.filename,
'stored_name': file_meta.stored_name, 'stored_name': file_meta.stored_name,
'file_path': file_meta.file_path,
'category_id': file_meta.category_id, '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 '', '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, 'data_count': file_meta.data_count,
...@@ -215,7 +220,7 @@ class DataManagementService: ...@@ -215,7 +220,7 @@ class DataManagementService:
if not matched: if not matched:
raise ValueError('文件不存在') 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(): if target_path.exists():
target_path.unlink() target_path.unlink()
...@@ -231,7 +236,7 @@ class DataManagementService: ...@@ -231,7 +236,7 @@ class DataManagementService:
if not file_meta: if not file_meta:
raise ValueError('文件不存在') 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(): if not target_path.exists():
raise ValueError('文件已丢失,请重新上传') raise ValueError('文件已丢失,请重新上传')
...@@ -248,13 +253,16 @@ class DataManagementService: ...@@ -248,13 +253,16 @@ class DataManagementService:
return [], 0 return [], 0
first_row = [item.strip() for item in rows[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: if has_header:
header = first_row header = first_row
data_rows = rows[1:] data_rows = rows[1:]
else: else:
header = ['time', 'current', 'voltage', 'temperature'] header = ['time', 'current', 'voltage', 'set_temperature', 'actual_temperature']
data_rows = rows data_rows = rows
index_map = self._build_index_map(header) index_map = self._build_index_map(header)
...@@ -272,7 +280,8 @@ class DataManagementService: ...@@ -272,7 +280,8 @@ class DataManagementService:
'time': self._read_value(row, index_map['time']), 'time': self._read_value(row, index_map['time']),
'current': self._to_float(self._read_value(row, index_map['current'])), 'current': self._to_float(self._read_value(row, index_map['current'])),
'voltage': self._to_float(self._read_value(row, index_map['voltage'])), '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: ...@@ -283,7 +292,8 @@ class DataManagementService:
'time': 0, 'time': 0,
'current': 1, 'current': 1,
'voltage': 2, 'voltage': 2,
'temperature': 3, 'set_temperature': 3,
'actual_temperature': 4,
} }
for index, name in enumerate(header): for index, name in enumerate(header):
...@@ -294,8 +304,10 @@ class DataManagementService: ...@@ -294,8 +304,10 @@ class DataManagementService:
index_map['current'] = index index_map['current'] = index
elif normalized == 'voltage': elif normalized == 'voltage':
index_map['voltage'] = index index_map['voltage'] = index
elif normalized == 'temperature': elif normalized == 'set_temperature':
index_map['temperature'] = index index_map['set_temperature'] = index
elif normalized == 'actual_temperature':
index_map['actual_temperature'] = index
return index_map return index_map
...@@ -307,10 +319,35 @@ class DataManagementService: ...@@ -307,10 +319,35 @@ class DataManagementService:
return 'current' return 'current'
if key in {'voltage', '电压'}: if key in {'voltage', '电压'}:
return 'voltage' return 'voltage'
if key in {'temperature', '温度'}: if key in {'set_temperature', '设定温度', 'set temperature'}:
return 'temperature' return 'set_temperature'
if key in {'actual_temperature', '实际温度', 'actual temperature', 'temperature', '温度'}:
return 'actual_temperature'
return key 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: def _read_value(self, row: list[str], index: int) -> str:
if index < len(row): if index < len(row):
return row[index].strip() return row[index].strip()
...@@ -322,6 +359,8 @@ class DataManagementService: ...@@ -322,6 +359,8 @@ class DataManagementService:
return self._read_xlsx_rows(file_path) return self._read_xlsx_rows(file_path)
if suffix == '.xls': if suffix == '.xls':
return self._read_xls_rows(file_path) return self._read_xls_rows(file_path)
if suffix == '.csv':
return self._read_csv_rows(file_path)
return [] return []
def _read_xlsx_rows(self, file_path: Path) -> list[list[str]]: def _read_xlsx_rows(self, file_path: Path) -> list[list[str]]:
...@@ -347,6 +386,24 @@ class DataManagementService: ...@@ -347,6 +386,24 @@ class DataManagementService:
rows.append(cells) rows.append(cells)
return rows 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: def _cell_to_text(self, value: Any) -> str:
if value is None: if value is None:
return '' return ''
...@@ -372,8 +429,8 @@ class DataManagementService: ...@@ -372,8 +429,8 @@ class DataManagementService:
workbook = Workbook() workbook = Workbook()
sheet = workbook.active sheet = workbook.active
sheet.title = '数据模板' sheet.title = '数据模板'
sheet.append(['时间', '电流', '电压', '温度']) sheet.append(['时间', '电流', '电压', '设定温度', '实际温度'])
sheet.append(['2026-04-16 10:00:00', 1.2, 220.5, 36.8]) sheet.append(['2026/1/22 16:38:01', 1.2, 220.5, 37.0, 36.8])
output = io.BytesIO() output = io.BytesIO()
workbook.save(output) workbook.save(output)
......
...@@ -8,6 +8,8 @@ ...@@ -8,6 +8,8 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"echarts": "^6.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.31", "vue": "^3.5.31",
"vue-router": "^5.0.4" "vue-router": "^5.0.4"
...@@ -500,7 +502,6 @@ ...@@ -500,7 +502,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"vue": "^3.2.0" "vue": "^3.2.0"
...@@ -1939,6 +1940,22 @@ ...@@ -1939,6 +1940,22 @@
"node": ">= 0.4" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.338", "version": "1.5.338",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.338.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.338.tgz",
...@@ -4067,6 +4084,21 @@ ...@@ -4067,6 +4084,21 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/eemeli" "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 @@ ...@@ -10,14 +10,15 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"echarts": "^6.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.31", "vue": "^3.5.31",
"vue-router": "^5.0.4" "vue-router": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"axios": "^1.12.2", "axios": "^1.12.2",
"element-plus": "^2.11.3", "element-plus": "^2.11.3",
"@vitejs/plugin-vue": "^6.0.5",
"sass-embedded": "^1.99.0", "sass-embedded": "^1.99.0",
"vite": "^8.0.3", "vite": "^8.0.3",
"vite-plugin-vue-devtools": "^8.1.1" "vite-plugin-vue-devtools": "^8.1.1"
......
<script setup> <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({ const props = defineProps({
records: { records: {
...@@ -8,124 +9,221 @@ const props = defineProps({ ...@@ -8,124 +9,221 @@ const props = defineProps({
}, },
}) })
const canvasRef = ref(null) const chartRef = ref(null)
let chartInstance = null
const numericPoints = computed(() => { const chartData = computed(() => {
return props.records const source = Array.isArray(props.records) ? props.records : []
return source
.map((item, idx) => ({ .map((item, idx) => ({
x: idx, idx,
time: item.time || `第${idx + 1}条`,
current: Number(item.current), current: Number(item.current),
voltage: Number(item.voltage), voltage: Number(item.voltage),
temperature: Number(item.temperature), actual_temperature: Number(item.actual_temperature),
})) }))
.filter((item) => { .filter((item) => {
return ( return (
Number.isFinite(item.current) || Number.isFinite(item.current) ||
Number.isFinite(item.voltage) || Number.isFinite(item.voltage) ||
Number.isFinite(item.temperature) Number.isFinite(item.actual_temperature)
) )
}) })
}) })
const draw = () => { const stats = computed(() => {
const canvas = canvasRef.value const temps = chartData.value
if (!canvas) { .map((item) => item.actual_temperature)
return .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 formatValue = (value) => {
const width = canvas.clientWidth return Number.isFinite(value) ? Number(value).toFixed(2) : '--'
const height = canvas.clientHeight }
canvas.width = width * dpr
canvas.height = height * dpr
const ctx = canvas.getContext('2d') const getSeriesData = (key) => {
ctx.scale(dpr, dpr) return chartData.value.map((item) => (Number.isFinite(item[key]) ? item[key] : null))
ctx.clearRect(0, 0, width, height) }
if (numericPoints.value.length < 2) { const renderChart = () => {
ctx.fillStyle = '#64748b' if (!chartRef.value) {
ctx.font = '14px Poppins, Noto Sans SC, sans-serif'
ctx.fillText('暂无足够数据生成曲线', 20, height / 2)
return return
} }
const padding = { top: 18, right: 20, bottom: 32, left: 44 } if (!chartInstance) {
const innerWidth = width - padding.left - padding.right chartInstance = echarts.init(chartRef.value)
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 hasData = chartData.value.length > 0
const y = padding.top + ((maxValue - value) / diff) * innerHeight
if (!started) { chartInstance.setOption(
ctx.moveTo(x, y) {
started = true animation: true,
} else { color: ['#409EFF', '#67C23A', '#FF6B6B'],
ctx.lineTo(x, y) 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) { const lines = [`<div style="margin-bottom:6px;font-weight:600;">${params[0].axisValue}</div>`]
ctx.strokeStyle = color params.forEach((item) => {
ctx.lineWidth = 2 lines.push(
ctx.stroke() `<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') const resizeChart = () => {
drawLine('voltage', '#f97316') chartInstance?.resize()
drawLine('temperature', '#ef4444')
} }
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
draw() renderChart()
window.addEventListener('resize', resizeChart)
}) })
watch( watch(
() => props.records, () => props.records,
async () => { async () => {
await nextTick() await nextTick()
draw() renderChart()
}, },
{ deep: true }, { deep: true },
) )
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
chartInstance?.dispose()
chartInstance = null
})
</script> </script>
<template> <template>
<div class="curve-box"> <div class="curve-box">
<div class="legend-row"> <div class="stats-row">
<span class="legend-item"><i class="dot dot-current" />电流</span> <div class="stat-card">
<span class="legend-item"><i class="dot dot-voltage" />电压</span> <div class="stat-label">总条数</div>
<span class="legend-item"><i class="dot dot-temperature" />温度</span> <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> </div>
<canvas ref="canvasRef" class="curve-canvas" /> </div>
<div ref="chartRef" class="echarts-box" />
</div> </div>
</template> </template>
...@@ -134,46 +232,38 @@ watch( ...@@ -134,46 +232,38 @@ watch(
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 14px;
}
.legend-row {
display: flex;
gap: 16px;
color: #334155;
font-size: 13px;
} }
.legend-item { .stats-row {
display: inline-flex; display: grid;
align-items: center; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px; gap: 12px;
} }
.dot { .stat-card {
width: 9px; padding: 14px 16px;
height: 9px; border-radius: 14px;
border-radius: 50%; background: #f3f6fb;
} }
.dot-current { .stat-label {
background: #0ea5e9; font-size: 13px;
} color: #64748b;
margin-bottom: 8px;
.dot-voltage {
background: #f97316;
} }
.dot-temperature { .stat-value {
background: #ef4444; font-size: 18px;
font-weight: 700;
color: #0f172a;
} }
.curve-canvas { .echarts-box {
width: 100%;
flex: 1; flex: 1;
min-height: 280px; min-height: 360px;
border-radius: 12px; border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.12); border: 1px solid rgba(15, 23, 42, 0.1);
background: linear-gradient(180deg, #ffffff, #f8fafc); background: linear-gradient(180deg, #ffffff, #f8fafc);
} }
</style> </style>
<script setup> <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 { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
...@@ -16,7 +16,7 @@ import { ...@@ -16,7 +16,7 @@ import {
import DataCurve from './components/DataCurve.vue' import DataCurve from './components/DataCurve.vue'
const categoryTree = ref([]) const categoryTree = ref([])
const selectedCategory = ref('') const selectedCategory = ref('all')
const searchForm = reactive({ const searchForm = reactive({
filename: '', filename: '',
...@@ -37,10 +37,17 @@ const categoryForm = reactive({ ...@@ -37,10 +37,17 @@ const categoryForm = reactive({
name: '', name: '',
}) })
const uploadDialogVisible = ref(false)
const uploadForm = reactive({
categoryId: '',
})
const layoutRef = ref(null) const layoutRef = ref(null)
const leftPaneWidth = ref(16) const leftPaneWidth = ref(16)
const middlePaneWidth = ref(38) const middlePaneWidth = ref(38)
const dragState = ref(null) const dragState = ref(null)
const pendingDragX = ref(null)
let dragFrameId = 0
const layoutStorageKey = 'data-management-layout-v1' const layoutStorageKey = 'data-management-layout-v1'
const MIN_LEFT = 12 const MIN_LEFT = 12
...@@ -58,17 +65,33 @@ const categoryDialogTitle = computed(() => { ...@@ -58,17 +65,33 @@ const categoryDialogTitle = computed(() => {
return categoryDialogMode.value === 'create' ? '新增分类' : '编辑分类' 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 normalizeCategoryTree = (treeData) => {
const source = Array.isArray(treeData) ? treeData : [] const source = Array.isArray(treeData) ? treeData : []
const oneLevel = const oneLevel = source.filter((item) => String(item?.id) !== 'all')
source.length === 1 && String(source[0]?.id) === 'all' ? source[0].children || [] : source
return oneLevel.map((item) => ({ const children = oneLevel.map((item) => ({
id: item.id, id: item.id,
name: item.name, name: item.name,
parent_id: item.parent_id, parent_id: item.parent_id,
children: [], children: [],
})) }))
return [
{
id: 'all',
name: '全部',
parent_id: null,
children,
},
]
} }
const setPaneWidths = (nextLeft, nextMiddle, target = 'left') => { const setPaneWidths = (nextLeft, nextMiddle, target = 'left') => {
...@@ -121,11 +144,48 @@ const saveLayoutCache = () => { ...@@ -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 = () => { const stopDragging = () => {
document.removeEventListener('mousemove', handleDragging) window.removeEventListener('mousemove', handleDragging)
document.removeEventListener('mouseup', stopDragging) window.removeEventListener('mouseup', stopDragging)
dragState.value = null 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() saveLayoutCache()
}
dragState.value = null
} }
const handleDragging = (event) => { const handleDragging = (event) => {
...@@ -133,12 +193,10 @@ const handleDragging = (event) => { ...@@ -133,12 +193,10 @@ const handleDragging = (event) => {
return return
} }
const delta = ((event.clientX - dragState.value.startX) / dragState.value.layoutWidth) * 100 pendingDragX.value = event.clientX
if (dragState.value.target === 'left') { if (!dragFrameId) {
setPaneWidths(dragState.value.startLeft + delta, dragState.value.startMiddle, 'left') dragFrameId = requestAnimationFrame(flushDragging)
} else {
setPaneWidths(dragState.value.startLeft, dragState.value.startMiddle + delta, 'middle')
} }
} }
...@@ -147,6 +205,9 @@ const startDragging = (target, event) => { ...@@ -147,6 +205,9 @@ const startDragging = (target, event) => {
return return
} }
event.preventDefault()
stopDragging()
dragState.value = { dragState.value = {
target, target,
startX: event.clientX, startX: event.clientX,
...@@ -155,16 +216,26 @@ const startDragging = (target, event) => { ...@@ -155,16 +216,26 @@ const startDragging = (target, event) => {
layoutWidth: layoutRef.value.clientWidth, layoutWidth: layoutRef.value.clientWidth,
} }
document.addEventListener('mousemove', handleDragging) document.body.style.userSelect = 'none'
document.addEventListener('mouseup', stopDragging) 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 loadCategoryTree = async () => {
const treeData = await getCategoryTree() const treeData = await getCategoryTree()
categoryTree.value = normalizeCategoryTree(treeData) categoryTree.value = normalizeCategoryTree(treeData)
if (!categoryTree.value.some((item) => item.id === selectedCategory.value)) { const allIds = [
selectedCategory.value = '' ...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 () => { ...@@ -172,7 +243,7 @@ const loadFileList = async () => {
loadingFiles.value = true loadingFiles.value = true
try { try {
const data = await getDataFiles({ const data = await getDataFiles({
category_id: selectedCategory.value || '', category_id: selectedCategory.value && selectedCategory.value !== 'all' ? selectedCategory.value : '',
filename: searchForm.filename.trim(), filename: searchForm.filename.trim(),
}) })
fileList.value = data fileList.value = data
...@@ -233,7 +304,7 @@ const handleDeleteCategory = async (node) => { ...@@ -233,7 +304,7 @@ const handleDeleteCategory = async (node) => {
await deleteCategory(node.id) await deleteCategory(node.id)
ElMessage.success('分类已删除') ElMessage.success('分类已删除')
if (selectedCategory.value === node.id) { if (selectedCategory.value === node.id) {
selectedCategory.value = '' selectedCategory.value = 'all'
} }
await loadCategoryTree() await loadCategoryTree()
await loadFileList() await loadFileList()
...@@ -251,22 +322,40 @@ const handleReset = async () => { ...@@ -251,22 +322,40 @@ const handleReset = async () => {
await loadFileList() await loadFileList()
} }
const openUploadDialog = () => {
uploadForm.categoryId = selectedCategory.value && selectedCategory.value !== 'all' ? String(selectedCategory.value) : ''
uploadDialogVisible.value = true
}
const beforeUploadExcel = (file) => { const beforeUploadExcel = (file) => {
if (!uploadForm.categoryId) {
ElMessage.warning('请先在弹窗中选择分类')
return false
}
const lowerName = file.name.toLowerCase() 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) { if (!valid) {
ElMessage.warning('仅支持 Excel 文件(.xlsx/.xls)') ElMessage.warning('仅支持数据文件(.xlsx/.xls/.csv)')
} }
return valid return valid
} }
const handleUpload = async (uploadRequest) => { const handleUpload = async (uploadRequest) => {
if (!uploadForm.categoryId) {
ElMessage.warning('请先选择分类后再上传')
uploadRequest.onError(new Error('未选择分类'))
return
}
const formData = new FormData() const formData = new FormData()
formData.append('file', uploadRequest.file) formData.append('file', uploadRequest.file)
formData.append('category_id', selectedCategory.value ? String(selectedCategory.value) : '') formData.append('category_id', String(uploadForm.categoryId))
try { try {
await uploadDataFile(formData) await uploadDataFile(formData)
selectedCategory.value = String(uploadForm.categoryId)
uploadDialogVisible.value = false
ElMessage.success('上传成功') ElMessage.success('上传成功')
await loadFileList() await loadFileList()
uploadRequest.onSuccess() uploadRequest.onSuccess()
...@@ -319,6 +408,7 @@ const handleDeleteFile = async (row) => { ...@@ -319,6 +408,7 @@ const handleDeleteFile = async (row) => {
onMounted(async () => { onMounted(async () => {
loadLayoutCache() loadLayoutCache()
await loadCategoryTree() await loadCategoryTree()
selectedCategory.value = selectedCategory.value || 'all'
await loadFileList() await loadFileList()
}) })
...@@ -328,7 +418,7 @@ onBeforeUnmount(() => { ...@@ -328,7 +418,7 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<section class="data-page"> <section class="data-page" :class="{ dragging: !!dragState }">
<div ref="layoutRef" class="data-layout"> <div ref="layoutRef" class="data-layout">
<el-card class="pane-card" shadow="hover" :style="{ width: `${leftPaneWidth}%` }"> <el-card class="pane-card" shadow="hover" :style="{ width: `${leftPaneWidth}%` }">
<template #header> <template #header>
...@@ -346,14 +436,20 @@ onBeforeUnmount(() => { ...@@ -346,14 +436,20 @@ onBeforeUnmount(() => {
:data="categoryTree" :data="categoryTree"
node-key="id" node-key="id"
highlight-current highlight-current
default-expand-all
:current-node-key="selectedCategory || undefined" :current-node-key="selectedCategory || undefined"
:expand-on-click-node="false" :expand-on-click-node="false"
@node-click="handleCategoryClick" @node-click="handleCategoryClick"
> >
<template #default="{ data }"> <template #default="{ data }">
<div class="tree-node"> <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-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="primary" :icon="Edit" @click.stop="openEditCategoryDialog(data)" />
<el-button link type="danger" :icon="Delete" @click.stop="handleDeleteCategory(data)" /> <el-button link type="danger" :icon="Delete" @click.stop="handleDeleteCategory(data)" />
</span> </span>
...@@ -362,7 +458,7 @@ onBeforeUnmount(() => { ...@@ -362,7 +458,7 @@ onBeforeUnmount(() => {
</el-tree> </el-tree>
</el-card> </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}%` }"> <el-card class="pane-card" shadow="hover" :style="{ width: `${middlePaneWidth}%` }">
<template #header> <template #header>
...@@ -379,14 +475,7 @@ onBeforeUnmount(() => { ...@@ -379,14 +475,7 @@ onBeforeUnmount(() => {
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
<el-button type="info" plain @click="handleDownloadTemplate">下载模板</el-button> <el-button type="info" plain @click="handleDownloadTemplate">下载模板</el-button>
<el-upload <el-button type="success" :icon="Upload" @click="openUploadDialog">上传文件</el-button>
: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-item>
</el-form> </el-form>
...@@ -403,7 +492,7 @@ onBeforeUnmount(() => { ...@@ -403,7 +492,7 @@ onBeforeUnmount(() => {
</el-table> </el-table>
</el-card> </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}%` }"> <el-card class="pane-card" shadow="hover" :style="{ width: `${rightPaneWidth}%` }">
<template #header> <template #header>
...@@ -432,7 +521,8 @@ onBeforeUnmount(() => { ...@@ -432,7 +521,8 @@ onBeforeUnmount(() => {
<el-table-column prop="time" label="时间" min-width="140" /> <el-table-column prop="time" label="时间" min-width="140" />
<el-table-column prop="current" label="电流" min-width="100" /> <el-table-column prop="current" label="电流" min-width="100" />
<el-table-column prop="voltage" 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> </el-table>
<DataCurve v-else :records="recordList" /> <DataCurve v-else :records="recordList" />
...@@ -440,6 +530,42 @@ onBeforeUnmount(() => { ...@@ -440,6 +530,42 @@ onBeforeUnmount(() => {
</el-card> </el-card>
</div> </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-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="420px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="分类名称" required> <el-form-item label="分类名称" required>
...@@ -458,6 +584,11 @@ onBeforeUnmount(() => { ...@@ -458,6 +584,11 @@ onBeforeUnmount(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.data-page { .data-page {
height: 100%; height: 100%;
&.dragging {
user-select: none;
cursor: col-resize;
}
} }
.data-layout { .data-layout {
...@@ -468,19 +599,25 @@ onBeforeUnmount(() => { ...@@ -468,19 +599,25 @@ onBeforeUnmount(() => {
} }
.pane-divider { .pane-divider {
width: 8px; width: 12px;
flex: 0 0 8px; flex: 0 0 12px;
position: relative; position: relative;
cursor: col-resize; cursor: col-resize;
user-select: none;
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 3px; left: 5px;
width: 2px; width: 2px;
background: rgba(148, 163, 184, 0.4); 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(() => { ...@@ -519,12 +656,40 @@ onBeforeUnmount(() => {
margin-left: 6px; 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 { .tree-node {
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; 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 { .tree-name {
...@@ -532,6 +697,21 @@ onBeforeUnmount(() => { ...@@ -532,6 +697,21 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .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