Files
one-pipe-system/src/views/batch/device-import/index.vue
sexygoat b510b4539f
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 11m14s
修复bug
2026-04-08 10:19:25 +08:00

695 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>