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;