feat: 新增导出
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m19s

This commit is contained in:
luo
2026-06-16 11:35:45 +08:00
parent 908367ba5d
commit 2a8f4e40d6
19 changed files with 1347 additions and 0 deletions

View File

@@ -41,6 +41,9 @@
>
批量设置套餐系列
</ElButton>
<ElButton type="primary" @click="showExportDialog" v-permission="'devices:export'">
导出
</ElButton>
</template>
</ArtTableHeader>
@@ -674,6 +677,13 @@
:identifier="operationLogsIdentifier"
download-permission="device:download_log_file"
/>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="device"
:query="exportQuery"
confirm-permission="devices:export"
title="导出设备"
/>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -712,6 +722,7 @@
import type { PackageSeriesResponse } from '@/types/api'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
defineOptions({ name: 'DeviceList' })
@@ -740,6 +751,7 @@
const selectedDevices = ref<Device[]>([])
const operationLogsDialogVisible = ref(false)
const operationLogsIdentifier = ref('')
const exportDialogVisible = ref(false)
const shopCascadeOptions = ref<any[]>([])
const shopCascadeProps = {
lazy: true,
@@ -1714,6 +1726,36 @@
}
}
const exportQuery = computed(() => {
const query: Record<string, unknown> = {
virtual_no: searchForm.virtual_no || undefined,
imei: searchForm.imei || undefined,
device_name: searchForm.device_name || undefined,
status: searchForm.status,
activation_status: searchForm.activation_status ?? undefined,
batch_no: searchForm.batch_no || undefined,
device_type: searchForm.device_type || undefined,
manufacturer: searchForm.manufacturer || undefined,
shop_id: searchForm.shop_id || undefined,
series_id: searchForm.series_id || undefined,
has_active_package: searchForm.has_active_package ?? undefined,
created_at_start: searchForm.created_at_start || undefined,
created_at_end: searchForm.created_at_end || undefined
}
Object.keys(query).forEach((key) => {
if (query[key] === undefined || query[key] === null || query[key] === '') {
delete query[key]
}
})
return query
})
const showExportDialog = () => {
exportDialogVisible.value = true
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })

View File

@@ -0,0 +1,295 @@
<template>
<ArtTableFullScreen>
<div class="export-task-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<div class="detail-header">
<ElButton @click="goBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">导出任务详情</h2>
<div class="detail-actions" v-if="taskDetail">
<ElButton
v-if="
taskDetail.status === ExportTaskStatus.COMPLETED && hasAuth('export_task:download')
"
type="primary"
@click="downloadTask"
>
下载文件
</ElButton>
<ElButton
v-if="canCancelTask && hasAuth('export_task:cancel')"
type="danger"
@click="cancelTask"
>
取消任务
</ElButton>
</div>
</div>
<div v-if="loading" class="detail-loading">
<ElIcon class="is-loading" :size="36">
<Loading />
</ElIcon>
<div>加载中...</div>
</div>
<DetailPage v-else-if="taskDetail" :sections="detailSections" :data="taskDetail" />
<ElEmpty v-else description="暂无详情" />
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElMessageBox, ElTag } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import type { ExportTaskDetail, ExportTaskScene } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
defineOptions({ name: 'ExportTaskDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const taskDetail = ref<ExportTaskDetail | null>(null)
const getSceneName = (scene?: ExportTaskScene) => {
const sceneMap: Record<ExportTaskScene, string> = {
device: '设备管理',
iot_card: 'IoT卡管理'
}
return scene ? sceneMap[scene] : '-'
}
const getStatusTagType = (status?: ExportTaskStatus) => {
const statusTypeMap: Record<
ExportTaskStatus,
'primary' | 'success' | 'warning' | 'danger' | 'info'
> = {
[ExportTaskStatus.PENDING]: 'info',
[ExportTaskStatus.PROCESSING]: 'warning',
[ExportTaskStatus.COMPLETED]: 'success',
[ExportTaskStatus.FAILED]: 'danger',
[ExportTaskStatus.CANCELED]: 'info'
}
return status ? statusTypeMap[status] : 'info'
}
const canCancelTask = computed(() =>
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(
taskDetail.value?.status as ExportTaskStatus
)
)
const detailSections = computed((): DetailSection[] => [
{
title: '任务基本信息',
fields: [
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{
label: '导出场景',
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getSceneName(data.scene))
},
{
label: '任务状态',
render: (data: ExportTaskDetail) =>
h(ElTag, { type: getStatusTagType(data.status) }, () => data.status_name || '-')
},
{ label: '导出格式', prop: 'format', formatter: (value: string) => value || '-' },
{
label: '取消请求',
formatter: (_value: unknown, data: ExportTaskDetail) =>
data.cancel_requested ? '是' : '否'
},
{
label: '创建时间',
prop: 'created_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '开始时间',
prop: 'started_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '完成时间',
prop: 'completed_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '错误信息',
prop: 'error_message',
fullWidth: true,
formatter: (value: string) => value || '-'
}
]
},
{
title: '处理进度',
fields: [
{ label: '进度', prop: 'progress', formatter: (value: number) => `${value ?? 0}%` },
{ label: '总行数', prop: 'total_rows', formatter: (value: number) => String(value ?? 0) },
{
label: '已处理行数',
prop: 'processed_rows',
formatter: (value: number) => String(value ?? 0)
},
{
label: '分片进度',
formatter: (_value: unknown, data: ExportTaskDetail) =>
`${data.success_shards ?? 0}/${data.total_shards ?? 0}`
},
{
label: '失败分片',
prop: 'failed_shards',
formatter: (value: number) => String(value ?? 0)
}
]
},
{
title: '文件信息',
fields: [
{
label: '文件Key',
prop: 'file_key',
fullWidth: true,
formatter: (value: string) => value || '-'
},
{
label: '下载地址',
fullWidth: true,
render: (data: ExportTaskDetail) => {
if (!data.download_url) return h('span', '-')
return h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => window.open(data.download_url, '_blank')
},
() => '打开下载地址'
)
}
},
{
label: '下载过期时间',
prop: 'download_expires_at',
formatter: (value: string) => formatDateTime(value) || '-'
}
]
}
])
const goBack = () => {
router.back()
}
const getTaskId = () => Number(route.query.id)
const getTaskDetail = async () => {
const taskId = getTaskId()
if (!taskId) {
ElMessage.error('缺少任务ID参数')
goBack()
return
}
loading.value = true
try {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 0) {
taskDetail.value = res.data
} else {
ElMessage.error(res.msg || '获取导出任务详情失败')
}
} catch (error) {
console.error('获取导出任务详情失败:', error)
ElMessage.error('获取导出任务详情失败')
} finally {
loading.value = false
}
}
const downloadTask = () => {
if (!taskDetail.value?.download_url) {
ElMessage.warning('当前任务暂无可用下载地址')
return
}
window.open(taskDetail.value.download_url, '_blank')
}
const cancelTask = () => {
if (!taskDetail.value) return
ElMessageBox.confirm(`确定取消导出任务 ${taskDetail.value.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(taskDetail.value!.id)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTaskDetail()
} else {
ElMessage.error(res.msg || '取消导出任务失败')
}
} catch (error) {
console.error('取消导出任务失败:', error)
ElMessage.error('取消导出任务失败')
}
})
}
onMounted(() => {
getTaskDetail()
})
</script>
<style scoped lang="scss">
.export-task-detail-page {
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.detail-actions {
display: flex;
gap: 10px;
margin-left: auto;
}
}
.detail-loading {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 220px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<ArtTableFullScreen>
<div class="export-task-list-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="110"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
/>
<ArtTable
:loading="loading"
:data="taskList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
@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>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElProgress, ElTag } from 'element-plus'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import type { SearchFormItem } from '@/types'
import type { ExportTaskItem, ExportTaskScene } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
defineOptions({ name: 'ExportTaskList' })
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const taskList = ref<ExportTaskItem[]>([])
const initialSearchState = {
scene: undefined as ExportTaskScene | undefined,
status: undefined as ExportTaskStatus | undefined,
dateRange: [] as string[],
start_time: '',
end_time: ''
}
const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const sceneOptions = [
{ label: '设备管理', value: 'device' },
{ label: 'IoT卡管理', value: 'iot_card' }
]
const statusOptions = [
{ label: '待处理', value: ExportTaskStatus.PENDING },
{ label: '处理中', value: ExportTaskStatus.PROCESSING },
{ label: '已完成', value: ExportTaskStatus.COMPLETED },
{ label: '失败', value: ExportTaskStatus.FAILED },
{ label: '已取消', value: ExportTaskStatus.CANCELED }
]
const searchFormItems: SearchFormItem[] = [
{
label: '导出场景',
prop: 'scene',
type: 'select',
config: { clearable: true, placeholder: '请选择导出场景' },
options: () => sceneOptions
},
{
label: '任务状态',
prop: 'status',
type: 'select',
config: { clearable: true, placeholder: '请选择任务状态' },
options: () => statusOptions
},
{
label: '创建时间',
prop: 'dateRange',
type: 'date',
config: {
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD'
}
}
]
const columnOptions = [
{ label: '任务编号', prop: 'task_no' },
{ label: '场景', prop: 'scene' },
{ label: '状态', prop: 'status_name' },
{ label: '进度', prop: 'progress' },
{ label: '格式', prop: 'format' },
{ label: '总行数', prop: 'total_rows' },
{ label: '已处理行数', prop: 'processed_rows' },
{ label: '分片', prop: 'total_shards' },
{ label: '错误信息', prop: 'error_message' },
{ label: '创建时间', prop: 'created_at' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'task_no',
label: '任务编号',
minWidth: 170,
showOverflowTooltip: true,
formatter: (row: ExportTaskItem) =>
h(
'span',
{
class: 'task-no-link',
onClick: () => goDetail(row)
},
row.task_no || '-'
)
},
{
prop: 'scene',
label: '场景',
width: 120,
formatter: (row: ExportTaskItem) => getSceneName(row.scene)
},
{
prop: 'status_name',
label: '状态',
width: 110,
formatter: (row: ExportTaskItem) =>
h(ElTag, { type: getStatusTagType(row.status) }, () => row.status_name || '-')
},
{
prop: 'progress',
label: '进度',
width: 150,
formatter: (row: ExportTaskItem) => h(ElProgress, { percentage: row.progress || 0 })
},
{ prop: 'format', label: '格式', width: 90 },
{ prop: 'total_rows', label: '总行数', width: 110 },
{ prop: 'processed_rows', label: '已处理行数', width: 120 },
{
prop: 'total_shards',
label: '分片',
width: 130,
formatter: (row: ExportTaskItem) => `${row.success_shards || 0}/${row.total_shards || 0}`
},
{ prop: 'error_message', label: '错误信息', minWidth: 180, showOverflowTooltip: true },
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: ExportTaskItem) => formatDateTime(row.created_at) || '-'
}
])
const getSceneName = (scene?: ExportTaskScene) => {
const option = sceneOptions.find((item) => item.value === scene)
return option?.label || '-'
}
const getStatusTagType = (status: ExportTaskStatus) => {
const statusTypeMap: Record<
ExportTaskStatus,
'primary' | 'success' | 'warning' | 'danger' | 'info'
> = {
[ExportTaskStatus.PENDING]: 'info',
[ExportTaskStatus.PROCESSING]: 'warning',
[ExportTaskStatus.COMPLETED]: 'success',
[ExportTaskStatus.FAILED]: 'danger',
[ExportTaskStatus.CANCELED]: 'info'
}
return statusTypeMap[status] || 'info'
}
const syncDateRange = () => {
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
searchForm.start_time = searchForm.dateRange[0] || ''
searchForm.end_time = searchForm.dateRange[1] || ''
} else {
searchForm.start_time = ''
searchForm.end_time = ''
}
}
const getTableData = async () => {
loading.value = true
try {
syncDateRange()
const res = await ExportTaskService.getExportTasks({
page: pagination.page,
page_size: pagination.pageSize,
scene: searchForm.scene,
status: searchForm.status,
start_time: searchForm.start_time || undefined,
end_time: searchForm.end_time || undefined
})
if (res.code === 0 && res.data) {
taskList.value = res.data.items || []
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取导出任务列表失败')
}
} catch (error) {
console.error('获取导出任务列表失败:', error)
ElMessage.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 = (pageSize: number) => {
pagination.pageSize = pageSize
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const getTaskDetail = async (id: number) => {
const res = await ExportTaskService.getExportTaskDetail(id)
if (res.code !== 0 || !res.data) {
throw new Error(res.msg || '获取导出任务详情失败')
}
return res.data
}
const goDetail = (row: ExportTaskItem) => {
if (!hasAuth('export_task:detail')) {
ElMessage.warning('您没有查看导出任务详情的权限')
return
}
router.push({
path: RoutesAlias.ExportTaskDetail,
query: { id: row.id }
})
}
const downloadTask = async (row: ExportTaskItem) => {
try {
const detail = await getTaskDetail(row.id)
if (!detail.download_url) {
ElMessage.warning('当前任务暂无可用下载地址')
return
}
window.open(detail.download_url, '_blank')
} catch (error) {
console.error('下载导出文件失败:', error)
ElMessage.error(error instanceof Error ? error.message : '下载导出文件失败')
}
}
const cancelTask = (row: ExportTaskItem) => {
ElMessageBox.confirm(`确定取消导出任务 ${row.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(row.id)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTableData()
} else {
ElMessage.error(res.msg || '取消导出任务失败')
}
} catch (error) {
console.error('取消导出任务失败:', error)
ElMessage.error('取消导出任务失败')
}
})
}
const getActions = (row: ExportTaskItem) => {
const actions: any[] = []
if (row.status === ExportTaskStatus.COMPLETED && hasAuth('export_task:download')) {
actions.push({ label: '下载', handler: () => downloadTask(row), type: 'primary' })
}
if (
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(row.status) &&
hasAuth('export_task:cancel')
) {
actions.push({ label: '取消', handler: () => cancelTask(row), type: 'danger' })
}
return actions
}
onMounted(() => {
getTableData()
})
</script>
<style scoped lang="scss">
.export-task-list-page {
height: 100%;
}
:deep(.task-no-link) {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
</style>

View File

@@ -42,6 +42,9 @@
>
批量设置套餐系列
</ElButton>
<ElButton type="primary" @click="showExportDialog" v-permission="'iot_card:export'">
导出
</ElButton>
</template>
</ArtTableHeader>
@@ -628,6 +631,13 @@
:identifier="operationLogsIdentifier"
download-permission="iot_card:download_log_file"
/>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="iot_card"
:query="exportQuery"
confirm-permission="iot_card:export"
title="导出IoT卡"
/>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -648,6 +658,7 @@
import { Loading } from '@element-plus/icons-vue'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
import UpdateRealnameStatusDialog from '@/components/business/UpdateRealnameStatusDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -697,6 +708,7 @@
// 操作审计日志弹窗
const operationLogsDialogVisible = ref(false)
const operationLogsIdentifier = ref('')
const exportDialogVisible = ref(false)
// 套餐系列绑定相关
const seriesBindingDialogVisible = ref(false)
@@ -1709,6 +1721,22 @@
return actions
}
const exportQuery = computed(() => {
const query: Record<string, unknown> = {}
Object.entries(formFilters).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value) && value.length === 0) return
query[key] = value
})
return query
})
const showExportDialog = () => {
exportDialogVisible.value = true
}
onMounted(() => {
getTableData()
})