2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 06:47:20 +08:00

feat: 备份支持上传

This commit is contained in:
耗子
2025-03-27 03:59:47 +08:00
parent 7ee6dd4d0e
commit 8683a85640
7 changed files with 172 additions and 148 deletions

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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 }),
// 恢复备份

View 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>

View File

@@ -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>

View 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>

View File

@@ -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" />