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;
|
||||
|
||||
Reference in new issue
Block a user