mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:43:11 +07:00
hoàn thành chức năng backup v2 restore
This commit is contained in:
@@ -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ộ cơ sở dữ liệu (SQL dump) và 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 ? (
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user