|
|
|
@ -6,109 +6,31 @@
|
|
|
|
:model="queryParams"
|
|
|
|
:model="queryParams"
|
|
|
|
ref="queryFormRef"
|
|
|
|
ref="queryFormRef"
|
|
|
|
:inline="true"
|
|
|
|
:inline="true"
|
|
|
|
label-width="68px"
|
|
|
|
label-width="80px"
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<el-form-item label="项目id" prop="dataId">
|
|
|
|
<el-form-item label="训练名称" prop="name">
|
|
|
|
<el-input
|
|
|
|
<el-input
|
|
|
|
v-model="queryParams.dataId"
|
|
|
|
v-model="queryParams.name"
|
|
|
|
placeholder="请输入项目id"
|
|
|
|
placeholder="请输入训练名称"
|
|
|
|
clearable
|
|
|
|
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
|
|
|
|
class="!w-240px"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
<el-form-item label="训练集比例" prop="train">
|
|
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
|
|
v-model="queryParams.train"
|
|
|
|
|
|
|
|
placeholder="请输入训练集比例"
|
|
|
|
|
|
|
|
clearable
|
|
|
|
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
|
|
|
|
class="!w-240px"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
<el-form-item label="验证图像比例" prop="val">
|
|
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
|
|
v-model="queryParams.val"
|
|
|
|
|
|
|
|
placeholder="请输入验证图像比例"
|
|
|
|
|
|
|
|
clearable
|
|
|
|
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
|
|
|
|
class="!w-240px"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
<el-form-item label="测试图像比例" prop="test">
|
|
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
|
|
v-model="queryParams.test"
|
|
|
|
|
|
|
|
placeholder="请输入测试图像比例"
|
|
|
|
|
|
|
|
clearable
|
|
|
|
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
|
|
|
|
class="!w-240px"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
<el-form-item label="轮次" prop="round">
|
|
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
|
|
v-model="queryParams.round"
|
|
|
|
|
|
|
|
placeholder="请输入轮次"
|
|
|
|
|
|
|
|
clearable
|
|
|
|
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
|
|
|
|
class="!w-240px"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
<el-form-item label="批次大小" prop="size">
|
|
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
|
|
v-model="queryParams.size"
|
|
|
|
|
|
|
|
placeholder="请输入批次大小"
|
|
|
|
|
|
|
|
clearable
|
|
|
|
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
|
|
|
|
class="!w-240px"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
<el-form-item label="图片大小(正方向,大于这个值进行缩放,小于这个值进行放大,不是正方形将图片周围涂黑)" prop="imageSize">
|
|
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
|
|
v-model="queryParams.imageSize"
|
|
|
|
|
|
|
|
placeholder="请输入图片大小(正方向,大于这个值进行缩放,小于这个值进行放大,不是正方形将图片周围涂黑)"
|
|
|
|
|
|
|
|
clearable
|
|
|
|
clearable
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
class="!w-240px"
|
|
|
|
class="!w-240px"
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</el-form-item>
|
|
|
|
</el-form-item>
|
|
|
|
<el-form-item label="预选训练模型" prop="modelPath">
|
|
|
|
<el-form-item label="数据集" prop="dataId">
|
|
|
|
<el-input
|
|
|
|
|
|
|
|
v-model="queryParams.modelPath"
|
|
|
|
|
|
|
|
placeholder="请输入预选训练模型"
|
|
|
|
|
|
|
|
clearable
|
|
|
|
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
|
|
|
|
class="!w-240px"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
<el-form-item label="训练图片路径" prop="path">
|
|
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
|
|
v-model="queryParams.path"
|
|
|
|
|
|
|
|
placeholder="请输入训练图片路径"
|
|
|
|
|
|
|
|
clearable
|
|
|
|
|
|
|
|
@keyup.enter="handleQuery"
|
|
|
|
|
|
|
|
class="!w-240px"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
|
|
<el-form-item label="类型,使用哪个gpu" prop="trainType">
|
|
|
|
|
|
|
|
<el-select
|
|
|
|
<el-select
|
|
|
|
v-model="queryParams.trainType"
|
|
|
|
v-model="queryParams.dataId"
|
|
|
|
placeholder="请选择类型,使用哪个gpu"
|
|
|
|
placeholder="请选择数据集"
|
|
|
|
clearable
|
|
|
|
clearable
|
|
|
|
class="!w-240px"
|
|
|
|
class="!w-240px"
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<el-option label="请选择字典生成" value="" />
|
|
|
|
<el-option
|
|
|
|
</el-select>
|
|
|
|
v-for="project in projectList"
|
|
|
|
</el-form-item>
|
|
|
|
:key="project.id"
|
|
|
|
<el-form-item label="创建时间" prop="createTime">
|
|
|
|
:label="project.name"
|
|
|
|
<el-date-picker
|
|
|
|
:value="project.id"
|
|
|
|
v-model="queryParams.createTime"
|
|
|
|
|
|
|
|
value-format="YYYY-MM-DD HH:mm:ss"
|
|
|
|
|
|
|
|
type="daterange"
|
|
|
|
|
|
|
|
start-placeholder="开始日期"
|
|
|
|
|
|
|
|
end-placeholder="结束日期"
|
|
|
|
|
|
|
|
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
|
|
|
|
|
|
|
class="!w-240px"
|
|
|
|
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
|
|
|
|
</el-select>
|
|
|
|
</el-form-item>
|
|
|
|
</el-form-item>
|
|
|
|
<el-form-item>
|
|
|
|
<el-form-item>
|
|
|
|
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
|
|
|
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
|
|
|
@ -128,7 +50,15 @@
|
|
|
|
:loading="exportLoading"
|
|
|
|
:loading="exportLoading"
|
|
|
|
v-hasPermi="['annotation:train:export']"
|
|
|
|
v-hasPermi="['annotation:train:export']"
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<Icon icon="ep:download" class="mr-5px" /> 导出
|
|
|
|
<Icon icon="ep:search" class="mr-5px" /> 导出
|
|
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
|
|
plain
|
|
|
|
|
|
|
|
@click="environmentInquiry()"
|
|
|
|
|
|
|
|
v-hasPermi="['annotation:train:create']"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Icon icon="ep:plus" class="mr-5px" /> 环境查询
|
|
|
|
</el-button>
|
|
|
|
</el-button>
|
|
|
|
</el-form-item>
|
|
|
|
</el-form-item>
|
|
|
|
</el-form>
|
|
|
|
</el-form>
|
|
|
|
@ -137,25 +67,23 @@
|
|
|
|
<!-- 列表 -->
|
|
|
|
<!-- 列表 -->
|
|
|
|
<ContentWrap>
|
|
|
|
<ContentWrap>
|
|
|
|
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
|
|
|
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
|
|
|
<el-table-column label="id" align="center" prop="id" />
|
|
|
|
<el-table-column label="训练名称" align="center" prop="name" />
|
|
|
|
<el-table-column label="项目id" align="center" prop="dataId" />
|
|
|
|
<el-table-column label="数据集" align="center" prop="dataId">
|
|
|
|
<el-table-column label="训练集比例" align="center" prop="train" />
|
|
|
|
<template #default="scope">
|
|
|
|
<el-table-column label="验证图像比例" align="center" prop="val" />
|
|
|
|
{{ getProjectName(scope.row.dataId) }}
|
|
|
|
<el-table-column label="测试图像比例" align="center" prop="test" />
|
|
|
|
</template>
|
|
|
|
<el-table-column label="轮次" align="center" prop="round" />
|
|
|
|
</el-table-column>
|
|
|
|
<el-table-column label="批次大小" align="center" prop="size" />
|
|
|
|
<el-table-column label="训练类型" align="center" prop="trainType">
|
|
|
|
<el-table-column label="图片大小(正方向,大于这个值进行缩放,小于这个值进行放大,不是正方形将图片周围涂黑)" align="center" prop="imageSize" />
|
|
|
|
<template #default="scope">
|
|
|
|
<el-table-column label="预选训练模型" align="center" prop="modelPath" />
|
|
|
|
<dict-tag :type="DICT_TYPE.TRAINING_STATUS" :value="scope.row.trainType" />
|
|
|
|
<el-table-column label="训练图片路径" align="center" prop="path" />
|
|
|
|
</template>
|
|
|
|
<el-table-column label="类型,使用哪个gpu" align="center" prop="trainType" />
|
|
|
|
</el-table-column>
|
|
|
|
<el-table-column
|
|
|
|
<el-table-column label="识别类型" align="center" prop="visualType">
|
|
|
|
label="创建时间"
|
|
|
|
<template #default="scope">
|
|
|
|
align="center"
|
|
|
|
<dict-tag :type="DICT_TYPE.VISUAL_TYPE" :value="getProjectType(scope.row.dataId)" />
|
|
|
|
prop="createTime"
|
|
|
|
</template>
|
|
|
|
:formatter="dateFormatter"
|
|
|
|
</el-table-column>
|
|
|
|
width="180px"
|
|
|
|
<el-table-column label="操作" align="center" width="300">
|
|
|
|
/>
|
|
|
|
|
|
|
|
<el-table-column label="操作" align="center">
|
|
|
|
|
|
|
|
<template #default="scope">
|
|
|
|
<template #default="scope">
|
|
|
|
<el-button
|
|
|
|
<el-button
|
|
|
|
link
|
|
|
|
link
|
|
|
|
@ -163,7 +91,29 @@
|
|
|
|
@click="openForm('update', scope.row.id)"
|
|
|
|
@click="openForm('update', scope.row.id)"
|
|
|
|
v-hasPermi="['annotation:train:update']"
|
|
|
|
v-hasPermi="['annotation:train:update']"
|
|
|
|
>
|
|
|
|
>
|
|
|
|
编辑
|
|
|
|
修改
|
|
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
link
|
|
|
|
|
|
|
|
type="success"
|
|
|
|
|
|
|
|
@click="initTrain(scope.row)"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
初始化
|
|
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
link
|
|
|
|
|
|
|
|
type="success"
|
|
|
|
|
|
|
|
@click="openTrainDrawer(scope.row)"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
训练
|
|
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
link
|
|
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
|
|
@click="handleInfo(scope.row)"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
信息
|
|
|
|
</el-button>
|
|
|
|
</el-button>
|
|
|
|
<el-button
|
|
|
|
<el-button
|
|
|
|
link
|
|
|
|
link
|
|
|
|
@ -187,13 +137,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 表单弹窗:添加/修改 -->
|
|
|
|
<!-- 表单弹窗:添加/修改 -->
|
|
|
|
<TrainForm ref="formRef" @success="getList" />
|
|
|
|
<TrainForm ref="formRef" @success="getList" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 训练抽屉 -->
|
|
|
|
|
|
|
|
<el-drawer
|
|
|
|
|
|
|
|
v-model="trainDrawerVisible"
|
|
|
|
|
|
|
|
title="训练详情"
|
|
|
|
|
|
|
|
direction="rtl"
|
|
|
|
|
|
|
|
size="50%"
|
|
|
|
|
|
|
|
:before-close="closeTrainDrawer"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<div class="train-drawer-content">
|
|
|
|
|
|
|
|
<!-- 训练信息显示区域 -->
|
|
|
|
|
|
|
|
<div class="train-info">
|
|
|
|
|
|
|
|
<h3>当前训练: {{ currentTrain?.name }}</h3>
|
|
|
|
|
|
|
|
<div v-if="trainData" class="train-stats">
|
|
|
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
|
|
|
<span class="stat-label">训练状态:</span>
|
|
|
|
|
|
|
|
<el-tag :type="getStatusType(getTrainingStatus())">
|
|
|
|
|
|
|
|
{{ getTrainingStatus() }}
|
|
|
|
|
|
|
|
</el-tag>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="stat-item" v-if="trainData.round !== undefined && trainData.roundTotal !== undefined">
|
|
|
|
|
|
|
|
<span class="stat-label">当前轮次:</span>
|
|
|
|
|
|
|
|
<span class="stat-value">{{ trainData.round }} / {{ trainData.roundTotal }}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="stat-item" v-if="trainData.round !== undefined && trainData.roundTotal !== undefined">
|
|
|
|
|
|
|
|
<span class="stat-label">训练进度:</span>
|
|
|
|
|
|
|
|
<el-progress
|
|
|
|
|
|
|
|
:percentage="trainProgress"
|
|
|
|
|
|
|
|
:status="trainData.round >= trainData.roundTotal ? 'success' : ''"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="stat-item" v-if="getMetricsValue('loss')">
|
|
|
|
|
|
|
|
<span class="stat-label">损失值:</span>
|
|
|
|
|
|
|
|
<span class="stat-value">{{ getMetricsValue('loss') }}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="stat-item" v-if="getMetricsValue('precision')">
|
|
|
|
|
|
|
|
<span class="stat-label">精度:</span>
|
|
|
|
|
|
|
|
<span class="stat-value">{{ (parseFloat(getMetricsValue('precision')) * 100).toFixed(2) + '%' }}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="stat-item" v-if="getMetricsValue('recall')">
|
|
|
|
|
|
|
|
<span class="stat-label">召回率:</span>
|
|
|
|
|
|
|
|
<span class="stat-value">{{ (parseFloat(getMetricsValue('recall')) * 100).toFixed(2) + '%' }}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="stat-item" v-if="getMetricsValue('map')">
|
|
|
|
|
|
|
|
<span class="stat-label">mAP:</span>
|
|
|
|
|
|
|
|
<span class="stat-value">{{ (parseFloat(getMetricsValue('map')) * 100).toFixed(2) + '%' }}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="stat-item" v-if="trainData.path">
|
|
|
|
|
|
|
|
<span class="stat-label">输出路径:</span>
|
|
|
|
|
|
|
|
<span class="stat-value">{{ trainData.path }}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div v-else class="loading-placeholder">
|
|
|
|
|
|
|
|
<el-skeleton :rows="5" animated />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 日志显示区域 -->
|
|
|
|
|
|
|
|
<div class="train-logs">
|
|
|
|
|
|
|
|
<h4>训练日志</h4>
|
|
|
|
|
|
|
|
<el-scrollbar height="300px" class="log-container">
|
|
|
|
|
|
|
|
<div v-if="trainLogs.length > 0" class="log-content">
|
|
|
|
|
|
|
|
<div v-for="(log, index) in trainLogs" :key="index" class="log-item">
|
|
|
|
|
|
|
|
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
|
|
|
|
|
|
|
|
<span class="log-message">{{ log.message }}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div v-else class="empty-logs">
|
|
|
|
|
|
|
|
<el-empty description="暂无日志" />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</el-scrollbar>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 底部按钮区域 -->
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
|
|
<div class="drawer-footer">
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
|
|
@click="startTraining"
|
|
|
|
|
|
|
|
:disabled="isTraining"
|
|
|
|
|
|
|
|
:loading="isTraining"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
{{ isTraining ? '训练中...' : '开始训练' }}
|
|
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
@click="exportTrainData"
|
|
|
|
|
|
|
|
:disabled="!hasTrainResult"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Icon icon="ep:download" class="mr-5px" /> 导出
|
|
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
|
|
<el-button @click="closeTrainDrawer">关闭</el-button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
</el-drawer>
|
|
|
|
</template>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
<script setup lang="ts">
|
|
|
|
import { dateFormatter } from '@/utils/formatTime'
|
|
|
|
|
|
|
|
import download from '@/utils/download'
|
|
|
|
import download from '@/utils/download'
|
|
|
|
import { TrainApi, TrainVO } from '@/api/annotation/train'
|
|
|
|
import { TrainApi, TrainVO } from '@/api/annotation/train'
|
|
|
|
|
|
|
|
import { MarkApi, DataVO } from '@/api/annotation/mark'
|
|
|
|
import TrainForm from './TrainForm.vue'
|
|
|
|
import TrainForm from './TrainForm.vue'
|
|
|
|
|
|
|
|
import { DICT_TYPE } from '@/utils/dict'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 训练 列表 */
|
|
|
|
|
|
|
|
|
|
|
|
/** 训练 列表 */
|
|
|
|
/** 训练 列表 */
|
|
|
|
defineOptions({ name: 'Train' })
|
|
|
|
defineOptions({ name: 'Train' })
|
|
|
|
@ -204,24 +252,55 @@ const { t } = useI18n() // 国际化
|
|
|
|
const loading = ref(true) // 列表的加载中
|
|
|
|
const loading = ref(true) // 列表的加载中
|
|
|
|
const list = ref<TrainVO[]>([]) // 列表的数据
|
|
|
|
const list = ref<TrainVO[]>([]) // 列表的数据
|
|
|
|
const total = ref(0) // 列表的总页数
|
|
|
|
const total = ref(0) // 列表的总页数
|
|
|
|
|
|
|
|
const projectList = ref<DataVO[]>([]) // 项目列表
|
|
|
|
|
|
|
|
|
|
|
|
const queryParams = reactive({
|
|
|
|
const queryParams = reactive({
|
|
|
|
pageNo: 1,
|
|
|
|
pageNo: 1,
|
|
|
|
pageSize: 10,
|
|
|
|
pageSize: 10,
|
|
|
|
|
|
|
|
name: undefined,
|
|
|
|
dataId: undefined,
|
|
|
|
dataId: undefined,
|
|
|
|
train: undefined,
|
|
|
|
|
|
|
|
val: undefined,
|
|
|
|
|
|
|
|
test: undefined,
|
|
|
|
|
|
|
|
round: undefined,
|
|
|
|
|
|
|
|
size: undefined,
|
|
|
|
|
|
|
|
imageSize: undefined,
|
|
|
|
|
|
|
|
modelPath: undefined,
|
|
|
|
|
|
|
|
path: undefined,
|
|
|
|
|
|
|
|
trainType: undefined,
|
|
|
|
|
|
|
|
createTime: [],
|
|
|
|
|
|
|
|
})
|
|
|
|
})
|
|
|
|
const queryFormRef = ref() // 搜索的表单
|
|
|
|
const queryFormRef = ref() // 搜索的表单
|
|
|
|
const exportLoading = ref(false) // 导出的加载中
|
|
|
|
const exportLoading = ref(false) // 导出的加载中
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 训练抽屉相关
|
|
|
|
|
|
|
|
const trainDrawerVisible = ref(false)
|
|
|
|
|
|
|
|
const currentTrain = ref<TrainVO | null>(null)
|
|
|
|
|
|
|
|
const trainData = ref<any>(null)
|
|
|
|
|
|
|
|
const trainLogs = ref<Array<{timestamp: number, message: string}>>([])
|
|
|
|
|
|
|
|
const isTraining = ref(false)
|
|
|
|
|
|
|
|
const trainProgress = ref(0)
|
|
|
|
|
|
|
|
let trainTimer: NodeJS.Timeout | null = null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 获取项目列表
|
|
|
|
|
|
|
|
const getProjectList = async () => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
// 调用实际API获取项目列表
|
|
|
|
|
|
|
|
const response = await MarkApi.getProjectStatusList({status:2})
|
|
|
|
|
|
|
|
projectList.value = response
|
|
|
|
|
|
|
|
console.log(projectList.value)
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
console.error('获取项目列表失败:', error)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 获取项目名称
|
|
|
|
|
|
|
|
const getProjectName = (dataId: number) => {
|
|
|
|
|
|
|
|
const project = projectList.value.find(p => p.id === dataId)
|
|
|
|
|
|
|
|
return project ? project.name : '未知项目'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// 获取项目状态
|
|
|
|
|
|
|
|
const getProjectStatus = (dataId: number) => {
|
|
|
|
|
|
|
|
const project = projectList.value.find(p => p.id === dataId)
|
|
|
|
|
|
|
|
return project ? project.status : ''
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 获取项目类型
|
|
|
|
|
|
|
|
const getProjectType = (dataId: number) => {
|
|
|
|
|
|
|
|
const project = projectList.value.find(p => p.id === dataId)
|
|
|
|
|
|
|
|
return project ? project.type : ''
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 查询列表 */
|
|
|
|
/** 查询列表 */
|
|
|
|
const getList = async () => {
|
|
|
|
const getList = async () => {
|
|
|
|
loading.value = true
|
|
|
|
loading.value = true
|
|
|
|
@ -252,6 +331,251 @@ const openForm = (type: string, id?: number) => {
|
|
|
|
formRef.value.open(type, id)
|
|
|
|
formRef.value.open(type, id)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 打开训练抽屉 */
|
|
|
|
|
|
|
|
const openTrainDrawer = (row: TrainVO) => {
|
|
|
|
|
|
|
|
currentTrain.value = row
|
|
|
|
|
|
|
|
trainDrawerVisible.value = true
|
|
|
|
|
|
|
|
startPolling()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 关闭训练抽屉 */
|
|
|
|
|
|
|
|
const closeTrainDrawer = () => {
|
|
|
|
|
|
|
|
trainDrawerVisible.value = false
|
|
|
|
|
|
|
|
stopPolling()
|
|
|
|
|
|
|
|
currentTrain.value = null
|
|
|
|
|
|
|
|
trainData.value = null
|
|
|
|
|
|
|
|
trainLogs.value = []
|
|
|
|
|
|
|
|
trainProgress.value = 0
|
|
|
|
|
|
|
|
isTraining.value = false
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 开始轮询训练数据 */
|
|
|
|
|
|
|
|
const startPolling = () => {
|
|
|
|
|
|
|
|
// 立即获取一次数据
|
|
|
|
|
|
|
|
fetchTrainData()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 每5秒获取一次数据
|
|
|
|
|
|
|
|
trainTimer = setInterval(() => {
|
|
|
|
|
|
|
|
fetchTrainData()
|
|
|
|
|
|
|
|
}, 5000)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 停止轮询 */
|
|
|
|
|
|
|
|
const stopPolling = () => {
|
|
|
|
|
|
|
|
if (trainTimer) {
|
|
|
|
|
|
|
|
clearInterval(trainTimer)
|
|
|
|
|
|
|
|
trainTimer = null
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 获取训练数据 */
|
|
|
|
|
|
|
|
const fetchTrainData = async () => {
|
|
|
|
|
|
|
|
if (!currentTrain.value) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
// 并行获取训练结果和日志
|
|
|
|
|
|
|
|
const [resultResponse, logResponse] = await Promise.all([
|
|
|
|
|
|
|
|
TrainApi.getTrainStatus(currentTrain.value.id),
|
|
|
|
|
|
|
|
TrainApi.getTrainInfoStatus(currentTrain.value.id)
|
|
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理训练结果
|
|
|
|
|
|
|
|
if (Array.isArray(resultResponse) && resultResponse.length > 0) {
|
|
|
|
|
|
|
|
// 获取最新的训练记录(数组中的第一个元素)
|
|
|
|
|
|
|
|
const latestRecord = resultResponse[0]
|
|
|
|
|
|
|
|
trainData.value = latestRecord
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有轮次信息,说明未训练
|
|
|
|
|
|
|
|
if (latestRecord.round === undefined && latestRecord.roundTotal === undefined) {
|
|
|
|
|
|
|
|
trainProgress.value = 0
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// 更新进度
|
|
|
|
|
|
|
|
const progress = Math.round((latestRecord.round / latestRecord.roundTotal) * 100)
|
|
|
|
|
|
|
|
trainProgress.value = Math.min(100, Math.max(0, progress))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// 没有结果数据,说明未训练
|
|
|
|
|
|
|
|
trainData.value = {
|
|
|
|
|
|
|
|
trainId: currentTrain.value.id,
|
|
|
|
|
|
|
|
path: null,
|
|
|
|
|
|
|
|
rate: null,
|
|
|
|
|
|
|
|
dataId: null
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
trainProgress.value = 0
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理日志数据
|
|
|
|
|
|
|
|
if (Array.isArray(logResponse) && logResponse.length > 0) {
|
|
|
|
|
|
|
|
// 将日志数据添加到日志列表
|
|
|
|
|
|
|
|
logResponse.forEach(logItem => {
|
|
|
|
|
|
|
|
if (logItem && logItem.message) {
|
|
|
|
|
|
|
|
trainLogs.value.unshift({
|
|
|
|
|
|
|
|
timestamp: logItem.timestamp || Date.now(),
|
|
|
|
|
|
|
|
message: logItem.message
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
// 只保留最新的50条日志
|
|
|
|
|
|
|
|
if (trainLogs.value.length > 50) {
|
|
|
|
|
|
|
|
trainLogs.value = trainLogs.value.slice(0, 50)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
console.error('获取训练数据失败:', error)
|
|
|
|
|
|
|
|
trainData.value = {
|
|
|
|
|
|
|
|
trainId: currentTrain.value.id,
|
|
|
|
|
|
|
|
path: null,
|
|
|
|
|
|
|
|
rate: null,
|
|
|
|
|
|
|
|
dataId: null
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
trainProgress.value = 0
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 获取训练状态 */
|
|
|
|
|
|
|
|
const getTrainingStatus = () => {
|
|
|
|
|
|
|
|
if (!trainData.value) return '未训练'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 如果只有 trainId, path, rate, dataId,说明未训练
|
|
|
|
|
|
|
|
if (trainData.value.round === undefined && trainData.value.roundTotal === undefined) {
|
|
|
|
|
|
|
|
return '未训练'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (trainData.value.round >= trainData.value.roundTotal) {
|
|
|
|
|
|
|
|
return '已完成'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return '训练中'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 从rate字符串中获取指标值 */
|
|
|
|
|
|
|
|
const getMetricsValue = (metric: string) => {
|
|
|
|
|
|
|
|
if (!trainData.value?.rate || trainData.value.rate === 'null') return null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const rateStr = trainData.value.rate
|
|
|
|
|
|
|
|
const metrics: Record<string, string> = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 解析 "precision:0.7850,recall:0.7120,map:0.6450" 格式
|
|
|
|
|
|
|
|
rateStr.split(',').forEach(item => {
|
|
|
|
|
|
|
|
const [key, value] = item.split(':')
|
|
|
|
|
|
|
|
if (key && value) {
|
|
|
|
|
|
|
|
metrics[key.trim().toLowerCase()] = value.trim()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 从info中提取损失值
|
|
|
|
|
|
|
|
if (metric === 'loss' && trainData.value.info) {
|
|
|
|
|
|
|
|
const lossMatch = trainData.value.info.match(/损失值[:\s]+([\d.]+)/)
|
|
|
|
|
|
|
|
if (lossMatch) {
|
|
|
|
|
|
|
|
return lossMatch[1]
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return metrics[metric] || null
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 开始训练 */
|
|
|
|
|
|
|
|
const startTraining = async () => {
|
|
|
|
|
|
|
|
if (!currentTrain.value) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
isTraining.value = true
|
|
|
|
|
|
|
|
await TrainApi.handleTrain(currentTrain.value)
|
|
|
|
|
|
|
|
message.success(`开始训练: ${currentTrain.value.name}`)
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
console.error('启动训练失败:', error)
|
|
|
|
|
|
|
|
message.error('启动训练失败')
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
isTraining.value = false
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 导出训练数据 */
|
|
|
|
|
|
|
|
const exportTrainData = async () => {
|
|
|
|
|
|
|
|
if (!currentTrain.value) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const data = await TrainApi.exportTrainData(currentTrain.value.id)
|
|
|
|
|
|
|
|
download.excel(data, `训练数据_${currentTrain.value.name}.xlsx`)
|
|
|
|
|
|
|
|
message.success('导出成功')
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
console.error('导出失败:', error)
|
|
|
|
|
|
|
|
message.error('导出失败')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 格式化时间 */
|
|
|
|
|
|
|
|
const formatTime = (timestamp: number) => {
|
|
|
|
|
|
|
|
return new Date(timestamp).toLocaleTimeString()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 获取状态类型 */
|
|
|
|
|
|
|
|
const getStatusType = (status: string) => {
|
|
|
|
|
|
|
|
const statusMap: Record<string, string> = {
|
|
|
|
|
|
|
|
'pending': 'info',
|
|
|
|
|
|
|
|
'running': 'warning',
|
|
|
|
|
|
|
|
'completed': 'success',
|
|
|
|
|
|
|
|
'failed': 'danger'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return statusMap[status] || 'info'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 获取状态文本 */
|
|
|
|
|
|
|
|
const getStatusText = (status: string) => {
|
|
|
|
|
|
|
|
const statusMap: Record<string, string> = {
|
|
|
|
|
|
|
|
'pending': '等待中',
|
|
|
|
|
|
|
|
'running': '训练中',
|
|
|
|
|
|
|
|
'completed': '已完成',
|
|
|
|
|
|
|
|
'failed': '失败'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return statusMap[status] || '未知'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 判断是否有训练结果 */
|
|
|
|
|
|
|
|
const hasTrainResult = computed(() => {
|
|
|
|
|
|
|
|
return trainData.value &&
|
|
|
|
|
|
|
|
trainData.value.round !== undefined &&
|
|
|
|
|
|
|
|
trainData.value.roundTotal !== undefined &&
|
|
|
|
|
|
|
|
trainData.value.round > 0
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 训练按钮操作 */
|
|
|
|
|
|
|
|
const handleTrain = async (row: TrainVO) => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
await TrainApi.handleTrain(row);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
console.error('训练失败:', error)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const initTrain = async (row: TrainVO) => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
await TrainApi.InfinityTrain(row);
|
|
|
|
|
|
|
|
message.info(`初始化完成: ${row.name}`)
|
|
|
|
|
|
|
|
getList()
|
|
|
|
|
|
|
|
// TODO: 实现训练逻辑
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
console.error('初始化失败:', error)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const environmentInquiry = async () => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
await TrainApi.environmentInquiry();
|
|
|
|
|
|
|
|
// TODO: 实现训练逻辑
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
console.error('训练失败:', error)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
/** 信息按钮操作 */
|
|
|
|
|
|
|
|
const handleInfo = async (row: TrainVO) => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
message.info(`查看训练信息: ${row.name}`)
|
|
|
|
|
|
|
|
// TODO: 实现信息查看逻辑
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
console.error('查看信息失败:', error)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 删除按钮操作 */
|
|
|
|
/** 删除按钮操作 */
|
|
|
|
const handleDelete = async (id: number) => {
|
|
|
|
const handleDelete = async (id: number) => {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
@ -282,6 +606,117 @@ const handleExport = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
/** 初始化 **/
|
|
|
|
/** 初始化 **/
|
|
|
|
onMounted(() => {
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
|
|
getProjectList()
|
|
|
|
getList()
|
|
|
|
getList()
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
|
|
stopPolling()
|
|
|
|
|
|
|
|
})
|
|
|
|
</script>
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
|
|
.train-drawer-content {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
|
|
padding: 0 16px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.train-info {
|
|
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.train-info h3 {
|
|
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
|
|
color: #303133;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.train-stats {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.stat-item {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.stat-label {
|
|
|
|
|
|
|
|
min-width: 80px;
|
|
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
|
|
color: #606266;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.stat-value {
|
|
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
|
|
color: #303133;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.loading-placeholder {
|
|
|
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.train-logs {
|
|
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
|
|
margin-top: 24px;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.train-logs h4 {
|
|
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
|
|
color: #303133;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.log-container {
|
|
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
|
|
border: 1px solid #dcdfe6;
|
|
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
|
|
padding: 8px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.log-content {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.log-item {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
|
|
font-family: 'Courier New', monospace;
|
|
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.log-item:hover {
|
|
|
|
|
|
|
|
background-color: #f5f7fa;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.log-time {
|
|
|
|
|
|
|
|
color: #909399;
|
|
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.log-message {
|
|
|
|
|
|
|
|
color: #606266;
|
|
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.empty-logs {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
height: 100px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-footer {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
|
|
padding: 16px 0;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
</style>
|