新增会员卡管理
This commit is contained in:
+17
-6
@@ -63,13 +63,24 @@ public class MemberCardHandler {
|
||||
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息")
|
||||
public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return request.bodyToMono(MemberCard.class)
|
||||
.flatMap(card -> {
|
||||
card.setMemberCardId(id);
|
||||
return memberCardService.save(card);
|
||||
return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡不存在: " + id)))
|
||||
.zipWith(request.bodyToMono(MemberCard.class))
|
||||
.flatMap(tuple -> {
|
||||
MemberCard existing = tuple.getT1();
|
||||
MemberCard body = tuple.getT2();
|
||||
// 仅更新非 null 字段,避免 null 覆盖已有值
|
||||
if (body.getMemberCardName() != null) existing.setMemberCardName(body.getMemberCardName());
|
||||
if (body.getMemberCardType() != null) existing.setMemberCardType(body.getMemberCardType());
|
||||
if (body.getMemberCardPrice() != null) existing.setMemberCardPrice(body.getMemberCardPrice());
|
||||
if (body.getMemberCardValidityDays() != null) existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
|
||||
if (body.getMemberCardTotalTimes() != null) existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
|
||||
if (body.getMemberCardAmount() != null) existing.setMemberCardAmount(body.getMemberCardAmount());
|
||||
if (body.getMemberCardStatus() != null) existing.setMemberCardStatus(body.getMemberCardStatus());
|
||||
if (body.getExtraConfig() != null) existing.setExtraConfig(body.getExtraConfig());
|
||||
return memberCardService.save(existing);
|
||||
})
|
||||
.flatMap(updated -> ServerResponse.ok().bodyValue(updated))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
.flatMap(updated -> ServerResponse.ok().bodyValue(updated));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型")
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ public interface MemberCardRepository extends R2dbcRepository<MemberCard, Long>
|
||||
"AND (:type IS NULL OR member_card_type = :type) " +
|
||||
"AND (:minPrice IS NULL OR member_card_price >= :minPrice) " +
|
||||
"AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " +
|
||||
"ORDER BY created_at DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}")
|
||||
"ORDER BY id ASC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}")
|
||||
Flux<MemberCard> findWithConditions(Integer status, String name, String type,
|
||||
Double minPrice, Double maxPrice, Pageable pageable);
|
||||
|
||||
|
||||
+3
@@ -257,8 +257,11 @@ public class SystemRouter {
|
||||
|
||||
// ===== 会员卡类型管理 =====
|
||||
.GET("/api/member-cards/active", memberCardHandler::getActiveCards)
|
||||
.GET("/api/member-cards", memberCardHandler::listMemberCards)
|
||||
.GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById)
|
||||
.POST("/api/member-cards", memberCardHandler::createMemberCard)
|
||||
.PUT("/api/member-cards/{id}", memberCardHandler::updateMemberCard)
|
||||
.DELETE("/api/member-cards/{id}", memberCardHandler::deleteMemberCard)
|
||||
|
||||
// ===== 会员卡记录管理(核心业务)=====
|
||||
.POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard)
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
-- ============================================
|
||||
-- V30: 新增会员卡管理菜单
|
||||
-- 描述: 为会员卡管理功能创建菜单项
|
||||
-- ============================================
|
||||
|
||||
-- 会员卡管理一级菜单(目录)
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||
(502, '会员卡管理', 0, 6, 'M', NULL, NULL, 1, NOW(), NOW());
|
||||
|
||||
-- 会员卡管理子菜单(页面)
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||
(503, '会员卡管理', 502, 1, 'C', 'business:memberCard:list', 'member/card/index', 1, NOW(), NOW());
|
||||
|
||||
-- 会员卡管理按钮权限
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||
(5031, '会员卡查询', 503, 1, 'F', 'business:memberCard:query', NULL, 1, NOW(), NOW()),
|
||||
(5032, '会员卡新增', 503, 2, 'F', 'business:memberCard:add', NULL, 1, NOW(), NOW()),
|
||||
(5033, '会员卡修改', 503, 3, 'F', 'business:memberCard:edit', NULL, 1, NOW(), NOW()),
|
||||
(5034, '会员卡删除', 503, 4, 'F', 'business:memberCard:remove', NULL, 1, NOW(), NOW());
|
||||
|
||||
-- 重置菜单序列
|
||||
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||
@@ -0,0 +1,107 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ==================== 会员基础信息 ====================
|
||||
|
||||
export interface Member {
|
||||
id?: number | string
|
||||
memberNo?: string
|
||||
nickname?: string
|
||||
phone?: string
|
||||
gender?: number
|
||||
genderDesc?: string
|
||||
avatar?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface MemberDetail {
|
||||
id?: number
|
||||
memberNo?: string
|
||||
nickname?: string
|
||||
phone?: string
|
||||
gender?: number
|
||||
genderDesc?: string
|
||||
birthday?: string
|
||||
address?: string
|
||||
avatar?: string
|
||||
subscribed?: boolean
|
||||
lastLoginAt?: string
|
||||
createdAt?: string
|
||||
memberCards?: MemberCardInfo[]
|
||||
activeCardCount?: number
|
||||
inactiveCardCount?: number
|
||||
}
|
||||
|
||||
export interface MemberCardInfo {
|
||||
id?: number
|
||||
memberCardId?: number
|
||||
memberCardName?: string
|
||||
memberCardType?: string
|
||||
memberCardTypeDesc?: string
|
||||
memberCardPrice?: number
|
||||
memberCardValidityDays?: number
|
||||
memberCardTotalTimes?: number
|
||||
memberCardAmount?: number
|
||||
memberCardStatus?: string
|
||||
memberCardStatusDesc?: string
|
||||
extraConfig?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface MemberCardRecord {
|
||||
id?: number
|
||||
memberCardRecordId?: number
|
||||
memberId?: number
|
||||
memberCardId?: number
|
||||
memberCardName?: string
|
||||
memberCardType?: string
|
||||
memberCardPrice?: number
|
||||
memberCardValidityDays?: number
|
||||
memberCardTotalTimes?: number
|
||||
status?: string
|
||||
remainingTimes?: number
|
||||
remainingAmount?: number
|
||||
expireTime?: string
|
||||
purchaseTime?: string
|
||||
}
|
||||
|
||||
export interface UpdateMemberDto {
|
||||
nickname?: string
|
||||
gender?: string
|
||||
birthday?: string
|
||||
avatar?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
export interface UpdatePhoneDto {
|
||||
phone: string
|
||||
}
|
||||
|
||||
export const memberApi = {
|
||||
/** 搜索会员 */
|
||||
search: (params: { searchValue?: string; pageNum?: number; pageSize?: number }) =>
|
||||
request.get<Member[]>('/admin/members', { params }),
|
||||
|
||||
/** 获取所有会员 */
|
||||
getAll: (params: { pageNum?: number; pageSize?: number; sortField?: string; sortOrder?: string }) =>
|
||||
request.get<Member[]>('/admin/members/all', { params }),
|
||||
|
||||
/** 获取会员详情 */
|
||||
getDetail: (id: number) =>
|
||||
request.get<MemberDetail>(`/admin/member/${id}`),
|
||||
|
||||
/** 更新会员信息 */
|
||||
update: (id: number, data: UpdateMemberDto) =>
|
||||
request.put<void>(`/admin/member/${id}`, data),
|
||||
|
||||
/** 管理员更新会员手机号 */
|
||||
updatePhone: (id: number, data: UpdatePhoneDto) =>
|
||||
request.post<void>(`/admin/member/${id}/phone`, data),
|
||||
|
||||
/** 获取会员绑定的会员卡记录 */
|
||||
getMemberCardRecords: (memberId: number) =>
|
||||
request.get<MemberCardRecord[]>(`/member-card-records/my-cards-with-status/${memberId}`, {
|
||||
params: { status: 'all' },
|
||||
}),
|
||||
}
|
||||
@@ -117,6 +117,18 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'CourseRecommendManagement',
|
||||
component: () => import('@/views/groupcourse/CourseRecommendManagement.vue'),
|
||||
meta: { title: '团课推荐' }
|
||||
},
|
||||
{
|
||||
path: 'members',
|
||||
name: 'MemberManagement',
|
||||
component: () => import('@/views/member/MemberManagement.vue'),
|
||||
meta: { title: '会员管理' }
|
||||
},
|
||||
{
|
||||
path: 'member-cards',
|
||||
name: 'MemberCardManagement',
|
||||
component: () => import('@/views/member/MemberCardManagement.vue'),
|
||||
meta: { title: '会员卡管理' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
|
||||
'gymCourse/type/index': '/courses/types',
|
||||
'gymCourse/label/index': '/courses/labels',
|
||||
'gymCourse/recommend/index': '/courses/recommend',
|
||||
'member/member/index': '/members',
|
||||
'member/card/index': '/member-cards',
|
||||
}
|
||||
|
||||
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
|
||||
@@ -100,7 +102,9 @@ function getMenuIcon(menuName: string): string {
|
||||
'团课管理': 'TrophyBase',
|
||||
'团课类型': 'Grid',
|
||||
'团课标签': 'Discount',
|
||||
'团课推荐': 'Star'
|
||||
'团课推荐': 'Star',
|
||||
'会员管理': 'Avatar',
|
||||
'会员卡管理': 'CreditCard',
|
||||
}
|
||||
return iconMap[menuName] || 'Document'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
<template>
|
||||
<div class="member-management">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="search-section">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索会员编号/昵称/手机号"
|
||||
clearable
|
||||
style="width: 280px"
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="sort-section">
|
||||
<el-button @click="handleResetSort" :type="sortField === 'createdAt' && sortOrder === 'desc' ? 'primary' : 'default'">
|
||||
<el-icon><Clock /></el-icon>
|
||||
最近注册
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="dataSource"
|
||||
style="width: 100%"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
||||
<el-table-column label="头像" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-avatar v-if="row.avatar" :src="row.avatar" :size="40" />
|
||||
<el-avatar v-else :size="40">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
</el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="memberNo" label="会员编号" width="140" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
{{ row.memberNo || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.nickname || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手机号" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ formatPhone(row.phone) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="性别" width="70">
|
||||
<template #default="{ row }">
|
||||
{{ row.genderDesc || genderMap[row.gender as number] || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="注册时间" width="170" prop="createdAt" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
{{ row.createdAt ? formatDateTime(row.createdAt) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="warning" link size="small" @click="handleEditPhone(row)">
|
||||
修改手机号
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
style="margin-top: 16px; justify-content: flex-end"
|
||||
@current-change="handleTableChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
title="会员详情"
|
||||
width="650px"
|
||||
>
|
||||
<div v-loading="detailLoading" style="min-height: 200px">
|
||||
<el-descriptions v-if="currentDetail" :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会员编号">{{ currentDetail.memberNo || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="昵称">{{ currentDetail.nickname || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ formatPhone(currentDetail.phone) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ currentDetail.genderDesc || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生日">{{ currentDetail.birthday || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址" :span="2">{{ currentDetail.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关注状态">
|
||||
<el-tag :type="currentDetail.subscribed ? 'success' : 'info'" size="small">
|
||||
{{ currentDetail.subscribed ? '已关注' : '未关注' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录">{{ formatDateTime(currentDetail.lastLoginAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatDateTime(currentDetail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="有效会员卡">{{ currentDetail.activeCardCount ?? 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="头像" :span="2">
|
||||
<el-avatar v-if="currentDetail.avatar" :src="currentDetail.avatar" :size="60" />
|
||||
<span v-else>未设置</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 绑定的会员卡记录 -->
|
||||
<div style="margin-top: 20px">
|
||||
<h4 style="margin-bottom: 10px">
|
||||
绑定的会员卡
|
||||
<span v-if="currentDetail?.activeCardCount !== undefined" style="font-weight: normal; font-size: 13px; color: #909399; margin-left: 8px">
|
||||
(有效 {{ currentDetail.activeCardCount }} 张 / 共 {{ (currentDetail.activeCardCount ?? 0) + (currentDetail.inactiveCardCount ?? 0) }} 张)
|
||||
</span>
|
||||
</h4>
|
||||
<el-table v-if="memberCardRecords.length > 0" :data="memberCardRecords" size="small" border>
|
||||
<el-table-column prop="memberCardName" label="卡名称" min-width="120" />
|
||||
<el-table-column label="卡类型" width="90">
|
||||
<template #default="{ row: rec }">
|
||||
{{ cardTypeMap[rec.memberCardType as string] || rec.memberCardType || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余" width="120">
|
||||
<template #default="{ row: rec }">
|
||||
<template v-if="rec.memberCardType === 'TIME_CARD'">
|
||||
{{ formatDateTime(rec.expireTime) }}
|
||||
</template>
|
||||
<template v-else-if="rec.memberCardType === 'COUNT_CARD'">
|
||||
{{ rec.remainingTimes ?? 0 }} 次
|
||||
</template>
|
||||
<template v-else-if="rec.memberCardType === 'STORED_VALUE_CARD'">
|
||||
¥{{ rec.remainingAmount ?? 0 }}
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="购买时间" width="170">
|
||||
<template #default="{ row: rec }">
|
||||
{{ formatDateTime(rec.purchaseTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row: rec }">
|
||||
<el-tag :type="rec.status === 'ACTIVE' ? 'success' : 'info'" size="small">
|
||||
{{ rec.status || '-' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="暂无绑定的会员卡" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
title="编辑会员信息"
|
||||
width="500px"
|
||||
>
|
||||
<div v-loading="editLoading" style="min-height: 200px">
|
||||
<el-form
|
||||
v-if="!editLoading"
|
||||
ref="editFormRef"
|
||||
:model="editForm"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="editForm.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="性别">
|
||||
<el-select v-model="editForm.gender" placeholder="请选择性别" style="width: 100%">
|
||||
<el-option value="MALE" label="男" />
|
||||
<el-option value="FEMALE" label="女" />
|
||||
<el-option value="UNKNOWN" label="未知" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="生日">
|
||||
<el-date-picker
|
||||
v-model="editForm.birthday"
|
||||
type="date"
|
||||
placeholder="选择生日"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="editForm.address" placeholder="请输入地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="头像">
|
||||
<div class="avatar-edit-row">
|
||||
<div class="avatar-upload">
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:http-request="uploadAvatar"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
>
|
||||
<el-avatar v-if="editForm.avatar" :src="editForm.avatar" :size="80" />
|
||||
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<span class="avatar-hint">点击上传</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="editForm.avatar"
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
@click="handleClearAvatar"
|
||||
>
|
||||
删除头像
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleEditSubmit" :loading="editSubmitting" :disabled="editLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改手机号弹窗 -->
|
||||
<el-dialog
|
||||
v-model="phoneVisible"
|
||||
title="修改手机号"
|
||||
width="400px"
|
||||
>
|
||||
<el-form
|
||||
ref="phoneFormRef"
|
||||
:model="phoneForm"
|
||||
:rules="phoneRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="会员ID">
|
||||
<span>{{ currentMemberId }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="新手机号" prop="phone">
|
||||
<el-input v-model="phoneForm.phone" placeholder="请输入新手机号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="phoneVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handlePhoneSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, UserFilled, Plus, Clock } from '@element-plus/icons-vue'
|
||||
import { memberApi, type Member, type MemberDetail, type MemberCardRecord, type UpdateMemberDto } from '@/api/member.api'
|
||||
import request from '@/utils/request'
|
||||
import { formatDateTime } from '@/utils/dateFormat'
|
||||
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<Member[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const sortField = ref<string>('')
|
||||
const sortOrder = ref<string>('')
|
||||
|
||||
const genderMap: Record<number, string> = {
|
||||
0: '未知',
|
||||
1: '男',
|
||||
2: '女',
|
||||
}
|
||||
|
||||
const cardTypeMap: Record<string, string> = {
|
||||
TIME_CARD: '时长卡',
|
||||
COUNT_CARD: '次卡',
|
||||
STORED_VALUE_CARD: '储值卡',
|
||||
}
|
||||
|
||||
/** 格式化手机号显示,null/空时显示"未绑定" */
|
||||
const formatPhone = (phone: string | undefined | null): string => {
|
||||
if (!phone) return '未绑定'
|
||||
return phone
|
||||
}
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// 详情相关
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const currentDetail = ref<MemberDetail | null>(null)
|
||||
const memberCardRecords = ref<MemberCardRecord[]>([])
|
||||
|
||||
// 编辑相关
|
||||
const editVisible = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const editFormRef = ref()
|
||||
const editingMemberId = ref<number>()
|
||||
const editForm = reactive<UpdateMemberDto>({
|
||||
nickname: '',
|
||||
gender: undefined,
|
||||
birthday: '',
|
||||
address: '',
|
||||
avatar: '',
|
||||
})
|
||||
|
||||
// 修改手机号相关
|
||||
const phoneVisible = ref(false)
|
||||
const phoneFormRef = ref()
|
||||
const currentMemberId = ref<number>()
|
||||
const phoneForm = reactive({
|
||||
phone: '',
|
||||
})
|
||||
const phoneRules = {
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
pageNum: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortField: sortField.value || 'createdAt',
|
||||
sortOrder: sortOrder.value || 'desc',
|
||||
}
|
||||
if (searchKeyword.value) {
|
||||
params.searchValue = searchKeyword.value
|
||||
}
|
||||
let res: any
|
||||
if (searchKeyword.value) {
|
||||
res = await memberApi.search(params)
|
||||
} else {
|
||||
res = await memberApi.getAll(params)
|
||||
}
|
||||
console.log('会员列表API返回:', res)
|
||||
|
||||
// 兼容多种返回格式:数组、{content/records/list/data: [...]}
|
||||
let list: any[] = []
|
||||
if (Array.isArray(res)) {
|
||||
list = res
|
||||
} else if (res?.content) {
|
||||
list = res.content
|
||||
} else if (res?.records) {
|
||||
list = res.records
|
||||
} else if (res?.list) {
|
||||
list = res.list
|
||||
} else if (res?.data && Array.isArray(res.data)) {
|
||||
list = res.data
|
||||
}
|
||||
dataSource.value = list
|
||||
|
||||
// 分页总数
|
||||
if (res?.totalElements !== undefined) {
|
||||
pagination.total = Number(res.totalElements)
|
||||
} else if (res?.total !== undefined) {
|
||||
pagination.total = Number(res.total)
|
||||
} else if (Array.isArray(res)) {
|
||||
pagination.total = res.length
|
||||
} else {
|
||||
pagination.total = list.length
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '加载数据失败'
|
||||
ElMessage.error(msg)
|
||||
console.error('加载会员列表失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = () => fetchData()
|
||||
const handleSizeChange = () => {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSortChange = ({ prop, order }: { prop: string; order: string | null }) => {
|
||||
sortField.value = prop || ''
|
||||
sortOrder.value = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleResetSort = () => {
|
||||
sortField.value = 'createdAt'
|
||||
sortOrder.value = 'desc'
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleDetail = async (row: Member) => {
|
||||
console.log('点击详情, row:', row, 'row.id:', row.id, 'type:', typeof row.id)
|
||||
if (!row.id) return
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentDetail.value = null
|
||||
memberCardRecords.value = []
|
||||
try {
|
||||
const detail = await memberApi.getDetail(Number(row.id))
|
||||
console.log('会员详情API返回:', detail)
|
||||
currentDetail.value = detail
|
||||
// 获取绑定的会员卡记录
|
||||
try {
|
||||
const records: any = await memberApi.getMemberCardRecords(Number(row.id))
|
||||
memberCardRecords.value = Array.isArray(records) ? records : (records?.content || records?.data || [])
|
||||
} catch {
|
||||
memberCardRecords.value = []
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '获取会员详情失败'
|
||||
ElMessage.error(msg)
|
||||
console.error('获取会员详情失败:', e)
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (row: Member) => {
|
||||
console.log('点击编辑, row:', row)
|
||||
if (!row.id) return
|
||||
editingMemberId.value = Number(row.id)
|
||||
editVisible.value = true
|
||||
editLoading.value = true
|
||||
// 先重置表单
|
||||
editForm.nickname = ''
|
||||
editForm.gender = undefined
|
||||
editForm.birthday = ''
|
||||
editForm.address = ''
|
||||
editForm.avatar = ''
|
||||
try {
|
||||
const detail = await memberApi.getDetail(Number(row.id))
|
||||
console.log('编辑-会员详情API返回:', detail)
|
||||
editForm.nickname = detail.nickname || ''
|
||||
// genderDesc 为中文"男"/"女",需转为后端枚举值
|
||||
if (detail.genderDesc === '男') {
|
||||
editForm.gender = 'MALE'
|
||||
} else if (detail.genderDesc === '女') {
|
||||
editForm.gender = 'FEMALE'
|
||||
} else {
|
||||
editForm.gender = 'UNKNOWN'
|
||||
}
|
||||
// birthday 可能是 ISO 格式("2026-01-01T00:00:00.000+00:00"),取前10位
|
||||
if (detail.birthday) {
|
||||
const bd = String(detail.birthday)
|
||||
editForm.birthday = bd.length >= 10 ? bd.substring(0, 10) : bd
|
||||
} else {
|
||||
editForm.birthday = ''
|
||||
}
|
||||
editForm.address = detail.address || ''
|
||||
editForm.avatar = detail.avatar || ''
|
||||
console.log('编辑表单数据:', JSON.parse(JSON.stringify(editForm)))
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '获取会员信息失败'
|
||||
ElMessage.error(msg)
|
||||
console.error('获取会员信息失败:', e)
|
||||
editVisible.value = false
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditSubmit = async () => {
|
||||
if (!editingMemberId.value) return
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
const body: UpdateMemberDto = {
|
||||
nickname: editForm.nickname || undefined,
|
||||
gender: editForm.gender || undefined,
|
||||
birthday: editForm.birthday || undefined,
|
||||
address: editForm.address || undefined,
|
||||
avatar: editForm.avatar || undefined,
|
||||
}
|
||||
await memberApi.update(editingMemberId.value, body)
|
||||
ElMessage.success('更新成功')
|
||||
editVisible.value = false
|
||||
fetchData()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '更新失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 头像上传 ==========
|
||||
|
||||
const beforeAvatarUpload = (file: File) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件')
|
||||
return false
|
||||
}
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
if (!isLt5M) {
|
||||
ElMessage.error('图片大小不能超过 5MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const uploadAvatar = async (options: any) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', options.file)
|
||||
try {
|
||||
const res: any = await request.post('/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
if (res && res.id) {
|
||||
// 通过预览接口获取图片URL
|
||||
const blob: any = await request.get(`/files/${res.id}/preview`, { responseType: 'blob' })
|
||||
editForm.avatar = URL.createObjectURL(blob)
|
||||
}
|
||||
ElMessage.success('头像上传成功')
|
||||
} catch {
|
||||
ElMessage.error('头像上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAvatar = () => {
|
||||
editForm.avatar = ''
|
||||
ElMessage.success('头像已清除,保存后生效')
|
||||
}
|
||||
|
||||
// ========== 修改手机号 ==========
|
||||
|
||||
const handleEditPhone = (row: Member) => {
|
||||
currentMemberId.value = Number(row.id)
|
||||
phoneForm.phone = ''
|
||||
phoneVisible.value = true
|
||||
}
|
||||
|
||||
const handlePhoneSubmit = async () => {
|
||||
if (!phoneFormRef.value || !currentMemberId.value) return
|
||||
try {
|
||||
await phoneFormRef.value.validate()
|
||||
await memberApi.updatePhone(currentMemberId.value, { phone: phoneForm.phone })
|
||||
ElMessage.success('手机号更新成功')
|
||||
phoneVisible.value = false
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error?.message !== 'cancel') {
|
||||
const msg = error?.response?.data?.message || error?.message || '手机号更新失败'
|
||||
ElMessage.error(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
.member-management {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.search-section {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sort-section {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-edit-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.avatar-uploader :deep(.el-upload) {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.avatar-uploader :deep(.el-upload:hover) {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.avatar-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.avatar-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user