From 4e96554554b512e9e37a1a2df7a5e62e0802f79d Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 17 Aug 2026 14:31:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E4=BA=BA=E5=91=98?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E8=BF=9B=E5=BA=A6=E6=9D=A1=20feat:=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E4=BA=BA=E5=91=98=E5=AF=BC=E5=87=BA=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E5=80=99=E5=AF=BC=E5=87=BA=E4=BA=BA=E5=91=98=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=20feat:=E5=A2=9E=E5=8A=A0=E5=90=8E=E5=8F=B0api=E5=92=8C?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=E8=84=B8=E5=90=8C=E6=AD=A5=E9=80=9A?= =?UTF-8?q?=E8=BF=87mqtt=E5=AE=9E=E7=8E=B0=E5=A2=9E=E9=87=8F=E5=8F=98?= =?UTF-8?q?=E5=8C=96=20feat:=E5=AF=BC=E5=85=A5=E9=A5=AD=E5=8D=A1=E5=8D=A1?= =?UTF-8?q?=E5=8F=B7=E6=B2=A1=E6=9C=89=E7=BB=91=E5=AE=9A=E5=8D=A1=E5=8F=B7?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=BB=91=E5=AE=9A=E5=8D=A1=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/user.ts | 23 ++ src/api/asyncTask.ts | 10 + src/components/AsyncTaskProgress.vue | 28 ++- src/views/admin/system/user/index.vue | 313 ++++++++++++-------------- 4 files changed, 200 insertions(+), 174 deletions(-) diff --git a/src/api/admin/user.ts b/src/api/admin/user.ts index 49a10c36..3a2ad92a 100644 --- a/src/api/admin/user.ts +++ b/src/api/admin/user.ts @@ -1,4 +1,13 @@ import request, { CommonHeaderEnum } from "/@/utils/request" +import type { AsyncTaskProgress } from "/@/api/asyncTask" + +/** 用户异步导出提交接口的统一响应。 */ +export interface UserExportTaskResponse { + /** 后端创建的初始异步任务进度。 */ + data: AsyncTaskProgress + /** 后端返回的任务提交提示;为空时页面使用默认提示。 */ + msg?: string +} /** * 同步用户食堂信息 @@ -41,6 +50,20 @@ export const pageList = (query: any) => { }) } +/** + * 按当前筛选条件提交用户信息异步导出任务。 + * + * @param query 用户列表筛选条件和可选用户 ID 集合 + * @returns 包含任务初始进度的统一响应 + */ +export const exportUsers = (query: Record) => { + return request({ + url: "/admin/user/export", + method: "post", + data: query, + }) +} + export const addObj = (obj: Object) => { return request({ url: "/admin/user", diff --git a/src/api/asyncTask.ts b/src/api/asyncTask.ts index 1f6c5267..7c5f97ed 100644 --- a/src/api/asyncTask.ts +++ b/src/api/asyncTask.ts @@ -22,6 +22,10 @@ export interface AsyncTaskProgress { finished: boolean /** 终态是否成功;任务未结束时为 false。 */ success: boolean + /** 导出等任务成功后生成的文件下载地址;导入任务为空。 */ + resultFileUrl?: string + /** 导出等任务成功后建议使用的下载文件名称;导入任务为空。 */ + resultFileName?: string } /** 通用异步任务轮询配置,导入、导出等业务只需提供自身命名空间和必要覆盖项。 */ @@ -38,6 +42,12 @@ export interface AsyncTaskPollingConfig { pollTimeoutMs?: number /** 连续查询失败多少次后暂停轮询并允许用户重试,默认 5 次。 */ maxConsecutiveErrors?: number + /** 是否等任务进入终态或轮询中断后再显示弹窗,默认 false。 */ + showDialogOnFinished?: boolean + /** 任务执行期间展示的等待提示;为空时不额外展示。 */ + waitingMessage?: string + /** 成功终态存在结果文件时的下载按钮文案,默认“下载结果”。 */ + downloadButtonText?: string /** 任务成功后由业务组件展示的覆盖文案。 */ successMessage?: string /** 任务失败后由业务组件展示的覆盖文案。 */ diff --git a/src/components/AsyncTaskProgress.vue b/src/components/AsyncTaskProgress.vue index 772dcd0d..92647b37 100644 --- a/src/components/AsyncTaskProgress.vue +++ b/src/components/AsyncTaskProgress.vue @@ -8,10 +8,15 @@ {{ state.progress?.message || "等待执行" }} {{ state.progress?.completed || 0 }} / {{ state.progress?.total || 0 }} + @@ -24,6 +29,7 @@ import { type AsyncTaskPollingConfig, type AsyncTaskProgress, } from "/@/api/asyncTask" +import { downBlobFile } from "/@/utils/other" /** 默认轮询间隔,兼顾进度及时性与接口压力。 */ const DEFAULT_POLL_INTERVAL_MS = 1000 @@ -59,10 +65,11 @@ const prop = defineProps({ /** 任务成功或后端终态失败事件,由具体业务决定刷新、下载或提示行为。 */ const emit = defineEmits(["success", "failure"]) -/** 进度弹窗的可见状态、服务端进度和本地查询错误。 */ +/** 进度弹窗的可见、轮询、下载状态,以及服务端进度和本地查询错误。 */ const state = reactive({ visible: false, polling: false, + downloading: false, progress: null as AsyncTaskProgress | null, queryError: "", }) @@ -81,6 +88,9 @@ let unmounted = false /** 任务仍在执行时禁止关闭弹窗,避免用户误以为任务已经停止。 */ const canClose = computed(() => !state.polling) +/** 仅在任务成功且后端已经返回结果文件地址后开放下载。 */ +const canDownload = computed(() => Boolean(state.progress?.finished && state.progress.success && state.progress.resultFileUrl)) + /** 根据任务终态映射 Element Plus 进度条状态。 */ const progressStatus = computed(() => { if (!state.progress?.finished) return undefined @@ -106,6 +116,7 @@ const scheduleNextPoll = () => { const pauseForQueryError = (message: string) => { stopPolling() state.queryError = message + state.visible = true } /** 查询一次任务进度,并根据终态、超时或网络状态决定是否继续轮询。 */ @@ -130,6 +141,7 @@ const pollProgress = async () => { consecutiveErrors = 0 if (state.progress?.finished) { stopPolling() + state.visible = true emit(state.progress.success ? "success" : "failure", state.progress) return } @@ -166,7 +178,8 @@ const start = (initialProgress: AsyncTaskProgress) => { } state.progress = { ...initialProgress, redisKey } state.queryError = "" - state.visible = true + state.downloading = false + state.visible = !prop.config.showDialogOnFinished state.polling = true pollingStartedAt = Date.now() consecutiveErrors = 0 @@ -183,6 +196,17 @@ const retry = () => { void pollProgress() } +/** 从成功终态保存的文件地址重新下载任务结果,并阻止连续点击发起重复请求。 */ +const downloadResult = async () => { + if (!canDownload.value || state.downloading || !state.progress?.resultFileUrl) return + state.downloading = true + try { + await downBlobFile(state.progress.resultFileUrl, {}, state.progress.resultFileName || "任务结果.xlsx") + } finally { + state.downloading = false + } +} + /** 关闭已经结束或暂停查询的任务进度弹窗。 */ const close = () => { if (state.polling) return diff --git a/src/views/admin/system/user/index.vue b/src/views/admin/system/user/index.vue index 01b85846..ae3de965 100644 --- a/src/views/admin/system/user/index.vue +++ b/src/views/admin/system/user/index.vue @@ -1,27 +1,13 @@ - + - + - + @@ -314,6 +227,8 @@ + @@ -323,7 +238,8 @@