refactor: frontend (#5068)

* refactor: setting components

* refactor: frontend

* fix: settings router
This commit is contained in:
Sline
2025-10-15 18:57:44 +08:00
committed by GitHub
parent a591ee1efc
commit 0b4403b67b
34 changed files with 1072 additions and 861 deletions

View File

@@ -37,7 +37,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
);
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准
return (
<Box

View File

@@ -71,10 +71,11 @@ export const BackupConfigViewer = memo(
};
useEffect(() => {
if (webdav_url && webdav_username && webdav_password) {
onInit();
if (!webdav_url || !webdav_username || !webdav_password) {
return;
}
}, []);
void onInit();
}, [webdav_url, webdav_username, webdav_password, onInit]);
const checkForm = () => {
const username = usernameRef.current?.value;

View File

@@ -103,94 +103,97 @@ export const BackupTableViewer = memo(
</TableHead>
<TableBody>
{datasource.length > 0 ? (
datasource?.map((file, index) => (
<TableRow key={index}>
<TableCell component="th" scope="row">
{file.platform === "windows" ? (
<WindowsIcon className="h-full w-full" />
) : file.platform === "linux" ? (
<LinuxIcon className="h-full w-full" />
) : (
<MacIcon className="h-full w-full" />
)}
{file.filename}
</TableCell>
<TableCell align="center">
{file.backup_time.fromNow()}
</TableCell>
<TableCell align="right">
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
{onExport && (
<>
<IconButton
color="primary"
aria-label={t("Export")}
size="small"
title={t("Export Backup")}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
await handleExport(file.filename);
}}
>
<DownloadIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
</>
datasource.map((file) => {
const rowKey = `${file.platform}-${file.filename}-${file.backup_time.valueOf()}`;
return (
<TableRow key={rowKey}>
<TableCell component="th" scope="row">
{file.platform === "windows" ? (
<WindowsIcon className="h-full w-full" />
) : file.platform === "linux" ? (
<LinuxIcon className="h-full w-full" />
) : (
<MacIcon className="h-full w-full" />
)}
<IconButton
color="secondary"
aria-label={t("Delete")}
size="small"
title={t("Delete Backup")}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to delete this backup file?"),
);
if (confirmed) {
await handleDelete(file.filename);
}
{file.filename}
</TableCell>
<TableCell align="center">
{file.backup_time.fromNow()}
</TableCell>
<TableCell align="right">
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<DeleteIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
<IconButton
color="primary"
aria-label={t("Restore")}
size="small"
title={t("Restore Backup")}
disabled={!file.allow_apply}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to restore this backup file?"),
);
if (confirmed) {
await handleRestore(file.filename);
}
}}
>
<RestoreIcon />
</IconButton>
</Box>
</TableCell>
</TableRow>
))
{onExport && (
<>
<IconButton
color="primary"
aria-label={t("Export")}
size="small"
title={t("Export Backup")}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
await handleExport(file.filename);
}}
>
<DownloadIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
</>
)}
<IconButton
color="secondary"
aria-label={t("Delete")}
size="small"
title={t("Delete Backup")}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
const confirmed = window.confirm(
t("Confirm to delete this backup file?"),
);
if (confirmed) {
void handleDelete(file.filename);
}
}}
>
<DeleteIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
<IconButton
color="primary"
aria-label={t("Restore")}
size="small"
title={t("Restore Backup")}
disabled={!file.allow_apply}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
const confirmed = window.confirm(
t("Confirm to restore this backup file?"),
);
if (confirmed) {
void handleRestore(file.filename);
}
}}
>
<RestoreIcon />
</IconButton>
</Box>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={3} align="center">

View File

@@ -7,6 +7,7 @@ import {
useEffect,
useImperativeHandle,
useMemo,
useReducer,
useRef,
useState,
} from "react";
@@ -36,16 +37,20 @@ dayjs.extend(customParseFormat);
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
type BackupSource = "local" | "webdav";
type CloseButtonPosition = { top: number; left: number } | null;
export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const [dialogPaper, setDialogPaper] = useState<HTMLElement | null>(null);
const [closeButtonPosition, setCloseButtonPosition] = useState<{
top: number;
left: number;
} | null>(null);
const [dialogPaper, setDialogPaper] = useReducer(
(_: HTMLElement | null, next: HTMLElement | null) => next,
null as HTMLElement | null,
);
const [closeButtonPosition, setCloseButtonPosition] = useReducer(
(_: CloseButtonPosition, next: CloseButtonPosition) => next,
null as CloseButtonPosition,
);
const [isLoading, setIsLoading] = useState(false);
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);

View File

@@ -16,7 +16,14 @@ import { invoke } from "@tauri-apps/api/core";
import { useLockFn } from "ahooks";
import yaml from "js-yaml";
import type { Ref } from "react";
import { useEffect, useImperativeHandle, useState } from "react";
import {
useCallback,
useEffect,
useImperativeHandle,
useReducer,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import MonacoEditor from "react-monaco-editor";
@@ -35,6 +42,91 @@ const Item = styled(ListItem)(() => ({
},
}));
type NameserverPolicy = Record<string, any>;
function parseNameserverPolicy(str: string): NameserverPolicy {
const result: NameserverPolicy = {};
if (!str) return result;
const ruleRegex = /\s*([^=]+?)\s*=\s*([^,]+)(?:,|$)/g;
let match: RegExpExecArray | null;
while ((match = ruleRegex.exec(str)) !== null) {
const [, domainsPart, serversPart] = match;
const domains = [domainsPart.trim()];
const servers = serversPart.split(";").map((s) => s.trim());
domains.forEach((domain) => {
result[domain] = servers;
});
}
return result;
}
function formatNameserverPolicy(policy: unknown): string {
if (!policy || typeof policy !== "object") return "";
return Object.entries(policy as Record<string, unknown>)
.map(([domain, servers]) => {
const serversStr = Array.isArray(servers) ? servers.join(";") : servers;
return `${domain}=${serversStr}`;
})
.join(", ");
}
function formatHosts(hosts: unknown): string {
if (!hosts || typeof hosts !== "object") return "";
const result: string[] = [];
Object.entries(hosts as Record<string, unknown>).forEach(
([domain, value]) => {
if (Array.isArray(value)) {
const ipsStr = value.join(";");
result.push(`${domain}=${ipsStr}`);
} else {
result.push(`${domain}=${value}`);
}
},
);
return result.join(", ");
}
function parseHosts(str: string): NameserverPolicy {
const result: NameserverPolicy = {};
if (!str) return result;
str.split(",").forEach((item) => {
const parts = item.trim().split("=");
if (parts.length < 2) return;
const domain = parts[0].trim();
const valueStr = parts.slice(1).join("=").trim();
if (valueStr.includes(";")) {
result[domain] = valueStr
.split(";")
.map((s) => s.trim())
.filter(Boolean);
} else {
result[domain] = valueStr;
}
});
return result;
}
function parseList(str: string): string[] {
if (!str?.trim()) return [];
return str
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
// 默认DNS配置
const DEFAULT_DNS_CONFIG = {
enable: true,
@@ -95,6 +187,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const [open, setOpen] = useState(false);
const [visualization, setVisualization] = useState(true);
const skipYamlSyncRef = useRef(false);
const [values, setValues] = useState<{
enable: boolean;
listen: string;
@@ -150,304 +243,91 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
});
// 用于YAML编辑模式
const [yamlContent, setYamlContent] = useState("");
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
// 获取DNS配置文件并初始化表单
initDnsConfig();
},
close: () => setOpen(false),
}));
// 初始化DNS配置
const initDnsConfig = async () => {
try {
// 尝试从dns_config.yaml文件读取配置
const dnsConfigExists = await invoke<boolean>(
"check_dns_config_exists",
{},
);
if (dnsConfigExists) {
// 如果存在配置文件,加载其内容
const dnsConfig = await invoke<string>("get_dns_config_content", {});
const config = yaml.load(dnsConfig) as any;
// 更新表单数据
updateValuesFromConfig(config);
// 更新YAML编辑器内容
setYamlContent(dnsConfig);
} else {
// 如果不存在配置文件,使用默认值
resetToDefaults();
}
} catch (err) {
console.error("Failed to initialize DNS config", err);
resetToDefaults();
}
};
const [yamlContent, setYamlContent] = useReducer(
(_: string, next: string) => next,
"",
);
// 从配置对象更新表单值
const updateValuesFromConfig = (config: any) => {
if (!config) return;
const updateValuesFromConfig = useCallback(
(config: any) => {
if (!config) return;
// 提取dns配置
const dnsConfig = config.dns || {};
// 提取hosts配置与dns同级
const hostsConfig = config.hosts || {};
const dnsConfig = config.dns || {};
const hostsConfig = config.hosts || {};
const enhancedMode =
dnsConfig["enhanced-mode"] || DEFAULT_DNS_CONFIG["enhanced-mode"];
const validEnhancedMode =
enhancedMode === "fake-ip" || enhancedMode === "redir-host"
? enhancedMode
: DEFAULT_DNS_CONFIG["enhanced-mode"];
const enhancedMode =
dnsConfig["enhanced-mode"] || DEFAULT_DNS_CONFIG["enhanced-mode"];
const validEnhancedMode =
enhancedMode === "fake-ip" || enhancedMode === "redir-host"
? enhancedMode
: DEFAULT_DNS_CONFIG["enhanced-mode"];
const fakeIpFilterMode =
dnsConfig["fake-ip-filter-mode"] ||
DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
const validFakeIpFilterMode =
fakeIpFilterMode === "blacklist" || fakeIpFilterMode === "whitelist"
? fakeIpFilterMode
: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
const fakeIpFilterMode =
dnsConfig["fake-ip-filter-mode"] ||
DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
const validFakeIpFilterMode =
fakeIpFilterMode === "blacklist" || fakeIpFilterMode === "whitelist"
? fakeIpFilterMode
: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
setValues({
enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable,
listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen,
enhancedMode: validEnhancedMode,
fakeIpRange:
dnsConfig["fake-ip-range"] ?? DEFAULT_DNS_CONFIG["fake-ip-range"],
fakeIpFilterMode: validFakeIpFilterMode,
preferH3: dnsConfig["prefer-h3"] ?? DEFAULT_DNS_CONFIG["prefer-h3"],
respectRules:
dnsConfig["respect-rules"] ?? DEFAULT_DNS_CONFIG["respect-rules"],
useHosts: dnsConfig["use-hosts"] ?? DEFAULT_DNS_CONFIG["use-hosts"],
useSystemHosts:
dnsConfig["use-system-hosts"] ?? DEFAULT_DNS_CONFIG["use-system-hosts"],
ipv6: dnsConfig.ipv6 ?? DEFAULT_DNS_CONFIG.ipv6,
fakeIpFilter:
dnsConfig["fake-ip-filter"]?.join(", ") ??
DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
nameserver:
dnsConfig.nameserver?.join(", ") ??
DEFAULT_DNS_CONFIG.nameserver.join(", "),
fallback:
dnsConfig.fallback?.join(", ") ??
DEFAULT_DNS_CONFIG.fallback.join(", "),
defaultNameserver:
dnsConfig["default-nameserver"]?.join(", ") ??
DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
proxyServerNameserver:
dnsConfig["proxy-server-nameserver"]?.join(", ") ??
(DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || ""),
directNameserver:
dnsConfig["direct-nameserver"]?.join(", ") ??
(DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || ""),
directNameserverFollowPolicy:
dnsConfig["direct-nameserver-follow-policy"] ??
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"],
fallbackGeoip:
dnsConfig["fallback-filter"]?.geoip ??
DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
fallbackGeoipCode:
dnsConfig["fallback-filter"]?.["geoip-code"] ??
DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
fallbackIpcidr:
dnsConfig["fallback-filter"]?.ipcidr?.join(", ") ??
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr.join(", "),
fallbackDomain:
dnsConfig["fallback-filter"]?.domain?.join(", ") ??
DEFAULT_DNS_CONFIG["fallback-filter"].domain.join(", "),
nameserverPolicy:
formatNameserverPolicy(dnsConfig["nameserver-policy"]) || "",
hosts: formatHosts(hostsConfig) || "",
});
};
// 重置为默认值
const resetToDefaults = () => {
setValues({
enable: DEFAULT_DNS_CONFIG.enable,
listen: DEFAULT_DNS_CONFIG.listen,
enhancedMode: DEFAULT_DNS_CONFIG["enhanced-mode"],
fakeIpRange: DEFAULT_DNS_CONFIG["fake-ip-range"],
fakeIpFilterMode: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"],
preferH3: DEFAULT_DNS_CONFIG["prefer-h3"],
respectRules: DEFAULT_DNS_CONFIG["respect-rules"],
useHosts: DEFAULT_DNS_CONFIG["use-hosts"],
useSystemHosts: DEFAULT_DNS_CONFIG["use-system-hosts"],
ipv6: DEFAULT_DNS_CONFIG.ipv6,
fakeIpFilter: DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
defaultNameserver: DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
nameserver: DEFAULT_DNS_CONFIG.nameserver.join(", "),
fallback: DEFAULT_DNS_CONFIG.fallback.join(", "),
proxyServerNameserver:
DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || "",
directNameserver:
DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || "",
directNameserverFollowPolicy:
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"] || false,
fallbackGeoip: DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
fallbackGeoipCode: DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
fallbackIpcidr:
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr?.join(", ") || "",
fallbackDomain:
DEFAULT_DNS_CONFIG["fallback-filter"].domain?.join(", ") || "",
nameserverPolicy: "",
hosts: "",
});
// 更新YAML编辑器内容
updateYamlFromValues();
};
// 从表单值更新YAML内容
const updateYamlFromValues = () => {
const config: Record<string, any> = {};
const dnsConfig = generateDnsConfig();
if (Object.keys(dnsConfig).length > 0) {
config.dns = dnsConfig;
}
const hosts = parseHosts(values.hosts);
if (Object.keys(hosts).length > 0) {
config.hosts = hosts;
}
setYamlContent(yaml.dump(config, { forceQuotes: true }));
};
// 从YAML更新表单值
const updateValuesFromYaml = () => {
try {
const parsedYaml = yaml.load(yamlContent) as any;
if (!parsedYaml) return;
updateValuesFromConfig(parsedYaml);
} catch {
showNotice("error", t("Invalid YAML format"));
}
};
// 解析nameserver-policy为对象
const parseNameserverPolicy = (str: string): Record<string, any> => {
const result: Record<string, any> = {};
if (!str) return result;
// 处理geosite:xxx,yyy格式
const ruleRegex = /\s*([^=]+?)\s*=\s*([^,]+)(?:,|$)/g;
let match;
while ((match = ruleRegex.exec(str)) !== null) {
const [, domainsPart, serversPart] = match;
// 处理域名部分
let domains;
if (domainsPart.startsWith("geosite:")) {
domains = [domainsPart.trim()];
} else {
domains = [domainsPart.trim()];
}
// 处理服务器部分
const servers = serversPart.split(";").map((s) => s.trim());
// 为每个域名组分配相同的服务器列表
domains.forEach((domain) => {
result[domain] = servers;
setValues({
enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable,
listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen,
enhancedMode: validEnhancedMode,
fakeIpRange:
dnsConfig["fake-ip-range"] ?? DEFAULT_DNS_CONFIG["fake-ip-range"],
fakeIpFilterMode: validFakeIpFilterMode,
preferH3: dnsConfig["prefer-h3"] ?? DEFAULT_DNS_CONFIG["prefer-h3"],
respectRules:
dnsConfig["respect-rules"] ?? DEFAULT_DNS_CONFIG["respect-rules"],
useHosts: dnsConfig["use-hosts"] ?? DEFAULT_DNS_CONFIG["use-hosts"],
useSystemHosts:
dnsConfig["use-system-hosts"] ??
DEFAULT_DNS_CONFIG["use-system-hosts"],
ipv6: dnsConfig.ipv6 ?? DEFAULT_DNS_CONFIG.ipv6,
fakeIpFilter:
dnsConfig["fake-ip-filter"]?.join(", ") ??
DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
nameserver:
dnsConfig.nameserver?.join(", ") ??
DEFAULT_DNS_CONFIG.nameserver.join(", "),
fallback:
dnsConfig.fallback?.join(", ") ??
DEFAULT_DNS_CONFIG.fallback.join(", "),
defaultNameserver:
dnsConfig["default-nameserver"]?.join(", ") ??
DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
proxyServerNameserver:
dnsConfig["proxy-server-nameserver"]?.join(", ") ??
(DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || ""),
directNameserver:
dnsConfig["direct-nameserver"]?.join(", ") ??
(DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || ""),
directNameserverFollowPolicy:
dnsConfig["direct-nameserver-follow-policy"] ??
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"],
fallbackGeoip:
dnsConfig["fallback-filter"]?.geoip ??
DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
fallbackGeoipCode:
dnsConfig["fallback-filter"]?.["geoip-code"] ??
DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
fallbackIpcidr:
dnsConfig["fallback-filter"]?.ipcidr?.join(", ") ??
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr.join(", "),
fallbackDomain:
dnsConfig["fallback-filter"]?.domain?.join(", ") ??
DEFAULT_DNS_CONFIG["fallback-filter"].domain.join(", "),
nameserverPolicy:
formatNameserverPolicy(dnsConfig["nameserver-policy"]) || "",
hosts: formatHosts(hostsConfig) || "",
});
}
},
[setValues],
);
return result;
};
// 格式化nameserver-policy为字符串
const formatNameserverPolicy = (policy: any): string => {
if (!policy || typeof policy !== "object") return "";
// 直接将对象转换为字符串格式
return Object.entries(policy)
.map(([domain, servers]) => {
const serversStr = Array.isArray(servers) ? servers.join(";") : servers;
return `${domain}=${serversStr}`;
})
.join(", ");
};
// 格式化hosts为字符串
const formatHosts = (hosts: any): string => {
if (!hosts || typeof hosts !== "object") return "";
const result: string[] = [];
Object.entries(hosts).forEach(([domain, value]) => {
if (Array.isArray(value)) {
// 处理数组格式的IP
const ipsStr = value.join(";");
result.push(`${domain}=${ipsStr}`);
} else {
// 处理单个IP或域名
result.push(`${domain}=${value}`);
}
});
return result.join(", ");
};
// 解析hosts字符串为对象
const parseHosts = (str: string): Record<string, any> => {
const result: Record<string, any> = {};
if (!str) return result;
str.split(",").forEach((item) => {
const parts = item.trim().split("=");
if (parts.length < 2) return;
const domain = parts[0].trim();
const valueStr = parts.slice(1).join("=").trim();
// 检查是否包含多个分号分隔的IP
if (valueStr.includes(";")) {
result[domain] = valueStr
.split(";")
.map((s) => s.trim())
.filter(Boolean);
} else {
result[domain] = valueStr;
}
});
return result;
};
// 初始化时设置默认YAML
useEffect(() => {
updateYamlFromValues();
}, []);
// 切换编辑模式时的处理
useEffect(() => {
if (visualization) {
updateValuesFromYaml();
} else {
updateYamlFromValues();
}
}, [visualization]);
// 解析列表字符串为数组
const parseList = (str: string): string[] => {
if (!str?.trim()) return [];
return str
.split(",")
.map((item) => item.trim())
.filter(Boolean);
};
// 生成DNS配置对象
const generateDnsConfig = () => {
const generateDnsConfig = useCallback(() => {
const dnsConfig: any = {
enable: values.enable,
listen: values.listen,
@@ -481,8 +361,132 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
return dnsConfig;
};
}, [values]);
const updateYamlFromValues = useCallback(() => {
const config: Record<string, any> = {};
const dnsConfig = generateDnsConfig();
if (Object.keys(dnsConfig).length > 0) {
config.dns = dnsConfig;
}
const hosts = parseHosts(values.hosts);
if (Object.keys(hosts).length > 0) {
config.hosts = hosts;
}
setYamlContent(yaml.dump(config, { forceQuotes: true }));
}, [generateDnsConfig, setYamlContent, values.hosts]);
// 重置为默认值
const resetToDefaults = useCallback(() => {
setValues({
enable: DEFAULT_DNS_CONFIG.enable,
listen: DEFAULT_DNS_CONFIG.listen,
enhancedMode: DEFAULT_DNS_CONFIG["enhanced-mode"],
fakeIpRange: DEFAULT_DNS_CONFIG["fake-ip-range"],
fakeIpFilterMode: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"],
preferH3: DEFAULT_DNS_CONFIG["prefer-h3"],
respectRules: DEFAULT_DNS_CONFIG["respect-rules"],
useHosts: DEFAULT_DNS_CONFIG["use-hosts"],
useSystemHosts: DEFAULT_DNS_CONFIG["use-system-hosts"],
ipv6: DEFAULT_DNS_CONFIG.ipv6,
fakeIpFilter: DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
defaultNameserver: DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
nameserver: DEFAULT_DNS_CONFIG.nameserver.join(", "),
fallback: DEFAULT_DNS_CONFIG.fallback.join(", "),
proxyServerNameserver:
DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || "",
directNameserver:
DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || "",
directNameserverFollowPolicy:
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"] || false,
fallbackGeoip: DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
fallbackGeoipCode: DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
fallbackIpcidr:
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr?.join(", ") || "",
fallbackDomain:
DEFAULT_DNS_CONFIG["fallback-filter"].domain?.join(", ") || "",
nameserverPolicy: "",
hosts: "",
});
updateYamlFromValues();
}, [setValues, updateYamlFromValues]);
// 从YAML更新表单值
const updateValuesFromYaml = useCallback(() => {
try {
const parsedYaml = yaml.load(yamlContent) as any;
if (!parsedYaml) return;
skipYamlSyncRef.current = true;
updateValuesFromConfig(parsedYaml);
} catch {
showNotice("error", t("Invalid YAML format"));
}
}, [yamlContent, t, updateValuesFromConfig]);
useEffect(() => {
if (skipYamlSyncRef.current) {
skipYamlSyncRef.current = false;
return;
}
updateYamlFromValues();
}, [updateYamlFromValues]);
const latestUpdateValuesFromYamlRef = useRef(updateValuesFromYaml);
const latestUpdateYamlFromValuesRef = useRef(updateYamlFromValues);
useEffect(() => {
latestUpdateValuesFromYamlRef.current = updateValuesFromYaml;
latestUpdateYamlFromValuesRef.current = updateYamlFromValues;
}, [updateValuesFromYaml, updateYamlFromValues]);
useEffect(() => {
if (visualization) {
latestUpdateValuesFromYamlRef.current();
} else {
latestUpdateYamlFromValuesRef.current();
}
}, [visualization]);
const initDnsConfig = useCallback(async () => {
try {
const dnsConfigExists = await invoke<boolean>(
"check_dns_config_exists",
{},
);
if (dnsConfigExists) {
const dnsConfig = await invoke<string>("get_dns_config_content", {});
const config = yaml.load(dnsConfig) as any;
updateValuesFromConfig(config);
setYamlContent(dnsConfig);
} else {
resetToDefaults();
}
} catch (err) {
console.error("Failed to initialize DNS config", err);
resetToDefaults();
}
}, [resetToDefaults, setYamlContent, updateValuesFromConfig]);
useImperativeHandle(
ref,
() => ({
open: () => {
setOpen(true);
void initDnsConfig();
},
close: () => setOpen(false),
}),
[initDnsConfig],
);
// 生成DNS配置对象
// 处理保存操作
const onSave = useLockFn(async () => {
try {

View File

@@ -1,7 +1,7 @@
import { Delete as DeleteIcon } from "@mui/icons-material";
import { Box, Button, Divider, List, ListItem, TextField } from "@mui/material";
import { useLockFn, useRequest } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { forwardRef, useImperativeHandle, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, Switch } from "@/components/base";
@@ -165,6 +165,19 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
await saveConfig();
});
const originEntries = useMemo(() => {
const counts: Record<string, number> = {};
return corsConfig.allowOrigins.map((origin, index) => {
const occurrence = (counts[origin] = (counts[origin] ?? 0) + 1);
const keyBase = origin || "origin";
return {
origin,
index,
key: `${keyBase}-${occurrence}`,
};
});
}, [corsConfig.allowOrigins]);
return (
<BaseDialog
open={open}
@@ -207,9 +220,9 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
<div style={{ marginBottom: 8, fontWeight: "bold" }}>
{t("Allowed Origins")}
</div>
{corsConfig.allowOrigins.map((origin, index) => (
{originEntries.map(({ origin, index, key }) => (
<div
key={index}
key={key}
style={{
display: "flex",
alignItems: "center",

View File

@@ -1,4 +1,4 @@
import { cloneElement, isValidElement, ReactNode, useRef } from "react";
import { createElement, isValidElement, ReactNode, useRef } from "react";
import noop from "@/utils/noop";
@@ -24,7 +24,7 @@ export function GuardState<T>(props: Props<T>) {
onGuard = noop,
onCatch = noop,
onChange = noop,
onFormat = (v: T) => v,
onFormat,
} = props;
const lockRef = useRef(false);
@@ -45,7 +45,7 @@ export function GuardState<T>(props: Props<T>) {
lockRef.current = true;
try {
const newValue = (onFormat as any)(...args);
const newValue = onFormat ? (onFormat as any)(...args) : (args[0] as T);
// 先在ui上响应操作
onChange(newValue);
@@ -81,5 +81,7 @@ export function GuardState<T>(props: Props<T>) {
}
lockRef.current = false;
};
return cloneElement(children, childProps);
const { children: nestedChildren, ...restProps } = childProps;
return createElement(children.type, restProps, nestedChildren);
}

View File

@@ -33,7 +33,7 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
const { verge, patchVerge } = useVerge();
const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({});
const [enableGlobalHotkey, setEnableHotkey] = useState(
const [enableGlobalHotkey, setEnableGlobalHotkey] = useState(
verge?.enable_global_hotkey ?? true,
);
@@ -102,7 +102,7 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
<Switch
edge="end"
checked={enableGlobalHotkey}
onChange={(e) => setEnableHotkey(e.target.checked)}
onChange={(e) => setEnableGlobalHotkey(e.target.checked)}
/>
</ItemWrapper>

View File

@@ -118,7 +118,7 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={async (e) => {
onChange={async () => {
await toggleDecorations();
}}
>
@@ -198,8 +198,8 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
value={verge?.menu_icon ?? "monochrome"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ menu_icon: e })}
onGuard={(e) => patchVerge({ menu_icon: e })}
onChange={(value) => onChangeData({ menu_icon: value })}
onGuard={(value) => patchVerge({ menu_icon: value })}
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
<MenuItem value="monochrome">{t("Monochrome")}</MenuItem>

View File

@@ -102,7 +102,13 @@ export function NetworkInterfaceViewer({ ref }: { ref?: Ref<DialogRef> }) {
);
}
const AddressDisplay = (props: { label: string; content: string }) => {
const AddressDisplay = ({
label,
content,
}: {
label: string;
content: string;
}) => {
const { t } = useTranslation();
return (
@@ -113,7 +119,7 @@ const AddressDisplay = (props: { label: string; content: string }) => {
margin: "8px 0",
}}
>
<Box>{props.label}</Box>
<Box>{label}</Box>
<Box
sx={({ palette }) => ({
borderRadius: "8px",
@@ -124,13 +130,11 @@ const AddressDisplay = (props: { label: string; content: string }) => {
: alpha(palette.grey[400], 0.3),
})}
>
<Box sx={{ display: "inline", userSelect: "text" }}>
{props.content}
</Box>
<Box sx={{ display: "inline", userSelect: "text" }}>{content}</Box>
<IconButton
size="small"
onClick={async () => {
await writeText(props.content);
await writeText(content);
showNotice("success", t("Copy Success"));
}}
>

View File

@@ -6,7 +6,7 @@ import {
DialogTitle,
TextField,
} from "@mui/material";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface Props {
@@ -19,10 +19,6 @@ export const PasswordInput = (props: Props) => {
const { t } = useTranslation();
const [passwd, setPasswd] = useState("");
useEffect(() => {
if (!open) return;
}, [open]);
return (
<Dialog open={true} maxWidth="xs" fullWidth>
<DialogTitle>{t("Please enter your root password")}</DialogTitle>

View File

@@ -20,8 +20,13 @@ interface ItemProps {
onClick?: () => void | Promise<any>;
}
export const SettingItem: React.FC<ItemProps> = (props) => {
const { label, extra, children, secondary, onClick } = props;
export const SettingItem: React.FC<ItemProps> = ({
label,
extra,
children,
secondary,
onClick,
}) => {
const clickable = !!onClick;
const primary = (
@@ -65,7 +70,7 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
export const SettingList: React.FC<{
title: string;
children: ReactNode;
}> = (props) => (
}> = ({ title, children }) => (
<List>
<ListSubheader
sx={[
@@ -78,9 +83,9 @@ export const SettingList: React.FC<{
]}
disableSticky
>
{props.title}
{title}
</ListSubheader>
{props.children}
{children}
</List>
);

View File

@@ -16,6 +16,7 @@ import {
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
@@ -38,6 +39,11 @@ import {
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
const DEFAULT_PAC = `function FindProxyForURL(url, host) {
return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;";
}`;
@@ -130,40 +136,37 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
errorRetryInterval: 5000,
});
const [prevMixedPort, setPrevMixedPort] = useState(clashConfig?.mixedPort);
const prevMixedPortRef = useRef(clashConfig?.mixedPort);
useEffect(() => {
if (clashConfig?.mixedPort && clashConfig.mixedPort !== prevMixedPort) {
setPrevMixedPort(clashConfig.mixedPort);
resetSystemProxy();
const mixedPort = clashConfig?.mixedPort;
if (!mixedPort || mixedPort === prevMixedPortRef.current) {
return;
}
}, [clashConfig?.mixedPort]);
const resetSystemProxy = async () => {
try {
const currentSysProxy = await getSystemProxy();
const currentAutoProxy = await getAutotemProxy();
prevMixedPortRef.current = mixedPort;
if (value.pac ? currentAutoProxy?.enable : currentSysProxy?.enable) {
// 临时关闭系统代理
await patchVergeConfig({ enable_system_proxy: false });
const updateProxy = async () => {
try {
const currentSysProxy = await getSystemProxy();
const currentAutoProxy = await getAutotemProxy();
// 减少等待时间
await new Promise((resolve) => setTimeout(resolve, 200));
// 重新开启系统代理
await patchVergeConfig({ enable_system_proxy: true });
// 更新UI状态
await Promise.all([
mutate("getSystemProxy"),
mutate("getAutotemProxy"),
]);
if (value.pac ? currentAutoProxy?.enable : currentSysProxy?.enable) {
await patchVergeConfig({ enable_system_proxy: false });
await sleep(200);
await patchVergeConfig({ enable_system_proxy: true });
await Promise.all([
mutate("getSystemProxy"),
mutate("getAutotemProxy"),
]);
}
} catch (err: any) {
showNotice("error", err.toString());
}
} catch (err: any) {
showNotice("error", err.toString());
}
};
};
updateProxy();
}, [clashConfig?.mixedPort, value.pac]);
const { systemProxyAddress } = useAppData();

View File

@@ -12,7 +12,7 @@ import {
TextField,
Typography,
} from "@mui/material";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
interface Props {
@@ -38,6 +38,14 @@ export const WebUIItem = (props: Props) => {
const [editValue, setEditValue] = useState(value);
const { t } = useTranslation();
const highlightedParts = useMemo(() => {
const placeholderRegex = /(%host|%port|%secret)/g;
if (!value) {
return ["NULL"];
}
return value.split(placeholderRegex).filter((part) => part !== "");
}, [value]);
if (editing || onlyEdit) {
return (
<>
@@ -78,10 +86,26 @@ export const WebUIItem = (props: Props) => {
);
}
const html = value
?.replace("%host", "<span>%host</span>")
.replace("%port", "<span>%port</span>")
.replace("%secret", "<span>%secret</span>");
const placeholderCounts: Record<string, number> = {};
let textCounter = 0;
const renderedParts = highlightedParts.map((part) => {
const isPlaceholder =
part === "%host" || part === "%port" || part === "%secret";
if (isPlaceholder) {
const count = placeholderCounts[part] ?? 0;
placeholderCounts[part] = count + 1;
return (
<span key={`placeholder-${part}-${count}`} className="placeholder">
{part}
</span>
);
}
const key = `text-${textCounter}-${part || "empty"}`;
textCounter += 1;
return <span key={key}>{part}</span>;
});
return (
<>
@@ -94,12 +118,13 @@ export const WebUIItem = (props: Props) => {
sx={({ palette }) => ({
overflow: "hidden",
textOverflow: "ellipsis",
"> span": {
"> .placeholder": {
color: palette.primary.main,
},
})}
dangerouslySetInnerHTML={{ __html: html || "NULL" }}
/>
>
{renderedParts}
</Typography>
<IconButton
size="small"
title={t("Open URL")}

View File

@@ -1,7 +1,7 @@
import { Box, Button, Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import type { Ref } from "react";
import { useImperativeHandle, useState } from "react";
import { useImperativeHandle, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, BaseEmpty, DialogRef } from "@/components/base";
@@ -12,6 +12,12 @@ import { showNotice } from "@/services/noticeService";
import { WebUIItem } from "./web-ui-item";
const DEFAULT_WEB_UI_LIST = [
"https://metacubex.github.io/metacubexd/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
"https://yacd.metacubex.one/?hostname=%host&port=%port&secret=%secret",
"https://board.zash.run.place/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
];
export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation();
@@ -26,11 +32,21 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
close: () => setOpen(false),
}));
const webUIList = verge?.web_ui_list || [
"https://metacubex.github.io/metacubexd/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
"https://yacd.metacubex.one/?hostname=%host&port=%port&secret=%secret",
"https://board.zash.run.place/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
];
const webUIList = verge?.web_ui_list || DEFAULT_WEB_UI_LIST;
const webUIEntries = useMemo(() => {
const counts: Record<string, number> = {};
return webUIList.map((item, index) => {
const keyBase = item && item.trim().length > 0 ? item : "entry";
const count = counts[keyBase] ?? 0;
counts[keyBase] = count + 1;
return {
item,
index,
key: `${keyBase}-${count}`,
};
});
}, [webUIList]);
const handleAdd = useLockFn(async (value: string) => {
const newList = [...webUIList, value];
@@ -118,9 +134,9 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
/>
)}
{webUIList.map((item, index) => (
{webUIEntries.map(({ item, index, key }) => (
<WebUIItem
key={index}
key={key}
value={item}
onChange={(v) => handleChange(index, v)}
onDelete={() => handleDelete(index)}

View File

@@ -23,8 +23,12 @@ interface Props {
onDelete: (uid: string) => void;
}
export const TestItem = (props: Props) => {
const { itemData, onEdit, onDelete: onDeleteItem } = props;
export const TestItem = ({
id,
itemData,
onEdit,
onDelete: removeTest,
}: Props) => {
const {
attributes,
listeners,
@@ -33,7 +37,7 @@ export const TestItem = (props: Props) => {
transition,
isDragging,
} = useSortable({
id: props.id,
id,
});
const { t } = useTranslation();
@@ -50,17 +54,19 @@ export const TestItem = (props: Props) => {
setDelay(result);
}, [url]);
useEffect(() => {
initIconCachePath();
}, [icon]);
async function initIconCachePath() {
const initIconCachePath = useCallback(async () => {
if (icon && icon.trim().startsWith("http")) {
const fileName = uid + "-" + getFileName(icon);
const iconPath = await downloadIconCache(icon, fileName);
setIconCachePath(convertFileSrc(iconPath));
} else {
setIconCachePath("");
}
}
}, [icon, uid]);
useEffect(() => {
void initIconCachePath();
}, [initIconCachePath]);
function getFileName(url: string) {
return url.substring(url.lastIndexOf("/") + 1);
@@ -74,7 +80,7 @@ export const TestItem = (props: Props) => {
const onDelete = useLockFn(async () => {
setAnchorEl(null);
try {
onDeleteItem(uid);
removeTest(uid);
} catch (err: any) {
showNotice("error", err.message || err.toString());
}
@@ -102,12 +108,12 @@ export const TestItem = (props: Props) => {
return () => {
if (unlistenFn) {
console.log(
`TestItem for ${props.id} unmounting or url changed, cleaning up test-all listener.`,
`TestItem for ${id} unmounting or url changed, cleaning up test-all listener.`,
);
unlistenFn();
}
};
}, [url, addListener, onDelay, props.id]);
}, [url, addListener, onDelay, id]);
return (
<Box

View File

@@ -26,12 +26,7 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
const [loading, setLoading] = useState(false);
const { verge, patchVerge } = useVerge();
const testList = verge?.test_list ?? [];
const {
control,
watch: _watch,
register: _register,
...formIns
} = useForm<IVergeTestItem>({
const { control, ...formIns } = useForm<IVergeTestItem>({
defaultValues: {
name: "",
icon: "",