Files
Friendship-and-chat-pc/src/views/member/user/index.vue
T

513 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>