mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 13:30:31 +08:00
feat(sysproxy-viewer): add visual editor for bypass list with chips display (#6007)
This commit is contained in:
parent
a940445081
commit
ee5e5ee8a6
216
src/components/base/base-split-chip-editor.tsx
Normal file
216
src/components/base/base-split-chip-editor.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { CodeRounded, ViewModuleRounded } from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
FormHelperText,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export type BaseSplitChipEditorMode = "visual" | "advanced";
|
||||||
|
|
||||||
|
interface BaseSplitChipEditorProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: ReactNode;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
separator?: string;
|
||||||
|
splitPattern?: RegExp;
|
||||||
|
defaultMode?: BaseSplitChipEditorMode;
|
||||||
|
showModeToggle?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
addLabel?: ReactNode;
|
||||||
|
emptyLabel?: ReactNode;
|
||||||
|
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>;
|
||||||
|
renderHeader?: (modeToggle: ReactNode) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/;
|
||||||
|
|
||||||
|
const splitValue = (value: string, splitPattern: RegExp) =>
|
||||||
|
value
|
||||||
|
.split(splitPattern)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export const BaseSplitChipEditor = ({
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
error = false,
|
||||||
|
helperText,
|
||||||
|
placeholder,
|
||||||
|
rows = 4,
|
||||||
|
separator = ",",
|
||||||
|
splitPattern = DEFAULT_SPLIT_PATTERN,
|
||||||
|
defaultMode = "visual",
|
||||||
|
showModeToggle = true,
|
||||||
|
ariaLabel,
|
||||||
|
addLabel,
|
||||||
|
emptyLabel,
|
||||||
|
modeLabels,
|
||||||
|
renderHeader,
|
||||||
|
}: BaseSplitChipEditorProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
const resolvedLabels = useMemo(
|
||||||
|
() => ({
|
||||||
|
visual: modeLabels?.visual ?? t("shared.editorModes.visualization"),
|
||||||
|
advanced: modeLabels?.advanced ?? t("shared.editorModes.advanced"),
|
||||||
|
add: addLabel ?? t("shared.actions.new"),
|
||||||
|
empty: emptyLabel ?? t("shared.statuses.empty"),
|
||||||
|
}),
|
||||||
|
[t, modeLabels, addLabel, emptyLabel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const values = useMemo(
|
||||||
|
() => splitValue(value, splitPattern),
|
||||||
|
[value, splitPattern],
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
return values.map((item) => {
|
||||||
|
const nextCount = (counts.get(item) ?? 0) + 1;
|
||||||
|
counts.set(item, nextCount);
|
||||||
|
return {
|
||||||
|
key: `${item}-${nextCount}`,
|
||||||
|
value: item,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [values]);
|
||||||
|
|
||||||
|
const handleAddDraft = () => {
|
||||||
|
const nextValues = splitValue(draft, splitPattern);
|
||||||
|
if (!nextValues.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextValue = [...values, ...nextValues].join(separator);
|
||||||
|
onChange(nextValue);
|
||||||
|
setDraft("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveItem = (index: number) => {
|
||||||
|
const nextValue = values.filter((_, itemIndex) => itemIndex !== index);
|
||||||
|
onChange(nextValue.join(separator));
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMode = mode === "visual" ? "advanced" : "visual";
|
||||||
|
const toggleLabel =
|
||||||
|
nextMode === "visual" ? resolvedLabels.visual : resolvedLabels.advanced;
|
||||||
|
const ToggleIcon = nextMode === "visual" ? ViewModuleRounded : CodeRounded;
|
||||||
|
const resolvedAriaLabel =
|
||||||
|
ariaLabel ?? (typeof toggleLabel === "string" ? toggleLabel : undefined);
|
||||||
|
|
||||||
|
const modeToggle = showModeToggle ? (
|
||||||
|
<Tooltip title={toggleLabel}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={resolvedAriaLabel}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(nextMode);
|
||||||
|
if (nextMode === "visual") {
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderHeader ? renderHeader(modeToggle) : modeToggle}
|
||||||
|
{mode === "visual" ? (
|
||||||
|
<Box sx={{ padding: "0 2px 5px" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 1,
|
||||||
|
minHeight: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item, index) => (
|
||||||
|
<Chip
|
||||||
|
key={item.key}
|
||||||
|
label={item.value}
|
||||||
|
size="small"
|
||||||
|
onDelete={
|
||||||
|
disabled ? undefined : () => handleRemoveItem(index)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{resolvedLabels.empty}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{ display: "flex", gap: 1, marginTop: 1, alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={draft}
|
||||||
|
placeholder={placeholder}
|
||||||
|
error={error}
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputBase-root": { minHeight: 32 },
|
||||||
|
"& .MuiInputBase-input": { padding: "4px 8px" },
|
||||||
|
}}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleAddDraft();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={handleAddDraft}
|
||||||
|
disabled={disabled || !draft.trim()}
|
||||||
|
sx={{ minHeight: 32, padding: "2px 8px" }}
|
||||||
|
>
|
||||||
|
{resolvedLabels.add}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{helperText && (
|
||||||
|
<FormHelperText error={error}>{helperText}</FormHelperText>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
error={error}
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
rows={rows}
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
value={value}
|
||||||
|
helperText={helperText}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,5 +3,6 @@ export { BasePage } from "./base-page";
|
|||||||
export { BaseEmpty } from "./base-empty";
|
export { BaseEmpty } from "./base-empty";
|
||||||
export { BaseLoading } from "./base-loading";
|
export { BaseLoading } from "./base-loading";
|
||||||
export { BaseErrorBoundary } from "./base-error-boundary";
|
export { BaseErrorBoundary } from "./base-error-boundary";
|
||||||
|
export { BaseSplitChipEditor } from "./base-split-chip-editor";
|
||||||
export { Switch } from "./base-switch";
|
export { Switch } from "./base-switch";
|
||||||
export { BaseLoadingOverlay } from "./base-loading-overlay";
|
export { BaseLoadingOverlay } from "./base-loading-overlay";
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { EditRounded } from "@mui/icons-material";
|
import { EditRounded } from "@mui/icons-material";
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
@ -22,7 +24,12 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { mutate } from "swr";
|
import { mutate } from "swr";
|
||||||
|
|
||||||
import { BaseDialog, DialogRef, Switch } from "@/components/base";
|
import {
|
||||||
|
BaseDialog,
|
||||||
|
BaseSplitChipEditor,
|
||||||
|
DialogRef,
|
||||||
|
Switch,
|
||||||
|
} from "@/components/base";
|
||||||
import { BaseFieldset } from "@/components/base/base-fieldset";
|
import { BaseFieldset } from "@/components/base/base-fieldset";
|
||||||
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
|
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
|
||||||
import { EditorViewer } from "@/components/profile/editor-viewer";
|
import { EditorViewer } from "@/components/profile/editor-viewer";
|
||||||
@ -85,9 +92,16 @@ const getValidReg = (isWindows: boolean) => {
|
|||||||
return new RegExp(rValid);
|
return new RegExp(rValid);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const splitBypass = (value?: string) =>
|
||||||
|
(value ?? "")
|
||||||
|
.split(/[,\n;\r]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isWindows = getSystem() === "windows";
|
const systemName = getSystem();
|
||||||
|
const isWindows = systemName === "windows";
|
||||||
const validReg = useMemo(() => getValidReg(isWindows), [isWindows]);
|
const validReg = useMemo(() => getValidReg(isWindows), [isWindows]);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -122,11 +136,13 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
proxy_host: proxy_host ?? "127.0.0.1",
|
proxy_host: proxy_host ?? "127.0.0.1",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const separator = useMemo(() => (isWindows ? ";" : ","), [isWindows]);
|
||||||
|
|
||||||
const defaultBypass = () => {
|
const defaultBypass = () => {
|
||||||
if (isWindows) {
|
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>";
|
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") {
|
if (systemName === "linux") {
|
||||||
return "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1";
|
return "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1";
|
||||||
}
|
}
|
||||||
return "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
|
return "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
|
||||||
@ -199,6 +215,14 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
return `http://${host}:${port}/commands/pac`;
|
return `http://${host}:${port}/commands/pac`;
|
||||||
}, [value.proxy_host]);
|
}, [value.proxy_host]);
|
||||||
|
|
||||||
|
const bypassError =
|
||||||
|
value.enable_bypass_check &&
|
||||||
|
!value.pac &&
|
||||||
|
!value.use_default &&
|
||||||
|
value.bypass
|
||||||
|
? !validReg.test(value.bypass)
|
||||||
|
: false;
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: () => {
|
open: () => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@ -560,14 +584,19 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
edge="end"
|
edge="end"
|
||||||
disabled={!enabled}
|
disabled={!enabled}
|
||||||
checked={value.use_default}
|
checked={value.use_default}
|
||||||
onChange={(_, e) =>
|
onChange={(_, e) => {
|
||||||
setValue((v) => ({
|
if (!e && !value.bypass) {
|
||||||
...v,
|
const nextBypass = defaultBypass();
|
||||||
use_default: e,
|
setValue((v) => ({
|
||||||
// 当取消选择use_default且当前bypass为空时,填充默认值
|
...v,
|
||||||
bypass: !e && !v.bypass ? defaultBypass() : v.bypass,
|
use_default: e,
|
||||||
}))
|
// 当取消选择use_default且当前bypass为空时,填充默认值
|
||||||
}
|
bypass: nextBypass,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValue((v) => ({ ...v, use_default: e }));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
)}
|
)}
|
||||||
@ -589,27 +618,32 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!value.pac && !value.use_default && (
|
{!value.pac && !value.use_default && (
|
||||||
<>
|
<BaseSplitChipEditor
|
||||||
<ListItemText
|
value={value.bypass ?? ""}
|
||||||
primary={t("settings.modals.sysproxy.fields.proxyBypass")}
|
separator={separator}
|
||||||
/>
|
disabled={!enabled}
|
||||||
<TextField
|
error={bypassError}
|
||||||
error={
|
helperText={
|
||||||
value.enable_bypass_check && value.bypass
|
bypassError
|
||||||
? !validReg.test(value.bypass)
|
? t("settings.modals.sysproxy.messages.invalidBypass")
|
||||||
: false
|
: undefined
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
placeholder="localhost"
|
||||||
size="small"
|
ariaLabel={t("settings.modals.sysproxy.fields.proxyBypass")}
|
||||||
multiline
|
onChange={(nextValue) => {
|
||||||
rows={4}
|
setValue((v) => ({ ...v, bypass: nextValue }));
|
||||||
sx={{ width: "100%" }}
|
}}
|
||||||
value={value.bypass}
|
renderHeader={(modeToggle) => (
|
||||||
onChange={(e) => {
|
<ListItem sx={{ padding: "5px 2px" }}>
|
||||||
setValue((v) => ({ ...v, bypass: e.target.value }));
|
<ListItemText
|
||||||
}}
|
primary={t("settings.modals.sysproxy.fields.proxyBypass")}
|
||||||
/>
|
/>
|
||||||
</>
|
{modeToggle ? (
|
||||||
|
<Box sx={{ marginLeft: "auto" }}>{modeToggle}</Box>
|
||||||
|
) : null}
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!value.pac && value.use_default && (
|
{!value.pac && value.use_default && (
|
||||||
@ -617,16 +651,13 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
<ListItemText
|
<ListItemText
|
||||||
primary={t("settings.modals.sysproxy.fields.bypass")}
|
primary={t("settings.modals.sysproxy.fields.bypass")}
|
||||||
/>
|
/>
|
||||||
<FlexBox>
|
<Box sx={{ padding: "0 2px 5px" }}>
|
||||||
<TextField
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||||
disabled={true}
|
{splitBypass(defaultBypass()).map((item) => (
|
||||||
size="small"
|
<Chip key={item} label={item} size="small" />
|
||||||
multiline
|
))}
|
||||||
rows={4}
|
</Box>
|
||||||
sx={{ width: "100%" }}
|
</Box>
|
||||||
value={defaultBypass()}
|
|
||||||
/>
|
|
||||||
</FlexBox>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user