feat: Add local file selection for import/restore operations (#10234)

This commit is contained in:
ssongliu 2025-09-02 17:55:46 +08:00 committed by GitHub
parent 324a47966b
commit 81fcc61ec9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 246 additions and 124 deletions

View file

@ -133,6 +133,28 @@ func (b *BaseApi) UpdateBackup(c *gin.Context) {
helper.Success(c)
}
// @Tags Backup Account
// @Summary Upload file for recover
// @Accept json
// @Param request body dto.UploadForRecover true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /backups/upload [post]
// @x-panel-log {"bodyKeys":["filePath"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"上传备份文件 [filePath]","formatEN":"upload backup file [filePath]"}
func (b *BaseApi) UploadForRecover(c *gin.Context) {
var req dto.UploadForRecover
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := backupService.UploadForRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Backup Account
// @Summary Load backup account options
// @Accept json

View file

@ -53,6 +53,11 @@ type BackupOption struct {
IsPublic bool `json:"isPublic"`
}
type UploadForRecover struct {
FilePath string `json:"filePath"`
TargetDir string `json:"targetDir"`
}
type CommonBackup struct {
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster"`
Name string `json:"name"`

View file

@ -39,6 +39,7 @@ type IBackupService interface {
Delete(id uint) error
RefreshToken(req dto.OperateByID) error
GetLocalDir() (string, error)
UploadForRecover(req dto.UploadForRecover) error
MysqlBackup(db dto.CommonBackup) error
PostgresqlBackup(db dto.CommonBackup) error
@ -309,6 +310,16 @@ func (u *BackupService) RefreshToken(req dto.OperateByID) error {
return backupRepo.Save(&backup)
}
func (u *BackupService) UploadForRecover(req dto.UploadForRecover) error {
fileOp := files.NewFileOp()
if !fileOp.Stat(req.TargetDir) {
if err := fileOp.CreateDir(req.TargetDir, constant.DirPerm); err != nil {
return err
}
}
return fileOp.Copy(req.FilePath, req.TargetDir)
}
func (u *BackupService) checkBackupConn(backup *model.BackupAccount) (bool, error) {
client, err := newClient(backup, false)
if err != nil {

View file

@ -21,6 +21,7 @@ func (s *BackupRouter) InitRouter(Router *gin.RouterGroup) {
backupRouter.POST("", baseApi.CreateBackup)
backupRouter.POST("/del", baseApi.DeleteBackup)
backupRouter.POST("/update", baseApi.UpdateBackup)
backupRouter.POST("/upload", baseApi.UploadForRecover)
backupRouter.POST("/backup", baseApi.Backup)
backupRouter.POST("/recover", baseApi.Recover)

View file

@ -35,6 +35,9 @@ export const deleteBackupRecord = (params: { ids: number[] }) => {
export const updateRecordDescription = (id: Number, description: String) => {
return http.post(`/backups/record/description/update`, { id: id, description: description });
};
export const uploadByRecover = (filePath: string, targetDir: String) => {
return http.post(`/backups/upload`, { filePath: filePath, targetDir: targetDir });
};
export const searchBackupRecords = (params: Backup.SearchBackupRecord) => {
return http.post<ResPage<Backup.RecordInfo>>(`/backups/record/search`, params, TimeoutEnum.T_5M);
};

View file

@ -46,37 +46,54 @@
</el-button>
</div>
<div>
<el-table :data="data" highlight-current-row height="40vh">
<el-table-column show-overflow-tooltip fix>
<el-table :data="data" highlight-current-row height="40vh" @row-click="openDir" class="cursor-pointer">
<el-table-column prop="name" show-overflow-tooltip fix>
<template #default="{ row }">
<div>
<svg-icon
:class="'table-icon'"
:iconName="row.isDir ? 'p-file-folder' : 'p-file-normal'"
></svg-icon>
<svg-icon
:class="'table-icon'"
:iconName="row.isDir ? 'p-file-folder' : 'p-file-normal'"
></svg-icon>
<template v-if="!row.isCreate">
{{ row.name }}
</template>
<template v-if="!row.isCreate">
<el-link underline="never" @click="openDir(row)">
{{ row.name }}
</el-link>
</template>
<template v-else>
<el-input
ref="rowRefs"
v-model="newFolder"
class="p-w-200"
placeholder="new folder"
@input="handleChange(newFolder, row)"
></el-input>
<el-button link @click="createFolder(row)" type="primary" size="small" class="ml-2">
{{ $t('commons.button.save') }}
</el-button>
<el-button link @click="cancelFolder(row)" type="primary" size="small" class="!ml-2">
{{ $t('commons.button.cancel') }}
</el-button>
</template>
</div>
<template v-else>
<el-input
ref="rowRefs"
v-model="newFolder"
class="p-w-200"
placeholder="new folder"
@input="handleChange(newFolder, row)"
></el-input>
<el-button link @click="createFolder(row)" type="primary" size="small" class="ml-2">
{{ $t('commons.button.save') }}
</el-button>
<el-button link @click="cancelFolder(row)" type="primary" size="small" class="!ml-2">
{{ $t('commons.button.cancel') }}
</el-button>
</template>
</template>
</el-table-column>
<el-table-column prop="size" width="160px" fix>
<template #default="{ row }">
<el-button
type="primary"
link
small
v-if="!row.isCreate"
:loading="row.btnLoading"
@click="row.isDir ? getDirSize(row.path) : getFileSize(row.path)"
>
<span v-if="row.isDir">
<span v-if="row.dirSize === undefined">
{{ $t('file.calculate') }}
</span>
<span v-else>{{ computeSize(row.dirSize) }}</span>
</span>
<span v-else>
{{ computeSize(row.size) }}
</span>
</el-button>
</template>
</el-table-column>
</el-table>
@ -107,11 +124,12 @@
<script lang="ts" setup>
import { File } from '@/api/interface/file';
import { createFile, getFilesList } from '@/api/modules/files';
import { computeDirSize, createFile, getFileContent, getFilesList } from '@/api/modules/files';
import { onUpdated, reactive, ref } from 'vue';
import i18n from '@/lang';
import { MsgSuccess, MsgWarning } from '@/utils/message';
import { useSearchableForSelect } from '@/views/host/file-management/hooks/searchable';
import { computeSize } from '@/utils/util';
const data = ref([]);
const loading = ref(false);
@ -170,7 +188,10 @@ const openPage = () => {
selectRow.value.path = form.dir ? form.path || '/' : '';
};
const openDir = async (row: File.File) => {
const openDir = async (row: File.File, column: any, event: any) => {
if (event?.target?.tagName === 'BUTTON' || event?.target?.tagName === 'SPAN') {
return;
}
if (row.isDir) {
const name = row.name;
paths.value.push(name);
@ -220,6 +241,40 @@ const jumpPath = async () => {
}
};
const getFileSize = async (path: string) => {
let params = {
path: path,
expand: true,
isDetail: true,
page: 1,
pageSize: 100,
};
updateByPath(path, { btnLoading: true });
try {
const res = await getFileContent(params);
updateByPath(path, { dirSize: res.data.size });
} finally {
updateByPath(path, { btnLoading: false });
}
};
const getDirSize = async (path: string) => {
const req = {
path: path,
};
updateByPath(path, { btnLoading: true });
try {
const res = await computeDirSize(req);
updateByPath(path, { dirSize: res.data.size });
} finally {
updateByPath(path, { btnLoading: false });
}
};
const updateByPath = (path: string, patch: Partial<(typeof data.value)[0]>) => {
data.value = data.value.map((item) => (item.path === path ? { ...item, ...patch } : item));
};
const getPaths = (reqPath: string) => {
const pathArray = reqPath.split('/');
paths.value = [];

View file

@ -9,71 +9,57 @@
>
<template #content>
<div v-loading="loading">
<div class="mb-4" v-if="type === 'mysql' || type === 'mariadb'">
<el-alert type="error" :title="$t('database.formatHelper', [remark])" />
<div>
<el-alert :closable="false" type="warning">
<template #default>
<ul>
<li v-if="type === 'mysql' || type === 'mariadb'">
{{ $t('database.formatHelper', [remark]) }}
</li>
<li v-if="type === 'website'">{{ $t('website.websiteBackupWarn') }}</li>
<span v-if="isDb()">
<li>{{ $t('database.supportUpType') }}</li>
<li>{{ $t('database.zipFormat') }}</li>
</span>
<span v-else>
<li>{{ $t('website.supportUpType') }}</li>
<li>{{ $t('website.zipFormat', [type + '.json']) }}</li>
</span>
</ul>
</template>
</el-alert>
</div>
<div class="mb-4" v-if="type === 'website'">
<el-alert :closable="false" type="warning" :title="$t('website.websiteBackupWarn')"></el-alert>
</div>
<el-upload
:limit="1"
ref="uploadRef"
drag
:on-exceed="handleExceed"
:on-change="fileOnChange"
:on-remove="fileOnRemove"
class="upload-demo"
:auto-upload="false"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
{{ $t('database.dropHelper') }}
<em>{{ $t('database.clickHelper') }}</em>
</div>
<template #tip>
<el-progress
v-if="isUpload"
text-inside
:stroke-width="12"
:percentage="uploadPercent"
></el-progress>
<div
v-if="type === 'mysql' || type === 'mariadb' || type === 'postgresql'"
class="w-4/5 el-upload__tip"
>
<span class="input-help">{{ $t('database.supportUpType') }}</span>
<span class="input-help">
{{ $t('database.zipFormat') }}
</span>
</div>
<div v-else class="w-4/5 el-upload__tip">
<span class="input-help">{{ $t('website.supportUpType') }}</span>
<span class="input-help">
{{ $t('website.zipFormat', [type + '.json']) }}
</span>
</div>
</template>
</el-upload>
<el-button :disabled="isUpload || uploaderFiles.length !== 1" icon="Upload" @click="onSubmit">
{{ $t('commons.button.upload') }}
</el-button>
<el-divider />
<ComplexTable
:pagination-config="paginationConfig"
class="mt-5"
@search="search"
v-model:selects="selects"
:data="data"
>
<template #toolbar>
<el-button
class="ml-2.5"
plain
:disabled="selects.length === 0"
@click="onBatchDelete(null)"
<el-upload
:limit="1"
class="float-left"
ref="uploadRef"
accept=".tar.gz,.sql,.sql.gz"
:show-file-list="false"
:on-exceed="handleExceed"
:on-change="fileOnChange"
:auto-upload="false"
>
<el-button class="float-left">
{{ $t('database.localUpload') }}
</el-button>
</el-upload>
<el-button class="float-left ml-3" @click="fileRef.acceptParams({ dir: false })">
{{ $t('database.hostSelect') }}
</el-button>
<el-button :disabled="selects.length === 0" @click="onBatchDelete(null)">
{{ $t('commons.button.delete') }}
</el-button>
<el-progress v-if="isUpload" text-inside :stroke-width="12" :percentage="uploadPercent" />
</template>
<el-table-column type="selection" fix />
<el-table-column :label="$t('commons.table.name')" show-overflow-tooltip prop="name" />
@ -131,6 +117,7 @@
</DialogPro>
<OpDialog ref="opRef" @search="search" />
<FileList ref="fileRef" @choose="loadFile" />
<TaskLog ref="taskLogRef" @close="search" />
</div>
</template>
@ -144,7 +131,7 @@ import { File } from '@/api/interface/file';
import { batchDeleteFile, checkFile, chunkUploadFileData, getUploadList } from '@/api/modules/files';
import { loadBaseDir } from '@/api/modules/setting';
import { MsgError, MsgSuccess } from '@/utils/message';
import { handleRecoverByUpload } from '@/api/modules/backup';
import { handleRecoverByUpload, uploadByRecover } from '@/api/modules/backup';
import TaskLog from '@/components/log/task/index.vue';
interface DialogProps {
@ -154,6 +141,7 @@ interface DialogProps {
remark: string;
}
const loading = ref();
const fileRef = ref();
const isUpload = ref();
const uploadPercent = ref<number>(0);
const selects = ref<any>([]);
@ -220,6 +208,32 @@ const search = async () => {
paginationConfig.total = res.data.total;
};
const loadFile = async (path: string) => {
let filaName = path.split('/').pop();
if (!filaName) {
MsgError(i18n.global.t('commons.msg.fileNameErr'));
return;
}
let reg = /^[a-zA-Z0-9\u4e00-\u9fa5]{1}[a-z:A-Z0-9_.\u4e00-\u9fa5-]{0,256}$/;
if (!reg.test(filaName)) {
MsgError(i18n.global.t('commons.msg.fileNameErr'));
return;
}
ElMessageBox.confirm(i18n.global.t('database.selectHelper', [path]), i18n.global.t('database.loadBackup'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
}).then(async () => {
uploadByRecover(path, baseDir.value)
.then(() => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
search();
})
.finally(() => {
loading.value = false;
});
});
};
const openTaskLog = (taskID: string) => {
taskLogRef.value.openWithTaskID(taskID);
};
@ -267,30 +281,32 @@ const onRecover = async (row: File.File) => {
recoverDialog.value = true;
};
const isDb = () => {
return type.value === 'mysql' || type.value === 'mariadb' || type.value === 'postgresql';
};
const uploaderFiles = ref<UploadFiles>([]);
const uploadRef = ref<UploadInstance>();
const beforeAvatarUpload = (rawFile) => {
if (type.value === 'app' || type.value === 'website') {
if (!rawFile.name.endsWith('.tar.gz')) {
MsgError(i18n.global.t('commons.msg.unSupportType'));
return false;
}
return true;
}
if (!rawFile.name.endsWith('.sql') && !rawFile.name.endsWith('.tar.gz') && !rawFile.name.endsWith('.sql.gz')) {
MsgError(i18n.global.t('commons.msg.unSupportType'));
return false;
}
return true;
};
const fileOnChange = (_uploadFile: UploadFile, uploadFiles: UploadFiles) => {
uploaderFiles.value = uploadFiles;
};
const fileOnRemove = (_uploadFile: UploadFile, uploadFiles: UploadFiles) => {
uploaderFiles.value = uploadFiles;
if (uploaderFiles.value.length !== 1) {
return;
}
const file = uploaderFiles.value[0];
if (!file.raw.name) {
MsgError(i18n.global.t('commons.msg.fileNameErr'));
return;
}
ElMessageBox.confirm(
i18n.global.t('database.selectHelper', [file.raw.name]),
i18n.global.t('database.loadBackup'),
{
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
},
).then(async () => {
onSubmit();
});
};
const handleUploadClose = () => {
@ -311,14 +327,7 @@ const handleExceed: UploadProps['onExceed'] = (files) => {
};
const onSubmit = async () => {
if (uploaderFiles.value.length !== 1) {
return;
}
const file = uploaderFiles.value[0];
if (!file.raw.name) {
MsgError(i18n.global.t('commons.msg.fileNameErr'));
return;
}
let reg = /^[a-zA-Z0-9\u4e00-\u9fa5]{1}[a-z:A-Z0-9_.\u4e00-\u9fa5-]{0,256}$/;
if (!reg.test(file.raw.name)) {
MsgError(i18n.global.t('commons.msg.fileNameErr'));
@ -329,10 +338,6 @@ const onSubmit = async () => {
MsgError(i18n.global.t('commons.msg.fileExist'));
return;
}
let isOk = beforeAvatarUpload(file.raw);
if (!isOk) {
return;
}
submitUpload(file);
};

View file

@ -467,6 +467,9 @@ const message = {
serviceNameHelper: 'Access between containers in the same network.',
backupList: 'Backup',
loadBackup: 'Import',
localUpload: 'Local Upload',
hostSelect: 'Server Selection',
selectHelper: 'Are you sure you want to import backup file {0}?',
remoteAccess: 'Remote access',
remoteHelper: 'Multiple IP comma-delimited, example: 172.16.10.111, 172.16.10.112',
remoteConnHelper:

View file

@ -454,6 +454,9 @@ const message = {
serviceNameHelper: '同じネットワーク内のコンテナ間のアクセス',
backupList: 'バックアップ',
loadBackup: '輸入',
localUpload: 'ローカルアップロード',
hostSelect: 'サーバー選択',
selectHelper: 'バックアップファイル {0} をインポートしてもよろしいですか',
remoteAccess: 'リモートアクセス',
remoteHelper: '複数のIP Comma delimited:172.16.10.111172.16.10.112',
remoteConnHelper:

View file

@ -457,6 +457,9 @@ const message = {
serviceNameHelper: '같은 네트워크 컨테이너 간의 접근.',
backupList: '백업',
loadBackup: '불러오기',
localUpload: '로컬 업로드',
hostSelect: '서버 선택',
selectHelper: '백업 파일 {0}() 가져오시겠습니까?',
remoteAccess: '원격 접근',
remoteHelper: '여러 IP 쉼표로 구분하여 입력, : 172.16.10.111, 172.16.10.112',
remoteConnHelper:

View file

@ -463,6 +463,9 @@ const message = {
serviceNameHelper: 'Akses antara kontena dalam rangkaian yang sama.',
backupList: 'Sandaran',
loadBackup: 'Import',
localUpload: 'Muat Naik Tempatan',
hostSelect: 'Pemilihan Pelayan',
selectHelper: 'Adakah anda pasti ingin mengimport fail sandaran {0}?',
remoteAccess: 'Akses jauh',
remoteHelper: 'Berbilang IP dipisahkan dengan koma, contoh: 172.16.10.111, 172.16.10.112',
remoteConnHelper:

View file

@ -461,6 +461,9 @@ const message = {
serviceNameHelper: 'Acesso entre containers na mesma rede.',
backupList: 'Backup',
loadBackup: 'Importar',
localUpload: 'Upload Local',
hostSelect: 'Seleção de Servidor',
selectHelper: 'Tem certeza de que deseja importar o arquivo de backup {0}?',
remoteAccess: 'Acesso remoto',
remoteHelper: 'Vários IPs separados por vírgula, exemplo: 172.16.10.111, 172.16.10.112',
remoteConnHelper:

View file

@ -456,6 +456,9 @@ const message = {
serviceNameHelper: 'Доступ между контейнерами в одной сети.',
backupList: 'Резервное копирование',
loadBackup: 'Импорт',
localUpload: 'Локальная загрузка',
hostSelect: 'Выбор сервера',
selectHelper: 'Вы уверены, что хотите импортировать файл резервной копии {0}?',
remoteAccess: 'Удаленный доступ',
remoteHelper: 'Несколько IP через запятую, например: 172.16.10.111, 172.16.10.112',
remoteConnHelper:

View file

@ -472,6 +472,9 @@ const message = {
serviceNameHelper: 'Aynı ağdaki konteynerler arası erişim.',
backupList: 'Yedekleme',
loadBackup: 'İçe Aktar',
localUpload: 'Yerel Yükleme',
hostSelect: 'Sunucu Seçimi',
selectHelper: '{0} yedek dosyasını içe aktarmak istediğinizden emin misiniz?',
remoteAccess: 'Uzaktan erişim',
remoteHelper: 'Birden fazla IP virgülle ayrılır, örnek: 172.16.10.111, 172.16.10.112',
remoteConnHelper:

View file

@ -456,6 +456,9 @@ const message = {
serviceNameHelper: '用於同一 network 下的容器間訪問',
backupList: '備份列表',
loadBackup: '導入備份',
localUpload: '本地上傳',
hostSelect: '伺服器選擇',
selectHelper: '是否確認導入備份文件 {0}',
remoteAccess: '遠程訪問',
remoteHelper: '多個 ip 以逗號分隔172.16.10.111,172.16.10.112',
remoteConnHelper: 'root 帳號遠程連接 MySQL 有安全風險開啟需謹慎',

View file

@ -454,6 +454,9 @@ const message = {
serviceNameHelper: '用于同一 network 下的容器间访问',
backupList: '备份列表',
loadBackup: '导入备份',
localUpload: '本地上传',
hostSelect: '服务器选择',
selectHelper: '是否确认导入备份文件 {0}',
remoteAccess: '远程访问',
remoteHelper: '多个 ip 以逗号分隔172.16.10.111,172.16.10.112',
remoteConnHelper: 'root 帐号远程连接 MySQL 有安全风险开启需谨慎',

View file

@ -6,10 +6,10 @@
action="#"
:auto-upload="false"
ref="uploadRef"
class="upload mt-2"
class="float-left mt-2"
:show-file-list="false"
:limit="1"
:accept="'.json'"
accept=".json"
:on-change="fileOnChange"
:on-exceed="handleExceed"
v-model:file-list="uploaderFiles"
@ -176,10 +176,3 @@ defineExpose({
acceptParams,
});
</script>
<style lang="scss" scoped>
.upload {
width: 60px;
float: left;
}
</style>