新增后台数据库备份与回滚
This commit is contained in:
@@ -104,3 +104,18 @@ export interface OperationLog {
|
||||
summary: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DatabaseBackup {
|
||||
filename: string;
|
||||
database_type: string;
|
||||
created_at: string;
|
||||
size_bytes: number;
|
||||
size_label: string;
|
||||
reason: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface RestoreDatabaseBackupResult {
|
||||
restored_backup: DatabaseBackup;
|
||||
pre_restore_backup: DatabaseBackup;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import AdminBackups from "../views/AdminBackups.vue";
|
||||
import AdminCertificates from "../views/AdminCertificates.vue";
|
||||
import AdminHome from "../views/AdminHome.vue";
|
||||
import AdminImports from "../views/AdminImports.vue";
|
||||
@@ -26,7 +27,8 @@ export const router = createRouter({
|
||||
{ path: "learners", component: AdminLearners },
|
||||
{ path: "certificates", component: AdminCertificates },
|
||||
{ path: "imports", component: AdminImports },
|
||||
{ path: "logs", component: AdminLogs }
|
||||
{ path: "logs", component: AdminLogs },
|
||||
{ path: "backups", component: AdminBackups }
|
||||
]
|
||||
},
|
||||
{ path: "/query", component: CertificateQuery },
|
||||
|
||||
182
frontend/src/views/AdminBackups.vue
Normal file
182
frontend/src/views/AdminBackups.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>备份管理</h2>
|
||||
<p>查看本地数据库备份,必要时手动备份或回滚到指定时间点。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button @click="loadBackups">刷新</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="createBackup">立即备份</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert
|
||||
class="risk-alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="回滚会覆盖当前数据库。系统会先自动备份当前库,再执行恢复;建议在无人操作后台时使用。"
|
||||
/>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3>本地备份点</h3>
|
||||
<p>备份文件保存在服务器的系统数据目录中,只能由系统管理员操作。</p>
|
||||
</div>
|
||||
<el-tag type="info">{{ backups.length }} 个备份</el-tag>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="backups" border empty-text="还没有数据库备份">
|
||||
<el-table-column label="备份时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="filename" label="文件名" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column label="数据库" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.database_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="size_label" label="大小" width="110" />
|
||||
<el-table-column label="来源" min-width="170">
|
||||
<template #default="{ row }">{{ reasonLabel(row.reason) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" plain size="small" :loading="restoring === row.filename" @click="confirmRestore(row)">
|
||||
回滚
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { http, type DatabaseBackup, type RestoreDatabaseBackupResult } from "../api";
|
||||
|
||||
const backups = ref<DatabaseBackup[]>([]);
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const restoring = ref("");
|
||||
|
||||
function formatTime(value: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string | null) {
|
||||
if (!reason || reason === "manual") return "手动备份";
|
||||
if (reason.startsWith("pre_restore_")) return "回滚前自动备份";
|
||||
return reason;
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await http.get<DatabaseBackup[]>("/admin/backups");
|
||||
backups.value = data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
creating.value = true;
|
||||
try {
|
||||
await http.post<DatabaseBackup>("/admin/backups", { reason: "manual" });
|
||||
ElMessage.success("数据库备份已创建");
|
||||
await loadBackups();
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRestore(row: DatabaseBackup) {
|
||||
const confirmation = `RESTORE ${row.filename}`;
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
`将数据库回滚到 ${formatTime(row.created_at)}。请输入确认语:${confirmation}`,
|
||||
"确认回滚数据库",
|
||||
{
|
||||
confirmButtonText: "确认回滚",
|
||||
cancelButtonText: "取消",
|
||||
inputPlaceholder: confirmation,
|
||||
inputValidator: (input) => input.trim() === confirmation || "确认语不正确",
|
||||
type: "warning",
|
||||
},
|
||||
);
|
||||
restoring.value = row.filename;
|
||||
const { data } = await http.post<RestoreDatabaseBackupResult>(`/admin/backups/${encodeURIComponent(row.filename)}/restore`, {
|
||||
confirmation: String(value),
|
||||
});
|
||||
ElMessage.success(`已回滚,回滚前备份:${data.pre_restore_backup.filename}`);
|
||||
await loadBackups();
|
||||
} finally {
|
||||
restoring.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadBackups);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-head p,
|
||||
.panel-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.risk-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panel-head h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-head,
|
||||
.panel-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,7 @@
|
||||
<el-option label="学员" value="learner" />
|
||||
<el-option label="证书" value="certificate" />
|
||||
<el-option label="导入批次" value="import_batch" />
|
||||
<el-option label="数据库备份" value="database_backup" />
|
||||
<el-option label="公开访问" value="public_access" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.risk" clearable placeholder="风险等级">
|
||||
@@ -119,6 +120,8 @@ const actionOptions = [
|
||||
{ value: "confirm_import_batch", label: "确认导入" },
|
||||
{ value: "delete_import_batch", label: "删除导入批次" },
|
||||
{ value: "export_certificates", label: "导出证书" },
|
||||
{ value: "create_database_backup", label: "创建数据库备份" },
|
||||
{ value: "restore_database_backup", label: "回滚数据库备份" },
|
||||
{ value: "public_search_certificate", label: "公开查询证书" },
|
||||
{ value: "public_download_certificate", label: "公开下载证书" },
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
|
||||
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
|
||||
<el-menu-item index="/admin/logs">操作日志</el-menu-item>
|
||||
<el-menu-item index="/admin/backups">备份管理</el-menu-item>
|
||||
</el-menu>
|
||||
<el-button class="logout" @click="logout">退出登录</el-button>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user