New Interface (initial commit)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user