diff --git a/.gitignore b/.gitignore index 33f5c97..741186c 100644 --- a/.gitignore +++ b/.gitignore @@ -259,6 +259,7 @@ gitee/ doraemon.jks device.id commit.sh +.opencode/ sql/ !go-backend/internal/store/sqlite/sql/ !go-backend/internal/store/sqlite/sql/schema.sql diff --git a/analysis/3x-ui b/analysis/3x-ui new file mode 160000 index 0000000..5b79667 --- /dev/null +++ b/analysis/3x-ui @@ -0,0 +1 @@ +Subproject commit 5b796672e90934b34186b0a6809857c417304f5f diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index a1c19ea..1776280 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -8,7 +8,14 @@ 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 { Checkbox } from "@heroui/checkbox"; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@heroui/modal"; import toast from "react-hot-toast"; import { updateConfigs, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData } from "@/api"; @@ -98,6 +105,21 @@ const CONFIG_ITEMS: ConfigItem[] = [ }, ]; +const BACKUP_TYPE_OPTIONS = [ + { value: "users", label: "用户" }, + { value: "nodes", label: "节点" }, + { value: "tunnels", label: "隧道" }, + { value: "forwards", label: "转发" }, + { value: "userTunnels", label: "用户隧道权限" }, + { value: "speedLimits", label: "限速规则" }, + { value: "tunnelGroups", label: "隧道分组" }, + { value: "userGroups", label: "用户分组" }, + { value: "permissions", label: "分组权限" }, + { value: "configs", label: "系统配置" }, +] as const; + +const BACKUP_TYPE_VALUES = BACKUP_TYPE_OPTIONS.map((option) => option.value); + // 初始化时从缓存读取配置,避免闪烁 const getInitialConfigs = (): Record => { if (typeof window === "undefined") return {}; @@ -142,6 +164,8 @@ export default function ConfigPage() { const [importTypes, setImportTypes] = useState([]); const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); + const [exportSelectorOpen, setExportSelectorOpen] = useState(false); + const [importSelectorOpen, setImportSelectorOpen] = useState(false); const [importFileName, setImportFileName] = useState(""); const fileInputRef = useRef(null); @@ -387,6 +411,7 @@ export default function ConfigPage() { try { await exportBackup(exportTypes); toast.success("导出成功"); + setExportSelectorOpen(false); } catch { toast.error("导出失败,请重试"); } finally { @@ -394,6 +419,17 @@ export default function ConfigPage() { } }; + const triggerImportFilePicker = () => { + if (importTypes.length === 0) { + toast.error("请先选择要导入的数据类型"); + + return; + } + + setImportSelectorOpen(false); + requestAnimationFrame(() => fileInputRef.current?.click()); + }; + const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -434,6 +470,90 @@ export default function ConfigPage() { } }; + const toggleTypeSelection = ( + type: string, + setTypes: React.Dispatch>, + ) => { + setTypes((prev) => + prev.includes(type) + ? prev.filter((item) => item !== type) + : [...prev, type], + ); + }; + + const isAllTypesSelected = (types: string[]) => + BACKUP_TYPE_VALUES.every((type) => types.includes(type)); + + const renderTypeSelection = ( + label: string, + selectedTypes: string[], + setTypes: React.Dispatch>, + ) => { + const allSelected = isAllTypesSelected(selectedTypes); + + return ( +
+
+ + {label} + +
+ + +
+
+ +
+ {BACKUP_TYPE_OPTIONS.map((option) => { + const isSelected = selectedTypes.includes(option.value); + + return ( + + ); + })} +
+
+ ); + }; + if (loading) { return (
@@ -615,57 +735,17 @@ export default function ConfigPage() {

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

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

+ 当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length} +

- -
@@ -678,27 +758,9 @@ export default function ConfigPage() {

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

- - setImportTypes(values as string[])} - > - 用户 - 节点 - 隧道 - 转发 - 用户隧道权限 - 限速规则 - 隧道分组 - 用户分组 - 分组权限 - 系统配置 - +

+ 当前已选 {importTypes.length} / {BACKUP_TYPE_VALUES.length} +

fileInputRef.current?.click()} + onPress={() => setImportSelectorOpen(true)} > - {importing ? "导入中..." : "选择文件导入"} + {importing ? "导入中..." : "选择并导入"} {importFileName && ( @@ -726,6 +788,52 @@ export default function ConfigPage() { + + + + {(onClose) => ( + <> + 选择导出内容 + + {renderTypeSelection("导出内容", exportTypes, setExportTypes)} + + + + + + + )} + + + + + + {(onClose) => ( + <> + 选择导入内容 + + {renderTypeSelection("导入内容", importTypes, setImportTypes)} + + + + + + + )} + + ); }