Download dashboard/src/components/shared/BackupDialog.vue from qa1145/astrbbbb: direct link, hf CLI and curl.
- Browser
- Download file 41.3 kB
-
https://huggingface.co/spaces/qa1145/astrbbbb/resolve/main/dashboard/src/components/shared/BackupDialog.vue
- Command line
-
hf download hf://spaces/qa1145/astrbbbb/dashboard/src/components/shared/BackupDialog.vue
-
curl -L -o BackupDialog.vue https://huggingface.co/spaces/qa1145/astrbbbb/resolve/main/dashboard/src/components/shared/BackupDialog.vue
41.3 kB
| <template> | |
| <v-dialog v-model="isOpen" persistent max-width="700" scrollable> | |
| <v-card> | |
| <v-card-title class="d-flex align-center"> | |
| <v-icon class="mr-2">mdi-backup-restore</v-icon> | |
| {{ t('features.settings.backup.dialog.title') }} | |
| </v-card-title> | |
| <v-card-text class="pa-6"> | |
| <!-- 选项卡 --> | |
| <v-tabs v-model="activeTab" color="primary" class="mb-4"> | |
| <v-tab value="export"> | |
| <v-icon class="mr-2">mdi-export</v-icon> | |
| {{ t('features.settings.backup.tabs.export') }} | |
| </v-tab> | |
| <v-tab value="import"> | |
| <v-icon class="mr-2">mdi-import</v-icon> | |
| {{ t('features.settings.backup.tabs.import') }} | |
| </v-tab> | |
| <v-tab value="list"> | |
| <v-icon class="mr-2">mdi-format-list-bulleted</v-icon> | |
| {{ t('features.settings.backup.tabs.list') }} | |
| </v-tab> | |
| </v-tabs> | |
| <v-window v-model="activeTab"> | |
| <!-- 导出标签页 --> | |
| <v-window-item value="export"> | |
| <div v-if="exportStatus === 'idle'" class="text-center py-8"> | |
| <v-icon size="64" color="primary" class="mb-4">mdi-cloud-upload</v-icon> | |
| <h3 class="mb-4">{{ t('features.settings.backup.export.title') }}</h3> | |
| <p class="mb-4 text-grey">{{ t('features.settings.backup.export.description') }}</p> | |
| <v-alert type="info" variant="tonal" class="mb-4 text-left"> | |
| <template v-slot:prepend> | |
| <v-icon>mdi-information</v-icon> | |
| </template> | |
| {{ t('features.settings.backup.export.includes') }} | |
| </v-alert> | |
| <v-btn color="primary" size="large" @click="startExport" :loading="exportStatus === 'processing'"> | |
| <v-icon class="mr-2">mdi-export</v-icon> | |
| {{ t('features.settings.backup.export.button') }} | |
| </v-btn> | |
| </div> | |
| <div v-else-if="exportStatus === 'processing'" class="text-center py-8"> | |
| <v-progress-circular indeterminate color="primary" size="64" class="mb-4"></v-progress-circular> | |
| <h3 class="mb-4">{{ t('features.settings.backup.export.processing') }}</h3> | |
| <p class="text-grey">{{ exportProgress.message || t('features.settings.backup.export.wait') }}</p> | |
| <v-progress-linear :model-value="exportProgress.current" :max="exportProgress.total" class="mt-4" color="primary"></v-progress-linear> | |
| </div> | |
| <div v-else-if="exportStatus === 'completed'" class="text-center py-8"> | |
| <v-icon size="64" color="success" class="mb-4">mdi-check-circle</v-icon> | |
| <h3 class="mb-4">{{ t('features.settings.backup.export.completed') }}</h3> | |
| <p class="mb-4">{{ exportResult?.filename }}</p> | |
| <v-btn color="primary" @click="downloadBackup(exportResult?.filename)" class="mr-2"> | |
| <v-icon class="mr-2">mdi-download</v-icon> | |
| {{ t('features.settings.backup.export.download') }} | |
| </v-btn> | |
| <v-btn color="grey" variant="text" @click="resetExport"> | |
| {{ t('features.settings.backup.export.another') }} | |
| </v-btn> | |
| </div> | |
| <div v-else-if="exportStatus === 'failed'" class="text-center py-8"> | |
| <v-icon size="64" color="error" class="mb-4">mdi-alert-circle</v-icon> | |
| <h3 class="mb-4">{{ t('features.settings.backup.export.failed') }}</h3> | |
| <v-alert type="error" variant="tonal" class="mb-4"> | |
| {{ exportError }} | |
| </v-alert> | |
| <v-btn color="primary" @click="resetExport"> | |
| {{ t('features.settings.backup.export.retry') }} | |
| </v-btn> | |
| </div> | |
| </v-window-item> | |
| <!-- 导入标签页 --> | |
| <v-window-item value="import"> | |
| <!-- 步骤1: 选择文件 --> | |
| <div v-if="importStatus === 'idle'" class="py-4"> | |
| <v-alert type="warning" variant="tonal" class="mb-4"> | |
| <template v-slot:prepend> | |
| <v-icon>mdi-alert</v-icon> | |
| </template> | |
| {{ t('features.settings.backup.import.warning') }} | |
| </v-alert> | |
| <v-file-input | |
| v-model="importFile" | |
| :label="t('features.settings.backup.import.selectFile')" | |
| accept=".zip" | |
| prepend-icon="mdi-file-upload" | |
| show-size | |
| class="mb-4" | |
| ></v-file-input> | |
| <div class="d-flex justify-center"> | |
| <v-btn | |
| color="primary" | |
| size="large" | |
| @click="uploadAndCheck" | |
| :disabled="!importFile" | |
| :loading="importStatus === 'uploading'" | |
| > | |
| <v-icon class="mr-2">mdi-upload</v-icon> | |
| {{ t('features.settings.backup.import.uploadAndCheck') }} | |
| </v-btn> | |
| </div> | |
| </div> | |
| <!-- 步骤1.5: 上传中 --> | |
| <div v-else-if="importStatus === 'uploading'" class="text-center py-8"> | |
| <v-icon size="64" color="primary" class="mb-4">mdi-cloud-upload</v-icon> | |
| <h3 class="mb-4">{{ t('features.settings.backup.import.uploading') }}</h3> | |
| <p class="text-grey mb-2"> | |
| {{ uploadProgress.message || t('features.settings.backup.import.uploadWait') }} | |
| </p> | |
| <p class="text-grey-darken-1 mb-4"> | |
| {{ formatFileSize(uploadProgress.uploaded) }} / {{ formatFileSize(uploadProgress.total) }} | |
| ({{ uploadProgress.percent }}%) | |
| </p> | |
| <v-progress-linear | |
| :model-value="uploadProgress.percent" | |
| :max="100" | |
| class="mt-2" | |
| color="primary" | |
| height="8" | |
| rounded | |
| ></v-progress-linear> | |
| </div> | |
| <!-- 步骤2: 确认导入 --> | |
| <div v-else-if="importStatus === 'confirm'" class="py-4"> | |
| <v-alert | |
| :type="versionAlertType" | |
| variant="tonal" | |
| class="mb-4" | |
| > | |
| <template v-slot:prepend> | |
| <v-icon>{{ versionAlertIcon }}</v-icon> | |
| </template> | |
| <div class="confirm-message"> | |
| <div class="text-h6 mb-2">{{ versionAlertTitle }}</div> | |
| <div class="mb-2"> | |
| <strong>{{ t('features.settings.backup.import.version.backupVersion') }}:</strong> {{ checkResult?.backup_version }}<br> | |
| <strong>{{ t('features.settings.backup.import.version.currentVersion') }}:</strong> {{ checkResult?.current_version }} | |
| </div> | |
| <div v-if="checkResult?.backup_time && checkResult?.backup_time !== '未知'" class="mb-2"> | |
| <strong>{{ t('features.settings.backup.import.version.backupTime') }}:</strong> {{ formatISODate(checkResult?.backup_time) }} | |
| </div> | |
| <div class="mt-3" style="white-space: pre-line;">{{ versionAlertMessage }}</div> | |
| </div> | |
| </v-alert> | |
| <!-- 备份摘要 --> | |
| <v-card variant="outlined" class="mb-4" v-if="checkResult?.backup_summary"> | |
| <v-card-title class="text-subtitle-1"> | |
| <v-icon class="mr-2">mdi-package-variant</v-icon> | |
| {{ t('features.settings.backup.import.backupContents') }} | |
| </v-card-title> | |
| <v-card-text> | |
| <div class="d-flex flex-wrap ga-2"> | |
| <v-chip v-if="checkResult.backup_summary.tables?.length" size="small" color="primary" variant="tonal" :ripple="false" class="non-interactive-chip"> | |
| {{ checkResult.backup_summary.tables.length }} {{ t('features.settings.backup.import.tables') }} | |
| </v-chip> | |
| <v-chip v-if="checkResult.backup_summary.has_knowledge_bases" size="small" color="success" variant="tonal" :ripple="false" class="non-interactive-chip"> | |
| {{ t('features.settings.backup.import.knowledgeBases') }} | |
| </v-chip> | |
| <v-chip v-if="checkResult.backup_summary.has_config" size="small" color="info" variant="tonal" :ripple="false" class="non-interactive-chip"> | |
| {{ t('features.settings.backup.import.configFiles') }} | |
| </v-chip> | |
| <v-chip v-for="dir in (checkResult.backup_summary.directories || [])" :key="dir" size="small" color="warning" variant="tonal" :ripple="false" class="non-interactive-chip"> | |
| {{ dir }} | |
| </v-chip> | |
| </div> | |
| </v-card-text> | |
| </v-card> | |
| <!-- 警告信息 --> | |
| <v-alert v-if="checkResult?.warnings?.length" type="warning" variant="tonal" class="mb-4"> | |
| <div v-for="(warning, idx) in checkResult.warnings" :key="idx">{{ warning }}</div> | |
| </v-alert> | |
| <div class="d-flex justify-center align-center mt-4" style="gap: 16px;"> | |
| <v-btn | |
| color="grey-darken-1" | |
| variant="outlined" | |
| size="large" | |
| @click="resetImport" | |
| > | |
| <v-icon class="mr-2">mdi-close</v-icon> | |
| {{ t('core.common.cancel') }} | |
| </v-btn> | |
| <v-btn | |
| v-if="checkResult?.can_import" | |
| color="error" | |
| size="large" | |
| variant="flat" | |
| @click="confirmImport" | |
| > | |
| <v-icon class="mr-2">mdi-alert</v-icon> | |
| {{ t('features.settings.backup.import.confirmImport') }} | |
| </v-btn> | |
| </div> | |
| </div> | |
| <!-- 步骤3: 导入进行中 --> | |
| <div v-else-if="importStatus === 'processing'" class="text-center py-8"> | |
| <v-progress-circular indeterminate color="primary" size="64" class="mb-4"></v-progress-circular> | |
| <h3 class="mb-4">{{ t('features.settings.backup.import.processing') }}</h3> | |
| <p class="text-grey">{{ importProgress.message || t('features.settings.backup.import.wait') }}</p> | |
| <v-progress-linear :model-value="importProgress.current" :max="importProgress.total" class="mt-4" color="primary"></v-progress-linear> | |
| </div> | |
| <div v-else-if="importStatus === 'completed'" class="text-center py-8"> | |
| <v-icon size="64" color="success" class="mb-4">mdi-check-circle</v-icon> | |
| <h3 class="mb-4">{{ t('features.settings.backup.import.completed') }}</h3> | |
| <v-alert type="info" variant="tonal" class="mb-4"> | |
| {{ t('features.settings.backup.import.restartRequired') }} | |
| </v-alert> | |
| <v-btn color="primary" @click="restartAstrBot" class="mr-2"> | |
| <v-icon class="mr-2">mdi-restart</v-icon> | |
| {{ t('features.settings.backup.import.restartNow') }} | |
| </v-btn> | |
| <v-btn color="grey" variant="text" @click="resetImport"> | |
| {{ t('core.common.close') }} | |
| </v-btn> | |
| </div> | |
| <div v-else-if="importStatus === 'failed'" class="text-center py-8"> | |
| <v-icon size="64" color="error" class="mb-4">mdi-alert-circle</v-icon> | |
| <h3 class="mb-4">{{ t('features.settings.backup.import.failed') }}</h3> | |
| <v-alert type="error" variant="tonal" class="mb-4"> | |
| {{ importError }} | |
| </v-alert> | |
| <v-btn color="primary" @click="resetImport"> | |
| {{ t('features.settings.backup.import.retry') }} | |
| </v-btn> | |
| </div> | |
| </v-window-item> | |
| <!-- 备份列表标签页 --> | |
| <v-window-item value="list"> | |
| <div v-if="loadingList" class="text-center py-8"> | |
| <v-progress-circular indeterminate color="primary"></v-progress-circular> | |
| </div> | |
| <div v-else-if="backupList.length === 0" class="text-center py-8"> | |
| <v-icon size="64" color="grey" class="mb-4">mdi-folder-open-outline</v-icon> | |
| <p class="text-grey">{{ t('features.settings.backup.list.empty') }}</p> | |
| </div> | |
| <v-list v-else lines="two"> | |
| <v-list-item | |
| v-for="backup in backupList" | |
| :key="backup.filename" | |
| > | |
| <template v-slot:prepend> | |
| <v-icon :color="backup.type === 'uploaded' ? 'orange' : 'primary'"> | |
| {{ backup.type === 'uploaded' ? 'mdi-upload' : 'mdi-zip-box' }} | |
| </v-icon> | |
| </template> | |
| <v-list-item-title>{{ backup.filename }}</v-list-item-title> | |
| <v-list-item-subtitle> | |
| {{ formatFileSize(backup.size) }} · {{ formatDate(backup.created_at) }} | |
| <v-chip size="x-small" color="primary" variant="tonal" class="ml-2"> | |
| v{{ backup.astrbot_version }} | |
| </v-chip> | |
| <v-chip v-if="backup.type === 'uploaded'" size="x-small" color="orange" variant="tonal" class="ml-1"> | |
| {{ t('features.settings.backup.list.uploaded') }} | |
| </v-chip> | |
| </v-list-item-subtitle> | |
| <template v-slot:append> | |
| <v-btn | |
| icon="mdi-restore" | |
| variant="text" | |
| size="small" | |
| color="success" | |
| :title="t('features.settings.backup.list.restore')" | |
| @click="restoreFromList(backup.filename)" | |
| ></v-btn> | |
| <v-btn | |
| icon="mdi-pencil" | |
| variant="text" | |
| size="small" | |
| :title="t('features.settings.backup.list.rename')" | |
| @click="openRenameDialog(backup.filename)" | |
| ></v-btn> | |
| <v-btn icon="mdi-download" variant="text" size="small" @click="downloadBackup(backup.filename)"></v-btn> | |
| <v-btn icon="mdi-delete" variant="text" size="small" color="error" @click="deleteBackup(backup.filename)"></v-btn> | |
| </template> | |
| </v-list-item> | |
| </v-list> | |
| <div class="d-flex justify-center mt-4"> | |
| <v-btn color="primary" variant="text" @click="loadBackupList"> | |
| <v-icon class="mr-2">mdi-refresh</v-icon> | |
| {{ t('features.settings.backup.list.refresh') }} | |
| </v-btn> | |
| </div> | |
| <!-- 提示信息 --> | |
| <p class="text-caption text-grey text-center mt-4"> | |
| <v-icon size="small" class="mr-1">mdi-information-outline</v-icon> | |
| {{ t('features.settings.backup.list.ftpHint') }} | |
| </p> | |
| </v-window-item> | |
| </v-window> | |
| </v-card-text> | |
| <v-card-actions class="px-6 py-4"> | |
| <v-spacer></v-spacer> | |
| <v-btn color="grey" variant="text" @click="handleClose" :disabled="isProcessing"> | |
| {{ t('core.common.close') }} | |
| </v-btn> | |
| </v-card-actions> | |
| </v-card> | |
| </v-dialog> | |
| <!-- 重命名对话框 --> | |
| <v-dialog v-model="renameDialogOpen" max-width="450" persistent> | |
| <v-card> | |
| <v-card-title> | |
| <v-icon class="mr-2">mdi-pencil</v-icon> | |
| {{ t('features.settings.backup.list.renameTitle') }} | |
| </v-card-title> | |
| <v-card-text> | |
| <v-text-field | |
| v-model="renameNewName" | |
| :label="t('features.settings.backup.list.newName')" | |
| :rules="[renameValidationRule]" | |
| :error-messages="renameError" | |
| variant="outlined" | |
| density="comfortable" | |
| autofocus | |
| @keyup.enter="confirmRename" | |
| > | |
| <template v-slot:append-inner> | |
| <span class="text-grey">.zip</span> | |
| </template> | |
| </v-text-field> | |
| <p class="text-caption text-grey mt-1"> | |
| {{ t('features.settings.backup.list.renameHint') }} | |
| </p> | |
| </v-card-text> | |
| <v-card-actions> | |
| <v-spacer></v-spacer> | |
| <v-btn color="grey" variant="text" @click="closeRenameDialog"> | |
| {{ t('core.common.cancel') }} | |
| </v-btn> | |
| <v-btn | |
| color="primary" | |
| variant="flat" | |
| @click="confirmRename" | |
| :loading="renameLoading" | |
| :disabled="!renameNewName || !!renameError" | |
| > | |
| {{ t('core.common.confirm') }} | |
| </v-btn> | |
| </v-card-actions> | |
| </v-card> | |
| </v-dialog> | |
| <WaitingForRestart ref="wfr"></WaitingForRestart> | |
| </template> | |
| <script setup> | |
| import { ref, computed, watch } from 'vue' | |
| import axios from 'axios' | |
| import { useI18n } from '@/i18n/composables' | |
| import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog' | |
| import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot' | |
| import WaitingForRestart from './WaitingForRestart.vue' | |
| const { t } = useI18n() | |
| const confirmDialog = useConfirmDialog() | |
| const isOpen = ref(false) | |
| const activeTab = ref('export') | |
| const wfr = ref(null) | |
| // 导出状态 | |
| const exportStatus = ref('idle') // idle, processing, completed, failed | |
| const exportTaskId = ref(null) | |
| const exportProgress = ref({ current: 0, total: 100, message: '' }) | |
| const exportResult = ref(null) | |
| const exportError = ref('') | |
| // 导入状态 | |
| const importStatus = ref('idle') // idle, uploading, confirm, processing, completed, failed | |
| const importFile = ref(null) | |
| const importTaskId = ref(null) | |
| const importProgress = ref({ current: 0, total: 100, message: '' }) | |
| const importError = ref('') | |
| const uploadedFilename = ref('') // 已上传的文件名 | |
| const checkResult = ref(null) // 预检查结果 | |
| // 分片上传状态 | |
| const CONCURRENT_UPLOADS = 5 // 并发上传数 | |
| const uploadId = ref('') | |
| const chunkSize = ref(0) // 分片大小(从后端获取) | |
| const uploadProgress = ref({ | |
| uploaded: 0, | |
| total: 0, | |
| percent: 0, | |
| message: '' | |
| }) | |
| // 备份列表 | |
| const loadingList = ref(false) | |
| const backupList = ref([]) | |
| // 重命名对话框状态 | |
| const renameDialogOpen = ref(false) | |
| const renameOldFilename = ref('') | |
| const renameNewName = ref('') | |
| const renameLoading = ref(false) | |
| const renameError = ref('') | |
| // 计算属性 | |
| const isProcessing = computed(() => { | |
| return exportStatus.value === 'processing' || | |
| importStatus.value === 'processing' || | |
| importStatus.value === 'uploading' | |
| }) | |
| // 版本检查相关的计算属性 | |
| const versionAlertType = computed(() => { | |
| const status = checkResult.value?.version_status | |
| if (status === 'major_diff') return 'error' | |
| if (status === 'minor_diff') return 'warning' | |
| return 'info' | |
| }) | |
| const versionAlertIcon = computed(() => { | |
| const status = checkResult.value?.version_status | |
| if (status === 'major_diff') return 'mdi-close-circle' | |
| if (status === 'minor_diff') return 'mdi-alert' | |
| return 'mdi-check-circle' | |
| }) | |
| const versionAlertTitle = computed(() => { | |
| const status = checkResult.value?.version_status | |
| if (status === 'major_diff') return t('features.settings.backup.import.version.majorDiffTitle') | |
| if (status === 'minor_diff') return t('features.settings.backup.import.version.minorDiffTitle') | |
| return t('features.settings.backup.import.version.matchTitle') | |
| }) | |
| const versionAlertMessage = computed(() => { | |
| const status = checkResult.value?.version_status | |
| if (status === 'major_diff') return t('features.settings.backup.import.version.majorDiffMessage') | |
| if (status === 'minor_diff') return t('features.settings.backup.import.version.minorDiffMessage') | |
| return t('features.settings.backup.import.version.matchMessage') | |
| }) | |
| // 监听对话框打开 | |
| watch(isOpen, (newVal) => { | |
| if (newVal) { | |
| loadBackupList() | |
| } else { | |
| resetAll() | |
| } | |
| }) | |
| // 监听标签页切换 | |
| watch(activeTab, (newVal) => { | |
| if (newVal === 'list') { | |
| loadBackupList() | |
| } | |
| }) | |
| // 加载备份列表 | |
| const loadBackupList = async () => { | |
| loadingList.value = true | |
| try { | |
| const response = await axios.get('/api/backup/list') | |
| if (response.data.status === 'ok') { | |
| backupList.value = response.data.data.items || [] | |
| } | |
| } catch (error) { | |
| console.error('Failed to load backup list:', error) | |
| } finally { | |
| loadingList.value = false | |
| } | |
| } | |
| // 开始导出 | |
| const startExport = async () => { | |
| exportStatus.value = 'processing' | |
| exportProgress.value = { current: 0, total: 100, message: '' } | |
| try { | |
| const response = await axios.post('/api/backup/export') | |
| if (response.data.status === 'ok') { | |
| exportTaskId.value = response.data.data.task_id | |
| pollExportProgress() | |
| } else { | |
| throw new Error(response.data.message) | |
| } | |
| } catch (error) { | |
| exportStatus.value = 'failed' | |
| exportError.value = error.message || 'Export failed' | |
| } | |
| } | |
| // 轮询导出进度 | |
| const pollExportProgress = async () => { | |
| if (!exportTaskId.value) return | |
| try { | |
| const response = await axios.get('/api/backup/progress', { | |
| params: { task_id: exportTaskId.value } | |
| }) | |
| if (response.data.status === 'ok') { | |
| const data = response.data.data | |
| if (data.status === 'processing' && data.progress) { | |
| exportProgress.value = { | |
| current: data.progress.current || 0, | |
| total: data.progress.total || 100, | |
| message: data.progress.message || '' | |
| } | |
| setTimeout(pollExportProgress, 1000) | |
| } else if (data.status === 'completed') { | |
| exportStatus.value = 'completed' | |
| exportResult.value = data.result | |
| loadBackupList() | |
| } else if (data.status === 'failed') { | |
| exportStatus.value = 'failed' | |
| exportError.value = data.error || 'Export failed' | |
| } else { | |
| setTimeout(pollExportProgress, 1000) | |
| } | |
| } | |
| } catch (error) { | |
| exportStatus.value = 'failed' | |
| exportError.value = error.message || 'Failed to get export progress' | |
| } | |
| } | |
| // 重置导出状态 | |
| const resetExport = () => { | |
| exportStatus.value = 'idle' | |
| exportTaskId.value = null | |
| exportProgress.value = { current: 0, total: 100, message: '' } | |
| exportResult.value = null | |
| exportError.value = '' | |
| } | |
| /** | |
| * 并发上传分片 | |
| * | |
| * 使用并发控制同时上传多个分片,提升上传速度。 | |
| * 后端按分片索引命名文件(如 0.part, 1.part),合并时按顺序读取, | |
| * 因此分片到达顺序不影响最终结果。 | |
| */ | |
| const uploadChunksInParallel = async (file, totalChunks, currentUploadId, currentChunkSize) => { | |
| // 跟踪已完成的字节数(使用原子操作避免并发问题) | |
| let completedBytes = 0 | |
| const chunkSizes = [] | |
| // 预计算每个分片的大小(使用后端返回的 chunk_size) | |
| for (let i = 0; i < totalChunks; i++) { | |
| const start = i * currentChunkSize | |
| const end = Math.min(start + currentChunkSize, file.size) | |
| chunkSizes[i] = end - start | |
| } | |
| // 上传单个分片的函数 | |
| const uploadSingleChunk = async (chunkIndex) => { | |
| const start = chunkIndex * currentChunkSize | |
| const end = Math.min(start + currentChunkSize, file.size) | |
| const chunk = file.slice(start, end) | |
| const formData = new FormData() | |
| formData.append('upload_id', currentUploadId) | |
| formData.append('chunk_index', chunkIndex.toString()) | |
| formData.append('chunk', chunk) | |
| const response = await axios.post('/api/backup/upload/chunk', formData, { | |
| headers: { 'Content-Type': 'multipart/form-data' } | |
| }) | |
| if (response.data.status !== 'ok') { | |
| throw new Error(response.data.message) | |
| } | |
| // 更新进度(累加已完成字节) | |
| completedBytes += chunkSizes[chunkIndex] | |
| uploadProgress.value.uploaded = completedBytes | |
| uploadProgress.value.percent = Math.round((completedBytes / file.size) * 100) | |
| return response | |
| } | |
| // 创建分片索引队列 | |
| const pendingChunks = Array.from({ length: totalChunks }, (_, i) => i) | |
| const activePromises = [] | |
| // 处理队列中的分片 | |
| while (pendingChunks.length > 0 || activePromises.length > 0) { | |
| // 填充并发槽位 | |
| while (pendingChunks.length > 0 && activePromises.length < CONCURRENT_UPLOADS) { | |
| const chunkIndex = pendingChunks.shift() | |
| const promise = uploadSingleChunk(chunkIndex).then(() => { | |
| // 完成后从活动列表移除 | |
| const idx = activePromises.indexOf(promise) | |
| if (idx > -1) activePromises.splice(idx, 1) | |
| }) | |
| activePromises.push(promise) | |
| } | |
| // 等待至少一个完成 | |
| if (activePromises.length > 0) { | |
| await Promise.race(activePromises) | |
| } | |
| } | |
| } | |
| // 上传并检查 | |
| const uploadAndCheck = async () => { | |
| if (!importFile.value) return | |
| importStatus.value = 'uploading' | |
| const file = importFile.value | |
| try { | |
| // 初始化上传进度 | |
| uploadProgress.value = { | |
| uploaded: 0, | |
| total: file.size, | |
| percent: 0, | |
| message: t('features.settings.backup.import.uploadInit') | |
| } | |
| // 步骤1: 初始化分片上传(后端计算并返回 chunk_size 和 total_chunks) | |
| const initResponse = await axios.post('/api/backup/upload/init', { | |
| filename: file.name, | |
| total_size: file.size | |
| }) | |
| if (initResponse.data.status !== 'ok') { | |
| throw new Error(initResponse.data.message) | |
| } | |
| uploadId.value = initResponse.data.data.upload_id | |
| chunkSize.value = initResponse.data.data.chunk_size | |
| const totalChunks = initResponse.data.data.total_chunks | |
| // 步骤2: 并行分片上传(5个并发连接) | |
| uploadProgress.value.message = t('features.settings.backup.import.uploadingChunks') | |
| await uploadChunksInParallel(file, totalChunks, uploadId.value, chunkSize.value) | |
| // 步骤3: 完成上传 | |
| uploadProgress.value.message = t('features.settings.backup.import.uploadComplete') | |
| const completeResponse = await axios.post('/api/backup/upload/complete', { | |
| upload_id: uploadId.value | |
| }) | |
| if (completeResponse.data.status !== 'ok') { | |
| throw new Error(completeResponse.data.message) | |
| } | |
| uploadedFilename.value = completeResponse.data.data.filename | |
| // 步骤4: 预检查 | |
| uploadProgress.value.message = t('features.settings.backup.import.checking') | |
| const checkResponse = await axios.post('/api/backup/check', { | |
| filename: uploadedFilename.value | |
| }) | |
| if (checkResponse.data.status !== 'ok') { | |
| throw new Error(checkResponse.data.message) | |
| } | |
| checkResult.value = checkResponse.data.data | |
| // 检查是否有效 | |
| if (!checkResult.value.valid) { | |
| importStatus.value = 'failed' | |
| importError.value = checkResult.value.error || t('features.settings.backup.import.invalidBackup') | |
| return | |
| } | |
| // 显示确认对话框 | |
| importStatus.value = 'confirm' | |
| } catch (error) { | |
| // 上传失败时尝试清理已上传的分片 | |
| if (uploadId.value) { | |
| try { | |
| await axios.post('/api/backup/upload/abort', { | |
| upload_id: uploadId.value | |
| }) | |
| } catch (abortError) { | |
| console.error('Failed to abort upload:', abortError) | |
| } | |
| } | |
| importStatus.value = 'failed' | |
| importError.value = error.response?.data?.message || error.message || 'Upload failed' | |
| } | |
| } | |
| // 确认导入 | |
| const confirmImport = async () => { | |
| if (!uploadedFilename.value) return | |
| importStatus.value = 'processing' | |
| importProgress.value = { current: 0, total: 100, message: '' } | |
| try { | |
| const response = await axios.post('/api/backup/import', { | |
| filename: uploadedFilename.value, | |
| confirmed: true | |
| }) | |
| if (response.data.status === 'ok') { | |
| importTaskId.value = response.data.data.task_id | |
| pollImportProgress() | |
| } else { | |
| throw new Error(response.data.message) | |
| } | |
| } catch (error) { | |
| importStatus.value = 'failed' | |
| importError.value = error.response?.data?.message || error.message || 'Import failed' | |
| } | |
| } | |
| // 轮询导入进度 | |
| const pollImportProgress = async () => { | |
| if (!importTaskId.value) return | |
| try { | |
| const response = await axios.get('/api/backup/progress', { | |
| params: { task_id: importTaskId.value } | |
| }) | |
| if (response.data.status === 'ok') { | |
| const data = response.data.data | |
| if (data.status === 'processing' && data.progress) { | |
| importProgress.value = { | |
| current: data.progress.current || 0, | |
| total: data.progress.total || 100, | |
| message: data.progress.message || '' | |
| } | |
| setTimeout(pollImportProgress, 1000) | |
| } else if (data.status === 'completed') { | |
| importStatus.value = 'completed' | |
| } else if (data.status === 'failed') { | |
| importStatus.value = 'failed' | |
| importError.value = data.error || 'Import failed' | |
| } else { | |
| setTimeout(pollImportProgress, 1000) | |
| } | |
| } | |
| } catch (error) { | |
| importStatus.value = 'failed' | |
| importError.value = error.message || 'Failed to get import progress' | |
| } | |
| } | |
| // 重置导入状态 | |
| const resetImport = async () => { | |
| // 如果有进行中的上传,先取消 | |
| if (uploadId.value && importStatus.value === 'uploading') { | |
| try { | |
| await axios.post('/api/backup/upload/abort', { | |
| upload_id: uploadId.value | |
| }) | |
| } catch (error) { | |
| console.error('Failed to abort upload:', error) | |
| } | |
| } | |
| importStatus.value = 'idle' | |
| importFile.value = null | |
| importTaskId.value = null | |
| importProgress.value = { current: 0, total: 100, message: '' } | |
| importError.value = '' | |
| uploadedFilename.value = '' | |
| checkResult.value = null | |
| uploadId.value = '' | |
| chunkSize.value = 0 | |
| uploadProgress.value = { uploaded: 0, total: 0, percent: 0, message: '' } | |
| } | |
| // 下载备份(使用浏览器原生下载,可显示下载进度) | |
| const downloadBackup = (filename) => { | |
| // 获取 token 用于鉴权(因为浏览器原生下载无法携带 Authorization header) | |
| const token = localStorage.getItem('token') | |
| if (!token) { | |
| alert(t('core.common.unauthorized')) | |
| return | |
| } | |
| // 直接使用浏览器下载,这样可以看到原生下载进度条 | |
| const downloadUrl = `/api/backup/download?filename=${encodeURIComponent(filename)}&token=${encodeURIComponent(token)}` | |
| // 创建隐藏的 a 标签触发下载 | |
| const link = document.createElement('a') | |
| link.href = downloadUrl | |
| link.download = filename | |
| link.style.display = 'none' | |
| document.body.appendChild(link) | |
| link.click() | |
| document.body.removeChild(link) | |
| } | |
| // 从列表中恢复备份 | |
| const restoreFromList = async (filename) => { | |
| // 切换到导入标签页并设置文件名 | |
| uploadedFilename.value = filename | |
| // 预检查 | |
| try { | |
| const checkResponse = await axios.post('/api/backup/check', { | |
| filename: filename | |
| }) | |
| if (checkResponse.data.status !== 'ok') { | |
| throw new Error(checkResponse.data.message) | |
| } | |
| checkResult.value = checkResponse.data.data | |
| if (!checkResult.value.valid) { | |
| alert(checkResult.value.error || t('features.settings.backup.import.invalidBackup')) | |
| return | |
| } | |
| // 切换到导入标签页并显示确认 | |
| activeTab.value = 'import' | |
| importStatus.value = 'confirm' | |
| } catch (error) { | |
| alert(error.response?.data?.message || error.message || 'Check failed') | |
| } | |
| } | |
| // 删除备份 | |
| const deleteBackup = async (filename) => { | |
| if (!(await askForConfirmation(t('features.settings.backup.list.confirmDelete'), confirmDialog))) return | |
| try { | |
| const response = await axios.post('/api/backup/delete', { filename }) | |
| if (response.data.status === 'ok') { | |
| loadBackupList() | |
| } else { | |
| alert(response.data.message || 'Delete failed') | |
| } | |
| } catch (error) { | |
| alert(error.message || 'Delete failed') | |
| } | |
| } | |
| // 重命名相关函数 | |
| const openRenameDialog = (filename) => { | |
| renameOldFilename.value = filename | |
| // 移除 .zip 后缀,只显示文件名部分 | |
| renameNewName.value = filename.replace(/\.zip$/i, '') | |
| renameError.value = '' | |
| renameDialogOpen.value = true | |
| } | |
| const closeRenameDialog = () => { | |
| renameDialogOpen.value = false | |
| renameOldFilename.value = '' | |
| renameNewName.value = '' | |
| renameError.value = '' | |
| } | |
| // 文件名验证规则 | |
| const renameValidationRule = (value) => { | |
| if (!value) return t('features.settings.backup.list.renameRequired') | |
| // 检查是否包含非法字符 | |
| if (/[\\/:*?"<>|]/.test(value)) { | |
| return t('features.settings.backup.list.renameInvalidChars') | |
| } | |
| // 检查是否包含路径遍历字符 | |
| if (value.includes('..')) { | |
| return t('features.settings.backup.list.renameInvalidChars') | |
| } | |
| return true | |
| } | |
| const confirmRename = async () => { | |
| if (!renameNewName.value || renameError.value) return | |
| // 前端验证 | |
| const validationResult = renameValidationRule(renameNewName.value) | |
| if (validationResult !== true) { | |
| renameError.value = validationResult | |
| return | |
| } | |
| renameLoading.value = true | |
| renameError.value = '' | |
| try { | |
| const response = await axios.post('/api/backup/rename', { | |
| filename: renameOldFilename.value, | |
| new_name: renameNewName.value | |
| }) | |
| if (response.data.status === 'ok') { | |
| closeRenameDialog() | |
| loadBackupList() | |
| } else { | |
| renameError.value = response.data.message || t('features.settings.backup.list.renameFailed') | |
| } | |
| } catch (error) { | |
| renameError.value = error.response?.data?.message || error.message || t('features.settings.backup.list.renameFailed') | |
| } finally { | |
| renameLoading.value = false | |
| } | |
| } | |
| // 格式化文件大小 | |
| const formatFileSize = (bytes) => { | |
| if (bytes === 0) return '0 B' | |
| const k = 1024 | |
| const sizes = ['B', 'KB', 'MB', 'GB'] | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)) | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] | |
| } | |
| // 格式化日期(从时间戳) | |
| const formatDate = (timestamp) => { | |
| return new Date(timestamp * 1000).toLocaleString() | |
| } | |
| // 格式化 ISO 日期字符串 | |
| const formatISODate = (isoString) => { | |
| if (!isoString) return '' | |
| try { | |
| return new Date(isoString).toLocaleString() | |
| } catch { | |
| return isoString | |
| } | |
| } | |
| // 重启 AstrBot | |
| const restartAstrBot = async () => { | |
| try { | |
| await restartAstrBotRuntime(wfr.value) | |
| } catch (error) { | |
| console.error(error) | |
| } | |
| } | |
| // 重置所有状态 | |
| const resetAll = async () => { | |
| resetExport() | |
| await resetImport() | |
| activeTab.value = 'export' | |
| } | |
| // 关闭对话框 | |
| const handleClose = () => { | |
| if (isProcessing.value) return | |
| isOpen.value = false | |
| } | |
| // 打开对话框 | |
| const open = () => { | |
| isOpen.value = true | |
| } | |
| defineExpose({ open }) | |
| </script> | |
| <style scoped> | |
| .v-list-item { | |
| border-bottom: 1px solid rgba(0, 0, 0, 0.08); | |
| } | |
| .v-list-item:last-child { | |
| border-bottom: none; | |
| } | |
| /* 禁用 Chip 的交互效果 */ | |
| .non-interactive-chip { | |
| pointer-events: none; | |
| cursor: default; | |
| } | |
| .non-interactive-chip:hover { | |
| box-shadow: none ; | |
| } | |
| </style> | |