新增后台数据库备份与回滚

This commit is contained in:
2026-06-23 12:27:16 +08:00
parent 668e0403af
commit 529e175d2c
17 changed files with 754 additions and 26 deletions

View File

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

View File

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

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

View File

@@ -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: "公开下载证书" },
];

View File

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