refactor: frontend (#5068)
* refactor: setting components * refactor: frontend * fix: settings router
This commit is contained in:
@@ -37,7 +37,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
|
||||
);
|
||||
|
||||
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
|
||||
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准
|
||||
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
Reference in New Issue
Block a user