| <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"> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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') |
| const exportTaskId = ref(null) |
| const exportProgress = ref({ current: 0, total: 100, message: '' }) |
| const exportResult = ref(null) |
| const exportError = ref('') |
| |
| |
| const importStatus = ref('idle') |
| 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 = '' |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| const uploadChunksInParallel = async (file, totalChunks, currentUploadId, currentChunkSize) => { |
| |
| let completedBytes = 0 |
| const chunkSizes = [] |
| |
| |
| 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') |
| } |
| |
| |
| 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 |
| |
| |
| uploadProgress.value.message = t('features.settings.backup.import.uploadingChunks') |
| |
| await uploadChunksInParallel(file, totalChunks, uploadId.value, chunkSize.value) |
| |
| |
| 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 |
| |
| |
| 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) => { |
| |
| const token = localStorage.getItem('token') |
| if (!token) { |
| alert(t('core.common.unauthorized')) |
| return |
| } |
| |
| |
| const downloadUrl = `/api/backup/download?filename=${encodeURIComponent(filename)}&token=${encodeURIComponent(token)}` |
| |
| |
| 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 |
| |
| 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() |
| } |
| |
| |
| const formatISODate = (isoString) => { |
| if (!isoString) return '' |
| try { |
| return new Date(isoString).toLocaleString() |
| } catch { |
| return isoString |
| } |
| } |
| |
| |
| 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; |
| } |
| |
| |
| .non-interactive-chip { |
| pointer-events: none; |
| cursor: default; |
| } |
| |
| .non-interactive-chip:hover { |
| box-shadow: none !important; |
| } |
| </style> |
| |