feat:会员管理优化增加导出
This commit is contained in:
1 parent
42d7049318
commit
57a35f2f9d
5 files changed
+1057
-24
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user