feat(member): add member management module with frontend and backend

- Add MemberQueryEndpoints with CRUD operations and status management
- Add frontend member management pages (list, detail, points, tags)
- Add frontend routes and API client for member module
- Move completed docs to docs/completed/
This commit is contained in:
movingsam 2026-02-17 16:29:34 +08:00
parent 75b2d130c2
commit cd9be23693
7 changed files with 971 additions and 0 deletions

View File

@ -0,0 +1,74 @@
import type { FenglingApi } from './typings';
import { requestClient } from '#/api/request';
export namespace MemberApi {
const apiPrefix = '/api/v1/members';
export interface QueryMembersParams {
tenantId?: number
phoneNumber?: string
status?: string
openId?: string
page?: number
pageSize?: number
}
export interface QueryMembersResponse {
items: FenglingApi.Member.Member[]
total: number
page: number
pageSize: number
}
export async function getMembersList(params: QueryMembersParams = {}) {
return requestClient.get<QueryMembersResponse>(apiPrefix, { params });
}
export async function getMemberById(id: string) {
return requestClient.get<FenglingApi.Member.Member>(`${apiPrefix}/${id}`);
}
export async function createMember(data: FenglingApi.Member.CreateMemberRequest) {
return requestClient.post<FenglingApi.Member.Member, FenglingApi.Member.CreateMemberRequest>(apiPrefix, data);
}
export async function updateMember(id: string, data: FenglingApi.Member.UpdateMemberRequest) {
return requestClient.put<FenglingApi.Member.Member, FenglingApi.Member.UpdateMemberRequest>(`${apiPrefix}/${id}`, data);
}
export async function updateMemberStatus(id: string, data: FenglingApi.Member.UpdateMemberStatusRequest) {
return requestClient.put<FenglingApi.Member.Member, FenglingApi.Member.UpdateMemberStatusRequest>(`${apiPrefix}/${id}/status`, data);
}
export async function deleteMember(id: string) {
return requestClient.delete<void>(`${apiPrefix}/${id}`);
}
export async function addMemberTag(id: string, data: FenglingApi.Member.AddMemberTagRequest) {
return requestClient.post<FenglingApi.Member.Member, FenglingApi.Member.AddMemberTagRequest>(`${apiPrefix}/${id}/tags`, data);
}
export async function removeMemberTag(id: string, tagId: string) {
return requestClient.delete<FenglingApi.Member.Member>(`${apiPrefix}/${id}/tags/${tagId}`);
}
export async function getPointsBalance(memberId: number) {
return requestClient.get<FenglingApi.Member.PointsBalance>(`/api/v1/members/${memberId}/points/balance`);
}
export async function getPointsHistory(memberId: number, page = 1, pageSize = 20) {
return requestClient.get<FenglingApi.Member.PointsHistoryResponse>(`/api/v1/members/${memberId}/points/history`, {
params: { page, pageSize },
});
}
export async function addPoints(memberId: number, data: {
points: number
transactionType: string
sourceId: string
remark?: string
}) {
return requestClient.post<any, typeof data>(`/api/v1/members/${memberId}/points`, data);
}
}

View File

@ -278,4 +278,69 @@ export namespace FenglingApi {
page: number page: number
pageSize: number pageSize: number
} }
export namespace Member {
export interface Member {
id: string
tenantId: number
phoneNumber: string
openId: string
unionId?: string
status: string
statusDesc: string
tags: MemberTag[]
createdAt: string
updatedAt?: string
}
export interface MemberTag {
tagId: string
tagName: string
}
export interface CreateMemberRequest {
tenantId: number
phoneNumber?: string
openId?: string
unionId?: string
source?: string
}
export interface UpdateMemberRequest {
phoneNumber?: string
}
export interface UpdateMemberStatusRequest {
action: 'freeze' | 'unfreeze' | 'deactivate'
}
export interface AddMemberTagRequest {
tagId: string
tagName?: string
}
export interface PointsBalance {
memberId: number
totalPoints: number
frozenPoints: number
availablePoints: number
}
export interface PointsTransaction {
id: number
points: number
transactionType: string
sourceId: string
remark?: string
createdAt: string
}
export interface PointsHistoryResponse {
memberId: number
page: number
pageSize: number
totalCount: number
transactions: PointsTransaction[]
}
}
} }

View File

@ -139,6 +139,52 @@ const routes: RouteRecordRaw[] = [
}, },
], ],
}, },
// ========== 会员管理 ==========
{
meta: {
icon: 'lucide:users',
order: 4,
title: '会员管理',
},
name: 'Member',
path: '/member',
children: [
{
name: 'MemberList',
path: '/member/members',
component: () => import('#/views/fengling/members/index.vue'),
meta: {
icon: 'lucide:user',
title: '会员列表',
},
},
{
name: 'MemberDetail',
path: '/member/members/:id',
component: () => import('#/views/fengling/members/detail.vue'),
meta: {
hide: true,
},
},
{
name: 'MemberPoints',
path: '/member/members/:id/points',
component: () => import('#/views/fengling/members/points.vue'),
meta: {
hide: true,
},
},
{
name: 'MemberTags',
path: '/member/tags',
component: () => import('#/views/fengling/members/tags.vue'),
meta: {
icon: 'lucide:tag',
title: '标签管理',
},
},
],
},
]; ];
export default routes; export default routes;

View File

@ -0,0 +1,197 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
ElDescriptions,
ElDescriptionsItem,
ElButton,
ElCard,
ElTag,
ElMessage,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElSpace,
} from 'element-plus';
import { MemberApi } from '#/api/fengling/member';
import type { FenglingApi } from '#/api/fengling';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const member = ref<FenglingApi.Member.Member | null>(null);
const editDialogVisible = ref(false);
const tagDialogVisible = ref(false);
const editForm = ref<{ phoneNumber: string }>({ phoneNumber: '' });
const newTag = ref<{ tagId: string; tagName: string }>({ tagId: '', tagName: '' });
const memberId = route.params.id as string;
const loadMember = async () => {
loading.value = true;
try {
member.value = await MemberApi.getMemberById(memberId);
} catch (error) {
ElMessage.error('加载会员详情失败');
} finally {
loading.value = false;
}
};
const handleEdit = () => {
editForm.value = {
phoneNumber: member.value?.phoneNumber || '',
};
editDialogVisible.value = true;
};
const handleSave = async () => {
try {
await MemberApi.updateMember(memberId, { phoneNumber: editForm.value.phoneNumber });
ElMessage.success('会员更新成功');
editDialogVisible.value = false;
loadMember();
} catch (error) {
ElMessage.error('会员更新失败');
}
};
const handleAddTag = () => {
if (!newTag.value.tagId) {
ElMessage.warning('请输入标签ID');
return;
}
MemberApi.addMemberTag(memberId, newTag.value)
.then(() => {
ElMessage.success('标签添加成功');
tagDialogVisible.value = false;
newTag.value = { tagId: '', tagName: '' };
loadMember();
})
.catch(() => {
ElMessage.error('标签添加失败');
});
};
const handleRemoveTag = async (tagId: string) => {
try {
await MemberApi.removeMemberTag(memberId, tagId);
ElMessage.success('标签移除成功');
loadMember();
} catch (error) {
ElMessage.error('标签移除失败');
}
};
const handleBack = () => {
router.push('/member/members');
};
const getStatusType = (status: string) => {
switch (status) {
case 'Active':
return 'success';
case 'Frozen':
return 'warning';
case 'Inactive':
return 'info';
default:
return '';
}
};
onMounted(() => {
loadMember();
});
</script>
<template>
<div class="p-5">
<div class="mb-4">
<el-button @click="handleBack">Back to List</el-button>
</div>
<el-card v-loading="loading">
<template #header>
<div class="flex items-center justify-between">
<span>Member Details</span>
<el-space>
<el-button type="primary" @click="handleEdit">Edit</el-button>
<el-button @click="router.push(`/member/members/${memberId}/points`)">View Points</el-button>
</el-space>
</div>
</template>
<el-descriptions :column="2" border v-if="member">
<el-descriptions-item label="ID">
{{ member.id }}
</el-descriptions-item>
<el-descriptions-item label="Tenant ID">
{{ member.tenantId }}
</el-descriptions-item>
<el-descriptions-item label="Phone Number">
{{ member.phoneNumber || '-' }}
</el-descriptions-item>
<el-descriptions-item label="OpenID">
{{ member.openId || '-' }}
</el-descriptions-item>
<el-descriptions-item label="UnionID">
{{ member.unionId || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Status">
<el-tag :type="getStatusType(member.status)">
{{ member.statusDesc }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Created At">
{{ member.createdAt }}
</el-descriptions-item>
<el-descriptions-item label="Updated At">
{{ member.updatedAt || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Tags" :span="2">
<el-tag
v-for="tag in member.tags"
:key="tag.tagId"
class="mr-2"
closable
@close="handleRemoveTag(tag.tagId)"
>
{{ tag.tagName }}
</el-tag>
<el-button size="small" @click="tagDialogVisible = true">Add Tag</el-button>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-dialog v-model="editDialogVisible" title="Edit Member" width="500px">
<el-form :model="editForm" label-width="120px">
<el-form-item label="Phone Number">
<el-input v-model="editForm.phoneNumber" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="handleSave">Save</el-button>
</template>
</el-dialog>
<el-dialog v-model="tagDialogVisible" title="Add Tag" width="400px">
<el-form :model="newTag" label-width="80px">
<el-form-item label="Tag ID">
<el-input v-model="newTag.tagId" placeholder="e.g., VIP, NEW_USER" />
</el-form-item>
<el-form-item label="Tag Name">
<el-input v-model="newTag.tagName" placeholder="e.g., VIP用户, 新用户" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="tagDialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="handleAddTag">Add</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@ -0,0 +1,235 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import {
ElTable,
ElTableColumn,
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElTag,
ElMessage,
ElPopconfirm,
ElSelect,
ElOption,
ElPagination,
ElSpace,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
ElIcon,
} from 'element-plus';
import { ArrowDown } from '@element-plus/icons-vue';
import { MemberApi } from '#/api/fengling/member';
import type { FenglingApi } from '#/api/fengling';
const router = useRouter();
const loading = ref(false);
const tableData = ref<FenglingApi.Member.Member[]>([]);
const dialogVisible = ref(false);
const dialogTitle = ref('Register Member');
const formData = ref<Partial<FenglingApi.Member.CreateMemberRequest>>({});
const searchPhone = ref('');
const searchStatus = ref('');
const pagination = ref({
page: 1,
pageSize: 10,
total: 0,
});
const statusOptions = [
{ value: '', label: 'All Status' },
{ value: 'Active', label: '正常' },
{ value: 'Frozen', label: '已冻结' },
{ value: 'Inactive', label: '已停用' },
];
const loadMembers = async () => {
loading.value = true;
try {
const response = await MemberApi.getMembersList({
tenantId: 1,
phoneNumber: searchPhone.value || undefined,
status: searchStatus.value || undefined,
page: pagination.value.page,
pageSize: pagination.value.pageSize,
});
tableData.value = response.items;
pagination.value.total = response.total;
} catch (error) {
ElMessage.error('加载会员列表失败');
} finally {
loading.value = false;
}
};
const handleCreate = () => {
dialogTitle.value = 'Register Member';
formData.value = {
tenantId: 1,
};
dialogVisible.value = true;
};
const handleView = (row: FenglingApi.Member.Member) => {
router.push(`/member/members/${row.id}`);
};
const handlePoints = (row: FenglingApi.Member.Member) => {
router.push(`/member/members/${row.id}/points`);
};
const handleStatus = async (row: FenglingApi.Member.Member, action: 'freeze' | 'unfreeze' | 'deactivate') => {
try {
await MemberApi.updateMemberStatus(row.id, { action });
ElMessage.success('状态更新成功');
loadMembers();
} catch (error) {
ElMessage.error('状态更新失败');
}
};
const handleDelete = async (id: string) => {
try {
await MemberApi.deleteMember(id);
ElMessage.success('会员删除成功');
loadMembers();
} catch (error) {
ElMessage.error('会员删除失败');
}
};
const handleSave = async () => {
try {
await MemberApi.createMember(formData.value as FenglingApi.Member.CreateMemberRequest);
ElMessage.success('会员注册成功');
dialogVisible.value = false;
loadMembers();
} catch (error) {
ElMessage.error('会员注册失败');
}
};
const getStatusType = (status: string) => {
switch (status) {
case 'Active':
return 'success';
case 'Frozen':
return 'warning';
case 'Inactive':
return 'info';
default:
return '';
}
};
onMounted(() => {
loadMembers();
});
</script>
<template>
<div class="p-5">
<div class="mb-4 flex items-center justify-between">
<div class="flex gap-2">
<el-input
v-model="searchPhone"
placeholder="Search by phone number"
clearable
@clear="loadMembers"
@keyup.enter="loadMembers"
style="width: 200px"
/>
<el-select v-model="searchStatus" placeholder="Status" clearable @change="loadMembers" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button type="primary" @click="loadMembers">Search</el-button>
</div>
<el-button type="primary" @click="handleCreate">Register Member</el-button>
</div>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="id" label="ID" width="220" />
<el-table-column prop="phoneNumber" label="Phone Number" width="130" />
<el-table-column prop="openId" label="OpenID" width="180" show-overflow-tooltip />
<el-table-column label="Status" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.statusDesc }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Tags" width="200">
<template #default="{ row }">
<el-tag v-for="tag in row.tags" :key="tag.tagId" class="mr-1" type="info">{{ tag.tagName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="Created At" width="170" />
<el-table-column label="Actions" width="280" fixed="right">
<template #default="{ row }">
<el-space>
<el-button size="small" @click="handleView(row)">View</el-button>
<el-button size="small" @click="handlePoints(row)">Points</el-button>
<el-dropdown @command="(cmd: string) => handleStatus(row, cmd as any)">
<el-button size="small">
Status <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="freeze" :disabled="row.status === 'Frozen'">Freeze</el-dropdown-item>
<el-dropdown-item command="unfreeze" :disabled="row.status === 'Active'">Unfreeze</el-dropdown-item>
<el-dropdown-item command="deactivate">Deactivate</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-popconfirm title="Are you sure to delete?" @confirm="handleDelete(row.id)">
<template #reference>
<el-button size="small" type="danger">Delete</el-button>
</template>
</el-popconfirm>
</el-space>
</template>
</el-table-column>
</el-table>
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@change="loadMembers"
/>
</div>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form :model="formData" label-width="120px">
<el-form-item label="Tenant ID">
<el-input v-model="formData.tenantId" type="number" />
</el-form-item>
<el-form-item label="Phone Number">
<el-input v-model="formData.phoneNumber" placeholder="1XXXXXXXXXX" />
</el-form-item>
<el-form-item label="OpenID">
<el-input v-model="formData.openId" />
</el-form-item>
<el-form-item label="UnionID">
<el-input v-model="formData.unionId" />
</el-form-item>
<el-form-item label="Source">
<el-input v-model="formData.source" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="handleSave">Save</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@ -0,0 +1,225 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
ElCard,
ElRow,
ElCol,
ElStatistic,
ElTable,
ElTableColumn,
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElSelect,
ElOption,
ElMessage,
ElTag,
ElPagination,
} from 'element-plus';
import { MemberApi } from '#/api/fengling/member';
import type { FenglingApi } from '#/api/fengling';
const router = useRouter();
const route = useRoute();
const loadingBalance = ref(false);
const loadingHistory = ref(false);
const balance = ref<FenglingApi.Member.PointsBalance | null>(null);
const transactions = ref<FenglingApi.Member.PointsTransaction[]>([]);
const addPointsDialogVisible = ref(false);
const addPointsForm = ref({
points: 0,
transactionType: 'ADMIN_ADJUST',
sourceId: '',
remark: '',
});
const memberId = computed(() => Number(route.params.id));
const pagination = ref({
page: 1,
pageSize: 20,
total: 0,
});
const transactionTypes = [
{ value: 'ADMIN_ADJUST', label: 'Admin Adjustment' },
{ value: 'REGISTER', label: 'Registration' },
{ value: 'LOGIN', label: 'Login' },
{ value: 'PURCHASE', label: 'Purchase' },
{ value: 'REFERRAL', label: 'Referral' },
{ value: 'REWARD', label: 'Reward' },
{ value: 'REDEMPTION', label: 'Redemption' },
{ value: 'EXPIRED', label: 'Expired' },
];
const loadBalance = async () => {
loadingBalance.value = true;
try {
balance.value = await MemberApi.getPointsBalance(memberId.value);
} catch (error) {
ElMessage.error('加载积分余额失败');
} finally {
loadingBalance.value = false;
}
};
const loadHistory = async () => {
loadingHistory.value = true;
try {
const response = await MemberApi.getPointsHistory(
memberId.value,
pagination.value.page,
pagination.value.pageSize
);
transactions.value = response.transactions;
pagination.value.total = response.totalCount;
} catch (error) {
ElMessage.error('加载积分明细失败');
} finally {
loadingHistory.value = false;
}
};
const handleAddPoints = async () => {
if (addPointsForm.value.points <= 0) {
ElMessage.warning('Please enter valid points');
return;
}
try {
await MemberApi.addPoints(memberId.value, {
points: addPointsForm.value.points,
transactionType: addPointsForm.value.transactionType,
sourceId: addPointsForm.value.sourceId || `admin_${Date.now()}`,
remark: addPointsForm.value.remark,
});
ElMessage.success('积分添加成功');
addPointsDialogVisible.value = false;
addPointsForm.value = { points: 0, transactionType: 'ADMIN_ADJUST', sourceId: '', remark: '' };
loadBalance();
loadHistory();
} catch (error) {
ElMessage.error('积分添加失败');
}
};
const handleBack = () => {
router.push(`/member/members/${memberId.value}`);
};
const getTransactionTypeTag = (type: string) => {
switch (type) {
case 'ADMIN_ADJUST':
return 'warning';
case 'REGISTER':
case 'LOGIN':
case 'PURCHASE':
case 'REFERRAL':
case 'REWARD':
return 'success';
case 'REDEMPTION':
case 'EXPIRED':
return 'info';
default:
return '';
}
};
onMounted(() => {
loadBalance();
loadHistory();
});
</script>
<template>
<div class="p-5">
<div class="mb-4">
<el-button @click="handleBack">Back to Member</el-button>
</div>
<el-row :gutter="20" class="mb-4">
<el-col :span="8">
<el-card v-loading="loadingBalance">
<el-statistic title="Total Points" :value="balance?.totalPoints || 0" />
</el-card>
</el-col>
<el-col :span="8">
<el-card v-loading="loadingBalance">
<el-statistic title="Available Points" :value="balance?.availablePoints || 0" />
</el-card>
</el-col>
<el-col :span="8">
<el-card v-loading="loadingBalance">
<el-statistic title="Frozen Points" :value="balance?.frozenPoints || 0" />
</el-card>
</el-col>
</el-row>
<el-card>
<template #header>
<div class="flex items-center justify-between">
<span>Points History</span>
<el-button type="primary" @click="addPointsDialogVisible = true">Add Points</el-button>
</div>
</template>
<el-table :data="transactions" v-loading="loadingHistory" border stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="Points" width="100">
<template #default="{ row }">
<span :class="row.points > 0 ? 'text-green-500' : 'text-red-500'">
{{ row.points > 0 ? '+' : '' }}{{ row.points }}
</span>
</template>
</el-table-column>
<el-table-column prop="transactionType" label="Type" width="150">
<template #default="{ row }">
<el-tag :type="getTransactionTypeTag(row.transactionType)">
{{ row.transactionType }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sourceId" label="Source ID" width="150" show-overflow-tooltip />
<el-table-column prop="remark" label="Remark" width="200" show-overflow-tooltip />
<el-table-column prop="createdAt" label="Created At" width="170" />
</el-table>
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@change="loadHistory"
/>
</div>
</el-card>
<el-dialog v-model="addPointsDialogVisible" title="Add Points" width="500px">
<el-form :model="addPointsForm" label-width="120px">
<el-form-item label="Points">
<el-input-number v-model="addPointsForm.points" :min="1" :max="999999" />
</el-form-item>
<el-form-item label="Transaction Type">
<el-select v-model="addPointsForm.transactionType" style="width: 100%">
<el-option v-for="item in transactionTypes" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="Source ID">
<el-input v-model="addPointsForm.sourceId" placeholder="Optional" />
</el-form-item>
<el-form-item label="Remark">
<el-input v-model="addPointsForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addPointsDialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="handleAddPoints">Add</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@ -0,0 +1,129 @@
<script lang="ts" setup>
import { ref } from 'vue';
import {
ElCard,
ElButton,
ElTag,
ElMessage,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElAlert,
} from 'element-plus';
const predefinedTags = ref([
{ id: 'VIP', name: 'VIP会员', color: 'warning' },
{ id: 'NEW_USER', name: '新用户', color: 'success' },
{ id: 'ACTIVE', name: '活跃用户', color: 'primary' },
{ id: 'INACTIVE', name: '不活跃', color: 'info' },
{ id: 'BLACKLIST', name: '黑名单', color: 'danger' },
{ id: 'WHITELIST', name: '白名单', color: 'success' },
{ id: 'BIRTHDAY', name: '生日用户', color: 'danger' },
{ id: 'HIGH_VALUE', name: '高价值用户', color: 'warning' },
]);
const dialogVisible = ref(false);
const editTag = ref<{ id: string; name: string; color: string }>({ id: '', name: '', color: '' });
const isEdit = ref(false);
const handleAdd = () => {
editTag.value = { id: '', name: '', color: 'primary' };
isEdit.value = false;
dialogVisible.value = true;
};
const handleEdit = (tag: { id: string; name: string; color: string }) => {
editTag.value = { ...tag };
isEdit.value = true;
dialogVisible.value = true;
};
const handleDelete = (tagId: string) => {
const index = predefinedTags.value.findIndex((t) => t.id === tagId);
if (index > -1) {
predefinedTags.value.splice(index, 1);
ElMessage.success('Tag deleted');
}
};
const handleSave = () => {
if (!editTag.value.id || !editTag.value.name) {
ElMessage.warning('Please fill in tag ID and name');
return;
}
if (isEdit.value) {
const index = predefinedTags.value.findIndex((t) => t.id === editTag.value.id);
if (index > -1) {
predefinedTags.value[index] = { ...editTag.value };
}
ElMessage.success('Tag updated');
} else {
predefinedTags.value.push({ ...editTag.value });
ElMessage.success('Tag added');
}
dialogVisible.value = false;
};
</script>
<template>
<div class="p-5">
<el-alert
title="Tag Management"
description="Manage predefined member tags. These tags can be assigned to members from the member detail page."
type="info"
:closable="false"
class="mb-4"
/>
<el-card>
<template #header>
<div class="flex items-center justify-between">
<span>Member Tags</span>
<el-button type="primary" @click="handleAdd">Add Tag</el-button>
</div>
</template>
<div class="flex flex-wrap gap-2">
<el-tag
v-for="tag in predefinedTags"
:key="tag.id"
:type="tag.color as any"
closable
@close="handleDelete(tag.id)"
@click="handleEdit(tag)"
class="cursor-pointer"
>
{{ tag.name }} ({{ tag.id }})
</el-tag>
</div>
<el-empty v-if="predefinedTags.length === 0" description="No tags defined" />
</el-card>
<el-dialog v-model="dialogVisible" :title="isEdit ? 'Edit Tag' : 'Add Tag'" width="400px">
<el-form :model="editTag" label-width="80px">
<el-form-item label="Tag ID">
<el-input v-model="editTag.id" :disabled="isEdit" placeholder="e.g., VIP, NEW_USER" />
</el-form-item>
<el-form-item label="Tag Name">
<el-input v-model="editTag.name" placeholder="e.g., VIP会员, 新用户" />
</el-form-item>
<el-form-item label="Color">
<el-select v-model="editTag.color" style="width: 100%">
<el-option label="Primary (Blue)" value="primary" />
<el-option label="Success (Green)" value="success" />
<el-option label="Warning (Orange)" value="warning" />
<el-option label="Danger (Red)" value="danger" />
<el-option label="Info (Gray)" value="info" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="handleSave">Save</el-button>
</template>
</el-dialog>
</div>
</template>