From ee5e5ee8a63d8c96ca51585afa5a67587c157aa4 Mon Sep 17 00:00:00 2001 From: Sline Date: Sun, 4 Jan 2026 14:58:50 +0800 Subject: [PATCH] feat(sysproxy-viewer): add visual editor for bypass list with chips display (#6007) --- .../base/base-split-chip-editor.tsx | 216 ++++++++++++++++++ src/components/base/index.ts | 1 + .../setting/mods/sysproxy-viewer.tsx | 115 ++++++---- 3 files changed, 290 insertions(+), 42 deletions(-) create mode 100644 src/components/base/base-split-chip-editor.tsx diff --git a/src/components/base/base-split-chip-editor.tsx b/src/components/base/base-split-chip-editor.tsx new file mode 100644 index 000000000..1b42cf1b0 --- /dev/null +++ b/src/components/base/base-split-chip-editor.tsx @@ -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>; + 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(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(); + 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 ? ( + + { + setMode(nextMode); + if (nextMode === "visual") { + setDraft(""); + } + }} + > + + + + ) : null; + + return ( + <> + {renderHeader ? renderHeader(modeToggle) : modeToggle} + {mode === "visual" ? ( + + + {items.length ? ( + items.map((item, index) => ( + handleRemoveItem(index) + } + /> + )) + ) : ( + + {resolvedLabels.empty} + + )} + + + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAddDraft(); + } + }} + /> + + + {helperText && ( + {helperText} + )} + + ) : ( + { + onChange(event.target.value); + }} + /> + )} + + ); +}; diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 826866719..f80733b2a 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -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"; diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 76bfa03a7..5f7163a57 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -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((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((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.*;"; } - 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,"; @@ -199,6 +215,14 @@ export const SysproxyViewer = forwardRef((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((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 })); + }} /> )} @@ -589,27 +618,32 @@ export const SysproxyViewer = forwardRef((props, ref) => { )} {!value.pac && !value.use_default && ( - <> - - { - setValue((v) => ({ ...v, bypass: e.target.value })); - }} - /> - + { + setValue((v) => ({ ...v, bypass: nextValue })); + }} + renderHeader={(modeToggle) => ( + + + {modeToggle ? ( + {modeToggle} + ) : null} + + )} + /> )} {!value.pac && value.use_default && ( @@ -617,16 +651,13 @@ export const SysproxyViewer = forwardRef((props, ref) => { - - - + + + {splitBypass(defaultBypass()).map((item) => ( + + ))} + + )}