code formatting with prettier
This commit is contained in:
@@ -12,7 +12,7 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
|
||||
## Preview
|
||||
|
||||
| Dark | Light |
|
||||
|-------------------------------------|--------------------------------------|
|
||||
| ----------------------------------- | ------------------------------------ |
|
||||
|  |  |
|
||||
|
||||
## Install
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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 и делаем его немного больше */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export const BaseStyledSelect: React.FC<BaseStyledSelectProps> = (props) => {
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-9 w-[180px]", // Задаем стандартные размеры, как у других селектов
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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 для отступов
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -188,7 +188,6 @@ const rules: {
|
||||
];
|
||||
const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
|
||||
|
||||
|
||||
const Combobox = ({
|
||||
options,
|
||||
value,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* Правая часть: элемент управления или иконка */}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 --- */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user