feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,920 @@
|
||||
<template>
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作卡片网格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div
|
||||
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' : ''
|
||||
]"
|
||||
>
|
||||
<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',
|
||||
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'
|
||||
]"
|
||||
>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<h3 class="text-lg font-bold text-text-primary group-hover:text-primary transition-colors">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="text-xs text-text-tertiary 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="
|
||||
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)]'
|
||||
: ''
|
||||
"
|
||||
@click="openModal(card.id)"
|
||||
>
|
||||
<span v-if="isLoading(card.id)">{{ locale.executing }}</span>
|
||||
<span v-else>{{ card.btnText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 背景装饰 -->
|
||||
<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'
|
||||
]"
|
||||
/>
|
||||
</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="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建备份模态框 -->
|
||||
<div
|
||||
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="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"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="text-xs font-bold text-text-primary group-hover:text-primary transition-colors"
|
||||
>
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-[10px] text-text-disabled 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 }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary 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"
|
||||
@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"
|
||||
@click="createBackup"
|
||||
>
|
||||
{{ createLoading ? locale.exporting : locale.startExport }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 恢复备份模态框 -->
|
||||
<div
|
||||
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="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"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</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"
|
||||
@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"
|
||||
/>
|
||||
<h5 class="text-sm font-bold text-text-secondary">
|
||||
{{ selectedFile ? selectedFile.name : locale.selectFile }}
|
||||
</h5>
|
||||
<p class="text-[10px] text-text-disabled font-bold uppercase mt-1 tracking-widest">
|
||||
{{ locale.fileHint }}
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.restoreMode }}</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'
|
||||
]"
|
||||
@click="restoreForm.mode = 'merge'"
|
||||
>
|
||||
<h6
|
||||
:class="[
|
||||
'text-xs font-bold',
|
||||
restoreForm.mode === 'merge' ? 'text-success' : 'text-text-tertiary'
|
||||
]"
|
||||
>
|
||||
{{ locale.restoreModes?.merge?.title ?? '合并恢复' }}
|
||||
</h6>
|
||||
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.merge?.desc ?? '保留现有数据并合并备份内容' }}</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'
|
||||
]"
|
||||
@click="restoreForm.mode = 'replace'"
|
||||
>
|
||||
<h6
|
||||
:class="[
|
||||
'text-xs font-bold',
|
||||
restoreForm.mode === 'replace' ? 'text-success' : 'text-text-tertiary'
|
||||
]"
|
||||
>
|
||||
{{ locale.restoreModes?.replace?.title ?? '覆盖恢复' }}
|
||||
</h6>
|
||||
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.replace?.desc ?? '清除现有数据后恢复备份内容' }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
|
||||
class="p-4 bg-bg-primary border border-border-secondary rounded-xl"
|
||||
>
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="restoreForm.overwriteSuperAdmin"
|
||||
type="checkbox"
|
||||
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>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-4 bg-warning-5 border border-warning-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 }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary 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"
|
||||
@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"
|
||||
@click="restoreBackup"
|
||||
>
|
||||
{{ uploadLoading ? restoreProgress || locale.restoring : locale.confirmRestore }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置序列模态框 -->
|
||||
<div
|
||||
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="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"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</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
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="sequenceForm.table"
|
||||
:options="locale.tableOptions || defaultTableOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-bg-primary-50 border border-border-secondary 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"
|
||||
>
|
||||
<AlertCircle class="w-4 h-4" />
|
||||
</div>
|
||||
<h6 class="text-xs font-bold text-text-secondary uppercase tracking-widest">
|
||||
{{ locale.sequenceHelpTitle }}
|
||||
</h6>
|
||||
</div>
|
||||
<p class="text-[11px] text-text-tertiary leading-relaxed font-medium">
|
||||
{{ locale.sequenceHelpDesc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary 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"
|
||||
@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"
|
||||
@click="resetSequence"
|
||||
>
|
||||
{{ sequenceLoading ? locale.resetting : locale.executeReset }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置数据库模态框 -->
|
||||
<div
|
||||
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="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"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</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"
|
||||
>
|
||||
<Trash2 class="text-error mb-4 w-12 h-12" />
|
||||
<h4 class="text-lg font-black text-error tracking-tight">
|
||||
{{ locale.dangerResetHeading }}
|
||||
</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>
|
||||
</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"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
</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"
|
||||
@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="
|
||||
resetConfirmText === CONFIRM_CODE
|
||||
? 'bg-error hover:bg-error text-text-primary shadow-[var(--error-glow-20)] active:scale-95'
|
||||
: ''
|
||||
"
|
||||
@click="resetDatabase"
|
||||
>
|
||||
{{ resetLoading ? locale.resetting : locale.confirmResetDatabase }}
|
||||
</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 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')
|
||||
const createLoading = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
const sequenceLoading = ref(false)
|
||||
const resetLoading = ref(false)
|
||||
const selectedFile = ref(null)
|
||||
const resetConfirmText = ref('')
|
||||
const restoreProgress = ref('')
|
||||
const hasSuperAdminInBackup = ref(false)
|
||||
const CONFIRM_CODE = 'CONFIRM-DATABASE-RESET-OPERATION'
|
||||
|
||||
// 卡片配置
|
||||
const cards = computed(() => [
|
||||
{
|
||||
id: 'backup',
|
||||
title: locale.value?.cards?.backup?.title ?? '备份数据库',
|
||||
desc: locale.value?.cards?.backup?.desc ?? '导出系统数据备份文件',
|
||||
icon: Download,
|
||||
colorClass: 'primary',
|
||||
btnText: locale.value?.cards?.backup?.button ?? '创建备份'
|
||||
},
|
||||
{
|
||||
id: 'restore',
|
||||
title: locale.value?.cards?.restore?.title ?? '恢复数据库',
|
||||
desc: locale.value?.cards?.restore?.desc ?? '从备份文件恢复系统数据',
|
||||
icon: Upload,
|
||||
colorClass: 'success',
|
||||
btnText: locale.value?.cards?.restore?.button ?? '恢复备份'
|
||||
},
|
||||
{
|
||||
id: 'reset-seq',
|
||||
title: locale.value?.cards?.resetSeq?.title ?? '修复数据序列',
|
||||
desc: locale.value?.cards?.resetSeq?.desc ?? '修复数据表自增序列',
|
||||
icon: RotateCw,
|
||||
colorClass: 'warning',
|
||||
btnText: locale.value?.cards?.resetSeq?.button ?? '修复序列'
|
||||
},
|
||||
{
|
||||
id: 'reset-db',
|
||||
title: locale.value?.cards?.resetDb?.title ?? '重置数据库',
|
||||
desc: locale.value?.cards?.resetDb?.desc ?? '清空除管理员外的系统数据',
|
||||
icon: Trash2,
|
||||
colorClass: 'error',
|
||||
btnText: locale.value?.cards?.resetDb?.button ?? '重置数据库',
|
||||
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(() => [
|
||||
{
|
||||
key: 'includeSongs',
|
||||
label: locale.value?.backupOptions?.songs?.label ?? '歌曲与排期数据',
|
||||
desc: locale.value?.backupOptions?.songs?.desc ?? '包含歌曲、投稿及排期数据'
|
||||
},
|
||||
{
|
||||
key: 'includeSystemData',
|
||||
label: locale.value?.backupOptions?.system?.label ?? '系统配置',
|
||||
desc: locale.value?.backupOptions?.system?.desc ?? '包含站点设置和全局参数'
|
||||
},
|
||||
{
|
||||
key: 'includeUsers',
|
||||
label: locale.value?.backupOptions?.users?.label ?? '用户账户',
|
||||
desc: locale.value?.backupOptions?.users?.desc ?? '包含用户账户和权限数据'
|
||||
}
|
||||
])
|
||||
|
||||
// 表单数据
|
||||
const createForm = ref({
|
||||
includeSongs: true,
|
||||
includeUsers: true,
|
||||
includeSystemData: true
|
||||
})
|
||||
|
||||
const restoreForm = ref({
|
||||
mode: 'merge',
|
||||
overwriteSuperAdmin: false
|
||||
})
|
||||
|
||||
const sequenceForm = ref({
|
||||
table: 'all'
|
||||
})
|
||||
|
||||
// 辅助函数
|
||||
const isLoading = (id) => {
|
||||
if (id === 'backup') return createLoading.value
|
||||
if (id === 'restore') return uploadLoading.value
|
||||
if (id === 'reset-seq') return sequenceLoading.value
|
||||
if (id === 'reset-db') return resetLoading.value
|
||||
return false
|
||||
}
|
||||
|
||||
const openModal = (id) => {
|
||||
activeModal.value = id
|
||||
if (id === 'reset-db') {
|
||||
resetConfirmText.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 文件处理
|
||||
const parseBackupSuperAdmin = async (file) => {
|
||||
try {
|
||||
const fileContent = await file.text()
|
||||
const backupData = JSON.parse(fileContent)
|
||||
const users = Array.isArray(backupData?.data?.users) ? backupData.data.users : []
|
||||
hasSuperAdminInBackup.value = users.some((item) => item?.role === 'SUPER_ADMIN')
|
||||
if (!hasSuperAdminInBackup.value) {
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
}
|
||||
} catch (error) {
|
||||
hasSuperAdminInBackup.value = false
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
showNotification(getMessage('parseBackupFailed'), 'error')
|
||||
console.error(getLogMessage('parseBackupFailed'), error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file && file.type === 'application/json') {
|
||||
selectedFile.value = file
|
||||
await parseBackupSuperAdmin(file)
|
||||
} else {
|
||||
showNotification(getMessage('invalidJsonFile'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileDrop = async (event) => {
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && file.type === 'application/json') {
|
||||
selectedFile.value = file
|
||||
await parseBackupSuperAdmin(file)
|
||||
} else {
|
||||
showNotification(getMessage('invalidJsonFile'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// API 操作
|
||||
const createBackup = async () => {
|
||||
createLoading.value = true
|
||||
try {
|
||||
let tables = 'all'
|
||||
if (createForm.value.includeUsers && createForm.value.includeSongs) {
|
||||
tables = 'all'
|
||||
} else if (createForm.value.includeUsers) {
|
||||
tables = 'users'
|
||||
} else if (createForm.value.includeSongs) {
|
||||
tables = ['songs', 'scheduleSongPool']
|
||||
} else if (createForm.value.includeSystemData) {
|
||||
tables = ['systemSettings']
|
||||
} else {
|
||||
throw new Error(getErrorMessage('noBackupContent'))
|
||||
}
|
||||
|
||||
const response = await $fetch('/api/admin/backup/export', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tables,
|
||||
includeSystemData: createForm.value.includeSystemData
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success && response.backup) {
|
||||
if (response.backup.downloadMode === 'direct' && response.backup.data) {
|
||||
const blob = new Blob([JSON.stringify(response.backup.data, null, 2)], {
|
||||
type: 'application/json'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = response.backup.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showNotification(getMessage('backupDownloaded'), 'success')
|
||||
activeModal.value = 'none'
|
||||
} else if (response.backup.downloadMode === 'file' && response.backup.filename) {
|
||||
const downloadUrl = `/api/admin/backup/download/${response.backup.filename}`
|
||||
const downloadResponse = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
})
|
||||
if (!downloadResponse.ok) throw new Error(getErrorMessage('downloadFailed', downloadResponse.status))
|
||||
const blob = await downloadResponse.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = response.backup.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showNotification(getMessage('backupDownloaded'), 'success')
|
||||
activeModal.value = 'none'
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || getErrorMessage('createBackupFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(getLogMessage('createBackupFailed'), error)
|
||||
showNotification(getErrorMessage('createBackupFailedWithMessage', error?.message), 'error')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const restoreBackup = async () => {
|
||||
if (!selectedFile.value) return showNotification(getMessage('selectBackupFile'), 'error')
|
||||
|
||||
uploadLoading.value = true
|
||||
restoreProgress.value = getProgressMessage('readingFile')
|
||||
|
||||
try {
|
||||
const fileContent = await selectedFile.value.text()
|
||||
let backupData
|
||||
try {
|
||||
backupData = JSON.parse(fileContent)
|
||||
} catch {
|
||||
throw new Error(getErrorMessage('invalidBackupFormat'))
|
||||
}
|
||||
|
||||
if (!backupData.data) throw new Error(getErrorMessage('missingBackupData'))
|
||||
const fileHasSuperAdmin = hasSuperAdminInBackup.value
|
||||
if (!fileHasSuperAdmin) {
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
}
|
||||
|
||||
let preservedSuperAdminIds = []
|
||||
let temporaryPreservedUserId = null
|
||||
|
||||
if (restoreForm.value.mode === 'replace') {
|
||||
restoreProgress.value = getProgressMessage('clearingData')
|
||||
const clearResult = await $fetch('/api/admin/backup/clear', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
|
||||
hasSuperAdminInBackup: fileHasSuperAdmin
|
||||
}
|
||||
})
|
||||
if (!clearResult.success) throw new Error(clearResult.message || getErrorMessage('clearDataFailed'))
|
||||
preservedSuperAdminIds = clearResult.preservedSuperAdminIds || []
|
||||
temporaryPreservedUserId = clearResult.temporaryPreservedUserId || null
|
||||
}
|
||||
|
||||
const tableOrder = [
|
||||
'users',
|
||||
'userIdentities',
|
||||
'systemSettings',
|
||||
'semesters',
|
||||
'playTimes',
|
||||
'songs',
|
||||
'scheduleSongPool',
|
||||
'votes',
|
||||
'schedules',
|
||||
'notificationSettings',
|
||||
'notifications',
|
||||
'songBlacklist',
|
||||
'userStatusLogs'
|
||||
]
|
||||
|
||||
const mappings = {
|
||||
users: {},
|
||||
songs: {},
|
||||
meta: {
|
||||
preservedSuperAdminIds,
|
||||
temporaryPreservedUserId
|
||||
}
|
||||
}
|
||||
const CHUNK_SIZE = 50
|
||||
let totalProcessed = 0
|
||||
let totalRecords = 0
|
||||
|
||||
for (const tableName of tableOrder) {
|
||||
if (backupData.data[tableName] && Array.isArray(backupData.data[tableName])) {
|
||||
totalRecords += backupData.data[tableName].length
|
||||
}
|
||||
}
|
||||
|
||||
for (const tableName of tableOrder) {
|
||||
const records = backupData.data[tableName]
|
||||
if (!records || !Array.isArray(records) || records.length === 0) continue
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
const response = await $fetch('/api/admin/backup/restore-chunk', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tableName,
|
||||
records: chunk,
|
||||
mappings,
|
||||
mode: restoreForm.value.mode,
|
||||
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
|
||||
hasSuperAdminInBackup: fileHasSuperAdmin
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.success) throw new Error(response.message || getErrorMessage('restoreTableFailed', 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)
|
||||
}
|
||||
totalProcessed += chunk.length
|
||||
}
|
||||
}
|
||||
|
||||
restoreProgress.value = getProgressMessage('fixingSequence')
|
||||
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'))
|
||||
}
|
||||
|
||||
restoreProgress.value = getProgressMessage('reloadingSmtp')
|
||||
const smtpReloadResult = await $fetch('/api/admin/smtp/reload', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!smtpReloadResult.success) {
|
||||
throw new Error(smtpReloadResult.message || getErrorMessage('smtpReloadFailed'))
|
||||
}
|
||||
|
||||
const shouldFinalizeTempUser =
|
||||
restoreForm.value.mode === 'replace' &&
|
||||
restoreForm.value.overwriteSuperAdmin &&
|
||||
fileHasSuperAdmin &&
|
||||
temporaryPreservedUserId
|
||||
|
||||
if (shouldFinalizeTempUser) {
|
||||
const restoredUserIds = Object.values(mappings.users).map((id) => Number(id))
|
||||
if (!restoredUserIds.includes(Number(temporaryPreservedUserId))) {
|
||||
restoreProgress.value = getProgressMessage('finalizingAdmin')
|
||||
await $fetch('/api/admin/backup/clear', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
finalizeTempUser: true
|
||||
}
|
||||
})
|
||||
}
|
||||
showNotification(getMessage('restoreSuccessRelogin'), 'success')
|
||||
activeModal.value = 'none'
|
||||
setTimeout(() => {
|
||||
if (auth.logout) {
|
||||
auth.logout()
|
||||
}
|
||||
localStorage.removeItem('auth-token')
|
||||
localStorage.removeItem('user-info')
|
||||
window.location.href = '/'
|
||||
}, 1200)
|
||||
return
|
||||
}
|
||||
|
||||
showNotification(getMessage('restoreSuccess'), 'success')
|
||||
activeModal.value = 'none'
|
||||
} catch (error) {
|
||||
console.error(getLogMessage('restoreBackupFailed'), error)
|
||||
showNotification(getErrorMessage('restoreBackupFailedWithMessage', error?.message), 'error')
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
restoreProgress.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const resetSequence = async () => {
|
||||
if (!sequenceForm.value.table) return
|
||||
sequenceLoading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/fix-sequence', {
|
||||
method: 'POST',
|
||||
body: { table: sequenceForm.value.table }
|
||||
})
|
||||
if (response.success) {
|
||||
showNotification(response.message || getMessage('sequenceResetSuccess'), 'success')
|
||||
activeModal.value = 'none'
|
||||
} else {
|
||||
throw new Error(response.error || response.message || getErrorMessage('resetFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(getLogMessage('resetSequenceFailed'), error)
|
||||
showNotification(getErrorMessage('resetSequenceFailedWithMessage', error?.message), 'error')
|
||||
} finally {
|
||||
sequenceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetDatabase = async () => {
|
||||
if (resetConfirmText.value !== CONFIRM_CODE) return
|
||||
resetLoading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/database/reset', { method: 'POST' })
|
||||
if (response.success) {
|
||||
showNotification(getMessage('databaseResetSuccess'), 'success')
|
||||
activeModal.value = 'none'
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} else {
|
||||
throw new Error(response.message || getErrorMessage('resetFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(getLogMessage('resetDatabaseFailed'), error)
|
||||
showNotification(getErrorMessage('resetDatabaseFailedWithMessage', error?.message), 'error')
|
||||
} finally {
|
||||
resetLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user