695 lines
21 KiB
Vue
695 lines
21 KiB
Vue
<template>
|
||
<div class="page-content">
|
||
<!-- 导入操作区 -->
|
||
<ElCard shadow="never">
|
||
<template #header>
|
||
<span style="font-weight: 500">批量导入设备</span>
|
||
</template>
|
||
|
||
<ElRow :gutter="20">
|
||
<ElCol :xs="24" :lg="12">
|
||
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
|
||
<template #title>
|
||
<div style="line-height: 1.8">
|
||
<p><strong>导入说明:</strong></p>
|
||
<p>1. 请先下载 CSV 模板文件,按照模板格式填写设备信息</p>
|
||
<p>2. 支持 CSV 格式(.csv),单次最多导入 1000 条</p>
|
||
<p>3. CSV 文件编码:UTF-8(推荐)或 GBK</p>
|
||
<p
|
||
>4.
|
||
<strong>必填字段:virtual_no(设备虚拟号)</strong
|
||
>、device_name(设备名称)、device_model(设备型号)</p
|
||
>
|
||
<p
|
||
>5.
|
||
可选字段:device_type(设备类型)、manufacturer(制造商)、max_sim_slots(最大插槽数,默认1)</p
|
||
>
|
||
<p
|
||
>6. virtual_no
|
||
为空将导入失败,设备号重复将自动跳过,导入后可在任务管理中查看详情</p
|
||
>
|
||
</div>
|
||
</template>
|
||
</ElAlert>
|
||
|
||
<ElButton type="primary" :icon="Download" @click="downloadTemplate"
|
||
>下载导入模板</ElButton
|
||
>
|
||
</ElCol>
|
||
|
||
<ElCol :xs="24" :lg="12">
|
||
<div class="upload-area">
|
||
<ElUpload
|
||
ref="uploadRef"
|
||
drag
|
||
:auto-upload="false"
|
||
:on-change="handleFileChange"
|
||
:limit="1"
|
||
accept=".csv"
|
||
>
|
||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||
<div class="el-upload__text">将 CSV 文件拖到此处,或<em>点击选择</em></div>
|
||
<template #tip>
|
||
<div class="el-upload__tip">只能上传 CSV 文件,且不超过 10MB</div>
|
||
</template>
|
||
</ElUpload>
|
||
<div style="margin-top: 16px; text-align: center">
|
||
<ElButton
|
||
type="success"
|
||
:loading="uploading"
|
||
:disabled="!fileList.length"
|
||
@click="submitUpload"
|
||
>
|
||
开始导入
|
||
</ElButton>
|
||
<ElButton @click="clearFiles">清空</ElButton>
|
||
</div>
|
||
</div>
|
||
</ElCol>
|
||
</ElRow>
|
||
</ElCard>
|
||
|
||
<!-- 导入记录 -->
|
||
<ElCard shadow="never" style="margin-top: 20px">
|
||
<template #header>
|
||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||
<span style="font-weight: 500">导入记录</span>
|
||
<div>
|
||
<ElSelect
|
||
v-model="statusFilter"
|
||
placeholder="状态筛选"
|
||
style="width: 120px; margin-right: 12px"
|
||
clearable
|
||
>
|
||
<ElOption label="全部" :value="null" />
|
||
<ElOption label="待处理" :value="1" />
|
||
<ElOption label="处理中" :value="2" />
|
||
<ElOption label="已完成" :value="3" />
|
||
<ElOption label="失败" :value="4" />
|
||
</ElSelect>
|
||
<ElButton @click="refreshList">刷新</ElButton>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<ArtTable
|
||
rowKey="id"
|
||
ref="tableRef"
|
||
:loading="loading"
|
||
:data="filteredRecords"
|
||
:marginTop="10"
|
||
:stripe="false"
|
||
>
|
||
<template #default>
|
||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||
</template>
|
||
</ArtTable>
|
||
</ElCard>
|
||
|
||
<!-- 导入详情对话框 -->
|
||
<ElDialog v-model="detailDialogVisible" title="设备导入详情" width="900px" align-center>
|
||
<ElDescriptions :column="2" border>
|
||
<ElDescriptionsItem label="任务编号" :span="2">{{
|
||
currentDetail.taskNo
|
||
}}</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="状态">{{ currentDetail.statusText }}</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="批次号">{{ currentDetail.batchNo }}</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="文件名" :span="2">{{
|
||
currentDetail.fileName
|
||
}}</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="总数">{{ currentDetail.totalCount }}</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="成功数">
|
||
<span style="color: var(--el-color-success)">{{ currentDetail.successCount }}</span>
|
||
</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="跳过数">{{ currentDetail.skipCount }}</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="失败数">
|
||
<span style="color: var(--el-color-danger)">{{ currentDetail.failCount }}</span>
|
||
</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="警告数">
|
||
<span style="color: var(--el-color-warning)">{{ currentDetail.warningCount }}</span>
|
||
</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="开始时间">{{ currentDetail.startedAt }}</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="完成时间">{{ currentDetail.completedAt }}</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="创建时间">{{ currentDetail.createdAt }}</ElDescriptionsItem>
|
||
<ElDescriptionsItem label="错误信息" :span="2">{{
|
||
currentDetail.errorMessage
|
||
}}</ElDescriptionsItem>
|
||
</ElDescriptions>
|
||
|
||
<ElDivider content-position="left">失败明细</ElDivider>
|
||
<div
|
||
v-if="currentDetail.failReasons && currentDetail.failReasons.length"
|
||
style="max-height: 300px; overflow-y: auto"
|
||
>
|
||
<ElTable :data="currentDetail.failReasons" border size="small">
|
||
<ElTableColumn label="行号" prop="row" width="80" />
|
||
<ElTableColumn label="设备编号" prop="deviceCode" width="150" />
|
||
<ElTableColumn label="ICCID" prop="iccid" width="200" />
|
||
<ElTableColumn label="失败原因" prop="message" min-width="200" />
|
||
</ElTable>
|
||
</div>
|
||
<ElEmpty v-else description="无失败记录" />
|
||
|
||
<template #footer>
|
||
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
|
||
<ElButton
|
||
v-if="currentDetail.failCount > 0"
|
||
type="primary"
|
||
:icon="Download"
|
||
@click="downloadFailData(currentDetail)"
|
||
>
|
||
下载失败数据
|
||
</ElButton>
|
||
</template>
|
||
</ElDialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { h, computed, watch } from 'vue'
|
||
import { ElMessage, ElTag, ElProgress, ElIcon, ElButton } from 'element-plus'
|
||
import { useRouter } from 'vue-router'
|
||
import { Download, UploadFilled, View, Loading } from '@element-plus/icons-vue'
|
||
import type { UploadInstance } from 'element-plus'
|
||
import { StorageService } from '@/api/modules/storage'
|
||
import { DeviceService } from '@/api/modules'
|
||
import { formatDateTime } from '@/utils/business/format'
|
||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
|
||
|
||
defineOptions({ name: 'DeviceImport' })
|
||
|
||
const router = useRouter()
|
||
|
||
interface FailReason {
|
||
row: number
|
||
deviceCode: string
|
||
iccid: string
|
||
message: string
|
||
}
|
||
|
||
interface ImportRecord {
|
||
id: string
|
||
taskNo: string
|
||
status: number
|
||
statusText: string
|
||
batchNo: string
|
||
fileName: string
|
||
totalCount: number
|
||
successCount: number
|
||
skipCount: number
|
||
failCount: number
|
||
warningCount: number
|
||
startedAt: string
|
||
completedAt: string
|
||
errorMessage: string
|
||
createdAt: string
|
||
failReasons?: FailReason[]
|
||
}
|
||
|
||
const uploadRef = ref<UploadInstance>()
|
||
const tableRef = ref()
|
||
const fileList = ref<File[]>([])
|
||
const uploading = ref(false)
|
||
const detailDialogVisible = ref(false)
|
||
const statusFilter = ref<number | null>(null)
|
||
const loading = ref(false)
|
||
|
||
const importRecords = ref<ImportRecord[]>([])
|
||
|
||
const currentDetail = ref<ImportRecord>({
|
||
id: '',
|
||
taskNo: '',
|
||
status: 1,
|
||
statusText: '',
|
||
batchNo: '',
|
||
fileName: '',
|
||
totalCount: 0,
|
||
successCount: 0,
|
||
skipCount: 0,
|
||
failCount: 0,
|
||
warningCount: 0,
|
||
startedAt: '',
|
||
completedAt: '',
|
||
errorMessage: '',
|
||
createdAt: ''
|
||
})
|
||
|
||
const filteredRecords = computed(() => {
|
||
if (statusFilter.value === null) return importRecords.value
|
||
return importRecords.value.filter((item) => item.status === statusFilter.value)
|
||
})
|
||
|
||
// 动态列配置
|
||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||
{
|
||
prop: 'taskNo',
|
||
label: '任务编号',
|
||
width: 200,
|
||
showOverflowTooltip: true
|
||
},
|
||
{
|
||
prop: 'status',
|
||
label: '状态',
|
||
width: 120,
|
||
formatter: (row: ImportRecord) => {
|
||
if (row.status === 1) {
|
||
return h(ElTag, { type: 'info' }, () => '待处理')
|
||
} else if (row.status === 2) {
|
||
return h(
|
||
ElTag,
|
||
{ type: 'warning' },
|
||
{
|
||
default: () => [
|
||
h(ElIcon, { class: 'is-loading' }, () => h(Loading)),
|
||
h('span', { style: { marginLeft: '4px' } }, '处理中')
|
||
]
|
||
}
|
||
)
|
||
} else if (row.status === 3) {
|
||
return h(ElTag, { type: 'success' }, () => '已完成')
|
||
} else if (row.status === 4) {
|
||
return h(ElTag, { type: 'danger' }, () => '失败')
|
||
} else {
|
||
return h(ElTag, { type: 'info' }, () => row.statusText || '-')
|
||
}
|
||
}
|
||
},
|
||
{
|
||
prop: 'batchNo',
|
||
label: '批次号',
|
||
width: 180,
|
||
showOverflowTooltip: true
|
||
},
|
||
{
|
||
prop: 'fileName',
|
||
label: '文件名',
|
||
minWidth: 200,
|
||
showOverflowTooltip: true
|
||
},
|
||
{
|
||
prop: 'totalCount',
|
||
label: '总数',
|
||
width: 100
|
||
},
|
||
{
|
||
prop: 'successCount',
|
||
label: '成功数',
|
||
width: 100,
|
||
formatter: (row: ImportRecord) => {
|
||
return h('span', { style: { color: 'var(--el-color-success)' } }, row.successCount)
|
||
}
|
||
},
|
||
{
|
||
prop: 'skipCount',
|
||
label: '跳过数',
|
||
width: 100
|
||
},
|
||
{
|
||
prop: 'failCount',
|
||
label: '失败数',
|
||
width: 100,
|
||
formatter: (row: ImportRecord) => {
|
||
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.failCount)
|
||
}
|
||
},
|
||
{
|
||
prop: 'warningCount',
|
||
label: '警告数',
|
||
width: 100,
|
||
formatter: (row: ImportRecord) => {
|
||
return h('span', { style: { color: 'var(--el-color-warning)' } }, row.warningCount)
|
||
}
|
||
},
|
||
{
|
||
prop: 'startedAt',
|
||
label: '开始时间',
|
||
width: 180
|
||
},
|
||
{
|
||
prop: 'completedAt',
|
||
label: '完成时间',
|
||
width: 180
|
||
},
|
||
{
|
||
prop: 'errorMessage',
|
||
label: '错误信息',
|
||
minWidth: 200,
|
||
showOverflowTooltip: true
|
||
},
|
||
{
|
||
prop: 'createdAt',
|
||
label: '创建时间',
|
||
width: 180
|
||
},
|
||
{
|
||
prop: 'operation',
|
||
label: '操作',
|
||
width: 180,
|
||
fixed: 'right',
|
||
formatter: (row: ImportRecord) => {
|
||
const buttons = []
|
||
|
||
// 显示"查看详情"按钮
|
||
buttons.push(
|
||
h(ArtButtonTable, {
|
||
text: '详情',
|
||
onClick: () => viewDetail(row)
|
||
})
|
||
),
|
||
buttons.push(
|
||
h(ArtButtonTable, {
|
||
text: '失败数据',
|
||
type: 'danger',
|
||
onClick: () => downloadFailData(row)
|
||
})
|
||
)
|
||
|
||
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
|
||
}
|
||
}
|
||
])
|
||
|
||
const downloadTemplate = () => {
|
||
// CSV模板内容 - 包含表头和示例数据
|
||
const csvContent = [
|
||
// 表头
|
||
'virtual_no,device_name,device_model,device_type,manufacturer,max_sim_slots',
|
||
// 示例数据
|
||
'DEV001,智能水表01,WM-2000,智能水表,华为,1',
|
||
'DEV002,GPS定位器01,GPS-3000,定位设备,小米,2',
|
||
'DEV003,智能燃气表01,GM-1500,智能燃气表,海尔,1'
|
||
].join('\n')
|
||
|
||
// 添加 BOM 头确保 Excel 正确识别 UTF-8 编码
|
||
const BOM = '\uFEFF'
|
||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||
|
||
// 创建下载链接
|
||
const link = document.createElement('a')
|
||
const url = URL.createObjectURL(blob)
|
||
link.setAttribute('href', url)
|
||
link.setAttribute('download', '设备导入模板.csv')
|
||
link.style.visibility = 'hidden'
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
URL.revokeObjectURL(url)
|
||
|
||
ElMessage.success('设备导入模板下载成功')
|
||
}
|
||
|
||
const handleFileChange = (uploadFile: any) => {
|
||
// 验证文件大小(10MB限制)
|
||
const maxSize = 10 * 1024 * 1024
|
||
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
|
||
ElMessage.error('文件大小不能超过 10MB')
|
||
uploadRef.value?.clearFiles()
|
||
fileList.value = []
|
||
return
|
||
}
|
||
|
||
// 验证文件类型
|
||
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.csv')) {
|
||
ElMessage.error('只能上传 CSV 文件')
|
||
uploadRef.value?.clearFiles()
|
||
fileList.value = []
|
||
return
|
||
}
|
||
|
||
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
|
||
}
|
||
|
||
const clearFiles = () => {
|
||
uploadRef.value?.clearFiles()
|
||
fileList.value = []
|
||
}
|
||
|
||
/**
|
||
* 三步上传流程
|
||
* 1. 调用 StorageService.getUploadUrl() 获取预签名 URL 和 file_key
|
||
* 2. 调用 StorageService.uploadFile() 上传文件到对象存储
|
||
* 3. 调用 DeviceService.importDevices() 触发后端导入任务
|
||
*/
|
||
const submitUpload = async () => {
|
||
if (!fileList.value.length) {
|
||
ElMessage.warning('请先选择CSV文件')
|
||
return
|
||
}
|
||
|
||
const file = fileList.value[0]
|
||
uploading.value = true
|
||
|
||
try {
|
||
// 第一步:获取预签名上传 URL
|
||
ElMessage.info('正在准备上传...')
|
||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||
file_name: file.name,
|
||
content_type: 'text/csv',
|
||
purpose: 'iot_import'
|
||
})
|
||
|
||
if (uploadUrlRes.code !== 0) {
|
||
throw new Error(uploadUrlRes.msg || '获取上传地址失败')
|
||
}
|
||
|
||
const { upload_url, file_key } = uploadUrlRes.data
|
||
|
||
// 第二步:上传文件到对象存储
|
||
ElMessage.info('正在上传文件...')
|
||
await StorageService.uploadFile(upload_url, file, 'text/csv')
|
||
|
||
// 第三步:调用设备导入API
|
||
ElMessage.info('正在创建导入任务...')
|
||
const importRes = await DeviceService.importDevices({
|
||
file_key,
|
||
batch_no: `DEV-${Date.now()}`
|
||
})
|
||
|
||
if (importRes.code !== 0) {
|
||
throw new Error(importRes.msg || '创建导入任务失败')
|
||
}
|
||
|
||
const taskNo = importRes.data.task_no
|
||
|
||
// 清空文件列表
|
||
clearFiles()
|
||
|
||
// 刷新任务列表
|
||
await fetchImportTasks()
|
||
|
||
// 显示成功消息
|
||
ElMessage.success({
|
||
message: `导入任务已创建!任务编号:${taskNo}`,
|
||
duration: 3000,
|
||
showClose: true
|
||
})
|
||
} catch (error: any) {
|
||
console.error('设备导入失败:', error)
|
||
ElMessage.error(error.message || '设备导入失败')
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
// 获取导入任务列表
|
||
const fetchImportTasks = async () => {
|
||
loading.value = true
|
||
try {
|
||
const params: any = {
|
||
page: 1,
|
||
page_size: 100
|
||
}
|
||
|
||
// 如果有状态筛选,添加到参数
|
||
if (statusFilter.value !== null) {
|
||
params.status = statusFilter.value
|
||
}
|
||
|
||
const res = await DeviceService.getImportTasks(params)
|
||
if (res.code === 0 && res.data) {
|
||
// 将API返回的数据映射到本地格式
|
||
importRecords.value = res.data.items.map((item: any) => ({
|
||
id: item.id.toString(),
|
||
taskNo: item.task_no || '-',
|
||
status: item.status,
|
||
statusText: item.status_text || '-',
|
||
batchNo: item.batch_no || '-',
|
||
fileName: item.file_name || '-',
|
||
totalCount: item.total_count || 0,
|
||
successCount: item.success_count || 0,
|
||
skipCount: item.skip_count || 0,
|
||
failCount: item.fail_count || 0,
|
||
warningCount: item.warning_count || 0,
|
||
startedAt: item.started_at ? formatDateTime(item.started_at) : '-',
|
||
completedAt: item.completed_at ? formatDateTime(item.completed_at) : '-',
|
||
errorMessage: item.error_message || '-',
|
||
createdAt: item.created_at ? formatDateTime(item.created_at) : '-'
|
||
}))
|
||
}
|
||
} catch (error) {
|
||
console.error('获取导入任务列表失败:', error)
|
||
ElMessage.error('获取导入任务列表失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const refreshList = () => {
|
||
fetchImportTasks()
|
||
}
|
||
|
||
const viewDetail = async (row: ImportRecord) => {
|
||
try {
|
||
const res = await DeviceService.getImportTaskDetail(Number(row.id))
|
||
if (res.code === 0 && res.data) {
|
||
const detail = res.data
|
||
currentDetail.value = {
|
||
id: detail.id.toString(),
|
||
taskNo: detail.task_no || '-',
|
||
status: detail.status,
|
||
statusText: detail.status_text || '-',
|
||
batchNo: detail.batch_no || '-',
|
||
fileName: detail.file_name || '-',
|
||
totalCount: detail.total_count || 0,
|
||
successCount: detail.success_count || 0,
|
||
skipCount: detail.skip_count || 0,
|
||
failCount: detail.fail_count || 0,
|
||
warningCount: detail.warning_count || 0,
|
||
startedAt: detail.started_at ? formatDateTime(detail.started_at) : '-',
|
||
completedAt: detail.completed_at ? formatDateTime(detail.completed_at) : '-',
|
||
errorMessage: detail.error_message || '-',
|
||
createdAt: detail.created_at ? formatDateTime(detail.created_at) : '-',
|
||
failReasons:
|
||
detail.failed_items?.map((item: any, index: number) => ({
|
||
row: index + 1,
|
||
deviceCode: item.virtual_no || '-',
|
||
iccid: item.iccid || '-',
|
||
message: item.reason || item.error || '未知错误'
|
||
})) || []
|
||
}
|
||
detailDialogVisible.value = true
|
||
}
|
||
} catch (error) {
|
||
console.error('获取任务详情失败:', error)
|
||
ElMessage.error('获取任务详情失败')
|
||
}
|
||
}
|
||
|
||
const downloadFailData = (row: ImportRecord) => {
|
||
if (!row.failReasons || row.failReasons.length === 0) {
|
||
ElMessage.warning('没有失败数据可下载')
|
||
return
|
||
}
|
||
|
||
// 生成失败数据CSV
|
||
const headers = ['行号', '设备编号', 'ICCID', '失败原因']
|
||
const csvRows = [
|
||
headers.join(','),
|
||
...row.failReasons.map((item) =>
|
||
[item.row, item.deviceCode, item.iccid, `"${item.message}"`].join(',')
|
||
)
|
||
]
|
||
const csvContent = csvRows.join('\n')
|
||
|
||
// 添加 BOM 头确保 Excel 正确识别 UTF-8 编码
|
||
const BOM = '\uFEFF'
|
||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||
|
||
// 创建下载链接
|
||
const link = document.createElement('a')
|
||
const url = URL.createObjectURL(blob)
|
||
link.setAttribute('href', url)
|
||
link.setAttribute('download', `导入失败数据_${row.batchNo}.csv`)
|
||
link.style.visibility = 'hidden'
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
URL.revokeObjectURL(url)
|
||
|
||
ElMessage.success('失败数据下载成功')
|
||
}
|
||
|
||
// 页面加载时获取任务列表
|
||
onMounted(() => {
|
||
fetchImportTasks()
|
||
})
|
||
|
||
// 监听状态筛选变化
|
||
watch(statusFilter, () => {
|
||
fetchImportTasks()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.page-content {
|
||
.upload-area {
|
||
:deep(.el-upload) {
|
||
width: 100%;
|
||
}
|
||
|
||
:deep(.el-upload-dragger) {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
:deep(.el-icon--upload) {
|
||
margin-bottom: 16px;
|
||
font-size: 67px;
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
|
||
:deep(.el-upload__text) {
|
||
font-size: 14px;
|
||
color: var(--el-text-color-regular);
|
||
|
||
em {
|
||
font-style: normal;
|
||
color: var(--el-color-primary);
|
||
}
|
||
}
|
||
|
||
:deep(.is-loading) {
|
||
animation: rotating 2s linear infinite;
|
||
}
|
||
|
||
@keyframes rotating {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.stat-card {
|
||
:deep(.el-card__body) {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 20px;
|
||
}
|
||
|
||
.stat-content {
|
||
.stat-label {
|
||
margin-bottom: 8px;
|
||
font-size: 14px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
}
|
||
|
||
.stat-icon {
|
||
font-size: 40px;
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
}
|
||
</style>
|