新增会员卡管理

This commit is contained in:
2026-07-09 15:33:05 +08:00
parent a8e69e0850
commit 8fb4e714d8
8 changed files with 809 additions and 8 deletions
@@ -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 = "逻辑删除会员卡类型")
@@ -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);
@@ -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)
@@ -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));
+107
View File
@@ -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' },
}),
}
+12
View File
@@ -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: '会员卡管理' }
}
]
}
+5 -1
View File
@@ -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>