Files
one-pipe-system/src/views/asset-management/task-management/iot-card-task/index.vue
sexygoat b48d6a1894
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m9s
feat: 新增导入字段,显示实名认证策略字段
2026-04-17 18:40:54 +08:00

872 lines
25 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>
<ArtTableFullScreen>
<div class="iot-card-task-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton
type="primary"
:icon="Upload"
@click="importDialogVisible = true"
v-permission="'lot_task:bulk_import'"
>
批量导入IoT卡
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="taskList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="120"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<!-- 失败数据对话框 -->
<ElDialog
v-model="failDataDialogVisible"
title="失败数据详情"
width="900px"
align-center
destroy-on-close
>
<div v-if="failDataLoading" style="padding: 40px; text-align: center">
<ElSkeleton :rows="5" animated />
</div>
<div v-else-if="failedItems.length > 0">
<ElAlert type="warning" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>失败数据汇总</strong></p>
<p> {{ failedItems.length }} 条记录导入失败</p>
</div>
</template>
</ElAlert>
<ElTable :data="failedItems" max-height="400" border>
<ElTableColumn prop="line" label="行号" width="80" />
<ElTableColumn prop="iccid" label="ICCID" width="200" show-overflow-tooltip />
<ElTableColumn prop="msisdn" label="MSISDN" width="150" show-overflow-tooltip />
<ElTableColumn prop="message" label="失败原因" min-width="200" show-overflow-tooltip />
</ElTable>
</div>
<div v-else style="padding: 40px; text-align: center">
<ElEmpty description="暂无失败数据" />
</div>
<template #footer>
<ElButton @click="failDataDialogVisible = false">关闭</ElButton>
<ElButton
v-if="failedItems.length > 0"
type="primary"
:icon="Download"
@click="downloadCurrentFailData"
>
下载失败数据
</ElButton>
</template>
</ElDialog>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入IoT卡" width="700px" align-center>
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载 Excel 模板文件按照模板格式填写IoT卡信息</p>
<p>2. 仅支持 Excel 格式.xlsx单次最多导入 1000 </p>
<p>3. 列格式请设置为文本格式避免长数字被转为科学计数法</p>
<p>4. <strong>必填字段ICCIDMSISDN手机号virtual_no虚拟号</strong></p>
<p>5. <strong>Excel 文件必须包含 virtual_no 否则整批导入将失败</strong></p>
<p>6. virtual_no 为空将导入失败</p>
<p>7. 必须选择运营商</p>
</div>
</template>
</ElAlert>
<div style="margin-bottom: 20px">
<ElButton type="primary" :icon="Download" @click="downloadTemplate">
下载导入模板
</ElButton>
</div>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="运营商" required>
<ElSelect
v-model="selectedCarrierId"
placeholder="请输入运营商名称搜索"
style="width: 100%"
filterable
remote
:remote-method="handleCarrierSearch"
:loading="carrierLoading"
clearable
>
<ElOption
v-for="carrier in carrierList"
:key="carrier.id"
:label="carrier.carrier_name"
:value="carrier.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="卡业务类型">
<ElSelect
v-model="selectedCardCategory"
placeholder="请选择卡业务类型"
style="width: 100%"
>
<ElOption label="普通卡" value="normal" />
<ElOption label="行业卡" value="industry" />
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="批次号" prop="batch_no">
<ElInput
v-model="importForm.batch_no"
placeholder="请输入批次号或点击生成"
:maxlength="100"
clearable
>
<template #append>
<ElButton @click="handleGenerateBatchNo">生成编码</ElButton>
</template>
</ElInput>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="实名认证策略">
<ElSelect
v-model="importForm.realname_policy"
placeholder="默认无需实名"
clearable
style="width: 100%"
>
<ElOption label="无需实名" value="none" />
<ElOption label="先实名后充值/购买" value="before_order" />
<ElOption label="先充值/购买后实名" value="after_order" />
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".xlsx"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> Excel 文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 .xlsx 格式的 Excel 文件且不超过 300MB</div>
</template>
</ElUpload>
<template #footer>
<ElButton @click="handleCancelImport">取消</ElButton>
<ElButton
type="primary"
:loading="uploading"
:disabled="!fileList.length || !selectedCarrierId"
@click="submitUpload"
>
开始导入
</ElButton>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, computed, onMounted } from 'vue'
import { CardService, CarrierService } from '@/api/modules'
import {
ElMessage,
ElTag,
ElFormItem,
ElSelect,
ElOption,
ElSkeleton,
ElEmpty
} from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { StorageService } from '@/api/modules/storage'
import { generatePackageCode } from '@/utils/codeGenerator'
import { RoutesAlias } from '@/router/routesAlias'
import type { IotCardImportTask, IotCardImportTaskStatus } from '@/types/api/card'
import type { Carrier } from '@/types/api'
defineOptions({ name: 'IotCardTask' })
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const tableRef = ref()
const uploadRef = ref<UploadInstance>()
const fileList = ref<File[]>([])
const uploading = ref(false)
const importDialogVisible = ref(false)
const selectedCarrierId = ref<number>()
const selectedCardCategory = ref<'normal' | 'industry'>('normal') // 默认普通卡
const carrierList = ref<Carrier[]>([])
const carrierLoading = ref(false)
const failDataDialogVisible = ref(false)
const failDataLoading = ref(false)
const failedItems = ref<any[]>([])
const currentFailTask = ref<IotCardImportTask | null>(null)
const importForm = reactive({
batch_no: '',
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order'
})
// 搜索表单初始值
const initialSearchState = {
status: undefined,
carrier_id: undefined,
batch_no: '',
dateRange: undefined as any
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 搜索表单配置
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '任务状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '待处理', value: 1 },
{ label: '处理中', value: 2 },
{ label: '已完成', value: 3 },
{ label: '失败', value: 4 }
]
},
{
label: '运营商',
prop: 'carrier_id',
type: 'select',
options: carrierList.value.map((carrier: Carrier) => ({
label: carrier.carrier_name,
value: carrier.id
})),
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: handleCarrierSearch,
placeholder: '请输入运营商名称搜索'
}
},
{
label: '批次号',
prop: 'batch_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入批次号'
}
},
{
label: '创建时间',
prop: 'dateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
])
// 列配置
const columnOptions = [
{ label: '任务编号', prop: 'task_no' },
{ label: '任务状态', prop: 'status' },
{ label: '运营商', prop: 'carrier_name' },
{ label: '卡业务类型', prop: 'card_category' },
{ label: '文件名', prop: 'file_name' },
{ label: '总数', prop: 'total_count' },
{ label: '成功数', prop: 'success_count' },
{ label: '失败数', prop: 'fail_count' },
{ label: '跳过数', prop: 'skip_count' },
{ label: '开始时间', prop: 'started_at' },
{ label: '完成时间', prop: 'completed_at' },
{ label: '错误信息', prop: 'error_message' },
{ label: '创建时间', prop: 'created_at' }
]
const taskList = ref<IotCardImportTask[]>([])
// 获取状态标签类型
const getStatusType = (status: IotCardImportTaskStatus) => {
switch (status) {
case 1:
return 'info'
case 2:
return 'warning'
case 3:
return 'success'
case 4:
return 'danger'
default:
return 'info'
}
}
// 查看详情 - 跳转到详情页面
const viewDetail = (row: IotCardImportTask) => {
router.push({
path: RoutesAlias.TaskDetail,
query: {
id: row.id,
task_type: 'card'
}
})
}
// 处理名称点击
const handleNameClick = (row: IotCardImportTask) => {
if (hasAuth('iot_card_task:view_detail')) {
viewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'task_no',
label: '任务编号',
width: 180,
formatter: (row: IotCardImportTask) => {
return h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleNameClick(row)
}
},
row.task_no
)
}
},
{
prop: 'status',
label: '任务状态',
width: 100,
formatter: (row: IotCardImportTask) => {
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text)
}
},
{
prop: 'carrier_name',
label: '运营商',
width: 120,
showOverflowTooltip: true
},
{
prop: 'card_category',
label: '卡业务类型',
width: 110,
formatter: (row: IotCardImportTask) => {
if (!row.card_category) return '-'
return row.card_category === 'normal'
? '普通卡'
: row.card_category === 'industry'
? '行业卡'
: row.card_category
}
},
{
prop: 'file_name',
label: '文件名',
minWidth: 250,
showOverflowTooltip: true
},
{
prop: 'total_count',
label: '总数',
width: 80
},
{
prop: 'success_count',
label: '成功数',
width: 80,
formatter: (row: IotCardImportTask) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
},
{
prop: 'fail_count',
label: '失败数',
width: 80,
formatter: (row: IotCardImportTask) => {
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
}
},
{
prop: 'skip_count',
label: '跳过数',
width: 80
},
{
prop: 'started_at',
label: '开始时间',
width: 180,
formatter: (row: IotCardImportTask) => (row.started_at ? formatDateTime(row.started_at) : '-')
},
{
prop: 'completed_at',
label: '完成时间',
width: 180,
formatter: (row: IotCardImportTask) =>
row.completed_at ? formatDateTime(row.completed_at) : '-'
},
{
prop: 'error_message',
label: '错误信息',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: IotCardImportTask) => row.error_message || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: IotCardImportTask) => formatDateTime(row.created_at)
}
])
onMounted(() => {
getTableData()
loadCarrierList()
})
// 获取IoT卡任务列表
const getTableData = async () => {
loading.value = true
try {
const params: any = {
page: pagination.page,
page_size: pagination.pageSize,
status: searchForm.status,
carrier_id: searchForm.carrier_id,
batch_no: searchForm.batch_no || undefined
}
// 处理时间范围
if (searchForm.dateRange && Array.isArray(searchForm.dateRange)) {
params.start_time = searchForm.dateRange[0]
params.end_time = searchForm.dateRange[1]
}
// 清理空值
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const res = await CardService.getIotCardImportTasks(params)
if (res.code === 0) {
taskList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
// 搜索
const handleSearch = () => {
pagination.page = 1
getTableData()
}
// 刷新表格
const handleRefresh = () => {
getTableData()
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getTableData()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.page = newCurrentPage
getTableData()
}
// 显示失败数据弹窗
const showFailDataDialog = async (row: IotCardImportTask) => {
try {
failDataDialogVisible.value = true
failDataLoading.value = true
failedItems.value = []
currentFailTask.value = row
const res = await CardService.getIotCardImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
const detail = res.data
failedItems.value =
detail.failed_items?.map((item: any) => ({
line: item.line || '-',
iccid: item.iccid || '-',
msisdn: item.msisdn || '-',
message: item.reason || item.error || '未知错误'
})) || []
if (failedItems.value.length === 0) {
ElMessage.warning('没有失败数据')
}
}
} catch (error) {
console.error('获取失败数据失败:', error)
} finally {
failDataLoading.value = false
}
}
// 下载当前失败数据
const downloadCurrentFailData = () => {
if (!currentFailTask.value || failedItems.value.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
try {
const headers = ['行号', 'ICCID', 'MSISDN', '失败原因']
const csvRows = [
headers.join(','),
...failedItems.value.map((item: any) =>
[item.line, item.iccid, item.msisdn, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
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', `IoT卡导入失败数据_${currentFailTask.value.task_no}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
} catch (error) {
console.error('下载失败数据失败:', error)
}
}
// 下载模板
const downloadTemplate = async () => {
try {
// 动态导入 xlsx 库
const XLSX = await import('xlsx')
// 创建示例数据按固定列顺序ICCID、MSISDN、虚拟号
const templateData = [
{
ICCID: '89860123456789012345',
'MSISDN接入号': '13800138000',
虚拟号: 'V001'
},
{
ICCID: '89860123456789012346',
'MSISDN接入号': '13800138001',
虚拟号: 'V002'
},
{
ICCID: '89860123456789012347',
'MSISDN接入号': '13800138002',
虚拟号: 'V003'
}
]
// 创建工作簿
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.json_to_sheet(templateData)
// 设置列宽
ws['!cols'] = [
{ wch: 25 }, // ICCID
{ wch: 20 }, // MSISDN接入号
{ wch: 15 } // 虚拟号
]
// 将所有单元格设置为文本格式,防止科学计数法
const range = XLSX.utils.decode_range(ws['!ref'] || 'A1')
for (let R = range.s.r; R <= range.e.r; ++R) {
for (let C = range.s.c; C <= range.e.c; ++C) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
if (!ws[cellAddress]) continue
ws[cellAddress].t = 's' // 设置为字符串类型
}
}
// 添加工作表到工作簿
XLSX.utils.book_append_sheet(wb, ws, 'IoT卡导入模板')
// 导出文件
XLSX.writeFile(wb, 'IoT卡导入模板.xlsx')
ElMessage.success('IoT卡导入模板下载成功')
} catch (error) {
console.error('下载模板失败:', error)
}
}
// 文件选择变化
const handleFileChange = (uploadFile: any) => {
const maxSize = 300 * 1024 * 1024
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
ElMessage.error('文件大小不能超过 300MB')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.xlsx')) {
ElMessage.error('只能上传 .xlsx 格式的 Excel 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
}
// 清空文件
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
selectedCarrierId.value = undefined
}
// 生成批次号
const handleGenerateBatchNo = () => {
const code = generatePackageCode().replace('PKG', 'IOT')
importForm.batch_no = code
ElMessage.success('批次号生成成功')
}
// 取消导入
const handleCancelImport = () => {
clearFiles()
selectedCardCategory.value = 'normal'
importForm.batch_no = ''
importForm.realname_policy = ''
importDialogVisible.value = false
}
// 加载运营商列表
const loadCarrierList = async (carrierName?: string) => {
carrierLoading.value = true
try {
const res = await CarrierService.getCarriers({
page: 1,
page_size: 20,
carrier_name: carrierName || undefined,
status: 1 // 只加载启用的运营商
})
if (res.code === 0) {
carrierList.value = res.data.items || []
}
} catch (error) {
console.error('获取运营商列表失败:', error)
} finally {
carrierLoading.value = false
}
}
// 运营商搜索处理
const handleCarrierSearch = (query: string) => {
loadCarrierList(query)
}
// 提交上传
const submitUpload = async () => {
if (!selectedCarrierId.value) {
ElMessage.warning('请先选择运营商')
return
}
if (!fileList.value.length) {
ElMessage.warning('请先选择 Excel 文件')
return
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
purpose: 'iot_import'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
return
}
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(
upload_url,
file,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
ElMessage.info('正在创建导入任务...')
const importRes = await CardService.importIotCards({
carrier_id: selectedCarrierId.value,
file_key,
batch_no: importForm.batch_no || undefined,
card_category: selectedCardCategory.value,
realname_policy: importForm.realname_policy || undefined
})
if (importRes.code !== 0) {
ElMessage.error(importRes.msg || '创建导入任务失败')
return
}
const taskNo = importRes.data.task_no
handleCancelImport()
await getTableData()
ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`,
duration: 3000,
showClose: true
})
} catch (error: any) {
console.error('IoT卡导入失败:', error)
console.log(error.message || 'IoT卡导入失败')
} finally {
uploading.value = false
}
}
// 获取操作按钮
const getActions = (row: IotCardImportTask) => {
const actions: any[] = []
if (row.fail_count > 0 && hasAuth('iot_card_task:download_fail_data')) {
actions.push({
label: '失败数据',
handler: () => showFailDataDialog(row),
type: 'danger'
})
}
return actions
}
</script>
<style lang="scss" scoped>
.iot-card-task-page {
: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);
}
}
}
</style>