Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
T
thermal-control-system
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
luwei
thermal-control-system
Commits
4d970f15
Commit
4d970f15
authored
Apr 23, 2026
by
luwei
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
功能开发
parent
b599ac8f
Changes
38
Show whitespace changes
Inline
Side-by-side
Showing
38 changed files
with
4264 additions
and
113 deletions
+4264
-113
eval_management.py
backend/app/api/eval_management.py
+54
-0
package_management.py
backend/app/api/package_management.py
+118
-0
train_management.py
backend/app/api/train_management.py
+113
-0
main.py
backend/app/main.py
+13
-0
lstm_predictor.py
backend/app/ml/lstm_predictor.py
+127
-0
lstm_trainer.py
backend/app/ml/lstm_trainer.py
+210
-0
__init__.py
backend/app/ml/models/__init__.py
+0
-0
train_utils.py
backend/app/ml/train_utils.py
+0
-0
__init__.py
backend/app/models/__init__.py
+4
-2
data_management.py
backend/app/models/data_management.py
+34
-0
eval_management.py
backend/app/models/eval_management.py
+25
-0
train_management.py
backend/app/models/train_management.py
+54
-0
eval_service.py
backend/app/services/eval_service.py
+139
-0
package_management_service.py
backend/app/services/package_management_service.py
+281
-0
train_service.py
backend/app/services/train_service.py
+320
-0
test.py
backend/app/test/test.py
+2
-0
requirements.txt
backend/requirements.txt
+3
-0
task_5.pt
backend/saved_models/task_5.pt
+0
-0
task_7.pt
backend/saved_models/task_7.pt
+0
-0
温度2_20260423163745355240_19日14时57分48秒转换结果_回路1_-对应控点3.csv
.../温度2_20260423163745355240_19日14时57分48秒转换结果_回路1_-对应控点3.csv
+0
-0
App.vue
frontend/src/App.vue
+52
-32
evalManagement.js
frontend/src/api/evalManagement.js
+25
-0
packageManagement.js
frontend/src/api/packageManagement.js
+47
-0
trainManagement.js
frontend/src/api/trainManagement.js
+41
-0
UnderConstruction.vue
frontend/src/components/common/UnderConstruction.vue
+15
-11
index.js
frontend/src/router/index.js
+4
-8
global.scss
frontend/src/styles/global.scss
+155
-8
index.vue
frontend/src/views/DataManagement/index.vue
+73
-52
EvalChart.vue
frontend/src/views/ModelEvaluation/components/EvalChart.vue
+147
-0
index.vue
frontend/src/views/ModelEvaluation/index.vue
+359
-0
index.vue
frontend/src/views/ModelList/index.vue
+160
-0
index.vue
frontend/src/views/ModelTraining/index.vue
+545
-0
AddPackage.vue
...end/src/views/PackageManagement/components/AddPackage.vue
+326
-0
PkgCategoryTree.vue
...rc/views/PackageManagement/components/PkgCategoryTree.vue
+238
-0
PkgDetail.vue
...tend/src/views/PackageManagement/components/PkgDetail.vue
+143
-0
PkgList.vue
frontend/src/views/PackageManagement/components/PkgList.vue
+180
-0
index.vue
frontend/src/views/PackageManagement/index.vue
+237
-0
init_tables.sql
sql/init_tables.sql
+20
-0
No files found.
backend/app/api/eval_management.py
0 → 100644
View file @
4d970f15
from
fastapi
import
APIRouter
,
HTTPException
from
pydantic
import
BaseModel
from
app.services.eval_service
import
EvalService
from
app.utils.response
import
success_response
router
=
APIRouter
()
service
=
EvalService
()
class
EvalRequest
(
BaseModel
):
model_id
:
int
package_id
:
int
@
router
.
get
(
'/packages'
)
def
list_packages
():
return
success_response
(
data
=
service
.
list_packages
())
@
router
.
get
(
'/models'
)
def
list_models
():
return
success_response
(
data
=
service
.
list_saved_models
())
@
router
.
get
(
'/records'
)
def
list_records
():
return
success_response
(
data
=
service
.
list_records
())
@
router
.
get
(
'/records/{record_id}'
)
def
get_record
(
record_id
:
int
):
try
:
return
success_response
(
data
=
service
.
get_record
(
record_id
))
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
404
,
detail
=
str
(
error
))
from
error
@
router
.
delete
(
'/records/{record_id}'
)
def
delete_record
(
record_id
:
int
):
try
:
service
.
delete_record
(
record_id
)
return
success_response
(
data
=
True
,
message
=
'评估记录已删除'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
404
,
detail
=
str
(
error
))
from
error
@
router
.
post
(
'/run'
)
def
run_evaluation
(
request
:
EvalRequest
):
try
:
result
=
service
.
evaluate
(
model_id
=
request
.
model_id
,
package_id
=
request
.
package_id
)
return
success_response
(
data
=
result
,
message
=
'评估完成'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
backend/app/api/package_management.py
0 → 100644
View file @
4d970f15
from
fastapi
import
APIRouter
,
HTTPException
,
Query
from
pydantic
import
BaseModel
,
Field
from
app.services.package_management_service
import
PackageManagementService
from
app.utils.response
import
success_response
router
=
APIRouter
()
service
=
PackageManagementService
()
class
CategoryCreateRequest
(
BaseModel
):
name
:
str
=
Field
(
min_length
=
1
,
max_length
=
100
)
parent_id
:
str
|
int
|
None
=
Field
(
default
=
'all'
)
class
CategoryUpdateRequest
(
BaseModel
):
name
:
str
=
Field
(
min_length
=
1
,
max_length
=
100
)
@
router
.
get
(
'/categories'
)
def
get_categories
():
return
success_response
(
data
=
service
.
get_category_tree
())
@
router
.
post
(
'/categories'
)
def
create_category
(
request
:
CategoryCreateRequest
):
try
:
parent_id
=
'all'
if
request
.
parent_id
is
None
else
str
(
request
.
parent_id
)
category
=
service
.
create_category
(
name
=
request
.
name
.
strip
(),
parent_id
=
parent_id
)
return
success_response
(
data
=
category
,
message
=
'分类创建成功'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
@
router
.
put
(
'/categories/{category_id}'
)
def
update_category
(
category_id
:
str
,
request
:
CategoryUpdateRequest
):
try
:
category
=
service
.
update_category
(
category_id
=
category_id
,
name
=
request
.
name
.
strip
())
return
success_response
(
data
=
category
,
message
=
'分类更新成功'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
@
router
.
delete
(
'/categories/{category_id}'
)
def
delete_category
(
category_id
:
str
):
try
:
service
.
delete_category
(
category_id
=
category_id
)
return
success_response
(
data
=
True
,
message
=
'分类删除成功'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
# Must be declared before /{package_id} routes to avoid path conflict
@
router
.
get
(
'/data-files'
)
def
list_all_data_files
():
return
success_response
(
data
=
service
.
list_all_data_files
())
class
PackageCreateRequest
(
BaseModel
):
name
:
str
=
Field
(
min_length
=
1
,
max_length
=
255
)
category_id
:
str
|
int
|
None
=
Field
(
default
=
None
)
remark
:
str
|
None
=
Field
(
default
=
None
)
file_ids
:
list
[
int
]
=
Field
(
default_factory
=
list
)
class
PreviewRequest
(
BaseModel
):
file_ids
:
list
[
int
]
=
Field
(
default_factory
=
list
)
@
router
.
post
(
'/preview'
)
def
preview_package
(
request
:
PreviewRequest
,
limit
:
int
=
Query
(
default
=
300
,
ge
=
1
,
le
=
2000
)):
try
:
result
=
service
.
preview_records
(
file_ids
=
request
.
file_ids
,
limit
=
limit
)
return
success_response
(
data
=
result
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
@
router
.
get
(
''
)
def
list_packages
(
category_id
:
str
=
Query
(
default
=
''
),
name
:
str
=
Query
(
default
=
''
),
):
return
success_response
(
data
=
service
.
list_packages
(
category_id
=
category_id
,
name
=
name
.
strip
()))
@
router
.
post
(
''
)
def
create_package
(
request
:
PackageCreateRequest
):
try
:
category_id
=
None
if
request
.
category_id
in
(
None
,
''
,
'all'
)
else
str
(
request
.
category_id
)
pkg
=
service
.
create_package
(
name
=
request
.
name
.
strip
(),
category_id
=
category_id
,
remark
=
request
.
remark
,
file_ids
=
request
.
file_ids
,
)
return
success_response
(
data
=
pkg
,
message
=
'数据包创建成功'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
@
router
.
get
(
'/{package_id}/records'
)
def
get_package_records
(
package_id
:
str
,
limit
:
int
=
Query
(
default
=
500
,
ge
=
1
,
le
=
5000
)):
try
:
result
=
service
.
get_package_records
(
package_id
=
package_id
,
limit
=
limit
)
return
success_response
(
data
=
result
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
@
router
.
delete
(
'/{package_id}'
)
def
delete_package
(
package_id
:
str
):
try
:
service
.
delete_package
(
package_id
=
package_id
)
return
success_response
(
data
=
True
,
message
=
'数据包删除成功'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
backend/app/api/train_management.py
0 → 100644
View file @
4d970f15
from
fastapi
import
APIRouter
,
HTTPException
from
pydantic
import
BaseModel
,
Field
from
app.services.train_service
import
TrainService
from
app.utils.response
import
success_response
router
=
APIRouter
()
service
=
TrainService
()
# ── request schemas ───────────────────────────────────────────────────────────
class
LSTMParams
(
BaseModel
):
seq_len
:
int
=
Field
(
default
=
20
,
ge
=
5
,
le
=
500
)
hidden_size
:
int
=
Field
(
default
=
64
,
ge
=
8
,
le
=
1024
)
num_layers
:
int
=
Field
(
default
=
2
,
ge
=
1
,
le
=
8
)
epochs
:
int
=
Field
(
default
=
50
,
ge
=
1
,
le
=
2000
)
batch_size
:
int
=
Field
(
default
=
32
,
ge
=
1
,
le
=
512
)
learning_rate
:
float
=
Field
(
default
=
0.001
,
gt
=
0
,
le
=
1
)
train_ratio
:
float
=
Field
(
default
=
0.8
,
ge
=
0.5
,
le
=
0.99
)
class
CreateTaskRequest
(
BaseModel
):
model_name
:
str
=
Field
(
min_length
=
1
,
max_length
=
255
)
package_id
:
int
params
:
LSTMParams
=
Field
(
default_factory
=
LSTMParams
)
# ── packages ──────────────────────────────────────────────────────────────────
@
router
.
get
(
'/packages'
)
def
list_packages
():
return
success_response
(
data
=
service
.
list_packages
())
# ── tasks ─────────────────────────────────────────────────────────────────────
@
router
.
get
(
'/tasks'
)
def
list_tasks
():
return
success_response
(
data
=
service
.
list_tasks
())
@
router
.
get
(
'/tasks/{task_id}'
)
def
get_task
(
task_id
:
int
):
try
:
return
success_response
(
data
=
service
.
get_task
(
task_id
))
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
404
,
detail
=
str
(
error
))
from
error
@
router
.
post
(
'/tasks'
)
def
create_task
(
request
:
CreateTaskRequest
):
try
:
task
=
service
.
create_task
(
model_name
=
request
.
model_name
.
strip
(),
package_id
=
request
.
package_id
,
params
=
request
.
params
.
model_dump
(),
)
return
success_response
(
data
=
task
,
message
=
'训练任务已启动'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
@
router
.
post
(
'/tasks/{task_id}/cancel'
)
def
cancel_task
(
task_id
:
int
):
try
:
service
.
cancel_task
(
task_id
)
return
success_response
(
data
=
True
,
message
=
'取消请求已发送'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
@
router
.
post
(
'/tasks/{task_id}/restart'
)
def
restart_task
(
task_id
:
int
):
try
:
task
=
service
.
restart_task
(
task_id
)
return
success_response
(
data
=
task
,
message
=
'重新训练已启动'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
@
router
.
delete
(
'/tasks/{task_id}'
)
def
delete_task
(
task_id
:
int
):
try
:
service
.
delete_task
(
task_id
)
return
success_response
(
data
=
True
,
message
=
'任务已删除'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
@
router
.
post
(
'/tasks/{task_id}/save'
)
def
save_model
(
task_id
:
int
):
try
:
model
=
service
.
save_model
(
task_id
)
return
success_response
(
data
=
model
,
message
=
'模型已保存'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
# ── saved models ──────────────────────────────────────────────────────────────
@
router
.
get
(
'/models'
)
def
list_models
():
return
success_response
(
data
=
service
.
list_saved_models
())
@
router
.
delete
(
'/models/{model_id}'
)
def
delete_model
(
model_id
:
int
):
try
:
service
.
delete_saved_model
(
model_id
)
return
success_response
(
data
=
True
,
message
=
'模型已删除'
)
except
ValueError
as
error
:
raise
HTTPException
(
status_code
=
400
,
detail
=
str
(
error
))
from
error
backend/app/main.py
View file @
4d970f15
...
...
@@ -4,10 +4,20 @@ from fastapi.middleware.cors import CORSMiddleware
from
fastapi.responses
import
JSONResponse
from
app.api.data_management
import
router
as
data_management_router
from
app.api.eval_management
import
router
as
eval_management_router
from
app.api.package_management
import
router
as
package_management_router
from
app.api.train_management
import
router
as
train_management_router
from
app.database
import
Base
,
engine
from
app.models
import
Category
,
DataFile
,
DataPackage
,
DataPackageFile
# noqa: F401
from
app.models.eval_management
import
EvalRecord
# noqa: F401
from
app.models.train_management
import
SavedModel
,
TrainTask
# noqa: F401
from
app.utils.response
import
error_response
,
success_response
def
create_app
()
->
FastAPI
:
# Auto-create any missing tables (safe: uses CREATE TABLE IF NOT EXISTS internally)
Base
.
metadata
.
create_all
(
bind
=
engine
)
app
=
FastAPI
(
title
=
'Thermal Control System API'
,
version
=
'0.1.0'
,
...
...
@@ -47,6 +57,9 @@ def create_app() -> FastAPI:
return
success_response
(
data
=
{
'status'
:
'ok'
},
message
=
'服务正常'
)
app
.
include_router
(
data_management_router
,
prefix
=
'/api/data'
,
tags
=
[
'数据管理'
])
app
.
include_router
(
package_management_router
,
prefix
=
'/api/packages'
,
tags
=
[
'数据包管理'
])
app
.
include_router
(
train_management_router
,
prefix
=
'/api/train'
,
tags
=
[
'模型训练'
])
app
.
include_router
(
eval_management_router
,
prefix
=
'/api/eval'
,
tags
=
[
'模型评估'
])
return
app
...
...
backend/app/ml/lstm_predictor.py
0 → 100644
View file @
4d970f15
"""LSTM inference — predict actual_temperature for every data point in a dataset.
Requires the same PyTorch environment used for training.
"""
from
__future__
import
annotations
import
math
from
pathlib
import
Path
from
typing
import
Any
import
numpy
as
np
from
app.ml.lstm_trainer
import
(
FEATURE_COLS
,
TARGET_IDX
,
_LSTMModel
,
_check_torch
,
_extract_features
,
)
# Maximum number of chart points stored in DB (subsample if dataset is larger)
CHART_MAX_POINTS
=
1500
def
predict_lstm
(
records
:
list
[
dict
[
str
,
Any
]],
model_path
:
Path
,
)
->
dict
[
str
,
Any
]:
"""
Run inference on *records* using the saved model at *model_path*.
Returns a dict::
{
'total_count': int, # total prediction points
'mae': float,
'rmse': float,
'chart_data': [ # sampled up to CHART_MAX_POINTS
{'index': int, 'time': str, 'actual': float, 'predicted': float},
...
],
}
"""
_check_torch
()
import
torch
checkpoint
=
torch
.
load
(
model_path
,
map_location
=
'cpu'
,
weights_only
=
False
)
seq_len
:
int
=
int
(
checkpoint
.
get
(
'seq_len'
,
20
))
hidden_size
:
int
=
int
(
checkpoint
.
get
(
'hidden_size'
,
64
))
num_layers
:
int
=
int
(
checkpoint
.
get
(
'num_layers'
,
2
))
input_size
:
int
=
int
(
checkpoint
.
get
(
'input_size'
,
len
(
FEATURE_COLS
)))
data_min
=
np
.
array
(
checkpoint
[
'data_min'
],
dtype
=
np
.
float32
)
data_max
=
np
.
array
(
checkpoint
[
'data_max'
],
dtype
=
np
.
float32
)
data_range
=
data_max
-
data_min
data_range
[
data_range
==
0
]
=
1.0
# ── prepare data ─────────────────────────────────────────────────────────
raw
=
_extract_features
(
records
)
if
len
(
raw
)
<
seq_len
+
1
:
raise
ValueError
(
f
'数据量不足:需至少 {seq_len + 1} 条有效记录,当前仅 {len(raw)} 条。'
)
data_norm
=
(
raw
-
data_min
)
/
data_range
# Build all sequences at once → (N-seq_len, seq_len, features)
n
=
len
(
data_norm
)
indices
=
np
.
arange
(
n
-
seq_len
)
X
=
np
.
stack
([
data_norm
[
i
:
i
+
seq_len
]
for
i
in
indices
],
axis
=
0
)
.
astype
(
np
.
float32
)
# ── model inference ───────────────────────────────────────────────────────
model
=
_LSTMModel
(
input_size
,
hidden_size
,
num_layers
)
model
.
load_state_dict
(
checkpoint
[
'model_state'
])
model
.
eval
()
with
torch
.
no_grad
():
X_t
=
torch
.
tensor
(
X
)
# Batch to avoid OOM on very large datasets
batch_sz
=
512
preds_norm
:
list
[
float
]
=
[]
for
start
in
range
(
0
,
len
(
X_t
),
batch_sz
):
out
=
model
(
X_t
[
start
:
start
+
batch_sz
])
preds_norm
.
extend
(
out
.
numpy
()
.
tolist
())
preds_norm_arr
=
np
.
array
(
preds_norm
,
dtype
=
np
.
float32
)
# Denormalize
t_min
=
float
(
data_min
[
TARGET_IDX
])
t_range
=
float
(
data_range
[
TARGET_IDX
])
preds_real
=
preds_norm_arr
*
t_range
+
t_min
actuals_real
=
raw
[
seq_len
:,
TARGET_IDX
]
# ── metrics ───────────────────────────────────────────────────────────────
errors
=
preds_real
-
actuals_real
mae
=
float
(
np
.
mean
(
np
.
abs
(
errors
)))
rmse
=
float
(
math
.
sqrt
(
float
(
np
.
mean
(
errors
**
2
))))
# ── build result points ───────────────────────────────────────────────────
times
=
[
str
(
r
.
get
(
'time'
,
''
))
for
r
in
records
]
total
=
len
(
preds_real
)
# Subsample for chart storage
if
total
<=
CHART_MAX_POINTS
:
sample_idx
=
list
(
range
(
total
))
else
:
step
=
total
/
CHART_MAX_POINTS
sample_idx
=
[
int
(
i
*
step
)
for
i
in
range
(
CHART_MAX_POINTS
)]
chart_data
=
[
{
'index'
:
seq_len
+
i
,
'time'
:
times
[
seq_len
+
i
]
if
(
seq_len
+
i
)
<
len
(
times
)
else
str
(
seq_len
+
i
),
'actual'
:
round
(
float
(
actuals_real
[
i
]),
4
),
'predicted'
:
round
(
float
(
preds_real
[
i
]),
4
),
}
for
i
in
sample_idx
]
return
{
'total_count'
:
total
,
'mae'
:
round
(
mae
,
6
),
'rmse'
:
round
(
rmse
,
6
),
'chart_data'
:
chart_data
,
}
backend/app/ml/lstm_trainer.py
0 → 100644
View file @
4d970f15
"""LSTM temperature forecasting trainer.
Uses PyTorch if available. If not installed, raises a descriptive RuntimeError
so the train service can mark the task as failed with a helpful message.
"""
from
__future__
import
annotations
import
threading
from
pathlib
import
Path
from
typing
import
Callable
import
numpy
as
np
# ── optional torch import ────────────────────────────────────────────────────
_TORCH_AVAILABLE
=
False
try
:
import
torch
import
torch.nn
as
nn
from
torch.utils.data
import
DataLoader
,
TensorDataset
_TORCH_AVAILABLE
=
True
class
_LSTMModel
(
nn
.
Module
):
def
__init__
(
self
,
input_size
:
int
,
hidden_size
:
int
,
num_layers
:
int
)
->
None
:
super
()
.
__init__
()
dropout
=
0.2
if
num_layers
>
1
else
0.0
self
.
lstm
=
nn
.
LSTM
(
input_size
,
hidden_size
,
num_layers
,
batch_first
=
True
,
dropout
=
dropout
,
)
self
.
fc
=
nn
.
Linear
(
hidden_size
,
1
)
def
forward
(
self
,
x
:
'torch.Tensor'
)
->
'torch.Tensor'
:
out
,
_
=
self
.
lstm
(
x
)
return
self
.
fc
(
out
[:,
-
1
,
:])
.
squeeze
(
-
1
)
except
ImportError
:
pass
# _TORCH_AVAILABLE stays False
# ── constants ────────────────────────────────────────────────────────────────
FEATURE_COLS
=
[
'current'
,
'voltage'
,
'set_temperature'
,
'actual_temperature'
]
TARGET_COL
=
'actual_temperature'
TARGET_IDX
=
FEATURE_COLS
.
index
(
TARGET_COL
)
def
_check_torch
()
->
None
:
if
not
_TORCH_AVAILABLE
:
raise
RuntimeError
(
'PyTorch 未安装,请执行:
\n
'
' pip install torch --index-url https://download.pytorch.org/whl/cpu
\n
'
'安装后重启后端服务。'
)
# ── data helpers ─────────────────────────────────────────────────────────────
def
_extract_features
(
records
:
list
[
dict
])
->
np
.
ndarray
:
"""Convert records list → (N, 4) float32 array; rows with parse errors skipped."""
rows
:
list
[
list
[
float
]]
=
[]
for
r
in
records
:
try
:
row
=
[
float
(
r
.
get
(
col
)
or
0
)
for
col
in
FEATURE_COLS
]
if
any
(
v
!=
v
for
v
in
row
):
# NaN guard
continue
rows
.
append
(
row
)
except
(
TypeError
,
ValueError
):
continue
return
np
.
array
(
rows
,
dtype
=
np
.
float32
)
def
_make_sequences
(
data
:
np
.
ndarray
,
seq_len
:
int
)
->
tuple
[
np
.
ndarray
,
np
.
ndarray
]:
"""Return (X, y): X.shape=(N, seq_len, features), y.shape=(N,)."""
xs
:
list
[
np
.
ndarray
]
=
[]
ys
:
list
[
float
]
=
[]
for
i
in
range
(
len
(
data
)
-
seq_len
):
xs
.
append
(
data
[
i
:
i
+
seq_len
])
ys
.
append
(
data
[
i
+
seq_len
,
TARGET_IDX
])
return
np
.
array
(
xs
,
dtype
=
np
.
float32
),
np
.
array
(
ys
,
dtype
=
np
.
float32
)
# ── public training entry point ───────────────────────────────────────────────
def
train_lstm
(
records
:
list
[
dict
],
params
:
dict
,
save_path
:
Path
,
on_progress
:
Callable
[[
int
,
float
,
float
|
None
],
None
],
cancel_event
:
threading
.
Event
,
)
->
dict
[
str
,
float
|
None
]:
"""
Train an LSTM model on *records* and persist it to *save_path*.
Args:
records: list of dicts with keys in FEATURE_COLS.
params: hyper-parameter dict (seq_len, hidden_size, num_layers,
epochs, batch_size, learning_rate, train_ratio).
save_path: destination .pt file.
on_progress: callback(pct, train_loss, val_loss) called after each epoch.
cancel_event: when set, training stops with InterruptedError.
Returns:
{'train_loss': float, 'val_loss': float|None}
"""
_check_torch
()
seq_len
=
max
(
1
,
int
(
params
.
get
(
'seq_len'
,
20
)))
hidden_size
=
max
(
1
,
int
(
params
.
get
(
'hidden_size'
,
64
)))
num_layers
=
max
(
1
,
int
(
params
.
get
(
'num_layers'
,
2
)))
epochs
=
max
(
1
,
int
(
params
.
get
(
'epochs'
,
50
)))
batch_size
=
max
(
1
,
int
(
params
.
get
(
'batch_size'
,
32
)))
lr
=
float
(
params
.
get
(
'learning_rate'
,
0.001
))
train_ratio
=
min
(
0.99
,
max
(
0.5
,
float
(
params
.
get
(
'train_ratio'
,
0.8
))))
# ── data preparation ────────────────────────────────────────────────────
data
=
_extract_features
(
records
)
min_required
=
seq_len
+
10
if
len
(
data
)
<
min_required
:
raise
ValueError
(
f
'有效数据量不足:需至少 {min_required} 条,当前仅 {len(data)} 条。'
'请检查数据包内容或减小序列长度。'
)
# min-max normalisation per feature
data_min
=
data
.
min
(
axis
=
0
)
data_max
=
data
.
max
(
axis
=
0
)
data_range
=
data_max
-
data_min
data_range
[
data_range
==
0
]
=
1.0
data_norm
=
(
data
-
data_min
)
/
data_range
X
,
y
=
_make_sequences
(
data_norm
,
seq_len
)
n_train
=
max
(
1
,
int
(
len
(
X
)
*
train_ratio
))
X_train
,
y_train
=
X
[:
n_train
],
y
[:
n_train
]
X_val
,
y_val
=
X
[
n_train
:],
y
[
n_train
:]
device
=
torch
.
device
(
'cpu'
)
X_train_t
=
torch
.
tensor
(
X_train
)
.
to
(
device
)
y_train_t
=
torch
.
tensor
(
y_train
)
.
to
(
device
)
has_val
=
len
(
X_val
)
>
0
if
has_val
:
X_val_t
=
torch
.
tensor
(
X_val
)
.
to
(
device
)
y_val_t
=
torch
.
tensor
(
y_val
)
.
to
(
device
)
train_loader
=
DataLoader
(
TensorDataset
(
X_train_t
,
y_train_t
),
batch_size
=
batch_size
,
shuffle
=
True
,
)
# ── model ────────────────────────────────────────────────────────────────
input_size
=
len
(
FEATURE_COLS
)
model
=
_LSTMModel
(
input_size
,
hidden_size
,
num_layers
)
.
to
(
device
)
optimizer
=
torch
.
optim
.
Adam
(
model
.
parameters
(),
lr
=
lr
)
criterion
=
nn
.
MSELoss
()
train_loss
=
0.0
val_loss
:
float
|
None
=
None
for
epoch
in
range
(
epochs
):
if
cancel_event
.
is_set
():
raise
InterruptedError
(
'训练已取消'
)
# ── train step ───────────────────────────────────────────────────────
model
.
train
()
epoch_loss
=
0.0
for
xb
,
yb
in
train_loader
:
optimizer
.
zero_grad
()
pred
=
model
(
xb
)
loss
=
criterion
(
pred
,
yb
)
loss
.
backward
()
optimizer
.
step
()
epoch_loss
+=
loss
.
item
()
*
len
(
xb
)
train_loss
=
epoch_loss
/
len
(
X_train
)
# ── val step ─────────────────────────────────────────────────────────
if
has_val
:
model
.
eval
()
with
torch
.
no_grad
():
val_pred
=
model
(
X_val_t
)
val_loss
=
criterion
(
val_pred
,
y_val_t
)
.
item
()
pct
=
int
((
epoch
+
1
)
/
epochs
*
100
)
on_progress
(
pct
,
train_loss
,
val_loss
)
# ── persist ──────────────────────────────────────────────────────────────
save_path
.
parent
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
torch
.
save
(
{
'model_state'
:
model
.
state_dict
(),
'params'
:
params
,
'data_min'
:
data_min
.
tolist
(),
'data_max'
:
data_max
.
tolist
(),
'feature_cols'
:
FEATURE_COLS
,
'target_col'
:
TARGET_COL
,
'input_size'
:
input_size
,
'hidden_size'
:
hidden_size
,
'num_layers'
:
num_layers
,
'seq_len'
:
seq_len
,
},
save_path
,
)
return
{
'train_loss'
:
round
(
float
(
train_loss
),
6
),
'val_loss'
:
round
(
float
(
val_loss
),
6
)
if
val_loss
is
not
None
else
None
,
}
backend/app/ml/models/__init__.py
deleted
100644 → 0
View file @
b599ac8f
backend/app/ml/train_utils.py
deleted
100644 → 0
View file @
b599ac8f
backend/app/models/__init__.py
View file @
4d970f15
from
app.models.data_management
import
Category
,
DataFile
from
app.models.data_management
import
Category
,
DataFile
,
DataPackage
,
DataPackageFile
from
app.models.eval_management
import
EvalRecord
from
app.models.train_management
import
SavedModel
,
TrainTask
__all__
=
[
'Category'
,
'DataFile'
]
__all__
=
[
'Category'
,
'DataFile'
,
'DataPackage'
,
'DataPackageFile'
,
'TrainTask'
,
'SavedModel'
,
'EvalRecord'
]
backend/app/models/data_management.py
View file @
4d970f15
...
...
@@ -44,3 +44,37 @@ class DataFile(Base):
data_count
:
Mapped
[
int
]
=
mapped_column
(
Integer
,
nullable
=
False
,
server_default
=
text
(
'0'
),
comment
=
'数据条数'
)
uploaded_at
:
Mapped
[
str
]
=
mapped_column
(
TIMESTAMP
,
nullable
=
False
,
server_default
=
text
(
'CURRENT_TIMESTAMP'
))
remark
:
Mapped
[
str
|
None
]
=
mapped_column
(
Text
,
nullable
=
True
)
class
DataPackage
(
Base
):
__tablename__
=
'data_packages'
__table_args__
=
(
Index
(
'idx_pkg_category'
,
'category_id'
),
{
'mysql_comment'
:
'数据包表'
},
)
id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
primary_key
=
True
,
autoincrement
=
True
)
name
:
Mapped
[
str
]
=
mapped_column
(
String
(
255
),
nullable
=
False
,
comment
=
'数据包名称'
)
category_id
:
Mapped
[
int
|
None
]
=
mapped_column
(
BIGINT
,
nullable
=
True
,
comment
=
'分类ID(data_package类型)'
)
remark
:
Mapped
[
str
|
None
]
=
mapped_column
(
Text
,
nullable
=
True
,
comment
=
'备注'
)
data_count
:
Mapped
[
int
]
=
mapped_column
(
Integer
,
nullable
=
False
,
server_default
=
text
(
'0'
),
comment
=
'数据条数'
)
created_at
:
Mapped
[
str
]
=
mapped_column
(
TIMESTAMP
,
nullable
=
False
,
server_default
=
text
(
'CURRENT_TIMESTAMP'
))
updated_at
:
Mapped
[
str
]
=
mapped_column
(
TIMESTAMP
,
nullable
=
False
,
server_default
=
text
(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
),
)
class
DataPackageFile
(
Base
):
__tablename__
=
'data_package_files'
__table_args__
=
(
Index
(
'idx_dpf_package'
,
'package_id'
),
Index
(
'idx_dpf_file'
,
'file_id'
),
{
'mysql_comment'
:
'数据包文件关联表'
},
)
id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
primary_key
=
True
,
autoincrement
=
True
)
package_id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
nullable
=
False
,
comment
=
'数据包ID'
)
file_id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
nullable
=
False
,
comment
=
'数据文件ID'
)
sort_order
:
Mapped
[
int
]
=
mapped_column
(
Integer
,
nullable
=
False
,
server_default
=
text
(
'0'
),
comment
=
'排序'
)
backend/app/models/eval_management.py
0 → 100644
View file @
4d970f15
from
sqlalchemy
import
BIGINT
,
FLOAT
,
TIMESTAMP
,
Index
,
Integer
,
JSON
,
String
,
text
from
sqlalchemy.orm
import
Mapped
,
mapped_column
from
app.database
import
Base
class
EvalRecord
(
Base
):
__tablename__
=
'eval_records'
__table_args__
=
(
Index
(
'idx_eval_model'
,
'model_id'
),
Index
(
'idx_eval_package'
,
'package_id'
),
{
'mysql_comment'
:
'模型评估记录表'
},
)
id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
primary_key
=
True
,
autoincrement
=
True
)
model_id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
nullable
=
False
,
comment
=
'已保存模型ID'
)
model_name
:
Mapped
[
str
]
=
mapped_column
(
String
(
255
),
nullable
=
False
,
comment
=
'模型名称'
)
package_id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
nullable
=
False
,
comment
=
'数据包ID'
)
package_name
:
Mapped
[
str
]
=
mapped_column
(
String
(
255
),
nullable
=
False
,
comment
=
'数据包名称'
)
total_count
:
Mapped
[
int
]
=
mapped_column
(
Integer
,
nullable
=
False
,
server_default
=
text
(
'0'
),
comment
=
'评估数据点总数'
)
mae
:
Mapped
[
float
|
None
]
=
mapped_column
(
FLOAT
,
nullable
=
True
,
comment
=
'平均绝对误差'
)
rmse
:
Mapped
[
float
|
None
]
=
mapped_column
(
FLOAT
,
nullable
=
True
,
comment
=
'均方根误差'
)
# Store up to ~2000 sampled points for chart rendering
chart_data
:
Mapped
[
list
|
None
]
=
mapped_column
(
JSON
,
nullable
=
True
,
comment
=
'图表数据(采样)'
)
created_at
:
Mapped
[
str
]
=
mapped_column
(
TIMESTAMP
,
nullable
=
False
,
server_default
=
text
(
'CURRENT_TIMESTAMP'
))
backend/app/models/train_management.py
0 → 100644
View file @
4d970f15
from
sqlalchemy
import
BIGINT
,
FLOAT
,
TIMESTAMP
,
Enum
,
Index
,
Integer
,
JSON
,
String
,
Text
,
text
from
sqlalchemy.orm
import
Mapped
,
mapped_column
from
app.database
import
Base
class
TrainTask
(
Base
):
__tablename__
=
'train_tasks'
__table_args__
=
(
Index
(
'idx_task_status'
,
'status'
),
{
'mysql_comment'
:
'模型训练任务表'
},
)
id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
primary_key
=
True
,
autoincrement
=
True
)
model_name
:
Mapped
[
str
]
=
mapped_column
(
String
(
255
),
nullable
=
False
,
comment
=
'模型名称'
)
package_id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
nullable
=
False
,
comment
=
'数据包ID'
)
package_name
:
Mapped
[
str
]
=
mapped_column
(
String
(
255
),
nullable
=
False
,
comment
=
'数据包名称'
)
params
:
Mapped
[
dict
]
=
mapped_column
(
JSON
,
nullable
=
False
,
comment
=
'LSTM超参数'
)
status
:
Mapped
[
str
]
=
mapped_column
(
Enum
(
'pending'
,
'running'
,
'completed'
,
'failed'
,
'cancelled'
,
name
=
'train_status_enum'
),
nullable
=
False
,
server_default
=
text
(
"'pending'"
),
comment
=
'训练状态'
,
)
progress
:
Mapped
[
int
]
=
mapped_column
(
Integer
,
nullable
=
False
,
server_default
=
text
(
'0'
),
comment
=
'进度 0-100'
)
train_loss
:
Mapped
[
float
|
None
]
=
mapped_column
(
FLOAT
,
nullable
=
True
,
comment
=
'训练损失'
)
val_loss
:
Mapped
[
float
|
None
]
=
mapped_column
(
FLOAT
,
nullable
=
True
,
comment
=
'验证损失'
)
error_msg
:
Mapped
[
str
|
None
]
=
mapped_column
(
Text
,
nullable
=
True
,
comment
=
'错误信息'
)
is_saved
:
Mapped
[
int
]
=
mapped_column
(
Integer
,
nullable
=
False
,
server_default
=
text
(
'0'
),
comment
=
'是否已保存为模型'
)
created_at
:
Mapped
[
str
]
=
mapped_column
(
TIMESTAMP
,
nullable
=
False
,
server_default
=
text
(
'CURRENT_TIMESTAMP'
))
updated_at
:
Mapped
[
str
]
=
mapped_column
(
TIMESTAMP
,
nullable
=
False
,
server_default
=
text
(
'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
),
)
class
SavedModel
(
Base
):
__tablename__
=
'saved_models'
__table_args__
=
(
Index
(
'idx_saved_task'
,
'task_id'
),
{
'mysql_comment'
:
'已保存模型表'
},
)
id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
primary_key
=
True
,
autoincrement
=
True
)
task_id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
nullable
=
False
,
comment
=
'来源训练任务ID'
)
model_name
:
Mapped
[
str
]
=
mapped_column
(
String
(
255
),
nullable
=
False
,
comment
=
'模型名称'
)
package_id
:
Mapped
[
int
]
=
mapped_column
(
BIGINT
,
nullable
=
False
,
comment
=
'数据包ID'
)
package_name
:
Mapped
[
str
]
=
mapped_column
(
String
(
255
),
nullable
=
False
,
comment
=
'数据包名称'
)
params
:
Mapped
[
dict
]
=
mapped_column
(
JSON
,
nullable
=
False
,
comment
=
'LSTM超参数'
)
file_path
:
Mapped
[
str
]
=
mapped_column
(
String
(
500
),
nullable
=
False
,
comment
=
'模型文件路径'
)
train_loss
:
Mapped
[
float
|
None
]
=
mapped_column
(
FLOAT
,
nullable
=
True
)
val_loss
:
Mapped
[
float
|
None
]
=
mapped_column
(
FLOAT
,
nullable
=
True
)
created_at
:
Mapped
[
str
]
=
mapped_column
(
TIMESTAMP
,
nullable
=
False
,
server_default
=
text
(
'CURRENT_TIMESTAMP'
))
backend/app/services/eval_service.py
0 → 100644
View file @
4d970f15
from
__future__
import
annotations
from
pathlib
import
Path
from
typing
import
Any
from
app.database
import
db_session
from
app.ml.lstm_predictor
import
predict_lstm
from
app.models
import
DataFile
,
DataPackage
,
DataPackageFile
from
app.models.eval_management
import
EvalRecord
from
app.models.train_management
import
SavedModel
from
app.services.data_management_service
import
DataManagementService
class
EvalService
:
def
__init__
(
self
)
->
None
:
self
.
_dm
=
DataManagementService
()
# ── dropdown data ─────────────────────────────────────────────────────────
def
list_packages
(
self
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
rows
=
session
.
query
(
DataPackage
)
.
order_by
(
DataPackage
.
created_at
.
desc
())
.
all
()
return
[{
'id'
:
p
.
id
,
'name'
:
p
.
name
,
'data_count'
:
p
.
data_count
}
for
p
in
rows
]
def
list_saved_models
(
self
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
rows
=
session
.
query
(
SavedModel
)
.
order_by
(
SavedModel
.
created_at
.
desc
())
.
all
()
return
[{
'id'
:
m
.
id
,
'model_name'
:
m
.
model_name
,
'package_name'
:
m
.
package_name
}
for
m
in
rows
]
# ── evaluate ──────────────────────────────────────────────────────────────
def
evaluate
(
self
,
model_id
:
int
,
package_id
:
int
)
->
dict
[
str
,
Any
]:
with
db_session
()
as
session
:
model
=
session
.
query
(
SavedModel
)
.
filter
(
SavedModel
.
id
==
model_id
)
.
first
()
if
not
model
:
raise
ValueError
(
'模型不存在'
)
pkg
=
session
.
query
(
DataPackage
)
.
filter
(
DataPackage
.
id
==
package_id
)
.
first
()
if
not
pkg
:
raise
ValueError
(
'数据包不存在'
)
model_path
=
Path
(
model
.
file_path
)
model_name
=
model
.
model_name
package_name
=
pkg
.
name
if
not
model_path
.
exists
():
raise
ValueError
(
'模型文件不存在,请重新训练并保存模型'
)
records
=
self
.
_load_package_records
(
package_id
)
if
not
records
:
raise
ValueError
(
'数据包中没有有效数据'
)
result
=
predict_lstm
(
records
=
records
,
model_path
=
model_path
)
with
db_session
()
as
session
:
record
=
EvalRecord
(
model_id
=
model_id
,
model_name
=
model_name
,
package_id
=
package_id
,
package_name
=
package_name
,
total_count
=
result
[
'total_count'
],
mae
=
result
[
'mae'
],
rmse
=
result
[
'rmse'
],
chart_data
=
result
[
'chart_data'
],
)
session
.
add
(
record
)
session
.
commit
()
session
.
refresh
(
record
)
return
self
.
_record_to_dict
(
record
)
# ── list / get ────────────────────────────────────────────────────────────
def
list_records
(
self
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
rows
=
(
session
.
query
(
EvalRecord
)
.
order_by
(
EvalRecord
.
created_at
.
desc
())
.
all
()
)
return
[
self
.
_record_to_dict
(
r
,
include_chart
=
False
)
for
r
in
rows
]
def
get_record
(
self
,
record_id
:
int
)
->
dict
[
str
,
Any
]:
with
db_session
()
as
session
:
row
=
session
.
query
(
EvalRecord
)
.
filter
(
EvalRecord
.
id
==
record_id
)
.
first
()
if
not
row
:
raise
ValueError
(
'评估记录不存在'
)
return
self
.
_record_to_dict
(
row
,
include_chart
=
True
)
def
delete_record
(
self
,
record_id
:
int
)
->
None
:
with
db_session
()
as
session
:
row
=
session
.
query
(
EvalRecord
)
.
filter
(
EvalRecord
.
id
==
record_id
)
.
first
()
if
not
row
:
raise
ValueError
(
'评估记录不存在'
)
session
.
delete
(
row
)
session
.
commit
()
# ── helpers ───────────────────────────────────────────────────────────────
def
_load_package_records
(
self
,
package_id
:
int
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
pf_rows
=
(
session
.
query
(
DataPackageFile
)
.
filter
(
DataPackageFile
.
package_id
==
package_id
)
.
order_by
(
DataPackageFile
.
sort_order
.
asc
())
.
all
()
)
file_ids
=
[
pf
.
file_id
for
pf
in
pf_rows
]
if
not
file_ids
:
return
[]
files
=
session
.
query
(
DataFile
)
.
filter
(
DataFile
.
id
.
in_
(
file_ids
))
.
all
()
file_map
=
{
f
.
id
:
f
for
f
in
files
}
all_records
:
list
[
dict
[
str
,
Any
]]
=
[]
for
fid
in
file_ids
:
if
fid
not
in
file_map
:
continue
fmeta
=
file_map
[
fid
]
path
=
self
.
_dm
.
_resolve_local_file_path
(
fmeta
.
file_path
,
fmeta
.
stored_name
)
recs
,
_
=
self
.
_dm
.
_read_records
(
path
,
limit
=
None
)
all_records
.
extend
(
recs
)
return
all_records
@
staticmethod
def
_record_to_dict
(
row
:
EvalRecord
,
include_chart
:
bool
=
True
)
->
dict
[
str
,
Any
]:
d
:
dict
[
str
,
Any
]
=
{
'id'
:
row
.
id
,
'model_id'
:
row
.
model_id
,
'model_name'
:
row
.
model_name
,
'package_id'
:
row
.
package_id
,
'package_name'
:
row
.
package_name
,
'total_count'
:
row
.
total_count
,
'mae'
:
row
.
mae
,
'rmse'
:
row
.
rmse
,
'created_at'
:
row
.
created_at
.
strftime
(
'
%
Y-
%
m-
%
d
%
H:
%
M:
%
S'
)
if
row
.
created_at
else
''
,
}
if
include_chart
:
d
[
'chart_data'
]
=
row
.
chart_data
or
[]
return
d
backend/app/services/package_management_service.py
0 → 100644
View file @
4d970f15
from
typing
import
Any
from
sqlalchemy
import
func
from
app.database
import
db_session
from
app.models
import
Category
,
DataFile
,
DataPackage
,
DataPackageFile
from
app.services.data_management_service
import
DataManagementService
class
PackageManagementService
:
def
__init__
(
self
)
->
None
:
self
.
_dm
=
DataManagementService
()
# ── categories ──────────────────────────────────────────────────────────
def
get_category_tree
(
self
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
rows
=
(
session
.
query
(
Category
)
.
filter
(
Category
.
type
==
'data_package'
)
.
filter
(
Category
.
parent_id
.
is_
(
None
))
.
order_by
(
Category
.
sort_order
.
asc
(),
Category
.
id
.
asc
())
.
all
()
)
return
[
{
'id'
:
item
.
id
,
'name'
:
item
.
name
,
'parent_id'
:
None
,
'children'
:
[],
}
for
item
in
rows
]
def
create_category
(
self
,
name
:
str
,
parent_id
:
str
=
'all'
)
->
dict
[
str
,
Any
]:
if
not
name
:
raise
ValueError
(
'分类名称不能为空'
)
if
str
(
parent_id
)
.
strip
()
.
lower
()
not
in
{
''
,
'all'
,
'none'
,
'null'
}:
raise
ValueError
(
'当前仅支持一级分类'
)
with
db_session
()
as
session
:
category
=
Category
(
name
=
name
,
type
=
'data_package'
,
parent_id
=
None
,
sort_order
=
0
,
)
session
.
add
(
category
)
session
.
commit
()
session
.
refresh
(
category
)
return
{
'id'
:
category
.
id
,
'name'
:
category
.
name
,
'parent_id'
:
None
,
'type'
:
category
.
type
,
'sort_order'
:
category
.
sort_order
,
}
def
update_category
(
self
,
category_id
:
str
,
name
:
str
)
->
dict
[
str
,
Any
]:
if
category_id
in
{
''
,
'all'
}:
raise
ValueError
(
'分类ID不能为空'
)
if
not
name
:
raise
ValueError
(
'分类名称不能为空'
)
db_id
=
self
.
_parse_int_id
(
category_id
,
'分类ID'
)
with
db_session
()
as
session
:
category
=
(
session
.
query
(
Category
)
.
filter
(
Category
.
id
==
db_id
,
Category
.
type
==
'data_package'
)
.
first
()
)
if
not
category
:
raise
ValueError
(
'分类不存在'
)
category
.
name
=
name
session
.
commit
()
session
.
refresh
(
category
)
return
{
'id'
:
category
.
id
,
'name'
:
category
.
name
,
'parent_id'
:
None
,
'type'
:
category
.
type
,
'sort_order'
:
category
.
sort_order
,
}
def
delete_category
(
self
,
category_id
:
str
)
->
None
:
if
category_id
in
{
''
,
'all'
}:
raise
ValueError
(
'分类ID不能为空'
)
db_id
=
self
.
_parse_int_id
(
category_id
,
'分类ID'
)
with
db_session
()
as
session
:
category
=
(
session
.
query
(
Category
)
.
filter
(
Category
.
id
==
db_id
,
Category
.
type
==
'data_package'
)
.
first
()
)
if
not
category
:
raise
ValueError
(
'分类不存在'
)
pkg_count
=
(
session
.
query
(
func
.
count
(
DataPackage
.
id
))
.
filter
(
DataPackage
.
category_id
==
db_id
)
.
scalar
()
)
if
pkg_count
and
pkg_count
>
0
:
raise
ValueError
(
'该分类下仍有数据包,无法删除'
)
session
.
delete
(
category
)
session
.
commit
()
# ── data files (for selection) ───────────────────────────────────────────
def
list_all_data_files
(
self
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
rows
=
(
session
.
query
(
DataFile
,
Category
)
.
outerjoin
(
Category
,
DataFile
.
category_id
==
Category
.
id
)
.
order_by
(
Category
.
name
.
asc
(),
DataFile
.
uploaded_at
.
desc
(),
DataFile
.
id
.
desc
())
.
all
()
)
return
[
{
'id'
:
f
.
id
,
'filename'
:
f
.
filename
,
'category_id'
:
f
.
category_id
,
'category_name'
:
c
.
name
if
c
else
''
,
'data_count'
:
f
.
data_count
,
'uploaded_at'
:
f
.
uploaded_at
.
strftime
(
'
%
Y-
%
m-
%
d
%
H:
%
M:
%
S'
)
if
f
.
uploaded_at
else
''
,
}
for
f
,
c
in
rows
]
# ── packages ─────────────────────────────────────────────────────────────
def
list_packages
(
self
,
category_id
:
str
=
''
,
name
:
str
=
''
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
query
=
session
.
query
(
DataPackage
)
if
category_id
not
in
(
''
,
'all'
,
None
):
db_id
=
self
.
_parse_int_id
(
category_id
,
'分类ID'
)
query
=
query
.
filter
(
DataPackage
.
category_id
==
db_id
)
if
name
:
query
=
query
.
filter
(
DataPackage
.
name
.
like
(
f
'
%
{name}
%
'
))
rows
=
query
.
order_by
(
DataPackage
.
created_at
.
desc
(),
DataPackage
.
id
.
desc
())
.
all
()
return
[
self
.
_pkg_to_dict
(
item
)
for
item
in
rows
]
def
create_package
(
self
,
name
:
str
,
category_id
:
str
|
None
,
remark
:
str
|
None
,
file_ids
:
list
[
int
],
)
->
dict
[
str
,
Any
]:
if
not
name
:
raise
ValueError
(
'数据包名称不能为空'
)
if
not
file_ids
:
raise
ValueError
(
'请至少选择一个数据文件'
)
cat_db_id
:
int
|
None
=
None
if
category_id
and
str
(
category_id
)
.
strip
()
.
lower
()
not
in
{
''
,
'all'
,
'none'
,
'null'
}:
cat_db_id
=
self
.
_parse_int_id
(
str
(
category_id
),
'分类ID'
)
with
db_session
()
as
session
:
if
cat_db_id
is
not
None
:
cat
=
(
session
.
query
(
Category
)
.
filter
(
Category
.
id
==
cat_db_id
,
Category
.
type
==
'data_package'
)
.
first
()
)
if
not
cat
:
raise
ValueError
(
'数据包分类不存在'
)
files
=
session
.
query
(
DataFile
)
.
filter
(
DataFile
.
id
.
in_
(
file_ids
))
.
all
()
if
len
(
files
)
!=
len
(
file_ids
):
raise
ValueError
(
'部分数据文件不存在'
)
total_count
=
sum
(
f
.
data_count
for
f
in
files
)
pkg
=
DataPackage
(
name
=
name
,
category_id
=
cat_db_id
,
remark
=
remark
,
data_count
=
total_count
,
)
session
.
add
(
pkg
)
session
.
flush
()
for
idx
,
fid
in
enumerate
(
file_ids
):
pf
=
DataPackageFile
(
package_id
=
pkg
.
id
,
file_id
=
fid
,
sort_order
=
idx
)
session
.
add
(
pf
)
session
.
commit
()
session
.
refresh
(
pkg
)
return
self
.
_pkg_to_dict
(
pkg
)
def
delete_package
(
self
,
package_id
:
str
)
->
None
:
db_id
=
self
.
_parse_int_id
(
package_id
,
'数据包ID'
)
with
db_session
()
as
session
:
pkg
=
session
.
query
(
DataPackage
)
.
filter
(
DataPackage
.
id
==
db_id
)
.
first
()
if
not
pkg
:
raise
ValueError
(
'数据包不存在'
)
session
.
query
(
DataPackageFile
)
.
filter
(
DataPackageFile
.
package_id
==
db_id
)
.
delete
()
session
.
delete
(
pkg
)
session
.
commit
()
def
get_package_records
(
self
,
package_id
:
str
,
limit
:
int
=
500
)
->
dict
[
str
,
Any
]:
db_id
=
self
.
_parse_int_id
(
package_id
,
'数据包ID'
)
with
db_session
()
as
session
:
pkg
=
session
.
query
(
DataPackage
)
.
filter
(
DataPackage
.
id
==
db_id
)
.
first
()
if
not
pkg
:
raise
ValueError
(
'数据包不存在'
)
pf_rows
=
(
session
.
query
(
DataPackageFile
)
.
filter
(
DataPackageFile
.
package_id
==
db_id
)
.
order_by
(
DataPackageFile
.
sort_order
.
asc
())
.
all
()
)
file_ids
=
[
pf
.
file_id
for
pf
in
pf_rows
]
files
=
session
.
query
(
DataFile
)
.
filter
(
DataFile
.
id
.
in_
(
file_ids
))
.
all
()
file_map
=
{
f
.
id
:
f
for
f
in
files
}
return
self
.
_merge_records
(
file_ids
,
file_map
,
limit
)
def
preview_records
(
self
,
file_ids
:
list
[
int
],
limit
:
int
=
300
)
->
dict
[
str
,
Any
]:
if
not
file_ids
:
return
{
'records'
:
[],
'count'
:
0
}
with
db_session
()
as
session
:
files
=
session
.
query
(
DataFile
)
.
filter
(
DataFile
.
id
.
in_
(
file_ids
))
.
all
()
file_map
=
{
f
.
id
:
f
for
f
in
files
}
return
self
.
_merge_records
(
file_ids
,
file_map
,
limit
)
# ── helpers ──────────────────────────────────────────────────────────────
def
_merge_records
(
self
,
file_ids
:
list
[
int
],
file_map
:
dict
[
int
,
Any
],
limit
:
int
,
)
->
dict
[
str
,
Any
]:
all_records
:
list
[
dict
[
str
,
Any
]]
=
[]
total_count
=
0
remaining
=
limit
for
fid
in
file_ids
:
if
fid
not
in
file_map
:
continue
file_meta
=
file_map
[
fid
]
path
=
self
.
_dm
.
_resolve_local_file_path
(
file_meta
.
file_path
,
file_meta
.
stored_name
)
records
,
count
=
self
.
_dm
.
_read_records
(
path
,
limit
=
remaining
if
remaining
>
0
else
0
)
total_count
+=
count
all_records
.
extend
(
records
)
remaining
-=
len
(
records
)
if
remaining
<=
0
:
break
return
{
'records'
:
all_records
,
'count'
:
total_count
}
def
_pkg_to_dict
(
self
,
pkg
:
DataPackage
)
->
dict
[
str
,
Any
]:
return
{
'id'
:
pkg
.
id
,
'name'
:
pkg
.
name
,
'category_id'
:
pkg
.
category_id
,
'remark'
:
pkg
.
remark
,
'data_count'
:
pkg
.
data_count
,
'created_at'
:
pkg
.
created_at
.
strftime
(
'
%
Y-
%
m-
%
d
%
H:
%
M:
%
S'
)
if
pkg
.
created_at
else
''
,
'updated_at'
:
pkg
.
updated_at
.
strftime
(
'
%
Y-
%
m-
%
d
%
H:
%
M:
%
S'
)
if
pkg
.
updated_at
else
''
,
}
@
staticmethod
def
_parse_int_id
(
value
:
str
,
label
:
str
=
'ID'
)
->
int
:
try
:
return
int
(
value
)
except
(
ValueError
,
TypeError
)
as
exc
:
raise
ValueError
(
f
'{label}格式错误'
)
from
exc
backend/app/services/train_service.py
0 → 100644
View file @
4d970f15
"""Training task management service.
Background training runs in a daemon thread. A threading.Event per task_id
provides cancellation support. Progress is persisted to DB at most every 2
%
.
"""
from
__future__
import
annotations
import
threading
from
pathlib
import
Path
from
typing
import
Any
from
app.database
import
db_session
from
app.ml.lstm_trainer
import
train_lstm
from
app.models
import
DataFile
,
DataPackage
,
DataPackageFile
from
app.models.train_management
import
SavedModel
,
TrainTask
from
app.services.data_management_service
import
DataManagementService
# ── module-level cancel registry ─────────────────────────────────────────────
_cancel_events
:
dict
[
int
,
threading
.
Event
]
=
{}
_registry_lock
=
threading
.
Lock
()
class
TrainService
:
def
__init__
(
self
)
->
None
:
self
.
_dm
=
DataManagementService
()
self
.
_models_dir
=
Path
(
__file__
)
.
resolve
()
.
parents
[
2
]
/
'saved_models'
self
.
_models_dir
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
# ── packages (for dropdown) ──────────────────────────────────────────────
def
list_packages
(
self
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
rows
=
(
session
.
query
(
DataPackage
)
.
order_by
(
DataPackage
.
created_at
.
desc
())
.
all
()
)
return
[{
'id'
:
p
.
id
,
'name'
:
p
.
name
,
'data_count'
:
p
.
data_count
}
for
p
in
rows
]
# ── tasks ────────────────────────────────────────────────────────────────
def
list_tasks
(
self
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
tasks
=
session
.
query
(
TrainTask
)
.
order_by
(
TrainTask
.
created_at
.
desc
())
.
all
()
return
[
self
.
_task_to_dict
(
t
)
for
t
in
tasks
]
def
get_task
(
self
,
task_id
:
int
)
->
dict
[
str
,
Any
]:
with
db_session
()
as
session
:
task
=
session
.
query
(
TrainTask
)
.
filter
(
TrainTask
.
id
==
task_id
)
.
first
()
if
not
task
:
raise
ValueError
(
'训练任务不存在'
)
return
self
.
_task_to_dict
(
task
)
def
create_task
(
self
,
model_name
:
str
,
package_id
:
int
,
params
:
dict
)
->
dict
[
str
,
Any
]:
with
db_session
()
as
session
:
pkg
=
session
.
query
(
DataPackage
)
.
filter
(
DataPackage
.
id
==
package_id
)
.
first
()
if
not
pkg
:
raise
ValueError
(
'数据包不存在'
)
task
=
TrainTask
(
model_name
=
model_name
,
package_id
=
package_id
,
package_name
=
pkg
.
name
,
params
=
params
,
status
=
'pending'
,
progress
=
0
,
)
session
.
add
(
task
)
session
.
commit
()
session
.
refresh
(
task
)
task_dict
=
self
.
_task_to_dict
(
task
)
self
.
_launch_thread
(
task_dict
[
'id'
],
package_id
,
params
)
return
task_dict
def
cancel_task
(
self
,
task_id
:
int
)
->
None
:
with
_registry_lock
:
event
=
_cancel_events
.
get
(
task_id
)
if
event
:
event
.
set
()
else
:
# Task may still be in 'pending' state (thread not started yet)
with
db_session
()
as
session
:
task
=
session
.
query
(
TrainTask
)
.
filter
(
TrainTask
.
id
==
task_id
)
.
first
()
if
task
and
task
.
status
in
(
'pending'
,
'running'
):
task
.
status
=
'cancelled'
session
.
commit
()
def
restart_task
(
self
,
task_id
:
int
)
->
dict
[
str
,
Any
]:
with
db_session
()
as
session
:
task
=
session
.
query
(
TrainTask
)
.
filter
(
TrainTask
.
id
==
task_id
)
.
first
()
if
not
task
:
raise
ValueError
(
'任务不存在'
)
if
task
.
status
in
(
'pending'
,
'running'
):
raise
ValueError
(
'任务仍在进行中,请先取消'
)
new_task
=
TrainTask
(
model_name
=
task
.
model_name
,
package_id
=
task
.
package_id
,
package_name
=
task
.
package_name
,
params
=
task
.
params
,
status
=
'pending'
,
progress
=
0
,
)
session
.
add
(
new_task
)
session
.
commit
()
session
.
refresh
(
new_task
)
task_dict
=
self
.
_task_to_dict
(
new_task
)
self
.
_launch_thread
(
task_dict
[
'id'
],
task_dict
[
'package_id'
],
task_dict
[
'params'
])
return
task_dict
def
delete_task
(
self
,
task_id
:
int
)
->
None
:
with
db_session
()
as
session
:
task
=
session
.
query
(
TrainTask
)
.
filter
(
TrainTask
.
id
==
task_id
)
.
first
()
if
not
task
:
raise
ValueError
(
'任务不存在'
)
if
task
.
status
in
(
'pending'
,
'running'
):
raise
ValueError
(
'请先取消正在进行的任务'
)
# Remove model file if exists (and model not saved to saved_models table)
model_path
=
self
.
_models_dir
/
f
'task_{task_id}.pt'
if
model_path
.
exists
()
and
not
task
.
is_saved
:
model_path
.
unlink
(
missing_ok
=
True
)
session
.
delete
(
task
)
session
.
commit
()
def
save_model
(
self
,
task_id
:
int
)
->
dict
[
str
,
Any
]:
with
db_session
()
as
session
:
task
=
session
.
query
(
TrainTask
)
.
filter
(
TrainTask
.
id
==
task_id
)
.
first
()
if
not
task
:
raise
ValueError
(
'任务不存在'
)
if
task
.
status
!=
'completed'
:
raise
ValueError
(
'只能保存已完成的训练任务'
)
if
task
.
is_saved
:
raise
ValueError
(
'模型已保存'
)
model_path
=
self
.
_models_dir
/
f
'task_{task_id}.pt'
if
not
model_path
.
exists
():
raise
ValueError
(
'模型文件不存在,可能已被清除,请重新训练'
)
saved
=
SavedModel
(
task_id
=
task_id
,
model_name
=
task
.
model_name
,
package_id
=
task
.
package_id
,
package_name
=
task
.
package_name
,
params
=
task
.
params
,
file_path
=
str
(
model_path
),
train_loss
=
task
.
train_loss
,
val_loss
=
task
.
val_loss
,
)
session
.
add
(
saved
)
task
.
is_saved
=
1
session
.
commit
()
session
.
refresh
(
saved
)
return
self
.
_saved_to_dict
(
saved
)
# ── saved models ─────────────────────────────────────────────────────────
def
list_saved_models
(
self
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
models
=
session
.
query
(
SavedModel
)
.
order_by
(
SavedModel
.
created_at
.
desc
())
.
all
()
return
[
self
.
_saved_to_dict
(
m
)
for
m
in
models
]
def
delete_saved_model
(
self
,
model_id
:
int
)
->
None
:
with
db_session
()
as
session
:
model
=
session
.
query
(
SavedModel
)
.
filter
(
SavedModel
.
id
==
model_id
)
.
first
()
if
not
model
:
raise
ValueError
(
'模型不存在'
)
# Reset is_saved on the originating task if it still exists
task
=
session
.
query
(
TrainTask
)
.
filter
(
TrainTask
.
id
==
model
.
task_id
)
.
first
()
if
task
:
task
.
is_saved
=
0
# Delete the .pt file only if nothing else references it
model_path
=
Path
(
model
.
file_path
)
if
model_path
.
exists
():
model_path
.
unlink
(
missing_ok
=
True
)
session
.
delete
(
model
)
session
.
commit
()
# ── private helpers ───────────────────────────────────────────────────────
def
_launch_thread
(
self
,
task_id
:
int
,
package_id
:
int
,
params
:
dict
)
->
None
:
cancel_event
=
threading
.
Event
()
with
_registry_lock
:
_cancel_events
[
task_id
]
=
cancel_event
thread
=
threading
.
Thread
(
target
=
self
.
_training_worker
,
args
=
(
task_id
,
package_id
,
params
,
cancel_event
),
daemon
=
True
,
name
=
f
'train-task-{task_id}'
,
)
thread
.
start
()
def
_training_worker
(
self
,
task_id
:
int
,
package_id
:
int
,
params
:
dict
,
cancel_event
:
threading
.
Event
,
)
->
None
:
try
:
self
.
_update_task
(
task_id
,
status
=
'running'
,
progress
=
0
)
records
=
self
.
_load_package_records
(
package_id
)
if
not
records
:
raise
ValueError
(
'数据包没有有效数据,请检查关联文件'
)
save_path
=
self
.
_models_dir
/
f
'task_{task_id}.pt'
last_pct
=
[
0
]
def
on_progress
(
pct
:
int
,
train_loss
:
float
,
val_loss
:
float
|
None
)
->
None
:
if
cancel_event
.
is_set
():
return
# Throttle: persist at most every 2 % to reduce DB writes
if
pct
-
last_pct
[
0
]
>=
2
or
pct
==
100
:
last_pct
[
0
]
=
pct
self
.
_update_task
(
task_id
,
progress
=
pct
,
train_loss
=
round
(
float
(
train_loss
),
6
),
val_loss
=
round
(
float
(
val_loss
),
6
)
if
val_loss
is
not
None
else
None
,
)
result
=
train_lstm
(
records
=
records
,
params
=
params
,
save_path
=
save_path
,
on_progress
=
on_progress
,
cancel_event
=
cancel_event
,
)
self
.
_update_task
(
task_id
,
status
=
'completed'
,
progress
=
100
,
train_loss
=
result
[
'train_loss'
],
val_loss
=
result
.
get
(
'val_loss'
),
)
except
InterruptedError
:
self
.
_update_task
(
task_id
,
status
=
'cancelled'
)
except
Exception
as
exc
:
self
.
_update_task
(
task_id
,
status
=
'failed'
,
error_msg
=
str
(
exc
)[:
2000
])
finally
:
with
_registry_lock
:
_cancel_events
.
pop
(
task_id
,
None
)
def
_update_task
(
self
,
task_id
:
int
,
**
kwargs
:
Any
)
->
None
:
with
db_session
()
as
session
:
task
=
session
.
query
(
TrainTask
)
.
filter
(
TrainTask
.
id
==
task_id
)
.
first
()
if
not
task
:
return
for
key
,
value
in
kwargs
.
items
():
setattr
(
task
,
key
,
value
)
session
.
commit
()
def
_load_package_records
(
self
,
package_id
:
int
)
->
list
[
dict
[
str
,
Any
]]:
with
db_session
()
as
session
:
pf_rows
=
(
session
.
query
(
DataPackageFile
)
.
filter
(
DataPackageFile
.
package_id
==
package_id
)
.
order_by
(
DataPackageFile
.
sort_order
.
asc
())
.
all
()
)
file_ids
=
[
pf
.
file_id
for
pf
in
pf_rows
]
if
not
file_ids
:
return
[]
files
=
session
.
query
(
DataFile
)
.
filter
(
DataFile
.
id
.
in_
(
file_ids
))
.
all
()
file_map
=
{
f
.
id
:
f
for
f
in
files
}
all_records
:
list
[
dict
[
str
,
Any
]]
=
[]
for
fid
in
file_ids
:
if
fid
not
in
file_map
:
continue
fmeta
=
file_map
[
fid
]
path
=
self
.
_dm
.
_resolve_local_file_path
(
fmeta
.
file_path
,
fmeta
.
stored_name
)
records
,
_
=
self
.
_dm
.
_read_records
(
path
,
limit
=
None
)
all_records
.
extend
(
records
)
return
all_records
@
staticmethod
def
_task_to_dict
(
task
:
TrainTask
)
->
dict
[
str
,
Any
]:
return
{
'id'
:
task
.
id
,
'model_name'
:
task
.
model_name
,
'package_id'
:
task
.
package_id
,
'package_name'
:
task
.
package_name
,
'params'
:
task
.
params
,
'status'
:
task
.
status
,
'progress'
:
task
.
progress
,
'train_loss'
:
task
.
train_loss
,
'val_loss'
:
task
.
val_loss
,
'error_msg'
:
task
.
error_msg
,
'is_saved'
:
bool
(
task
.
is_saved
),
'created_at'
:
task
.
created_at
.
strftime
(
'
%
Y-
%
m-
%
d
%
H:
%
M:
%
S'
)
if
task
.
created_at
else
''
,
}
@
staticmethod
def
_saved_to_dict
(
model
:
SavedModel
)
->
dict
[
str
,
Any
]:
return
{
'id'
:
model
.
id
,
'task_id'
:
model
.
task_id
,
'model_name'
:
model
.
model_name
,
'package_id'
:
model
.
package_id
,
'package_name'
:
model
.
package_name
,
'params'
:
model
.
params
,
'train_loss'
:
model
.
train_loss
,
'val_loss'
:
model
.
val_loss
,
'created_at'
:
model
.
created_at
.
strftime
(
'
%
Y-
%
m-
%
d
%
H:
%
M:
%
S'
)
if
model
.
created_at
else
''
,
}
backend/app/test/test.py
0 → 100644
View file @
4d970f15
import
torch
print
(
torch
.
cuda
.
is_available
())
\ No newline at end of file
backend/requirements.txt
View file @
4d970f15
...
...
@@ -6,3 +6,6 @@ SQLAlchemy==2.0.43
PyMySQL==1.1.2
openpyxl==3.1.5
xlrd==2.0.1
numpy>=1.24.0
# PyTorch (CPU): pip install torch --index-url https://download.pytorch.org/whl/cpu
# PyTorch (CUDA): pip install torch --index-url https://download.pytorch.org/whl/cu121
backend/saved_models/task_5.pt
0 → 100644
View file @
4d970f15
File added
backend/saved_models/task_7.pt
0 → 100644
View file @
4d970f15
File added
backend/uploads/data/温度2_20260423163745355240_19日14时57分48秒转换结果_回路1_-对应控点3.csv
0 → 100644
View file @
4d970f15
This source diff could not be displayed because it is too large. You can
view the blob
instead.
frontend/src/App.vue
View file @
4d970f15
...
...
@@ -7,12 +7,14 @@ const router = useRouter()
const
tabs
=
[
{
label
:
'数据管理'
,
path
:
'/data-management'
},
{
label
:
'实时监控'
,
path
:
'/realtime-monitor'
},
{
label
:
'历史数据'
,
path
:
'/history-data'
},
{
label
:
'数据包管理'
,
path
:
'/package-management'
},
{
label
:
'模型训练'
,
path
:
'/model-training'
},
{
label
:
'模型列表'
,
path
:
'/model-list'
},
{
label
:
'模型评估'
,
path
:
'/model-evaluation'
},
{
label
:
'数据包管理'
,
path
:
'/package-management'
},
{
label
:
'实时监控'
,
path
:
'/realtime-monitor'
},
{
label
:
'历史数据'
,
path
:
'/history-data'
},
]
const
activeTab
=
computed
(()
=>
{
...
...
@@ -29,6 +31,7 @@ const handleTabChange = (path) => {
<div
class=
"app-shell"
>
<header
class=
"top-header"
>
<div
class=
"project-title"
>
热实验温度控制系统
</div>
<div
class=
"header-divider"
></div>
<el-tabs
class=
"top-tabs"
:model-value=
"activeTab"
@
tab-change=
"handleTabChange"
>
<el-tab-pane
v-for=
"tab in tabs"
...
...
@@ -48,34 +51,37 @@ const handleTabChange = (path) => {
<
style
lang=
"scss"
scoped
>
.app-shell
{
min-height
:
100vh
;
background
:
radial-gradient
(
circle
at
8%
12%
,
rgba
(
14
,
165
,
233
,
0
.22
)
,
transparent
38%
)
,
radial-gradient
(
circle
at
96%
8%
,
rgba
(
34
,
197
,
94
,
0
.2
)
,
transparent
36%
)
,
#f2f8fb
;
background
:
var
(
--
bg-page
);
}
.top-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
20px
;
height
:
72px
;
padding
:
0
20px
;
backdrop-filter
:
blur
(
8px
);
border-bottom
:
1px
solid
rgba
(
15
,
23
,
42
,
0
.08
);
background
:
rgba
(
255
,
255
,
255
,
0
.84
);
gap
:
0
;
height
:
52px
;
padding
:
0
24px
;
background
:
#ffffff
;
border-bottom
:
1px
solid
var
(
--
border-color
);
position
:
sticky
;
top
:
0
;
z-index
:
20
;
}
.header-divider
{
width
:
1px
;
height
:
18px
;
background
:
var
(
--
border-color
);
margin
:
0
20px
0
4px
;
flex-shrink
:
0
;
}
.project-title
{
flex
:
0
0
auto
;
font-size
:
22px
;
letter-spacing
:
1px
;
font-size
:
15px
;
font-weight
:
700
;
color
:
#0f172a
;
color
:
var
(
--
text-primary
)
;
white-space
:
nowrap
;
letter-spacing
:
0
.5px
;
}
.top-tabs
{
...
...
@@ -90,25 +96,40 @@ const handleTabChange = (path) => {
display
:
none
;
}
:deep
(
.el-tabs__nav-wrap
)
{
height
:
52px
;
}
:deep
(
.el-tabs__item
)
{
color
:
#334155
;
height
:
52px
;
line-height
:
52px
;
padding
:
0
16px
;
font-size
:
14px
;
font-weight
:
500
;
color
:
var
(
--
text-secondary
);
transition
:
color
0
.15s
;
&
:hover
{
color
:
var
(
--
primary
);
background
:
var
(
--
primary-light
);
}
:deep
(
.el-tabs__item.is-active
)
{
color
:
#0f766e
;
&
.is-active
{
color
:
var
(
--
primary
);
font-weight
:
600
;
}
}
:deep
(
.el-tabs__active-bar
)
{
background-color
:
#0f766e
;
height
:
3px
;
border-radius
:
999px
;
background-color
:
var
(
--
primary
);
height
:
2px
;
border-radius
:
0
;
bottom
:
0
;
}
}
.app-main
{
height
:
calc
(
100vh
-
72px
);
padding
:
16px
20px
20px
;
height
:
calc
(
100vh
-
52px
);
}
@media
(
max-width
:
980px
)
{
...
...
@@ -116,11 +137,11 @@ const handleTabChange = (path) => {
flex-direction
:
column
;
align-items
:
flex-start
;
height
:
auto
;
padding
:
1
2
px
;
padding
:
1
0px
16
px
;
}
.
project-title
{
font-size
:
18px
;
.
header-divider
{
display
:
none
;
}
.top-tabs
{
...
...
@@ -129,8 +150,7 @@ const handleTabChange = (path) => {
.app-main
{
height
:
auto
;
min-height
:
calc
(
100vh
-
120px
);
padding
:
12px
;
min-height
:
calc
(
100vh
-
100px
);
}
}
</
style
>
frontend/src/api/evalManagement.js
0 → 100644
View file @
4d970f15
import
request
from
'@/utils/request'
export
function
getEvalPackages
()
{
return
request
.
get
(
'/eval/packages'
)
}
export
function
getEvalModels
()
{
return
request
.
get
(
'/eval/models'
)
}
export
function
runEvaluation
(
payload
)
{
return
request
.
post
(
'/eval/run'
,
payload
)
}
export
function
getEvalRecords
()
{
return
request
.
get
(
'/eval/records'
)
}
export
function
getEvalRecord
(
recordId
)
{
return
request
.
get
(
`/eval/records/
${
recordId
}
`
)
}
export
function
deleteEvalRecord
(
recordId
)
{
return
request
.
delete
(
`/eval/records/
${
recordId
}
`
)
}
frontend/src/api/packageManagement.js
0 → 100644
View file @
4d970f15
import
request
from
'@/utils/request'
// ── categories ──────────────────────────────────────────────────────────────
export
function
getPkgCategoryTree
()
{
return
request
.
get
(
'/packages/categories'
)
}
export
function
createPkgCategory
(
payload
)
{
return
request
.
post
(
'/packages/categories'
,
payload
)
}
export
function
updatePkgCategory
(
categoryId
,
payload
)
{
return
request
.
put
(
`/packages/categories/
${
categoryId
}
`
,
payload
)
}
export
function
deletePkgCategory
(
categoryId
)
{
return
request
.
delete
(
`/packages/categories/
${
categoryId
}
`
)
}
// ── data files (for selection) ───────────────────────────────────────────────
export
function
getAllDataFiles
()
{
return
request
.
get
(
'/packages/data-files'
)
}
// ── packages ─────────────────────────────────────────────────────────────────
export
function
getPackages
(
params
)
{
return
request
.
get
(
'/packages'
,
{
params
})
}
export
function
createPackage
(
payload
)
{
return
request
.
post
(
'/packages'
,
payload
)
}
export
function
deletePackage
(
packageId
)
{
return
request
.
delete
(
`/packages/
${
packageId
}
`
)
}
export
function
getPackageRecords
(
packageId
,
params
)
{
return
request
.
get
(
`/packages/
${
packageId
}
/records`
,
{
params
})
}
export
function
previewPackage
(
payload
,
params
)
{
return
request
.
post
(
'/packages/preview'
,
payload
,
{
params
})
}
frontend/src/api/trainManagement.js
0 → 100644
View file @
4d970f15
import
request
from
'@/utils/request'
export
function
getTrainPackages
()
{
return
request
.
get
(
'/train/packages'
)
}
export
function
getTrainTasks
()
{
return
request
.
get
(
'/train/tasks'
)
}
export
function
getTrainTask
(
taskId
)
{
return
request
.
get
(
`/train/tasks/
${
taskId
}
`
)
}
export
function
createTrainTask
(
payload
)
{
return
request
.
post
(
'/train/tasks'
,
payload
)
}
export
function
cancelTrainTask
(
taskId
)
{
return
request
.
post
(
`/train/tasks/
${
taskId
}
/cancel`
)
}
export
function
restartTrainTask
(
taskId
)
{
return
request
.
post
(
`/train/tasks/
${
taskId
}
/restart`
)
}
export
function
deleteTrainTask
(
taskId
)
{
return
request
.
delete
(
`/train/tasks/
${
taskId
}
`
)
}
export
function
saveTrainModel
(
taskId
)
{
return
request
.
post
(
`/train/tasks/
${
taskId
}
/save`
)
}
export
function
getSavedModels
()
{
return
request
.
get
(
'/train/models'
)
}
export
function
deleteSavedModel
(
modelId
)
{
return
request
.
delete
(
`/train/models/
${
modelId
}
`
)
}
frontend/src/components/common/UnderConstruction.vue
View file @
4d970f15
...
...
@@ -21,26 +21,30 @@ defineProps({
height
:
100%
;
display
:
grid
;
place-items
:
center
;
background
:
var
(
--
bg-page
);
}
.uc-card
{
width
:
min
(
640px
,
100%
);
border-radius
:
18px
;
padding
:
38px
30px
;
background
:
linear-gradient
(
135deg
,
#ffffff
,
#f2fbf8
);
border
:
1px
solid
rgba
(
15
,
118
,
110
,
0
.2
);
box-shadow
:
0
12px
30px
rgba
(
2
,
8
,
23
,
0
.1
);
width
:
min
(
480px
,
100%
);
border-radius
:
4px
;
padding
:
40px
32px
;
background
:
var
(
--
bg-white
);
border
:
1px
solid
var
(
--
border-color
);
box-shadow
:
var
(
--
shadow-card
);
text-align
:
center
;
h2
{
margin
:
0
0
10px
;
font-size
:
28px
;
color
:
#0f172a
;
margin
:
0
0
12px
;
font-size
:
16px
;
font-weight
:
600
;
color
:
var
(
--
text-primary
);
}
p
{
margin
:
0
;
color
:
#334155
;
font-size
:
15px
;
color
:
var
(
--
text-secondary
);
font-size
:
13px
;
line-height
:
1
.6
;
}
}
</
style
>
frontend/src/router/index.js
View file @
4d970f15
...
...
@@ -27,26 +27,22 @@ const router = createRouter({
{
path
:
'/model-training'
,
name
:
'model-training'
,
component
:
()
=>
import
(
'@/components/common/UnderConstruction.vue'
),
props
:
{
title
:
'模型训练'
},
component
:
()
=>
import
(
'@/views/ModelTraining/index.vue'
),
},
{
path
:
'/model-list'
,
name
:
'model-list'
,
component
:
()
=>
import
(
'@/components/common/UnderConstruction.vue'
),
props
:
{
title
:
'模型列表'
},
component
:
()
=>
import
(
'@/views/ModelList/index.vue'
),
},
{
path
:
'/model-evaluation'
,
name
:
'model-evaluation'
,
component
:
()
=>
import
(
'@/components/common/UnderConstruction.vue'
),
props
:
{
title
:
'模型评估'
},
component
:
()
=>
import
(
'@/views/ModelEvaluation/index.vue'
),
},
{
path
:
'/package-management'
,
name
:
'package-management'
,
component
:
()
=>
import
(
'@/components/common/UnderConstruction.vue'
),
props
:
{
title
:
'数据包管理'
},
component
:
()
=>
import
(
'@/views/PackageManagement/index.vue'
),
},
],
})
...
...
frontend/src/styles/global.scss
View file @
4d970f15
@import
url('https://fonts.googleapis.com/css2?family=Poppins:wght@500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap')
;
:root
{
--page-text
:
#0f172a
;
--page-muted
:
#64748b
;
--brand
:
#0f766e
;
--brand-soft
:
#14b8a6
;
--primary
:
#165DFF
;
--primary-hover
:
#4080FF
;
--primary-light
:
#E8F3FF
;
--primary-border
:
#BEDAFF
;
--text-primary
:
#1D2129
;
--text-secondary
:
#4E5969
;
--text-tertiary
:
#86909C
;
--bg-page
:
#F2F3F5
;
--bg-white
:
#FFFFFF
;
--border-color
:
#E5E6EB
;
--shadow-card
:
0
1px
4px
rgba
(
0
,
0
,
0
,
0
.08
);
--shadow-card-hover
:
0
2px
8px
rgba
(
0
,
0
,
0
,
0
.12
);
}
*
{
...
...
@@ -21,6 +27,147 @@ body,
}
body
{
font-family
:
'Poppins'
,
'Noto Sans SC'
,
'Microsoft YaHei'
,
sans-serif
;
color
:
var
(
--
page-text
);
font-family
:
'Microsoft YaHei'
,
'PingFang SC'
,
'Helvetica Neue'
,
Arial
,
sans-serif
;
font-size
:
13px
;
color
:
var
(
--
text-primary
);
-webkit-font-smoothing
:
antialiased
;
}
/* ── Element Plus global overrides ──────────────────────────────────────── */
/* Table header */
.el-table
th
.el-table__cell
{
background-color
:
#F7F8FA
!
important
;
color
:
var
(
--
text-primary
)
!
important
;
font-weight
:
600
!
important
;
font-size
:
13px
!
important
;
border-bottom
:
1px
solid
var
(
--
border-color
)
!
important
;
}
/* Table row hover */
.el-table__row
:hover
>
td
.el-table__cell
{
background-color
:
var
(
--
primary-light
)
!
important
;
}
/* Table selected row */
.el-table__body
tr
.current-row
>
td
.el-table__cell
{
background-color
:
var
(
--
primary-light
)
!
important
;
}
/* Table body font */
.el-table
td
.el-table__cell
{
font-size
:
13px
;
color
:
var
(
--
text-primary
);
border-bottom
:
1px
solid
var
(
--
border-color
);
}
/* Card */
.el-card
{
--el-card-border-color
:
var
(
--
border-color
);
border-radius
:
4px
;
box-shadow
:
var
(
--
shadow-card
)
!
important
;
}
/* Button sizes */
.el-button
{
font-size
:
13px
;
font-weight
:
500
;
}
/* Primary button */
.el-button--primary
{
--el-button-bg-color
:
var
(
--
primary
);
--el-button-border-color
:
var
(
--
primary
);
--el-button-hover-bg-color
:
var
(
--
primary-hover
);
--el-button-hover-border-color
:
var
(
--
primary-hover
);
--el-button-active-bg-color
:
#0E4FD9
;
--el-button-active-border-color
:
#0E4FD9
;
}
/* Primary outlined button */
.el-button--primary.is-plain
{
--el-button-bg-color
:
transparent
;
--el-button-text-color
:
var
(
--
primary
);
--el-button-border-color
:
var
(
--
primary
);
--el-button-hover-bg-color
:
var
(
--
primary-light
);
--el-button-hover-border-color
:
var
(
--
primary
);
--el-button-hover-text-color
:
var
(
--
primary
);
}
/* Input */
.el-input__wrapper
{
font-size
:
13px
;
}
/* Tag */
.el-tag
{
font-size
:
12px
;
}
/* Empty description */
.el-empty__description
p
{
font-size
:
13px
;
color
:
var
(
--
text-tertiary
);
}
/* Tree node */
.el-tree-node__content
{
height
:
32px
;
font-size
:
13px
;
}
.el-tree-node.is-current
>
.el-tree-node__content
{
background-color
:
var
(
--
primary-light
)
!
important
;
color
:
var
(
--
primary
)
!
important
;
font-weight
:
600
;
border-left
:
3px
solid
var
(
--
primary
);
}
.el-tree-node__content
:hover
{
background-color
:
#F2F3F5
!
important
;
}
/* Radio button (outlined style) */
.el-radio-button__inner
{
font-size
:
13px
;
font-weight
:
500
;
}
.el-radio-button
:first-child
.el-radio-button__inner
{
border-left
:
1px
solid
var
(
--
border-color
);
}
.el-radio-button__original-radio
:checked
+
.el-radio-button__inner
{
background-color
:
var
(
--
primary
)
!
important
;
border-color
:
var
(
--
primary
)
!
important
;
box-shadow
:
none
!
important
;
}
/* Dialog */
.el-dialog__header
{
font-size
:
14px
;
font-weight
:
600
;
padding-bottom
:
12px
;
border-bottom
:
1px
solid
var
(
--
border-color
);
}
/* Tabs (navigation) */
.el-tabs__item
{
font-size
:
14px
!
important
;
font-weight
:
500
!
important
;
color
:
var
(
--
text-secondary
)
!
important
;
}
.el-tabs__item.is-active
{
color
:
var
(
--
primary
)
!
important
;
font-weight
:
600
!
important
;
}
.el-tabs__active-bar
{
background-color
:
var
(
--
primary
)
!
important
;
height
:
2px
!
important
;
}
.el-tabs__item
:hover
{
color
:
var
(
--
primary
)
!
important
;
}
frontend/src/views/DataManagement/index.vue
View file @
4d970f15
...
...
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
<
template
>
<section
class=
"data-page"
:class=
"
{ dragging: !!dragState }">
<div
ref=
"layoutRef"
class=
"data-layout"
>
<el-card
class=
"pane-card"
shadow=
"hover"
:style=
"
{ width: `${leftPaneWidth}%` }">
<el-card
class=
"pane-card
pane-card--tree
"
shadow=
"hover"
:style=
"
{ width: `${leftPaneWidth}%` }">
<template
#
header
>
<div
class=
"pane-header"
>
<div>
...
...
@@ -475,11 +475,11 @@ 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-button
type=
"
success
"
:icon=
"Upload"
@
click=
"openUploadDialog"
>
上传文件
</el-button>
<el-button
type=
"
primary
"
:icon=
"Upload"
@
click=
"openUploadDialog"
>
上传文件
</el-button>
</el-form-item>
</el-form>
<el-table
:data=
"fileList"
border
stripe
v-loading=
"loadingFiles"
height=
"calc(100vh - 2
7
0px)"
>
<el-table
:data=
"fileList"
border
stripe
v-loading=
"loadingFiles"
height=
"calc(100vh - 2
5
0px)"
>
<el-table-column
prop=
"filename"
label=
"文件名"
min-width=
"160"
/>
<el-table-column
prop=
"uploaded_at"
label=
"上传时间"
min-width=
"170"
/>
<el-table-column
prop=
"data_count"
label=
"数据量"
width=
"90"
/>
...
...
@@ -516,7 +516,7 @@ onBeforeUnmount(() => {
:data=
"recordList"
border
stripe
height=
"calc(100vh - 2
7
0px)"
height=
"calc(100vh - 2
5
0px)"
>
<el-table-column
prop=
"time"
label=
"时间"
min-width=
"140"
/>
<el-table-column
prop=
"current"
label=
"电流"
min-width=
"100"
/>
...
...
@@ -584,6 +584,7 @@ onBeforeUnmount(() => {
<
style
lang=
"scss"
scoped
>
.data-page
{
height
:
100%
;
background
:
var
(
--
bg-page
);
&
.dragging
{
user-select
:
none
;
...
...
@@ -596,41 +597,62 @@ onBeforeUnmount(() => {
display
:
flex
;
align-items
:
stretch
;
gap
:
0
;
background
:
var
(
--
bg-white
);
border
:
1px
solid
var
(
--
border-color
);
box-shadow
:
var
(
--
shadow-card
);
}
.pane-divider
{
width
:
12
px
;
flex
:
0
0
12px
;
position
:
relative
;
width
:
4
px
;
flex
-shrink
:
0
;
background
:
var
(
--
border-color
)
;
cursor
:
col-resize
;
user-select
:
none
;
transition
:
background
0
.15s
;
position
:
relative
;
&
:
:
before
{
&
:
:
after
{
content
:
''
;
position
:
absolute
;
top
:
0
;
bottom
:
0
;
left
:
5px
;
width
:
2px
;
background
:
rgba
(
148
,
163
,
184
,
0
.4
);
transition
:
background
0
.2s
ease
;
inset
:
0
-2px
;
}
&
:hover::before
{
background
:
rgba
(
59
,
130
,
246
,
0
.7
);
&
:hover
,
.dragging
&
{
background
:
var
(
--
primary-border
);
}
}
.pane-card
{
height
:
100%
;
min-width
:
0
;
border
:
1px
solid
rgba
(
15
,
23
,
42
,
0
.08
);
border-radius
:
0
;
border
:
none
;
border-right
:
1px
solid
var
(
--
border-color
);
box-shadow
:
none
!
important
;
background
:
var
(
--
bg-white
);
:deep
(
.el-card__header
)
{
padding
:
0
16px
;
height
:
44px
;
display
:
flex
;
align-items
:
center
;
border-bottom
:
1px
solid
var
(
--
border-color
);
background
:
#F7F8FA
;
}
:deep
(
.el-card__body
)
{
height
:
calc
(
100%
-
56
px
);
height
:
calc
(
100%
-
44
px
);
display
:
flex
;
flex-direction
:
column
;
gap
:
10px
;
padding
:
12px
16px
;
}
&
--tree
{
:deep
(
.el-card__body
)
{
padding
:
8px
0
;
overflow-y
:
auto
;
}
}
}
...
...
@@ -638,36 +660,17 @@ onBeforeUnmount(() => {
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
12px
;
width
:
100%
;
font-size
:
14px
;
font-weight
:
600
;
color
:
#0f172a
;
}
.pane-tip
{
margin-left
:
6px
;
font-size
:
12px
;
font-weight
:
400
;
color
:
#64748b
;
color
:
var
(
--
text-primary
);
}
.file-title
{
font-size
:
13px
;
color
:
#475569
;
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
));
color
:
var
(
--
text-secondary
);
font-weight
:
400
;
margin-left
:
4px
;
}
.tree-node
{
...
...
@@ -675,33 +678,48 @@ onBeforeUnmount(() => {
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
10
px
;
gap
:
8
px
;
padding-right
:
4px
;
}
.tree-main
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
8
px
;
gap
:
6
px
;
min-width
:
0
;
}
.tree-icon
{
font-size
:
1
6
px
;
color
:
#eab308
;
font-size
:
1
4
px
;
color
:
var
(
--
primary
)
;
flex
:
0
0
auto
;
}
.tree-name
{
max-width
:
120px
;
font-size
:
13px
;
color
:
var
(
--
text-primary
);
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
color
:
#1e293b
;
}
.tree-actions
{
opacity
:
0
.8
;
display
:
none
;
flex-shrink
:
0
;
.el-button
{
padding
:
0
3px
;
font-size
:
13px
;
color
:
var
(
--
text-tertiary
);
&
:hover
{
color
:
var
(
--
primary
);
}
}
}
.tree-node
:hover
.tree-actions
{
display
:
inline-flex
;
}
.upload-panel
{
...
...
@@ -710,12 +728,13 @@ onBeforeUnmount(() => {
.upload-panel__icon
{
font-size
:
28px
;
color
:
#409eff
;
color
:
var
(
--
primary
)
;
margin-bottom
:
8px
;
}
.search-form
{
margin-bottom
:
4px
;
flex-shrink
:
0
;
}
.content-wrap
{
...
...
@@ -737,6 +756,8 @@ onBeforeUnmount(() => {
width
:
100%
!
important
;
min-height
:
420px
;
margin-bottom
:
12px
;
border-right
:
none
;
border-bottom
:
1px
solid
var
(
--
border-color
);
}
}
</
style
>
frontend/src/views/ModelEvaluation/components/EvalChart.vue
0 → 100644
View file @
4d970f15
<
script
setup
>
import
*
as
echarts
from
'echarts'
import
{
computed
,
nextTick
,
onBeforeUnmount
,
onMounted
,
ref
,
watch
}
from
'vue'
const
props
=
defineProps
({
chartData
:
{
type
:
Array
,
default
:
()
=>
[],
},
height
:
{
type
:
String
,
default
:
'360px'
,
},
})
const
chartRef
=
ref
(
null
)
let
chartInstance
=
null
const
validData
=
computed
(()
=>
Array
.
isArray
(
props
.
chartData
)
?
props
.
chartData
.
filter
((
d
)
=>
d
!=
null
)
:
[],
)
const
xLabels
=
computed
(()
=>
validData
.
value
.
map
((
d
)
=>
d
.
time
||
String
(
d
.
index
??
''
)))
const
actualSeries
=
computed
(()
=>
validData
.
value
.
map
((
d
)
=>
d
.
actual
??
null
))
const
predictedSeries
=
computed
(()
=>
validData
.
value
.
map
((
d
)
=>
d
.
predicted
??
null
))
const
renderChart
=
()
=>
{
if
(
!
chartRef
.
value
)
return
if
(
!
chartInstance
)
chartInstance
=
echarts
.
init
(
chartRef
.
value
)
const
hasData
=
validData
.
value
.
length
>
0
chartInstance
.
setOption
(
{
animation
:
false
,
color
:
[
'#409EFF'
,
'#F56C6C'
],
tooltip
:
{
trigger
:
'axis'
,
backgroundColor
:
'rgba(255,255,255,0.96)'
,
borderColor
:
'#e2e8f0'
,
borderWidth
:
1
,
textStyle
:
{
color
:
'#334155'
},
extraCssText
:
'box-shadow: 0 8px 24px rgba(15,23,42,0.14); border-radius: 10px;'
,
formatter
(
params
)
{
if
(
!
params
?.
length
)
return
''
const
lines
=
[
`<div style="margin-bottom:6px;font-weight:600;font-size:12px;">
${
params
[
0
].
axisValue
}
</div>`
,
]
params
.
forEach
((
item
)
=>
{
const
val
=
item
.
data
!=
null
?
Number
(
item
.
data
).
toFixed
(
4
)
:
'--'
lines
.
push
(
`<div style="display:flex;align-items:center;gap:8px;min-width:180px;justify-content:space-between;">
<span>
${
item
.
marker
}${
item
.
seriesName
}
</span>
<strong>
${
val
}
℃</strong>
</div>`
,
)
})
return
lines
.
join
(
''
)
},
},
legend
:
{
bottom
:
4
,
itemWidth
:
20
,
itemHeight
:
10
,
textStyle
:
{
color
:
'#475569'
,
fontSize
:
12
},
data
:
[
'实际温度 (℃)'
,
'预测温度 (℃)'
],
},
grid
:
{
top
:
16
,
left
:
16
,
right
:
20
,
bottom
:
52
,
containLabel
:
true
},
xAxis
:
{
type
:
'category'
,
boundaryGap
:
false
,
data
:
xLabels
.
value
,
axisLabel
:
{
color
:
'#64748b'
,
fontSize
:
11
,
rotate
:
35
,
interval
:
Math
.
max
(
0
,
Math
.
floor
(
xLabels
.
value
.
length
/
12
)
-
1
),
hideOverlap
:
true
,
},
axisLine
:
{
lineStyle
:
{
color
:
'#cbd5e1'
}
},
},
yAxis
:
{
type
:
'value'
,
name
:
'温度 (℃)'
,
nameTextStyle
:
{
color
:
'#64748b'
,
fontSize
:
11
},
axisLabel
:
{
color
:
'#64748b'
,
fontSize
:
11
},
splitLine
:
{
lineStyle
:
{
type
:
'dashed'
,
color
:
'rgba(148,163,184,0.4)'
}
},
},
series
:
[
{
name
:
'实际温度 (℃)'
,
type
:
'line'
,
smooth
:
false
,
symbol
:
'none'
,
lineStyle
:
{
width
:
2
,
type
:
'solid'
},
data
:
actualSeries
.
value
,
},
{
name
:
'预测温度 (℃)'
,
type
:
'line'
,
smooth
:
false
,
symbol
:
'none'
,
lineStyle
:
{
width
:
2
,
type
:
'dashed'
},
data
:
predictedSeries
.
value
,
},
],
graphic
:
hasData
?
[]
:
[
{
type
:
'text'
,
left
:
'center'
,
top
:
'middle'
,
style
:
{
text
:
'暂无评估数据'
,
fill
:
'#94a3b8'
,
fontSize
:
14
},
},
],
dataZoom
:
hasData
?
[
{
type
:
'inside'
,
start
:
0
,
end
:
100
},
{
type
:
'slider'
,
start
:
0
,
end
:
100
,
height
:
20
,
bottom
:
28
},
]
:
[],
},
true
,
)
}
const
resizeChart
=
()
=>
chartInstance
?.
resize
()
watch
(()
=>
props
.
chartData
,
async
()
=>
{
await
nextTick
();
renderChart
()
},
{
deep
:
true
})
onMounted
(
async
()
=>
{
await
nextTick
()
renderChart
()
window
.
addEventListener
(
'resize'
,
resizeChart
)
})
onBeforeUnmount
(()
=>
{
window
.
removeEventListener
(
'resize'
,
resizeChart
)
chartInstance
?.
dispose
()
chartInstance
=
null
})
</
script
>
<
template
>
<div
ref=
"chartRef"
:style=
"
{ width: '100%', height: props.height }" />
</
template
>
frontend/src/views/ModelEvaluation/index.vue
View file @
4d970f15
<
script
setup
>
import
{
Refresh
}
from
'@element-plus/icons-vue'
import
{
ElMessage
,
ElMessageBox
}
from
'element-plus'
import
{
onMounted
,
reactive
,
ref
}
from
'vue'
import
{
deleteEvalRecord
,
getEvalModels
,
getEvalPackages
,
getEvalRecord
,
getEvalRecords
,
runEvaluation
,
}
from
'@/api/evalManagement'
import
EvalChart
from
'./components/EvalChart.vue'
// ── form ──────────────────────────────────────────────────────────────────────
const
packages
=
ref
([])
const
models
=
ref
([])
const
form
=
reactive
({
model_id
:
''
,
package_id
:
''
})
const
evaluating
=
ref
(
false
)
// Current evaluation result (shown directly above the records table)
const
currentResult
=
ref
(
null
)
// { model_name, package_name, mae, rmse, chart_data }
// ── records ───────────────────────────────────────────────────────────────────
const
records
=
ref
([])
const
loadingRecords
=
ref
(
false
)
// ── dialog for historical view ────────────────────────────────────────────────
const
dialogVisible
=
ref
(
false
)
const
dialogRecord
=
ref
(
null
)
const
dialogLoading
=
ref
(
false
)
// ── actions ───────────────────────────────────────────────────────────────────
const
loadDropdowns
=
async
()
=>
{
const
[
pkgs
,
mdls
]
=
await
Promise
.
all
([
getEvalPackages
(),
getEvalModels
()])
packages
.
value
=
pkgs
models
.
value
=
mdls
}
const
loadRecords
=
async
()
=>
{
loadingRecords
.
value
=
true
try
{
records
.
value
=
await
getEvalRecords
()
}
finally
{
loadingRecords
.
value
=
false
}
}
const
handleEvaluate
=
async
()
=>
{
if
(
!
form
.
model_id
)
{
ElMessage
.
warning
(
'请选择模型'
);
return
}
if
(
!
form
.
package_id
)
{
ElMessage
.
warning
(
'请选择数据包'
);
return
}
evaluating
.
value
=
true
currentResult
.
value
=
null
try
{
const
result
=
await
runEvaluation
({
model_id
:
form
.
model_id
,
package_id
:
form
.
package_id
})
currentResult
.
value
=
result
ElMessage
.
success
(
'评估完成'
)
await
loadRecords
()
}
catch
(
e
)
{
ElMessage
.
error
(
e
?.
message
||
'评估失败'
)
}
finally
{
evaluating
.
value
=
false
}
}
const
handleView
=
async
(
row
)
=>
{
dialogLoading
.
value
=
true
dialogVisible
.
value
=
true
dialogRecord
.
value
=
null
try
{
dialogRecord
.
value
=
await
getEvalRecord
(
row
.
id
)
}
finally
{
dialogLoading
.
value
=
false
}
}
const
handleDelete
=
async
(
row
)
=>
{
try
{
await
ElMessageBox
.
confirm
(
`确定删除模型"
${
row
.
model_name
}
"在"
${
row
.
package_name
}
"上的评估记录吗?`
,
'提示'
,
{
type
:
'warning'
},
)
await
deleteEvalRecord
(
row
.
id
)
ElMessage
.
success
(
'已删除'
)
if
(
currentResult
.
value
?.
id
===
row
.
id
)
currentResult
.
value
=
null
await
loadRecords
()
}
catch
{
// user cancelled
}
}
const
fmtMetric
=
(
v
)
=>
(
v
!=
null
?
Number
(
v
).
toFixed
(
5
)
:
'-'
)
onMounted
(
async
()
=>
{
await
Promise
.
all
([
loadDropdowns
(),
loadRecords
()])
})
</
script
>
<
template
>
<div
class=
"eval-page"
>
<!-- ── top: config ──────────────────────────────────────────────────── -->
<el-card
class=
"config-card"
shadow=
"hover"
>
<template
#
header
>
<span
class=
"card-title"
>
评估配置
</span>
</
template
>
<el-form
:model=
"form"
inline
class=
"eval-form"
>
<el-form-item
label=
"选择模型"
required
>
<el-select
v-model=
"form.model_id"
placeholder=
"请选择已保存模型"
filterable
style=
"width: 260px"
>
<el-option
v-for=
"m in models"
:key=
"m.id"
:label=
"`${m.model_name}(训练包:${m.package_name})`"
:value=
"m.id"
/>
</el-select>
</el-form-item>
<el-form-item
label=
"选择数据包"
required
>
<el-select
v-model=
"form.package_id"
placeholder=
"请选择评估数据包"
filterable
style=
"width: 240px"
>
<el-option
v-for=
"p in packages"
:key=
"p.id"
:label=
"`${p.name}(${p.data_count} 条)`"
:value=
"p.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button
type=
"primary"
:loading=
"evaluating"
@
click=
"handleEvaluate"
>
开始评估
</el-button>
</el-form-item>
</el-form>
<!-- result metrics + chart -->
<
template
v-if=
"evaluating"
>
<div
class=
"eval-loading"
>
<el-icon
class=
"is-loading"
style=
"font-size:24px;color:#409EFF"
><Refresh
/></el-icon>
<span>
正在推理,请稍候…
</span>
</div>
</
template
>
<
template
v-else-if=
"currentResult"
>
<div
class=
"metrics-row"
>
<div
class=
"metric-chip"
>
<span
class=
"metric-label"
>
评估样本
</span>
<span
class=
"metric-value"
>
{{
currentResult
.
total_count
}}
条
</span>
</div>
<div
class=
"metric-chip"
>
<span
class=
"metric-label"
>
MAE
</span>
<span
class=
"metric-value"
>
{{
fmtMetric
(
currentResult
.
mae
)
}}
℃
</span>
</div>
<div
class=
"metric-chip"
>
<span
class=
"metric-label"
>
RMSE
</span>
<span
class=
"metric-value"
>
{{
fmtMetric
(
currentResult
.
rmse
)
}}
℃
</span>
</div>
<div
class=
"metric-chip"
>
<span
class=
"metric-label"
>
模型
</span>
<span
class=
"metric-value"
>
{{
currentResult
.
model_name
}}
</span>
</div>
<div
class=
"metric-chip"
>
<span
class=
"metric-label"
>
数据包
</span>
<span
class=
"metric-value"
>
{{
currentResult
.
package_name
}}
</span>
</div>
</div>
<EvalChart
:chart-data=
"currentResult.chart_data"
height=
"340px"
/>
</
template
>
</el-card>
<!-- ── bottom: records table ────────────────────────────────────────── -->
<el-card
class=
"records-card"
shadow=
"hover"
>
<
template
#
header
>
<div
class=
"card-header-row"
>
<span
class=
"card-title"
>
评估记录
</span>
<el-button
:icon=
"Refresh"
size=
"small"
plain
:loading=
"loadingRecords"
@
click=
"loadRecords"
>
刷新
</el-button>
</div>
</
template
>
<el-table
:data=
"records"
v-loading=
"loadingRecords"
border
stripe
height=
"calc(100vh - 580px)"
>
<el-table-column
prop=
"model_name"
label=
"模型名称"
min-width=
"140"
show-overflow-tooltip
/>
<el-table-column
prop=
"package_name"
label=
"评估数据包"
min-width=
"130"
show-overflow-tooltip
/>
<el-table-column
label=
"样本数"
width=
"90"
align=
"center"
>
<
template
#
default=
"{ row }"
>
{{
row
.
total_count
}}
</
template
>
</el-table-column>
<el-table-column
label=
"MAE (℃)"
width=
"110"
align=
"center"
>
<
template
#
default=
"{ row }"
>
<span
class=
"metric-cell"
>
{{
fmtMetric
(
row
.
mae
)
}}
</span>
</
template
>
</el-table-column>
<el-table-column
label=
"RMSE (℃)"
width=
"110"
align=
"center"
>
<
template
#
default=
"{ row }"
>
<span
class=
"metric-cell"
>
{{
fmtMetric
(
row
.
rmse
)
}}
</span>
</
template
>
</el-table-column>
<el-table-column
prop=
"created_at"
label=
"评估时间"
width=
"165"
/>
<el-table-column
label=
"操作"
width=
"120"
fixed=
"right"
align=
"center"
>
<
template
#
default=
"{ row }"
>
<el-button
link
type=
"primary"
@
click=
"handleView(row)"
>
查看
</el-button>
<el-button
link
type=
"danger"
@
click=
"handleDelete(row)"
>
删除
</el-button>
</
template
>
</el-table-column>
</el-table>
</el-card>
<!-- ── historical view dialog ───────────────────────────────────────── -->
<el-dialog
v-model=
"dialogVisible"
:title=
"dialogRecord ? `${dialogRecord.model_name} — ${dialogRecord.package_name}` : '评估详情'"
width=
"860px"
destroy-on-close
>
<div
v-loading=
"dialogLoading"
style=
"min-height: 120px"
>
<
template
v-if=
"dialogRecord"
>
<div
class=
"metrics-row"
style=
"margin-bottom: 12px"
>
<div
class=
"metric-chip"
>
<span
class=
"metric-label"
>
样本数
</span>
<span
class=
"metric-value"
>
{{
dialogRecord
.
total_count
}}
</span>
</div>
<div
class=
"metric-chip"
>
<span
class=
"metric-label"
>
MAE
</span>
<span
class=
"metric-value"
>
{{
fmtMetric
(
dialogRecord
.
mae
)
}}
℃
</span>
</div>
<div
class=
"metric-chip"
>
<span
class=
"metric-label"
>
RMSE
</span>
<span
class=
"metric-value"
>
{{
fmtMetric
(
dialogRecord
.
rmse
)
}}
℃
</span>
</div>
<div
class=
"metric-chip"
>
<span
class=
"metric-label"
>
评估时间
</span>
<span
class=
"metric-value"
>
{{
dialogRecord
.
created_at
}}
</span>
</div>
</div>
<EvalChart
:chart-data=
"dialogRecord.chart_data || []"
height=
"380px"
/>
</
template
>
</div>
<
template
#
footer
>
<el-button
@
click=
"dialogVisible = false"
>
关闭
</el-button>
</
template
>
</el-dialog>
</div>
</template>
<
style
lang=
"scss"
scoped
>
.eval-page
{
height
:
100%
;
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
overflow-y
:
auto
;
}
.config-card
,
.records-card
{
flex-shrink
:
0
;
:deep
(
.el-card__header
)
{
padding
:
12px
20px
;
border-bottom
:
1px
solid
#e8edf3
;
}
:deep
(
.el-card__body
)
{
padding
:
16px
20px
;
}
}
.records-card
{
flex
:
1
;
min-height
:
200px
;
}
.card-title
{
font-size
:
14px
;
font-weight
:
600
;
color
:
#0f172a
;
}
.card-header-row
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
}
.eval-form
{
:deep
(
.el-form-item
)
{
margin-bottom
:
0
;
margin-right
:
16px
;
}
}
.eval-loading
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
padding
:
24px
0
8px
;
color
:
#64748b
;
font-size
:
14px
;
}
.metrics-row
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
10px
;
padding
:
16px
0
12px
;
}
.metric-chip
{
display
:
flex
;
flex-direction
:
column
;
align-items
:
flex-start
;
gap
:
2px
;
background
:
#f8fafc
;
border
:
1px
solid
#e2e8f0
;
border-radius
:
8px
;
padding
:
8px
16px
;
min-width
:
100px
;
}
.metric-label
{
font-size
:
11px
;
color
:
#94a3b8
;
font-weight
:
500
;
text-transform
:
uppercase
;
letter-spacing
:
0
.5px
;
}
.metric-value
{
font-size
:
15px
;
font-weight
:
600
;
color
:
#0f172a
;
}
.metric-cell
{
font-size
:
13px
;
color
:
#0f766e
;
font-weight
:
500
;
}
</
style
>
frontend/src/views/ModelList/index.vue
View file @
4d970f15
<
script
setup
>
import
{
Refresh
}
from
'@element-plus/icons-vue'
import
{
ElMessage
,
ElMessageBox
}
from
'element-plus'
import
{
onMounted
,
ref
}
from
'vue'
import
{
deleteSavedModel
,
getSavedModels
}
from
'@/api/trainManagement'
const
models
=
ref
([])
const
loading
=
ref
(
false
)
const
loadModels
=
async
()
=>
{
loading
.
value
=
true
try
{
models
.
value
=
await
getSavedModels
()
}
finally
{
loading
.
value
=
false
}
}
const
handleDelete
=
async
(
row
)
=>
{
try
{
await
ElMessageBox
.
confirm
(
`确定删除模型"
${
row
.
model_name
}
"吗?删除后无法恢复。`
,
'提示'
,
{
type
:
'warning'
,
})
await
deleteSavedModel
(
row
.
id
)
ElMessage
.
success
(
'模型已删除'
)
await
loadModels
()
}
catch
{
// user cancelled
}
}
const
formatParams
=
(
params
)
=>
{
if
(
!
params
)
return
'-'
return
[
`seq=
${
params
.
seq_len
}
`
,
`hidden=
${
params
.
hidden_size
}
`
,
`layers=
${
params
.
num_layers
}
`
,
`epochs=
${
params
.
epochs
}
`
,
`lr=
${
params
.
learning_rate
}
`
,
].
join
(
' / '
)
}
const
formatLoss
=
(
row
)
=>
{
if
(
row
.
train_loss
==
null
)
return
'-'
const
train
=
`训练
${
Number
(
row
.
train_loss
).
toFixed
(
5
)}
`
const
val
=
row
.
val_loss
!=
null
?
` / 验证
${
Number
(
row
.
val_loss
).
toFixed
(
5
)}
`
:
''
return
train
+
val
}
onMounted
(
loadModels
)
</
script
>
<
template
>
<div
class=
"model-list-page"
>
<el-card
shadow=
"hover"
class=
"list-card"
>
<template
#
header
>
<div
class=
"card-header-row"
>
<span
class=
"card-title"
>
已保存模型
</span>
<el-button
:icon=
"Refresh"
size=
"small"
plain
:loading=
"loading"
@
click=
"loadModels"
>
刷新
</el-button>
</div>
</
template
>
<el-table
:data=
"models"
v-loading=
"loading"
border
stripe
height=
"calc(100vh - 160px)"
>
<el-table-column
type=
"index"
width=
"55"
label=
"#"
align=
"center"
/>
<el-table-column
prop=
"model_name"
label=
"模型名称"
min-width=
"150"
show-overflow-tooltip
/>
<el-table-column
prop=
"package_name"
label=
"训练数据包"
min-width=
"140"
show-overflow-tooltip
/>
<el-table-column
label=
"LSTM 参数"
min-width=
"280"
show-overflow-tooltip
>
<
template
#
default=
"{ row }"
>
<el-tooltip
:content=
"formatParams(row.params)"
placement=
"top"
>
<span
class=
"params-text"
>
{{
formatParams
(
row
.
params
)
}}
</span>
</el-tooltip>
</
template
>
</el-table-column>
<el-table-column
label=
"训练损失"
min-width=
"190"
show-overflow-tooltip
>
<
template
#
default=
"{ row }"
>
<span
class=
"loss-text"
>
{{
formatLoss
(
row
)
}}
</span>
</
template
>
</el-table-column>
<el-table-column
prop=
"created_at"
label=
"保存时间"
width=
"170"
/>
<el-table-column
label=
"操作"
width=
"90"
fixed=
"right"
align=
"center"
>
<
template
#
default=
"{ row }"
>
<el-button
link
type=
"danger"
@
click=
"handleDelete(row)"
>
删除
</el-button>
</
template
>
</el-table-column>
</el-table>
<el-empty
v-if=
"!loading && models.length === 0"
description=
"暂无已保存的模型,请先在模型训练页面完成训练并保存"
style=
"padding: 40px 0"
/>
</el-card>
</div>
</template>
<
style
lang=
"scss"
scoped
>
.model-list-page
{
height
:
100%
;
overflow
:
hidden
;
background
:
var
(
--
bg-page
);
}
.list-card
{
height
:
100%
;
overflow
:
hidden
;
border-radius
:
4px
;
border
:
1px
solid
var
(
--
border-color
);
box-shadow
:
var
(
--
shadow-card
)
!
important
;
:deep
(
.el-card__header
)
{
padding
:
0
20px
;
height
:
44px
;
display
:
flex
;
align-items
:
center
;
border-bottom
:
1px
solid
var
(
--
border-color
);
background
:
#F7F8FA
;
}
:deep
(
.el-card__body
)
{
padding
:
16px
20px
;
height
:
calc
(
100%
-
44px
);
overflow
:
hidden
;
}
}
.card-title
{
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--
text-primary
);
}
.card-header-row
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
width
:
100%
;
}
.params-text
,
.loss-text
{
font-size
:
12px
;
color
:
var
(
--
text-secondary
);
white-space
:
nowrap
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
display
:
block
;
}
.loss-text
{
color
:
var
(
--
primary
);
font-weight
:
500
;
}
</
style
>
frontend/src/views/ModelTraining/index.vue
View file @
4d970f15
<
script
setup
>
import
{
Refresh
}
from
'@element-plus/icons-vue'
import
{
ElMessage
,
ElMessageBox
}
from
'element-plus'
import
{
computed
,
onBeforeUnmount
,
onMounted
,
reactive
,
ref
}
from
'vue'
import
{
cancelTrainTask
,
createTrainTask
,
deleteTrainTask
,
getTrainPackages
,
getTrainTasks
,
restartTrainTask
,
saveTrainModel
,
}
from
'@/api/trainManagement'
// ── form ──────────────────────────────────────────────────────────────────────
const
form
=
reactive
({
model_name
:
''
,
package_id
:
''
,
params
:
{
seq_len
:
20
,
hidden_size
:
64
,
num_layers
:
2
,
epochs
:
50
,
batch_size
:
32
,
learning_rate
:
0.001
,
train_ratio
:
0.8
,
},
})
const
packages
=
ref
([])
const
submitting
=
ref
(
false
)
// ── tasks table ───────────────────────────────────────────────────────────────
const
tasks
=
ref
([])
const
loadingTasks
=
ref
(
false
)
let
pollTimer
=
null
const
hasActiveTasks
=
computed
(()
=>
tasks
.
value
.
some
((
t
)
=>
t
.
status
===
'pending'
||
t
.
status
===
'running'
),
)
const
startPolling
=
()
=>
{
if
(
pollTimer
)
return
pollTimer
=
setInterval
(
loadTasks
,
3000
)
}
const
stopPolling
=
()
=>
{
if
(
pollTimer
)
{
clearInterval
(
pollTimer
)
pollTimer
=
null
}
}
const
loadTasks
=
async
()
=>
{
try
{
tasks
.
value
=
await
getTrainTasks
()
if
(
hasActiveTasks
.
value
)
{
startPolling
()
}
else
{
stopPolling
()
}
}
catch
{
// ignore poll errors silently
}
}
const
handleStartTraining
=
async
()
=>
{
if
(
!
form
.
model_name
.
trim
())
{
ElMessage
.
warning
(
'请输入模型名称'
)
return
}
if
(
!
form
.
package_id
)
{
ElMessage
.
warning
(
'请选择数据包'
)
return
}
submitting
.
value
=
true
try
{
await
createTrainTask
({
model_name
:
form
.
model_name
.
trim
(),
package_id
:
form
.
package_id
,
params
:
{
...
form
.
params
},
})
ElMessage
.
success
(
'训练任务已提交'
)
form
.
model_name
=
''
await
loadTasks
()
}
finally
{
submitting
.
value
=
false
}
}
const
handleCancel
=
async
(
task
)
=>
{
try
{
await
cancelTrainTask
(
task
.
id
)
ElMessage
.
success
(
'已发送取消请求'
)
await
loadTasks
()
}
catch
(
e
)
{
ElMessage
.
error
(
e
?.
message
||
'取消失败'
)
}
}
const
handleRestart
=
async
(
task
)
=>
{
try
{
await
restartTrainTask
(
task
.
id
)
ElMessage
.
success
(
'重新训练已启动'
)
await
loadTasks
()
}
catch
(
e
)
{
ElMessage
.
error
(
e
?.
message
||
'重启失败'
)
}
}
const
handleSave
=
async
(
task
)
=>
{
try
{
await
saveTrainModel
(
task
.
id
)
ElMessage
.
success
(
'模型已保存,可在模型列表中查看'
)
await
loadTasks
()
}
catch
(
e
)
{
ElMessage
.
error
(
e
?.
message
||
'保存失败'
)
}
}
const
handleDelete
=
async
(
task
)
=>
{
try
{
await
ElMessageBox
.
confirm
(
`确定删除训练任务"
${
task
.
model_name
}
"吗?`
,
'提示'
,
{
type
:
'warning'
,
})
await
deleteTrainTask
(
task
.
id
)
ElMessage
.
success
(
'已删除'
)
await
loadTasks
()
}
catch
{
// user cancelled
}
}
// ── display helpers ───────────────────────────────────────────────────────────
const
STATUS_MAP
=
{
pending
:
{
type
:
'info'
,
text
:
'等待中'
},
running
:
{
type
:
'warning'
,
text
:
'训练中'
},
completed
:
{
type
:
'success'
,
text
:
'已完成'
},
failed
:
{
type
:
'danger'
,
text
:
'失败'
},
cancelled
:
{
type
:
''
,
text
:
'已取消'
},
}
const
getStatusTag
=
(
status
)
=>
STATUS_MAP
[
status
]
||
{
type
:
'info'
,
text
:
status
}
const
formatParams
=
(
params
)
=>
{
if
(
!
params
)
return
'-'
return
[
`序列长度
${
params
.
seq_len
}
`
,
`隐藏层
${
params
.
hidden_size
}
`
,
`层数
${
params
.
num_layers
}
`
,
`轮数
${
params
.
epochs
}
`
,
`批次
${
params
.
batch_size
}
`
,
`学习率
${
params
.
learning_rate
}
`
,
`训练比
${
params
.
train_ratio
}
`
,
].
join
(
' / '
)
}
const
formatLoss
=
(
task
)
=>
{
if
(
task
.
train_loss
==
null
)
return
'-'
const
train
=
`训练:
${
Number
(
task
.
train_loss
).
toFixed
(
5
)}
`
const
val
=
task
.
val_loss
!=
null
?
` / 验证:
${
Number
(
task
.
val_loss
).
toFixed
(
5
)}
`
:
''
return
train
+
val
}
onMounted
(
async
()
=>
{
loadingTasks
.
value
=
true
try
{
await
Promise
.
all
([
getTrainPackages
().
then
((
d
)
=>
(
packages
.
value
=
d
)),
loadTasks
(),
])
}
finally
{
loadingTasks
.
value
=
false
}
})
onBeforeUnmount
(
stopPolling
)
</
script
>
<
template
>
<div
class=
"train-page"
>
<!-- ── config card ──────────────────────────────────────────────────── -->
<el-card
class=
"config-card"
shadow=
"hover"
>
<template
#
header
>
<span
class=
"card-title"
>
训练配置
</span>
</
template
>
<el-form
:model=
"form"
label-position=
"top"
class=
"train-form"
>
<!-- row 1: package + model name -->
<div
class=
"form-row"
>
<el-form-item
label=
"选择数据包"
required
class=
"form-item-wide"
>
<el-select
v-model=
"form.package_id"
placeholder=
"请选择数据包"
filterable
style=
"width: 100%"
>
<el-option
v-for=
"pkg in packages"
:key=
"pkg.id"
:label=
"`${pkg.name}(${pkg.data_count} 条)`"
:value=
"pkg.id"
/>
</el-select>
</el-form-item>
<el-form-item
label=
"模型名称"
required
class=
"form-item-wide"
>
<el-input
v-model=
"form.model_name"
placeholder=
"请输入模型名称"
maxlength=
"100"
show-word-limit
/>
</el-form-item>
</div>
<!-- row 2: LSTM params -->
<div
class=
"params-section"
>
<span
class=
"params-label"
>
LSTM 超参数
</span>
<div
class=
"params-grid"
>
<el-form-item
label=
"序列长度"
>
<el-input-number
v-model=
"form.params.seq_len"
:min=
"5"
:max=
"500"
:step=
"5"
controls-position=
"right"
style=
"width: 100%"
/>
</el-form-item>
<el-form-item
label=
"隐藏层大小"
>
<el-input-number
v-model=
"form.params.hidden_size"
:min=
"8"
:max=
"1024"
:step=
"8"
controls-position=
"right"
style=
"width: 100%"
/>
</el-form-item>
<el-form-item
label=
"LSTM 层数"
>
<el-input-number
v-model=
"form.params.num_layers"
:min=
"1"
:max=
"8"
controls-position=
"right"
style=
"width: 100%"
/>
</el-form-item>
<el-form-item
label=
"训练轮数 (Epochs)"
>
<el-input-number
v-model=
"form.params.epochs"
:min=
"1"
:max=
"2000"
:step=
"10"
controls-position=
"right"
style=
"width: 100%"
/>
</el-form-item>
<el-form-item
label=
"批次大小 (Batch)"
>
<el-input-number
v-model=
"form.params.batch_size"
:min=
"1"
:max=
"512"
:step=
"8"
controls-position=
"right"
style=
"width: 100%"
/>
</el-form-item>
<el-form-item
label=
"学习率"
>
<el-input-number
v-model=
"form.params.learning_rate"
:min=
"0.00001"
:max=
"1"
:step=
"0.0001"
:precision=
"5"
controls-position=
"right"
style=
"width: 100%"
/>
</el-form-item>
<el-form-item
label=
"训练集比例"
>
<el-input-number
v-model=
"form.params.train_ratio"
:min=
"0.5"
:max=
"0.99"
:step=
"0.05"
:precision=
"2"
controls-position=
"right"
style=
"width: 100%"
/>
</el-form-item>
</div>
</div>
<!-- action -->
<div
class=
"form-action"
>
<el-button
type=
"primary"
size=
"large"
:loading=
"submitting"
@
click=
"handleStartTraining"
>
开始训练
</el-button>
</div>
</el-form>
</el-card>
<!-- ── tasks table ──────────────────────────────────────────────────── -->
<el-card
class=
"tasks-card"
shadow=
"hover"
>
<
template
#
header
>
<div
class=
"card-header-row"
>
<span
class=
"card-title"
>
训练记录
</span>
<el-button
:icon=
"Refresh"
size=
"small"
plain
:loading=
"loadingTasks"
@
click=
"loadTasks"
>
刷新
</el-button>
</div>
</
template
>
<el-table
:data=
"tasks"
v-loading=
"loadingTasks"
border
stripe
:height=
"tableHeight"
>
<el-table-column
prop=
"model_name"
label=
"模型名称"
min-width=
"140"
show-overflow-tooltip
/>
<el-table-column
prop=
"package_name"
label=
"数据包"
min-width=
"130"
show-overflow-tooltip
/>
<el-table-column
label=
"参数"
min-width=
"180"
show-overflow-tooltip
>
<
template
#
default=
"{ row }"
>
<el-tooltip
:content=
"formatParams(row.params)"
placement=
"top"
>
<span
class=
"params-cell"
>
{{
formatParams
(
row
.
params
)
}}
</span>
</el-tooltip>
</
template
>
</el-table-column>
<el-table-column
label=
"状态"
width=
"100"
align=
"center"
>
<
template
#
default=
"{ row }"
>
<el-tag
:type=
"getStatusTag(row.status).type"
size=
"small"
>
{{
getStatusTag
(
row
.
status
).
text
}}
</el-tag>
</
template
>
</el-table-column>
<el-table-column
label=
"进度"
width=
"150"
align=
"center"
>
<
template
#
default=
"{ row }"
>
<template
v-if=
"row.status === 'running'"
>
<el-progress
:percentage=
"row.progress"
:stroke-width=
"6"
:show-text=
"true"
style=
"width: 120px"
/>
</
template
>
<
template
v-else-if=
"row.status === 'completed'"
>
<el-progress
:percentage=
"100"
status=
"success"
:stroke-width=
"6"
style=
"width: 120px"
/>
</
template
>
<span
v-else
class=
"muted"
>
—
</span>
</template>
</el-table-column>
<el-table-column
label=
"损失"
min-width=
"200"
show-overflow-tooltip
>
<
template
#
default=
"{ row }"
>
<span
v-if=
"row.status === 'failed'"
class=
"err-text"
>
{{
row
.
error_msg
||
'未知错误'
}}
</span>
<span
v-else
>
{{
formatLoss
(
row
)
}}
</span>
</
template
>
</el-table-column>
<el-table-column
prop=
"created_at"
label=
"创建时间"
width=
"160"
/>
<el-table-column
label=
"操作"
width=
"210"
fixed=
"right"
>
<
template
#
default=
"{ row }"
>
<!-- running / pending -->
<template
v-if=
"row.status === 'running' || row.status === 'pending'"
>
<el-button
link
type=
"warning"
@
click=
"handleCancel(row)"
>
取消
</el-button>
</
template
>
<!-- completed -->
<
template
v-else-if=
"row.status === 'completed'"
>
<el-button
v-if=
"!row.is_saved"
link
type=
"primary"
@
click=
"handleSave(row)"
>
保存模型
</el-button>
<el-tag
v-else
type=
"success"
size=
"small"
style=
"margin-right: 6px"
>
已保存
</el-tag>
<el-button
link
type=
"info"
@
click=
"handleRestart(row)"
>
重新训练
</el-button>
<el-button
link
type=
"danger"
@
click=
"handleDelete(row)"
>
删除
</el-button>
</
template
>
<!-- failed / cancelled -->
<
template
v-else
>
<el-button
link
type=
"info"
@
click=
"handleRestart(row)"
>
重新训练
</el-button>
<el-button
link
type=
"danger"
@
click=
"handleDelete(row)"
>
删除
</el-button>
</
template
>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<
script
>
// tableHeight is a non-reactive calculation; compute once
const
tableHeight
=
'calc(100vh - 520px)'
export
default
{}
</
script
>
<
style
lang=
"scss"
scoped
>
.train-page
{
height
:
100%
;
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
overflow-y
:
auto
;
background
:
var
(
--
bg-page
);
}
.config-card
,
.tasks-card
{
flex-shrink
:
0
;
border
:
1px
solid
var
(
--
border-color
);
box-shadow
:
var
(
--
shadow-card
)
!
important
;
border-radius
:
4px
;
:deep
(
.el-card__header
)
{
padding
:
0
20px
;
height
:
44px
;
display
:
flex
;
align-items
:
center
;
border-bottom
:
1px
solid
var
(
--
border-color
);
background
:
#F7F8FA
;
}
:deep
(
.el-card__body
)
{
padding
:
16px
20px
;
}
}
.tasks-card
{
flex
:
1
;
min-height
:
0
;
overflow
:
hidden
;
}
.card-title
{
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--
text-primary
);
}
.card-header-row
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
width
:
100%
;
}
.train-form
{
.form-row
{
display
:
flex
;
gap
:
24px
;
flex-wrap
:
wrap
;
margin-bottom
:
4px
;
}
.form-item-wide
{
flex
:
1
;
min-width
:
220px
;
:deep
(
.el-form-item__content
)
{
display
:
block
;
}
}
}
.params-section
{
border-top
:
1px
solid
var
(
--
border-color
);
padding-top
:
12px
;
margin-bottom
:
8px
;
}
.params-label
{
display
:
block
;
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--
text-secondary
);
margin-bottom
:
12px
;
}
.params-grid
{
display
:
grid
;
grid-template-columns
:
repeat
(
auto-fill
,
minmax
(
160px
,
1fr
));
gap
:
12px
24px
;
:deep
(
.el-form-item
)
{
margin-bottom
:
0
;
}
}
.form-action
{
border-top
:
1px
solid
var
(
--
border-color
);
padding-top
:
16px
;
text-align
:
right
;
}
.params-cell
{
font-size
:
12px
;
color
:
var
(
--
text-secondary
);
white-space
:
nowrap
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
display
:
block
;
}
.muted
{
color
:
var
(
--
text-tertiary
);
}
.err-text
{
color
:
#F53F3F
;
font-size
:
12px
;
white-space
:
nowrap
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
display
:
block
;
}
</
style
>
frontend/src/views/PackageManagement/components/AddPackage.vue
0 → 100644
View file @
4d970f15
<
script
setup
>
import
{
ArrowLeft
}
from
'@element-plus/icons-vue'
import
{
ElMessage
}
from
'element-plus'
import
{
computed
,
onMounted
,
reactive
,
ref
,
watch
}
from
'vue'
import
{
createPackage
,
getAllDataFiles
,
getPkgCategoryTree
,
previewPackage
}
from
'@/api/packageManagement'
import
DataCurve
from
'@/views/DataManagement/components/DataCurve.vue'
const
emit
=
defineEmits
([
'cancel'
,
'saved'
])
// ── data files ────────────────────────────────────────────────────────────────
const
allFiles
=
ref
([])
const
selectedFileIds
=
ref
([])
const
fileTableRef
=
ref
(
null
)
const
fileSearchText
=
ref
(
''
)
const
filteredFiles
=
computed
(()
=>
{
const
kw
=
fileSearchText
.
value
.
trim
().
toLowerCase
()
if
(
!
kw
)
return
allFiles
.
value
return
allFiles
.
value
.
filter
(
(
f
)
=>
f
.
filename
.
toLowerCase
().
includes
(
kw
)
||
(
f
.
category_name
||
''
).
toLowerCase
().
includes
(
kw
),
)
})
const
handleFileSelectionChange
=
(
rows
)
=>
{
selectedFileIds
.
value
=
rows
.
map
((
r
)
=>
r
.
id
)
}
// ── categories ────────────────────────────────────────────────────────────────
const
categoryOptions
=
ref
([])
const
loadCategories
=
async
()
=>
{
const
data
=
await
getPkgCategoryTree
()
const
source
=
Array
.
isArray
(
data
)
?
data
:
[]
categoryOptions
.
value
=
source
.
filter
((
item
)
=>
String
(
item
?.
id
)
!==
'all'
)
.
map
((
item
)
=>
({
label
:
item
.
name
,
value
:
String
(
item
.
id
)
}))
}
// ── form ──────────────────────────────────────────────────────────────────────
const
form
=
reactive
({
categoryId
:
''
,
name
:
''
,
remark
:
''
,
})
// ── preview ───────────────────────────────────────────────────────────────────
const
previewLoading
=
ref
(
false
)
const
previewRecords
=
ref
([])
const
previewTotal
=
ref
(
0
)
const
previewMode
=
ref
(
'table'
)
let
previewDebounceTimer
=
null
const
triggerPreview
=
()
=>
{
clearTimeout
(
previewDebounceTimer
)
if
(
!
selectedFileIds
.
value
.
length
)
{
previewRecords
.
value
=
[]
previewTotal
.
value
=
0
return
}
previewDebounceTimer
=
setTimeout
(
async
()
=>
{
previewLoading
.
value
=
true
try
{
const
result
=
await
previewPackage
({
file_ids
:
selectedFileIds
.
value
},
{
limit
:
300
})
previewRecords
.
value
=
result
.
records
previewTotal
.
value
=
result
.
count
}
finally
{
previewLoading
.
value
=
false
}
},
600
)
}
watch
(
selectedFileIds
,
triggerPreview
,
{
deep
:
true
})
// ── save ──────────────────────────────────────────────────────────────────────
const
saving
=
ref
(
false
)
const
handleGenerate
=
async
()
=>
{
if
(
!
form
.
name
.
trim
())
{
ElMessage
.
warning
(
'请输入数据包名称'
)
return
}
if
(
!
selectedFileIds
.
value
.
length
)
{
ElMessage
.
warning
(
'请至少选择一个数据文件'
)
return
}
saving
.
value
=
true
try
{
await
createPackage
({
name
:
form
.
name
.
trim
(),
category_id
:
form
.
categoryId
||
null
,
remark
:
form
.
remark
.
trim
()
||
null
,
file_ids
:
selectedFileIds
.
value
,
})
ElMessage
.
success
(
'数据包创建成功'
)
emit
(
'saved'
)
}
finally
{
saving
.
value
=
false
}
}
// ── init ──────────────────────────────────────────────────────────────────────
onMounted
(
async
()
=>
{
const
[
filesData
]
=
await
Promise
.
all
([
getAllDataFiles
(),
loadCategories
()])
allFiles
.
value
=
filesData
})
</
script
>
<
template
>
<div
class=
"add-pkg-page"
>
<!-- header -->
<div
class=
"add-pkg-header"
>
<el-button
:icon=
"ArrowLeft"
plain
@
click=
"emit('cancel')"
>
返回列表
</el-button>
<span
class=
"add-pkg-title"
>
新增数据包
</span>
</div>
<!-- top: file selection -->
<div
class=
"section-card file-section"
>
<div
class=
"section-title"
>
选择数据文件
<span
class=
"section-hint"
>
已选
{{
selectedFileIds
.
length
}}
个文件
</span>
</div>
<div
class=
"file-search-bar"
>
<el-input
v-model=
"fileSearchText"
placeholder=
"按文件名或分类搜索"
clearable
style=
"width: 280px"
/>
</div>
<el-table
ref=
"fileTableRef"
:data=
"filteredFiles"
border
stripe
height=
"220"
@
selection-change=
"handleFileSelectionChange"
>
<el-table-column
type=
"selection"
width=
"46"
/>
<el-table-column
prop=
"filename"
label=
"文件名"
min-width=
"200"
show-overflow-tooltip
/>
<el-table-column
prop=
"category_name"
label=
"所属分类"
width=
"130"
show-overflow-tooltip
/>
<el-table-column
prop=
"data_count"
label=
"数据量"
width=
"80"
align=
"center"
/>
<el-table-column
prop=
"uploaded_at"
label=
"上传时间"
width=
"160"
/>
</el-table>
</div>
<!-- bottom: form + preview -->
<div
class=
"section-bottom"
>
<!-- left: form -->
<div
class=
"section-card form-section"
>
<div
class=
"section-title"
>
生成数据包
</div>
<el-form
label-position=
"top"
class=
"pkg-form"
>
<el-form-item
label=
"数据包分类"
>
<el-select
v-model=
"form.categoryId"
placeholder=
"请选择(可选)"
clearable
style=
"width: 100%"
>
<el-option
v-for=
"item in categoryOptions"
:key=
"item.value"
:label=
"item.label"
:value=
"item.value"
/>
</el-select>
</el-form-item>
<el-form-item
label=
"数据包名称"
required
>
<el-input
v-model=
"form.name"
maxlength=
"100"
show-word-limit
placeholder=
"请输入数据包名称"
/>
</el-form-item>
<el-form-item
label=
"备注"
>
<el-input
v-model=
"form.remark"
type=
"textarea"
:rows=
"3"
maxlength=
"500"
show-word-limit
placeholder=
"备注(可选)"
/>
</el-form-item>
<el-form-item>
<el-button
type=
"primary"
:loading=
"saving"
style=
"width: 100%"
@
click=
"handleGenerate"
>
生成数据包
</el-button>
</el-form-item>
</el-form>
</div>
<!-- right: preview -->
<div
class=
"section-card preview-section"
>
<div
class=
"section-title"
>
生成结果预览
<span
v-if=
"previewTotal"
class=
"section-hint"
>
共
{{
previewTotal
}}
条(展示前 300 条)
</span>
<el-radio-group
v-model=
"previewMode"
size=
"small"
style=
"margin-left: auto"
>
<el-radio-button
value=
"table"
>
表格
</el-radio-button>
<el-radio-button
value=
"curve"
>
曲线
</el-radio-button>
</el-radio-group>
</div>
<div
class=
"preview-content"
v-loading=
"previewLoading"
>
<el-empty
v-if=
"!previewLoading && !previewRecords.length"
description=
"请在上方选择数据文件,预览将自动更新"
:image-size=
"80"
/>
<el-table
v-else-if=
"previewMode === 'table'"
:data=
"previewRecords"
border
stripe
height=
"100%"
>
<el-table-column
prop=
"time"
label=
"时间"
min-width=
"140"
/>
<el-table-column
prop=
"current"
label=
"电流"
min-width=
"80"
/>
<el-table-column
prop=
"voltage"
label=
"电压"
min-width=
"80"
/>
<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=
"previewRecords"
/>
</div>
</div>
</div>
</div>
</
template
>
<
style
lang=
"scss"
scoped
>
.add-pkg-page
{
height
:
100%
;
display
:
flex
;
flex-direction
:
column
;
gap
:
0
;
overflow
:
hidden
;
}
.add-pkg-header
{
display
:
flex
;
align-items
:
center
;
gap
:
16px
;
padding
:
12px
20px
;
background
:
rgba
(
255
,
255
,
255
,
0
.9
);
border-bottom
:
1px
solid
#e8edf3
;
flex-shrink
:
0
;
}
.add-pkg-title
{
font-size
:
16px
;
font-weight
:
600
;
color
:
#0f172a
;
}
.section-card
{
background
:
#fff
;
border
:
1px
solid
#e8edf3
;
border-radius
:
8px
;
padding
:
16px
;
overflow
:
hidden
;
}
.section-title
{
font-size
:
14px
;
font-weight
:
600
;
color
:
#0f172a
;
margin-bottom
:
12px
;
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
}
.section-hint
{
font-size
:
12px
;
font-weight
:
400
;
color
:
#64748b
;
}
.file-search-bar
{
margin-bottom
:
10px
;
}
.file-section
{
flex-shrink
:
0
;
margin
:
12px
12px
0
;
}
.section-bottom
{
flex
:
1
;
display
:
flex
;
gap
:
12px
;
padding
:
12px
;
overflow
:
hidden
;
min-height
:
0
;
}
.form-section
{
width
:
320px
;
flex-shrink
:
0
;
overflow-y
:
auto
;
}
.preview-section
{
flex
:
1
;
display
:
flex
;
flex-direction
:
column
;
min-width
:
0
;
overflow
:
hidden
;
.section-title
{
flex-shrink
:
0
;
}
}
.preview-content
{
flex
:
1
;
overflow
:
hidden
;
min-height
:
0
;
}
.pkg-form
{
:deep
(
.el-form-item
)
{
margin-bottom
:
16px
;
}
}
</
style
>
frontend/src/views/PackageManagement/components/PkgCategoryTree.vue
0 → 100644
View file @
4d970f15
<
script
setup
>
import
{
Delete
,
Edit
,
Folder
,
FolderOpened
}
from
'@element-plus/icons-vue'
import
{
ElMessage
,
ElMessageBox
}
from
'element-plus'
import
{
onMounted
,
reactive
,
ref
,
computed
}
from
'vue'
import
{
createPkgCategory
,
deletePkgCategory
,
getPkgCategoryTree
,
updatePkgCategory
}
from
'@/api/packageManagement'
const
props
=
defineProps
({
modelValue
:
{
type
:
[
String
,
Number
],
default
:
'all'
,
},
style
:
{
type
:
Object
,
default
:
()
=>
({}),
},
})
const
emit
=
defineEmits
([
'update:modelValue'
,
'select'
])
const
categoryTree
=
ref
([])
const
dialogVisible
=
ref
(
false
)
const
dialogMode
=
ref
(
'create'
)
const
editingId
=
ref
(
''
)
const
form
=
reactive
({
name
:
''
})
const
dialogTitle
=
computed
(()
=>
(
dialogMode
.
value
===
'create'
?
'新增分类'
:
'编辑分类'
))
const
normalizeCategoryTree
=
(
treeData
)
=>
{
const
source
=
Array
.
isArray
(
treeData
)
?
treeData
:
[]
const
children
=
source
.
filter
((
item
)
=>
String
(
item
?.
id
)
!==
'all'
)
.
map
((
item
)
=>
({
id
:
item
.
id
,
name
:
item
.
name
,
parent_id
:
item
.
parent_id
,
children
:
[]
}))
return
[{
id
:
'all'
,
name
:
'全部'
,
parent_id
:
null
,
children
}]
}
const
loadCategoryTree
=
async
()
=>
{
const
data
=
await
getPkgCategoryTree
()
categoryTree
.
value
=
normalizeCategoryTree
(
data
)
const
allIds
=
[
...
categoryTree
.
value
.
map
((
item
)
=>
String
(
item
.
id
)),
...
categoryTree
.
value
.
flatMap
((
item
)
=>
(
item
.
children
||
[]).
map
((
c
)
=>
String
(
c
.
id
))),
]
if
(
!
allIds
.
includes
(
String
(
props
.
modelValue
)))
{
emit
(
'update:modelValue'
,
'all'
)
emit
(
'select'
,
'all'
)
}
}
const
handleNodeClick
=
(
node
)
=>
{
emit
(
'update:modelValue'
,
node
.
id
)
emit
(
'select'
,
node
.
id
)
}
const
openCreate
=
()
=>
{
dialogMode
.
value
=
'create'
editingId
.
value
=
''
form
.
name
=
''
dialogVisible
.
value
=
true
}
const
openEdit
=
(
node
)
=>
{
dialogMode
.
value
=
'edit'
editingId
.
value
=
node
.
id
form
.
name
=
node
.
name
dialogVisible
.
value
=
true
}
const
submitCategory
=
async
()
=>
{
const
name
=
form
.
name
.
trim
()
if
(
!
name
)
{
ElMessage
.
warning
(
'请输入分类名称'
)
return
}
if
(
dialogMode
.
value
===
'create'
)
{
await
createPkgCategory
({
name
,
parent_id
:
null
})
}
else
{
await
updatePkgCategory
(
editingId
.
value
,
{
name
})
}
dialogVisible
.
value
=
false
ElMessage
.
success
(
'分类保存成功'
)
await
loadCategoryTree
()
}
const
handleDelete
=
async
(
node
)
=>
{
try
{
await
ElMessageBox
.
confirm
(
`确定删除分类"
${
node
.
name
}
"吗?`
,
'提示'
,
{
type
:
'warning'
})
await
deletePkgCategory
(
node
.
id
)
ElMessage
.
success
(
'分类已删除'
)
if
(
String
(
props
.
modelValue
)
===
String
(
node
.
id
))
{
emit
(
'update:modelValue'
,
'all'
)
emit
(
'select'
,
'all'
)
}
await
loadCategoryTree
()
}
catch
{
// user cancelled
}
}
onMounted
(
loadCategoryTree
)
</
script
>
<
template
>
<el-card
class=
"pane-card"
shadow=
"hover"
:style=
"props.style"
>
<template
#
header
>
<div
class=
"pane-header"
>
<span>
分类树
</span>
<el-button
type=
"primary"
plain
size=
"small"
@
click=
"openCreate"
>
新增
</el-button>
</div>
</
template
>
<el-tree
:data=
"categoryTree"
node-key=
"id"
highlight-current
default-expand-all
:current-node-key=
"modelValue || undefined"
:expand-on-click-node=
"false"
@
node-click=
"handleNodeClick"
>
<
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>
</div>
<span
v-if=
"data.id !== 'all'"
class=
"tree-actions"
>
<el-button
link
type=
"primary"
:icon=
"Edit"
@
click
.
stop=
"openEdit(data)"
/>
<el-button
link
type=
"danger"
:icon=
"Delete"
@
click
.
stop=
"handleDelete(data)"
/>
</span>
</div>
</
template
>
</el-tree>
</el-card>
<el-dialog
v-model=
"dialogVisible"
:title=
"dialogTitle"
width=
"420px"
>
<el-form
label-position=
"top"
>
<el-form-item
label=
"分类名称"
required
>
<el-input
v-model=
"form.name"
maxlength=
"100"
show-word-limit
/>
</el-form-item>
</el-form>
<
template
#
footer
>
<el-button
@
click=
"dialogVisible = false"
>
取消
</el-button>
<el-button
type=
"primary"
@
click=
"submitCategory"
>
保存
</el-button>
</
template
>
</el-dialog>
</template>
<
style
lang=
"scss"
scoped
>
.pane-card
{
height
:
100%
;
overflow
:
hidden
;
border-radius
:
0
;
border
:
none
;
border-right
:
1px
solid
var
(
--
border-color
);
box-shadow
:
none
!
important
;
display
:
flex
;
flex-direction
:
column
;
background
:
var
(
--
bg-white
);
:deep
(
.el-card__header
)
{
padding
:
0
16px
;
height
:
44px
;
display
:
flex
;
align-items
:
center
;
border-bottom
:
1px
solid
var
(
--
border-color
);
background
:
#F7F8FA
;
}
:deep
(
.el-card__body
)
{
flex
:
1
;
overflow-y
:
auto
;
padding
:
8px
0
;
}
}
.pane-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
width
:
100%
;
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--
text-primary
);
}
.tree-node
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
width
:
100%
;
min-width
:
0
;
padding-right
:
4px
;
}
.tree-main
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
min-width
:
0
;
}
.tree-icon
{
color
:
var
(
--
primary
);
flex-shrink
:
0
;
font-size
:
14px
;
}
.tree-name
{
font-size
:
13px
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
color
:
var
(
--
text-primary
);
}
.tree-actions
{
display
:
none
;
flex-shrink
:
0
;
gap
:
0
;
.el-button
{
padding
:
0
3px
;
font-size
:
13px
;
color
:
var
(
--
text-tertiary
);
&
:hover
{
color
:
var
(
--
primary
);
}
}
}
.tree-node
:hover
.tree-actions
{
display
:
inline-flex
;
}
</
style
>
frontend/src/views/PackageManagement/components/PkgDetail.vue
0 → 100644
View file @
4d970f15
<
script
setup
>
import
{
ref
,
watch
}
from
'vue'
import
{
getPackageRecords
}
from
'@/api/packageManagement'
import
DataCurve
from
'@/views/DataManagement/components/DataCurve.vue'
const
props
=
defineProps
({
package
:
{
type
:
Object
,
default
:
null
,
},
style
:
{
type
:
Object
,
default
:
()
=>
({}),
},
})
const
loading
=
ref
(
false
)
const
records
=
ref
([])
const
contentMode
=
ref
(
'table'
)
const
loadRecords
=
async
(
pkg
)
=>
{
if
(
!
pkg
)
{
records
.
value
=
[]
return
}
loading
.
value
=
true
try
{
const
result
=
await
getPackageRecords
(
pkg
.
id
,
{
limit
:
500
})
records
.
value
=
result
.
records
}
finally
{
loading
.
value
=
false
}
}
watch
(()
=>
props
.
package
,
(
pkg
)
=>
{
contentMode
.
value
=
'table'
loadRecords
(
pkg
)
},
{
immediate
:
true
})
</
script
>
<
template
>
<el-card
class=
"pane-card"
shadow=
"hover"
:style=
"props.style"
>
<template
#
header
>
<div
class=
"pane-header"
>
<span>
数据包内容
<span
v-if=
"props.package"
class=
"pkg-title"
>
—
{{
props
.
package
.
name
}}
</span>
</span>
<el-radio-group
v-model=
"contentMode"
size=
"small"
>
<el-radio-button
value=
"table"
>
表格
</el-radio-button>
<el-radio-button
value=
"curve"
>
曲线
</el-radio-button>
</el-radio-group>
</div>
</
template
>
<div
class=
"content-wrap"
v-loading=
"loading"
>
<el-empty
v-if=
"!props.package"
description=
"请选择一个数据包查看"
/>
<el-table
v-else-if=
"contentMode === 'table'"
:data=
"records"
border
stripe
height=
"calc(100vh - 250px)"
>
<el-table-column
prop=
"time"
label=
"时间"
min-width=
"140"
/>
<el-table-column
prop=
"current"
label=
"电流"
min-width=
"90"
/>
<el-table-column
prop=
"voltage"
label=
"电压"
min-width=
"90"
/>
<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=
"records"
/>
</div>
</el-card>
</template>
<
style
lang=
"scss"
scoped
>
.pane-card
{
height
:
100%
;
overflow
:
hidden
;
border-radius
:
0
;
border
:
none
;
box-shadow
:
none
!
important
;
display
:
flex
;
flex-direction
:
column
;
background
:
var
(
--
bg-white
);
:deep
(
.el-card__header
)
{
padding
:
0
16px
;
height
:
44px
;
display
:
flex
;
align-items
:
center
;
border-bottom
:
1px
solid
var
(
--
border-color
);
background
:
#F7F8FA
;
}
:deep
(
.el-card__body
)
{
flex
:
1
;
overflow
:
hidden
;
padding
:
0
;
}
}
.pane-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
width
:
100%
;
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--
text-primary
);
}
.pkg-title
{
font-weight
:
400
;
color
:
var
(
--
text-secondary
);
font-size
:
13px
;
margin-left
:
4px
;
}
.content-wrap
{
height
:
100%
;
padding
:
0
16px
12px
;
}
:deep
(
.el-radio-group
)
{
.el-radio-button__inner
{
background
:
transparent
;
color
:
var
(
--
text-secondary
);
border-color
:
var
(
--
border-color
);
font-size
:
13px
;
padding
:
4px
12px
;
}
.el-radio-button__original-radio
:checked
+
.el-radio-button__inner
{
background
:
var
(
--
primary
)
!
important
;
border-color
:
var
(
--
primary
)
!
important
;
color
:
#fff
;
box-shadow
:
none
!
important
;
}
}
</
style
>
frontend/src/views/PackageManagement/components/PkgList.vue
0 → 100644
View file @
4d970f15
<
script
setup
>
import
{
Plus
,
Search
}
from
'@element-plus/icons-vue'
import
{
ElMessage
,
ElMessageBox
}
from
'element-plus'
import
{
onMounted
,
reactive
,
ref
,
watch
}
from
'vue'
import
{
deletePackage
,
getPackages
}
from
'@/api/packageManagement'
const
props
=
defineProps
({
categoryId
:
{
type
:
[
String
,
Number
],
default
:
'all'
,
},
refreshKey
:
{
type
:
Number
,
default
:
0
,
},
style
:
{
type
:
Object
,
default
:
()
=>
({}),
},
})
const
emit
=
defineEmits
([
'view'
,
'add'
])
const
loading
=
ref
(
false
)
const
packageList
=
ref
([])
const
searchName
=
ref
(
''
)
const
currentPackage
=
ref
(
null
)
const
loadPackages
=
async
()
=>
{
loading
.
value
=
true
try
{
const
data
=
await
getPackages
({
category_id
:
props
.
categoryId
&&
String
(
props
.
categoryId
)
!==
'all'
?
String
(
props
.
categoryId
)
:
''
,
name
:
searchName
.
value
.
trim
(),
})
packageList
.
value
=
data
}
finally
{
loading
.
value
=
false
}
}
const
handleSearch
=
()
=>
loadPackages
()
const
handleReset
=
()
=>
{
searchName
.
value
=
''
loadPackages
()
}
const
handleView
=
(
row
)
=>
{
currentPackage
.
value
=
row
emit
(
'view'
,
row
)
}
const
handleDelete
=
async
(
row
)
=>
{
try
{
await
ElMessageBox
.
confirm
(
`确定删除数据包"
${
row
.
name
}
"吗?`
,
'提示'
,
{
type
:
'warning'
})
await
deletePackage
(
row
.
id
)
ElMessage
.
success
(
'数据包已删除'
)
if
(
currentPackage
.
value
?.
id
===
row
.
id
)
{
currentPackage
.
value
=
null
}
await
loadPackages
()
}
catch
{
// user cancelled
}
}
watch
(()
=>
props
.
categoryId
,
loadPackages
)
watch
(()
=>
props
.
refreshKey
,
()
=>
{
currentPackage
.
value
=
null
loadPackages
()
})
onMounted
(
loadPackages
)
</
script
>
<
template
>
<el-card
class=
"pane-card"
shadow=
"hover"
:style=
"props.style"
>
<template
#
header
>
<div
class=
"pane-header"
>
<span>
数据包列表
</span>
<el-button
type=
"primary"
:icon=
"Plus"
size=
"small"
@
click=
"emit('add')"
>
新增数据包
</el-button>
</div>
</
template
>
<div
class=
"search-bar"
>
<el-input
v-model=
"searchName"
placeholder=
"数据包名称"
clearable
:prefix-icon=
"Search"
@
keyup
.
enter=
"handleSearch"
@
clear=
"handleReset"
/>
<el-button
type=
"primary"
@
click=
"handleSearch"
>
搜索
</el-button>
<el-button
@
click=
"handleReset"
>
重置
</el-button>
</div>
<el-table
:data=
"packageList"
v-loading=
"loading"
border
stripe
highlight-current-row
height=
"calc(100vh - 265px)"
:row-class-name=
"({ row }) => (currentPackage?.id === row.id ? 'current-row' : '')"
>
<el-table-column
prop=
"name"
label=
"数据包名称"
min-width=
"150"
show-overflow-tooltip
/>
<el-table-column
prop=
"created_at"
label=
"创建时间"
min-width=
"160"
/>
<el-table-column
prop=
"data_count"
label=
"数据量"
width=
"80"
align=
"center"
/>
<el-table-column
label=
"操作"
width=
"130"
fixed=
"right"
>
<
template
#
default=
"{ row }"
>
<el-button
link
type=
"primary"
@
click=
"handleView(row)"
>
查看
</el-button>
<el-button
link
type=
"danger"
@
click=
"handleDelete(row)"
>
删除
</el-button>
</
template
>
</el-table-column>
</el-table>
</el-card>
</template>
<
style
lang=
"scss"
scoped
>
.pane-card
{
height
:
100%
;
overflow
:
hidden
;
border-radius
:
0
;
border
:
none
;
border-right
:
1px
solid
var
(
--
border-color
);
box-shadow
:
none
!
important
;
display
:
flex
;
flex-direction
:
column
;
background
:
var
(
--
bg-white
);
:deep
(
.el-card__header
)
{
padding
:
0
16px
;
height
:
44px
;
display
:
flex
;
align-items
:
center
;
border-bottom
:
1px
solid
var
(
--
border-color
);
background
:
#F7F8FA
;
}
:deep
(
.el-card__body
)
{
flex
:
1
;
overflow
:
hidden
;
padding
:
12px
16px
;
}
}
.pane-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
width
:
100%
;
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--
text-primary
);
}
.search-bar
{
display
:
flex
;
gap
:
8px
;
margin-bottom
:
12px
;
.el-input
{
flex
:
1
;
}
}
:deep
(
.el-table
.current-row
>
td
)
{
background
:
var
(
--
primary-light
)
!
important
;
}
:deep
(
.el-table
.el-button.is-link
)
{
font-size
:
13px
;
padding
:
0
4px
;
height
:
auto
;
line-height
:
1
;
}
</
style
>
frontend/src/views/PackageManagement/index.vue
View file @
4d970f15
<
script
setup
>
import
{
computed
,
onBeforeUnmount
,
onMounted
,
ref
}
from
'vue'
import
PkgCategoryTree
from
'./components/PkgCategoryTree.vue'
import
PkgList
from
'./components/PkgList.vue'
import
PkgDetail
from
'./components/PkgDetail.vue'
import
AddPackage
from
'./components/AddPackage.vue'
// ── view state ────────────────────────────────────────────────────────────────
const
currentView
=
ref
(
'list'
)
// 'list' | 'add'
const
selectedCategoryId
=
ref
(
'all'
)
const
selectedPackage
=
ref
(
null
)
const
refreshKey
=
ref
(
0
)
const
handleCategorySelect
=
(
categoryId
)
=>
{
selectedCategoryId
.
value
=
categoryId
selectedPackage
.
value
=
null
}
const
handleViewPackage
=
(
pkg
)
=>
{
selectedPackage
.
value
=
pkg
}
const
handleAddPackage
=
()
=>
{
currentView
.
value
=
'add'
}
const
handlePackageSaved
=
()
=>
{
currentView
.
value
=
'list'
refreshKey
.
value
++
selectedPackage
.
value
=
null
}
const
handleAddCancel
=
()
=>
{
currentView
.
value
=
'list'
}
// ── drag-resize layout ────────────────────────────────────────────────────────
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
=
'pkg-management-layout-v1'
const
MIN_LEFT
=
12
const
MAX_LEFT
=
24
const
MIN_MIDDLE
=
30
const
MAX_MIDDLE
=
56
const
MIN_RIGHT
=
24
const
rightPaneWidth
=
computed
(()
=>
Number
((
100
-
leftPaneWidth
.
value
-
middlePaneWidth
.
value
).
toFixed
(
2
)),
)
const
setPaneWidths
=
(
nextLeft
,
nextMiddle
,
target
=
'left'
)
=>
{
let
left
=
Math
.
min
(
Math
.
max
(
nextLeft
,
MIN_LEFT
),
MAX_LEFT
)
let
middle
=
Math
.
min
(
Math
.
max
(
nextMiddle
,
MIN_MIDDLE
),
MAX_MIDDLE
)
if
(
100
-
left
-
middle
<
MIN_RIGHT
)
{
if
(
target
===
'left'
)
{
left
=
100
-
middle
-
MIN_RIGHT
if
(
left
<
MIN_LEFT
)
{
left
=
MIN_LEFT
middle
=
100
-
left
-
MIN_RIGHT
}
}
else
{
middle
=
100
-
left
-
MIN_RIGHT
if
(
middle
<
MIN_MIDDLE
)
{
middle
=
MIN_MIDDLE
left
=
100
-
middle
-
MIN_RIGHT
}
}
}
leftPaneWidth
.
value
=
Number
(
left
.
toFixed
(
2
))
middlePaneWidth
.
value
=
Number
(
middle
.
toFixed
(
2
))
}
const
loadLayoutCache
=
()
=>
{
const
raw
=
localStorage
.
getItem
(
layoutStorageKey
)
if
(
!
raw
)
return
try
{
const
parsed
=
JSON
.
parse
(
raw
)
if
(
typeof
parsed
.
left
===
'number'
&&
typeof
parsed
.
middle
===
'number'
)
{
setPaneWidths
(
parsed
.
left
,
parsed
.
middle
,
'middle'
)
}
}
catch
{
localStorage
.
removeItem
(
layoutStorageKey
)
}
}
const
saveLayoutCache
=
()
=>
{
localStorage
.
setItem
(
layoutStorageKey
,
JSON
.
stringify
({
left
:
leftPaneWidth
.
value
,
middle
:
middlePaneWidth
.
value
}),
)
}
const
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
=
()
=>
{
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
)
=>
{
if
(
!
dragState
.
value
)
return
pendingDragX
.
value
=
event
.
clientX
if
(
!
dragFrameId
)
{
dragFrameId
=
requestAnimationFrame
(
flushDragging
)
}
}
const
startDragging
=
(
target
,
event
)
=>
{
if
(
window
.
innerWidth
<=
980
||
!
layoutRef
.
value
)
return
event
.
preventDefault
()
stopDragging
()
dragState
.
value
=
{
target
,
startX
:
event
.
clientX
,
startLeft
:
leftPaneWidth
.
value
,
startMiddle
:
middlePaneWidth
.
value
,
layoutWidth
:
layoutRef
.
value
.
clientWidth
,
}
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
)
}
onMounted
(
loadLayoutCache
)
onBeforeUnmount
(
stopDragging
)
</
script
>
<
template
>
<section
class=
"pkg-page"
:class=
"
{ dragging: !!dragState }">
<!-- add package sub-page -->
<AddPackage
v-if=
"currentView === 'add'"
@
saved=
"handlePackageSaved"
@
cancel=
"handleAddCancel"
/>
<!-- list view: three-pane layout -->
<div
v-else
ref=
"layoutRef"
class=
"pkg-layout"
>
<PkgCategoryTree
:model-value=
"selectedCategoryId"
:style=
"
{ width: `${leftPaneWidth}%` }"
@update:model-value="(v) => (selectedCategoryId = v)"
@select="handleCategorySelect"
/>
<div
class=
"pane-divider"
@
mousedown
.
prevent=
"(e) => startDragging('left', e)"
/>
<PkgList
:category-id=
"selectedCategoryId"
:refresh-key=
"refreshKey"
:style=
"
{ width: `${middlePaneWidth}%` }"
@view="handleViewPackage"
@add="handleAddPackage"
/>
<div
class=
"pane-divider"
@
mousedown
.
prevent=
"(e) => startDragging('middle', e)"
/>
<PkgDetail
:package=
"selectedPackage"
:style=
"
{ width: `${rightPaneWidth}%` }"
/>
</div>
</section>
</
template
>
<
style
lang=
"scss"
scoped
>
.pkg-page
{
height
:
100%
;
background
:
var
(
--
bg-page
);
&
.dragging
{
user-select
:
none
;
cursor
:
col-resize
;
}
}
.pkg-layout
{
height
:
100%
;
display
:
flex
;
align-items
:
stretch
;
gap
:
0
;
background
:
var
(
--
bg-white
);
border
:
1px
solid
var
(
--
border-color
);
box-shadow
:
var
(
--
shadow-card
);
}
.pane-divider
{
width
:
4px
;
flex-shrink
:
0
;
background
:
var
(
--
border-color
);
cursor
:
col-resize
;
transition
:
background
0
.15s
;
position
:
relative
;
&
:
:
after
{
content
:
''
;
position
:
absolute
;
inset
:
0
-2px
;
}
&
:hover
,
.dragging
&
{
background
:
var
(
--
primary-border
);
}
}
</
style
>
sql/init_tables.sql
View file @
4d970f15
...
...
@@ -30,3 +30,23 @@ CREATE TABLE IF NOT EXISTS data_files (
ALTER
TABLE
data_files
ADD
COLUMN
IF
NOT
EXISTS
file_path
VARCHAR
(
500
)
NOT
NULL
COMMENT
'文件路径'
;
CREATE
TABLE
IF
NOT
EXISTS
data_packages
(
id
BIGINT
PRIMARY
KEY
AUTO_INCREMENT
,
name
VARCHAR
(
255
)
NOT
NULL
COMMENT
'数据包名称'
,
category_id
BIGINT
DEFAULT
NULL
COMMENT
'分类ID(data_package类型)'
,
remark
TEXT
NULL
COMMENT
'备注'
,
data_count
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_pkg_category
(
category_id
)
)
COMMENT
=
'数据包表'
;
CREATE
TABLE
IF
NOT
EXISTS
data_package_files
(
id
BIGINT
PRIMARY
KEY
AUTO_INCREMENT
,
package_id
BIGINT
NOT
NULL
COMMENT
'数据包ID'
,
file_id
BIGINT
NOT
NULL
COMMENT
'数据文件ID'
,
sort_order
INT
NOT
NULL
DEFAULT
0
COMMENT
'排序'
,
INDEX
idx_dpf_package
(
package_id
),
INDEX
idx_dpf_file
(
file_id
)
)
COMMENT
=
'数据包文件关联表'
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment