feat: 七月迭代公共状态与异步任务交互
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m59s

This commit is contained in:
luo
2026-07-24 11:48:02 +08:00
parent 15122e225a
commit a0c44ff538
14 changed files with 677 additions and 63 deletions

View File

@@ -113,6 +113,9 @@
if (res.code === 0) {
ElMessage.success(res.data?.message || '导出任务已创建')
if (res.data?.task_id) {
localStorage.setItem(`export-task-active:${props.scene}`, String(res.data.task_id))
}
emit('success', res.data)
visible.value = false
} else {

View File

@@ -0,0 +1,178 @@
import {
computed,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
type Ref,
type ShallowRef
} from 'vue'
import { isAsyncTaskActive, isAsyncTaskTerminal, type AsyncTaskProgress } from '@/types/api'
const POLL_DELAYS = [2000, 3000, 5000]
const MAX_POLL_DELAY = 10000
export interface AsyncTaskPollingOptions<T extends AsyncTaskProgress> {
storageKey: string
fetchTask: (taskId: number) => Promise<T>
isForbidden?: (error: unknown) => boolean
autoRestore?: boolean
}
export interface AsyncTaskPollingState<T extends AsyncTaskProgress> {
taskId: Ref<number | null>
task: ShallowRef<T | null>
loading: Ref<boolean>
forbidden: Ref<boolean>
error: Ref<string | null>
isActive: Readonly<Ref<boolean>>
start: (taskId: number) => Promise<void>
retry: () => Promise<void>
stop: () => void
clear: () => void
}
const readStoredTaskId = (storageKey: string): number | null => {
try {
const value = Number(localStorage.getItem(storageKey))
return Number.isInteger(value) && value > 0 ? value : null
} catch {
return null
}
}
const writeStoredTaskId = (storageKey: string, taskId: number | null) => {
try {
if (taskId) localStorage.setItem(storageKey, String(taskId))
else localStorage.removeItem(storageKey)
} catch {
// Storage may be unavailable in private browsing or restricted webviews.
}
}
const getErrorMessage = (error: any) =>
error?.response?.data?.msg || error?.message || '获取导出任务详情失败'
export function useAsyncTaskPolling<T extends AsyncTaskProgress>(
options: AsyncTaskPollingOptions<T>
): AsyncTaskPollingState<T> {
const taskId = ref<number | null>(readStoredTaskId(options.storageKey))
const task = shallowRef<T | null>(null)
const loading = ref(false)
const forbidden = ref(false)
const error = ref<string | null>(null)
const isActive = computed(() => isAsyncTaskActive(task.value?.status))
let timer: number | undefined
let pollIndex = 0
let requestInFlight = false
const clearTimer = () => {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
}
const stop = () => {
clearTimer()
}
const clear = () => {
stop()
taskId.value = null
task.value = null
error.value = null
forbidden.value = false
writeStoredTaskId(options.storageKey, null)
}
const schedule = () => {
clearTimer()
if (document.hidden || !isActive.value || !taskId.value) return
const delay = POLL_DELAYS[pollIndex] ?? MAX_POLL_DELAY
pollIndex += 1
timer = window.setTimeout(() => {
timer = undefined
void refresh()
}, delay)
}
const refresh = async () => {
if (!taskId.value || requestInFlight || document.hidden) return
requestInFlight = true
loading.value = true
forbidden.value = false
error.value = null
try {
const nextTask = await options.fetchTask(taskId.value)
task.value = nextTask
if (isAsyncTaskTerminal(nextTask.status)) {
stop()
writeStoredTaskId(options.storageKey, null)
} else {
schedule()
}
} catch (requestError) {
if (options.isForbidden?.(requestError) ?? false) {
forbidden.value = true
stop()
} else {
error.value = getErrorMessage(requestError)
stop()
}
} finally {
loading.value = false
requestInFlight = false
}
}
const start = async (nextTaskId: number) => {
if (!Number.isInteger(nextTaskId) || nextTaskId <= 0) return
taskId.value = nextTaskId
task.value = null
pollIndex = 0
writeStoredTaskId(options.storageKey, nextTaskId)
await refresh()
}
const retry = async () => {
if (!taskId.value) return
await refresh()
}
const handleVisibilityChange = () => {
if (document.hidden) {
stop()
return
}
if (taskId.value && isActive.value) {
pollIndex = 0
void refresh()
}
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
if (options.autoRestore !== false && taskId.value) void refresh()
})
onBeforeUnmount(() => {
stop()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
return {
taskId,
task: task as ShallowRef<T | null>,
loading,
forbidden,
error,
isActive,
start,
retry,
stop,
clear
}
}

View File

@@ -0,0 +1,28 @@
export enum AsyncTaskStatus {
PENDING = 1,
PROCESSING = 2,
COMPLETED = 3,
FAILED = 4,
CANCELED = 5
}
export interface AsyncTaskProgress {
task_id?: number
status: number
status_name?: string
total_count?: number
success_count?: number
failed_count?: number
failure_details?: unknown[] | null
error_code?: string
error_summary?: string
updated_at?: string
}
export const isAsyncTaskActive = (status?: AsyncTaskStatus) =>
status === AsyncTaskStatus.PENDING || status === AsyncTaskStatus.PROCESSING
export const isAsyncTaskTerminal = (status?: AsyncTaskStatus) =>
status === AsyncTaskStatus.COMPLETED ||
status === AsyncTaskStatus.FAILED ||
status === AsyncTaskStatus.CANCELED

View File

@@ -21,23 +21,30 @@ export interface ExportTaskQueryParams extends PaginationParams {
export interface ExportTaskItem {
id: number
task_id?: number
task_no: string
scene: ExportTaskScene
status: ExportTaskStatus
status_name: string
progress: number
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
total_rows?: number
processed_rows?: number
total_shards?: number
success_shards?: number
failed_shards?: number
file_key?: string
error_message?: string
cancel_requested?: boolean
total_count?: number
success_count?: number
failed_count?: number
error_code?: string
error_summary?: string
updated_at?: string
created_at: string
started_at: string
completed_at: string
started_at?: string
completed_at?: string
}
export interface ExportTaskListResponse {

View File

@@ -111,5 +111,8 @@ export * from './pollingMonitor'
// 导出任务相关
export * from './exportTask'
// 通用异步任务相关
export * from './asyncTask'
// 订单套餐批量作废任务相关
export * from './orderPackageInvalidateTask'

View File

@@ -87,8 +87,10 @@ declare module 'vue' {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCalendar: typeof import('element-plus/es')['ElCalendar']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
@@ -111,7 +113,9 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
@@ -123,6 +127,8 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']

View File

@@ -992,7 +992,6 @@
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
page: 1,
page_size: 10
})
@@ -1015,7 +1014,6 @@
oldDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
page: 1,
page_size: 10
})
@@ -1045,7 +1043,6 @@
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
keyword: query,
page: 1,
page_size: 20
@@ -1075,7 +1072,6 @@
oldDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
keyword: query,
page: 1,
page_size: 20
@@ -1113,8 +1109,6 @@
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
shop_id: shopId,
keyword: query,
page: 1,
page_size: 20
@@ -1174,13 +1168,14 @@
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
shop_id: shopId,
page: 1,
page_size: 10
})
if (res.code === 0 && res.data) {
newIotCardOptions.value = res.data.items || []
if (newIotCardOptions.value.length === 0) {
ElMessage.info('暂无同店铺可用的新资产')
}
}
} catch (error) {
console.error('加载IoT卡列表失败:', error)
@@ -1228,8 +1223,6 @@
newDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
shop_id: shopId,
keyword: query,
page: 1,
page_size: 20
@@ -1287,13 +1280,14 @@
newDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
shop_id: shopId,
page: 1,
page_size: 10
})
if (res.code === 0 && res.data) {
newDeviceOptions.value = res.data.items || []
if (newDeviceOptions.value.length === 0) {
ElMessage.info('暂无同店铺可用的新资产')
}
}
} catch (error) {
console.error('加载设备列表失败:', error)

View File

@@ -18,25 +18,28 @@
</div>
</div>
<div v-if="loading" class="detail-loading">
<div v-if="polling.loading && !taskDetail" class="detail-loading">
<ElIcon class="is-loading" :size="36">
<Loading />
</ElIcon>
<div>加载中...</div>
</div>
<DetailPage
v-else-if="taskDetail && canViewDetail"
v-if="taskDetail && canViewDetail"
:sections="detailSections"
:data="taskDetail"
/>
<ElEmpty v-else :description="forbidden ? '暂无权限查看该导出任务详情' : '暂无详情'" />
<ElEmpty
v-else
:description="polling.forbidden ? '暂无权限查看该导出任务详情' : '暂无详情'"
/>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { computed, h, onMounted } 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'
@@ -48,15 +51,29 @@
import { formatDateTime } from '@/utils/business/format'
import type { ExportTaskDetail } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
defineOptions({ name: 'ExportTaskDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const forbidden = ref(false)
const taskDetail = ref<ExportTaskDetail | null>(null)
const polling = useAsyncTaskPolling<ExportTaskDetail>({
storageKey: `export-task-detail:${String(route.query.id || '')}`,
autoRestore: false,
fetchTask: async (taskId) => {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 403) {
const error = new Error('您没有查看该导出任务详情的权限') as Error & { status?: number }
error.status = 403
throw error
}
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取导出任务详情失败')
return res.data
},
isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403
})
const taskDetail = polling.task
const taskPermissions = computed(
() => getExportTaskSceneConfig(taskDetail.value?.scene)?.permissions
@@ -102,6 +119,7 @@
title: '任务基本信息',
fields: [
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{ label: '任务ID', prop: 'task_id', formatter: (value: number) => String(value ?? '-') },
{
label: '导出场景',
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getExportTaskSceneName(data.scene))
@@ -132,11 +150,17 @@
prop: 'completed_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '更新时间',
prop: 'updated_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '错误信息',
prop: 'error_message',
prop: 'error_summary',
fullWidth: true,
formatter: (value: string) => value || '-'
formatter: (value: string, data: ExportTaskDetail) =>
data.error_summary || data.error_message || value || '-'
}
]
},
@@ -144,6 +168,22 @@
title: '处理进度',
fields: [
{ label: '进度', prop: 'progress', formatter: (value: number) => `${value ?? 0}%` },
{
label: '总数',
prop: 'total_count',
formatter: (value: number, data: ExportTaskDetail) =>
String(value ?? data.total_rows ?? 0)
},
{
label: '成功数',
prop: 'success_count',
formatter: (value: number) => String(value ?? 0)
},
{
label: '失败数',
prop: 'failed_count',
formatter: (value: number) => String(value ?? 0)
},
{ label: '总行数', prop: 'total_rows', formatter: (value: number) => String(value ?? 0) },
{
label: '已处理行数',
@@ -203,33 +243,16 @@
const getTaskId = () => Number(route.query.id)
const getTaskDetail = async () => {
const loadTaskDetail = async () => {
const taskId = getTaskId()
if (!taskId) {
ElMessage.error('缺少任务ID参数')
goBack()
return
}
loading.value = true
forbidden.value = false
try {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 0) {
taskDetail.value = res.data
if (!canViewDetail.value) {
forbidden.value = true
ElMessage.warning('您没有查看该导出任务详情的权限')
}
} else {
ElMessage.error(res.msg || '获取导出任务详情失败')
}
} catch (error) {
console.error('获取导出任务详情失败:', error)
ElMessage.error('获取导出任务详情失败')
} finally {
loading.value = false
}
await polling.start(taskId)
if (polling.error.value) ElMessage.error(polling.error.value)
if (polling.forbidden.value) ElMessage.warning('您没有查看该导出任务详情的权限')
}
const downloadTask = () => {
@@ -259,10 +282,12 @@
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(taskDetail.value!.id)
const res = await ExportTaskService.cancelExportTask(
taskDetail.value!.task_id ?? taskDetail.value!.id
)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTaskDetail()
polling.retry()
} else {
ElMessage.error(res.msg || '取消导出任务失败')
}
@@ -274,7 +299,7 @@
}
onMounted(() => {
getTaskDetail()
loadTaskDetail()
})
</script>

View File

@@ -49,8 +49,9 @@
import { RoutesAlias } from '@/router/routesAlias'
import { getExportTaskSceneConfig } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type { ExportTaskItem, ExportTaskScene } from '@/types/api'
import type { ExportTaskDetail, ExportTaskItem, ExportTaskScene } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
defineOptions({ name: 'ExportTaskList' })
@@ -116,11 +117,15 @@
{ label: '任务编号', prop: 'task_no' },
{ label: '状态', prop: 'status_name' },
{ label: '进度', prop: 'progress' },
{ label: '总数', prop: 'total_count' },
{ label: '成功数', prop: 'success_count' },
{ label: '失败数', prop: 'failed_count' },
{ label: '格式', prop: 'format' },
{ label: '总行数', prop: 'total_rows' },
{ label: '已处理行数', prop: 'processed_rows' },
{ label: '分片', prop: 'total_shards' },
{ label: '错误信息', prop: 'error_message' },
{ label: '错误信息', prop: 'error_summary' },
{ label: '更新时间', prop: 'updated_at' },
{ label: '创建时间', prop: 'created_at' }
]
@@ -153,6 +158,24 @@
width: 150,
formatter: (row: ExportTaskItem) => h(ElProgress, { percentage: row.progress || 0 })
},
{
prop: 'total_count',
label: '总数',
width: 100,
formatter: (row: ExportTaskItem) => row.total_count ?? row.total_rows ?? 0
},
{
prop: 'success_count',
label: '成功数',
width: 100,
formatter: (row: ExportTaskItem) => row.success_count ?? 0
},
{
prop: 'failed_count',
label: '失败数',
width: 100,
formatter: (row: ExportTaskItem) => row.failed_count ?? 0
},
{ prop: 'format', label: '格式', width: 90 },
{ prop: 'total_rows', label: '总行数', width: 110 },
{ prop: 'processed_rows', label: '已处理行数', width: 120 },
@@ -162,12 +185,24 @@
width: 130,
formatter: (row: ExportTaskItem) => `${row.success_shards || 0}/${row.total_shards || 0}`
},
{ prop: 'error_message', label: '错误信息', minWidth: 180, showOverflowTooltip: true },
{
prop: 'error_summary',
label: '错误信息',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ExportTaskItem) => row.error_summary || row.error_message || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: ExportTaskItem) => formatDateTime(row.created_at) || '-'
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: ExportTaskItem) => formatDateTime(row.updated_at) || '-'
}
])
@@ -257,6 +292,25 @@
return res.data
}
const activeTaskPolling = useAsyncTaskPolling<ExportTaskDetail>({
storageKey: computed(() => `export-task-active:${currentScene.value}`).value,
fetchTask: async (taskId) => {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 403) {
const error = new Error('暂无权限查看导出任务') as Error & { status?: number }
error.status = 403
throw error
}
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取导出任务详情失败')
return res.data
},
isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403
})
watch(activeTaskPolling.task, () => {
if (activeTaskPolling.task.value) getTableData()
})
const getRowPermissions = (row: ExportTaskItem) => {
return getExportTaskSceneConfig(row.scene)?.permissions || currentPermissions.value
}
@@ -269,7 +323,7 @@
router.push({
path: RoutesAlias.ExportTaskDetail,
query: { id: row.id }
query: { id: row.task_id ?? row.id }
})
}
@@ -280,7 +334,7 @@
}
try {
const detail = await getTaskDetail(row.id)
const detail = await getTaskDetail(row.task_id ?? row.id)
if (!detail.download_url) {
ElMessage.warning('当前任务暂无可用下载地址')
return
@@ -305,7 +359,7 @@
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(row.id)
const res = await ExportTaskService.cancelExportTask(row.task_id ?? row.id)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTableData()