feat(会员标签): 实现会员标签管理功能

添加会员标签相关API接口和页面
重构用户详情页标签管理逻辑
新增标签管理页面,支持增删改查操作
This commit is contained in:
dzq 2025-12-06 09:26:14 +08:00
parent 673c992c09
commit 71e52b3505
4 changed files with 758 additions and 19 deletions

View File

@ -0,0 +1,126 @@
import { http } from "@/utils/http";
/**
*
*/
export interface MembershipTagMemberDTO {
/** 主键,自动递增 */
id?: number;
/** 企业微信ID */
corpid?: string;
/** 标签ID */
tagId?: number;
/** 汇邦云用户ID */
ab98UserId?: number;
/** 微信用户ID */
wxUserId?: number;
/** 创建者ID */
creatorId?: number;
/** 创建时间 */
createTime?: string;
/** 更新者ID */
updaterId?: number;
/** 更新时间 */
updateTime?: string;
/** 标签名称(可选,通过关联查询或缓存获取) */
tagName?: string;
}
/**
*
*/
export interface SearchMembershipTagMemberQuery extends BasePageQuery {
/** 企业微信ID */
corpid?: string;
/** 标签ID */
tagId?: number;
/** 汇邦云用户ID */
ab98UserId?: number;
/** 微信用户ID */
wxUserId?: number;
}
/**
*
*/
export interface AddMembershipTagMemberCommand {
/** 企业微信ID */
corpid?: string;
/** 标签ID */
tagId: number;
/** 汇邦云用户ID */
ab98UserId?: number;
/** 微信用户ID */
wxUserId?: number;
}
/**
*
*/
export interface UpdateMembershipTagMemberCommand extends AddMembershipTagMemberCommand {
/** 主键,自动递增 */
id: number;
}
/**
*
*/
export const getMembershipTagMemberListApi = (params: SearchMembershipTagMemberQuery) => {
return http.request<ResponseData<PageDTO<MembershipTagMemberDTO>>>("get", "/membership/tagMembers", { params });
};
/**
*
*/
export const getMembershipTagMemberDetailApi = (id: number) => {
return http.request<ResponseData<MembershipTagMemberDTO>>("get", `/membership/tagMembers/${id}`);
};
/**
*
*/
export const addMembershipTagMemberApi = (data: AddMembershipTagMemberCommand) => {
return http.request<ResponseData<void>>("post", "/membership/tagMembers", { data });
};
/**
*
*/
export const updateMembershipTagMemberApi = (id: number, data: UpdateMembershipTagMemberCommand) => {
return http.request<ResponseData<void>>("put", `/membership/tagMembers/${id}`, { data });
};
/**
*
*/
export const deleteMembershipTagMemberApi = (ids: number[]) => {
return http.request<ResponseData<void>>("delete", `/membership/tagMembers/${ids.join(',')}`);
};
/**
* ID查询关联用户
*/
export const getMembershipTagMemberByTagIdApi = (tagId: number) => {
return http.request<ResponseData<MembershipTagMemberDTO[]>>("get", `/membership/tagMembers/byTag/${tagId}`);
};
/**
* ID查询标签
*/
export const getMembershipTagMemberByAb98UserIdApi = (ab98UserId: number) => {
return http.request<ResponseData<MembershipTagMemberDTO[]>>("get", `/membership/tagMembers/byAb98User/${ab98UserId}`);
};
/**
* ID查询标签
*/
export const getMembershipTagMemberByWxUserIdApi = (wxUserId: number) => {
return http.request<ResponseData<MembershipTagMemberDTO[]>>("get", `/membership/tagMembers/byWxUser/${wxUserId}`);
};
/**
*
*/
export const getUserTagsApi = (params: { corpid: string; ab98UserId?: number; wxUserId?: number }) => {
return http.request<ResponseData<MembershipTagMemberDTO[]>>("get", "/membership/tagMembers/userTags", { params });
};

View File

@ -0,0 +1,98 @@
import { http } from "@/utils/http";
/**
*
*/
export interface MembershipTagDTO {
/** 主键,自动递增 */
id?: number;
/** 企业微信ID */
corpid?: string;
/** 标签名称 */
name?: string;
/** 标签颜色(十六进制代码) */
color?: string;
/** 标签说明 */
remark?: string;
/** 状态0-禁用 1-启用 */
status?: number;
/** 创建者ID */
creatorId?: number;
/** 创建时间 */
createTime?: string;
/** 更新者ID */
updaterId?: number;
/** 更新时间 */
updateTime?: string;
}
/**
*
*/
export interface MembershipTagQuery extends BasePageQuery {
/** 企业微信ID */
corpid?: string;
/** 标签名称 */
name?: string;
/** 状态0-禁用 1-启用 */
status?: number;
}
/**
*
*/
export interface AddMembershipTagCommand {
/** 企业微信ID */
corpid?: string;
/** 标签名称 */
name: string;
/** 标签颜色(十六进制代码) */
color?: string;
/** 标签说明 */
remark?: string;
/** 状态0-禁用 1-启用 */
status?: number;
}
/**
*
*/
export interface UpdateMembershipTagCommand extends AddMembershipTagCommand {
/** 主键,自动递增 */
id: number;
}
/**
*
*/
export const getMembershipTagListApi = (params: MembershipTagQuery) => {
return http.request<ResponseData<PageDTO<MembershipTagDTO>>>("get", "/membership/tags", { params });
};
/**
*
*/
export const getMembershipTagDetailApi = (id: number) => {
return http.request<ResponseData<MembershipTagDTO>>("get", `/membership/tags/${id}`);
};
/**
*
*/
export const addMembershipTagApi = (data: AddMembershipTagCommand) => {
return http.request<ResponseData<void>>("post", "/membership/tags", { data });
};
/**
*
*/
export const updateMembershipTagApi = (id: number, data: UpdateMembershipTagCommand) => {
return http.request<ResponseData<void>>("put", `/membership/tags/${id}`, { data });
};
/**
*
*/
export const deleteMembershipTagApi = (ids: number[]) => {
return http.request<ResponseData<void>>("delete", `/membership/tags/${ids.join(',')}`);
};

View File

@ -0,0 +1,485 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import {
getMembershipTagListApi,
type MembershipTagDTO,
type MembershipTagQuery,
addMembershipTagApi,
type AddMembershipTagCommand,
updateMembershipTagApi,
type UpdateMembershipTagCommand,
deleteMembershipTagApi
} from "@/api/membership/membershipTag";
import { type PaginationProps } from "@pureadmin/table";
import { CommonUtils } from "@/utils/common";
import { useWxStore } from "@/store/modules/wx";
import { ElMessage, ElMessageBox } from "element-plus";
import Search from "@iconify-icons/ep/search";
import Refresh from "@iconify-icons/ep/refresh";
import Plus from "@iconify-icons/ep/plus";
import Edit from "@iconify-icons/ep/edit-pen";
import Delete from "@iconify-icons/ep/delete";
defineOptions({
name: "MembershipTag"
});
const wxStore = useWxStore();
const formRef = ref();
const drawerVisible = ref(false);
const drawerLoading = ref(false);
const drawerFormRef = ref();
const drawerForm = reactive<AddMembershipTagCommand>({
corpid: wxStore.corpid || "",
name: "",
color: "#409EFF",
remark: "",
status: 1
});
const isEdit = ref(false);
const currentTagId = ref<number | null>(null);
const pageLoading = ref(false);
const dataList = ref<MembershipTagDTO[]>([]);
const pagination = reactive<PaginationProps>({
total: 0,
pageSize: 12,
currentPage: 1,
background: true
});
const searchFormParams = reactive<MembershipTagQuery & { search?: string }>({
corpid: wxStore.corpid || "",
name: undefined,
status: undefined,
search: undefined
});
//
const handleSearchInput = () => {
searchFormParams.name = searchFormParams.search || undefined;
};
//
async function onSearch() {
handleSearchInput();
pagination.currentPage = 1;
getList();
}
//
async function getList() {
CommonUtils.fillPaginationParams(searchFormParams, pagination);
pageLoading.value = true;
try {
const { data } = await getMembershipTagListApi(searchFormParams);
dataList.value = data.rows;
pagination.total = data.total;
} catch (error) {
console.error("获取标签列表失败:", error);
ElMessage.error("获取标签列表失败");
} finally {
pageLoading.value = false;
}
}
//
const resetForm = formEl => {
if (!formEl) return;
formEl.resetFields();
//
searchFormParams.name = undefined;
onSearch();
};
//
const openDrawer = (tag?: MembershipTagDTO) => {
drawerVisible.value = true;
if (tag) {
//
isEdit.value = true;
currentTagId.value = tag.id!;
drawerForm.name = tag.name || "";
drawerForm.color = tag.color || "#409EFF";
drawerForm.remark = tag.remark || "";
drawerForm.status = tag.status ?? 1;
} else {
//
isEdit.value = false;
currentTagId.value = null;
drawerForm.name = "";
drawerForm.color = "#409EFF";
drawerForm.remark = "";
drawerForm.status = 1;
}
};
//
const closeDrawer = () => {
drawerVisible.value = false;
drawerFormRef.value?.resetFields();
isEdit.value = false;
currentTagId.value = null;
};
//
const convertColorToHex = (color: string): string => {
if (!color) return "#409EFF";
// 16
if (color.startsWith('#')) {
return color.toUpperCase();
}
// RGB
if (color.startsWith('rgb')) {
const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*\d+\.?\d*)?\)/);
if (rgbMatch) {
const r = parseInt(rgbMatch[1]);
const g = parseInt(rgbMatch[2]);
const b = parseInt(rgbMatch[3]);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`;
}
}
// HSL
if (color.startsWith('hsl')) {
// HSLRGB
return "#409EFF";
}
return color.toUpperCase();
};
//
const handleColorChange = (color: string) => {
drawerForm.color = convertColorToHex(color);
};
//
const submitDrawer = async () => {
if (!drawerFormRef.value) return;
await drawerFormRef.value.validate(async (valid) => {
if (valid) {
try {
drawerLoading.value = true;
// 16
const processedForm = {
...drawerForm,
color: convertColorToHex(drawerForm.color)
};
if (isEdit.value && currentTagId.value) {
const updateData: UpdateMembershipTagCommand = {
id: currentTagId.value,
...processedForm
};
await updateMembershipTagApi(currentTagId.value, updateData);
ElMessage.success("标签更新成功");
} else {
await addMembershipTagApi(processedForm);
ElMessage.success("标签添加成功");
}
closeDrawer();
getList();
} catch (error) {
console.error(isEdit.value ? "更新标签失败:" : "添加标签失败:", error);
ElMessage.error(isEdit.value ? "更新标签失败,请重试" : "添加标签失败,请重试");
} finally {
drawerLoading.value = false;
}
}
});
};
//
const handleDelete = async (row: MembershipTagDTO) => {
try {
await ElMessageBox.confirm(`确定删除标签 "${row.name}" 吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
});
await deleteMembershipTagApi([row.id!]);
ElMessage.success("删除成功");
getList();
} catch (error) {
if (error !== "cancel") {
console.error("删除标签失败:", error);
ElMessage.error("删除失败");
}
}
};
//
const predefineColors = [
"#409EFF",
"#67C23A",
"#E6A23C",
"#F56C6C",
"#909399",
"#FF9A6C",
"#A0CFFF",
"#B3E19D",
"#F3D19E",
"#FAB6B6",
"#C0C4CC",
"#FFD7A6"
];
//
const drawerRules = reactive({
name: [
{ required: true, message: "请输入标签名称", trigger: "blur" }
],
color: [
{ required: true, message: "请选择标签颜色", trigger: "change" }
],
status: [
{ required: true, message: "请选择状态", trigger: "change" }
]
});
onMounted(() => {
getList();
});
</script>
<template>
<div class="main">
<div class="float-right w-full">
<el-form ref="formRef" :inline="true" :model="searchFormParams"
class="search-form bg-bg_color flex w-[99/100] pl-[22px] pt-[12px]">
<el-form-item prop="search">
<el-input v-model="searchFormParams.search" placeholder="请输入标签名称" clearable class="!w-[300px]"
@keydown.enter.prevent="onSearch" @change="handleSearchInput" />
</el-form-item>
<el-form-item prop="status">
<el-select v-model="searchFormParams.status" placeholder="请选择状态" clearable class="!w-[160px]">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="useRenderIcon(Search)" :loading="pageLoading" @click="onSearch">
搜索
</el-button>
</el-form-item>
<el-form-item>
<el-button :icon="useRenderIcon(Refresh)" @click="resetForm(formRef)">
重置
</el-button>
</el-form-item>
<el-form-item class="space-item">
</el-form-item>
<el-form-item>
<el-button type="success" :icon="useRenderIcon(Plus)" @click="() => openDrawer()">
新增标签
</el-button>
</el-form-item>
</el-form>
<div class="grid-container">
<el-row :gutter="12">
<el-col v-for="(item, index) in dataList" :key="item.id" :xs="24" :sm="12" :md="8" :lg="6">
<el-card class="tag-card" :body-style="{ padding: '16px' }">
<div class="card-header">
<div class="tag-color" :style="{ backgroundColor: item.color || '#409EFF' }"></div>
<div class="tag-name">{{ item.name }}</div>
<div class="tag-status">
<el-tag :type="item.status === 1 ? 'success' : 'danger'" size="small">
{{ item.status === 1 ? '启用' : '禁用' }}
</el-tag>
</div>
</div>
<div class="card-body">
<div class="tag-remark" v-if="item.remark">{{ item.remark }}</div>
<div class="tag-remark empty" v-else>暂无说明</div>
<div class="tag-meta">
<div class="meta-item">
<span class="label">创建时间</span>
<span class="value">{{ item.createTime || '未知' }}</span>
</div>
<div class="meta-item">
<span class="label">更新时间</span>
<span class="value">{{ item.updateTime || '未知' }}</span>
</div>
</div>
</div>
<div class="card-actions">
<el-button type="primary" :icon="useRenderIcon(Edit)" size="small" @click.stop="openDrawer(item)">
编辑
</el-button>
<el-button type="danger" :icon="useRenderIcon(Delete)" size="small" @click.stop="handleDelete(item)">
删除
</el-button>
</div>
</el-card>
</el-col>
</el-row>
<div class="pagination-wrapper">
<el-pagination background layout="prev, pager, next" :page-size="pagination.pageSize"
:total="pagination.total" v-model:current-page="pagination.currentPage" @current-change="getList"
@size-change="getList" />
</div>
</div>
</div>
<!-- 新增/编辑标签抽屉 -->
<el-drawer v-model="drawerVisible" :title="isEdit ? '编辑标签' : '新增标签'" size="400px" :close-on-click-modal="false">
<el-form ref="drawerFormRef" :model="drawerForm" :rules="drawerRules" label-width="80px">
<el-form-item label="标签名称" prop="name">
<el-input v-model="drawerForm.name" placeholder="请输入标签名称" maxlength="20" show-word-limit />
</el-form-item>
<el-form-item label="标签颜色" prop="color">
<el-color-picker v-model="drawerForm.color" show-alpha :predefine="predefineColors"
@change="handleColorChange" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="drawerForm.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标签说明" prop="remark">
<el-input v-model="drawerForm.remark" type="textarea" placeholder="请输入标签说明" maxlength="100" show-word-limit
:rows="3" />
</el-form-item>
</el-form>
<template #footer>
<div style="flex: auto">
<el-button @click="closeDrawer">取消</el-button>
<el-button type="primary" :loading="drawerLoading" @click="submitDrawer">{{ isEdit ? '确认更新' : '确认添加'
}}</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped lang="scss">
:deep(.el-dropdown-menu__item i) {
margin: 0;
}
.search-form {
:deep(.el-form-item) {
margin-bottom: 12px;
margin-right: 12px;
}
}
.tag-card {
margin-bottom: 12px;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid #e4e7ed;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
background: linear-gradient(135deg, #f0f9ff 0%, #e6f7ff 100%);
transform: translateY(-4px);
border-color: #409eff;
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 12px;
.tag-color {
width: 16px;
height: 16px;
border-radius: 4px;
margin-right: 8px;
}
.tag-name {
font-size: 16px;
font-weight: 600;
color: #303133;
flex: 1;
}
.tag-status {
margin-left: 8px;
}
}
.card-body {
flex: 1;
.tag-remark {
font-size: 14px;
color: #606266;
line-height: 1.5;
margin-bottom: 12px;
min-height: 20px;
&.empty {
color: #909399;
font-style: italic;
}
}
.tag-meta {
.meta-item {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
.label {
font-weight: 500;
}
.value {
margin-left: 4px;
}
}
}
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
}
.grid-container {
margin: 12px 0;
padding-bottom: 0px;
position: relative;
.el-row {
margin-bottom: -20px;
}
}
.pagination-wrapper {
position: relative;
background: var(--el-bg-color);
padding: 9px 12px;
margin-top: 20px;
text-align: center;
:deep(.el-pagination) {
margin: 0;
padding: 0;
}
}
.space-item {
flex: 1;
width: 100%;
}
</style>

View File

@ -4,7 +4,8 @@ import { useRoute } from "vue-router";
import { type Ab98UserDetailDTO, getAb98UserDetailApi } from "@/api/ab98/user";
import { type WxUserDTO, getWxUserDetailApi } from "@/api/wx/wxUser";
import { getOrderListApi, type OrderDTO } from "@/api/shop/order";
import { Ab98UserTagDTO, addAb98UserTagApi, deleteAb98UserTagConfirmApi, getAb98UserTagListApi } from "@/api/ab98/tag";
import { MembershipTagMemberDTO, getUserTagsApi, addMembershipTagMemberApi, deleteMembershipTagMemberApi } from "@/api/membership/MembershipTagMember";
import { getMembershipTagListApi, addMembershipTagApi, type MembershipTagDTO } from "@/api/membership/membershipTag";
import { ElMessage, ElMessageBox } from "element-plus";
import { PureTableBar } from "@/components/RePureTableBar";
import { useWxStore } from "@/store/modules/wx";
@ -20,11 +21,12 @@ const wxStore = useWxStore();
const userInfo = ref<Ab98UserDetailDTO | WxUserDTO>({});
const userType = ref<'ab98' | 'wx'>('ab98'); // ab98
const loading = ref(false);
const tags = ref<Ab98UserTagDTO[]>([]);
const tags = ref<MembershipTagMemberDTO[]>([]);
const tagsLoading = ref(false);
const showAddTagDialog = ref(false);
const availableTags = ref<MembershipTagDTO[]>([]);
const addTagForm = ref({
tagName: ''
tagId: null as number | null
});
const balanceVisible = ref(false);
@ -102,28 +104,48 @@ async function fetchUserTags() {
if (userType.value !== 'ab98') return;
tagsLoading.value = true;
const { data } = await getAb98UserTagListApi({
ab98UserId: (userInfo.value as Ab98UserDetailDTO).ab98UserId,
pageSize: 6,
pageNum: 1
const { data } = await getUserTagsApi({
corpid: wxStore.corpid,
ab98UserId: (userInfo.value as Ab98UserDetailDTO).ab98UserId
});
tags.value = data.rows;
tags.value = data;
} finally {
tagsLoading.value = false;
}
}
async function fetchAvailableTags() {
try {
const { data } = await getMembershipTagListApi({
corpid: wxStore.corpid,
pageNum: 1,
pageSize: 100
});
availableTags.value = data.rows;
} catch (error) {
console.error('Failed to fetch available tags:', error);
}
}
async function handleAddTag() {
try {
if (userType.value !== 'ab98') return;
if (!addTagForm.value.tagId) {
ElMessage.warning('请选择一个标签');
return;
}
tagsLoading.value = true;
await addAb98UserTagApi({
ab98UserId: (userInfo.value as Ab98UserDetailDTO).ab98UserId,
tagName: addTagForm.value.tagName
//
await addMembershipTagMemberApi({
corpid: wxStore.corpid,
tagId: addTagForm.value.tagId,
ab98UserId: (userInfo.value as Ab98UserDetailDTO).ab98UserId
});
showAddTagDialog.value = false;
addTagForm.value.tagName = '';
addTagForm.value.tagId = null;
await fetchUserTags();
ElMessage.success('标签添加成功');
} catch (error) {
@ -133,7 +155,7 @@ async function handleAddTag() {
}
}
async function handleDeleteTag(tagId: number) {
async function handleDeleteTag(id: number) {
try {
await ElMessageBox.confirm('确定要删除这个标签吗?', '提示', {
confirmButtonText: '确定',
@ -141,7 +163,7 @@ async function handleDeleteTag(tagId: number) {
type: 'warning'
});
tagsLoading.value = true;
await deleteAb98UserTagConfirmApi(tagId);
await deleteMembershipTagMemberApi([id]);
await fetchUserTags();
ElMessage.success('标签删除成功');
} catch (error) {
@ -155,11 +177,17 @@ async function handleDeleteTag(tagId: number) {
onMounted(() => {
fetchUserDetail();
fetchAvailableTags();
});
async function handleModifyBalance() {
balanceVisible.value = true;
}
async function handleShowAddTagDialog() {
await fetchAvailableTags();
showAddTagDialog.value = true;
}
</script>
<template>
@ -219,12 +247,12 @@ async function handleModifyBalance() {
<el-descriptions-item label="标签" :span="2">
<div class="tag-container">
<div class="user-tags" v-if="tags.length > 0">
<el-tag v-for="tag in tags" :key="tag.tagId" class="tag-item" closable
@close="handleDeleteTag(tag.tagId)">
<el-tag v-for="tag in tags" :key="tag.id" class="tag-item" closable
@close="handleDeleteTag(tag.id)">
{{ tag.tagName }}
</el-tag>
</div>
<el-button type="primary" size="small" @click="showAddTagDialog = true">添加标签</el-button>
<el-button type="primary" size="small" @click="handleShowAddTagDialog">添加标签</el-button>
</div>
</el-descriptions-item>
</el-descriptions>
@ -305,8 +333,10 @@ async function handleModifyBalance() {
</div>
<el-dialog v-model="showAddTagDialog" title="添加标签" width="30%">
<el-form :model="addTagForm" label-width="80px">
<el-form-item label="标签名称">
<el-input v-model="addTagForm.tagName" placeholder="请输入标签名称" />
<el-form-item label="选择标签">
<el-select v-model="addTagForm.tagId" placeholder="请选择标签" filterable>
<el-option v-for="tag in availableTags" :key="tag.id" :label="tag.name" :value="tag.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>