This commit is contained in:
28
src/api/modules/exportTask.ts
Normal file
28
src/api/modules/exportTask.ts
Normal 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`, {})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
144
src/components/business/ExportTaskCreateDialog.vue
Normal file
144
src/components/business/ExportTaskCreateDialog.vue
Normal 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>
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -379,6 +379,9 @@
|
||||
"enterpriseDevices": "企业设备列表",
|
||||
"recordsManagement": "记录管理",
|
||||
"taskManagement": "任务管理",
|
||||
"exportTaskManagement": "导出管理",
|
||||
"exportTaskList": "导出列表",
|
||||
"exportTaskDetail": "导出任务详情",
|
||||
"exchangeManagement": "换货管理",
|
||||
"exchangeDetail": "换货单详情"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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', // 订单列表
|
||||
|
||||
80
src/types/api/exportTask.ts
Normal file
80
src/types/api/exportTask.ts
Normal 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>
|
||||
@@ -97,3 +97,6 @@ export * from './manualTrigger'
|
||||
|
||||
// 轮询监控相关
|
||||
export * from './pollingMonitor'
|
||||
|
||||
// 导出任务相关
|
||||
export * from './exportTask'
|
||||
|
||||
2
src/types/components.d.ts
vendored
2
src/types/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user