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

1358 lines
32 KiB
Vue

<template>
<div class="backup-manager">
<!-- 标题 -->
<div class="header">
<h3>{{ locale.title }}</h3>
<p class="description">{{ locale.desc }}</p>
</div>
<!-- 主要功能区 -->
<div class="actions-grid">
<div class="action-card">
<div class="action-icon">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
</div>
<div class="card-content">
<h4>{{ locale.exportTitle }}</h4>
<p>{{ locale.exportDesc }}</p>
<button
:disabled="createLoading"
class="action-btn primary"
@click="showCreateModal = true"
>
<span v-if="createLoading">{{ locale.exporting }}</span>
<span v-else>{{ locale.startExport }}</span>
</button>
</div>
</div>
<div class="action-card">
<div class="action-icon">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17,8 12,3 7,8" />
<line x1="12" x2="12" y1="3" y2="15" />
</svg>
</div>
<div class="card-content">
<h4>{{ locale.importTitle }}</h4>
<p>{{ locale.importDesc }}</p>
<button
:disabled="uploadLoading"
class="action-btn secondary"
@click="showUploadModal = true"
>
<span v-if="uploadLoading">{{ locale.importing }}</span>
<span v-else>{{ locale.chooseFile }}</span>
</button>
</div>
</div>
</div>
<!-- 创建备份模态框 -->
<div v-if="showCreateModal" class="backupmanager-modal-overlay" @click="showCreateModal = false">
<div class="modal" @click.stop>
<div class="modal-header">
<h3>{{ locale.createTitle }}</h3>
<button class="close-btn" @click="showCreateModal = false">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<line x1="18" x2="6" y1="6" y2="18" />
<line x1="6" x2="18" y1="6" y2="18" />
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ locale.backupType }}</label>
<div class="radio-group">
<label class="radio-option">
<input v-model="createForm.tables" type="radio" value="all" >
<span>{{ locale.fullBackup }}</span>
<small>{{ locale.fullBackupDesc }}</small>
</label>
<label class="radio-option">
<input v-model="createForm.tables" type="radio" value="users" >
<span>{{ locale.userDataOnly }}</span>
<small>{{ locale.userDataOnlyDesc }}</small>
</label>
</div>
</div>
<div class="form-group">
<label class="checkbox-option">
<input v-model="createForm.includeSystemData" type="checkbox" >
<span>{{ locale.includeSystemSettings }}</span>
<small>{{ locale.includeSystemSettingsDesc }}</small>
</label>
</div>
</div>
<div class="modal-footer">
<button class="action-btn secondary" @click="showCreateModal = false">{{ locale.cancel }}</button>
<button :disabled="createLoading" class="action-btn primary" @click="createBackup">
<span v-if="createLoading">{{ locale.creating }}</span>
<span v-else>{{ locale.createBackup }}</span>
</button>
</div>
</div>
</div>
<!-- 导入备份模态框 -->
<div v-if="showUploadModal" class="backupmanager-modal-overlay" @click="showUploadModal = false">
<div class="modal" @click.stop>
<div class="modal-header">
<h3>{{ locale.importFileTitle }}</h3>
<button class="close-btn" @click="showUploadModal = false">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<line x1="18" x2="6" y1="6" y2="18" />
<line x1="6" x2="18" y1="6" y2="18" />
</svg>
</button>
</div>
<div class="modal-body">
<div class="upload-section">
<div
:class="{ 'drag-over': isDragOver }"
class="upload-area"
@click="$refs.fileInput.click()"
@dragleave="isDragOver = false"
@drop="handleDrop"
@dragover.prevent="isDragOver = true"
>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17,8 12,3 7,8" />
<line x1="12" x2="12" y1="3" y2="15" />
</svg>
<h4>{{ locale.uploadTitle }}</h4>
<p>{{ locale.uploadDesc }}</p>
<input
ref="fileInput"
accept=".json,application/json"
style="display: none"
type="file"
@change="handleFileSelect"
>
</div>
<div v-if="selectedFile" class="selected-file">
<div class="file-info">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
</svg>
<div class="file-details">
<span class="file-name">{{ selectedFile.name }}</span>
<span class="file-size">{{ formatFileSize(selectedFile.size) }}</span>
</div>
</div>
<button
class="remove-file-btn"
@click="clearSelectedFile"
>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<line x1="18" x2="6" y1="6" y2="18" />
<line x1="6" x2="18" y1="6" y2="18" />
</svg>
</button>
</div>
</div>
<div class="form-group">
<label>{{ locale.restoreMode }}</label>
<div class="radio-group">
<label class="radio-option">
<input v-model="restoreForm.mode" type="radio" value="merge" >
<span>{{ locale.mergeMode }}</span>
<small>{{ locale.mergeModeDesc }}</small>
</label>
<label class="radio-option">
<input v-model="restoreForm.mode" type="radio" value="replace" >
<span>{{ locale.replaceMode }}</span>
<small>{{ locale.replaceModeDesc }}</small>
</label>
</div>
</div>
<div v-if="restoreForm.mode === 'replace'" class="form-group">
<label class="checkbox-option danger">
<input v-model="restoreForm.clearExisting" type="checkbox" >
<span>{{ locale.confirmClear }}</span>
<small>{{ locale.confirmClearDesc }}</small>
</label>
</div>
<div
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
class="form-group"
>
<label class="checkbox-option">
<input v-model="restoreForm.overwriteSuperAdmin" type="checkbox" >
<span>{{ locale.overwriteSuperAdmin }}</span>
<small>{{ locale.overwriteSuperAdminDesc }}</small>
</label>
</div>
<div class="warning-box">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>
<div>
<h4>{{ locale.warningTitle }}</h4>
<p>{{ locale.warningDesc }}</p>
</div>
</div>
</div>
<div class="modal-footer">
<button class="action-btn secondary" @click="showUploadModal = false">{{ locale.cancel }}</button>
<button
:disabled="
!selectedFile ||
uploadLoading ||
(restoreForm.mode === 'replace' && !restoreForm.clearExisting)
"
class="action-btn primary"
@click="uploadFile"
>
<span v-if="uploadLoading">{{ locale.importing }}</span>
<span v-else>{{ locale.startImport }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useAuth } from '~/composables/useAuth'
import { useLocale } from '~/utils/locale'
const { admin } = useLocale()
const locale = computed(() => admin.value?.backupManager || {})
const { msg: getLocaleText } = useLocaleText(locale)
// 响应式数据
const createLoading = ref(false)
const uploadLoading = ref(false)
const showCreateModal = ref(false)
const showUploadModal = ref(false)
const selectedFile = ref(null)
const isDragOver = ref(false)
const hasSuperAdminInBackup = ref(false)
// 表单数据
const createForm = ref({
tables: 'all',
includeSystemData: true
})
const getDefaultRestoreForm = () => ({
mode: 'merge',
clearExisting: false,
overwriteSuperAdmin: false
})
const restoreForm = ref(getDefaultRestoreForm())
// 创建备份
const createBackup = async () => {
createLoading.value = true
try {
const response = await $fetch('/api/admin/backup/export', {
method: 'POST',
body: {
tables: createForm.value.tables,
includeSystemData: createForm.value.includeSystemData
}
})
console.log('服务器响应:', response)
if (response.success && response.backup) {
const { backup } = response
// 强制进行浏览器下载
let dataToDownload
if (backup.downloadMode === 'direct' && backup.data) {
// 直接下载模式:服务器返回了完整数据
dataToDownload = backup.data
console.log('使用直接下载模式')
// 创建并下载文件
try {
const dataStr = JSON.stringify(dataToDownload, null, 2)
const blob = new Blob([dataStr], {
type: 'application/json;charset=utf-8'
})
// 创建下载链接
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = backup.filename
link.style.display = 'none'
// 添加到DOM,点击,然后移除
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 清理URL对象
URL.revokeObjectURL(url)
// 显示成功消息
const sizeText = backup.size ? ` (${formatFileSize(backup.size)})` : ''
if (window.$showNotification) {
window.$showNotification(getLocaleText('downloaded', backup.filename, sizeText), 'success')
}
console.log('✅ 文件下载成功:', backup.filename)
} catch (downloadError) {
console.error('下载失败:', downloadError)
if (window.$showNotification) {
window.$showNotification(getLocaleText('fileDownloadFailed', downloadError.message), 'error')
}
}
} else if (backup.downloadMode === 'file' && backup.filename) {
// 文件下载模式:通过API下载文件
console.log('使用文件下载模式')
try {
// 使用 $fetch 进行认证请求,然后创建下载
const response = await $fetch(
`/api/admin/backup/download?filename=${encodeURIComponent(backup.filename)}`,
{
method: 'GET'
}
)
// 创建 Blob 并下载
const blob = new Blob([response], {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = backup.filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
// 显示成功消息
const sizeText = backup.size ? ` (${formatFileSize(backup.size)})` : ''
if (window.$showNotification) {
window.$showNotification(getLocaleText('downloaded', backup.filename, sizeText), 'success')
}
console.log('✅ 文件下载成功:', backup.filename)
} catch (downloadError) {
console.error('文件下载失败:', downloadError)
if (window.$showNotification) {
window.$showNotification(getLocaleText('fileDownloadFailed', downloadError.message), 'error')
}
}
} else {
console.error('无效的下载模式或缺少数据')
if (window.$showNotification) {
window.$showNotification(locale.value?.invalidResponse || '服务器返回了无效响应', 'error')
}
showCreateModal.value = false
return
}
} else {
console.error('服务器响应格式错误:', response)
if (window.$showNotification) {
window.$showNotification(locale.value?.serverResponseInvalid || '服务器响应格式无效', 'error')
}
}
showCreateModal.value = false
} catch (error) {
console.error('创建备份失败:', error)
const errorMessage = error.data?.message || error.message || locale.value?.unknownError || '未知错误'
if (window.$showNotification) {
window.$showNotification(getLocaleText('createFailed', errorMessage), 'error')
}
} finally {
createLoading.value = false
}
}
// 文件选择处理
const parseBackupSuperAdmin = async (file) => {
try {
const text = await file.text()
const backupData = JSON.parse(text)
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
if (window.$showNotification) {
window.$showNotification(locale.value?.parseFailed || '备份文件解析失败', 'error')
}
console.error('解析备份文件失败:', error)
}
}
const clearSelectedFile = () => {
selectedFile.value = null
hasSuperAdminInBackup.value = false
restoreForm.value.overwriteSuperAdmin = false
}
const handleFileSelect = async (event) => {
const file = event.target.files[0]
if (file) {
selectedFile.value = file
await parseBackupSuperAdmin(file)
}
}
// 拖拽处理
const handleDrop = async (event) => {
event.preventDefault()
isDragOver.value = false
const files = event.dataTransfer.files
if (files.length > 0) {
selectedFile.value = files[0]
await parseBackupSuperAdmin(files[0])
}
}
// 上传文件
const uploadFile = async () => {
if (!selectedFile.value) return
uploadLoading.value = true
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
formData.append('mode', restoreForm.value.mode)
formData.append('clearExisting', restoreForm.value.clearExisting)
formData.append('overwriteSuperAdmin', restoreForm.value.overwriteSuperAdmin)
const response = await $fetch('/api/admin/backup/restore', {
method: 'POST',
body: formData
})
if (response.success) {
// 关闭模态框并重置表单
showUploadModal.value = false
clearSelectedFile()
restoreForm.value = getDefaultRestoreForm()
// 显示成功通知
if (window.$showNotification) {
window.$showNotification(
getLocaleText('restoreSuccess', response.details?.tablesProcessed || 0, response.details?.recordsRestored || 0),
'success'
)
// 如果有错误,显示警告
if (response.details?.errors && response.details.errors.length > 0) {
setTimeout(() => {
window.$showNotification(
getLocaleText('restoreWarning', response.details.errors.length),
'warning'
)
}, 1000)
}
// 显示即将重定向的通知
setTimeout(() => {
window.$showNotification(locale.value?.restoreCompleteRedirect || '恢复完成,即将刷新页面', 'info')
}, 2000)
}
// 清除认证状态并重定向到首页
setTimeout(() => {
const { logout } = useAuth()
if (logout) {
logout()
}
// 清除本地存储的认证信息
localStorage.removeItem('auth-token')
localStorage.removeItem('user-info')
// 重定向到首页
window.location.href = '/'
}, 5000)
} else {
if (window.$showNotification) {
window.$showNotification(getLocaleText('importFailed', response.message || locale.value.unknownError), 'error')
}
}
} catch (error) {
console.error('导入备份失败:', error)
if (window.$showNotification) {
window.$showNotification(getLocaleText('importFailed', error.data?.message || error.message), 'error')
}
} finally {
uploadLoading.value = false
}
}
// 格式化文件大小
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
/* 主容器 */
.backup-manager {
min-height: 100vh;
background: var(--panel-bg-deep);
color: var(--panel-border-light);
padding: 2rem;
position: relative;
}
/* 头部区域 */
.header {
text-align: center;
margin-bottom: 3rem;
}
.header h3 {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 1rem 0;
}
.header .description {
font-size: 1.125rem;
color: var(--text-muted-dark);
margin: 0;
}
/* 操作卡片网格 */
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
}
/* 操作卡片 */
.action-card {
background: var(--panel-border);
border: 1px solid var(--panel-border-dark);
border-radius: 12px;
padding: 2rem;
text-align: center;
transition: all 0.2s ease;
}
.action-card:hover {
border-color: var(--panel-border-dark);
background: var(--panel-border-subtle);
}
.action-card .card-content {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
min-height: 200px;
}
/* 图标样式 */
.action-icon {
width: 4rem;
height: 4rem;
margin: 0 auto 1.5rem;
background: var(--panel-border-dark);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
}
.action-icon svg {
width: 2rem;
height: 2rem;
}
/* 卡片文本 */
.action-card h4 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.75rem 0;
}
.action-card p {
color: var(--text-muted-dark);
font-size: 1rem;
margin: 0 0 2rem 0;
line-height: 1.6;
flex-grow: 1;
}
/* 按钮样式 */
.action-btn {
width: 100%;
padding: 1rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: block;
text-align: center;
color: white;
box-sizing: border-box;
margin-bottom: 0.5rem;
}
.action-btn.primary {
background: var(--color-accent);
}
.action-btn.primary:hover {
background: var(--color-accent);
}
.action-btn.secondary {
background: var(--panel-border-dark);
}
.action-btn.secondary:hover {
background: var(--panel-border-dark);
}
.action-btn:disabled {
background: var(--panel-border-dark) !important;
color: var(--text-muted) !important;
cursor: not-allowed;
opacity: 0.6;
}
.action-btn svg {
width: 1.125rem;
height: 1.125rem;
}
/* 模态框样式 */
.backupmanager-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--modal-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1.5rem;
}
.modal {
background: var(--panel-border);
border: 1px solid var(--panel-border-dark);
border-radius: 12px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.progress-modal {
max-width: 600px;
}
.progress-info {
padding: 20px;
}
.current-status h4 {
margin: 0 0 15px 0;
color: var(--color-accent);
font-size: 18px;
}
.progress-stats p {
margin: 5px 0;
color: var(--text-muted-dark);
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--panel-border-dark);
border-radius: 4px;
margin: 15px 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent));
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-details,
.progress-errors {
margin-top: 20px;
}
.progress-details h5,
.progress-errors h5 {
margin: 0 0 10px 0;
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
.details-list,
.errors-list {
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--panel-border-dark);
border-radius: 6px;
padding: 10px;
background: var(--text-primary);
}
.detail-item {
padding: 4px 0;
color: var(--text-muted-dark);
font-size: 13px;
border-bottom: 1px solid var(--panel-border-dark);
}
.detail-item:last-child {
border-bottom: none;
}
.error-item {
padding: 4px 0;
color: var(--color-error-light);
font-size: 13px;
border-bottom: 1px solid var(--panel-bg-darker);
}
.error-item:last-child {
border-bottom: none;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem 2rem 0 2rem;
margin-bottom: 1.5rem;
}
.modal-title {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.close-btn {
background: var(--panel-border-dark);
border: none;
color: var(--text-muted-dark);
cursor: pointer;
padding: 0.75rem;
border-radius: 8px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--panel-border-dark);
color: var(--text-primary);
}
.close-btn svg {
width: 1.25rem;
height: 1.25rem;
}
.modal-body {
padding: 0 2rem 2rem 2rem;
}
.modal-footer {
padding: 1.5rem 2rem;
border-top: 1px solid var(--panel-border-dark);
display: flex;
gap: 1rem;
justify-content: flex-end;
background: var(--panel-border-subtle);
}
.modal-footer button {
min-width: 120px;
}
/* 表单样式 */
.form-group {
margin-bottom: 2rem;
}
.form-group label {
display: block;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.radio-option,
.checkbox-option {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
background: var(--text-primary);
border: 1px solid var(--panel-border-dark);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.radio-option:hover,
.checkbox-option:hover {
border-color: var(--panel-border-dark);
background: var(--panel-border-dark);
}
.radio-option input[type='radio'],
.checkbox-option input[type='checkbox'] {
width: 1.25rem;
height: 1.25rem;
margin: 0;
accent-color: var(--color-accent);
cursor: pointer;
}
.radio-option > div,
.checkbox-option > div {
flex: 1;
}
.radio-option span,
.checkbox-option span {
display: block;
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.radio-option small,
.checkbox-option small {
display: block;
font-size: 0.875rem;
color: var(--text-muted-dark);
line-height: 1.4;
}
.checkbox-option.danger {
border-color: var(--color-error-hover);
background: var(--backup-manager-danger-bg);
}
.checkbox-option.danger span {
color: var(--color-error-light);
}
.checkbox-option.danger small {
color: var(--color-error-light);
}
/* 文件上传区域 */
.upload-section {
margin-bottom: 2rem;
}
.upload-area {
border: 2px dashed var(--panel-border-dark);
border-radius: 8px;
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--text-primary);
}
.upload-area:hover,
.upload-area.drag-over {
border-color: var(--color-accent);
background: var(--panel-border-dark);
}
.upload-area svg {
width: 4rem;
height: 4rem;
color: var(--color-accent);
margin-bottom: 1.5rem;
}
.upload-area h4 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
}
.upload-area p {
color: var(--text-muted-dark);
font-size: 1rem;
margin: 0;
}
.selected-file {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem;
background: var(--text-primary);
border: 1px solid var(--panel-border-dark);
border-radius: 8px;
margin-top: 1rem;
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
}
.file-info svg {
width: 1.5rem;
height: 1.5rem;
color: var(--color-accent);
flex-shrink: 0;
}
.file-details {
flex: 1;
}
.file-name {
display: block;
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.file-size {
display: block;
font-size: 0.875rem;
color: var(--text-muted-dark);
}
.remove-file-btn {
background: var(--backup-manager-file-remove-bg);
border: none;
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: var(--color-error-light);
}
.remove-file-btn:hover {
background: var(--backup-manager-file-remove-bg-hover);
}
.remove-file-btn svg {
width: 1rem;
height: 1rem;
}
/* 警告框 */
.warning-box {
background: var(--backup-manager-warning-box-bg);
border: 1px solid var(--color-orange);
border-radius: 8px;
padding: 1.5rem;
display: flex;
gap: 1rem;
align-items: flex-start;
margin-bottom: 2rem;
}
.warning-box svg {
width: 1.5rem;
height: 1.5rem;
color: var(--color-orange);
flex-shrink: 0;
margin-top: 0.125rem;
}
.warning-box > div {
flex: 1;
}
.warning-box h4 {
color: var(--color-orange);
font-weight: 600;
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.warning-box p {
color: var(--color-orange);
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
}
/* 通知样式 */
.notification {
position: fixed;
top: 1.5rem;
right: 1.5rem;
padding: 1rem 1.5rem;
border-radius: 8px;
color: white;
font-weight: 500;
box-shadow: 0 4px 12px var(--mask-30);
z-index: 1001;
animation: slideInRight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 300px;
max-width: 500px;
}
.notification.success {
background: var(--color-success);
border: 1px solid var(--color-success);
}
.notification.error {
background: var(--color-error-hover);
border: 1px solid var(--color-error);
}
.notification.warning {
background: var(--color-orange);
border: 1px solid var(--color-orange);
}
.notification.info {
background: var(--color-accent-hover);
border: 1px solid var(--color-accent);
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes slideOutRight {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
}
/* 响应式设计 */
@media (max-width: 1024px) {
.actions-grid {
grid-template-columns: 1fr;
max-width: 600px;
}
}
@media (max-width: 768px) {
.backup-manager {
padding: 1.5rem;
}
.header h3 {
font-size: 1.75rem;
}
.actions-grid {
gap: 1.5rem;
grid-template-columns: 1fr;
}
.action-card {
padding: 2rem;
}
.action-card .card-content {
width: 100%;
display: flex;
flex-direction: column;
height: auto;
min-height: 180px;
}
.action-card p {
flex-grow: 1;
margin-bottom: 1.5rem;
}
.action-btn {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
padding: 1rem 1.5rem !important;
font-size: 1rem !important;
display: block !important;
text-align: center !important;
border-radius: 8px !important;
box-sizing: border-box !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.75rem !important;
}
.modal {
margin: 1rem;
max-height: calc(100vh - 2rem);
}
.notification {
top: 1rem;
right: 1rem;
left: 1rem;
max-width: none;
}
}
@media (max-width: 480px) {
.backup-manager {
padding: 1rem;
}
.header {
margin-bottom: 2rem;
}
.header h3 {
font-size: 1.5rem;
}
.actions-grid {
gap: 1rem;
grid-template-columns: 1fr;
width: 100%;
}
.action-card {
padding: 1.5rem;
width: 100%;
box-sizing: border-box;
}
.action-card .card-content {
width: 100%;
display: flex;
flex-direction: column;
height: auto;
min-height: 160px;
}
.action-icon {
width: 3rem;
height: 3rem;
margin-bottom: 1rem;
}
.action-icon svg {
width: 1.5rem;
height: 1.5rem;
}
.action-card h4 {
font-size: 1.25rem;
margin-bottom: 0.75rem;
}
.action-card p {
font-size: 0.9rem;
margin-bottom: 1.5rem;
flex-grow: 1;
}
.action-btn {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
padding: 1rem !important;
font-size: 0.95rem !important;
font-weight: 600 !important;
display: block !important;
text-align: center !important;
border-radius: 8px !important;
box-sizing: border-box !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
margin-bottom: 0.75rem !important;
}
.action-btn span {
display: inline-block;
width: 100%;
}
.action-btn svg {
display: none !important;
}
.modal {
margin: 0.5rem;
max-width: calc(100vw - 1rem);
border-radius: 1rem;
}
.modal-header {
padding: 1.25rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-body {
padding: 0 1.25rem 1.25rem;
}
.modal-footer {
padding: 1rem 1.25rem;
flex-direction: column;
gap: 0.75rem;
}
.modal-footer .action-btn {
width: 100% !important;
order: 2;
}
.modal-footer .action-btn.primary {
order: 1;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
font-size: 1rem;
margin-bottom: 0.75rem;
}
.radio-option,
.checkbox-option {
padding: 1rem;
gap: 0.75rem;
}
.radio-option input[type='radio'],
.checkbox-option input[type='checkbox'] {
width: 1.125rem;
height: 1.125rem;
}
.upload-area {
padding: 1.5rem 1rem;
}
.upload-area svg {
width: 2.5rem;
height: 2.5rem;
margin-bottom: 1rem;
}
.upload-area h4 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.upload-area p {
font-size: 0.85rem;
}
.selected-file {
margin-top: 1rem;
}
.file-info {
gap: 0.75rem;
}
.file-name {
font-size: 0.875rem;
}
.file-size {
font-size: 0.75rem;
}
.warning-box {
padding: 1rem;
gap: 0.75rem;
}
.warning-box h4 {
font-size: 0.875rem;
}
.warning-box p {
font-size: 0.8rem;
}
}
</style>