Files
gym-manage/gym-manage-cuit/src/views/MemberManage.vue
T

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>