mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-28 07:14:40 +08:00
feat(sysproxy-viewer): add visual editor for bypass list with chips display (#6007)
This commit is contained in:
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 { BaseLoading } from "./base-loading";
|
||||
export { BaseErrorBoundary } from "./base-error-boundary";
|
||||
export { BaseSplitChipEditor } from "./base-split-chip-editor";
|
||||
export { Switch } from "./base-switch";
|
||||
export { BaseLoadingOverlay } from "./base-loading-overlay";
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { EditRounded } from "@mui/icons-material";
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -22,7 +24,12 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { TooltipIcon } from "@/components/base/base-tooltip-icon";
|
||||
import { EditorViewer } from "@/components/profile/editor-viewer";
|
||||
@@ -85,9 +92,16 @@ const getValidReg = (isWindows: boolean) => {
|
||||
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) => {
|
||||
const { t } = useTranslation();
|
||||
const isWindows = getSystem() === "windows";
|
||||
const systemName = getSystem();
|
||||
const isWindows = systemName === "windows";
|
||||
const validReg = useMemo(() => getValidReg(isWindows), [isWindows]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -122,11 +136,13 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
proxy_host: proxy_host ?? "127.0.0.1",
|
||||
});
|
||||
|
||||
const separator = useMemo(() => (isWindows ? ";" : ","), [isWindows]);
|
||||
|
||||
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") {
|
||||
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 "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`;
|
||||
}, [value.proxy_host]);
|
||||
|
||||
const bypassError =
|
||||
value.enable_bypass_check &&
|
||||
!value.pac &&
|
||||
!value.use_default &&
|
||||
value.bypass
|
||||
? !validReg.test(value.bypass)
|
||||
: false;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => {
|
||||
setOpen(true);
|
||||
@@ -560,14 +584,19 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
onChange={(_, e) => {
|
||||
if (!e && !value.bypass) {
|
||||
const nextBypass = defaultBypass();
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
use_default: e,
|
||||
// 当取消选择use_default且当前bypass为空时,填充默认值
|
||||
bypass: nextBypass,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setValue((v) => ({ ...v, use_default: e }));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
@@ -589,27 +618,32 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
)}
|
||||
|
||||
{!value.pac && !value.use_default && (
|
||||
<>
|
||||
<ListItemText
|
||||
primary={t("settings.modals.sysproxy.fields.proxyBypass")}
|
||||
/>
|
||||
<TextField
|
||||
error={
|
||||
value.enable_bypass_check && 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 }));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<BaseSplitChipEditor
|
||||
value={value.bypass ?? ""}
|
||||
separator={separator}
|
||||
disabled={!enabled}
|
||||
error={bypassError}
|
||||
helperText={
|
||||
bypassError
|
||||
? t("settings.modals.sysproxy.messages.invalidBypass")
|
||||
: undefined
|
||||
}
|
||||
placeholder="localhost"
|
||||
ariaLabel={t("settings.modals.sysproxy.fields.proxyBypass")}
|
||||
onChange={(nextValue) => {
|
||||
setValue((v) => ({ ...v, bypass: nextValue }));
|
||||
}}
|
||||
renderHeader={(modeToggle) => (
|
||||
<ListItem sx={{ padding: "5px 2px" }}>
|
||||
<ListItemText
|
||||
primary={t("settings.modals.sysproxy.fields.proxyBypass")}
|
||||
/>
|
||||
{modeToggle ? (
|
||||
<Box sx={{ marginLeft: "auto" }}>{modeToggle}</Box>
|
||||
) : null}
|
||||
</ListItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!value.pac && value.use_default && (
|
||||
@@ -617,16 +651,13 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
<ListItemText
|
||||
primary={t("settings.modals.sysproxy.fields.bypass")}
|
||||
/>
|
||||
<FlexBox>
|
||||
<TextField
|
||||
disabled={true}
|
||||
size="small"
|
||||
multiline
|
||||
rows={4}
|
||||
sx={{ width: "100%" }}
|
||||
value={defaultBypass()}
|
||||
/>
|
||||
</FlexBox>
|
||||
<Box sx={{ padding: "0 2px 5px" }}>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
{splitBypass(defaultBypass()).map((item) => (
|
||||
<Chip key={item} label={item} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user