feat:会员管理优化增加导出

This commit is contained in:
dzc committed 2026-10-30 20:03:11 +08:00
1 parent 42d7049318
commit 57a35f2f9d
5 files changed
+1057 -24

No files matched your search

+35
View File
@@ -9,6 +9,9 @@ export interface UserVO {
loginIp: string
mark: string
mobile: string
wechatNum?: string | null
wechatQrCode?: string | null
addressAreaNames?: string[]
name: string | undefined
nickname: string | undefined
registerIp: string
@@ -27,6 +30,38 @@ export const getUserPage = async (params) => {
return await request.get({ url: `/member/user/page`, params })
}
export interface UserExportProgress {
taskId: string
status: 'WAITING' | 'RUNNING' | 'SUCCESS' | 'FAILURE'
total: number
processed: number
progress: number
message: string
fileName: string
existingTask: boolean
retentionHours: number
}
// 使用与列表相同的查询参数序列化方式,时间范围、标签数组等保持一致。
export const exportUsers = (params: Record<string, unknown>) => {
return request.post<UserExportProgress>({ url: '/member/user/export-async', params })
}
export const getUserExportProgress = (taskId: string) => {
return request.get<UserExportProgress>({
url: '/member/user/export-progress',
params: { taskId }
})
}
export const downloadUserExport = (taskId: string) => {
return request.download<Blob>({
url: '/member/user/export-download',
params: { taskId },
timeout: 300000
})
}
// 查询会员用户详情
export const getUser = async (id: number) => {
return await request.get({ url: `/member/user/get?id=` + id })
+9 -1
View File
@@ -3,6 +3,8 @@
type Props = {
src?: string
alt?: string
fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
width?: string | number
height?: string | number
download?: boolean | string
@@ -10,6 +12,7 @@ type Props = {
}
const props = withDefaults(defineProps<Props>(), {
fit: 'cover',
width: 50,
height: 50,
download: false,
@@ -61,7 +64,8 @@ function dispatchUrl(url?: string) {
<template>
<div v-if="props.src" class="flex flex-col items-center">
<el-image
hide-on-click-modal preview-teleported :src="`${realSrc}`" fit="cover"
hide-on-click-modal preview-teleported :src="`${realSrc}`" :fit="props.fit" :alt="props.alt"
:class="{ 'preview-image-contain': props.fit === 'contain' }"
:style="`width:${realWidth};height:${realHeight};`" :preview-src-list="realSrcList" loading="lazy">
<template #error>
<div class="image-slot">
@@ -79,6 +83,10 @@ hide-on-click-modal preview-teleported :src="`${realSrc}`" fit="cover"
background-color: #ebeef5;
box-shadow: 0 0 5px 1px #ccc;
&.preview-image-contain :deep(.el-image__inner:hover) {
transform: none;
}
:deep(.el-image__inner) {
transition: all 0.3s;
cursor: pointer;
@@ -0,0 +1,501 @@
<template>
<el-dialog
v-model="visible"
title="会员用户导出"
width="560px"
:close-on-click-modal="false"
append-to-body
class="member-user-export-dialog"
>
<template #header>
<div class="export-dialog-heading">
<span class="export-dialog-heading__icon" aria-hidden="true">
<Icon icon="ep:document" :size="22" />
</span>
<div>
<h2>会员用户导出</h2>
<p>按点击导出时的搜索条件导出全部匹配用户</p>
</div>
</div>
</template>
<div class="export-dialog-content">
<el-alert
v-if="existingTask"
title="已有导出任务正在处理,本次筛选条件未重新提交。请完成当前导出后再按新条件导出。"
type="info"
:closable="false"
show-icon
/>
<section
v-if="submitting || task"
class="export-progress-panel"
:class="{
'is-success': task?.status === 'SUCCESS',
'is-failure': task?.status === 'FAILURE'
}"
>
<div class="export-progress-heading">
<span class="export-status-icon" :class="{ 'is-loading': submitting }" aria-hidden="true">
<Icon :icon="statusIcon" :size="22" />
</span>
<div class="export-progress-copy" role="status" aria-live="polite">
<p class="export-status-title">{{ statusTitle }}</p>
<p class="export-status-message">{{
submitting ? '正在创建导出任务,请稍候' : task?.message
}}</p>
</div>
<span v-if="task && !submitting" class="export-percentage"
>{{ task.progress }}<small>%</small></span
>
</div>
<template v-if="task && !submitting">
<el-progress
:percentage="task.progress"
:status="progressStatus"
:stroke-width="8"
:show-text="false"
:aria-label="`会员用户导出进度 ${task.progress}%`"
/>
<div class="export-count">
<span
>已导出 <strong>{{ task.processed.toLocaleString() }}</strong> 条</span
>
<span>共 {{ task.total.toLocaleString() }} 条</span>
</div>
</template>
</section>
<div v-if="isActive || task?.status === 'SUCCESS'" class="export-hint">
<Icon icon="ep:info-filled" :size="15" aria-hidden="true" />
<p v-if="isActive"> 关闭弹窗后任务继续,可点击“查看导出进度”重新打开。 </p>
<p v-else-if="task?.status === 'SUCCESS'">
临时导出文件,请及时下载;失效后可重新导出。
</p>
</div>
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
:closable="false"
show-icon
/>
</div>
<template #footer>
<div class="export-dialog-actions">
<el-button @click="visible = false">{{ isActive ? '后台导出' : '关闭' }}</el-button>
<el-button v-if="pollFailed" type="primary" @click="startPolling">重试查询进度</el-button>
<el-button
v-if="task?.status === 'SUCCESS'"
type="primary"
:loading="downloading"
@click="downloadFile"
>
<Icon icon="ep:download" class="mr-6px" />
下载 Excel
</el-button>
<el-button
v-if="
!submitting && (pollFailed || task?.status === 'FAILURE' || (!task && errorMessage))
"
type="primary"
@click="submitExport"
>
重新导出
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import * as UserApi from '@/api/member/user'
import download from '@/utils/download'
import { getTenantId } from '@/utils/auth'
import { useUserStore } from '@/store/modules/user'
const visible = ref(false)
const submitting = ref(false)
const downloading = ref(false)
const task = ref<UserApi.UserExportProgress>()
const errorMessage = ref('')
const pollFailed = ref(false)
const existingTask = ref(false)
const userStore = useUserStore()
const storageKey = `member-user-export:${getTenantId() ?? ''}:${userStore.getUser.id}`
const isActive = computed(
() => task.value?.status === 'WAITING' || task.value?.status === 'RUNNING'
)
const progressStatus = computed(() => {
if (task.value?.status === 'SUCCESS') return 'success'
if (task.value?.status === 'FAILURE') return 'exception'
return undefined
})
const statusTitle = computed(() => {
if (submitting.value) return '正在提交导出任务'
if (pollFailed.value) return '进度查询暂时中断'
if (task.value?.status === 'SUCCESS') return '导出已完成'
if (task.value?.status === 'FAILURE') return '导出未完成'
if (task.value?.status === 'WAITING') return '等待开始导出'
return '正在导出用户数据'
})
const statusIcon = computed(() => {
if (submitting.value) return 'ep:loading'
if (task.value?.status === 'SUCCESS') return 'ep:circle-check-filled'
if (task.value?.status === 'FAILURE' || pollFailed.value) return 'ep:warning-filled'
return 'ep:download'
})
let querySnapshot: Record<string, unknown> = {}
let timer: ReturnType<typeof setTimeout> | undefined
let pollingVersion = 0
let activePage = true
let autoDownload = false
const rememberTask = (taskId?: string) => {
try {
if (taskId) sessionStorage.setItem(storageKey, taskId)
else sessionStorage.removeItem(storageKey)
} catch {
// 浏览器禁用存储时,当前页面仍可正常导出。
}
}
const stopPolling = () => {
pollingVersion++
clearTimeout(timer)
timer = undefined
}
const downloadFile = async () => {
if (!task.value || task.value.status !== 'SUCCESS' || downloading.value) return
const downloadTask = task.value
downloading.value = true
errorMessage.value = ''
try {
const data = await UserApi.downloadUserExport(downloadTask.taskId)
download.excel(data, downloadTask.fileName || '会员用户.xlsx')
} catch {
errorMessage.value = '文件已生成,下载失败,请点击“下载 Excel”重试。'
visible.value = true
} finally {
downloading.value = false
}
}
const poll = async (version: number) => {
if (!task.value || !activePage || version !== pollingVersion) return
try {
const data = await UserApi.getUserExportProgress(task.value.taskId)
if (!activePage || version !== pollingVersion) return
task.value = data
pollFailed.value = false
errorMessage.value = ''
if (isActive.value) {
timer = setTimeout(() => poll(version), 2000)
} else {
rememberTask()
if (data.status === 'SUCCESS' && autoDownload) {
autoDownload = false
await downloadFile()
}
}
} catch {
if (!activePage || version !== pollingVersion) return
pollFailed.value = true
errorMessage.value = '进度查询失败,后台任务会继续,请重试查询进度。'
}
}
const startPolling = () => {
stopPolling()
pollFailed.value = false
errorMessage.value = ''
void poll(pollingVersion)
}
const submitExport = async () => {
if (submitting.value) return
stopPolling()
submitting.value = true
task.value = undefined
existingTask.value = false
pollFailed.value = false
errorMessage.value = ''
try {
const data = await UserApi.exportUsers(querySnapshot)
if (!data?.taskId) throw new Error('导出任务编号为空')
task.value = data
existingTask.value = data.existingTask
autoDownload = true
rememberTask(data.taskId)
startPolling()
} catch {
errorMessage.value = '导出任务提交失败,请重试。'
} finally {
submitting.value = false
}
}
const open = async (params: Record<string, unknown>) => {
visible.value = true
if (submitting.value) return
// 从同一个 search-form 对象拍下快照,只去掉分页;保留男/女页面固定的性别条件。
querySnapshot = Object.fromEntries(
Object.entries(params)
.filter(([key]) => key !== 'pageNo' && key !== 'pageSize')
.map(([key, value]) => [key, Array.isArray(value) ? [...value] : value])
)
if (isActive.value) {
autoDownload = true
startPolling()
return
}
await submitExport()
}
onMounted(() => {
try {
const taskId = sessionStorage.getItem(storageKey)
if (!taskId) return
task.value = {
taskId,
status: 'WAITING',
total: 0,
processed: 0,
progress: 0,
message: '正在恢复导出进度',
fileName: '',
existingTask: false,
retentionHours: 24
}
autoDownload = true
startPolling()
} catch {
// 不影响正常提交导出。
}
})
onActivated(() => {
activePage = true
if (isActive.value) startPolling()
})
onDeactivated(() => {
activePage = false
stopPolling()
})
onBeforeUnmount(() => {
activePage = false
stopPolling()
})
defineExpose({ open, submitting, isActive })
</script>
<style scoped lang="scss">
.export-dialog-heading {
display: flex;
align-items: center;
gap: 12px;
padding-right: 28px;
h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
line-height: 1.5;
color: var(--el-text-color-primary);
}
p {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
}
.export-dialog-heading__icon,
.export-status-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.export-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.export-progress-panel {
padding: 20px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: var(--el-fill-color-extra-light);
&.is-success .export-status-icon {
color: var(--el-color-success);
background: var(--el-color-success-light-9);
}
&.is-failure .export-status-icon {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
}
.export-progress-heading {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.export-progress-copy {
flex: 1;
min-width: 0;
p {
margin: 0;
line-height: 1.6;
overflow-wrap: anywhere;
}
}
.export-status-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.export-status-message {
margin-top: 4px !important;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.export-percentage {
flex-shrink: 0;
font-size: 28px;
font-weight: 600;
line-height: 1;
color: var(--el-text-color-primary);
font-variant-numeric: tabular-nums;
small {
margin-left: 2px;
font-size: 14px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
}
.export-count {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
font-variant-numeric: tabular-nums;
strong {
font-size: 14px;
color: var(--el-text-color-primary);
}
}
.export-hint {
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--el-text-color-regular);
> :first-child {
flex-shrink: 0;
margin-top: 3px;
color: var(--el-text-color-placeholder);
}
p {
margin: 0;
font-size: 12px;
line-height: 1.7;
}
}
.export-dialog-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
.is-loading > :first-child {
animation: export-loading 1s linear infinite;
}
@keyframes export-loading {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.is-loading > :first-child {
animation: none;
}
}
</style>
<style lang="scss">
.el-dialog.member-user-export-dialog {
max-width: calc(100vw - 32px);
padding: 0 !important;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
> .el-dialog__header {
padding: 20px 24px;
margin-right: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
> .el-dialog__body {
padding: 24px !important;
}
> .el-dialog__footer {
padding: 16px 24px 20px;
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-extra-light);
}
.el-dialog__headerbtn {
top: 16px;
right: 14px;
width: 32px;
height: 32px;
}
@media (max-width: 480px) {
> .el-dialog__header,
> .el-dialog__body,
> .el-dialog__footer {
padding-left: 20px !important;
padding-right: 20px !important;
}
}
}
</style>
+197 -23
View File
@@ -3,7 +3,13 @@
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="-mb-15px" label-width="68px">
<el-form
ref="queryFormRef"
:inline="true"
:model="queryParams"
class="-mb-15px"
label-width="68px"
>
<el-form-item label="用户昵称" prop="nickname">
<el-input
v-model="queryParams.nickname"
@@ -31,6 +37,15 @@
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="微信号" prop="wechatNum">
<el-input
v-model.trim="queryParams.wechatNum"
class="!w-240px"
clearable
placeholder="请输入微信号"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item v-if="props.sex === undefined" label="用户性别" prop="sex">
<el-select
v-model="queryParams.sex"
@@ -76,6 +91,18 @@
/>
</el-select>
</el-form-item>
<el-form-item label="用户地址" prop="addressAreaId">
<el-cascader
v-model="queryParams.addressAreaId"
:options="areaList"
:props="{ ...defaultProps, checkStrictly: true }"
class="!w-240px"
clearable
filterable
placeholder="请选择省/市/区"
separator="-"
/>
</el-form-item>
<el-form-item label="注册时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
@@ -113,41 +140,88 @@
<Icon class="mr-5px" icon="ep:refresh" />
重置
</el-button>
<el-button
v-hasPermi="['member:user:query']"
type="success"
plain
:loading="exportDialogRef?.submitting"
@click="exportDialogRef?.open(queryParams)"
>
<Icon class="mr-5px" icon="ep:download" />
{{ exportDialogRef?.isActive ? '查看导出进度' : '导出' }}
</el-button>
<el-button v-hasPermi="['promotion:coupon:send']" @click="openCoupon">发送优惠券</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<ContentWrap class="member-user-list">
<el-table
v-loading="loading"
class="member-user-table"
:data="list"
:show-overflow-tooltip="true"
:show-overflow-tooltip="false"
:stripe="true"
border
flexible
scrollbar-always-on
:scrollbar-tabindex="0"
table-layout="fixed"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column align="center" label="用户编号" prop="id" width="120px" />
<el-table-column align="center" label="用户ID" prop="userCode" width="100px" />
<el-table-column align="center" label="性别" prop="sex" width="70px">
<el-table-column align="center" label="用户编号" prop="id" width="84px" />
<el-table-column align="center" label="用户ID" prop="userCode" width="84px" />
<el-table-column align="center" label="性别" prop="sex" width="76px">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="row.sex" />
</template>
</el-table-column>
<el-table-column align="center" label="头像" prop="avatar" width="80px">
<el-table-column align="center" label="头像" prop="avatar" width="76px">
<template #default="{ row }">
<div class="w-[50px] h-[50px]">
<PreviewImage :src="row.avatar" />
</div>
</template>
</el-table-column>
<el-table-column align="center" label="手机号" prop="mobile" width="120px" />
<el-table-column align="center" label="昵称" prop="nickname" width="120px" />
<el-table-column align="center" label="会员" prop="nickname" />
<el-table-column align="center" width="160px" label="用户标签">
<el-table-column align="center" label="手机号" prop="mobile" min-width="116px" />
<el-table-column align="center" label="昵称" prop="nickname" min-width="128px" />
<el-table-column align="center" label="用户地址" prop="addressAreaNames" min-width="240px">
<template #default="{ row }">
<div class="grid grid-cols-2 gap-1">
<div v-if="row.addressAreaNames?.length" class="member-user-addresses">
<div v-for="areaName in row.addressAreaNames" :key="areaName">{{ areaName }}</div>
</div>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column align="center" label="微信号" prop="wechatNum" min-width="144px">
<template #default="{ row }">
{{ row.wechatNum || '—' }}
</template>
</el-table-column>
<el-table-column
align="center"
label="微信二维码"
prop="wechatQrCode"
width="100px"
:show-overflow-tooltip="false"
>
<template #default="{ row }">
<PreviewImage
v-if="row.wechatQrCode"
:src="row.wechatQrCode"
:width="64"
:height="64"
fit="contain"
alt="微信二维码"
/>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column align="center" min-width="184px" label="用户标签">
<template #default="{ row }">
<div v-if="row.memberTagList?.length" class="member-user-tags">
<dict-tag
v-for="v in row.memberTagList"
:key="v.tagBizType"
@@ -155,9 +229,10 @@
:value="v.tagBizType"
/>
</div>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column align="center" label="是否禁用" width="100px">
<el-table-column align="center" label="是否禁用" width="92px">
<template #default="{ row }">
<div class="switch-cell">
<el-switch
@@ -171,23 +246,29 @@
</div>
</template>
</el-table-column>
<el-table-column align="center" label="时间" width="250px">
<el-table-column align="center" label="时间" min-width="256px">
<template #default="{ row }">
<div>
<div class="member-user-times">
<div>
<span>登录时间:</span>
<span>{{ formatDate(row.loginDate) }}</span>
<span class="member-user-times__label">登录时间:</span>
<span>{{ formatDate(row.loginDate) || '—' }}</span>
</div>
<div>
<span>注册时间:</span>
<span>{{ formatDate(row.createTime) }}</span>
<span class="member-user-times__label">注册时间:</span>
<span>{{ formatDate(row.createTime) || '—' }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column :show-overflow-tooltip="false" align="center" fixed="right" label="操作" width="100px">
<el-table-column
:show-overflow-tooltip="false"
align="center"
fixed="right"
label="操作"
width="144px"
>
<template #default="{ row }">
<div class="flex items-center justify-center">
<div class="member-user-actions">
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
<el-dropdown
v-hasPermi="[
@@ -203,7 +284,10 @@
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="checkPermi(['member:user:update'])" command="handleUpdate">
<el-dropdown-item
v-if="checkPermi(['member:user:update'])"
command="handleUpdate"
>
编辑
</el-dropdown-item>
<el-dropdown-item
@@ -247,6 +331,7 @@
</ContentWrap>
<!-- 表单弹窗:添加/修改 -->
<UserExportDialog ref="exportDialogRef" />
<UserForm ref="formRef" @success="getList" />
<!-- 修改用户等级弹窗 -->
<UserLevelUpdateForm ref="updateLevelFormRef" @success="getList" />
@@ -262,6 +347,7 @@
<script lang="ts" setup>
import { formatDate } from '@/utils/formatTime'
import * as UserApi from '@/api/member/user'
import * as AreaApi from '@/api/system/area'
import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import UserForm from './UserForm.vue'
@@ -271,8 +357,10 @@ import UserLevelUpdateForm from './components/UserLevelUpdateForm.vue'
import UserPointUpdateForm from './components/UserPointUpdateForm.vue'
import UserMemberTagForm from './components/UserMemberTagForm.vue'
import UserOpenVipForm from './components/UserOpenVipForm.vue'
import UserExportDialog from './components/UserExportDialog.vue'
import { CouponSendForm } from '@/views/mall/promotion/coupon/components'
import { checkPermi } from '@/utils/permission'
import { defaultProps } from '@/utils/tree'
defineOptions({ name: 'MemberUser' })
@@ -288,6 +376,8 @@ const queryParams = reactive({
pageSize: 10,
nickname: null,
mobile: null,
wechatNum: null as string | null,
addressAreaId: null as number | null,
userCode: null,
sex: props.sex ?? null,
status: null as number | null,
@@ -298,6 +388,8 @@ const queryParams = reactive({
groupId: null
})
const queryFormRef = ref() // 搜索的表单
const exportDialogRef = ref<InstanceType<typeof UserExportDialog>>()
const areaList = ref([]) // 省市区列表
const updateLevelFormRef = ref() // 修改会员等级表单
const updatePointFormRef = ref() // 修改会员积分表单
const updateMemberTagFormRef = ref() // 修改用户标签表单
@@ -398,12 +490,94 @@ const handleCommand = (command: string, row: UserApi.UserVO) => {
}
/** 初始化 **/
onMounted(() => {
onMounted(async () => {
getList()
areaList.value = await AreaApi.getAreaTree()
})
</script>
<style lang="scss" scoped>
.member-user-list {
width: 100%;
max-width: 100%;
min-width: 0;
:deep(.el-card__body) {
min-width: 0;
}
}
.member-user-table {
width: 100%;
max-width: 100%;
:deep(.el-table__cell) {
padding: 10px 0;
}
:deep(.cell) {
line-height: 22px;
white-space: normal;
overflow-wrap: anywhere;
word-break: normal;
text-overflow: clip;
}
:deep(.el-table__header .cell) {
white-space: nowrap;
}
:deep(.el-scrollbar__wrap:focus-visible) {
outline: 2px solid var(--el-color-primary);
outline-offset: -2px;
}
}
.member-user-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
align-items: center;
:deep(.el-tag) {
max-width: 100%;
height: auto;
min-height: var(--el-tag-height, 24px);
white-space: normal;
}
}
.member-user-addresses {
display: grid;
gap: 4px;
text-align: left;
}
.member-user-times {
display: grid;
gap: 4px;
text-align: left;
> div {
display: flex;
gap: 4px;
white-space: nowrap;
}
&__label {
color: var(--el-text-color-secondary);
}
}
.member-user-actions {
display: flex;
gap: 12px;
justify-content: center;
align-items: center;
white-space: nowrap;
}
.switch-cell {
display: flex;
justify-content: center;
+315
View File
@@ -0,0 +1,315 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const { parse, compileTemplate } = require('vue/compiler-sfc')
const { ref, computed } = require('vue')
const source = fs.readFileSync(
path.join(__dirname, '../src/views/member/user/components/UserExportDialog.vue'),
'utf8'
)
const { descriptor } = parse(source)
const script = ts.transpileModule(descriptor.scriptSetup.content, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const waiting = (overrides = {}) => ({
taskId: 'task-1',
status: 'WAITING',
total: 0,
processed: 0,
progress: 0,
message: '等待处理',
fileName: '会员用户.xlsx',
existingTask: false,
retentionHours: 24,
...overrides
})
const finished = () => waiting({ status: 'SUCCESS', total: 3, processed: 3, progress: 100 })
const flush = async () => {
for (let index = 0; index < 8; index++) await Promise.resolve()
}
// 执行真实组件的 setup 脚本,以 Vue 响应式状态、可控计时器和 API 替身验证异步交互。
function createHarness(overrides = {}, savedTask) {
const lifecycle = {}
const timers = new Map()
const calls = { exports: [], polls: [], downloads: [], files: [] }
const storage = new Map(savedTask ? [['member-user-export:1:100', savedTask]] : [])
const api = {
exportUsers: async (params) => {
calls.exports.push(params)
return waiting()
},
getUserExportProgress: async (taskId) => {
calls.polls.push(taskId)
return waiting({ status: 'RUNNING' })
},
downloadUserExport: async (taskId) => {
calls.downloads.push(taskId)
return new Blob(['excel'])
},
...overrides
}
const context = {
exports: {},
ref,
computed,
Blob,
Object,
onMounted: (fn) => {
lifecycle.mounted = fn
},
onActivated: (fn) => {
lifecycle.activated = fn
},
onDeactivated: (fn) => {
lifecycle.deactivated = fn
},
onBeforeUnmount: (fn) => {
lifecycle.unmounted = fn
},
setTimeout: (fn, delay) => {
const id = Symbol()
timers.set(id, { fn, delay })
return id
},
clearTimeout: (id) => timers.delete(id),
sessionStorage: {
getItem: (key) => storage.get(key),
setItem: (key, value) => storage.set(key, value),
removeItem: (key) => storage.delete(key)
},
defineExpose: (exposed) => {
context.exposed = exposed
},
require: (name) => {
if (name === '@/api/member/user') return api
if (name === '@/utils/download')
return { default: { excel: (...args) => calls.files.push(args) } }
if (name === '@/utils/auth') return { getTenantId: () => '1' }
if (name === '@/store/modules/user') return { useUserStore: () => ({ getUser: { id: 100 } }) }
throw new Error(`Unexpected import: ${name}`)
}
}
vm.runInNewContext(
script +
'\nglobalThis.state = { task, errorMessage, pollFailed, existingTask, visible, downloadFile, startPolling, submitExport };',
context
)
return {
...context,
api,
calls,
lifecycle,
timers,
storage,
tick: async () => {
const next = timers.entries().next().value
assert.ok(next, '应存在下一次轮询')
timers.delete(next[0])
await next[1].fn()
await flush()
}
}
}
test('进度弹窗模板可编译', () => {
assert.deepEqual(
compileTemplate({
source: descriptor.template.content,
filename: 'UserExportDialog.vue',
id: 'export'
}).errors,
[]
)
})
test('提交表单全部条件的快照,忽略分页,保留零值和男女页面条件,并阻止重复点击', async () => {
let resolveSubmit
let captured
const h = createHarness({
exportUsers: (params) => {
captured = params
return new Promise((resolve) => {
resolveSubmit = resolve
})
}
})
const params = {
pageNo: 50,
pageSize: 10,
nickname: '会员',
userCode: '000001',
mobile: '139',
wechatNum: 'wx_test',
addressAreaId: 330100,
sex: 2,
status: 0,
memberRecentTagList: ['4', '5'],
createTime: ['2026-10-01 00:00:00', '2026-10-30 23:59:59'],
loginDate: ['2026-10-20 00:00:00', '2026-10-30 23:59:59'],
levelId: null,
groupId: null
}
const pending = h.exposed.open(params)
await h.exposed.open(params)
params.memberRecentTagList.push('6')
params.createTime[0] = 'changed'
assert.equal(captured.pageNo, undefined)
assert.equal(captured.pageSize, undefined)
assert.equal(captured.sex, 2)
assert.equal(captured.status, 0)
assert.deepEqual(Array.from(captured.memberRecentTagList), ['4', '5'])
assert.equal(captured.createTime[0], '2026-10-01 00:00:00')
assert.deepEqual(
Object.keys(captured).sort(),
Object.keys(params)
.filter((key) => !['pageNo', 'pageSize'].includes(key))
.sort()
)
resolveSubmit(waiting())
await pending
await flush()
assert.equal(h.exposed.isActive.value, true)
assert.equal(h.timers.size, 1)
assert.equal([...h.timers.values()][0].delay, 2000)
assert.equal(h.storage.get('member-user-export:1:100'), 'task-1')
})
test('完成后停止轮询并下载一次,允许手动再次下载', async () => {
const h = createHarness()
await h.exposed.open({ sex: 1 })
await flush()
h.api.getUserExportProgress = async () => finished()
await h.tick()
assert.equal(h.state.task.value.progress, 100)
assert.equal(h.exposed.isActive.value, false)
assert.equal(h.timers.size, 0)
assert.deepEqual(h.calls.downloads, ['task-1'])
assert.equal(h.calls.files.length, 1)
assert.equal(h.storage.size, 0)
await h.state.downloadFile()
assert.equal(h.calls.files.length, 2)
})
test('查询失败时保留任务并提供重试,不重复创建导出', async () => {
const h = createHarness({
getUserExportProgress: async () => {
throw new Error('network')
}
})
await h.exposed.open({ sex: 2 })
await flush()
assert.equal(h.state.pollFailed.value, true)
assert.equal(h.timers.size, 0)
h.api.getUserExportProgress = async () =>
waiting({ status: 'RUNNING', total: 10, processed: 5, progress: 50 })
h.state.startPolling()
await flush()
assert.equal(h.calls.exports.length, 1)
assert.equal(h.state.task.value.progress, 50)
assert.equal(h.state.pollFailed.value, false)
assert.equal(h.timers.size, 1)
})
test('任务失败停止轮询且不下载,可用原条件重试', async () => {
const h = createHarness({
getUserExportProgress: async () => waiting({ status: 'FAILURE', message: '导出失败' })
})
await h.exposed.open({ sex: 2, addressAreaId: 330100 })
await flush()
assert.equal(h.exposed.isActive.value, false)
assert.equal(h.timers.size, 0)
assert.equal(h.calls.downloads.length, 0)
await h.state.submitExport()
await flush()
assert.equal(h.calls.exports.length, 2)
assert.equal(h.calls.exports[1].addressAreaId, 330100)
})
test('KeepAlive 离开页面后忽略过时响应,返回时恢复轮询', async () => {
let resolvePoll
const h = createHarness({
getUserExportProgress: () =>
new Promise((resolve) => {
resolvePoll = resolve
})
})
await h.exposed.open({ sex: 1 })
h.lifecycle.deactivated()
resolvePoll(finished())
await flush()
assert.equal(h.state.task.value.status, 'WAITING')
assert.equal(h.calls.downloads.length, 0)
assert.equal(h.timers.size, 0)
h.api.getUserExportProgress = async () => finished()
h.lifecycle.activated()
await flush()
assert.equal(h.state.task.value.status, 'SUCCESS')
assert.equal(h.calls.downloads.length, 1)
h.lifecycle.unmounted()
assert.equal(h.timers.size, 0)
})
test('刷新后按用户和租户恢复任务,完成后下载并清除缓存', async () => {
const h = createHarness({ getUserExportProgress: async () => finished() }, 'task-1')
h.lifecycle.mounted()
await flush()
assert.equal(h.calls.exports.length, 0)
assert.equal(h.state.task.value.status, 'SUCCESS')
assert.equal(h.calls.downloads.length, 1)
assert.equal(h.storage.size, 0)
})
test('下载失败保持成功状态并打开弹窗,重试只下载文件', async () => {
const h = createHarness({
getUserExportProgress: async () => finished(),
downloadUserExport: async () => {
throw new Error('download interrupted')
}
})
await h.exposed.open({ status: 0 })
await flush()
assert.equal(h.state.task.value.status, 'SUCCESS')
assert.match(h.state.errorMessage.value, /下载失败/)
assert.equal(h.state.visible.value, true)
h.api.downloadUserExport = async () => new Blob(['excel'])
await h.state.downloadFile()
assert.equal(h.state.errorMessage.value, '')
assert.equal(h.calls.exports.length, 1)
assert.equal(h.calls.files.length, 1)
})
test('复用已有任务的提示在后续进度轮询后仍然保留', async () => {
const h = createHarness({ exportUsers: async () => waiting({ existingTask: true }) })
await h.exposed.open({ sex: 2 })
await flush()
assert.equal(h.state.existingTask.value, true)
await h.tick()
assert.equal(h.state.existingTask.value, true)
})
test('开始另一个任务不会改变正在下载的文件名', async () => {
let resolveDownload
const h = createHarness({
getUserExportProgress: async () => finished(),
downloadUserExport: () =>
new Promise((resolve) => {
resolveDownload = resolve
})
})
await h.exposed.open({ sex: 1 })
await flush()
h.api.exportUsers = async () => waiting({ taskId: 'task-2', fileName: '新的导出.xlsx' })
h.api.getUserExportProgress = async () => waiting({ taskId: 'task-2' })
await h.exposed.open({ sex: 2 })
await flush()
resolveDownload(new Blob(['first export']))
await flush()
assert.equal(h.calls.files[0][1], '会员用户.xlsx')
assert.equal(h.state.task.value.taskId, 'task-2')
})