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

@@ -0,0 +1,28 @@
import { BaseService } from '../BaseService'
import type {
CancelExportTaskApiResponse,
CreateExportTaskApiResponse,
CreateExportTaskRequest,
ExportTaskDetail,
ExportTaskDetailApiResponse,
ExportTaskListApiResponse,
ExportTaskQueryParams
} from '@/types/api'
export class ExportTaskService extends BaseService {
static getExportTasks(params?: ExportTaskQueryParams): Promise<ExportTaskListApiResponse> {
return this.get<ExportTaskListApiResponse>('/api/admin/export-tasks', params)
}
static createExportTask(data: CreateExportTaskRequest): Promise<CreateExportTaskApiResponse> {
return this.post<CreateExportTaskApiResponse>('/api/admin/export-tasks', data)
}
static getExportTaskDetail(id: number): Promise<ExportTaskDetailApiResponse> {
return this.getOne<ExportTaskDetail>(`/api/admin/export-tasks/${id}`)
}
static cancelExportTask(id: number): Promise<CancelExportTaskApiResponse> {
return this.post<CancelExportTaskApiResponse>(`/api/admin/export-tasks/${id}/cancel`, {})
}
}

View File

@@ -31,6 +31,7 @@ export { PollingConfigService } from './pollingConfig'
export { ManualTriggerService } from './manualTrigger'
export { PollingMonitorService } from './pollingMonitor'
export { SuperAdminService } from './superAdmin'
export { ExportTaskService } from './exportTask'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'

View File

@@ -0,0 +1,144 @@
<template>
<ElDialog v-model="visible" :title="title" width="520px" destroy-on-close>
<ElAlert type="info" :closable="false" show-icon class="export-rule-alert">
<template #title>{{ description }}</template>
</ElAlert>
<ElForm label-width="100px" class="export-task-form">
<ElFormItem label="导出场景">
<ElTag>{{ sceneName }}</ElTag>
</ElFormItem>
<ElFormItem label="导出格式">
<ElRadioGroup v-model="form.format">
<ElRadio label="xlsx">XLSX</ElRadio>
<ElRadio label="csv">CSV</ElRadio>
</ElRadioGroup>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="visible = false">取消</ElButton>
<ElButton
v-if="!confirmPermission || hasAuth(confirmPermission)"
type="primary"
:loading="submitting"
@click="submit"
>
创建导出任务
</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api'
const props = withDefaults(
defineProps<{
modelValue: boolean
scene: ExportTaskScene
query?: Record<string, unknown>
defaultFormat?: ExportTaskFormat
title?: string
description?: string
confirmPermission?: string
}>(),
{
query: () => ({}),
defaultFormat: 'xlsx',
title: '创建导出任务',
description: '导出规则基于列表中的检索条件全量导出,不仅导出当前分页数据。'
}
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success', value: CreateExportTaskResponse): void
}>()
const { hasAuth } = useAuth()
const submitting = ref(false)
const form = reactive({
format: props.defaultFormat
})
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const sceneName = computed(() => {
const sceneMap: Record<ExportTaskScene, string> = {
device: '设备管理',
iot_card: 'IoT卡管理'
}
return sceneMap[props.scene]
})
watch(
() => props.modelValue,
(opened) => {
if (opened) {
form.format = props.defaultFormat
}
}
)
const getNormalizedQuery = () => {
const query: Record<string, unknown> = {}
Object.entries(props.query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value) && value.length === 0) return
query[key] = value
})
return query
}
const submit = async () => {
if (props.confirmPermission && !hasAuth(props.confirmPermission)) {
ElMessage.warning('您没有创建导出任务的权限')
return
}
submitting.value = true
try {
const res = await ExportTaskService.createExportTask({
scene: props.scene,
format: form.format,
query: getNormalizedQuery()
})
if (res.code === 0) {
ElMessage.success(res.data?.message || '导出任务已创建')
emit('success', res.data)
visible.value = false
} else {
ElMessage.error(res.msg || '创建导出任务失败')
}
} catch (error) {
console.error('创建导出任务失败:', error)
ElMessage.error('创建导出任务失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.export-rule-alert {
margin-bottom: 18px;
}
.export-task-form {
padding-top: 4px;
}
</style>

View File

@@ -445,6 +445,9 @@
"enterpriseDevices": "Enterprise Devices",
"recordsManagement": "Records Management",
"taskManagement": "Task Management",
"exportTaskManagement": "Export Management",
"exportTaskList": "Export List",
"exportTaskDetail": "Export Task Detail",
"exchangeManagement": "Exchange Management",
"exchangeDetail": "Exchange Order Detail"
},

View File

@@ -379,6 +379,9 @@
"enterpriseDevices": "企业设备列表",
"recordsManagement": "记录管理",
"taskManagement": "任务管理",
"exportTaskManagement": "导出管理",
"exportTaskList": "导出列表",
"exportTaskDetail": "导出任务详情",
"exchangeManagement": "换货管理",
"exchangeDetail": "换货单详情"
},

View File

@@ -440,6 +440,39 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true,
keepAlive: false
}
},
// 导出管理
{
path: 'export-task-management',
name: 'ExportTaskManagement',
component: '',
meta: {
title: 'menus.assetManagement.exportTaskManagement',
keepAlive: true
},
children: [
{
path: 'export-task-list',
name: 'ExportTaskList',
component: RoutesAlias.ExportTaskList,
meta: {
title: 'menus.assetManagement.exportTaskList',
permissions: ['export_task:list'],
keepAlive: true
}
},
{
path: 'export-task-detail',
name: 'ExportTaskDetail',
component: RoutesAlias.ExportTaskDetail,
meta: {
title: 'menus.assetManagement.exportTaskDetail',
permissions: ['export_task:detail'],
isHide: true,
keepAlive: false
}
}
]
}
]
},

View File

@@ -60,6 +60,8 @@ export enum RoutesAlias {
IotCardTask = '/asset-management/task-management/iot-card-task', // IoT卡任务
DeviceTask = '/asset-management/task-management/device-task', // 设备任务
TaskDetail = '/asset-management/task-management/task-detail', // 任务详情IoT卡/设备任务详情)
ExportTaskList = '/asset-management/export-task-management/export-task-list', // 导出列表
ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情
// 订单管理
OrderList = '/order-management/order-list', // 订单列表

View File

@@ -0,0 +1,80 @@
import type { BaseResponse, PaginationParams } from './common'
export type ExportTaskScene = 'device' | 'iot_card'
export type ExportTaskFormat = 'xlsx' | 'csv'
export enum ExportTaskStatus {
PENDING = 1,
PROCESSING = 2,
COMPLETED = 3,
FAILED = 4,
CANCELED = 5
}
export interface ExportTaskQueryParams extends PaginationParams {
scene?: ExportTaskScene
status?: ExportTaskStatus
start_time?: string
end_time?: string
}
export interface ExportTaskItem {
id: number
task_no: string
scene: ExportTaskScene
status: ExportTaskStatus
status_name: string
progress: number
format: ExportTaskFormat
total_rows: number
processed_rows: number
total_shards: number
success_shards: number
failed_shards: number
file_key: string
error_message: string
cancel_requested: boolean
created_at: string
started_at: string
completed_at: string
}
export interface ExportTaskListResponse {
page: number
size: number
total: number
items: ExportTaskItem[]
}
export interface CreateExportTaskRequest {
scene: ExportTaskScene
format: ExportTaskFormat
query?: Record<string, unknown>
}
export interface CreateExportTaskResponse {
task_id: number
task_no: string
status: ExportTaskStatus
status_name: string
message: string
}
export interface ExportTaskDetail extends ExportTaskItem {
download_url?: string
download_expires_at?: string
}
export interface CancelExportTaskResponse {
task_id: number
status: ExportTaskStatus
status_name: string
cancel_requested: boolean
message: string
}
export type ExportTaskListApiResponse = BaseResponse<ExportTaskListResponse>
export type CreateExportTaskApiResponse = BaseResponse<CreateExportTaskResponse>
export type ExportTaskDetailApiResponse = BaseResponse<ExportTaskDetail>
export type CancelExportTaskApiResponse = BaseResponse<CancelExportTaskResponse>

View File

@@ -97,3 +97,6 @@ export * from './manualTrigger'
// 轮询监控相关
export * from './pollingMonitor'
// 导出任务相关
export * from './exportTask'

View File

@@ -107,6 +107,7 @@ declare module 'vue' {
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
@@ -132,6 +133,7 @@ declare module 'vue' {
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
ElWatermark: typeof import('element-plus/es')['ElWatermark']
ExportTaskCreateDialog: typeof import('./../components/business/ExportTaskCreateDialog.vue')['default']
HorizontalSubmenu: typeof import('./../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default']
ImportDialog: typeof import('./../components/business/ImportDialog.vue')['default']
LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default']

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()
})