mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 18:56:37 +08:00
feat(config): use tunnel-style selectors for backup import/export (#140)
* feat(config): use tunnel-style backup selectors with select-all * feat(config): move backup selectors into modals * chore(repo): ignore .opencode and add analysis reference
This commit is contained in:
@@ -259,6 +259,7 @@ gitee/
|
|||||||
doraemon.jks
|
doraemon.jks
|
||||||
device.id
|
device.id
|
||||||
commit.sh
|
commit.sh
|
||||||
|
.opencode/
|
||||||
sql/
|
sql/
|
||||||
!go-backend/internal/store/sqlite/sql/
|
!go-backend/internal/store/sqlite/sql/
|
||||||
!go-backend/internal/store/sqlite/sql/schema.sql
|
!go-backend/internal/store/sqlite/sql/schema.sql
|
||||||
|
|||||||
Submodule
+1
Submodule analysis/3x-ui added at 5b796672e9
@@ -8,7 +8,14 @@ import { Spinner } from "@heroui/spinner";
|
|||||||
import { Divider } from "@heroui/divider";
|
import { Divider } from "@heroui/divider";
|
||||||
import { Switch } from "@heroui/switch";
|
import { Switch } from "@heroui/switch";
|
||||||
import { Select, SelectItem } from "@heroui/select";
|
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 toast from "react-hot-toast";
|
||||||
|
|
||||||
import { updateConfigs, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData } from "@/api";
|
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<string, string> => {
|
const getInitialConfigs = (): Record<string, string> => {
|
||||||
if (typeof window === "undefined") return {};
|
if (typeof window === "undefined") return {};
|
||||||
@@ -142,6 +164,8 @@ export default function ConfigPage() {
|
|||||||
const [importTypes, setImportTypes] = useState<string[]>([]);
|
const [importTypes, setImportTypes] = useState<string[]>([]);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [exportSelectorOpen, setExportSelectorOpen] = useState(false);
|
||||||
|
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
|
||||||
const [importFileName, setImportFileName] = useState("");
|
const [importFileName, setImportFileName] = useState("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -387,6 +411,7 @@ export default function ConfigPage() {
|
|||||||
try {
|
try {
|
||||||
await exportBackup(exportTypes);
|
await exportBackup(exportTypes);
|
||||||
toast.success("导出成功");
|
toast.success("导出成功");
|
||||||
|
setExportSelectorOpen(false);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("导出失败,请重试");
|
toast.error("导出失败,请重试");
|
||||||
} finally {
|
} 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<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
@@ -434,6 +470,90 @@ export default function ConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleTypeSelection = (
|
||||||
|
type: string,
|
||||||
|
setTypes: React.Dispatch<React.SetStateAction<string[]>>,
|
||||||
|
) => {
|
||||||
|
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<React.SetStateAction<string[]>>,
|
||||||
|
) => {
|
||||||
|
const allSelected = isAllTypesSelected(selectedTypes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-default-700 dark:text-default-300">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])}
|
||||||
|
>
|
||||||
|
{allSelected ? "取消全选" : "全选"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="light" onPress={() => setTypes([])}>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{BACKUP_TYPE_OPTIONS.map((option) => {
|
||||||
|
const isSelected = selectedTypes.includes(option.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${
|
||||||
|
isSelected
|
||||||
|
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||||
|
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTypeSelection(option.value, setTypes)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
classNames={{
|
||||||
|
base: "pointer-events-none",
|
||||||
|
}}
|
||||||
|
color="primary"
|
||||||
|
isSelected={isSelected}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
isSelected
|
||||||
|
? "text-default-900 dark:text-default-100"
|
||||||
|
: "text-default-700 dark:text-default-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -615,57 +735,17 @@ export default function ConfigPage() {
|
|||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
选择要导出的数据类型,导出为 JSON 格式文件
|
选择要导出的数据类型,导出为 JSON 格式文件
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-default-500">
|
||||||
<CheckboxGroup
|
当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}
|
||||||
classNames={{
|
</p>
|
||||||
wrapper: "gap-4",
|
|
||||||
}}
|
|
||||||
label="选择导出内容"
|
|
||||||
orientation="horizontal"
|
|
||||||
value={exportTypes}
|
|
||||||
onValueChange={(values) => setExportTypes(values as string[])}
|
|
||||||
>
|
|
||||||
<Checkbox value="users">用户</Checkbox>
|
|
||||||
<Checkbox value="nodes">节点</Checkbox>
|
|
||||||
<Checkbox value="tunnels">隧道</Checkbox>
|
|
||||||
<Checkbox value="forwards">转发</Checkbox>
|
|
||||||
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
|
||||||
<Checkbox value="speedLimits">限速规则</Checkbox>
|
|
||||||
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
|
||||||
<Checkbox value="userGroups">用户分组</Checkbox>
|
|
||||||
<Checkbox value="permissions">分组权限</Checkbox>
|
|
||||||
<Checkbox value="configs">系统配置</Checkbox>
|
|
||||||
</CheckboxGroup>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
isLoading={exporting}
|
isLoading={exporting}
|
||||||
onPress={handleExport}
|
onPress={() => setExportSelectorOpen(true)}
|
||||||
>
|
>
|
||||||
{exporting ? "导出中..." : "导出数据"}
|
{exporting ? "导出中..." : "选择并导出"}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="bordered"
|
|
||||||
onPress={() => {
|
|
||||||
setExportTypes([
|
|
||||||
"users",
|
|
||||||
"nodes",
|
|
||||||
"tunnels",
|
|
||||||
"forwards",
|
|
||||||
"userTunnels",
|
|
||||||
"speedLimits",
|
|
||||||
"tunnelGroups",
|
|
||||||
"userGroups",
|
|
||||||
"permissions",
|
|
||||||
"configs",
|
|
||||||
]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
全选
|
|
||||||
</Button>
|
|
||||||
<Button variant="flat" onPress={() => setExportTypes([])}>
|
|
||||||
清空
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -678,27 +758,9 @@ export default function ConfigPage() {
|
|||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
选择要导入的数据类型,支持从备份文件恢复数据
|
选择要导入的数据类型,支持从备份文件恢复数据
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-default-500">
|
||||||
<CheckboxGroup
|
当前已选 {importTypes.length} / {BACKUP_TYPE_VALUES.length}
|
||||||
classNames={{
|
</p>
|
||||||
wrapper: "gap-4",
|
|
||||||
}}
|
|
||||||
label="选择导入内容"
|
|
||||||
orientation="horizontal"
|
|
||||||
value={importTypes}
|
|
||||||
onValueChange={(values) => setImportTypes(values as string[])}
|
|
||||||
>
|
|
||||||
<Checkbox value="users">用户</Checkbox>
|
|
||||||
<Checkbox value="nodes">节点</Checkbox>
|
|
||||||
<Checkbox value="tunnels">隧道</Checkbox>
|
|
||||||
<Checkbox value="forwards">转发</Checkbox>
|
|
||||||
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
|
||||||
<Checkbox value="speedLimits">限速规则</Checkbox>
|
|
||||||
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
|
||||||
<Checkbox value="userGroups">用户分组</Checkbox>
|
|
||||||
<Checkbox value="permissions">分组权限</Checkbox>
|
|
||||||
<Checkbox value="configs">系统配置</Checkbox>
|
|
||||||
</CheckboxGroup>
|
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -713,9 +775,9 @@ export default function ConfigPage() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
isLoading={importing}
|
isLoading={importing}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => fileInputRef.current?.click()}
|
onPress={() => setImportSelectorOpen(true)}
|
||||||
>
|
>
|
||||||
{importing ? "导入中..." : "选择文件导入"}
|
{importing ? "导入中..." : "选择并导入"}
|
||||||
</Button>
|
</Button>
|
||||||
{importFileName && (
|
{importFileName && (
|
||||||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
@@ -726,6 +788,52 @@ export default function ConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Modal isOpen={exportSelectorOpen} onOpenChange={setExportSelectorOpen}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>选择导出内容</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
{renderTypeSelection("导出内容", exportTypes, setExportTypes)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" isLoading={exporting} onPress={handleExport}>
|
||||||
|
{exporting ? "导出中..." : "确认导出"}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={importSelectorOpen} onOpenChange={setImportSelectorOpen}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>选择导入内容</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
{renderTypeSelection("导入内容", importTypes, setImportTypes)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isDisabled={importTypes.length === 0}
|
||||||
|
onPress={triggerImportFilePicker}
|
||||||
|
>
|
||||||
|
下一步选择文件
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user