mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 06:47:20 +08:00
feat: 备份支持上传
This commit is contained in:
@@ -1,22 +1,29 @@
|
||||
package request
|
||||
|
||||
import "mime/multipart"
|
||||
|
||||
type BackupList struct {
|
||||
Type string `json:"type" form:"type" validate:"required|in:path,website,mysql,postgres,redis,panel"`
|
||||
Type string `uri:"type" form:"type" validate:"required|in:path,website,mysql,postgres,redis,panel"`
|
||||
}
|
||||
|
||||
type BackupCreate struct {
|
||||
Type string `json:"type" form:"type" validate:"required|in:website,mysql,postgres,redis,panel"`
|
||||
Type string `uri:"type" form:"type" validate:"required|in:website,mysql,postgres,redis,panel"`
|
||||
Target string `json:"target" form:"target" validate:"required"`
|
||||
Path string `json:"path" form:"path"`
|
||||
}
|
||||
|
||||
type BackupUpload struct {
|
||||
Type string `uri:"type" form:"type"` // 校验没有必要,因为根本没经过验证器
|
||||
File *multipart.FileHeader `form:"file"`
|
||||
}
|
||||
|
||||
type BackupFile struct {
|
||||
Type string `json:"type" form:"type" validate:"required|in:website,mysql,postgres,redis,panel"`
|
||||
Type string `uri:"type" form:"type" validate:"required|in:website,mysql,postgres,redis,panel"`
|
||||
File string `json:"file" form:"file" validate:"required"`
|
||||
}
|
||||
|
||||
type BackupRestore struct {
|
||||
Type string `json:"type" form:"type" validate:"required|in:website,mysql,postgres,redis,panel"`
|
||||
Type string `uri:"type" form:"type" validate:"required|in:website,mysql,postgres,redis,panel"`
|
||||
File string `json:"file" form:"file" validate:"required"`
|
||||
Target string `json:"target" form:"target" validate:"required"`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"github.com/go-rat/chix"
|
||||
|
||||
@@ -55,38 +56,44 @@ func (s *BackupService) Create(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *BackupService) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(2 << 30); err != nil {
|
||||
binder := chix.NewBind(r)
|
||||
defer binder.Release()
|
||||
|
||||
req := new(request.BackupUpload)
|
||||
if err := binder.MultipartForm(req, 2<<30); err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
if err := binder.URI(req); err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "上传文件失败:%v", err)
|
||||
// 只允许上传 .sql .zip .tar .gz .tgz .bz2 .xz .7z
|
||||
if !slices.Contains([]string{".sql", ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z"}, filepath.Ext(req.File.Filename)) {
|
||||
Error(w, http.StatusForbidden, "unsupported file type")
|
||||
return
|
||||
}
|
||||
path, err := s.backupRepo.GetPath(biz.BackupType(r.FormValue("type")))
|
||||
|
||||
path, err := s.backupRepo.GetPath(biz.BackupType(req.Type))
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !io.Exists(filepath.Dir(path)) {
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "创建文件夹失败:%v", err)
|
||||
return
|
||||
}
|
||||
if io.Exists(filepath.Join(path, req.File.Filename)) {
|
||||
Error(w, http.StatusForbidden, "target backup %s already exists", path)
|
||||
return
|
||||
}
|
||||
|
||||
src, _ := handler.Open()
|
||||
out, err := os.OpenFile(filepath.Join(path, handler.Filename), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
|
||||
src, _ := req.File.Open()
|
||||
out, err := os.OpenFile(filepath.Join(path, req.File.Filename), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "打开文件失败:%v", err)
|
||||
Error(w, http.StatusInternalServerError, "open file error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = stdio.Copy(out, src); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "写入文件失败:%v", err)
|
||||
Error(w, http.StatusInternalServerError, "write file error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,7 @@ export default {
|
||||
create: (type: string, target: string, path: string): any =>
|
||||
http.Post(`/backup/${type}`, { target, path }),
|
||||
// 上传备份
|
||||
upload: (type: string, formData: FormData): any => {
|
||||
return http.Post(`/backup/${type}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
upload: (type: string, formData: FormData): any => http.Post(`/backup/${type}/upload`, formData),
|
||||
// 删除备份
|
||||
delete: (type: string, file: string): any => http.Delete(`/backup/${type}/delete`, { file }),
|
||||
// 恢复备份
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import website from '@/api/panel/website'
|
||||
|
||||
defineOptions({
|
||||
name: 'backup-index'
|
||||
})
|
||||
|
||||
import app from '@/api/panel/app'
|
||||
import backup from '@/api/panel/backup'
|
||||
import dashboard from '@/api/panel/dashboard'
|
||||
import ListView from '@/views/backup/ListView.vue'
|
||||
import { NButton, NInput } from 'naive-ui'
|
||||
|
||||
const currentTab = ref('website')
|
||||
const createModal = ref(false)
|
||||
const createModel = ref({
|
||||
target: '',
|
||||
path: ''
|
||||
})
|
||||
|
||||
const { data: installedDbAndPhp } = useRequest(dashboard.installedDbAndPhp, {
|
||||
initialData: {
|
||||
@@ -36,60 +26,10 @@ const mySQLInstalled = computed(() => {
|
||||
const postgreSQLInstalled = computed(() => {
|
||||
return installedDbAndPhp.value.db.find((item: any) => item.value === 'postgresql')
|
||||
})
|
||||
|
||||
const websites = ref<any>([])
|
||||
|
||||
const handleCreate = () => {
|
||||
useRequest(
|
||||
backup.create(currentTab.value, createModel.value.target, createModel.value.path)
|
||||
).onSuccess(() => {
|
||||
createModal.value = false
|
||||
window.$bus.emit('backup:refresh')
|
||||
window.$message.success('创建成功')
|
||||
})
|
||||
}
|
||||
|
||||
watch(currentTab, () => {
|
||||
if (currentTab.value === 'website') {
|
||||
createModel.value.target = websites.value[0]?.value
|
||||
} else {
|
||||
createModel.value.target = ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
useRequest(app.isInstalled('nginx')).onSuccess(({ data }) => {
|
||||
if (data.installed) {
|
||||
useRequest(website.list(1, 10000)).onSuccess(({ data }: { data: any }) => {
|
||||
for (const item of data.items) {
|
||||
websites.value.push({
|
||||
label: item.name,
|
||||
value: item.name
|
||||
})
|
||||
}
|
||||
createModel.value.target = websites.value[0]?.value
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<common-page show-footer>
|
||||
<template #action>
|
||||
<n-button v-if="currentTab == 'website'" type="primary" @click="createModal = true">
|
||||
<TheIcon :size="18" icon="material-symbols:add" />
|
||||
备份网站
|
||||
</n-button>
|
||||
<n-button v-if="currentTab == 'mysql'" type="primary" @click="createModal = true">
|
||||
<TheIcon :size="18" icon="material-symbols:add" />
|
||||
备份 MySQL
|
||||
</n-button>
|
||||
<n-button v-if="currentTab == 'postgres'" type="primary" @click="createModal = true">
|
||||
<TheIcon :size="18" icon="material-symbols:add" />
|
||||
备份 PostgreSQL
|
||||
</n-button>
|
||||
</template>
|
||||
<n-flex vertical>
|
||||
<n-tabs v-model:value="currentTab" type="line" animated>
|
||||
<n-tab-pane name="website" tab="网站">
|
||||
@@ -104,37 +44,4 @@ onMounted(() => {
|
||||
</n-tabs>
|
||||
</n-flex>
|
||||
</common-page>
|
||||
<n-modal
|
||||
v-model:show="createModal"
|
||||
preset="card"
|
||||
title="创建备份"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="createModal = false"
|
||||
>
|
||||
<n-form :model="createModel">
|
||||
<n-form-item v-if="currentTab == 'website'" path="name" label="网站">
|
||||
<n-select v-model:value="createModel.target" :options="websites" placeholder="选择网站" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="currentTab != 'website'" path="name" label="数据库名">
|
||||
<n-input
|
||||
v-model:value="createModel.target"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
placeholder="输入数据库名称"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="path" label="保存目录">
|
||||
<n-input
|
||||
v-model:value="createModel.path"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
placeholder="留空使用默认路径"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="info" block @click="handleCreate">提交</n-button>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
@@ -2,16 +2,25 @@
|
||||
import backup from '@/api/panel/backup'
|
||||
import { renderIcon } from '@/utils'
|
||||
import type { MessageReactive } from 'naive-ui'
|
||||
import { NButton, NDataTable, NInput, NPopconfirm } from 'naive-ui'
|
||||
import { NButton, NDataTable, NFlex, NInput, NPopconfirm } from 'naive-ui'
|
||||
|
||||
import app from '@/api/panel/app'
|
||||
import website from '@/api/panel/website'
|
||||
import { formatDateTime } from '@/utils'
|
||||
import UploadModal from '@/views/backup/UploadModal.vue'
|
||||
|
||||
const type = defineModel<string>('type', { type: String, required: true })
|
||||
|
||||
let messageReactive: MessageReactive | null = null
|
||||
|
||||
const uploadModal = ref(false)
|
||||
|
||||
const createModal = ref(false)
|
||||
const createModel = ref({
|
||||
target: '',
|
||||
path: ''
|
||||
})
|
||||
|
||||
const restoreModal = ref(false)
|
||||
const restoreModel = ref({
|
||||
file: '',
|
||||
@@ -107,6 +116,16 @@ const { loading, data, page, total, pageSize, pageCount, refresh } = usePaginati
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreate = () => {
|
||||
useRequest(backup.create(type.value, createModel.value.target, createModel.value.path)).onSuccess(
|
||||
() => {
|
||||
createModal.value = false
|
||||
window.$bus.emit('backup:refresh')
|
||||
window.$message.success('创建成功')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleRestore = () => {
|
||||
messageReactive = window.$message.loading('恢复中...', {
|
||||
duration: 0
|
||||
@@ -140,15 +159,14 @@ onMounted(() => {
|
||||
})
|
||||
}
|
||||
if (type.value === 'website') {
|
||||
createModel.value.target = websites.value[0]?.value
|
||||
restoreModel.value.target = websites.value[0]?.value
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
refresh()
|
||||
window.$bus.on('backup:refresh', () => {
|
||||
refresh()
|
||||
})
|
||||
window.$bus.on('backup:refresh', refresh)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -157,26 +175,65 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:scroll-x="1000"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-key="(row: any) => row.name"
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
:pagination="{
|
||||
page: page,
|
||||
pageCount: pageCount,
|
||||
pageSize: pageSize,
|
||||
itemCount: total,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100, 200]
|
||||
}"
|
||||
/>
|
||||
<n-flex vertical :size="20">
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="createModal = true"> 创建备份 </n-button>
|
||||
<n-button type="primary" @click="uploadModal = true" ghost> 上传备份 </n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:scroll-x="1000"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-key="(row: any) => row.name"
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
:pagination="{
|
||||
page: page,
|
||||
pageCount: pageCount,
|
||||
pageSize: pageSize,
|
||||
itemCount: total,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100, 200]
|
||||
}"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-modal
|
||||
v-model:show="createModal"
|
||||
preset="card"
|
||||
title="创建备份"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="createModal = false"
|
||||
>
|
||||
<n-form :model="createModel">
|
||||
<n-form-item v-if="type == 'website'" path="name" label="网站">
|
||||
<n-select v-model:value="createModel.target" :options="websites" placeholder="选择网站" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="type != 'website'" path="name" label="数据库名">
|
||||
<n-input
|
||||
v-model:value="createModel.target"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
placeholder="输入数据库名称"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="path" label="保存目录">
|
||||
<n-input
|
||||
v-model:value="createModel.path"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
placeholder="留空使用默认路径"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="info" block @click="handleCreate">提交</n-button>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="restoreModal"
|
||||
preset="card"
|
||||
@@ -197,6 +254,7 @@ onUnmounted(() => {
|
||||
</n-form>
|
||||
<n-button type="info" block @click="handleRestore">提交</n-button>
|
||||
</n-modal>
|
||||
<upload-modal v-model:show="uploadModal" v-model:type="type" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
55
web/src/views/backup/UploadModal.vue
Normal file
55
web/src/views/backup/UploadModal.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadCustomRequestOptions } from 'naive-ui'
|
||||
|
||||
import api from '@/api/panel/backup'
|
||||
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const type = defineModel<string>('type', { type: String, required: true })
|
||||
const upload = ref<any>(null)
|
||||
|
||||
const uploadRequest = ({ file, onFinish, onError, onProgress }: UploadCustomRequestOptions) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file as File)
|
||||
const { uploading } = useRequest(api.upload(type.value, formData))
|
||||
.onSuccess(() => {
|
||||
onFinish()
|
||||
window.$bus.emit('backup:refresh')
|
||||
window.$message.success(`上传 ${file.name} 成功`)
|
||||
})
|
||||
.onError(() => {
|
||||
onError()
|
||||
})
|
||||
.onComplete(() => {
|
||||
stopWatch()
|
||||
})
|
||||
const stopWatch = watch(uploading, (progress) => {
|
||||
onProgress({ percent: Math.ceil((progress.loaded / progress.total) * 100) })
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
title="上传备份"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<n-upload ref="upload" multiple directory-dnd :custom-request="uploadRequest">
|
||||
<n-upload-dragger>
|
||||
<div style="margin-bottom: 12px">
|
||||
<the-icon :size="48" icon="bi:arrow-up-square" />
|
||||
</div>
|
||||
<NText text-18> 点击或者拖动文件到该区域来上传</NText>
|
||||
<NP depth="3" m-10> 大文件建议使用 SFTP 等方式上传 </NP>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -40,13 +40,7 @@ const uploadRequest = ({ file, onFinish, onError, onProgress }: UploadCustomRequ
|
||||
:segmented="false"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<n-upload
|
||||
ref="upload"
|
||||
multiple
|
||||
directory-dnd
|
||||
action="/api/panel/file/upload"
|
||||
:custom-request="uploadRequest"
|
||||
>
|
||||
<n-upload ref="upload" multiple directory-dnd :custom-request="uploadRequest">
|
||||
<n-upload-dragger>
|
||||
<div style="margin-bottom: 12px">
|
||||
<the-icon :size="48" icon="bi:arrow-up-square" />
|
||||
|
||||
Reference in New Issue
Block a user