feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+822
View File
@@ -0,0 +1,822 @@
<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>