New Interface (initial commit)

This commit is contained in:
coolcoala
2025-07-04 02:28:27 +03:00
parent 4435a5aee4
commit 686490ded1
121 changed files with 12852 additions and 13274 deletions

View File

@@ -4,18 +4,16 @@ import { useForm } from "react-hook-form";
import { useVerge } from "@/hooks/use-verge";
import { isValidUrl } from "@/utils/helper";
import { useLockFn } from "ahooks";
import {
TextField,
Button,
Grid,
Stack,
IconButton,
InputAdornment,
} from "@mui/material";
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
// Новые импорты
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 { Eye, EyeOff } from "lucide-react";
import { cn } from "@root/lib/utils";
export interface BackupConfigViewerProps {
onBackupSuccess: () => Promise<void>;
@@ -26,219 +24,142 @@ 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 usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const urlRef = useRef<HTMLInputElement>(null);
const { register, handleSubmit, watch } = useForm<IWebDavConfig>({
defaultValues: {
url: webdav_url,
username: webdav_username,
password: webdav_password,
},
const form = useForm<IWebDavConfig>({
defaultValues: { url: '', username: '', password: '' },
});
// Синхронизируем форму с данными из verge
useEffect(() => {
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;
const url = watch("url");
const username = watch("username");
const password = watch("password");
const webdavChanged =
webdav_url !== url ||
webdav_username !== username ||
webdav_password !== password;
console.log(
"webdavChanged",
webdavChanged,
webdav_url,
webdav_username,
webdav_password,
);
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev);
};
useEffect(() => {
if (webdav_url && webdav_username && webdav_password) {
onInit();
}
}, []);
const webdavChanged = webdav_url !== url || webdav_username !== username || webdav_password !== password;
const checkForm = () => {
const username = usernameRef.current?.value;
const password = passwordRef.current?.value;
const url = urlRef.current?.value;
if (!url) {
urlRef.current?.focus();
showNotice("error", t("WebDAV URL Required"));
throw new Error(t("WebDAV URL Required"));
} else if (!isValidUrl(url)) {
urlRef.current?.focus();
showNotice("error", t("Invalid WebDAV URL"));
throw new Error(t("Invalid WebDAV URL"));
}
if (!username) {
usernameRef.current?.focus();
showNotice("error", t("WebDAV URL Required"));
throw new Error(t("Username Required"));
}
if (!password) {
passwordRef.current?.focus();
showNotice("error", t("WebDAV URL Required"));
throw new Error(t("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) => {
checkForm();
try {
setLoading(true);
await saveWebdavConfig(
data.url.trim(),
data.username.trim(),
data.password,
).then(() => {
showNotice("success", t("WebDAV Config Saved"));
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 () => {
checkForm();
try {
setLoading(true);
await createWebdavBackup().then(async () => {
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 onSubmit={(e) => e.preventDefault()}>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 9 }}>
<Grid container spacing={2}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label={t("WebDAV Server URL")}
variant="outlined"
size="small"
{...register("url")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={urlRef}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
label={t("Username")}
variant="outlined"
size="small"
{...register("username")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={usernameRef}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
label={t("Password")}
type={showPassword ? "text" : "password"}
variant="outlined"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={passwordRef}
{...register("password")}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/>
</Grid>
</Grid>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<Stack
direction="column"
justifyContent="space-between"
alignItems="stretch"
sx={{ height: "100%" }}
>
{webdavChanged ||
webdav_url === undefined ||
webdav_username === undefined ||
webdav_password === undefined ? (
<Button
variant="contained"
color={"primary"}
sx={{ height: "100%" }}
type="button"
onClick={handleSubmit(save)}
>
{t("Save")}
</Button>
) : (
<>
<Button
variant="contained"
color="success"
onClick={handleBackup}
type="button"
size="large"
>
{t("Backup")}
</Button>
<Button
variant="outlined"
onClick={onRefresh}
type="button"
size="large"
>
{t("Refresh")}
</Button>
</>
<Form {...form}>
<form onSubmit={e => e.preventDefault()} className="flex flex-col sm:flex-row gap-4">
{/* Левая часть: поля ввода */}
<div className="flex-1 space-y-4">
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t("WebDAV Server URL")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
</Stack>
</Grid>
</Grid>
</form>
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Username")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Password")}</FormLabel>
<div className="relative">
<FormControl>
<Input type={showPassword ? "text" : "password"} {...field} className="pr-10" />
</FormControl>
<Button
type="button"
variant="ghost"
size="icon"
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" />}
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Правая часть: кнопки действий */}
<div className="flex sm:flex-col gap-2">
{webdavChanged || !webdav_url ? (
<Button type="button" className="w-full h-full" onClick={handleSubmit(save)}>
{t("Save")}
</Button>
) : (
<>
<Button type="button" className="w-full" onClick={handleBackup}>
{t("Backup")}
</Button>
<Button type="button" variant="outline" className="w-full" onClick={onRefresh}>
{t("Refresh")}
</Button>
</>
)}
</div>
</form>
</Form>
);
},
}
);

View File

@@ -1,33 +1,28 @@
import { SVGProps, memo } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
import { restartApp } from "@/services/cmds";
import { deleteWebdavBackup, restoreWebDavBackup } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
// Новые импорты
import {
Box,
Paper,
IconButton,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
TablePagination,
} from "@mui/material";
import { Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Dayjs } from "dayjs";
import {
deleteWebdavBackup,
restoreWebDavBackup,
restartApp,
} from "@/services/cmds";
import DeleteIcon from "@mui/icons-material/Delete";
import RestoreIcon from "@mui/icons-material/Restore";
import { showNotice } from "@/services/noticeService";
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Trash2, History } from "lucide-react";
export type BackupFile = IWebDavFile & {
platform: string;
backup_time: Dayjs;
backup_time: dayjs.Dayjs;
allow_apply: boolean;
};
@@ -36,154 +31,12 @@ 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>;
}
export const BackupTableViewer = memo(
({
datasource,
page,
onPageChange,
total,
onRefresh,
}: BackupTableViewerProps) => {
const { t } = useTranslation();
const handleDelete = useLockFn(async (filename: string) => {
await deleteWebdavBackup(filename);
await onRefresh();
});
const handleRestore = useLockFn(async (filename: string) => {
await restoreWebDavBackup(filename).then(() => {
showNotice("success", t("Restore Success, App will restart in 1s"));
});
await restartApp();
});
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Filename")}</TableCell>
<TableCell>{t("Backup Time")}</TableCell>
<TableCell align="right">{t("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{datasource.length > 0 ? (
datasource?.map((file, index) => (
<TableRow key={index}>
<TableCell component="th" scope="row">
{file.platform === "windows" ? (
<WindowsIcon className="h-full w-full" />
) : file.platform === "linux" ? (
<LinuxIcon className="h-full w-full" />
) : (
<MacIcon className="h-full w-full" />
)}
{file.filename}
</TableCell>
<TableCell align="center">
{file.backup_time.fromNow()}
</TableCell>
<TableCell align="right">
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<IconButton
color="secondary"
aria-label={t("Delete")}
size="small"
title={t("Delete Backup")}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to delete this backup file?"),
);
if (confirmed) {
await handleDelete(file.filename);
}
}}
>
<DeleteIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
<IconButton
color="primary"
aria-label={t("Restore")}
size="small"
title={t("Restore Backup")}
disabled={!file.allow_apply}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to restore this backup file?"),
);
if (confirmed) {
await handleRestore(file.filename);
}
}}
>
<RestoreIcon />
</IconButton>
</Box>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} align="center">
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 150,
}}
>
<Typography
variant="body1"
color="textSecondary"
align="center"
>
{t("No Backups")}
</Typography>
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[]}
component="div"
count={total}
rowsPerPage={DEFAULT_ROWS_PER_PAGE}
page={page}
onPageChange={onPageChange}
labelRowsPerPage={t("Rows per page")}
/>
</TableContainer>
);
},
);
// Ваши кастомные иконки остаются без изменений
function LinuxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -264,3 +117,120 @@ function MacIcon(props: SVGProps<SVGSVGElement>) {
</svg>
);
}
export const BackupTableViewer = memo(
({ datasource, page, onPageChange, total, onRefresh }: BackupTableViewerProps) => {
const { t } = useTranslation();
const handleDelete = useLockFn(async (filename: string) => {
await deleteWebdavBackup(filename);
await onRefresh();
});
const handleRestore = useLockFn(async (filename: string) => {
await restoreWebDavBackup(filename);
showNotice("success", t("Restore Success, App will restart in 1s"));
await restartApp();
});
return (
// Используем простой div в качестве контейнера
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("Filename")}</TableHead>
<TableHead className="text-center">{t("Backup Time")}</TableHead>
<TableHead className="text-right">{t("Actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{datasource.length > 0 ? (
datasource.map((file, index) => (
<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>
</div>
</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>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
{t("No Backups")}
</TableCell>
</TableRow>
)}
</TableBody>
</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>
</div>
);
}
);

View File

@@ -1,57 +1,68 @@
import {
forwardRef,
useImperativeHandle,
useState,
useCallback,
useEffect,
} from "react";
import { forwardRef, useImperativeHandle, useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base";
import getSystem from "@/utils/get-system";
import { BaseLoadingOverlay } from "@/components/base";
import dayjs from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import {
BackupTableViewer,
BackupFile,
DEFAULT_ROWS_PER_PAGE,
} from "./backup-table-viewer";
import { BackupConfigViewer } from "./backup-config-viewer";
import { Box, Paper, Divider } from "@mui/material";
import { useLockFn } from "ahooks";
// Новые импорты
import { listWebDavBackup } from "@/services/cmds";
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 { BackupConfigViewer } from "./backup-config-viewer"; // Наш рефакторенный компонент
dayjs.extend(customParseFormat);
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
export interface DialogRef {
open: () => void;
close: () => void;
}
export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
const [dataSource, setDataSource] = useState<BackupFile[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const OS = getSystem();
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
},
open: () => setOpen(true),
close: () => setOpen(false),
}));
// Handle page change
const handleChangePage = useCallback(
(_: React.MouseEvent<HTMLButtonElement> | null, page: number) => {
setPage(page);
(_: React.MouseEvent<HTMLButtonElement> | null, newPage: number) => {
setPage(newPage);
},
[],
);
const fetchAndSetBackupFiles = async () => {
const getAllBackupFiles = async (): Promise<BackupFile[]> => {
const files = await listWebDavBackup();
return files
.map((file) => {
const platform = file.filename.split("-")[0];
const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN);
if (fileBackupTimeStr === null) return null;
return {
...file,
platform,
backup_time: dayjs(fileBackupTimeStr[0], DATE_FORMAT),
allow_apply: true,
};
})
.filter((item): item is BackupFile => item !== null)
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
};
const fetchAndSetBackupFiles = useLockFn(async () => {
try {
setIsLoading(true);
const files = await getAllBackupFiles();
@@ -61,35 +72,10 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
setBackupFiles([]);
setTotal(0);
console.error(error);
// Notice.error(t("Failed to fetch backup files"));
} finally {
setIsLoading(false);
}
};
const getAllBackupFiles = async () => {
const files = await listWebDavBackup();
return files
.map((file) => {
const platform = file.filename.split("-")[0];
const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN)!;
if (fileBackupTimeStr === null) {
return null;
}
const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT);
const allowApply = true;
return {
...file,
platform,
backup_time: backupTime,
allow_apply: allowApply,
} as BackupFile;
})
.filter((item) => item !== null)
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
};
});
useEffect(() => {
setDataSource(
@@ -101,35 +87,26 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
}, [page, backupFiles]);
return (
<BaseDialog
open={open}
title={t("Backup Setting")}
// contentSx={{ width: 600, maxHeight: 800 }}
okBtn={t("")}
cancelBtn={t("Close")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
disableOk
>
<Box>
<BaseLoadingOverlay isLoading={isLoading} />
<Paper elevation={2} sx={{ padding: 2 }}>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t("Backup Setting")}</DialogTitle>
</DialogHeader>
{/* Основной контейнер с relative для оверлея загрузки */}
<div className="relative space-y-4">
<BaseLoadingOverlay isLoading={isLoading} />
<BackupConfigViewer
setLoading={setIsLoading}
onBackupSuccess={async () => {
fetchAndSetBackupFiles();
}}
onSaveSuccess={async () => {
fetchAndSetBackupFiles();
}}
onRefresh={async () => {
fetchAndSetBackupFiles();
}}
onInit={async () => {
fetchAndSetBackupFiles();
}}
onBackupSuccess={fetchAndSetBackupFiles}
onSaveSuccess={fetchAndSetBackupFiles}
onRefresh={fetchAndSetBackupFiles}
onInit={fetchAndSetBackupFiles}
/>
<Divider sx={{ marginY: 2 }} />
<Separator />
<BackupTableViewer
datasource={dataSource}
page={page}
@@ -137,8 +114,14 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
total={total}
onRefresh={fetchAndSetBackupFiles}
/>
</Paper>
</Box>
</BaseDialog>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,26 +1,31 @@
import { mutate } from "swr";
import { forwardRef, useImperativeHandle, useState } from "react";
import { BaseDialog, DialogRef } from "@/components/base";
import { useTranslation } from "react-i18next";
import { useVerge } from "@/hooks/use-verge";
import { useLockFn } from "ahooks";
import { LoadingButton } from "@mui/lab";
import { mutate } from "swr";
// Новые импорты
import { DialogRef } from "@/components/base";
import { Button } from "@/components/ui/button";
import {
SwitchAccessShortcutRounded,
RestartAltRounded,
} from "@mui/icons-material";
import {
Box,
Chip,
CircularProgress,
List,
ListItemButton,
ListItemText,
} from "@mui/material";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Loader2, Replace, RotateCw } from "lucide-react";
import { cn } from "@root/lib/utils";
// Логика и сервисы
import { useVerge } from "@/hooks/use-verge";
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" },
{ name: "Mihomo Alpha", core: "verge-mihomo-alpha", chip: "Alpha Version" },
@@ -28,7 +33,6 @@ const VALID_CORE = [
export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { verge, mutateVerge } = useVerge();
const [open, setOpen] = useState(false);
@@ -45,18 +49,15 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
const onCoreChange = useLockFn(async (core: string) => {
if (core === clash_core) return;
try {
setChangingCore(core);
closeAllConnections();
const errorMsg = await changeClashCore(core);
if (errorMsg) {
showNotice("error", errorMsg);
setChangingCore(null);
return;
}
mutateVerge();
setTimeout(() => {
mutate("getClashConfig");
@@ -74,10 +75,10 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
setRestarting(true);
await restartCore();
showNotice("success", t(`Clash Core Restarted`));
setRestarting(false);
} catch (err: any) {
setRestarting(false);
showNotice("error", err.message || err.toString());
} finally {
setRestarting(false);
}
});
@@ -85,81 +86,79 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
try {
setUpgrading(true);
await upgradeCore();
setUpgrading(false);
showNotice("success", t(`Core Version Updated`));
} catch (err: any) {
setUpgrading(false);
const errMsg = err.response?.data?.message || err.toString();
const showMsg = errMsg.includes("already using latest version")
? "Already Using Latest Core Version"
: errMsg;
showNotice("error", t(showMsg));
} finally {
setUpgrading(false);
}
});
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{t("Clash Core")}
<Box>
<LoadingButton
variant="contained"
size="small"
startIcon={<SwitchAccessShortcutRounded />}
loadingPosition="start"
loading={upgrading}
disabled={restarting || changingCore !== null}
sx={{ marginRight: "8px" }}
onClick={onUpgrade}
>
{t("Upgrade")}
</LoadingButton>
<LoadingButton
variant="contained"
size="small"
startIcon={<RestartAltRounded />}
loadingPosition="start"
loading={restarting}
disabled={upgrading}
onClick={onRestart}
>
{t("Restart")}
</LoadingButton>
</Box>
</Box>
}
contentSx={{
pb: 0,
width: 400,
height: 180,
overflowY: "auto",
userSelect: "text",
marginTop: "-8px",
}}
disableOk
cancelBtn={t("Close")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List component="nav">
{VALID_CORE.map((each) => (
<ListItemButton
key={each.core}
selected={each.core === clash_core}
onClick={() => onCoreChange(each.core)}
disabled={changingCore !== null || restarting || upgrading}
>
<ListItemText primary={each.name} secondary={`/${each.core}`} />
{changingCore === each.core ? (
<CircularProgress size={20} sx={{ mr: 1 }} />
) : (
<Chip label={t(`${each.chip}`)} size="small" />
)}
</ListItemButton>
))}
</List>
</BaseDialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Добавляем отступ справа (pr-12), чтобы освободить место для крестика */}
<DialogHeader className="pr-12">
<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" />}
{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" />}
{t("Restart")}
</Button>
</div>
</div>
</DialogHeader>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
<div className="space-y-2 py-4">
{VALID_CORE.map((each) => {
const isSelected = each.core === clash_core;
const isChanging = changingCore === each.core;
const isDisabled = changingCore !== null || restarting || upgrading;
return (
<div
key={each.core}
data-selected={isSelected}
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"
)}
>
<div>
<p className="font-semibold text-sm">{each.name}</p>
<p className="text-xs text-muted-foreground">{`/${each.core}`}</p>
</div>
<div className="w-28 text-right flex justify-end">
{isChanging ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Badge variant={isSelected ? "default" : "secondary"}>{t(each.chip)}</Badge>
)}
</div>
</div>
);
})}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,69 +1,120 @@
import { BaseDialog, Switch } from "@/components/base";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn, useRequest } from "ahooks";
import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import { Shuffle } from "@mui/icons-material";
// Новые импорты
import { DialogRef, Switch } from "@/components/base";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
CircularProgress,
IconButton,
List,
ListItem,
ListItemText,
Stack,
TextField,
} from "@mui/material";
import { useLockFn, useRequest } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Shuffle, Loader2 } from "lucide-react";
const OS = getSystem();
interface ClashPortViewerProps {}
interface ClashPortViewerRef {
open: () => void;
close: () => void;
}
const generateRandomPort = () =>
Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025;
const generateRandomPort = () => Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025;
export const ClashPortViewer = forwardRef<
ClashPortViewerRef,
ClashPortViewerProps
>((props, ref) => {
// Компонент для одной строки настроек порта
const PortSettingRow = ({
label,
port,
setPort,
isEnabled,
setIsEnabled,
isFixed = false,
}: {
label: string;
port: number;
setPort: (port: number) => void;
isEnabled: boolean;
setIsEnabled?: (enabled: boolean) => void;
isFixed?: boolean;
}) => {
const { t } = useTranslation();
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/\D/g, ""); // Удаляем все нечисловые символы
if (value === "") {
setPort(0);
return;
}
const num = parseInt(value, 10);
if (!isNaN(num) && num >= 0 && num <= 65535) {
setPort(num);
}
};
return (
<div className="flex items-center justify-between py-2">
<p className="text-sm font-medium">{label}</p>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-24 h-8 text-center"
value={port || ""}
onChange={handleNumericChange}
disabled={!isEnabled}
/>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setPort(generateRandomPort())}
disabled={!isEnabled}
>
<Shuffle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Random Port")}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
checked={isEnabled}
onCheckedChange={isFixed ? undefined : setIsEnabled}
disabled={isFixed}
/>
</div>
</div>
);
};
export const ClashPortViewer = forwardRef<ClashPortViewerRef>((props, ref) => {
const { t } = useTranslation();
const { clashInfo, patchInfo } = useClashInfo();
const { verge, patchVerge } = useVerge();
const [open, setOpen] = useState(false);
// Mixed Port
const [mixedPort, setMixedPort] = useState(
verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897,
);
const [mixedPort, setMixedPort] = useState(0);
const [socksPort, setSocksPort] = useState(0);
const [socksEnabled, setSocksEnabled] = useState(false);
const [httpPort, setHttpPort] = useState(0);
const [httpEnabled, setHttpEnabled] = useState(false);
const [redirPort, setRedirPort] = useState(0);
const [redirEnabled, setRedirEnabled] = useState(false);
const [tproxyPort, setTproxyPort] = useState(0);
const [tproxyEnabled, setTproxyEnabled] = useState(false);
// 其他端口状态
const [socksPort, setSocksPort] = useState(verge?.verge_socks_port ?? 7898);
const [socksEnabled, setSocksEnabled] = useState(
verge?.verge_socks_enabled ?? false,
);
const [httpPort, setHttpPort] = useState(verge?.verge_port ?? 7899);
const [httpEnabled, setHttpEnabled] = useState(
verge?.verge_http_enabled ?? false,
);
const [redirPort, setRedirPort] = useState(verge?.verge_redir_port ?? 7895);
const [redirEnabled, setRedirEnabled] = useState(
verge?.verge_redir_enabled ?? false,
);
const [tproxyPort, setTproxyPort] = useState(
verge?.verge_tproxy_port ?? 7896,
);
const [tproxyEnabled, setTproxyEnabled] = useState(
verge?.verge_tproxy_enabled ?? false,
);
// 添加保存请求防止GUI卡死
const { loading, run: saveSettings } = useRequest(
async (params: { clashConfig: any; vergeConfig: any }) => {
const { clashConfig, vergeConfig } = params;
@@ -73,24 +124,24 @@ export const ClashPortViewer = forwardRef<
manual: true,
onSuccess: () => {
setOpen(false);
showNotice("success", t("Port settings saved")); // 调用提示函数
showNotice("success", t("Port settings saved"));
},
onError: () => {
showNotice("error", t("Failed to save settings")); // 调用提示函数
showNotice("error", t("Failed to save settings"));
},
},
);
useImperativeHandle(ref, () => ({
open: () => {
setMixedPort(verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897);
setSocksPort(verge?.verge_socks_port ?? 7898);
setMixedPort(verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7890);
setSocksPort(verge?.verge_socks_port ?? 7891);
setSocksEnabled(verge?.verge_socks_enabled ?? false);
setHttpPort(verge?.verge_port ?? 7899);
setHttpPort(verge?.verge_port ?? 7892);
setHttpEnabled(verge?.verge_http_enabled ?? false);
setRedirPort(verge?.verge_redir_port ?? 7895);
setRedirPort(verge?.verge_redir_port ?? 7893);
setRedirEnabled(verge?.verge_redir_enabled ?? false);
setTproxyPort(verge?.verge_tproxy_port ?? 7896);
setTproxyPort(verge?.verge_tproxy_port ?? 7894);
setTproxyEnabled(verge?.verge_tproxy_enabled ?? false);
setOpen(true);
},
@@ -98,40 +149,31 @@ export const ClashPortViewer = forwardRef<
}));
const onSave = useLockFn(async () => {
// 端口冲突检测
const portList = [
mixedPort,
socksEnabled ? socksPort : -1,
httpEnabled ? httpPort : -1,
redirEnabled ? redirPort : -1,
tproxyEnabled ? tproxyPort : -1,
].filter((p) => p !== -1);
].filter((p) => p > 0);
if (new Set(portList).size !== portList.length) {
showNotice("error", t("Port conflict detected"));
return;
}
// 验证端口范围
const isValidPort = (port: number) => port >= 1 && port <= 65535;
const allPortsValid = [
mixedPort,
socksEnabled ? socksPort : 0,
httpEnabled ? httpPort : 0,
redirEnabled ? redirPort : 0,
tproxyEnabled ? tproxyPort : 0,
].every((port) => port === 0 || isValidPort(port));
const allPortsValid = portList.every((port) => port >= 1 && port <= 65535);
if (!allPortsValid) {
showNotice("error", t("Port out of range (1-65535)"));
return;
}
// 准备配置数据
const clashConfig = {
"mixed-port": mixedPort,
"socks-port": socksPort,
port: httpPort,
"redir-port": redirPort,
"tproxy-port": tproxyPort,
"socks-port": socksEnabled ? socksPort : 0,
"port": httpEnabled ? httpPort : 0,
"redir-port": redirEnabled ? redirPort : 0,
"tproxy-port": tproxyEnabled ? tproxyPort : 0,
};
const vergeConfig = {
@@ -146,221 +188,36 @@ export const ClashPortViewer = forwardRef<
verge_tproxy_enabled: tproxyEnabled,
};
// 提交保存请求
await saveSettings({ clashConfig, vergeConfig });
});
// 优化的数字输入处理
const handleNumericChange =
(setter: (value: number) => void) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/\D+/, "");
if (value === "") {
setter(0);
return;
}
const num = parseInt(value, 10);
if (!isNaN(num) && num >= 0 && num <= 65535) {
setter(num);
}
};
return (
<BaseDialog
open={open}
title={t("Port Configuration")}
contentSx={{
width: 400,
}}
okBtn={
loading ? (
<Stack direction="row" alignItems="center" spacing={1}>
<CircularProgress size={20} />
{t("Saving...")}
</Stack>
) : (
t("Save")
)
}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List sx={{ width: "100%" }}>
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}>
<ListItemText
primary={t("Mixed Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={mixedPort}
onChange={(e) =>
setMixedPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setMixedPort(generateRandomPort())}
title={t("Random Port")}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={true}
disabled={true}
sx={{ ml: 0.5, opacity: 0.7 }}
/>
</div>
</ListItem>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Port Configuration")}</DialogTitle>
</DialogHeader>
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}>
<ListItemText
primary={t("Socks Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={socksPort}
onChange={(e) =>
setSocksPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
disabled={!socksEnabled}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setSocksPort(generateRandomPort())}
title={t("Random Port")}
disabled={!socksEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={socksEnabled}
onChange={(_, c) => setSocksEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
<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} />
{OS !== "windows" && (
<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} />
)}
</div>
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}>
<ListItemText
primary={t("Http Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={httpPort}
onChange={(e) =>
setHttpPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
disabled={!httpEnabled}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setHttpPort(generateRandomPort())}
title={t("Random Port")}
disabled={!httpEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={httpEnabled}
onChange={(_, c) => setHttpEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
{OS !== "windows" && (
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}>
<ListItemText
primary={t("Redir Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={redirPort}
onChange={(e) =>
setRedirPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
disabled={!redirEnabled}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setRedirPort(generateRandomPort())}
title={t("Random Port")}
disabled={!redirEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={redirEnabled}
onChange={(_, c) => setRedirEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
)}
{OS === "linux" && (
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}>
<ListItemText
primary={t("Tproxy Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={tproxyPort}
onChange={(e) =>
setTproxyPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
disabled={!tproxyEnabled}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setTproxyPort(generateRandomPort())}
title={t("Random Port")}
disabled={!tproxyEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={tproxyEnabled}
onChange={(_, c) => setTproxyEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
)}
</List>
</BaseDialog>
<DialogFooter>
<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")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,15 +1,18 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, Chip } from "@mui/material";
import { getRuntimeYaml } from "@/services/cmds";
import { DialogRef } from "@/components/base";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { EditorViewer } from "@/components/profile/editor-viewer"; // Наш обновленный компонент
// Новые импорты
import { Badge } from "@/components/ui/badge";
export const ConfigViewer = forwardRef<DialogRef>((_, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [runtimeConfig, setRuntimeConfig] = useState("");
// useImperativeHandle остается без изменений
useImperativeHandle(ref, () => ({
open: () => {
getRuntimeYaml().then((data) => {
@@ -21,14 +24,18 @@ export const ConfigViewer = forwardRef<DialogRef>((_, ref) => {
}));
if (!open) return null;
return (
<EditorViewer
open={true}
title={
<Box display="flex" alignItems="center" gap={2}>
{t("Runtime Config")}
<Chip label={t("ReadOnly")} size="small" />
</Box>
// --- НАЧАЛО ИЗМЕНЕНИЙ ---
// Заменяем Box на div и Chip на Badge
<div className="flex items-center gap-2">
<span>{t("Runtime Config")}</span>
<Badge variant="secondary">{t("ReadOnly")}</Badge>
</div>
// --- КОНЕЦ ИЗМЕНЕНИЙ ---
}
initialData={Promise.resolve(runtimeConfig)}
readOnly

View File

@@ -1,185 +1,144 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { useClashInfo } from "@/hooks/use-clash";
import { showNotice } from "@/services/noticeService";
import { ContentCopy } from "@mui/icons-material";
import {
Alert,
Box,
CircularProgress,
IconButton,
List,
ListItem,
ListItemText,
Snackbar,
TextField,
Tooltip,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { useClashInfo } from "@/hooks/use-clash";
import { showNotice } from "@/services/noticeService";
// Новые импорты
import { DialogRef } from "@/components/base";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} 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 { Copy, Loader2 } from "lucide-react";
export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [copySuccess, setCopySuccess] = useState<null | string>(null);
const [isSaving, setIsSaving] = useState(false);
const { clashInfo, patchInfo } = useClashInfo();
const [controller, setController] = useState(clashInfo?.server || "");
const [secret, setSecret] = useState(clashInfo?.secret || "");
const [controller, setController] = useState("");
const [secret, setSecret] = useState("");
// 对话框打开时初始化配置
useImperativeHandle(ref, () => ({
open: async () => {
setOpen(true);
setController(clashInfo?.server || "");
setSecret(clashInfo?.secret || "");
setOpen(true);
},
close: () => setOpen(false),
}));
// 保存配置
const onSave = useLockFn(async () => {
if (!controller.trim()) {
showNotice("error", t("Controller address cannot be empty"));
return;
}
if (!secret.trim()) {
showNotice("error", t("Secret cannot be empty"));
return;
}
// Секрет может быть пустым
// if (!secret.trim()) {
// showNotice("error", t("Secret cannot be empty"));
// return;
// }
try {
setIsSaving(true);
await patchInfo({ "external-controller": controller, secret });
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);
setCopySuccess(type);
setTimeout(() => setCopySuccess(null));
} 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 (
<BaseDialog
open={open}
title={t("External Controller")}
contentSx={{ width: 400 }}
okBtn={
isSaving ? (
<Box display="flex" alignItems="center" gap={1}>
<CircularProgress size={16} color="inherit" />
{t("Saving...")}
</Box>
) : (
t("Save")
)
}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem
sx={{
padding: "5px 2px",
display: "flex",
justifyContent: "space-between",
}}
>
<ListItemText primary={t("External Controller")} />
<Box display="flex" alignItems="center" gap={1}>
<TextField
size="small"
sx={{
width: 175,
opacity: 1,
pointerEvents: "auto",
}}
value={controller}
placeholder="Required"
onChange={(e) => setController(e.target.value)}
disabled={isSaving}
/>
<Tooltip title={t("Copy to clipboard")}>
<IconButton
size="small"
onClick={() => handleCopyToClipboard(controller, "controller")}
color="primary"
disabled={isSaving}
>
<ContentCopy fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("External Controller")}</DialogTitle>
</DialogHeader>
<ListItem
sx={{
padding: "5px 2px",
display: "flex",
justifyContent: "space-between",
}}
>
<ListItemText primary={t("Core Secret")} />
<Box display="flex" alignItems="center" gap={1}>
<TextField
size="small"
sx={{
width: 175,
opacity: 1,
pointerEvents: "auto",
}}
value={secret}
placeholder={t("Recommended")}
onChange={(e) => setSecret(e.target.value)}
disabled={isSaving}
/>
<Tooltip title={t("Copy to clipboard")}>
<IconButton
size="small"
onClick={() => handleCopyToClipboard(secret, "secret")}
color="primary"
<div className="space-y-4 py-4">
<div className="grid gap-2">
<Label htmlFor="controller-address">{t("External Controller")}</Label>
<div className="flex items-center gap-2">
<Input
id="controller-address"
value={controller}
placeholder="127.0.0.1:9090"
onChange={(e) => setController(e.target.value)}
disabled={isSaving}
>
<ContentCopy fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
</List>
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<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>
</Tooltip>
</TooltipProvider>
</div>
</div>
<Snackbar
open={copySuccess !== null}
autoHideDuration={2000}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
>
<Alert severity="success">
{copySuccess === "controller"
? t("Controller address copied to clipboard")
: t("Secret copied to clipboard")}
</Alert>
</Snackbar>
</BaseDialog>
<div className="grid gap-2">
<Label htmlFor="core-secret">{t("Core Secret")}</Label>
<div className="flex items-center gap-2">
<Input
id="core-secret"
value={secret}
placeholder={t("Recommended")}
onChange={(e) => setSecret(e.target.value)}
disabled={isSaving}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<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>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<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" />}
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,14 @@
import { useRef, useState } from "react";
import { alpha, Box, IconButton, styled } from "@mui/material";
import { DeleteRounded } from "@mui/icons-material";
import { parseHotkey } from "@/utils/parse-hotkey";
import { useTranslation } from "react-i18next";
import { parseHotkey } from "@/utils/parse-hotkey";
import { cn } from "@root/lib/utils";
const KeyWrapper = styled("div")(({ theme }) => ({
position: "relative",
width: 165,
minHeight: 36,
// Новые импорты
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
"> input": {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
opacity: 0,
},
"> input:focus + .list": {
borderColor: alpha(theme.palette.primary.main, 0.75),
},
".list": {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
width: "100%",
height: "100%",
minHeight: 36,
boxSizing: "border-box",
padding: "3px 4px",
border: "1px solid",
borderRadius: 4,
borderColor: alpha(theme.palette.text.secondary, 0.15),
"&:last-child": {
marginRight: 0,
},
},
".item": {
color: theme.palette.text.primary,
border: "1px solid",
borderColor: alpha(theme.palette.text.secondary, 0.2),
borderRadius: "2px",
padding: "1px 5px",
margin: "2px 0",
},
".delimiter": {
lineHeight: "25px",
padding: "0 2px",
},
}));
interface Props {
value: string[];
@@ -63,55 +22,66 @@ export const HotkeyInput = (props: Props) => {
const changeRef = useRef<string[]>([]);
const [keys, setKeys] = useState(value);
const handleKeyUp = () => {
const ret = changeRef.current.slice();
if (ret.length) {
onChange(ret);
changeRef.current = [];
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
e.stopPropagation();
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Передаем e.key (строку), а не e.nativeEvent (объект)
const key = parseHotkey(e.key);
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
if (key === "UNIDENTIFIED") return;
changeRef.current = [...new Set([...changeRef.current, key])];
setKeys(changeRef.current);
};
const handleClear = () => {
onChange([]);
setKeys([]);
changeRef.current = [];
};
return (
<Box sx={{ display: "flex", alignItems: "center" }}>
<KeyWrapper>
<input
onKeyUp={() => {
const ret = changeRef.current.slice();
if (ret.length) {
onChange(ret);
changeRef.current = [];
}
}}
onKeyDown={(e) => {
const evt = e.nativeEvent;
e.preventDefault();
e.stopPropagation();
const key = parseHotkey(evt.key);
if (key === "UNIDENTIFIED") return;
changeRef.current = [...new Set([...changeRef.current, key])];
setKeys(changeRef.current);
}}
<div className="flex items-center gap-2">
<div className="relative rounded-md ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<Input
readOnly
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
className="absolute inset-0 z-10 h-full w-full cursor-text opacity-0"
/>
<div className="list">
{keys.map((key, index) => (
<Box display="flex">
<span className="delimiter" hidden={index === 0}>
+
</span>
<div key={key} className="item">
<div className="flex min-h-9 w-48 flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 py-2 text-sm">
{keys && keys.length > 0 ? (
keys.map((key) => (
<Badge key={key} variant="secondary">
{key}
</div>
</Box>
))}
</Badge>
))
) : (
<span className="text-muted-foreground">{t("Press any key")}</span>
)}
</div>
</KeyWrapper>
</div>
<IconButton
size="small"
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={t("Delete")}
color="inherit"
onClick={() => {
onChange([]);
setKeys([]);
}}
onClick={handleClear}
>
<DeleteRounded fontSize="inherit" />
</IconButton>
</Box>
<X className="h-4 w-4" />
</Button>
</div>
);
};

View File

@@ -1,18 +1,24 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { styled, Typography, Switch } from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef } from "@/components/base";
import { HotkeyInput } from "./hotkey-input";
import { showNotice } from "@/services/noticeService";
const ItemWrapper = styled("div")`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
`;
// Новые импорты
import { DialogRef } from "@/components/base";
import { HotkeyInput } from "./hotkey-input"; // Наш обновленный компонент
import { Switch } from "@/components/ui/switch"; // Стандартный Switch
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
const HOTKEY_FUNC = [
"open_or_close_dashboard",
@@ -31,27 +37,18 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
const { verge, patchVerge } = useVerge();
const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({});
const [enableGlobalHotkey, setEnableHotkey] = useState(
verge?.enable_global_hotkey ?? true,
);
const [enableGlobalHotkey, setEnableHotkey] = useState(true);
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
setEnableHotkey(verge?.enable_global_hotkey ?? true);
const map = {} as typeof hotkeyMap;
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);
},
close: () => setOpen(false),
@@ -61,13 +58,7 @@ 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}`;
})
@@ -79,40 +70,51 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
enable_global_hotkey: enableGlobalHotkey,
});
setOpen(false);
showNotice("success", t("Saved Successfully"));
} catch (err: any) {
showNotice("error", err.toString());
}
});
return (
<BaseDialog
open={open}
title={t("Hotkey Setting")}
contentSx={{ width: 450, maxHeight: 380 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<ItemWrapper style={{ marginBottom: 16 }}>
<Typography>{t("Enable Global Hotkey")}</Typography>
<Switch
edge="end"
checked={enableGlobalHotkey}
onChange={(e) => setEnableHotkey(e.target.checked)}
/>
</ItemWrapper>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Hotkey Setting")}</DialogTitle>
</DialogHeader>
{HOTKEY_FUNC.map((func) => (
<ItemWrapper key={func}>
<Typography>{t(func)}</Typography>
<HotkeyInput
value={hotkeyMap[func] ?? []}
onChange={(v) => setHotkeyMap((m) => ({ ...m, [func]: v }))}
/>
</ItemWrapper>
))}
</BaseDialog>
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<Label htmlFor="enable-global-hotkey" className="font-medium">
{t("Enable Global Hotkey")}
</Label>
<Switch
id="enable-global-hotkey"
checked={enableGlobalHotkey}
onCheckedChange={setEnableHotkey}
/>
</div>
<Separator />
{HOTKEY_FUNC.map((func) => (
<div key={func} className="flex items-center justify-between">
<Label className="text-muted-foreground">{t(func)}</Label>
<HotkeyInput
value={hotkeyMap[func] ?? []}
onChange={(v) => setHotkeyMap((m) => ({ ...m, [func]: v }))}
/>
</div>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Cancel")}</Button>
</DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,41 +1,40 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { forwardRef, useImperativeHandle, useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
List,
Button,
Select,
MenuItem,
styled,
ListItem,
ListItemText,
Box,
} from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { GuardState } from "./guard-state";
import { useLockFn } from "ahooks";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { convertFileSrc } from "@tauri-apps/api/core";
import { copyIconFile, getAppDir } from "@/services/cmds";
import { join } from "@tauri-apps/api/path";
import { exists } from "@tauri-apps/plugin-fs";
import getSystem from "@/utils/get-system";
import { join } from "@tauri-apps/api/path";
// Новые импорты
import { useVerge } from "@/hooks/use-verge";
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
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 getSystem from "@/utils/get-system";
const OS = getSystem();
const getIcons = async (icon_dir: string, name: string) => {
const updateTime = localStorage.getItem(`icon_${name}_update_time`) || "";
const icon_png = await join(icon_dir, `${name}-${updateTime}.png`);
const icon_ico = await join(icon_dir, `${name}-${updateTime}.ico`);
return {
icon_png,
icon_ico,
};
return { icon_png, icon_ico };
};
// Наш переиспользуемый компонент для строки настроек
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) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
@@ -45,42 +44,21 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
const [sysproxyIcon, setSysproxyIcon] = useState("");
const [tunIcon, setTunIcon] = useState("");
useEffect(() => {
initIconPath();
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");
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);
}, []);
async function initIconPath() {
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",
);
if (await exists(common_icon_ico)) {
setCommonIcon(common_icon_ico);
} else {
setCommonIcon(common_icon_png);
}
if (await exists(sysproxy_icon_ico)) {
setSysproxyIcon(sysproxy_icon_ico);
} else {
setSysproxyIcon(sysproxy_icon_png);
}
if (await exists(tun_icon_ico)) {
setTunIcon(tun_icon_ico);
} else {
setTunIcon(tun_icon_png);
}
}
useEffect(() => {
if (open) initIconPath();
}, [open, initIconPath]);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
@@ -95,298 +73,129 @@ 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]) {
onChangeData({ [key]: false });
await patchVerge({ [key]: false });
} else {
const selected = await openDialog({
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 });
}
}
});
return (
<BaseDialog
open={open}
title={t("Layout Setting")}
contentSx={{ width: 450 }}
disableOk
cancelBtn={t("Close")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List>
<Item>
<ListItemText primary={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 edge="end" />
</GuardState>
</Item>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Layout Setting")}</DialogTitle>
</DialogHeader>
<Item>
<ListItemText primary={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 edge="end" />
</GuardState>
</Item>
<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 />
</GuardState>
</SettingRow>
<Item>
<ListItemText primary={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 edge="end" />
</GuardState>
</Item>
<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>
<Item>
<ListItemText
primary={
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<span>{t("Hover Jump Navigator")}</span>
<TooltipIcon
title={t("Hover Jump Navigator Info")}
sx={{ opacity: "0.7" }}
/>
</Box>
}
/>
<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 edge="end" />
</GuardState>
</Item>
<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>
<Item>
<ListItemText primary={t("Nav Icon")} />
<GuardState
value={verge?.menu_icon ?? "monochrome"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ menu_icon: e })}
onGuard={(e) => patchVerge({ menu_icon: e })}
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
<MenuItem value="monochrome">{t("Monochrome")}</MenuItem>
<MenuItem value="colorful">{t("Colorful")}</MenuItem>
<MenuItem value="disable">{t("Disable")}</MenuItem>
</Select>
</GuardState>
</Item>
<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>
{OS === "macos" && (
<Item>
<ListItemText primary={t("Tray Icon")} />
<GuardState
value={verge?.tray_icon ?? "monochrome"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ tray_icon: e })}
onGuard={(e) => patchVerge({ tray_icon: e })}
>
<Select
size="small"
sx={{ width: 140, "> div": { py: "7.5px" } }}
>
<MenuItem value="monochrome">{t("Monochrome")}</MenuItem>
<MenuItem value="colorful">{t("Colorful")}</MenuItem>
</Select>
</GuardState>
</Item>
)}
{/* {OS === "macos" && (
<Item>
<ListItemText primary={t("Enable Tray Speed")} />
<GuardState
value={verge?.enable_tray_speed ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_tray_speed: e })}
onGuard={(e) => patchVerge({ enable_tray_speed: e })}
>
<Switch edge="end" />
</GuardState>
</Item>
)} */}
{OS === "macos" && (
<Item>
<ListItemText primary={t("Enable Tray Icon")} />
<GuardState
value={
verge?.enable_tray_icon === false &&
verge?.enable_tray_speed === false
? true
: (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 edge="end" />
</GuardState>
</Item>
)}
<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>
<Item>
<ListItemText primary={t("Common Tray Icon")} />
<GuardState
value={verge?.common_tray_icon}
onCatch={onError}
onChange={(e) => onChangeData({ common_tray_icon: e })}
onGuard={(e) => patchVerge({ common_tray_icon: e })}
>
<Button
variant="outlined"
size="small"
startIcon={
verge?.common_tray_icon &&
commonIcon && (
<img height="20px" src={convertFileSrc(commonIcon)} />
)
}
onClick={async () => {
if (verge?.common_tray_icon) {
onChangeData({ common_tray_icon: false });
patchVerge({ common_tray_icon: false });
} else {
const selected = await openDialog({
directory: false,
multiple: false,
filters: [
{
name: "Tray Icon Image",
extensions: ["png", "ico"],
},
],
});
{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>
if (selected) {
await copyIconFile(`${selected}`, "common");
await initIconPath();
onChangeData({ common_tray_icon: true });
patchVerge({ common_tray_icon: true });
console.log();
}
}
}}
>
{verge?.common_tray_icon ? t("Clear") : t("Browse")}
</Button>
</GuardState>
</Item>
<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>
</>
)}
<Item>
<ListItemText primary={t("System Proxy Tray Icon")} />
<GuardState
value={verge?.sysproxy_tray_icon}
onCatch={onError}
onChange={(e) => onChangeData({ sysproxy_tray_icon: e })}
onGuard={(e) => patchVerge({ sysproxy_tray_icon: e })}
>
<Button
variant="outlined"
size="small"
startIcon={
verge?.sysproxy_tray_icon &&
sysproxyIcon && (
<img height="20px" src={convertFileSrc(sysproxyIcon)} />
)
}
onClick={async () => {
if (verge?.sysproxy_tray_icon) {
onChangeData({ sysproxy_tray_icon: false });
patchVerge({ sysproxy_tray_icon: false });
} else {
const selected = await openDialog({
directory: false,
multiple: false,
filters: [
{
name: "Tray Icon Image",
extensions: ["png", "ico"],
},
],
});
if (selected) {
await copyIconFile(`${selected}`, "sysproxy");
await initIconPath();
onChangeData({ sysproxy_tray_icon: true });
patchVerge({ sysproxy_tray_icon: true });
}
}
}}
>
{verge?.sysproxy_tray_icon ? t("Clear") : t("Browse")}
</Button>
</GuardState>
</Item>
<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>
<Item>
<ListItemText primary={t("Tun Tray Icon")} />
<GuardState
value={verge?.tun_tray_icon}
onCatch={onError}
onChange={(e) => onChangeData({ tun_tray_icon: e })}
onGuard={(e) => patchVerge({ tun_tray_icon: e })}
>
<Button
variant="outlined"
size="small"
startIcon={
verge?.tun_tray_icon &&
tunIcon && <img height="20px" src={convertFileSrc(tunIcon)} />
}
onClick={async () => {
if (verge?.tun_tray_icon) {
onChangeData({ tun_tray_icon: false });
patchVerge({ tun_tray_icon: false });
} else {
const selected = await openDialog({
directory: false,
multiple: false,
filters: [
{
name: "Tun Icon Image",
extensions: ["png", "ico"],
},
],
});
if (selected) {
await copyIconFile(`${selected}`, "tun");
await initIconPath();
onChangeData({ tun_tray_icon: true });
patchVerge({ tun_tray_icon: true });
}
}
}}
>
{verge?.tun_tray_icon ? t("Clear") : t("Browse")}
</Button>
</GuardState>
</Item>
</List>
</BaseDialog>
<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>
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Close")}</Button></DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
const Item = styled(ListItem)(() => ({
padding: "5px 2px",
}));

View File

@@ -1,20 +1,38 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
List,
ListItem,
ListItemText,
TextField,
Typography,
InputAdornment,
} from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { entry_lightweight_mode } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
// Новые импорты
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
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>
</div>
);
export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
@@ -22,7 +40,7 @@ export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
const [open, setOpen] = useState(false);
const [values, setValues] = useState({
autoEnterLiteMode: false,
autoEnterLiteModeDelay: 10, // 默认10分钟
autoEnterLiteModeDelay: 10,
});
useImperativeHandle(ref, () => ({
@@ -43,103 +61,73 @@ export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
auto_light_weight_minutes: values.autoEnterLiteModeDelay,
});
setOpen(false);
showNotice("success", t("Saved Successfully"));
} catch (err: any) {
showNotice("error", err.message || err.toString());
}
});
return (
<BaseDialog
open={open}
title={t("LightWeight Mode Settings")}
contentSx={{ width: 450 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Enter LightWeight Mode Now")} />
<Typography
variant="button"
sx={{
cursor: "pointer",
color: "primary.main",
"&:hover": { textDecoration: "underline" },
}}
onClick={async () => await entry_lightweight_mode()}
>
{t("Enable")}
</Typography>
</ListItem>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("LightWeight Mode Settings")}</DialogTitle>
</DialogHeader>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Auto Enter LightWeight Mode")}
sx={{ maxWidth: "fit-content" }}
/>
<TooltipIcon
title={t("Auto Enter LightWeight Mode Info")}
sx={{ opacity: "0.7" }}
/>
<Switch
edge="end"
checked={values.autoEnterLiteMode}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoEnterLiteMode: c }))
}
sx={{ marginLeft: "auto" }}
/>
</ListItem>
<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>
{values.autoEnterLiteMode && (
<>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Enter LightWeight Mode Delay")} />
<TextField
autoComplete="off"
size="small"
type="number"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 150 }}
value={values.autoEnterLiteModeDelay}
onChange={(e) =>
setValues((v) => ({
...v,
autoEnterLiteModeDelay: parseInt(e.target.value) || 1,
}))
}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("mins")}
</InputAdornment>
),
},
}}
/>
</ListItem>
<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>
<ListItem sx={{ padding: "5px 2px" }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontStyle: "italic" }}
>
{t(
"When closing the window, LightWeight Mode will be automatically activated after _n minutes",
{ n: values.autoEnterLiteModeDelay },
)}
</Typography>
</ListItem>
</>
)}
</List>
</BaseDialog>
{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>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,19 +1,40 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
List,
ListItem,
ListItemText,
MenuItem,
Select,
TextField,
InputAdornment,
} from "@mui/material";
// Новые импорты
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
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
} 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>
</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> );
};
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
@@ -51,206 +72,110 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const onSave = useLockFn(async () => {
try {
await patchVerge({
app_log_level: values.appLogLevel,
app_log_level: values.appLogLevel as any,
auto_close_connection: values.autoCloseConnection,
auto_check_update: values.autoCheckUpdate,
enable_builtin_enhanced: values.enableBuiltinEnhanced,
proxy_layout_column: values.proxyLayoutColumn,
proxy_layout_column: Number(values.proxyLayoutColumn),
default_latency_test: values.defaultLatencyTest,
default_latency_timeout: values.defaultLatencyTimeout,
default_latency_timeout: Number(values.defaultLatencyTimeout),
auto_log_clean: values.autoLogClean as any,
});
setOpen(false);
showNotice("success", t("Saved Successfully"));
} catch (err: any) {
showNotice("error", err.toString());
}
});
const handleValueChange = (key: keyof typeof values, value: any) => {
setValues(v => ({ ...v, [key]: value }));
};
return (
<BaseDialog
open={open}
title={t("Miscellaneous")}
contentSx={{ width: 450 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("App Log Level")} />
<Select
size="small"
sx={{ width: 100, "> div": { py: "7.5px" } }}
value={values.appLogLevel}
onChange={(e) =>
setValues((v) => ({
...v,
appLogLevel: e.target.value as string,
}))
}
>
{["trace", "debug", "info", "warn", "error", "silent"].map((i) => (
<MenuItem value={i} key={i}>
{i[0].toUpperCase() + i.slice(1).toLowerCase()}
</MenuItem>
))}
</Select>
</ListItem>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("Miscellaneous")}</DialogTitle>
</DialogHeader>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Auto Close Connections")}
sx={{ maxWidth: "fit-content" }}
/>
<TooltipIcon
title={t("Auto Close Connections Info")}
sx={{ opacity: "0.7" }}
/>
<Switch
edge="end"
checked={values.autoCloseConnection}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoCloseConnection: c }))
}
sx={{ marginLeft: "auto" }}
/>
</ListItem>
<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>
<SelectContent>
{["trace", "debug", "info", "warn", "error", "silent"].map((i) => (
<SelectItem value={i} key={i}>{i[0].toUpperCase() + i.slice(1).toLowerCase()}</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Check Update")} />
<Switch
edge="end"
checked={values.autoCheckUpdate}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoCheckUpdate: c }))
}
/>
</ListItem>
<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>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Enable Builtin Enhanced")}
sx={{ maxWidth: "fit-content" }}
/>
<TooltipIcon
title={t("Enable Builtin Enhanced Info")}
sx={{ opacity: "0.7" }}
/>
<Switch
edge="end"
checked={values.enableBuiltinEnhanced}
onChange={(_, c) =>
setValues((v) => ({ ...v, enableBuiltinEnhanced: c }))
}
sx={{ marginLeft: "auto" }}
/>
</ListItem>
<SettingRow label={<LabelWithIcon icon={RefreshCw} text={t("Auto Check Update")} />}>
<Switch checked={values.autoCheckUpdate} onCheckedChange={(c) => handleValueChange("autoCheckUpdate", c)} />
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Proxy Layout Columns")} />
<Select
size="small"
sx={{ width: 135, "> div": { py: "7.5px" } }}
value={values.proxyLayoutColumn}
onChange={(e) =>
setValues((v) => ({
...v,
proxyLayoutColumn: e.target.value as number,
}))
}
>
<MenuItem value={6} key={6}>
{t("Auto Columns")}
</MenuItem>
{[1, 2, 3, 4, 5].map((i) => (
<MenuItem value={i} key={i}>
{i}
</MenuItem>
))}
</Select>
</ListItem>
<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>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Log Clean")} />
<Select
size="small"
sx={{ width: 135, "> div": { py: "7.5px" } }}
value={values.autoLogClean}
onChange={(e) =>
setValues((v) => ({
...v,
autoLogClean: e.target.value as number,
}))
}
>
{[
{ key: t("Never Clean"), value: 0 },
{ key: t("Retain _n Days", { n: 1 }), value: 1 },
{ 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) => (
<MenuItem key={i.value} value={i.value}>
{i.key}
</MenuItem>
))}
</Select>
</ListItem>
<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>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Default Latency Test")}
sx={{ maxWidth: "fit-content" }}
/>
<TooltipIcon
title={t("Default Latency Test Info")}
sx={{ opacity: "0.7" }}
/>
<TextField
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250, marginLeft: "auto" }}
value={values.defaultLatencyTest}
placeholder="https://cp.cloudflare.com/generate_204"
onChange={(e) =>
setValues((v) => ({ ...v, defaultLatencyTest: e.target.value }))
}
/>
</ListItem>
<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 },
{ key: t("Retain _n Days", { n: 1 }), value: 1 },
{ 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>))}
</SelectContent>
</Select>
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Default Latency Timeout")} />
<TextField
autoComplete="new-password"
size="small"
type="number"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.defaultLatencyTimeout}
placeholder="10000"
onChange={(e) =>
setValues((v) => ({
...v,
defaultLatencyTimeout: parseInt(e.target.value),
}))
}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">{t("millis")}</InputAdornment>
),
},
}}
/>
</ListItem>
</List>
</BaseDialog>
<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)}
/>
</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>
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,140 +1,117 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { forwardRef, useImperativeHandle, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base";
import { getNetworkInterfacesInfo } from "@/services/cmds";
import { alpha, Box, Button, IconButton } from "@mui/material";
import { ContentCopyRounded } from "@mui/icons-material";
import { useLockFn } from "ahooks";
import useSWR from "swr";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
// Новые импорты
import { getNetworkInterfacesInfo } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { DialogRef } from "@/components/base";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
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();
const handleCopy = useLockFn(async () => {
if (!props.content) return;
await writeText(props.content);
showNotice("success", t("Copy Success"));
});
return (
<div className="flex justify-between items-center text-sm my-2">
<p className="text-muted-foreground">{props.label}</p>
<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>
</TooltipProvider>
</div>
</div>
);
};
export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [networkInterfaces, setNetworkInterfaces] = useState<
INetworkInterface[]
>([]);
const [isV4, setIsV4] = useState(true);
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
},
open: () => setOpen(true),
close: () => setOpen(false),
}));
useEffect(() => {
if (!open) return;
getNetworkInterfacesInfo().then((res) => {
setNetworkInterfaces(res);
});
}, [open]);
const { data: networkInterfaces } = useSWR(
open ? "clash-verge-rev-internal://network-interfaces" : null,
getNetworkInterfacesInfo,
{ fallbackData: [] }
);
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{t("Network Interface")}
<Box>
<Button
variant="contained"
size="small"
onClick={() => {
setIsV4((prev) => !prev);
}}
>
{isV4 ? "Ipv6" : "Ipv4"}
</Button>
</Box>
</Box>
}
contentSx={{ width: 450 }}
disableOk
cancelBtn={t("Close")}
onCancel={() => setOpen(false)}
>
{networkInterfaces.map((item) => (
<Box key={item.name}>
<h4>{item.name}</h4>
<Box>
{isV4 && (
<>
{item.addr.map(
(address) =>
address.V4 && (
<AddressDisplay
key={address.V4.ip}
label={t("Ip Address")}
content={address.V4.ip}
/>
),
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<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>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
</div>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto -mx-6 px-6">
{networkInterfaces?.map((item, index) => (
<div key={item.name} className="py-2">
<h4 className="font-semibold text-base mb-1">{item.name}</h4>
<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.V6 && <AddressDisplay key={address.V6.ip} label={t("Ip Address")} content={address.V6.ip} />)}
<AddressDisplay label={t("Mac Address")} content={item.mac_addr ?? ""} />
</>
)}
<AddressDisplay
label={t("Mac Address")}
content={item.mac_addr ?? ""}
/>
</>
)}
{!isV4 && (
<>
{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 ?? ""}
/>
</>
)}
</Box>
</Box>
))}
</BaseDialog>
</div>
{index < networkInterfaces.length - 1 && <Separator className="mt-2"/>}
</div>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
const AddressDisplay = (props: { label: string; content: string }) => {
const { t } = useTranslation();
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
margin: "8px 0",
}}
>
<Box>{props.label}</Box>
<Box
sx={({ palette }) => ({
borderRadius: "8px",
padding: "2px 2px 2px 8px",
background:
palette.mode === "dark"
? alpha(palette.background.paper, 0.3)
: alpha(palette.grey[400], 0.3),
})}
>
<Box sx={{ display: "inline", userSelect: "text" }}>
{props.content}
</Box>
<IconButton
size="small"
onClick={async () => {
await writeText(props.content);
showNotice("success", t("Copy Success"));
}}
>
<ContentCopyRounded sx={{ fontSize: "18px" }} />
</IconButton>
</Box>
</Box>
);
};

View File

@@ -1,54 +1,75 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
// Новые импорты
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
} from "@mui/material";
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
interface Props {
// Компонент теперь сам управляет своим состоянием,
// но вызывает onConfirm при подтверждении
onConfirm: (passwd: string) => Promise<void>;
// onCancel?: () => void; // Можно добавить, если нужна кнопка отмены
}
export const PasswordInput = (props: Props) => {
const { onConfirm } = props;
const { t } = useTranslation();
const [passwd, setPasswd] = useState("");
useEffect(() => {
if (!open) return;
}, [open]);
const handleSubmit = async (event?: React.FormEvent) => {
// Предотвращаем стандартную отправку формы
event?.preventDefault();
await onConfirm(passwd);
};
return (
<Dialog open={true} maxWidth="xs" fullWidth>
<DialogTitle>{t("Please enter your root password")}</DialogTitle>
// Этот диалог будет открыт всегда, пока он отрендерен на странице
<AlertDialog open={true}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("Please enter your root password")}</AlertDialogTitle>
<AlertDialogDescription>
{t("This action requires administrator privileges.")}
</AlertDialogDescription>
</AlertDialogHeader>
<DialogContent>
<TextField
sx={{ mt: 1 }}
autoFocus
label={t("Password")}
fullWidth
size="small"
type="password"
value={passwd}
onKeyDown={(e) => e.key === "Enter" && onConfirm(passwd)}
onChange={(e) => setPasswd(e.target.value)}
></TextField>
</DialogContent>
<form onSubmit={handleSubmit}>
<div className="py-4">
<Label htmlFor="password-input">{t("Password")}</Label>
<Input
id="password-input"
type="password"
autoFocus
value={passwd}
onChange={(e) => setPasswd(e.target.value)}
className="mt-2"
/>
</div>
{/* Скрытая кнопка для того, чтобы Enter в поле ввода вызывал onSubmit */}
<button type="submit" className="hidden" />
</form>
<DialogActions>
<Button
onClick={async () => await onConfirm(passwd)}
variant="contained"
>
{t("Confirm")}
</Button>
</DialogActions>
</Dialog>
<AlertDialogFooter>
{/* У этого диалога нет кнопки отмены */}
<AlertDialogAction asChild>
<Button type="button" onClick={handleSubmit}>
{t("Confirm")}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,21 +1,32 @@
import React, { ReactNode, useState } from "react";
import {
Box,
List,
ListItem,
ListItemButton,
ListItemText,
ListSubheader,
} from "@mui/material";
import { ChevronRightRounded } from "@mui/icons-material";
import CircularProgress from "@mui/material/CircularProgress";
import isAsyncFunction from "@/utils/is-async-function";
import { cn } from "@root/lib/utils";
// Новые импорты
import { Loader2, ChevronRight } from "lucide-react";
// --- Новый компонент SettingList ---
interface ListProps {
title: string;
children: ReactNode;
}
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>
);
// --- Новый компонент SettingItem ---
interface ItemProps {
label: ReactNode;
extra?: ReactNode;
children?: ReactNode;
secondary?: ReactNode;
extra?: ReactNode; // Для иконок-подсказок рядом с лейблом
children?: ReactNode; // Для элементов управления (Switch, Select и т.д.)
secondary?: ReactNode; // Для текста-описания под лейблом
onClick?: () => void | Promise<any>;
}
@@ -23,16 +34,11 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
const { label, extra, children, secondary, onClick } = props;
const clickable = !!onClick;
const primary = (
<Box sx={{ display: "flex", alignItems: "center", fontSize: "14px" }}>
<span>{label}</span>
{extra ? extra : null}
</Box>
);
const [isLoading, setIsLoading] = useState(false);
const handleClick = () => {
if (onClick) {
// Если onClick - асинхронная функция, показываем спиннер
if (isAsyncFunction(onClick)) {
setIsLoading(true);
onClick()!.finally(() => setIsLoading(false));
@@ -42,44 +48,34 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
}
};
return clickable ? (
<ListItem disablePadding>
<ListItemButton onClick={handleClick} disabled={isLoading}>
<ListItemText primary={primary} secondary={secondary} />
return (
<div
onClick={clickable ? handleClick : undefined}
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"
)}
>
{/* Левая часть: заголовок и описание */}
<div className="flex flex-col gap-1 pr-4">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label}</p>
{extra}
</div>
{secondary && <p className="text-sm text-muted-foreground">{secondary}</p>}
</div>
{/* Правая часть: элемент управления или иконка */}
<div className="flex-shrink-0">
{isLoading ? (
<CircularProgress color="inherit" size={20} />
<Loader2 className="h-5 w-5 animate-spin" />
) : clickable ? (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronRightRounded />
children
)}
</ListItemButton>
</ListItem>
) : (
<ListItem sx={{ pt: "5px", pb: "5px" }}>
<ListItemText primary={primary} secondary={secondary} />
{children}
</ListItem>
</div>
</div>
);
};
export const SettingList: React.FC<{
title: string;
children: ReactNode;
}> = (props) => (
<List>
<ListSubheader
sx={[
{ background: "transparent", fontSize: "16px", fontWeight: "700" },
({ palette }) => {
return {
color: palette.text.primary,
};
},
]}
disableSticky
>
{props.title}
</ListSubheader>
{props.children}
</List>
);

View File

@@ -1,36 +1,37 @@
import { Button, ButtonGroup } from "@mui/material";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
// Определяем возможные значения для TypeScript
type StackMode = "system" | "gvisor" | "mixed";
interface Props {
value?: string;
onChange?: (value: string) => void;
onChange?: (value: StackMode) => void;
}
export const StackModeSwitch = (props: Props) => {
const { value, onChange } = props;
const { t } = useTranslation();
// Массив с опциями для удобного рендеринга
const modes: StackMode[] = ["system", "gvisor", "mixed"];
return (
<ButtonGroup size="small" sx={{ my: "4px" }}>
<Button
variant={value?.toLowerCase() === "system" ? "contained" : "outlined"}
onClick={() => onChange?.("system")}
sx={{ textTransform: "capitalize" }}
>
System
</Button>
<Button
variant={value?.toLowerCase() === "gvisor" ? "contained" : "outlined"}
onClick={() => onChange?.("gvisor")}
sx={{ textTransform: "capitalize" }}
>
gVisor
</Button>
<Button
variant={value?.toLowerCase() === "mixed" ? "contained" : "outlined"}
onClick={() => onChange?.("mixed")}
sx={{ textTransform: "capitalize" }}
>
Mixed
</Button>
</ButtonGroup>
// Используем наш стандартный контейнер для создания группы кнопок
<div className="flex items-center rounded-md border bg-muted p-0.5">
{modes.map((mode) => (
<Button
key={mode}
// Активная кнопка получает основной цвет темы
variant={value?.toLowerCase() === mode ? "default" : "ghost"}
onClick={() => onChange?.(mode)}
size="sm"
className="capitalize px-3 text-xs"
>
{/* Используем t() для возможной локализации в будущем */}
{t(mode)}
</Button>
))}
</div>
);
};

View File

@@ -1,6 +1,12 @@
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { forwardRef, useImperativeHandle, useState, useMemo, useEffect, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import useSWR, { mutate } from "swr";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
// Новые импорты
import { DialogRef, Switch } from "@/components/base";
import { BaseFieldset } from "@/components/base/base-fieldset";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";
@@ -14,66 +20,71 @@ import {
} from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import { EditRounded } from "@mui/icons-material";
import {
Autocomplete,
Button,
InputAdornment,
List,
ListItem,
ListItemText,
styled,
TextField,
Typography,
} from "@mui/material";
import { useLockFn } from "ahooks";
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import useSWR, { mutate } from "swr";
const DEFAULT_PAC = `function FindProxyForURL(url, host) {
return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;";
}`;
/** NO_PROXY validation */
// *., cdn*., *, etc.
const domain_subdomain_part = String.raw`(?:[a-z0-9\-\*]+\.|\*)*`;
// .*, .cn, .moe, .co*, *
const domain_tld_part = String.raw`(?:\w{2,64}\*?|\*)`;
// *epicgames*, *skk.moe, *.skk.moe, skk.*, sponsor.cdn.skk.moe, *.*, etc.
// also matches 192.168.*, 10.*, 127.0.0.*, etc. (partial ipv4)
const rDomainSimple = domain_subdomain_part + domain_tld_part;
import { Button } from "@/components/ui/button";
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 { Check, ChevronsUpDown, Edit, Loader2 } from "lucide-react";
import { cn } from "@root/lib/utils";
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 ipv6_part = "(?:[a-fA-F0-9:])+";
const rLocal = `localhost|<local>|localdomain`;
const getValidReg = (isWindows: boolean) => {
// 127.0.0.1 (full ipv4)
const rIPv4Unix = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}(?:\/\d{1,2})?`;
const rIPv4Windows = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}`;
const rIPv6Unix = String.raw`(?:${ipv6_part}:+)+${ipv6_part}(?:\/\d{1,3})?`;
const rIPv6Windows = String.raw`(?:${ipv6_part}:+)+${ipv6_part}`;
const rValidPart = `${rDomainSimple}|${
isWindows ? rIPv4Windows : rIPv4Unix
}|${isWindows ? rIPv6Windows : rIPv6Unix}|${rLocal}`;
const rValidPart = `${rDomainSimple}|${isWindows ? rIPv4Windows : rIPv4Unix}|${isWindows ? rIPv6Windows : rIPv6Unix}|${rLocal}`;
const separator = isWindows ? ";" : ",";
const rValid = String.raw`^(${rValidPart})(?:${separator}\s?(${rValidPart}))*${separator}?$`;
return new RegExp(rValid);
};
// --- Компонент 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 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) => {
const { t } = useTranslation();
const isWindows = getSystem() === "windows";
@@ -91,57 +102,25 @@ 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, mutate: mutateClash } = 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();
}
@@ -151,36 +130,20 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
try {
const currentSysProxy = await getSystemProxy();
const currentAutoProxy = await getAutotemProxy();
if (value.pac ? currentAutoProxy?.enable : currentSysProxy?.enable) {
// 临时关闭系统代理
await patchVergeConfig({ enable_system_proxy: false });
// 减少等待时间
await new Promise((resolve) => setTimeout(resolve, 200));
// 重新开启系统代理
await patchVergeConfig({ enable_system_proxy: true });
// 更新UI状态
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();
// 为当前状态计算系统代理地址
const getSystemProxyAddress = useMemo(() => {
if (!clashConfig) return "-";
const isPacMode = value.pac ?? false;
if (isPacMode) {
const host = value.proxy_host || "127.0.0.1";
const port = verge?.verge_mixed_port || clashConfig["mixed-port"] || 7897;
@@ -188,448 +151,160 @@ 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";
// 根据环境判断PAC端口
const port = import.meta.env.DEV ? 11233 : 33331;
return `http://${host}:${port}/commands/pac`;
}, [value.proxy_host]);
const fetchNetworkInterfaces = async () => {
try {
const interfaces = await getNetworkInterfacesInfo();
const ipAddresses: string[] = [];
interfaces.forEach((iface) => {
iface.addr.forEach((address) => {
if (address.V4 && address.V4.ip) ipAddresses.push(address.V4.ip);
if (address.V6 && address.V6.ip) ipAddresses.push(address.V6.ip);
});
});
let hostname = "";
try {
hostname = await getSystemHostname();
if (hostname && hostname !== "localhost" && hostname !== "127.0.0.1") {
hostname = hostname + ".local";
}
} catch (err) { console.error("Failed to get hostname:", err); }
const options = ["127.0.0.1", "localhost"];
if (hostname) options.push(hostname);
options.push(...ipAddresses);
setHostOptions(Array.from(new Set(options)));
} catch (error) {
console.error("Failed to get network interfaces:", error);
setHostOptions(["127.0.0.1", "localhost"]);
}
};
useImperativeHandle(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((p) => setSysproxy(p));
getAutotemProxy().then((p) => setAutoproxy(p));
getSystemProxy().then(setSysproxy);
getAutotemProxy().then(setAutoproxy);
fetchNetworkInterfaces();
},
close: () => setOpen(false),
}));
// 获取网络接口和主机名
const fetchNetworkInterfaces = async () => {
try {
// 获取系统网络接口信息
const interfaces = await getNetworkInterfacesInfo();
const ipAddresses: string[] = [];
// 从interfaces中提取IPv4和IPv6地址
interfaces.forEach((iface) => {
iface.addr.forEach((address) => {
if (address.V4 && address.V4.ip) {
ipAddresses.push(address.V4.ip);
}
if (address.V6 && address.V6.ip) {
ipAddresses.push(address.V6.ip);
}
});
});
// 获取当前系统的主机名
let hostname = "";
try {
hostname = await getSystemHostname();
console.log("获取到主机名:", hostname);
} catch (err) {
console.error("获取主机名失败:", err);
}
// 构建选项列表
const options = ["127.0.0.1", "localhost"];
// 确保主机名添加到列表,即使它是空字符串也记录下来
if (hostname) {
// 如果主机名不是localhost或127.0.0.1,则添加它
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
hostname = hostname + ".local";
options.push(hostname);
console.log("主机名已添加到选项中:", hostname);
} else {
console.log("主机名与已有选项重复:", hostname);
}
} else {
console.log("主机名为空");
}
// 添加IP地址
options.push(...ipAddresses);
// 去重
const uniqueOptions = Array.from(new Set(options));
console.log("最终选项列表:", uniqueOptions);
setHostOptions(uniqueOptions);
} catch (error) {
console.error("获取网络接口失败:", error);
// 失败时至少提供基本选项
setHostOptions(["127.0.0.1", "localhost"]);
}
};
const onSave = useLockFn(async () => {
if (value.duration < 1) {
showNotice(
"error",
t("Proxy Daemon Duration Cannot be Less than 1 Second"),
);
return;
}
if (value.bypass && !validReg.test(value.bypass)) {
showNotice("error", t("Invalid Bypass Format"));
return;
}
// 修改验证规则允许IP和主机名
const ipv4Regex =
/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Regex =
/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
const hostnameRegex =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
if (
!ipv4Regex.test(value.proxy_host) &&
!ipv6Regex.test(value.proxy_host) &&
!hostnameRegex.test(value.proxy_host)
) {
showNotice("error", t("Invalid Proxy Host Format"));
return;
}
if (value.duration < 1) { showNotice("error", t("Proxy Daemon Duration Cannot be Less than 1 Second")); return; }
if (value.bypass && !validReg.test(value.bypass)) { showNotice("error", t("Invalid Bypass Format")); return; }
const ipv4Regex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
const hostnameRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][a-zA-Z0-9\-]*[A-Za-z0-9])$/;
if (!ipv4Regex.test(value.proxy_host) && !ipv6Regex.test(value.proxy_host) && !hostnameRegex.test(value.proxy_host)) { showNotice("error", t("Invalid Proxy Host Format")); return; }
setSaving(true);
setOpen(false);
setSaving(false);
const patch: Partial<IVergeConfig> = {};
if (value.guard !== enable_proxy_guard) {
patch.enable_proxy_guard = value.guard;
}
if (value.duration !== proxy_guard_duration) {
patch.proxy_guard_duration = value.duration;
}
if (value.bypass !== system_proxy_bypass) {
patch.system_proxy_bypass = value.bypass;
}
if (value.pac !== proxy_auto_config) {
patch.proxy_auto_config = value.pac;
}
if (value.use_default !== use_default_bypass) {
patch.use_default_bypass = value.use_default;
}
let proxyHost = value.proxy_host;
if (ipv6Regex.test(proxyHost) && !proxyHost.startsWith("[") && !proxyHost.endsWith("]")) { proxyHost = `[${proxyHost}]`; }
const patch: Partial<IVergeConfig> = {
enable_proxy_guard: value.guard,
proxy_guard_duration: value.duration,
system_proxy_bypass: value.bypass,
proxy_auto_config: value.pac,
use_default_bypass: value.use_default,
proxy_host: proxyHost,
};
let pacContent = value.pac_content;
if (pacContent) {
pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host);
// 将 mixed-port 转换为字符串
const mixedPortStr = (clashConfig?.["mixed-port"] || "").toString();
pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr);
}
patch.pac_file_content = pacContent;
if (pacContent !== pac_file_content) {
patch.pac_file_content = pacContent;
try {
await patchVerge(patch);
setTimeout(() => {
if (enabled) resetSystemProxy();
}, 50);
} catch (err: any) {
showNotice("error", err.toString());
} finally {
setSaving(false);
setOpen(false);
}
// 处理IPv6地址如果是IPv6地址但没有被方括号包围则添加方括号
let proxyHost = value.proxy_host;
if (
ipv6Regex.test(proxyHost) &&
!proxyHost.startsWith("[") &&
!proxyHost.endsWith("]")
) {
proxyHost = `[${proxyHost}]`;
}
if (proxyHost !== proxy_host) {
patch.proxy_host = proxyHost;
}
// 判断是否需要重置系统代理
const needResetProxy =
value.pac !== proxy_auto_config ||
proxyHost !== proxy_host ||
pacContent !== pac_file_content ||
value.bypass !== system_proxy_bypass ||
value.use_default !== use_default_bypass;
Promise.resolve().then(async () => {
try {
// 乐观更新本地状态
if (Object.keys(patch).length > 0) {
mutateVerge({ ...verge, ...patch }, false);
}
if (Object.keys(patch).length > 0) {
await patchVerge(patch);
}
setTimeout(async () => {
try {
await Promise.all([
mutate("getSystemProxy"),
mutate("getAutotemProxy"),
]);
// 如果需要重置代理且代理当前启用
if (needResetProxy && enabled) {
const [currentSysProxy, currentAutoProxy] = await Promise.all([
getSystemProxy(),
getAutotemProxy(),
]);
const isProxyActive = value.pac
? currentAutoProxy?.enable
: currentSysProxy?.enable;
if (isProxyActive) {
await patchVergeConfig({ enable_system_proxy: false });
await new Promise((resolve) => setTimeout(resolve, 50));
await patchVergeConfig({ enable_system_proxy: true });
await Promise.all([
mutate("getSystemProxy"),
mutate("getAutotemProxy"),
]);
}
}
} catch (err) {
console.warn("代理状态更新失败:", err);
}
}, 50);
} catch (err: any) {
console.error("配置保存失败:", err);
mutateVerge();
showNotice("error", err.toString());
// setOpen(true);
}
});
});
return (
<BaseDialog
open={open}
title={t("System Proxy Setting")}
contentSx={{ width: 450, maxHeight: 565 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
loading={saving}
disableOk={saving}
>
<List>
<BaseFieldset label={t("Current System Proxy")} padding="15px 10px">
<FlexBox>
<Typography className="label">{t("Enable status")}</Typography>
<Typography className="value">
{value.pac
? autoproxy?.enable
? t("Enabled")
: t("Disabled")
: sysproxy?.enable
? t("Enabled")
: t("Disabled")}
</Typography>
</FlexBox>
{!value.pac && (
<>
<FlexBox>
<Typography className="label">{t("Server Addr")}</Typography>
<Typography className="value">
{getSystemProxyAddress}
</Typography>
</FlexBox>
</>
)}
{value.pac && (
<FlexBox>
<Typography className="label">{t("PAC URL")}</Typography>
<Typography className="value">
{getCurrentPacUrl || "-"}
</Typography>
</FlexBox>
)}
</BaseFieldset>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Proxy Host")} />
<Autocomplete
size="small"
sx={{ width: 150 }}
options={hostOptions}
value={value.proxy_host}
freeSolo
renderInput={(params) => (
<TextField {...params} placeholder="127.0.0.1" size="small" />
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<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>
</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" />
</SettingRow>
<SettingRow label={t("Use PAC Mode")}>
<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>
<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>
</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>
)}
onChange={(_, newValue) => {
setValue((v) => ({
...v,
proxy_host: newValue || "127.0.0.1",
}));
}}
onInputChange={(_, newInputValue) => {
setValue((v) => ({
...v,
proxy_host: newInputValue || "127.0.0.1",
}));
}}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Use PAC Mode")} />
<Switch
edge="end"
disabled={!enabled}
checked={value.pac}
onChange={(_, e) => setValue((v) => ({ ...v, pac: e }))}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Proxy Guard")}
sx={{ maxWidth: "fit-content" }}
/>
<TooltipIcon title={t("Proxy Guard Info")} sx={{ opacity: "0.7" }} />
<Switch
edge="end"
disabled={!enabled}
checked={value.guard}
onChange={(_, e) => setValue((v) => ({ ...v, guard: e }))}
sx={{ marginLeft: "auto" }}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Guard Duration")} />
<TextField
disabled={!enabled}
size="small"
value={value.duration}
sx={{ width: 100 }}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">s</InputAdornment>,
},
}}
onChange={(e) => {
setValue((v) => ({
...v,
duration: +e.target.value.replace(/\D/, ""),
}));
}}
/>
</ListItem>
{!value.pac && (
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Always use Default Bypass")} />
<Switch
edge="end"
disabled={!enabled}
checked={value.use_default}
onChange={(_, e) =>
setValue((v) => ({
...v,
use_default: e,
// 当取消选择use_default且当前bypass为空时填充默认值
bypass: !e && !v.bypass ? defaultBypass() : v.bypass,
}))
}
/>
</ListItem>
)}
{!value.pac && !value.use_default && (
<>
<ListItemText primary={t("Proxy Bypass")} />
<TextField
error={value.bypass ? !validReg.test(value.bypass) : false}
disabled={!enabled}
size="small"
multiline
rows={4}
sx={{ width: "100%" }}
value={value.bypass}
onChange={(e) => {
setValue((v) => ({ ...v, bypass: e.target.value }));
}}
/>
</>
)}
{!value.pac && value.use_default && (
<>
<ListItemText primary={t("Bypass")} />
<FlexBox>
<TextField
disabled={true}
size="small"
multiline
rows={4}
sx={{ width: "100%" }}
value={defaultBypass()}
/>
</FlexBox>
</>
)}
{value.pac && (
<>
<ListItem sx={{ padding: "5px 2px", alignItems: "start" }}>
<ListItemText
primary={t("PAC Script Content")}
sx={{ padding: "3px 0" }}
/>
<Button
startIcon={<EditRounded />}
variant="outlined"
onClick={() => {
setEditorOpen(true);
}}
>
{t("Edit")} PAC
</Button>
{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)}
/>
)}
</ListItem>
</>
)}
</List>
</BaseDialog>
{!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>
)}
{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>
)}
</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>
</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)} />}
</>
);
});
const FlexBox = styled("div")`
display: flex;
margin-top: 4px;
.label {
flex: none;
//width: 85px;
}
`;

View File

@@ -1,7 +1,8 @@
import { useTranslation } from "react-i18next";
import { Button, ButtonGroup } from "@mui/material";
import { Button } from "@/components/ui/button";
type ThemeValue = IVergeConfig["theme_mode"];
// Определяем возможные значения темы для TypeScript
type ThemeValue = "light" | "dark" | "system";
interface Props {
value?: ThemeValue;
@@ -12,20 +13,25 @@ export const ThemeModeSwitch = (props: Props) => {
const { value, onChange } = props;
const { t } = useTranslation();
const modes = ["light", "dark", "system"] as const;
const modes: ThemeValue[] = ["light", "dark", "system"];
return (
<ButtonGroup size="small" sx={{ my: "4px" }}>
// Создаем ту же самую группу кнопок, что и раньше
<div className="flex items-center rounded-md border bg-muted p-0.5">
{modes.map((mode) => (
<Button
key={mode}
variant={mode === value ? "contained" : "outlined"}
variant={mode === value ? "default" : "ghost"}
onClick={() => onChange?.(mode)}
sx={{ textTransform: "capitalize" }}
size="sm"
className="capitalize px-3 text-xs"
>
{/* Ключевое исправление: мы используем ключи `theme.light`, `theme.dark` и т.д.
Это стандартный подход в i18next для корректной локализации.
*/}
{t(`theme.${mode}`)}
</Button>
))}
</ButtonGroup>
</div>
);
};

View File

@@ -1,25 +1,61 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { forwardRef, useImperativeHandle, useState, useMemo } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
Button,
List,
ListItem,
ListItemText,
styled,
TextField,
useTheme,
} from "@mui/material";
import { useTheme } from "@mui/material/styles"; // Оставляем для получения дефолтных цветов темы
// Новые импорты
import { useVerge } from "@/hooks/use-verge";
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
import { BaseDialog, DialogRef } from "@/components/base";
import { DialogRef } from "@/components/base";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { EditRounded } from "@mui/icons-material";
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 { Label } from "@/components/ui/label";
import { Edit } from "lucide-react";
interface Props {}
// Дочерний компонент для одной строки настройки цвета
const ColorSettingRow = ({ label, value, placeholder, onChange }: {
label: string;
value: string;
placeholder: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) => (
<div className="flex items-center justify-between">
<Label>{label}</Label>
<div className="flex items-center gap-2">
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Этот контейнер теперь позиционирован, чтобы спрятать input внутри */}
<div className="relative h-6 w-6 cursor-pointer">
{/* Видимый образец цвета */}
<div
className="h-full w-full rounded-full border"
style={{ backgroundColor: value || placeholder }}
/>
{/* Невидимый input, который и открывает палитру */}
<Input
type="color"
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
value={value || placeholder}
onChange={onChange}
/>
</div>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
<Input
className="w-32 h-8 font-mono text-sm"
value={value ?? ""}
placeholder={placeholder}
onChange={onChange}
/>
</div>
</div>
);
export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const { verge, patchVerge } = useVerge();
@@ -34,12 +70,6 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
close: () => setOpen(false),
}));
const textProps = {
size: "small",
autoComplete: "off",
sx: { width: 135 },
} as const;
const handleChange = (field: keyof typeof theme) => (e: any) => {
setTheme((t) => ({ ...t, [field]: e.target.value }));
};
@@ -48,111 +78,86 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
try {
await patchVerge({ theme_setting: theme });
setOpen(false);
showNotice("success", t("Saved Successfully, please restart the app to take effect"));
} catch (err: any) {
showNotice("error", err.toString());
}
});
// default theme
const { palette } = useTheme();
const dt = palette.mode === "light" ? defaultTheme : defaultDarkTheme;
const muiTheme = useTheme();
const dt = muiTheme.palette.mode === "light" ? defaultTheme : defaultDarkTheme;
type ThemeKey = keyof typeof theme & keyof typeof defaultTheme;
const renderItem = (label: string, key: ThemeKey) => {
return (
<Item>
<ListItemText primary={label} />
<Round sx={{ background: theme[key] || dt[key] }} />
<TextField
{...textProps}
value={theme[key] ?? ""}
placeholder={dt[key]}
onChange={handleChange(key)}
onKeyDown={(e) => e.key === "Enter" && onSave()}
/>
</Item>
<ColorSettingRow
label={label}
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Добавляем `?? ''` чтобы value всегда был строкой
value={theme[key] ?? ""}
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
placeholder={dt[key]}
onChange={handleChange(key)}
/>
);
};
return (
<BaseDialog
open={open}
title={t("Theme Setting")}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
contentSx={{ width: 400, maxHeight: 505, overflow: "auto", pb: 0 }}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List sx={{ pt: 0 }}>
{renderItem(t("Primary Color"), "primary_color")}
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Theme Setting")}</DialogTitle>
</DialogHeader>
{renderItem(t("Secondary Color"), "secondary_color")}
<div className="max-h-[70vh] overflow-y-auto space-y-3 p-1">
{renderItem(t("Primary Color"), "primary_color")}
{renderItem(t("Secondary Color"), "secondary_color")}
{renderItem(t("Primary Text"), "primary_text")}
{renderItem(t("Secondary Text"), "secondary_text")}
{renderItem(t("Info Color"), "info_color")}
{renderItem(t("Warning Color"), "warning_color")}
{renderItem(t("Error Color"), "error_color")}
{renderItem(t("Success Color"), "success_color")}
{renderItem(t("Primary Text"), "primary_text")}
<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")}
/>
</div>
{renderItem(t("Secondary Text"), "secondary_text")}
<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>
</div>
</div>
{renderItem(t("Info Color"), "info_color")}
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{renderItem(t("Warning Color"), "warning_color")}
{renderItem(t("Error Color"), "error_color")}
{renderItem(t("Success Color"), "success_color")}
<Item>
<ListItemText primary={t("Font Family")} />
<TextField
{...textProps}
value={theme.font_family ?? ""}
onChange={handleChange("font_family")}
onKeyDown={(e) => e.key === "Enter" && onSave()}
/>
</Item>
<Item>
<ListItemText primary={t("CSS Injection")} />
<Button
startIcon={<EditRounded />}
variant="outlined"
onClick={() => {
setEditorOpen(true);
}}
>
{t("Edit")} CSS
</Button>
{editorOpen && (
<EditorViewer
open={true}
title={`${t("Edit")} CSS`}
initialData={Promise.resolve(theme.css_injection ?? "")}
language="css"
onSave={(_prev, curr) => {
theme.css_injection = curr;
handleChange("css_injection");
}}
onClose={() => {
setEditorOpen(false);
}}
/>
)}
</Item>
</List>
</BaseDialog>
{editorOpen && (
<EditorViewer
open={true}
title={`${t("Edit")} CSS`}
initialData={Promise.resolve(theme.css_injection ?? "")}
language="css"
onSave={(_prev, curr) => {
setTheme(v => ({ ...v, css_injection: curr }));
}}
onClose={() => setEditorOpen(false)}
/>
)}
</>
);
});
const Item = styled(ListItem)(() => ({
padding: "5px 2px",
}));
const Round = styled("div")(() => ({
width: "24px",
height: "24px",
borderRadius: "18px",
display: "inline-block",
marginRight: "8px",
}));

View File

@@ -1,32 +1,51 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
List,
ListItem,
ListItemText,
Box,
Typography,
Button,
TextField,
} from "@mui/material";
import { useClash } from "@/hooks/use-clash";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { StackModeSwitch } from "./stack-mode-switch";
import { enhanceProfiles } from "@/services/cmds";
import getSystem from "@/utils/get-system";
import { useLockFn, useRequest } from "ahooks";
import { mutate } from "swr";
import { useClash, useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { enhanceProfiles, restartCore } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
// Новые импорты
import { DialogRef, Switch } from "@/components/base";
import { StackModeSwitch } from "./stack-mode-switch";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} 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";
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>
</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> );
};
export const TunViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { clash, mutateClash, patchClash } = useClash();
const [open, setOpen] = useState(false);
const [values, setValues] = useState({
stack: "mixed",
stack: "gvisor" as StackMode,
device: OS === "macos" ? "utun1024" : "Mihomo",
autoRoute: true,
autoDetectInterface: true,
@@ -39,7 +58,10 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
open: () => {
setOpen(true);
setValues({
stack: clash?.tun.stack ?? "gvisor",
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Добавляем утверждение типа, чтобы TypeScript был уверен в значении
stack: (clash?.tun.stack as StackMode) ?? "gvisor",
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
device: clash?.tun.device ?? (OS === "macos" ? "utun1024" : "Mihomo"),
autoRoute: clash?.tun["auto-route"] ?? true,
autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true,
@@ -51,16 +73,23 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
close: () => setOpen(false),
}));
const resetToDefaults = () => {
setValues({
stack: "gvisor",
device: OS === "macos" ? "utun1024" : "Mihomo",
autoRoute: true,
autoDetectInterface: true,
dnsHijack: ["any:53"],
strictRoute: false,
mtu: 1500,
});
};
const onSave = useLockFn(async () => {
try {
let tun = {
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,
@@ -68,13 +97,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
mtu: values.mtu ?? 1500,
};
await patchClash({ tun });
await mutateClash(
(old) => ({
...(old! || {}),
tun,
}),
false,
);
await mutateClash((old) => ({ ...(old! || {}), tun }), false);
try {
await enhanceProfiles();
showNotice("success", t("Settings Applied"));
@@ -88,152 +111,50 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
});
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between" gap={1}>
<Typography variant="h6">{t("Tun Mode")}</Typography>
<Button
variant="outlined"
size="small"
onClick={async () => {
let tun = {
stack: "gvisor",
device: OS === "macos" ? "utun1024" : "Mihomo",
"auto-route": true,
"auto-detect-interface": true,
"dns-hijack": ["any:53"],
"strict-route": false,
mtu: 1500,
};
setValues({
stack: "gvisor",
device: OS === "macos" ? "utun1024" : "Mihomo",
autoRoute: true,
autoDetectInterface: true,
dnsHijack: ["any:53"],
strictRoute: false,
mtu: 1500,
});
await patchClash({ tun });
await mutateClash(
(old) => ({
...(old! || {}),
tun,
}),
false,
);
}}
>
{t("Reset to Default")}
</Button>
</Box>
}
contentSx={{ width: 450 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Stack")} />
<StackModeSwitch
value={values.stack}
onChange={(value) => {
setValues((v) => ({
...v,
stack: value,
}));
}}
/>
</ListItem>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex justify-between items-center pr-12">
<DialogTitle>{t("Tun Mode")}</DialogTitle>
<Button variant="outline" size="sm" onClick={resetToDefaults}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("Reset to Default")}
</Button>
</div>
</DialogHeader>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Device")} />
<TextField
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.device}
placeholder="Mihomo"
onChange={(e) =>
setValues((v) => ({ ...v, device: e.target.value }))
}
/>
</ListItem>
<div className="max-h-[70vh] overflow-y-auto space-y-1 px-1">
<SettingRow label={<LabelWithIcon icon={Layers} text={t("Stack")} />}>
<StackModeSwitch
value={values.stack}
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>
<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>
<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>
<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 }))} />
</SettingRow>
</div>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Route")} />
<Switch
edge="end"
checked={values.autoRoute}
onChange={(_, c) => setValues((v) => ({ ...v, autoRoute: c }))}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Strict Route")} />
<Switch
edge="end"
checked={values.strictRoute}
onChange={(_, c) => setValues((v) => ({ ...v, strictRoute: c }))}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Detect Interface")} />
<Switch
edge="end"
checked={values.autoDetectInterface}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoDetectInterface: c }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("DNS Hijack")} />
<TextField
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.dnsHijack.join(",")}
placeholder="Please use , to separate multiple DNS servers"
onChange={(e) =>
setValues((v) => ({ ...v, dnsHijack: e.target.value.split(",") }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("MTU")} />
<TextField
autoComplete="new-password"
size="small"
type="number"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.mtu}
placeholder="1500"
onChange={(e) =>
setValues((v) => ({
...v,
mtu: parseInt(e.target.value),
}))
}
/>
</ListItem>
</List>
</BaseDialog>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,31 +1,31 @@
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 { Box, LinearProgress, Button } from "@mui/material";
import { useTranslation } from "react-i18next";
import { relaunch } from "@tauri-apps/plugin-process";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { BaseDialog, DialogRef } from "@/components/base";
import { useUpdateState, useSetUpdateState } from "@/services/states";
import { Event, UnlistenFn } from "@tauri-apps/api/event";
import { portableFlag } from "@/pages/_layout";
import { open as openUrl } from "@tauri-apps/plugin-shell";
import ReactMarkdown from "react-markdown";
// Новые импорты
import { DialogRef } from "@/components/base";
import { useUpdateState, useSetUpdateState } from "@/services/states";
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 { 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();
@@ -34,11 +34,10 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
focusThrottleInterval: 36e5,
});
const [downloaded, setDownloaded] = useState(0);
const [buffer, setBuffer] = useState(0);
const [total, setTotal] = useState(0);
useImperativeHandle(ref, () => ({
@@ -47,44 +46,29 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
}));
const markdownContent = useMemo(() => {
if (!updateInfo?.body) {
return "New Version is available";
}
return updateInfo?.body;
}, [updateInfo]);
if (!updateInfo?.body) return t("New Version is available");
return updateInfo.body;
}, [updateInfo, t]);
const breakChangeFlag = useMemo(() => {
if (!updateInfo?.body) {
return false;
}
return updateInfo?.body.toLowerCase().includes("break change");
return updateInfo?.body?.toLowerCase().includes("break change") ?? false;
}, [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);
setDownloaded(0); // Сбрасываем прогресс перед новой загрузкой
setTotal(0);
if (currentProgressListener) {
currentProgressListener();
}
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);
setBuffer(e.payload.chunkLength);
setDownloaded((a) => {
return a + e.payload.chunkLength;
});
setDownloaded((prev) => prev + e.payload.chunkLength);
},
);
setCurrentProgressListener(() => progressListener);
@@ -96,74 +80,66 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
showNotice("error", err?.message || err.toString());
} finally {
setUpdateState(false);
if (progressListener) {
progressListener();
}
progressListener?.();
setCurrentProgressListener(null);
}
});
useEffect(() => {
return () => {
if (currentProgressListener) {
console.log("UpdateViewer unmounting, cleaning up progress listener.");
currentProgressListener();
}
};
return () => { currentProgressListener?.(); };
}, [currentProgressListener]);
const downloadProgress = total > 0 ? (downloaded / total) * 100 : 0;
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{`New Version v${updateInfo?.version}`}
<Box>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<div className="flex justify-between items-center">
<DialogTitle>{t("New Version")} v{updateInfo?.version}</DialogTitle>
<Button
variant="contained"
size="small"
onClick={() => {
openUrl(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
);
}}
variant="outline"
size="sm"
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")}
</Button>
</Box>
</Box>
}
contentSx={{ minWidth: 360, maxWidth: 400, height: "50vh" }}
okBtn={t("Update")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onUpdate}
>
<Box sx={{ height: "calc(100% - 10px)", overflow: "auto" }}>
<ReactMarkdown
components={{
a: ({ node, ...props }) => {
const { children } = props;
return (
<a {...props} target="_blank">
{children}
</a>
);
},
}}
>
{markdownContent}
</ReactMarkdown>
</Box>
{updateState && (
<LinearProgress
variant="buffer"
value={(downloaded / total) * 100}
valueBuffer={buffer}
sx={{ marginTop: "5px" }}
/>
)}
</BaseDialog>
</div>
</DialogHeader>
<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>
)}
{/* Оборачиваем ReactMarkdown для красивой стилизации */}
<article className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
components={{ a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" /> }}
>
{markdownContent}
</ReactMarkdown>
</article>
</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>
)}
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onUpdate} disabled={updateState || breakChangeFlag}>
{t("Update")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,20 +1,14 @@
import { useState } from "react";
import {
Divider,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
CheckRounded,
CloseRounded,
DeleteRounded,
EditRounded,
OpenInNewRounded,
} from "@mui/icons-material";
import React, { useState } from "react";
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 { Check, X, Trash2, Edit3, ExternalLink } from "lucide-react";
interface Props {
value?: string;
onlyEdit?: boolean;
@@ -24,6 +18,25 @@ interface Props {
onCancel?: () => void;
}
// Новая функция для безопасного рендеринга URL с подсветкой
const HighlightedUrl = ({ url }: { url: string }) => {
// Разбиваем строку по плейсхолдерам, сохраняя их в результате
const parts = url.split(/(%host%|%port%|%secret%)/g);
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>
) : (
<span key={index}>{part}</span>
)
)}
</p>
);
};
export const WebUIItem = (props: Props) => {
const {
value,
@@ -38,97 +51,83 @@ export const WebUIItem = (props: Props) => {
const [editValue, setEditValue] = useState(value);
const { t } = useTranslation();
const handleSave = () => {
onChange(editValue);
setEditing(false);
};
const handleCancel = () => {
onCancel?.();
setEditing(false);
};
// --- Рендер режима редактирования ---
if (editing || onlyEdit) {
return (
<>
<Stack spacing={0.75} direction="row" mt={1} mb={1} alignItems="center">
<TextField
autoComplete="new-password"
fullWidth
size="small"
<div className="w-full">
<div className="flex items-center gap-2 mt-1 mb-1">
<Input
autoFocus
placeholder={t("Support %host, %port, %secret")}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder={t("Support %host, %port, %secret")}
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }}
/>
<IconButton
size="small"
title={t("Save")}
color="inherit"
onClick={() => {
onChange(editValue);
setEditing(false);
}}
>
<CheckRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title={t("Cancel")}
color="inherit"
onClick={() => {
onCancel?.();
setEditing(false);
}}
>
<CloseRounded fontSize="inherit" />
</IconButton>
</Stack>
<Divider />
</>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" onClick={handleSave}><Check className="h-4 w-4" /></Button>
</TooltipTrigger>
<TooltipContent><p>{t("Save")}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" onClick={handleCancel}><X className="h-4 w-4" /></Button>
</TooltipTrigger>
<TooltipContent><p>{t("Cancel")}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{!onlyEdit && <Separator />}
</div>
);
}
const html = value
?.replace("%host", "<span>%host</span>")
.replace("%port", "<span>%port</span>")
.replace("%secret", "<span>%secret</span>");
// --- Рендер режима просмотра ---
return (
<>
<Stack spacing={0.75} direction="row" alignItems="center" mt={1} mb={1}>
<Typography
component="div"
width="100%"
title={value}
color={value ? "text.primary" : "text.secondary"}
sx={({ palette }) => ({
overflow: "hidden",
textOverflow: "ellipsis",
"> span": {
color: palette.primary.main,
},
})}
dangerouslySetInnerHTML={{ __html: html || "NULL" }}
/>
<IconButton
size="small"
title={t("Open URL")}
color="inherit"
onClick={() => onOpenUrl?.(value)}
>
<OpenInNewRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title={t("Edit")}
color="inherit"
onClick={() => {
setEditing(true);
setEditValue(value);
}}
>
<EditRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title={t("Delete")}
color="inherit"
onClick={onDelete}
>
<DeleteRounded fontSize="inherit" />
</IconButton>
</Stack>
<Divider />
</>
<div className="w-full">
<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>}
</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>
</TooltipProvider>
</div>
<Separator />
</div>
);
};

View File

@@ -1,17 +1,28 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Button, Box, Typography } from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { useLockFn } from "ahooks";
import { openWebUrl } from "@/services/cmds";
import { BaseDialog, BaseEmpty, DialogRef } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import { useClashInfo } from "@/hooks/use-clash";
import { WebUIItem } from "./web-ui-item";
import { showNotice } from "@/services/noticeService";
// Новые импорты
import { DialogRef, BaseEmpty } from "@/components/base";
import { WebUIItem } from "./web-ui-item"; // Наш обновленный компонент
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Plus } from "lucide-react";
export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const { verge, patchVerge, mutateVerge } = useVerge();
@@ -29,6 +40,7 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
"https://board.zash.run.place/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
];
// Вся ваша логика остается без изменений
const handleAdd = useLockFn(async (value: string) => {
const newList = [...webUIList, value];
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
@@ -59,18 +71,10 @@ 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) {
showNotice("error", e.message || e.toString());
@@ -78,63 +82,59 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
});
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{t("Web UI")}
<Button
variant="contained"
size="small"
disabled={editing}
onClick={() => setEditing(true)}
>
{t("New")}
</Button>
</Box>
}
contentSx={{
width: 450,
height: 300,
pb: 1,
overflowY: "auto",
userSelect: "text",
}}
cancelBtn={t("Close")}
disableOk
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
{!editing && webUIList.length === 0 && (
<BaseEmpty
extra={
<Typography mt={2} sx={{ fontSize: "12px" }}>
{t("Replace host, port, secret with %host, %port, %secret")}
</Typography>
}
/>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader className="pr-7">
<div className="flex justify-between items-center">
<DialogTitle>{t("Web UI")}</DialogTitle>
<Button size="sm" disabled={editing} onClick={() => setEditing(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("New")}
</Button>
</div>
</DialogHeader>
{webUIList.map((item, index) => (
<WebUIItem
key={index}
value={item}
onChange={(v) => handleChange(index, v)}
onDelete={() => handleDelete(index)}
onOpenUrl={handleOpenUrl}
/>
))}
{editing && (
<WebUIItem
value=""
onlyEdit
onChange={(v) => {
setEditing(false);
handleAdd(v || "");
}}
onCancel={() => setEditing(false)}
/>
)}
</BaseDialog>
<div className="max-h-[60vh] overflow-y-auto -mx-6 px-6">
{!editing && webUIList.length === 0 ? (
<div className="h-40"> {/* Задаем высоту для центрирования */}
<BaseEmpty
extra={
<p className="mt-2 text-xs text-center">
{t("Replace host, port, secret with %host, %port, %secret")}
</p>
}
/>
</div>
) : (
webUIList.map((item, index) => (
<WebUIItem
key={index}
value={item}
onChange={(v) => handleChange(index, v)}
onDelete={() => handleDelete(index)}
onOpenUrl={handleOpenUrl}
/>
))
)}
{editing && (
<WebUIItem
value=""
onlyEdit
onChange={(v) => {
setEditing(false);
if (v) handleAdd(v);
}}
onCancel={() => setEditing(false)}
/>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,25 +1,43 @@
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { useClash } from "@/hooks/use-clash";
import { useListen } from "@/hooks/use-listen";
import { useVerge } from "@/hooks/use-verge";
import { updateGeoData } from "@/services/api";
import { invoke_uwp_tool } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import { LanRounded, SettingsRounded } from "@mui/icons-material";
import { MenuItem, Select, TextField, Typography } from "@mui/material";
import { invoke } from "@tauri-apps/api/core";
import { useLockFn } from "ahooks";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR, { mutate } from "swr";
import { useLockFn } from "ahooks";
import { invoke } from "@tauri-apps/api/core";
import getSystem from "@/utils/get-system";
// Сервисы и хуки
import { useClash } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { updateGeoData, closeAllConnections } from "@/services/api";
import { showNotice } from "@/services/noticeService";
import { useServiceInstaller } from "@/hooks/useServiceInstaller";
import { getRunningMode, invoke_uwp_tool } from "@/services/cmds";
// Компоненты
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GuardState } from "./mods/guard-state";
// Иконки
import {
Settings, Network, Dna, Globe2, Timer, FileText, Plug, RadioTower,
LayoutDashboard, Cog, Repeat, Map as MapIcon
} from "lucide-react";
// Модальные окна
import { ClashCoreViewer } from "./mods/clash-core-viewer";
import { ClashPortViewer } from "./mods/clash-port-viewer";
import { ControllerViewer } from "./mods/controller-viewer";
import { DnsViewer } from "./mods/dns-viewer";
import { GuardState } from "./mods/guard-state";
import { NetworkInterfaceViewer } from "./mods/network-interface-viewer";
import { SettingItem, SettingList } from "./mods/setting-comp";
import { WebUIViewer } from "./mods/web-ui-viewer";
const isWIN = getSystem() === "windows";
@@ -28,6 +46,20 @@ interface Props {
onError: (err: Error) => void;
}
// Компонент для строки настроек
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 SettingClash = ({ onError }: Props) => {
const { t } = useTranslation();
@@ -39,18 +71,14 @@ const SettingClash = ({ onError }: Props) => {
"allow-lan": allowLan,
"log-level": logLevel,
"unified-delay": unifiedDelay,
dns,
} = clash ?? {};
const { enable_random_port = false, verge_mixed_port } = verge ?? {};
const { verge_mixed_port } = verge ?? {};
// 独立跟踪DNS设置开关状态
const [dnsSettingsEnabled, setDnsSettingsEnabled] = useState(() => {
return verge?.enable_dns_settings ?? false;
});
const { addListener } = useListen();
const webRef = useRef<DialogRef>(null);
const portRef = useRef<DialogRef>(null);
const ctrlRef = useRef<DialogRef>(null);
@@ -58,23 +86,22 @@ const SettingClash = ({ onError }: Props) => {
const networkRef = useRef<DialogRef>(null);
const dnsRef = useRef<DialogRef>(null);
const onSwitchFormat = (_e: any, value: boolean) => value;
const onSwitchFormat = (value: boolean) => value;
const onSelectFormat = (value: string) => value;
const onChangeData = (patch: Partial<IConfigData>) => {
mutateClash((old) => ({ ...(old! || {}), ...patch }), false);
};
const onChangeVerge = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
const onUpdateGeo = async () => {
const onUpdateGeo = useLockFn(async () => {
try {
await updateGeoData();
showNotice("success", t("GeoData Updated"));
} catch (err: any) {
showNotice("error", err?.response.data.message || err.toString());
showNotice("error", err?.response?.data?.message || err.toString());
}
};
});
// 实现DNS设置开关处理函数
const handleDnsToggle = useLockFn(async (enable: boolean) => {
try {
setDnsSettingsEnabled(enable);
@@ -94,170 +121,70 @@ const SettingClash = ({ onError }: Props) => {
});
return (
<SettingList title={t("Clash Setting")}>
<WebUIViewer ref={webRef} />
<ClashPortViewer ref={portRef} />
<ControllerViewer ref={ctrlRef} />
<ClashCoreViewer ref={coreRef} />
<NetworkInterfaceViewer ref={networkRef} />
<DnsViewer ref={dnsRef} />
<div>
<h3 className="text-lg font-medium mb-4">{t("Clash Setting")}</h3>
<div className="space-y-1">
<WebUIViewer ref={webRef} />
<ClashPortViewer ref={portRef} />
<ControllerViewer ref={ctrlRef} />
<ClashCoreViewer ref={coreRef} />
<NetworkInterfaceViewer ref={networkRef} />
<DnsViewer ref={dnsRef} />
<SettingItem
label={t("Allow Lan")}
extra={
<TooltipIcon
title={t("Network Interface")}
color={"inherit"}
icon={LanRounded}
onClick={() => {
networkRef.current?.open();
}}
/>
}
>
<GuardState
value={allowLan ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ "allow-lan": e })}
onGuard={(e) => patchClash({ "allow-lan": e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<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>
<SettingItem
label={t("DNS Overwrite")}
extra={
<TooltipIcon
icon={SettingsRounded}
onClick={() => dnsRef.current?.open()}
/>
}
>
<Switch
edge="end"
checked={dnsSettingsEnabled}
onChange={(_, checked) => handleDnsToggle(checked)}
/>
</SettingItem>
<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>
<SettingItem label={t("IPv6")}>
<GuardState
value={ipv6 ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ ipv6: e })}
onGuard={(e) => patchClash({ ipv6: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<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}>
<Switch />
</GuardState>
</SettingRow>
<SettingItem
label={t("Unified Delay")}
extra={
<TooltipIcon
title={t("Unified Delay Info")}
sx={{ opacity: "0.7" }}
/>
}
>
<GuardState
value={unifiedDelay ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ "unified-delay": e })}
onGuard={(e) => patchClash({ "unified-delay": e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<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>
<SettingItem
label={t("Log Level")}
extra={
<TooltipIcon title={t("Log Level Info")} sx={{ opacity: "0.7" }} />
}
>
<GuardState
value={logLevel === "warn" ? "warning" : (logLevel ?? "info")}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ "log-level": e })}
onGuard={(e) => patchClash({ "log-level": e })}
>
<Select size="small" sx={{ width: 100, "> div": { py: "7.5px" } }}>
<MenuItem value="debug">Debug</MenuItem>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="warning">Warn</MenuItem>
<MenuItem value="error">Error</MenuItem>
<MenuItem value="silent">Silent</MenuItem>
</Select>
</GuardState>
</SettingItem>
<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>
<SelectContent>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="silent">Silent</SelectItem>
<SelectItem value="debug">Debug</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingItem label={t("Port Config")}>
<TextField
autoComplete="new-password"
disabled={false}
size="small"
value={verge_mixed_port ?? 7897}
sx={{ width: 100, input: { py: "7.5px", cursor: "pointer" } }}
onClick={(e) => {
portRef.current?.open();
(e.target as any).blur();
}}
/>
</SettingItem>
<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>
<SettingItem
onClick={() => ctrlRef.current?.open()}
label={
<>
{t("External")}
<TooltipIcon
title={t(
"Enable one-click random API port and key. Click to randomize the port and key",
)}
sx={{ opacity: "0.7" }}
/>
</>
}
/>
<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>} />
<SettingItem onClick={() => webRef.current?.open()} label={t("Web UI")} />
<SettingRow onClick={() => webRef.current?.open()} label={<LabelWithIcon icon={LayoutDashboard} text={t("Yacd Web UI")} />} />
<SettingItem
label={t("Clash Core")}
extra={
<TooltipIcon
icon={SettingsRounded}
onClick={() => coreRef.current?.open()}
/>
}
>
<Typography sx={{ py: "7px", pr: 1 }}>{version}</Typography>
</SettingItem>
<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 && (
<SettingItem
onClick={invoke_uwp_tool}
label={t("Open UWP tool")}
extra={
<TooltipIcon
title={t("Open UWP tool Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
)}
{isWIN && <SettingRow onClick={useLockFn(invoke_uwp_tool)} label={<LabelWithIcon icon={Repeat} text={t("UWP Loopback Tool")} />} extra={<TooltipIcon tooltip={t("Open UWP tool Info")} />} />}
<SettingItem onClick={onUpdateGeo} label={t("Update GeoData")} />
</SettingList>
<SettingRow onClick={onUpdateGeo} label={<LabelWithIcon icon={MapIcon} text={t("Update GeoData")} />} />
</div>
</div>
);
};

View File

@@ -1,267 +1,180 @@
import { mutate } from "swr";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
SettingsRounded,
PlayArrowRounded,
PauseRounded,
WarningRounded,
BuildRounded,
DeleteForeverRounded,
} from "@mui/icons-material";
import { useLockFn } from "ahooks";
import { mutate } from "swr";
import { invoke } from "@tauri-apps/api/core";
import getSystem from "@/utils/get-system";
// Сервисы и хуки
import { useVerge } from "@/hooks/use-verge";
import { useSystemProxyState } from "@/hooks/use-system-proxy-state";
import { useSystemProxyState } from "@/hooks/use-system-proxy-state"; // Ваш хук
import { useSystemState } from "@/hooks/use-system-state";
import { useServiceInstaller } from "@/hooks/useServiceInstaller";
import { uninstallService, restartCore, stopCore, invoke_uwp_tool } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
// Компоненты
import { DialogRef, Switch } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GuardState } from "./mods/guard-state";
// Иконки
import { Settings, PlayCircle, PauseCircle, AlertTriangle, Wrench, Trash2, Funnel, Monitor, Power, BellOff, Repeat } from "lucide-react";
// Модальные окна
import { SysproxyViewer } from "./mods/sysproxy-viewer";
import { TunViewer } from "./mods/tun-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { uninstallService, restartCore, stopCore } from "@/services/cmds";
import { useLockFn } from "ahooks";
import { Button, Tooltip } from "@mui/material";
import { useSystemState } from "@/hooks/use-system-state";
import { showNotice } from "@/services/noticeService";
import { useServiceInstaller } from "@/hooks/useServiceInstaller";
const isWIN = getSystem() === "windows";
interface Props { onError?: (err: Error) => void; }
interface Props {
onError?: (err: Error) => void;
}
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 SettingSystem = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, mutateVerge, patchVerge } = useVerge();
const { verge, patchVerge, mutateVerge } = useVerge();
const { installServiceAndRestartCore } = useServiceInstaller();
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Используем синтаксис переименования: `actualState` становится `systemProxyActualState`
const {
actualState: systemProxyActualState,
indicator: systemProxyIndicator,
toggleSystemProxy,
} = useSystemProxyState();
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
const { isAdminMode, isServiceMode, mutateRunningMode } = useSystemState();
// +++ isTunAvailable 现在使用 SWR 的 isServiceMode
const isTunAvailable = isServiceMode || isAdminMode;
const sysproxyRef = useRef<DialogRef>(null);
const tunRef = useRef<DialogRef>(null);
const { enable_tun_mode, enable_auto_launch, enable_silent_start } =
verge ?? {};
const { enable_tun_mode, enable_auto_launch, enable_silent_start } = verge ?? {};
const onSwitchFormat = (_e: any, value: boolean) => value;
const onSwitchFormat = (val: boolean) => val;
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
// 抽象服务操作逻辑
const handleServiceOperation = useLockFn(
async ({
beforeMsg,
action,
actionMsg,
successMsg,
}: {
beforeMsg: string;
action: () => Promise<void>;
actionMsg: string;
successMsg: string;
}) => {
const handleServiceOperation = useLockFn(async ({ beforeMsg, action, actionMsg, successMsg }: { beforeMsg: string; action: () => Promise<void>; actionMsg: string; successMsg: string; }) => {
try {
showNotice("info", beforeMsg);
await stopCore();
showNotice("info", actionMsg);
await action();
showNotice("success", successMsg);
showNotice("info", t("Restarting Core..."));
await restartCore();
await mutateRunningMode();
} catch (err: any) {
showNotice("error", err.message || err.toString());
try {
showNotice("info", beforeMsg);
await stopCore();
showNotice("info", actionMsg);
await action();
showNotice("success", successMsg);
showNotice("info", t("Restarting Core..."));
showNotice("info", t("Try running core as Sidecar..."));
await restartCore();
await mutateRunningMode();
} catch (err: any) {
showNotice("error", err.message || err.toString());
try {
showNotice("info", t("Try running core as Sidecar..."));
await restartCore();
await mutateRunningMode();
} catch (e: any) {
showNotice("error", e?.message || e?.toString());
}
} catch (e: any) {
showNotice("error", e?.message || e?.toString());
}
},
);
}
});
// 卸载系统服务
const onUninstallService = () =>
handleServiceOperation({
const onUninstallService = () => handleServiceOperation({
beforeMsg: t("Stopping Core..."),
action: uninstallService,
actionMsg: t("Uninstalling Service..."),
successMsg: t("Service Uninstalled Successfully"),
});
});
return (
<SettingList title={t("System Setting")}>
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
<div>
<h3 className="text-lg font-medium mb-4">{t("System Setting")}</h3>
<div className="space-y-1">
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
<SettingItem
label={t("Tun Mode")}
extra={
<>
<TooltipIcon
title={t("Tun Mode Info")}
icon={SettingsRounded}
onClick={() => tunRef.current?.open()}
/>
{!isTunAvailable && (
<Tooltip title={t("TUN requires Service Mode or Admin Mode")}>
<WarningRounded sx={{ color: "warning.main", mr: 1 }} />
</Tooltip>
)}
{!isServiceMode && !isAdminMode && (
<Tooltip title={t("Install Service")}>
<Button
variant="outlined"
color="primary"
size="small"
onClick={installServiceAndRestartCore}
sx={{ mr: 1, minWidth: "32px", p: "4px" }}
>
<BuildRounded fontSize="small" />
</Button>
</Tooltip>
)}
{isServiceMode && (
<Tooltip title={t("Uninstall Service")}>
<Button
// variant="outlined"
color="secondary"
size="small"
onClick={onUninstallService}
sx={{ mr: 1, minWidth: "32px", p: "4px" }}
>
<DeleteForeverRounded fontSize="small" />
</Button>
</Tooltip>
)}
</>
}
>
<GuardState
value={enable_tun_mode ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => {
if (!isTunAvailable) return;
onChangeData({ enable_tun_mode: e });
}}
onGuard={(e) => {
if (!isTunAvailable) {
showNotice("error", t("TUN requires Service Mode or Admin Mode"));
return Promise.reject(
new Error(t("TUN requires Service Mode or Admin Mode")),
);
}
return patchVerge({ enable_tun_mode: e });
}}
<SettingRow
label={<LabelWithIcon icon={Funnel} text={t("Tun Mode")} />}
extra={
<div className="flex items-center gap-1">
<TooltipIcon tooltip={t("Tun Mode Info")} icon={<Settings className="h-4 w-4" />} onClick={() => tunRef.current?.open()} />
{!isTunAvailable && <TooltipProvider><Tooltip><TooltipTrigger><AlertTriangle className="h-4 w-4 text-amber-500" /></TooltipTrigger><TooltipContent><p>{t("TUN requires Service Mode or Admin Mode")}</p></TooltipContent></Tooltip></TooltipProvider>}
{!isServiceMode && !isAdminMode && <TooltipProvider><Tooltip><TooltipTrigger asChild><Button variant="outline" size="icon" className="h-7 w-7" onClick={installServiceAndRestartCore}><Wrench className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent><p>{t("Install Service")}</p></TooltipContent></Tooltip></TooltipProvider>}
{isServiceMode && <TooltipProvider><Tooltip><TooltipTrigger asChild><Button variant="destructive" size="icon" className="h-7 w-7" onClick={onUninstallService}><Trash2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent><p>{t("Uninstall Service")}</p></TooltipContent></Tooltip></TooltipProvider>}
</div>
}
>
<Switch edge="end" disabled={!isTunAvailable} />
</GuardState>
</SettingItem>
<SettingItem
label={t("System Proxy")}
extra={
<>
<TooltipIcon
title={t("System Proxy Info")}
icon={SettingsRounded}
onClick={() => sysproxyRef.current?.open()}
/>
{systemProxyIndicator ? (
<PlayArrowRounded sx={{ color: "success.main", mr: 1 }} />
) : (
<PauseRounded sx={{ color: "error.main", mr: 1 }} />
)}
</>
}
>
<GuardState
value={systemProxyActualState}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => toggleSystemProxy(e)}
>
<Switch edge="end" checked={systemProxyActualState} />
</GuardState>
</SettingItem>
<GuardState
value={enable_tun_mode ?? false}
valueProps="checked"
onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_tun_mode: e })}
onGuard={(e) => { if (!isTunAvailable) { showNotice("error", t("TUN requires Service Mode or Admin Mode")); return Promise.reject(new Error(t("TUN requires Service Mode or Admin Mode"))); } return patchVerge({ enable_tun_mode: e }); }}
onCatch={onError}
>
<Switch disabled={!isTunAvailable} />
</GuardState>
</SettingRow>
<SettingItem
label={t("Auto Launch")}
extra={
isAdminMode && (
<Tooltip
title={t("Administrator mode may not support auto launch")}
>
<WarningRounded sx={{ color: "warning.main", mr: 1 }} />
</Tooltip>
)
}
>
<GuardState
value={enable_auto_launch ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => {
// 移除管理员模式检查提示
onChangeData({ enable_auto_launch: e });
}}
onGuard={async (e) => {
if (isAdminMode) {
showNotice(
"info",
t("Administrator mode may not support auto launch"),
);
}
try {
// 先触发UI更新立即看到反馈
onChangeData({ enable_auto_launch: e });
await patchVerge({ enable_auto_launch: e });
await mutate("getAutoLaunchStatus");
return Promise.resolve();
} catch (error) {
// 如果出错,恢复原始状态
onChangeData({ enable_auto_launch: !e });
return Promise.reject(error);
}
}}
<SettingRow
label={<LabelWithIcon icon={Monitor} text={t("System Proxy")} />}
extra={
<div className="flex items-center gap-2">
<TooltipIcon tooltip={t("System Proxy Info")} icon={<Settings className="h-4 w-4" />} onClick={() => sysproxyRef.current?.open()} />
{systemProxyIndicator ? <PlayCircle className="h-5 w-5 text-green-500" /> : <PauseCircle className="h-5 w-5 text-red-500" />}
</div>
}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<GuardState value={systemProxyActualState} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onGuard={(e) => toggleSystemProxy(e)} onCatch={onError}>
<Switch />
</GuardState>
</SettingRow>
<SettingItem
label={t("Silent Start")}
extra={
<TooltipIcon title={t("Silent Start Info")} sx={{ opacity: "0.7" }} />
}
>
<GuardState
value={enable_silent_start ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_silent_start: e })}
onGuard={(e) => patchVerge({ enable_silent_start: e })}
<SettingRow
label={<LabelWithIcon icon={Power} text={t("Auto Launch")} />}
extra={isAdminMode && <TooltipProvider><Tooltip><TooltipTrigger><AlertTriangle className="h-4 w-4 text-amber-500" /></TooltipTrigger><TooltipContent><p>{t("Administrator mode may not support auto launch")}</p></TooltipContent></Tooltip></TooltipProvider>}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
</SettingList>
<GuardState
value={enable_auto_launch ?? false}
valueProps="checked"
onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_auto_launch: e })}
onGuard={async (e) => { if (isAdminMode) { showNotice("info", t("Administrator mode may not support auto launch")); } try { onChangeData({ enable_auto_launch: e }); await patchVerge({ enable_auto_launch: e }); await mutate("getAutoLaunchStatus"); return Promise.resolve(); } catch (error) { onChangeData({ enable_auto_launch: !e }); return Promise.reject(error); } }}
onCatch={onError}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={BellOff} text={t("Silent Start")} />} extra={<TooltipIcon tooltip={t("Silent Start Info")} />}>
<GuardState
value={enable_silent_start ?? false}
valueProps="checked"
onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_silent_start: e })}
onGuard={(e) => patchVerge({ enable_silent_start: e })}
onCatch={onError}
>
<Switch />
</GuardState>
</SettingRow>
</div>
</div>
);
};

View File

@@ -1,6 +1,9 @@
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Typography } from "@mui/material";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { version } from "@root/package.json";
// Сервисы и хуки
import {
exitApp,
openAppDir,
@@ -9,11 +12,31 @@ import {
openDevTools,
exportDiagnosticInfo,
} from "@/services/cmds";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { useVerge } from "@/hooks/use-verge";
import { version } from "@root/package.json";
import { showNotice } from "@/services/noticeService";
// Компоненты
import { DialogRef } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
// --- НАЧАЛО ИЗМЕНЕНИЙ 1: Импортируем все нужные иконки ---
import {
Settings,
Copy,
Info,
Archive,
FileCode,
Folder,
FolderCog,
FolderClock,
RefreshCw,
Terminal,
Feather,
LogOut,
ClipboardList,
} from "lucide-react";
// Модальные окна
import { ConfigViewer } from "./mods/config-viewer";
import { HotkeyViewer } from "./mods/hotkey-viewer";
import { MiscViewer } from "./mods/misc-viewer";
@@ -22,18 +45,43 @@ import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-viewer";
import { BackupViewer } from "./mods/backup-viewer";
import { LiteModeViewer } from "./mods/lite-mode-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { ContentCopyRounded } from "@mui/icons-material";
import { showNotice } from "@/services/noticeService";
interface Props {
onError?: (err: Error) => void;
}
// Наш переиспользуемый компонент для строки настроек
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">
{/* Мы ожидаем, что 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();
const { verge, patchVerge, mutateVerge } = useVerge();
const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null);
const miscRef = useRef<DialogRef>(null);
@@ -61,84 +109,51 @@ const SettingVergeAdvanced = ({ onError }: Props) => {
showNotice("success", t("Copy Success"), 1000);
}, []);
// Вспомогательная функция для создания лейбла с иконкой
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>
);
};
return (
<SettingList title={t("Verge Advanced Setting")}>
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
<MiscViewer ref={miscRef} />
<LayoutViewer ref={layoutRef} />
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<LiteModeViewer ref={liteModeRef} />
<div>
<h3 className="text-lg font-medium mb-4">{t("Verge Advanced Setting")}</h3>
<div className="space-y-1">
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
<MiscViewer ref={miscRef} />
<LayoutViewer ref={layoutRef} />
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<LiteModeViewer ref={liteModeRef} />
<SettingItem
onClick={() => backupRef.current?.open()}
label={t("Backup Setting")}
extra={
<TooltipIcon
title={t("Backup Setting Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 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")} />} />
<SettingItem
onClick={() => configRef.current?.open()}
label={t("Runtime Config")}
/>
<SettingRow label={<LabelWithIcon icon={ClipboardList} text={t("Export Diagnostic Info")} />}>
<TooltipIcon tooltip={t("Copy")} icon={<Copy className="h-4 w-4"/>} onClick={onExportDiagnosticInfo} />
</SettingRow>
<SettingItem
onClick={openAppDir}
label={t("Open Conf Dir")}
extra={
<TooltipIcon
title={t("Open Conf Dir Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
<SettingItem onClick={openCoreDir} label={t("Open Core Dir")} />
<SettingItem onClick={openLogsDir} label={t("Open Logs Dir")} />
<SettingItem onClick={onCheckUpdate} label={t("Check for Updates")} />
<SettingItem onClick={openDevTools} label={t("Open Dev Tools")} />
<SettingItem
label={t("LightWeight Mode Settings")}
extra={
<TooltipIcon
title={t("LightWeight Mode Info")}
sx={{ opacity: "0.7" }}
/>
}
onClick={() => liteModeRef.current?.open()}
/>
<SettingItem
onClick={() => {
exitApp();
}}
label={t("Exit")}
/>
<SettingItem
label={t("Export Diagnostic Info")}
extra={
<TooltipIcon
icon={ContentCopyRounded}
onClick={onExportDiagnosticInfo}
/>
}
></SettingItem>
<SettingItem label={t("Verge Version")}>
<Typography sx={{ py: "7px", pr: 1 }}>v{version}</Typography>
</SettingItem>
</SettingList>
<SettingRow label={<LabelWithIcon icon={Info} text={t("Verge Version")} />}>
<p className="text-sm font-medium pr-2 font-mono">v{version}</p>
</SettingRow>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}
</div>
</div>
);
};

View File

@@ -1,26 +1,36 @@
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { open } from "@tauri-apps/plugin-dialog";
import { Button, MenuItem, Select, Input } from "@mui/material";
import { copyClashEnv } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { DialogRef } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { ThemeModeSwitch } from "./mods/theme-mode-switch";
import { languages } from "@/services/i18n";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import { routers } from "@/pages/_routers";
// Компоненты
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 { GuardState } from "./mods/guard-state";
import { ThemeModeSwitch } from "./mods/theme-mode-switch"; // Импортируем наш новый компонент
// Иконки
import {
Copy, Languages, Palette, MousePointerClick, Terminal, Home, FileTerminal,
SwatchBook, LayoutTemplate, Sparkles, Keyboard
} from "lucide-react";
// Модальные окна
import { ConfigViewer } from "./mods/config-viewer";
import { HotkeyViewer } from "./mods/hotkey-viewer";
import { MiscViewer } from "./mods/misc-viewer";
import { ThemeViewer } from "./mods/theme-viewer";
import { GuardState } from "./mods/guard-state";
import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-viewer";
import { BackupViewer } from "./mods/backup-viewer";
import getSystem from "@/utils/get-system";
import { routers } from "@/pages/_routers";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { ContentCopyRounded } from "@mui/icons-material";
import { languages } from "@/services/i18n";
import { showNotice } from "@/services/noticeService";
interface Props {
onError?: (err: Error) => void;
@@ -30,31 +40,29 @@ 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] };
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>
</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 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);
const miscRef = useRef<DialogRef>(null);
@@ -70,181 +78,103 @@ const SettingVergeBasic = ({ onError }: Props) => {
const onCopyClashEnv = useCallback(async () => {
await copyClashEnv();
showNotice("success", t("Copy Success"), 1000);
}, []);
}, [t]);
return (
<SettingList title={t("Verge Basic Setting")}>
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
<MiscViewer ref={miscRef} />
<LayoutViewer ref={layoutRef} />
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<div>
<h3 className="text-lg font-medium mb-4">{t("Verge Basic Setting")}</h3>
<div className="space-y-1">
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
<MiscViewer ref={miscRef} />
<LayoutViewer ref={layoutRef} />
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<SettingItem label={t("Language")}>
<GuardState
value={language ?? "en"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ language: e })}
onGuard={(e) => patchVerge({ language: e })}
>
<Select size="small" sx={{ width: 110, "> div": { py: "7.5px" } }}>
{languageOptions.map(({ code, label }) => (
<MenuItem key={code} value={code}>
{label}
</MenuItem>
))}
</Select>
</GuardState>
</SettingItem>
<SettingItem label={t("Theme Mode")}>
<GuardState
value={theme_mode}
onCatch={onError}
onChange={(e) => onChangeData({ theme_mode: e })}
onGuard={(e) => patchVerge({ theme_mode: e })}
>
<ThemeModeSwitch />
</GuardState>
</SettingItem>
{OS !== "linux" && (
<SettingItem label={t("Tray Click Event")}>
<GuardState
value={tray_event ?? "main_window"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ tray_event: e })}
onGuard={(e) => patchVerge({ tray_event: e })}
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
<MenuItem value="main_window">{t("Show Main Window")}</MenuItem>
<MenuItem value="tray_menu">{t("Show Tray Menu")}</MenuItem>
<MenuItem value="system_proxy">{t("System Proxy")}</MenuItem>
<MenuItem value="tun_mode">{t("Tun Mode")}</MenuItem>
<MenuItem value="disable">{t("Disable")}</MenuItem>
<SettingRow label={<LabelWithIcon icon={Languages} text={t("Language")} />}>
<GuardState value={language ?? "en"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ language: e })} onGuard={(e) => patchVerge({ language: e })}>
<Select onValueChange={(value) => onChangeData({ language: value })} value={language}>
<SelectTrigger className="w-32 h-8"><SelectValue /></SelectTrigger>
<SelectContent>{languageOptions.map(({ code, label }) => (<SelectItem key={code} value={code}>{label}</SelectItem>))}</SelectContent>
</Select>
</GuardState>
</SettingItem>
)}
</SettingRow>
<SettingItem
label={t("Copy Env Type")}
extra={
<TooltipIcon icon={ContentCopyRounded} onClick={onCopyClashEnv} />
}
>
<GuardState
value={env_type ?? (OS === "windows" ? "powershell" : "bash")}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ env_type: e })}
onGuard={(e) => patchVerge({ env_type: e })}
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
<MenuItem value="bash">Bash</MenuItem>
<MenuItem value="fish">Fish</MenuItem>
<MenuItem value="nushell">Nushell</MenuItem>
<MenuItem value="cmd">CMD</MenuItem>
<MenuItem value="powershell">PowerShell</MenuItem>
</Select>
</GuardState>
</SettingItem>
<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>
<SettingItem label={t("Start Page")}>
<GuardState
value={start_page ?? "/"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ start_page: e })}
onGuard={(e) => patchVerge({ start_page: e })}
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
{routers.map((page: { label: string; path: string }) => {
return (
<MenuItem key={page.path} value={page.path}>
{t(page.label)}
</MenuItem>
);
})}
</Select>
</GuardState>
</SettingItem>
{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>
)}
<SettingItem label={t("Startup Script")}>
<GuardState
value={startup_script ?? ""}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ startup_script: e })}
onGuard={(e) => patchVerge({ startup_script: e })}
>
<Input
value={startup_script}
disabled
disableUnderline
sx={{ width: 230 }}
endAdornment={
<>
<Button
onClick={async () => {
const selected = await open({
directory: false,
multiple: false,
filters: [
{
name: "Shell Script",
extensions: ["sh", "bat", "ps1"],
},
],
});
if (selected) {
onChangeData({ startup_script: `${selected}` });
patchVerge({ startup_script: `${selected}` });
}
}}
>
{t("Browse")}
</Button>
{startup_script && (
<Button
onClick={async () => {
onChangeData({ startup_script: "" });
patchVerge({ startup_script: "" });
}}
>
{t("Clear")}
</Button>
)}
</>
}
></Input>
</GuardState>
</SettingItem>
<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>
<SettingItem
onClick={() => themeRef.current?.open()}
label={t("Theme Setting")}
/>
<SettingRow label={<LabelWithIcon icon={Home} text={t("Start Page")} />}>
<GuardState value={start_page ?? "/"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ start_page: e })} onGuard={(e) => patchVerge({ start_page: e })}>
<Select onValueChange={(value) => onChangeData({ start_page: value })} value={start_page}>
<SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{routers
.filter((page) => !!page.label) // 1. Оставляем только страницы, у которых есть `label`
.map((page) => ( // 2. Теперь TypeScript уверен, что у `page` есть `label`
<SelectItem key={page.path} value={page.path}>
{t(page.label!)}
</SelectItem>
))}
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingItem
onClick={() => layoutRef.current?.open()}
label={t("Layout 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>
<SettingItem
onClick={() => miscRef.current?.open()}
label={t("Miscellaneous")}
/>
<SettingItem
onClick={() => hotkeyRef.current?.open()}
label={t("Hotkey Setting")}
/>
</SettingList>
<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>
);
};