code formatting with prettier

This commit is contained in:
coolcoala
2025-07-14 05:23:32 +03:00
parent eb1e4fe0c3
commit 5cdc5075f8
58 changed files with 5163 additions and 1846 deletions

View File

@@ -12,7 +12,7 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
## Preview
| Dark | Light |
|-------------------------------------|--------------------------------------|
| ----------------------------------- | ------------------------------------ |
| ![Preview](./docs/preview_dark.png) | ![Preview](./docs/preview_light.png) |
## Install

View File

@@ -1,4 +1,5 @@
## v0.1
- rewritten interface from MUI to shadcn/ui
- rewritten main page:
- one big power button and lists with proxy selection

View File

@@ -6,7 +6,8 @@ body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif, "twemoji mozilla";
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif,
"twemoji mozilla";
-webkit-font-smoothing: antialiased;
user-select: none;

View File

@@ -8,7 +8,10 @@ function ErrorFallback({ error }: FallbackProps) {
const { t } = useTranslation();
return (
<div role="alert" className="m-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
<div
role="alert"
className="m-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive"
>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
<h3 className="font-semibold">{t("Something went wrong")}</h3>

View File

@@ -15,7 +15,7 @@ export const BaseFieldset: React.FC<Props> = (props) => {
<fieldset
className={cn(
"relative rounded-md border border-border p-4", // Базовые стили
className // Дополнительные классы от пользователя
className, // Дополнительные классы от пользователя
)}
>
{/* 2. Используем legend. Он абсолютно спозиционирован относительно fieldset. */}

View File

@@ -18,7 +18,7 @@ export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({
<div
className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-background/70 backdrop-blur-sm",
className
className,
)}
>
{/* 3. Используем наш BaseLoading и делаем его немного больше */}

View File

@@ -3,10 +3,10 @@ import { BaseErrorBoundary } from "./base-error-boundary";
import { cn } from "@root/lib/utils";
interface Props {
title?: ReactNode; // Заголовок страницы
header?: ReactNode; // Элементы в правой части шапки (кнопки и т.д.)
children?: ReactNode; // Основное содержимое страницы
className?: string; // Дополнительные классы для основной области контента
title?: ReactNode; // Заголовок страницы
header?: ReactNode; // Элементы в правой части шапки (кнопки и т.д.)
children?: ReactNode; // Основное содержимое страницы
className?: string; // Дополнительные классы для основной области контента
}
export const BasePage: React.FC<Props> = (props) => {
@@ -16,7 +16,6 @@ export const BasePage: React.FC<Props> = (props) => {
<BaseErrorBoundary>
{/* 1. Корневой контейнер: flex-колонка на всю высоту */}
<div className="h-full flex flex-col bg-background text-foreground">
{/* 2. Шапка: не растягивается, имеет фиксированную высоту и нижнюю границу */}
<header
data-tauri-drag-region="true"
@@ -25,16 +24,13 @@ export const BasePage: React.FC<Props> = (props) => {
<h2 className="text-xl font-bold" data-tauri-drag-region="true">
{title}
</h2>
<div data-tauri-drag-region="true">
{header}
</div>
<div data-tauri-drag-region="true">{header}</div>
</header>
{/* 3. Основная область: занимает все оставшееся место и прокручивается */}
<main className={cn("flex-1 overflow-y-auto min-h-0", className)}>
{children}
</main>
</div>
</BaseErrorBoundary>
);

View File

@@ -5,7 +5,12 @@ import { cn } from "@root/lib/utils";
// Новые импорты
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CaseSensitive, WholeWord, Regex } from "lucide-react"; // Иконки из lucide-react
export type SearchState = {
@@ -40,7 +45,7 @@ export const BaseSearchBox = (props: SearchProps) => {
return new RegExp(searchText, flags).test(content);
}
let pattern = searchText.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); // Экранируем спецсимволы
let pattern = searchText.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); // Экранируем спецсимволы
if (matchWholeWord) {
pattern = `\\b${pattern}\\b`;
}
@@ -55,16 +60,27 @@ export const BaseSearchBox = (props: SearchProps) => {
}, [matchCase, matchWholeWord, useRegularExpression]);
useEffect(() => {
props.onSearch(createMatcher(text), { text, matchCase, matchWholeWord, useRegularExpression });
props.onSearch(createMatcher(text), {
text,
matchCase,
matchWholeWord,
useRegularExpression,
});
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]); // Убрали text из зависимостей
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setText(value);
props.onSearch(createMatcher(value), { text: value, matchCase, matchWholeWord, useRegularExpression });
props.onSearch(createMatcher(value), {
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
});
};
const getToggleVariant = (isActive: boolean) => (isActive ? "secondary" : "ghost");
const getToggleVariant = (isActive: boolean) =>
isActive ? "secondary" : "ghost";
return (
<div className="w-full">
@@ -81,33 +97,56 @@ export const BaseSearchBox = (props: SearchProps) => {
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={getToggleVariant(matchCase)} size="icon" className="h-7 w-7" onClick={() => setMatchCase(!matchCase)}>
<Button
variant={getToggleVariant(matchCase)}
size="icon"
className="h-7 w-7"
onClick={() => setMatchCase(!matchCase)}
>
<CaseSensitive className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Match Case")}</p></TooltipContent>
<TooltipContent>
<p>{t("Match Case")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={getToggleVariant(matchWholeWord)} size="icon" className="h-7 w-7" onClick={() => setMatchWholeWord(!matchWholeWord)}>
<Button
variant={getToggleVariant(matchWholeWord)}
size="icon"
className="h-7 w-7"
onClick={() => setMatchWholeWord(!matchWholeWord)}
>
<WholeWord className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Match Whole Word")}</p></TooltipContent>
<TooltipContent>
<p>{t("Match Whole Word")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={getToggleVariant(useRegularExpression)} size="icon" className="h-7 w-7" onClick={() => setUseRegularExpression(!useRegularExpression)}>
<Button
variant={getToggleVariant(useRegularExpression)}
size="icon"
className="h-7 w-7"
onClick={() => setUseRegularExpression(!useRegularExpression)}
>
<Regex className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Use Regular Expression")}</p></TooltipContent>
<TooltipContent>
<p>{t("Use Regular Expression")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Отображение ошибки под полем ввода */}
{errorMessage && <p className="mt-1 text-xs text-destructive">{errorMessage}</p>}
{errorMessage && (
<p className="mt-1 text-xs text-destructive">{errorMessage}</p>
)}
</div>
);
};

View File

@@ -26,7 +26,7 @@ export const BaseStyledSelect: React.FC<BaseStyledSelectProps> = (props) => {
<SelectTrigger
className={cn(
"h-9 w-[180px]", // Задаем стандартные размеры, как у других селектов
className
className,
)}
>
<SelectValue placeholder={placeholder} />

View File

@@ -19,7 +19,7 @@ export const BaseStyledTextField = React.forwardRef<
ref={ref}
className={cn(
"h-9", // Задаем стандартную компактную высоту
className
className,
)}
placeholder={props.placeholder ?? t("Filter conditions")}
autoComplete="off"

View File

@@ -5,18 +5,11 @@ import { cn } from "@root/lib/utils";
// Тип пропсов остается без изменений
export type SwitchProps = React.ComponentPropsWithoutRef<typeof ShadcnSwitch>;
const Switch = React.forwardRef<
HTMLButtonElement,
SwitchProps
>(({ className, ...props }, ref) => {
return (
<ShadcnSwitch
className={cn(className)}
ref={ref}
{...props}
/>
);
});
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ className, ...props }, ref) => {
return <ShadcnSwitch className={cn(className)} ref={ref} {...props} />;
},
);
Switch.displayName = "Switch";

View File

@@ -52,7 +52,6 @@ interface Props {
scrollerRef: (element: HTMLElement | Window | null) => void;
}
export const ConnectionTable = (props: Props) => {
const { connections, onShowDetail, scrollerRef } = props;
@@ -60,11 +59,16 @@ export const ConnectionTable = (props: Props) => {
try {
const saved = localStorage.getItem("connection-table-widths");
return saved ? JSON.parse(saved) : {};
} catch { return {}; }
} catch {
return {};
}
});
useEffect(() => {
localStorage.setItem("connection-table-widths", JSON.stringify(columnSizing));
localStorage.setItem(
"connection-table-widths",
JSON.stringify(columnSizing),
);
}, [columnSizing]);
const connRows = useMemo((): ConnectionRow[] => {
@@ -86,7 +90,7 @@ export const ConnectionTable = (props: Props) => {
ulSpeed: each.curUpload ?? 0,
chains,
rule,
process: truncateStr(metadata.process || metadata.processPath) ?? '',
process: truncateStr(metadata.process || metadata.processPath) ?? "",
time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
remoteDestination: Destination,
@@ -96,20 +100,104 @@ export const ConnectionTable = (props: Props) => {
});
}, [connections]);
const columns = useMemo<ColumnDef<ConnectionRow>[]>(() => [
{ accessorKey: "host", header: () => t("Host"), size: columnSizing?.host || 220, minSize: 180 },
{ accessorKey: "download", header: () => t("Downloaded"), size: columnSizing?.download || 88, cell: ({ getValue }) => <div className="text-right">{parseTraffic(getValue<number>()).join(" ")}</div> },
{ accessorKey: "upload", header: () => t("Uploaded"), size: columnSizing?.upload || 88, cell: ({ getValue }) => <div className="text-right">{parseTraffic(getValue<number>()).join(" ")}</div> },
{ accessorKey: "dlSpeed", header: () => t("DL Speed"), size: columnSizing?.dlSpeed || 88, cell: ({ getValue }) => <div className="text-right">{parseTraffic(getValue<number>()).join(" ")}/s</div> },
{ accessorKey: "ulSpeed", header: () => t("UL Speed"), size: columnSizing?.ulSpeed || 88, cell: ({ getValue }) => <div className="text-right">{parseTraffic(getValue<number>()).join(" ")}/s</div> },
{ accessorKey: "chains", header: () => t("Chains"), size: columnSizing?.chains || 340, minSize: 180 },
{ accessorKey: "rule", header: () => t("Rule"), size: columnSizing?.rule || 280, minSize: 180 },
{ accessorKey: "process", header: () => t("Process"), size: columnSizing?.process || 220, minSize: 180 },
{ accessorKey: "time", header: () => t("Time"), size: columnSizing?.time || 120, minSize: 100, cell: ({ getValue }) => <div className="text-right">{dayjs(getValue<string>()).fromNow()}</div> },
{ accessorKey: "source", header: () => t("Source"), size: columnSizing?.source || 200, minSize: 130 },
{ accessorKey: "remoteDestination", header: () => t("Destination"), size: columnSizing?.remoteDestination || 200, minSize: 130 },
{ accessorKey: "type", header: () => t("Type"), size: columnSizing?.type || 160, minSize: 100 },
], [columnSizing]);
const columns = useMemo<ColumnDef<ConnectionRow>[]>(
() => [
{
accessorKey: "host",
header: () => t("Host"),
size: columnSizing?.host || 220,
minSize: 180,
},
{
accessorKey: "download",
header: () => t("Downloaded"),
size: columnSizing?.download || 88,
cell: ({ getValue }) => (
<div className="text-right">
{parseTraffic(getValue<number>()).join(" ")}
</div>
),
},
{
accessorKey: "upload",
header: () => t("Uploaded"),
size: columnSizing?.upload || 88,
cell: ({ getValue }) => (
<div className="text-right">
{parseTraffic(getValue<number>()).join(" ")}
</div>
),
},
{
accessorKey: "dlSpeed",
header: () => t("DL Speed"),
size: columnSizing?.dlSpeed || 88,
cell: ({ getValue }) => (
<div className="text-right">
{parseTraffic(getValue<number>()).join(" ")}/s
</div>
),
},
{
accessorKey: "ulSpeed",
header: () => t("UL Speed"),
size: columnSizing?.ulSpeed || 88,
cell: ({ getValue }) => (
<div className="text-right">
{parseTraffic(getValue<number>()).join(" ")}/s
</div>
),
},
{
accessorKey: "chains",
header: () => t("Chains"),
size: columnSizing?.chains || 340,
minSize: 180,
},
{
accessorKey: "rule",
header: () => t("Rule"),
size: columnSizing?.rule || 280,
minSize: 180,
},
{
accessorKey: "process",
header: () => t("Process"),
size: columnSizing?.process || 220,
minSize: 180,
},
{
accessorKey: "time",
header: () => t("Time"),
size: columnSizing?.time || 120,
minSize: 100,
cell: ({ getValue }) => (
<div className="text-right">
{dayjs(getValue<string>()).fromNow()}
</div>
),
},
{
accessorKey: "source",
header: () => t("Source"),
size: columnSizing?.source || 200,
minSize: 130,
},
{
accessorKey: "remoteDestination",
header: () => t("Destination"),
size: columnSizing?.remoteDestination || 200,
minSize: 130,
},
{
accessorKey: "type",
header: () => t("Type"),
size: columnSizing?.type || 160,
minSize: 100,
},
],
[columnSizing],
);
const table = useReactTable({
data: connRows,
@@ -117,37 +205,42 @@ export const ConnectionTable = (props: Props) => {
state: { columnSizing },
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: 'onChange',
columnResizeMode: "onChange",
});
const VirtuosoTableComponents = useMemo<TableComponents<Row<ConnectionRow>>>(() => ({
// Явно типизируем `ref` для каждого компонента
Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
const VirtuosoTableComponents = useMemo<TableComponents<Row<ConnectionRow>>>(
() => ({
// Явно типизируем `ref` для каждого компонента
Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
<div className="h-full" {...props} ref={ref} />
)),
Table: (props) => (
<Table {...props} className="w-full border-collapse" />
),
TableHead: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableHeader {...props} ref={ref} />
)),
// Явно типизируем пропсы и `ref` для TableRow
TableRow: React.forwardRef<HTMLTableRowElement, { item: Row<ConnectionRow> } & React.HTMLAttributes<HTMLTableRowElement>>(
({ item: row, ...props }, ref) => {
// `Virtuoso` передает нам готовую строку `row` в пропсе `item`.
// Больше не нужно искать ее по индексу!
return (
<TableRow
{...props}
ref={ref}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onShowDetail(row.original.connectionData)}
/>
);
)),
Table: (props) => <Table {...props} className="w-full border-collapse" />,
TableHead: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableHeader {...props} ref={ref} />
)),
// Явно типизируем пропсы и `ref` для TableRow
TableRow: React.forwardRef<
HTMLTableRowElement,
{ item: Row<ConnectionRow> } & React.HTMLAttributes<HTMLTableRowElement>
>(({ item: row, ...props }, ref) => {
// `Virtuoso` передает нам готовую строку `row` в пропсе `item`.
// Больше не нужно искать ее по индексу!
return (
<TableRow
{...props}
ref={ref}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onShowDetail(row.original.connectionData)}
/>
);
}),
TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableBody {...props} ref={ref} />
)),
}),
TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => <TableBody {...props} ref={ref} />)
}), []);
[],
);
return (
<div className="h-full rounded-md border overflow-hidden">
@@ -156,17 +249,29 @@ export const ConnectionTable = (props: Props) => {
scrollerRef={scrollerRef}
data={table.getRowModel().rows}
components={VirtuosoTableComponents}
fixedHeaderContent={() => (
fixedHeaderContent={() =>
table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent bg-background/95 backdrop-blur">
<TableRow
key={headerGroup.id}
className="hover:bg-transparent bg-background/95 backdrop-blur"
>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} style={{ width: header.getSize() }} className="p-2">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
<TableHead
key={header.id}
style={{ width: header.getSize() }}
className="p-2"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))
)}
}
itemContent={(index, row) => (
<>
{row.getVisibleCells().map((cell) => (

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@root/lib/utils";
// Компоненты и иконки
@@ -9,23 +9,28 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ChevronsUpDown, Timer, WholeWord } from 'lucide-react';
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ChevronsUpDown, Timer, WholeWord } from "lucide-react";
// Логика
import { useVerge } from '@/hooks/use-verge';
import { useAppData } from '@/providers/app-data-provider';
import delayManager from '@/services/delay';
import { updateProxy, deleteConnection } from '@/services/api';
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";
import delayManager from "@/services/delay";
import { updateProxy, deleteConnection } from "@/services/api";
// --- Типы и константы ---
const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group';
const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type';
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"];
type ProxySortType = 'default' | 'delay' | 'name';
type ProxySortType = "default" | "delay" | "name";
interface IProxyGroup {
name: string;
type: string;
@@ -35,15 +40,25 @@ interface IProxyGroup {
}
// --- Вспомогательная функция для цвета задержки ---
function getDelayBadgeVariant(delayValue: number): 'default' | 'secondary' | 'destructive' | 'outline' {
if (delayValue < 0) return 'secondary';
if (delayValue >= 150) return 'destructive';
return 'default';
function getDelayBadgeVariant(
delayValue: number,
): "default" | "secondary" | "destructive" | "outline" {
if (delayValue < 0) return "secondary";
if (delayValue >= 150) return "destructive";
return "default";
}
// --- Дочерний компонент для элемента списка с "живым" обновлением пинга ---
const ProxySelectItem = ({ proxyName, groupName }: { proxyName: string, groupName: string }) => {
const [delay, setDelay] = useState(() => delayManager.getDelay(proxyName, groupName));
const ProxySelectItem = ({
proxyName,
groupName,
}: {
proxyName: string;
groupName: string;
}) => {
const [delay, setDelay] = useState(() =>
delayManager.getDelay(proxyName, groupName),
);
const [isJustUpdated, setIsJustUpdated] = useState(false);
useEffect(() => {
@@ -71,44 +86,62 @@ const ProxySelectItem = ({ proxyName, groupName }: { proxyName: string, groupNam
variant={getDelayBadgeVariant(delay)}
className={cn(
"ml-4 flex-shrink-0 px-2 h-5 justify-center transition-colors duration-300",
isJustUpdated && "bg-primary/20 border-primary/50"
isJustUpdated && "bg-primary/20 border-primary/50",
)}
>
{(delay < 0) || (delay > 10000) ? '---' : delay}
{delay < 0 || delay > 10000 ? "---" : delay}
</Badge>
</div>
</SelectItem>
);
};
export const ProxySelectors: React.FC = () => {
const { t } = useTranslation();
const { verge } = useVerge();
const { proxies, connections, clashConfig, refreshProxy } = useAppData();
const mode = clashConfig?.mode?.toLowerCase() || 'rule';
const isGlobalMode = mode === 'global';
const isDirectMode = mode ==='direct';
const mode = clashConfig?.mode?.toLowerCase() || "rule";
const isGlobalMode = mode === "global";
const isDirectMode = mode === "direct";
const [selectedGroup, setSelectedGroup] = useState<string>('');
const [selectedProxy, setSelectedProxy] = useState<string>('');
const [sortType, setSortType] = useState<ProxySortType>(() => (localStorage.getItem(STORAGE_KEY_SORT_TYPE) as ProxySortType) || 'default');
const [selectedGroup, setSelectedGroup] = useState<string>("");
const [selectedProxy, setSelectedProxy] = useState<string>("");
const [sortType, setSortType] = useState<ProxySortType>(
() =>
(localStorage.getItem(STORAGE_KEY_SORT_TYPE) as ProxySortType) ||
"default",
);
useEffect(() => {
if (!proxies?.groups) return;
if (isGlobalMode) { setSelectedGroup('GLOBAL'); return; }
if (isDirectMode) { setSelectedGroup('DIRECT'); return; }
if (isGlobalMode) {
setSelectedGroup("GLOBAL");
return;
}
if (isDirectMode) {
setSelectedGroup("DIRECT");
return;
}
const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP);
const primaryGroup = proxies.groups.find((g: IProxyGroup) => g.type === 'Selector' && g.name.toLowerCase().includes('auto')) || proxies.groups.find((g: IProxyGroup) => g.type === 'Selector');
const primaryGroup =
proxies.groups.find(
(g: IProxyGroup) =>
g.type === "Selector" && g.name.toLowerCase().includes("auto"),
) || proxies.groups.find((g: IProxyGroup) => g.type === "Selector");
if (savedGroup && proxies.groups.some((g: IProxyGroup) => g.name === savedGroup)) {
if (
savedGroup &&
proxies.groups.some((g: IProxyGroup) => g.name === savedGroup)
) {
setSelectedGroup(savedGroup);
} else if (primaryGroup) {
setSelectedGroup(primaryGroup.name);
} else if (proxies.groups.length > 0) {
const firstSelector = proxies.groups.find((g: IProxyGroup) => g.type === 'Selector');
const firstSelector = proxies.groups.find(
(g: IProxyGroup) => g.type === "Selector",
);
if (firstSelector) {
setSelectedGroup(firstSelector.name);
}
@@ -117,38 +150,56 @@ export const ProxySelectors: React.FC = () => {
useEffect(() => {
if (!selectedGroup || !proxies) return;
if (isGlobalMode) { setSelectedProxy(proxies.global?.now || ''); return; }
if (isDirectMode) { setSelectedProxy('DIRECT'); return; }
const group = proxies.groups.find((g: IProxyGroup) => g.name === selectedGroup);
if (isGlobalMode) {
setSelectedProxy(proxies.global?.now || "");
return;
}
if (isDirectMode) {
setSelectedProxy("DIRECT");
return;
}
const group = proxies.groups.find(
(g: IProxyGroup) => g.name === selectedGroup,
);
if (group) {
const current = group.now;
const firstInList = typeof group.all?.[0] === 'string' ? group.all[0] : group.all?.[0]?.name;
setSelectedProxy(current || firstInList || '');
const firstInList =
typeof group.all?.[0] === "string"
? group.all[0]
: group.all?.[0]?.name;
setSelectedProxy(current || firstInList || "");
}
}, [selectedGroup, proxies, isGlobalMode, isDirectMode]);
const handleProxyListOpen = useCallback((isOpen: boolean) => {
if (!isOpen || isDirectMode) return;
const handleProxyListOpen = useCallback(
(isOpen: boolean) => {
if (!isOpen || isDirectMode) return;
const timeout = verge?.default_latency_timeout || 5000;
const timeout = verge?.default_latency_timeout || 5000;
if (isGlobalMode) {
const proxyList = proxies?.global?.all;
if (proxyList) {
const proxyNames = proxyList
.map((p: any) => (typeof p === 'string' ? p : p.name))
.filter((name: string) => name && !presetList.includes(name));
if (isGlobalMode) {
const proxyList = proxies?.global?.all;
if (proxyList) {
const proxyNames = proxyList
.map((p: any) => (typeof p === "string" ? p : p.name))
.filter((name: string) => name && !presetList.includes(name));
delayManager.checkListDelay(proxyNames, 'GLOBAL', timeout);
delayManager.checkListDelay(proxyNames, "GLOBAL", timeout);
}
} else {
const group = proxies?.groups?.find(
(g: IProxyGroup) => g.name === selectedGroup,
);
if (group && group.all) {
const proxyNames = group.all
.map((p: any) => (typeof p === "string" ? p : p.name))
.filter(Boolean);
delayManager.checkListDelay(proxyNames, selectedGroup, timeout);
}
}
} else {
const group = proxies?.groups?.find((g: IProxyGroup) => g.name === selectedGroup);
if (group && group.all) {
const proxyNames = group.all.map((p: any) => typeof p === 'string' ? p : p.name).filter(Boolean);
delayManager.checkListDelay(proxyNames, selectedGroup, timeout);
}
}
}, [selectedGroup, proxies, isGlobalMode, isDirectMode, verge]);
},
[selectedGroup, proxies, isGlobalMode, isDirectMode, verge],
);
const handleGroupChange = (newGroup: string) => {
if (isGlobalMode || isDirectMode) return;
@@ -176,7 +227,11 @@ export const ProxySelectors: React.FC = () => {
};
const handleSortChange = () => {
const nextSort: Record<ProxySortType, ProxySortType> = { default: 'delay', delay: 'name', name: 'default' };
const nextSort: Record<ProxySortType, ProxySortType> = {
default: "delay",
delay: "name",
name: "default",
};
const newSortType = nextSort[sortType];
setSortType(newSortType);
localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType);
@@ -187,27 +242,31 @@ export const ProxySelectors: React.FC = () => {
const allowedTypes = ["Selector", "URLTest", "Fallback"];
return proxies.groups.filter((g: IProxyGroup) =>
allowedTypes.includes(g.type) &&
!g.hidden
return proxies.groups.filter(
(g: IProxyGroup) => allowedTypes.includes(g.type) && !g.hidden,
);
}, [proxies]);
const proxyOptions = useMemo(() => {
let options: { name: string }[] = [];
if (isDirectMode) return [{ name: "DIRECT" }];
const sourceList = isGlobalMode ? proxies?.global?.all : proxies?.groups?.find((g: IProxyGroup) => g.name === selectedGroup)?.all;
const sourceList = isGlobalMode
? proxies?.global?.all
: proxies?.groups?.find((g: IProxyGroup) => g.name === selectedGroup)
?.all;
if (sourceList) {
options = sourceList.map((proxy: any) => ({
name: typeof proxy === 'string' ? proxy : proxy.name,
})).filter((p: { name: string }) => p.name);
options = sourceList
.map((proxy: any) => ({
name: typeof proxy === "string" ? proxy : proxy.name,
}))
.filter((p: { name: string }) => p.name);
}
if (sortType === 'name') return options.sort((a, b) => a.name.localeCompare(b.name));
if (sortType === 'delay') {
if (sortType === "name")
return options.sort((a, b) => a.name.localeCompare(b.name));
if (sortType === "delay") {
return options.sort((a, b) => {
const delayA = delayManager.getDelay(a.name, selectedGroup);
const delayB = delayManager.getDelay(b.name, selectedGroup);
@@ -223,8 +282,14 @@ export const ProxySelectors: React.FC = () => {
<TooltipProvider>
<div className="flex justify-center flex-col gap-2 md:items-end">
<div className="flex flex-col items-start gap-2">
<label className="text-sm font-medium text-muted-foreground">{t("Group")}</label>
<Select value={selectedGroup} onValueChange={handleGroupChange} disabled={isGlobalMode || isDirectMode}>
<label className="text-sm font-medium text-muted-foreground">
{t("Group")}
</label>
<Select
value={selectedGroup}
onValueChange={handleGroupChange}
disabled={isGlobalMode || isDirectMode}
>
<SelectTrigger className="w-100">
<Tooltip>
<TooltipTrigger asChild>
@@ -239,7 +304,9 @@ export const ProxySelectors: React.FC = () => {
</SelectTrigger>
<SelectContent>
{selectorGroups.map((group: IProxyGroup) => (
<SelectItem key={group.name} value={group.name}>{group.name}</SelectItem>
<SelectItem key={group.name} value={group.name}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -247,26 +314,39 @@ export const ProxySelectors: React.FC = () => {
<div className="flex flex-col items-start gap-2">
<div className="flex justify-between items-center w-100">
<label className="text-sm font-medium text-muted-foreground">{t("Proxy")}</label>
<label className="text-sm font-medium text-muted-foreground">
{t("Proxy")}
</label>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">
<Button variant="ghost" size="sm" onClick={handleSortChange} disabled={isDirectMode}>
{sortType === 'default' && <ChevronsUpDown className="h-4 w-4" />}
{sortType === 'delay' && <Timer className="h-4 w-4" />}
{sortType === 'name' && <WholeWord className="h-4 w-4" />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{sortType === 'default' && <p>{t("Sort by default")}</p>}
{sortType === 'delay' && <p>{t("Sort by delay")}</p>}
{sortType === 'name' && <p>{t("Sort by name")}</p>}
</TooltipContent>
</Tooltip>
<TooltipTrigger asChild>
<span className="truncate">
<Button
variant="ghost"
size="sm"
onClick={handleSortChange}
disabled={isDirectMode}
>
{sortType === "default" && (
<ChevronsUpDown className="h-4 w-4" />
)}
{sortType === "delay" && <Timer className="h-4 w-4" />}
{sortType === "name" && <WholeWord className="h-4 w-4" />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{sortType === "default" && <p>{t("Sort by default")}</p>}
{sortType === "delay" && <p>{t("Sort by delay")}</p>}
{sortType === "name" && <p>{t("Sort by name")}</p>}
</TooltipContent>
</Tooltip>
</div>
<Select value={selectedProxy} onValueChange={handleProxyChange} disabled={isDirectMode} onOpenChange={handleProxyListOpen}>
<Select
value={selectedProxy}
onValueChange={handleProxyChange}
disabled={isDirectMode}
onOpenChange={handleProxyListOpen}
>
<SelectTrigger className="w-100">
<Tooltip>
<TooltipTrigger asChild>
@@ -280,11 +360,11 @@ export const ProxySelectors: React.FC = () => {
</Tooltip>
</SelectTrigger>
<SelectContent>
{proxyOptions.map(proxy => (
{proxyOptions.map((proxy) => (
<ProxySelectItem
key={proxy.name}
proxyName={proxy.name}
groupName={selectedGroup}
key={proxy.name}
proxyName={proxy.name}
groupName={selectedGroup}
/>
))}
</SelectContent>

View File

@@ -10,33 +10,45 @@ type ThemeProviderProps = {
};
function hexToHsl(hex?: string): string | undefined {
if (!hex) return undefined;
let r = 0, g = 0, b = 0;
hex = hex.replace("#", "");
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
r = parseInt(hex.substring(0, 2), 16) / 255;
g = parseInt(hex.substring(2, 4), 16) / 255;
b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
if (!hex) return undefined;
let r = 0,
g = 0,
b = 0;
hex = hex.replace("#", "");
if (hex.length === 3)
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
r = parseInt(hex.substring(0, 2), 16) / 255;
g = parseInt(hex.substring(2, 4), 16) / 255;
b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
let h = 0,
s = 0,
l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
return `${(h * 360).toFixed(1)} ${s.toFixed(3)} ${l.toFixed(3)}`;
h /= 6;
}
return `${(h * 360).toFixed(1)} ${s.toFixed(3)} ${l.toFixed(3)}`;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const { verge } = useVerge();
const themeModeSetting = verge?.theme_mode || "system";
const customThemeSettings = verge?.theme_setting || {};
@@ -44,32 +56,50 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
const root = window.document.documentElement; // <html> тег
const appWindow = getCurrentWebviewWindow();
const applyTheme = (mode: 'light' | 'dark') => {
const applyTheme = (mode: "light" | "dark") => {
root.classList.remove("light", "dark");
root.classList.add(mode);
appWindow.setTheme(mode as TauriTheme).catch(console.error);
const basePalette = mode === 'light' ? defaultTheme : defaultDarkTheme;
const basePalette = mode === "light" ? defaultTheme : defaultDarkTheme;
const variables = {
"--background": hexToHsl(basePalette.background_color),
"--foreground": hexToHsl(customThemeSettings.primary_text || basePalette.primary_text),
"--foreground": hexToHsl(
customThemeSettings.primary_text || basePalette.primary_text,
),
"--card": hexToHsl(basePalette.background_color), // Используем тот же фон
"--card-foreground": hexToHsl(customThemeSettings.primary_text || basePalette.primary_text),
"--card-foreground": hexToHsl(
customThemeSettings.primary_text || basePalette.primary_text,
),
"--popover": hexToHsl(basePalette.background_color),
"--popover-foreground": hexToHsl(customThemeSettings.primary_text || basePalette.primary_text),
"--primary": hexToHsl(customThemeSettings.primary_color || basePalette.primary_color),
"--popover-foreground": hexToHsl(
customThemeSettings.primary_text || basePalette.primary_text,
),
"--primary": hexToHsl(
customThemeSettings.primary_color || basePalette.primary_color,
),
"--primary-foreground": hexToHsl("#ffffff"), // Предполагаем белый текст на основном цвете
"--secondary": hexToHsl(customThemeSettings.secondary_color || basePalette.secondary_color),
"--secondary-foreground": hexToHsl(customThemeSettings.primary_text || basePalette.primary_text),
"--muted-foreground": hexToHsl(customThemeSettings.secondary_text || basePalette.secondary_text),
"--destructive": hexToHsl(customThemeSettings.error_color || basePalette.error_color),
"--ring": hexToHsl(customThemeSettings.primary_color || basePalette.primary_color),
"--secondary": hexToHsl(
customThemeSettings.secondary_color || basePalette.secondary_color,
),
"--secondary-foreground": hexToHsl(
customThemeSettings.primary_text || basePalette.primary_text,
),
"--muted-foreground": hexToHsl(
customThemeSettings.secondary_text || basePalette.secondary_text,
),
"--destructive": hexToHsl(
customThemeSettings.error_color || basePalette.error_color,
),
"--ring": hexToHsl(
customThemeSettings.primary_color || basePalette.primary_color,
),
};
for (const [key, value] of Object.entries(variables)) {
if(value) root.style.setProperty(key, value);
if (value) root.style.setProperty(key, value);
}
if (customThemeSettings.font_family) {
@@ -77,7 +107,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
} else {
root.style.removeProperty("--font-sans");
}
let styleElement = document.querySelector("style#verge-theme");
if (!styleElement) {
styleElement = document.createElement("style");
@@ -90,12 +120,17 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
};
if (themeModeSetting === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
applyTheme(systemTheme);
const unlistenPromise = appWindow.onThemeChanged(({ payload }) => {
if (verge?.theme_mode === 'system') applyTheme(payload);
if (verge?.theme_mode === "system") applyTheme(payload);
});
return () => { unlistenPromise.then(f => f()); };
return () => {
unlistenPromise.then((f) => f());
};
} else {
applyTheme(themeModeSetting);
}

View File

@@ -13,20 +13,24 @@ export const useCustomTheme = () => {
const setMode = useSetThemeMode();
useEffect(() => {
setMode(theme_mode === "light" || theme_mode === "dark" ? theme_mode : "system");
setMode(
theme_mode === "light" || theme_mode === "dark" ? theme_mode : "system",
);
}, [theme_mode, setMode]);
useEffect(() => {
const root = document.documentElement;
const activeTheme = mode === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
: mode;
const activeTheme =
mode === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: mode;
root.classList.remove("light", "dark");
root.classList.add(activeTheme);
appWindow.setTheme(activeTheme as Theme).catch(console.error);
}, [mode, appWindow]);
useEffect(() => {
@@ -34,7 +38,9 @@ export const useCustomTheme = () => {
const unlistenPromise = appWindow.onThemeChanged(({ payload }) => {
setMode(payload);
});
return () => { unlistenPromise.then(f => f()); };
return () => {
unlistenPromise.then((f) => f());
};
}, [theme_mode, appWindow, setMode]);
return {};

View File

@@ -20,33 +20,35 @@ export const FileInput: React.FC<Props> = (props) => {
const [fileName, setFileName] = useState("");
// Вся ваша логика для чтения файла остается без изменений
const onFileInput = useLockFn(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const onFileInput = useLockFn(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
setLoading(true);
setFileName(file.name);
setLoading(true);
try {
const value = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target?.result as string);
};
reader.onerror = (err) => reject(err);
reader.readAsText(file);
});
onChange(file, value);
} catch (error) {
console.error("File reading error:", error);
} finally {
setLoading(false);
// Очищаем value у input, чтобы можно было выбрать тот же файл еще раз
if (inputRef.current) {
inputRef.current.value = "";
try {
const value = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target?.result as string);
};
reader.onerror = (err) => reject(err);
reader.readAsText(file);
});
onChange(file, value);
} catch (error) {
console.error("File reading error:", error);
} finally {
setLoading(false);
// Очищаем value у input, чтобы можно было выбрать тот же файл еще раз
if (inputRef.current) {
inputRef.current.value = "";
}
}
}
});
},
);
return (
// Заменяем Box на div с flex и gap для отступов

View File

@@ -45,13 +45,16 @@ export const GroupItem = (props: Props) => {
async function initIconCachePath() {
if (group.icon && group.icon.trim().startsWith("http")) {
const fileName = group.name.replaceAll(" ", "") + "-" + getFileName(group.icon);
const fileName =
group.name.replaceAll(" ", "") + "-" + getFileName(group.icon);
const iconPath = await downloadIconCache(group.icon, fileName);
setIconCachePath(convertFileSrc(iconPath));
}
}
useEffect(() => { initIconCachePath(); }, [group.icon, group.name]);
useEffect(() => {
initIconCachePath();
}, [group.icon, group.name]);
const style = {
transform: CSS.Transform.toString(transform),
@@ -67,14 +70,17 @@ export const GroupItem = (props: Props) => {
className={cn(
"flex items-center p-2 mb-1 rounded-lg transition-shadow",
typeStyles[type],
isDragging && "shadow-lg"
isDragging && "shadow-lg",
)}
>
{/* Ручка для перетаскивания */}
<div
{...attributes}
{...listeners}
className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
className={cn(
"p-1 text-muted-foreground rounded-sm",
isSortable ? "cursor-move hover:bg-accent" : "cursor-default",
)}
>
<GripVertical className="h-5 w-5" />
</div>
@@ -82,7 +88,13 @@ export const GroupItem = (props: Props) => {
{/* Иконка группы */}
{group.icon && (
<img
src={group.icon.startsWith('data') ? group.icon : group.icon.startsWith('<svg') ? `data:image/svg+xml;base64,${btoa(group.icon ?? "")}` : (iconCachePath || group.icon)}
src={
group.icon.startsWith("data")
? group.icon
: group.icon.startsWith("<svg")
? `data:image/svg+xml;base64,${btoa(group.icon ?? "")}`
: iconCachePath || group.icon
}
className="w-8 h-8 mx-2 rounded-md"
alt={group.name}
/>
@@ -97,7 +109,12 @@ export const GroupItem = (props: Props) => {
</div>
{/* Кнопка действия */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onDelete}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onDelete}
>
{type === "delete" ? (
<Undo2 className="h-4 w-4" />
) : (

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,9 @@ export const LogViewer = (props: Props) => {
// Вспомогательная функция для определения варианта Badge
const getLogLevelVariant = (level: string): "destructive" | "secondary" => {
return level === "error" || level === "exception" ? "destructive" : "secondary";
return level === "error" || level === "exception"
? "destructive"
: "secondary";
};
return (
@@ -41,7 +43,10 @@ export const LogViewer = (props: Props) => {
<div className="h-[300px] overflow-y-auto space-y-2 p-1">
{logInfo.length > 0 ? (
logInfo.map(([level, log], index) => (
<div key={index} className="pb-2 border-b border-border last:border-b-0">
<div
key={index}
className="pb-2 border-b border-border last:border-b-0"
>
<div className="flex items-start gap-3">
<Badge variant={getLogLevelVariant(level)} className="mt-0.5">
{level}
@@ -60,7 +65,9 @@ export const LogViewer = (props: Props) => {
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
<Button type="button" variant="outline">
{t("Close")}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -28,14 +28,14 @@ export const ProfileBox = React.forwardRef<HTMLDivElement, ProfileBoxProps>(
"data-[selected=true]:text-card-foreground",
// --- Дополнительные классы от пользователя ---
className
className,
)}
{...props}
>
{children}
</div>
);
}
},
);
ProfileBox.displayName = "ProfileBox";

View File

@@ -66,9 +66,9 @@ import {
ListTree,
CheckCircle,
Infinity,
RefreshCw
RefreshCw,
} from "lucide-react";
import {t} from "i18next";
import { t } from "i18next";
// Активируем плагин для dayjs
dayjs.extend(relativeTime);
@@ -93,7 +93,7 @@ const parseExpire = (expire?: number | string): string | null => {
if (!expireDate.isValid()) return null;
const now = dayjs();
if (expireDate.isBefore(now)) return t("Expired");
return t('Expires in', { duration: expireDate.fromNow(true) });
return t("Expires in", { duration: expireDate.fromNow(true) });
};
type MenuItemAction = {
@@ -262,7 +262,9 @@ export const ProfileItem = (props: Props) => {
zIndex: isDragging ? 100 : undefined,
};
const homeMenuItem: MenuItemAction[] = hasHome ? [{ label: "Home", handler: onOpenHome, icon: ExternalLink }] : [];
const homeMenuItem: MenuItemAction[] = hasHome
? [{ label: "Home", handler: onOpenHome, icon: ExternalLink }]
: [];
const mainMenuItems: MenuItemAction[] = [
{ label: "Select", handler: onForceSelect, icon: CheckCircle },
@@ -272,12 +274,32 @@ export const ProfileItem = (props: Props) => {
];
const editMenuItems: MenuItemAction[] = [
{ label: "Edit Rules", handler: onEditRules, disabled: !option?.rules, icon: ListChecks },
{ label: "Edit Proxies", handler: onEditProxies, disabled: !option?.proxies, icon: ListFilter },
{ label: "Edit Groups", handler: onEditGroups, disabled: !option?.groups, icon: ListTree },
{
label: "Edit Rules",
handler: onEditRules,
disabled: !option?.rules,
icon: ListChecks,
},
{
label: "Edit Proxies",
handler: onEditProxies,
disabled: !option?.proxies,
icon: ListFilter,
},
{
label: "Edit Groups",
handler: onEditGroups,
disabled: !option?.groups,
icon: ListTree,
},
];
const deleteMenuItem: MenuItemAction = { label: "Delete", handler: () => setConfirmOpen(true), icon: Trash2, isDestructive: true };
const deleteMenuItem: MenuItemAction = {
label: "Delete",
handler: () => setConfirmOpen(true),
icon: Trash2,
isDestructive: true,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
@@ -310,7 +332,14 @@ export const ProfileItem = (props: Props) => {
<p className="text-sm font-semibold truncate" title={name}>
{name}
</p>
{expireInfo === t("Expired") ? <Badge variant="destructive" className="text-xs bg-red-500 text-white dark:bg-red-500">{t(expireInfo)}</Badge> : null}
{expireInfo === t("Expired") ? (
<Badge
variant="destructive"
className="text-xs bg-red-500 text-white dark:bg-red-500"
>
{t(expireInfo)}
</Badge>
) : null}
</div>
<div className="flex items-center flex-shrink-0">
<Badge
@@ -334,7 +363,11 @@ export const ProfileItem = (props: Props) => {
<div className="flex items-center">
<Clock className="h-3 w-3 inline mr-1.5" />
<span>
{expireInfo === null ? <Infinity className="h-3 w-3 inline mr-1.5"/>: expireInfo}
{expireInfo === null ? (
<Infinity className="h-3 w-3 inline mr-1.5" />
) : (
expireInfo
)}
</span>
</div>
</div>
@@ -351,7 +384,6 @@ export const ProfileItem = (props: Props) => {
)}
</div>
</div>
</div>
</div>
@@ -369,36 +401,78 @@ export const ProfileItem = (props: Props) => {
</Card>
</ContextMenuTrigger>
<ContextMenuContent className="w-56" onClick={e => e.stopPropagation()}>
<ContextMenuContent
className="w-56"
onClick={(e) => e.stopPropagation()}
>
{/* Объединяем все части меню */}
{[...homeMenuItem, ...mainMenuItems].map(item => (
<ContextMenuItem key={item.label} onSelect={item.handler} disabled={item.disabled}>
<item.icon className="mr-2 h-4 w-4" /><span>{t(item.label)}</span>
{[...homeMenuItem, ...mainMenuItems].map((item) => (
<ContextMenuItem
key={item.label}
onSelect={item.handler}
disabled={item.disabled}
>
<item.icon className="mr-2 h-4 w-4" />
<span>{t(item.label)}</span>
</ContextMenuItem>
))}
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger disabled={!hasUrl || isLoading}><DownloadCloud className="mr-2 h-4 w-4" /><span>{t("Update")}</span></ContextMenuSubTrigger>
<ContextMenuPortal><ContextMenuSubContent>
<ContextMenuItem onSelect={() => onUpdate(0)}>{t("Update")}</ContextMenuItem>
<ContextMenuItem onSelect={() => onUpdate(2)}>{t("Update via proxy")}</ContextMenuItem>
</ContextMenuSubContent></ContextMenuPortal>
<ContextMenuSubTrigger disabled={!hasUrl || isLoading}>
<DownloadCloud className="mr-2 h-4 w-4" />
<span>{t("Update")}</span>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent>
<ContextMenuItem onSelect={() => onUpdate(0)}>
{t("Update")}
</ContextMenuItem>
<ContextMenuItem onSelect={() => onUpdate(2)}>
{t("Update via proxy")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
<ContextMenuSeparator />
{editMenuItems.map(item => (
<ContextMenuItem key={item.label} onSelect={item.handler} disabled={item.disabled}>
<item.icon className="mr-2 h-4 w-4" /><span>{t(item.label)}</span>
{editMenuItems.map((item) => (
<ContextMenuItem
key={item.label}
onSelect={item.handler}
disabled={item.disabled}
>
<item.icon className="mr-2 h-4 w-4" />
<span>{t(item.label)}</span>
</ContextMenuItem>
))}
<ContextMenuSeparator />
<ContextMenuItem onSelect={deleteMenuItem.handler} className={cn(deleteMenuItem.isDestructive && "text-destructive focus:text-destructive focus:bg-destructive/10")}>
<deleteMenuItem.icon className="mr-2 h-4 w-4" /><span>{t(deleteMenuItem.label)}</span>
<ContextMenuItem
onSelect={deleteMenuItem.handler}
className={cn(
deleteMenuItem.isDestructive &&
"text-destructive focus:text-destructive focus:bg-destructive/10",
)}
>
<deleteMenuItem.icon className="mr-2 h-4 w-4" />
<span>{t(deleteMenuItem.label)}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* Модальные окна для редактирования */}
{fileOpen && <EditorViewer open={true} title={`${t("Edit File")}: ${name}`} onClose={() => setFileOpen(false)} initialData={readProfileFile(uid)} language="yaml" schema="clash" onSave={async (p, c) => { await saveProfileFile(uid, c || ""); onSave?.(p, c); }} />}
{fileOpen && (
<EditorViewer
open={true}
title={`${t("Edit File")}: ${name}`}
onClose={() => setFileOpen(false)}
initialData={readProfileFile(uid)}
language="yaml"
schema="clash"
onSave={async (p, c) => {
await saveProfileFile(uid, c || "");
onSave?.(p, c);
}}
/>
)}
{rulesOpen && (
<RulesEditorViewer
@@ -407,7 +481,7 @@ export const ProfileItem = (props: Props) => {
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
property={option?.rules ?? ""}
groupsUid={option?.groups ?? ""} // <-- Добавлен недостающий пропс
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
onSave={onSave}
/>
)}
@@ -429,7 +503,7 @@ export const ProfileItem = (props: Props) => {
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
property={option?.groups ?? ""}
proxiesUid={option?.proxies ?? ""} // <-- Добавлен недостающий пропс
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
onSave={onSave}
/>
)}
@@ -438,7 +512,7 @@ export const ProfileItem = (props: Props) => {
open={confirmOpen}
onOpenChange={setConfirmOpen}
onConfirm={onDelete}
title={t('Delete Profile', { name })}
title={t("Delete Profile", { name })}
description={t("This action cannot be undone.")}
/>
</div>

View File

@@ -6,12 +6,17 @@ import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { ProfileBox } from "./profile-box"; // Наш рефакторенный компонент
import { LogViewer } from "./log-viewer"; // Наш рефакторенный компонент
import { LogViewer } from "./log-viewer"; // Наш рефакторенный компонент
// Новые импорты
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Tooltip,
TooltipContent,
@@ -60,7 +65,9 @@ export const ProfileMore = (props: Props) => {
<ProfileBox onDoubleClick={onEditFile}>
{/* Верхняя строка: Название и Бейдж */}
<div className="flex justify-between items-center mb-2">
<p className="font-semibold text-base truncate">{t(`Global ${id}`)}</p>
<p className="font-semibold text-base truncate">
{t(`Global ${id}`)}
</p>
<Badge variant="secondary">{id}</Badge>
</div>

View File

@@ -1,8 +1,19 @@
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { createProfile, patchProfile, importProfile, enhanceProfiles } from "@/services/cmds";
import {
createProfile,
patchProfile,
importProfile,
enhanceProfiles,
} from "@/services/cmds";
import { useProfiles } from "@/hooks/use-profiles";
import { showNotice } from "@/services/noticeService";
import { version } from "@root/package.json";
@@ -34,11 +45,10 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {ClipboardPaste, Loader2, X} from "lucide-react";
import {readText} from "@tauri-apps/plugin-clipboard-manager";
import { ClipboardPaste, Loader2, X } from "lucide-react";
import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { cn } from "@root/lib/utils";
interface Props {
onChange: (isActivating?: boolean) => void;
}
@@ -48,300 +58,469 @@ export interface ProfileViewerRef {
edit: (item: IProfileItem) => void;
}
export const ProfileViewer = forwardRef<ProfileViewerRef, Props>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const { profiles } = useProfiles();
const fileDataRef = useRef<string | null>(null);
export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
(props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const { profiles } = useProfiles();
const fileDataRef = useRef<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [importUrl, setImportUrl] = useState("");
const [isUrlValid, setIsUrlValid] = useState(true);
const [isCheckingUrl, setIsCheckingUrl] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [loading, setLoading] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [importUrl, setImportUrl] = useState("");
const [isUrlValid, setIsUrlValid] = useState(true);
const [isCheckingUrl, setIsCheckingUrl] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [loading, setLoading] = useState(false);
const form = useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
danger_accept_invalid_certs: false,
const form = useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
danger_accept_invalid_certs: false,
},
},
},
});
});
const { control, watch, handleSubmit, reset, setValue } = form;
const { control, watch, handleSubmit, reset, setValue } = form;
useImperativeHandle(ref, () => ({
create: () => {
reset({ type: "remote", name: "", desc: "", url: "", option: { with_proxy: false, self_proxy: false, danger_accept_invalid_certs: false } });
fileDataRef.current = null;
setImportUrl("");
setShowAdvanced(false);
setOpenType("new");
setOpen(true);
},
edit: (item) => {
reset(item);
fileDataRef.current = null;
setImportUrl(item.url || "");
setShowAdvanced(true);
setOpenType("edit");
setOpen(true);
},
}));
useImperativeHandle(ref, () => ({
create: () => {
reset({
type: "remote",
name: "",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
danger_accept_invalid_certs: false,
},
});
fileDataRef.current = null;
setImportUrl("");
setShowAdvanced(false);
setOpenType("new");
setOpen(true);
},
edit: (item) => {
reset(item);
fileDataRef.current = null;
setImportUrl(item.url || "");
setShowAdvanced(true);
setOpenType("edit");
setOpen(true);
},
}));
const selfProxy = watch("option.self_proxy");
const withProxy = watch("option.with_proxy");
useEffect(() => { if (selfProxy) setValue("option.with_proxy", false); }, [selfProxy, setValue]);
useEffect(() => { if (withProxy) setValue("option.self_proxy", false); }, [withProxy, setValue]);
const selfProxy = watch("option.self_proxy");
const withProxy = watch("option.with_proxy");
useEffect(() => {
if (selfProxy) setValue("option.with_proxy", false);
}, [selfProxy, setValue]);
useEffect(() => {
if (withProxy) setValue("option.self_proxy", false);
}, [withProxy, setValue]);
useEffect(() => {
if (!importUrl) {
setIsUrlValid(true);
setIsCheckingUrl(false);
return;
}
setIsCheckingUrl(true);
const handler = setTimeout(() => {
try {
new URL(importUrl);
useEffect(() => {
if (!importUrl) {
setIsUrlValid(true);
} catch (error) {
setIsUrlValid(false);
} finally {
setIsCheckingUrl(false);
return;
}
}, 500);
return () => {
clearTimeout(handler);
};
}, [importUrl]);
setIsCheckingUrl(true);
const handleImport = useLockFn(async () => {
if (!importUrl) return;
setIsImporting(true);
try {
await importProfile(importUrl);
showNotice("success", t("Profile Imported Successfully"));
props.onChange();
await enhanceProfiles();
setOpen(false);
} catch (err) {
showNotice("info", t("Import failed, retrying with Clash proxy..."));
const handler = setTimeout(() => {
try {
new URL(importUrl);
setIsUrlValid(true);
} catch (error) {
setIsUrlValid(false);
} finally {
setIsCheckingUrl(false);
}
}, 500);
return () => {
clearTimeout(handler);
};
}, [importUrl]);
const handleImport = useLockFn(async () => {
if (!importUrl) return;
setIsImporting(true);
try {
await importProfile(importUrl, { with_proxy: false, self_proxy: true });
showNotice("success", t("Profile Imported with Clash proxy"));
await importProfile(importUrl);
showNotice("success", t("Profile Imported Successfully"));
props.onChange();
await enhanceProfiles();
setOpen(false);
} catch (retryErr: any) {
showNotice("error", `${t("Import failed even with Clash proxy")}: ${retryErr?.message || retryErr.toString()}`);
}
} finally {
setIsImporting(false);
}
});
const onCopyLink = async () => {
const text = await readText();
if (text) setImportUrl(text);
};
const handleSaveAdvanced = useLockFn(
handleSubmit(async (formData) => {
const form = { ...formData, url: formData.url || importUrl };
setLoading(true);
try {
if (!form.type) throw new Error("`Type` should not be null");
if (form.type === "remote" && !form.url) throw new Error("The URL should not be null");
if (form.option?.update_interval) form.option.update_interval = +form.option.update_interval;
else delete form.option?.update_interval;
if (form.option?.user_agent === "") delete form.option.user_agent;
const name = form.name || `${form.type} file`;
const item = { ...form, name };
const isUpdate = openType === "edit";
const isActivating = isUpdate && form.uid === (profiles?.current ?? "");
if (openType === "new") {
await createProfile(item, fileDataRef.current);
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
} catch (err) {
showNotice("info", t("Import failed, retrying with Clash proxy..."));
try {
await importProfile(importUrl, {
with_proxy: false,
self_proxy: true,
});
showNotice("success", t("Profile Imported with Clash proxy"));
props.onChange();
await enhanceProfiles();
setOpen(false);
} catch (retryErr: any) {
showNotice(
"error",
`${t("Import failed even with Clash proxy")}: ${retryErr?.message || retryErr.toString()}`,
);
}
setOpen(false);
props.onChange(isActivating);
} catch (err: any) {
showNotice("error", err.message || err.toString());
} finally {
setLoading(false);
setIsImporting(false);
}
}),
);
});
const formType = watch("type");
const isRemote = formType === "remote";
const isLocal = formType === "local";
const onCopyLink = async () => {
const text = await readText();
if (text) setImportUrl(text);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[95vw] sm:max-w-md">
<DialogHeader>
<DialogTitle>{openType === "new" ? t("Create Profile") : t("Edit Profile")}</DialogTitle>
</DialogHeader>
const handleSaveAdvanced = useLockFn(
handleSubmit(async (formData) => {
const form = { ...formData, url: formData.url || importUrl };
{openType === "new" && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 flex-grow sm:flex-grow-0">
<Input
type="text"
placeholder={t("Profile URL")}
value={importUrl}
onChange={(e) => setImportUrl(e.target.value)}
disabled={isImporting}
className={cn(
"h-9 min-w-[200px] flex-grow sm:w-65",
!isUrlValid && "border-destructive focus-visible:ring-destructive"
setLoading(true);
try {
if (!form.type) throw new Error("`Type` should not be null");
if (form.type === "remote" && !form.url)
throw new Error("The URL should not be null");
if (form.option?.update_interval)
form.option.update_interval = +form.option.update_interval;
else delete form.option?.update_interval;
if (form.option?.user_agent === "") delete form.option.user_agent;
const name = form.name || `${form.type} file`;
const item = { ...form, name };
const isUpdate = openType === "edit";
const isActivating =
isUpdate && form.uid === (profiles?.current ?? "");
if (openType === "new") {
await createProfile(item, fileDataRef.current);
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
}
setOpen(false);
props.onChange(isActivating);
} catch (err: any) {
showNotice("error", err.message || err.toString());
} finally {
setLoading(false);
}
}),
);
const formType = watch("type");
const isRemote = formType === "remote";
const isLocal = formType === "local";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[95vw] sm:max-w-md">
<DialogHeader>
<DialogTitle>
{openType === "new" ? t("Create Profile") : t("Edit Profile")}
</DialogTitle>
</DialogHeader>
{openType === "new" && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 flex-grow sm:flex-grow-0">
<Input
type="text"
placeholder={t("Profile URL")}
value={importUrl}
onChange={(e) => setImportUrl(e.target.value)}
disabled={isImporting}
className={cn(
"h-9 min-w-[200px] flex-grow sm:w-65",
!isUrlValid &&
"border-destructive focus-visible:ring-destructive",
)}
/>
{importUrl ? (
<Button
variant="ghost"
size="icon"
title={t("Clear")}
onClick={() => setImportUrl("")}
className="h-9 w-9 flex-shrink-0"
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
title={t("Paste")}
onClick={onCopyLink}
className="h-9 w-9 flex-shrink-0"
>
<ClipboardPaste className="h-4 w-4" />
</Button>
)}
/>
{importUrl ? (
<Button
variant="ghost"
size="icon"
title={t("Clear")}
onClick={() => setImportUrl("")}
className="h-9 w-9 flex-shrink-0"
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
title={t("Paste")}
onClick={onCopyLink}
className="h-9 w-9 flex-shrink-0"
>
<ClipboardPaste className="h-4 w-4" />
</Button>
</div>
<Button
onClick={handleImport}
disabled={
!importUrl || isCheckingUrl || !isUrlValid || isImporting
}
className="flex-shrink-0 min-w-[5.5rem]"
>
{isCheckingUrl || isImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t("Import")
)}
</Button>
{!isUrlValid && importUrl && (
<p className="text-sm text-destructive px-1">
{t("Please enter a valid URL")}
</p>
)}
</div>
<Button
onClick={handleImport}
disabled={!importUrl || isCheckingUrl || !isUrlValid || isImporting}
className="flex-shrink-0 min-w-[5.5rem]"
variant="outline"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{(isCheckingUrl || isImporting) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t("Import")
)}
{showAdvanced
? t("Hide Advanced Settings")
: t("Show Advanced Settings")}
</Button>
{!isUrlValid && importUrl && (
<p className="text-sm text-destructive px-1">
{t("Please enter a valid URL")}
</p>
)}
</div>
)}
<Button variant="outline" onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? t("Hide Advanced Settings") : t("Show Advanced Settings")}
</Button>
</div>
)}
{(openType === "edit" || showAdvanced) && (
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
handleSaveAdvanced();
}}
className="space-y-4 max-h-[60vh] overflow-y-auto px-1 pt-4"
>
<FormField
control={control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Type")}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={openType === "edit"}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="remote">Remote</SelectItem>
<SelectItem value="local">Local</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Name")}</FormLabel>
<FormControl>
<Input placeholder={t("Profile Name")} {...field} />
</FormControl>
</FormItem>
)}
/>
{(openType === 'edit' || showAdvanced) && (
<Form {...form}>
<form onSubmit={e => { e.preventDefault(); handleSaveAdvanced(); }} className="space-y-4 max-h-[60vh] overflow-y-auto px-1 pt-4">
<FormField control={control} name="type" render={({ field }) => (
<FormItem>
<FormLabel>{t("Type")}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={openType === "edit"}>
<FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
<SelectContent>
<SelectItem value="remote">Remote</SelectItem>
<SelectItem value="local">Local</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}/>
<FormField
control={control}
name="desc"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Descriptions")}</FormLabel>
<FormControl>
<Input
placeholder={t("Profile Description")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField control={control} name="name" render={({ field }) => (
<FormItem><FormLabel>{t("Name")}</FormLabel><FormControl><Input placeholder={t("Profile Name")} {...field} /></FormControl></FormItem>
)}/>
{isRemote && (
<FormField
control={control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Subscription URL")}</FormLabel>
<FormControl>
<Textarea
placeholder={t("Leave blank to use the URL above")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField control={control} name="desc" render={({ field }) => (
<FormItem><FormLabel>{t("Descriptions")}</FormLabel><FormControl><Input placeholder={t("Profile Description")} {...field} /></FormControl></FormItem>
)}/>
{isRemote && (
<FormField control={control} name="url" render={({ field }) => (
<FormItem><FormLabel>{t("Subscription URL")}</FormLabel><FormControl><Textarea placeholder={t("Leave blank to use the URL above")} {...field} /></FormControl></FormItem>
)}/>
)}
{isLocal && openType === "new" && (
{isLocal && openType === "new" && (
<FormItem>
<FormLabel>{t("File")}</FormLabel>
<FormControl><Input type="file" accept=".yml,.yaml" onChange={(e) => {
<FormLabel>{t("File")}</FormLabel>
<FormControl>
<Input
type="file"
accept=".yml,.yaml"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValue("name", form.getValues("name") || file.name);
const reader = new FileReader();
reader.onload = (event) => { fileDataRef.current = event.target?.result as string; };
reader.readAsText(file);
setValue(
"name",
form.getValues("name") || file.name,
);
const reader = new FileReader();
reader.onload = (event) => {
fileDataRef.current = event.target
?.result as string;
};
reader.readAsText(file);
}
}} /></FormControl>
}}
/>
</FormControl>
</FormItem>
)}
)}
{isRemote && (
<div className="space-y-4 rounded-md border p-4">
<FormField control={control} name="option.update_interval" render={({ field }) => (
<FormItem><FormLabel>{t("Update Interval (mins)")}</FormLabel><FormControl><Input type="number" placeholder="1440" {...field} onChange={e => field.onChange(+e.target.value)} /></FormControl></FormItem>
)}/>
<FormField control={control} name="option.user_agent" render={({ field }) => (
<FormItem><FormLabel>User Agent</FormLabel><FormControl><Input placeholder={`clash-verge/v${version}`} {...field} /></FormControl></FormItem>
)}/>
<FormField control={control} name="option.with_proxy" render={({ field }) => (
<FormItem className="flex items-center justify-between"><FormLabel>{t("Use System Proxy")}</FormLabel><FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl></FormItem>
)}/>
<FormField control={control} name="option.self_proxy" render={({ field }) => (
<FormItem className="flex items-center justify-between"><FormLabel>{t("Use Clash Proxy")}</FormLabel><FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl></FormItem>
)}/>
<FormField control={control} name="option.danger_accept_invalid_certs" render={({ field }) => (
<FormItem className="flex items-center justify-between"><FormLabel className="text-destructive">{t("Accept Invalid Certs (Danger)")}</FormLabel><FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl></FormItem>
)}/>
</div>
)}
{isRemote && (
<div className="space-y-4 rounded-md border p-4">
<FormField
control={control}
name="option.update_interval"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Update Interval (mins)")}</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1440"
{...field}
onChange={(e) => field.onChange(+e.target.value)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="option.user_agent"
render={({ field }) => (
<FormItem>
<FormLabel>User Agent</FormLabel>
<FormControl>
<Input
placeholder={`clash-verge/v${version}`}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="option.with_proxy"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<FormLabel>{t("Use System Proxy")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="option.self_proxy"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<FormLabel>{t("Use Clash Proxy")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="option.danger_accept_invalid_certs"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<FormLabel className="text-destructive">
{t("Accept Invalid Certs (Danger)")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
)}
<button type="submit" className="hidden" />
</form>
</Form>
)}
<button type="submit" className="hidden" />
</form>
</Form>
)}
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
{(openType === 'edit' || showAdvanced) && (
<Button type="button" onClick={handleSaveAdvanced} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
{(openType === "edit" || showAdvanced) && (
<Button
type="button"
onClick={handleSaveAdvanced}
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
});
</DialogFooter>
</DialogContent>
</Dialog>
);
},
);

View File

@@ -50,28 +50,38 @@ export const ProxyItem = (props: Props) => {
className={cn(
"flex items-center p-2 mb-1 rounded-lg transition-shadow",
typeStyles[type],
isDragging && "shadow-lg"
isDragging && "shadow-lg",
)}
>
{/* Ручка для перетаскивания */}
<div
{...attributes}
{...listeners}
className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
className={cn(
"p-1 text-muted-foreground rounded-sm",
isSortable ? "cursor-move hover:bg-accent" : "cursor-default",
)}
>
<GripVertical className="h-5 w-5" />
</div>
{/* Название и тип прокси */}
<div className="flex-1 min-w-0 ml-2">
<p className="text-sm font-semibold truncate" title={proxy.name}>{proxy.name}</p>
<p className="text-sm font-semibold truncate" title={proxy.name}>
{proxy.name}
</p>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<Badge variant="outline">{proxy.type}</Badge>
</div>
</div>
{/* Кнопка действия */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onDelete}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onDelete}
>
{type === "delete" ? (
<Undo2 className="h-4 w-4" />
) : (

View File

@@ -22,9 +22,16 @@ const typeStyles = {
};
// Вспомогательная функция для цвета политики прокси
const PROXY_COLOR_CLASSES = ["text-sky-500", "text-violet-500", "text-amber-500", "text-lime-500", "text-emerald-500"];
const PROXY_COLOR_CLASSES = [
"text-sky-500",
"text-violet-500",
"text-amber-500",
"text-lime-500",
"text-emerald-500",
];
const getProxyColorClass = (proxyName: string): string => {
if (proxyName === "REJECT" || proxyName === "REJECT-DROP") return "text-destructive";
if (proxyName === "REJECT" || proxyName === "REJECT-DROP")
return "text-destructive";
if (proxyName === "DIRECT") return "text-primary";
let sum = 0;
for (let i = 0; i < proxyName.length; i++) sum += proxyName.charCodeAt(i);
@@ -66,21 +73,27 @@ export const RuleItem = (props: Props) => {
className={cn(
"flex items-center p-2 mb-1 rounded-lg transition-shadow",
typeStyles[type],
isDragging && "shadow-lg"
isDragging && "shadow-lg",
)}
>
{/* Ручка для перетаскивания */}
<div
{...attributes}
{...listeners}
className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
className={cn(
"p-1 text-muted-foreground rounded-sm",
isSortable ? "cursor-move hover:bg-accent" : "cursor-default",
)}
>
<GripVertical className="h-5 w-5" />
</div>
{/* Основной контент */}
<div className="flex-1 min-w-0 ml-2">
<p className="text-sm font-semibold truncate" title={ruleContent || "-"}>
<p
className="text-sm font-semibold truncate"
title={ruleContent || "-"}
>
{ruleContent || "-"}
</p>
<div className="flex items-center justify-between text-xs mt-1">
@@ -92,7 +105,12 @@ export const RuleItem = (props: Props) => {
</div>
{/* Кнопка действия */}
<Button variant="ghost" size="icon" className="h-8 w-8 ml-2" onClick={onDelete}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 ml-2"
onClick={onDelete}
>
{type === "delete" ? (
<Undo2 className="h-4 w-4" />
) : (

View File

@@ -188,7 +188,6 @@ const rules: {
];
const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
const Combobox = ({
options,
value,

View File

@@ -76,13 +76,39 @@ export const ProxyItemMini = (props: Props) => {
{proxy.now}
</span>
)}
{!!proxy.provider && (<Badge variant="outline" className="flex-shrink-0">{proxy.provider}</Badge>)}
<Badge variant="outline" className="flex-shrink-0">{proxy.type}</Badge>
{proxy.udp && (<Badge variant="outline" className="flex-shrink-0">UDP</Badge>)}
{proxy.xudp && <Badge variant="outline" className="flex-shrink-0">XUDP</Badge>}
{proxy.tfo && <Badge variant="outline" className="flex-shrink-0">TFO</Badge>}
{proxy.mptcp && <Badge variant="outline" className="flex-shrink-0">MPTCP</Badge>}
{proxy.smux && <Badge variant="outline" className="flex-shrink-0">SMUX</Badge>}
{!!proxy.provider && (
<Badge variant="outline" className="flex-shrink-0">
{proxy.provider}
</Badge>
)}
<Badge variant="outline" className="flex-shrink-0">
{proxy.type}
</Badge>
{proxy.udp && (
<Badge variant="outline" className="flex-shrink-0">
UDP
</Badge>
)}
{proxy.xudp && (
<Badge variant="outline" className="flex-shrink-0">
XUDP
</Badge>
)}
{proxy.tfo && (
<Badge variant="outline" className="flex-shrink-0">
TFO
</Badge>
)}
{proxy.mptcp && (
<Badge variant="outline" className="flex-shrink-0">
MPTCP
</Badge>
)}
{proxy.smux && (
<Badge variant="outline" className="flex-shrink-0">
SMUX
</Badge>
)}
</div>
)}
</div>

View File

@@ -10,11 +10,17 @@ import { saveWebdavConfig, createWebdavBackup } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@root/lib/utils";
export interface BackupConfigViewerProps {
onBackupSuccess: () => Promise<void>;
onSaveSuccess: () => Promise<void>;
@@ -24,23 +30,29 @@ export interface BackupConfigViewerProps {
}
export const BackupConfigViewer = memo(
({ onBackupSuccess, onSaveSuccess, onRefresh, onInit, setLoading }: BackupConfigViewerProps) => {
({
onBackupSuccess,
onSaveSuccess,
onRefresh,
onInit,
setLoading,
}: BackupConfigViewerProps) => {
const { t } = useTranslation();
const { verge } = useVerge();
const { webdav_url, webdav_username, webdav_password } = verge || {};
const [showPassword, setShowPassword] = useState(false);
const form = useForm<IWebDavConfig>({
defaultValues: { url: '', username: '', password: '' },
defaultValues: { url: "", username: "", password: "" },
});
// Синхронизируем форму с данными из verge
useEffect(() => {
form.reset({
url: webdav_url,
username: webdav_username,
password: webdav_password
});
form.reset({
url: webdav_url,
username: webdav_username,
password: webdav_password,
});
}, [webdav_url, webdav_username, webdav_password, form.reset]);
const { register, handleSubmit, watch, getValues } = form;
@@ -48,47 +60,77 @@ export const BackupConfigViewer = memo(
const username = watch("username");
const password = watch("password");
const webdavChanged = webdav_url !== url || webdav_username !== username || webdav_password !== password;
const webdavChanged =
webdav_url !== url ||
webdav_username !== username ||
webdav_password !== password;
const checkForm = () => {
const values = getValues();
if (!values.url) { showNotice("error", t("WebDAV URL Required")); throw new Error("URL Required"); }
if (!isValidUrl(values.url)) { showNotice("error", t("Invalid WebDAV URL")); throw new Error("Invalid URL"); }
if (!values.username) { showNotice("error", t("Username Required")); throw new Error("Username Required"); }
if (!values.password) { showNotice("error", t("Password Required")); throw new Error("Password Required"); }
const values = getValues();
if (!values.url) {
showNotice("error", t("WebDAV URL Required"));
throw new Error("URL Required");
}
if (!isValidUrl(values.url)) {
showNotice("error", t("Invalid WebDAV URL"));
throw new Error("Invalid URL");
}
if (!values.username) {
showNotice("error", t("Username Required"));
throw new Error("Username Required");
}
if (!values.password) {
showNotice("error", t("Password Required"));
throw new Error("Password Required");
}
};
const save = useLockFn(async (data: IWebDavConfig) => {
try { checkForm(); } catch { return; }
try {
setLoading(true);
await saveWebdavConfig(data.url.trim(), data.username.trim(), data.password);
showNotice("success", t("WebDAV Config Saved"));
await onSaveSuccess();
} catch (error) {
showNotice("error", t("WebDAV Config Save Failed", { error }), 3000);
} finally {
setLoading(false);
}
try {
checkForm();
} catch {
return;
}
try {
setLoading(true);
await saveWebdavConfig(
data.url.trim(),
data.username.trim(),
data.password,
);
showNotice("success", t("WebDAV Config Saved"));
await onSaveSuccess();
} catch (error) {
showNotice("error", t("WebDAV Config Save Failed", { error }), 3000);
} finally {
setLoading(false);
}
});
const handleBackup = useLockFn(async () => {
try { checkForm(); } catch { return; }
try {
setLoading(true);
await createWebdavBackup();
showNotice("success", t("Backup Created"));
await onBackupSuccess();
} catch (error) {
showNotice("error", t("Backup Failed", { error }));
} finally {
setLoading(false);
}
try {
checkForm();
} catch {
return;
}
try {
setLoading(true);
await createWebdavBackup();
showNotice("success", t("Backup Created"));
await onBackupSuccess();
} catch (error) {
showNotice("error", t("Backup Failed", { error }));
} finally {
setLoading(false);
}
});
return (
<Form {...form}>
<form onSubmit={e => e.preventDefault()} className="flex flex-col sm:flex-row gap-4">
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col sm:flex-row gap-4"
>
{/* Левая часть: поля ввода */}
<div className="flex-1 space-y-4">
<FormField
@@ -97,7 +139,9 @@ export const BackupConfigViewer = memo(
render={({ field }) => (
<FormItem>
<FormLabel>{t("WebDAV Server URL")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
@@ -109,7 +153,9 @@ export const BackupConfigViewer = memo(
render={({ field }) => (
<FormItem>
<FormLabel>{t("Username")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
@@ -122,7 +168,11 @@ export const BackupConfigViewer = memo(
<FormLabel>{t("Password")}</FormLabel>
<div className="relative">
<FormControl>
<Input type={showPassword ? "text" : "password"} {...field} className="pr-10" />
<Input
type={showPassword ? "text" : "password"}
{...field}
className="pr-10"
/>
</FormControl>
<Button
type="button"
@@ -131,7 +181,11 @@ export const BackupConfigViewer = memo(
className="absolute top-0 right-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<FormMessage />
@@ -144,7 +198,11 @@ export const BackupConfigViewer = memo(
{/* Правая часть: кнопки действий */}
<div className="flex sm:flex-col gap-2">
{webdavChanged || !webdav_url ? (
<Button type="button" className="w-full h-full" onClick={handleSubmit(save)}>
<Button
type="button"
className="w-full h-full"
onClick={handleSubmit(save)}
>
{t("Save")}
</Button>
) : (
@@ -152,7 +210,12 @@ export const BackupConfigViewer = memo(
<Button type="button" className="w-full" onClick={handleBackup}>
{t("Backup")}
</Button>
<Button type="button" variant="outline" className="w-full" onClick={onRefresh}>
<Button
type="button"
variant="outline"
className="w-full"
onClick={onRefresh}
>
{t("Refresh")}
</Button>
</>
@@ -161,5 +224,5 @@ export const BackupConfigViewer = memo(
</form>
</Form>
);
}
},
);

View File

@@ -16,10 +16,14 @@ import {
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Trash2, History } from "lucide-react";
export type BackupFile = IWebDavFile & {
platform: string;
backup_time: dayjs.Dayjs;
@@ -31,7 +35,10 @@ export const DEFAULT_ROWS_PER_PAGE = 5;
export interface BackupTableViewerProps {
datasource: BackupFile[];
page: number;
onPageChange: (event: React.MouseEvent<HTMLButtonElement> | null, page: number) => void;
onPageChange: (
event: React.MouseEvent<HTMLButtonElement> | null,
page: number,
) => void;
total: number;
onRefresh: () => Promise<void>;
}
@@ -118,9 +125,14 @@ function MacIcon(props: SVGProps<SVGSVGElement>) {
);
}
export const BackupTableViewer = memo(
({ datasource, page, onPageChange, total, onRefresh }: BackupTableViewerProps) => {
({
datasource,
page,
onPageChange,
total,
onRefresh,
}: BackupTableViewerProps) => {
const { t } = useTranslation();
const handleDelete = useLockFn(async (filename: string) => {
@@ -151,50 +163,66 @@ export const BackupTableViewer = memo(
<TableRow key={index}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{file.platform === "windows" ? ( <WindowsIcon className="h-5 w-5" />
) : file.platform === "linux" ? ( <LinuxIcon className="h-5 w-5" />
) : ( <MacIcon className="h-5 w-5" /> )}
<span>{file.filename}</span>
{file.platform === "windows" ? (
<WindowsIcon className="h-5 w-5" />
) : file.platform === "linux" ? (
<LinuxIcon className="h-5 w-5" />
) : (
<MacIcon className="h-5 w-5" />
)}
<span>{file.filename}</span>
</div>
</TableCell>
<TableCell className="text-center">{file.backup_time.fromNow()}</TableCell>
<TableCell className="text-center">
{file.backup_time.fromNow()}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={async () => {
const confirmed = window.confirm(t("Confirm to delete this backup file?"));
if (confirmed) await handleDelete(file.filename);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Delete Backup")}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!file.allow_apply}
onClick={async () => {
const confirmed = window.confirm(t("Confirm to restore this backup file?"));
if (confirmed) await handleRestore(file.filename);
}}
>
<History className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Restore Backup")}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={async () => {
const confirmed = window.confirm(
t("Confirm to delete this backup file?"),
);
if (confirmed)
await handleDelete(file.filename);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Delete Backup")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!file.allow_apply}
onClick={async () => {
const confirmed = window.confirm(
t("Confirm to restore this backup file?"),
);
if (confirmed)
await handleRestore(file.filename);
}}
>
<History className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Restore Backup")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
@@ -210,27 +238,27 @@ export const BackupTableViewer = memo(
</Table>
{/* Новая кастомная пагинация */}
<div className="flex items-center justify-end space-x-2 p-2 border-t border-border">
<div className="flex-1 text-sm text-muted-foreground">
{t("Total")} {total}
</div>
<Button
variant="outline"
size="sm"
onClick={(e) => onPageChange(e, page - 1)}
disabled={page === 0}
>
{t("Previous")}
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => onPageChange(e, page + 1)}
disabled={(page + 1) * DEFAULT_ROWS_PER_PAGE >= total}
>
{t("Next")}
</Button>
<div className="flex-1 text-sm text-muted-foreground">
{t("Total")} {total}
</div>
<Button
variant="outline"
size="sm"
onClick={(e) => onPageChange(e, page - 1)}
disabled={page === 0}
>
{t("Previous")}
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => onPageChange(e, page + 1)}
disabled={(page + 1) * DEFAULT_ROWS_PER_PAGE >= total}
>
{t("Next")}
</Button>
</div>
</div>
);
}
},
);

View File

@@ -1,4 +1,10 @@
import { forwardRef, useImperativeHandle, useState, useCallback, useEffect } from "react";
import {
forwardRef,
useImperativeHandle,
useState,
useCallback,
useEffect,
} from "react";
import { useTranslation } from "react-i18next";
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
@@ -6,11 +12,22 @@ import { useLockFn } from "ahooks";
// Новые импорты
import { listWebDavBackup } from "@/services/cmds";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { BaseLoadingOverlay } from "@/components/base"; // Наш рефакторенный компонент
import { BackupTableViewer, BackupFile, DEFAULT_ROWS_PER_PAGE } from "./backup-table-viewer"; // Наш рефакторенный компонент
import {
BackupTableViewer,
BackupFile,
DEFAULT_ROWS_PER_PAGE,
} from "./backup-table-viewer"; // Наш рефакторенный компонент
import { BackupConfigViewer } from "./backup-config-viewer"; // Наш рефакторенный компонент
dayjs.extend(customParseFormat);
@@ -118,7 +135,9 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
<Button type="button" variant="outline">
{t("Close")}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -24,7 +24,6 @@ import { changeClashCore, restartCore } from "@/services/cmds";
import { closeAllConnections, upgradeCore } from "@/services/api";
import { showNotice } from "@/services/noticeService";
// Константы и интерфейсы
const VALID_CORE = [
{ name: "Mihomo", core: "verge-mihomo", chip: "Release Version" },
@@ -107,12 +106,28 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
<div className="flex justify-between items-center">
<DialogTitle>{t("Clash Core")}</DialogTitle>
<div className="flex items-center gap-2">
<Button size="sm" disabled={restarting || changingCore !== null} onClick={onUpgrade}>
{upgrading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Replace className="mr-2 h-4 w-4" />}
<Button
size="sm"
disabled={restarting || changingCore !== null}
onClick={onUpgrade}
>
{upgrading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Replace className="mr-2 h-4 w-4" />
)}
{t("Upgrade")}
</Button>
<Button size="sm" disabled={upgrading || changingCore !== null} onClick={onRestart}>
{restarting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCw className="mr-2 h-4 w-4" />}
<Button
size="sm"
disabled={upgrading || changingCore !== null}
onClick={onRestart}
>
{restarting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RotateCw className="mr-2 h-4 w-4" />
)}
{t("Restart")}
</Button>
</div>
@@ -133,8 +148,10 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
onClick={() => !isDisabled && onCoreChange(each.core)}
className={cn(
"flex items-center justify-between p-3 rounded-md transition-colors",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-accent",
isSelected && "bg-accent"
isDisabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-accent",
isSelected && "bg-accent",
)}
>
<div>
@@ -145,7 +162,9 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
{isChanging ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Badge variant={isSelected ? "default" : "secondary"}>{t(each.chip)}</Badge>
<Badge variant={isSelected ? "default" : "secondary"}>
{t(each.chip)}
</Badge>
)}
</div>
</div>
@@ -155,7 +174,9 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
<Button type="button" variant="outline">
{t("Close")}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -18,7 +18,12 @@ import {
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Shuffle, Loader2 } from "lucide-react";
const OS = getSystem();
@@ -28,7 +33,8 @@ interface ClashPortViewerRef {
close: () => void;
}
const generateRandomPort = () => Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025;
const generateRandomPort = () =>
Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025;
// Компонент для одной строки настроек порта
const PortSettingRow = ({
@@ -85,7 +91,9 @@ const PortSettingRow = ({
<Shuffle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Random Port")}</p></TooltipContent>
<TooltipContent>
<p>{t("Random Port")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
@@ -98,7 +106,6 @@ const PortSettingRow = ({
);
};
export const ClashPortViewer = forwardRef<ClashPortViewerRef>((props, ref) => {
const { t } = useTranslation();
const { clashInfo, patchInfo } = useClashInfo();
@@ -171,7 +178,7 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((props, ref) => {
const clashConfig = {
"mixed-port": mixedPort,
"socks-port": socksEnabled ? socksPort : 0,
"port": httpEnabled ? httpPort : 0,
port: httpEnabled ? httpPort : 0,
"redir-port": redirEnabled ? redirPort : 0,
"tproxy-port": tproxyEnabled ? tproxyPort : 0,
};
@@ -199,19 +206,53 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((props, ref) => {
</DialogHeader>
<div className="py-4 space-y-1">
<PortSettingRow label={t("Mixed Port")} port={mixedPort} setPort={setMixedPort} isEnabled={true} isFixed={true} />
<PortSettingRow label={t("Socks Port")} port={socksPort} setPort={setSocksPort} isEnabled={socksEnabled} setIsEnabled={setSocksEnabled} />
<PortSettingRow label={t("Http Port")} port={httpPort} setPort={setHttpPort} isEnabled={httpEnabled} setIsEnabled={setHttpEnabled} />
<PortSettingRow
label={t("Mixed Port")}
port={mixedPort}
setPort={setMixedPort}
isEnabled={true}
isFixed={true}
/>
<PortSettingRow
label={t("Socks Port")}
port={socksPort}
setPort={setSocksPort}
isEnabled={socksEnabled}
setIsEnabled={setSocksEnabled}
/>
<PortSettingRow
label={t("Http Port")}
port={httpPort}
setPort={setHttpPort}
isEnabled={httpEnabled}
setIsEnabled={setHttpEnabled}
/>
{OS !== "windows" && (
<PortSettingRow label={t("Redir Port")} port={redirPort} setPort={setRedirPort} isEnabled={redirEnabled} setIsEnabled={setRedirEnabled} />
<PortSettingRow
label={t("Redir Port")}
port={redirPort}
setPort={setRedirPort}
isEnabled={redirEnabled}
setIsEnabled={setRedirEnabled}
/>
)}
{OS === "linux" && (
<PortSettingRow label={t("Tproxy Port")} port={tproxyPort} setPort={setTproxyPort} isEnabled={tproxyEnabled} setIsEnabled={setTproxyEnabled} />
<PortSettingRow
label={t("Tproxy Port")}
port={tproxyPort}
setPort={setTproxyPort}
isEnabled={tproxyEnabled}
setIsEnabled={setTproxyEnabled}
/>
)}
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={onSave} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}

View File

@@ -17,10 +17,14 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Copy, Loader2 } from "lucide-react";
export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -55,24 +59,31 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
showNotice("success", t("Configuration saved successfully"));
setOpen(false);
} catch (err: any) {
showNotice("error", err.message || t("Failed to save configuration"), 4000);
showNotice(
"error",
err.message || t("Failed to save configuration"),
4000,
);
} finally {
setIsSaving(false);
}
});
const handleCopyToClipboard = useLockFn(async (text: string, type: string) => {
try {
await navigator.clipboard.writeText(text);
// --- ИЗМЕНЕНИЕ: Используем showNotice вместо Snackbar ---
const message = type === "controller"
? t("Controller address copied to clipboard")
: t("Secret copied to clipboard");
showNotice("success", message);
} catch (err) {
showNotice("error", t("Failed to copy"));
}
});
const handleCopyToClipboard = useLockFn(
async (text: string, type: string) => {
try {
await navigator.clipboard.writeText(text);
// --- ИЗМЕНЕНИЕ: Используем showNotice вместо Snackbar ---
const message =
type === "controller"
? t("Controller address copied to clipboard")
: t("Secret copied to clipboard");
showNotice("success", message);
} catch (err) {
showNotice("error", t("Failed to copy"));
}
},
);
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -83,7 +94,9 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
<div className="space-y-4 py-4">
<div className="grid gap-2">
<Label htmlFor="controller-address">{t("External Controller")}</Label>
<Label htmlFor="controller-address">
{t("External Controller")}
</Label>
<div className="flex items-center gap-2">
<Input
id="controller-address"
@@ -95,11 +108,20 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleCopyToClipboard(controller, "controller")} disabled={isSaving}>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleCopyToClipboard(controller, "controller")
}
disabled={isSaving}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Copy to clipboard")}</p></TooltipContent>
<TooltipContent>
<p>{t("Copy to clipboard")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -118,11 +140,18 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleCopyToClipboard(secret, "secret")} disabled={isSaving}>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopyToClipboard(secret, "secret")}
disabled={isSaving}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Copy to clipboard")}</p></TooltipContent>
<TooltipContent>
<p>{t("Copy to clipboard")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -131,7 +160,9 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Cancel")}</Button>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={onSave} disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}

View File

@@ -34,7 +34,6 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { AlertTriangle, RotateCcw, Code } from "lucide-react";
const DEFAULT_DNS_CONFIG = {
enable: true,
listen: ":53",
@@ -46,15 +45,44 @@ const DEFAULT_DNS_CONFIG = {
"use-hosts": false,
"use-system-hosts": false,
ipv6: true,
"fake-ip-filter": ["*.lan", "*.local", "*.arpa", "time.*.com", "ntp.*.com", "+.market.xiaomi.com", "localhost.ptlogin2.qq.com", "*.msftncsi.com", "www.msftconnecttest.com"],
"default-nameserver": ["system", "223.6.6.6", "8.8.8.8", "2400:3200::1", "2001:4860:4860::8888"],
nameserver: ["8.8.8.8", "https://doh.pub/dns-query", "https://dns.alidns.com/dns-query"],
"fake-ip-filter": [
"*.lan",
"*.local",
"*.arpa",
"time.*.com",
"ntp.*.com",
"+.market.xiaomi.com",
"localhost.ptlogin2.qq.com",
"*.msftncsi.com",
"www.msftconnecttest.com",
],
"default-nameserver": [
"system",
"223.6.6.6",
"8.8.8.8",
"2400:3200::1",
"2001:4860:4860::8888",
],
nameserver: [
"8.8.8.8",
"https://doh.pub/dns-query",
"https://dns.alidns.com/dns-query",
],
fallback: [],
"nameserver-policy": {},
"proxy-server-nameserver": ["https://doh.pub/dns-query", "https://dns.alidns.com/dns-query", "tls://223.5.5.5"],
"proxy-server-nameserver": [
"https://doh.pub/dns-query",
"https://dns.alidns.com/dns-query",
"tls://223.5.5.5",
],
"direct-nameserver": [],
"direct-nameserver-follow-policy": false,
"fallback-filter": { geoip: true, "geoip-code": "CN", ipcidr: ["240.0.0.0/4", "0.0.0.0/32"], domain: ["+.google.com", "+.facebook.com", "+.youtube.com"] },
"fallback-filter": {
geoip: true,
"geoip-code": "CN",
ipcidr: ["240.0.0.0/4", "0.0.0.0/32"],
domain: ["+.google.com", "+.facebook.com", "+.youtube.com"],
},
};
interface Props {
@@ -63,74 +91,193 @@ interface Props {
// Функция-помощник, которая всегда возвращает состояние в правильном формате (со строками)
const formatValues = (config: any = {}): any => {
const dnsConfig = config.dns || {};
const hostsConfig = config.hosts || {};
const formatList = (arr: any[] | undefined = []): string => (arr || []).join(", ");
const formatHosts = (hosts: any): string => !hosts ? "" : Object.entries(hosts).map(([domain, value]) => `${domain}=${Array.isArray(value) ? value.join(';') : value}`).join(", ");
const formatNameserverPolicy = (policy: any): string => !policy ? "" : Object.entries(policy).map(([domain, servers]) => `${domain}=${Array.isArray(servers) ? servers.join(';') : servers}`).join(", ");
const dnsConfig = config.dns || {};
const hostsConfig = config.hosts || {};
const formatList = (arr: any[] | undefined = []): string =>
(arr || []).join(", ");
const formatHosts = (hosts: any): string =>
!hosts
? ""
: Object.entries(hosts)
.map(
([domain, value]) =>
`${domain}=${Array.isArray(value) ? value.join(";") : value}`,
)
.join(", ");
const formatNameserverPolicy = (policy: any): string =>
!policy
? ""
: Object.entries(policy)
.map(
([domain, servers]) =>
`${domain}=${Array.isArray(servers) ? servers.join(";") : servers}`,
)
.join(", ");
const enhancedMode = dnsConfig["enhanced-mode"];
const validEnhancedMode = ["fake-ip", "redir-host"].includes(enhancedMode) ? enhancedMode : DEFAULT_DNS_CONFIG["enhanced-mode"];
const fakeIpFilterMode = dnsConfig["fake-ip-filter-mode"];
const validFakeIpFilterMode = ["blacklist", "whitelist"].includes(fakeIpFilterMode) ? fakeIpFilterMode : DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
const enhancedMode = dnsConfig["enhanced-mode"];
const validEnhancedMode = ["fake-ip", "redir-host"].includes(enhancedMode)
? enhancedMode
: DEFAULT_DNS_CONFIG["enhanced-mode"];
const fakeIpFilterMode = dnsConfig["fake-ip-filter-mode"];
const validFakeIpFilterMode = ["blacklist", "whitelist"].includes(
fakeIpFilterMode,
)
? fakeIpFilterMode
: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
return {
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: formatList(dnsConfig["fake-ip-filter"] ?? DEFAULT_DNS_CONFIG["fake-ip-filter"]),
defaultNameserver: formatList(dnsConfig["default-nameserver"] ?? DEFAULT_DNS_CONFIG["default-nameserver"]),
nameserver: formatList(dnsConfig.nameserver ?? DEFAULT_DNS_CONFIG.nameserver),
fallback: formatList(dnsConfig.fallback ?? DEFAULT_DNS_CONFIG.fallback),
proxyServerNameserver: formatList(dnsConfig["proxy-server-nameserver"] ?? DEFAULT_DNS_CONFIG["proxy-server-nameserver"]),
directNameserver: formatList(dnsConfig["direct-nameserver"] ?? DEFAULT_DNS_CONFIG["direct-nameserver"]),
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: formatList(dnsConfig["fallback-filter"]?.ipcidr) ?? formatList(DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr),
fallbackDomain: formatList(dnsConfig["fallback-filter"]?.domain) ?? formatList(DEFAULT_DNS_CONFIG["fallback-filter"].domain),
nameserverPolicy: formatNameserverPolicy(dnsConfig["nameserver-policy"]) || "",
hosts: formatHosts(hostsConfig) || "",
};
return {
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: formatList(
dnsConfig["fake-ip-filter"] ?? DEFAULT_DNS_CONFIG["fake-ip-filter"],
),
defaultNameserver: formatList(
dnsConfig["default-nameserver"] ??
DEFAULT_DNS_CONFIG["default-nameserver"],
),
nameserver: formatList(
dnsConfig.nameserver ?? DEFAULT_DNS_CONFIG.nameserver,
),
fallback: formatList(dnsConfig.fallback ?? DEFAULT_DNS_CONFIG.fallback),
proxyServerNameserver: formatList(
dnsConfig["proxy-server-nameserver"] ??
DEFAULT_DNS_CONFIG["proxy-server-nameserver"],
),
directNameserver: formatList(
dnsConfig["direct-nameserver"] ?? DEFAULT_DNS_CONFIG["direct-nameserver"],
),
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:
formatList(dnsConfig["fallback-filter"]?.ipcidr) ??
formatList(DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr),
fallbackDomain:
formatList(dnsConfig["fallback-filter"]?.domain) ??
formatList(DEFAULT_DNS_CONFIG["fallback-filter"].domain),
nameserverPolicy:
formatNameserverPolicy(dnsConfig["nameserver-policy"]) || "",
hosts: formatHosts(hostsConfig) || "",
};
};
export const DnsViewer = forwardRef<DialogRef, Props>(({ onSave }, ref) => {
const { t } = useTranslation();
const themeMode = useThemeMode();
const [open, setOpen] = useState(false);
const [visualization, setVisualization] = useState(true);
const [values, setValues] = useState(() => formatValues({ dns: DEFAULT_DNS_CONFIG, hosts: {} }));
const [values, setValues] = useState(() =>
formatValues({ dns: DEFAULT_DNS_CONFIG, hosts: {} }),
);
const [yamlContent, setYamlContent] = useState("");
const [prevData, setPrevData] = useState("");
const parseList = (str: string = ""): string[] => str ? str.split(",").map(s => s.trim()).filter(Boolean) : [];
const parseHosts = (str: string): Record<string, any> => str.split(",").reduce((acc, item) => { const parts = item.trim().split("="); if (parts.length >= 2) { const domain = parts[0].trim(); const valueStr = parts.slice(1).join("=").trim(); acc[domain] = valueStr.includes(";") ? valueStr.split(";").map(s => s.trim()).filter(Boolean) : valueStr; } return acc; }, {} as Record<string, any>);
const parseNameserverPolicy = (str: string): Record<string, any> => str.split(",").reduce((acc, item) => { const parts = item.trim().split("="); if (parts.length >= 2) { const domain = parts[0].trim(); const serversStr = parts.slice(1).join("=").trim(); acc[domain] = serversStr.includes(";") ? serversStr.split(";").map(s => s.trim()).filter(Boolean) : serversStr; } return acc; }, {} as Record<string, any>);
const parseList = (str: string = ""): string[] =>
str
? str
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
const parseHosts = (str: string): Record<string, any> =>
str.split(",").reduce(
(acc, item) => {
const parts = item.trim().split("=");
if (parts.length >= 2) {
const domain = parts[0].trim();
const valueStr = parts.slice(1).join("=").trim();
acc[domain] = valueStr.includes(";")
? valueStr
.split(";")
.map((s) => s.trim())
.filter(Boolean)
: valueStr;
}
return acc;
},
{} as Record<string, any>,
);
const parseNameserverPolicy = (str: string): Record<string, any> =>
str.split(",").reduce(
(acc, item) => {
const parts = item.trim().split("=");
if (parts.length >= 2) {
const domain = parts[0].trim();
const serversStr = parts.slice(1).join("=").trim();
acc[domain] = serversStr.includes(";")
? serversStr
.split(";")
.map((s) => s.trim())
.filter(Boolean)
: serversStr;
}
return acc;
},
{} as Record<string, any>,
);
const generateDnsConfig = () => {
const dnsConfig: any = { enable: values.enable, listen: values.listen, "enhanced-mode": values.enhancedMode, "fake-ip-range": values.fakeIpRange, "fake-ip-filter-mode": values.fakeIpFilterMode, "prefer-h3": values.preferH3, "respect-rules": values.respectRules, "use-hosts": values.useHosts, "use-system-hosts": values.useSystemHosts, ipv6: values.ipv6, "fake-ip-filter": parseList(values.fakeIpFilter), "default-nameserver": parseList(values.defaultNameserver), nameserver: parseList(values.nameserver), "direct-nameserver-follow-policy": values.directNameserverFollowPolicy, "fallback-filter": { geoip: values.fallbackGeoip, "geoip-code": values.fallbackGeoipCode, ipcidr: parseList(values.fallbackIpcidr), domain: parseList(values.fallbackDomain) }};
const dnsConfig: any = {
enable: values.enable,
listen: values.listen,
"enhanced-mode": values.enhancedMode,
"fake-ip-range": values.fakeIpRange,
"fake-ip-filter-mode": values.fakeIpFilterMode,
"prefer-h3": values.preferH3,
"respect-rules": values.respectRules,
"use-hosts": values.useHosts,
"use-system-hosts": values.useSystemHosts,
ipv6: values.ipv6,
"fake-ip-filter": parseList(values.fakeIpFilter),
"default-nameserver": parseList(values.defaultNameserver),
nameserver: parseList(values.nameserver),
"direct-nameserver-follow-policy": values.directNameserverFollowPolicy,
"fallback-filter": {
geoip: values.fallbackGeoip,
"geoip-code": values.fallbackGeoipCode,
ipcidr: parseList(values.fallbackIpcidr),
domain: parseList(values.fallbackDomain),
},
};
if (values.fallback) dnsConfig["fallback"] = parseList(values.fallback);
const policy = parseNameserverPolicy(values.nameserverPolicy);
if (Object.keys(policy).length > 0) dnsConfig["nameserver-policy"] = policy;
if (values.proxyServerNameserver) dnsConfig["proxy-server-nameserver"] = parseList(values.proxyServerNameserver);
if (values.directNameserver) dnsConfig["direct-nameserver"] = parseList(values.directNameserver);
if (values.proxyServerNameserver)
dnsConfig["proxy-server-nameserver"] = parseList(
values.proxyServerNameserver,
);
if (values.directNameserver)
dnsConfig["direct-nameserver"] = parseList(values.directNameserver);
return dnsConfig;
};
const updateYamlFromValues = () => {
const config: Record<string, any> = {};
const dnsConfig = generateDnsConfig();
if (Object.keys(dnsConfig).length > 0) { config.dns = dnsConfig; }
if (Object.keys(dnsConfig).length > 0) {
config.dns = dnsConfig;
}
const hosts = parseHosts(values.hosts);
if (Object.keys(hosts).length > 0) { config.hosts = hosts; }
if (Object.keys(hosts).length > 0) {
config.hosts = hosts;
}
setYamlContent(yaml.dump(config, { forceQuotes: true }));
};
@@ -162,20 +309,30 @@ export const DnsViewer = forwardRef<DialogRef, Props>(({ onSave }, ref) => {
try {
let finalConfig: Record<string, any>;
if (visualization) {
finalConfig = { dns: generateDnsConfig(), hosts: parseHosts(values.hosts) };
finalConfig = {
dns: generateDnsConfig(),
hosts: parseHosts(values.hosts),
};
} else {
const parsed = yaml.load(yamlContent);
if (typeof parsed !== "object" || parsed === null) throw new Error(t("Invalid configuration"));
if (typeof parsed !== "object" || parsed === null)
throw new Error(t("Invalid configuration"));
finalConfig = parsed as Record<string, any>;
}
const currentData = yaml.dump(finalConfig, { forceQuotes: true });
await invoke("save_dns_config", { dnsConfig: finalConfig });
const [isValid, errorMsg] = await invoke<[boolean, string]>("validate_dns_config", {});
const [isValid, errorMsg] = await invoke<[boolean, string]>(
"validate_dns_config",
{},
);
if (!isValid) {
const cleanErrorMsg = errorMsg.split(/msg="([^"]+)"/)[1] || errorMsg;
showNotice("error", t("DNS configuration error") + ": " + cleanErrorMsg);
showNotice(
"error",
t("DNS configuration error") + ": " + cleanErrorMsg,
);
return;
}
@@ -187,21 +344,32 @@ export const DnsViewer = forwardRef<DialogRef, Props>(({ onSave }, ref) => {
}
});
const handleChange = (field: keyof typeof values) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string) => {
const value = typeof e === 'string' ? e : (e.target.type === "checkbox" ? (e.target as any).checked : e.target.value);
setValues((prev: any) => ({ ...prev, [field]: value }));
};
const handleChange =
(field: keyof typeof values) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string) => {
const value =
typeof e === "string"
? e
: e.target.type === "checkbox"
? (e.target as any).checked
: e.target.value;
setValues((prev: any) => ({ ...prev, [field]: value }));
};
const handleSwitchChange = (field: keyof typeof values) => (checked: boolean) => {
setValues((prev: any) => ({ ...prev, [field]: checked }));
};
const handleSwitchChange =
(field: keyof typeof values) => (checked: boolean) => {
setValues((prev: any) => ({ ...prev, [field]: checked }));
};
useEffect(() => {
if (visualization && open) updateYamlFromValues();
}, [values, visualization, open]);
useImperativeHandle(ref, () => ({
open: () => { setOpen(true); initDnsConfig(); },
open: () => {
setOpen(true);
initDnsConfig();
},
close: () => setOpen(false),
}));
@@ -214,8 +382,18 @@ export const DnsViewer = forwardRef<DialogRef, Props>(({ onSave }, ref) => {
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 pr-12">
<DialogTitle>{t("DNS Overwrite")}</DialogTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={resetToDefaults}><RotateCcw className="mr-2 h-4 w-4"/>{t("Reset to Default")}</Button>
<Button variant="secondary" size="sm" onClick={() => setVisualization(prev => !prev)}><Code className="mr-2 h-4 w-4"/>{visualization ? t("Advanced") : t("Visualization")}</Button>
<Button variant="outline" size="sm" onClick={resetToDefaults}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("Reset to Default")}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setVisualization((prev) => !prev)}
>
<Code className="mr-2 h-4 w-4" />
{visualization ? t("Advanced") : t("Visualization")}
</Button>
</div>
</div>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
@@ -224,61 +402,289 @@ export const DnsViewer = forwardRef<DialogRef, Props>(({ onSave }, ref) => {
<div className="flex-1 min-h-0 py-4">
{visualization ? (
<div className="h-full pr-4 -mr-4 space-y-6 overflow-y-auto">
<Alert variant="destructive" className="bg-amber-500/10 border-amber-500/50 text-amber-700 dark:text-amber-400">
<Alert
variant="destructive"
className="bg-amber-500/10 border-amber-500/50 text-amber-700 dark:text-amber-400"
>
<AlertTriangle className="h-4 w-4 !text-amber-500" />
<AlertTitle>{t("Warning")}</AlertTitle>
<AlertDescription>{t("DNS Settings Warning")}</AlertDescription>
</Alert>
<div className="space-y-4">
<h4 className="font-semibold">{t("DNS Settings")}</h4>
<div className="flex items-center justify-between"><Label htmlFor="dns-enable">{t("Enable DNS")}</Label><Switch id="dns-enable" checked={values.enable} onCheckedChange={handleSwitchChange("enable")} /></div>
<div className="grid gap-2"><Label htmlFor="dns-listen">{t("DNS Listen")}</Label><Input id="dns-listen" value={values.listen || ''} onChange={handleChange("listen")} /></div>
<div className="grid gap-2"><Label>{t("Enhanced Mode")}</Label><Select value={values.enhancedMode} onValueChange={handleChange("enhancedMode")}><SelectTrigger><SelectValue/></SelectTrigger><SelectContent><SelectItem value="fake-ip">fake-ip</SelectItem><SelectItem value="redir-host">redir-host</SelectItem></SelectContent></Select></div>
<div className="grid gap-2"><Label htmlFor="dns-fake-ip-range">{t("Fake IP Range")}</Label><Input id="dns-fake-ip-range" value={values.fakeIpRange || ''} onChange={handleChange("fakeIpRange")} /></div>
<div className="grid gap-2"><Label>{t("Fake IP Filter Mode")}</Label><Select value={values.fakeIpFilterMode} onValueChange={handleChange("fakeIpFilterMode")}><SelectTrigger><SelectValue/></SelectTrigger><SelectContent><SelectItem value="blacklist">blacklist</SelectItem><SelectItem value="whitelist">whitelist</SelectItem></SelectContent></Select></div>
<div className="flex items-center justify-between"><Label>{t("IPv6")}</Label><Switch checked={values.ipv6} onCheckedChange={handleSwitchChange("ipv6")} /></div>
<div className="flex items-center justify-between"><Label>{t("Prefer H3")}</Label><Switch checked={values.preferH3} onCheckedChange={handleSwitchChange("preferH3")} /></div>
<div className="flex items-center justify-between"><Label>{t("Respect Rules")}</Label><Switch checked={values.respectRules} onCheckedChange={handleSwitchChange("respectRules")} /></div>
<div className="flex items-center justify-between"><Label>{t("Use Hosts")}</Label><Switch checked={values.useHosts} onCheckedChange={handleSwitchChange("useHosts")} /></div>
<div className="flex items-center justify-between"><Label>{t("Use System Hosts")}</Label><Switch checked={values.useSystemHosts} onCheckedChange={handleSwitchChange("useSystemHosts")} /></div>
<div className="flex items-center justify-between"><Label>{t("Direct Nameserver Follow Policy")}</Label><Switch checked={values.directNameserverFollowPolicy} onCheckedChange={handleSwitchChange("directNameserverFollowPolicy")} /></div>
<div className="grid gap-2"><Label htmlFor="dns-default-nameserver">{t("Default Nameserver")}</Label><Textarea id="dns-default-nameserver" value={values.defaultNameserver || ''} onChange={handleChange("defaultNameserver")} rows={3} /></div>
<div className="grid gap-2"><Label htmlFor="dns-nameserver">{t("Nameserver")}</Label><Textarea id="dns-nameserver" value={values.nameserver || ''} onChange={handleChange("nameserver")} rows={3} /></div>
<div className="grid gap-2"><Label htmlFor="dns-fallback">{t("Fallback")}</Label><Textarea id="dns-fallback" value={values.fallback || ''} onChange={handleChange("fallback")} rows={3} /></div>
<div className="grid gap-2"><Label htmlFor="dns-proxy-server">{t("Proxy Server Nameserver")}</Label><Textarea id="dns-proxy-server" value={values.proxyServerNameserver || ''} onChange={handleChange("proxyServerNameserver")} rows={3} /></div>
<div className="grid gap-2"><Label htmlFor="dns-direct-server">{t("Direct Nameserver")}</Label><Textarea id="dns-direct-server" value={values.directNameserver || ''} onChange={handleChange("directNameserver")} rows={3} /></div>
<div className="grid gap-2"><Label htmlFor="dns-fake-ip-filter">{t("Fake IP Filter")}</Label><Textarea id="dns-fake-ip-filter" value={values.fakeIpFilter || ''} onChange={handleChange("fakeIpFilter")} rows={3} /></div>
<div className="grid gap-2"><Label htmlFor="dns-policy">{t("Nameserver Policy")}</Label><Textarea id="dns-policy" value={values.nameserverPolicy || ''} onChange={handleChange("nameserverPolicy")} rows={3} /></div>
<h4 className="font-semibold">{t("DNS Settings")}</h4>
<div className="flex items-center justify-between">
<Label htmlFor="dns-enable">{t("Enable DNS")}</Label>
<Switch
id="dns-enable"
checked={values.enable}
onCheckedChange={handleSwitchChange("enable")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dns-listen">{t("DNS Listen")}</Label>
<Input
id="dns-listen"
value={values.listen || ""}
onChange={handleChange("listen")}
/>
</div>
<div className="grid gap-2">
<Label>{t("Enhanced Mode")}</Label>
<Select
value={values.enhancedMode}
onValueChange={handleChange("enhancedMode")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fake-ip">fake-ip</SelectItem>
<SelectItem value="redir-host">redir-host</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="dns-fake-ip-range">
{t("Fake IP Range")}
</Label>
<Input
id="dns-fake-ip-range"
value={values.fakeIpRange || ""}
onChange={handleChange("fakeIpRange")}
/>
</div>
<div className="grid gap-2">
<Label>{t("Fake IP Filter Mode")}</Label>
<Select
value={values.fakeIpFilterMode}
onValueChange={handleChange("fakeIpFilterMode")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blacklist">blacklist</SelectItem>
<SelectItem value="whitelist">whitelist</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label>{t("IPv6")}</Label>
<Switch
checked={values.ipv6}
onCheckedChange={handleSwitchChange("ipv6")}
/>
</div>
<div className="flex items-center justify-between">
<Label>{t("Prefer H3")}</Label>
<Switch
checked={values.preferH3}
onCheckedChange={handleSwitchChange("preferH3")}
/>
</div>
<div className="flex items-center justify-between">
<Label>{t("Respect Rules")}</Label>
<Switch
checked={values.respectRules}
onCheckedChange={handleSwitchChange("respectRules")}
/>
</div>
<div className="flex items-center justify-between">
<Label>{t("Use Hosts")}</Label>
<Switch
checked={values.useHosts}
onCheckedChange={handleSwitchChange("useHosts")}
/>
</div>
<div className="flex items-center justify-between">
<Label>{t("Use System Hosts")}</Label>
<Switch
checked={values.useSystemHosts}
onCheckedChange={handleSwitchChange("useSystemHosts")}
/>
</div>
<div className="flex items-center justify-between">
<Label>{t("Direct Nameserver Follow Policy")}</Label>
<Switch
checked={values.directNameserverFollowPolicy}
onCheckedChange={handleSwitchChange(
"directNameserverFollowPolicy",
)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dns-default-nameserver">
{t("Default Nameserver")}
</Label>
<Textarea
id="dns-default-nameserver"
value={values.defaultNameserver || ""}
onChange={handleChange("defaultNameserver")}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dns-nameserver">{t("Nameserver")}</Label>
<Textarea
id="dns-nameserver"
value={values.nameserver || ""}
onChange={handleChange("nameserver")}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dns-fallback">{t("Fallback")}</Label>
<Textarea
id="dns-fallback"
value={values.fallback || ""}
onChange={handleChange("fallback")}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dns-proxy-server">
{t("Proxy Server Nameserver")}
</Label>
<Textarea
id="dns-proxy-server"
value={values.proxyServerNameserver || ""}
onChange={handleChange("proxyServerNameserver")}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dns-direct-server">
{t("Direct Nameserver")}
</Label>
<Textarea
id="dns-direct-server"
value={values.directNameserver || ""}
onChange={handleChange("directNameserver")}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dns-fake-ip-filter">
{t("Fake IP Filter")}
</Label>
<Textarea
id="dns-fake-ip-filter"
value={values.fakeIpFilter || ""}
onChange={handleChange("fakeIpFilter")}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dns-policy">{t("Nameserver Policy")}</Label>
<Textarea
id="dns-policy"
value={values.nameserverPolicy || ""}
onChange={handleChange("nameserverPolicy")}
rows={3}
/>
</div>
</div>
<Separator />
<div className="space-y-4">
<h4 className="font-semibold">{t("Fallback Filter Settings")}</h4>
<div className="flex items-center justify-between"><Label htmlFor="fallback-geoip">{t("GeoIP Filtering")}</Label><Switch id="fallback-geoip" checked={values.fallbackGeoip} onCheckedChange={handleSwitchChange("fallbackGeoip")} /></div>
<div className="grid gap-2"><Label htmlFor="fallback-geoip-code">{t("GeoIP Code")}</Label><Input id="fallback-geoip-code" value={values.fallbackGeoipCode || ''} onChange={handleChange("fallbackGeoipCode")} /></div>
<div className="grid gap-2"><Label htmlFor="fallback-ip-cidr">{t("Fallback IP CIDR")}</Label><Textarea id="fallback-ip-cidr" value={values.fallbackIpcidr || ''} onChange={handleChange("fallbackIpcidr")} rows={3} /></div>
<div className="grid gap-2"><Label htmlFor="fallback-domain">{t("Fallback Domain")}</Label><Textarea id="fallback-domain" value={values.fallbackDomain || ''} onChange={handleChange("fallbackDomain")} rows={3} /></div>
<h4 className="font-semibold">
{t("Fallback Filter Settings")}
</h4>
<div className="flex items-center justify-between">
<Label htmlFor="fallback-geoip">{t("GeoIP Filtering")}</Label>
<Switch
id="fallback-geoip"
checked={values.fallbackGeoip}
onCheckedChange={handleSwitchChange("fallbackGeoip")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="fallback-geoip-code">{t("GeoIP Code")}</Label>
<Input
id="fallback-geoip-code"
value={values.fallbackGeoipCode || ""}
onChange={handleChange("fallbackGeoipCode")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="fallback-ip-cidr">
{t("Fallback IP CIDR")}
</Label>
<Textarea
id="fallback-ip-cidr"
value={values.fallbackIpcidr || ""}
onChange={handleChange("fallbackIpcidr")}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="fallback-domain">
{t("Fallback Domain")}
</Label>
<Textarea
id="fallback-domain"
value={values.fallbackDomain || ""}
onChange={handleChange("fallbackDomain")}
rows={3}
/>
</div>
</div>
<Separator />
<div className="space-y-4">
<h4 className="font-semibold">{t("Hosts Settings")}</h4>
<div className="grid gap-2"><Label htmlFor="hosts-settings">{t("Hosts")}</Label><Textarea id="hosts-settings" value={values.hosts || ''} onChange={handleChange("hosts")} rows={4} /></div>
<h4 className="font-semibold">{t("Hosts Settings")}</h4>
<div className="grid gap-2">
<Label htmlFor="hosts-settings">{t("Hosts")}</Label>
<Textarea
id="hosts-settings"
value={values.hosts || ""}
onChange={handleChange("hosts")}
rows={4}
/>
</div>
</div>
</div>
) : (
<div className="h-full rounded-md border">
<MonacoEditor height="100%" language="yaml" value={yamlContent} theme={themeMode === "light" ? "vs" : "vs-dark"} options={{ tabSize: 2, minimap: { enabled: document.documentElement.clientWidth >= 1500 }, mouseWheelZoom: true, quickSuggestions: { strings: true, comments: true, other: true }, padding: { top: 16 }, fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""}`, fontLigatures: false, smoothScrolling: true }} onChange={(value) => setYamlContent(value || "")} />
<MonacoEditor
height="100%"
language="yaml"
value={yamlContent}
theme={themeMode === "light" ? "vs" : "vs-dark"}
options={{
tabSize: 2,
minimap: {
enabled: document.documentElement.clientWidth >= 1500,
},
mouseWheelZoom: true,
quickSuggestions: {
strings: true,
comments: true,
other: true,
},
padding: { top: 16 },
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""}`,
fontLigatures: false,
smoothScrolling: true,
}}
onChange={(value) => setYamlContent(value || "")}
/>
</div>
)}
</div>
<DialogFooter className="mt-4">
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={handleSave}>{t("Save")}</Button>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={handleSave}>
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -9,7 +9,6 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
interface Props {
value: string[];
onChange: (value: string[]) => void;

View File

@@ -47,7 +47,10 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
verge?.hotkeys?.forEach((text) => {
const [func, key] = text.split(",").map((e) => e.trim());
if (!func || !key) return;
map[func] = key.split("+").map((e) => e.trim()).map((k) => (k === "PLUS" ? "+" : k));
map[func] = key
.split("+")
.map((e) => e.trim())
.map((k) => (k === "PLUS" ? "+" : k));
});
setHotkeyMap(map);
},
@@ -58,7 +61,11 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
const hotkeys = Object.entries(hotkeyMap)
.map(([func, keys]) => {
if (!func || !keys?.length) return "";
const key = keys.map((k) => k.trim()).filter(Boolean).map((k) => (k === "+" ? "PLUS" : k)).join("+");
const key = keys
.map((k) => k.trim())
.filter(Boolean)
.map((k) => (k === "+" ? "PLUS" : k))
.join("+");
if (!key) return "";
return `${func},${key}`;
})
@@ -110,9 +117,13 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Cancel")}</Button>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
<Button type="button" onClick={onSave}>
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,4 +1,10 @@
import { forwardRef, useImperativeHandle, useState, useEffect, useCallback } from "react";
import {
forwardRef,
useImperativeHandle,
useState,
useEffect,
useCallback,
} from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
@@ -14,8 +20,21 @@ import { GuardState } from "./guard-state";
import { copyIconFile, getAppDir } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import getSystem from "@/utils/get-system";
const OS = getSystem();
@@ -28,11 +47,22 @@ const getIcons = async (icon_dir: string, name: string) => {
};
// Наш переиспользуемый компонент для строки настроек
const SettingRow = ({ label, extra, children }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-3 border-b border-border last:border-b-0">
<div className="flex items-center gap-2"><p className="text-sm font-medium">{label}</p>{extra && <div className="text-muted-foreground">{extra}</div>}</div>
<div>{children}</div>
const SettingRow = ({
label,
extra,
children,
}: {
label: React.ReactNode;
extra?: React.ReactNode;
children?: React.ReactNode;
}) => (
<div className="flex items-center justify-between py-3 border-b border-border last:border-b-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label}</p>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
</div>
);
export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
@@ -47,13 +77,22 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
const initIconPath = useCallback(async () => {
const appDir = await getAppDir();
const icon_dir = await join(appDir, "icons");
const { icon_png: common_icon_png, icon_ico: common_icon_ico } = await getIcons(icon_dir, "common");
const { icon_png: sysproxy_icon_png, icon_ico: sysproxy_icon_ico } = await getIcons(icon_dir, "sysproxy");
const { icon_png: tun_icon_png, icon_ico: tun_icon_ico } = await getIcons(icon_dir, "tun");
const { icon_png: common_icon_png, icon_ico: common_icon_ico } =
await getIcons(icon_dir, "common");
const { icon_png: sysproxy_icon_png, icon_ico: sysproxy_icon_ico } =
await getIcons(icon_dir, "sysproxy");
const { icon_png: tun_icon_png, icon_ico: tun_icon_ico } = await getIcons(
icon_dir,
"tun",
);
setCommonIcon(await exists(common_icon_ico) ? common_icon_ico : common_icon_png);
setSysproxyIcon(await exists(sysproxy_icon_ico) ? sysproxy_icon_ico : sysproxy_icon_png);
setTunIcon(await exists(tun_icon_ico) ? tun_icon_ico : tun_icon_png);
setCommonIcon(
(await exists(common_icon_ico)) ? common_icon_ico : common_icon_png,
);
setSysproxyIcon(
(await exists(sysproxy_icon_ico)) ? sysproxy_icon_ico : sysproxy_icon_png,
);
setTunIcon((await exists(tun_icon_ico)) ? tun_icon_ico : tun_icon_png);
}, []);
useEffect(() => {
@@ -73,25 +112,28 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
mutateVerge({ ...verge, ...patch }, false);
};
const handleIconChange = useLockFn(async (type: 'common' | 'sysproxy' | 'tun') => {
const key = `${type}_tray_icon` as keyof IVergeConfig;
if (verge?.[key]) {
const handleIconChange = useLockFn(
async (type: "common" | "sysproxy" | "tun") => {
const key = `${type}_tray_icon` as keyof IVergeConfig;
if (verge?.[key]) {
onChangeData({ [key]: false });
await patchVerge({ [key]: false });
} else {
} else {
const selected = await openDialog({
directory: false, multiple: false,
filters: [{ name: "Tray Icon Image", extensions: ["png", "ico"] }],
directory: false,
multiple: false,
filters: [{ name: "Tray Icon Image", extensions: ["png", "ico"] }],
});
if (selected) {
const path = Array.isArray(selected) ? selected[0] : selected;
await copyIconFile(path, type);
await initIconPath();
onChangeData({ [key]: true });
await patchVerge({ [key]: true });
const path = Array.isArray(selected) ? selected[0] : selected;
await copyIconFile(path, type);
await initIconPath();
onChangeData({ [key]: true });
await patchVerge({ [key]: true });
}
}
});
}
},
);
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -101,99 +143,196 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
</DialogHeader>
<div className="py-4 space-y-1">
<SettingRow label={t("Traffic Graph")}>
<GuardState value={verge?.traffic_graph ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ traffic_graph: e })} onGuard={(e) => patchVerge({ traffic_graph: e })}>
<Switch />
<SettingRow label={t("Traffic Graph")}>
<GuardState
value={verge?.traffic_graph ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ traffic_graph: e })}
onGuard={(e) => patchVerge({ traffic_graph: e })}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={t("Memory Usage")}>
<GuardState
value={verge?.enable_memory_usage ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_memory_usage: e })}
onGuard={(e) => patchVerge({ enable_memory_usage: e })}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={t("Proxy Group Icon")}>
<GuardState
value={verge?.enable_group_icon ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_group_icon: e })}
onGuard={(e) => patchVerge({ enable_group_icon: e })}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow
label={t("Hover Jump Navigator")}
extra={<TooltipIcon tooltip={t("Hover Jump Navigator Info")} />}
>
<GuardState
value={verge?.enable_hover_jump_navigator ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_hover_jump_navigator: e })}
onGuard={(e) => patchVerge({ enable_hover_jump_navigator: e })}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={t("Nav Icon")}>
<GuardState
value={verge?.menu_icon ?? "monochrome"}
onCatch={onError}
onFormat={(v) => v}
onChange={(e) => onChangeData({ menu_icon: e })}
onGuard={(e) => patchVerge({ menu_icon: e })}
>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 1 --- */}
<Select
onValueChange={(value) =>
onChangeData({ menu_icon: value as any })
}
value={verge?.menu_icon}
>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 1 --- */}
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">{t("Monochrome")}</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
<SelectItem value="disable">{t("Disable")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
{OS === "macos" && (
<>
<SettingRow label={t("Tray Icon")}>
<GuardState
value={verge?.tray_icon ?? "monochrome"}
onCatch={onError}
onFormat={(v) => v}
onChange={(e) => onChangeData({ tray_icon: e })}
onGuard={(e) => patchVerge({ tray_icon: e })}
>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 2 --- */}
<Select
onValueChange={(value) =>
onChangeData({ tray_icon: value as any })
}
value={verge?.tray_icon}
>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">
{t("Monochrome")}
</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
</SettingRow>
<SettingRow label={t("Memory Usage")}>
<GuardState value={verge?.enable_memory_usage ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ enable_memory_usage: e })} onGuard={(e) => patchVerge({ enable_memory_usage: e })}>
<Switch />
<SettingRow label={t("Enable Tray Icon")}>
<GuardState
value={verge?.enable_tray_icon ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_tray_icon: e })}
onGuard={(e) => patchVerge({ enable_tray_icon: e })}
>
<Switch />
</GuardState>
</SettingRow>
</SettingRow>
</>
)}
<SettingRow label={t("Proxy Group Icon")}>
<GuardState value={verge?.enable_group_icon ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ enable_group_icon: e })} onGuard={(e) => patchVerge({ enable_group_icon: e })}>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={t("Common Tray Icon")}>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => handleIconChange("common")}
>
{verge?.common_tray_icon && commonIcon && (
<img
src={convertFileSrc(commonIcon)}
className="h-5 mr-2"
alt="common tray icon"
/>
)}
{verge?.common_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
<SettingRow label={t("Hover Jump Navigator")} extra={<TooltipIcon tooltip={t("Hover Jump Navigator Info")} />}>
<GuardState value={verge?.enable_hover_jump_navigator ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ enable_hover_jump_navigator: e })} onGuard={(e) => patchVerge({ enable_hover_jump_navigator: e })}>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={t("System Proxy Tray Icon")}>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => handleIconChange("sysproxy")}
>
{verge?.sysproxy_tray_icon && sysproxyIcon && (
<img
src={convertFileSrc(sysproxyIcon)}
className="h-5 mr-2"
alt="system proxy tray icon"
/>
)}
{verge?.sysproxy_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
<SettingRow label={t("Nav Icon")}>
<GuardState value={verge?.menu_icon ?? "monochrome"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ menu_icon: e })} onGuard={(e) => patchVerge({ menu_icon: e })}>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 1 --- */}
<Select
onValueChange={(value) => onChangeData({ menu_icon: value as any })}
value={verge?.menu_icon}
>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 1 --- */}
<SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">{t("Monochrome")}</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
<SelectItem value="disable">{t("Disable")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
{OS === "macos" && (
<>
<SettingRow label={t("Tray Icon")}>
<GuardState value={verge?.tray_icon ?? "monochrome"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ tray_icon: e })} onGuard={(e) => patchVerge({ tray_icon: e })}>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 2 --- */}
<Select
onValueChange={(value) => onChangeData({ tray_icon: value as any })}
value={verge?.tray_icon}
>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}
<SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">{t("Monochrome")}</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingRow label={t("Enable Tray Icon")}>
<GuardState value={verge?.enable_tray_icon ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ enable_tray_icon: e })} onGuard={(e) => patchVerge({ enable_tray_icon: e })}>
<Switch />
</GuardState>
</SettingRow>
</>
)}
<SettingRow label={t("Common Tray Icon")}>
<Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange('common')}>
{verge?.common_tray_icon && commonIcon && <img src={convertFileSrc(commonIcon)} className="h-5 mr-2" alt="common tray icon" />}
{verge?.common_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
<SettingRow label={t("System Proxy Tray Icon")}>
<Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange('sysproxy')}>
{verge?.sysproxy_tray_icon && sysproxyIcon && <img src={convertFileSrc(sysproxyIcon)} className="h-5 mr-2" alt="system proxy tray icon"/>}
{verge?.sysproxy_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
<SettingRow label={t("Tun Tray Icon")}>
<Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange('tun')}>
{verge?.tun_tray_icon && tunIcon && <img src={convertFileSrc(tunIcon)} className="h-5 mr-2" alt="tun mode tray icon"/>}
{verge?.tun_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
<SettingRow label={t("Tun Tray Icon")}>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => handleIconChange("tun")}
>
{verge?.tun_tray_icon && tunIcon && (
<img
src={convertFileSrc(tunIcon)}
className="h-5 mr-2"
alt="tun mode tray icon"
/>
)}
{verge?.tun_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Close")}</Button></DialogClose>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Close")}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -20,19 +20,25 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
// Наш переиспользуемый компонент для строки настроек
const SettingRow = ({ label, extra, children }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label}</p>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
const SettingRow = ({
label,
extra,
children,
}: {
label: React.ReactNode;
extra?: React.ReactNode;
children?: React.ReactNode;
}) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label}</p>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
</div>
);
export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
@@ -75,57 +81,73 @@ export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
</DialogHeader>
<div className="py-4 space-y-2">
<SettingRow label={t("Enter LightWeight Mode Now")}>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Меняем variant="link" на "outline" для вида кнопки */}
<Button variant="outline" size="sm" onClick={entry_lightweight_mode}>
{t("Enable")}
</Button>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
</SettingRow>
<SettingRow
label={t("Auto Enter LightWeight Mode")}
extra={<TooltipIcon tooltip={t("Auto Enter LightWeight Mode Info")} />}
<SettingRow label={t("Enter LightWeight Mode Now")}>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Меняем variant="link" на "outline" для вида кнопки */}
<Button
variant="outline"
size="sm"
onClick={entry_lightweight_mode}
>
<Switch
checked={values.autoEnterLiteMode}
onCheckedChange={(c) => setValues((v) => ({ ...v, autoEnterLiteMode: c }))}
/>
</SettingRow>
{t("Enable")}
</Button>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
</SettingRow>
{values.autoEnterLiteMode && (
<div className="pl-4">
<SettingRow label={t("Auto Enter LightWeight Mode Delay")}>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-24 h-8"
value={values.autoEnterLiteModeDelay}
onChange={(e) =>
setValues((v) => ({
...v,
autoEnterLiteModeDelay: parseInt(e.target.value) || 1,
}))
}
/>
<span className="text-sm text-muted-foreground">{t("mins")}</span>
</div>
</SettingRow>
<SettingRow
label={t("Auto Enter LightWeight Mode")}
extra={
<TooltipIcon tooltip={t("Auto Enter LightWeight Mode Info")} />
}
>
<Switch
checked={values.autoEnterLiteMode}
onCheckedChange={(c) =>
setValues((v) => ({ ...v, autoEnterLiteMode: c }))
}
/>
</SettingRow>
<p className="text-xs text-muted-foreground italic mt-2">
{t(
"When closing the window, LightWeight Mode will be automatically activated after _n minutes",
{ n: values.autoEnterLiteModeDelay }
)}
</p>
{values.autoEnterLiteMode && (
<div className="pl-4">
<SettingRow label={t("Auto Enter LightWeight Mode Delay")}>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-24 h-8"
value={values.autoEnterLiteModeDelay}
onChange={(e) =>
setValues((v) => ({
...v,
autoEnterLiteModeDelay: parseInt(e.target.value) || 1,
}))
}
/>
<span className="text-sm text-muted-foreground">
{t("mins")}
</span>
</div>
)}
</SettingRow>
<p className="text-xs text-muted-foreground italic mt-2">
{t(
"When closing the window, LightWeight Mode will be automatically activated after _n minutes",
{ n: values.autoEnterLiteModeDelay },
)}
</p>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={onSave}>
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -8,34 +8,71 @@ import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
FileText, Unplug, RefreshCw, Zap, Columns, ArchiveRestore, Link as LinkIcon, Timer
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FileText,
Unplug,
RefreshCw,
Zap,
Columns,
ArchiveRestore,
Link as LinkIcon,
Timer,
} from "lucide-react";
interface Props {}
// Наш переиспользуемый компонент для строки настроек
const SettingRow = ({ label, extra, children }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-3 border-b border-border last:border-b-0">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{label}</div>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
const SettingRow = ({
label,
extra,
children,
}: {
label: React.ReactNode;
extra?: React.ReactNode;
children?: React.ReactNode;
}) => (
<div className="flex items-center justify-between py-3 border-b border-border last:border-b-0">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{label}</div>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
</div>
);
// Вспомогательная функция для создания лейбла с иконкой
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return ( <span className="flex items-center gap-3"><Icon className="h-4 w-4 text-muted-foreground" />{text}</span> );
const LabelWithIcon = ({
icon,
text,
}: {
icon: React.ElementType;
text: string;
}) => {
const Icon = icon;
return (
<span className="flex items-center gap-3">
<Icon className="h-4 w-4 text-muted-foreground" />
{text}
</span>
);
};
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
@@ -89,7 +126,7 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
});
const handleValueChange = (key: keyof typeof values, value: any) => {
setValues(v => ({ ...v, [key]: value }));
setValues((v) => ({ ...v, [key]: value }));
};
return (
@@ -100,42 +137,106 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto px-1 space-y-1">
<SettingRow label={<LabelWithIcon icon={FileText} text={t("App Log Level")} />}>
<Select value={values.appLogLevel} onValueChange={(v) => handleValueChange("appLogLevel", v)}>
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
<SettingRow
label={<LabelWithIcon icon={FileText} text={t("App Log Level")} />}
>
<Select
value={values.appLogLevel}
onValueChange={(v) => handleValueChange("appLogLevel", v)}
>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["trace", "debug", "info", "warn", "error", "silent"].map((i) => (
<SelectItem value={i} key={i}>{i[0].toUpperCase() + i.slice(1).toLowerCase()}</SelectItem>
{["trace", "debug", "info", "warn", "error", "silent"].map(
(i) => (
<SelectItem value={i} key={i}>
{i[0].toUpperCase() + i.slice(1).toLowerCase()}
</SelectItem>
),
)}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={
<LabelWithIcon icon={Unplug} text={t("Auto Close Connections")} />
}
extra={<TooltipIcon tooltip={t("Auto Close Connections Info")} />}
>
<Switch
checked={values.autoCloseConnection}
onCheckedChange={(c) =>
handleValueChange("autoCloseConnection", c)
}
/>
</SettingRow>
<SettingRow
label={
<LabelWithIcon icon={RefreshCw} text={t("Auto Check Update")} />
}
>
<Switch
checked={values.autoCheckUpdate}
onCheckedChange={(c) => handleValueChange("autoCheckUpdate", c)}
/>
</SettingRow>
<SettingRow
label={
<LabelWithIcon icon={Zap} text={t("Enable Builtin Enhanced")} />
}
extra={<TooltipIcon tooltip={t("Enable Builtin Enhanced Info")} />}
>
<Switch
checked={values.enableBuiltinEnhanced}
onCheckedChange={(c) =>
handleValueChange("enableBuiltinEnhanced", c)
}
/>
</SettingRow>
<SettingRow
label={
<LabelWithIcon icon={Columns} text={t("Proxy Layout Columns")} />
}
>
<Select
value={String(values.proxyLayoutColumn)}
onValueChange={(v) =>
handleValueChange("proxyLayoutColumn", Number(v))
}
>
<SelectTrigger className="w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">{t("Auto Columns")}</SelectItem>
{[1, 2, 3, 4, 5].map((i) => (
<SelectItem value={String(i)} key={i}>
{i}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Unplug} text={t("Auto Close Connections")} />} extra={<TooltipIcon tooltip={t("Auto Close Connections Info")} />}>
<Switch checked={values.autoCloseConnection} onCheckedChange={(c) => handleValueChange("autoCloseConnection", c)} />
</SettingRow>
<SettingRow label={<LabelWithIcon icon={RefreshCw} text={t("Auto Check Update")} />}>
<Switch checked={values.autoCheckUpdate} onCheckedChange={(c) => handleValueChange("autoCheckUpdate", c)} />
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Zap} text={t("Enable Builtin Enhanced")} />} extra={<TooltipIcon tooltip={t("Enable Builtin Enhanced Info")} />}>
<Switch checked={values.enableBuiltinEnhanced} onCheckedChange={(c) => handleValueChange("enableBuiltinEnhanced", c)} />
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Columns} text={t("Proxy Layout Columns")} />}>
<Select value={String(values.proxyLayoutColumn)} onValueChange={(v) => handleValueChange("proxyLayoutColumn", Number(v))}>
<SelectTrigger className="w-36 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="6">{t("Auto Columns")}</SelectItem>
{[1, 2, 3, 4, 5].map((i) => (<SelectItem value={String(i)} key={i}>{i}</SelectItem>))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={ArchiveRestore} text={t("Auto Log Clean")} />}>
<Select value={String(values.autoLogClean)} onValueChange={(v) => handleValueChange("autoLogClean", Number(v))}>
<SelectTrigger className="w-48 h-8"><SelectValue /></SelectTrigger>
<SettingRow
label={
<LabelWithIcon icon={ArchiveRestore} text={t("Auto Log Clean")} />
}
>
<Select
value={String(values.autoLogClean)}
onValueChange={(v) =>
handleValueChange("autoLogClean", Number(v))
}
>
<SelectTrigger className="w-48 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
{ key: t("Never Clean"), value: 0 },
@@ -143,37 +244,65 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
{ key: t("Retain _n Days", { n: 7 }), value: 2 },
{ key: t("Retain _n Days", { n: 30 }), value: 3 },
{ key: t("Retain _n Days", { n: 90 }), value: 4 },
].map((i) => (<SelectItem key={i.value} value={String(i.value)}>{i.key}</SelectItem>))}
].map((i) => (
<SelectItem key={i.value} value={String(i.value)}>
{i.key}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={LinkIcon} text={t("Default Latency Test")} />} extra={<TooltipIcon tooltip={t("Default Latency Test Info")} />}>
<SettingRow
label={
<LabelWithIcon icon={LinkIcon} text={t("Default Latency Test")} />
}
extra={<TooltipIcon tooltip={t("Default Latency Test Info")} />}
>
<Input
className="w-75 h-8"
value={values.defaultLatencyTest}
placeholder="https://www.google.com/generate_204"
onChange={(e) => handleValueChange("defaultLatencyTest", e.target.value)}
onChange={(e) =>
handleValueChange("defaultLatencyTest", e.target.value)
}
/>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Timer} text={t("Default Latency Timeout")} />}>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-24 h-8"
value={values.defaultLatencyTimeout}
placeholder="5000"
onChange={(e) => handleValueChange("defaultLatencyTimeout", Number(e.target.value))}
/>
<span className="text-sm text-muted-foreground">{t("millis")}</span>
</div>
<SettingRow
label={
<LabelWithIcon icon={Timer} text={t("Default Latency Timeout")} />
}
>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-24 h-8"
value={values.defaultLatencyTimeout}
placeholder="5000"
onChange={(e) =>
handleValueChange(
"defaultLatencyTimeout",
Number(e.target.value),
)
}
/>
<span className="text-sm text-muted-foreground">
{t("millis")}
</span>
</div>
</SettingRow>
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={onSave}>
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -18,10 +18,14 @@ import {
DialogClose,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Copy } from "lucide-react";
// Дочерний компонент AddressDisplay (без изменений)
const AddressDisplay = (props: { label: string; content: string }) => {
const { t } = useTranslation();
@@ -37,21 +41,27 @@ const AddressDisplay = (props: { label: string; content: string }) => {
<div className="flex items-center gap-2 rounded-md bg-muted px-2 py-1">
<span className="font-mono">{props.content}</span>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleCopy}>
<Copy className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Copy to clipboard")}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleCopy}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Copy to clipboard")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
};
export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -65,7 +75,7 @@ export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
const { data: networkInterfaces } = useSWR(
open ? "clash-verge-rev-internal://network-interfaces" : null,
getNetworkInterfacesInfo,
{ fallbackData: [] }
{ fallbackData: [] },
);
return (
@@ -75,11 +85,25 @@ export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
<div className="flex justify-between items-center pr-12">
<DialogTitle>{t("Network Interface")}</DialogTitle>
<div className="flex items-center rounded-md border bg-muted p-0.5">
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Меняем `secondary` на `default` для активной кнопки */}
<Button variant={isV4 ? "default" : "ghost"} size="sm" className="px-3 text-xs" onClick={() => setIsV4(true)}>IPv4</Button>
<Button variant={!isV4 ? "default" : "ghost"} size="sm" className="px-3 text-xs" onClick={() => setIsV4(false)}>IPv6</Button>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Меняем `secondary` на `default` для активной кнопки */}
<Button
variant={isV4 ? "default" : "ghost"}
size="sm"
className="px-3 text-xs"
onClick={() => setIsV4(true)}
>
IPv4
</Button>
<Button
variant={!isV4 ? "default" : "ghost"}
size="sm"
className="px-3 text-xs"
onClick={() => setIsV4(false)}
>
IPv6
</Button>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
</div>
</div>
</DialogHeader>
@@ -91,25 +115,53 @@ export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
<div>
{isV4 ? (
<>
{item.addr.map((address) => address.V4 && <AddressDisplay key={address.V4.ip} label={t("Ip Address")} content={address.V4.ip} />)}
<AddressDisplay label={t("Mac Address")} content={item.mac_addr ?? ""} />
{item.addr.map(
(address) =>
address.V4 && (
<AddressDisplay
key={address.V4.ip}
label={t("Ip Address")}
content={address.V4.ip}
/>
),
)}
<AddressDisplay
label={t("Mac Address")}
content={item.mac_addr ?? ""}
/>
</>
) : (
<>
{item.addr.map((address) => address.V6 && <AddressDisplay key={address.V6.ip} label={t("Ip Address")} content={address.V6.ip} />)}
<AddressDisplay label={t("Mac Address")} content={item.mac_addr ?? ""} />
{item.addr.map(
(address) =>
address.V6 && (
<AddressDisplay
key={address.V6.ip}
label={t("Ip Address")}
content={address.V6.ip}
/>
),
)}
<AddressDisplay
label={t("Mac Address")}
content={item.mac_addr ?? ""}
/>
</>
)}
</div>
{index < networkInterfaces.length - 1 && <Separator className="mt-2"/>}
{index < networkInterfaces.length - 1 && (
<Separator className="mt-2" />
)}
</div>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Close")}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -15,7 +15,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
interface Props {
// Компонент теперь сам управляет своим состоянием,
// но вызывает onConfirm при подтверждении
@@ -39,7 +38,9 @@ export const PasswordInput = (props: Props) => {
<AlertDialog open={true}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("Please enter your root password")}</AlertDialogTitle>
<AlertDialogTitle>
{t("Please enter your root password")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("This action requires administrator privileges.")}
</AlertDialogDescription>

View File

@@ -14,19 +14,16 @@ interface ListProps {
export const SettingList: React.FC<ListProps> = ({ title, children }) => (
<div>
<h3 className="text-lg font-medium mb-4 px-1">{title}</h3>
<div className="flex flex-col">
{children}
</div>
<div className="flex flex-col">{children}</div>
</div>
);
// --- Новый компонент SettingItem ---
interface ItemProps {
label: ReactNode;
extra?: ReactNode; // Для иконок-подсказок рядом с лейблом
children?: ReactNode; // Для элементов управления (Switch, Select и т.д.)
secondary?: ReactNode; // Для текста-описания под лейблом
extra?: ReactNode; // Для иконок-подсказок рядом с лейблом
children?: ReactNode; // Для элементов управления (Switch, Select и т.д.)
secondary?: ReactNode; // Для текста-описания под лейблом
onClick?: () => void | Promise<any>;
}
@@ -54,7 +51,7 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
className={cn(
"flex items-center justify-between py-4 border-b border-border last:border-b-0",
clickable && "cursor-pointer hover:bg-accent/50 -mx-4 px-4",
isLoading && "cursor-default opacity-70"
isLoading && "cursor-default opacity-70",
)}
>
{/* Левая часть: заголовок и описание */}
@@ -63,7 +60,9 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
<p className="text-sm font-medium">{label}</p>
{extra}
</div>
{secondary && <p className="text-sm text-muted-foreground">{secondary}</p>}
{secondary && (
<p className="text-sm text-muted-foreground">{secondary}</p>
)}
</div>
{/* Правая часть: элемент управления или иконка */}

View File

@@ -1,4 +1,11 @@
import { forwardRef, useImperativeHandle, useState, useMemo, useEffect, ReactNode } from "react";
import {
forwardRef,
useImperativeHandle,
useState,
useMemo,
useEffect,
ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import useSWR, { mutate } from "swr";
@@ -21,12 +28,29 @@ import {
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Check, ChevronsUpDown, Edit, Loader2 } from "lucide-react";
import { cn } from "@root/lib/utils";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
@@ -34,7 +58,8 @@ import { TooltipIcon } from "@/components/base/base-tooltip-icon";
// --- Вся ваша оригинальная логика, константы и хелперы ---
const DEFAULT_PAC = `function FindProxyForURL(url, host) { return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;"; }`;
const ipv4_part = String.raw`\d{1,3}`;
const rDomainSimple = String.raw`(?:[a-z0-9\-\*]+\.|\*)*` + String.raw`(?:\w{2,64}\*?|\*)`;
const rDomainSimple =
String.raw`(?:[a-z0-9\-\*]+\.|\*)*` + String.raw`(?:\w{2,64}\*?|\*)`;
const ipv6_part = "(?:[a-fA-F0-9:])+";
const rLocal = `localhost|<local>|localdomain`;
const getValidReg = (isWindows: boolean) => {
@@ -49,40 +74,78 @@ const getValidReg = (isWindows: boolean) => {
};
// --- Компонент Combobox для замены Autocomplete ---
const Combobox = ({ options, value, onValueChange, placeholder }: { options: string[], value: string, onValueChange: (value: string) => void, placeholder?: string }) => {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="w-48 h-8 justify-between font-normal">
{value || placeholder || "Select..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command onValueChange={onValueChange}>
<CommandInput placeholder="Search or type..." />
<CommandEmpty>No results found.</CommandEmpty>
<CommandList>
{options.map((option) => (
<CommandItem key={option} value={option} onSelect={(currentValue) => { onValueChange(options.find(opt => opt.toLowerCase() === currentValue) || ''); setOpen(false); }}>
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
{option}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
const Combobox = ({
options,
value,
onValueChange,
placeholder,
}: {
options: string[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
}) => {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-48 h-8 justify-between font-normal"
>
{value || placeholder || "Select..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command onValueChange={onValueChange}>
<CommandInput placeholder="Search or type..." />
<CommandEmpty>No results found.</CommandEmpty>
<CommandList>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={(currentValue) => {
onValueChange(
options.find((opt) => opt.toLowerCase() === currentValue) ||
"",
);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
// --- Наш переиспользуемый компонент для строки настроек ---
const SettingRow = ({ label, children }: { label: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-2">
<Label className="text-sm text-muted-foreground flex items-center gap-2">{label}</Label>
<div>{children}</div>
</div>
const SettingRow = ({
label,
children,
}: {
label: React.ReactNode;
children?: React.ReactNode;
}) => (
<div className="flex items-center justify-between py-2">
<Label className="text-sm text-muted-foreground flex items-center gap-2">
{label}
</Label>
<div>{children}</div>
</div>
);
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
@@ -102,25 +165,50 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
type AutoProxy = Awaited<ReturnType<typeof getAutotemProxy>>;
const [autoproxy, setAutoproxy] = useState<AutoProxy>();
const { enable_system_proxy: enabled, proxy_auto_config, pac_file_content, enable_proxy_guard, use_default_bypass, system_proxy_bypass, proxy_guard_duration, proxy_host } = verge ?? {};
const {
enable_system_proxy: enabled,
proxy_auto_config,
pac_file_content,
enable_proxy_guard,
use_default_bypass,
system_proxy_bypass,
proxy_guard_duration,
proxy_host,
} = verge ?? {};
const [value, setValue] = useState({
guard: enable_proxy_guard, bypass: system_proxy_bypass, duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true, pac: proxy_auto_config, pac_content: pac_file_content ?? DEFAULT_PAC,
guard: enable_proxy_guard,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? "127.0.0.1",
});
const defaultBypass = () => {
if (isWindows) return "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;<local>";
if (getSystem() === "linux") return "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,::1";
if (isWindows)
return "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;<local>";
if (getSystem() === "linux")
return "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,::1";
return "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,localhost,*.local,*.crashlytics.com,<local>";
};
const { data: clashConfig } = useSWR("getClashConfig", getClashConfig, { revalidateOnFocus: false, revalidateIfStale: true, dedupingInterval: 1000, errorRetryInterval: 5000 });
const [prevMixedPort, setPrevMixedPort] = useState(clashConfig?.["mixed-port"]);
const { data: clashConfig } = useSWR("getClashConfig", getClashConfig, {
revalidateOnFocus: false,
revalidateIfStale: true,
dedupingInterval: 1000,
errorRetryInterval: 5000,
});
const [prevMixedPort, setPrevMixedPort] = useState(
clashConfig?.["mixed-port"],
);
useEffect(() => {
if (clashConfig?.["mixed-port"] && clashConfig?.["mixed-port"] !== prevMixedPort) {
if (
clashConfig?.["mixed-port"] &&
clashConfig?.["mixed-port"] !== prevMixedPort
) {
setPrevMixedPort(clashConfig?.["mixed-port"]);
resetSystemProxy();
}
@@ -134,9 +222,14 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
await patchVergeConfig({ enable_system_proxy: false });
await new Promise((resolve) => setTimeout(resolve, 200));
await patchVergeConfig({ enable_system_proxy: true });
await Promise.all([ mutate("getSystemProxy"), mutate("getAutotemProxy") ]);
await Promise.all([
mutate("getSystemProxy"),
mutate("getAutotemProxy"),
]);
}
} catch (err: any) { showNotice("error", err.toString()); }
} catch (err: any) {
showNotice("error", err.toString());
}
};
const { systemProxyAddress } = useAppData();
@@ -151,7 +244,13 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
} else {
return systemProxyAddress;
}
}, [value.pac, value.proxy_host, verge?.verge_mixed_port, clashConfig, systemProxyAddress]);
}, [
value.pac,
value.proxy_host,
verge?.verge_mixed_port,
clashConfig,
systemProxyAddress,
]);
const getCurrentPacUrl = useMemo(() => {
const host = value.proxy_host || "127.0.0.1";
@@ -175,7 +274,9 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
if (hostname && hostname !== "localhost" && hostname !== "127.0.0.1") {
hostname = hostname + ".local";
}
} catch (err) { console.error("Failed to get hostname:", err); }
} catch (err) {
console.error("Failed to get hostname:", err);
}
const options = ["127.0.0.1", "localhost"];
if (hostname) options.push(hostname);
options.push(...ipAddresses);
@@ -190,8 +291,12 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
open: () => {
setOpen(true);
setValue({
guard: enable_proxy_guard, bypass: system_proxy_bypass, duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true, pac: proxy_auto_config, pac_content: pac_file_content ?? DEFAULT_PAC,
guard: enable_proxy_guard,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? "127.0.0.1",
});
getSystemProxy().then(setSysproxy);
@@ -340,65 +445,170 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader><DialogTitle>{t("System Proxy Setting")}</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>{t("System Proxy Setting")}</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto space-y-4 py-4 px-1">
<BaseFieldset label={t("Current System Proxy")}>
<div className="text-sm space-y-2">
<div className="flex justify-between"><span className="text-muted-foreground">{t("Enable status")}</span><span>{value.pac ? (autoproxy?.enable ? t("Enabled") : t("Disabled")) : (sysproxy?.enable ? t("Enabled") : t("Disabled"))}</span></div>
{!value.pac && <div className="flex justify-between"><span className="text-muted-foreground">{t("Server Addr")}</span><span className="font-mono">{getSystemProxyAddress}</span></div>}
{value.pac && <div className="flex justify-between"><span className="text-muted-foreground">{t("PAC URL")}</span><span className="font-mono">{getCurrentPacUrl || "-"}</span></div>}
<div className="text-sm space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("Enable status")}
</span>
<span>
{value.pac
? autoproxy?.enable
? t("Enabled")
: t("Disabled")
: sysproxy?.enable
? t("Enabled")
: t("Disabled")}
</span>
</div>
{!value.pac && (
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("Server Addr")}
</span>
<span className="font-mono">{getSystemProxyAddress}</span>
</div>
)}
{value.pac && (
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("PAC URL")}
</span>
<span className="font-mono">{getCurrentPacUrl || "-"}</span>
</div>
)}
</div>
</BaseFieldset>
<SettingRow label={t("Proxy Host")}>
<Combobox options={hostOptions} value={value.proxy_host} onValueChange={(val) => setValue(v => ({...v, proxy_host: val}))} placeholder="127.0.0.1" />
<Combobox
options={hostOptions}
value={value.proxy_host}
onValueChange={(val) =>
setValue((v) => ({ ...v, proxy_host: val }))
}
placeholder="127.0.0.1"
/>
</SettingRow>
<SettingRow label={t("Use PAC Mode")}>
<Switch disabled={!enabled} checked={value.pac} onCheckedChange={(e) => setValue((v) => ({ ...v, pac: e }))} />
<Switch
disabled={!enabled}
checked={value.pac}
onCheckedChange={(e) => setValue((v) => ({ ...v, pac: e }))}
/>
</SettingRow>
<SettingRow label={<>{t("Proxy Guard")} <TooltipIcon tooltip={t("Proxy Guard Info")} /></>}>
<Switch disabled={!enabled} checked={value.guard} onCheckedChange={(e) => setValue((v) => ({ ...v, guard: e }))} />
<SettingRow
label={
<>
{t("Proxy Guard")}{" "}
<TooltipIcon tooltip={t("Proxy Guard Info")} />
</>
}
>
<Switch
disabled={!enabled}
checked={value.guard}
onCheckedChange={(e) => setValue((v) => ({ ...v, guard: e }))}
/>
</SettingRow>
<SettingRow label={t("Guard Duration")}>
<div className="flex items-center gap-2">
<Input disabled={!enabled} type="number" className="w-24 h-8" value={value.duration} onChange={(e) => setValue((v) => ({ ...v, duration: +e.target.value.replace(/\D/, "") }))}/>
<span className="text-sm text-muted-foreground">s</span>
</div>
<div className="flex items-center gap-2">
<Input
disabled={!enabled}
type="number"
className="w-24 h-8"
value={value.duration}
onChange={(e) =>
setValue((v) => ({
...v,
duration: +e.target.value.replace(/\D/, ""),
}))
}
/>
<span className="text-sm text-muted-foreground">s</span>
</div>
</SettingRow>
{!value.pac && (
<SettingRow label={t("Always use Default Bypass")}>
<Switch disabled={!enabled} checked={value.use_default} onCheckedChange={(e) => setValue((v) => ({...v, use_default: e, bypass: !e && !v.bypass ? defaultBypass() : v.bypass}))}/>
</SettingRow>
<SettingRow label={t("Always use Default Bypass")}>
<Switch
disabled={!enabled}
checked={value.use_default}
onCheckedChange={(e) =>
setValue((v) => ({
...v,
use_default: e,
bypass: !e && !v.bypass ? defaultBypass() : v.bypass,
}))
}
/>
</SettingRow>
)}
{!value.pac && !value.use_default && (
<div className="space-y-2">
<Label>{t("Proxy Bypass")}</Label>
<Textarea
id="proxy-bypass"
disabled={!enabled}
rows={4}
value={value.bypass}
onChange={(e) => setValue((v) => ({ ...v, bypass: e.target.value }))}
// Вместо пропса `error` используем условные классы
className={cn(
(value.bypass && !validReg.test(value.bypass)) && "border-destructive focus-visible:ring-destructive"
)}
/>
</div>
<div className="space-y-2">
<Label>{t("Proxy Bypass")}</Label>
<Textarea
id="proxy-bypass"
disabled={!enabled}
rows={4}
value={value.bypass}
onChange={(e) =>
setValue((v) => ({ ...v, bypass: e.target.value }))
}
// Вместо пропса `error` используем условные классы
className={cn(
value.bypass &&
!validReg.test(value.bypass) &&
"border-destructive focus-visible:ring-destructive",
)}
/>
</div>
)}
{value.pac && (
<SettingRow label={t("PAC Script Content")}>
<Button variant="outline" size="sm" onClick={() => setEditorOpen(true)}><Edit className="mr-2 h-4 w-4"/>{t("Edit")} PAC</Button>
</SettingRow>
<SettingRow label={t("PAC Script Content")}>
<Button
variant="outline"
size="sm"
onClick={() => setEditorOpen(true)}
>
<Edit className="mr-2 h-4 w-4" />
{t("Edit")} PAC
</Button>
</SettingRow>
)}
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave} disabled={saving}>{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}{t("Save")}</Button>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={onSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{editorOpen && <EditorViewer open={true} title={`${t("Edit")} PAC`} initialData={Promise.resolve(value.pac_content ?? "")} language="javascript" onSave={(_prev, curr) => { let pac = DEFAULT_PAC; if (curr && curr.trim().length > 0) { pac = curr; } setValue((v) => ({ ...v, pac_content: pac })); }} onClose={() => setEditorOpen(false)} />}
{editorOpen && (
<EditorViewer
open={true}
title={`${t("Edit")} PAC`}
initialData={Promise.resolve(value.pac_content ?? "")}
language="javascript"
onSave={(_prev, curr) => {
let pac = DEFAULT_PAC;
if (curr && curr.trim().length > 0) {
pac = curr;
}
setValue((v) => ({ ...v, pac_content: pac }));
}}
onClose={() => setEditorOpen(false)}
/>
)}
</>
);
});

View File

@@ -1,4 +1,10 @@
import { forwardRef, useImperativeHandle, useState, useEffect, useCallback } from "react";
import {
forwardRef,
useImperativeHandle,
useState,
useEffect,
useCallback,
} from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
@@ -8,18 +14,33 @@ import { DialogRef } from "@/components/base";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Edit } from "lucide-react";
import { useThemeMode } from "@/services/states"; // Наш хук для получения текущего режима
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {HexColorPicker} from "react-colorful";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { HexColorPicker } from "react-colorful";
interface Props {}
const ColorSettingRow = ({ label, value, placeholder, onChange }: {
const ColorSettingRow = ({
label,
value,
placeholder,
onChange,
}: {
label: string;
value: string;
placeholder: string;
@@ -41,7 +62,10 @@ const ColorSettingRow = ({ label, value, placeholder, onChange }: {
/>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 border-0">
<HexColorPicker color={color} onChange={(newColor) => onChange({ target: { value: newColor } })} />
<HexColorPicker
color={color}
onChange={(newColor) => onChange({ target: { value: newColor } })}
/>
</PopoverContent>
</Popover>
<Input
@@ -55,7 +79,6 @@ const ColorSettingRow = ({ label, value, placeholder, onChange }: {
);
};
export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -65,9 +88,12 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
const [theme, setTheme] = useState(theme_setting || {});
const mode = useThemeMode();
const resolvedMode = mode === 'system'
? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: mode;
const resolvedMode =
mode === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: mode;
useImperativeHandle(ref, () => ({
open: () => {
@@ -77,9 +103,10 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
close: () => setOpen(false),
}));
const handleChange = (field: keyof typeof theme) => (e: { target: { value: string } }) => {
setTheme((t) => ({ ...t, [field]: e.target.value }));
};
const handleChange =
(field: keyof typeof theme) => (e: { target: { value: string } }) => {
setTheme((t) => ({ ...t, [field]: e.target.value }));
};
const onSave = useLockFn(async () => {
try {
@@ -92,7 +119,6 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
}
});
const dt = resolvedMode === "light" ? defaultTheme : defaultDarkTheme;
type ThemeKey = keyof typeof theme & keyof typeof defaultTheme;
@@ -128,23 +154,34 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
<div className="flex items-center justify-between py-2">
<Label>{t("Font Family")}</Label>
<Input
className="w-48 h-8"
value={theme.font_family ?? ""}
onChange={handleChange("font_family")}
className="w-48 h-8"
value={theme.font_family ?? ""}
onChange={handleChange("font_family")}
/>
</div>
<div className="flex items-center justify-between py-2">
<Label>{t("CSS Injection")}</Label>
<Button variant="outline" size="sm" onClick={() => setEditorOpen(true)}>
<Edit className="mr-2 h-4 w-4" />{t("Edit")} CSS
<Button
variant="outline"
size="sm"
onClick={() => setEditorOpen(true)}
>
<Edit className="mr-2 h-4 w-4" />
{t("Edit")} CSS
</Button>
</div>
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={onSave}>
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -156,7 +193,7 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
initialData={Promise.resolve(theme.css_injection ?? "")}
language="css"
onSave={(_prev, curr) => {
setTheme(v => ({ ...v, css_injection: curr }));
setTheme((v) => ({ ...v, css_injection: curr }));
}}
onClose={() => setEditorOpen(false)}
/>

View File

@@ -22,21 +22,49 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RotateCcw, Layers, Laptop, Route, RouteOff, Network, Dna, Gauge } from "lucide-react";
import {
RotateCcw,
Layers,
Laptop,
Route,
RouteOff,
Network,
Dna,
Gauge,
} from "lucide-react";
const OS = getSystem();
type StackMode = "mixed" | "gvisor" | "system";
// Компоненты-хелперы
const SettingRow = ({ label, children }: { label: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-3 border-b border-border last:border-b-0">
<div className="flex items-center gap-2"><div className="text-sm font-medium">{label}</div></div>
<div>{children}</div>
const SettingRow = ({
label,
children,
}: {
label: React.ReactNode;
children?: React.ReactNode;
}) => (
<div className="flex items-center justify-between py-3 border-b border-border last:border-b-0">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{label}</div>
</div>
<div>{children}</div>
</div>
);
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return ( <span className="flex items-center gap-3"><Icon className="h-4 w-4 text-muted-foreground" />{text}</span> );
const LabelWithIcon = ({
icon,
text,
}: {
icon: React.ElementType;
text: string;
}) => {
const Icon = icon;
return (
<span className="flex items-center gap-3">
<Icon className="h-4 w-4 text-muted-foreground" />
{text}
</span>
);
};
export const TunViewer = forwardRef<DialogRef>((props, ref) => {
@@ -89,7 +117,12 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
try {
const tun = {
stack: values.stack,
device: values.device === "" ? (OS === "macos" ? "utun1024" : "Mihomo") : values.device,
device:
values.device === ""
? OS === "macos"
? "utun1024"
: "Mihomo"
: values.device,
"auto-route": values.autoRoute,
"auto-detect-interface": values.autoDetectInterface,
"dns-hijack": values.dnsHijack[0] === "" ? [] : values.dnsHijack,
@@ -130,29 +163,90 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
onChange={(value) => setValues((v) => ({ ...v, stack: value }))}
/>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Laptop} text={t("Device")} />}>
<Input className="h-8 w-40" value={values.device} placeholder="Mihomo" onChange={(e) => setValues((v) => ({ ...v, device: e.target.value }))} />
<SettingRow
label={<LabelWithIcon icon={Laptop} text={t("Device")} />}
>
<Input
className="h-8 w-40"
value={values.device}
placeholder="Mihomo"
onChange={(e) =>
setValues((v) => ({ ...v, device: e.target.value }))
}
/>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Route} text={t("Auto Route")} />}>
<Switch checked={values.autoRoute} onCheckedChange={(c) => setValues((v) => ({ ...v, autoRoute: c }))} />
<SettingRow
label={<LabelWithIcon icon={Route} text={t("Auto Route")} />}
>
<Switch
checked={values.autoRoute}
onCheckedChange={(c) =>
setValues((v) => ({ ...v, autoRoute: c }))
}
/>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={RouteOff} text={t("Strict Route")} />}>
<Switch checked={values.strictRoute} onCheckedChange={(c) => setValues((v) => ({ ...v, strictRoute: c }))} />
<SettingRow
label={<LabelWithIcon icon={RouteOff} text={t("Strict Route")} />}
>
<Switch
checked={values.strictRoute}
onCheckedChange={(c) =>
setValues((v) => ({ ...v, strictRoute: c }))
}
/>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Network} text={t("Auto Detect Interface")} />}>
<Switch checked={values.autoDetectInterface} onCheckedChange={(c) => setValues((v) => ({ ...v, autoDetectInterface: c }))} />
<SettingRow
label={
<LabelWithIcon icon={Network} text={t("Auto Detect Interface")} />
}
>
<Switch
checked={values.autoDetectInterface}
onCheckedChange={(c) =>
setValues((v) => ({ ...v, autoDetectInterface: c }))
}
/>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Dna} text={t("DNS Hijack")} />}>
<Input className="h-8 w-40" value={values.dnsHijack.join(",")} placeholder="any:53" onChange={(e) => setValues((v) => ({ ...v, dnsHijack: e.target.value.split(",") }))} />
<SettingRow
label={<LabelWithIcon icon={Dna} text={t("DNS Hijack")} />}
>
<Input
className="h-8 w-40"
value={values.dnsHijack.join(",")}
placeholder="any:53"
onChange={(e) =>
setValues((v) => ({
...v,
dnsHijack: e.target.value.split(","),
}))
}
/>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Gauge} text={t("MTU")} />}>
<Input type="number" className="h-8 w-40" value={values.mtu} placeholder="1500" onChange={(e) => setValues((v) => ({ ...v, mtu: parseInt(e.target.value, 10) || 0 }))} />
<Input
type="number"
className="h-8 w-40"
value={values.mtu}
placeholder="1500"
onChange={(e) =>
setValues((v) => ({
...v,
mtu: parseInt(e.target.value, 10) || 0,
}))
}
/>
</SettingRow>
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={onSave}>
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,5 +1,11 @@
import useSWR from "swr";
import { forwardRef, useImperativeHandle, useState, useMemo, useEffect } from "react";
import {
forwardRef,
useImperativeHandle,
useState,
useMemo,
useEffect,
} from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { relaunch } from "@tauri-apps/plugin-process";
@@ -15,17 +21,24 @@ import { portableFlag } from "@/pages/_layout";
import { useListen } from "@/hooks/use-listen";
import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertTriangle, ExternalLink } from "lucide-react";
export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [currentProgressListener, setCurrentProgressListener] = useState<UnlistenFn | null>(null);
const [currentProgressListener, setCurrentProgressListener] =
useState<UnlistenFn | null>(null);
const updateState = useUpdateState();
const setUpdateState = useSetUpdateState();
@@ -55,9 +68,15 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
}, [updateInfo]);
const onUpdate = useLockFn(async () => {
if (portableFlag) { showNotice("error", t("Portable Updater Error")); return; }
if (portableFlag) {
showNotice("error", t("Portable Updater Error"));
return;
}
if (!updateInfo?.body) return;
if (breakChangeFlag) { showNotice("error", t("Break Change Update Error")); return; }
if (breakChangeFlag) {
showNotice("error", t("Break Change Update Error"));
return;
}
if (updateState) return;
setUpdateState(true);
@@ -66,7 +85,9 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
if (currentProgressListener) currentProgressListener();
const progressListener = await addListener("tauri://update-download-progress", (e: Event<any>) => {
const progressListener = await addListener(
"tauri://update-download-progress",
(e: Event<any>) => {
setTotal(e.payload.contentLength);
setDownloaded((prev) => prev + e.payload.chunkLength);
},
@@ -86,7 +107,9 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
});
useEffect(() => {
return () => { currentProgressListener?.(); };
return () => {
currentProgressListener?.();
};
}, [currentProgressListener]);
const downloadProgress = total > 0 ? (downloaded / total) * 100 : 0;
@@ -96,11 +119,17 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<div className="flex justify-between items-center">
<DialogTitle>{t("New Version")} v{updateInfo?.version}</DialogTitle>
<DialogTitle>
{t("New Version")} v{updateInfo?.version}
</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={() => openUrl(`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`)}
onClick={() =>
openUrl(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
)
}
>
<ExternalLink className="mr-2 h-4 w-4" />
{t("Go to Release Page")}
@@ -110,16 +139,20 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
<div className="max-h-[60vh] overflow-y-auto my-4 pr-6 -mr-6">
{breakChangeFlag && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("Warning")}</AlertTitle>
<AlertDescription>{t("Break Change Warning")}</AlertDescription>
</Alert>
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("Warning")}</AlertTitle>
<AlertDescription>{t("Break Change Warning")}</AlertDescription>
</Alert>
)}
{/* Оборачиваем ReactMarkdown для красивой стилизации */}
<article className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
components={{ a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" /> }}
components={{
a: ({ node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}}
>
{markdownContent}
</ReactMarkdown>
@@ -127,15 +160,25 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
</div>
{updateState && (
<div className="w-full space-y-1">
<Progress value={downloadProgress} />
<p className="text-xs text-muted-foreground text-right">{Math.round(downloadProgress)}%</p>
</div>
<div className="w-full space-y-1">
<Progress value={downloadProgress} />
<p className="text-xs text-muted-foreground text-right">
{Math.round(downloadProgress)}%
</p>
</div>
)}
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onUpdate} disabled={updateState || breakChangeFlag}>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button
type="button"
onClick={onUpdate}
disabled={updateState || breakChangeFlag}
>
{t("Update")}
</Button>
</DialogFooter>

View File

@@ -5,10 +5,14 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Check, X, Trash2, Edit3, ExternalLink } from "lucide-react";
interface Props {
value?: string;
onlyEdit?: boolean;
@@ -26,17 +30,18 @@ const HighlightedUrl = ({ url }: { url: string }) => {
return (
<p className="truncate text-sm" title={url}>
{parts.map((part, index) =>
part.startsWith('%') && part.endsWith('%') ? (
<span key={index} className="font-semibold text-primary">{part}</span>
part.startsWith("%") && part.endsWith("%") ? (
<span key={index} className="font-semibold text-primary">
{part}
</span>
) : (
<span key={index}>{part}</span>
)
),
)}
</p>
);
};
export const WebUIItem = (props: Props) => {
const {
value,
@@ -71,20 +76,31 @@ export const WebUIItem = (props: Props) => {
placeholder={t("Support %host, %port, %secret")}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") handleCancel();
}}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" onClick={handleSave}><Check className="h-4 w-4" /></Button>
<Button size="icon" onClick={handleSave}>
<Check className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Save")}</p></TooltipContent>
<TooltipContent>
<p>{t("Save")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" onClick={handleCancel}><X className="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" onClick={handleCancel}>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Cancel")}</p></TooltipContent>
<TooltipContent>
<p>{t("Cancel")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -96,35 +112,65 @@ export const WebUIItem = (props: Props) => {
// --- Рендер режима просмотра ---
return (
<div className="w-full">
<div className="flex items-center gap-2 mt-1 mb-1 h-10"> {/* h-10 для сохранения высоты */}
<div className="flex items-center gap-2 mt-1 mb-1 h-10">
{" "}
{/* h-10 для сохранения высоты */}
<div className="flex-1 min-w-0">
{value ? <HighlightedUrl url={value} /> : <p className="text-sm text-muted-foreground">NULL</p>}
{value ? (
<HighlightedUrl url={value} />
) : (
<p className="text-sm text-muted-foreground">NULL</p>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onOpenUrl?.(value)}>
<ExternalLink className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Open URL")}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setEditing(true); setEditValue(value); }}>
<Edit3 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Edit")}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Delete")}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onOpenUrl?.(value)}
>
<ExternalLink className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Open URL")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
setEditing(true);
setEditValue(value);
}}
>
<Edit3 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Edit")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Delete")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Separator />

View File

@@ -20,7 +20,6 @@ import {
} from "@/components/ui/dialog";
import { Plus } from "lucide-react";
export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { clashInfo } = useClashInfo();
@@ -71,9 +70,14 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
if (!clashInfo.server?.includes(":")) {
throw new Error(`failed to parse the server "${clashInfo.server}"`);
}
const port = clashInfo.server.slice(clashInfo.server.indexOf(":") + 1).trim();
const port = clashInfo.server
.slice(clashInfo.server.indexOf(":") + 1)
.trim();
url = url.replaceAll("%port", port || "9097");
url = url.replaceAll("%secret", encodeURIComponent(clashInfo.secret || ""));
url = url.replaceAll(
"%secret",
encodeURIComponent(clashInfo.secret || ""),
);
}
await openWebUrl(url);
} catch (e: any) {
@@ -87,7 +91,11 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
<DialogHeader className="pr-7">
<div className="flex justify-between items-center">
<DialogTitle>{t("Web UI")}</DialogTitle>
<Button size="sm" disabled={editing} onClick={() => setEditing(true)}>
<Button
size="sm"
disabled={editing}
onClick={() => setEditing(true)}
>
<Plus className="mr-2 h-4 w-4" />
{t("New")}
</Button>
@@ -96,7 +104,9 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
<div className="max-h-[60vh] overflow-y-auto -mx-6 px-6">
{!editing && webUIList.length === 0 ? (
<div className="h-40"> {/* Задаем высоту для центрирования */}
<div className="h-40">
{" "}
{/* Задаем высоту для центрирования */}
<BaseEmpty
extra={
<p className="mt-2 text-xs text-center">
@@ -130,9 +140,11 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Close")}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -28,8 +28,18 @@ import { GuardState } from "./mods/guard-state";
// Иконки
import {
Settings, Network, Dna, Globe2, Timer, FileText, Plug, RadioTower,
LayoutDashboard, Cog, Repeat, Map as MapIcon
Settings,
Network,
Dna,
Globe2,
Timer,
FileText,
Plug,
RadioTower,
LayoutDashboard,
Cog,
Repeat,
Map as MapIcon,
} from "lucide-react";
// Модальные окна
@@ -47,17 +57,44 @@ interface Props {
}
// Компонент для строки настроек
const SettingRow = ({ label, extra, children, onClick }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; onClick?: () => void; }) => (
<div className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? 'cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md' : ''}`} onClick={onClick}>
<div className="flex items-center gap-2"><div className="text-sm font-medium">{label}</div>{extra && <div className="text-muted-foreground">{extra}</div>}</div>
<div>{children}</div>
const SettingRow = ({
label,
extra,
children,
onClick,
}: {
label: React.ReactNode;
extra?: React.ReactNode;
children?: React.ReactNode;
onClick?: () => void;
}) => (
<div
className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? "cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md" : ""}`}
onClick={onClick}
>
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{label}</div>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
</div>
);
// Вспомогательная функция для создания лейбла с иконкой
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return ( <span className="flex items-center gap-3"><Icon className="h-4 w-4 text-muted-foreground" />{text}</span> );
const LabelWithIcon = ({
icon,
text,
}: {
icon: React.ElementType;
text: string;
}) => {
const Icon = icon;
return (
<span className="flex items-center gap-3">
<Icon className="h-4 w-4 text-muted-foreground" />
{text}
</span>
);
};
const SettingClash = ({ onError }: Props) => {
@@ -131,32 +168,93 @@ const SettingClash = ({ onError }: Props) => {
<NetworkInterfaceViewer ref={networkRef} />
<DnsViewer ref={dnsRef} />
<SettingRow label={<LabelWithIcon icon={Network} text={t("Allow Lan")} />} extra={<TooltipIcon tooltip={t("Network Interface")} icon={<Settings className="h-4 w-4"/>} onClick={() => networkRef.current?.open()} />}>
<GuardState value={allowLan ?? false} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onChange={(e) => onChangeData({ "allow-lan": e })} onGuard={(e) => patchClash({ "allow-lan": e })} onCatch={onError}>
<SettingRow
label={<LabelWithIcon icon={Network} text={t("Allow Lan")} />}
extra={
<TooltipIcon
tooltip={t("Network Interface")}
icon={<Settings className="h-4 w-4" />}
onClick={() => networkRef.current?.open()}
/>
}
>
<GuardState
value={allowLan ?? false}
valueProps="checked"
onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ "allow-lan": e })}
onGuard={(e) => patchClash({ "allow-lan": e })}
onCatch={onError}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Dna} text={t("DNS Overwrite")} />} extra={<TooltipIcon tooltip={t("DNS Settings")} icon={<Settings className="h-4 w-4"/>} onClick={() => dnsRef.current?.open()} />}>
<Switch checked={dnsSettingsEnabled} onCheckedChange={handleDnsToggle} />
<SettingRow
label={<LabelWithIcon icon={Dna} text={t("DNS Overwrite")} />}
extra={
<TooltipIcon
tooltip={t("DNS Settings")}
icon={<Settings className="h-4 w-4" />}
onClick={() => dnsRef.current?.open()}
/>
}
>
<Switch
checked={dnsSettingsEnabled}
onCheckedChange={handleDnsToggle}
/>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Globe2} text={t("IPv6")} />}>
<GuardState value={ipv6 ?? false} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onChange={(e) => onChangeData({ ipv6: e })} onGuard={(e) => patchClash({ ipv6: e })} onCatch={onError}>
<GuardState
value={ipv6 ?? false}
valueProps="checked"
onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ ipv6: e })}
onGuard={(e) => patchClash({ ipv6: e })}
onCatch={onError}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Timer} text={t("Unified Delay")} />} extra={<TooltipIcon tooltip={t("Unified Delay Info")} />}>
<GuardState value={unifiedDelay ?? false} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onChange={(e) => onChangeData({ "unified-delay": e })} onGuard={(e) => patchClash({ "unified-delay": e })} onCatch={onError}>
<SettingRow
label={<LabelWithIcon icon={Timer} text={t("Unified Delay")} />}
extra={<TooltipIcon tooltip={t("Unified Delay Info")} />}
>
<GuardState
value={unifiedDelay ?? false}
valueProps="checked"
onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ "unified-delay": e })}
onGuard={(e) => patchClash({ "unified-delay": e })}
onCatch={onError}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={FileText} text={t("Log Level")} />} extra={<TooltipIcon tooltip={t("Log Level Info")} />}>
<GuardState value={logLevel ?? "info"} valueProps="value" onChangeProps="onValueChange" onFormat={onSelectFormat} onChange={(e) => onChangeData({ "log-level": e })} onGuard={(e) => patchClash({ "log-level": e })} onCatch={onError}>
<SettingRow
label={<LabelWithIcon icon={FileText} text={t("Log Level")} />}
extra={<TooltipIcon tooltip={t("Log Level Info")} />}
>
<GuardState
value={logLevel ?? "info"}
valueProps="value"
onChangeProps="onValueChange"
onFormat={onSelectFormat}
onChange={(e) => onChangeData({ "log-level": e })}
onGuard={(e) => patchClash({ "log-level": e })}
onCatch={onError}
>
<Select value={logLevel}>
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
@@ -168,21 +266,67 @@ const SettingClash = ({ onError }: Props) => {
</GuardState>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Plug} text={t("Port Config")} />}>
<Button variant="outline" className="w-28 h-8 font-mono" onClick={() => portRef.current?.open()}>{verge_mixed_port ?? 7897}</Button>
<SettingRow
label={<LabelWithIcon icon={Plug} text={t("Port Config")} />}
>
<Button
variant="outline"
className="w-28 h-8 font-mono"
onClick={() => portRef.current?.open()}
>
{verge_mixed_port ?? 7897}
</Button>
</SettingRow>
<SettingRow onClick={() => ctrlRef.current?.open()} label={<div className="flex items-center gap-3"><RadioTower className="h-4 w-4 text-muted-foreground" />{t("External Controller")} <TooltipIcon tooltip={t("Enable one-click random API port and key. Click to randomize the port and key")} /></div>} />
<SettingRow
onClick={() => ctrlRef.current?.open()}
label={
<div className="flex items-center gap-3">
<RadioTower className="h-4 w-4 text-muted-foreground" />
{t("External Controller")}{" "}
<TooltipIcon
tooltip={t(
"Enable one-click random API port and key. Click to randomize the port and key",
)}
/>
</div>
}
/>
<SettingRow onClick={() => webRef.current?.open()} label={<LabelWithIcon icon={LayoutDashboard} text={t("Yacd Web UI")} />} />
<SettingRow
onClick={() => webRef.current?.open()}
label={
<LabelWithIcon icon={LayoutDashboard} text={t("Yacd Web UI")} />
}
/>
<SettingRow label={<LabelWithIcon icon={Cog} text={t("Clash Core")} />} extra={<TooltipIcon tooltip={t("Clash Core Settings")} icon={<Settings className="h-4 w-4"/>} onClick={() => coreRef.current?.open()} />}>
<SettingRow
label={<LabelWithIcon icon={Cog} text={t("Clash Core")} />}
extra={
<TooltipIcon
tooltip={t("Clash Core Settings")}
icon={<Settings className="h-4 w-4" />}
onClick={() => coreRef.current?.open()}
/>
}
>
<p className="text-sm font-medium pr-2 font-mono">{version}</p>
</SettingRow>
{isWIN && <SettingRow onClick={useLockFn(invoke_uwp_tool)} label={<LabelWithIcon icon={Repeat} text={t("UWP Loopback Tool")} />} extra={<TooltipIcon tooltip={t("Open UWP tool Info")} />} />}
{isWIN && (
<SettingRow
onClick={useLockFn(invoke_uwp_tool)}
label={
<LabelWithIcon icon={Repeat} text={t("UWP Loopback Tool")} />
}
extra={<TooltipIcon tooltip={t("Open UWP tool Info")} />}
/>
)}
<SettingRow onClick={onUpdateGeo} label={<LabelWithIcon icon={MapIcon} text={t("Update GeoData")} />} />
<SettingRow
onClick={onUpdateGeo}
label={<LabelWithIcon icon={MapIcon} text={t("Update GeoData")} />}
/>
</div>
</div>
);

View File

@@ -46,7 +46,6 @@ import { UpdateViewer } from "./mods/update-viewer";
import { BackupViewer } from "./mods/backup-viewer";
import { LiteModeViewer } from "./mods/lite-mode-viewer";
interface Props {
onError?: (err: Error) => void;
}
@@ -63,22 +62,19 @@ const SettingRow = ({
children?: React.ReactNode;
onClick?: () => void;
}) => (
<div
className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? 'cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md' : ''}`}
onClick={onClick}
>
<div className="flex items-center gap-2">
{/* Мы ожидаем, что label теперь может быть сложным компонентом */}
<div className="text-sm font-medium">{label}</div>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>
{children}
</div>
<div
className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? "cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md" : ""}`}
onClick={onClick}
>
<div className="flex items-center gap-2">
{/* Мы ожидаем, что label теперь может быть сложным компонентом */}
<div className="text-sm font-medium">{label}</div>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
</div>
);
const SettingVergeAdvanced = ({ onError }: Props) => {
const { t } = useTranslation();
@@ -110,7 +106,13 @@ const SettingVergeAdvanced = ({ onError }: Props) => {
}, []);
// Вспомогательная функция для создания лейбла с иконкой
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const LabelWithIcon = ({
icon,
text,
}: {
icon: React.ElementType;
text: string;
}) => {
const Icon = icon;
return (
<span className="flex items-center gap-3">
@@ -122,7 +124,9 @@ const SettingVergeAdvanced = ({ onError }: Props) => {
return (
<div>
<h3 className="text-lg font-medium mb-4">{t("Verge Advanced Setting")}</h3>
<h3 className="text-lg font-medium mb-4">
{t("Verge Advanced Setting")}
</h3>
<div className="space-y-1">
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
@@ -134,21 +138,71 @@ const SettingVergeAdvanced = ({ onError }: Props) => {
<LiteModeViewer ref={liteModeRef} />
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 2: Добавляем иконки к каждому пункту --- */}
<SettingRow onClick={() => backupRef.current?.open()} label={<LabelWithIcon icon={Archive} text={t("Backup Setting")} />} extra={<TooltipIcon tooltip={t("Backup Setting Info")} />} />
<SettingRow onClick={() => configRef.current?.open()} label={<LabelWithIcon icon={FileCode} text={t("Runtime Config")} />} />
<SettingRow onClick={openAppDir} label={<LabelWithIcon icon={Folder} text={t("Open Conf Dir")} />} extra={<TooltipIcon tooltip={t("Open Conf Dir Info")} />} />
<SettingRow onClick={openCoreDir} label={<LabelWithIcon icon={FolderCog} text={t("Open Core Dir")} />} />
<SettingRow onClick={openLogsDir} label={<LabelWithIcon icon={FolderClock} text={t("Open Logs Dir")} />} />
<SettingRow onClick={onCheckUpdate} label={<LabelWithIcon icon={RefreshCw} text={t("Check for Updates")} />} />
<SettingRow onClick={openDevTools} label={<LabelWithIcon icon={Terminal} text={t("Open Dev Tools")} />} />
<SettingRow label={<LabelWithIcon icon={Feather} text={t("LightWeight Mode Settings")} />} extra={<TooltipIcon tooltip={t("LightWeight Mode Info")} />} onClick={() => liteModeRef.current?.open()} />
<SettingRow onClick={exitApp} label={<LabelWithIcon icon={LogOut} text={t("Exit")} />} />
<SettingRow
onClick={() => backupRef.current?.open()}
label={<LabelWithIcon icon={Archive} text={t("Backup Setting")} />}
extra={<TooltipIcon tooltip={t("Backup Setting Info")} />}
/>
<SettingRow
onClick={() => configRef.current?.open()}
label={<LabelWithIcon icon={FileCode} text={t("Runtime Config")} />}
/>
<SettingRow
onClick={openAppDir}
label={<LabelWithIcon icon={Folder} text={t("Open Conf Dir")} />}
extra={<TooltipIcon tooltip={t("Open Conf Dir Info")} />}
/>
<SettingRow
onClick={openCoreDir}
label={<LabelWithIcon icon={FolderCog} text={t("Open Core Dir")} />}
/>
<SettingRow
onClick={openLogsDir}
label={<LabelWithIcon icon={FolderClock} text={t("Open Logs Dir")} />}
/>
<SettingRow
onClick={onCheckUpdate}
label={
<LabelWithIcon icon={RefreshCw} text={t("Check for Updates")} />
}
/>
<SettingRow
onClick={openDevTools}
label={<LabelWithIcon icon={Terminal} text={t("Open Dev Tools")} />}
/>
<SettingRow
label={
<LabelWithIcon
icon={Feather}
text={t("LightWeight Mode Settings")}
/>
}
extra={<TooltipIcon tooltip={t("LightWeight Mode Info")} />}
onClick={() => liteModeRef.current?.open()}
/>
<SettingRow
onClick={exitApp}
label={<LabelWithIcon icon={LogOut} text={t("Exit")} />}
/>
<SettingRow label={<LabelWithIcon icon={ClipboardList} text={t("Export Diagnostic Info")} />}>
<TooltipIcon tooltip={t("Copy")} icon={<Copy className="h-4 w-4"/>} onClick={onExportDiagnosticInfo} />
<SettingRow
label={
<LabelWithIcon
icon={ClipboardList}
text={t("Export Diagnostic Info")}
/>
}
>
<TooltipIcon
tooltip={t("Copy")}
icon={<Copy className="h-4 w-4" />}
onClick={onExportDiagnosticInfo}
/>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Info} text={t("Verge Version")} />}>
<SettingRow
label={<LabelWithIcon icon={Info} text={t("Verge Version")} />}
>
<p className="text-sm font-medium pr-2 font-mono">v{version}</p>
</SettingRow>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}

View File

@@ -12,13 +12,28 @@ import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GuardState } from "./mods/guard-state";
import { ThemeModeSwitch } from "./mods/theme-mode-switch"; // Импортируем наш новый компонент
import {
Copy, Languages, Palette, MousePointerClick, Terminal, Home, FileTerminal,
SwatchBook, LayoutTemplate, Sparkles, Keyboard
Copy,
Languages,
Palette,
MousePointerClick,
Terminal,
Home,
FileTerminal,
SwatchBook,
LayoutTemplate,
Sparkles,
Keyboard,
} from "lucide-react";
import { ConfigViewer } from "./mods/config-viewer";
@@ -37,28 +52,69 @@ const OS = getSystem();
const languageOptions = Object.entries(languages).map(([code, _]) => {
const labels: { [key: string]: string } = {
en: "English", ru: "Русский", zh: "中文", fa: "فارسی", tt: "Татар", id: "Bahasa Indonesia",
ar: "العربية", ko: "한국어", tr: "Türkçe",
en: "English",
ru: "Русский",
zh: "中文",
fa: "فارسی",
tt: "Татар",
id: "Bahasa Indonesia",
ar: "العربية",
ko: "한국어",
tr: "Türkçe",
};
return { code, label: labels[code] || code };
});
const SettingRow = ({ label, extra, children, onClick }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; onClick?: () => void; }) => (
<div className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? 'cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md' : ''}`} onClick={onClick}>
<div className="flex items-center gap-2"><div className="text-sm font-medium">{label}</div>{extra && <div className="text-muted-foreground">{extra}</div>}</div>
<div>{children}</div>
const SettingRow = ({
label,
extra,
children,
onClick,
}: {
label: React.ReactNode;
extra?: React.ReactNode;
children?: React.ReactNode;
onClick?: () => void;
}) => (
<div
className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? "cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md" : ""}`}
onClick={onClick}
>
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{label}</div>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
</div>
);
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return ( <span className="flex items-center gap-3"><Icon className="h-4 w-4 text-muted-foreground" />{text}</span> );
const LabelWithIcon = ({
icon,
text,
}: {
icon: React.ElementType;
text: string;
}) => {
const Icon = icon;
return (
<span className="flex items-center gap-3">
<Icon className="h-4 w-4 text-muted-foreground" />
{text}
</span>
);
};
const SettingVergeBasic = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const { theme_mode, language, tray_event, env_type, startup_script, start_page } = verge ?? {};
const {
theme_mode,
language,
tray_event,
env_type,
startup_script,
start_page,
} = verge ?? {};
const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null);
@@ -91,7 +147,9 @@ const SettingVergeBasic = ({ onError }: Props) => {
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<SettingRow label={<LabelWithIcon icon={Languages} text={t("Language")} />}>
<SettingRow
label={<LabelWithIcon icon={Languages} text={t("Language")} />}
>
<GuardState
value={language ?? "en"}
onCatch={onError}
@@ -101,90 +159,204 @@ const SettingVergeBasic = ({ onError }: Props) => {
onGuard={(e) => patchVerge({ language: e })}
>
<Select>
<SelectTrigger className="w-32 h-8"><SelectValue /></SelectTrigger>
<SelectContent>{languageOptions.map(({ code, label }) => (<SelectItem key={code} value={code}>{label}</SelectItem>))}</SelectContent>
<SelectTrigger className="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languageOptions.map(({ code, label }) => (
<SelectItem key={code} value={code}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Palette} text={t("Theme Mode")} />}>
<GuardState
value={theme_mode}
onCatch={onError}
onChange={(e) => onChangeData({ theme_mode: e })}
onGuard={(e) => patchVerge({ theme_mode: e })}
>
<ThemeModeSwitch />
</GuardState>
<SettingRow
label={<LabelWithIcon icon={Palette} text={t("Theme Mode")} />}
>
<GuardState
value={theme_mode}
onCatch={onError}
onChange={(e) => onChangeData({ theme_mode: e })}
onGuard={(e) => patchVerge({ theme_mode: e })}
>
<ThemeModeSwitch />
</GuardState>
</SettingRow>
{OS !== "linux" && (
<SettingRow label={<LabelWithIcon icon={MousePointerClick} text={t("Tray Click Event")} />}>
<GuardState value={tray_event ?? "main_window"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ tray_event: e })} onGuard={(e) => patchVerge({ tray_event: e })}>
<Select onValueChange={(value) => onChangeData({ tray_event: value })} value={tray_event}>
<SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="main_window">{t("Show Main Window")}</SelectItem>
<SelectItem value="tray_menu">{t("Show Tray Menu")}</SelectItem>
<SelectItem value="system_proxy">{t("System Proxy")}</SelectItem>
<SelectItem value="tun_mode">{t("Tun Mode")}</SelectItem>
<SelectItem value="disable">{t("Disable")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingRow
label={
<LabelWithIcon
icon={MousePointerClick}
text={t("Tray Click Event")}
/>
}
>
<GuardState
value={tray_event ?? "main_window"}
onCatch={onError}
onFormat={(v) => v}
onChange={(e) => onChangeData({ tray_event: e })}
onGuard={(e) => patchVerge({ tray_event: e })}
>
<Select
onValueChange={(value) => onChangeData({ tray_event: value })}
value={tray_event}
>
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="main_window">
{t("Show Main Window")}
</SelectItem>
<SelectItem value="tray_menu">
{t("Show Tray Menu")}
</SelectItem>
<SelectItem value="system_proxy">
{t("System Proxy")}
</SelectItem>
<SelectItem value="tun_mode">{t("Tun Mode")}</SelectItem>
<SelectItem value="disable">{t("Disable")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
)}
<SettingRow label={<LabelWithIcon icon={Copy} text={t("Copy Env Type")} />} extra={<TooltipIcon tooltip={t("Copy")} icon={<Copy className="h-4 w-4"/>} onClick={onCopyClashEnv} />}>
<GuardState value={env_type ?? (OS === "windows" ? "powershell" : "bash")} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ env_type: e })} onGuard={(e) => patchVerge({ env_type: e })}>
<Select onValueChange={(value) => onChangeData({ env_type: value })} value={env_type}>
<SelectTrigger className="w-36 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="fish">Fish</SelectItem>
<SelectItem value="nushell">Nushell</SelectItem>
<SelectItem value="cmd">CMD</SelectItem>
<SelectItem value="powershell">PowerShell</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Home} text={t("Start Page")} />}>
<GuardState
value={start_page ?? "/"}
onCatch={onError}
onChangeProps="onValueChange"
onFormat={(value: string) => value}
onChange={(e) => onChangeData({ start_page: e })}
onGuard={(e) => patchVerge({ start_page: e })}
<SettingRow
label={<LabelWithIcon icon={Copy} text={t("Copy Env Type")} />}
extra={
<TooltipIcon
tooltip={t("Copy")}
icon={<Copy className="h-4 w-4" />}
onClick={onCopyClashEnv}
/>
}
>
<GuardState
value={env_type ?? (OS === "windows" ? "powershell" : "bash")}
onCatch={onError}
onFormat={(v) => v}
onChange={(e) => onChangeData({ env_type: e })}
onGuard={(e) => patchVerge({ env_type: e })}
>
<Select
onValueChange={(value) => onChangeData({ env_type: value })}
value={env_type}
>
<Select>
<SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{routers
.filter(page => page.label && page.path !== '/')
.map(page => (
<SelectItem key={page.path} value={page.path}>{t(page.label!)}</SelectItem>
))
}
</SelectContent>
</Select>
</GuardState>
<SelectTrigger className="w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="fish">Fish</SelectItem>
<SelectItem value="nushell">Nushell</SelectItem>
<SelectItem value="cmd">CMD</SelectItem>
<SelectItem value="powershell">PowerShell</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={FileTerminal} text={t("Startup Script")} />}>
<div className="flex items-center gap-2">
<Input readOnly value={startup_script ?? ""} placeholder={t("Not Set")} className="h-8 flex-1" />
<Button variant="outline" size="sm" className="h-8" onClick={async () => { const selected = await open({ directory: false, multiple: false, filters: [{ name: "Shell Script", extensions: ["sh", "bat", "ps1"] }] }); if (selected) { const path = Array.isArray(selected) ? selected[0] : selected; onChangeData({ startup_script: path }); patchVerge({ startup_script: path }); } }}>{t("Browse")}</Button>
{startup_script && <Button variant="destructive" size="sm" className="h-8" onClick={async () => { onChangeData({ startup_script: "" }); patchVerge({ startup_script: "" }); }}>{t("Clear")}</Button>}
</div>
<SettingRow
label={<LabelWithIcon icon={Home} text={t("Start Page")} />}
>
<GuardState
value={start_page ?? "/"}
onCatch={onError}
onChangeProps="onValueChange"
onFormat={(value: string) => value}
onChange={(e) => onChangeData({ start_page: e })}
onGuard={(e) => patchVerge({ start_page: e })}
>
<Select>
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{routers
.filter((page) => page.label && page.path !== "/")
.map((page) => (
<SelectItem key={page.path} value={page.path}>
{t(page.label!)}
</SelectItem>
))}
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingRow onClick={() => themeRef.current?.open()} label={<LabelWithIcon icon={SwatchBook} text={t("Theme Setting")} />} />
<SettingRow onClick={() => layoutRef.current?.open()} label={<LabelWithIcon icon={LayoutTemplate} text={t("Layout Setting")} />} />
<SettingRow onClick={() => miscRef.current?.open()} label={<LabelWithIcon icon={Sparkles} text={t("Miscellaneous")} />} />
<SettingRow onClick={() => hotkeyRef.current?.open()} label={<LabelWithIcon icon={Keyboard} text={t("Hotkey Setting")} />} />
<SettingRow
label={
<LabelWithIcon icon={FileTerminal} text={t("Startup Script")} />
}
>
<div className="flex items-center gap-2">
<Input
readOnly
value={startup_script ?? ""}
placeholder={t("Not Set")}
className="h-8 flex-1"
/>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={async () => {
const selected = await open({
directory: false,
multiple: false,
filters: [
{ name: "Shell Script", extensions: ["sh", "bat", "ps1"] },
],
});
if (selected) {
const path = Array.isArray(selected) ? selected[0] : selected;
onChangeData({ startup_script: path });
patchVerge({ startup_script: path });
}
}}
>
{t("Browse")}
</Button>
{startup_script && (
<Button
variant="destructive"
size="sm"
className="h-8"
onClick={async () => {
onChangeData({ startup_script: "" });
patchVerge({ startup_script: "" });
}}
>
{t("Clear")}
</Button>
)}
</div>
</SettingRow>
<SettingRow
onClick={() => themeRef.current?.open()}
label={<LabelWithIcon icon={SwatchBook} text={t("Theme Setting")} />}
/>
<SettingRow
onClick={() => layoutRef.current?.open()}
label={
<LabelWithIcon icon={LayoutTemplate} text={t("Layout Setting")} />
}
/>
<SettingRow
onClick={() => miscRef.current?.open()}
label={<LabelWithIcon icon={Sparkles} text={t("Miscellaneous")} />}
/>
<SettingRow
onClick={() => hotkeyRef.current?.open()}
label={<LabelWithIcon icon={Keyboard} text={t("Hotkey Setting")} />}
/>
</div>
</div>
);

View File

@@ -11,7 +11,12 @@ import { cn } from "@root/lib/utils";
// Новые импорты
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Switch } from "@/components/base";
import { DialogRef } from "@/components/base";
import { GuardState } from "@/components/setting/mods/guard-state";
@@ -40,7 +45,8 @@ const ProxyControlSwitches = ({ label, onError }: ProxySwitchProps) => {
const isSystemProxyMode = label === t("System Proxy") || !label;
const isTunMode = label === t("Tun Mode");
const onChangeData = (patch: Partial<IVergeConfig>) => mutateVerge({ ...verge, ...patch }, false);
const onChangeData = (patch: Partial<IVergeConfig>) =>
mutateVerge({ ...verge, ...patch }, false);
const onInstallService = installServiceAndRestartCore;
return (
@@ -48,20 +54,54 @@ const ProxyControlSwitches = ({ label, onError }: ProxySwitchProps) => {
<div className="space-y-2">
{/* Системный прокси */}
{isSystemProxyMode && (
<div className={cn("flex items-center justify-between p-2 rounded-lg transition-colors", enable_system_proxy && "bg-green-500/10")}>
<div
className={cn(
"flex items-center justify-between p-2 rounded-lg transition-colors",
enable_system_proxy && "bg-green-500/10",
)}
>
<div className="flex items-center gap-3">
{enable_system_proxy ? <PlayCircle className="h-7 w-7 text-green-600" /> : <PauseCircle className="h-7 w-7 text-muted-foreground" />}
{enable_system_proxy ? (
<PlayCircle className="h-7 w-7 text-green-600" />
) : (
<PauseCircle className="h-7 w-7 text-muted-foreground" />
)}
<div>
<p className="font-semibold text-sm">{t("System Proxy")}</p>
<p className="text-xs text-muted-foreground">{t("Enable this for most users")}</p>
<p className="text-xs text-muted-foreground">
{t("Enable this for most users")}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => sysproxyRef.current?.open()}><Settings className="h-4 w-4" /></Button></TooltipTrigger>
<TooltipContent><p>{t("System Proxy Info")}</p></TooltipContent>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => sysproxyRef.current?.open()}
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("System Proxy Info")}</p>
</TooltipContent>
</Tooltip>
<GuardState value={enable_system_proxy ?? false} valueProps="checked" onCatch={onError} onFormat={(e) => e} onChange={(e) => onChangeData({ enable_system_proxy: e })} onGuard={async (e) => { if (!e && verge?.auto_close_connection) { closeAllConnections(); } await patchVerge({ enable_system_proxy: e }); }}>
<GuardState
value={enable_system_proxy ?? false}
valueProps="checked"
onCatch={onError}
onFormat={(e) => e}
onChange={(e) => onChangeData({ enable_system_proxy: e })}
onGuard={async (e) => {
if (!e && verge?.auto_close_connection) {
closeAllConnections();
}
await patchVerge({ enable_system_proxy: e });
}}
>
<Switch />
</GuardState>
</div>
@@ -70,26 +110,79 @@ const ProxyControlSwitches = ({ label, onError }: ProxySwitchProps) => {
{/* TUN режим */}
{isTunMode && (
<div className={cn("flex items-center justify-between p-2 rounded-lg transition-colors", enable_tun_mode && "bg-green-500/10", isSidecarMode && "opacity-60")}>
<div
className={cn(
"flex items-center justify-between p-2 rounded-lg transition-colors",
enable_tun_mode && "bg-green-500/10",
isSidecarMode && "opacity-60",
)}
>
<div className="flex items-center gap-3">
{enable_tun_mode ? <PlayCircle className="h-7 w-7 text-green-600" /> : <PauseCircle className="h-7 w-7 text-muted-foreground" />}
{enable_tun_mode ? (
<PlayCircle className="h-7 w-7 text-green-600" />
) : (
<PauseCircle className="h-7 w-7 text-muted-foreground" />
)}
<div>
<p className="font-semibold text-sm">{t("Tun Mode")}</p>
<p className="text-xs text-muted-foreground">{t("System-level virtual network adapter")}</p>
<p className="text-xs text-muted-foreground">
{t("System-level virtual network adapter")}
</p>
</div>
</div>
<div className="flex items-center gap-1">
{isSidecarMode && (
<Tooltip>
<TooltipTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={onInstallService}><Wrench className="h-4 w-4" /></Button></TooltipTrigger>
<TooltipContent><p>{t("Install Service")}</p></TooltipContent>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onInstallService}
>
<Wrench className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Install Service")}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => tunRef.current?.open()}><Settings className="h-4 w-4" /></Button></TooltipTrigger>
<TooltipContent><p>{t("Tun Mode Info")}</p></TooltipContent>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => tunRef.current?.open()}
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Tun Mode Info")}</p>
</TooltipContent>
</Tooltip>
<GuardState value={enable_tun_mode ?? false} valueProps="checked" onCatch={onError} onFormat={(e) => e} onChange={(e) => { if (isSidecarMode) return Promise.reject(); onChangeData({ enable_tun_mode: e }); }} onGuard={(e) => { if (isSidecarMode) { showNotice("error", t("TUN requires Service Mode or Admin Mode")); return Promise.reject(); } return patchVerge({ enable_tun_mode: e }); }}>
<GuardState
value={enable_tun_mode ?? false}
valueProps="checked"
onCatch={onError}
onFormat={(e) => e}
onChange={(e) => {
if (isSidecarMode) return Promise.reject();
onChangeData({ enable_tun_mode: e });
}}
onGuard={(e) => {
if (isSidecarMode) {
showNotice(
"error",
t("TUN requires Service Mode or Admin Mode"),
);
return Promise.reject();
}
return patchVerge({ enable_tun_mode: e });
}}
>
<Switch disabled={isSidecarMode} />
</GuardState>
</div>

View File

@@ -28,14 +28,14 @@ export const TestBox = React.forwardRef<HTMLDivElement, TestBoxProps>(
"data-[selected=true]:bg-primary/20 data-[selected=true]:text-primary data-[selected=true]:shadow-lg",
// --- Дополнительные классы от пользователя ---
className
className,
)}
{...props}
>
{children}
</div>
);
}
},
);
TestBox.displayName = "TestBox";

View File

@@ -15,7 +15,12 @@ import { cmdTestDelay, downloadIconCache } from "@/services/cmds";
import { BaseLoading } from "@/components/base";
import { TestBox } from "./test-box"; // Наш рефакторенный компонент
import { Separator } from "@/components/ui/separator";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Languages } from "lucide-react"; // Новая иконка
// Вспомогательная функция для цвета задержки
@@ -35,7 +40,14 @@ interface Props {
export const TestItem = (props: Props) => {
const { itemData, onEdit, onDelete: onDeleteItem } = props;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props.id });
const { t } = useTranslation();
const [delay, setDelay] = useState(-1);
@@ -59,7 +71,9 @@ export const TestItem = (props: Props) => {
}
}
useEffect(() => { initIconCachePath(); }, [icon]);
useEffect(() => {
initIconCachePath();
}, [icon]);
const onDelete = useLockFn(async () => {
try {
@@ -81,7 +95,9 @@ export const TestItem = (props: Props) => {
unlistenFn = await addListener("verge://test-all", onDelay);
};
setupListener();
return () => { unlistenFn?.(); };
return () => {
unlistenFn?.();
};
}, [url, addListener, onDelay]);
const style = {
@@ -96,32 +112,53 @@ export const TestItem = (props: Props) => {
<ContextMenuTrigger>
<TestBox>
{/* Мы применяем `listeners` к иконке, чтобы за нее можно было таскать */}
<div {...listeners} className="flex h-12 cursor-move items-center justify-center">
<div
{...listeners}
className="flex h-12 cursor-move items-center justify-center"
>
{icon ? (
<img
src={icon.startsWith('data') ? icon : icon.startsWith('<svg') ? `data:image/svg+xml;base64,${btoa(icon)}` : (iconCachePath || icon)}
className="h-10"
alt={name}
/>
<img
src={
icon.startsWith("data")
? icon
: icon.startsWith("<svg")
? `data:image/svg+xml;base64,${btoa(icon)}`
: iconCachePath || icon
}
className="h-10"
alt={name}
/>
) : (
<Languages className="h-10 w-10 text-muted-foreground" />
)}
</div>
<p className="mt-1 text-center text-sm font-semibold truncate" title={name}>{name}</p>
<p
className="mt-1 text-center text-sm font-semibold truncate"
title={name}
>
{name}
</p>
<Separator className="my-2" />
<div
className="flex h-6 items-center justify-center text-sm font-medium"
onClick={(e) => { e.stopPropagation(); onDelay(); }}
onClick={(e) => {
e.stopPropagation();
onDelay();
}}
>
{delay === -2 ? (
<BaseLoading className="h-4 w-4" />
) : delay === -1 ? (
<span className="cursor-pointer rounded-md px-2 py-0.5 hover:bg-accent">{t("Test")}</span>
<span className="cursor-pointer rounded-md px-2 py-0.5 hover:bg-accent">
{t("Test")}
</span>
) : (
<span className={`cursor-pointer rounded-md px-2 py-0.5 hover:bg-accent ${getDelayColorClass(delay)}`}>
<span
className={`cursor-pointer rounded-md px-2 py-0.5 hover:bg-accent ${getDelayColorClass(delay)}`}
>
{delayManager.formatDelay(delay)} ms
</span>
)}
@@ -131,7 +168,11 @@ export const TestItem = (props: Props) => {
<ContextMenuContent>
{menu.map((item) => (
<ContextMenuItem key={item.label} onClick={item.handler} className={item.isDestructive ? "text-destructive" : ""}>
<ContextMenuItem
key={item.label}
onClick={item.handler}
className={item.isDestructive ? "text-destructive" : ""}
>
{t(item.label)}
</ContextMenuItem>
))}

View File

@@ -51,7 +51,9 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
const { control, handleSubmit, reset, setValue } = form;
const patchTestList = async (uid: string, patch: Partial<IVergeTestItem>) => {
const newList = testList.map((x) => (x.uid === uid ? { ...x, ...patch } : x));
const newList = testList.map((x) =>
x.uid === uid ? { ...x, ...patch } : x,
);
await patchVerge({ test_list: newList });
};
@@ -81,7 +83,10 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
formData.icon = formData.icon.replace(/<!--[\s\S]*?-->/g, "");
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
const doc = new DOMParser().parseFromString(formData.icon, "image/svg+xml");
const doc = new DOMParser().parseFromString(
formData.icon,
"image/svg+xml",
);
if (doc.querySelector("parsererror")) {
throw new Error("`Icon`svg format error");
}
@@ -112,7 +117,9 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{openType === "new" ? t("Create Test") : t("Edit Test")}</DialogTitle>
<DialogTitle>
{openType === "new" ? t("Create Test") : t("Edit Test")}
</DialogTitle>
</DialogHeader>
<Form {...form}>
@@ -124,7 +131,9 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
render={({ field }) => (
<FormItem>
<FormLabel>{t("Name")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
@@ -135,7 +144,13 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
render={({ field }) => (
<FormItem>
<FormLabel>{t("Icon")}</FormLabel>
<FormControl><Textarea {...field} rows={4} placeholder="<svg>...</svg> or http(s)://..." /></FormControl>
<FormControl>
<Textarea
{...field}
rows={4}
placeholder="<svg>...</svg> or http(s)://..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
@@ -147,7 +162,13 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
render={({ field }) => (
<FormItem>
<FormLabel>{t("Test URL")}</FormLabel>
<FormControl><Textarea {...field} rows={3} placeholder="https://www.google.com" /></FormControl>
<FormControl>
<Textarea
{...field}
rows={3}
placeholder="https://www.google.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
@@ -157,11 +178,15 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
</Form>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={handleOk} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={handleOk} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -450,7 +450,7 @@ const Layout = () => {
<NoticeManager />
<div className="h-screen w-screen bg-background text-foreground overflow-hidden">
<div className="h-full w-full relative">
{React.cloneElement(routersEles, { key: location.pathname })}
{React.cloneElement(routersEles, { key: location.pathname })}
</div>
</div>
</SWRConfig>

View File

@@ -1,4 +1,10 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from "react";
import React, {
useMemo,
useRef,
useState,
useCallback,
useEffect,
} from "react";
import { useLockFn } from "ahooks";
import { Virtuoso } from "react-virtuoso";
import { useTranslation } from "react-i18next";
@@ -14,16 +20,48 @@ import { cn } from "@root/lib/utils";
import { BaseEmpty } from "@/components/base";
import { ConnectionItem } from "@/components/connection/connection-item";
import { ConnectionTable } from "@/components/connection/connection-table";
import { ConnectionDetail, ConnectionDetailRef } from "@/components/connection/connection-detail";
import { BaseSearchBox, type SearchState } from "@/components/base/base-search-box";
import {
ConnectionDetail,
ConnectionDetailRef,
} from "@/components/connection/connection-detail";
import {
BaseSearchBox,
type SearchState,
} from "@/components/base/base-search-box";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
// Иконки
import { List, Table2, PlayCircle, PauseCircle, ArrowDown, ArrowUp, Menu } from "lucide-react";
import {
List,
Table2,
PlayCircle,
PauseCircle,
ArrowDown,
ArrowUp,
Menu,
} from "lucide-react";
const initConn: IConnections = {
uploadTotal: 0,
@@ -44,9 +82,15 @@ const ConnectionsPage = () => {
const isTableLayout = setting.layout === "table";
const orderOpts: Record<string, OrderFunc> = {
Default: (list) => list.sort((a, b) => new Date(b.start || "0").getTime()! - new Date(a.start || "0").getTime()!),
Default: (list) =>
list.sort(
(a, b) =>
new Date(b.start || "0").getTime()! -
new Date(a.start || "0").getTime()!,
),
"Upload Speed": (list) => list.sort((a, b) => b.curUpload! - a.curUpload!),
"Download Speed": (list) => list.sort((a, b) => b.curDownload! - a.curDownload!),
"Download Speed": (list) =>
list.sort((a, b) => b.curDownload! - a.curDownload!),
};
const [isPaused, setIsPaused] = useState(false);
@@ -54,7 +98,11 @@ const ConnectionsPage = () => {
const displayData = useMemo(() => {
if (!pageVisible) return initConn;
const currentData = { uploadTotal: connections.uploadTotal, downloadTotal: connections.downloadTotal, connections: connections.data };
const currentData = {
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data,
};
if (isPaused) return frozenData ?? currentData;
return currentData;
}, [isPaused, frozenData, connections, pageVisible]);
@@ -63,37 +111,54 @@ const ConnectionsPage = () => {
const orderFunc = orderOpts[curOrderOpt];
let conns = displayData.connections.filter((conn) => {
const { host, destinationIP, process } = conn.metadata;
return match(host || "") || match(destinationIP || "") || match(process || "");
return (
match(host || "") || match(destinationIP || "") || match(process || "")
);
});
if (orderFunc) conns = orderFunc(conns);
return conns;
}, [displayData, match, curOrderOpt]);
const [scrollingElement, setScrollingElement] = useState<HTMLElement | Window | null>(null);
const [scrollingElement, setScrollingElement] = useState<
HTMLElement | Window | null
>(null);
const [isScrolled, setIsScrolled] = useState(false);
const scrollerRefCallback = useCallback((node: HTMLElement | Window | null) => {
setScrollingElement(node);
}, []);
const scrollerRefCallback = useCallback(
(node: HTMLElement | Window | null) => {
setScrollingElement(node);
},
[],
);
useEffect(() => {
if (!scrollingElement) return;
const handleScroll = () => {
const scrollTop = scrollingElement instanceof Window ? scrollingElement.scrollY : scrollingElement.scrollTop;
const scrollTop =
scrollingElement instanceof Window
? scrollingElement.scrollY
: scrollingElement.scrollTop;
setIsScrolled(scrollTop > 5);
};
scrollingElement.addEventListener('scroll', handleScroll);
return () => scrollingElement.removeEventListener('scroll', handleScroll);
scrollingElement.addEventListener("scroll", handleScroll);
return () => scrollingElement.removeEventListener("scroll", handleScroll);
}, [scrollingElement]);
const onCloseAll = useLockFn(closeAllConnections);
const detailRef = useRef<ConnectionDetailRef>(null!);
const handleSearch = useCallback((m: (content: string) => boolean) => setMatch(() => m), []);
const handleSearch = useCallback(
(m: (content: string) => boolean) => setMatch(() => m),
[],
);
const handlePauseToggle = useCallback(() => {
setIsPaused((prev) => {
if (!prev) {
setFrozenData({ uploadTotal: connections.uploadTotal, downloadTotal: connections.downloadTotal, connections: connections.data });
setFrozenData({
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data,
});
} else {
setFrozenData(null);
}
@@ -112,41 +177,118 @@ const ConnectionsPage = () => {
return (
<div className="h-full w-full relative">
<div className={cn(
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
{ "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled }
)}>
<div
className={cn(
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
{ "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled },
)}
>
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold tracking-tight">{t("Connections")}</h2>
<h2 className="text-2xl font-semibold tracking-tight">
{t("Connections")}
</h2>
<TooltipProvider delayDuration={100}>
<div className="flex items-center gap-2">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1"><ArrowDown className="h-4 w-4 text-green-500" />{parseTraffic(displayData.downloadTotal)}</div>
<div className="flex items-center gap-1"><ArrowUp className="h-4 w-4 text-sky-500" />{parseTraffic(displayData.uploadTotal)}</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<ArrowDown className="h-4 w-4 text-green-500" />
{parseTraffic(displayData.downloadTotal)}
</div>
<Separator orientation="vertical" className="h-6 mx-2" />
<Tooltip><TooltipTrigger asChild><Button variant="ghost" size="icon" onClick={() => setSetting((o) => (o?.layout !== "table" ? { ...o, layout: "table" } : { ...o, layout: "list" }))}>{isTableLayout ? <List className="h-5 w-5" /> : <Table2 className="h-5 w-5" />}</Button></TooltipTrigger><TooltipContent><p>{isTableLayout ? t("List View") : t("Table View")}</p></TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button variant="ghost" size="icon" onClick={handlePauseToggle}>{isPaused ? <PlayCircle className="h-5 w-5" /> : <PauseCircle className="h-5 w-5" />}</Button></TooltipTrigger><TooltipContent><p>{isPaused ? t("Resume") : t("Pause")}</p></TooltipContent></Tooltip>
<Button size="sm" variant="destructive" onClick={onCloseAll}>{t("Close All")}</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" title={t("Menu")}><Menu className="h-5 w-5" /></Button></DropdownMenuTrigger>
<div className="flex items-center gap-1">
<ArrowUp className="h-4 w-4 text-sky-500" />
{parseTraffic(displayData.uploadTotal)}
</div>
</div>
<Separator orientation="vertical" className="h-6 mx-2" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
setSetting((o) =>
o?.layout !== "table"
? { ...o, layout: "table" }
: { ...o, layout: "list" },
)
}
>
{isTableLayout ? (
<List className="h-5 w-5" />
) : (
<Table2 className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isTableLayout ? t("List View") : t("Table View")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handlePauseToggle}
>
{isPaused ? (
<PlayCircle className="h-5 w-5" />
) : (
<PauseCircle className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isPaused ? t("Resume") : t("Pause")}</p>
</TooltipContent>
</Tooltip>
<Button size="sm" variant="destructive" onClick={onCloseAll}>
{t("Close All")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title={t("Menu")}>
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuItems.map((item) => (<DropdownMenuItem key={item.path} onSelect={() => navigate(item.path)} disabled={location.pathname === item.path}>{item.label}</DropdownMenuItem>))}
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuItems.map((item) => (
<DropdownMenuItem
key={item.path}
onSelect={() => navigate(item.path)}
disabled={location.pathname === item.path}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenu>
</div>
</TooltipProvider>
</div>
<div className="flex items-center gap-2 mt-2">
{!isTableLayout && (
<Select value={curOrderOpt} onValueChange={(value) => setOrderOpt(value)}>
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t("Sort by")} /></SelectTrigger>
<SelectContent>{Object.keys(orderOpts).map((opt) => (<SelectItem key={opt} value={opt}>{t(opt)}</SelectItem>))}</SelectContent>
<Select
value={curOrderOpt}
onValueChange={(value) => setOrderOpt(value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("Sort by")} />
</SelectTrigger>
<SelectContent>
{Object.keys(orderOpts).map((opt) => (
<SelectItem key={opt} value={opt}>
{t(opt)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="flex-1"><BaseSearchBox onSearch={handleSearch} /></div>
<div className="flex-1">
<BaseSearchBox onSearch={handleSearch} />
</div>
</div>
</div>
@@ -156,9 +298,9 @@ const ConnectionsPage = () => {
) : isTableLayout ? (
<div className="p-4 pt-0 h-full w-full">
<ConnectionTable
connections={filterConn}
onShowDetail={(detail) => detailRef.current?.open(detail)}
scrollerRef={scrollerRefCallback}
connections={filterConn}
onShowDetail={(detail) => detailRef.current?.open(detail)}
scrollerRef={scrollerRefCallback}
/>
</div>
) : (
@@ -166,7 +308,12 @@ const ConnectionsPage = () => {
scrollerRef={scrollerRefCallback}
data={filterConn}
className="h-full w-full"
itemContent={(_, item) => <ConnectionItem value={item} onShowDetail={() => detailRef.current?.open(item)} />}
itemContent={(_, item) => (
<ConnectionItem
value={item}
onShowDetail={() => detailRef.current?.open(item)}
/>
)}
/>
)}
<ConnectionDetail ref={detailRef} />

View File

@@ -82,14 +82,34 @@ const TestPage = () => {
// Список тестов
const testList = verge?.test_list ?? [
{ uid: nanoid(), name: "Apple", url: "https://www.apple.com", icon: apple },
{ uid: nanoid(), name: "GitHub", url: "https://www.github.com", icon: github },
{ uid: nanoid(), name: "Google", url: "https://www.google.com", icon: google },
{ uid: nanoid(), name: "Youtube", url: "https://www.youtube.com", icon: youtube },
{
uid: nanoid(),
name: "GitHub",
url: "https://www.github.com",
icon: github,
},
{
uid: nanoid(),
name: "Google",
url: "https://www.google.com",
icon: google,
},
{
uid: nanoid(),
name: "Youtube",
url: "https://www.youtube.com",
icon: youtube,
},
];
const onTestListItemChange = (uid: string, patch?: Partial<IVergeTestItem>) => {
const onTestListItemChange = (
uid: string,
patch?: Partial<IVergeTestItem>,
) => {
if (patch) {
const newList = testList.map((x) => (x.uid === uid ? { ...x, ...patch } : x));
const newList = testList.map((x) =>
x.uid === uid ? { ...x, ...patch } : x,
);
mutateVerge({ ...verge, test_list: newList }, false);
} else {
mutateVerge();
@@ -134,7 +154,12 @@ const TestPage = () => {
return (
<div className="h-full w-full relative">
<div className={cn("absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200", { "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled })}>
<div
className={cn(
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
{ "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled },
)}
>
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold tracking-tight">{t("Test")}</h2>
<div className="flex items-center gap-2">
@@ -142,7 +167,11 @@ const TestPage = () => {
<Play className="mr-2 h-4 w-4" />
{t("Test All")}
</Button>
<Button size="sm" variant="secondary" onClick={() => viewerRef.current?.create()}>
<Button
size="sm"
variant="secondary"
onClick={() => viewerRef.current?.create()}
>
<Plus className="mr-2 h-4 w-4" />
{t("New")}
</Button>
@@ -156,7 +185,11 @@ const TestPage = () => {
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuItems.map((item) => (
<DropdownMenuItem key={item.path} onSelect={() => navigate(item.path)} disabled={location.pathname === item.path}>
<DropdownMenuItem
key={item.path}
onSelect={() => navigate(item.path)}
disabled={location.pathname === item.path}
>
{item.label}
</DropdownMenuItem>
))}
@@ -166,8 +199,15 @@ const TestPage = () => {
</div>
</div>
<div ref={scrollerRef} className="absolute top-0 left-0 right-0 bottom-0 pt-20 overflow-y-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<div
ref={scrollerRef}
className="absolute top-0 left-0 right-0 bottom-0 pt-20 overflow-y-auto"
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<div className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
<SortableContext items={testList.map((x) => x.uid)}>