hoàn thành chức năng backup v2 restore

This commit is contained in:
2026-04-02 09:21:03 +07:00
parent 2aa7d68e52
commit 8f2ccf473b
8 changed files with 732 additions and 40 deletions
+127
View File
@@ -40,6 +40,13 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState<number | null>(null); // userId being mutated
// Backup / Restore state
const [backupLoading, setBackupLoading] = useState(false);
const [restoreLoading, setRestoreLoading] = useState(false);
const [backupMsg, setBackupMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const restoreInputRef = useState<HTMLInputElement | null>(null);
const [filterStatus, setFilterStatus] = useState<UserStatus | "all">("all");
const [filterRole, setFilterRole] = useState<UserRole | "all">("all");
const [search, setSearch] = useState("");
@@ -238,6 +245,126 @@ export default function AdminPage() {
)}
</div>
{/* ── Backup & Restore ─────────────────────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 shadow-sm">
<h2 className="text-sm font-bold text-gray-800 mb-1">Sao lưu & Khôi phục</h2>
<p className="text-xs text-gray-400 mb-4">
File ZIP chứa toàn bộ sở dữ liệu (SQL dump) các file PDF đã tải lên.
Dùng đ di chuyển sang server khác.
</p>
{backupMsg && (
<div className={`mb-4 text-xs px-3 py-2 rounded-lg flex items-center justify-between ${
backupMsg.type === "ok"
? "bg-green-50 border border-green-200 text-green-700"
: "bg-red-50 border border-red-200 text-red-700"
}`}>
{backupMsg.text}
<button onClick={() => setBackupMsg(null)} className="ml-3 opacity-60 hover:opacity-100"></button>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3">
{/* Download backup */}
<button
disabled={backupLoading}
onClick={async () => {
setBackupLoading(true);
setBackupMsg(null);
try {
const blob = await api.adminDownloadBackup();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
a.href = url;
a.download = `lms_backup_${ts}.zip`;
a.click();
URL.revokeObjectURL(url);
setBackupMsg({ type: "ok", text: "Tải backup thành công." });
} catch (e: unknown) {
setBackupMsg({ type: "err", text: e instanceof Error ? e.message : "Backup thất bại." });
} finally {
setBackupLoading(false);
}
}}
className="flex items-center justify-center gap-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white px-4 py-2 rounded-lg transition"
>
{backupLoading ? (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
)}
{backupLoading ? "Đang tạo backup…" : "Tải backup (.zip)"}
</button>
{/* Restore */}
<label
className={`flex items-center justify-center gap-2 text-sm font-medium border-2 border-dashed px-4 py-2 rounded-lg transition cursor-pointer ${
restoreLoading
? "opacity-60 cursor-not-allowed border-gray-300 text-gray-400"
: "border-orange-300 text-orange-600 hover:bg-orange-50"
}`}
>
{restoreLoading ? (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12" />
</svg>
)}
{restoreLoading ? "Đang khôi phục…" : "Khôi phục từ backup (.zip)"}
<input
type="file"
accept=".zip"
className="hidden"
disabled={restoreLoading}
ref={el => { restoreInputRef[1](el); }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!confirm(
`⚠️ Khôi phục từ "${file.name}" sẽ XOÁ TOÀN BỘ dữ liệu hiện tại và thay bằng backup.\n\nBạn có chắc chắn không?`
)) {
e.target.value = "";
return;
}
setRestoreLoading(true);
setBackupMsg(null);
try {
const result = await api.adminRestoreBackup(file);
const ts = result.backup_created_at
? new Date(result.backup_created_at).toLocaleString("vi-VN")
: "";
setBackupMsg({
type: "ok",
text: `Khôi phục thành công${ts ? " (backup ngày " + ts + ")" : ""}. Trang sẽ tải lại sau 3 giây.`,
});
setTimeout(() => window.location.reload(), 3000);
} catch (err: unknown) {
setBackupMsg({ type: "err", text: err instanceof Error ? err.message : "Khôi phục thất bại." });
} finally {
setRestoreLoading(false);
e.target.value = "";
}
}}
/>
</label>
</div>
<p className="text-[10px] text-gray-300 mt-3">
Khôi phục sẽ ghi đè toàn bộ dữ liệu hiện tại (người dùng, PDF, annotation). Hãy tải backup trước khi khôi phục.
</p>
</div>
{/* ── Table ────────────────────────────────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
{filtered.length === 0 ? (
+27
View File
@@ -74,4 +74,31 @@ export const api = {
}),
adminDeleteUser: (userId: number) =>
request<void>(`/api/admin/users/${userId}`, { method: "DELETE" }),
// Backup / Restore
/** Triggers pg_dump + file pack; returns a Blob for the browser to download. */
adminDownloadBackup: async (): Promise<Blob> => {
const res = await fetch("/api/admin/backup/download", { credentials: "include" });
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? "Backup failed");
}
return res.blob();
},
/** Upload a backup ZIP to restore the system. */
adminRestoreBackup: async (file: File): Promise<{ message: string; backup_created_at: string }> => {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/admin/backup/restore", {
method: "POST",
credentials: "include",
body: form,
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? "Restore failed");
}
return res.json();
},
};