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'))
......
...@@ -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 const hasData = chartData.value.length > 0
.flatMap((item) => [item.current, item.voltage, item.temperature])
.filter((value) => Number.isFinite(value))
const minValue = Math.min(...allValues) chartInstance.setOption(
const maxValue = Math.max(...allValues) {
const diff = maxValue - minValue || 1 animation: true,
color: ['#409EFF', '#67C23A', '#FF6B6B'],
ctx.strokeStyle = 'rgba(15, 23, 42, 0.14)' tooltip: {
ctx.lineWidth = 1 trigger: 'axis',
ctx.beginPath() backgroundColor: 'rgba(255,255,255,0.96)',
ctx.moveTo(padding.left, padding.top) borderColor: '#e2e8f0',
ctx.lineTo(padding.left, height - padding.bottom) borderWidth: 1,
ctx.lineTo(width - padding.right, height - padding.bottom) textStyle: {
ctx.stroke() color: '#334155',
},
const drawLine = (key, color) => { extraCssText: 'box-shadow: 0 10px 30px rgba(15,23,42,0.16); border-radius: 12px;',
ctx.beginPath() formatter(params) {
let started = false if (!params?.length) {
return ''
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) { 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> </div>
<canvas ref="canvasRef" class="curve-canvas" />
<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 { .stats-row {
display: flex; display: grid;
gap: 16px; grid-template-columns: repeat(3, minmax(0, 1fr));
color: #334155; gap: 12px;
font-size: 13px;
} }
.legend-item { .stat-card {
display: inline-flex; padding: 14px 16px;
align-items: center; border-radius: 14px;
gap: 6px; background: #f3f6fb;
} }
.dot { .stat-label {
width: 9px; font-size: 13px;
height: 9px; color: #64748b;
border-radius: 50%; margin-bottom: 8px;
}
.dot-current {
background: #0ea5e9;
}
.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>
This diff is collapsed.
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