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:
sagit
2026-02-18 21:14:05 +08:00
committed by GitHub
parent 17f8a06704
commit d6c17aee79
3 changed files with 179 additions and 69 deletions
+1
View File
@@ -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
+1
Submodule analysis/3x-ui added at 5b796672e9
+177 -69
View File
@@ -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<string, string> => {
if (typeof window === "undefined") return {};
@@ -142,6 +164,8 @@ export default function ConfigPage() {
const [importTypes, setImportTypes] = useState<string[]>([]);
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<HTMLInputElement>(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<HTMLInputElement>) => {
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) {
return (
<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">
选择要导出的数据类型,导出为 JSON 格式文件
</p>
<CheckboxGroup
classNames={{
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>
<p className="text-xs text-default-500">
当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}
</p>
<div className="flex gap-3">
<Button
color="primary"
isLoading={exporting}
onPress={handleExport}
onPress={() => setExportSelectorOpen(true)}
>
{exporting ? "导出中..." : "导出数据"}
</Button>
<Button
variant="bordered"
onPress={() => {
setExportTypes([
"users",
"nodes",
"tunnels",
"forwards",
"userTunnels",
"speedLimits",
"tunnelGroups",
"userGroups",
"permissions",
"configs",
]);
}}
>
全选
</Button>
<Button variant="flat" onPress={() => setExportTypes([])}>
清空
{exporting ? "导出中..." : "选择并导出"}
</Button>
</div>
</div>
@@ -678,27 +758,9 @@ export default function ConfigPage() {
<p className="text-sm text-gray-600 dark:text-gray-400">
选择要导入的数据类型,支持从备份文件恢复数据
</p>
<CheckboxGroup
classNames={{
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>
<p className="text-xs text-default-500">
当前已选 {importTypes.length} / {BACKUP_TYPE_VALUES.length}
</p>
<input
ref={fileInputRef}
@@ -713,9 +775,9 @@ export default function ConfigPage() {
color="primary"
isLoading={importing}
variant="flat"
onPress={() => fileInputRef.current?.click()}
onPress={() => setImportSelectorOpen(true)}
>
{importing ? "导入中..." : "选择文件导入"}
{importing ? "导入中..." : "选择并导入"}
</Button>
{importFileName && (
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
@@ -726,6 +788,52 @@ export default function ConfigPage() {
</div>
</CardBody>
</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>
);
}