|
@@ -0,0 +1,528 @@
|
|
|
+<template>
|
|
|
+ <div class="table-box">
|
|
|
+ <ProTable ref="proTable" :columns="columns" row-key="id" :request-api="listDataAugmentationApi">
|
|
|
+ <!-- 表格 header 按钮 -->
|
|
|
+ <template #tableHeader="scope">
|
|
|
+ <el-button type="primary" v-auth="['demo:DataAugmentation:add']" icon="CirclePlus" @click="openDialog(1, '任务新增')"> 新增 </el-button>
|
|
|
+ <!-- <el-button type="primary" v-auth="['demo:DataAugmentation:import']" icon="Upload" plain @click="batchAdd"> 导入</el-button> -->
|
|
|
+ <el-button type="primary" v-auth="['demo:DataAugmentation:export']" icon="Download" plain @click="downloadFile"> 导出 </el-button>
|
|
|
+ <el-button
|
|
|
+ type="danger"
|
|
|
+ v-auth="['demo:DataAugmentation:remove']"
|
|
|
+ icon="Delete"
|
|
|
+ plain
|
|
|
+ :disabled="!scope.isSelected"
|
|
|
+ @click="batchDelete(scope.selectedListIds)"
|
|
|
+ >
|
|
|
+ 批量删除
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ <!-- 表格操作 -->
|
|
|
+ <template #operation="scope">
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ link
|
|
|
+ icon="View"
|
|
|
+ @click="startDataAugmentation(scope.row)"
|
|
|
+ v-if="scope.row.status == '0' || scope.row.status == '3' || scope.row.status == '4'"
|
|
|
+ >
|
|
|
+ 开始
|
|
|
+ </el-button>
|
|
|
+ <el-popconfirm title="确定终止此任务吗?" @confirm="stopDataAugmentation(scope.row)" v-if="scope.row.status == '1'">
|
|
|
+ <template #reference>
|
|
|
+ <el-button type="primary" link icon="View"> 终止 </el-button>
|
|
|
+ </template>
|
|
|
+ </el-popconfirm>
|
|
|
+ <el-button type="primary" link icon="View" @click="compareDataAugmentation(scope.row)" v-if="scope.row.status == '2'"> 预览 </el-button>
|
|
|
+ <el-button type="primary" link icon="View" v-auth="['demo:DataAugmentation:query']" @click="openDialog(3, '任务查看', scope.row)">
|
|
|
+ 查看
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" link icon="View" v-auth="['demo:DataAugmentation:query']" @click="openLogDialog(scope.row.id)"> 日志 </el-button>
|
|
|
+ <el-button type="primary" link icon="Delete" v-auth="['demo:DataAugmentation:remove']" @click="deleteDataAugmentation(scope.row)">
|
|
|
+ 删除
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </ProTable>
|
|
|
+ <DataAugmentationFormDialog ref="formDialogRef" />
|
|
|
+ <ImportExcel ref="dialogRef" />
|
|
|
+ <el-dialog v-model="dialogVisible" :title="dialogTitle" width="80%">
|
|
|
+ <div class="image-dialog" v-if="imageIdx >= 0 && cacheImages[imageIdx]">
|
|
|
+ <div style="width: 50%">
|
|
|
+ <el-image v-for="(image, index) in cacheImages[imageIdx].origin" :key="index" :src="'data:image/png;base64,' + image"></el-image>
|
|
|
+ <!-- <el-tag>结果:</el-tag> -->
|
|
|
+ </div>
|
|
|
+ <div style="width: 50%">
|
|
|
+ <el-image v-for="(image, index) in cacheImages[imageIdx].stable" :key="index" :src="'data:image/png;base64,' + image"></el-image>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="image-dialog-btn" v-if="imageFps == 0">
|
|
|
+ <el-button type="primary" @click="pre_picture" :disabled="imageIdx <= 0">上一个</el-button>
|
|
|
+ <el-button type="primary" @click="next_picture" :disabled="imageIdx >= fileCount - 1">下一个</el-button>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ <el-dialog v-model="logDialogVisible" title="日志" width="80%">
|
|
|
+ <el-text class="mx-1">{{ logDialog }}</el-text>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="tsx" name="DataAugmentation">
|
|
|
+import { ref, reactive, onMounted } from 'vue'
|
|
|
+import { useHandleData } from '@/hooks/useHandleData'
|
|
|
+import { useDownload } from '@/hooks/useDownload'
|
|
|
+import { ElMessageBox, ElMessage } from 'element-plus'
|
|
|
+import ProTable from '@/components/ProTable/index.vue'
|
|
|
+import ImportExcel from '@/components/ImportExcel/index.vue'
|
|
|
+import DataAugmentationFormDialog from '@/components/DataAugmentationFormDialog/index.vue'
|
|
|
+import { ProTableInstance, ColumnProps, EnumProps } from '@/components/ProTable/interface'
|
|
|
+import {
|
|
|
+ listDataAugmentationApi,
|
|
|
+ delDataAugmentationApi,
|
|
|
+ addDataAugmentationApi,
|
|
|
+ updateDataAugmentationApi,
|
|
|
+ importTemplateApi,
|
|
|
+ importDataAugmentationDataApi,
|
|
|
+ exportDataAugmentationApi,
|
|
|
+ getDataAugmentationApi,
|
|
|
+ startDataAugmentationApi,
|
|
|
+ stopDataAugmentationApi,
|
|
|
+ getCompareImageApi,
|
|
|
+ getCompareImageCountApi,
|
|
|
+ getDialogApi
|
|
|
+} from '@/api/modules/demo/dataAugmentation'
|
|
|
+import { listDataApi } from '@/api/modules/system/dictData'
|
|
|
+import { S } from 'vite/dist/node/types.d-aGj9QkWt'
|
|
|
+import { servicesVersion } from 'typescript'
|
|
|
+const dialogVisible = ref(false)
|
|
|
+const taskId = ref('')
|
|
|
+const imageIdx = ref(0)
|
|
|
+const imageBase64List = ref({
|
|
|
+ origin: '',
|
|
|
+ stable: ''
|
|
|
+})
|
|
|
+const inFileCount = ref(0)
|
|
|
+const outFileCount = ref(0)
|
|
|
+// 直接缓存所有图片
|
|
|
+const cacheImages = ref({})
|
|
|
+const dialogTitle = ref('')
|
|
|
+const fileCount = ref(0)
|
|
|
+const imageFps = ref(0)
|
|
|
+const intervalChangeFps: any = ref()
|
|
|
+const taskType = ref([])
|
|
|
+const taskTypeEnums: EnumProps[] = []
|
|
|
+
|
|
|
+const hyperparameterConfiguration = []
|
|
|
+const hyperparameter = ref('')
|
|
|
+const logDialogVisible = ref(false)
|
|
|
+const logDialog = ref('')
|
|
|
+const openLogDialog = async (id: string | number) => {
|
|
|
+ const res: any = await getDialogApi(id)
|
|
|
+ logDialog.value = res.data
|
|
|
+ logDialogVisible.value = true
|
|
|
+}
|
|
|
+const getTaskType = async () => {
|
|
|
+ const res: any = await listDataApi({
|
|
|
+ dictName: '',
|
|
|
+ dictType: 'biz_data_augmentation',
|
|
|
+ dictLabel: '任务类型',
|
|
|
+ pageNum: 0,
|
|
|
+ pageSize: 1000
|
|
|
+ })
|
|
|
+ if (res.data.list.length != 0) {
|
|
|
+ taskType.value = res.data.list.map(item => ({
|
|
|
+ label: item.dictValue,
|
|
|
+ value: item.dictValue
|
|
|
+ }))
|
|
|
+ res.data.list.forEach(item => {
|
|
|
+ taskTypeEnums.push({
|
|
|
+ label: item.dictValue,
|
|
|
+ value: item.dictValue,
|
|
|
+ disabled: false,
|
|
|
+ tagType: 'default'
|
|
|
+ })
|
|
|
+ })
|
|
|
+ for (let i = 0; i < taskType.value.length; i++) {
|
|
|
+ let dictValue = taskType.value[i].value
|
|
|
+ const res: any = await listDataApi({
|
|
|
+ dictName: '',
|
|
|
+ dictType: 'biz_data_augmentation',
|
|
|
+ dictLabel: dictValue + '超参配置',
|
|
|
+ pageNum: 0,
|
|
|
+ pageSize: 1000
|
|
|
+ })
|
|
|
+ if (res.data.list.length != 0) {
|
|
|
+ //console.log(res.data.list[0].dictValue)
|
|
|
+ let obj = {}
|
|
|
+ obj[dictValue] = res.data.list[0].dictValue
|
|
|
+ hyperparameterConfiguration.push(obj)
|
|
|
+ } else {
|
|
|
+ ElMessage.error('${dictValue}未在数据字典中设置超参!')
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ ElMessage.error('task_type为空,请在数据字典中设置具体的任务类型!')
|
|
|
+ }
|
|
|
+
|
|
|
+ // console.log(taskType.value)
|
|
|
+}
|
|
|
+onMounted(() => {
|
|
|
+ getTaskType()
|
|
|
+})
|
|
|
+
|
|
|
+const startDataAugmentation = async (params: any) => {
|
|
|
+ const res = await startDataAugmentationApi(params.id)
|
|
|
+ if (res.code === 200) {
|
|
|
+ ElMessage.success('任务已经开始,请等待')
|
|
|
+ } else {
|
|
|
+ ElMessage.error('任务开始失败,请检查!')
|
|
|
+ }
|
|
|
+ proTable.value?.getTableList()
|
|
|
+}
|
|
|
+
|
|
|
+const stopDataAugmentation = async (params: any) => {
|
|
|
+ const res = await stopDataAugmentationApi(params.id)
|
|
|
+ if (res.code === 200) {
|
|
|
+ ElMessage.success('任务终止成功')
|
|
|
+ } else {
|
|
|
+ ElMessage.error('任务终止失败!')
|
|
|
+ }
|
|
|
+ proTable.value?.getTableList()
|
|
|
+}
|
|
|
+
|
|
|
+const loadImageData = async (taskId: string, imageIdx: number) => {
|
|
|
+ const res: any = await getCompareImageApi(taskId, imageIdx)
|
|
|
+ // imageBase64List.value.origin = res.origin
|
|
|
+ // imageBase64List.value.stable = res.stable
|
|
|
+ cacheImages.value[imageIdx].origin = res.origin
|
|
|
+ cacheImages.value[imageIdx].stable = res.stable
|
|
|
+}
|
|
|
+
|
|
|
+const compareDataAugmentation = async (params: any) => {
|
|
|
+ taskId.value = params.id
|
|
|
+ imageIdx.value = 0
|
|
|
+ cacheImages.value = {}
|
|
|
+ const resCount: any = await getCompareImageCountApi(params.id)
|
|
|
+ if (resCount.code === 200) {
|
|
|
+ inFileCount.value = resCount.data.inFileCount
|
|
|
+ outFileCount.value = resCount.data.outFileCount
|
|
|
+ } else {
|
|
|
+ ElMessage.error('获取图片对比数量失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ dialogVisible.value = true
|
|
|
+
|
|
|
+ dialogTitle.value = '缓存图片中'
|
|
|
+ fileCount.value = Math.min(inFileCount.value, outFileCount.value)
|
|
|
+ for (let idx = 1; idx <= fileCount.value; idx++) {
|
|
|
+ dialogTitle.value = '缓存图片中: 第' + idx + '个样本 共' + fileCount.value + '个样本'
|
|
|
+ if (cacheImages.value[idx - 1]) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const res: any = await getCompareImageApi(taskId.value, idx - 1)
|
|
|
+ // console.log(res)
|
|
|
+
|
|
|
+ cacheImages.value[idx - 1] = {
|
|
|
+ origin: res.origin,
|
|
|
+ stable: res.stable
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // console.log(cacheImages.value[0])
|
|
|
+ dialogTitle.value = '预览: 第1个样本 共' + fileCount.value + '个样本'
|
|
|
+ // next_picture()
|
|
|
+}
|
|
|
+const next_picture = async () => {
|
|
|
+ if (imageIdx.value < fileCount.value - 1) {
|
|
|
+ if (!cacheImages.value[imageIdx.value + 1]) {
|
|
|
+ await loadImageData(taskId.value, imageIdx.value + 1)
|
|
|
+ }
|
|
|
+ imageIdx.value = imageIdx.value + 1
|
|
|
+ }
|
|
|
+ dialogTitle.value = '预览: 第' + (imageIdx.value + 1) + '个样本 共' + fileCount.value + '个样本'
|
|
|
+}
|
|
|
+const pre_picture = async () => {
|
|
|
+ if (imageIdx.value > 0) {
|
|
|
+ if (!cacheImages.value[imageIdx.value - 1]) {
|
|
|
+ await loadImageData(taskId.value, imageIdx.value - 1)
|
|
|
+ }
|
|
|
+ imageIdx.value = imageIdx.value - 1
|
|
|
+ }
|
|
|
+ dialogTitle.value = '预览: 第' + (imageIdx.value + 1) + '个样本 共' + fileCount.value + '个样本'
|
|
|
+}
|
|
|
+
|
|
|
+// ProTable 实例
|
|
|
+const proTable = ref<ProTableInstance>()
|
|
|
+
|
|
|
+// 删除视频去抖动信息
|
|
|
+const deleteDataAugmentation = async (params: any) => {
|
|
|
+ await useHandleData(delDataAugmentationApi, params.id, '删除任务【' + params.name + '】?')
|
|
|
+ proTable.value?.getTableList()
|
|
|
+}
|
|
|
+
|
|
|
+// 批量删除视频去抖动信息
|
|
|
+const batchDelete = async (ids: string[]) => {
|
|
|
+ await useHandleData(delDataAugmentationApi, ids, '删除所选任务?')
|
|
|
+ proTable.value?.clearSelection()
|
|
|
+ proTable.value?.getTableList()
|
|
|
+}
|
|
|
+
|
|
|
+// 导出视频去抖动列表
|
|
|
+const downloadFile = async () => {
|
|
|
+ ElMessageBox.confirm('确认导出任务数据?', '温馨提示', { type: 'warning' }).then(() =>
|
|
|
+ useDownload(exportDataAugmentationApi, '任务列表', proTable.value?.searchParam)
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// 批量添加视频去抖动
|
|
|
+const dialogRef = ref<InstanceType<typeof ImportExcel> | null>(null)
|
|
|
+const batchAdd = () => {
|
|
|
+ const params = {
|
|
|
+ title: '视频去抖动',
|
|
|
+ tempApi: importTemplateApi,
|
|
|
+ importApi: importDataAugmentationDataApi,
|
|
|
+ getTableList: proTable.value?.getTableList
|
|
|
+ }
|
|
|
+ dialogRef.value?.acceptParams(params)
|
|
|
+}
|
|
|
+
|
|
|
+const formDialogRef = ref<InstanceType<typeof DataAugmentationFormDialog> | null>(null)
|
|
|
+// 打开弹框的功能
|
|
|
+const openDialog = async (type: number, title: string, row?: any) => {
|
|
|
+ // hyperparameter.value = ''
|
|
|
+ let res = { data: {} }
|
|
|
+ if (row?.id) {
|
|
|
+ res = await getDataAugmentationApi(row?.id || null)
|
|
|
+ // hyperparameter.value = res.data?.hyperparameterConfiguration
|
|
|
+ }
|
|
|
+ // console.log(itemsOptions[1].compOptions?.value)
|
|
|
+ // 重置表单
|
|
|
+ // setItemsOptions()
|
|
|
+ const params = {
|
|
|
+ title,
|
|
|
+ width: 580,
|
|
|
+ isEdit: type !== 3,
|
|
|
+ itemsOptions: itemsOptions,
|
|
|
+ model: type == 1 ? model : res.data,
|
|
|
+ api: type == 1 ? addDataAugmentationApi : updateDataAugmentationApi,
|
|
|
+ getTableList: proTable.value?.getTableList
|
|
|
+ }
|
|
|
+ formDialogRef.value?.openDialog(params)
|
|
|
+}
|
|
|
+
|
|
|
+const statusEnums: EnumProps[] = [
|
|
|
+ {
|
|
|
+ label: '未开始',
|
|
|
+ value: '0',
|
|
|
+ disabled: false,
|
|
|
+ tagType: 'default'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '进行中',
|
|
|
+ value: '1',
|
|
|
+ disabled: false,
|
|
|
+ tagType: 'primary'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '完成',
|
|
|
+ value: '2',
|
|
|
+ disabled: false,
|
|
|
+ tagType: 'success'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '失败',
|
|
|
+ value: '3',
|
|
|
+ disabled: false,
|
|
|
+ tagType: 'danger'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '已中断',
|
|
|
+ value: '4',
|
|
|
+ disabled: false,
|
|
|
+ tagType: 'default'
|
|
|
+ }
|
|
|
+]
|
|
|
+
|
|
|
+// 表格配置项
|
|
|
+const columns = reactive<ColumnProps<any>[]>([
|
|
|
+ { type: 'selection', fixed: 'left', width: 70 },
|
|
|
+ { prop: 'id', label: '主键ID', width: 180 },
|
|
|
+ {
|
|
|
+ prop: 'name',
|
|
|
+ label: '任务名称',
|
|
|
+ search: {
|
|
|
+ el: 'input'
|
|
|
+ },
|
|
|
+ width: 150
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'taskType',
|
|
|
+ label: '任务类型',
|
|
|
+ search: {
|
|
|
+ el: 'select'
|
|
|
+ },
|
|
|
+ width: 100,
|
|
|
+ tag: true,
|
|
|
+ enum: taskTypeEnums
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'status',
|
|
|
+ label: '任务状态',
|
|
|
+ search: {
|
|
|
+ el: 'select'
|
|
|
+ },
|
|
|
+ width: 100,
|
|
|
+ tag: true,
|
|
|
+ enum: statusEnums
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'startTime',
|
|
|
+ label: '开始时间',
|
|
|
+ width: 180
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'endTime',
|
|
|
+ label: '结束时间',
|
|
|
+ width: 180
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'costSecond',
|
|
|
+ label: '耗时',
|
|
|
+ search: {
|
|
|
+ el: 'input'
|
|
|
+ },
|
|
|
+ width: 80
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'log',
|
|
|
+ label: '日志',
|
|
|
+ width: 120
|
|
|
+ },
|
|
|
+
|
|
|
+ {
|
|
|
+ prop: 'algorithmPath',
|
|
|
+ label: '日志路径',
|
|
|
+ width: 120
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'hyperparameterConfiguration',
|
|
|
+ label: '超参配置',
|
|
|
+ width: 120
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'remarks',
|
|
|
+ label: '备注',
|
|
|
+ search: {
|
|
|
+ el: 'input'
|
|
|
+ },
|
|
|
+ width: 120
|
|
|
+ },
|
|
|
+ {
|
|
|
+ prop: 'operation',
|
|
|
+ label: '操作',
|
|
|
+ width: 230,
|
|
|
+ fixed: 'right'
|
|
|
+ }
|
|
|
+])
|
|
|
+// 表单配置项
|
|
|
+let itemsOptions: ProForm.ItemsOptions[] = [
|
|
|
+ {
|
|
|
+ label: '任务名称',
|
|
|
+ prop: 'name',
|
|
|
+ rules: [{ required: true, message: '任务名称不能为空', trigger: 'blur' }],
|
|
|
+ compOptions: {
|
|
|
+ placeholder: '请输入任务名称'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '任务类型',
|
|
|
+ prop: 'taskType',
|
|
|
+ rules: [{ required: true, message: '任务类型不能为空', trigger: 'change' }],
|
|
|
+ compOptions: {
|
|
|
+ elTagName: 'select', // 指定使用 el-select 组件
|
|
|
+ placeholder: '请选择任务类型',
|
|
|
+ enum: taskType,
|
|
|
+ onChange: (value: string) => {
|
|
|
+ model.value['taskType'] = value
|
|
|
+ hyperparameterConfiguration.forEach(obj => {
|
|
|
+ if (value in obj) {
|
|
|
+ // console.log(obj[value])
|
|
|
+ addParams(obj[value])
|
|
|
+ openDialog(1, '任务新增')
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '图片集压缩包',
|
|
|
+ prop: 'inputOssId',
|
|
|
+ rules: [{ required: true, message: '数据压缩包不能为空', trigger: 'change' }],
|
|
|
+ compOptions: {
|
|
|
+ elTagName: 'file-upload',
|
|
|
+ fileSize: 4096,
|
|
|
+ fileType: ['zip'],
|
|
|
+ placeholder: '请上传图片集压缩包'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '备注',
|
|
|
+ prop: 'remarks',
|
|
|
+ rules: [
|
|
|
+ {
|
|
|
+ required: false,
|
|
|
+ trigger: 'blur'
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ compOptions: {
|
|
|
+ placeholder: '请输入备注'
|
|
|
+ }
|
|
|
+ }
|
|
|
+]
|
|
|
+const model = ref({})
|
|
|
+const setItemsOptions = () => {
|
|
|
+ if (itemsOptions.length > 4) {
|
|
|
+ itemsOptions.splice(4) // 如果里面有新增参数,删除,重新添加
|
|
|
+ }
|
|
|
+}
|
|
|
+const addParams = params => {
|
|
|
+ setItemsOptions()
|
|
|
+ if (params == 'null') {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ let validJsonString = params.replace(/'/g, '"')
|
|
|
+ try {
|
|
|
+ const obj: { [key: string]: number } = JSON.parse(validJsonString)
|
|
|
+ Object.keys(obj).forEach(key => {
|
|
|
+ // model.value[key] = obj[key]
|
|
|
+ itemsOptions.push({
|
|
|
+ label: key,
|
|
|
+ prop: key,
|
|
|
+ rules: [{ required: true, trigger: 'blur' }],
|
|
|
+ compOptions: {
|
|
|
+ type: 'input',
|
|
|
+ clearable: true,
|
|
|
+ placeholder: obj[key]
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ console.error('解析 JSON 字符串时出错:', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.image-dialog {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ .el-image {
|
|
|
+ margin-right: 20px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ }
|
|
|
+}
|
|
|
+.image-dialog-btn {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ margin-top: 20px;
|
|
|
+}
|
|
|
+</style>
|