Files
BYW-VoiceHub/app/components/Admin/CardCodesManager.vue
T

823 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 class="text-lg font-black">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1 font-medium">
{{ locale.desc }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
class="flex items-center gap-2 px-4 py-2 rounded-xl border border-border-secondary bg-bg-secondary text-text-secondary text-xs font-bold hover:border-border-tertiary transition-all"
@click="refreshAll"
>
<RefreshCw :size="14" /> {{ locale.refresh }}
</button>
<button
class="flex items-center gap-2 px-4 py-2 rounded-xl border border-border-secondary bg-bg-secondary text-text-secondary text-xs font-bold hover:border-border-tertiary transition-all"
:disabled="exporting"
@click="exportCodes"
>
<Download :size="14" /> {{ exporting ? locale.exporting : exportButtonText }}
</button>
</div>
</div>
<div class="grid grid-cols-2 xl:grid-cols-4 gap-3">
<div v-for="item in stats" :key="item.label" class="rounded-2xl border border-border-secondary bg-bg-secondary-50 p-4">
<p class="text-[10px] font-black uppercase tracking-[0.24em] text-text-tertiary">{{ item.label }}</p>
<div class="mt-2 flex items-end justify-between gap-2">
<span class="text-2xl font-black text-text-primary">{{ item.value }}</span>
<span :class="['text-[10px] font-bold px-2 py-1 rounded-full', item.badgeClass]">{{ item.hint }}</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] gap-6">
<section class="min-w-0 rounded-2xl border border-border-secondary bg-bg-secondary-40 p-5 space-y-5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-1 flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled" />
<input
v-model="filters.q"
type="text"
:placeholder="locale.searchPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-xl pl-9 pr-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40 transition-all"
@keyup.enter="fetchCodes(1)"
>
</div>
<CustomSelect
v-model="filters.status"
:label="locale.status"
:options="statusFilterOptions"
label-key="label"
value-key="value"
:placeholder="locale.allStatus"
class-name="min-w-[160px] sm:w-40"
@change="fetchCodes(1)"
/>
</div>
<button
class="text-xs font-bold text-text-tertiary hover:text-text-primary transition-colors"
@click="resetFilters"
>
{{ locale.clearFilters }}
</button>
</div>
<div v-if="selectedIds.length" class="flex flex-wrap items-center gap-2 rounded-xl border border-primary-20 bg-primary-5 p-3">
<span class="text-xs font-black text-primary">{{ getLocaleMessage('selectedItems', selectedIds.length) }}</span>
<CustomSelect
v-model="bulkStatus"
:options="bulkStatusOptions"
label-key="label"
value-key="value"
class-name="w-32"
/>
<button
class="px-3 py-2 rounded-lg bg-primary-hover text-text-primary text-xs font-bold hover:bg-primary transition-colors"
:disabled="saving"
@click="applyBulkStatus"
>
{{ locale.applyBulk }}
</button>
<button class="px-3 py-2 rounded-lg bg-bg-secondary text-text-secondary text-xs font-bold border border-border-secondary" @click="clearSelection">
{{ locale.cancelSelection }}
</button>
</div>
<div class="overflow-x-auto rounded-2xl border border-border-secondary">
<div v-if="loading" class="flex min-w-[880px] items-center justify-center bg-bg-primary-60 p-8 text-center text-sm text-text-tertiary">
{{ locale.loading }}
</div>
<table v-else class="min-w-[880px] table-fixed text-left text-sm">
<thead class="bg-bg-primary-80 text-text-tertiary">
<tr>
<th class="px-3 py-3 w-10">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-tertiary bg-bg-secondary text-primary"
:checked="allVisibleSelected"
:indeterminate="someVisibleSelected && !allVisibleSelected"
@change="toggleSelectAll"
>
</th>
<th class="px-3 py-3 w-16">ID</th>
<th class="px-3 py-3 w-64">{{ locale.cardCode }}</th>
<th class="px-3 py-3 w-28">{{ locale.status }}</th>
<th class="px-3 py-3 w-60">{{ locale.note }}</th>
<th class="px-3 py-3 w-40">{{ locale.time }}</th>
<th class="px-3 py-3 w-44">{{ locale.actions }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in codes" :key="item.id" class="border-t border-border-secondary-80 hover:bg-bg-primary-70 transition-colors">
<td class="px-3 py-3 align-top">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-tertiary bg-bg-secondary text-primary"
:checked="selectedIds.includes(item.id)"
@change="toggleSelect(item.id)"
>
</td>
<td class="px-3 py-3 align-top text-text-tertiary">{{ item.id }}</td>
<td class="px-3 py-3 align-top font-mono text-text-primary">
<div class="flex max-w-full items-center gap-2 rounded-xl border border-border-secondary bg-bg-primary px-3 py-2">
<span class="block min-w-0 flex-1 truncate text-xs tracking-[0.08em]" :title="item.code">{{ item.code }}</span>
<button class="shrink-0 text-text-tertiary hover:text-text-primary transition-colors" :title="locale.copy" @click="copyCode(item.code)">
<Copy :size="14" />
</button>
</div>
</td>
<td class="px-3 py-3 align-top">
<span :class="['inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-black tracking-[0.2em]', statusMeta(item.status).class]">
{{ statusMeta(item.status).label }}
</span>
<p v-if="item.lockedAt || item.redeemedAt" class="mt-1 text-[10px] text-text-tertiary leading-relaxed">
<span v-if="item.lockedAt">{{ getLocaleMessage('lockedAt', formatDate(item.lockedAt)) }}</span>
<span v-if="item.redeemedAt">{{ item.lockedAt ? ' · ' : '' }}{{ getLocaleMessage('redeemedAt', formatDate(item.redeemedAt)) }}</span>
</p>
</td>
<td class="px-3 py-3 align-top">
<div class="space-y-2">
<textarea
v-model="item.noteDraft"
rows="2"
:placeholder="locale.note"
class="w-full resize-none rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40"
/>
<button class="text-[10px] font-black uppercase tracking-[0.2em] text-primary hover:text-primary transition-colors" @click="saveNote(item)">
{{ locale.saveNote }}
</button>
</div>
</td>
<td class="px-3 py-3 align-top text-[11px] text-text-tertiary leading-relaxed">
<p>{{ getLocaleMessage('createdAt', formatDate(item.createdAt)) }}</p>
<p>{{ getLocaleMessage('updatedAt', formatDate(item.updatedAt)) }}</p>
</td>
<td class="px-3 py-3 align-top">
<div class="flex flex-wrap gap-2">
<button class="rounded-lg border border-border-secondary bg-bg-primary px-2.5 py-1.5 text-[11px] font-bold text-text-secondary" @click="updateStatus([item.id], 'AVAILABLE')">{{ locale.available }}</button>
<button class="rounded-lg border border-border-secondary bg-bg-primary px-2.5 py-1.5 text-[11px] font-bold text-warning" @click="updateStatus([item.id], 'LOCKED')">{{ locale.locked }}</button>
<button class="rounded-lg border border-border-secondary bg-bg-primary px-2.5 py-1.5 text-[11px] font-bold text-success" @click="updateStatus([item.id], 'REDEEMED')">{{ locale.redeemed }}</button>
<button class="rounded-lg border border-border-secondary bg-bg-primary px-2.5 py-1.5 text-[11px] font-bold text-error" @click="updateStatus([item.id], 'INVALID')">{{ locale.invalid }}</button>
</div>
</td>
</tr>
<tr v-if="!codes.length">
<td colspan="7" class="px-3 py-10 text-center text-sm text-text-tertiary">{{ locale.empty }}</td>
</tr>
</tbody>
</table>
</div>
<Pagination
v-model:current-page="pagination.page"
:total-pages="pagination.totalPages"
:total-items="pagination.total"
:item-name="locale.itemName"
@change="fetchCodes"
/>
</section>
<section class="space-y-4">
<div class="rounded-2xl border border-border-secondary bg-bg-secondary-40 p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-black text-text-primary">{{ locale.createTitle }}</h3>
<p class="mt-1 text-[11px] text-text-tertiary">{{ locale.createDesc }}</p>
</div>
<div class="inline-flex shrink-0 rounded-xl border border-border-secondary bg-bg-primary p-1">
<button
:class="['min-w-[72px] whitespace-nowrap px-2.5 py-1.5 rounded-lg text-xs font-bold transition-colors', createMode === 'manual' ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary']"
@click="createMode = 'manual'"
>
{{ locale.manualImport }}
</button>
<button
:class="['min-w-[72px] whitespace-nowrap px-2.5 py-1.5 rounded-lg text-xs font-bold transition-colors', createMode === 'generate' ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary']"
@click="createMode = 'generate'"
>
{{ locale.autoGenerate }}
</button>
</div>
</div>
<div v-if="createMode === 'manual'" class="space-y-3">
<textarea
v-model="manualCodes"
rows="4"
:placeholder="locale.manualPlaceholder"
class="w-full resize-none rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40"
/>
</div>
<div v-else class="grid grid-cols-2 gap-2.5">
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.generateCount }}</label>
<input v-model.number="generateForm.count" type="number" min="1" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-primary-40">
</div>
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.prefix }}</label>
<input v-model="generateForm.prefix" type="text" :placeholder="locale.prefixPlaceholder" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-primary-40">
</div>
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.randomLength }}</label>
<input v-model.number="generateForm.length" type="number" min="4" max="32" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-primary-40">
</div>
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.charset }}</label>
<input v-model="generateForm.charset" type="text" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-primary-40">
</div>
</div>
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.note }}</label>
<input v-model="createNote" type="text" :placeholder="locale.bulkNotePlaceholder" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40">
</div>
<div class="flex flex-wrap gap-2">
<button
class="flex items-center gap-2 rounded-xl bg-primary-hover px-3.5 py-2 text-xs font-black text-text-primary hover:bg-primary transition-colors disabled:opacity-50"
:disabled="saving"
@click="createCodes"
>
<Plus :size="14" /> {{ locale.createCardCode }}
</button>
<button class="rounded-xl border border-border-secondary bg-bg-primary px-3.5 py-2 text-xs font-bold text-text-secondary" @click="fillDemoCodes">
{{ locale.fillDemo }}
</button>
</div>
<div
v-if="createMode === 'generate' && lastGeneratedCodes.length"
class="rounded-2xl border border-border-secondary bg-bg-primary-60 p-3 space-y-3"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<p class="text-xs font-black text-text-primary">{{ getLocaleMessage('lastGeneratedTitle', lastGeneratedCodes.length) }}</p>
<p class="mt-1 text-[11px] text-text-tertiary">{{ locale.lastGeneratedDesc }}</p>
</div>
<button
class="flex items-center gap-2 rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-xs font-bold text-text-secondary hover:border-border-tertiary hover:text-text-primary transition-colors"
@click="copyLastGeneratedCodes"
>
<Copy :size="14" /> {{ locale.copyAll }}
</button>
</div>
<div class="max-h-32 overflow-y-auto rounded-xl border border-border-secondary bg-bg-primary p-3 font-mono text-xs leading-relaxed text-text-primary">
<p v-for="code in lastGeneratedCodes" :key="code" class="break-all">{{ code }}</p>
</div>
</div>
</div>
<div class="rounded-2xl border border-border-secondary bg-bg-secondary-40 p-4 space-y-3">
<h3 class="text-sm font-black text-text-primary uppercase tracking-[0.24em]">{{ locale.quickTipsTitle }}</h3>
<ul class="space-y-2 text-[12px] leading-relaxed text-text-tertiary">
<li v-for="tip in locale.quickTips" :key="tip" class="flex items-start gap-2">
<span class="shrink-0 text-text-disabled">·</span>
<span>{{ tip }}</span>
</li>
</ul>
</div>
</section>
</div>
<section class="rounded-2xl border border-border-secondary bg-bg-secondary-40 p-5 space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-black text-text-primary uppercase tracking-[0.24em]">{{ locale.redeemLogsTitle }}</h3>
<p class="mt-1 text-[11px] text-text-tertiary">{{ locale.redeemLogsDesc }}</p>
</div>
<button
class="rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-xs font-bold text-text-secondary"
@click="fetchRedeemLogs"
>
{{ locale.refreshLogs }}
</button>
</div>
<div class="grid grid-cols-1 gap-3 xl:grid-cols-6">
<div class="xl:col-span-2">
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.keyword }}</label>
<input
v-model="logFilters.q"
type="text"
:placeholder="locale.logSearchPlaceholder"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40"
@keyup.enter="fetchRedeemLogs"
>
</div>
<div>
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.source }}</label>
<select
v-model="logFilters.source"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary focus:outline-none focus:border-primary-40"
>
<option value="">{{ locale.allSources }}</option>
<option value="ADMIN_MANUAL">{{ locale.sources?.adminManual || '手动核销' }}</option>
<option value="SCHEDULE_AUTO">{{ locale.sources?.scheduleAuto || '排期自动' }}</option>
<option value="SCHEDULE_REMOVE">{{ locale.sources?.scheduleRemove || '移除排期' }}</option>
</select>
</div>
<div>
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.redeemer }}</label>
<input
v-model="logFilters.redeemer"
type="text"
:placeholder="locale.redeemerPlaceholder"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40"
>
</div>
<div>
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.startDate }}</label>
<input
v-model="logFilters.startDate"
type="date"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary focus:outline-none focus:border-primary-40"
>
</div>
<div>
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.endDate }}</label>
<input
v-model="logFilters.endDate"
type="date"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary focus:outline-none focus:border-primary-40"
>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
class="rounded-xl bg-primary-hover px-4 py-2 text-xs font-black text-text-primary hover:bg-primary transition-colors"
@click="fetchRedeemLogs"
>
{{ locale.queryLogs }}
</button>
<button
class="rounded-xl border border-border-secondary bg-bg-primary px-4 py-2 text-xs font-bold text-text-secondary"
@click="resetLogFilters"
>
{{ locale.clearConditions }}
</button>
</div>
<div v-if="logsLoading" class="rounded-xl border border-border-secondary bg-bg-primary-60 p-6 text-center text-sm text-text-tertiary">
{{ locale.loadingLogs }}
</div>
<div v-else class="overflow-hidden rounded-2xl border border-border-secondary">
<table class="min-w-full text-left text-sm">
<thead class="bg-bg-primary-80 text-text-tertiary">
<tr>
<th class="px-3 py-3 w-20">{{ locale.logId }}</th>
<th class="px-3 py-3">{{ locale.cardCode }}</th>
<th class="px-3 py-3 w-28">{{ locale.source }}</th>
<th class="px-3 py-3 w-56">{{ locale.redeemer }}</th>
<th class="px-3 py-3 w-40">{{ locale.redeemedTime }}</th>
<th class="px-3 py-3">{{ locale.relatedSong }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in redeemLogs"
:key="item.id"
class="border-t border-border-secondary-80 hover:bg-bg-primary-70 transition-colors"
>
<td class="px-3 py-3 text-text-tertiary">{{ item.id }}</td>
<td class="px-3 py-3 font-mono text-text-primary break-all">{{ item.code || item.codeSnapshot }}</td>
<td class="px-3 py-3">
<span :class="['inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-black tracking-[0.2em]', logSourceMeta(item.source).class]">
{{ logSourceMeta(item.source).label }}
</span>
</td>
<td class="px-3 py-3 text-text-secondary">
{{ item.redeemer?.name || locale.unknownUser }}
<span class="text-text-tertiary">({{ item.redeemer?.username || 'unknown' }})</span>
</td>
<td class="px-3 py-3 text-text-tertiary">{{ formatDate(item.redeemedAt) }}</td>
<td class="px-3 py-3 text-text-secondary">
<span v-if="item.song">{{ item.song.title }}<span class="text-text-tertiary">{{ item.song.artist ? ` · ${item.song.artist}` : '' }}</span></span>
<span v-else class="text-text-tertiary">—</span>
</td>
</tr>
<tr v-if="!redeemLogs.length">
<td colspan="6" class="px-3 py-8 text-center text-sm text-text-tertiary">{{ locale.noRedeemLogs }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { Copy, Download, Plus, RefreshCw, Search } from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import Pagination from '~/components/UI/Common/Pagination.vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
const { showToast } = useToast()
const { admin } = useLocale()
const locale = computed(() => admin.value?.cardCodesManager || {})
const { msg: getLocaleMessage, nested: getNestedMessage } = useLocaleText(locale)
const codes = ref([])
const redeemLogs = ref([])
const loading = ref(false)
const logsLoading = ref(false)
const saving = ref(false)
const exporting = ref(false)
const selectedIds = ref([])
const bulkStatus = ref('AVAILABLE')
const pagination = ref({ page: 1, limit: 10, total: 0, totalPages: 1 })
const cardStats = ref({ total: 0, available: 0, locked: 0, redeemed: 0 })
const filters = ref({ q: '', status: '' })
const logFilters = ref({ q: '', source: '', redeemer: '', startDate: '', endDate: '' })
const createMode = ref('manual')
const manualCodes = ref('')
const createNote = ref('')
const generateForm = ref({ count: 20, prefix: 'VH-', length: 10, charset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' })
const lastGeneratedCodes = ref([])
const statusFilterOptions = computed(() => [
{ label: locale.value?.allStatus || 'All Status', value: '' },
{ label: locale.value?.available || 'Available', value: 'AVAILABLE' },
{ label: locale.value?.lockedStatus || 'Locked', value: 'LOCKED' },
{ label: locale.value?.redeemedStatus || 'Redeemed', value: 'REDEEMED' },
{ label: locale.value?.invalidStatus || 'Invalid', value: 'INVALID' }
])
const bulkStatusOptions = computed(() => [
{ label: locale.value?.setAvailable || 'Set Available', value: 'AVAILABLE' },
{ label: locale.value?.setLocked || 'Set Locked', value: 'LOCKED' },
{ label: locale.value?.setRedeemed || 'Set Redeemed', value: 'REDEEMED' },
{ label: locale.value?.setInvalid || 'Set Invalid', value: 'INVALID' }
])
const statusMeta = (status) => {
const statusMap = {
AVAILABLE: { label: locale.value?.available || 'Available', class: 'bg-success-10 text-success border border-success-20' },
LOCKED: { label: locale.value?.locked || 'Locked', class: 'bg-warning-10 text-warning border border-warning-20' },
REDEEMED: { label: locale.value?.redeemed || 'Redeemed', class: 'bg-primary-10 text-primary border border-primary-20' },
INVALID: { label: locale.value?.invalid || 'Invalid', class: 'bg-error-10 text-error border border-error-20' }
}
return statusMap[status] || { label: status || locale.value?.unknown || 'Unknown', class: 'bg-bg-quaternary-10 text-text-secondary border border-border-tertiary-20' }
}
const logSourceMeta = (source) => {
const sourceMap = {
ADMIN_MANUAL: { label: locale.value?.sources?.adminManual || 'Manual Redeem', class: 'bg-primary-10 text-primary border border-primary-20' },
ADMIN: { label: locale.value?.sources?.admin || 'Admin Action', class: 'bg-primary-10 text-primary border border-primary-20' },
SCHEDULE_AUTO: { label: locale.value?.sources?.scheduleAuto || 'Schedule Auto', class: 'bg-success-10 text-success border border-success-20' },
SCHEDULE_REMOVE: { label: locale.value?.sources?.scheduleRemove || 'Schedule Remove', class: 'bg-warning-10 text-warning border border-warning-20' },
SCHEDULE: { label: locale.value?.sources?.schedule || 'Schedule', class: 'bg-success-10 text-success border border-success-20' },
WITHDRAW: { label: locale.value?.sources?.withdraw || 'Withdraw', class: 'bg-warning-10 text-warning border border-warning-20' },
UNKNOWN: { label: locale.value?.unknown || 'Unknown', class: 'bg-bg-quaternary-10 text-text-secondary border border-border-tertiary-20' }
}
return sourceMap[source] || sourceMap.UNKNOWN
}
const stats = computed(() => {
const total = cardStats.value?.total ?? 0
const available = cardStats.value?.available ?? 0
const locked = cardStats.value?.locked ?? 0
const redeemed = cardStats.value?.redeemed ?? 0
return [
{ label: locale.value?.stats?.total || 'Total', value: total, hint: locale.value?.stats?.all || 'All', badgeClass: 'bg-bg-tertiary text-text-primary' },
{ label: locale.value?.stats?.available || 'Available', value: available, hint: locale.value?.stats?.unused || 'Unused', badgeClass: 'bg-success-10 text-success' },
{ label: locale.value?.stats?.locked || 'Locked', value: locked, hint: locale.value?.stats?.pendingRedeem || 'Pending redeem', badgeClass: 'bg-warning-10 text-warning' },
{ label: locale.value?.stats?.redeemed || 'Redeemed', value: redeemed, hint: locale.value?.stats?.completed || 'Completed', badgeClass: 'bg-primary-10 text-primary' }
]
})
const allVisibleSelected = computed(() => codes.value.length > 0 && codes.value.every((item) => selectedIds.value.includes(item.id)))
const someVisibleSelected = computed(() => codes.value.some((item) => selectedIds.value.includes(item.id)))
const exportButtonText = computed(() => (selectedIds.value.length ? getLocaleMessage('exportSelected', selectedIds.value.length) : getLocaleMessage('exportFiltered')))
const queryString = computed(() => {
const query = new URLSearchParams()
if (filters.value.q.trim()) query.set('q', filters.value.q.trim())
if (filters.value.status) query.set('status', filters.value.status)
query.set('page', String(pagination.value.page))
query.set('limit', String(pagination.value.limit))
return query.toString()
})
const normalizeRows = (rows) => rows.map((row) => {
const existing = codes.value.find((item) => item.id === row.id)
const hasUnsavedNote = existing && existing.noteDraft !== (existing.note || '')
return {
...row,
noteDraft: hasUnsavedNote ? existing.noteDraft : (row.note || '')
}
})
const fetchCodes = async (page = pagination.value.page) => {
const nextPage = Number(page)
pagination.value.page = Number.isFinite(nextPage) && nextPage > 0 ? nextPage : 1
loading.value = true
try {
const res = await $fetch(`/api/admin/card-codes?${queryString.value}`)
if (res?.success) {
codes.value = normalizeRows(res.data || [])
pagination.value = {
...pagination.value,
...(res.pagination || {}),
totalPages: Math.max(1, Number(res.pagination?.totalPages || 1))
}
cardStats.value = {
total: Number(res.stats?.total || 0),
available: Number(res.stats?.available || 0),
locked: Number(res.stats?.locked || 0),
redeemed: Number(res.stats?.redeemed || 0)
}
}
} catch (error) {
console.error('获取点歌券失败', error)
showToast(getNestedMessage('messages', 'fetchFailed'), 'error')
} finally {
loading.value = false
}
}
const fetchRedeemLogs = async () => {
logsLoading.value = true
try {
const query = new URLSearchParams()
query.set('limit', '80')
if (logFilters.value.q.trim()) query.set('q', logFilters.value.q.trim())
if (logFilters.value.source) query.set('source', logFilters.value.source)
if (logFilters.value.redeemer.trim()) query.set('redeemer', logFilters.value.redeemer.trim())
if (logFilters.value.startDate) query.set('startDate', logFilters.value.startDate)
if (logFilters.value.endDate) query.set('endDate', logFilters.value.endDate)
const res = await $fetch(`/api/admin/card-codes/redeem-logs?${query.toString()}`)
if (res?.success) {
redeemLogs.value = Array.isArray(res.data) ? res.data : []
}
} catch (error) {
console.error('获取点歌券兑换日志失败', error)
showToast(getNestedMessage('messages', 'fetchLogsFailed'), 'error')
} finally {
logsLoading.value = false
}
}
const resetLogFilters = async () => {
logFilters.value = { q: '', source: '', redeemer: '', startDate: '', endDate: '' }
await fetchRedeemLogs()
}
const refreshAll = async () => {
await Promise.all([fetchCodes(), fetchRedeemLogs()])
}
const resetFilters = async () => {
filters.value = { q: '', status: '' }
await fetchCodes(1)
}
const clearSelection = () => {
selectedIds.value = []
}
const toggleSelect = (id) => {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter((itemId) => itemId !== id)
return
}
selectedIds.value = [...selectedIds.value, id]
}
const toggleSelectAll = () => {
if (allVisibleSelected.value) {
selectedIds.value = selectedIds.value.filter((id) => !codes.value.some((item) => item.id === id))
return
}
const next = new Set(selectedIds.value)
codes.value.forEach((item) => next.add(item.id))
selectedIds.value = [...next]
}
const formatDate = (value) => {
if (!value) return '—'
try {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value))
} catch {
return String(value)
}
}
const copyCode = async (code) => {
try {
await navigator.clipboard.writeText(code)
showToast(getNestedMessage('messages', 'copied'), 'success')
} catch (error) {
console.error('复制失败', error)
showToast(getNestedMessage('messages', 'copyFailed'), 'error')
}
}
const copyLastGeneratedCodes = async () => {
if (!lastGeneratedCodes.value.length) {
showToast(getNestedMessage('messages', 'noGeneratedToCopy'), 'warning')
return
}
try {
await navigator.clipboard.writeText(lastGeneratedCodes.value.join('\n'))
showToast(getNestedMessage('messages', 'copiedGenerated', lastGeneratedCodes.value.length), 'success')
} catch (error) {
console.error('批量复制点歌券失败', error)
showToast(getNestedMessage('messages', 'copyFailed'), 'error')
}
}
const buildExportQuery = () => {
const query = new URLSearchParams()
if (selectedIds.value.length) {
query.set('ids', selectedIds.value.join(','))
return query
}
if (filters.value.q.trim()) query.set('q', filters.value.q.trim())
if (filters.value.status) query.set('status', filters.value.status)
return query
}
const exportCodes = async () => {
exporting.value = true
try {
const query = buildExportQuery()
const response = await fetch(`/api/admin/card-codes/export?${query.toString()}`, {
credentials: 'same-origin'
})
if (!response.ok) {
let message = getNestedMessage('messages', 'exportFailed')
try {
const errorBody = await response.json()
message = errorBody?.message || errorBody?.statusMessage || message
} catch {
// CSV 接口错误响应不一定是 JSON。
}
throw new Error(message)
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `card-codes-${new Date().toISOString().replaceAll(':', '-').slice(0, 19)}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
showToast(selectedIds.value.length ? getNestedMessage('messages', 'exportSelectedSuccess') : getNestedMessage('messages', 'exportFilteredSuccess'), 'success')
} catch (error) {
console.error('导出点歌券失败', error)
showToast(error?.message || getNestedMessage('messages', 'exportFailed'), 'error')
} finally {
exporting.value = false
}
}
const updateStatus = async (ids, status) => {
saving.value = true
try {
if (Array.isArray(ids) && ids.length === 1) {
// 单条更新走新的 PUT 接口,便于更精细的审计
const id = ids[0]
await $fetch(`/api/admin/card-codes/${id}`, {
method: 'PUT',
body: { status }
})
} else {
await $fetch('/api/admin/card-codes/update', {
method: 'POST',
body: { ids, status }
})
}
showToast(getNestedMessage('messages', 'statusUpdated'), 'success')
await Promise.all([fetchCodes(), fetchRedeemLogs()])
} catch (error) {
console.error('更新点歌券状态失败', error)
showToast(getNestedMessage('messages', 'updateStatusFailed'), 'error')
} finally {
saving.value = false
}
}
const saveNote = async (item) => {
saving.value = true
try {
await $fetch('/api/admin/card-codes/update', {
method: 'POST',
body: { id: item.id, note: item.noteDraft }
})
showToast(getNestedMessage('messages', 'noteSaved'), 'success')
item.note = item.noteDraft || null
} catch (error) {
console.error('保存备注失败', error)
showToast(getNestedMessage('messages', 'saveNoteFailed'), 'error')
} finally {
saving.value = false
}
}
const applyBulkStatus = async () => {
if (!selectedIds.value.length) return
await updateStatus(selectedIds.value, bulkStatus.value)
clearSelection()
}
const createCodes = async () => {
saving.value = true
try {
let body = { note: createNote.value.trim() }
if (createMode.value === 'manual') {
const list = manualCodes.value
.split(/[,,\n\s]+/)
.map((value) => value.trim())
.filter(Boolean)
if (!list.length) {
showToast(getNestedMessage('messages', 'inputRequired'), 'warning')
return
}
body = { ...body, codes: [...new Set(list)] }
} else {
body = {
...body,
count: Math.max(1, Number(createFormCount.value)),
prefix: generateForm.value.prefix,
length: Math.max(4, Number(generateForm.value.length)),
charset: generateForm.value.charset
}
}
const res = await $fetch('/api/admin/card-codes/create', {
method: 'POST',
body
})
const inserted = Array.isArray(res?.data) ? res.data.length : 0
const skipped = Number(res?.skipped || 0)
lastGeneratedCodes.value = createMode.value === 'generate'
? (Array.isArray(res?.data) ? res.data.map((item) => item.code).filter(Boolean) : [])
: []
showToast(skipped ? getNestedMessage('messages', 'createDone', inserted, skipped) : getNestedMessage('messages', 'createSuccess', inserted), 'success')
manualCodes.value = ''
createNote.value = ''
await fetchCodes()
} catch (error) {
console.error('创建点歌券失败', error)
showToast(error?.data?.message || error?.message || getNestedMessage('messages', 'createFailed'), 'error')
} finally {
saving.value = false
}
}
const createFormCount = computed(() => generateForm.value.count)
const fillDemoCodes = () => {
createMode.value = 'manual'
manualCodes.value = ['DEMO-2026-A001', 'DEMO-2026-A002', 'DEMO-2026-A003'].join('\n')
createNote.value = getLocaleMessage('demoNote')
}
onMounted(refreshAll)
</script>
<style scoped>
textarea {
scrollbar-width: thin;
scrollbar-color: var(--panel-bg-hover) transparent;
}
textarea::-webkit-scrollbar {
width: 4px;
}
textarea::-webkit-scrollbar-thumb {
background: var(--panel-bg-hover);
border-radius: 9999px;
}
</style>