feat: 迁移自定义功能至 1.5.9.9 版本

包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
2026-09-12 19:26:42 +08:00
parent 34f0679117
commit 1106aeea6c
161 changed files with 18963 additions and 27900 deletions
+216 -253
View File
@@ -2,9 +2,9 @@
<div class="max-w-[1200px] mx-auto space-y-8 pb-20 px-2">
<!-- 页面标题 -->
<div class="space-y-1">
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary font-medium">
{{ locale.desc }}
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">数据库操作</h2>
<p class="text-xs text-zinc-500 font-medium">
执行系统底层维护任务,包括备份、恢复及全局数据重置
</p>
</div>
@@ -14,53 +14,49 @@
v-for="card in cards"
:key="card.id"
:class="[
'group relative bg-bg-secondary-40 border border-border-secondary rounded-2xl p-8 transition-all hover:border-border-tertiary hover:shadow-2xl hover:shadow-[0_25px_50px_var(--shadow-color-deep)]',
card.isDanger ? 'hover:border-error-20' : ''
'group relative bg-zinc-900/40 border border-zinc-800 rounded-2xl p-8 transition-all hover:border-zinc-700 hover:shadow-2xl hover:shadow-black/40',
card.isDanger ? 'hover:border-rose-500/20' : ''
]"
>
<div class="flex flex-col h-full space-y-6">
<div class="flex items-center justify-between">
<div
:class="[
'p-3.5 rounded-2xl bg-bg-primary border border-border-secondary transition-all flex items-center justify-center',
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 transition-all',
card.isDanger
? 'text-error border-error-10'
: card.colorClass === 'primary'
? 'text-primary border-primary-20'
: card.colorClass === 'warning'
? 'text-warning border-warning-20'
: 'text-success border-success-20'
? 'text-rose-500 border-rose-500/10'
: `text-${card.color}-500 border-${card.color}-500/10 shadow-lg`
]"
>
<component :is="card.icon" class="w-6 h-6" />
</div>
<span
v-if="card.isDanger"
class="px-2 py-0.5 bg-error-10 text-error text-[9px] font-black uppercase tracking-widest border border-error-20 rounded"
>{{ locale.highRisk }}</span
class="px-2 py-0.5 bg-rose-500/10 text-rose-500 text-[9px] font-black uppercase tracking-widest border border-rose-500/20 rounded"
>高风险操作</span
>
</div>
<div class="flex-1 space-y-2">
<h3 class="text-lg font-bold text-text-primary group-hover:text-primary transition-colors">
<h3 class="text-lg font-bold text-zinc-100 group-hover:text-blue-400 transition-colors">
{{ card.title }}
</h3>
<p class="text-xs text-text-tertiary leading-relaxed font-medium">
<p class="text-xs text-zinc-500 leading-relaxed font-medium">
{{ card.desc }}
</p>
</div>
<button
:disabled="isLoading(card.id)"
class="w-full py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all active:scale-95 bg-bg-primary border border-border-secondary text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary hover:border-border-tertiary"
class="w-full py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all active:scale-95 bg-zinc-950 border border-zinc-800 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 hover:border-zinc-700"
:class="
card.isDanger
? 'bg-bg-primary border border-error-30 text-error hover:bg-error hover:text-text-primary hover:border-error shadow-lg shadow-[var(--error-glow-5)]'
? 'bg-zinc-950 border border-rose-900/30 text-rose-500 hover:bg-rose-600 hover:text-white hover:border-rose-600 shadow-lg shadow-rose-900/5'
: ''
"
@click="openModal(card.id)"
>
<span v-if="isLoading(card.id)">{{ locale.executing }}</span>
<span v-if="isLoading(card.id)">执行中...</span>
<span v-else>{{ card.btnText }}</span>
</button>
</div>
@@ -69,19 +65,19 @@
<div
:class="[
'absolute -right-4 -bottom-4 w-32 h-32 blur-3xl opacity-0 group-hover:opacity-100 transition-opacity rounded-full pointer-events-none',
card.isDanger ? 'bg-error-5' : card.colorClass === 'primary' ? 'bg-primary-5' : card.colorClass === 'warning' ? 'bg-warning-5' : 'bg-success-5'
card.isDanger ? 'bg-rose-500/5' : `bg-${card.color}-500/5`
]"
/>
</div>
</div>
<!-- 维护建议 -->
<div class="bg-primary-hover-5 border border-primary-10 rounded-xl p-5 flex items-start gap-4">
<AlertCircle class="text-primary shrink-0 mt-0.5 w-[18px] h-[18px]" />
<div class="bg-blue-600/5 border border-blue-500/10 rounded-xl p-5 flex items-start gap-4">
<AlertCircle class="text-blue-500 shrink-0 mt-0.5 w-[18px] h-[18px]" />
<div class="space-y-1">
<p class="text-[11px] font-bold text-text-secondary">{{ locale.maintenanceTitle }}</p>
<p class="text-[10px] text-text-tertiary leading-relaxed">
{{ locale.maintenanceDesc }}
<p class="text-[11px] font-bold text-zinc-300">数据库维护建议</p>
<p class="text-[10px] text-zinc-500 leading-relaxed">
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
</p>
</div>
</div>
@@ -91,14 +87,14 @@
v-if="activeModal === 'backup'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.backupTitle }}</h3>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">创建数据库备份</h3>
<button
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
@@ -106,52 +102,52 @@
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1">
{{ locale.selectContent }}
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
选择包含的内容
</p>
<div class="space-y-2">
<label
v-for="(item, i) in backupOptions"
:key="i"
class="flex items-start gap-4 p-4 bg-bg-primary-50 border border-border-secondary rounded-xl cursor-pointer hover:border-border-tertiary transition-all group"
class="flex items-start gap-4 p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl cursor-pointer hover:border-zinc-700 transition-all group"
>
<div class="shrink-0 mt-0.5">
<input
v-model="createForm[item.key]"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
>
</div>
<div>
<p
class="text-xs font-bold text-text-primary group-hover:text-primary transition-colors"
class="text-xs font-bold text-zinc-200 group-hover:text-blue-400 transition-colors"
>
{{ item.label }}
</p>
<p class="text-[10px] text-text-disabled font-medium mt-0.5">{{ item.desc }}</p>
<p class="text-[10px] text-zinc-600 font-medium mt-0.5">{{ item.desc }}</p>
</div>
</label>
</div>
</div>
<div class="p-3 bg-primary-5 border border-primary-10 rounded-xl">
<p class="text-[10px] text-text-tertiary text-center italic">
{{ locale.backupHint }}
<div class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl">
<p class="text-[10px] text-zinc-500 text-center italic">
备份文件将以 .json 格式生成并自动下载
</p>
</div>
</div>
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="createLoading"
class="px-8 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
class="px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="createBackup"
>
{{ createLoading ? locale.exporting : locale.startExport }}
{{ createLoading ? '正在导出...' : '开始导出' }}
</button>
</div>
</div>
@@ -162,14 +158,14 @@
v-if="activeModal === 'restore'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.restoreTitle }}</h3>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">恢复数据库备份</h3>
<button
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
@@ -177,19 +173,19 @@
</div>
<div class="p-8 space-y-6">
<div
class="border-2 border-dashed border-border-secondary rounded-2xl p-10 flex flex-col items-center justify-center text-center group hover:border-success-50 hover:bg-success-5 transition-all cursor-pointer"
class="border-2 border-dashed border-zinc-800 rounded-2xl p-10 flex flex-col items-center justify-center text-center group hover:border-emerald-500/50 hover:bg-emerald-500/5 transition-all cursor-pointer"
@click="$refs.fileInput.click()"
@dragover.prevent
@drop.prevent="handleFileDrop"
>
<Upload
class="w-8 h-8 text-text-secondary mb-4 group-hover:text-success transition-colors"
class="w-8 h-8 text-zinc-700 mb-4 group-hover:text-emerald-500 transition-colors"
/>
<h5 class="text-sm font-bold text-text-secondary">
{{ selectedFile ? selectedFile.name : locale.selectFile }}
<h5 class="text-sm font-bold text-zinc-300">
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
</h5>
<p class="text-[10px] text-text-disabled font-bold uppercase mt-1 tracking-widest">
{{ locale.fileHint }}
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
仅支持 VoiceHub 导出的 .json 格式
</p>
<input
ref="fileInput"
@@ -201,54 +197,54 @@
</div>
<div class="space-y-3">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.restoreMode }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>恢复模式</label
>
<div class="grid grid-cols-2 gap-3">
<button
:class="[
'p-4 border rounded-xl text-left transition-all',
restoreForm.mode === 'merge'
? 'bg-bg-primary border-success-30'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
? 'bg-zinc-950 border-emerald-500/30'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
@click="restoreForm.mode = 'merge'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'merge' ? 'text-success' : 'text-text-tertiary'
restoreForm.mode === 'merge' ? 'text-emerald-400' : 'text-zinc-500'
]"
>
{{ locale.restoreModes?.merge?.title ?? '合并恢复' }}
增量模式
</h6>
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.merge?.desc ?? '保留现有数据并合并备份内容' }}</p>
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">仅导入不重复的新记录</p>
</button>
<button
:class="[
'p-4 border rounded-xl text-left transition-all',
restoreForm.mode === 'replace'
? 'bg-bg-primary border-success-30'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
? 'bg-zinc-950 border-emerald-500/30'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
@click="restoreForm.mode = 'replace'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'replace' ? 'text-success' : 'text-text-tertiary'
restoreForm.mode === 'replace' ? 'text-emerald-400' : 'text-zinc-500'
]"
>
{{ locale.restoreModes?.replace?.title ?? '覆盖恢复' }}
覆盖模式
</h6>
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.replace?.desc ?? '清除现有数据后恢复备份内容' }}</p>
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">清空现有表后完整恢复</p>
</button>
</div>
</div>
<div
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
class="p-4 bg-bg-primary border border-border-secondary rounded-xl"
class="p-4 bg-zinc-950 border border-zinc-800 rounded-xl"
>
<label class="flex items-start gap-3 cursor-pointer">
<input
@@ -257,36 +253,36 @@
class="mt-0.5 accent-emerald-500"
>
<div>
<p class="text-xs font-bold text-text-primary">{{ locale.overwriteSuperAdmin }}</p>
<p class="text-[10px] text-text-tertiary mt-1">
{{ locale.overwriteSuperAdminDesc }}
<p class="text-xs font-bold text-zinc-200">覆盖备份中的超级管理员账号数据</p>
<p class="text-[10px] text-zinc-500 mt-1">
关闭时将保留当前超级管理员账号及其第三方绑定、2FA等关联数据
</p>
</div>
</label>
</div>
<div
class="p-4 bg-warning-5 border border-warning-10 rounded-xl flex items-start gap-3"
class="p-4 bg-amber-500/5 border border-amber-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-warning shrink-0 mt-0.5 w-4 h-4" />
<p class="text-[10px] text-text-tertiary leading-normal font-medium">
{{ locale.replaceWarning }}
<AlertCircle class="text-amber-500 shrink-0 mt-0.5 w-4 h-4" />
<p class="text-[10px] text-zinc-500 leading-normal font-medium">
注意:覆盖模式将永久清空当前数据库中对应的表内容。此操作将导致现有会话中断。
</p>
</div>
</div>
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="uploadLoading || !selectedFile"
class="px-8 py-2 bg-success hover:bg-success text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
class="px-8 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="restoreBackup"
>
{{ uploadLoading ? restoreProgress || locale.restoring : locale.confirmRestore }}
{{ uploadLoading ? restoreProgress || '正在恢复...' : '确认并开始恢复' }}
</button>
</div>
</div>
@@ -297,14 +293,14 @@
v-if="activeModal === 'reset-seq'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.resetSequenceTitle }}</h3>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">重置数据表序列</h3>
<button
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
@@ -312,47 +308,47 @@
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.selectTargetTable }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>选择目标表</label
>
<CustomSelect
v-model="sequenceForm.table"
:options="locale.tableOptions || defaultTableOptions"
label-key="label"
value-key="value"
:options="tableOptions"
class="w-full"
/>
</div>
<div class="p-6 bg-bg-primary-50 border border-border-secondary rounded-2xl space-y-4">
<div class="p-6 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-4">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-warning-10 text-warning flex items-center justify-center"
class="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center"
>
<AlertCircle class="w-4 h-4" />
</div>
<h6 class="text-xs font-bold text-text-secondary uppercase tracking-widest">
{{ locale.sequenceHelpTitle }}
<h6 class="text-xs font-bold text-zinc-300 uppercase tracking-widest">
什么是重置序列?
</h6>
</div>
<p class="text-[11px] text-text-tertiary leading-relaxed font-medium">
{{ locale.sequenceHelpDesc }}
<p class="text-[11px] text-zinc-500 leading-relaxed font-medium">
如果您的数据表 ID
出现了断档或在手动操作数据库后无法自增,重置序列可以将数据库底层的计数器更新为当前 ID
最大值 +1,从而解决 ID 冲突导致的写入失败问题。此操作不会修改任何现有数据。
</p>
</div>
</div>
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="sequenceLoading || !sequenceForm.table"
class="px-8 py-2 bg-warning hover:bg-warning text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
class="px-8 py-2 bg-amber-600 hover:bg-amber-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="resetSequence"
>
{{ sequenceLoading ? locale.resetting : locale.executeReset }}
{{ sequenceLoading ? '正在重置...' : '执行重置' }}
</button>
</div>
</div>
@@ -363,14 +359,14 @@
v-if="activeModal === 'reset-db'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-error tracking-tight">{{ locale.dangerResetTitle }}</h3>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-rose-500 tracking-tight">危险操作:重置数据库</h3>
<button
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
@@ -378,120 +374,74 @@
</div>
<div class="p-8 space-y-6">
<div
class="p-6 bg-error-10 border border-error-20 rounded-2xl flex flex-col items-center text-center"
class="p-6 bg-rose-600/10 border border-rose-500/20 rounded-2xl flex flex-col items-center text-center"
>
<Trash2 class="text-error mb-4 w-12 h-12" />
<h4 class="text-lg font-black text-error tracking-tight">
{{ locale.dangerResetHeading }}
<Trash2 class="text-rose-500 mb-4 w-12 h-12" />
<h4 class="text-lg font-black text-rose-500 tracking-tight">
您正在执行极其危险的操作!
</h4>
<p class="text-xs text-text-tertiary mt-2 font-medium leading-relaxed">
{{ locale.dangerResetPrefix }}
<span class="text-text-secondary font-bold"
>{{ locale.dangerResetScope }}</span
<p class="text-xs text-zinc-500 mt-2 font-medium leading-relaxed">
重置操作将永久删除系统中的所有
<span class="text-zinc-300 font-bold"
>歌曲、投稿记录、排期文件、通知、日志及除您以外的用户账号</span
>。
</p>
</div>
<div class="space-y-3">
<label
class="text-[11px] font-black text-error-80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
class="text-[11px] font-black text-rose-500/80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
>
{{ locale.confirmCodeLabel }}
请输入以下代码以确认操作
</label>
<div
class="bg-bg-primary border border-error-30 rounded-xl px-4 py-3 font-mono text-[10px] text-error text-center select-all"
class="bg-zinc-950 border border-rose-900/30 rounded-xl px-4 py-3 font-mono text-[10px] text-rose-400 text-center select-all"
>
{{ CONFIRM_CODE }}
</div>
<input
v-model="resetConfirmText"
type="text"
:placeholder="locale.confirmCodePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-error-40 text-center font-mono placeholder:text-text-secondary"
placeholder="在此输入上述代码..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-rose-500/40 text-center font-mono placeholder:text-zinc-700"
>
</div>
<div class="grid grid-cols-2 gap-3 pt-2">
<button
class="py-3 bg-bg-secondary hover:bg-bg-tertiary text-text-tertiary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
class="py-3 bg-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="resetConfirmText !== CONFIRM_CODE || resetLoading"
class="py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg disabled:bg-bg-tertiary disabled:text-text-disabled disabled:cursor-not-allowed disabled:border-border-tertiary"
class="py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg disabled:bg-zinc-800 disabled:text-zinc-600 disabled:cursor-not-allowed disabled:border-zinc-700"
:class="
resetConfirmText === CONFIRM_CODE
? 'bg-error hover:bg-error text-text-primary shadow-[var(--error-glow-20)] active:scale-95'
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
: ''
"
@click="resetDatabase"
>
{{ resetLoading ? locale.resetting : locale.confirmResetDatabase }}
{{ resetLoading ? '正在重置...' : '确认彻底重置' }}
</button>
</div>
</div>
</div>
</div>
<!-- 自动备份设置 -->
<BackupAutoSettings
:visible="activeModal === 'auto-backup'"
@close="activeModal = 'none'"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { Download, Upload, RotateCw, Trash2, AlertCircle, CloudUpload, X } from '@lucide/vue'
import { ref } from 'vue'
import { Download, Upload, RotateCw, Trash2, AlertCircle, X } from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import BackupAutoSettings from '~/components/Admin/BackupAutoSettings.vue'
import { useToast } from '~/composables/useToast'
import { useAuth } from '~/composables/useAuth'
import { useLocale } from '~/utils/locale'
const { showToast: showNotification } = useToast()
const auth = useAuth()
const { admin } = useLocale()
const locale = computed(() => admin.value?.databaseManager || {})
const defaultTableOptions = [
{ label: '重置所有表', value: 'all' },
{ label: '歌曲表', value: 'Song' },
{ label: '用户表', value: 'User' },
{ label: '用户身份表', value: 'UserIdentity' },
{ label: '用户状态日志表', value: 'UserStatusLog' },
{ label: '投票表', value: 'Vote' },
{ label: '排期表', value: 'Schedule' },
{ label: '通知表', value: 'Notification' },
{ label: '通知设置表', value: 'NotificationSettings' },
{ label: '播放时段表', value: 'PlayTime' },
{ label: '学期表', value: 'Semester' },
{ label: '系统设置表', value: 'SystemSettings' },
{ label: '歌曲黑名单表', value: 'SongBlacklist' },
{ label: '歌曲重播申请表', value: 'SongReplayRequest' },
{ label: '投稿时段表', value: 'RequestTime' },
{ label: '邮件模板表', value: 'EmailTemplate' }
]
const getMessage = (key) => locale.value?.messages?.[key] ?? `数据库操作:${key}`
const getLogMessage = (key) => locale.value?.logs?.[key] || key
const formatString = (value, args) => {
if (typeof value !== 'string') return value
return value.replace(/{(\d+)}/g, (match, index) =>
args[index] !== undefined ? String(args[index]) : match
)
}
const getErrorMessage = (key, ...args) => {
const message = locale.value?.errors?.[key]
if (typeof message === 'function') return message(...args)
return formatString(message, args) || args.find(Boolean) || key
}
const getProgressMessage = (key, ...args) => {
const message = locale.value?.progress?.[key]
if (typeof message === 'function') return message(...args)
return formatString(message, args) || `数据库操作进行中:${key}`
}
// 状态
const activeModal = ref('none')
@@ -506,68 +456,60 @@ const hasSuperAdminInBackup = ref(false)
const CONFIRM_CODE = 'CONFIRM-DATABASE-RESET-OPERATION'
// 卡片配置
const cards = computed(() => [
const cards = [
{
id: 'backup',
title: locale.value?.cards?.backup?.title ?? '备份数据库',
desc: locale.value?.cards?.backup?.desc ?? '导出系统数据备份文件',
title: '创建备份',
desc: '导出当前数据库的所有数据到文件,用于安全备份或迁移。',
icon: Download,
colorClass: 'primary',
btnText: locale.value?.cards?.backup?.button ?? '创建备份'
color: 'blue',
btnText: '创建备份文件'
},
{
id: 'restore',
title: locale.value?.cards?.restore?.title ?? '恢复数据库',
desc: locale.value?.cards?.restore?.desc ?? '从备份文件恢复系统数据',
title: '恢复备份',
desc: '从之前导出的备份文件中恢复系统数据。',
icon: Upload,
colorClass: 'success',
btnText: locale.value?.cards?.restore?.button ?? '恢复备份'
color: 'emerald',
btnText: '选择备份文件'
},
{
id: 'reset-seq',
title: locale.value?.cards?.resetSeq?.title ?? '修复数据序列',
desc: locale.value?.cards?.resetSeq?.desc ?? '修复数据表自增序列',
title: '重置序列',
desc: '修复数据表的自增ID序列,确保新记录的ID从正确值开始。',
icon: RotateCw,
colorClass: 'warning',
btnText: locale.value?.cards?.resetSeq?.button ?? '修复序列'
color: 'amber',
btnText: '开始重置序列'
},
{
id: 'reset-db',
title: locale.value?.cards?.resetDb?.title ?? '重置数据库',
desc: locale.value?.cards?.resetDb?.desc ?? '清空除管理员外的系统数据',
title: '重置数据库',
desc: '清空除管理员账号外的所有系统数据。此操作不可撤销。',
icon: Trash2,
colorClass: 'error',
btnText: locale.value?.cards?.resetDb?.button ?? '重置数据库',
color: 'rose',
btnText: '立即重置数据库',
isDanger: true
},
{
id: 'auto-backup',
title: locale.value?.cards?.autoBackup?.title ?? '自动备份',
desc: locale.value?.cards?.autoBackup?.desc ?? '配置多种备份方式,通过外部触发实现定时自动备份',
icon: CloudUpload,
colorClass: 'success',
btnText: locale.value?.cards?.autoBackup?.button ?? '配置自动备份'
}
])
]
// 备份选项
const backupOptions = computed(() => [
const backupOptions = [
{
key: 'includeSongs',
label: locale.value?.backupOptions?.songs?.label ?? '歌曲与排期数据',
desc: locale.value?.backupOptions?.songs?.desc ?? '包含歌曲、投稿及排期数据'
label: '歌曲与排期数据',
desc: '包含所有歌曲库、用户投稿记录及历史播音排期'
},
{
key: 'includeSystemData',
label: locale.value?.backupOptions?.system?.label ?? '系统配置',
desc: locale.value?.backupOptions?.system?.desc ?? '包含站点设置和全局参数'
label: '系统配置信息',
desc: '包含站点设置、黑名单、播出时段等全局参数'
},
{
key: 'includeUsers',
label: locale.value?.backupOptions?.users?.label ?? '用户账户',
desc: locale.value?.backupOptions?.users?.desc ?? '包含用户账户和权限数据'
label: '用户账户数据',
desc: '包含所有注册用户的权限、偏好设置(不含管理员敏感信息)'
}
])
]
// 表单数据
const createForm = ref({
@@ -582,9 +524,37 @@ const restoreForm = ref({
})
const sequenceForm = ref({
table: 'all'
table: '重置所有表 (All)'
})
const tableOptions = [
'重置所有表 (All)',
'歌曲表 (Song)',
'用户表 (User)',
'投票表 (Vote)',
'排期表 (Schedule)',
'通知表 (Notification)',
'通知设置表 (NotificationSettings)',
'播放时段表 (PlayTime)',
'学期表 (Semester)',
'系统设置表 (SystemSettings)',
'歌曲黑名单表 (SongBlacklist)'
]
const labelToValueMap = {
'重置所有表 (All)': 'all',
'歌曲表 (Song)': 'Song',
'用户表 (User)': 'User',
'投票表 (Vote)': 'Vote',
'排期表 (Schedule)': 'Schedule',
'通知表 (Notification)': 'Notification',
'通知设置表 (NotificationSettings)': 'NotificationSettings',
'播放时段表 (PlayTime)': 'PlayTime',
'学期表 (Semester)': 'Semester',
'系统设置表 (SystemSettings)': 'SystemSettings',
'歌曲黑名单表 (SongBlacklist)': 'SongBlacklist'
}
// 辅助函数
const isLoading = (id) => {
if (id === 'backup') return createLoading.value
@@ -614,8 +584,8 @@ const parseBackupSuperAdmin = async (file) => {
} catch (error) {
hasSuperAdminInBackup.value = false
restoreForm.value.overwriteSuperAdmin = false
showNotification(getMessage('parseBackupFailed'), 'error')
console.error(getLogMessage('parseBackupFailed'), error)
showNotification('无法解析备份文件,请检查文件格式是否正确。', 'error')
console.error('解析备份文件失败:', error)
}
}
@@ -625,7 +595,7 @@ const handleFileSelect = async (event) => {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification(getMessage('invalidJsonFile'), 'error')
showNotification('请选择有效的JSON备份文件', 'error')
}
}
@@ -635,7 +605,7 @@ const handleFileDrop = async (event) => {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification(getMessage('invalidJsonFile'), 'error')
showNotification('请选择有效的JSON备份文件', 'error')
}
}
@@ -649,11 +619,11 @@ const createBackup = async () => {
} else if (createForm.value.includeUsers) {
tables = 'users'
} else if (createForm.value.includeSongs) {
tables = ['songs', 'scheduleSongPool']
tables = 'songs'
} else if (createForm.value.includeSystemData) {
tables = ['systemSettings']
} else {
throw new Error(getErrorMessage('noBackupContent'))
throw new Error('请至少选择一项备份内容')
}
const response = await $fetch('/api/admin/backup/export', {
@@ -677,7 +647,7 @@ const createBackup = async () => {
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification(getMessage('backupDownloaded'), 'success')
showNotification('备份文件已下载', 'success')
activeModal.value = 'none'
} else if (response.backup.downloadMode === 'file' && response.backup.filename) {
const downloadUrl = `/api/admin/backup/download/${response.backup.filename}`
@@ -685,7 +655,7 @@ const createBackup = async () => {
method: 'GET',
credentials: 'include'
})
if (!downloadResponse.ok) throw new Error(getErrorMessage('downloadFailed', downloadResponse.status))
if (!downloadResponse.ok) throw new Error(`下载失败: ${downloadResponse.status}`)
const blob = await downloadResponse.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -695,25 +665,25 @@ const createBackup = async () => {
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification(getMessage('backupDownloaded'), 'success')
showNotification('备份文件已下载', 'success')
activeModal.value = 'none'
}
} else {
throw new Error(response.message || getErrorMessage('createBackupFailed'))
throw new Error(response.message || '备份创建失败')
}
} catch (error) {
console.error(getLogMessage('createBackupFailed'), error)
showNotification(getErrorMessage('createBackupFailedWithMessage', error?.message), 'error')
console.error('创建备份失败:', error)
showNotification('创建备份失败: ' + error.message, 'error')
} finally {
createLoading.value = false
}
}
const restoreBackup = async () => {
if (!selectedFile.value) return showNotification(getMessage('selectBackupFile'), 'error')
if (!selectedFile.value) return showNotification('请选择备份文件', 'error')
uploadLoading.value = true
restoreProgress.value = getProgressMessage('readingFile')
restoreProgress.value = '正在读取文件...'
try {
const fileContent = await selectedFile.value.text()
@@ -721,10 +691,10 @@ const restoreBackup = async () => {
try {
backupData = JSON.parse(fileContent)
} catch {
throw new Error(getErrorMessage('invalidBackupFormat'))
throw new Error('无法解析备份文件,文件格式不正确')
}
if (!backupData.data) throw new Error(getErrorMessage('missingBackupData'))
if (!backupData.data) throw new Error('备份文件缺少数据部分')
const fileHasSuperAdmin = hasSuperAdminInBackup.value
if (!fileHasSuperAdmin) {
restoreForm.value.overwriteSuperAdmin = false
@@ -734,7 +704,7 @@ const restoreBackup = async () => {
let temporaryPreservedUserId = null
if (restoreForm.value.mode === 'replace') {
restoreProgress.value = getProgressMessage('clearingData')
restoreProgress.value = '正在清空现有数据...'
const clearResult = await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
@@ -742,7 +712,7 @@ const restoreBackup = async () => {
hasSuperAdminInBackup: fileHasSuperAdmin
}
})
if (!clearResult.success) throw new Error(clearResult.message || getErrorMessage('clearDataFailed'))
if (!clearResult.success) throw new Error(clearResult.message || '清空数据失败')
preservedSuperAdminIds = clearResult.preservedSuperAdminIds || []
temporaryPreservedUserId = clearResult.temporaryPreservedUserId || null
}
@@ -754,7 +724,6 @@ const restoreBackup = async () => {
'semesters',
'playTimes',
'songs',
'scheduleSongPool',
'votes',
'schedules',
'notificationSettings',
@@ -788,14 +757,7 @@ const restoreBackup = async () => {
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
const chunk = records.slice(i, i + CHUNK_SIZE)
const progressPercent = Math.round((totalProcessed / totalRecords) * 100)
restoreProgress.value = getProgressMessage(
'restoringTable',
tableName,
i + 1,
Math.min(i + CHUNK_SIZE, records.length),
records.length,
progressPercent
)
restoreProgress.value = `正在恢复 ${tableName} (${i + 1}-${Math.min(i + CHUNK_SIZE, records.length)}/${records.length}) ${progressPercent}%`
const response = await $fetch('/api/admin/backup/restore-chunk', {
method: 'POST',
@@ -809,7 +771,7 @@ const restoreBackup = async () => {
}
})
if (!response.success) throw new Error(response.message || getErrorMessage('restoreTableFailed', tableName))
if (!response.success) throw new Error(response.message || `恢复表 ${tableName} 失败`)
if (response.newMappings) {
if (response.newMappings.users) Object.assign(mappings.users, response.newMappings.users)
if (response.newMappings.songs) Object.assign(mappings.songs, response.newMappings.songs)
@@ -818,21 +780,21 @@ const restoreBackup = async () => {
}
}
restoreProgress.value = getProgressMessage('fixingSequence')
restoreProgress.value = '正在修复数据表序列...'
const sequenceResult = await $fetch('/api/admin/fix-sequence', {
method: 'POST',
body: { table: 'all' }
})
if (!sequenceResult.success) {
throw new Error(sequenceResult.message || sequenceResult.error || getErrorMessage('fixSequenceFailed'))
throw new Error(sequenceResult.message || sequenceResult.error || '序列修复失败')
}
restoreProgress.value = getProgressMessage('reloadingSmtp')
restoreProgress.value = '正在重载SMTP配置...'
const smtpReloadResult = await $fetch('/api/admin/smtp/reload', {
method: 'POST'
})
if (!smtpReloadResult.success) {
throw new Error(smtpReloadResult.message || getErrorMessage('smtpReloadFailed'))
throw new Error(smtpReloadResult.message || 'SMTP配置重载失败')
}
const shouldFinalizeTempUser =
@@ -844,7 +806,7 @@ const restoreBackup = async () => {
if (shouldFinalizeTempUser) {
const restoredUserIds = Object.values(mappings.users).map((id) => Number(id))
if (!restoredUserIds.includes(Number(temporaryPreservedUserId))) {
restoreProgress.value = getProgressMessage('finalizingAdmin')
restoreProgress.value = '正在完成管理员替换...'
await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
@@ -852,7 +814,7 @@ const restoreBackup = async () => {
}
})
}
showNotification(getMessage('restoreSuccessRelogin'), 'success')
showNotification('数据库恢复成功,正在重新登录', 'success')
activeModal.value = 'none'
setTimeout(() => {
if (auth.logout) {
@@ -865,11 +827,11 @@ const restoreBackup = async () => {
return
}
showNotification(getMessage('restoreSuccess'), 'success')
showNotification('数据库恢复成功', 'success')
activeModal.value = 'none'
} catch (error) {
console.error(getLogMessage('restoreBackupFailed'), error)
showNotification(getErrorMessage('restoreBackupFailedWithMessage', error?.message), 'error')
console.error('恢复备份失败:', error)
showNotification('恢复备份失败: ' + error.message, 'error')
} finally {
uploadLoading.value = false
restoreProgress.value = ''
@@ -880,19 +842,20 @@ const resetSequence = async () => {
if (!sequenceForm.value.table) return
sequenceLoading.value = true
try {
const tableValue = labelToValueMap[sequenceForm.value.table] || sequenceForm.value.table
const response = await $fetch('/api/admin/fix-sequence', {
method: 'POST',
body: { table: sequenceForm.value.table }
body: { table: tableValue }
})
if (response.success) {
showNotification(response.message || getMessage('sequenceResetSuccess'), 'success')
showNotification(response.message || '序列重置成功', 'success')
activeModal.value = 'none'
} else {
throw new Error(response.error || response.message || getErrorMessage('resetFailed'))
throw new Error(response.error || response.message || '重置失败')
}
} catch (error) {
console.error(getLogMessage('resetSequenceFailed'), error)
showNotification(getErrorMessage('resetSequenceFailedWithMessage', error?.message), 'error')
console.error('重置序列失败:', error)
showNotification('重置序列失败: ' + error.message, 'error')
} finally {
sequenceLoading.value = false
}
@@ -904,15 +867,15 @@ const resetDatabase = async () => {
try {
const response = await $fetch('/api/admin/database/reset', { method: 'POST' })
if (response.success) {
showNotification(getMessage('databaseResetSuccess'), 'success')
showNotification('数据库已成功重置', 'success')
activeModal.value = 'none'
setTimeout(() => window.location.reload(), 1500)
} else {
throw new Error(response.message || getErrorMessage('resetFailed'))
throw new Error(response.message || '重置失败')
}
} catch (error) {
console.error(getLogMessage('resetDatabaseFailed'), error)
showNotification(getErrorMessage('resetDatabaseFailedWithMessage', error?.message), 'error')
console.error('重置数据库失败:', error)
showNotification('重置数据库失败: ' + error.message, 'error')
} finally {
resetLoading.value = false
}