1029 lines
31 KiB
Vue
1029 lines
31 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import {
|
|
getAllMembers,
|
|
searchMembers,
|
|
getMemberDetail,
|
|
updateMember,
|
|
getActiveCards,
|
|
getMemberCardRecords,
|
|
purchaseCard,
|
|
refundCard,
|
|
verifyAdminPassword,
|
|
type Member,
|
|
type MemberDetail,
|
|
type UpdateMemberDto,
|
|
type MemberCardType,
|
|
type MemberCardRecord,
|
|
} from '@/api/member'
|
|
import AppLayout from '@/components/AppLayout.vue'
|
|
import { useToast } from '@/composables/useToast'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
const toast = useToast()
|
|
const authStore = useAuthStore()
|
|
|
|
// --- 数据 ---
|
|
const members = ref<Member[]>([])
|
|
const loading = ref(false)
|
|
const keyword = ref('')
|
|
|
|
const currentPage = ref(0)
|
|
const pageSize = ref(10)
|
|
|
|
const sortOptions = [
|
|
{ label: '新增用户 ↓', value: 'createdAt_desc' },
|
|
{ label: '新增用户 ↑', value: 'createdAt_asc' },
|
|
{ label: '修改用户 ↓', value: 'updatedAt_desc' },
|
|
{ label: '修改用户 ↑', value: 'updatedAt_asc' },
|
|
{ label: 'ID ↓', value: 'id_desc' },
|
|
{ label: 'ID ↑', value: 'id_asc' },
|
|
]
|
|
const sortBy = ref('createdAt_desc')
|
|
|
|
// --- 详情弹窗 ---
|
|
const showDetailModal = ref(false)
|
|
const detailMember = ref<MemberDetail | null>(null)
|
|
const detailLoading = ref(false)
|
|
|
|
// --- 编辑弹窗 ---
|
|
const showEditModal = ref(false)
|
|
const editId = ref<number | null>(null)
|
|
const saveLoading = ref(false)
|
|
|
|
const editForm = ref<UpdateMemberDto>({
|
|
nickname: '',
|
|
gender: undefined,
|
|
birthday: '',
|
|
avatar: '',
|
|
address: '',
|
|
})
|
|
|
|
// --- 会员卡管理 ---
|
|
const showCardModal = ref(false)
|
|
const cardMember = ref<Member | null>(null)
|
|
const cardMemberRecords = ref<MemberCardRecord[]>([])
|
|
const activeCards = ref<MemberCardType[]>([])
|
|
const cardLoading = ref(false)
|
|
const bindLoading = ref<number | null>(null)
|
|
const unbindLoading = ref<number | null>(null)
|
|
|
|
// --- 密码验证 ---
|
|
const showPasswordModal = ref(false)
|
|
const passwordInput = ref('')
|
|
const passwordError = ref('')
|
|
const passwordLoading = ref(false)
|
|
let passwordResolve: ((ok: boolean) => void) | null = null
|
|
|
|
// --- 确认弹窗 ---
|
|
const showConfirmModal = ref(false)
|
|
const confirmMessage = ref('')
|
|
let confirmResolve: ((ok: boolean) => void) | null = null
|
|
|
|
const genderMap: Record<number, string> = {
|
|
0: '未知',
|
|
1: '男',
|
|
2: '女',
|
|
}
|
|
|
|
function getGenderLabel(gender: number): string {
|
|
return genderMap[gender] || '未知'
|
|
}
|
|
|
|
function formatTime(t: string | null): string {
|
|
if (!t) return '--'
|
|
return new Date(t).toLocaleString('zh-CN')
|
|
}
|
|
|
|
function formatDate(d: string | null): string {
|
|
if (!d) return '--'
|
|
return d
|
|
}
|
|
|
|
// --- 分页 ---
|
|
const totalElements = computed(() => members.value.length)
|
|
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalElements.value / pageSize.value)))
|
|
|
|
const pagedMembers = computed(() => {
|
|
const sorted = [...members.value].sort((a, b) => {
|
|
switch (sortBy.value) {
|
|
case 'id_asc': return (a.id ?? 0) - (b.id ?? 0)
|
|
case 'id_desc': return (b.id ?? 0) - (a.id ?? 0)
|
|
case 'createdAt_asc': return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()
|
|
case 'createdAt_desc': return new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()
|
|
case 'updatedAt_asc': return new Date(a.updatedAt || 0).getTime() - new Date(b.updatedAt || 0).getTime()
|
|
case 'updatedAt_desc': return new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime()
|
|
default: return 0
|
|
}
|
|
})
|
|
const start = currentPage.value * pageSize.value
|
|
return sorted.slice(start, start + pageSize.value)
|
|
})
|
|
|
|
function changePage(page: number) {
|
|
if (page >= 0 && page < totalPages.value) {
|
|
currentPage.value = page
|
|
}
|
|
}
|
|
|
|
// --- 搜索 ---
|
|
async function fetchMembers() {
|
|
loading.value = true
|
|
try {
|
|
const keywordTrimmed = keyword.value.trim()
|
|
if (keywordTrimmed) {
|
|
const list = await searchMembers(keywordTrimmed, 1, 200)
|
|
members.value = list
|
|
} else {
|
|
const list = await getAllMembers(1, 200)
|
|
members.value = list
|
|
}
|
|
currentPage.value = 0
|
|
} catch {
|
|
members.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function handleSearch() {
|
|
fetchMembers()
|
|
}
|
|
|
|
// --- 查看详情 ---
|
|
async function handleViewDetail(member: Member) {
|
|
detailLoading.value = true
|
|
showDetailModal.value = true
|
|
detailMember.value = null
|
|
try {
|
|
const detail = await getMemberDetail(member.id)
|
|
detailMember.value = detail
|
|
} catch {
|
|
toast.error('获取会员详情失败')
|
|
showDetailModal.value = false
|
|
} finally {
|
|
detailLoading.value = false
|
|
}
|
|
}
|
|
|
|
// --- 编辑 ---
|
|
function handleEdit(member: Member) {
|
|
editId.value = member.id
|
|
editForm.value = {
|
|
nickname: member.nickname,
|
|
gender: member.gender,
|
|
birthday: member.birthday || '',
|
|
avatar: member.avatar || '',
|
|
address: member.address || '',
|
|
}
|
|
showEditModal.value = true
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!editId.value) return
|
|
saveLoading.value = true
|
|
try {
|
|
await updateMember(editId.value, editForm.value)
|
|
toast.success('会员信息更新成功')
|
|
showEditModal.value = false
|
|
fetchMembers()
|
|
} catch {
|
|
toast.error('更新失败')
|
|
} finally {
|
|
saveLoading.value = false
|
|
}
|
|
}
|
|
|
|
// --- 会员卡管理 ---
|
|
async function openCardModal(member: Member) {
|
|
cardMember.value = member
|
|
showCardModal.value = true
|
|
cardLoading.value = true
|
|
try {
|
|
const [cards, records] = await Promise.all([
|
|
getActiveCards(),
|
|
getMemberCardRecords(member.id),
|
|
])
|
|
activeCards.value = cards
|
|
cardMemberRecords.value = records
|
|
} catch {
|
|
toast.error('获取会员卡信息失败')
|
|
} finally {
|
|
cardLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function requirePassword(): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
passwordInput.value = ''
|
|
passwordError.value = ''
|
|
passwordResolve = resolve
|
|
showPasswordModal.value = true
|
|
})
|
|
}
|
|
|
|
async function confirmPassword() {
|
|
if (!passwordInput.value) {
|
|
passwordError.value = '请输入密码'
|
|
return
|
|
}
|
|
passwordLoading.value = true
|
|
passwordError.value = ''
|
|
try {
|
|
const ok = await verifyAdminPassword(passwordInput.value)
|
|
if (ok) {
|
|
showPasswordModal.value = false
|
|
passwordResolve?.(true)
|
|
} else {
|
|
passwordError.value = '密码错误'
|
|
}
|
|
} catch {
|
|
passwordError.value = '验证失败'
|
|
} finally {
|
|
passwordLoading.value = false
|
|
}
|
|
}
|
|
|
|
function cancelPassword() {
|
|
showPasswordModal.value = false
|
|
passwordResolve?.(false)
|
|
}
|
|
|
|
async function requireConfirm(message: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
confirmMessage.value = message
|
|
confirmResolve = resolve
|
|
showConfirmModal.value = true
|
|
})
|
|
}
|
|
|
|
function onConfirmYes() {
|
|
showConfirmModal.value = false
|
|
confirmResolve?.(true)
|
|
}
|
|
|
|
function onConfirmNo() {
|
|
showConfirmModal.value = false
|
|
confirmResolve?.(false)
|
|
}
|
|
|
|
async function handleBindCard(cardType: MemberCardType) {
|
|
if (!cardMember.value) return
|
|
const ok = await requirePassword()
|
|
if (!ok) return
|
|
bindLoading.value = cardType.memberCardId
|
|
try {
|
|
await purchaseCard(cardMember.value.id, cardType.memberCardId)
|
|
toast.success(`成功绑定: ${cardType.memberCardName}`)
|
|
// 刷新卡记录
|
|
const records = await getMemberCardRecords(cardMember.value.id)
|
|
cardMemberRecords.value = records
|
|
} catch {
|
|
toast.error('绑定失败')
|
|
} finally {
|
|
bindLoading.value = null
|
|
}
|
|
}
|
|
|
|
async function handleUnbindCard(record: MemberCardRecord) {
|
|
const confirmed = await requireConfirm('确定要解绑该会员卡吗?')
|
|
if (!confirmed) return
|
|
const ok = await requirePassword()
|
|
if (!ok) return
|
|
unbindLoading.value = record.memberCardRecordId
|
|
try {
|
|
await refundCard(record.memberCardRecordId)
|
|
toast.success('解绑成功')
|
|
// 刷新卡记录
|
|
if (cardMember.value) {
|
|
const records = await getMemberCardRecords(cardMember.value.id)
|
|
cardMemberRecords.value = records
|
|
}
|
|
} catch {
|
|
toast.error('解绑失败')
|
|
} finally {
|
|
unbindLoading.value = null
|
|
}
|
|
}
|
|
|
|
function getCardTypeLabel(type: string): string {
|
|
const map: Record<string, string> = {
|
|
TIME_CARD: '时长卡',
|
|
COUNT_CARD: '次卡',
|
|
STORED_VALUE_CARD: '储值卡',
|
|
}
|
|
return map[type] || type
|
|
}
|
|
|
|
function getCardStatusDesc(status: string): string {
|
|
const map: Record<string, string> = {
|
|
ACTIVE: '有效',
|
|
USED_UP: '已用完',
|
|
EXPIRED: '已过期',
|
|
REFUNDED: '已退款',
|
|
}
|
|
return map[status] || status
|
|
}
|
|
|
|
function getCardNameByRecord(record: MemberCardRecord): string {
|
|
const card = activeCards.value.find(c => c.memberCardId === record.memberCardId)
|
|
return card?.memberCardName || `卡#${record.memberCardId}`
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchMembers()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout>
|
|
<!-- 工具栏 -->
|
|
<div class="toolbar">
|
|
<div class="search-row">
|
|
<input
|
|
v-model="keyword"
|
|
type="text"
|
|
placeholder="搜索会员编号、昵称或手机号..."
|
|
class="search-input"
|
|
@keyup.enter="handleSearch"
|
|
/>
|
|
<button class="btn btn-outline" @click="handleSearch">
|
|
<i class="fas fa-search"></i> 搜索
|
|
</button>
|
|
<select v-model="sortBy" class="sort-select" @change="currentPage = 0">
|
|
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
|
|
{{ opt.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 表格 -->
|
|
<div class="table-wrapper">
|
|
<div class="table-header">
|
|
<h3>会员列表 ({{ totalElements }} 条)</h3>
|
|
</div>
|
|
|
|
<!-- skeleton -->
|
|
<table v-if="loading">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th><th>会员编号</th><th>昵称</th><th>手机号</th>
|
|
<th>性别</th><th>生日</th><th>注册时间</th><th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="i in 5" :key="'sk-' + i">
|
|
<td v-for="j in 8" :key="j">
|
|
<span class="sk-td skeleton"></span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- real table -->
|
|
<table v-else>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th><th>会员编号</th><th>昵称</th><th>手机号</th>
|
|
<th>性别</th><th>生日</th><th>注册时间</th><th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="m in pagedMembers" :key="m.id">
|
|
<td>{{ m.id }}</td>
|
|
<td>{{ m.memberNo }}</td>
|
|
<td>{{ m.nickname }}</td>
|
|
<td>{{ m.phone }}</td>
|
|
<td>{{ getGenderLabel(m.gender) }}</td>
|
|
<td>{{ formatDate(m.birthday) }}</td>
|
|
<td>{{ formatTime(m.createdAt) }}</td>
|
|
<td class="actions">
|
|
<button class="btn-sm" @click="handleViewDetail(m)">详情</button>
|
|
<button class="btn-sm" @click="handleEdit(m)" v-if="authStore.hasPermission('business:member:edit')">编辑</button>
|
|
<button class="btn-sm btn-card" @click="openCardModal(m)">卡片</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="pagedMembers.length === 0">
|
|
<td colspan="8" class="empty">暂无会员数据</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- pagination -->
|
|
<div class="pagination" v-if="totalPages > 1">
|
|
<button :disabled="currentPage === 0" @click="changePage(currentPage - 1)">上一页</button>
|
|
<span>第 {{ currentPage + 1 }} / {{ totalPages }} 页</span>
|
|
<button :disabled="currentPage >= totalPages - 1" @click="changePage(currentPage + 1)">下一页</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 详情弹窗 -->
|
|
<div v-if="showDetailModal" class="modal-overlay" @click.self="showDetailModal = false">
|
|
<div class="modal modal-detail">
|
|
<h3>会员详情</h3>
|
|
<div v-if="detailLoading" class="loading-center">
|
|
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
|
</div>
|
|
<div v-else-if="detailMember" class="detail-grid">
|
|
<div class="detail-item">
|
|
<span class="detail-label">会员编号</span>
|
|
<span class="detail-value">{{ detailMember.memberNo }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">昵称</span>
|
|
<span class="detail-value">{{ detailMember.nickname }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">手机号</span>
|
|
<span class="detail-value">{{ detailMember.phone }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">性别</span>
|
|
<span class="detail-value">{{ detailMember.genderDesc }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">生日</span>
|
|
<span class="detail-value">{{ formatDate(detailMember.birthday) }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">地址</span>
|
|
<span class="detail-value">{{ detailMember.address || '--' }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">头像</span>
|
|
<span class="detail-value">
|
|
<img v-if="detailMember.avatar" :src="detailMember.avatar" class="avatar-preview" />
|
|
<span v-else>--</span>
|
|
</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">注册时间</span>
|
|
<span class="detail-value">{{ formatTime(detailMember.createdAt) }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">最后登录</span>
|
|
<span class="detail-value">{{ formatTime(detailMember.lastLoginAt) }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">关注服务号</span>
|
|
<span class="detail-value">
|
|
<span :class="detailMember.subscribed ? 'badge-green' : 'badge-gray'">
|
|
{{ detailMember.subscribed ? '已关注' : '未关注' }}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- 会员卡信息 -->
|
|
<div class="detail-section">
|
|
<span class="detail-label">会员卡</span>
|
|
<div class="detail-value">
|
|
<span v-if="detailMember.memberCards.length === 0">暂无会员卡</span>
|
|
<div v-else class="card-list">
|
|
<div
|
|
v-for="card in detailMember.memberCards"
|
|
:key="card.id"
|
|
class="card-item"
|
|
>
|
|
<span class="card-name">{{ card.memberCardName }}</span>
|
|
<span class="card-remain">
|
|
<template v-if="card.remainingTimes > 0">{{ card.remainingTimes }}/{{ card.memberCardTotalTimes }}次</template>
|
|
<template v-else-if="card.remainingAmount > 0">¥{{ card.remainingAmount }}/¥{{ card.memberCardAmount }}</template>
|
|
<template v-else-if="card.expireTime">到期 {{ formatTime(card.expireTime) }}</template>
|
|
</span>
|
|
<span class="card-status" :class="card.status === 'ACTIVE' ? 'badge-green' : 'badge-gray'">
|
|
{{ card.statusDesc }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" @click="showDetailModal = false">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 编辑弹窗 -->
|
|
<div v-if="showEditModal" class="modal-overlay" @click.self="showEditModal = false">
|
|
<div class="modal">
|
|
<h3>编辑会员信息</h3>
|
|
<div class="form-group">
|
|
<label>昵称</label>
|
|
<input v-model="editForm.nickname" placeholder="请输入昵称" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>性别</label>
|
|
<select v-model.number="editForm.gender" class="form-select">
|
|
<option :value="undefined" disabled>请选择性别</option>
|
|
<option :value="0">未知</option>
|
|
<option :value="1">男</option>
|
|
<option :value="2">女</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>生日</label>
|
|
<input v-model="editForm.birthday" type="date" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>头像 URL</label>
|
|
<input v-model="editForm.avatar" placeholder="请输入头像URL" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>地址</label>
|
|
<input v-model="editForm.address" placeholder="请输入地址" />
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" @click="showEditModal = false">取消</button>
|
|
<button class="btn btn-primary" @click="handleSave" :disabled="saveLoading">
|
|
<i v-if="saveLoading" class="fas fa-spinner fa-spin"></i>
|
|
{{ saveLoading ? '保存中...' : '保存' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 会员卡管理弹窗 -->
|
|
<div v-if="showCardModal" class="modal-overlay" @click.self="showCardModal = false">
|
|
<div class="modal modal-card">
|
|
<h3>会员卡管理 - {{ cardMember?.nickname || cardMember?.memberNo }}</h3>
|
|
|
|
<div v-if="cardLoading" class="loading-center">
|
|
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
|
</div>
|
|
<template v-else>
|
|
<!-- 已绑定的卡 -->
|
|
<div class="card-section">
|
|
<h4>已绑定卡片</h4>
|
|
<div v-if="cardMemberRecords.length === 0" class="empty-text">暂无绑定卡片</div>
|
|
<div v-else class="card-record-list">
|
|
<div
|
|
v-for="record in cardMemberRecords"
|
|
:key="record.id"
|
|
class="card-record-item"
|
|
>
|
|
<div class="card-record-info">
|
|
<span class="card-name">{{ getCardNameByRecord(record) }}</span>
|
|
<span class="card-badge" :class="record.status === 'ACTIVE' ? 'badge-green' : 'badge-gray'">
|
|
{{ getCardStatusDesc(record.status) }}
|
|
</span>
|
|
</div>
|
|
<div class="card-record-detail">
|
|
<span v-if="record.remainingTimes > 0">剩余: {{ record.remainingTimes }}次</span>
|
|
<span v-if="record.remainingAmount > 0">余额: ¥{{ record.remainingAmount }}</span>
|
|
<span v-if="record.expireTime">到期: {{ formatTime(record.expireTime) }}</span>
|
|
<span class="card-purchase-time">购买: {{ formatTime(record.purchaseTime) }}</span>
|
|
</div>
|
|
<div class="card-record-actions">
|
|
<button
|
|
v-if="record.status !== 'REFUNDED' && authStore.hasPermission('business:memberCard:refund')"
|
|
class="btn-sm btn-danger"
|
|
:disabled="unbindLoading === record.memberCardRecordId"
|
|
@click="handleUnbindCard(record)"
|
|
>
|
|
<i v-if="unbindLoading === record.memberCardRecordId" class="fas fa-spinner fa-spin"></i>
|
|
解绑
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 可绑定的卡片类型 -->
|
|
<div class="card-section">
|
|
<h4>可绑定卡片</h4>
|
|
<div v-if="activeCards.length === 0" class="empty-text">暂无可用卡片类型</div>
|
|
<div v-else class="card-type-grid">
|
|
<div
|
|
v-for="card in activeCards"
|
|
:key="card.id"
|
|
class="card-type-item"
|
|
>
|
|
<div class="card-type-header">
|
|
<span class="card-type-name">{{ card.memberCardName }}</span>
|
|
<span class="card-type-tag">{{ getCardTypeLabel(card.memberCardType) }}</span>
|
|
</div>
|
|
<div class="card-type-detail">
|
|
<span v-if="card.memberCardPrice">¥{{ card.memberCardPrice }}</span>
|
|
<span v-if="card.memberCardValidityDays">{{ card.memberCardValidityDays }}天</span>
|
|
<span v-if="card.memberCardTotalTimes">{{ card.memberCardTotalTimes }}次</span>
|
|
<span v-if="card.memberCardAmount">面额 ¥{{ card.memberCardAmount }}</span>
|
|
</div>
|
|
<button
|
|
class="btn-sm btn-primary-sm"
|
|
:disabled="bindLoading === card.memberCardId || !authStore.hasPermission('business:memberCard:purchase')"
|
|
@click="handleBindCard(card)"
|
|
>
|
|
<i v-if="bindLoading === card.memberCardId" class="fas fa-spinner fa-spin"></i>
|
|
绑定
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" @click="showCardModal = false">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 密码验证弹窗 -->
|
|
<div v-if="showPasswordModal" class="modal-overlay" @click.self="cancelPassword">
|
|
<div class="modal modal-password">
|
|
<h3>管理员验证</h3>
|
|
<p class="password-hint">绑定/解绑会员卡需要验证管理员密码</p>
|
|
<div class="form-group">
|
|
<label>密码</label>
|
|
<input
|
|
v-model="passwordInput"
|
|
type="password"
|
|
placeholder="请输入管理员密码"
|
|
@keyup.enter="confirmPassword"
|
|
/>
|
|
<span v-if="passwordError" class="form-error">{{ passwordError }}</span>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" @click="cancelPassword" :disabled="passwordLoading">取消</button>
|
|
<button class="btn btn-primary" @click="confirmPassword" :disabled="passwordLoading">
|
|
<i v-if="passwordLoading" class="fas fa-spinner fa-spin"></i>
|
|
{{ passwordLoading ? '验证中...' : '确认' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 确认弹窗 -->
|
|
<div v-if="showConfirmModal" class="modal-overlay" @click.self="onConfirmNo">
|
|
<div class="modal modal-confirm">
|
|
<h3>操作确认</h3>
|
|
<p class="confirm-text">{{ confirmMessage }}</p>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" @click="onConfirmNo">取消</button>
|
|
<button class="btn btn-danger-filled" @click="onConfirmYes">确定</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.toolbar { margin-bottom: 20px; }
|
|
|
|
.search-row {
|
|
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
|
}
|
|
|
|
.search-input {
|
|
padding: 9px 16px; border-radius: 12px; border: 1px solid #e2e8f0;
|
|
font-size: 14px; width: 300px; outline: none; transition: 0.15s;
|
|
}
|
|
|
|
.search-input:focus {
|
|
border-color: #4f7cff; box-shadow: 0 0 0 3px rgba(79,124,255,0.12);
|
|
}
|
|
|
|
.sort-select {
|
|
padding: 9px 14px; border-radius: 12px; border: 1px solid #e2e8f0;
|
|
font-size: 13px; color: #1e293b; background: white; outline: none;
|
|
cursor: pointer; transition: 0.15s; min-width: 150px;
|
|
}
|
|
|
|
.sort-select:focus {
|
|
border-color: #4f7cff; box-shadow: 0 0 0 3px rgba(79,124,255,0.12);
|
|
}
|
|
|
|
.btn {
|
|
padding: 9px 20px; border-radius: 40px; border: 1px solid #e2e8f0;
|
|
background: white; font-weight: 500; font-size: 13px; color: #1e293b;
|
|
display: inline-flex; align-items: center; gap: 8px; cursor: pointer; transition: 0.15s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #4f7cff; border-color: #4f7cff; color: white;
|
|
box-shadow: 0 4px 10px rgba(79,124,255,0.25);
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) { background: #3f6ae0; }
|
|
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
.btn-outline:hover { background: #f0f5ff; }
|
|
|
|
.table-wrapper {
|
|
background: white; border-radius: 20px; border: 1px solid #edf2f9;
|
|
padding: 20px 24px 8px 24px;
|
|
}
|
|
|
|
.table-header {
|
|
display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;
|
|
}
|
|
|
|
.table-header h3 {
|
|
font-weight: 600; font-size: 16px; color: #0b1a33;
|
|
}
|
|
|
|
table {
|
|
width: 100%; border-collapse: collapse; font-size: 14px;
|
|
}
|
|
|
|
th {
|
|
text-align: left; padding: 12px 8px 12px 0;
|
|
font-weight: 600; color: #4e627c; border-bottom: 1px solid #eef2f8;
|
|
}
|
|
|
|
td {
|
|
padding: 14px 8px 14px 0; border-bottom: 1px solid #f1f5fa; color: #1e2f44;
|
|
}
|
|
|
|
tr:last-child td { border-bottom: none; }
|
|
|
|
td.empty {
|
|
text-align: center; padding: 40px; color: #8b9ab0;
|
|
}
|
|
|
|
.skeleton {
|
|
background: linear-gradient(90deg, #eef2f8 25%, #e0e7f0 50%, #eef2f8 75%);
|
|
background-size: 200% 100%;
|
|
animation: shimmer 1.5s infinite;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.sk-td {
|
|
display: block;
|
|
height: 16px;
|
|
width: 80%;
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% { background-position: -200% 0; }
|
|
100% { background-position: 200% 0; }
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(6px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
tr { animation: fadeIn 0.35s ease; }
|
|
|
|
.actions { white-space: nowrap; }
|
|
|
|
.btn-sm {
|
|
padding: 5px 12px; border-radius: 20px; border: 1px solid #e2e8f0;
|
|
background: white; font-size: 12px; cursor: pointer; margin-right: 6px; transition: 0.15s;
|
|
}
|
|
|
|
.btn-sm:hover { background: #f0f5ff; }
|
|
|
|
.pagination {
|
|
display: flex; justify-content: center; align-items: center;
|
|
gap: 16px; padding: 16px 0; font-size: 13px; color: #4e627c;
|
|
}
|
|
|
|
.pagination button {
|
|
padding: 6px 16px; border-radius: 8px; border: 1px solid #e2e8f0;
|
|
background: white; cursor: pointer; font-size: 13px; transition: 0.15s;
|
|
}
|
|
|
|
.pagination button:hover:not(:disabled) { background: #f0f5ff; }
|
|
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
|
/* ---- modal ---- */
|
|
.modal-overlay {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,0.3);
|
|
display: flex; align-items: center; justify-content: center; z-index: 200;
|
|
animation: fadeIn 0.15s ease;
|
|
}
|
|
|
|
.modal {
|
|
background: white; border-radius: 20px; padding: 28px;
|
|
width: 560px; max-width: 92vw; max-height: 85vh; overflow-y: auto;
|
|
box-shadow: 0 12px 40px rgba(0,0,0,0.12);
|
|
animation: fadeIn 0.2s ease;
|
|
}
|
|
|
|
.modal-detail { width: 620px; }
|
|
|
|
.modal h3 {
|
|
font-size: 18px; font-weight: 600; color: #0b1a33; margin-bottom: 20px;
|
|
}
|
|
|
|
.loading-center {
|
|
text-align: center; padding: 40px; color: #8b9ab0; font-size: 14px;
|
|
}
|
|
|
|
.detail-grid {
|
|
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
|
}
|
|
|
|
.detail-item {
|
|
display: flex; flex-direction: column; gap: 4px;
|
|
}
|
|
|
|
.detail-label {
|
|
font-size: 12px; font-weight: 600; color: #8b9ab0; text-transform: uppercase;
|
|
}
|
|
|
|
.detail-value {
|
|
font-size: 15px; color: #1e293b; font-weight: 500;
|
|
}
|
|
|
|
.detail-section {
|
|
grid-column: 1 / -1;
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
margin-top: 8px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid #eef2f8;
|
|
}
|
|
|
|
.avatar-preview {
|
|
width: 48px; height: 48px; border-radius: 50%; object-fit: cover;
|
|
border: 2px solid #e2e8f0;
|
|
}
|
|
|
|
.badge-green {
|
|
display: inline-block;
|
|
padding: 2px 10px; border-radius: 20px; font-size: 12px;
|
|
background: #e0f5ec; color: #0b8b5e; font-weight: 500;
|
|
}
|
|
|
|
.badge-gray {
|
|
display: inline-block;
|
|
padding: 2px 10px; border-radius: 20px; font-size: 12px;
|
|
background: #f1f5f9; color: #64748b; font-weight: 500;
|
|
}
|
|
|
|
.card-list {
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
}
|
|
|
|
.card-item {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 10px 14px; background: #f8fafd; border-radius: 10px;
|
|
border: 1px solid #eef2f8;
|
|
}
|
|
|
|
.card-name {
|
|
flex: 1; font-weight: 500; color: #1e293b;
|
|
}
|
|
|
|
.card-remain {
|
|
font-size: 13px; color: #4e627c;
|
|
}
|
|
|
|
.card-status {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.form-group { margin-bottom: 14px; }
|
|
.form-group label {
|
|
display: block; font-size: 13px; font-weight: 600; color: #1e293b; margin-bottom: 4px;
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group select {
|
|
width: 100%; padding: 9px 12px; border-radius: 10px;
|
|
border: 1px solid #e2e8f0; font-size: 14px; outline: none;
|
|
transition: 0.15s; background: #f8fafd; font-family: inherit;
|
|
}
|
|
.form-group input:focus,
|
|
.form-group select:focus {
|
|
border-color: #4f7cff; box-shadow: 0 0 0 3px rgba(79,124,255,0.12);
|
|
}
|
|
|
|
.form-select { appearance: auto; }
|
|
|
|
.modal-actions {
|
|
display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;
|
|
}
|
|
|
|
/* ---- card management ---- */
|
|
.modal-card {
|
|
width: 680px;
|
|
}
|
|
|
|
.card-section {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card-section h4 {
|
|
font-size: 14px; font-weight: 600; color: #1e293b;
|
|
margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #edf2f9;
|
|
}
|
|
|
|
.empty-text {
|
|
color: #8b9ab0; font-size: 13px; padding: 12px 0;
|
|
}
|
|
|
|
.card-record-list {
|
|
display: flex; flex-direction: column; gap: 10px;
|
|
}
|
|
|
|
.card-record-item {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 12px 14px; border-radius: 12px; border: 1px solid #eef2f8;
|
|
background: #f9fafc; flex-wrap: wrap; gap: 8px;
|
|
}
|
|
|
|
.card-record-info {
|
|
display: flex; align-items: center; gap: 10px;
|
|
}
|
|
|
|
.card-record-info .card-name {
|
|
font-weight: 600; font-size: 13px; color: #1e293b;
|
|
}
|
|
|
|
.card-record-detail {
|
|
display: flex; gap: 12px; font-size: 12px; color: #4e627c; flex-wrap: wrap;
|
|
}
|
|
|
|
.card-purchase-time {
|
|
color: #8b9ab0;
|
|
}
|
|
|
|
.card-record-actions {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.card-type-grid {
|
|
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
|
|
}
|
|
|
|
.card-type-item {
|
|
padding: 12px; border-radius: 12px; border: 1px solid #eef2f8;
|
|
background: #f9fafc; display: flex; flex-direction: column; gap: 8px;
|
|
}
|
|
|
|
.card-type-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
}
|
|
|
|
.card-type-name {
|
|
font-weight: 600; font-size: 13px; color: #1e293b;
|
|
}
|
|
|
|
.card-type-tag {
|
|
font-size: 11px; padding: 2px 8px; border-radius: 20px;
|
|
background: #e0e7ff; color: #4f7cff; font-weight: 500;
|
|
}
|
|
|
|
.card-type-detail {
|
|
font-size: 12px; color: #4e627c; display: flex; gap: 8px; flex-wrap: wrap;
|
|
}
|
|
|
|
.btn-primary-sm {
|
|
padding: 5px 14px; border-radius: 20px; border: none;
|
|
background: #4f7cff; color: white; font-size: 12px; cursor: pointer;
|
|
transition: 0.15s; align-self: flex-end;
|
|
}
|
|
|
|
.btn-primary-sm:hover:not(:disabled) { background: #3f6ae0; }
|
|
.btn-primary-sm:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
|
|
.btn-danger {
|
|
margin-right: 0;
|
|
border-color: #fecaca; color: #dc2626;
|
|
}
|
|
|
|
.btn-danger:hover:not(:disabled) { background: #fef2f2; }
|
|
.btn-danger:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
|
|
.btn-card {
|
|
border-color: #d1e7dd; color: #0f5132;
|
|
}
|
|
|
|
.btn-card:hover { background: #f0faf5; }
|
|
|
|
/* ---- password modal ---- */
|
|
.modal-password {
|
|
width: 400px;
|
|
}
|
|
|
|
.password-hint {
|
|
font-size: 13px; color: #4e627c; margin: -8px 0 8px 0;
|
|
}
|
|
|
|
.form-error {
|
|
display: block; font-size: 12px; color: #dc2626; margin-top: 4px;
|
|
}
|
|
|
|
/* ---- confirm modal ---- */
|
|
.modal-confirm {
|
|
width: 380px;
|
|
}
|
|
|
|
.confirm-text {
|
|
font-size: 14px; color: #4e627c; margin: 12px 0;
|
|
}
|
|
|
|
.btn-danger-filled {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 8px 20px; border-radius: 6px; font-size: 14px;
|
|
border: none; cursor: pointer; transition: background 0.2s;
|
|
background: #dc2626; color: #fff;
|
|
}
|
|
.btn-danger-filled:hover { background: #b91c1c; }
|
|
</style>
|