513 lines
18 KiB
Vue
513 lines
18 KiB
Vue
<template>
|
||
<doc-alert title="会员用户、标签、分组" url="https://doc.iocoder.cn/member/user/" />
|
||
|
||
<ContentWrap>
|
||
<!-- 搜索工作栏 -->
|
||
<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" class="!w-240px" clearable placeholder="请输入用户昵称"
|
||
@keyup.enter="handleQuery" />
|
||
</el-form-item>
|
||
<el-form-item label="用户ID" prop="userCode">
|
||
<el-input v-model="queryParams.userCode" class="!w-240px" clearable placeholder="App 我的页面显示的 ID"
|
||
@keyup.enter="handleQuery" />
|
||
</el-form-item>
|
||
<el-form-item label="手机号" prop="mobile">
|
||
<el-input v-model="queryParams.mobile" class="!w-240px" clearable placeholder="请输入手机号"
|
||
@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" class="!w-240px" clearable placeholder="请选择用户性别">
|
||
<el-option v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" :key="dict.value" :label="dict.label"
|
||
:value="dict.value" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="是否禁用" prop="status">
|
||
<el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择禁用状态">
|
||
<el-option label="已禁用" :value="CommonStatusEnum.DISABLE" />
|
||
<el-option label="未禁用" :value="CommonStatusEnum.ENABLE" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="用户标签" prop="memberRecentTagList">
|
||
<el-select v-model="queryParams.memberRecentTagList" class="!w-240px" clearable collapse-tags
|
||
collapse-tags-tooltip filterable multiple placeholder="请选择用户标签">
|
||
<el-option v-for="dict in getStrDictOptions(DICT_TYPE.MEMBER_RECENT_TAG)" :key="dict.value"
|
||
:label="dict.label" :value="dict.value" />
|
||
</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"
|
||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-240px" end-placeholder="结束日期"
|
||
start-placeholder="开始日期" type="daterange" value-format="YYYY-MM-DD HH:mm:ss" />
|
||
</el-form-item>
|
||
<el-form-item label="登录时间" prop="loginDate">
|
||
<el-date-picker v-model="queryParams.loginDate" :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||
class="!w-240px" end-placeholder="结束日期" start-placeholder="开始日期" type="daterange"
|
||
value-format="YYYY-MM-DD HH:mm:ss" />
|
||
</el-form-item>
|
||
<el-form-item v-if="false" label="用户等级" prop="levelId">
|
||
<MemberLevelSelect v-model="queryParams.levelId" />
|
||
</el-form-item>
|
||
<el-form-item v-if="false" label="用户分组" prop="groupId">
|
||
<MemberGroupSelect v-model="queryParams.groupId" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button @click="handleQuery">
|
||
<Icon class="mr-5px" icon="ep:search" />
|
||
搜索
|
||
</el-button>
|
||
<el-button @click="resetQuery">
|
||
<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 class="member-user-list">
|
||
<el-table v-loading="loading" class="member-user-table" :data="list" :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="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="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" 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 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" :type="DICT_TYPE.MEMBER_RECENT_TAG"
|
||
:value="v.tagBizType" />
|
||
</div>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column align="center" label="是否禁用" width="92px">
|
||
<template #default="{ row }">
|
||
<div class="switch-cell">
|
||
<el-switch v-model="row.status" size="small" :active-value="CommonStatusEnum.DISABLE"
|
||
:inactive-value="CommonStatusEnum.ENABLE" :disabled="!checkPermi(['member:user:update'])"
|
||
@change="handleStatusChange(row)" />
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column align="center" label="邀请人" min-width="196px">
|
||
<template #default="{ row }">
|
||
<component :is="row.inviter?.memberExists ? 'button' : 'div'" v-if="row.inviter" class="member-user-inviter"
|
||
:class="{ 'member-user-inviter--link': row.inviter.memberExists }"
|
||
:type="row.inviter.memberExists ? 'button' : undefined"
|
||
:aria-label="row.inviter.memberExists ? `查看邀请人${row.inviter.nickname || row.inviter.id}的详情` : undefined"
|
||
@click="row.inviter.memberExists && openDetail(row.inviter.id)">
|
||
<el-avatar :src="row.inviter.avatar || undefined" :size="32" aria-hidden="true">
|
||
<Icon icon="ep:user" />
|
||
</el-avatar>
|
||
<span class="member-user-inviter__text">
|
||
<span>{{ row.inviter.memberExists ? row.inviter.nickname || '未设置昵称' : '会员资料缺失' }}</span>
|
||
<span class="member-user-inviter__id">ID {{ row.inviter.userCode || row.inviter.id }}</span>
|
||
</span>
|
||
</component>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column align="center" label="时间" min-width="256px">
|
||
<template #default="{ row }">
|
||
<div class="member-user-times">
|
||
<div>
|
||
<span class="member-user-times__label">登录时间:</span>
|
||
<span>{{ formatDate(row.loginDate) || '—' }}</span>
|
||
</div>
|
||
<div>
|
||
<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="144px">
|
||
<template #default="{ row }">
|
||
<div class="member-user-actions">
|
||
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
|
||
<el-dropdown v-hasPermi="[
|
||
'member:user:update',
|
||
'member:user:update-level',
|
||
'member:user:update-point'
|
||
]" @command="(command) => handleCommand(command, row)">
|
||
<el-button link type="primary">
|
||
<Icon icon="ep:d-arrow-right" />
|
||
更多
|
||
</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item v-if="checkPermi(['member:user:update'])" command="handleUpdate">
|
||
编辑
|
||
</el-dropdown-item>
|
||
<el-dropdown-item v-if="checkPermi(['member:user:update-level'])" command="handleUpdateLevel">
|
||
修改等级
|
||
</el-dropdown-item>
|
||
<el-dropdown-item v-if="checkPermi(['member:user:update-point'])" command="handleUpdatePoint">
|
||
修改积分
|
||
</el-dropdown-item>
|
||
<el-dropdown-item v-if="checkPermi(['pay:wallet:update-member-tag'])" command="handleUpdateMemberTag">
|
||
修改用户标签
|
||
</el-dropdown-item>
|
||
<el-dropdown-item v-if="checkPermi(['pay:wallet:update-member-tag'])" command="handleOpenVipTag">
|
||
开通会员
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<!-- 分页 -->
|
||
<Pagination v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNo" :total="total"
|
||
@pagination="getList" />
|
||
</ContentWrap>
|
||
|
||
<!-- 表单弹窗:添加/修改 -->
|
||
<UserExportDialog ref="exportDialogRef" />
|
||
<UserForm ref="formRef" @success="getList" />
|
||
<!-- 修改用户等级弹窗 -->
|
||
<UserLevelUpdateForm ref="updateLevelFormRef" @success="getList" />
|
||
<!-- 修改用户积分弹窗 -->
|
||
<UserPointUpdateForm ref="updatePointFormRef" @success="getList" />
|
||
<!-- 发送优惠券弹窗 -->
|
||
<CouponSendForm ref="couponSendFormRef" />
|
||
<!-- 修改用户标签弹窗 -->
|
||
<UserMemberTagForm ref="updateMemberTagFormRef" @success="getList" />
|
||
<!-- 开通会员弹窗 -->
|
||
<UserOpenVipForm ref="userOpenVipFormRef" @success="getList" />
|
||
</template>
|
||
<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'
|
||
import MemberLevelSelect from '@/views/member/level/components/MemberLevelSelect.vue'
|
||
import MemberGroupSelect from '@/views/member/group/components/MemberGroupSelect.vue'
|
||
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' })
|
||
|
||
const props = defineProps<{ sex?: number }>()
|
||
|
||
const message = useMessage() // 消息弹窗
|
||
|
||
const loading = ref(true) // 列表的加载中
|
||
const total = ref(0) // 列表的总页数
|
||
const list = ref([]) // 列表的数据
|
||
const queryParams = reactive({
|
||
pageNo: 1,
|
||
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,
|
||
loginDate: [],
|
||
createTime: [],
|
||
memberRecentTagList: [] as string[],
|
||
levelId: null,
|
||
groupId: null
|
||
})
|
||
const queryFormRef = ref() // 搜索的表单
|
||
const exportDialogRef = ref<InstanceType<typeof UserExportDialog>>()
|
||
const areaList = ref([]) // 省市区列表
|
||
const updateLevelFormRef = ref() // 修改会员等级表单
|
||
const updatePointFormRef = ref() // 修改会员积分表单
|
||
const updateMemberTagFormRef = ref() // 修改用户标签表单
|
||
const userOpenVipFormRef = ref() // 开通会员
|
||
const selectedIds = ref<number[]>([]) // 表格的选中 ID 数组
|
||
|
||
/** 查询列表 */
|
||
const getList = async () => {
|
||
loading.value = true
|
||
try {
|
||
const data = await UserApi.getUserPage(queryParams)
|
||
list.value = data.list
|
||
total.value = data.total
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 搜索按钮操作 */
|
||
const handleQuery = () => {
|
||
queryParams.pageNo = 1
|
||
getList()
|
||
}
|
||
|
||
/** 重置按钮操作 */
|
||
const resetQuery = () => {
|
||
queryFormRef.value.resetFields()
|
||
handleQuery()
|
||
}
|
||
|
||
/** 打开会员详情 */
|
||
const { push } = useRouter()
|
||
const openDetail = (id: number) => {
|
||
push({ name: 'MemberUserDetail', params: { id } })
|
||
}
|
||
|
||
/** 添加/修改操作 */
|
||
const formRef = ref()
|
||
const openForm = (type: string, id?: number) => {
|
||
formRef.value.open(type, id)
|
||
}
|
||
|
||
/** 表格选中事件 */
|
||
const handleSelectionChange = (rows: UserApi.UserVO[]) => {
|
||
selectedIds.value = rows.map((row) => row.id)
|
||
}
|
||
|
||
/** 发送优惠券 */
|
||
const couponSendFormRef = ref()
|
||
const openCoupon = () => {
|
||
if (selectedIds.value.length === 0) {
|
||
message.warning('请选择要发送优惠券的用户')
|
||
return
|
||
}
|
||
couponSendFormRef.value.open(selectedIds.value)
|
||
}
|
||
|
||
/** 是否禁用开关(参考原用户配置里的是否推送) */
|
||
const handleStatusChange = async (row: UserApi.UserVO) => {
|
||
const isDisable = row.status === CommonStatusEnum.DISABLE
|
||
const nickname = row.nickname || row.mobile || row.id
|
||
try {
|
||
await message.confirm(
|
||
isDisable
|
||
? `确认禁用用户「${nickname}」?禁用后将无法登录 App。`
|
||
: `确认启用用户「${nickname}」?`
|
||
)
|
||
await UserApi.updateUserStatus({ id: row.id, status: row.status })
|
||
message.success(isDisable ? '禁用成功' : '启用成功')
|
||
} catch {
|
||
// 取消或失败时回滚开关
|
||
row.status =
|
||
row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE
|
||
}
|
||
}
|
||
|
||
/** 操作分发 */
|
||
const handleCommand = (command: string, row: UserApi.UserVO) => {
|
||
switch (command) {
|
||
case 'handleUpdate':
|
||
openForm('update', row.id)
|
||
break
|
||
case 'handleUpdateLevel':
|
||
updateLevelFormRef.value.open(row.id)
|
||
break
|
||
case 'handleUpdatePoint':
|
||
updatePointFormRef.value.open(row.id)
|
||
break
|
||
case 'handleUpdateMemberTag':
|
||
updateMemberTagFormRef.value.open(row.id)
|
||
break
|
||
case 'handleOpenVipTag':
|
||
userOpenVipFormRef.value.open(row.id)
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
/** 初始化 **/
|
||
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;
|
||
}
|
||
|
||
.member-user-inviter {
|
||
display: flex;
|
||
width: 100%;
|
||
gap: 10px;
|
||
align-items: center;
|
||
padding: 4px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
color: var(--el-text-color-primary);
|
||
background: transparent;
|
||
font: inherit;
|
||
text-align: left;
|
||
|
||
.el-avatar {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
&__text {
|
||
display: grid;
|
||
min-width: 0;
|
||
gap: 2px;
|
||
}
|
||
|
||
&__id {
|
||
color: var(--el-text-color-regular);
|
||
font-size: 12px;
|
||
}
|
||
|
||
&--link {
|
||
cursor: pointer;
|
||
|
||
&:hover {
|
||
color: var(--el-color-primary);
|
||
background: var(--el-fill-color-light);
|
||
}
|
||
|
||
&:focus-visible {
|
||
outline: 2px solid var(--el-color-primary);
|
||
outline-offset: 2px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.switch-cell {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
</style>
|