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:
parent
75b2d130c2
commit
cd9be23693
74
apps/web-ele/src/api/fengling/member.ts
Normal file
74
apps/web-ele/src/api/fengling/member.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -278,4 +278,69 @@ export namespace FenglingApi {
|
||||
page: 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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
197
apps/web-ele/src/views/fengling/members/detail.vue
Normal file
197
apps/web-ele/src/views/fengling/members/detail.vue
Normal 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>
|
||||
235
apps/web-ele/src/views/fengling/members/index.vue
Normal file
235
apps/web-ele/src/views/fengling/members/index.vue
Normal 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>
|
||||
225
apps/web-ele/src/views/fengling/members/points.vue
Normal file
225
apps/web-ele/src/views/fengling/members/points.vue
Normal 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>
|
||||
129
apps/web-ele/src/views/fengling/members/tags.vue
Normal file
129
apps/web-ele/src/views/fengling/members/tags.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user