From f879a58bb465d47f75264fd36caadf7680dc05d7 Mon Sep 17 00:00:00 2001 From: sagit Date: Fri, 13 Feb 2026 03:06:32 +0000 Subject: [PATCH] feat(frontend): add backup export and import UI Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- vite-frontend/src/api/index.ts | 43 ++++++ vite-frontend/src/pages/config.tsx | 205 ++++++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 3 deletions(-) diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index f0b56d2..0863be0 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -210,3 +210,46 @@ export const importRemoteNode = (data: { remoteUrl: string; token: string; }) => Network.post("/federation/node/import", data); + +import axios from "axios"; + +export interface BackupTypes { + users?: boolean; + nodes?: boolean; + tunnels?: boolean; + forwards?: boolean; + userTunnels?: boolean; + speedLimits?: boolean; + tunnelGroups?: boolean; + userGroups?: boolean; + permissions?: boolean; + configs?: boolean; +} + +export const exportBackup = async (types: string[] = []) => { + const token = window.localStorage.getItem("token"); + const baseURL = axios.defaults.baseURL || "/api/v1/"; + + const response = await axios.post(`${baseURL}/backup/export`, { types }, { + headers: { + Authorization: token, + "Content-Type": "application/json", + }, + responseType: "blob", + }); + + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, ""); + link.setAttribute("download", `backup_${timestamp}.json`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +}; + +export const importBackup = (data: { + types: string[]; + [key: string]: any; +}) => Network.post("/backup/import", data); diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index 4ee5498..71bc198 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "@heroui/button"; import { Card, CardBody, CardHeader } from "@heroui/card"; @@ -7,9 +7,10 @@ import { Spinner } from "@heroui/spinner"; import { Divider } from "@heroui/divider"; import { Switch } from "@heroui/switch"; import { Select, SelectItem } from "@heroui/select"; +import { Checkbox, CheckboxGroup } from "@heroui/checkbox"; import toast from "react-hot-toast"; -import { updateConfigs } from "@/api"; +import { updateConfigs, exportBackup, importBackup } from "@/api"; import { SettingsIcon } from "@/components/icons"; import { isAdmin } from "@/utils/auth"; import { @@ -130,12 +131,19 @@ export default function ConfigPage() { useState>(initialConfigs); const [loading, setLoading] = useState( Object.keys(initialConfigs).length === 0, - ); // 如果有缓存数据,不显示loading + ); const [saving, setSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); const [originalConfigs, setOriginalConfigs] = useState>(initialConfigs); + const [exportTypes, setExportTypes] = useState([]); + const [importTypes, setImportTypes] = useState([]); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + const [importFileName, setImportFileName] = useState(""); + const fileInputRef = useRef(null); + // 权限检查 useEffect(() => { if (!isAdmin()) { @@ -331,6 +339,60 @@ export default function ConfigPage() { } }; + const handleExport = async () => { + if (exportTypes.length === 0) { + toast.error("请至少选择一种数据类型"); + return; + } + setExporting(true); + try { + await exportBackup(exportTypes); + toast.success("导出成功"); + } catch { + toast.error("导出失败,请重试"); + } finally { + setExporting(false); + } + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (importTypes.length === 0) { + toast.error("请先选择要导入的数据类型"); + return; + } + + setImportFileName(file.name); + setImporting(true); + + try { + const text = await file.text(); + const data = JSON.parse(text); + + const response = await importBackup({ + types: importTypes, + ...data, + }); + + if (response.code === 0) { + toast.success(`导入成功: ${JSON.stringify(response.data)}`); + setImportTypes([]); + setImportFileName(""); + } else { + toast.error("导入失败: " + response.msg); + } + } catch { + toast.error("导入失败,请检查文件格式"); + } finally { + setImporting(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + if (loading) { return (
@@ -427,6 +489,143 @@ export default function ConfigPage() { )} + + {/* 备份与恢复 */} + + +
+
+

数据备份与恢复

+

+ 导出或导入系统数据,支持选择特定数据类型 +

+
+
+
+ + + + + {/* 导出部分 */} +
+

导出数据

+

+ 选择要导出的数据类型,导出为 JSON 格式文件 +

+ + setExportTypes(values as string[])} + > + 用户 + 节点 + 隧道 + 转发 + 用户隧道权限 + 限速规则 + 隧道分组 + 用户分组 + 分组权限 + 系统配置 + + +
+ + + +
+
+ + + + {/* 导入部分 */} +
+

导入数据

+

+ 选择要导入的数据类型,支持从备份文件恢复数据 +

+ + setImportTypes(values as string[])} + > + 用户 + 节点 + 隧道 + 转发 + 用户隧道权限 + 限速规则 + 隧道分组 + 用户分组 + 分组权限 + 系统配置 + + + + +
+ + {importFileName && ( + + 已选择: {importFileName} + + )} +
+
+
+
); }