Files
AstrBot/dashboard/src/components/shared/BackupDialog.vue
T
エイカク 64de474139 fix: 修复 Windows 打包版后端重启失败问题 (#5009)
* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: avoid frozen restart crash from multiprocessing import

* fix: include missing frozen dependencies for windows backend

* fix: use execv for stable backend reboot args

* Revert "fix: use execv for stable backend reboot args"

This reverts commit 9cc27becff.

* Revert "fix: include missing frozen dependencies for windows backend"

This reverts commit 52554bea1f.

* Revert "fix: avoid frozen restart crash from multiprocessing import"

This reverts commit 10548645b0.

* fix: reset pyinstaller onefile env before reboot

* fix: unify electron restart path and tray-exit backend cleanup

* fix: stabilize desktop restart detection and frozen reboot args

* fix: make dashboard restart wait detection robust

* fix: revert dashboard restart waiting interaction tweaks

* fix: pass auth token for desktop graceful restart

* fix: avoid false failure during graceful restart wait

* fix: start restart waiting before electron restart call

* fix: harden restart waiting and reboot arg parsing

* fix: parse start_time as numeric timestamp
2026-02-10 21:33:06 +09:00

1000 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 !important;
}
</style>