summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/CollapsibleSectionHeader.module.scss49
-rw-r--r--src/components/CollapsibleSectionHeader.tsx51
-rw-r--r--src/components/ContentHeader.module.scss34
-rw-r--r--src/components/ContentHeader.tsx13
-rw-r--r--src/components/ErrorBoundary.tsx33
-rw-r--r--src/components/ErrorBoundaryFallback.module.scss37
-rw-r--r--src/components/ErrorBoundaryFallback.tsx31
-rw-r--r--src/components/Field.module.scss42
-rw-r--r--src/components/Field.tsx27
-rw-r--r--src/components/Icon.tsx20
-rw-r--r--src/components/Input.module.scss28
-rw-r--r--src/components/Loading2.module.scss8
-rw-r--r--src/components/Loading2.tsx14
-rw-r--r--src/components/Modal.module.scss21
-rw-r--r--src/components/Modal.tsx56
-rw-r--r--src/components/Search.module.scss32
-rw-r--r--src/components/Search.tsx38
-rw-r--r--src/components/SideBar.module.scss191
-rw-r--r--src/components/SideBar.tsx123
-rw-r--r--src/components/StateProvider.tsx99
-rw-r--r--src/components/SwitchThemed.tsx36
-rw-r--r--src/components/about/About.tsx2
-rw-r--r--src/components/backend/APIConfig.module.scss266
-rw-r--r--src/components/backend/APIConfig.tsx210
-rw-r--r--src/components/backend/APIDiscovery.module.scss5
-rw-r--r--src/components/backend/APIDiscovery.tsx2
-rw-r--r--src/components/backend/BackendList.module.scss186
-rw-r--r--src/components/backend/BackendList.tsx152
-rw-r--r--src/components/config/Config.module.scss34
-rw-r--r--src/components/config/Config.tsx46
-rw-r--r--src/components/connections/ConnectionCard.module.scss155
-rw-r--r--src/components/connections/ConnectionCard.tsx34
-rw-r--r--src/components/connections/ConnectionDetailModal.module.scss142
-rw-r--r--src/components/connections/ConnectionDetailModal.tsx98
-rw-r--r--src/components/connections/ConnectionSettingsModal.module.scss365
-rw-r--r--src/components/connections/ConnectionSettingsModal.tsx252
-rw-r--r--src/components/connections/ConnectionStats.module.scss100
-rw-r--r--src/components/connections/ConnectionStats.tsx81
-rw-r--r--src/components/connections/ConnectionTable.module.scss485
-rw-r--r--src/components/connections/ConnectionTable.scss84
-rw-r--r--src/components/connections/ConnectionTable.tsx778
-rw-r--r--src/components/connections/Connections.css71
-rw-r--r--src/components/connections/Connections.module.scss227
-rw-r--r--src/components/connections/Connections.tsx311
-rw-r--r--src/components/connections/ConnectionsHeader.module.scss64
-rw-r--r--src/components/connections/ConnectionsHeader.tsx136
-rw-r--r--src/components/connections/ModalCloseAllConnections.module.scss7
-rw-r--r--src/components/connections/ModalCloseAllConnections.tsx21
-rw-r--r--src/components/connections/ModalConnectionDetails.module.scss49
-rw-r--r--src/components/connections/ModalConnectionDetails.tsx73
-rw-r--r--src/components/connections/ModalManageConnectionColumns.module.scss61
-rw-r--r--src/components/connections/ModalManageConnectionColumns.tsx156
-rw-r--r--src/components/connections/ModalSourceIP.module.scss9
-rw-r--r--src/components/connections/ModalSourceIP.tsx71
-rw-r--r--src/components/home/Home.module.scss197
-rw-r--r--src/components/home/Home.tsx22
-rw-r--r--src/components/home/MemoryCard.tsx50
-rw-r--r--src/components/home/MemoryChart.tsx84
-rw-r--r--src/components/home/StatCards.tsx72
-rw-r--r--src/components/home/TrafficCard.tsx74
-rw-r--r--src/components/home/TrafficChart.tsx118
-rw-r--r--src/components/home/TrafficNow.module.scss85
-rw-r--r--src/components/home/TrafficNow.tsx104
-rw-r--r--src/components/logs/LogSearch.ts6
-rw-r--r--src/components/logs/Logs.module.scss257
-rw-r--r--src/components/logs/Logs.tsx144
-rw-r--r--src/components/logs/LogsHeader.module.scss33
-rw-r--r--src/components/logs/LogsHeader.tsx67
-rw-r--r--src/components/proxies/ClosePrevConns.tsx48
-rw-r--r--src/components/proxies/Proxies.module.scss151
-rw-r--r--src/components/proxies/Proxies.tsx204
-rw-r--r--src/components/proxies/ProxiesHeader.module.scss22
-rw-r--r--src/components/proxies/ProxiesHeader.tsx136
-rw-r--r--src/components/proxies/Proxy.module.scss149
-rw-r--r--src/components/proxies/Proxy.tsx156
-rw-r--r--src/components/proxies/ProxyCard.module.scss232
-rw-r--r--src/components/proxies/ProxyCard.tsx214
-rw-r--r--src/components/proxies/ProxyGroup.module.scss101
-rw-r--r--src/components/proxies/ProxyGroup.tsx289
-rw-r--r--src/components/proxies/ProxyLatency.module.scss38
-rw-r--r--src/components/proxies/ProxyLatency.tsx6
-rw-r--r--src/components/proxies/ProxyList.module.scss40
-rw-r--r--src/components/proxies/ProxyList.tsx31
-rw-r--r--src/components/proxies/ProxyPageFab.tsx56
-rw-r--r--src/components/proxies/ProxyProvider.module.scss51
-rw-r--r--src/components/proxies/ProxyProvider.tsx250
-rw-r--r--src/components/proxies/ProxyProviderList.tsx52
-rw-r--r--src/components/proxies/Settings.module.scss115
-rw-r--r--src/components/proxies/Settings.tsx393
-rw-r--r--src/components/proxies/index.tsx1
-rw-r--r--src/components/rules/Rule.module.scss116
-rw-r--r--src/components/rules/Rule.tsx149
-rw-r--r--src/components/rules/RuleProviderItem.module.scss108
-rw-r--r--src/components/rules/RuleProviderItem.tsx69
-rw-r--r--src/components/rules/Rules.module.scss127
-rw-r--r--src/components/rules/Rules.tsx135
-rw-r--r--src/components/rules/RulesHeader.tsx80
-rw-r--r--src/components/rules/RulesPageFab.tsx24
-rw-r--r--src/components/shared/BaseModal.module.scss12
-rw-r--r--src/components/shared/BaseModal.tsx22
-rw-r--r--src/components/shared/Basic.module.scss15
-rw-r--r--src/components/shared/Basic.tsx9
-rw-r--r--src/components/shared/Button.module.scss (renamed from src/components/Button.module.scss)36
-rw-r--r--src/components/shared/Button.tsx (renamed from src/components/Button.tsx)6
-rw-r--r--src/components/shared/Collapsible.tsx (renamed from src/components/Collapsible.tsx)2
-rw-r--r--src/components/shared/Fab.module.scss33
-rw-r--r--src/components/shared/Fab.tsx155
-rw-r--r--src/components/shared/FeatherIcons.ts4
-rw-r--r--src/components/shared/Head.tsx5
-rw-r--r--src/components/shared/Input.module.scss36
-rw-r--r--src/components/shared/Input.tsx (renamed from src/components/Input.tsx)11
-rw-r--r--src/components/shared/Loading.module.scss (renamed from src/components/Loading.module.scss)0
-rw-r--r--src/components/shared/Loading.tsx (renamed from src/components/Loading.tsx)0
-rw-r--r--src/components/shared/Modal.module.scss52
-rw-r--r--src/components/shared/Modal.tsx81
-rw-r--r--src/components/shared/PageHeader.module.scss400
-rw-r--r--src/components/shared/PageHeader.tsx206
-rw-r--r--src/components/shared/Popover.module.scss43
-rw-r--r--src/components/shared/Popover.tsx127
-rw-r--r--src/components/shared/SegmentedControl.module.scss50
-rw-r--r--src/components/shared/SegmentedControl.tsx48
-rw-r--r--src/components/shared/Select.module.scss12
-rw-r--r--src/components/shared/Select.tsx10
-rw-r--r--src/components/shared/Selection.module.scss (renamed from src/components/Selection.module.scss)0
-rw-r--r--src/components/shared/Selection.tsx (renamed from src/components/Selection.tsx)8
-rw-r--r--src/components/shared/Sparkline.module.scss5
-rw-r--r--src/components/shared/Sparkline.tsx120
-rw-r--r--src/components/shared/SvgGithub.tsx (renamed from src/components/SvgGithub.tsx)0
-rw-r--r--src/components/shared/SvgYacd.module.scss (renamed from src/components/SvgYacd.module.scss)0
-rw-r--r--src/components/shared/SvgYacd.tsx (renamed from src/components/SvgYacd.tsx)0
-rw-r--r--src/components/shared/SwitchThemed.module.scss56
-rw-r--r--src/components/shared/SwitchThemed.tsx33
-rw-r--r--src/components/shared/TextFilter.module.scss (renamed from src/components/shared/TextFitler.module.scss)0
-rw-r--r--src/components/shared/TextFilter.tsx (renamed from src/components/shared/TextFitler.tsx)10
-rw-r--r--src/components/shared/ThemeSwitcher.tsx8
-rw-r--r--src/components/shared/Toast.module.scss87
-rw-r--r--src/components/shared/Toast.tsx40
-rw-r--r--src/components/shared/ToggleSwitch.module.scss (renamed from src/components/ToggleSwitch.module.scss)0
-rw-r--r--src/components/shared/ToggleSwitch.tsx (renamed from src/components/ToggleSwitch.tsx)12
-rw-r--r--src/components/shared/Tooltip.tsx6
-rw-r--r--src/components/shared/TrafficChartSample.tsx4
-rw-r--r--src/components/styleguide/StyleGuide.tsx (renamed from src/components/StyleGuide.tsx)12
-rw-r--r--src/components/svg/Equalizer.tsx27
143 files changed, 7055 insertions, 5320 deletions
diff --git a/src/components/CollapsibleSectionHeader.module.scss b/src/components/CollapsibleSectionHeader.module.scss
deleted file mode 100644
index 3fd8bd3..0000000
--- a/src/components/CollapsibleSectionHeader.module.scss
+++ /dev/null
@@ -1,49 +0,0 @@
-.header {
- display: flex;
- align-items: center;
- padding: 5px;
- user-select: none;
-
- &:focus {
- outline: none;
- }
-
- .arrow {
- display: inline-flex;
- transform: rotate(0deg);
- transition: transform 0.3s;
-
- &.isOpen {
- transform: rotate(180deg);
- }
-
- &:focus {
- outline: var(--color-focus-blue) solid 1px;
- }
- }
-}
-
-.btn {
- margin-left: 5px;
-}
-
-.lock {
- display: inline-flex;
- align-items: center;
- margin-left: 6px;
- color: var(--color-latency-medium, #d4b75c);
- cursor: help;
-}
-
-/* TODO duplicate with connQty in Connections.module.css */
-.qty {
- font-family: var(--font-normal);
- font-size: 0.75em;
- margin-left: 3px;
- padding: 2px 7px;
- display: inline-flex;
- justify-content: center;
- align-items: center;
- background-color: var(--bg-near-transparent);
- border-radius: 30px;
-}
diff --git a/src/components/CollapsibleSectionHeader.tsx b/src/components/CollapsibleSectionHeader.tsx
deleted file mode 100644
index 729f072..0000000
--- a/src/components/CollapsibleSectionHeader.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import * as React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import s from './CollapsibleSectionHeader.module.scss';
-import { SectionNameType } from './shared/Basic';
-import { Lock } from './shared/FeatherIcons';
-
-type Props = {
- name: string;
- type: string;
- qty?: number | string;
- toggle?: () => void;
- isOpen?: boolean;
- // URLTest/Fallback group has a manually-fixed selection
- fixed?: boolean;
-};
-
-export default function Header({ name, type, toggle, qty, fixed }: Props) {
- const { t } = useTranslation();
- const handleKeyDown = React.useCallback(
- (e: React.KeyboardEvent) => {
- e.preventDefault();
- if (e.key === 'Enter' || e.key === ' ') {
- toggle();
- }
- },
- [toggle]
- );
- return (
- <div
- className={s.header}
- onClick={toggle}
- style={{ cursor: 'pointer' }}
- tabIndex={0}
- onKeyDown={handleKeyDown}
- role="button"
- >
- <div>
- <SectionNameType name={name} type={type} />
- </div>
-
- {fixed ? (
- <span className={s.lock} title={t('group_fixed_tip')}>
- <Lock size={13} />
- </span>
- ) : null}
-
- {typeof qty === 'number' ? <span className={s.qty}>{qty}</span> : null}
- </div>
- );
-}
diff --git a/src/components/ContentHeader.module.scss b/src/components/ContentHeader.module.scss
deleted file mode 100644
index be1697b..0000000
--- a/src/components/ContentHeader.module.scss
+++ /dev/null
@@ -1,34 +0,0 @@
-@use '~/styles/utils/custom-media' as *;
-
-.root {
- height: 60px;
- display: flex;
- align-items: center;
- padding: 0 15px;
-
- @media (max-width: 768px) {
- min-height: 48px;
- height: auto;
- padding: 5px 15px;
- flex-wrap: wrap;
- }
-
- @media (--breakpoint-not-small) {
- padding: 0 40px;
- }
-}
-
-.h1 {
- white-space: nowrap;
- font-size: 1.7em;
-
- @media (max-width: 768px) {
- font-size: 1.4em;
- }
-
- @media (--breakpoint-not-small) {
- font-size: 2em;
- }
- text-align: left;
- margin: 0;
-}
diff --git a/src/components/ContentHeader.tsx b/src/components/ContentHeader.tsx
deleted file mode 100644
index 4709037..0000000
--- a/src/components/ContentHeader.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react';
-
-import s0 from './ContentHeader.module.scss';
-
-type Props = {
- children?: React.ReactNode;
-};
-
-function ContentHeader({ children }: Props) {
- return <div className={s0.root}>{children}</div>;
-}
-
-export default React.memo(ContentHeader);
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
deleted file mode 100644
index ed395d1..0000000
--- a/src/components/ErrorBoundary.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import * as React from 'react';
-
-// import { getSentry } from '../misc/sentry';
-import { deriveMessageFromError, Err } from '../misc/errors';
-
-import ErrorBoundaryFallback from './ErrorBoundaryFallback';
-
-type Props = {
- children: React.ReactNode;
-};
-
-type State = {
- error?: Err;
-};
-
-class ErrorBoundary extends React.Component<Props, State> {
- state = { error: null };
-
- static getDerivedStateFromError(error: Err) {
- return { error };
- }
-
- render() {
- if (this.state.error) {
- const { message, detail } = deriveMessageFromError(this.state.error);
- return <ErrorBoundaryFallback message={message} detail={detail} />;
- } else {
- return this.props.children;
- }
- }
-}
-
-export default ErrorBoundary;
diff --git a/src/components/ErrorBoundaryFallback.module.scss b/src/components/ErrorBoundaryFallback.module.scss
deleted file mode 100644
index d2adadb..0000000
--- a/src/components/ErrorBoundaryFallback.module.scss
+++ /dev/null
@@ -1,37 +0,0 @@
-.root {
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- overflow: hidden;
-
- padding: 20px;
- background: var(--color-background);
- color: var(--color-text);
- text-align: center;
-}
-
-.yacd {
- color: #2a477a;
- opacity: 0.6;
- display: flex;
- justify-content: center;
- align-items: center;
- padding: 40px;
-}
-
-.link {
- display: inline-flex;
- align-items: center;
-
- color: var(--color-text-secondary);
- &:hover,
- &:active {
- color: var(--color-focus-blue);
- }
-
- svg {
- margin-right: 5px;
- }
-}
diff --git a/src/components/ErrorBoundaryFallback.tsx b/src/components/ErrorBoundaryFallback.tsx
deleted file mode 100644
index 9f41d91..0000000
--- a/src/components/ErrorBoundaryFallback.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-
-import s0 from './ErrorBoundaryFallback.module.scss';
-import SvgGithub from './SvgGithub';
-import SvgYacd from './SvgYacd';
-const yacdRepoIssueUrl = 'https://github.com/metacubex/yacd';
-
-type Props = {
- message?: string;
- detail?: string;
-};
-
-function ErrorBoundaryFallback({ message, detail }: Props) {
- return (
- <div className={s0.root}>
- <div className={s0.yacd}>
- <SvgYacd width={150} height={150} />
- </div>
- {message ? <h1>{message}</h1> : null}
- {detail ? <p>{detail}</p> : null}
- <p>
- <a className={s0.link} href={yacdRepoIssueUrl}>
- <SvgGithub width={16} height={16} />
- metacubex/yacd
- </a>
- </p>
- </div>
- );
-}
-
-export default ErrorBoundaryFallback;
diff --git a/src/components/Field.module.scss b/src/components/Field.module.scss
deleted file mode 100644
index 72a5149..0000000
--- a/src/components/Field.module.scss
+++ /dev/null
@@ -1,42 +0,0 @@
-.root {
- position: relative;
- padding: 10px 0;
- input {
- -webkit-appearance: none;
- background-color: transparent;
- background-image: none;
- border: none;
- border-radius: 0;
- border-bottom: 1px solid var(--color-input-border);
- box-sizing: border-box;
- color: inherit;
- display: inline-block;
- font-size: inherit;
- height: 40px;
- outline: none;
- padding: 0 4px;
- width: 100%;
- &:focus {
- border-color: var(--color-focus-blue);
- }
- }
-
- label {
- position: absolute;
- left: 5px;
- bottom: 22px;
- transition: transform 150ms ease-in-out;
- transform-origin: 0 0;
- font-size: 0.9em;
- &.floatAbove {
- transform: scale(0.75) translateY(-25px);
- }
- }
-
- input {
- &:focus + label {
- color: var(--color-focus-blue);
- transform: scale(0.75) translateY(-25px);
- }
- }
-}
diff --git a/src/components/Field.tsx b/src/components/Field.tsx
deleted file mode 100644
index a0d43cf..0000000
--- a/src/components/Field.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import * as React from 'react';
-
-import s from './Field.module.scss';
-
-const { useCallback } = React;
-
-type Props = {
- name: string;
- value?: string | number;
- type?: 'text' | 'number';
- onChange?: (...args: any[]) => any;
- id?: string;
- label?: string;
- placeholder?: string;
-};
-
-export default function Field({ id, label, value, onChange, ...props }: Props) {
- const valueOnChange = useCallback((e) => onChange(e), [onChange]);
- return (
- <div className={s.root}>
- <input id={id} value={value} onChange={valueOnChange} {...props} />
- <label htmlFor={id} className={s.floatAbove}>
- {label}
- </label>
- </div>
- );
-}
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx
deleted file mode 100644
index 560fb6d..0000000
--- a/src/components/Icon.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import cx from 'clsx';
-import React from 'react';
-
-type Props = {
- id: string;
- width?: number;
- height?: number;
- className?: string;
-};
-
-const Icon = ({ id, width = 20, height = 20, className, ...props }: Props) => {
- const c = cx('icon', id, className);
- const href = '#' + id;
- return (
- <svg className={c} width={width} height={height} {...props}>
- <use xlinkHref={href} />
- </svg>
- );
-};
-export default React.memo(Icon);
diff --git a/src/components/Input.module.scss b/src/components/Input.module.scss
deleted file mode 100644
index 44a686c..0000000
--- a/src/components/Input.module.scss
+++ /dev/null
@@ -1,28 +0,0 @@
-.input {
- -webkit-appearance: none;
- background-color: var(--color-input-bg);
- background-image: none;
- border-radius: 8px;
- border: 1px solid var(--color-input-border);
- box-sizing: border-box;
- color: inherit;
- display: inline-block;
- font-size: inherit;
- height: 35px;
- outline: none;
- padding: 0 15px;
- width: 100%;
- font-size: small;
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
-}
-
-.input:focus {
- border-color: var(--color-focus-blue);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
-}
-
-input::-webkit-outer-spin-button,
-input::-webkit-inner-spin-button {
- -webkit-appearance: none;
- margin: 0;
-}
diff --git a/src/components/Loading2.module.scss b/src/components/Loading2.module.scss
deleted file mode 100644
index 067281e..0000000
--- a/src/components/Loading2.module.scss
+++ /dev/null
@@ -1,8 +0,0 @@
-.lo {
- opacity: 0.5;
- width: 100%;
- height: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
-}
diff --git a/src/components/Loading2.tsx b/src/components/Loading2.tsx
deleted file mode 100644
index b847eb4..0000000
--- a/src/components/Loading2.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-
-import s0 from './Loading2.module.scss';
-import SvgYacd from './SvgYacd';
-
-function Loading() {
- return (
- <div className={s0.lo}>
- <SvgYacd width={280} height={280} animate c0="transparent" c1="#646464" />
- </div>
- );
-}
-
-export default Loading;
diff --git a/src/components/Modal.module.scss b/src/components/Modal.module.scss
deleted file mode 100644
index 59b0630..0000000
--- a/src/components/Modal.module.scss
+++ /dev/null
@@ -1,21 +0,0 @@
-.overlay {
- position: fixed;
- top: 0;
- right: 0;
- left: 0;
- bottom: 0;
- background: #444;
- z-index: 1024;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.content {
- outline: none;
- color: var(--color-text);
- background: var(--bg-modal);
- padding: 20px;
- border-radius: var(--border-radius);
- box-shadow: var(--shadow-card);
-}
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
deleted file mode 100644
index dfb9683..0000000
--- a/src/components/Modal.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import cx from 'clsx';
-import * as React from 'react';
-import ReactModalBase, { Props as ReactModalProps } from 'react-modal';
-
-import s0 from './Modal.module.scss';
-
-type Props = ReactModalProps & {
- isOpen: boolean;
- onRequestClose: (...args: any[]) => any;
- children: React.ReactNode;
-};
-
-const ReactModal = ReactModalBase as unknown as React.ComponentType<ReactModalProps>;
-
-function withBaseClass(
- className: ReactModalProps['className'],
- baseClassName: string
-): ReactModalProps['className'] {
- if (!className) {
- return baseClassName;
- }
-
- if (typeof className === 'string') {
- return cx(className, baseClassName);
- }
-
- return {
- ...className,
- base: cx(className.base, baseClassName),
- };
-}
-
-function ModalAPIConfig({
- isOpen,
- onRequestClose,
- className,
- overlayClassName,
- children,
- ...otherProps
-}: Props) {
- const contentCls = withBaseClass(className, s0.content);
- const overlayCls = withBaseClass(overlayClassName, s0.overlay);
- return (
- <ReactModal
- isOpen={isOpen}
- onRequestClose={onRequestClose}
- className={contentCls}
- overlayClassName={overlayCls}
- {...otherProps}
- >
- {children}
- </ReactModal>
- );
-}
-
-export default React.memo(ModalAPIConfig);
diff --git a/src/components/Search.module.scss b/src/components/Search.module.scss
deleted file mode 100644
index f9b829e..0000000
--- a/src/components/Search.module.scss
+++ /dev/null
@@ -1,32 +0,0 @@
-.RuleSearch {
- width: 100%;
-}
-
-.input {
- -webkit-appearance: none;
- appearance: none;
- background-color: var(--color-input-bg);
- background-image: none;
- border-radius: 8px;
- border: 1px solid transparent;
- box-sizing: border-box;
- color: var(--color-text);
- display: inline-block;
- font-size: 0.95em;
- height: 40px;
- outline: none;
- padding: 0 16px;
- transition: all 0.2s ease;
- width: 100%;
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
-
- &::placeholder {
- color: var(--color-text-secondary);
- opacity: 0.6;
- }
-
- &:focus {
- border-color: var(--color-focus-blue);
- box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15);
- }
-}
diff --git a/src/components/Search.tsx b/src/components/Search.tsx
deleted file mode 100644
index c1eee58..0000000
--- a/src/components/Search.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import debounce from 'lodash-es/debounce';
-import React, { useCallback, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-
-import s0 from './Search.module.scss';
-
-function RuleSearch({ dispatch, searchText, updateSearchText, className }) {
- const { t } = useTranslation();
- const [text, setText] = useState(searchText);
- const updateSearchTextInternal = useCallback(
- (v) => {
- dispatch(updateSearchText(v));
- },
- [dispatch, updateSearchText]
- );
- const updateSearchTextDebounced = useMemo(
- () => debounce(updateSearchTextInternal, 300),
- [updateSearchTextInternal]
- );
- const onChange = (e) => {
- setText(e.target.value);
- updateSearchTextDebounced(e.target.value);
- };
-
- return (
- <div className={className || s0.RuleSearch}>
- <input
- type="text"
- value={text}
- onChange={onChange}
- className={s0.input}
- placeholder={t('Search')}
- />
- </div>
- );
-}
-
-export default RuleSearch;
diff --git a/src/components/SideBar.module.scss b/src/components/SideBar.module.scss
deleted file mode 100644
index 74f7087..0000000
--- a/src/components/SideBar.module.scss
+++ /dev/null
@@ -1,191 +0,0 @@
-@use '~/styles/utils/custom-media' as *;
-
-.root {
- background: var(--color-bg-sidebar);
- width: 180px;
- min-width: 180px;
- flex-shrink: 0;
- position: relative;
-
- @media (max-width: 768px) {
- width: 100%;
- min-width: 0;
- background: var(--color-background);
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- z-index: 100;
- padding: max(8px, env(safe-area-inset-top)) 16px 8px;
- }
-}
-
-.logo_meta {
- margin-top: 12px;
- height: 120px;
- background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3FpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQyIDc5LjE2MDkyNCwgMjAxNy8wNy8xMy0wMTowNjozOSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo1OGE5MDMzMS0zZWM3LTRhNGItOTUyNS1mZDNlYTZmZDU5ZGUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTFENDRDNEQ1OEQxMTFFQ0JBRjI4MzI2MEM4NEU4NDkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTFENDRDNEM1OEQxMTFFQ0JBRjI4MzI2MEM4NEU4NDkiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjE1NWRkNmQzLTczOTItMmM0ZC1iNzQxLWYzY2JhYWQ5NjUxOCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo1OGE5MDMzMS0zZWM3LTRhNGItOTUyNS1mZDNlYTZmZDU5ZGUiLz4gPC9y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');
- background-size: 80px;
- background-repeat: no-repeat;
- background-position: center;
- @media (max-width: 768px) {
- display: none;
- }
-}
-
-.logo_singbox {
- margin-top: 12px;
- height: 120px;
- background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAyNyIgaGVpZ2h0PSIxMTA5IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWw6c3BhY2U9InByZXNlcnZlIiBvdmVyZmxvdz0iaGlkZGVuIj4KICA8ZGVmcz4KICAgIDxmaWx0ZXIgaWQ9ImZ4MCIgeD0iLTEwJSIgeT0iLTEwJSIgd2lkdGg9IjEyMCUiIGhlaWdodD0iMTIwJSIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBwcmltaXRpdmVVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICAgICA8ZmVDb21wb25lbnRUcmFuc2ZlciBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiPgogICAgICAgIDxmZUZ1bmNSIHR5cGU9ImRpc2NyZXRlIiB0YWJsZVZhbHVlcz0iMCAwIiAvPgogICAgICAgICAgPGZlRnVuY0cgdHlwZT0iZGlzY3JldGUiIHRhYmxlVmFsdWVzPSIwIDAiIC8+CiAgICAgICAgICA8ZmVGdW5jQiB0eXBlPSJkaXNjcmV0ZSIgdGFibGVWYWx1ZXM9IjAgMCIgLz4KICAgICAgICAgIDxmZUZ1bmNBIHR5cGU9ImxpbmVhciIgc2xvcGU9IjAuNCIgaW50ZXJjZXB0PSIwIiAvPgogICAgICA8L2ZlQ29tcG9uZW50VHJhbnNmZXI+CiAgICAgICAgPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iNC41ODMzMyA0LjU4MzMzIiAvPgogICAgPC9maWx0ZXI+CiAgICAgIDxjbGlwUGF0aCBpZD0iY2xpcDEiPgogICAgICA8cmVjdCB4PSI2OTIiIHk9Ijg1NSIgd2lkdGg9IjEwMjciIGhlaWdodD0iMTEwOSIgLz4KICAgIDwvY2xpcFBhdGg+CiAgICAgIDxjbGlwUGF0aCBpZD0iY2xpcDIiPgogICAgICA8cmVjdCB4PSItMiIgeT0iLTIiIHdpZHRoPSI1NDEiIGhlaWdodD0iNzg2IiAvPgogICAgPC9jbGlwUGF0aD4KICAgICAgPGNsaXBQYXRoIGlkPSJjbGlwMyI+CiAgICAgIDxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSI1MzUiIGhlaWdodD0iNzgyIiAvPgogICAgPC9jbGlwUGF0aD4KICA8L2RlZnM+CiAgICA8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDEpIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNjkyIC04NTUpIj4KICAgIDxwYXRoIGQ9Ik02OTIgMTE5MSA2OTIgMTU3NS42OUM2OTIgMTY0MC40MSA3MzEuNDk5IDE2NTEuMTkgNzMxLjQ5OSAxNjUxLjE5TDExNDguMDMgMTkzMS42MkMxMjEyLjY2IDE5NzQuNzcgMTE5NC43MSAxODgxLjI5IDExOTQuNzEgMTg4MS4yOUwxMTk0LjcxIDE1MjguOTYgNjkyIDExOTFaIiBmaWxsPSIjMzc0NzRGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIC8+CiAgICAgICAgPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAyKSIgZmlsdGVyPSJ1cmwoI2Z4MCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDExODQgMTE4MikiPgogICAgICA8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDMpIj4KICAgICAgICA8cGF0aCBkPSJNNTIwLjQ4MiAxNS40ODE5IDUyMC40ODIgNDAwLjE3NkM1MjAuNDgyIDQ2NC44OSA0ODAuOTgzIDQ3NS42NzYgNDgwLjk4MyA0NzUuNjc2IDQ4MC45ODMgNDc1LjY3NiAxMjkuMDg2IDcxMi45NjMgNjQuNDUyMyA3NTYuMTA2LTAuMTgxODE0IDc5OS4yNSAxNy43NzIxIDcwNS43NzMgMTcuNzcyMSA3MDUuNzczTDE3Ljc3MjEgMzUzLjQzNyA1MjAuNDgyIDE1LjQ4MTlaIiBmaWxsPSIjNDU1QTY0IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIC8+CiAgICAgIDwvZz4KICAgIDwvZz4KICAgICAgICA8cGF0aCBkPSJNMTY5OCAxMTkxIDE2OTggMTU3NS42OUMxNjk4IDE2NDAuNDEgMTY1OC41IDE2NTEuMTkgMTY1OC41IDE2NTEuMTkgMTY1OC41IDE2NTEuMTkgMTMwNi42IDE4ODguNDggMTI0MS45NyAxOTMxLjYyIDExNzcuMzQgMTk3NC43NyAxMTk1LjI5IDE4ODEuMjkgMTE5NS4yOSAxODgxLjI5TDExOTUuMjkgMTUyOC45NiAxNjk4IDExOTFaIiBmaWxsPSIjNDU1QTY0IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIC8+CiAgICAgICAgPHBhdGggZD0iTTEyNDEuNzEgODY4LjQ3M0MxMjEyLjk2IDg1MC41MDkgMTE2OS44NSA4NTAuNTA5IDExNDQuNyA4NjguNDczTDcxMy41NTcgMTE2My4wN0M2ODQuODE0IDExODEuMDQgNjg0LjgxNCAxMjEzLjM3IDcxMy41NTcgMTIzMS4zM0wxMTQ0LjcgMTUyOS41M0MxMTczLjQ0IDE1NDcuNDkgMTIxNi41NiAxNTQ3LjQ5IDEyNDEuNzEgMTUyOS41M0wxNjc2LjQ0IDEyMjcuNzRDMTcwNS4xOSAxMjA5Ljc4IDE3MDUuMTkgMTE3Ny40NCAxNjc2LjQ0IDExNTkuNDhMMTI0MS43MSA4NjguNDczWiIgZmlsbD0iIzU0NkU3QSIgZmlsbC1ydWxlPSJldmVub2RkIiAvPgogICAgICAgIDxwYXRoIGQ9Ik0xMTk1IDE5NDlDMTE3My40IDE5NDkgMTE1OSAxOTM1LjE5IDExNTkgMTkxNy45MkwxMTU5IDE1MzEuMDhDMTE1OSAxNTEzLjgyIDExNzMuNCAxNTAwIDExOTUgMTUwMCAxMjE2LjYgMTUwMCAxMjMxIDE1MTMuODIgMTIzMSAxNTMxLjA4TDEyMzEgMTkxNC40NkMxMjMxIDE5MzUuMTkgMTIxNi42IDE5NDkgMTE5NSAxOTQ5WiIgZmlsbD0iIzU0NkU3QSIgZmlsbC1ydWxlPSJldmVub2RkIiAvPgogICAgICAgIDxwYXRoIGQ9Ik0xNTUzLjkyIDE0MzUuOTJDMTU1My45MiAxNDcxLjg5IDE1NTcuNSAxNDg2LjI3IDE1MTguMDMgMTUxMS40NUwxNDI4LjMyIDE1NjguOTlDMTM4OC44NSAxNTk0LjE3IDEzNzQuNSAxNTcyLjU5IDEzNzQuNSAxNTQwLjIyTDEzNzQuNSAxNDQ2LjcxQzEzNzQuNSAxNDM5LjUyIDEzNzQuNSAxNDM1LjkyIDEzNjMuNzMgMTQyOC43MyAxMjcwLjQzIDEzNjMuOTkgOTExLjU5MSAxMTE1Ljg0IDg0NyAxMDY5LjA5TDEwMTIuMDcgOTU0QzEwNTguNzIgOTgyLjc3MiAxMzk5LjYxIDEyMDkuMzUgMTUzOS41NiAxMzA2LjQ1IDE1NDYuNzQgMTMxMC4wNSAxNTUwLjMzIDEzMTcuMjQgMTU1MC4zMyAxMzIwLjg0TDE1NTAuMzMgMTQzNS45MloiIGZpbGw9IiM5OUFBQjUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgLz4KICAgICAgICA8cGF0aCBkPSJNMTU0My40MSAxMzEwLjIxQzEzOTkuODIgMTIxMy4xNyAxMDU4Ljc5IDk4Ni43NTIgMTAxNS43MiA5NThMOTUxLjEwMyA5OTcuNTM0IDg0NyAxMDY5LjQxQzkxMS42MTUgMTExNi4xNCAxMjcwLjU5IDEzNjAuNTMgMTM2My45MiAxNDI1LjIyIDEzNzEuMSAxNDI4LjgxIDEzNzEuMSAxNDMyLjQxIDEzNzEuMSAxNDM2TDE1NDcgMTMxMy44QzE1NDcgMTMxMy44IDE1NDcgMTMxMC4yMSAxNTQzLjQxIDEzMTAuMjFaIiBmaWxsPSIjQ0NENkREIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIC8+CiAgICAgICAgPHBhdGggZD0iTTE1NTQuOSAxNDM1LjQ4IDE1NTQuOSAxMzI0LjE5QzE1NTQuOSAxMzE3LjAxIDE1NTEuMyAxMzEzLjQyIDE1NDQuMTEgMTMwOS44MyAxNDAwLjI4IDEyMTIuODkgMTA1OC42NyA5ODYuNzIxIDEwMTUuNTEgOTU4TDk0MCAxMDA4LjI2QzEwNjIuMjYgMTA5MC44MyAxMzg5LjQ5IDEzMDYuMjQgMTQ3NS43OSAxMzY3LjI3IDE0ODYuNTggMTM3NC40NSAxNDg2LjU4IDEzODEuNjMgMTQ4Ni41OCAxMzg1LjIyTDE0ODYuNTggMTUzNiAxNTIyLjU0IDE1MTAuODdDMTU1OC41IDE0ODUuNzQgMTU1NC45IDE0NjcuNzkgMTU1NC45IDE0MzUuNDhaIiBmaWxsPSIjQ0NENkREIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIC8+CiAgICAgICAgPHBhdGggZD0iTTE1NDMuMjMgMTMwOS45NUMxMzk5LjYgMTIxMi45OCAxMDU4LjQ5IDk4Ni43MzEgMTAxNS40IDk1OEw5NDAgMTAwOC4yOEMxMDYyLjA4IDEwOTAuODggMTM4OC44MyAxMzA2LjM2IDE0NzUuMDEgMTM2Ny40MSAxNDc1LjAxIDEzNjcuNDEgMTQ3OC42IDEzNzEgMTQ3OC42IDEzNzFMMTU1NCAxMzE3LjEzQzE1NDYuODIgMTMxMy41NCAxNTQ2LjgyIDEzMDkuOTUgMTU0My4yMyAxMzA5Ljk1WiIgZmlsbD0iI0UxRThFRCIgZmlsbC1ydWxlPSJldmVub2RkIiAvPgogIDwvZz4KPC9zdmc+');
- background-size: 80px;
- background-repeat: no-repeat;
- background-position: center;
- @media (max-width: 768px) {
- display: none;
- }
-}
-
-.rows {
- @media (max-width: 768px) {
- display: flex;
- justify-content: space-around;
- align-items: center;
- overflow: visible;
- background: var(--color-bg-sidebar);
- border-radius: 20px;
- padding: 6px 8px;
- box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.08);
- border: 1px solid var(--color-separator);
- }
-}
-
-/* a router link */
-.row {
- color: var(--color-text);
- text-decoration: none;
-
- display: flex;
- align-items: center;
- padding: 6px 10px;
- margin: 4px 8px;
- border-radius: 8px;
- box-shadow: inset 0 0 0 1px transparent;
- transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
-
- &:hover {
- background-color: rgba(176, 206, 255, 0.221);
- color: var(--color-focus-blue);
- }
-
- @media (--breakpoint-not-small) {
- padding: 8px 20px;
- }
-
- @media (max-width: 768px) {
- flex-direction: row;
- justify-content: center;
- margin: 0;
- border-radius: 14px;
- padding: 8px 14px;
- flex: 1;
-
- &:hover {
- transform: none;
- background-color: rgba(176, 206, 255, 0.221);
- box-shadow: none;
- }
- }
-
- svg {
- color: var(--color-icon);
- width: 22px;
- height: 22px;
- flex-shrink: 0;
- transition: transform 0.18s ease;
-
- @media (--breakpoint-not-small) {
- width: 24px;
- height: 24px;
- }
- }
-}
-
-.rowActive {
- background: linear-gradient(135deg, #60a5fa 0%, var(--color-focus-blue) 100%);
- color: #fff;
-
- &:hover {
- color: #fff;
- background: linear-gradient(135deg, #60a5fa 0%, var(--color-focus-blue) 100%);
- }
-
- svg {
- color: #fff;
- }
-
- .label {
- font-weight: 600;
- }
-
- @media (max-width: 768px) {
- background: rgba(99, 160, 255, 0.18);
- color: var(--color-focus-blue);
- border-bottom: none;
- box-shadow: none;
-
- svg {
- color: var(--color-focus-blue);
- transform: scale(1.15);
- }
- }
-}
-
-.label {
- padding-left: 14px;
- font-size: 0.75em;
- @media (max-width: 768px) {
- display: none;
- }
-
- @media (--breakpoint-not-small) {
- font-size: 1em;
- }
-}
-
-.footer {
- position: absolute;
- bottom: 10px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
-}
-
-@media (max-width: 768px) {
- .footer {
- display: none;
- }
-}
-
-.iconWrapper {
- --sz: 40px;
-
- width: var(--sz);
- height: var(--sz);
- display: flex;
- justify-content: center;
- align-items: center;
-
- outline: none;
- padding: 5px;
- color: var(--color-text);
- border-radius: 100%;
- border: 1px solid transparent;
-}
-.iconWrapper:hover {
- opacity: 0.6;
-}
-.iconWrapper:focus {
- border-color: var(--color-focus-blue);
-}
diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx
deleted file mode 100644
index 15db264..0000000
--- a/src/components/SideBar.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import { useSuspenseQuery } from '@tanstack/react-query';
-import cx from 'clsx';
-import * as React from 'react';
-import { useTranslation } from 'react-i18next';
-import { FcAreaChart, FcDocument, FcGlobe, FcLink, FcRuler, FcSettings } from 'react-icons/fc';
-import { Link, useLocation } from 'react-router-dom';
-
-import { fetchVersion } from '~/api/version';
-import { Info } from '~/components/shared/FeatherIcons';
-import { ThemeSwitcher } from '~/components/shared/ThemeSwitcher';
-import { Tooltip } from '~/components/shared/Tooltip';
-import { connect } from '~/components/StateProvider';
-import { getClashAPIConfig } from '~/store/app';
-import { ClashAPIConfig } from '~/types';
-
-import s from './SideBar.module.scss';
-
-type Props = { apiConfig: ClashAPIConfig };
-
-const icons = {
- activity: FcAreaChart,
- globe: FcGlobe,
- command: FcRuler,
- file: FcDocument,
- settings: FcSettings,
- link: FcLink,
-};
-
-const SideBarRow = React.memo(function SideBarRow({
- isActive,
- to,
- iconId,
- labelText,
-}: SideBarRowProps) {
- const Comp = icons[iconId];
- const className = cx(s.row, isActive ? s.rowActive : null);
- return (
- <Link to={to} className={className}>
- <Comp />
- <div className={s.label}>{labelText}</div>
- </Link>
- );
-});
-
-interface SideBarRowProps {
- isActive: boolean;
- to: string;
- iconId?: string;
- labelText?: string;
-}
-
-const pages = [
- {
- to: '/home',
- iconId: 'activity',
- labelText: 'Overview',
- },
- {
- to: '/proxies',
- iconId: 'globe',
- labelText: 'Proxies',
- },
- {
- to: '/rules',
- iconId: 'command',
- labelText: 'Rules',
- },
- {
- to: '/connections',
- iconId: 'link',
- labelText: 'Conns',
- },
- {
- to: '/logs',
- iconId: 'file',
- labelText: 'Logs',
- },
- {
- to: '/configs',
- iconId: 'settings',
- labelText: 'Config',
- },
-];
-
-const mapState = (s) => ({
- apiConfig: getClashAPIConfig(s),
-});
-
-export default connect(mapState)(SideBar);
-
-function SideBar(props: Props) {
- const { t } = useTranslation();
- const location = useLocation();
-
- const { data: version } = useSuspenseQuery({
- queryKey: ['/version', props.apiConfig],
- queryFn: () => fetchVersion('/version', props.apiConfig),
- });
- return (
- <div className={s.root}>
- <div className={version.meta && version.premium ? s.logo_singbox : s.logo_meta} />
- <div className={s.rows}>
- {pages.map(({ to, iconId, labelText }) => (
- <SideBarRow
- key={to}
- to={to}
- isActive={location.pathname === to}
- iconId={iconId}
- labelText={t(labelText)}
- />
- ))}
- </div>
- <div className={s.footer}>
- <ThemeSwitcher />
- <Tooltip label={t('about')}>
- <Link to="/about" className={s.iconWrapper}>
- <Info size={20} />
- </Link>
- </Tooltip>
- </div>
- </div>
- );
-}
diff --git a/src/components/StateProvider.tsx b/src/components/StateProvider.tsx
deleted file mode 100644
index 1476b49..0000000
--- a/src/components/StateProvider.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { produce, setAutoFreeze } from 'immer';
-import React from 'react';
-
-// in logs store we update logs in place
-// outside of immer produce
-// this is just workaround
-setAutoFreeze(false);
-
-const { createContext, memo, useMemo, useRef, useEffect, useCallback, useContext, useState } =
- React;
-
-export const immer = { produce, setAutoFreeze };
-
-const StateContext = createContext(null);
-const DispatchContext = createContext(null);
-const ActionsContext = createContext(null);
-
-export function useStoreState() {
- return useContext(StateContext);
-}
-
-export function useStoreDispatch() {
- return useContext(DispatchContext);
-}
-
-export function useStoreActions() {
- return useContext(ActionsContext);
-}
-
-// boundActionCreators
-export default function Provider({ initialState, actions = {}, children }) {
- const stateRef = useRef(initialState);
- const [state, setState] = useState(initialState);
- const getState = useCallback(() => stateRef.current, []);
- useEffect(() => {
- if (import.meta.env.DEV) {
- (window as any).getState2 = getState;
- }
- }, [getState]);
- const dispatch = useCallback(
- (actionId: string | ((a: any, b: any) => any), fn: (s: any) => void) => {
- if (typeof actionId === 'function') return actionId(dispatch, getState);
-
- const stateNext = produce(getState(), fn);
- if (stateNext !== stateRef.current) {
- if (import.meta.env.DEV) {
-
- console.log(actionId, stateNext);
- }
- stateRef.current = stateNext;
- setState(stateNext);
- }
- },
- [getState]
- );
- const boundActions = useMemo(() => bindActions(actions, dispatch), [actions, dispatch]);
-
- return (
- <StateContext.Provider value={state}>
- <DispatchContext.Provider value={dispatch}>
- <ActionsContext.Provider value={boundActions}>{children}</ActionsContext.Provider>
- </DispatchContext.Provider>
- </StateContext.Provider>
- );
-}
-
-export function connect(mapStateToProps: any) {
- return (Component: any) => {
- const MemoComponent = memo(Component);
- function Connected(props: any) {
- const state = useContext(StateContext);
- const dispatch = useContext(DispatchContext);
- const mapped = mapStateToProps(state, props);
- const nextProps = { dispatch, ...props, ...mapped };
- return <MemoComponent {...nextProps} />;
- }
- return Connected;
- };
-}
-
-// steal from https://github.com/reduxjs/redux/blob/master/src/bindActionCreators.ts
-function bindAction(action: any, dispatch: any) {
- return function (...args: any[]) {
- return dispatch(action(...args));
- };
-}
-
-function bindActions(actions: any, dispatch: any) {
- const boundActions = {};
- for (const key in actions) {
- const action = actions[key];
- if (typeof action === 'function') {
- boundActions[key] = bindAction(action, dispatch);
- } else if (typeof action === 'object') {
- boundActions[key] = bindActions(action, dispatch);
- }
- }
- return boundActions;
-}
diff --git a/src/components/SwitchThemed.tsx b/src/components/SwitchThemed.tsx
deleted file mode 100644
index 923eff1..0000000
--- a/src/components/SwitchThemed.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import * as React from 'react';
-import ReactSwitch from 'react-switch';
-
-import { State } from '~/store/types';
-
-import { getTheme } from '../store/app';
-
-import { connect } from './StateProvider';
-
-// workaround https://github.com/vitejs/vite/issues/2139#issuecomment-802981228
-// @ts-ignore
-const Switch = ReactSwitch.default ? ReactSwitch.default : ReactSwitch;
-
-function SwitchThemed({ checked = false, onChange, theme, name }) {
- const offColor = theme === 'dark' ? '#393939' : '#e9e9e9';
- const onColor = theme === 'dark' ? '#306081' : '#005caf';
- return (
- <Switch
- onChange={onChange}
- checked={checked}
- uncheckedIcon={false}
- checkedIcon={false}
- offColor={offColor}
- onColor={onColor}
- offHandleColor="#fff"
- onHandleColor="#fff"
- handleDiameter={24}
- height={28}
- width={44}
- className="rs"
- name={name}
- />
- );
-}
-
-export default connect((s: State) => ({ theme: getTheme(s) }))(SwitchThemed);
diff --git a/src/components/about/About.tsx b/src/components/about/About.tsx
index 10e8f6e..8d122fe 100644
--- a/src/components/about/About.tsx
+++ b/src/components/about/About.tsx
@@ -1,6 +1,5 @@
import * as React from 'react';
-import ContentHeader from '~/components/ContentHeader';
import { GitHub } from '~/components/shared/FeatherIcons';
import { useAboutVersionQuery } from '~/modules/about/hooks';
import { getCoreVersionMeta } from '~/modules/about/utils';
@@ -34,7 +33,6 @@ export function About({ apiConfig }: Props) {
return (
<>
- <ContentHeader>About</ContentHeader>
{coreVersionMeta && version?.version ? (
<Version
name={coreVersionMeta.name}
diff --git a/src/components/backend/APIConfig.module.scss b/src/components/backend/APIConfig.module.scss
index 6581d46..cfc740f 100644
--- a/src/components/backend/APIConfig.module.scss
+++ b/src/components/backend/APIConfig.module.scss
@@ -1,52 +1,268 @@
.root {
+ // 弹层里这块是 flex item,写死宽度才不会被内容撑成 shrink-to-fit
+ width: 520px;
+ max-width: 100%;
+ margin: 0 auto;
+ padding: 40px 0 48px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
&:focus {
outline: none;
}
}
-.header {
+/* 顶部品牌区 */
+.hero {
display: flex;
- justify-content: center;
+ flex-direction: column;
align-items: center;
+ text-align: center;
+ gap: 8px;
+}
- .icon {
- --stroke: #f3f3f3;
- color: #20497e;
- opacity: 0.7;
- transition: opacity 400ms;
- &:hover {
- opacity: 1;
- }
+.logo {
+ --stroke: var(--color-background);
+ color: #20497e;
+ opacity: 0.85;
+ transition: opacity 400ms;
+ &:hover {
+ opacity: 1;
}
}
-.body {
- padding: 15px 0 0;
+.title {
+ margin: 4px 0 0;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--color-text-highlight);
+}
+
+.desc {
+ margin: 0;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ color: var(--color-text-secondary);
+}
+
+/* 表单卡片 */
+.card {
+ padding: 20px;
+ border-radius: var(--border-radius);
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ box-shadow: var(--shadow-card);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+/* 协议 / 主机 / 端口一行,secret 独占一行 */
+.grid {
+ display: grid;
+ gap: 14px 12px;
+ grid-template-columns: minmax(140px, auto) minmax(0, 1fr) 88px;
+ grid-template-areas:
+ 'protocol host port'
+ 'secret secret secret';
+
+ @media screen and (max-width: 30em) {
+ grid-template-columns: minmax(0, 1fr) 88px;
+ grid-template-areas:
+ 'protocol protocol'
+ 'host port'
+ 'secret secret';
+ }
+}
+
+.protocol {
+ grid-area: protocol;
+}
+.host {
+ grid-area: host;
+}
+.port {
+ grid-area: port;
+}
+.secret {
+ grid-area: secret;
+}
+
+.field {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
}
-.hostnamePort {
+.label {
display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+}
+
+.optional {
+ padding: 1px 6px;
+ border-radius: 100px;
+ background: var(--color-badge-bg);
+ color: var(--color-badge-fg);
+ font-size: 0.65rem;
+ font-weight: 400;
+ text-transform: lowercase;
+}
+
+.input {
+ -webkit-appearance: none;
+ width: 100%;
+ min-width: 0;
+ height: 38px;
+ box-sizing: border-box;
+ padding: 0 10px;
+ font-family: inherit;
+ font-size: 0.875rem;
+ color: var(--color-text);
+ background: var(--color-input-bg);
+ border: 1px solid var(--color-input-border);
+ border-radius: 10px;
+ outline: none;
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
- div {
- flex: 1 1 auto;
+ &::placeholder {
+ color: var(--color-text-secondary);
+ opacity: 0.6;
}
- div:nth-child(2) {
- flex-grow: 0;
- flex-basis: 120px;
- margin-left: 10px;
+ &:focus {
+ border-color: var(--color-focus-blue);
+ box-shadow: 0 0 0 3px var(--color-accent-soft-bg);
}
}
+/* 协议分段器与输入框对齐高度 */
+.protocolControl {
+ height: 38px;
+}
+
+.inputWithAction {
+ position: relative;
+ display: flex;
+ align-items: center;
+
+ .input {
+ padding-right: 40px;
+ }
+}
+
+.iconBtn {
+ position: absolute;
+ right: 4px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ appearance: none;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--color-icon);
+ cursor: pointer;
+
+ &:hover {
+ color: var(--color-text);
+ background: var(--color-hover-soft);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: -2px;
+ }
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.status {
+ min-width: 0;
+ font-size: 0.75rem;
+ line-height: 1.4;
+}
+
+.buttons {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.preview {
+ display: block;
+ color: var(--color-text-secondary);
+ font-family: var(--font-mono);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
.error {
- height: 20px;
- font-size: 0.8em;
- color: #ff8b8b;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--color-danger);
+
+ svg {
+ flex-shrink: 0;
+ }
+}
+
+/* 已保存后端 */
+.saved {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
}
-.footer {
- padding: 5px 0 10px;
+.savedHeader {
display: flex;
- justify-content: flex-end;
align-items: center;
+ gap: 8px;
+ padding: 0 4px;
+}
+
+.savedTitle {
+ margin: 0;
+ font-size: 0.8rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--color-text-secondary);
+}
+
+.count {
+ padding: 1px 8px;
+ border-radius: 100px;
+ background: var(--color-badge-bg);
+ color: var(--color-badge-fg);
+ font-size: 0.7rem;
+}
+
+.empty {
+ margin: 0;
+ padding: 20px;
+ text-align: center;
+ font-size: 0.8rem;
+ color: var(--color-text-secondary);
+ border: 1px dashed var(--color-card-border);
+ border-radius: var(--border-radius);
}
diff --git a/src/components/backend/APIConfig.tsx b/src/components/backend/APIConfig.tsx
index 72c72ca..3f26690 100644
--- a/src/components/backend/APIConfig.tsx
+++ b/src/components/backend/APIConfig.tsx
@@ -1,13 +1,18 @@
+import cx from 'clsx';
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
-import Button from '~/components/Button';
-import Field from '~/components/Field';
-import SvgYacd from '~/components/SvgYacd';
+import Button from '~/components/shared/Button';
+import { AlertCircle, Eye, EyeOff } from '~/components/shared/FeatherIcons';
+import { SegmentedControl } from '~/components/shared/SegmentedControl';
+import SvgYacd from '~/components/shared/SvgYacd';
+import { useToggle } from '~/hooks/basic';
import { useBackendConfigForm } from '~/modules/backend/hooks';
+import type { Protocol } from '~/modules/backend/utils';
import type { ClashAPIConfigWithAddedAt } from '~/store/types';
import type { ClashAPIConfig } from '~/types';
-import s0 from './APIConfig.module.scss';
+import s from './APIConfig.module.scss';
import { BackendList } from './BackendList';
type Props = {
@@ -16,9 +21,13 @@ type Props = {
onAddConfig: (config: ClashAPIConfig) => void;
onRemoveConfig: (config: ClashAPIConfig) => void;
onSelectConfig: (config: ClashAPIConfig) => void;
+ onUpdateConfig: (prev: ClashAPIConfig, next: ClashAPIConfig) => void;
};
-const { useRef } = React;
+const protocolOptions: { value: Protocol; label: string }[] = [
+ { value: 'http', label: 'HTTP' },
+ { value: 'https', label: 'HTTPS' },
+];
export default function APIConfig({
apiConfigs,
@@ -26,51 +35,162 @@ export default function APIConfig({
onAddConfig,
onRemoveConfig,
onSelectConfig,
+ onUpdateConfig,
}: Props) {
- const contentEl = useRef(null);
- const { baseURL, secret, errMsg, handleInputOnChange, handleContentOnKeyDown, onConfirm } =
- useBackendConfigForm({ onAddConfig });
+ const { t } = useTranslation();
+ const [showSecret, toggleSecret] = useToggle();
+ const {
+ protocol,
+ host,
+ port,
+ secret,
+ errMsg,
+ baseURLPreview,
+ isSubmitting,
+ editing,
+ startEdit,
+ cancelEdit,
+ handleProtocolOnChange,
+ handleInputOnChange,
+ handleContentOnKeyDown,
+ onConfirm,
+ } = useBackendConfigForm({ onAddConfig, onUpdateConfig });
+
+ const SecretIcon = showSecret ? EyeOff : Eye;
return (
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions
- <div className={s0.root} ref={contentEl} onKeyDown={handleContentOnKeyDown}>
- <div className={s0.header}>
- <div className={s0.icon}>
- <SvgYacd width={160} height={160} stroke="var(--stroke)" />
+ // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
+ <div className={s.root} onKeyDown={handleContentOnKeyDown}>
+ <header className={s.hero}>
+ <div className={s.logo}>
+ <SvgYacd width={96} height={96} stroke="var(--stroke)" />
</div>
- </div>
- <div className={s0.body}>
- <div className={s0.hostnamePort}>
- <Field
- id="baseURL"
- name="baseURL"
- label="API Base URL"
- type="text"
- placeholder="http://127.0.0.1:9090"
- value={baseURL}
- onChange={handleInputOnChange}
- />
- <Field
- id="secret"
- name="secret"
- label="Secret(optional)"
- value={secret}
- type="text"
- onChange={handleInputOnChange}
- />
+ <h1 className={s.title}>
+ {editing ? t('backend_form_title_edit') : t('backend_form_title')}
+ </h1>
+ <p className={s.desc}>{editing ? editing.baseURL : t('backend_form_desc')}</p>
+ </header>
+
+ <section className={s.card}>
+ <div className={s.grid}>
+ <div className={cx(s.field, s.protocol)}>
+ <span className={s.label} id="backend-protocol-label">
+ {t('protocol')}
+ </span>
+ <SegmentedControl
+ options={protocolOptions}
+ value={protocol}
+ onChange={handleProtocolOnChange}
+ label={t('protocol')}
+ className={s.protocolControl}
+ />
+ </div>
+
+ <div className={cx(s.field, s.host)}>
+ <label className={s.label} htmlFor="backend-host">
+ {t('host')}
+ </label>
+ <input
+ className={s.input}
+ id="backend-host"
+ name="host"
+ type="text"
+ autoComplete="off"
+ spellCheck={false}
+ placeholder="127.0.0.1"
+ value={host}
+ onChange={handleInputOnChange}
+ />
+ </div>
+
+ <div className={cx(s.field, s.port)}>
+ <label className={s.label} htmlFor="backend-port">
+ {t('port')}
+ </label>
+ <input
+ className={s.input}
+ id="backend-port"
+ name="port"
+ type="text"
+ inputMode="numeric"
+ autoComplete="off"
+ placeholder="9090"
+ value={port}
+ onChange={handleInputOnChange}
+ />
+ </div>
+
+ <div className={cx(s.field, s.secret)}>
+ <label className={s.label} htmlFor="backend-secret">
+ {t('secret')}
+ <span className={s.optional}>{t('optional')}</span>
+ </label>
+ <div className={s.inputWithAction}>
+ <input
+ className={s.input}
+ id="backend-secret"
+ name="secret"
+ type={showSecret ? 'text' : 'password'}
+ autoComplete="off"
+ spellCheck={false}
+ value={secret}
+ onChange={handleInputOnChange}
+ />
+ <button
+ type="button"
+ className={s.iconBtn}
+ onClick={toggleSecret}
+ title={showSecret ? t('hide_secret') : t('show_secret')}
+ aria-label={showSecret ? t('hide_secret') : t('show_secret')}
+ >
+ <SecretIcon size={16} />
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div className={s.actions}>
+ <div className={s.status}>
+ {errMsg ? (
+ <span className={s.error}>
+ <AlertCircle size={14} />
+ {errMsg}
+ </span>
+ ) : baseURLPreview ? (
+ <span className={s.preview}>{baseURLPreview}</span>
+ ) : null}
+ </div>
+ <div className={s.buttons}>
+ {editing ? (
+ <Button kind="minimal" label={t('cancel_edit')} onClick={cancelEdit} />
+ ) : null}
+ <Button
+ label={editing ? t('save_backend') : t('add_backend')}
+ onClick={onConfirm}
+ isLoading={isSubmitting}
+ />
+ </div>
</div>
- </div>
- <div className={s0.error}>{errMsg ? errMsg : null}</div>
- <div className={s0.footer}>
- <Button label="Add" onClick={onConfirm} />
- </div>
- <div style={{ height: 20 }} />
- <BackendList
- apiConfigs={apiConfigs}
- selectedClashAPIConfigIndex={selectedClashAPIConfigIndex}
- onRemove={onRemoveConfig}
- onSelect={onSelectConfig}
- />
+ </section>
+
+ <section className={s.saved}>
+ <div className={s.savedHeader}>
+ <h2 className={s.savedTitle}>{t('saved_backends')}</h2>
+ {apiConfigs.length > 0 ? <span className={s.count}>{apiConfigs.length}</span> : null}
+ </div>
+ {apiConfigs.length > 0 ? (
+ <BackendList
+ apiConfigs={apiConfigs}
+ selectedClashAPIConfigIndex={selectedClashAPIConfigIndex}
+ editing={editing}
+ onRemove={onRemoveConfig}
+ onSelect={onSelectConfig}
+ onEdit={startEdit}
+ />
+ ) : (
+ <p className={s.empty}>{t('no_saved_backends')}</p>
+ )}
+ </section>
</div>
);
}
diff --git a/src/components/backend/APIDiscovery.module.scss b/src/components/backend/APIDiscovery.module.scss
index e2161f8..630ec36 100644
--- a/src/components/backend/APIDiscovery.module.scss
+++ b/src/components/backend/APIDiscovery.module.scss
@@ -6,7 +6,10 @@
left: 0;
right: 0;
- transform: none;
+ // Modal 用独立的 translate 属性做居中,这里必须同样用 translate 抵消。
+ // 注意别在同一条规则里再写 transform:postcss-preset-env 会把两者合并成
+ // transform: translate(0,0),translate 声明被吞掉,弹层就会偏移半屏。
+ translate: none;
padding: 0;
border-radius: 0;
diff --git a/src/components/backend/APIDiscovery.tsx b/src/components/backend/APIDiscovery.tsx
index f848623..aace980 100644
--- a/src/components/backend/APIDiscovery.tsx
+++ b/src/components/backend/APIDiscovery.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
-import Modal from '~/components/Modal';
+import Modal from '~/components/shared/Modal';
import { ThemeSwitcher } from '~/components/shared/ThemeSwitcher';
import { DOES_NOT_SUPPORT_FETCH, errors } from '~/misc/errors';
diff --git a/src/components/backend/BackendList.module.scss b/src/components/backend/BackendList.module.scss
index f3be8da..94d27c7 100644
--- a/src/components/backend/BackendList.module.scss
+++ b/src/components/backend/BackendList.module.scss
@@ -1,102 +1,156 @@
.ul {
- position: relative;
margin: 0;
padding: 0;
list-style: none;
- line-height: 1.8;
-
- --width-max-content: 230px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
}
.li {
- position: relative;
- margin: 5px 0;
- padding: 10px 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px 8px 4px;
border-radius: var(--border-radius);
- display: grid;
- place-content: center;
- grid-template-columns: 40px 1fr 40px;
- grid-template-rows: 30px;
- grid-template-areas: 'close url .';
- column-gap: 10px;
- border: 1px solid var(--bg-near-transparent);
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ transition:
+ border-color 0.2s ease,
+ background-color 0.2s ease;
+
+ &:hover {
+ border-color: var(--color-focus-blue);
+ }
}
-.li:hover {
- background-color: var(--bg-near-transparent);
+.selected {
+ border-color: var(--color-focus-blue);
+ background: var(--color-node-active-bg);
}
-.close {
- opacity: 0;
- grid-area: close;
- place-self: center;
- cursor: pointer;
+/* 上方表单正绑着这一条 */
+.editing {
+ border-color: var(--color-focus-blue);
+ box-shadow: 0 0 0 3px var(--color-accent-soft-bg);
}
-.li:hover .close,
-.li:hover .eye {
- opacity: 1;
+/* 整行左侧是切换后端的点击区 */
+.main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 8px;
+ appearance: none;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: inherit;
+ font-family: inherit;
+ text-align: left;
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: -2px;
+ }
}
-.close:focus,
-.eye:focus {
- opacity: 1;
+
+.dot {
+ flex-shrink: 0;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--color-text-secondary);
+ opacity: 0.4;
+
+ .selected & {
+ background: var(--color-success);
+ opacity: 1;
+ }
}
-.hasSecret {
- grid-template-rows: repeat(2, 30px);
- grid-template-areas:
- 'close url .'
- 'close secret eye';
+.body {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
}
.url {
- grid-area: url;
-}
-.secret {
- grid-area: secret;
-}
-.eye {
- grid-area: eye;
- opacity: 0;
- place-self: center;
- cursor: pointer;
+ font-size: 0.875rem;
+ color: var(--color-text-highlight);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.url,
.secret {
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-.btn {
- outline: none;
- appearance: none;
- border: 1px solid transparent;
- background-color: transparent;
- color: inherit;
+.tools {
+ flex-shrink: 0;
display: flex;
align-items: center;
- padding: 5px;
- border-radius: 100px;
-}
-.btn:focus {
- border-color: var(--color-focus-blue);
-}
-.btn:hover:enabled {
- background-color: var(--color-focus-blue);
- color: white;
-}
-.btn:active:enabled {
- transform: scale(0.97);
+ gap: 4px;
}
-.btn:disabled {
- color: var(--color-text-secondary);
+
+.badge {
+ padding: 2px 8px;
+ border-radius: 100px;
+ background: var(--color-accent-soft-bg);
+ color: var(--color-accent-soft-fg);
+ font-size: 0.68rem;
+ font-weight: 500;
+ white-space: nowrap;
}
-.url {
+.iconBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ appearance: none;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--color-icon);
cursor: pointer;
+ transition:
+ color 0.2s ease,
+ background-color 0.2s ease;
+
+ &:hover:enabled {
+ color: var(--color-text);
+ background: var(--color-hover-soft);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: -2px;
+ }
+
+ &:disabled {
+ opacity: 0.35;
+ cursor: default;
+ }
}
-.url:hover {
- color: var(--color-text-highlight);
+
+.active {
+ color: var(--color-accent-soft-fg);
+ background: var(--color-accent-soft-bg);
+}
+
+.remove:hover:enabled {
+ color: var(--color-danger);
+ background: var(--color-danger-soft-bg);
}
diff --git a/src/components/backend/BackendList.tsx b/src/components/backend/BackendList.tsx
index 66fc319..1cbbe05 100644
--- a/src/components/backend/BackendList.tsx
+++ b/src/components/backend/BackendList.tsx
@@ -1,7 +1,8 @@
import cx from 'clsx';
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
-import { Eye, EyeOff, X as Close } from '~/components/shared/FeatherIcons';
+import { Edit3, Eye, EyeOff, Trash2 } from '~/components/shared/FeatherIcons';
import { useToggle } from '~/hooks/basic';
import type { ClashAPIConfigWithAddedAt } from '~/store/types';
import type { ClashAPIConfig } from '~/types';
@@ -11,110 +12,111 @@ import s from './BackendList.module.scss';
type Props = {
apiConfigs: ClashAPIConfigWithAddedAt[];
selectedClashAPIConfigIndex: number;
+ editing: ClashAPIConfig | null;
onRemove: (x: ClashAPIConfig) => void;
onSelect: (x: ClashAPIConfig) => void;
+ onEdit: (x: ClashAPIConfig) => void;
};
export function BackendList({
apiConfigs,
selectedClashAPIConfigIndex,
+ editing,
onRemove,
onSelect,
+ onEdit,
}: Props) {
return (
- <>
- <ul className={s.ul}>
- {apiConfigs.map((item, idx) => {
- return (
- <li
- className={cx(s.li, {
- [s.hasSecret]: item.secret,
- [s.isSelected]: idx === selectedClashAPIConfigIndex,
- })}
- key={item.baseURL + item.secret}
- >
- <Item
- disableRemove={idx === selectedClashAPIConfigIndex}
- baseURL={item.baseURL}
- secret={item.secret}
- onRemove={onRemove}
- onSelect={onSelect}
- />
- </li>
- );
- })}
- </ul>
- </>
+ <ul className={s.ul}>
+ {apiConfigs.map((item, idx) => (
+ <Item
+ key={item.baseURL + item.secret}
+ baseURL={item.baseURL}
+ secret={item.secret}
+ isSelected={idx === selectedClashAPIConfigIndex}
+ isEditing={
+ editing != null && editing.baseURL === item.baseURL && editing.secret === item.secret
+ }
+ onRemove={onRemove}
+ onSelect={onSelect}
+ onEdit={onEdit}
+ />
+ ))}
+ </ul>
);
}
function Item({
baseURL,
secret,
- disableRemove,
+ isSelected,
+ isEditing,
onRemove,
onSelect,
+ onEdit,
}: {
baseURL: string;
secret?: string;
- disableRemove: boolean;
+ isSelected: boolean;
+ isEditing: boolean;
onRemove: (x: ClashAPIConfig) => void;
onSelect: (x: ClashAPIConfig) => void;
+ onEdit: (x: ClashAPIConfig) => void;
}) {
+ const { t } = useTranslation();
const [show, toggle] = useToggle();
- const Icon = show ? EyeOff : Eye;
-
- const handleTap = React.useCallback((e: React.KeyboardEvent) => {
- e.stopPropagation();
- }, []);
+ const SecretIcon = show ? EyeOff : Eye;
return (
- <>
- <Button
- disabled={disableRemove}
- onClick={() => onRemove({ baseURL, secret })}
- className={s.close}
- >
- <Close size={20} />
- </Button>
- <span
- className={s.url}
- tabIndex={0}
- role="button"
+ <li className={cx(s.li, { [s.selected]: isSelected, [s.editing]: isEditing })}>
+ <button
+ type="button"
+ className={s.main}
+ title={isSelected ? undefined : t('use_this_backend')}
onClick={() => onSelect({ baseURL, secret })}
- onKeyUp={handleTap}
>
- {baseURL}
- </span>
- <span />
- {secret ? (
- <>
- <span className={s.secret}>{show ? secret : '***'}</span>
-
- <Button onClick={toggle} className={s.eye}>
- <Icon size={20} />
- </Button>
- </>
- ) : null}
- </>
- );
-}
+ <span className={s.dot} />
+ <span className={s.body}>
+ <span className={s.url}>{baseURL}</span>
+ <span className={s.secret}>
+ {secret ? (show ? secret : '••••••••') : t('backend_no_secret')}
+ </span>
+ </span>
+ </button>
-function Button({
- children,
- onClick,
- className,
- disabled,
-}: {
- children: React.ReactNode;
-
- onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown;
- className: string;
- disabled?: boolean;
-}) {
- return (
- <button disabled={disabled} className={cx(className, s.btn)} onClick={onClick}>
- {children}
- </button>
+ <div className={s.tools}>
+ {isSelected ? <span className={s.badge}>{t('backend_in_use')}</span> : null}
+ {secret ? (
+ <button
+ type="button"
+ className={s.iconBtn}
+ onClick={toggle}
+ title={show ? t('hide_secret') : t('show_secret')}
+ aria-label={show ? t('hide_secret') : t('show_secret')}
+ >
+ <SecretIcon size={16} />
+ </button>
+ ) : null}
+ <button
+ type="button"
+ className={cx(s.iconBtn, { [s.active]: isEditing })}
+ onClick={() => onEdit({ baseURL, secret })}
+ title={t('edit_backend')}
+ aria-label={t('edit_backend')}
+ >
+ <Edit3 size={16} />
+ </button>
+ <button
+ type="button"
+ className={cx(s.iconBtn, s.remove)}
+ disabled={isSelected}
+ onClick={() => onRemove({ baseURL, secret })}
+ title={t('remove_backend')}
+ aria-label={t('remove_backend')}
+ >
+ <Trash2 size={16} />
+ </button>
+ </div>
+ </li>
);
}
diff --git a/src/components/config/Config.module.scss b/src/components/config/Config.module.scss
index 1c178b4..1e72168 100644
--- a/src/components/config/Config.module.scss
+++ b/src/components/config/Config.module.scss
@@ -1,5 +1,3 @@
-@use '~/styles/utils/custom-media' as *;
-
.root {
max-width: 1000px;
padding: 30px 20px;
@@ -24,27 +22,23 @@
}
}
+// 同一项下并排的多个按钮(如内核更新的稳定版 / Alpha 版)
+.buttonGroup {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
.wrapSwitch {
height: 40px;
display: flex;
align-items: center;
}
-.sep {
- max-width: 900px;
- padding: 0 15px;
- @media (--breakpoint-not-small) {
- padding: 0 40px;
- }
- > div {
- border-top: 1px dashed var(--color-separator);
- }
-}
-
.label {
padding: 10px 0 8px;
font-size: 0.85rem;
- color: var(--color-text-secondary, #999);
+ color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -60,17 +54,13 @@
color: var(--color-text-highlight);
}
+// 与代理组 / 连接表格 / 规则列表同一张卡:--color-card 而不是更灰一档的
+// --bg-log-info-card,否则设置页的卡片在浅色下比别处暗一档
.card {
- background: var(--bg-log-info-card);
- border: 1px solid var(--color-separator);
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-card);
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
-
- &:hover {
- border-color: var(--color-focus-blue);
- box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
- }
}
diff --git a/src/components/config/Config.tsx b/src/components/config/Config.tsx
index 252cf04..bdbd27a 100644
--- a/src/components/config/Config.tsx
+++ b/src/components/config/Config.tsx
@@ -1,9 +1,7 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
-import Button from '~/components/Button';
-import Input from '~/components/Input';
-import { Selection2 } from '~/components/Selection';
+import Button from '~/components/shared/Button';
import {
Cpu,
DownloadCloud,
@@ -14,10 +12,11 @@ import {
Tool,
Trash2,
} from '~/components/shared/FeatherIcons';
+import Input from '~/components/shared/Input';
import Select from '~/components/shared/Select';
+import { Selection2 } from '~/components/shared/Selection';
+import Switch from '~/components/shared/SwitchThemed';
import TrafficChartSample from '~/components/shared/TrafficChartSample';
-import { useStoreActions } from '~/components/StateProvider';
-import Switch from '~/components/SwitchThemed';
import { useConfigPage } from '~/modules/config/hooks';
import {
CONFIG_CHART_STYLE_PROPS,
@@ -28,6 +27,7 @@ import {
PORT_FIELDS,
TUN_STACK_OPTIONS,
} from '~/modules/config/utils';
+import { useStoreActions } from '~/store/StateProvider';
import { ClashGeneralConfig, DispatchFn } from '~/store/types';
import { ClashAPIConfig } from '~/types';
@@ -40,12 +40,7 @@ type Props = {
apiConfig: ClashAPIConfig;
};
-export default function Config({
- dispatch,
- configs,
- selectedChartStyleIndex,
- apiConfig,
-}: Props) {
+export default function Config({ dispatch, configs, selectedChartStyleIndex, apiConfig }: Props) {
const { t, i18n } = useTranslation();
const { selectChartStyleIndex, updateAppConfig } = useStoreActions();
@@ -57,8 +52,10 @@ export default function Config({
handleReloadConfigFile,
handleRestartCore,
handleUpgradeCore,
+ upgradingChannel,
handleUpgradeGeo,
handleUpgradeUI,
+ isUpgradingUI,
handleFlushFakeIPPool,
versionQuery: { data: version },
} = useConfigPage({
@@ -91,7 +88,7 @@ export default function Config({
onBlur={handleInputOnBlur}
/>
</div>
- ) : null
+ ) : null,
)}
<div>
@@ -230,6 +227,8 @@ export default function Config({
<Button
start={<DownloadCloud size={16} />}
label={t('upgrade_ui')}
+ isLoading={isUpgradingUI}
+ disabled={isUpgradingUI}
onClick={handleUpgradeUI}
/>
</div>
@@ -254,12 +253,23 @@ export default function Config({
)}
{version.meta && !version.premium && (
<div>
- <div className={s0.label}>⚠️ Upgrade ⚠️</div>
- <Button
- start={<RotateCw size={16} />}
- label={t('upgrade_core')}
- onClick={handleUpgradeCore}
- />
+ <div className={s0.label}> {t('upgrade_core')} </div>
+ <div className={s0.buttonGroup}>
+ <Button
+ start={<DownloadCloud size={16} />}
+ label={t('upgrade_core_release')}
+ isLoading={upgradingChannel === 'release'}
+ disabled={upgradingChannel !== null}
+ onClick={() => handleUpgradeCore('release')}
+ />
+ <Button
+ start={<DownloadCloud size={16} />}
+ label={t('upgrade_core_alpha')}
+ isLoading={upgradingChannel === 'alpha'}
+ disabled={upgradingChannel !== null}
+ onClick={() => handleUpgradeCore('alpha')}
+ />
+ </div>
</div>
)}
</div>
diff --git a/src/components/connections/ConnectionCard.module.scss b/src/components/connections/ConnectionCard.module.scss
index e0c42c3..3c4691a 100644
--- a/src/components/connections/ConnectionCard.module.scss
+++ b/src/components/connections/ConnectionCard.module.scss
@@ -1,133 +1,156 @@
.card {
- background: var(--color-bg-card);
- border-radius: 12px;
- padding: 10px 14px;
- margin-bottom: 8px;
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 4px;
+ height: 112px;
+ margin-bottom: 8px;
+ padding: 10px 14px;
+ border-radius: 14px;
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ box-shadow: var(--shadow-card);
cursor: pointer;
- transition: transform 0.1s ease;
+ overflow: hidden;
+ transition:
+ border-color 0.15s ease,
+ background-color 0.15s ease;
+
+ &:hover {
+ border-color: var(--color-focus-blue);
+ }
&:active {
- transform: scale(0.98);
+ transform: scale(0.99);
}
}
.row {
display: flex;
- justify-content: space-between;
align-items: center;
+ gap: 8px;
+ height: 28px;
min-width: 0;
}
+.dot {
+ flex: 0 0 7px;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--color-text-secondary);
+ opacity: 0.4;
+}
+
+.dotBusy {
+ background: var(--color-success);
+ opacity: 1;
+}
+
.host {
- color: #40c4aa; // Similar to the image
+ flex: 1;
+ min-width: 0;
+ font-size: 0.9rem;
font-weight: 600;
- font-size: 0.95rem;
+ color: var(--color-text-highlight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- flex: 1;
- margin-right: 8px;
}
.time {
- color: var(--color-text-secondary);
- font-size: 0.8rem;
flex-shrink: 0;
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
+}
+
+.chip {
+ padding: 3px 8px;
+ border-radius: 7px;
+ font-size: 0.68rem;
+ white-space: nowrap;
+}
+
+.chipTcp {
+ background: var(--color-accent-soft-bg);
+ color: var(--color-accent-soft-fg);
}
-.typeProtocol {
- color: var(--color-text);
- font-size: 0.85rem;
- opacity: 0.8;
+.chipUdp {
+ background: var(--color-udp-bg);
+ color: var(--color-udp-fg);
}
.totals {
display: flex;
- gap: 10px;
- font-size: 0.8rem;
+ gap: 12px;
+ margin-left: auto;
+ font-size: 0.72rem;
+ font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
span {
display: flex;
align-items: center;
- gap: 4px;
- }
-
- svg {
- opacity: 0.5;
+ gap: 3px;
}
}
-.ruleChain {
+.chain {
display: flex;
align-items: center;
gap: 6px;
- color: var(--color-text-secondary);
- font-size: 0.8rem;
- overflow: hidden;
flex: 1;
- margin-right: 8px;
+ min-width: 0;
+ font-size: 0.74rem;
+ color: var(--color-text-secondary);
+}
- .rule {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
+.rule {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
- .arrow {
- opacity: 0.5;
- font-size: 0.7rem;
- }
+.arrow {
+ flex-shrink: 0;
+ opacity: 0.55;
+}
- .chains {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-weight: 500;
- color: var(--color-text);
- opacity: 0.9;
- }
+.node {
+ font-weight: 500;
+ color: var(--color-focus-blue);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.speedAndAction {
display: flex;
align-items: center;
gap: 8px;
+ flex-shrink: 0;
}
.speed {
- display: flex;
- align-items: center;
- gap: 4px;
- font-size: 0.8rem;
- color: var(--color-text-secondary);
- font-family: 'Roboto Mono', monospace;
-
- .speedIcon {
- color: #40c4aa;
- opacity: 0.8;
- }
+ font-size: 0.74rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-success);
}
.closeBtn {
- background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
- border: none;
- border-radius: 50%;
- width: 28px;
- height: 28px;
display: flex;
align-items: center;
justify-content: center;
+ width: 26px;
+ height: 26px;
+ border: none;
+ border-radius: 50%;
+ background: var(--color-badge-bg);
color: var(--color-text-secondary);
cursor: pointer;
- transition: all 0.2s ease;
&:hover {
- background: rgba(231, 76, 60, 0.1);
- color: #e74c3c;
+ background: var(--color-danger-soft-bg);
+ color: var(--color-danger);
}
}
diff --git a/src/components/connections/ConnectionCard.tsx b/src/components/connections/ConnectionCard.tsx
index b7a8ee6..1e681f9 100644
--- a/src/components/connections/ConnectionCard.tsx
+++ b/src/components/connections/ConnectionCard.tsx
@@ -1,12 +1,12 @@
+import cx from 'clsx';
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { ArrowDown, ArrowDownCircle, ArrowUp, X } from '~/components/shared/FeatherIcons';
+import { ArrowDown, ArrowUp, X } from '~/components/shared/FeatherIcons';
import prettyBytes from '~/misc/pretty-bytes';
import { formatElapsed, getDateFnsLocale } from '~/modules/connections/utils';
import { FormattedConn } from '~/store/connections';
-
import s from './ConnectionCard.module.scss';
interface Props {
@@ -19,6 +19,7 @@ const ConnectionCard = React.memo(function ConnectionCard({ conn, onDisconnect,
const { i18n } = useTranslation();
const timeAgo = formatElapsed(conn.start, getDateFnsLocale(i18n.language));
+ const busy = (conn.downloadSpeedCurr ?? 0) + (conn.uploadSpeedCurr ?? 0) > 0;
return (
<div
@@ -34,39 +35,46 @@ const ConnectionCard = React.memo(function ConnectionCard({ conn, onDisconnect,
}}
>
<div className={s.row}>
+ <span className={cx(s.dot, { [s.dotBusy]: busy })} aria-hidden />
<div className={s.host}>{conn.host}</div>
<div className={s.time}>{timeAgo}</div>
</div>
+
<div className={s.row}>
- <div className={s.typeProtocol}>{conn.type.replace(/\((.*)\)/, ' | $1')}</div>
+ <span className={cx(s.chip, conn.network === 'udp' ? s.chipUdp : s.chipTcp)}>
+ {conn.type}
+ </span>
<div className={s.totals}>
<span>
- {prettyBytes(conn.download)} <ArrowDown size={12} />
+ <ArrowDown size={11} />
+ {prettyBytes(conn.download)}
</span>
<span>
- {prettyBytes(conn.upload)} <ArrowUp size={12} />
+ <ArrowUp size={11} />
+ {prettyBytes(conn.upload)}
</span>
</div>
</div>
+
<div className={s.row}>
- <div className={s.ruleChain}>
+ <div className={s.chain}>
<span className={s.rule}>{conn.rule}</span>
- <span className={s.arrow}>→</span>
- <span className={s.chains}>{conn.chains}</span>
+ <span className={s.arrow} aria-hidden>
+ ›
+ </span>
+ <span className={s.node}>{conn.chainNode}</span>
</div>
<div className={s.speedAndAction}>
- <div className={s.speed}>
- {prettyBytes(conn.downloadSpeedCurr)}/s
- <ArrowDownCircle size={16} className={s.speedIcon} />
- </div>
+ <span className={s.speed}>{prettyBytes(conn.downloadSpeedCurr)}/s</span>
<button
+ type="button"
className={s.closeBtn}
onClick={(e) => {
e.stopPropagation();
onDisconnect(conn.id, e);
}}
>
- <X size={16} />
+ <X size={14} />
</button>
</div>
</div>
diff --git a/src/components/connections/ConnectionDetailModal.module.scss b/src/components/connections/ConnectionDetailModal.module.scss
new file mode 100644
index 0000000..a164ca7
--- /dev/null
+++ b/src/components/connections/ConnectionDetailModal.module.scss
@@ -0,0 +1,142 @@
+// 覆盖层与面板都挂在连接表格容器内(absolute 定位),
+// 模糊只作用于表格区域,不影响页面其它部分
+.overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ background: rgba(15, 23, 42, 0.32);
+ backdrop-filter: blur(3px) saturate(0.9);
+ animation: overlayFade 0.18s ease;
+}
+
+.panel {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ translate: -50% -50%;
+ z-index: 21;
+ display: flex;
+ flex-direction: column;
+ width: min(560px, calc(100% - 32px));
+ max-height: calc(100% - 32px);
+ border-radius: 18px;
+ background: var(--bg-modal);
+ border: 1px solid var(--color-card-border);
+ box-shadow: var(--shadow-popover);
+ overflow: hidden;
+ animation: panelIn 0.18s ease;
+}
+
+@keyframes overlayFade {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes panelIn {
+ from {
+ opacity: 0;
+ scale: 0.96;
+ }
+ to {
+ opacity: 1;
+ scale: 1;
+ }
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ gap: 12px;
+ padding: 16px 22px;
+ border-bottom: 1px solid var(--color-separator);
+}
+
+.headerTitle {
+ min-width: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--color-text-highlight);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.headerClose {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: auto;
+ flex-shrink: 0;
+ width: 26px;
+ height: 26px;
+ border: none;
+ border-radius: 50%;
+ background: var(--color-badge-bg);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+
+ &:hover {
+ background: var(--color-hover-soft);
+ color: var(--color-text-highlight);
+ }
+}
+
+.body {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 18px 22px 22px;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
+ gap: 0;
+ border-radius: 12px;
+ border: 1px solid var(--color-card-border);
+ overflow: hidden;
+}
+
+.item {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+ padding: 10px 14px;
+ background: var(--color-card);
+
+ &:nth-child(4n + 1),
+ &:nth-child(4n + 2) {
+ background: var(--color-track);
+ }
+
+ &:nth-child(odd) {
+ border-right: 1px solid var(--color-card-border);
+ }
+
+ &:nth-last-child(-n + 2) {
+ border-bottom: none;
+ }
+}
+
+.label {
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.value {
+ font-size: 0.82rem;
+ color: var(--color-text);
+ overflow-wrap: anywhere;
+ font-variant-numeric: tabular-nums;
+}
diff --git a/src/components/connections/ConnectionDetailModal.tsx b/src/components/connections/ConnectionDetailModal.tsx
new file mode 100644
index 0000000..c56c600
--- /dev/null
+++ b/src/components/connections/ConnectionDetailModal.tsx
@@ -0,0 +1,98 @@
+import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { X } from '~/components/shared/FeatherIcons';
+import prettyBytes from '~/misc/pretty-bytes';
+import { formatElapsed, getDateFnsLocale } from '~/modules/connections/utils';
+import { FormattedConn } from '~/store/connections';
+
+import s from './ConnectionDetailModal.module.scss';
+
+type Props = {
+ conn: FormattedConn | null;
+ onRequestClose: () => void;
+};
+
+type DetailItem = { label: string; value: string };
+
+function DetailGrid({ items }: { items: DetailItem[] }) {
+ return (
+ <div className={s.grid}>
+ {items.map((item) => (
+ <div key={item.label} className={s.item}>
+ <span className={s.label}>{item.label}</span>
+ <span className={s.value} title={item.value}>
+ {item.value || '-'}
+ </span>
+ </div>
+ ))}
+ </div>
+ );
+}
+
+export default function ConnectionDetailModal({ conn, onRequestClose }: Props) {
+ const { t, i18n } = useTranslation();
+
+ const items = React.useMemo<DetailItem[]>(() => {
+ if (!conn) return [];
+ const locale = getDateFnsLocale(i18n.language);
+ // 顺序按「连接怎么走 → 流量 → 基础信息 → 地址与元数据」组织
+ return [
+ // 路由
+ { label: t('c_host'), value: conn.host },
+ { label: t('c_rule'), value: conn.rule },
+ { label: t('c_node'), value: conn.chainNode },
+ { label: t('c_full_chain'), value: conn.chainsFull },
+ // 流量
+ { label: t('c_dl'), value: prettyBytes(conn.download) },
+ { label: t('c_ul'), value: prettyBytes(conn.upload) },
+ {
+ label: t('c_dl_speed'),
+ value: `${prettyBytes(conn.downloadSpeedCurr ?? 0)}/s`,
+ },
+ {
+ label: t('c_ul_speed'),
+ value: `${prettyBytes(conn.uploadSpeedCurr ?? 0)}/s`,
+ },
+ // 基础信息
+ { label: t('c_source'), value: conn.source },
+ { label: t('c_type'), value: conn.type },
+ { label: t('c_network'), value: conn.network.toUpperCase() },
+ {
+ label: t('c_time'),
+ value: formatElapsed(conn.start, locale),
+ },
+ // 地址与元数据
+ { label: t('c_destination'), value: `${conn.destinationIP}:${conn.destinationPort}` },
+ { label: t('c_destination_ip'), value: conn.destinationIP },
+ { label: t('c_sni'), value: conn.sniffHost },
+ { label: t('c_process'), value: conn.process ?? '' },
+ { label: t('c_source_port'), value: conn.sourcePort },
+ { label: t('c_conn_id'), value: conn.id },
+ ];
+ }, [conn, t, i18n.language]);
+
+ return (
+ <>
+ <div className={s.overlay} onClick={onRequestClose} aria-hidden />
+ <div className={s.panel} role="dialog" aria-modal="true" aria-label={t('conn_details')}>
+ <div className={s.header}>
+ <span className={s.headerTitle} title={conn?.host}>
+ {conn?.host || t('conn_details')}
+ </span>
+ <button
+ type="button"
+ className={s.headerClose}
+ onClick={onRequestClose}
+ aria-label={t('close')}
+ >
+ <X size={15} />
+ </button>
+ </div>
+ <div className={s.body}>
+ <DetailGrid items={items} />
+ </div>
+ </div>
+ </>
+ );
+}
diff --git a/src/components/connections/ConnectionSettingsModal.module.scss b/src/components/connections/ConnectionSettingsModal.module.scss
new file mode 100644
index 0000000..cfb46b2
--- /dev/null
+++ b/src/components/connections/ConnectionSettingsModal.module.scss
@@ -0,0 +1,365 @@
+// 选择器写两遍是为了压过 Modal.module.scss 的 .content(同为单类选择器,
+// 打包顺序不确定),确保这里的 padding / max-height / overflow 生效
+.modal.modal {
+ display: flex;
+ flex-direction: column;
+ width: 640px;
+ max-width: calc(100vw - 48px);
+ max-height: calc(100vh - 64px);
+ padding: 0;
+ border-radius: 18px;
+ overflow: hidden;
+
+ // Modal 默认用 translate(-50%,-50%) 居中,而带 transform/translate 的祖先会成为
+ // position: fixed 的包含块 —— dnd 拖拽中的元素正是 fixed 定位的,会整体偏移半个
+ // 弹窗。这里换成 inset + auto margin 居中,不引入任何变换。
+ inset: 0;
+ translate: none;
+ margin: auto;
+ height: fit-content;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ padding: 16px 22px;
+ border-bottom: 1px solid var(--color-separator);
+}
+
+.headerTitle {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--color-text-highlight);
+}
+
+.headerClose {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: auto;
+ width: 26px;
+ height: 26px;
+ border: none;
+ border-radius: 50%;
+ background: var(--color-badge-bg);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+
+ &:hover {
+ background: var(--color-hover-soft);
+ color: var(--color-text-highlight);
+ }
+}
+
+.body {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 4px 22px 20px;
+}
+
+/* ---------- 单行设置 ---------- */
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 0;
+ border-bottom: 1px solid var(--color-separator);
+}
+
+.rowLabel {
+ font-size: 0.85rem;
+ color: var(--color-text);
+ white-space: nowrap;
+}
+
+.rowHint {
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.rowControl {
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+}
+
+.textInput {
+ width: 240px;
+ max-width: 100%;
+ height: 34px;
+ padding: 0 11px;
+ border-radius: 9px;
+ border: 1px solid var(--color-input-border);
+ background: var(--color-input-bg);
+ color: var(--color-text);
+ font: inherit;
+ font-size: 0.8rem;
+ outline: none;
+
+ &:focus {
+ border-color: var(--color-focus-blue);
+ }
+}
+
+/* ---------- 分区 ---------- */
+
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 18px 0 0;
+}
+
+.sectionTitle {
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--color-text-highlight);
+}
+
+.sectionHint {
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
+ margin-top: -6px;
+}
+
+/* ---------- 列管理 ---------- */
+
+.columnsGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+
+ @media (max-width: 640px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.columnsPane {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+}
+
+.paneHead {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
+}
+
+.paneCount {
+ margin-left: auto;
+ padding: 1px 8px;
+ border-radius: 6px;
+ background: var(--color-badge-bg);
+ color: var(--color-badge-fg);
+ font-variant-numeric: tabular-nums;
+}
+
+.paneBodyFilled,
+.paneBodyOutlined {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px;
+ border-radius: 12px;
+ min-height: 120px;
+ max-height: 240px;
+ overflow-y: auto;
+}
+
+.paneBodyFilled {
+ background: var(--color-track);
+}
+
+.paneBodyOutlined {
+ border: 1px solid var(--color-card-border);
+}
+
+.enabledItem {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 9px;
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+}
+
+.enabledItemDragging {
+ border-color: var(--color-focus-blue);
+ box-shadow: var(--shadow-popover);
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 22px;
+ height: 22px;
+ border-radius: 6px;
+ color: var(--color-text-secondary);
+ cursor: grab;
+
+ &:hover {
+ background: var(--color-hover-soft);
+ color: var(--color-text-highlight);
+ }
+
+ &:active {
+ cursor: grabbing;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: 1px;
+ }
+}
+
+.availableItem {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 7px 10px;
+ border: none;
+ border-radius: 9px;
+ background: transparent;
+ color: var(--color-text);
+ font: inherit;
+ cursor: pointer;
+ text-align: left;
+
+ &:hover {
+ background: var(--color-hover-soft);
+ }
+}
+
+.itemLabel {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.78rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.addSign {
+ flex-shrink: 0;
+ color: var(--color-text-secondary);
+}
+
+.removeBtn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ background: var(--color-hover-soft);
+ color: var(--color-text-highlight);
+ }
+
+ &:disabled {
+ opacity: 0.35;
+ cursor: default;
+ }
+}
+
+.removeBtn:hover {
+ color: var(--color-danger);
+}
+
+.paneEmpty {
+ padding: 34px 0;
+ text-align: center;
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+ opacity: 0.7;
+}
+
+/* ---------- 客户端标签 ---------- */
+
+.tagList {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.tagRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ .textInput {
+ flex: 1;
+ width: auto;
+ min-width: 0;
+ }
+}
+
+/* ---------- 页脚 ---------- */
+
+.footer {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+ padding: 14px 22px;
+ border-top: 1px solid var(--color-separator);
+ background: var(--color-track);
+}
+
+.ghostBtn {
+ height: 36px;
+ padding: 0 15px;
+ border-radius: 10px;
+ border: 1px solid var(--color-card-border);
+ background: var(--color-card);
+ color: var(--color-text);
+ font: inherit;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ align-self: flex-start;
+
+ &:hover {
+ border-color: var(--color-focus-blue);
+ color: var(--color-focus-blue);
+ }
+}
+
+.primaryBtn {
+ height: 36px;
+ margin-left: auto;
+ padding: 0 20px;
+ border-radius: 10px;
+ border: none;
+ background: var(--color-focus-blue);
+ color: #fff;
+ font: inherit;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+
+ &:hover {
+ filter: brightness(1.08);
+ }
+}
diff --git a/src/components/connections/ConnectionSettingsModal.tsx b/src/components/connections/ConnectionSettingsModal.tsx
new file mode 100644
index 0000000..1d0d5e4
--- /dev/null
+++ b/src/components/connections/ConnectionSettingsModal.tsx
@@ -0,0 +1,252 @@
+import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
+import cx from 'clsx';
+import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Menu, Trash2, X } from '~/components/shared/FeatherIcons';
+import Modal from '~/components/shared/Modal';
+import Switch from '~/components/shared/SwitchThemed';
+import { ConnectionColumn, ConnectionSettings, SourceMapItem } from '~/modules/connections/utils';
+
+import s from './ConnectionSettingsModal.module.scss';
+
+type Props = {
+ isOpen: boolean;
+ onRequestClose: () => void;
+ settings: ConnectionSettings;
+ updateSettings: (patch: Partial<ConnectionSettings>) => void;
+ visibleColumns: ConnectionColumn[];
+ availableColumns: ConnectionColumn[];
+ addColumn: (id: string) => void;
+ removeColumn: (id: string) => void;
+ reorderColumns: (fromIndex: number, toIndex: number) => void;
+ resetColumns: () => void;
+ sourceMap: SourceMapItem[];
+ setSourceMap: (updater: React.SetStateAction<SourceMapItem[]>) => void;
+};
+
+function ToggleRow({
+ label,
+ hint,
+ checked,
+ onChange,
+}: {
+ label: string;
+ hint: string;
+ checked: boolean;
+ onChange: (value: boolean) => void;
+}) {
+ return (
+ <div className={s.row}>
+ <span className={s.rowLabel}>{label}</span>
+ <span className={s.rowHint}>{hint}</span>
+ <div className={s.rowControl}>
+ <Switch checked={checked} onChange={onChange} />
+ </div>
+ </div>
+ );
+}
+
+export default function ConnectionSettingsModal({
+ isOpen,
+ onRequestClose,
+ settings,
+ updateSettings,
+ visibleColumns,
+ availableColumns,
+ addColumn,
+ removeColumn,
+ reorderColumns,
+ resetColumns,
+ sourceMap,
+ setSourceMap,
+}: Props) {
+ const { t } = useTranslation();
+
+ const setSource = (key: keyof SourceMapItem, index: number, value: string) => {
+ setSourceMap((prev) => prev.map((item, i) => (i === index ? { ...item, [key]: value } : item)));
+ };
+
+ const onDragEnd = (result: DropResult) => {
+ if (!result.destination) return;
+ reorderColumns(result.source.index, result.destination.index);
+ };
+
+ return (
+ <Modal
+ isOpen={isOpen}
+ onRequestClose={onRequestClose}
+ title={t('conn_settings')}
+ className={s.modal}
+ >
+ <div className={s.header}>
+ <span className={s.headerTitle}>{t('conn_settings')}</span>
+ <button
+ type="button"
+ className={s.headerClose}
+ onClick={onRequestClose}
+ aria-label={t('close_all_confirm_no')}
+ >
+ <X size={15} />
+ </button>
+ </div>
+
+ <div className={s.body}>
+ <div className={s.row}>
+ <span className={s.rowLabel}>{t('hide_conn_regex')}</span>
+ <div className={s.rowControl}>
+ <input
+ type="text"
+ className={s.textInput}
+ value={settings.hideRegex}
+ placeholder="direct|dns-out"
+ onChange={(e) => updateSettings({ hideRegex: e.target.value })}
+ />
+ </div>
+ </div>
+
+ <ToggleRow
+ label={t('hide_conn')}
+ hint={t('hide_conn_hint')}
+ checked={settings.hideEnabled}
+ onChange={(value) => updateSettings({ hideEnabled: value })}
+ />
+ <ToggleRow
+ label={t('full_chain')}
+ hint={t('full_chain_hint')}
+ checked={settings.fullChain}
+ onChange={(value) => updateSettings({ fullChain: value })}
+ />
+
+ <section className={s.section}>
+ <span className={s.sectionTitle}>{t('custom_columns')}</span>
+ <div className={s.columnsGrid}>
+ <div className={s.columnsPane}>
+ <div className={s.paneHead}>
+ <span>{t('columns_enabled')}</span>
+ <span className={s.paneCount}>{visibleColumns.length}</span>
+ </div>
+ <DragDropContext onDragEnd={onDragEnd}>
+ <Droppable droppableId="enabled-columns">
+ {(provided) => (
+ <div
+ className={s.paneBodyFilled}
+ ref={provided.innerRef}
+ {...provided.droppableProps}
+ >
+ {visibleColumns.map((column, index) => (
+ <Draggable key={column.id} draggableId={column.id} index={index}>
+ {(dragProvided, snapshot) => (
+ <div
+ ref={dragProvided.innerRef}
+ {...dragProvided.draggableProps}
+ className={cx(s.enabledItem, {
+ [s.enabledItemDragging]: snapshot.isDragging,
+ })}
+ >
+ <span
+ {...dragProvided.dragHandleProps}
+ className={s.dragHandle}
+ title={t('drag_to_reorder')}
+ >
+ <Menu size={13} />
+ </span>
+ <span className={s.itemLabel}>{t(column.labelKey)}</span>
+ <button
+ type="button"
+ className={s.removeBtn}
+ onClick={() => removeColumn(column.id)}
+ title={t('remove')}
+ >
+ <X size={13} />
+ </button>
+ </div>
+ )}
+ </Draggable>
+ ))}
+ {provided.placeholder}
+ </div>
+ )}
+ </Droppable>
+ </DragDropContext>
+ </div>
+
+ <div className={s.columnsPane}>
+ <div className={s.paneHead}>
+ <span>{t('columns_available')}</span>
+ <span className={s.paneCount}>{availableColumns.length}</span>
+ </div>
+ <div className={s.paneBodyOutlined}>
+ {availableColumns.map((column) => (
+ <button
+ key={column.id}
+ type="button"
+ className={s.availableItem}
+ onClick={() => addColumn(column.id)}
+ >
+ <span className={s.itemLabel}>{t(column.labelKey)}</span>
+ <span className={s.addSign} aria-hidden>
+ +
+ </span>
+ </button>
+ ))}
+ {availableColumns.length === 0 ? (
+ <span className={s.paneEmpty}>{t('columns_all_enabled')}</span>
+ ) : null}
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section className={s.section}>
+ <span className={s.sectionTitle}>{t('client_tag')}</span>
+ <span className={s.sectionHint}>{t('sourceip_tip')}</span>
+ <div className={s.tagList}>
+ {sourceMap.map((item, index) => (
+ <div key={index} className={s.tagRow}>
+ <input
+ type="text"
+ className={s.textInput}
+ value={item.reg}
+ placeholder={t('c_source')}
+ onChange={(e) => setSource('reg', index, e.target.value)}
+ />
+ <input
+ type="text"
+ className={s.textInput}
+ value={item.name}
+ placeholder={t('device_name')}
+ onChange={(e) => setSource('name', index, e.target.value)}
+ />
+ <button
+ type="button"
+ className={s.removeBtn}
+ onClick={() => setSourceMap((prev) => prev.filter((_, i) => i !== index))}
+ title={t('delete')}
+ >
+ <Trash2 size={13} />
+ </button>
+ </div>
+ ))}
+ </div>
+ <button
+ type="button"
+ className={s.ghostBtn}
+ onClick={() => setSourceMap((prev) => [...prev, { reg: '', name: '' }])}
+ >
+ {t('add_tag')}
+ </button>
+ </section>
+ </div>
+
+ <div className={s.footer}>
+ <button type="button" className={s.ghostBtn} onClick={resetColumns}>
+ {t('reset_default_columns')}
+ </button>
+ <button type="button" className={s.primaryBtn} onClick={onRequestClose}>
+ {t('done')}
+ </button>
+ </div>
+ </Modal>
+ );
+}
diff --git a/src/components/connections/ConnectionStats.module.scss b/src/components/connections/ConnectionStats.module.scss
new file mode 100644
index 0000000..e74715f
--- /dev/null
+++ b/src/components/connections/ConnectionStats.module.scss
@@ -0,0 +1,100 @@
+.grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ padding: 16px 32px 0;
+
+ @media (max-width: 1024px) {
+ padding: 12px 16px 0;
+ }
+
+ // 窄屏保持四格一行,只是缩成一条紧凑的统计带
+ @media (max-width: 768px) {
+ gap: 6px;
+ padding: 8px 12px 0;
+ }
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 13px 15px;
+ border-radius: 14px;
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ box-shadow: var(--shadow-card);
+ min-width: 0;
+
+ @media (max-width: 768px) {
+ gap: 1px;
+ padding: 7px 8px;
+ border-radius: 10px;
+ box-shadow: none;
+ }
+}
+
+.label {
+ font-size: 0.72rem;
+ letter-spacing: 0.02em;
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @media (max-width: 768px) {
+ font-size: 0.62rem;
+ }
+}
+
+.valueRow {
+ display: flex;
+ align-items: baseline;
+ gap: 5px;
+ min-width: 0;
+}
+
+.value {
+ font-size: 1.35rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-text-highlight);
+
+ @media (max-width: 768px) {
+ font-size: 0.95rem;
+ }
+}
+
+.download {
+ color: var(--color-success);
+}
+
+.upload {
+ color: var(--color-focus-blue);
+}
+
+.unit {
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
+
+ @media (max-width: 768px) {
+ font-size: 0.6rem;
+ }
+}
+
+/* 空 hint 也占位,四张卡片高度才对齐 */
+.hint {
+ font-size: 0.7rem;
+ color: var(--color-text-secondary);
+ opacity: 0.8;
+ min-height: 1em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ // 窄屏放不下,累计流量的上下行明细只在宽屏显示
+ @media (max-width: 768px) {
+ display: none;
+ }
+}
diff --git a/src/components/connections/ConnectionStats.tsx b/src/components/connections/ConnectionStats.tsx
new file mode 100644
index 0000000..83f48e2
--- /dev/null
+++ b/src/components/connections/ConnectionStats.tsx
@@ -0,0 +1,81 @@
+import cx from 'clsx';
+import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import prettyBytes from '~/misc/pretty-bytes';
+
+import s from './ConnectionStats.module.scss';
+
+/** prettyBytes 的输出拆成数值和单位,便于两者用不同字号 */
+function splitBytes(n: number): [string, string] {
+ const [value, unit] = prettyBytes(n).split(' ');
+ return [value, unit];
+}
+
+type Props = {
+ activeCount: number;
+ downloadSpeed: number;
+ uploadSpeed: number;
+ downloadTotal: number;
+ uploadTotal: number;
+};
+
+function Stat({
+ label,
+ value,
+ unit,
+ hint,
+ tone,
+}: {
+ label: string;
+ value: string;
+ unit: string;
+ hint?: string;
+ tone?: 'download' | 'upload';
+}) {
+ return (
+ <div className={s.card}>
+ <span className={s.label}>{label}</span>
+ <div className={s.valueRow}>
+ <span
+ className={cx(s.value, {
+ [s.download]: tone === 'download',
+ [s.upload]: tone === 'upload',
+ })}
+ >
+ {value}
+ </span>
+ <span className={s.unit}>{unit}</span>
+ </div>
+ <span className={s.hint}>{hint ?? ''}</span>
+ </div>
+ );
+}
+
+export function ConnectionStats({
+ activeCount,
+ downloadSpeed,
+ uploadSpeed,
+ downloadTotal,
+ uploadTotal,
+}: Props) {
+ const { t } = useTranslation();
+
+ const [dlValue, dlUnit] = splitBytes(downloadSpeed);
+ const [ulValue, ulUnit] = splitBytes(uploadSpeed);
+ const [totalValue, totalUnit] = splitBytes(downloadTotal + uploadTotal);
+
+ return (
+ <div className={s.grid}>
+ <Stat label={t('Active Connections')} value={String(activeCount)} unit={t('conn_unit')} />
+ <Stat label={t('c_dl_speed')} value={dlValue} unit={`${dlUnit}/s`} tone="download" />
+ <Stat label={t('c_ul_speed')} value={ulValue} unit={`${ulUnit}/s`} tone="upload" />
+ <Stat
+ label={t('total_traffic')}
+ value={totalValue}
+ unit={totalUnit}
+ hint={`↓ ${prettyBytes(downloadTotal)} · ↑ ${prettyBytes(uploadTotal)}`}
+ />
+ </div>
+ );
+}
diff --git a/src/components/connections/ConnectionTable.module.scss b/src/components/connections/ConnectionTable.module.scss
index e8e3daf..11280aa 100644
--- a/src/components/connections/ConnectionTable.module.scss
+++ b/src/components/connections/ConnectionTable.module.scss
@@ -1,256 +1,401 @@
-.tr {
- transition: all 0.2s ease;
- cursor: pointer;
+.card {
display: flex;
- align-items: stretch;
- width: 100%;
- min-width: fit-content;
-
- &:hover {
- // hover 作用到每个 td,避免 odd 行背景覆盖
- .td {
- background: rgba(66, 133, 244, 0.08);
- }
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ position: relative;
+ border-radius: 16px;
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ box-shadow: var(--shadow-card);
+ overflow: hidden;
- .odd {
- background: rgba(66, 133, 244, 0.08);
- }
+ @media (max-width: 768px) {
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ border-radius: 0;
}
}
-.th {
- height: 48px;
- background: var(--bg-log-info-card);
- top: 0;
- font-size: 0.8em;
+.body {
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ---------- 表头 ---------- */
+
+.headWrap {
+ overflow: hidden;
+ flex-shrink: 0;
+ background: var(--color-track);
+ border-bottom: 1px solid var(--color-card-border);
+}
+
+// 列间距和左右内边距由 ConnectionTable 的 COLUMN_GAP / ROW_PADDING_X 内联施加,
+// 因为列宽计算必须扣掉它们,写在这里两边会不同步
+.headRow {
+ display: grid;
+ align-items: center;
+ height: 42px;
+}
+
+.headCell {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ min-width: 0;
+ font-size: 0.72rem;
font-weight: 600;
- user-select: none;
- text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.02em;
color: var(--color-text-secondary);
- border-bottom: 2px solid var(--color-separator);
- position: sticky;
- z-index: 20;
- padding: 0 8px;
white-space: nowrap;
- overflow: hidden;
- flex-shrink: 0; // Ensure fixed width is respected
+ user-select: none;
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: -2px;
+ border-radius: 4px;
+ }
+}
+
+.headCellSortable {
+ cursor: pointer;
&:hover {
color: var(--color-text-highlight);
}
}
-.headerText {
- overflow: hidden;
- text-overflow: ellipsis;
+.headCellActive {
+ color: var(--color-focus-blue);
+
+ &:hover {
+ color: var(--color-focus-blue);
+ }
}
-.cellText {
+.headText {
overflow: hidden;
text-overflow: ellipsis;
- width: 100%;
}
-.sortIconContainer {
- margin-left: 4px;
+.headArrow {
+ font-size: 0.62rem;
flex-shrink: 0;
- display: flex;
- align-items: center;
}
-.rotate180 {
- transform: rotate(180deg);
+/* ---------- 行 ---------- */
+
+.listWrap {
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
}
-.btnSection {
- button {
- margin-right: 15px;
- }
+.list {
+ overflow-x: auto;
}
-.break {
- word-wrap: break-word;
- word-break: break-all;
- align-items: center;
- text-align: left;
+.mobileList {
+ overflow-x: hidden;
}
-.td {
- padding: 0 8px;
- font-size: 0.875em;
- cursor: pointer;
- vertical-align: middle;
- white-space: nowrap;
+.rowWrap {
border-bottom: 1px solid var(--color-separator);
- transition: color 0.15s ease;
- box-sizing: border-box;
- flex-shrink: 0; // Ensure fixed width is respected
+}
+
+// 同上,gap / padding 见 ConnectionTable 的常量
+.row {
+ display: grid;
+ align-items: center;
+ height: 44px;
+ cursor: pointer;
+ user-select: none;
&:hover {
- color: var(--color-text-highlight);
+ background: var(--color-hover-soft);
}
- font-family: var(--font-normal);
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: -2px;
+ }
}
-.overlay {
- background: #444;
+.cell {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
}
-.modal {
- background-color: var(--bg-modal);
+.cellRight {
+ justify-content: flex-end;
}
-.table {
- border-collapse: separate;
- border-spacing: 0;
- width: 100%;
- background: transparent;
+/* ---------- 单元格内容 ---------- */
- @media (max-width: 768px) {
- display: none;
+.closeBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ appearance: none;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: var(--color-badge-bg);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+
+ &:hover {
+ background: var(--color-danger-soft-bg);
+ color: var(--color-danger);
}
}
-.tableWrapper {
- margin-top: 0;
- border-radius: 12px;
- overflow: hidden;
- background: transparent;
- box-shadow: none;
+.dot {
+ flex: 0 0 7px;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+}
- @media (max-width: 768px) {
- background: transparent;
- box-shadow: none;
+.dotBusy {
+ background: var(--color-success);
+ box-shadow: 0 0 0 3px var(--color-success-soft-bg);
+ animation: pulseDot 2.4s ease-in-out infinite;
+}
+
+.dotIdle {
+ background: var(--color-text-secondary);
+ opacity: 0.4;
+}
+
+@keyframes pulseDot {
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.45;
+ transform: scale(0.82);
}
}
-.theadWrapper {
- width: 100%;
+.hostText {
+ font-size: 0.82rem;
+ font-weight: 500;
+ color: var(--color-text-highlight);
overflow: hidden;
- background: var(--bg-log-info-card);
- border-top-left-radius: 12px;
- border-top-right-radius: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chip {
+ display: inline-block;
+ max-width: 100%;
+ padding: 3px 8px;
+ border-radius: 7px;
+ font-size: 0.7rem;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.cardsView {
- display: none;
+.chipTcp {
+ background: var(--color-accent-soft-bg);
+ color: var(--color-accent-soft-fg);
+}
- @media (max-width: 768px) {
- display: block;
- padding: 0 4px;
- }
+.chipUdp {
+ background: var(--color-udp-bg);
+ color: var(--color-udp-fg);
}
-.mobileSortToolbar {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 0;
- margin-bottom: 12px;
+.chipNeutral {
+ background: var(--color-badge-bg);
+ color: var(--color-badge-fg);
}
-.sortSelectWrapper {
- display: flex;
- align-items: center;
- gap: 10px;
- flex: 1;
- background: var(--color-bg-card);
- padding: 8px 12px;
- border-radius: 12px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+.chainGroup,
+.chainFull {
+ font-size: 0.76rem;
+ color: var(--color-text-secondary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chainSep {
+ flex: 0 0 auto;
+ font-size: 0.7rem;
+ color: var(--color-text-secondary);
+ opacity: 0.6;
+}
+
+.chainNode {
+ font-size: 0.76rem;
+ font-weight: 500;
color: var(--color-focus-blue);
- position: relative;
- border: 1px solid var(--color-bg-card-border, rgba(0, 0, 0, 0.05));
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
- select {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- opacity: 0;
- cursor: pointer;
- z-index: 2;
- }
+.chainDirect {
+ color: var(--color-success);
+}
- .selectedValue {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 0.95rem;
- font-weight: 600;
- color: var(--color-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
+.chainReject {
+ color: var(--color-danger);
}
-.selectArrow {
- opacity: 0.5;
+.num {
+ font-size: 0.75rem;
color: var(--color-text-secondary);
- flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.sortDirBtn {
- background: var(--color-bg-card);
- border: 1px solid var(--color-bg-card-border, rgba(0, 0, 0, 0.05));
+.speed {
+ font-size: 0.76rem;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.speedDl {
+ color: var(--color-success);
+}
+
+.speedUl {
color: var(--color-focus-blue);
+}
+
+.speedDim {
+ color: var(--color-text-secondary);
+ opacity: 0.55;
+}
+
+/* ---------- 空态 ---------- */
+
+.empty {
display: flex;
+ flex-direction: column;
align-items: center;
justify-content: center;
- width: 38px;
- height: 38px;
- border-radius: 12px;
- cursor: pointer;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
- transition: all 0.2s ease;
+ gap: 6px;
+ height: 100%;
+}
- &:active {
- transform: scale(0.95);
- background: var(--color-bg-secondary);
- }
+.emptyTitle {
+ font-size: 0.88rem;
+ color: var(--color-text);
}
-.table thead tr:first-child th:first-child {
- border-top-left-radius: 12px;
+.emptyHint {
+ font-size: 0.78rem;
+ color: var(--color-text-secondary);
}
-.table thead tr:first-child th:last-child {
- border-top-right-radius: 12px;
+/* ---------- 页脚 ---------- */
+
+.footer {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ height: 36px;
+ flex-shrink: 0;
+ padding: 0 18px;
+ border-top: 1px solid var(--color-card-border);
+ background: var(--color-track);
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
}
-.table tbody tr:last-child td:first-child {
- border-bottom-left-radius: 12px;
+.footerNote {
+ margin-left: auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @media (max-width: 768px) {
+ display: none;
+ }
}
-.table tbody tr:last-child td:last-child {
- border-bottom-right-radius: 12px;
+/* ---------- 移动端排序工具条 ---------- */
+
+.mobileToolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 42px;
+ flex-shrink: 0;
}
-.td.odd {
- background: var(--color-row-odd);
+.sortSelect {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+ height: 34px;
+ padding: 0 10px;
+ border-radius: 10px;
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ color: var(--color-text);
+ font-size: 0.82rem;
+
+ span {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ select {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ cursor: pointer;
+ }
}
-/* download upload td cells */
-.center {
- min-width: 7em;
- text-align: center;
+.sortSelectArrow {
+ flex-shrink: 0;
+ color: var(--color-text-secondary);
}
-.sortIconContainer {
- display: inline-flex;
+.sortDirBtn {
+ display: flex;
align-items: center;
justify-content: center;
- margin-left: 4px;
- width: 16px;
- height: 16px;
- opacity: 0.7;
-}
-
-.rotate180 {
- transform: rotate(180deg);
+ width: 34px;
+ height: 34px;
+ flex-shrink: 0;
+ border-radius: 10px;
+ border: 1px solid var(--color-card-border);
+ background: var(--color-card);
+ color: var(--color-focus-blue);
+ cursor: pointer;
}
diff --git a/src/components/connections/ConnectionTable.scss b/src/components/connections/ConnectionTable.scss
deleted file mode 100644
index 1559fb5..0000000
--- a/src/components/connections/ConnectionTable.scss
+++ /dev/null
@@ -1,84 +0,0 @@
-.connections-table {
- .ctrl {
- min-width: 3.5em;
- text-align: center;
- display: flex;
- justify-content: center;
- align-items: center;
-
- svg {
- height: 16px;
- width: 16px;
- opacity: 0.6;
- transition: all 0.2s ease;
- cursor: pointer;
-
- &:hover {
- opacity: 1;
- color: #e74c3c;
- transform: scale(1.1);
- }
- }
- }
-
- .type {
- min-width: 8em;
- text-align: center;
-
- // 类型标签样式
- &::before {
- content: '';
- }
- }
-
- .start,
- .downloadSpeedCurr,
- .uploadSpeedCurr,
- .download,
- .upload {
- min-width: 7em;
- text-align: center;
- font-variant-numeric: tabular-nums;
- }
-
- .downloadSpeedCurr,
- .download {
- color: #27ae60;
- }
-
- .uploadSpeedCurr,
- .upload {
- color: #3498db;
- }
-
- // 进程列
- .process {
- max-width: 12em;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- // 域名/Host列
- .host {
- max-width: 20em;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- // 规则列
- .rule {
- max-width: 15em;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- // 节点链
- .chains {
- // 不截断:完整展示节点链(必要时允许换行)
- max-width: none;
- overflow: visible;
- text-overflow: unset;
- white-space: nowrap;
- word-break: break-word;
- }
-}
diff --git a/src/components/connections/ConnectionTable.tsx b/src/components/connections/ConnectionTable.tsx
index 80bed4f..f62972b 100644
--- a/src/components/connections/ConnectionTable.tsx
+++ b/src/components/connections/ConnectionTable.tsx
@@ -1,378 +1,518 @@
-import './ConnectionTable.scss';
-
-import {
- ColumnDef,
- getCoreRowModel,
- getSortedRowModel,
- SortingState,
- useReactTable,
- VisibilityState,
-} from '@tanstack/react-table';
import cx from 'clsx';
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import * as React from 'react';
import { useTranslation } from 'react-i18next';
-import { List as VirtualList, RowComponentProps } from 'react-window';
+import { RowComponentProps, List as VirtualList } from 'react-window';
-import * as connAPI from '~/api/connections';
-import { ArrowDown, ArrowUp, ChevronDown, Sliders, XCircle } from '~/components/shared/FeatherIcons';
+import { ArrowDown, ArrowUp, ChevronDown, Sliders, X } from '~/components/shared/FeatherIcons';
import prettyBytes from '~/misc/pretty-bytes';
-import { formatElapsed, getDateFnsLocale } from '~/modules/connections/utils';
+import { useElementWidth } from '~/modules/connections/hooks';
+import {
+ ConnectionColumn,
+ formatElapsed,
+ getDateFnsLocale,
+ SortState,
+} from '~/modules/connections/utils';
import { FormattedConn } from '~/store/connections';
-
import ConnectionCard from './ConnectionCard';
+import ConnectionDetailModal from './ConnectionDetailModal';
import s from './ConnectionTable.module.scss';
-import MOdalCloseConnection from './ModalCloseAllConnections';
-import ModalConnectionDetails from './ModalConnectionDetails';
-const sortById = { id: 'id', desc: true };
+const ROW_HEIGHT = 44;
+const CARD_HEIGHT = 120;
+/** 纵向滚动条宽度,见 main.scss */
+const SCROLLBAR_SIZE = 8;
+// 列间距和行内边距由 JS 施加而非写在 scss 里:computeWidths 必须把它们算进可用
+// 宽度,两处一旦不同步表格就会横向溢出
+const COLUMN_GAP = 10;
+const ROW_PADDING_X = 12;
-const COLUMN_WIDTHS = {
- ctrl: 50,
- start: 100,
- type: 120,
- host: 300,
- rule: 200,
- chains: 250,
- download: 100,
- upload: 100,
- downloadSpeedCurr: 100,
- uploadSpeedCurr: 100,
- source: 170,
- destinationIP: 170,
- process: 130,
- sniffHost: 150,
-};
+/**
+ * 按 grow 把剩余宽度分给流式列,返回每列实际像素宽和整行宽度。
+ *
+ * 分配受 max 约束:吃满上限的列退出分配、把余量让给还有余地的列,循环到分完为止。
+ * 全部吃满后剩下的空间就留白,不再无限撑宽那几列。
+ */
+function computeWidths(columns: ConnectionColumn[], containerWidth: number) {
+ const chrome = Math.max(0, columns.length - 1) * COLUMN_GAP + ROW_PADDING_X * 2;
+ const widths = columns.map((c) => c.width);
+ const minTotal = widths.reduce((sum, w) => sum + w, 0);
+ // 列宽之外还要放下 gap 和 padding,可用于列的空间要先把它们扣掉
+ const contentSpace = Math.max(0, containerWidth - SCROLLBAR_SIZE - chrome);
+
+ let extra = contentSpace - minTotal;
+ const pool = new Set(columns.map((c, i) => i).filter((i) => (columns[i].grow ?? 0) > 0));
-const TOTAL_WIDTH = Object.values(COLUMN_WIDTHS).reduce((a, b) => a + b, 0);
+ while (extra > 0.5 && pool.size > 0) {
+ const growTotal = [...pool].reduce((sum, i) => sum + (columns[i].grow ?? 0), 0);
+ const saturated: number[] = [];
+ let consumed = 0;
-const getColumnStyle = (columnId: string) => {
- const width = COLUMN_WIDTHS[columnId] || 100;
- const style: React.CSSProperties = {
- width,
- minWidth: width,
- flex: `0 0 ${width}px`,
- flexShrink: 0,
- };
+ for (const i of pool) {
+ const share = (extra * (columns[i].grow ?? 0)) / growTotal;
+ const room = (columns[i].max ?? Infinity) - widths[i];
+ const add = Math.min(share, room);
+ widths[i] += add;
+ consumed += add;
+ if (add < share) saturated.push(i);
+ }
- if (['download', 'upload', 'downloadSpeedCurr', 'uploadSpeedCurr', 'start'].includes(columnId)) {
- style.justifyContent = 'flex-end';
+ extra -= consumed;
+ if (consumed <= 0.5) break;
+ for (const i of saturated) pool.delete(i);
}
- if (columnId === 'ctrl') {
- style.justifyContent = 'center';
+ const columnsTotal = widths.reduce((sum, w) => sum + w, 0);
+ // 列没占满时行仍然铺满容器,这样 hover 背景和分隔线不会只画一半
+ const tableWidth = Math.max(columnsTotal + chrome, containerWidth - SCROLLBAR_SIZE);
+ return { widths, tableWidth };
+}
+
+function Cell({
+ column,
+ conn,
+ isClosed,
+ fullChain,
+ locale,
+ onClose,
+}: {
+ column: ConnectionColumn;
+ conn: FormattedConn;
+ isClosed: boolean;
+ fullChain: boolean;
+ locale: ReturnType<typeof getDateFnsLocale>;
+ onClose: (id: string, e: React.MouseEvent) => void;
+}) {
+ switch (column.kind) {
+ case 'ctrl':
+ return (
+ <button
+ type="button"
+ className={s.closeBtn}
+ onClick={(e) => onClose(conn.id, e)}
+ title="close"
+ >
+ <X size={13} />
+ </button>
+ );
+
+ case 'host': {
+ const busy = !isClosed && (conn.downloadSpeedCurr ?? 0) + (conn.uploadSpeedCurr ?? 0) > 0;
+ return (
+ <>
+ <span className={cx(s.dot, { [s.dotBusy]: busy, [s.dotIdle]: !busy })} aria-hidden />
+ <span className={s.hostText} title={conn.host}>
+ {conn.host}
+ </span>
+ </>
+ );
+ }
+
+ case 'chip': {
+ if (column.id === 'type') {
+ const udp = conn.network === 'udp';
+ return (
+ <span className={cx(s.chip, udp ? s.chipUdp : s.chipTcp)} title={conn.type}>
+ {conn.type}
+ </span>
+ );
+ }
+ const value = String((conn as any)[column.id] ?? '');
+ return (
+ <span className={cx(s.chip, s.chipNeutral)} title={value}>
+ {value}
+ </span>
+ );
+ }
+
+ case 'chain': {
+ if (fullChain) {
+ return (
+ <span className={s.chainFull} title={conn.chainsFull}>
+ {conn.chainsFull}
+ </span>
+ );
+ }
+ return (
+ <>
+ {conn.chainGroup ? (
+ <>
+ <span className={s.chainGroup} title={conn.chainGroup}>
+ {conn.chainGroup}
+ </span>
+ <span className={s.chainSep} aria-hidden>
+ ›
+ </span>
+ </>
+ ) : null}
+ <span
+ className={cx(s.chainNode, {
+ [s.chainDirect]: conn.outboundType === 'Direct',
+ [s.chainReject]: conn.outboundType === 'Reject',
+ })}
+ title={conn.chainNode}
+ >
+ {conn.chainNode}
+ </span>
+ </>
+ );
+ }
+
+ default: {
+ switch (column.id) {
+ case 'start':
+ return (
+ <span className={s.num}>{isClosed ? '—' : formatElapsed(conn.start, locale)}</span>
+ );
+ case 'download':
+ case 'upload':
+ return <span className={s.num}>{prettyBytes((conn as any)[column.id])}</span>;
+ case 'downloadSpeedCurr':
+ case 'uploadSpeedCurr': {
+ const speed = (conn as any)[column.id] as number;
+ const dim = isClosed || speed < 1;
+ return (
+ <span
+ className={cx(s.speed, {
+ [s.speedDim]: dim,
+ [s.speedDl]: !dim && column.id === 'downloadSpeedCurr',
+ [s.speedUl]: !dim && column.id === 'uploadSpeedCurr',
+ })}
+ >
+ {isClosed ? '—' : `${prettyBytes(speed)}/s`}
+ </span>
+ );
+ }
+ case 'network':
+ return <span className={s.num}>{conn.network.toUpperCase()}</span>;
+ default: {
+ const value = String((conn as any)[column.id] ?? '');
+ return (
+ <span className={s.num} title={value}>
+ {value}
+ </span>
+ );
+ }
+ }
+ }
}
+}
- return style;
+type RowProps = {
+ rows: FormattedConn[];
+ columns: ConnectionColumn[];
+ widths: number[];
+ tableWidth: number;
+ gridTemplate: string;
+ isClosed: boolean;
+ fullChain: boolean;
+ locale: ReturnType<typeof getDateFnsLocale>;
+ onClose: (id: string, e: React.MouseEvent) => void;
+ onOpenDetails: (conn: FormattedConn) => void;
};
-function Table({ data, columns, hiddenColumns, apiConfig, height }) {
- const { t, i18n } = useTranslation();
- const [operationId, setOperationId] = useState('');
- const [showModalDisconnect, setShowModalDisconnect] = useState(false);
- const [selectedConn, setSelectedConn] = useState<FormattedConn | null>(null);
+function DesktopRow({
+ index,
+ style,
+ rows,
+ columns,
+ tableWidth,
+ gridTemplate,
+ isClosed,
+ fullChain,
+ locale,
+ onClose,
+ onOpenDetails,
+}: RowComponentProps<RowProps>) {
+ const conn = rows[index];
+
+ return (
+ <div style={{ ...style, width: tableWidth }} className={s.rowWrap}>
+ <div
+ className={s.row}
+ style={{
+ gridTemplateColumns: gridTemplate,
+ columnGap: COLUMN_GAP,
+ paddingLeft: ROW_PADDING_X,
+ paddingRight: ROW_PADDING_X,
+ }}
+ onClick={() => onOpenDetails(conn)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onOpenDetails(conn);
+ }
+ }}
+ >
+ {columns.map((column) => (
+ <div key={column.id} className={cx(s.cell, { [s.cellRight]: column.align === 'right' })}>
+ <Cell
+ column={column}
+ conn={conn}
+ isClosed={isClosed}
+ fullChain={fullChain}
+ locale={locale}
+ onClose={onClose}
+ />
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
- const [isMobile, setIsMobile] = useState(false);
+function MobileRow({ index, style, rows, onClose, onOpenDetails }: RowComponentProps<RowProps>) {
+ const conn = rows[index];
+ return (
+ <div style={style}>
+ <ConnectionCard conn={conn} onDisconnect={onClose} onClick={() => onOpenDetails(conn)} />
+ </div>
+ );
+}
- const headerRef = React.useRef<HTMLDivElement>(null);
+type Props = {
+ data: FormattedConn[];
+ totalCount: number;
+ columns: ConnectionColumn[];
+ sort: SortState;
+ setSort: (key: string) => void;
+ isClosed: boolean;
+ fullChain: boolean;
+ onCloseConn: (id: string) => void;
+};
+
+export default function ConnectionTable({
+ data,
+ totalCount,
+ columns,
+ sort,
+ setSort,
+ isClosed,
+ fullChain,
+ onCloseConn,
+}: Props) {
+ const { t, i18n } = useTranslation();
+ // 只存 id,每次渲染从实时 data 里取最新对象,弹窗里的流量/时长才能实时更新
+ const [detailId, setDetailId] = React.useState<string | null>(null);
+ const [isMobile, setIsMobile] = React.useState(false);
+ const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
+ const headRef = React.useRef<HTMLDivElement>(null);
- useEffect(() => {
+ React.useEffect(() => {
const mql = window.matchMedia('(max-width: 768px)');
setIsMobile(mql.matches);
- const listener = (e) => setIsMobile(e.matches);
+ const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener('change', listener);
return () => mql.removeEventListener('change', listener);
}, []);
- // react-table v8 列定义:从项目内部的 ConnectionColumn 形态映射而来
- const columnDefs = useMemo<ColumnDef<FormattedConn>[]>(
- () =>
- columns.map((c) => ({
- id: c.accessor,
- accessorFn: (row: FormattedConn) => (row as any)[c.accessor],
- header: c.Header ?? c.accessor,
- enableSorting: c.accessor !== 'ctrl',
- sortDescFirst: c.sortDescFirst ?? false,
- })),
- [columns]
- );
-
- // 从本地存储加载排序状态(v7 sortBy 与 v8 SortingState 形态一致,可直接复用)
- const [sorting, setSorting] = useState<SortingState>(() => {
- return JSON.parse(localStorage.getItem('tableSortBy')) || [sortById];
- });
+ const locale = getDateFnsLocale(i18n.language);
- // hiddenColumns 为需隐藏的列 id 列表,转换为 v8 的可见性映射
- const columnVisibility = useMemo<VisibilityState>(
- () => Object.fromEntries(hiddenColumns.map((id: string) => [id, false])),
- [hiddenColumns]
+ const { widths, tableWidth } = React.useMemo(
+ () => computeWidths(columns, containerWidth),
+ [columns, containerWidth],
);
+ const gridTemplate = React.useMemo(() => widths.map((w) => `${w}px`).join(' '), [widths]);
- const table = useReactTable({
- data,
- columns: columnDefs,
- state: { sorting, columnVisibility },
- onSortingChange: setSorting,
- getCoreRowModel: getCoreRowModel(),
- getSortedRowModel: getSortedRowModel(),
- autoResetAll: false,
- });
-
- const rows = table.getRowModel().rows;
-
- const sortOptions = useMemo(() => {
- return columns
- .filter((c) => c.accessor !== 'id' && c.accessor !== 'ctrl')
- .map((c) => ({
- label: t(c.Header),
- value: c.accessor,
- }));
- }, [columns, t]);
+ const handleClose = React.useCallback(
+ (id: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ onCloseConn(id);
+ },
+ [onCloseConn],
+ );
- const currentSort = sorting[0] || sortById;
+ const handleOpenDetails = React.useCallback((conn: FormattedConn) => {
+ setDetailId(conn.id);
+ }, []);
- const locale = getDateFnsLocale(i18n.language);
+ const handleCloseDetails = React.useCallback(() => {
+ setDetailId(null);
+ }, []);
- const disconnectOperation = useCallback(() => {
- connAPI.closeConnById(apiConfig, operationId);
- setShowModalDisconnect(false);
- }, [apiConfig, operationId]);
+ // 详情里的连接被关闭/移出列表(如切换标签、筛选变化)时自动关闭弹窗
+ const detailConn = detailId ? (data.find((c) => c.id === detailId) ?? null) : null;
+ React.useEffect(() => {
+ if (detailId && !detailConn) setDetailId(null);
+ }, [detailId, detailConn]);
- const handlerDisconnect = useCallback((id, e) => {
- e.stopPropagation();
- setOperationId(id);
- setShowModalDisconnect(true);
- }, []);
+ // Esc 关闭详情
+ React.useEffect(() => {
+ if (!detailConn) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setDetailId(null);
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [detailConn]);
- const renderCell = useCallback(
- (cell, locale) => {
- switch (cell.column.id) {
- case 'ctrl':
- return (
- <XCircle
- style={{ cursor: 'pointer' }}
- onClick={(e) => handlerDisconnect(cell.row.original.id, e)}
- ></XCircle>
- );
- case 'start':
- return formatElapsed(cell.getValue(), locale);
- case 'download':
- case 'upload':
- return prettyBytes(cell.getValue());
- case 'downloadSpeedCurr':
- case 'uploadSpeedCurr':
- return prettyBytes(cell.getValue()) + '/s';
- default:
- return cell.getValue();
- }
- },
- [handlerDisconnect]
+ const rowProps = React.useMemo<RowProps>(
+ () => ({
+ rows: data,
+ columns,
+ widths,
+ tableWidth,
+ gridTemplate,
+ isClosed,
+ fullChain,
+ locale,
+ onClose: handleClose,
+ onOpenDetails: handleOpenDetails,
+ }),
+ [
+ data,
+ columns,
+ widths,
+ tableWidth,
+ gridTemplate,
+ isClosed,
+ fullChain,
+ locale,
+ handleClose,
+ handleOpenDetails,
+ ],
);
- // 当排序状态改变时,将新状态保存到本地存储
- useEffect(() => {
- localStorage.setItem('tableSortBy', JSON.stringify(sorting));
- }, [sorting]);
+ const rowHeight = React.useCallback(() => {
+ return isMobile ? CARD_HEIGHT : ROW_HEIGHT;
+ }, [isMobile]);
- const MobileRow = useCallback(
- ({ index, style }: RowComponentProps) => {
- const row = rows[index];
- const conn = row.original as FormattedConn;
- return (
- <div style={style}>
- <ConnectionCard
- key={conn.id}
- conn={conn}
- onDisconnect={handlerDisconnect}
- onClick={() => setSelectedConn(conn)}
- />
- </div>
- );
- },
- [rows, handlerDisconnect]
- );
+ const rowKey = React.useCallback((index: number, props: RowProps) => props.rows[index].id, []);
- const DesktopRow = useCallback(
- ({ index, style }: RowComponentProps) => {
- const row = rows[index];
- return (
- <div
- style={{
- ...style,
- display: 'flex',
- width: TOTAL_WIDTH,
- }}
- className={s.tr}
- onClick={() => setSelectedConn(row.original as FormattedConn)}
- role="button"
- tabIndex={0}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- setSelectedConn(row.original as FormattedConn);
- }
- }}
- >
- {row.getVisibleCells().map((cell) => {
- const columnStyle = getColumnStyle(cell.column.id);
- return (
- <div
- key={cell.id}
- className={cx(s.td, index % 2 === 0 ? s.odd : false, cell.column.id)}
- style={{
- display: 'flex',
- alignItems: 'center',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
- ...columnStyle,
- }}
- >
- <span className={s.cellText}>{renderCell(cell, locale)}</span>
- </div>
- );
- })}
- </div>
- );
- },
- [rows, renderCell, locale]
+ const syncHeadScroll = React.useCallback((e: React.UIEvent<HTMLDivElement>) => {
+ if (headRef.current) headRef.current.scrollLeft = e.currentTarget.scrollLeft;
+ }, []);
+
+ const sortableColumns = React.useMemo(
+ () => columns.filter((c) => c.sortable !== false),
+ [columns],
);
+ const sortLabel = sortableColumns.find((c) => c.id === sort.key)?.labelKey;
- const handleDesktopListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
- if (headerRef.current) {
- headerRef.current.scrollLeft = e.currentTarget.scrollLeft;
- }
- }, []);
+ const empty = data.length === 0;
return (
- <div className={s.tableWrapper} style={{ height, overflow: 'hidden' }}>
- {isMobile ? (
- <div className={s.cardsView}>
- <div className={s.mobileSortToolbar}>
- <div className={s.sortSelectWrapper}>
- <div className={s.selectedValue}>
- <Sliders size={14} />
- <span>
- {t('Sort')}: {sortOptions.find((opt) => opt.value === currentSort.id)?.label}
- </span>
- </div>
- <select
- value={currentSort.id}
- onChange={(e) => setSorting([{ id: e.target.value, desc: currentSort.desc }])}
- >
- {sortOptions.map((opt) => (
- <option key={opt.value} value={opt.value}>
- {opt.label}
+ <div className={s.card}>
+ <div className={s.body} ref={containerRef}>
+ {isMobile ? (
+ <div className={s.mobileToolbar}>
+ <div className={s.sortSelect}>
+ <Sliders size={14} />
+ <span>
+ {t('Sort')}: {sortLabel ? t(sortLabel) : ''}
+ </span>
+ <select value={sort.key} onChange={(e) => setSort(e.target.value)}>
+ {sortableColumns.map((column) => (
+ <option key={column.id} value={column.id}>
+ {t(column.labelKey)}
</option>
))}
</select>
- <ChevronDown size={14} className={s.selectArrow} />
+ <ChevronDown size={14} className={s.sortSelectArrow} />
</div>
<button
+ type="button"
className={s.sortDirBtn}
- onClick={() => setSorting([{ id: currentSort.id, desc: !currentSort.desc }])}
+ onClick={() => setSort(sort.key)}
+ aria-label={t(sort.dir === 'desc' ? 'sort_desc' : 'sort_asc')}
>
- {currentSort.desc ? <ArrowDown size={18} /> : <ArrowUp size={18} />}
+ {sort.dir === 'desc' ? <ArrowDown size={16} /> : <ArrowUp size={16} />}
</button>
</div>
- <VirtualList
- style={{ height: height - 50, width: '100%' }}
- rowCount={rows.length}
- rowHeight={120}
- rowComponent={MobileRow}
- rowProps={{}}
- />
- </div>
- ) : (
- <div
- className={cx(s.table, 'connections-table')}
- style={{
- display: 'flex',
- flexDirection: 'column',
- height: '100%',
- width: '100%',
- }}
- >
- <div
- className={s.theadWrapper}
- ref={headerRef}
- style={{ overflow: 'hidden', width: '100%' }}
- >
- <div className={s.thead} style={{ width: TOTAL_WIDTH }}>
- {table.getHeaderGroups().map((headerGroup) => (
- <div className={s.tr} key={headerGroup.id} style={{ display: 'flex' }}>
- {headerGroup.headers.map((header) => {
- const columnStyle = getColumnStyle(header.column.id);
- const sortDir = header.column.getIsSorted();
- const canSort = header.column.getCanSort();
- const sortHandler = header.column.getToggleSortingHandler();
- return (
- <div
- key={header.id}
- className={s.th}
- onClick={sortHandler}
- onKeyDown={
- canSort
- ? (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- sortHandler?.(e);
- }
- }
- : undefined
- }
- role={canSort ? 'button' : undefined}
- tabIndex={canSort ? 0 : undefined}
- style={{
- display: 'flex',
- alignItems: 'center',
- cursor: canSort ? 'pointer' : 'default',
- ...columnStyle,
- }}
- >
- <span className={s.headerText}>
- {t(header.column.columnDef.header as string)}
- </span>
- {header.column.id !== 'ctrl' ? (
- <span className={s.sortIconContainer}>
- {sortDir ? (
- <ChevronDown
- size={14}
- className={sortDir === 'desc' ? '' : s.rotate180}
- />
- ) : null}
- </span>
- ) : null}
- </div>
- );
- })}
- </div>
- ))}
+ ) : (
+ <div className={s.headWrap} ref={headRef}>
+ <div
+ className={s.headRow}
+ style={{
+ width: tableWidth,
+ gridTemplateColumns: gridTemplate,
+ columnGap: COLUMN_GAP,
+ paddingLeft: ROW_PADDING_X,
+ paddingRight: ROW_PADDING_X,
+ }}
+ role="row"
+ >
+ {columns.map((column) => {
+ const sortable = column.sortable !== false;
+ const active = sortable && sort.key === column.id;
+ return (
+ // role 是条件表达式,oxlint 静态分析不出来
+ // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
+ <div
+ key={column.id}
+ className={cx(s.headCell, {
+ [s.cellRight]: column.align === 'right',
+ [s.headCellSortable]: sortable,
+ [s.headCellActive]: active,
+ })}
+ onClick={sortable ? () => setSort(column.id) : undefined}
+ onKeyDown={
+ sortable
+ ? (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setSort(column.id);
+ }
+ }
+ : undefined
+ }
+ role={sortable ? 'button' : undefined}
+ tabIndex={sortable ? 0 : undefined}
+ >
+ <span className={s.headText}>
+ {column.kind === 'ctrl' ? '' : t(column.labelKey)}
+ </span>
+ {active ? (
+ <span className={s.headArrow}>{sort.dir === 'desc' ? '↓' : '↑'}</span>
+ ) : null}
+ </div>
+ );
+ })}
</div>
</div>
- <VirtualList
- style={{ height: height - 50, width: '100%' }}
- onScroll={handleDesktopListScroll}
- rowCount={rows.length}
- rowHeight={44}
- rowComponent={DesktopRow}
- rowProps={{}}
- />
+ )}
+
+ <div className={s.listWrap}>
+ {empty ? (
+ <div className={s.empty}>
+ <span className={s.emptyTitle}>{t('conn_empty_title')}</span>
+ <span className={s.emptyHint}>{t('conn_empty_hint')}</span>
+ </div>
+ ) : (
+ <VirtualList
+ className={isMobile ? s.mobileList : s.list}
+ style={{ height: '100%', width: '100%' }}
+ onScroll={isMobile ? undefined : syncHeadScroll}
+ rowCount={data.length}
+ rowHeight={rowHeight}
+ rowComponent={isMobile ? MobileRow : DesktopRow}
+ rowKey={rowKey}
+ rowProps={rowProps}
+ />
+ )}
</div>
- )}
- <MOdalCloseConnection
- confirm={'disconnect'}
- isOpen={showModalDisconnect}
- onRequestClose={() => setShowModalDisconnect(false)}
- primaryButtonOnTap={disconnectOperation}
- ></MOdalCloseConnection>
- <ModalConnectionDetails
- isOpen={!!selectedConn}
- onRequestClose={() => setSelectedConn(null)}
- connection={selectedConn}
- />
+ </div>
+
+ {detailConn ? (
+ <ConnectionDetailModal conn={detailConn} onRequestClose={handleCloseDetails} />
+ ) : null}
+
+ <div className={s.footer}>
+ <span>{t('conn_shown', { shown: data.length, total: totalCount })}</span>
+ {sortLabel ? (
+ <span>
+ {t('conn_sorted_by', {
+ column: t(sortLabel),
+ dir: t(sort.dir === 'desc' ? 'sort_desc' : 'sort_asc'),
+ })}
+ </span>
+ ) : null}
+ <span className={s.footerNote}>
+ {isClosed ? t('conn_note_closed') : t('conn_note_active')}
+ </span>
+ </div>
</div>
);
}
-
-export default Table;
diff --git a/src/components/connections/Connections.css b/src/components/connections/Connections.css
deleted file mode 100644
index a7bec45..0000000
--- a/src/components/connections/Connections.css
+++ /dev/null
@@ -1,71 +0,0 @@
-.react-tabs {
- -webkit-tap-highlight-color: transparent;
-}
-
-.react-tabs__tab-list {
- margin: 0px;
- padding: 0;
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.react-tabs__tab {
- display: inline-flex;
- align-items: center;
- border: none;
- border-radius: 8px;
- position: relative;
- list-style: none;
- padding: 8px 16px;
- cursor: pointer;
- font-size: 1em;
- font-weight: 500;
- opacity: 0.6;
- background: transparent;
- transition: all 0.2s ease;
-}
-
-.react-tabs__tab:hover {
- opacity: 1;
- color: var(--color-focus-blue);
- background: rgba(176, 206, 255, 0.221);
-}
-
-.react-tabs__tab--selected {
- opacity: 1;
- background: var(--color-focus-blue);
- color: white;
-}
-
-.react-tabs__tab--selected:hover {
- background: var(--color-focus-blue);
- opacity: 1;
-}
-
-.react-tabs__tab--disabled {
- color: GrayText;
- cursor: default;
-}
-
-.react-tabs__tab:focus {
- outline: none;
- box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3);
-}
-
-.react-tabs__tab:focus:after {
- content: '';
- position: absolute;
-}
-
-.react-tabs__tab-panel {
- display: none;
-}
-
-.react-tabs__tab-panel--selected {
- display: block;
-}
-
-._btn_lzu00_1 {
- margin-right: 10px;
-}
diff --git a/src/components/connections/Connections.module.scss b/src/components/connections/Connections.module.scss
index 918a66c..7e65ab2 100644
--- a/src/components/connections/Connections.module.scss
+++ b/src/components/connections/Connections.module.scss
@@ -1,227 +1,22 @@
-.placeHolder {
- margin-top: 15%;
- height: 100%;
+.page {
display: flex;
flex-direction: column;
- align-items: center;
- justify-content: center;
- color: var(--color-text-secondary);
- opacity: 0.15;
-
- @media (max-width: 768px) {
- margin-top: 25%;
- }
-}
-
-.connQty {
- font-family: var(--font-normal);
- font-size: 0.7em;
- margin-left: 6px;
- padding: 2px 8px;
- display: inline-flex;
- justify-content: center;
- align-items: center;
- background-color: rgba(255, 255, 255, 0.15);
- border-radius: 10px;
- font-weight: 600;
- min-width: 20px;
-}
-
-.inputWrapper {
- width: 100%;
- max-width: 300px;
- min-width: 0;
- margin-left: auto;
-
- @media (max-width: 768px) {
- max-width: none;
- grid-column: 1 / -1;
- margin-left: 0;
- order: 3;
- }
-}
-
-.input {
- -webkit-appearance: none;
- appearance: none;
- background-color: var(--color-input-bg);
- background-image: none;
- border-radius: 8px;
- border: 1px solid transparent;
- box-sizing: border-box;
- color: var(--color-text);
- display: inline-block;
- font-size: 0.95em;
- height: 40px;
- outline: none;
- padding: 0 16px;
- transition: all 0.2s ease;
- width: 100%;
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
-
- &::placeholder {
- color: var(--color-text-secondary);
- opacity: 0.6;
- }
-
- &:focus {
- border-color: var(--color-focus-blue);
- box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15);
- }
-}
-
-.toolbar {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-shrink: 0;
- padding: 2px;
- border-radius: 10px;
-
- @media (max-width: 768px) {
- grid-column: 2;
- grid-row: 2;
- justify-content: flex-end;
- width: auto;
- padding: 2px;
- background: transparent;
- }
-}
-
-.toolbarBtn {
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
- width: 34px;
- height: 34px;
- border: none;
- border-radius: 8px;
- background-color: transparent;
- color: var(--color-text-secondary);
- cursor: pointer;
- transition: all 0.2s ease;
-
- &:hover {
- background-color: var(--color-focus-blue);
- color: white;
- transform: translateY(-1px);
- box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3);
- }
-
- &:active {
- transform: translateY(0) scale(0.95);
- }
-}
-
-.toolbarBtnBadge {
- position: absolute;
- top: -3px;
- right: -3px;
- font-size: 9px;
- font-weight: 700;
- background: linear-gradient(135deg, #e74c3c, #c0392b);
- color: white;
- border-radius: 50%;
- width: 15px;
- height: 15px;
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 2px 4px rgba(231, 76, 60, 0.4);
-}
-
-.controls {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- width: 100%;
- min-width: 0;
-
- @media (max-width: 768px) {
- display: grid;
- grid-template-columns: 1fr auto;
- gap: 8px;
- padding: 4px 0;
- }
-}
-
-.tabGroup {
- display: flex;
- align-items: center;
- gap: 12px;
- min-width: 0;
-
- @media (max-width: 768px) {
- display: contents;
- }
+ height: 100%;
+ min-height: 0;
}
-.tabList {
- padding: 0 !important;
- border: none !important;
+.tableArea {
display: flex;
- align-items: center;
- gap: 4px;
- min-width: 0;
- background-color: var(--color-bg-sidebar);
- border-radius: 12px;
- padding: 4px !important;
-
- @media (max-width: 768px) {
- grid-column: 1 / -1;
- justify-content: space-between;
- width: 100%;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ margin: 12px 32px 24px;
- :global(.react-tabs__tab) {
- flex: 1;
- text-align: center;
- justify-content: center;
- padding: 6px 4px !important;
- font-size: 0.9em;
- }
+ @media (max-width: 1024px) {
+ margin: 12px 16px 16px;
}
-}
-
-.sourceSelect {
- width: auto;
- min-width: 130px;
- height: 40px;
- flex-shrink: 0;
@media (max-width: 768px) {
- width: 100% !important;
- grid-column: 1;
- height: 36px;
+ margin: 8px 12px 10px;
}
}
-
-.toolbarDivider {
- width: 1px;
- height: 20px;
- background: var(--color-text-secondary);
- opacity: 0.2;
- margin: 0 2px;
-}
-
-.contentWrapper {
- margin: 0 45px 20px;
- min-width: 0;
- background-color: var(--bg-log-info-card);
- border-radius: 12px;
- box-shadow: var(--shadow-card);
- border: 1px solid var(--color-separator);
- overflow: hidden;
-
- @media (max-width: 768px) {
- margin: 10px 15px 15px;
- background-color: transparent;
- border: none;
- box-shadow: none;
- }
-}
-
-.scrollArea {
- overflow: visible;
-}
diff --git a/src/components/connections/Connections.tsx b/src/components/connections/Connections.tsx
index 9e19d33..9c60cc1 100644
--- a/src/components/connections/Connections.tsx
+++ b/src/components/connections/Connections.tsx
@@ -1,60 +1,26 @@
-import './Connections.css';
-
-import React from 'react';
+import * as React from 'react';
import { useTranslation } from 'react-i18next';
-import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
-import * as connAPI from '~/api/connections';
-import ContentHeader from '~/components/ContentHeader';
-import Input from '~/components/Input';
-import { Fab, position as fabPosition } from '~/components/shared/Fab';
-import { Pause, Play, RefreshCcw, Settings, Tag, X as IconClose } from '~/components/shared/FeatherIcons';
-import Select from '~/components/shared/Select';
-import SvgYacd from '~/components/SvgYacd';
-import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight';
import {
+ useCloseConnections,
useConnectionColumns,
useConnectionFilters,
+ useConnectionSettings,
useConnectionsStream,
+ useConnectionStats,
useSourceMapState,
} from '~/modules/connections/hooks';
-import { CONNECTIONS_PADDING_BOTTOM } from '~/modules/connections/utils';
-import { FormattedConn } from '~/store/connections';
+import { getInitialSort, saveSort, sortConns, SortState } from '~/modules/connections/utils';
import { ClashAPIConfig } from '~/types';
import s from './Connections.module.scss';
+import ConnectionSettingsModal from './ConnectionSettingsModal';
+import { ConnectionsHeader, ConnTabKey } from './ConnectionsHeader';
+import { ConnectionStats } from './ConnectionStats';
import ConnectionTable from './ConnectionTable';
import ModalCloseAllConnections from './ModalCloseAllConnections';
-import ModalManageConnectionColumns from './ModalManageConnectionColumns';
-import ModalSourceIP from './ModalSourceIP';
-
-const { useState, useCallback } = React;
-function renderTableOrPlaceholder(
- columns,
- hiddenColumns,
- conns: FormattedConn[],
- height: number,
- apiConfig: ClashAPIConfig
-) {
- return conns.length > 0 ? (
- <ConnectionTable
- data={conns}
- columns={columns}
- hiddenColumns={hiddenColumns}
- height={height}
- apiConfig={apiConfig}
- />
- ) : (
- <div className={s.placeHolder}>
- <SvgYacd width={200} height={200} c1="var(--color-text)" />
- </div>
- );
-}
-
-function ConnQty({ qty }) {
- return qty < 100 ? '' + qty : '99+';
-}
+const { useCallback, useState } = React;
type Props = {
apiConfig: ClashAPIConfig;
@@ -62,19 +28,23 @@ type Props = {
export default function Connections({ apiConfig }: Props) {
const { t } = useTranslation();
- const [showModalColumn, setModalColumn] = useState(false);
- const { hiddenColumns, setHiddenColumns, columns, setColumns, resetColumns } =
- useConnectionColumns();
+ const [activeTab, setActiveTab] = useState<ConnTabKey>('active');
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
- const closeModalColumn = () => {
- setModalColumn(false);
- };
+ const { sourceMap, setSourceMap } = useSourceMapState();
+ const { settings, updateSettings } = useConnectionSettings();
+ const {
+ visibleColumns,
+ availableColumns,
+ addColumn,
+ removeColumn,
+ reorderColumns,
+ resetColumns,
+ } = useConnectionColumns();
- const { sourceMapModal, sourceMap, setSourceMap, openModalSource, closeModalSource } =
- useSourceMapState();
- const [refContainer, containerHeight] = useRemainingViewPortHeight();
- const { conns, closedConns, isRefreshPaused, toggleIsRefreshPaused, closeAllConnections } =
+ const { conns, closedConns, total, isRefreshPaused, toggleIsRefreshPaused, closeAllConnections } =
useConnectionsStream(apiConfig, sourceMap);
+
const {
filterKeyword,
setFilterKeyword,
@@ -83,158 +53,103 @@ export default function Connections({ apiConfig }: Props) {
filteredConns,
filteredClosedConns,
connIpSet,
- } = useConnectionFilters({ conns, closedConns, sourceMap, t });
+ isFiltering,
+ } = useConnectionFilters({ conns, closedConns, sourceMap, settings, t });
+
+ const stats = useConnectionStats(conns, total);
+
+ const [sort, setSortState] = useState<SortState>(() => getInitialSort());
+ // 点同一列切换升降序,点别的列换列并回到升序
+ const setSort = useCallback((key: string) => {
+ setSortState((prev) => {
+ const next: SortState =
+ prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' };
+ saveSort(next);
+ return next;
+ });
+ }, []);
- const [isCloseFilterModalOpen, setIsCloseFilterModalOpen] = useState(false);
- const openCloseFilterModal = useCallback(() => setIsCloseFilterModalOpen(true), []);
- const closeCloseFilterModal = useCallback(() => setIsCloseFilterModalOpen(false), []);
+ const isClosedTab = activeTab === 'closed';
+ const rows = isClosedTab ? filteredClosedConns : filteredConns;
+ const poolSize = isClosedTab ? closedConns.length : conns.length;
+
+ const sortedRows = React.useMemo(() => sortConns(rows, sort), [rows, sort]);
+
+ const { closeConn, closeConns } = useCloseConnections(apiConfig);
- const closeFilterConnections = useCallback(async () => {
- await Promise.allSettled(
- filteredConns.map((connection) => connAPI.closeConnById(apiConfig, connection.id))
- );
- closeCloseFilterModal();
- }, [apiConfig, filteredConns, closeCloseFilterModal]);
const [isCloseAllModalOpen, setIsCloseAllModalOpen] = useState(false);
- const openCloseAllModal = useCallback(() => setIsCloseAllModalOpen(true), []);
- const closeCloseAllModal = useCallback(() => setIsCloseAllModalOpen(false), []);
- const handleCloseAllConnections = useCallback(() => {
+ const [isCloseFilteredModalOpen, setIsCloseFilteredModalOpen] = useState(false);
+
+ const handleCloseAll = useCallback(() => {
closeAllConnections();
- closeCloseAllModal();
- }, [closeAllConnections, closeCloseAllModal]);
+ setIsCloseAllModalOpen(false);
+ }, [closeAllConnections]);
+
+ const handleCloseFiltered = useCallback(async () => {
+ await closeConns(filteredConns);
+ setIsCloseFilteredModalOpen(false);
+ }, [closeConns, filteredConns]);
return (
- <div>
- <Tabs>
- <ContentHeader>
- <div className={s.controls}>
- <div className={s.tabGroup}>
- <TabList className={s.tabList}>
- <Tab>
- <span>{t('Active')}</span>
- <span className={s.connQty}>
- <ConnQty qty={filteredConns.length} />
- </span>
- </Tab>
- <Tab>
- <span>{t('Closed')}</span>
- <span className={s.connQty}>
- <ConnQty qty={filteredClosedConns.length} />
- </span>
- </Tab>
- </TabList>
- <Select
- options={connIpSet}
- selected={filterSourceIpStr}
- className={s.sourceSelect}
- onChange={(e) => setFilterSourceIpStr(e.target.value)}
- />
- </div>
- <div style={{ flex: 1 }} />
- <div className={s.inputWrapper}>
- <Input
- type="text"
- name="filter"
- autoComplete="off"
- className={s.input}
- value={filterKeyword}
- placeholder={t('Search')}
- onChange={(e) => setFilterKeyword(e.target.value)}
- />
- </div>
- <div className={s.toolbar}>
- <button
- className={s.toolbarBtn}
- onClick={openCloseAllModal}
- title={t('close_all_connections')}
- >
- <IconClose size={15} />
- </button>
- <button
- className={s.toolbarBtn}
- onClick={openCloseFilterModal}
- title={t('close_filter_connections')}
- >
- <IconClose size={13} />
- <span className={s.toolbarBtnBadge}>F</span>
- </button>
- <span className={s.toolbarDivider} />
- <button
- className={s.toolbarBtn}
- onClick={() => setModalColumn(true)}
- title={t('manage_column')}
- >
- <Settings size={15} />
- </button>
- <button className={s.toolbarBtn} onClick={resetColumns} title={t('reset_column')}>
- <RefreshCcw size={15} />
- </button>
- <button className={s.toolbarBtn} onClick={openModalSource} title={t('client_tag')}>
- <Tag size={15} />
- </button>
- </div>
- </div>
- </ContentHeader>
- <div ref={refContainer} className={s.contentWrapper}>
- <div
- className={s.scrollArea}
- style={{
- height: containerHeight - CONNECTIONS_PADDING_BOTTOM,
- }}
- >
- <TabPanel>
- {renderTableOrPlaceholder(
- columns,
- hiddenColumns,
- filteredConns,
- containerHeight - CONNECTIONS_PADDING_BOTTOM,
- apiConfig
- )}
- <Fab
- icon={isRefreshPaused ? <Play size={16} /> : <Pause size={16} />}
- mainButtonStyles={isRefreshPaused ? { background: '#e74c3c' } : {}}
- style={fabPosition}
- text={isRefreshPaused ? t('Resume Refresh') : t('Pause Refresh')}
- onClick={toggleIsRefreshPaused}
- />
- </TabPanel>
- <TabPanel>
- {renderTableOrPlaceholder(
- columns,
- hiddenColumns,
- filteredClosedConns,
- containerHeight - CONNECTIONS_PADDING_BOTTOM,
- apiConfig
- )}
- </TabPanel>
- </div>
- </div>
- <ModalCloseAllConnections
- isOpen={isCloseAllModalOpen}
- primaryButtonOnTap={handleCloseAllConnections}
- onRequestClose={closeCloseAllModal}
- />
- <ModalCloseAllConnections
- confirm={'close_filter_connections'}
- isOpen={isCloseFilterModalOpen}
- primaryButtonOnTap={closeFilterConnections}
- onRequestClose={closeCloseFilterModal}
- />
- <ModalManageConnectionColumns
- isOpen={showModalColumn}
- onRequestClose={closeModalColumn}
- columns={columns}
- hiddenColumns={hiddenColumns}
- setColumns={setColumns}
- setHiddenColumns={setHiddenColumns}
- />
- <ModalSourceIP
- isOpen={sourceMapModal}
- onRequestClose={closeModalSource}
- sourceMap={sourceMap}
- setSourceMap={setSourceMap}
+ <div className={s.page}>
+ <ConnectionsHeader
+ activeTab={activeTab}
+ setActiveTab={setActiveTab}
+ activeCount={filteredConns.length}
+ closedCount={filteredClosedConns.length}
+ connIpSet={connIpSet}
+ filterSourceIpStr={filterSourceIpStr}
+ setFilterSourceIpStr={setFilterSourceIpStr}
+ filterKeyword={filterKeyword}
+ setFilterKeyword={setFilterKeyword}
+ isRefreshPaused={isRefreshPaused}
+ toggleIsRefreshPaused={toggleIsRefreshPaused}
+ isFiltering={isFiltering}
+ onCloseFiltered={() => setIsCloseFilteredModalOpen(true)}
+ onCloseAll={() => setIsCloseAllModalOpen(true)}
+ onOpenSettings={() => setIsSettingsOpen(true)}
+ />
+
+ <ConnectionStats {...stats} />
+
+ <div className={s.tableArea}>
+ <ConnectionTable
+ data={sortedRows}
+ totalCount={poolSize}
+ columns={visibleColumns}
+ sort={sort}
+ setSort={setSort}
+ isClosed={isClosedTab}
+ fullChain={settings.fullChain}
+ onCloseConn={closeConn}
/>
- </Tabs>
+ </div>
+
+ <ModalCloseAllConnections
+ isOpen={isCloseAllModalOpen}
+ primaryButtonOnTap={handleCloseAll}
+ onRequestClose={() => setIsCloseAllModalOpen(false)}
+ />
+ <ModalCloseAllConnections
+ confirm={'close_filter_connections'}
+ isOpen={isCloseFilteredModalOpen}
+ primaryButtonOnTap={handleCloseFiltered}
+ onRequestClose={() => setIsCloseFilteredModalOpen(false)}
+ />
+ <ConnectionSettingsModal
+ isOpen={isSettingsOpen}
+ onRequestClose={() => setIsSettingsOpen(false)}
+ settings={settings}
+ updateSettings={updateSettings}
+ visibleColumns={visibleColumns}
+ availableColumns={availableColumns}
+ addColumn={addColumn}
+ removeColumn={removeColumn}
+ reorderColumns={reorderColumns}
+ resetColumns={resetColumns}
+ sourceMap={sourceMap}
+ setSourceMap={setSourceMap}
+ />
</div>
);
}
diff --git a/src/components/connections/ConnectionsHeader.module.scss b/src/components/connections/ConnectionsHeader.module.scss
new file mode 100644
index 0000000..3b71761
--- /dev/null
+++ b/src/components/connections/ConnectionsHeader.module.scss
@@ -0,0 +1,64 @@
+/**
+ * 只放连接页特有的东西,顶栏本身的样式在 shared/PageHeader.module.scss。
+ *
+ * DOM 顺序就是桌面端的视觉顺序(标题 · 标签 · 来源 · 搜索 · 操作),
+ * 窄屏要把操作钮提到第一行、搜索和来源并排到第二行,所以这里只拨 order。
+ */
+
+.sourceSelect {
+ min-width: 132px;
+ max-width: 190px;
+
+ @media (max-width: 1024px) {
+ flex: 1 1 auto;
+ max-width: none;
+ }
+
+ @media (max-width: 768px) {
+ order: 4;
+ flex: 0 0 auto;
+ min-width: 96px;
+ max-width: 130px;
+ }
+}
+
+.search {
+ @media (max-width: 768px) {
+ order: 3;
+ }
+}
+
+.actions {
+ @media (max-width: 768px) {
+ order: 1;
+ }
+}
+
+.rowBreak {
+ @media (max-width: 768px) {
+ order: 2;
+ }
+}
+
+/** 「关闭筛选结果」的图标,右上角挂一个 F 角标以区别于「全部关闭」 */
+.btnIconBadged {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+
+.btnBadge {
+ position: absolute;
+ top: -5px;
+ right: -6px;
+ min-width: 12px;
+ height: 12px;
+ padding: 0 2px;
+ border-radius: 6px;
+ background: var(--color-danger);
+ color: #fff;
+ font-size: 8px;
+ font-weight: 700;
+ line-height: 12px;
+ text-align: center;
+}
diff --git a/src/components/connections/ConnectionsHeader.tsx b/src/components/connections/ConnectionsHeader.tsx
new file mode 100644
index 0000000..b4845e8
--- /dev/null
+++ b/src/components/connections/ConnectionsHeader.tsx
@@ -0,0 +1,136 @@
+import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Pause, Play, Sliders, X } from '~/components/shared/FeatherIcons';
+import {
+ HeaderActions,
+ HeaderButton,
+ HeaderIconButton,
+ HeaderRowBreak,
+ HeaderSearch,
+ headerSearchInlineClass,
+ headerSelectClass,
+ HeaderTab,
+ HeaderTabs,
+ HeaderTitle,
+ PageHeader,
+} from '~/components/shared/PageHeader';
+import Select from '~/components/shared/Select';
+
+import s from './ConnectionsHeader.module.scss';
+
+export type ConnTabKey = 'active' | 'closed';
+
+type Props = {
+ activeTab: ConnTabKey;
+ setActiveTab: (tab: ConnTabKey) => void;
+ activeCount: number;
+ closedCount: number;
+ connIpSet: string[][];
+ filterSourceIpStr: string;
+ setFilterSourceIpStr: (value: string) => void;
+ filterKeyword: string;
+ setFilterKeyword: (value: string) => void;
+ isRefreshPaused: boolean;
+ toggleIsRefreshPaused: () => void;
+ isFiltering: boolean;
+ onCloseFiltered: () => void;
+ onCloseAll: () => void;
+ onOpenSettings: () => void;
+};
+
+export function ConnectionsHeader({
+ activeTab,
+ setActiveTab,
+ activeCount,
+ closedCount,
+ connIpSet,
+ filterSourceIpStr,
+ setFilterSourceIpStr,
+ filterKeyword,
+ setFilterKeyword,
+ isRefreshPaused,
+ toggleIsRefreshPaused,
+ isFiltering,
+ onCloseFiltered,
+ onCloseAll,
+ onOpenSettings,
+}: Props) {
+ const { t } = useTranslation();
+
+ return (
+ <PageHeader>
+ <HeaderTitle>{t('Connections')}</HeaderTitle>
+
+ <HeaderTabs label={t('Connections')}>
+ <HeaderTab
+ active={activeTab === 'active'}
+ label={t('Active')}
+ count={activeCount}
+ onClick={() => setActiveTab('active')}
+ />
+ <HeaderTab
+ active={activeTab === 'closed'}
+ label={t('Closed')}
+ count={closedCount}
+ onClick={() => setActiveTab('closed')}
+ />
+ </HeaderTabs>
+
+ <Select
+ options={connIpSet}
+ selected={filterSourceIpStr}
+ className={`${headerSelectClass} ${s.sourceSelect}`}
+ aria-label={t('c_source')}
+ onChange={(e) => setFilterSourceIpStr(e.target.value)}
+ />
+
+ <HeaderSearch
+ className={`${headerSearchInlineClass} ${s.search}`}
+ value={filterKeyword}
+ onChange={setFilterKeyword}
+ placeholder={t('search_conns_placeholder')}
+ />
+
+ <HeaderActions className={s.actions}>
+ <HeaderButton
+ variant={isRefreshPaused ? 'paused' : 'ghost'}
+ icon={isRefreshPaused ? <Play size={14} /> : <Pause size={14} />}
+ label={isRefreshPaused ? t('Resume Refresh') : t('Pause Refresh')}
+ hideLabelAt="md"
+ onClick={toggleIsRefreshPaused}
+ />
+
+ {isFiltering ? (
+ <HeaderButton
+ icon={
+ <span className={s.btnIconBadged}>
+ <X size={14} />
+ <span className={s.btnBadge}>F</span>
+ </span>
+ }
+ label={t('close_filtered')}
+ title={t('close_filter_connections')}
+ onClick={onCloseFiltered}
+ />
+ ) : null}
+
+ <HeaderButton
+ variant="danger"
+ icon={<X size={14} />}
+ label={t('close_all')}
+ onClick={onCloseAll}
+ />
+
+ <HeaderIconButton
+ icon={<Sliders size={17} />}
+ label={t('conn_settings')}
+ onClick={onOpenSettings}
+ />
+ </HeaderActions>
+
+ {/* 窄屏下强制换行,让搜索和来源筛选独占第二行 */}
+ <HeaderRowBreak className={s.rowBreak} />
+ </PageHeader>
+ );
+}
diff --git a/src/components/connections/ModalCloseAllConnections.module.scss b/src/components/connections/ModalCloseAllConnections.module.scss
index 2b2befa..6fd4ea0 100644
--- a/src/components/connections/ModalCloseAllConnections.module.scss
+++ b/src/components/connections/ModalCloseAllConnections.module.scss
@@ -6,13 +6,6 @@
color: var(--color-text);
max-width: 300px;
line-height: 1.4;
- transform: scale(1.2);
- opacity: 0.6;
- transition: all 0.3s ease;
-}
-.afterOpen {
- opacity: 1;
- transform: scale(1);
}
.btngrp {
diff --git a/src/components/connections/ModalCloseAllConnections.tsx b/src/components/connections/ModalCloseAllConnections.tsx
index 2c376bd..09e0f3d 100644
--- a/src/components/connections/ModalCloseAllConnections.tsx
+++ b/src/components/connections/ModalCloseAllConnections.tsx
@@ -1,14 +1,12 @@
-import cx from 'clsx';
import React from 'react';
import { useTranslation } from 'react-i18next';
-import Button from '~/components/Button';
-import Modal from '~/components/Modal';
-import modalStyle from '~/components/Modal.module.scss';
+import Button from '~/components/shared/Button';
+import Modal from '~/components/shared/Modal';
import s from './ModalCloseAllConnections.module.scss';
-const { useRef, useCallback, useMemo } = React;
+const { useRef, useCallback } = React;
type Props = {
confirm?: string;
@@ -30,21 +28,14 @@ export default function Comp({
primaryButtonRef.current.focus();
}
}, []);
- const className = useMemo(
- () => ({
- base: cx(modalStyle.content, s.cnt),
- afterOpen: s.afterOpen,
- beforeClose: '',
- }),
- []
- );
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
onAfterOpen={onAfterOpen}
- className={className}
- overlayClassName={cx(modalStyle.overlay, s.overlay)}
+ title={t(confirm)}
+ className={s.cnt}
+ overlayClassName={s.overlay}
>
<p>{t(confirm)}</p>
<div className={s.btngrp}>
diff --git a/src/components/connections/ModalConnectionDetails.module.scss b/src/components/connections/ModalConnectionDetails.module.scss
deleted file mode 100644
index 67834cd..0000000
--- a/src/components/connections/ModalConnectionDetails.module.scss
+++ /dev/null
@@ -1,49 +0,0 @@
-.content {
- max-width: 600px;
- width: 90%;
- max-height: 90vh;
- overflow-y: auto;
- padding: 24px;
- background-color: var(--bg-modal);
- border-radius: 12px;
- box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
- border: 1px solid var(--color-border);
-}
-
-.overlay {
- background-color: rgba(0, 0, 0, 0.1) !important;
- backdrop-filter: blur(2px);
-}
-
-.details {
- display: grid;
- grid-template-columns: min-content 1fr;
- gap: 12px 24px;
- font-size: 14px;
- line-height: 1.5;
-}
-
-.label {
- font-weight: 600;
- text-align: right;
- color: var(--color-text-secondary);
- white-space: nowrap;
-}
-
-.value {
- color: var(--color-text-primary);
- word-break: break-all;
- font-family: 'Roboto Mono', Consolas, Menlo, monospace;
-}
-
-.header {
- font-size: 20px;
- font-weight: 700;
- margin-bottom: 24px;
- padding-bottom: 12px;
- border-bottom: 1px solid var(--color-border);
- color: var(--color-text-primary);
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
diff --git a/src/components/connections/ModalConnectionDetails.tsx b/src/components/connections/ModalConnectionDetails.tsx
deleted file mode 100644
index a65e16e..0000000
--- a/src/components/connections/ModalConnectionDetails.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import cx from 'clsx';
-import { formatDistance } from 'date-fns';
-import { enUS, zhCN, zhTW } from 'date-fns/locale';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-
-import Modal from '~/components/Modal';
-import modalStyle from '~/components/Modal.module.scss';
-import prettyBytes from '~/misc/pretty-bytes';
-import { FormattedConn } from '~/store/connections';
-
-import s from './ModalConnectionDetails.module.scss';
-
-type Props = {
- isOpen: boolean;
- onRequestClose: () => void;
- connection?: FormattedConn;
-};
-
-export default function ModalConnectionDetails({ isOpen, onRequestClose, connection }: Props) {
- const { t, i18n } = useTranslation();
-
- let locale = enUS;
- if (i18n.language === 'zh-CN') {
- locale = zhCN;
- } else if (i18n.language === 'zh-TW') {
- locale = zhTW;
- }
-
- if (!connection) return null;
-
- const rows = [
- { label: 'ID', value: connection.id },
- { label: 'Host', value: connection.host },
- { label: 'Sniff Host', value: connection.sniffHost },
- { label: 'Process', value: connection.process },
- { label: 'Destination', value: `${connection.destinationIP}:${connection.destinationPort}` },
- { label: 'Remote Destination', value: connection.remoteDestination },
- { label: 'Rule', value: connection.rule },
- { label: 'Chains', value: connection.chains },
- { label: 'Type', value: connection.type },
- { label: 'Network', value: connection.network },
- { label: 'Source', value: `${connection.sourceIP}:${connection.sourcePort}` },
- { label: 'Upload', value: prettyBytes(connection.upload) },
- { label: 'Download', value: prettyBytes(connection.download) },
- {
- label: 'Start Time',
- value: formatDistance(connection.start, 0, { locale }),
- },
- ];
-
- return (
- <Modal
- isOpen={isOpen}
- onRequestClose={onRequestClose}
- className={cx(modalStyle.content, s.content)}
- overlayClassName={cx(modalStyle.overlay, s.overlay)}
- shouldCloseOnOverlayClick={true}
- shouldCloseOnEsc={true}
- >
- <div className={s.header}>{t('Connection Details')}</div>
- <div className={s.details}>
- {rows.map((row) => (
- <React.Fragment key={row.label}>
- <div className={s.label}>{row.label}:</div>
- <div className={s.value}>{row.value || '-'}</div>
- </React.Fragment>
- ))}
- </div>
- </Modal>
- );
-}
diff --git a/src/components/connections/ModalManageConnectionColumns.module.scss b/src/components/connections/ModalManageConnectionColumns.module.scss
deleted file mode 100644
index e0fd36b..0000000
--- a/src/components/connections/ModalManageConnectionColumns.module.scss
+++ /dev/null
@@ -1,61 +0,0 @@
-.columnManagerRow {
- width: 280px;
- display: flex;
- margin: 4px 0;
- padding: 8px 12px;
- align-items: center;
- background: var(--bg-near-transparent);
- border-radius: 10px;
- transition: all 0.2s ease;
-
- &:hover {
- background: rgba(66, 133, 244, 0.1);
- }
-
- .columnManageLabel {
- flex: 1;
- margin-left: 12px;
- font-size: 0.95em;
- font-weight: 500;
- }
-
- .columnMoveButtons {
- display: flex;
- flex-direction: column;
- margin-right: 8px;
- gap: 2px;
- }
-
- .moveBtn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 16px;
- padding: 0;
- border: none;
- border-radius: 4px;
- background-color: transparent;
- color: var(--color-text-secondary);
- cursor: pointer;
- transition: all 0.15s ease;
-
- &:hover:not(:disabled) {
- background-color: var(--color-focus-blue);
- color: white;
- transform: scale(1.1);
- }
-
- &:disabled {
- opacity: 0.25;
- cursor: not-allowed;
- }
- }
-
- .columnManageSwitch {
- transform: scale(0.75);
- height: 22px;
- display: flex;
- align-items: center;
- }
-}
diff --git a/src/components/connections/ModalManageConnectionColumns.tsx b/src/components/connections/ModalManageConnectionColumns.tsx
deleted file mode 100644
index e884569..0000000
--- a/src/components/connections/ModalManageConnectionColumns.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import BaseModal from '~/components/shared/BaseModal';
-import { ChevronDown, ChevronUp, Menu } from '~/components/shared/FeatherIcons';
-import Switch from '~/components/SwitchThemed';
-import { ConnectionColumn } from '~/modules/connections/utils';
-
-import s from './ModalManageConnectionColumns.module.scss';
-
-const getItemStyle = (isDragging, draggableStyle) => {
- return {
- ...draggableStyle,
- ...(isDragging && {
- background: 'transparent',
- }),
- };
-};
-
-export default function ModalManageConnectionColumns({
- isOpen,
- onRequestClose,
- columns,
- hiddenColumns,
- setColumns,
- setHiddenColumns,
-}) {
- const { t } = useTranslation();
-
- const onDragEnd = (result) => {
- if (!result.destination) {
- return;
- }
-
- const items = Array.from(columns);
- const [removed] = items.splice(result.source.index, 1);
- items.splice(result.destination.index, 0, removed);
- setColumns(items);
- };
-
- const onShowChange = (column, val) => {
- const nextHiddenColumns = !val
- ? [...hiddenColumns, column.accessor]
- : hiddenColumns.filter((accessor) => accessor !== column.accessor);
-
- setHiddenColumns(nextHiddenColumns);
- };
-
- const moveColumn = (columnAccessor: string, direction: 'up' | 'down') => {
- const items: ConnectionColumn[] = Array.from(columns);
- const currentIndex = items.findIndex((c) => c.accessor === columnAccessor);
- if (currentIndex === -1) return;
-
- // 计算目标位置,跳过 id 列
- let targetIndex = currentIndex;
- if (direction === 'up') {
- // 往上移动
- targetIndex = currentIndex - 1;
- // 跳过 id 列
- while (targetIndex >= 0 && items[targetIndex].accessor === 'id') {
- targetIndex--;
- }
- if (targetIndex < 0) return;
- } else {
- // 往下移动
- targetIndex = currentIndex + 1;
- // 跳过 id 列
- while (targetIndex < items.length && items[targetIndex].accessor === 'id') {
- targetIndex++;
- }
- if (targetIndex >= items.length) return;
- }
-
- // 交换位置
- const [removed] = items.splice(currentIndex, 1);
- items.splice(targetIndex, 0, removed);
- setColumns(items);
- };
-
- // 获取非 id 列的显示列表
- const visibleColumns = columns.filter((i) => i.accessor !== 'id');
-
- return (
- <BaseModal isOpen={isOpen} onRequestClose={onRequestClose}>
- <div>
- <DragDropContext onDragEnd={onDragEnd}>
- <Droppable droppableId="droppable-modal">
- {(provided) => (
- <div {...provided.droppableProps} ref={provided.innerRef}>
- {visibleColumns.map((column, displayIndex) => {
- const show = !hiddenColumns.includes(column.accessor);
- const isFirst = displayIndex === 0;
- const isLast = displayIndex === visibleColumns.length - 1;
-
- return (
- <Draggable
- key={column.accessor}
- draggableId={column.accessor}
- index={columns.findIndex((a) => a.accessor === column.accessor)}
- >
- {(provided, snapshot) => (
- <div
- ref={provided.innerRef}
- {...provided.draggableProps}
- {...provided.dragHandleProps}
- className={s.columnManagerRow}
- style={getItemStyle(snapshot.isDragging, provided.draggableProps.style)}
- >
- <Menu size={16} />
- <span className={s.columnManageLabel}>{t(column.Header)}</span>
- <div className={s.columnMoveButtons}>
- <button
- className={s.moveBtn}
- onClick={(e) => {
- e.stopPropagation();
- moveColumn(column.accessor, 'up');
- }}
- disabled={isFirst}
- title={t('Move Up')}
- >
- <ChevronUp size={14} />
- </button>
- <button
- className={s.moveBtn}
- onClick={(e) => {
- e.stopPropagation();
- moveColumn(column.accessor, 'down');
- }}
- disabled={isLast}
- title={t('Move Down')}
- >
- <ChevronDown size={14} />
- </button>
- </div>
- <div className={s.columnManageSwitch}>
- <Switch
- size="mini"
- checked={show}
- onChange={(val) => onShowChange(column, val)}
- />
- </div>
- </div>
- )}
- </Draggable>
- );
- })}
- {provided.placeholder}
- </div>
- )}
- </Droppable>
- </DragDropContext>
- </div>
- </BaseModal>
- );
-}
diff --git a/src/components/connections/ModalSourceIP.module.scss b/src/components/connections/ModalSourceIP.module.scss
deleted file mode 100644
index 7a60dcf..0000000
--- a/src/components/connections/ModalSourceIP.module.scss
+++ /dev/null
@@ -1,9 +0,0 @@
-.sourceipTable {
- input {
- width: 120px;
- }
-}
-
-.iptableTipContainer {
- width: 300px;
-}
diff --git a/src/components/connections/ModalSourceIP.tsx b/src/components/connections/ModalSourceIP.tsx
deleted file mode 100644
index 357cf7d..0000000
--- a/src/components/connections/ModalSourceIP.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import Button from '~/components/Button';
-import Input from '~/components/Input';
-import BaseModal from '~/components/shared/BaseModal';
-
-import s from './ModalSourceIP.module.scss';
-
-export default function ModalSourceIP({ isOpen, onRequestClose, sourceMap, setSourceMap }) {
- const { t } = useTranslation();
- const setSource = (key, index, val) => {
- setSourceMap((prev) =>
- prev.map((source, currentIndex) =>
- currentIndex === index ? { ...source, [key]: val } : source
- )
- );
- };
-
- const removeSource = (index) => {
- setSourceMap((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
- };
-
- const addSource = () => {
- setSourceMap((prev) => [...prev, { reg: '', name: '' }]);
- };
-
- return (
- <BaseModal isOpen={isOpen} onRequestClose={onRequestClose}>
- <table className={s.sourceipTable}>
- <thead>
- <tr>
- <th>{t('c_source')}</th>
- <th>{t('device_name')}</th>
- </tr>
- </thead>
- <tbody>
- {sourceMap.map((source, index) => (
- <tr key={`${index}`}>
- <td>
- <Input
- type="text"
- name="reg"
- autoComplete="off"
- value={source.reg}
- onChange={(e) => setSource('reg', index, e.target.value)}
- />
- </td>
- <td>
- <Input
- type="text"
- name="name"
- autoComplete="off"
- value={source.name}
- onChange={(e) => setSource('name', index, e.target.value)}
- />
- </td>
- <td>
- <Button onClick={() => removeSource(index)}>{t('delete')}</Button>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- <div>
- <div className={s.iptableTipContainer}>{t('sourceip_tip')}</div>
- <Button onClick={addSource}>{t('add_tag')}</Button>
- </div>
- </BaseModal>
- );
-}
diff --git a/src/components/home/Home.module.scss b/src/components/home/Home.module.scss
index 7fa6ada..940a906 100644
--- a/src/components/home/Home.module.scss
+++ b/src/components/home/Home.module.scss
@@ -1,8 +1,195 @@
-@use '~/styles/utils/custom-media' as *;
+.page {
+ color: var(--color-text);
+ padding: 16px 16px 32px;
-.root {
- padding: 6px 15px;
- @media screen and (min-width: 30em) {
- padding: 10px 40px;
+ @media (min-width: 768px) {
+ padding: 28px 40px 48px;
}
}
+
+.pageHeader {
+ margin-bottom: 24px;
+}
+
+.eyebrow {
+ margin: 0 0 4px;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ color: var(--color-text-secondary);
+}
+
+.pageTitle {
+ margin: 0;
+ font-size: 2rem;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.15;
+ color: var(--color-text-highlight);
+}
+
+.grid {
+ display: grid;
+ gap: 20px;
+ grid-template-columns: 1fr;
+
+ @media (min-width: 768px) {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ padding: 20px;
+ border: 1px solid var(--color-separator);
+ border-radius: 16px;
+ background-color: var(--color-bg-card);
+ box-shadow: var(--shadow-card);
+ min-width: 0;
+}
+
+.trafficCard {
+ @media (min-width: 768px) {
+ grid-column: span 2;
+ }
+}
+
+.cardLabel {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+}
+
+.cardTitle {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--color-text-highlight);
+}
+
+.cardSubtitle {
+ margin: 2px 0 0;
+ font-size: 0.8125rem;
+ color: var(--color-text-secondary);
+}
+
+/* 大号数值 + 小号单位 */
+.value {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ margin-top: 10px;
+ font-size: 2.25rem;
+ font-weight: 800;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-text-highlight);
+}
+
+.unit {
+ font-size: 0.875rem;
+ font-weight: 600;
+ font-style: normal;
+ color: var(--color-text-secondary);
+}
+
+.meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 14px;
+ font-size: 0.8125rem;
+ color: var(--color-text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+
+.dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+/* 实时流量卡片 */
+.cardHead {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.legend {
+ display: flex;
+ gap: 28px;
+}
+
+.legendItem {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ /* 固定列宽 + 右对齐,速率位数变化时数字不会推着标签左右抖动 */
+ min-width: 104px;
+
+ @media (min-width: 768px) {
+ align-items: flex-end;
+ }
+}
+
+.legendLabel {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.8125rem;
+ color: var(--color-text-secondary);
+}
+
+.legendValue {
+ display: flex;
+ align-items: baseline;
+ gap: 4px;
+ margin-top: 4px;
+ font-size: 1.5rem;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-text-highlight);
+}
+
+.chart {
+ height: 220px;
+ margin-top: 16px;
+}
+
+.memoryChart {
+ height: 96px;
+ margin-top: 12px;
+}
+
+.metaRows {
+ margin-top: 16px;
+ border-top: 1px solid var(--color-separator);
+}
+
+.metaRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 0;
+ font-size: 0.8125rem;
+ color: var(--color-text-secondary);
+
+ & + & {
+ border-top: 1px solid var(--color-separator);
+ }
+}
+
+.metaValue {
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ color: var(--color-text);
+}
diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx
index 489f4c5..6ea1e62 100644
--- a/src/components/home/Home.tsx
+++ b/src/components/home/Home.tsx
@@ -1,9 +1,11 @@
-import React from 'react';
+import { useTranslation } from 'react-i18next';
import { ClashAPIConfig } from '~/types';
-import s0 from './Home.module.scss';
-import TrafficNow from './TrafficNow';
+import s from './Home.module.scss';
+import MemoryCard from './MemoryCard';
+import StatCards from './StatCards';
+import TrafficCard from './TrafficCard';
type Props = {
apiConfig: ClashAPIConfig;
@@ -11,10 +13,18 @@ type Props = {
};
export default function Home({ apiConfig, selectedChartStyleIndex }: Props) {
+ const { t } = useTranslation();
+
return (
- <div>
- <div className={s0.root}>
- <TrafficNow apiConfig={apiConfig} selectedChartStyleIndex={selectedChartStyleIndex} />
+ <div className={s.page}>
+ <header className={s.pageHeader}>
+ <p className={s.eyebrow}>{t('overview_eyebrow')}</p>
+ <h1 className={s.pageTitle}>{t('Overview')}</h1>
+ </header>
+ <div className={s.grid}>
+ <StatCards apiConfig={apiConfig} selectedChartStyleIndex={selectedChartStyleIndex} />
+ <TrafficCard apiConfig={apiConfig} selectedChartStyleIndex={selectedChartStyleIndex} />
+ <MemoryCard apiConfig={apiConfig} selectedChartStyleIndex={selectedChartStyleIndex} />
</div>
</div>
);
diff --git a/src/components/home/MemoryCard.tsx b/src/components/home/MemoryCard.tsx
new file mode 100644
index 0000000..7537bd7
--- /dev/null
+++ b/src/components/home/MemoryCard.tsx
@@ -0,0 +1,50 @@
+import { Suspense } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import prettyBytes from '~/misc/pretty-bytes';
+import { CHART_WINDOW, useMemory, useRulesCount } from '~/modules/home/hooks';
+import { formatCount, latestOf, peakOf, splitBytes } from '~/modules/home/utils';
+import { ClashAPIConfig } from '~/types';
+
+import s from './Home.module.scss';
+import MemoryChart from './MemoryChart';
+
+type Props = {
+ apiConfig: ClashAPIConfig;
+ selectedChartStyleIndex: number;
+};
+
+export default function MemoryCard({ apiConfig, selectedChartStyleIndex }: Props) {
+ const { t } = useTranslation();
+ const memory = useMemory(apiConfig);
+ const rulesCount = useRulesCount(apiConfig);
+
+ const { value, unit } = splitBytes(latestOf(memory.inuse));
+ const peak = peakOf(memory.inuse, CHART_WINDOW);
+
+ return (
+ <section className={s.card}>
+ <h2 className={s.cardTitle}>{t('Memory Usage')}</h2>
+ <p className={s.cardSubtitle}>{t('current_process_memory')}</p>
+ <div className={s.value}>
+ <span>{value}</span>
+ <em className={s.unit}>{unit}</em>
+ </div>
+ <div className={s.memoryChart}>
+ <Suspense fallback={null}>
+ <MemoryChart memory={memory} styleIndex={selectedChartStyleIndex} />
+ </Suspense>
+ </div>
+ <div className={s.metaRows}>
+ <div className={s.metaRow}>
+ <span>{t('memory_peak')}</span>
+ <span className={s.metaValue}>{prettyBytes(peak)}</span>
+ </div>
+ <div className={s.metaRow}>
+ <span>{t('rule_count')}</span>
+ <span className={s.metaValue}>{formatCount(rulesCount)}</span>
+ </div>
+ </div>
+ </section>
+ );
+}
diff --git a/src/components/home/MemoryChart.tsx b/src/components/home/MemoryChart.tsx
new file mode 100644
index 0000000..344dedd
--- /dev/null
+++ b/src/components/home/MemoryChart.tsx
@@ -0,0 +1,84 @@
+import * as React from 'react';
+import { Line } from 'react-chartjs-2';
+
+import { chartJSResource, chartStyles, commonDataSetProps } from '~/misc/chart';
+import prettyBytes from '~/misc/pretty-bytes';
+import { CHART_WINDOW, useScrollingChart } from '~/modules/home/hooks';
+import { gradientFill, rangeOf } from '~/modules/home/utils';
+
+const { useMemo, useRef } = React;
+
+/** 多画几个窗口外的点,左边缘的线段才不会在数据滚动时被截断 */
+const OVERSCAN = 3;
+
+/** 内存读数很平稳,量程上下各留 2% 的余量,免得细微波动被放大成剧烈起伏 */
+const PAD_RATIO = 0.02;
+
+type Memory = { inuse: number[]; labels: number[] };
+
+type Props = {
+ memory: Memory;
+ styleIndex: number;
+};
+
+const options: any = {
+ responsive: true,
+ maintainAspectRatio: false,
+ parsing: false,
+ animation: false,
+ interaction: {
+ mode: 'index',
+ intersect: false,
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ enabled: true,
+ displayColors: false,
+ callbacks: {
+ title: (items: any[]) => new Date(items[0].parsed.x).toLocaleTimeString(),
+ label: (ctx: any) => ` ${prettyBytes(ctx.parsed.y)}`,
+ },
+ },
+ },
+ scales: {
+ // x 的窗口、y 的量程都由动画帧逐帧写入
+ x: { type: 'time', display: false },
+ y: { display: false },
+ },
+ elements: {
+ line: { borderWidth: 2, cubicInterpolationMode: 'monotone' },
+ point: { radius: 0, hitRadius: 10, hoverRadius: 4 },
+ },
+};
+
+export default function MemoryChart({ memory, styleIndex }: Props) {
+ chartJSResource.read();
+
+ const chartRef = useRef<any>(null);
+ const size = CHART_WINDOW + OVERSCAN;
+
+ // 内存不是从 0 开始的量,量程跟着数据区间走,否则曲线会被压成贴顶的一条直线
+ useScrollingChart(chartRef, () => rangeOf(memory.inuse, size, PAD_RATIO));
+
+ const style = chartStyles[styleIndex] || chartStyles[0];
+
+ const data = useMemo(() => {
+ const labels = memory.labels.slice(-size);
+ return {
+ datasets: [
+ {
+ ...commonDataSetProps,
+ borderWidth: 2,
+ borderColor: style.inuse.borderColor,
+ backgroundColor: gradientFill(style.inuse.borderColor),
+ // 内核推送的首帧是 0,和缓冲里未填充的 null 一样当作没有数据
+ data: memory.inuse.slice(-size).map((y, i) => ({ x: labels[i], y: y > 0 ? y : null })),
+ fill: true,
+ },
+ ],
+ };
+ }, [memory, size, style]);
+
+ return <Line ref={chartRef} data={data} options={options} redraw={false} />;
+}
diff --git a/src/components/home/StatCards.tsx b/src/components/home/StatCards.tsx
new file mode 100644
index 0000000..cd82366
--- /dev/null
+++ b/src/components/home/StatCards.tsx
@@ -0,0 +1,72 @@
+import { useTranslation } from 'react-i18next';
+
+import { chartStyles } from '~/misc/chart';
+import { useConnectionSummary } from '~/modules/home/hooks';
+import { formatCount, splitBytes } from '~/modules/home/utils';
+import { ClashAPIConfig } from '~/types';
+
+import s from './Home.module.scss';
+
+type Props = {
+ apiConfig: ClashAPIConfig;
+ selectedChartStyleIndex: number;
+};
+
+type StatCardProps = {
+ label: string;
+ value: string;
+ unit: string;
+ meta: string;
+ dotColor: string;
+};
+
+function StatCard({ label, value, unit, meta, dotColor }: StatCardProps) {
+ return (
+ <section className={s.card}>
+ <span className={s.cardLabel}>{label}</span>
+ <div className={s.value}>
+ <span>{value}</span>
+ <em className={s.unit}>{unit}</em>
+ </div>
+ <div className={s.meta}>
+ <span className={s.dot} style={{ backgroundColor: dotColor }} />
+ <span>{meta}</span>
+ </div>
+ </section>
+ );
+}
+
+export default function StatCards({ apiConfig, selectedChartStyleIndex }: Props) {
+ const { t } = useTranslation();
+ const { upTotal, dlTotal, connNumber, tcpNumber, udpNumber } = useConnectionSummary(apiConfig);
+ const style = chartStyles[selectedChartStyleIndex] || chartStyles[0];
+
+ const dl = splitBytes(dlTotal);
+ const up = splitBytes(upTotal);
+
+ return (
+ <>
+ <StatCard
+ label={t('Download Total')}
+ value={dl.value}
+ unit={dl.unit}
+ meta={t('since_core_start')}
+ dotColor={style.down.borderColor}
+ />
+ <StatCard
+ label={t('Upload Total')}
+ value={up.value}
+ unit={up.unit}
+ meta={t('since_core_start')}
+ dotColor={style.up.borderColor}
+ />
+ <StatCard
+ label={t('Active Connections')}
+ value={formatCount(connNumber)}
+ unit={t('conn_unit')}
+ meta={`TCP ${formatCount(tcpNumber)} · UDP ${formatCount(udpNumber)}`}
+ dotColor="#22c55e"
+ />
+ </>
+ );
+}
diff --git a/src/components/home/TrafficCard.tsx b/src/components/home/TrafficCard.tsx
new file mode 100644
index 0000000..7489798
--- /dev/null
+++ b/src/components/home/TrafficCard.tsx
@@ -0,0 +1,74 @@
+import cx from 'clsx';
+import { Suspense } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { chartStyles } from '~/misc/chart';
+import { CHART_WINDOW, useTraffic } from '~/modules/home/hooks';
+import { splitTrafficRate } from '~/modules/home/utils';
+import { ClashAPIConfig } from '~/types';
+
+import s from './Home.module.scss';
+import TrafficChart from './TrafficChart';
+
+type Props = {
+ apiConfig: ClashAPIConfig;
+ selectedChartStyleIndex: number;
+};
+
+function Legend({ label, color, rate }: { label: string; color: string; rate: number }) {
+ const { value, unit } = splitTrafficRate(rate);
+ return (
+ <div className={s.legendItem}>
+ <span className={s.legendLabel}>
+ <span className={s.dot} style={{ backgroundColor: color }} />
+ {label}
+ </span>
+ <span className={s.legendValue}>
+ <span>{value}</span>
+ <em className={s.unit}>{unit}</em>
+ </span>
+ </div>
+ );
+}
+
+export default function TrafficCard({ apiConfig, selectedChartStyleIndex }: Props) {
+ const { t } = useTranslation();
+ const traffic = useTraffic(apiConfig);
+ const style = chartStyles[selectedChartStyleIndex] || chartStyles[0];
+
+ const downLabel = t('Download');
+ const upLabel = t('Upload');
+
+ return (
+ <section className={cx(s.card, s.trafficCard)}>
+ <div className={s.cardHead}>
+ <div>
+ <h2 className={s.cardTitle}>{t('realtime_traffic')}</h2>
+ <p className={s.cardSubtitle}>{t('last_n_seconds', { seconds: CHART_WINDOW })}</p>
+ </div>
+ <div className={s.legend}>
+ <Legend
+ label={downLabel}
+ color={style.down.borderColor}
+ rate={traffic.down[traffic.down.length - 1] || 0}
+ />
+ <Legend
+ label={upLabel}
+ color={style.up.borderColor}
+ rate={traffic.up[traffic.up.length - 1] || 0}
+ />
+ </div>
+ </div>
+ <div className={s.chart}>
+ <Suspense fallback={null}>
+ <TrafficChart
+ traffic={traffic}
+ styleIndex={selectedChartStyleIndex}
+ downLabel={downLabel}
+ upLabel={upLabel}
+ />
+ </Suspense>
+ </div>
+ </section>
+ );
+}
diff --git a/src/components/home/TrafficChart.tsx b/src/components/home/TrafficChart.tsx
new file mode 100644
index 0000000..2584886
--- /dev/null
+++ b/src/components/home/TrafficChart.tsx
@@ -0,0 +1,118 @@
+import * as React from 'react';
+import { Line } from 'react-chartjs-2';
+
+import { chartJSResource, chartStyles, commonDataSetProps } from '~/misc/chart';
+import { CHART_WINDOW, useScrollingChart } from '~/modules/home/hooks';
+import { formatTrafficRate, gradientFill, peakOf } from '~/modules/home/utils';
+
+const { useMemo, useRef } = React;
+
+/** 多画几个窗口外的点,左边缘的线段才不会在数据滚动时被截断 */
+const OVERSCAN = 3;
+
+/** 纵轴最小量程,空闲时不至于把噪声放大成大起大落 */
+const Y_FLOOR = 100 * 1024;
+
+/**
+ * 速率跨度可以从几百 B/s 到几十 MB/s,直接画会被尖峰压成一条贴底的直线。
+ * 用 log1p 压缩后再画(0 仍然映射到 0),读数时用 expm1 还原。
+ */
+const compress = (v: number) => Math.log1p(v);
+const decompress = (v: number) => Math.expm1(v);
+
+type Traffic = { up: number[]; down: number[]; labels: number[] };
+
+type Props = {
+ traffic: Traffic;
+ styleIndex: number;
+ downLabel: string;
+ upLabel: string;
+};
+
+const options: any = {
+ responsive: true,
+ maintainAspectRatio: false,
+ parsing: false,
+ // 曲线的移动完全交给 useScrollingChart 逐帧推进坐标轴,元素本身不做补间
+ animation: false,
+ interaction: {
+ mode: 'index',
+ intersect: false,
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ enabled: true,
+ callbacks: {
+ title: (items: any[]) => new Date(items[0].parsed.x).toLocaleTimeString(),
+ // 画的是压缩后的值,提示里要还原成真实速率
+ label: (ctx: any) =>
+ ` ${ctx.dataset.label} ${formatTrafficRate(decompress(ctx.parsed.y))}`,
+ },
+ },
+ },
+ scales: {
+ // x 的窗口、y 的量程都由动画帧逐帧写入
+ x: { type: 'time', display: false },
+ y: {
+ display: true,
+ border: { display: false },
+ grid: {
+ color: 'rgba(148, 163, 184, 0.22)',
+ drawTicks: false,
+ },
+ // 固定 5 条刻度,网格线始终落在同样的像素高度,量程变化时不会跳动
+ ticks: { display: false, count: 5 },
+ },
+ },
+ elements: {
+ // monotone 插值:曲线平滑但不会在两点之间过冲到负值,面积图不会出现假的凹陷
+ line: { borderWidth: 2, cubicInterpolationMode: 'monotone' },
+ point: { radius: 0, hitRadius: 10, hoverRadius: 4 },
+ },
+};
+
+export default function TrafficChart({ traffic, styleIndex, downLabel, upLabel }: Props) {
+ chartJSResource.read();
+
+ const chartRef = useRef<any>(null);
+ const size = CHART_WINDOW + OVERSCAN;
+
+ useScrollingChart(chartRef, () => ({
+ min: 0,
+ max: compress(Math.max(peakOf(traffic.up, size), peakOf(traffic.down, size), Y_FLOOR)) * 1.15,
+ }));
+
+ const style = chartStyles[styleIndex] || chartStyles[0];
+
+ const data = useMemo(() => {
+ const labels = traffic.labels.slice(-size);
+ const toPoints = (values: number[]) =>
+ values.slice(-size).map((y, i) => ({ x: labels[i], y: y === null ? null : compress(y) }));
+
+ return {
+ datasets: [
+ {
+ ...commonDataSetProps,
+ borderWidth: 2,
+ label: downLabel,
+ borderColor: style.down.borderColor,
+ backgroundColor: gradientFill(style.down.borderColor),
+ data: toPoints(traffic.down),
+ fill: true,
+ },
+ {
+ ...commonDataSetProps,
+ borderWidth: 2,
+ label: upLabel,
+ borderColor: style.up.borderColor,
+ backgroundColor: gradientFill(style.up.borderColor),
+ data: toPoints(traffic.up),
+ fill: true,
+ },
+ ],
+ };
+ }, [traffic, size, style, downLabel, upLabel]);
+
+ return <Line ref={chartRef} data={data} options={options} redraw={false} />;
+}
diff --git a/src/components/home/TrafficNow.module.scss b/src/components/home/TrafficNow.module.scss
deleted file mode 100644
index 2b0bcdf..0000000
--- a/src/components/home/TrafficNow.module.scss
+++ /dev/null
@@ -1,85 +0,0 @@
-.TrafficNow {
- color: var(--color-text);
- display: flex;
- flex-direction: column;
- grid-gap: 20px;
- gap: 20px;
- padding: 10px 0;
-
- .overview {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 20px;
-
- & > div:nth-child(3) {
- grid-column: 1 / -1;
- }
-
- @media (min-width: 768px) {
- grid-template-columns: 1fr 1fr 1fr;
-
- & > div:nth-child(3) {
- grid-column: auto;
- }
- }
- }
-
- .chartsRow {
- display: flex;
- flex-direction: column;
- gap: 20px;
-
- @media (min-width: 768px) {
- flex-direction: row;
-
- & > .sec {
- flex: 1;
- min-width: 0;
- }
- }
- }
-
- .sec {
- padding: 20px;
- background-color: var(--color-bg-card);
- border-radius: 12px;
- box-shadow:
- 0 4px 6px -1px rgba(0, 0, 0, 0.1),
- 0 2px 4px -1px rgba(0, 0, 0, 0.06);
- transition:
- transform 0.2s ease,
- box-shadow 0.2s ease;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- min-height: 140px;
-
- &:hover {
- box-shadow:
- 0 10px 15px -3px rgba(0, 0, 0, 0.1),
- 0 4px 6px -2px rgba(0, 0, 0, 0.05);
- }
-
- .header {
- display: flex;
- align-items: center;
- color: var(--color-text-secondary);
- font-size: 0.85rem;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.025em;
-
- span {
- margin-left: 8px;
- }
- }
-
- .value {
- padding: 12px 0;
- font-size: 1.75rem;
- font-weight: 700;
- color: var(--color-text);
- font-family: 'Roboto Mono', monospace;
- }
- }
-}
diff --git a/src/components/home/TrafficNow.tsx b/src/components/home/TrafficNow.tsx
deleted file mode 100644
index c46cf67..0000000
--- a/src/components/home/TrafficNow.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import * as React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import {
- Download,
- ArrowDown,
- ArrowUp,
- Cpu,
- Link as LinkIcon,
- Upload,
-} from '~/components/shared/FeatherIcons';
-import Sparkline from '~/components/shared/Sparkline';
-import useMemory from '~/hooks/useMemory';
-import useTraffic from '~/hooks/useTraffic';
-import { useConnectionSummary } from '~/modules/home/hooks';
-import { formatTrafficRate } from '~/modules/home/utils';
-import { ClashAPIConfig } from '~/types';
-
-import s0 from './TrafficNow.module.scss';
-
-type Props = {
- apiConfig: ClashAPIConfig;
- selectedChartStyleIndex: number;
-};
-
-export default function TrafficNow({ apiConfig, selectedChartStyleIndex }: Props) {
- const { t } = useTranslation();
- const traffic = useTraffic(apiConfig);
- const memory = useMemory(apiConfig);
- const { upTotal, dlTotal, connNumber, mUsage } = useConnectionSummary(apiConfig);
-
- const upStr = formatTrafficRate(traffic.up[traffic.up.length - 1] || 0);
- const downStr = formatTrafficRate(traffic.down[traffic.down.length - 1] || 0);
-
- return (
- <div className={s0.TrafficNow}>
- <div className={s0.overview}>
- <div className={s0.sec}>
- <div className={s0.header}>
- <Download size={16} />
- <span>{t('Download Total')}</span>
- </div>
- <div className={s0.value}>{dlTotal}</div>
- </div>
- <div className={s0.sec}>
- <div className={s0.header}>
- <Upload size={16} />
- <span>{t('Upload Total')}</span>
- </div>
- <div className={s0.value}>{upTotal}</div>
- </div>
- <div className={s0.sec}>
- <div className={s0.header}>
- <LinkIcon size={16} />
- <span>{t('Active Connections')}</span>
- </div>
- <div className={s0.value}>{connNumber}</div>
- </div>
- </div>
-
- <div className={s0.chartsRow}>
- <div className={s0.sec}>
- <div className={s0.header}>
- <ArrowDown size={16} />
- <span>{t('Download')}</span>
- </div>
- <div className={s0.value}>{downStr}</div>
- <Sparkline
- data={traffic.down}
- labels={traffic.labels}
- type="down"
- styleIndex={selectedChartStyleIndex}
- />
- </div>
- <div className={s0.sec}>
- <div className={s0.header}>
- <ArrowUp size={16} />
- <span>{t('Upload')}</span>
- </div>
- <div className={s0.value}>{upStr}</div>
- <Sparkline
- data={traffic.up}
- labels={traffic.labels}
- type="up"
- styleIndex={selectedChartStyleIndex}
- />
- </div>
- <div className={s0.sec}>
- <div className={s0.header}>
- <Cpu size={16} />
- <span>{t('Memory Usage')}</span>
- </div>
- <div className={s0.value}>{mUsage}</div>
- <Sparkline
- data={memory.inuse}
- labels={memory.labels}
- type="inuse"
- styleIndex={selectedChartStyleIndex}
- />
- </div>
- </div>
- </div>
- );
-}
diff --git a/src/components/logs/LogSearch.ts b/src/components/logs/LogSearch.ts
deleted file mode 100644
index d5972a4..0000000
--- a/src/components/logs/LogSearch.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import Search from '~/components/Search';
-import { connect } from '~/components/StateProvider';
-import { getSearchText, updateSearchText } from '~/store/logs';
-
-const mapState = (s) => ({ searchText: getSearchText(s), updateSearchText });
-export default connect(mapState)(Search);
diff --git a/src/components/logs/Logs.module.scss b/src/components/logs/Logs.module.scss
index c90071d..47b996f 100644
--- a/src/components/logs/Logs.module.scss
+++ b/src/components/logs/Logs.module.scss
@@ -1,190 +1,217 @@
-.headerControls {
+.page {
display: flex;
- align-items: center;
- gap: 10px;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+
+.listArea {
+ display: flex;
+ flex-direction: column;
flex: 1;
- justify-content: flex-end;
- max-width: 360px;
+ min-height: 0;
+ margin: 12px 32px 24px;
- & > div {
- flex: 1;
+ @media (max-width: 1024px) {
+ margin: 12px 16px 16px;
}
@media (max-width: 768px) {
- max-width: none;
- justify-content: stretch;
+ margin: 8px 12px 10px;
}
}
-.searchWrapper {
+// 与连接页表格 / 规则页列表同一张卡:窄屏下退掉卡片边界
+.card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
flex: 1;
- max-width: 300px;
+ min-height: 0;
+ border-radius: 16px;
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ box-shadow: var(--shadow-card);
+ overflow: hidden;
@media (max-width: 768px) {
- max-width: none;
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ border-radius: 0;
}
}
-.levelSelect {
- flex-shrink: 0;
- width: auto;
- min-width: 90px;
-}
+.scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
-.clearBtn {
- background: none;
- border: none;
- color: var(--color-text-secondary);
- cursor: pointer;
- padding: 8px;
- border-radius: 4px;
- display: flex;
- align-items: center;
- justify-content: center;
- transition:
- background-color 0.2s,
- color 0.2s;
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
- &:hover {
- background-color: var(--bg-near-transparent);
- color: var(--color-text);
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--color-card-border);
+ border-radius: 3px;
}
}
-.logLine {
+/* ---------- 日志行 ---------- */
+
+.line {
display: flex;
- font-family: var(--font-normal);
- font-size: 12px;
- padding: 4px 8px;
- border-bottom: 1px solid var(--bg-near-transparent);
- word-break: break-all;
+ align-items: baseline;
+ gap: 10px;
+ padding: 5px 18px;
+ font-size: 0.75rem;
+ border-bottom: 1px solid var(--color-card-border);
&:hover {
- background-color: var(--bg-near-transparent);
+ background-color: var(--color-hover-soft);
}
+ // 这里不用 content-visibility:自动跟随是拿 scrollHeight 直接顶到底的,
+ // 屏外行只有估算高度会让落点跳。日志上限 300 行,靠 LogLine 的 memo 就够了
+
@media (max-width: 768px) {
- flex-direction: column;
+ // 窄屏放不下三列,时间和级别并排占一行,正文另起一行
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 6px 12px;
}
}
-.logMeta {
- display: flex;
- align-items: center;
+.time {
flex-shrink: 0;
- margin-right: 12px;
- min-width: 180px;
+ width: 148px;
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ color: var(--color-text-secondary);
+ opacity: 0.8;
@media (max-width: 768px) {
- margin-bottom: 4px;
+ width: auto;
}
}
-.logTime {
- color: var(--color-text-secondary);
- margin-right: 8px;
- opacity: 0.8;
-}
-
-.logType {
- text-transform: uppercase;
- font-weight: bold;
- font-size: 10px;
- padding: 1px 6px;
- border-radius: 3px;
- min-width: 50px;
+.type {
+ flex-shrink: 0;
+ width: 44px;
text-align: center;
+ text-transform: uppercase;
+ font-weight: 700;
+ font-size: 0.6rem;
+ padding: 1px 0;
+ border-radius: 4px;
&[data-type='debug'] {
- color: #389d3d;
- background-color: rgba(56, 157, 61, 0.1);
+ color: var(--color-success);
+ background-color: var(--color-success-soft-bg);
}
+
&[data-type='info'] {
- color: #0ea5e9;
- background-color: rgba(14, 165, 233, 0.1);
+ color: var(--color-accent-soft-fg);
+ background-color: var(--color-accent-soft-bg);
}
+
&[data-type='warning'] {
- color: #f59e0b;
- background-color: rgba(245, 158, 11, 0.1);
+ color: var(--color-warn);
+ background-color: var(--color-warn-soft-bg);
}
+
&[data-type='error'] {
- color: #ef4444;
- background-color: rgba(239, 68, 68, 0.1);
+ color: var(--color-danger);
+ background-color: var(--color-danger-soft-bg);
}
}
-.logText {
- color: var(--color-text);
- line-height: 1.5;
+.payload {
flex: 1;
-}
-
-.logsWrapper {
- position: relative;
- margin: 20px 45px;
- padding: 10px;
- background-color: var(--bg-log-info-card);
- border-radius: 8px;
+ min-width: 0;
color: var(--color-text);
- overflow-y: auto;
- box-shadow:
- 0 4px 6px -1px rgba(0, 0, 0, 0.1),
- 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ line-height: 1.5;
+ word-break: break-all;
@media (max-width: 768px) {
- margin: 10px 15px;
- }
-
- &::-webkit-scrollbar {
- width: 6px;
- }
- &::-webkit-scrollbar-thumb {
- background-color: var(--bg-near-transparent);
- border-radius: 3px;
+ flex-basis: 100%;
}
}
-.scrollToBottomBtn {
+/* ---------- 回到底部 ---------- */
+
+.toBottomBtn {
position: absolute;
- bottom: 80px;
- right: 20px;
- background-color: var(--color-focus-blue);
- color: white;
- border: none;
- border-radius: 50%;
- width: 36px;
- height: 36px;
+ right: 18px;
+ bottom: 52px;
+ z-index: 2;
display: flex;
align-items: center;
justify-content: center;
+ appearance: none;
+ width: 34px;
+ height: 34px;
+ border: 1px solid var(--color-card-border);
+ border-radius: 50%;
+ background: var(--color-card);
+ color: var(--color-text);
cursor: pointer;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
- z-index: 10;
- transition: transform 0.2s;
+ box-shadow: var(--shadow-popover);
+ transition:
+ border-color 0.15s ease,
+ color 0.15s ease;
&:hover {
- transform: scale(1.1);
+ border-color: var(--color-focus-blue);
+ color: var(--color-focus-blue);
}
- @media (max-width: 768px) {
- right: 25px;
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: 2px;
}
}
-.logPlaceholder {
+/* ---------- 空态 ---------- */
+
+.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
+ gap: 6px;
+ height: 100%;
+}
+
+.emptyTitle {
+ font-size: 0.88rem;
+ color: var(--color-text);
+}
+
+.emptyHint {
+ font-size: 0.78rem;
color: var(--color-text-secondary);
+}
- div:nth-child(2) {
- font-size: 1.2em;
- margin-top: 20px;
- opacity: 0.6;
- }
+/* ---------- 页脚 ---------- */
+
+.footer {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ height: 36px;
+ flex-shrink: 0;
+ padding: 0 18px;
+ border-top: 1px solid var(--color-card-border);
+ background: var(--color-track);
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
}
-.logPlaceholderIcon {
- opacity: 0.2;
+.paused {
+ margin-left: auto;
+ color: var(--color-warn);
}
diff --git a/src/components/logs/Logs.tsx b/src/components/logs/Logs.tsx
index 71acbb8..a8510b8 100644
--- a/src/components/logs/Logs.tsx
+++ b/src/components/logs/Logs.tsx
@@ -1,109 +1,103 @@
+import { useAtomValue, useSetAtom } from 'jotai';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
-import ContentHeader from '~/components/ContentHeader';
-import { Fab, position as fabPosition } from '~/components/shared/Fab';
-import { ArrowDown, Pause, Play, Trash2 } from '~/components/shared/FeatherIcons';
-import Select from '~/components/shared/Select';
-import { useStoreActions } from '~/components/StateProvider';
-import SvgYacd from '~/components/SvgYacd';
-import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight';
-import { LOG_LEVEL_OPTIONS } from '~/modules/config/utils';
-import { useLogsPage } from '~/modules/logs/hooks';
-import { LOG_TYPES, LOGS_HEIGHT_RATIO } from '~/modules/logs/utils';
+import { ArrowDown } from '~/components/shared/FeatherIcons';
+import { useFilteredLogs, useLogsPage } from '~/modules/logs/hooks';
+import { LOG_TYPES } from '~/modules/logs/utils';
import { updateConfigs } from '~/store/configs';
-import { clearLogs } from '~/store/logs';
-import { DispatchFn, Log } from '~/store/types';
-import { ClashAPIConfig } from '~/types';
+import { clearLogsAtom, logsForDisplayAtom } from '~/store/logs';
+import { useStoreActions } from '~/store/StateProvider';
+import { DispatchFn } from '~/store/types';
+import { ClashAPIConfig, Log } from '~/types';
import s from './Logs.module.scss';
-import LogSearch from './LogSearch';
+import { LogsHeader } from './LogsHeader';
-type LogLineProps = Partial<Log>;
-
-function LogLine({ time, payload, type }: LogLineProps) {
+// 一屏最多几十行,但列表整体可以有几百行;memo 之后新日志进来只渲染新增的那一条
+const LogLine = React.memo(function LogLine({ time, payload, type }: Log) {
return (
- <div className={s.logLine}>
- <div className={s.logMeta}>
- <span className={s.logTime}>{time}</span>
- <span className={s.logType} data-type={type}>
- {LOG_TYPES[type]}
- </span>
- </div>
- <div className={s.logText}>{payload}</div>
+ <div className={s.line}>
+ <span className={s.time}>{time}</span>
+ <span className={s.type} data-type={type}>
+ {LOG_TYPES[type]}
+ </span>
+ <span className={s.payload}>{payload}</span>
</div>
);
-}
+});
type Props = {
dispatch: DispatchFn;
logLevel: string;
apiConfig: ClashAPIConfig;
- logs: Log[];
logStreamingPaused: boolean;
};
-export default function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }: Props) {
+export default function Logs({ dispatch, logLevel, apiConfig, logStreamingPaused }: Props) {
+ const { t } = useTranslation();
const actions = useStoreActions();
+ const logs = useAtomValue(logsForDisplayAtom);
const { toggleIsRefreshPaused, scrollRef, isAtBottom, scrollToBottom, onScroll } = useLogsPage({
- dispatch,
logLevel,
apiConfig,
logs,
logStreamingPaused,
updateAppConfig: actions.app.updateAppConfig,
});
- const [refLogsContainer, containerHeight] = useRemainingViewPortHeight();
- const { t } = useTranslation();
+
+ const visibleLogs = useFilteredLogs(logs);
+
+ const setLogLevel = React.useCallback(
+ (level: string) => dispatch(updateConfigs(apiConfig, { 'log-level': level })),
+ [dispatch, apiConfig],
+ );
+ const onClear = useSetAtom(clearLogsAtom);
return (
- <div>
- <ContentHeader>
- <div className={s.headerControls}>
- <LogSearch className={s.searchWrapper} />
- <Select
- className={s.levelSelect}
- options={LOG_LEVEL_OPTIONS}
- selected={logLevel ? logLevel.toLowerCase() : 'info'}
- onChange={(e) => dispatch(updateConfigs(apiConfig, { 'log-level': e.target.value }))}
- />
- <button className={s.clearBtn} onClick={() => dispatch(clearLogs())} title={t('Clear')}>
- <Trash2 size={18} />
- </button>
- </div>
- </ContentHeader>
- <div ref={refLogsContainer} style={{ position: 'relative' }}>
- <div
- className={s.logsWrapper}
- style={{ height: containerHeight * LOGS_HEIGHT_RATIO }}
- ref={scrollRef}
- onScroll={onScroll}
- >
- {logs.length === 0 ? (
- <div className={s.logPlaceholder} style={{ height: '100%' }}>
- <div className={s.logPlaceholderIcon}>
- <SvgYacd width={200} height={200} />
+ <div className={s.page}>
+ <LogsHeader
+ logLevel={logLevel}
+ setLogLevel={setLogLevel}
+ isPaused={logStreamingPaused}
+ toggleIsPaused={toggleIsRefreshPaused}
+ onClear={onClear}
+ />
+
+ <div className={s.listArea}>
+ <div className={s.card}>
+ <div className={s.scroll} ref={scrollRef} onScroll={onScroll}>
+ {visibleLogs.length === 0 ? (
+ <div className={s.empty}>
+ <span className={s.emptyTitle}>
+ {logs.length === 0 ? t('no_logs') : t('logs_no_match')}
+ </span>
+ <span className={s.emptyHint}>
+ {logs.length === 0 ? t('logs_empty_hint') : t('rules_empty_hint')}
+ </span>
</div>
- <div>{t('no_logs')}</div>
- </div>
- ) : (
- logs.map((log, index) => <LogLine {...log} key={log.id || index} />)
- )}
- </div>
+ ) : (
+ visibleLogs.map((log, index) => <LogLine {...log} key={log.id || index} />)
+ )}
+ </div>
- {logs.length > 0 && !isAtBottom && (
- <button className={s.scrollToBottomBtn} onClick={scrollToBottom}>
- <ArrowDown size={16} />
- </button>
- )}
+ {visibleLogs.length > 0 && !isAtBottom ? (
+ <button
+ type="button"
+ className={s.toBottomBtn}
+ onClick={scrollToBottom}
+ aria-label={t('logs_scroll_to_bottom')}
+ title={t('logs_scroll_to_bottom')}
+ >
+ <ArrowDown size={16} />
+ </button>
+ ) : null}
- <Fab
- icon={logStreamingPaused ? <Play size={16} /> : <Pause size={16} />}
- mainButtonStyles={logStreamingPaused ? { background: '#e74c3c' } : {}}
- style={fabPosition}
- text={logStreamingPaused ? t('Resume Refresh') : t('Pause Refresh')}
- onClick={toggleIsRefreshPaused}
- ></Fab>
+ <div className={s.footer}>
+ <span>{t('logs_shown', { count: visibleLogs.length })}</span>
+ {logStreamingPaused ? <span className={s.paused}>{t('logs_paused')}</span> : null}
+ </div>
+ </div>
</div>
</div>
);
diff --git a/src/components/logs/LogsHeader.module.scss b/src/components/logs/LogsHeader.module.scss
new file mode 100644
index 0000000..0509bb9
--- /dev/null
+++ b/src/components/logs/LogsHeader.module.scss
@@ -0,0 +1,33 @@
+/**
+ * 只放日志页特有的东西,顶栏本身的样式在 shared/PageHeader.module.scss。
+ * DOM 顺序即桌面端视觉顺序(标题 · 级别 · 搜索 · 操作),
+ * 窄屏把操作钮提到第一行,级别和搜索并排到第二行。
+ */
+
+.levelSelect {
+ min-width: 104px;
+
+ @media (max-width: 768px) {
+ order: 4;
+ min-width: 88px;
+ max-width: 110px;
+ }
+}
+
+.search {
+ @media (max-width: 768px) {
+ order: 3;
+ }
+}
+
+.actions {
+ @media (max-width: 768px) {
+ order: 1;
+ }
+}
+
+.rowBreak {
+ @media (max-width: 768px) {
+ order: 2;
+ }
+}
diff --git a/src/components/logs/LogsHeader.tsx b/src/components/logs/LogsHeader.tsx
new file mode 100644
index 0000000..5092429
--- /dev/null
+++ b/src/components/logs/LogsHeader.tsx
@@ -0,0 +1,67 @@
+import { useTranslation } from 'react-i18next';
+
+import { Pause, Play, Trash2 } from '~/components/shared/FeatherIcons';
+import {
+ HeaderActions,
+ HeaderButton,
+ HeaderRowBreak,
+ HeaderSearch,
+ headerSearchInlineClass,
+ headerSelectClass,
+ HeaderTitle,
+ PageHeader,
+} from '~/components/shared/PageHeader';
+import Select from '~/components/shared/Select';
+import { TextFilter } from '~/components/shared/TextFilter';
+import { LOG_LEVEL_OPTIONS } from '~/modules/config/utils';
+import { logFilterText } from '~/store/logs';
+
+import s from './LogsHeader.module.scss';
+
+type Props = {
+ logLevel: string;
+ setLogLevel: (level: string) => void;
+ isPaused: boolean;
+ toggleIsPaused: () => void;
+ onClear: () => void;
+};
+
+export function LogsHeader({ logLevel, setLogLevel, isPaused, toggleIsPaused, onClear }: Props) {
+ const { t } = useTranslation();
+
+ return (
+ <PageHeader>
+ <HeaderTitle>{t('Logs')}</HeaderTitle>
+
+ <Select
+ options={LOG_LEVEL_OPTIONS}
+ selected={logLevel ? logLevel.toLowerCase() : 'info'}
+ className={`${headerSelectClass} ${s.levelSelect}`}
+ aria-label={t('log_level')}
+ onChange={(e) => setLogLevel(e.target.value)}
+ />
+
+ <HeaderSearch className={`${headerSearchInlineClass} ${s.search}`}>
+ <TextFilter textAtom={logFilterText} placeholder={t('search_logs_placeholder')} />
+ </HeaderSearch>
+
+ <HeaderActions className={s.actions}>
+ <HeaderButton
+ variant={isPaused ? 'paused' : 'ghost'}
+ icon={isPaused ? <Play size={14} /> : <Pause size={14} />}
+ label={isPaused ? t('Resume Refresh') : t('Pause Refresh')}
+ onClick={toggleIsPaused}
+ />
+ <HeaderButton
+ variant="danger"
+ icon={<Trash2 size={14} />}
+ label={t('Clear')}
+ onClick={onClear}
+ />
+ </HeaderActions>
+
+ {/* 窄屏下强制换行,让级别筛选和搜索独占第二行 */}
+ <HeaderRowBreak className={s.rowBreak} />
+ </PageHeader>
+ );
+}
diff --git a/src/components/proxies/ClosePrevConns.tsx b/src/components/proxies/ClosePrevConns.tsx
deleted file mode 100644
index 6718fb2..0000000
--- a/src/components/proxies/ClosePrevConns.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import * as React from 'react';
-
-import Button from '../Button';
-import { FlexCenter } from '../shared/Styled';
-
-const { useRef, useEffect } = React;
-
-type Props = {
- onClickPrimaryButton?: () => void;
- onClickSecondaryButton?: () => void;
-};
-
-export function ClosePrevConns({ onClickPrimaryButton, onClickSecondaryButton }: Props) {
- const primaryButtonRef = useRef<HTMLButtonElement>(null);
- const secondaryButtonRef = useRef<HTMLButtonElement>(null);
- useEffect(() => {
- primaryButtonRef.current.focus();
- }, []);
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.keyCode === 39) {
- secondaryButtonRef.current.focus();
- } else if (e.keyCode === 37) {
- primaryButtonRef.current.focus();
- }
- };
-
- return (
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions
- <div onKeyDown={handleKeyDown}>
- <h2>Close Connections?</h2>
- <p>
- Click 'Yes' to close those connections that are still using the old selected proxy in this
- group
- </p>
- <div style={{ height: 30 }} />
- <FlexCenter>
- <Button onClick={onClickPrimaryButton} ref={primaryButtonRef}>
- Yes
- </Button>
- <div style={{ width: 20 }} />
- <Button onClick={onClickSecondaryButton} ref={secondaryButtonRef}>
- No
- </Button>
- </FlexCenter>
- </div>
- );
-}
diff --git a/src/components/proxies/Proxies.module.scss b/src/components/proxies/Proxies.module.scss
index a43d18b..457b173 100644
--- a/src/components/proxies/Proxies.module.scss
+++ b/src/components/proxies/Proxies.module.scss
@@ -1,153 +1,56 @@
-@use '~/styles/utils/custom-media' as *;
-
-.topBar {
- position: sticky;
- top: 0;
- z-index: 1;
- background-color: var(--color-background2);
- backdrop-filter: blur(36px);
-
- & > div {
- width: 100%;
- }
+.page {
+ padding-bottom: 32px;
}
-.topBarRight {
+.groupsContainer {
+ padding: 16px 32px;
display: flex;
- align-items: center;
- flex-wrap: wrap;
- flex: 1;
- justify-content: flex-end;
+ flex-direction: column;
+ gap: 12px;
+
+ @media (max-width: 1024px) {
+ padding: 12px 16px 16px;
+ }
@media (max-width: 768px) {
- order: 3;
- flex: 1 1 100%;
- width: 100%;
- margin-top: 8px;
+ gap: 8px;
+ padding: 8px 12px 12px;
}
}
-.textFilterContainer {
- max-width: 300px;
- min-width: 150px;
- flex: 1;
- margin-right: 8px;
+// 单列布局下 .column 是唯一的一层包裹,间距和 .group 的 order 都得由它提供;
+// 双列布局在窄屏会把它 display: contents 掉,改由 .groupsContainer 接管
+.column {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
@media (max-width: 768px) {
- max-width: none;
- margin-right: 8px;
+ gap: 8px;
}
}
.group {
- padding: 10px 0;
// let the browser skip layout/paint for off-screen groups — large
// subscriptions can put thousands of proxy nodes in the DOM. `auto` in
// contain-intrinsic-size keeps the last rendered size as the placeholder
// so scrolling doesn't jump; 200px is only the pre-first-render estimate.
content-visibility: auto;
contain-intrinsic-size: auto 200px;
-
- @media (--breakpoint-not-small) {
- padding: 10px 0;
- }
}
-.groupsContainer {
- margin: 0 45px 20px;
- padding: 2px 0 10px;
-
- @media (max-width: 768px) {
- margin: 10px 15px 15px;
+.doubleColumn {
+ @media screen and (min-width: 1200px) {
+ flex-direction: row;
+ align-items: flex-start;
}
- &.doubleColumn {
- display: flex;
- flex-direction: column;
- gap: 0;
-
- @media screen and (min-width: 1200px) {
- flex-direction: row;
- align-items: flex-start;
- gap: 12px;
- }
-
- .column {
- flex: 1;
- display: flex;
- flex-direction: column;
-
- @media screen and (max-width: 1199px) {
- display: contents;
- }
- }
-
- .group {
- @media screen and (min-width: 1200px) {
- padding: 10px 0;
- }
- }
- }
-}
-
-.tabsContainer {
- display: flex;
- align-items: center;
- background-color: var(--color-bg-sidebar);
- border-radius: 12px;
- padding: 4px;
-
- @media (max-width: 768px) {
- order: 1;
- flex: 1 1 100%;
- width: 100%;
- }
-}
-
-.tab {
- display: flex;
- align-items: center;
- padding: 8px 16px;
- border-radius: 8px;
- cursor: pointer;
- user-select: none;
- font-size: 1em;
- font-weight: 500;
- color: var(--color-text-secondary);
- transition: all 0.2s ease;
- user-select: none;
-
- @media (max-width: 768px) {
- padding: 6px 10px;
- font-size: 0.85em;
+ .column {
flex: 1;
- justify-content: center;
min-width: 0;
- white-space: nowrap;
- }
- &:hover {
- color: var(--color-focus-blue);
- background: rgba(176, 206, 255, 0.221);
- }
-
- &.active {
- background-color: var(--color-focus-blue);
- color: #fff;
+ @media screen and (max-width: 1199px) {
+ display: contents;
+ }
}
}
-
-.tabCount {
- font-family: var(--font-normal);
- font-size: 0.7em;
- margin-left: 6px;
- padding: 2px 8px;
- display: inline-flex;
- justify-content: center;
- align-items: center;
- background-color: rgba(255, 255, 255, 0.15);
- border-radius: 10px;
- font-weight: 600;
- min-width: 20px;
- flex-shrink: 0;
-}
diff --git a/src/components/proxies/Proxies.tsx b/src/components/proxies/Proxies.tsx
index 101e27e..bed071d 100644
--- a/src/components/proxies/Proxies.tsx
+++ b/src/components/proxies/Proxies.tsx
@@ -1,96 +1,86 @@
import cx from 'clsx';
import * as React from 'react';
-import { useTranslation } from 'react-i18next';
-import Button from '~/components/Button';
-import ContentHeader from '~/components/ContentHeader';
-import { ClosePrevConns } from '~/components/proxies/ClosePrevConns';
+import { ProxiesHeader } from '~/components/proxies/ProxiesHeader';
import { ProxyGroup } from '~/components/proxies/ProxyGroup';
-import { ProxyPageFab } from '~/components/proxies/ProxyPageFab';
import { ProxyProvider } from '~/components/proxies/ProxyProvider';
-import Settings from '~/components/proxies/Settings';
-import BaseModal from '~/components/shared/BaseModal';
-import { TextFilter } from '~/components/shared/TextFitler';
-import { Tooltip } from '~/components/shared/Tooltip';
-import { useStoreActions } from '~/components/StateProvider';
-import Equalizer from '~/components/svg/Equalizer';
-import { useProxiesPage } from '~/modules/proxies/hooks';
-import { formatQty } from '~/modules/proxies/utils';
-import { proxyFilterText } from '~/store/proxies';
-import { DelayMapping, DispatchFn, FormattedProxyProvider, ProxiesMapping } from '~/store/types';
+import {
+ useCollapseAll,
+ useDelayMapping,
+ useProxiesPage,
+ useProxiesQuery,
+ useTestAllLatency,
+ useUpdateProviderItems,
+ useVisibleGroupNames,
+ useVisibleProviders,
+} from '~/modules/proxies/hooks';
+import { ProxiesAppConfig } from '~/modules/proxies/utils';
import { ClashAPIConfig } from '~/types';
import s0 from './Proxies.module.scss';
-type AppConfig = {
- proxySortBy: string;
- hideUnavailableProxies: boolean;
- autoCloseOldConns: boolean;
- proxiesLayout: string;
- proxyGroupByProvider: boolean;
- latencyTestUrl: string;
- latencyTestTimeout: number;
- latencyTestExpectedStatus: string;
- preferBackendLatencyTestUrl: boolean;
- providerHealthcheckTimeout: number;
-};
-
type Props = {
- dispatch: DispatchFn;
- groupNames: string[];
- proxies: ProxiesMapping;
- delay: DelayMapping;
collapsibleIsOpen: Record<string, boolean>;
- proxyProviders: FormattedProxyProvider[];
apiConfig: ClashAPIConfig;
- showModalClosePrevConns: boolean;
- appConfig: AppConfig;
+ appConfig: ProxiesAppConfig;
};
-export default function Proxies({
- dispatch,
- groupNames,
- proxies,
- delay,
- collapsibleIsOpen,
- proxyProviders,
- apiConfig,
- showModalClosePrevConns,
- appConfig,
-}: Props) {
+export default function Proxies({ collapsibleIsOpen, apiConfig, appConfig }: Props) {
// the panel's configured test URL only feeds the latency-color threshold here;
- // the actual URL used per request is resolved in the store thunks
+ // the actual URL used per request is resolved in the mutation hooks
const httpsLatencyTest = appConfig.latencyTestUrl.startsWith('https://');
+
+ const { data, dataUpdatedAt } = useProxiesQuery(apiConfig);
+ const { proxies, groupNames, proxyProviders } = data;
+ const delay = useDelayMapping(proxies, dataUpdatedAt);
+
+ // 搜索同时作用于代理组/提供商本身和它们旗下的节点
+ const visibleGroupNames = useVisibleGroupNames(groupNames, proxies);
+ const visibleProviders = useVisibleProviders(proxyProviders);
+
const {
- isSettingsModalOpen,
- openSettingsModal,
- closeSettingsModal,
+ isSettingsOpen,
+ toggleSettings,
+ closeSettings,
activeTab,
setActiveTab,
- handleTabKeyDown,
proxyGroups,
providers,
} = useProxiesPage({
- dispatch,
- apiConfig,
- groupNames,
- proxyProviders,
+ groupNames: visibleGroupNames,
+ proxyProviders: visibleProviders,
proxiesLayout: appConfig.proxiesLayout,
});
- const {
- proxies: { closeModalClosePrevConns, closePrevConnsAndTheModal },
- } = useStoreActions();
+ const providerNames = React.useMemo(
+ () => proxyProviders.map((item) => item.name),
+ [proxyProviders],
+ );
+ const visibleProviderNames = React.useMemo(
+ () => visibleProviders.map((item) => item.name),
+ [visibleProviders],
+ );
- const { t } = useTranslation();
+ // 折叠是视图操作,只作用于搜索后可见的卡片
+ const [toggleCollapseAll, allCollapsed] = useCollapseAll({
+ prefix: activeTab === 'proxies' ? 'proxyGroup' : 'proxyProvider',
+ names: activeTab === 'proxies' ? visibleGroupNames : visibleProviderNames,
+ collapsibleIsOpen,
+ });
+
+ const [testAll, isTestingLatency] = useTestAllLatency(apiConfig, appConfig);
+ const [updateAllProviders, isUpdatingProviders] = useUpdateProviderItems(
+ apiConfig,
+ providerNames,
+ );
+
+ const containerClassName = cx(s0.groupsContainer, {
+ [s0.doubleColumn]: appConfig.proxiesLayout === 'double',
+ });
const content =
activeTab === 'proxies' ? (
- <div
- className={cx(s0.groupsContainer, {
- [s0.doubleColumn]: appConfig.proxiesLayout === 'double',
- })}
- >
+ <div className={containerClassName}>
{proxyGroups.map((column, i) => (
<div key={i} className={s0.column}>
{column.map(({ name, i: originalIndex }) => (
@@ -99,13 +89,10 @@ export default function Proxies({
name={name}
delay={delay}
apiConfig={apiConfig}
- dispatch={dispatch}
+ appConfig={appConfig}
proxies={proxies}
- hideUnavailableProxies={appConfig.hideUnavailableProxies}
- proxySortBy={appConfig.proxySortBy}
isOpen={Boolean(collapsibleIsOpen[`proxyGroup:${name}`])}
httpsLatencyTest={httpsLatencyTest}
- proxyGroupByProvider={appConfig.proxyGroupByProvider}
/>
</div>
))}
@@ -113,11 +100,7 @@ export default function Proxies({
))}
</div>
) : (
- <div
- className={cx(s0.groupsContainer, {
- [s0.doubleColumn]: appConfig.proxiesLayout === 'double',
- })}
- >
+ <div className={containerClassName}>
{providers.map((column, i) => (
<div key={i} className={s0.column}>
{column.map(({ item, i: originalIndex }) => (
@@ -132,11 +115,9 @@ export default function Proxies({
proxyMapping={proxies}
httpsLatencyTest={httpsLatencyTest}
delay={delay}
- hideUnavailableProxies={appConfig.hideUnavailableProxies}
- proxySortBy={appConfig.proxySortBy}
isOpen={Boolean(collapsibleIsOpen[`proxyProvider:${item.name}`])}
- dispatch={dispatch}
apiConfig={apiConfig}
+ appConfig={appConfig}
/>
</div>
))}
@@ -146,58 +127,25 @@ export default function Proxies({
);
return (
- <>
- <BaseModal isOpen={isSettingsModalOpen} onRequestClose={closeSettingsModal}>
- <Settings appConfig={appConfig} />
- </BaseModal>
- <div className={s0.topBar}>
- <ContentHeader>
- <div className={s0.tabsContainer}>
- <div
- className={cx(s0.tab, { [s0.active]: activeTab === 'proxies' })}
- onClick={() => setActiveTab('proxies')}
- onKeyDown={handleTabKeyDown('proxies')}
- role="button"
- tabIndex={0}
- >
- {t('Proxies')}
- <span className={s0.tabCount}>{formatQty(groupNames.length)}</span>
- </div>
- {proxyProviders.length > 0 && (
- <div
- className={cx(s0.tab, { [s0.active]: activeTab === 'providers' })}
- onClick={() => setActiveTab('providers')}
- onKeyDown={handleTabKeyDown('providers')}
- role="button"
- tabIndex={0}
- >
- {t('proxy_provider')}
- <span className={s0.tabCount}>{formatQty(proxyProviders.length)}</span>
- </div>
- )}
- </div>
- <div style={{ flex: 1 }} />
- <div className={s0.topBarRight}>
- <div className={s0.textFilterContainer}>
- <TextFilter textAtom={proxyFilterText} placeholder={t('Search')} />
- </div>
- <Tooltip label={t('settings')}>
- <Button kind="minimal" onClick={openSettingsModal}>
- <Equalizer size={16} />
- </Button>
- </Tooltip>
- </div>
- </ContentHeader>
- </div>
+ <div className={s0.page}>
+ <ProxiesHeader
+ activeTab={activeTab}
+ setActiveTab={setActiveTab}
+ groupCount={visibleGroupNames.length}
+ providerCount={proxyProviders.length}
+ visibleProviderCount={visibleProviders.length}
+ appConfig={appConfig}
+ isSettingsOpen={isSettingsOpen}
+ toggleSettings={toggleSettings}
+ closeSettings={closeSettings}
+ onToggleCollapseAll={toggleCollapseAll}
+ allCollapsed={allCollapsed}
+ onTestAll={testAll}
+ isTestingLatency={isTestingLatency}
+ onUpdateAllProviders={updateAllProviders}
+ isUpdatingProviders={isUpdatingProviders}
+ />
{content}
- <div style={{ height: 60 }} />
- <ProxyPageFab dispatch={dispatch} apiConfig={apiConfig} proxyProviders={proxyProviders} />
- <BaseModal isOpen={showModalClosePrevConns} onRequestClose={closeModalClosePrevConns}>
- <ClosePrevConns
- onClickPrimaryButton={() => closePrevConnsAndTheModal(apiConfig)}
- onClickSecondaryButton={closeModalClosePrevConns}
- />
- </BaseModal>
- </>
+ </div>
);
}
diff --git a/src/components/proxies/ProxiesHeader.module.scss b/src/components/proxies/ProxiesHeader.module.scss
new file mode 100644
index 0000000..22c1006
--- /dev/null
+++ b/src/components/proxies/ProxiesHeader.module.scss
@@ -0,0 +1,22 @@
+/**
+ * 只放代理页特有的东西,顶栏本身的样式在 shared/PageHeader.module.scss。
+ *
+ * DOM 顺序就是桌面端的视觉顺序(标题 · 标签 · 搜索 · 操作),
+ * 窄屏要把操作钮提到第一行、搜索独占第二行,所以这里只拨 order。
+ * 代理页的操作钮不多,标题 + 标签 + 操作钮一行放得下;不这么排的话
+ * 搜索会先换行并吃掉整行(它的 flex-basis 是 100%),把操作钮挤成第三行。
+ *
+ * 换行本身不用额外的占位元素——搜索的 flex-basis: 100% 已经保证了它独占一行。
+ */
+
+.actions {
+ @media (max-width: 1024px) {
+ order: 1;
+ }
+}
+
+.search {
+ @media (max-width: 1024px) {
+ order: 2;
+ }
+}
diff --git a/src/components/proxies/ProxiesHeader.tsx b/src/components/proxies/ProxiesHeader.tsx
new file mode 100644
index 0000000..3915d85
--- /dev/null
+++ b/src/components/proxies/ProxiesHeader.tsx
@@ -0,0 +1,136 @@
+import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { ChevronDown, ChevronUp, Sliders, Zap } from '~/components/shared/FeatherIcons';
+import {
+ HeaderActions,
+ HeaderButton,
+ HeaderIconButton,
+ HeaderSearch,
+ HeaderTab,
+ HeaderTabs,
+ HeaderTitle,
+ PageHeader,
+} from '~/components/shared/PageHeader';
+import { Popover } from '~/components/shared/Popover';
+import { RotateIcon } from '~/components/shared/RotateIcon';
+import { TextFilter } from '~/components/shared/TextFilter';
+import { proxyFilterText } from '~/store/proxies';
+
+import s from './ProxiesHeader.module.scss';
+import Settings from './Settings';
+
+type AppConfig = React.ComponentProps<typeof Settings>['appConfig'];
+
+type TabKey = 'proxies' | 'providers';
+
+type Props = {
+ activeTab: TabKey;
+ setActiveTab: (tab: TabKey) => void;
+ /** 搜索后可见的代理组数 */
+ groupCount: number;
+ /** 提供商总数,决定标签是否出现(搜索时标签不该消失) */
+ providerCount: number;
+ /** 搜索后可见的提供商数,只影响标签上的计数 */
+ visibleProviderCount: number;
+ appConfig: AppConfig;
+ isSettingsOpen: boolean;
+ toggleSettings: () => void;
+ closeSettings: () => void;
+ onToggleCollapseAll: () => void;
+ allCollapsed: boolean;
+ onTestAll: () => void;
+ isTestingLatency: boolean;
+ onUpdateAllProviders: () => void;
+ isUpdatingProviders: boolean;
+};
+
+export function ProxiesHeader({
+ activeTab,
+ setActiveTab,
+ groupCount,
+ providerCount,
+ visibleProviderCount,
+ appConfig,
+ isSettingsOpen,
+ toggleSettings,
+ closeSettings,
+ onToggleCollapseAll,
+ allCollapsed,
+ onTestAll,
+ isTestingLatency,
+ onUpdateAllProviders,
+ isUpdatingProviders,
+}: Props) {
+ const { t } = useTranslation();
+
+ return (
+ <PageHeader>
+ <HeaderTitle>{t('Proxies')}</HeaderTitle>
+
+ <HeaderTabs label={t('Proxies')}>
+ <HeaderTab
+ active={activeTab === 'proxies'}
+ label={t('proxy_groups')}
+ count={groupCount}
+ onClick={() => setActiveTab('proxies')}
+ />
+ {providerCount > 0 ? (
+ <HeaderTab
+ active={activeTab === 'providers'}
+ label={t('providers')}
+ count={visibleProviderCount}
+ onClick={() => setActiveTab('providers')}
+ />
+ ) : null}
+ </HeaderTabs>
+
+ <HeaderSearch className={s.search}>
+ <TextFilter textAtom={proxyFilterText} placeholder={t('search_proxies_placeholder')} />
+ </HeaderSearch>
+
+ <HeaderActions className={s.actions}>
+ {activeTab === 'providers' && providerCount > 0 ? (
+ <HeaderButton
+ icon={<RotateIcon isRotating={isUpdatingProviders} />}
+ label={t('update_all_proxy_provider')}
+ hideLabelAt="md"
+ onClick={onUpdateAllProviders}
+ />
+ ) : null}
+
+ <HeaderButton
+ icon={allCollapsed ? <ChevronDown size={17} /> : <ChevronUp size={17} />}
+ label={allCollapsed ? t('expand_all') : t('collapse_all')}
+ onClick={onToggleCollapseAll}
+ />
+
+ <HeaderButton
+ variant="primary"
+ icon={<Zap size={16} />}
+ label={isTestingLatency ? t('testing') : t('test_all')}
+ busy={isTestingLatency}
+ disabled={isTestingLatency}
+ onClick={onTestAll}
+ />
+
+ <Popover
+ isOpen={isSettingsOpen}
+ onClose={closeSettings}
+ label={t('settings')}
+ trigger={
+ <HeaderIconButton
+ icon={<Sliders size={17} />}
+ label={t('settings')}
+ active={isSettingsOpen}
+ expanded={isSettingsOpen}
+ onClick={toggleSettings}
+ />
+ }
+ >
+ <Settings appConfig={appConfig} />
+ </Popover>
+ </HeaderActions>
+ </PageHeader>
+ );
+}
diff --git a/src/components/proxies/Proxy.module.scss b/src/components/proxies/Proxy.module.scss
index 066026f..3ae9511 100644
--- a/src/components/proxies/Proxy.module.scss
+++ b/src/components/proxies/Proxy.module.scss
@@ -1,125 +1,100 @@
-@use '~/styles/utils/custom-media' as *;
-
.proxy {
- padding: 5px;
+ padding: 9px 11px;
position: relative;
- border-radius: var(--border-radius);
- overflow: hidden;
- box-shadow: var(--shadow-card);
- transition: transform 0.2s ease, box-shadow 0.2s ease;
-
display: flex;
flex-direction: column;
- justify-content: space-between;
-
- border: 1px solid var(--color-proxy-border);
+ gap: 5px;
+ min-width: 0;
+ border-radius: 10px;
+ border: 1px solid var(--color-card-border);
+ background-color: var(--color-card);
+ color: var(--color-text);
+ transition:
+ border-color 0.15s ease,
+ background-color 0.15s ease,
+ box-shadow 0.15s ease;
- &:focus {
+ &:focus-visible {
+ outline: none;
border-color: var(--color-focus-blue);
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.4);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
}
- &:hover {
- box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
- z-index: 1;
- }
-
- @media (--breakpoint-not-small) {
- border-radius: var(--border-radius);
- padding: 10px;
- }
-
- background-color: var(--color-bg-proxy);
- color: var(--color-text);
-
&.now {
- background-color: var(--color-sb-active-row-bg);
- color: var(--color-sb-active-row-font);
+ background-color: var(--color-node-active-bg);
border-color: var(--color-focus-blue);
+ box-shadow: inset 0 0 0 1px var(--color-focus-blue);
}
&.error {
opacity: 0.5;
}
+
&.selectable {
- transition: transform 0.2s ease-in-out;
cursor: pointer;
+
&:hover {
- border-color: var(--card-hover-border-lightness);
+ border-color: var(--color-focus-blue);
}
}
}
-.proxyType {
- font-family: var(--font-mono);
- font-size: 0.6em;
- @media (--breakpoint-not-small) {
- font-size: 0.7em;
- }
- color: #f596aa;
- opacity: 0.6;
-
- .now & {
- color: inherit;
- opacity: 0.8;
- }
-}
-.udpType {
- font-family: var(--font-mono);
- font-size: 0.6em;
- @media (--breakpoint-not-small) {
- font-size: 0.7em;
- }
- color: #51a8dd;
- opacity: 0.6;
-
- .now & {
- color: inherit;
- opacity: 0.8;
- }
-}
-.tfoType {
- padding: 2px;
-}
-.row {
+.topRow,
+.bottomRow {
display: flex;
align-items: center;
- height: auto;
- font-weight: 400;
justify-content: space-between;
+ gap: 8px;
+ min-width: 0;
}
.proxyName {
- width: 100%;
- margin-bottom: 5px;
- font-size: 0.75em;
- @media (--breakpoint-not-small) {
- font-size: 0.85em;
- }
+ font-size: 0.82rem;
+ font-weight: 500;
+ color: var(--color-text-highlight);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
}
+.udpBadge {
+ flex-shrink: 0;
+ font-family: var(--font-mono);
+ font-size: 0.6rem;
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: 0.02em;
+ padding: 3px 5px;
+ border-radius: 5px;
+ background: var(--color-badge-bg);
+ color: var(--color-badge-fg);
+}
+
+.proxyType {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+/* ---------- 折叠态圆点 ---------- */
+
.proxySmall {
- position: relative;
- width: 15px;
- height: 15px;
+ width: 12px;
+ height: 12px;
border-radius: 50%;
-
- .now {
- position: absolute;
- width: 9px;
- height: 9px;
- margin: auto;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- border-radius: 50%;
- background-color: rgb(255, 253, 253);
- }
+ flex-shrink: 0;
&.selectable {
- transition: transform 0.1s ease-in-out;
cursor: pointer;
+ transition: transform 0.1s ease-in-out;
+
&:hover {
transform: scale(1.5);
}
diff --git a/src/components/proxies/Proxy.tsx b/src/components/proxies/Proxy.tsx
index dd5a38c..ac140fe 100644
--- a/src/components/proxies/Proxy.tsx
+++ b/src/components/proxies/Proxy.tsx
@@ -1,12 +1,8 @@
import cx from 'clsx';
import * as React from 'react';
-import { Tooltip } from '~/components/shared/Tooltip';
import { keyCodes } from '~/misc/keycode';
-import { DispatchFn, ProxyItem } from '~/store/types';
-import { ClashAPIConfig } from '~/types';
-
-import { healthcheckProxy } from '../../store/proxies';
+import { ProxyItem } from '~/store/types';
import s0 from './Proxy.module.scss';
import { ProxyLatency } from './ProxyLatency';
@@ -20,7 +16,6 @@ const colorMap = {
normal: '#d4b75c',
// orange
bad: '#e67f3c',
- // bad: '#F56C6C',
na: '#909399',
};
@@ -30,47 +25,37 @@ function getLabelColor(
}: {
number?: number;
} = {},
- httpsTest: boolean
+ httpsTest: boolean,
) {
const delayMap = {
good: httpsTest ? 800 : 200,
normal: httpsTest ? 1500 : 500,
};
- if (number === 0) {
+ // 没测过(undefined)和测出 0 都算不可用;原本靠 undefined 参与比较恒为 false 落到最后一行
+ if (typeof number !== 'number' || number === 0) {
return colorMap.na;
- } else if (number < delayMap.good) {
+ }
+ if (number < delayMap.good) {
return colorMap.good;
- } else if (number < delayMap.normal) {
+ }
+ if (number < delayMap.normal) {
return colorMap.normal;
- } else if (typeof number === 'number') {
- return colorMap.bad;
}
- return colorMap.na;
-}
-
-function getProxyDotBackgroundColor(
- latency: {
- number?: number;
- },
- // proxyType: string,
- httpsTest: boolean
-) {
- // if (NonProxyTypes.indexOf(proxyType) > -1) {
- // return 'linear-gradient(135deg, white 15%, #999 15% 30%, white 30% 45%, #999 45% 60%, white 60% 75%, #999 75% 90%, white 90% 100%)';
- // }
- return getLabelColor(latency, httpsTest);
+ return colorMap.bad;
}
-type ProxyProps = {
+type ProxyBaseProps = {
name: string;
now?: boolean;
proxy: ProxyItem;
- latency: { number?: number; error?: string; testing?: boolean };
+ latency?: { number?: number; error?: string; testing?: boolean };
httpsLatencyTest: boolean;
isSelectable?: boolean;
onClick?: (proxyName: string) => unknown;
- apiConfig: ClashAPIConfig;
- dispatch: DispatchFn;
+};
+
+type ProxyProps = ProxyBaseProps & {
+ onTestLatency: (name: string, providerName?: string) => void;
};
export const ProxySmall = memo(function ProxySmall({
@@ -81,12 +66,12 @@ export const ProxySmall = memo(function ProxySmall({
httpsLatencyTest,
isSelectable,
onClick,
-}: ProxyProps) {
+}: ProxyBaseProps) {
const delay = proxy.history[proxy.history.length - 1]?.delay;
const latencyNumber = latency?.number ?? delay;
const color = useMemo(
- () => getProxyDotBackgroundColor({ number: latencyNumber }, httpsLatencyTest),
- [latencyNumber, httpsLatencyTest]
+ () => getLabelColor({ number: latencyNumber }, httpsLatencyTest),
+ [latencyNumber, httpsLatencyTest],
);
const title = useMemo(() => {
@@ -107,22 +92,28 @@ export const ProxySmall = memo(function ProxySmall({
doSelect();
}
},
- [doSelect]
+ [doSelect],
);
+ // 当前选中的节点画成空心圆:用单个元素的 inset 阴影描边,
+ // 避免再叠一个绝对定位的内圆——两个盒子各自取整会让圆心看起来偏掉
+ const dotStyle = now
+ ? { background: 'var(--color-card)', boxShadow: `inset 0 0 0 3px ${color}` }
+ : { background: color };
+
return (
+ // role 是条件表达式,oxlint 静态分析不出来
+ // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
<div
title={title}
className={cx(s0.proxySmall, {
[s0.selectable]: isSelectable,
})}
- style={{ background: color, scale: now ? '1.2' : '1' }}
+ style={dotStyle}
onClick={doSelect}
onKeyDown={handleKeyDown}
role={isSelectable ? 'menuitem' : ''}
- >
- {now && <div className={s0.now} />}
- </div>
+ />
);
});
@@ -131,6 +122,29 @@ function formatProxyType(t: string) {
return t;
}
+function formatUdpType(udp: boolean, xudp?: boolean) {
+ if (!udp) return '';
+ return xudp ? 'XUDP' : 'UDP';
+}
+
+function TfoIcon() {
+ return (
+ <svg
+ viewBox="0 0 1024 1024"
+ version="1.1"
+ xmlns="http://www.w3.org/2000/svg"
+ width="10"
+ height="10"
+ aria-label="TFO"
+ >
+ <path
+ fill="currentColor"
+ d="M648.093513 719.209284l-1.492609-40.940127 31.046263-26.739021c202.73892-174.805813 284.022131-385.860697 255.70521-561.306199-176.938111-28.786027-389.698834 51.857494-563.907604 254.511123l-26.31256 30.619803-40.38573-0.938211c-60.557271-1.407317-111.903014 12.79379-162.822297 47.0385l189.561318 127.084977-37.95491 68.489421c-9.126237 16.461343-0.554398 53.307457 29.084549 82.818465 29.5963 29.511008 67.380626 38.381369 83.287571 29.852176l68.318836-36.760822 127.639376 191.267156c36.163779-52.11337 50.450177-103.629696 48.189941-165.039887zM994.336107 16.105249l10.490908 2.686696 2.64405 10.405615c47.46496 178.089552-1.023503 451.492838-274.170913 686.898568 4.051367 111.263324-35.396151 200.222809-127.255561 291.741051l-15.779008 15.693715-145.934494-218.731157c-51.217805 27.59194-128.790816 10.405616-183.93205-44.522388-55.226525-55.013296-72.41285-132.287785-43.498885-184.529093L0.002773 430.325513l15.736362-15.65107c89.300652-88.959484 178.64395-128.108481 289.011709-125.549722C539.730114 15.806727 815.56422-31.061189 994.336107 16.105249zM214.93844 805.098259c28.572797 28.572797 22.346486 79.49208-12.537914 114.376479C156.428175 965.489735 34.034254 986.002445 34.034254 986.002445s25.331704-127.084978 66.612998-168.323627c34.8844-34.8844 85.633099-41.281295 114.291188-12.580559zM661.01524 298.549479a63.968948 63.968948 0 1 0 0 127.937897 63.968948 63.968948 0 0 0 0-127.937897z"
+ />
+ </svg>
+ );
+}
+
export const Proxy = memo(function Proxy({
now,
name,
@@ -139,56 +153,35 @@ export const Proxy = memo(function Proxy({
httpsLatencyTest,
isSelectable,
onClick,
- apiConfig,
- dispatch,
+ onTestLatency,
}: ProxyProps) {
const delay = proxy.history[proxy.history.length - 1]?.delay;
const latencyNumber =
typeof latency?.number === 'number'
? latency.number
: typeof delay === 'number'
- ? delay
- : undefined;
+ ? delay
+ : undefined;
const hasLatencyNumber = typeof latencyNumber === 'number' && latencyNumber > 0;
const color = useMemo(
() => getLabelColor({ number: hasLatencyNumber ? latencyNumber : undefined }, httpsLatencyTest),
- [hasLatencyNumber, latencyNumber, httpsLatencyTest]
+ [hasLatencyNumber, latencyNumber, httpsLatencyTest],
);
const isTestingLatency = Boolean(latency?.testing);
const doSelect = React.useCallback(() => {
isSelectable && onClick && onClick(name);
}, [name, onClick, isSelectable]);
- function formatUdpType(udp: boolean, xudp?: boolean) {
- if (!udp) return '';
- return xudp ? 'XUDP' : 'UDP';
- }
- function formatTfo(t: boolean) {
- if (!t) return '';
- return (
- <svg
- viewBox="0 0 1024 1024"
- version="1.1"
- xmlns="http://www.w3.org/2000/svg"
- p-id="2962"
- width="10"
- height="10"
- >
- <path
- d="M648.093513 719.209284l-1.492609-40.940127 31.046263-26.739021c202.73892-174.805813 284.022131-385.860697 255.70521-561.306199-176.938111-28.786027-389.698834 51.857494-563.907604 254.511123l-26.31256 30.619803-40.38573-0.938211c-60.557271-1.407317-111.903014 12.79379-162.822297 47.0385l189.561318 127.084977-37.95491 68.489421c-9.126237 16.461343-0.554398 53.307457 29.084549 82.818465 29.5963 29.511008 67.380626 38.381369 83.287571 29.852176l68.318836-36.760822 127.639376 191.267156c36.163779-52.11337 50.450177-103.629696 48.189941-165.039887zM994.336107 16.105249l10.490908 2.686696 2.64405 10.405615c47.46496 178.089552-1.023503 451.492838-274.170913 686.898568 4.051367 111.263324-35.396151 200.222809-127.255561 291.741051l-15.779008 15.693715-145.934494-218.731157c-51.217805 27.59194-128.790816 10.405616-183.93205-44.522388-55.226525-55.013296-72.41285-132.287785-43.498885-184.529093L0.002773 430.325513l15.736362-15.65107c89.300652-88.959484 178.64395-128.108481 289.011709-125.549722C539.730114 15.806727 815.56422-31.061189 994.336107 16.105249zM214.93844 805.098259c28.572797 28.572797 22.346486 79.49208-12.537914 114.376479C156.428175 965.489735 34.034254 986.002445 34.034254 986.002445s25.331704-127.084978 66.612998-168.323627c34.8844-34.8844 85.633099-41.281295 114.291188-12.580559zM661.01524 298.549479a63.968948 63.968948 0 1 0 0 127.937897 63.968948 63.968948 0 0 0 0-127.937897z"
- p-id="2963"
- />
- </svg>
- );
- }
+
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.keyCode === keyCodes.Enter) {
doSelect();
}
},
- [doSelect]
+ [doSelect],
);
+
const className = useMemo(() => {
return cx(s0.proxy, {
[s0.now]: now,
@@ -199,10 +192,14 @@ export const Proxy = memo(function Proxy({
const runLatencyTest = React.useCallback(() => {
if (isTestingLatency) return;
- dispatch(healthcheckProxy(apiConfig, name));
- }, [apiConfig, dispatch, isTestingLatency, name]);
+ onTestLatency(name, proxy.providerName);
+ }, [onTestLatency, isTestingLatency, name, proxy.providerName]);
+
+ const udpLabel = formatUdpType(proxy.udp, proxy.xudp);
return (
+ // role 是条件表达式,oxlint 静态分析不出来
+ // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
<div
tabIndex={0}
className={className}
@@ -210,23 +207,18 @@ export const Proxy = memo(function Proxy({
onKeyDown={handleKeyDown}
role={isSelectable ? 'menuitem' : ''}
>
- <div className={cx(s0.proxyName, s0.row)}>
- <Tooltip label={name} aria-label={`proxy name: ${name}`}>
- <span>{name}</span>
- </Tooltip>
- <span className={s0.udpType} style={{ paddingLeft: 4 }}>
- {formatUdpType(proxy.udp, proxy.xudp)}
+ <div className={s0.topRow}>
+ <span className={s0.proxyName} title={name}>
+ {name}
</span>
+ {udpLabel ? <span className={s0.udpBadge}>{udpLabel}</span> : null}
</div>
- <div className={s0.row}>
- <div className={s0.row}>
- <span className={s0.proxyType} style={{ paddingRight: 4 }}>
- {formatProxyType(proxy.type)}
- </span>
-
- {formatTfo(proxy.tfo)}
- </div>
+ <div className={s0.bottomRow}>
+ <span className={s0.proxyType}>
+ {formatProxyType(proxy.type)}
+ {proxy.tfo ? <TfoIcon /> : null}
+ </span>
<ProxyLatency
number={hasLatencyNumber ? latencyNumber : undefined}
diff --git a/src/components/proxies/ProxyCard.module.scss b/src/components/proxies/ProxyCard.module.scss
new file mode 100644
index 0000000..b6a956a
--- /dev/null
+++ b/src/components/proxies/ProxyCard.module.scss
@@ -0,0 +1,232 @@
+.card {
+ padding: 12px 14px;
+ background-color: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ border-radius: 14px;
+ box-shadow: var(--shadow-card);
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+/* ---------- 头部 ---------- */
+
+.cardHeader {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ cursor: pointer;
+ user-select: none;
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: 4px;
+ border-radius: 6px;
+ }
+
+ // 未 hover 时测速按钮隐形但占位,避免布局跳动
+ &:hover .iconAction,
+ &:focus-within .iconAction {
+ opacity: 1;
+ pointer-events: auto;
+ }
+}
+
+.name {
+ font-size: 0.98rem;
+ font-weight: 600;
+ color: var(--color-text-highlight);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.typeBadge {
+ flex-shrink: 0;
+ font-size: 0.68rem;
+ font-weight: 500;
+ line-height: 1;
+ padding: 4px 7px;
+ border-radius: 6px;
+ background: var(--color-badge-bg);
+ color: var(--color-badge-fg);
+}
+
+.fixedBadge {
+ flex-shrink: 0;
+ font-size: 0.68rem;
+ font-weight: 500;
+ line-height: 1;
+ padding: 4px 7px;
+ border-radius: 6px;
+ background: var(--color-accent-soft-bg);
+ color: var(--color-accent-soft-fg);
+ cursor: help;
+}
+
+.headerSpacer {
+ flex: 1;
+ min-width: 8px;
+}
+
+.headerActions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.iconAction {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ appearance: none;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: none;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ // 隐藏时同时关掉命中区域,避免误点到看不见的按钮
+ opacity: 0;
+ pointer-events: none;
+ transition:
+ opacity 0.15s ease,
+ background-color 0.15s ease,
+ color 0.15s ease;
+
+ &:hover {
+ background: var(--color-hover-soft);
+ color: var(--color-focus-blue);
+ }
+
+ &:focus-visible {
+ opacity: 1;
+ pointer-events: auto;
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: 1px;
+ }
+
+ // 触屏设备没有 hover,常驻显示
+ @media (hover: none) {
+ opacity: 1;
+ pointer-events: auto;
+ }
+}
+
+.iconActionBusy {
+ opacity: 1;
+ pointer-events: auto;
+ color: var(--color-focus-blue);
+ animation: zapPulse 1s ease-in-out infinite;
+}
+
+@keyframes zapPulse {
+ 0%,
+ 100% {
+ opacity: 0.5;
+ }
+ 50% {
+ opacity: 1;
+ }
+}
+
+.headerLatency {
+ flex-shrink: 0;
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.chevron {
+ display: inline-flex;
+ flex-shrink: 0;
+ color: var(--color-text-secondary);
+ transform: rotate(0deg);
+ transition: transform 0.3s ease;
+}
+
+.chevronOpen {
+ transform: rotate(180deg);
+}
+
+/* ---------- 状态行(当前节点 + 圆点) ---------- */
+
+.statusRow {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 8px;
+ // 至少要能放下 hover 放大后的圆点(12px × 1.5)
+ min-height: 22px;
+}
+
+.nowName {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex: 0 1 auto;
+ min-width: 0;
+ font-size: 0.8rem;
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.nowDot {
+ flex-shrink: 0;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+}
+
+/* 圆点插槽靠右(左侧没有当前节点时改为靠左),宽度由 flex 决定而不是内容,
+ 测量它来决定圆点还是进度条 */
+.dotsSlot {
+ flex: 1 1 auto;
+ min-width: 0;
+ // 撑满状态行高度、右侧留出 padding,这样 hover 放大的圆点不会被 overflow 裁掉;
+ // padding 不影响测量(用的是 content box 宽度)
+ align-self: stretch;
+ padding-right: 3px;
+ margin-right: -3px;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.dotsSlotStart {
+ justify-content: flex-start;
+ padding-right: 0;
+ margin-right: 0;
+ padding-left: 3px;
+ margin-left: -3px;
+}
+
+.availBar {
+ width: 100%;
+ max-width: 220px;
+ display: flex;
+ align-items: center;
+}
+
+.availBarTrack {
+ flex: 1;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--color-track);
+ overflow: hidden;
+}
+
+.availBarFill {
+ height: 100%;
+ border-radius: 3px;
+ background: #67c23a;
+ transition: width 0.4s ease;
+}
diff --git a/src/components/proxies/ProxyCard.tsx b/src/components/proxies/ProxyCard.tsx
new file mode 100644
index 0000000..03e7a4b
--- /dev/null
+++ b/src/components/proxies/ProxyCard.tsx
@@ -0,0 +1,214 @@
+import cx from 'clsx';
+import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { ChevronDown, Zap } from '~/components/shared/FeatherIcons';
+import { DelayMapping } from '~/store/types';
+
+import s from './ProxyCard.module.scss';
+
+const { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } = React;
+
+// 折叠态圆点的排布节奏:圆点 12px + 间距 5px
+const DOT_PITCH = 17;
+
+export function getLatencyColor(number: number | undefined, httpsTest: boolean): string {
+ if (!number || number === 0) return '#909399';
+ const good = httpsTest ? 800 : 200;
+ const normal = httpsTest ? 1500 : 500;
+ if (number < good) return '#67c23a';
+ if (number < normal) return '#d4b75c';
+ return '#e67f3c';
+}
+
+function countAvailableProxies(names: string[], delay: DelayMapping): number {
+ return names.filter((name) => {
+ const d = delay[name];
+ return d && typeof d.number === 'number' && d.number > 0;
+ }).length;
+}
+
+/** 代理组 / 提供商卡片的外壳 */
+export function ProxyCard({ children }: { children: React.ReactNode }) {
+ return <div className={s.card}>{children}</div>;
+}
+
+/** 卡片头部:名称 + 类型徽章 + 右侧延迟 / 测速 / 折叠箭头。整行点击折叠。 */
+export function ProxyCardHeader({
+ name,
+ type,
+ isOpen,
+ toggle,
+ badges,
+ latency,
+ latencyColor,
+ onTest,
+ isTesting,
+ extraActions,
+}: {
+ name: string;
+ type: string;
+ isOpen: boolean;
+ toggle: () => void;
+ badges?: React.ReactNode;
+ latency?: number;
+ latencyColor?: string;
+ onTest?: () => void;
+ isTesting?: boolean;
+ extraActions?: React.ReactNode;
+}) {
+ const { t } = useTranslation();
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggle();
+ }
+ },
+ [toggle],
+ );
+
+ return (
+ <div
+ className={s.cardHeader}
+ onClick={toggle}
+ onKeyDown={handleKeyDown}
+ role="button"
+ tabIndex={0}
+ aria-expanded={isOpen}
+ >
+ <span className={s.name} title={name}>
+ {name}
+ </span>
+ {type ? <span className={s.typeBadge}>{type}</span> : null}
+ {badges}
+ <span className={s.headerSpacer} />
+ {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
+ <div className={s.headerActions} onClick={(e) => e.stopPropagation()}>
+ {extraActions}
+ {onTest ? (
+ <button
+ type="button"
+ className={cx(s.iconAction, { [s.iconActionBusy]: isTesting })}
+ onClick={onTest}
+ title={t('Test Latency')}
+ aria-label={t('Test Latency')}
+ disabled={isTesting}
+ >
+ <Zap size={15} />
+ </button>
+ ) : null}
+ </div>
+ {typeof latency === 'number' && latency > 0 ? (
+ <span className={s.headerLatency} style={{ color: latencyColor }}>
+ {latency} ms
+ </span>
+ ) : null}
+ <span className={cx(s.chevron, { [s.chevronOpen]: isOpen })} aria-hidden>
+ <ChevronDown size={18} />
+ </span>
+ </div>
+ );
+}
+
+/** 头部下方的可点击图标按钮(提供商卡片的「更新」等) */
+export function ProxyCardAction({
+ onClick,
+ title,
+ children,
+ isBusy,
+}: {
+ onClick: () => void;
+ title: string;
+ children: React.ReactNode;
+ isBusy?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ className={cx(s.iconAction, { [s.iconActionBusy]: isBusy })}
+ onClick={onClick}
+ title={title}
+ aria-label={title}
+ >
+ {children}
+ </button>
+ );
+}
+
+const AvailabilityBar = memo(function AvailabilityBar({
+ all,
+ delay,
+}: {
+ all: string[];
+ delay: DelayMapping;
+}) {
+ const total = all.length;
+ const available = useMemo(() => countAvailableProxies(all, delay), [all, delay]);
+ const pct = total > 0 ? Math.round((available / total) * 100) : 0;
+
+ return (
+ <div className={s.availBar} title={`${available}/${total}`}>
+ <div className={s.availBarTrack}>
+ <div className={s.availBarFill} style={{ width: `${pct}%` }} />
+ </div>
+ </div>
+ );
+});
+
+/**
+ * 状态行:左侧当前节点,右侧节点圆点(没有当前节点时圆点靠左)。圆点插槽的宽度由 flex 决定而不是内容,
+ * 所以可以直接测量它来判断一行放不放得下——放不下就退化成可用率进度条。
+ */
+export function ProxyCardStatusRow({
+ nowName,
+ nowColor,
+ itemCount,
+ allItems,
+ delay,
+ renderDots,
+}: {
+ nowName?: string | null;
+ nowColor?: string;
+ /** 参与圆点渲染的节点数(过滤后) */
+ itemCount: number;
+ /** 计算可用率用的完整节点列表 */
+ allItems: string[];
+ delay: DelayMapping;
+ renderDots: () => React.ReactNode;
+}) {
+ const dotsSlotRef = useRef<HTMLDivElement>(null);
+ const [slotWidth, setSlotWidth] = useState(0);
+
+ useLayoutEffect(() => {
+ const el = dotsSlotRef.current;
+ if (!el) return;
+ // sync read before first paint to avoid flash — content box, to match
+ // the ResizeObserver's contentRect below (the slot has horizontal padding)
+ const cs = getComputedStyle(el);
+ const w = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
+ if (w > 0) setSlotWidth(w);
+ const ro = new ResizeObserver((entries) => {
+ setSlotWidth(entries[0].contentRect.width);
+ });
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ const dotsPerRow = slotWidth > 0 ? Math.floor(slotWidth / DOT_PITCH) : Infinity;
+ const showBar = itemCount > dotsPerRow;
+
+ return (
+ <div className={s.statusRow}>
+ {nowName ? (
+ <span className={s.nowName} title={nowName}>
+ <i className={s.nowDot} style={{ background: nowColor }} aria-hidden />
+ {nowName}
+ </span>
+ ) : null}
+ <div className={cx(s.dotsSlot, { [s.dotsSlotStart]: !nowName })} ref={dotsSlotRef}>
+ {showBar ? <AvailabilityBar all={allItems} delay={delay} /> : renderDots()}
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/proxies/ProxyGroup.module.scss b/src/components/proxies/ProxyGroup.module.scss
deleted file mode 100644
index 19529cf..0000000
--- a/src/components/proxies/ProxyGroup.module.scss
+++ /dev/null
@@ -1,101 +0,0 @@
-.header {
- margin-bottom: 12px;
-}
-
-.group {
- padding: 10px;
- background-color: var(--bg-log-info-card);
- border: 1px solid var(--color-separator);
- border-radius: 12px;
- box-shadow: var(--shadow-card);
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
-}
-
-.zapWrapper {
- width: 20px;
- height: 20px;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.arrow {
- display: inline-flex;
- transform: rotate(0deg);
- transition: transform 0.3s;
-
- &.isOpen {
- transform: rotate(180deg);
- }
-
- &:focus {
- outline: var(--color-focus-blue) solid 1px;
- }
-}
-
-.groupHeader {
- display: flex;
- align-items: center;
- justify-content: space-between;
- user-select: none;
-
- .btnGroup {
- display: flex;
- flex-direction: row-reverse;
- }
-
- @media screen and (min-width: 768px) {
- justify-content: flex-start;
- .btnGroup {
- flex-direction: row;
- }
- }
-}
-
-.nowRow {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 2px 5px 0;
- min-width: 0;
-}
-
-.nowName {
- font-size: 0.8em;
- color: var(--color-text-secondary);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- min-width: 0;
- flex: 1;
-}
-
-.nowLatency {
- font-size: 0.75em;
- font-family: var(--font-mono);
- flex-shrink: 0;
- margin-left: 8px;
-}
-
-.availBar {
- margin: 8px 0;
- height: 15px;
- padding: 0 10px;
- display: flex;
- align-items: center;
-}
-
-.availBarTrack {
- flex: 1;
- height: 6px;
- border-radius: 3px;
- background: var(--color-separator);
- overflow: hidden;
-}
-
-.availBarFill {
- height: 100%;
- border-radius: 3px;
- background: #67c23a;
- transition: width 0.4s ease;
-}
diff --git a/src/components/proxies/ProxyGroup.tsx b/src/components/proxies/ProxyGroup.tsx
index d838685..f8be26d 100644
--- a/src/components/proxies/ProxyGroup.tsx
+++ b/src/components/proxies/ProxyGroup.tsx
@@ -1,25 +1,26 @@
-import { useSuspenseQuery } from '@tanstack/react-query';
-import cx from 'clsx';
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
-
-import { fetchVersion } from '~/api/version';
-import { ChevronDown, Zap } from '~/components/shared/FeatherIcons';
-import { useFilteredAndSorted } from '~/modules/proxies/hooks';
-import { getProxyLatency } from '~/modules/proxies/utils';
-import { switchProxy } from '~/store/proxies';
-import { DelayMapping, DispatchFn, ProxiesMapping, ProxyItem } from '~/store/types';
+import Collapsible from '~/components/shared/Collapsible';
+import { useVersion } from '~/hooks/useVersion';
+import {
+ useFilterAwareCollapse,
+ useFilteredAndSorted,
+ useFilterSegments,
+ useSwitchProxy,
+ useTestGroupLatency,
+ useTestProxyLatency,
+} from '~/modules/proxies/hooks';
+import { getProxyLatency, matchesFilter, ProxiesAppConfig } from '~/modules/proxies/utils';
+import { useStoreActions } from '~/store/StateProvider';
+import { DelayMapping, ProxiesMapping, ProxyItem } from '~/store/types';
import { ClashAPIConfig } from '~/types';
-import Button from '../Button';
-import Collapsible from '../Collapsible';
-import CollapsibleSectionHeader from '../CollapsibleSectionHeader';
-import { useStoreActions } from '../StateProvider';
-
-import s0 from './ProxyGroup.module.scss';
+import { getLatencyColor, ProxyCard, ProxyCardHeader, ProxyCardStatusRow } from './ProxyCard';
+import s0 from './ProxyCard.module.scss';
import { ProxyList, ProxyListGroupedByProvider, ProxyListSummaryView } from './ProxyList';
-const { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } = React;
+const { memo, useCallback, useMemo } = React;
function buildNowChain(proxies: ProxiesMapping, groupName: string): string | null {
const group = proxies[groupName] as ProxyItem & { now?: string };
@@ -37,94 +38,51 @@ function buildNowChain(proxies: ProxiesMapping, groupName: string): string | nul
depth++;
}
- return parts.join(' ⊙ ');
-}
-
-function countAvailableProxies(names: string[], delay: DelayMapping): number {
- return names.filter((name) => {
- const d = delay[name];
- return d && typeof d.number === 'number' && d.number > 0;
- }).length;
+ return parts.join(' › ');
}
-function getLatencyColor(number: number | undefined, httpsTest: boolean): string {
- if (!number || number === 0) return '#909399';
- const good = httpsTest ? 800 : 200;
- const normal = httpsTest ? 1500 : 500;
- if (number < good) return '#67c23a';
- if (number < normal) return '#d4b75c';
- return '#e67f3c';
-}
-
-function ZapWrapper() {
- return (
- <div className={s0.zapWrapper}>
- <Zap size={16} />
- </div>
- );
-}
-
-const ProxyAvailabilityBar = memo(function ProxyAvailabilityBar({
- all,
- delay,
-}: {
- all: string[];
- delay: DelayMapping;
-}) {
- const total = all.length;
- const available = useMemo(() => countAvailableProxies(all, delay), [all, delay]);
- const pct = total > 0 ? Math.round((available / total) * 100) : 0;
-
- return (
- <div className={s0.availBar}>
- <div className={s0.availBarTrack}>
- <div className={s0.availBarFill} style={{ width: `${pct}%` }} />
- </div>
- </div>
- );
-});
-
type Props = {
name: string;
delay: DelayMapping;
- hideUnavailableProxies: boolean;
- proxySortBy: string;
proxies: ProxiesMapping;
isOpen: boolean;
httpsLatencyTest: boolean;
apiConfig: ClashAPIConfig;
- dispatch: DispatchFn;
- proxyGroupByProvider?: boolean;
+ appConfig: ProxiesAppConfig;
};
export const ProxyGroup = memo(function ProxyGroup({
name,
delay,
- hideUnavailableProxies,
- proxySortBy,
proxies,
isOpen,
httpsLatencyTest,
apiConfig,
- dispatch,
- proxyGroupByProvider = false,
+ appConfig,
}: Props) {
+ const { t } = useTranslation();
const group = proxies[name] as ProxyItem & { all?: string[]; now?: string; fixed?: string };
const { all: allItems = [], type, now, fixed } = group || {};
- const all = useFilteredAndSorted(allItems, delay, hideUnavailableProxies, proxySortBy, proxies);
+ // 组名本身命中搜索时,整组节点原样展示,不再逐个过滤
+ const filterSegments = useFilterSegments();
+ const nameMatched = matchesFilter(name, filterSegments);
+ const all = useFilteredAndSorted(
+ allItems,
+ delay,
+ appConfig.hideUnavailableProxies,
+ appConfig.proxySortBy,
+ proxies,
+ nameMatched,
+ );
const nowChain = useMemo(() => buildNowChain(proxies, name), [proxies, name]);
const nowLatency = useMemo(
() => (now ? getProxyLatency(proxies, delay, now) : undefined),
[proxies, delay, now],
);
- const availableCount = useMemo(() => countAvailableProxies(allItems, delay), [allItems, delay]);
- const qtyLabel = `${availableCount}/${allItems.length}`;
+ const nowLatencyColor = getLatencyColor(nowLatency?.number, httpsLatencyTest);
- const { data: version } = useSuspenseQuery({
- queryKey: ['/version', apiConfig],
- queryFn: () => fetchVersion('/version', apiConfig),
- });
+ const version = useVersion(apiConfig);
const isSelectable = useMemo(
() => ['Selector', version.meta && 'Fallback', version.meta && 'URLTest'].includes(type),
@@ -133,139 +91,78 @@ export const ProxyGroup = memo(function ProxyGroup({
const {
app: { updateCollapsibleIsOpen },
- proxies: { requestDelayForGroup },
} = useStoreActions();
- const toggle = useCallback(() => {
- updateCollapsibleIsOpen('proxyGroup', name, !isOpen);
- }, [isOpen, updateCollapsibleIsOpen, name]);
+ const onToggle = useCallback(
+ (next: boolean) => updateCollapsibleIsOpen('proxyGroup', name, next),
+ [updateCollapsibleIsOpen, name],
+ );
+ const [effectiveIsOpen, toggle] = useFilterAwareCollapse({ isOpen, nameMatched, onToggle });
+ const switchProxy = useSwitchProxy(apiConfig, appConfig.autoCloseOldConns);
const itemOnTapCallback = useCallback(
- (proxyName) => {
+ (proxyName: string) => {
if (!isSelectable) return;
- dispatch(switchProxy(apiConfig, name, proxyName));
+ switchProxy(name, proxyName);
},
- [apiConfig, dispatch, name, isSelectable],
+ [switchProxy, name, isSelectable],
);
- const [isTestingLatency, setIsTestingLatency] = useState(false);
- // measure collapsed container to decide dots vs bar
- const summaryContainerRef = useRef<HTMLDivElement>(null);
- const [containerWidth, setContainerWidth] = useState(0);
- useLayoutEffect(() => {
- const el = summaryContainerRef.current;
- if (!el) return;
- // sync read before first paint to avoid flash
- const w = el.offsetWidth;
- if (w > 0) setContainerWidth(w);
- const ro = new ResizeObserver((entries) => {
- setContainerWidth(entries[0].contentRect.width);
- });
- ro.observe(el);
- return () => ro.disconnect();
- }, []);
- // dot slot = 15px width + 10px gap; padding-left:10px eats into available space
- // n items fit when 15 + (n-1)*25 <= containerWidth - 10 → n <= containerWidth/25
- const dotsPerRow = containerWidth > 0 ? Math.floor(containerWidth / 25) : Infinity;
- const showBar = all.length > dotsPerRow;
- const testLatency = useCallback(async () => {
- setIsTestingLatency(true);
- try {
- await requestDelayForGroup(apiConfig, name, version.meta === true, all);
- } catch (err) {}
- setIsTestingLatency(false);
- }, [all, apiConfig, name, version.meta, requestDelayForGroup]);
+ const [testGroup, isTestingLatency] = useTestGroupLatency(apiConfig, appConfig);
+ const testLatency = useCallback(
+ () => testGroup({ groupName: name, isMeta: version.meta === true, memberNames: all }),
+ [testGroup, name, version.meta, all],
+ );
+
+ const onTestLatency = useTestProxyLatency(apiConfig, appConfig);
+
+ const listProps = {
+ all,
+ delay,
+ httpsLatencyTest,
+ now,
+ isSelectable,
+ itemOnTapCallback,
+ onTestLatency,
+ proxies,
+ };
return (
- <div className={s0.group}>
- <div className={s0.groupHeader}>
- <CollapsibleSectionHeader
- name={name}
- type={type}
- toggle={toggle}
- qty={qtyLabel}
- fixed={!!fixed}
- />
- <div className={s0.btnGroup}>
- <Button
- kind="minimal"
- onClick={toggle}
- className={s0.btn}
- title="Toggle collapsible section"
- >
- <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}>
- <ChevronDown size={20} />
+ <ProxyCard>
+ <ProxyCardHeader
+ name={name}
+ type={type}
+ isOpen={effectiveIsOpen}
+ toggle={toggle}
+ latency={nowLatency?.number}
+ latencyColor={nowLatencyColor}
+ onTest={testLatency}
+ isTesting={isTestingLatency}
+ badges={
+ fixed ? (
+ <span className={s0.fixedBadge} title={t('group_fixed_tip')}>
+ {t('group_fixed')}
</span>
- </Button>
- <Button
- title="Test latency"
- kind="minimal"
- onClick={testLatency}
- isLoading={isTestingLatency}
- >
- <ZapWrapper />
- </Button>
- </div>
- </div>
- <Collapsible isOpen={isOpen}>
- {proxyGroupByProvider ? (
- <ProxyListGroupedByProvider
- apiConfig={apiConfig}
- all={all}
- delay={delay}
- dispatch={dispatch}
- httpsLatencyTest={httpsLatencyTest}
- now={now}
- isSelectable={isSelectable}
- itemOnTapCallback={itemOnTapCallback}
- proxies={proxies}
- />
+ ) : null
+ }
+ />
+
+ <ProxyCardStatusRow
+ nowName={nowChain}
+ nowColor={nowLatencyColor}
+ itemCount={all.length}
+ allItems={allItems}
+ delay={delay}
+ renderDots={() => <ProxyListSummaryView {...listProps} />}
+ />
+
+ <Collapsible isOpen={effectiveIsOpen}>
+ {appConfig.proxyGroupByProvider ? (
+ <ProxyListGroupedByProvider {...listProps} />
) : (
- <ProxyList
- apiConfig={apiConfig}
- all={all}
- delay={delay}
- dispatch={dispatch}
- httpsLatencyTest={httpsLatencyTest}
- now={now}
- isSelectable={isSelectable}
- itemOnTapCallback={itemOnTapCallback}
- proxies={proxies}
- />
- )}
- </Collapsible>
- <Collapsible isOpen={!isOpen}>
- {nowChain && (
- <div className={s0.nowRow}>
- <span className={s0.nowName}>⊙ {nowChain}</span>
- {nowLatency?.number ? (
- <span
- className={s0.nowLatency}
- style={{ color: getLatencyColor(nowLatency.number, httpsLatencyTest) }}
- >
- {nowLatency.number} ms
- </span>
- ) : null}
- </div>
+ <ProxyList {...listProps} />
)}
- <div ref={summaryContainerRef}>
- {showBar ? (
- <ProxyAvailabilityBar all={allItems} delay={delay} />
- ) : (
- <ProxyListSummaryView
- apiConfig={apiConfig}
- all={all}
- delay={delay}
- dispatch={dispatch}
- httpsLatencyTest={httpsLatencyTest}
- now={now}
- isSelectable={isSelectable}
- itemOnTapCallback={itemOnTapCallback}
- proxies={proxies}
- />
- )}
- </div>
</Collapsible>
- </div>
+ </ProxyCard>
);
});
diff --git a/src/components/proxies/ProxyLatency.module.scss b/src/components/proxies/ProxyLatency.module.scss
index ac39100..b94b4e3 100644
--- a/src/components/proxies/ProxyLatency.module.scss
+++ b/src/components/proxies/ProxyLatency.module.scss
@@ -1,26 +1,23 @@
-@use '~/styles/utils/custom-media' as *;
-
.proxyLatency {
display: inline-flex;
align-items: center;
- justify-content: center;
- min-width: 50px;
- padding: 4px 10px;
+ justify-content: flex-end;
gap: 4px;
- border-radius: 9999px;
- border: 1px solid var(--color-proxy-border);
- /* Use theme-aware latency background with sensible default */
- background: var(--bg-latency, #ffffff);
+ flex-shrink: 0;
+ padding: 2px 4px;
+ margin: -2px -4px;
+ border-radius: 6px;
+ font-family: var(--font-mono);
+ font-size: 0.74rem;
+ font-weight: 500;
color: inherit;
- font-size: 0.75em;
- transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease,
- color 0.15s ease, transform 0.15s ease;
+ background: transparent;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
user-select: none;
outline: none;
- @media (--breakpoint-not-small) {
- padding: 5px 12px;
- font-size: 0.8em;
- }
+ white-space: nowrap;
}
.clickable {
@@ -29,9 +26,12 @@
.clickable:hover,
.clickable:focus-visible {
- background: var(--color-bg-proxy);
- border-color: var(--card-hover-border-lightness);
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+ background: var(--color-hover-soft);
+}
+
+.clickable:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: 1px;
}
.placeholder {
diff --git a/src/components/proxies/ProxyLatency.tsx b/src/components/proxies/ProxyLatency.tsx
index 8267f2e..facced7 100644
--- a/src/components/proxies/ProxyLatency.tsx
+++ b/src/components/proxies/ProxyLatency.tsx
@@ -64,7 +64,7 @@ export function ProxyLatency({ number, color, isTesting, error, onClick }: Proxy
e.stopPropagation();
onClick();
},
- [isTesting, onClick]
+ [isTesting, onClick],
);
const handleKeyDown = React.useCallback(
@@ -76,10 +76,12 @@ export function ProxyLatency({ number, color, isTesting, error, onClick }: Proxy
onClick();
}
},
- [isTesting, onClick]
+ [isTesting, onClick],
);
return (
+ // role 是条件表达式,oxlint 静态分析不出来
+ // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
<span
className={className}
style={{ color: hasNumber ? color : undefined }}
diff --git a/src/components/proxies/ProxyList.module.scss b/src/components/proxies/ProxyList.module.scss
index f4b1211..23c084a 100644
--- a/src/components/proxies/ProxyList.module.scss
+++ b/src/components/proxies/ProxyList.module.scss
@@ -1,33 +1,43 @@
-@use '~/styles/utils/custom-media' as *;
-
.list {
- margin: 8px 0;
display: grid;
- grid-gap: 10px;
+ gap: 8px;
}
.detail {
- grid-template-columns: auto auto;
+ margin-top: 12px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
- @media (--breakpoint-not-small) {
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ @media (max-width: 480px) {
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
.summary {
- grid-template-columns: repeat(auto-fill, 15px);
- padding-left: 10px;
+ display: flex;
+ flex-wrap: nowrap;
+ gap: 5px;
+ align-items: center;
}
.providerGroup {
- margin-top: 8px;
+ margin-top: 12px;
}
.providerLabel {
- font-size: 0.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 0.72rem;
font-weight: 600;
- color: var(--color-text-secondary, #909399);
- padding: 2px 0 4px;
- border-bottom: 1px solid var(--color-separator);
- margin-bottom: 4px;
+ color: var(--color-text-secondary);
+}
+
+.providerQty {
+ font-family: var(--font-mono);
+ font-weight: 400;
+}
+
+.providerGroup .detail {
+ margin-top: 6px;
}
diff --git a/src/components/proxies/ProxyList.tsx b/src/components/proxies/ProxyList.tsx
index 01ea729..1063941 100644
--- a/src/components/proxies/ProxyList.tsx
+++ b/src/components/proxies/ProxyList.tsx
@@ -1,9 +1,9 @@
import cx from 'clsx';
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { getProxyLatency } from '~/modules/proxies/utils';
-import { DelayMapping, DispatchFn, ProxiesMapping } from '~/store/types';
-import { ClashAPIConfig } from '~/types';
+import { DelayMapping, ProxiesMapping } from '~/store/types';
import { Proxy, ProxySmall } from './Proxy';
import s from './ProxyList.module.scss';
@@ -13,8 +13,7 @@ type ProxyListProps = {
proxies: ProxiesMapping;
delay: DelayMapping;
httpsLatencyTest: boolean;
- apiConfig: ClashAPIConfig;
- dispatch: DispatchFn;
+ onTestLatency: (name: string, providerName?: string) => void;
now?: string;
isSelectable?: boolean;
itemOnTapCallback?: (x: string) => void;
@@ -26,8 +25,7 @@ export function ProxyList({
proxies,
delay,
httpsLatencyTest,
- apiConfig,
- dispatch,
+ onTestLatency,
now,
isSelectable,
itemOnTapCallback,
@@ -46,9 +44,8 @@ export function ProxyList({
};
return (
<Proxy
- apiConfig={apiConfig}
- dispatch={dispatch}
proxy={proxy}
+ onTestLatency={onTestLatency}
latency={getProxyLatency(proxies, delay, proxyName)}
httpsLatencyTest={httpsLatencyTest}
key={proxyName}
@@ -68,8 +65,6 @@ export function ProxyListSummaryView({
proxies,
delay,
httpsLatencyTest,
- apiConfig,
- dispatch,
now,
isSelectable,
itemOnTapCallback,
@@ -86,8 +81,6 @@ export function ProxyListSummaryView({
};
return (
<ProxySmall
- apiConfig={apiConfig}
- dispatch={dispatch}
proxy={proxy}
latency={getProxyLatency(proxies, delay, proxyName)}
httpsLatencyTest={httpsLatencyTest}
@@ -108,12 +101,12 @@ export function ProxyListGroupedByProvider({
proxies,
delay,
httpsLatencyTest,
- apiConfig,
- dispatch,
+ onTestLatency,
now,
isSelectable,
itemOnTapCallback,
}: ProxyListProps) {
+ const { t } = useTranslation();
// Group proxy names by their providerName
const groups: { label: string; names: string[] }[] = React.useMemo(() => {
const map = new Map<string, string[]>();
@@ -129,7 +122,12 @@ export function ProxyListGroupedByProvider({
<div>
{groups.map(({ label, names }) => (
<div key={label} className={s.providerGroup}>
- {label ? <div className={s.providerLabel}>{label}</div> : null}
+ {label ? (
+ <div className={s.providerLabel}>
+ <span>{label}</span>
+ <span className={s.providerQty}>{t('node_qty', { n: names.length })}</span>
+ </div>
+ ) : null}
<div className={cx(s.list, s.detail)}>
{names.map((proxyName) => {
const proxy = proxies[proxyName] || {
@@ -141,9 +139,8 @@ export function ProxyListGroupedByProvider({
};
return (
<Proxy
- apiConfig={apiConfig}
- dispatch={dispatch}
proxy={proxy}
+ onTestLatency={onTestLatency}
latency={getProxyLatency(proxies, delay, proxyName)}
httpsLatencyTest={httpsLatencyTest}
key={proxyName}
diff --git a/src/components/proxies/ProxyPageFab.tsx b/src/components/proxies/ProxyPageFab.tsx
deleted file mode 100644
index 62739d7..0000000
--- a/src/components/proxies/ProxyPageFab.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import * as React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import { Action, Fab, IsFetching, position as fabPosition } from '~/components/shared/Fab';
-import { Zap } from '~/components/shared/FeatherIcons';
-import { RotateIcon } from '~/components/shared/RotateIcon';
-import { useTestLatencyAction, useUpdateProviderItems } from '~/modules/proxies/hooks';
-import { DispatchFn, FormattedProxyProvider } from '~/store/types';
-import { ClashAPIConfig } from '~/types';
-
-function StatefulZap({ isLoading }: { isLoading: boolean }) {
- return isLoading ? (
- <IsFetching>
- <Zap width={16} height={16} />
- </IsFetching>
- ) : (
- <Zap width={16} height={16} />
- );
-}
-
-export function ProxyPageFab({
- dispatch,
- apiConfig,
- proxyProviders,
-}: {
- dispatch: DispatchFn;
- apiConfig: ClashAPIConfig;
- proxyProviders: FormattedProxyProvider[];
-}) {
- const { t } = useTranslation();
- const [requestDelayAllFn, isTestingLatency] = useTestLatencyAction({
- dispatch,
- apiConfig,
- });
-
- const [updateProviders, isUpdating] = useUpdateProviderItems({
- apiConfig,
- dispatch,
- names: proxyProviders.map((item) => item.name),
- });
-
- return (
- <Fab
- icon={<StatefulZap isLoading={isTestingLatency} />}
- onClick={requestDelayAllFn}
- text={t('Test Latency')}
- style={fabPosition}
- >
- {proxyProviders.length > 0 ? (
- <Action text={t('update_all_proxy_provider')} onClick={updateProviders}>
- <RotateIcon isRotating={isUpdating} />
- </Action>
- ) : null}
- </Fab>
- );
-}
diff --git a/src/components/proxies/ProxyProvider.module.scss b/src/components/proxies/ProxyProvider.module.scss
index d22e50b..bee8a58 100644
--- a/src/components/proxies/ProxyProvider.module.scss
+++ b/src/components/proxies/ProxyProvider.module.scss
@@ -1,39 +1,20 @@
-@use '~/styles/utils/custom-media' as *;
-
-.updatedAt {
- margin-bottom: 12px;
- margin-left: 5px;
- small {
- color: #777;
- }
-}
-
-.body {
- padding: 10px;
- background-color: var(--bg-log-info-card);
- border: 1px solid var(--color-separator);
- border-radius: 12px;
- box-shadow: var(--shadow-card);
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
-
- &:hover {
- border-color: var(--color-focus-blue);
- }
-}
-
-.actionFooter {
+.meta {
display: flex;
- button {
- margin: 0 5px;
- &:first-child {
- margin-left: 0;
- }
- }
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ margin-top: 6px;
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
}
-.refresh {
- display: flex;
- justify-content: center;
- align-items: center;
- cursor: pointer;
+.qtyBadge {
+ flex-shrink: 0;
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ font-weight: 500;
+ line-height: 1;
+ padding: 4px 7px;
+ border-radius: 6px;
+ background: var(--color-badge-bg);
+ color: var(--color-badge-fg);
}
diff --git a/src/components/proxies/ProxyProvider.tsx b/src/components/proxies/ProxyProvider.tsx
index 83e3f66..c681a0b 100644
--- a/src/components/proxies/ProxyProvider.tsx
+++ b/src/components/proxies/ProxyProvider.tsx
@@ -1,209 +1,169 @@
-import cx from 'clsx';
import { formatDistance } from 'date-fns';
-import { LazyMotion, domAnimation, m } from 'framer-motion';
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
-
-import Button from '~/components/Button';
-import Collapsible from '~/components/Collapsible';
-import CollapsibleSectionHeader from '~/components/CollapsibleSectionHeader';
-import s0 from '~/components/proxies/ProxyGroup.module.scss';
-import { ChevronDown, RotateCw, Zap } from '~/components/shared/FeatherIcons';
-import { useStoreActions } from '~/components/StateProvider';
-import { useFilteredAndSorted, useUpdateProviderItem } from '~/modules/proxies/hooks';
-import { healthcheckProviderByName } from '~/store/proxies';
-import { DelayMapping, DispatchFn, ProxiesMapping, SubscriptionInfo } from '~/store/types';
+import Collapsible from '~/components/shared/Collapsible';
+import { RotateIcon } from '~/components/shared/RotateIcon';
+import {
+ useFilterAwareCollapse,
+ useFilteredAndSorted,
+ useFilterSegments,
+ useHealthcheckProvider,
+ useTestProxyLatency,
+ useUpdateProviderItem,
+} from '~/modules/proxies/hooks';
+import { matchesFilter, ProxiesAppConfig } from '~/modules/proxies/utils';
+import { useStoreActions } from '~/store/StateProvider';
+import { DelayMapping, ProxiesMapping, SubscriptionInfo } from '~/store/types';
import { ClashAPIConfig } from '~/types';
+import { ProxyCard, ProxyCardAction, ProxyCardHeader, ProxyCardStatusRow } from './ProxyCard';
import { ProxyList, ProxyListSummaryView } from './ProxyList';
import s from './ProxyProvider.module.scss';
-const { memo, useState, useCallback } = React;
+const { memo, useCallback } = React;
type Props = {
name: string;
proxies: Array<string>;
delay: DelayMapping;
- hideUnavailableProxies: boolean;
- proxySortBy: string;
type: 'Proxy' | 'Rule';
vehicleType: 'HTTP' | 'File' | 'Compatible';
updatedAt?: string;
subscriptionInfo?: SubscriptionInfo;
proxyMapping: ProxiesMapping;
httpsLatencyTest: boolean;
- dispatch: DispatchFn;
isOpen: boolean;
apiConfig: ClashAPIConfig;
+ appConfig: ProxiesAppConfig;
};
export const ProxyProvider = memo(function ProxyProvider({
name,
proxies: all,
delay,
- hideUnavailableProxies,
- proxySortBy,
vehicleType,
updatedAt,
subscriptionInfo,
proxyMapping,
httpsLatencyTest,
isOpen,
- dispatch,
apiConfig,
+ appConfig,
}: Props) {
- const proxies = useFilteredAndSorted(all, delay, hideUnavailableProxies, proxySortBy);
- const [isHealthcheckLoading, setIsHealthcheckLoading] = useState(false);
+ const { t } = useTranslation();
+ // 提供商名本身命中搜索时,旗下节点原样展示,不再逐个过滤
+ const filterSegments = useFilterSegments();
+ const nameMatched = matchesFilter(name, filterSegments);
+ const proxies = useFilteredAndSorted(
+ all,
+ delay,
+ appConfig.hideUnavailableProxies,
+ appConfig.proxySortBy,
+ undefined,
+ nameMatched,
+ );
- const updateProvider = useUpdateProviderItem({ dispatch, apiConfig, name });
+ const [updateProviderItem, isUpdating] = useUpdateProviderItem(apiConfig);
+ const updateProvider = useCallback(() => updateProviderItem(name), [updateProviderItem, name]);
- const healthcheckProvider = useCallback(async () => {
- setIsHealthcheckLoading(true);
- await dispatch(healthcheckProviderByName(apiConfig, name));
- setIsHealthcheckLoading(false);
- }, [apiConfig, dispatch, name, setIsHealthcheckLoading]);
+ const [healthcheck, isHealthcheckLoading] = useHealthcheckProvider(
+ apiConfig,
+ appConfig.providerHealthcheckTimeout,
+ );
+ const healthcheckProvider = useCallback(() => healthcheck(name), [healthcheck, name]);
const {
app: { updateCollapsibleIsOpen },
} = useStoreActions();
- const toggle = useCallback(() => {
- updateCollapsibleIsOpen('proxyProvider', name, !isOpen);
- }, [isOpen, updateCollapsibleIsOpen, name]);
+ const onToggle = useCallback(
+ (next: boolean) => updateCollapsibleIsOpen('proxyProvider', name, next),
+ [updateCollapsibleIsOpen, name],
+ );
+ const [effectiveIsOpen, toggle] = useFilterAwareCollapse({ isOpen, nameMatched, onToggle });
+
+ const onTestLatency = useTestProxyLatency(apiConfig, appConfig);
- const timeAgo = formatDistance(new Date(updatedAt), new Date());
- const total = subscriptionInfo ? formatBytes(subscriptionInfo.Total) : 0;
+ const listProps = {
+ all: proxies,
+ proxies: proxyMapping,
+ delay,
+ httpsLatencyTest,
+ onTestLatency,
+ };
+
+ const timeAgo = updatedAt ? formatDistance(new Date(updatedAt), new Date()) : null;
const used = subscriptionInfo
? formatBytes(subscriptionInfo.Download + subscriptionInfo.Upload)
- : 0;
+ : null;
+ const total = subscriptionInfo ? formatBytes(subscriptionInfo.Total) : null;
const percentage = subscriptionInfo
? (
((subscriptionInfo.Download + subscriptionInfo.Upload) / subscriptionInfo.Total) *
100
- ).toFixed(2)
- : 0;
+ ).toFixed(1)
+ : null;
+
const expireStr = () => {
- if (subscriptionInfo.Expire === 0) {
- return 'Null';
- }
+ if (!subscriptionInfo || subscriptionInfo.Expire === 0) return null;
const expire = new Date(subscriptionInfo.Expire * 1000);
- const getYear = expire.getFullYear() + '-';
- const getMonth =
- (expire.getMonth() + 1 < 10 ? '0' + (expire.getMonth() + 1) : expire.getMonth() + 1) + '-';
- const getDate = (expire.getDate() < 10 ? '0' + expire.getDate() : expire.getDate()) + ' ';
- return getYear + getMonth + getDate;
+ const month = String(expire.getMonth() + 1).padStart(2, '0');
+ const date = String(expire.getDate()).padStart(2, '0');
+ return `${expire.getFullYear()}-${month}-${date}`;
};
+ const expire = expireStr();
+
return (
- <div className={s.body}>
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- flexWrap: 'wrap',
- justifyContent: 'space-between',
- userSelect: 'none',
- }}
- >
- <CollapsibleSectionHeader
- name={name}
- toggle={toggle}
- type={vehicleType}
- isOpen={isOpen}
- qty={proxies.length}
- />
- <div style={{ display: 'flex' }}>
- <Button
- kind="minimal"
- onClick={toggle}
- className={s0.btn}
- title="Toggle collapsible section"
+ <ProxyCard>
+ <ProxyCardHeader
+ name={name}
+ type={vehicleType}
+ isOpen={effectiveIsOpen}
+ toggle={toggle}
+ onTest={healthcheckProvider}
+ isTesting={isHealthcheckLoading}
+ badges={<span className={s.qtyBadge}>{t('node_qty', { n: proxies.length })}</span>}
+ extraActions={
+ <ProxyCardAction
+ onClick={updateProvider}
+ title={t('update_proxy_provider')}
+ isBusy={isUpdating}
>
- <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}>
- <ChevronDown size={20} />
- </span>
- </Button>
- <Button kind="minimal" start={<Refresh />} onClick={updateProvider} />
- <Button
- kind="minimal"
- start={<Zap size={16} />}
- onClick={healthcheckProvider}
- isLoading={isHealthcheckLoading}
- />
- </div>
- </div>
- <div className={s.updatedAt}>
- {subscriptionInfo && (
- <small>
- {used} / {total} ( {percentage}% ) &nbsp;&nbsp; Expire: {expireStr()}{' '}
- </small>
- )}
- <br />
- <small>Updated {timeAgo} ago</small>
+ <RotateIcon isRotating={isUpdating} />
+ </ProxyCardAction>
+ }
+ />
+
+ <div className={s.meta}>
+ {subscriptionInfo ? (
+ <span>
+ {used} / {total} ({percentage}%)
+ </span>
+ ) : null}
+ {expire ? <span>{t('expire_at', { date: expire })}</span> : null}
+ {timeAgo ? <span>{t('updated_ago', { time: timeAgo })}</span> : null}
</div>
- <Collapsible isOpen={isOpen}>
- <ProxyList
- all={proxies}
- proxies={proxyMapping}
- delay={delay}
- httpsLatencyTest={httpsLatencyTest}
- apiConfig={apiConfig}
- dispatch={dispatch}
- />
- <div className={s.actionFooter}>
- <Button text="Update" start={<Refresh />} onClick={updateProvider} />
- <Button
- text="Health Check"
- start={<Zap size={16} />}
- onClick={healthcheckProvider}
- isLoading={isHealthcheckLoading}
- />
- </div>
- </Collapsible>
- <Collapsible isOpen={!isOpen}>
- <ProxyListSummaryView
- all={proxies}
- proxies={proxyMapping}
- delay={delay}
- httpsLatencyTest={httpsLatencyTest}
- apiConfig={apiConfig}
- dispatch={dispatch}
- />
+
+ <ProxyCardStatusRow
+ itemCount={proxies.length}
+ allItems={all}
+ delay={delay}
+ renderDots={() => <ProxyListSummaryView {...listProps} />}
+ />
+
+ <Collapsible isOpen={effectiveIsOpen}>
+ <ProxyList {...listProps} />
</Collapsible>
- </div>
+ </ProxyCard>
);
});
-const button = {
- rest: { scale: 1 },
- pressed: { scale: 0.95 },
-};
-const arrow = {
- rest: { rotate: 0 },
- hover: { rotate: 360, transition: { duration: 0.3 } },
-};
-
-function formatBytes(bytes, decimals = 2) {
- if (!+bytes) return '0 Bytes';
+function formatBytes(bytes: number, decimals = 2) {
+ if (!+bytes) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
-function Refresh() {
- return (
- <LazyMotion features={domAnimation}>
- <m.div
- className={s.refresh}
- variants={button}
- initial="rest"
- whileHover="hover"
- whileTap="pressed"
- >
- <m.div className="flexCenter" variants={arrow}>
- <RotateCw size={16} />
- </m.div>
- </m.div>
- </LazyMotion>
- );
-}
diff --git a/src/components/proxies/ProxyProviderList.tsx b/src/components/proxies/ProxyProviderList.tsx
deleted file mode 100644
index a4a268e..0000000
--- a/src/components/proxies/ProxyProviderList.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import * as React from 'react';
-
-import { ProxyProvider } from '~/components/proxies/ProxyProvider';
-import { DelayMapping, DispatchFn, FormattedProxyProvider, ProxiesMapping } from '~/store/types';
-import { ClashAPIConfig } from '~/types';
-
-export function ProxyProviderList({
- items,
- delay,
- proxies,
- httpsLatencyTest,
- hideUnavailableProxies,
- proxySortBy,
- dispatch,
- apiConfig,
- collapsibleIsOpen,
-}: {
- items: FormattedProxyProvider[];
- delay: DelayMapping;
- proxies: ProxiesMapping;
- httpsLatencyTest: boolean;
- hideUnavailableProxies: boolean;
- proxySortBy: string;
- dispatch: DispatchFn;
- apiConfig: ClashAPIConfig;
- collapsibleIsOpen: Record<string, boolean>;
-}) {
- if (items.length === 0) return null;
- return (
- <div>
- {items.map((item) => (
- <ProxyProvider
- key={item.name}
- name={item.name}
- proxies={item.proxies}
- type={item.type}
- vehicleType={item.vehicleType}
- updatedAt={item.updatedAt}
- subscriptionInfo={item.subscriptionInfo}
- proxyMapping={proxies}
- httpsLatencyTest={httpsLatencyTest}
- delay={delay}
- hideUnavailableProxies={hideUnavailableProxies}
- proxySortBy={proxySortBy}
- dispatch={dispatch}
- apiConfig={apiConfig}
- isOpen={Boolean(collapsibleIsOpen[`proxyProvider:${item.name}`])}
- />
- ))}
- </div>
- );
-}
diff --git a/src/components/proxies/Settings.module.scss b/src/components/proxies/Settings.module.scss
index d3eb540..3bddfe6 100644
--- a/src/components/proxies/Settings.module.scss
+++ b/src/components/proxies/Settings.module.scss
@@ -1,84 +1,99 @@
-.labeledInput {
- max-width: 85vw;
- width: 400px;
+.panel {
+ width: 340px;
+ // 让内容跟着弹层收缩,宽度不够时不要顶出横向滚动条
+ max-width: 100%;
display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.sectionTitle {
+ margin: 0;
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-text-secondary);
+}
+
+.row {
+ display: flex;
+ align-items: center;
justify-content: space-between;
+ gap: 12px;
+}
+
+.stackedRow {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 0;
+}
+
+.rowLabel {
+ font-size: 0.8rem;
+ color: var(--color-text);
+ line-height: 1.35;
+}
+
+.rowControl {
+ display: flex;
align-items: center;
- font-size: 13px;
- padding: 13px 0;
+ flex-shrink: 0;
}
-.urlInputWrapper {
+.inputWrapper {
position: relative;
display: flex;
align-items: center;
- width: 240px;
}
-.urlInput {
+.input {
width: 100%;
- padding: 4px 24px 4px 8px;
- border: 1px solid var(--color-separator);
- border-radius: 4px;
- background: var(--color-bg-1, transparent);
- color: inherit;
- font-size: 12px;
+ padding: 8px 26px 8px 10px;
+ border: 1px solid var(--color-card-border);
+ border-radius: 10px;
+ background: var(--color-track);
+ color: var(--color-text);
+ font-family: inherit;
+ font-size: 0.78rem;
outline: none;
+ transition:
+ border-color 0.15s ease,
+ box-shadow 0.15s ease;
+
+ &::placeholder {
+ color: var(--color-text-secondary);
+ opacity: 0.7;
+ }
&:focus {
- border-color: var(--color-focus-blue, #409eff);
+ border-color: var(--color-focus-blue);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
-.urlClearBtn {
+.clearBtn {
position: absolute;
- right: 6px;
+ right: 8px;
background: none;
border: none;
cursor: pointer;
- color: var(--color-text-secondary, #909399);
+ color: var(--color-text-secondary);
padding: 0;
line-height: 1;
- font-size: 14px;
+ font-size: 15px;
display: flex;
align-items: center;
&:hover {
- color: var(--color-text, inherit);
- }
-}
-
-.timeoutInputWrapper {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.timeoutInput {
- width: 70px;
- padding: 4px 8px;
- border: 1px solid var(--color-separator);
- border-radius: 4px;
- background: var(--color-bg-1, transparent);
- color: inherit;
- font-size: 12px;
- text-align: right;
- outline: none;
-
- &:focus {
- border-color: var(--color-focus-blue, #409eff);
+ color: var(--color-text);
}
}
-.timeoutUnit {
- font-size: 12px;
- color: var(--color-text-secondary, #909399);
-}
-
-hr {
+.divider {
height: 1px;
background-color: var(--color-separator);
border: none;
- outline: none;
- margin: 1rem 0px;
+ margin: 2px 0;
}
diff --git a/src/components/proxies/Settings.tsx b/src/components/proxies/Settings.tsx
index 33e20ff..e823fd9 100644
--- a/src/components/proxies/Settings.tsx
+++ b/src/components/proxies/Settings.tsx
@@ -1,222 +1,237 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
-import Select from '~/components/shared/Select';
-import { PROXY_SORT_OPTIONS } from '~/modules/proxies/utils';
-
-import { useStoreActions } from '../StateProvider';
-import Switch from '../SwitchThemed';
+import { SegmentedControl } from '~/components/shared/SegmentedControl';
+import Switch from '~/components/shared/SwitchThemed';
+import {
+ getProxySortDirection,
+ getProxySortKey,
+ HEALTHCHECK_TIMEOUT_PRESETS,
+ LATENCY_TIMEOUT_PRESETS,
+ nextProxySortBy,
+ ProxiesAppConfig,
+ ProxySortKey,
+ withCurrentTimeout,
+} from '~/modules/proxies/utils';
+import { useStoreActions } from '~/store/StateProvider';
import s from './Settings.module.scss';
-const { useCallback } = React;
-
-type AppConfig = {
- proxySortBy: string;
- hideUnavailableProxies: boolean;
- autoCloseOldConns: boolean;
- proxiesLayout: string;
- proxyGroupByProvider: boolean;
- latencyTestUrl: string;
- latencyTestTimeout: number;
- latencyTestExpectedStatus: string;
- preferBackendLatencyTestUrl: boolean;
- providerHealthcheckTimeout: number;
-};
+const { useCallback, useMemo } = React;
type Props = {
- appConfig: AppConfig;
+ appConfig: ProxiesAppConfig;
};
+function Row({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
+ return (
+ <div className={s.row}>
+ <span className={s.rowLabel}>{label}</span>
+ <div className={s.rowControl}>{children}</div>
+ </div>
+ );
+}
+
+/** 单独一行、控件占满宽度(输入框、分段控件用) */
+function StackedRow({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
+ return (
+ <div className={s.stackedRow}>
+ <span className={s.rowLabel}>{label}</span>
+ {children}
+ </div>
+ );
+}
+
+function ClearableInput({
+ value,
+ placeholder,
+ onChange,
+ onClear,
+ ariaLabel,
+}: {
+ value: string;
+ placeholder?: string;
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
+ onClear: () => void;
+ ariaLabel: string;
+}) {
+ return (
+ <div className={s.inputWrapper}>
+ <input
+ className={s.input}
+ type="text"
+ value={value}
+ placeholder={placeholder}
+ onChange={onChange}
+ spellCheck={false}
+ aria-label={ariaLabel}
+ />
+ {value ? (
+ <button className={s.clearBtn} onClick={onClear} tabIndex={-1} aria-label="clear">
+ ×
+ </button>
+ ) : null}
+ </div>
+ );
+}
+
export default function Settings({ appConfig }: Props) {
const {
app: { updateAppConfig },
} = useStoreActions();
+ const { t } = useTranslation();
- const handleProxySortByOnChange = useCallback(
- (e) => {
- updateAppConfig('proxySortBy', e.target.value);
- },
+ const handleLatencyUrlChange = useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>) => updateAppConfig('latencyTestUrl', e.target.value),
[updateAppConfig],
);
-
- const handleHideUnavailablesSwitchOnChange = useCallback(
- (v) => {
- updateAppConfig('hideUnavailableProxies', v);
- },
+ const handleLatencyUrlClear = useCallback(
+ () => updateAppConfig('latencyTestUrl', ''),
[updateAppConfig],
);
- const handleLatencyUrlChange = useCallback(
- (e: React.ChangeEvent<HTMLInputElement>) => {
- updateAppConfig('latencyTestUrl', e.target.value);
- },
+ const handleExpectedStatusChange = useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>) =>
+ updateAppConfig('latencyTestExpectedStatus', e.target.value.trim()),
[updateAppConfig],
);
-
- const handleLatencyUrlClear = useCallback(() => {
- updateAppConfig('latencyTestUrl', '');
- }, [updateAppConfig]);
-
- const handleLatencyTimeoutChange = useCallback(
- (e: React.ChangeEvent<HTMLInputElement>) => {
- const v = parseInt(e.target.value, 10);
- if (!isNaN(v) && v > 0) updateAppConfig('latencyTestTimeout', v);
- },
+ const handleExpectedStatusClear = useCallback(
+ () => updateAppConfig('latencyTestExpectedStatus', ''),
[updateAppConfig],
);
- const handleProviderHealthcheckTimeoutChange = useCallback(
- (e: React.ChangeEvent<HTMLInputElement>) => {
- const v = parseInt(e.target.value, 10);
- if (!isNaN(v) && v > 0) updateAppConfig('providerHealthcheckTimeout', v);
- },
- [updateAppConfig],
+ const secondsOptions = useCallback(
+ (presets: number[], current: number) =>
+ withCurrentTimeout(presets, current).map((ms) => ({
+ value: ms,
+ label: t('secs', { n: Math.round(ms / 100) / 10 }),
+ })),
+ [t],
);
- const handleExpectedStatusChange = useCallback(
- (e: React.ChangeEvent<HTMLInputElement>) => {
- updateAppConfig('latencyTestExpectedStatus', e.target.value.trim());
- },
- [updateAppConfig],
+ const latencyTimeoutOptions = useMemo(
+ () => secondsOptions(LATENCY_TIMEOUT_PRESETS, appConfig.latencyTestTimeout),
+ [secondsOptions, appConfig.latencyTestTimeout],
+ );
+ const healthcheckTimeoutOptions = useMemo(
+ () => secondsOptions(HEALTHCHECK_TIMEOUT_PRESETS, appConfig.providerHealthcheckTimeout),
+ [secondsOptions, appConfig.providerHealthcheckTimeout],
);
- const handleExpectedStatusClear = useCallback(() => {
- updateAppConfig('latencyTestExpectedStatus', '');
- }, [updateAppConfig]);
+ const sortKey = getProxySortKey(appConfig.proxySortBy);
+ const sortDirection = getProxySortDirection(appConfig.proxySortBy);
+ const sortOptions = useMemo(() => {
+ const arrow = sortDirection === 'Desc' ? ' ↓' : ' ↑';
+ const withArrow = (key: ProxySortKey, text: string) =>
+ key === sortKey && key !== 'Natural' ? `${text}${arrow}` : text;
+ return [
+ { value: 'Natural' as const, label: t('sort_natural'), title: t('order_natural') },
+ { value: 'Latency' as const, label: withArrow('Latency', t('sort_latency')) },
+ { value: 'Name' as const, label: withArrow('Name', t('sort_name')) },
+ ];
+ }, [sortKey, sortDirection, t]);
+
+ const handleSortChange = useCallback(
+ (key: ProxySortKey) =>
+ updateAppConfig('proxySortBy', nextProxySortBy(appConfig.proxySortBy, key)),
+ [appConfig.proxySortBy, updateAppConfig],
+ );
- const { t } = useTranslation();
return (
- <>
- <div className={s.labeledInput}>
- <span>{t('latency_test_url')}</span>
- <div className={s.urlInputWrapper}>
- <input
- className={s.urlInput}
- type="text"
- value={appConfig.latencyTestUrl}
- onChange={handleLatencyUrlChange}
- spellCheck={false}
- />
- {appConfig.latencyTestUrl && (
- <button className={s.urlClearBtn} onClick={handleLatencyUrlClear} tabIndex={-1}>
- ×
- </button>
- )}
- </div>
- </div>
- <div className={s.labeledInput}>
- <span>{t('latency_test_timeout')}</span>
- <div className={s.timeoutInputWrapper}>
- <input
- className={s.timeoutInput}
- type="number"
- min={100}
- max={30000}
- step={100}
- value={appConfig.latencyTestTimeout}
- onChange={handleLatencyTimeoutChange}
- />
- <span className={s.timeoutUnit}>ms</span>
- </div>
- </div>
- <div className={s.labeledInput}>
- <span>{t('provider_healthcheck_timeout')}</span>
- <div className={s.timeoutInputWrapper}>
- <input
- className={s.timeoutInput}
- type="number"
- min={1000}
- max={60000}
- step={500}
- value={appConfig.providerHealthcheckTimeout}
- onChange={handleProviderHealthcheckTimeoutChange}
- />
- <span className={s.timeoutUnit}>ms</span>
- </div>
- </div>
- <div className={s.labeledInput}>
- <span>{t('latency_test_expected_status')}</span>
- <div className={s.urlInputWrapper}>
- <input
- className={s.urlInput}
- type="text"
- placeholder="200/204"
- value={appConfig.latencyTestExpectedStatus}
- onChange={handleExpectedStatusChange}
- spellCheck={false}
- />
- {appConfig.latencyTestExpectedStatus && (
- <button className={s.urlClearBtn} onClick={handleExpectedStatusClear} tabIndex={-1}>
- ×
- </button>
- )}
- </div>
- </div>
- <div className={s.labeledInput}>
- <span>{t('prefer_backend_test_url')}</span>
- <div>
- <Switch
- name="preferBackendLatencyTestUrl"
- checked={appConfig.preferBackendLatencyTestUrl}
- onChange={(v) => updateAppConfig('preferBackendLatencyTestUrl', v)}
- />
- </div>
- </div>
- <hr />
- <div className={s.labeledInput}>
- <span>{t('sort_in_grp')}</span>
- <div>
- <Select
- options={PROXY_SORT_OPTIONS.map((o) => {
- return [o[0], t(o[1])];
- })}
- selected={appConfig.proxySortBy}
- onChange={handleProxySortByOnChange}
- />
- </div>
- </div>
- <hr />
- <div className={s.labeledInput}>
- <span>{t('hide_unavail_proxies')}</span>
- <div>
- <Switch
- name="hideUnavailableProxies"
- checked={appConfig.hideUnavailableProxies}
- onChange={handleHideUnavailablesSwitchOnChange}
- />
- </div>
- </div>
- <div className={s.labeledInput}>
- <span>{t('auto_close_conns')}</span>
- <div>
- <Switch
- name="autoCloseOldConns"
- checked={appConfig.autoCloseOldConns}
- onChange={(v) => updateAppConfig('autoCloseOldConns', v)}
- />
- </div>
- </div>
- <div className={s.labeledInput}>
- <span>{t('double_column_layout')}</span>
- <div>
- <Switch
- name="proxiesLayout"
- checked={appConfig.proxiesLayout === 'double'}
- onChange={(v) => updateAppConfig('proxiesLayout', v ? 'double' : 'single')}
- />
- </div>
- </div>
- <div className={s.labeledInput}>
- <span>{t('group_by_provider')}</span>
- <div>
- <Switch
- name="proxyGroupByProvider"
- checked={appConfig.proxyGroupByProvider}
- onChange={(v) => updateAppConfig('proxyGroupByProvider', v)}
- />
- </div>
- </div>
- </>
+ <div className={s.panel}>
+ <p className={s.sectionTitle}>{t('settings_latency')}</p>
+
+ <StackedRow label={t('latency_test_url')}>
+ <ClearableInput
+ value={appConfig.latencyTestUrl}
+ onChange={handleLatencyUrlChange}
+ onClear={handleLatencyUrlClear}
+ ariaLabel={t('latency_test_url')}
+ />
+ </StackedRow>
+
+ <StackedRow label={t('latency_test_timeout')}>
+ <SegmentedControl
+ options={latencyTimeoutOptions}
+ value={appConfig.latencyTestTimeout}
+ onChange={(v) => updateAppConfig('latencyTestTimeout', v)}
+ label={t('latency_test_timeout')}
+ />
+ </StackedRow>
+
+ <StackedRow label={t('provider_healthcheck_timeout')}>
+ <SegmentedControl
+ options={healthcheckTimeoutOptions}
+ value={appConfig.providerHealthcheckTimeout}
+ onChange={(v) => updateAppConfig('providerHealthcheckTimeout', v)}
+ label={t('provider_healthcheck_timeout')}
+ />
+ </StackedRow>
+
+ <StackedRow label={t('latency_test_expected_status')}>
+ <ClearableInput
+ value={appConfig.latencyTestExpectedStatus}
+ placeholder="200/204"
+ onChange={handleExpectedStatusChange}
+ onClear={handleExpectedStatusClear}
+ ariaLabel={t('latency_test_expected_status')}
+ />
+ </StackedRow>
+
+ <Row label={t('prefer_backend_test_url')}>
+ <Switch
+ name="preferBackendLatencyTestUrl"
+ checked={appConfig.preferBackendLatencyTestUrl}
+ onChange={(v: boolean) => updateAppConfig('preferBackendLatencyTestUrl', v)}
+ />
+ </Row>
+
+ <hr className={s.divider} />
+ <p className={s.sectionTitle}>{t('settings_display')}</p>
+
+ <StackedRow label={t('sort_in_grp')}>
+ <SegmentedControl
+ options={sortOptions}
+ value={sortKey}
+ onChange={handleSortChange}
+ label={t('sort_in_grp')}
+ />
+ </StackedRow>
+
+ <Row label={t('hide_unavail_proxies')}>
+ <Switch
+ name="hideUnavailableProxies"
+ checked={appConfig.hideUnavailableProxies}
+ onChange={(v: boolean) => updateAppConfig('hideUnavailableProxies', v)}
+ />
+ </Row>
+
+ <Row label={t('double_column_layout')}>
+ <Switch
+ name="proxiesLayout"
+ checked={appConfig.proxiesLayout === 'double'}
+ onChange={(v: boolean) => updateAppConfig('proxiesLayout', v ? 'double' : 'single')}
+ />
+ </Row>
+
+ <Row label={t('group_by_provider')}>
+ <Switch
+ name="proxyGroupByProvider"
+ checked={appConfig.proxyGroupByProvider}
+ onChange={(v: boolean) => updateAppConfig('proxyGroupByProvider', v)}
+ />
+ </Row>
+
+ <hr className={s.divider} />
+ <p className={s.sectionTitle}>{t('settings_behavior')}</p>
+
+ <Row label={t('auto_close_conns')}>
+ <Switch
+ name="autoCloseOldConns"
+ checked={appConfig.autoCloseOldConns}
+ onChange={(v: boolean) => updateAppConfig('autoCloseOldConns', v)}
+ />
+ </Row>
+ </div>
);
}
diff --git a/src/components/proxies/index.tsx b/src/components/proxies/index.tsx
deleted file mode 100644
index 4ec01f9..0000000
--- a/src/components/proxies/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { ProxyList } from './ProxyList';
diff --git a/src/components/rules/Rule.module.scss b/src/components/rules/Rule.module.scss
index 48a22e7..69b6f3b 100644
--- a/src/components/rules/Rule.module.scss
+++ b/src/components/rules/Rule.module.scss
@@ -1,81 +1,135 @@
-@use '~/styles/utils/custom-media' as *;
-
.rule {
display: flex;
align-items: center;
- padding: 12px 20px;
- transition: background-color 0.2s ease;
- border-bottom: 1px solid var(--color-separator);
+ height: 100%;
+ padding: 10px 18px;
+ transition: background-color 0.15s ease;
&:hover {
- background-color: var(--bg-near-transparent);
+ background-color: var(--color-hover-soft);
+ }
+
+ &.disabled {
+ opacity: 0.45;
+ }
+
+ @media (max-width: 768px) {
+ padding: 10px 12px;
}
}
-.left {
+.index {
width: 40px;
flex-shrink: 0;
color: var(--color-text-secondary);
- font-size: 11px;
- opacity: 0.4;
+ font-size: 0.68rem;
+ opacity: 0.5;
font-family: var(--font-mono);
+
+ @media (max-width: 768px) {
+ width: 28px;
+ }
}
-.right {
+.main {
flex: 1;
min-width: 0;
- margin-left: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
}
.payloadRow {
display: flex;
align-items: baseline;
- margin-bottom: 4px;
+ gap: 10px;
+ min-width: 0;
}
.payload {
font-family: var(--font-mono);
- font-size: 13px;
+ font-size: 0.8rem;
color: var(--color-text);
- word-break: break-all;
line-height: 1.4;
+ word-break: break-all;
+ // 定高行装不下第三行,超出的部分省略掉
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
-.size {
- margin-left: 12px;
- font-size: 10px;
- color: var(--color-text-secondary);
- background: var(--bg-near-transparent);
+.entryCount {
+ flex-shrink: 0;
+ font-size: 0.65rem;
+ color: var(--color-badge-fg);
+ background: var(--color-badge-bg);
padding: 1px 6px;
- border-radius: 3px;
+ border-radius: 5px;
white-space: nowrap;
- text-transform: uppercase;
}
.metaRow {
display: flex;
align-items: center;
- gap: 12px;
- font-size: 11px;
+ gap: 10px;
+ font-size: 0.7rem;
+ min-width: 0;
}
.typeTag {
- display: flex;
+ display: inline-flex;
align-items: center;
gap: 4px;
- color: var(--color-text-secondary);
- background: var(--bg-near-transparent);
- padding: 2px 8px;
- border-radius: 4px;
+ flex-shrink: 0;
+ color: var(--color-badge-fg);
+ background: var(--color-badge-bg);
+ padding: 2px 7px;
+ border-radius: 5px;
span {
font-weight: 500;
text-transform: uppercase;
- font-size: 10px;
+ font-size: 0.62rem;
+ letter-spacing: 0.02em;
}
}
-.proxyTag {
+.proxy {
font-weight: 600;
- letter-spacing: 0.02em;
+ letter-spacing: 0.01em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.hitInfo {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+ margin-left: auto;
+ color: var(--color-text-secondary);
+ cursor: default;
+}
+
+// DOM 里排在最前,桌面端就在行首;用 mini 尺寸的开关,
+// 布局盒子和看到的一样大,不需要再拿 scale + transform-origin 去凑
+.switch {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ margin-right: 12px;
+
+ &.pending {
+ opacity: 0.6;
+ pointer-events: none;
+ }
+
+ // 窄屏维持原样:拨到行尾
+ @media (max-width: 768px) {
+ order: 1;
+ margin-right: 0;
+ margin-left: 12px;
+ }
}
diff --git a/src/components/rules/Rule.tsx b/src/components/rules/Rule.tsx
index e1fd047..0cb896a 100644
--- a/src/components/rules/Rule.tsx
+++ b/src/components/rules/Rule.tsx
@@ -1,74 +1,147 @@
-import React from 'react';
+import cx from 'clsx';
+import { formatDistanceToNow } from 'date-fns';
+import * as React from 'react';
+import { useTranslation } from 'react-i18next';
-import { FileText, Globe, Hash, Link, Shield, Zap } from '~/components/shared/FeatherIcons';
+import type { RuleExtra } from '~/api/rules';
+import {
+ Activity,
+ FileText,
+ Globe,
+ Hash,
+ Link,
+ Shield,
+ Zap,
+} from '~/components/shared/FeatherIcons';
+import SwitchThemed from '~/components/shared/SwitchThemed';
+import { useToggleRuleDisabled } from '~/modules/rules/hooks';
+import type { RuleProviderIndex } from '~/modules/rules/utils';
+import { ClashAPIConfig } from '~/types';
-import s0 from './Rule.module.scss';
+import s from './Rule.module.scss';
-const colorMap = {
- _default: 'var(--color-focus-blue)',
+const proxyColor: Record<string, string> = {
DIRECT: '#f5bc41',
REJECT: '#cb3166',
};
-function getStyleFor({ proxy }) {
- let color = colorMap._default;
- if (colorMap[proxy]) {
- color = colorMap[proxy];
- }
- return { color };
-}
-
function getIconFor(type: string) {
switch (type) {
case 'Domain':
case 'DomainSuffix':
case 'DomainKeyword':
- return <Link size={14} />;
+ return <Link size={12} />;
case 'IPCIDR':
case 'IPCIDR6':
- return <Hash size={14} />;
+ return <Hash size={12} />;
case 'GeoSite':
case 'GeoIP':
- return <Globe size={14} />;
+ return <Globe size={12} />;
case 'REJECT':
- return <Shield size={14} />;
+ return <Shield size={12} />;
case 'DIRECT':
- return <Zap size={14} />;
+ return <Zap size={12} />;
default:
- return <FileText size={14} />;
+ return <FileText size={12} />;
}
}
type Props = {
- id?: number;
- type?: string;
- payload?: string;
- proxy?: string;
- size?: number;
+ id: number;
+ type: string;
+ payload: string;
+ proxy: string;
+ size: number;
+ extra?: RuleExtra;
+ apiConfig: ClashAPIConfig;
+ provider?: RuleProviderIndex;
};
-function Rule({ type, payload, proxy, id, size }: Props) {
- const styleProxy = getStyleFor({ proxy });
+/** GeoSite/GeoIP 的条目数后端直接给,RuleSet 的要去提供商表里查 */
+function getEntryCount({
+ type,
+ payload,
+ size,
+ provider,
+}: {
+ type: string;
+ payload: string;
+ size: number;
+ provider?: RuleProviderIndex;
+}): number | undefined {
+ if ((type === 'GeoSite' || type === 'GeoIP') && size >= 0) {
+ return size;
+ }
+ if (type === 'RuleSet') {
+ return provider?.byName?.[payload]?.ruleCount;
+ }
+ return undefined;
+}
+
+function Rule({ type, payload, proxy, id, size, extra, apiConfig, provider }: Props) {
+ const { t } = useTranslation();
+ const { toggleRule, isPending } = useToggleRuleDisabled(apiConfig);
+ const disabled = extra?.disabled ?? false;
+ const entryCount = getEntryCount({ type, payload, size, provider });
+
+ const hitTitle = extra
+ ? extra.hitCount > 0
+ ? t('rule_hit_tip', {
+ count: extra.hitCount,
+ time: formatDistanceToNow(new Date(extra.hitAt), { addSuffix: true }),
+ })
+ : t('rule_never_hit')
+ : undefined;
+
return (
- <div className={s0.rule}>
- <div className={s0.left}>{id}</div>
- <div className={s0.right}>
- <div className={s0.payloadRow}>
- <div className={s0.payload}>{payload}</div>
- {(type === 'GeoSite' || type === 'GeoIP') && <div className={s0.size}>size: {size}</div>}
+ <div className={cx(s.rule, { [s.disabled]: disabled })}>
+ {/* 桌面端在最左边,窄屏靠 .switch 的 order 拨到行尾 */}
+ {extra ? (
+ <div
+ className={cx(s.switch, { [s.pending]: isPending })}
+ title={disabled ? t('rule_enable') : t('rule_disable')}
+ >
+ <SwitchThemed
+ size="mini"
+ name={`rule-${id}`}
+ checked={!disabled}
+ onChange={(checked: boolean) => toggleRule(id, !checked)}
+ />
+ </div>
+ ) : null}
+
+ <div className={s.index}>{id}</div>
+
+ <div className={s.main}>
+ <div className={s.payloadRow}>
+ <div className={s.payload}>{payload}</div>
+ {typeof entryCount === 'number' ? (
+ <div className={s.entryCount}>{t('rule_entry_count', { count: entryCount })}</div>
+ ) : null}
</div>
- <div className={s0.metaRow}>
- <div className={s0.typeTag}>
+
+ <div className={s.metaRow}>
+ <span className={s.typeTag}>
{getIconFor(type)}
<span>{type}</span>
- </div>
- <div className={s0.proxyTag} style={styleProxy}>
+ </span>
+ <span
+ className={s.proxy}
+ style={{ color: proxyColor[proxy] ?? 'var(--color-focus-blue)' }}
+ >
{proxy}
- </div>
+ </span>
+ {extra ? (
+ <span className={s.hitInfo} title={hitTitle}>
+ <Activity size={12} />
+ {extra.hitCount}
+ </span>
+ ) : null}
</div>
</div>
</div>
);
}
-export default Rule;
+// 上千行的虚拟列表,滚动时不该因为父组件重渲染而整屏重算
+export default React.memo(Rule);
diff --git a/src/components/rules/RuleProviderItem.module.scss b/src/components/rules/RuleProviderItem.module.scss
index 933fa08..df321ca 100644
--- a/src/components/rules/RuleProviderItem.module.scss
+++ b/src/components/rules/RuleProviderItem.module.scss
@@ -1,29 +1,37 @@
-.RuleProviderItem {
+.item {
display: flex;
align-items: center;
height: 100%;
- padding: 16px 20px;
- transition: all 0.2s ease;
+ padding: 12px 18px;
+ transition: background-color 0.15s ease;
&:hover {
- background: var(--bg-near-transparent);
+ background: var(--color-hover-soft);
- .refreshButton {
+ .refreshBtn {
opacity: 1;
}
}
+
+ @media (max-width: 768px) {
+ padding: 12px;
+ }
}
-.left {
- width: 32px;
+.index {
+ width: 40px;
flex-shrink: 0;
color: var(--color-text-secondary);
- font-size: 11px;
- opacity: 0.4;
+ font-size: 0.68rem;
+ opacity: 0.5;
font-family: var(--font-mono);
+
+ @media (max-width: 768px) {
+ width: 28px;
+ }
}
-.middle {
+.main {
flex: 1;
min-width: 0;
display: flex;
@@ -34,30 +42,29 @@
.nameRow {
display: flex;
align-items: center;
- flex-wrap: wrap;
gap: 8px;
+ min-width: 0;
}
.name {
- font-size: 14px;
+ font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-highlight);
-}
-
-.badgeGroup {
- display: flex;
- gap: 6px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.badge {
- display: flex;
+ display: inline-flex;
align-items: center;
gap: 4px;
- font-size: 10px;
- color: var(--color-text-secondary);
- background: var(--bg-near-transparent);
- padding: 2px 6px;
- border-radius: 4px;
+ flex-shrink: 0;
+ font-size: 0.62rem;
+ color: var(--color-badge-fg);
+ background: var(--color-badge-bg);
+ padding: 2px 7px;
+ border-radius: 5px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
@@ -66,41 +73,52 @@
display: flex;
align-items: center;
gap: 8px;
- font-size: 11px;
+ font-size: 0.7rem;
color: var(--color-text-secondary);
}
.dot {
- opacity: 0.5;
+ opacity: 0.4;
}
-.right {
+.refreshBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ appearance: none;
+ flex-shrink: 0;
margin-left: 12px;
-}
-
-.refreshButton {
- opacity: 0.4;
- transition: all 0.2s ease;
- padding: 8px !important;
- border-radius: 50% !important;
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 8px;
+ background: none;
color: var(--color-text-secondary);
+ cursor: pointer;
+ // 悬停在整行上才完全显形,静态时不抢眼
+ opacity: 0.45;
+ transition:
+ opacity 0.15s ease,
+ background-color 0.15s ease,
+ color 0.15s ease;
- &:hover {
- opacity: 1;
+ &:hover:not(:disabled) {
color: var(--color-focus-blue);
- background: var(--bg-near-transparent) !important;
+ background: var(--color-hover-soft);
}
-}
-.rotating {
- animation: rotate 1s linear infinite;
-}
+ &:focus-visible {
+ opacity: 1;
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: 2px;
+ }
-@keyframes rotate {
- from {
- transform: rotate(0deg);
+ &:disabled {
+ cursor: default;
}
- to {
- transform: rotate(360deg);
+
+ // 触屏没有 hover,直接常显
+ @media (hover: none) {
+ opacity: 1;
}
}
diff --git a/src/components/rules/RuleProviderItem.tsx b/src/components/rules/RuleProviderItem.tsx
index 4cd5773..ec9732b 100644
--- a/src/components/rules/RuleProviderItem.tsx
+++ b/src/components/rules/RuleProviderItem.tsx
@@ -1,13 +1,18 @@
import { formatDistance } from 'date-fns';
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
-import Button from '~/components/Button';
-import { Activity, Database, RefreshCw } from '~/components/shared/FeatherIcons';
+import type { RuleProvider } from '~/api/rule-provider';
+import { Activity, Database } from '~/components/shared/FeatherIcons';
+import { RotateIcon } from '~/components/shared/RotateIcon';
import { useUpdateRuleProviderItem } from '~/modules/rules/hooks';
+import { ClashAPIConfig } from '~/types';
import s from './RuleProviderItem.module.scss';
-export function RuleProviderItem({
+type Props = RuleProvider & { apiConfig: ClashAPIConfig };
+
+function RuleProviderItemInner({
idx,
name,
vehicleType,
@@ -15,42 +20,46 @@ export function RuleProviderItem({
updatedAt,
ruleCount,
apiConfig,
-}) {
- const [onClickRefreshButton, isRefreshing] = useUpdateRuleProviderItem(name, apiConfig);
+}: Props) {
+ const { t } = useTranslation();
+ const [refresh, isRefreshing] = useUpdateRuleProviderItem(name, apiConfig);
const timeAgo = formatDistance(new Date(updatedAt), new Date());
+
return (
- <div className={s.RuleProviderItem}>
- <div className={s.left}>{idx}</div>
- <div className={s.middle}>
+ <div className={s.item}>
+ <div className={s.index}>{idx}</div>
+
+ <div className={s.main}>
<div className={s.nameRow}>
<span className={s.name}>{name}</span>
- <div className={s.badgeGroup}>
- <span className={s.badge}>
- <Database size={12} />
- {vehicleType}
- </span>
- <span className={s.badge}>
- <Activity size={12} />
- {behavior}
- </span>
- </div>
+ <span className={s.badge}>
+ <Database size={11} />
+ {vehicleType}
+ </span>
+ <span className={s.badge}>
+ <Activity size={11} />
+ {behavior}
+ </span>
</div>
<div className={s.infoRow}>
- <span className={s.count}>{ruleCount} rules</span>
+ <span>{t('rule_entry_count', { count: ruleCount })}</span>
<span className={s.dot}>•</span>
- <span className={s.time}>Updated {timeAgo} ago</span>
+ <span>{t('updated_ago', { time: timeAgo })}</span>
</div>
</div>
- <div className={s.right}>
- <Button
- kind="minimal"
- onClick={onClickRefreshButton}
- disabled={isRefreshing}
- className={s.refreshButton}
- >
- <RefreshCw size={18} className={isRefreshing ? s.rotating : ''} />
- </Button>
- </div>
+
+ <button
+ type="button"
+ className={s.refreshBtn}
+ onClick={refresh}
+ disabled={isRefreshing}
+ aria-label={t('update_rule_provider')}
+ title={t('update_rule_provider')}
+ >
+ <RotateIcon isRotating={isRefreshing} />
+ </button>
</div>
);
}
+
+export const RuleProviderItem = React.memo(RuleProviderItemInner);
diff --git a/src/components/rules/Rules.module.scss b/src/components/rules/Rules.module.scss
index f764d09..701fe14 100644
--- a/src/components/rules/Rules.module.scss
+++ b/src/components/rules/Rules.module.scss
@@ -1,97 +1,90 @@
-@use '~/styles/utils/custom-media' as *;
-
-.container {
+.page {
display: flex;
flex-direction: column;
height: 100%;
+ min-height: 0;
}
-.filterWrapper {
- width: 100%;
- max-width: 300px;
+.listArea {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ margin: 12px 32px 24px;
+
+ @media (max-width: 1024px) {
+ margin: 12px 16px 16px;
+ }
@media (max-width: 768px) {
- order: 3;
- flex: 1 1 100%;
- max-width: none;
- margin-top: 8px;
+ margin: 8px 12px 10px;
}
}
-.listWrapper {
- margin: 10px 45px 20px;
- background-color: var(--bg-log-info-card);
- border-radius: 12px;
+// 与连接页表格同一张卡:窄屏下退掉卡片边界,直接铺在页面底色上
+.card {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ border-radius: 16px;
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
box-shadow: var(--shadow-card);
- border: 1px solid var(--color-separator);
overflow: hidden;
@media (max-width: 768px) {
- margin: 10px 15px 15px;
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ border-radius: 0;
}
}
-.RuleProviderItemWrapper {
- border-bottom: 1px solid var(--color-separator);
+.listWrap {
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
}
-.tabsContainer {
- display: flex;
- align-items: center;
- background-color: var(--color-bg-sidebar);
- border-radius: 12px;
- padding: 4px;
-
- @media (max-width: 768px) {
- order: 1;
- flex: 1 1 100%;
- width: 100%;
- }
+.row {
+ border-bottom: 1px solid var(--color-card-border);
}
-.tab {
+/* ---------- 空态 ---------- */
+
+.empty {
display: flex;
+ flex-direction: column;
align-items: center;
- padding: 8px 16px;
- border-radius: 8px;
- cursor: pointer;
- font-size: 1em;
- font-weight: 500;
- color: var(--color-text-secondary);
- transition: all 0.2s ease;
- user-select: none;
-
- @media (max-width: 768px) {
- padding: 6px 10px;
- font-size: 0.85em;
- flex: 1;
- justify-content: center;
- min-width: 0;
- white-space: nowrap;
- }
+ justify-content: center;
+ gap: 6px;
+ height: 100%;
+}
- &:hover {
- color: var(--color-focus-blue);
- background: rgba(176, 206, 255, 0.221);
- }
+.emptyTitle {
+ font-size: 0.88rem;
+ color: var(--color-text);
+}
- &.active {
- background-color: var(--color-focus-blue);
- color: #fff;
- }
+.emptyHint {
+ font-size: 0.78rem;
+ color: var(--color-text-secondary);
}
-.tabCount {
- font-family: var(--font-normal);
- font-size: 0.7em;
- margin-left: 6px;
- padding: 2px 8px;
- display: inline-flex;
- justify-content: center;
+/* ---------- 页脚 ---------- */
+
+.footer {
+ display: flex;
align-items: center;
- background-color: rgba(255, 255, 255, 0.15);
- border-radius: 10px;
- font-weight: 600;
- min-width: 20px;
+ gap: 14px;
+ height: 36px;
flex-shrink: 0;
+ padding: 0 18px;
+ border-top: 1px solid var(--color-card-border);
+ background: var(--color-track);
+ font-size: 0.72rem;
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
}
diff --git a/src/components/rules/Rules.tsx b/src/components/rules/Rules.tsx
index c0b02ae..5bf00a5 100644
--- a/src/components/rules/Rules.tsx
+++ b/src/components/rules/Rules.tsx
@@ -1,42 +1,29 @@
-import cx from 'clsx';
-import React from 'react';
+import * as React from 'react';
import { useTranslation } from 'react-i18next';
-import { List as VirtualList, RowComponentProps } from 'react-window';
+import { RowComponentProps, List as VirtualList } from 'react-window';
-import ContentHeader from '~/components/ContentHeader';
-import { TextFilter } from '~/components/shared/TextFitler';
-import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight';
-import { useRulesPage } from '~/modules/rules/hooks';
-import { formatQty, getItemSizeFactory, RulesListItemData } from '~/modules/rules/utils';
-import { ruleFilterText } from '~/store/rules';
+import { useRulesPage, useUpdateAllRuleProviderItems } from '~/modules/rules/hooks';
+import { PROVIDER_ROW_HEIGHT, RULE_ROW_HEIGHT, type RulesRowProps } from '~/modules/rules/utils';
import { ClashAPIConfig } from '~/types';
import Rule from './Rule';
import { RuleProviderItem } from './RuleProviderItem';
import s from './Rules.module.scss';
-import { RulesPageFab } from './RulesPageFab';
-
-type RulesRowProps = {
- data: RulesListItemData;
-};
-
-function Row({ index, style, data }: RowComponentProps<RulesRowProps>) {
- const { rules, provider, apiConfig } = data;
+import { RulesHeader } from './RulesHeader';
+function Row({ index, style, rules, provider, apiConfig }: RowComponentProps<RulesRowProps>) {
if (!rules) {
- const name = provider.names[index];
- const item = provider.byName[name];
+ const item = provider.byName[provider.names[index]];
return (
- <div style={style} className={s.RuleProviderItemWrapper}>
+ <div style={style} className={s.row}>
<RuleProviderItem apiConfig={apiConfig} {...item} />
</div>
);
}
- const r = rules[index];
return (
- <div style={style}>
- <Rule {...r} />
+ <div style={style} className={s.row}>
+ <Rule {...rules[index]} apiConfig={apiConfig} provider={provider} />
</div>
);
}
@@ -46,59 +33,69 @@ type RulesProps = {
};
export default function Rules({ apiConfig }: RulesProps) {
- const [refRulesContainer, containerHeight] = useRemainingViewPortHeight();
- const { rules, provider, activeTab, setActiveTab, isRulesTab, handleTabKeyDown } =
+ const { t } = useTranslation();
+ const { rules, provider, providerCount, activeTab, setActiveTab, isRulesTab } =
useRulesPage(apiConfig);
- const getItemSize = getItemSizeFactory({ isRulesTab });
+ const [updateAllProviders, isUpdatingProviders] = useUpdateAllRuleProviderItems(apiConfig);
- const { t } = useTranslation();
+ const rowCount = isRulesTab ? rules.length : provider.names.length;
+
+ // rowProps 每次渲染新建对象会让所有行跟着重渲染,虚拟列表就白做了
+ const rowProps = React.useMemo<RulesRowProps>(
+ () => ({ rules: isRulesTab ? rules : null, provider, apiConfig }),
+ [isRulesTab, rules, provider, apiConfig],
+ );
+
+ const rowHeight = React.useCallback(
+ () => (isRulesTab ? RULE_ROW_HEIGHT : PROVIDER_ROW_HEIGHT),
+ [isRulesTab],
+ );
+
+ const rowKey = React.useCallback(
+ (index: number, { rules, provider }: RulesRowProps) =>
+ rules ? rules[index].id : provider.names[index],
+ [],
+ );
return (
- <div className={s.container}>
- <ContentHeader>
- <div className={s.tabsContainer}>
- <div
- className={cx(s.tab, { [s.active]: activeTab === 'rules' })}
- onClick={() => setActiveTab('rules')}
- onKeyDown={handleTabKeyDown('rules')}
- role="button"
- tabIndex={0}
- >
- {t('Rules')}
- <span className={s.tabCount}>{formatQty(rules.length)}</span>
+ <div className={s.page}>
+ <RulesHeader
+ activeTab={activeTab}
+ setActiveTab={setActiveTab}
+ ruleCount={rules.length}
+ providerCount={providerCount}
+ visibleProviderCount={provider.names.length}
+ onUpdateAllProviders={updateAllProviders}
+ isUpdatingProviders={isUpdatingProviders}
+ />
+
+ <div className={s.listArea}>
+ <div className={s.card}>
+ <div className={s.listWrap}>
+ {rowCount === 0 ? (
+ <div className={s.empty}>
+ <span className={s.emptyTitle}>
+ {t(isRulesTab ? 'rules_empty_title' : 'rule_providers_empty_title')}
+ </span>
+ <span className={s.emptyHint}>{t('rules_empty_hint')}</span>
+ </div>
+ ) : (
+ <VirtualList
+ style={{ height: '100%', width: '100%' }}
+ rowCount={rowCount}
+ rowHeight={rowHeight}
+ rowComponent={Row}
+ rowKey={rowKey}
+ rowProps={rowProps}
+ />
+ )}
+ </div>
+
+ <div className={s.footer}>
+ <span>{t('rules_shown', { count: rowCount })}</span>
</div>
- {provider.names.length > 0 && (
- <div
- className={cx(s.tab, { [s.active]: activeTab === 'providers' })}
- onClick={() => setActiveTab('providers')}
- onKeyDown={handleTabKeyDown('providers')}
- role="button"
- tabIndex={0}
- >
- {t('rule_provider')}
- <span className={s.tabCount}>{formatQty(provider.names.length)}</span>
- </div>
- )}
- </div>
- <div style={{ flex: 1 }} />
- <div className={s.filterWrapper}>
- <TextFilter textAtom={ruleFilterText} placeholder={t('Search')} />
</div>
- </ContentHeader>
- <div ref={refRulesContainer} className={s.listWrapper}>
- <VirtualList
- style={{ height: containerHeight, width: '100%' }}
- rowCount={isRulesTab ? rules.length : provider.names.length}
- rowHeight={getItemSize}
- rowComponent={Row}
- rowProps={{
- data: { rules: isRulesTab ? rules : null, provider, apiConfig } as RulesListItemData,
- }}
- />
</div>
- {provider && provider.names && provider.names.length > 0 ? (
- <RulesPageFab apiConfig={apiConfig} />
- ) : null}
</div>
);
}
diff --git a/src/components/rules/RulesHeader.tsx b/src/components/rules/RulesHeader.tsx
new file mode 100644
index 0000000..f15a863
--- /dev/null
+++ b/src/components/rules/RulesHeader.tsx
@@ -0,0 +1,80 @@
+import { useTranslation } from 'react-i18next';
+
+import {
+ HeaderActions,
+ HeaderButton,
+ HeaderSearch,
+ HeaderTab,
+ HeaderTabs,
+ HeaderTitle,
+ PageHeader,
+} from '~/components/shared/PageHeader';
+import { RotateIcon } from '~/components/shared/RotateIcon';
+import { TextFilter } from '~/components/shared/TextFilter';
+import type { RulesTabKey } from '~/modules/rules/utils';
+import { ruleFilterText } from '~/store/rules';
+
+type Props = {
+ activeTab: RulesTabKey;
+ setActiveTab: (tab: RulesTabKey) => void;
+ /** 搜索后可见的规则数 */
+ ruleCount: number;
+ /** 提供商总数,决定标签是否出现 */
+ providerCount: number;
+ /** 搜索后可见的提供商数,只影响标签上的计数 */
+ visibleProviderCount: number;
+ onUpdateAllProviders: () => void;
+ isUpdatingProviders: boolean;
+};
+
+export function RulesHeader({
+ activeTab,
+ setActiveTab,
+ ruleCount,
+ providerCount,
+ visibleProviderCount,
+ onUpdateAllProviders,
+ isUpdatingProviders,
+}: Props) {
+ const { t } = useTranslation();
+
+ return (
+ <PageHeader>
+ <HeaderTitle>{t('Rules')}</HeaderTitle>
+
+ <HeaderTabs label={t('Rules')}>
+ <HeaderTab
+ active={activeTab === 'rules'}
+ label={t('Rules')}
+ count={ruleCount}
+ onClick={() => setActiveTab('rules')}
+ />
+ {providerCount > 0 ? (
+ <HeaderTab
+ active={activeTab === 'providers'}
+ label={t('rule_provider')}
+ count={visibleProviderCount}
+ onClick={() => setActiveTab('providers')}
+ />
+ ) : null}
+ </HeaderTabs>
+
+ <HeaderSearch>
+ <TextFilter textAtom={ruleFilterText} placeholder={t('search_rules_placeholder')} />
+ </HeaderSearch>
+
+ <HeaderActions>
+ {/* 和代理页一致:更新全部只在提供商标签页出现 */}
+ {activeTab === 'providers' && providerCount > 0 ? (
+ <HeaderButton
+ variant="primary"
+ icon={<RotateIcon isRotating={isUpdatingProviders} />}
+ label={t('update_all_rule_provider')}
+ busy={isUpdatingProviders}
+ onClick={onUpdateAllProviders}
+ />
+ ) : null}
+ </HeaderActions>
+ </PageHeader>
+ );
+}
diff --git a/src/components/rules/RulesPageFab.tsx b/src/components/rules/RulesPageFab.tsx
deleted file mode 100644
index f9dee23..0000000
--- a/src/components/rules/RulesPageFab.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import * as React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import { Fab, position as fabPosition } from '~/components/shared/Fab';
-import { RotateIcon } from '~/components/shared/RotateIcon';
-import { useUpdateAllRuleProviderItems } from '~/modules/rules/hooks';
-import { ClashAPIConfig } from '~/types';
-
-type RulesPageFabProps = {
- apiConfig: ClashAPIConfig;
-};
-
-export function RulesPageFab({ apiConfig }: RulesPageFabProps) {
- const [update, isLoading] = useUpdateAllRuleProviderItems(apiConfig);
- const { t } = useTranslation();
- return (
- <Fab
- icon={<RotateIcon isRotating={isLoading} />}
- text={t('update_all_rule_provider')}
- style={fabPosition}
- onClick={update}
- />
- );
-}
diff --git a/src/components/shared/BaseModal.module.scss b/src/components/shared/BaseModal.module.scss
index 229b920..0d24a74 100644
--- a/src/components/shared/BaseModal.module.scss
+++ b/src/components/shared/BaseModal.module.scss
@@ -2,16 +2,10 @@
background-color: rgba(0, 0, 0, 0.6);
}
.cnt {
- position: absolute;
background-color: var(--bg-modal);
color: var(--color-text);
line-height: 1.4;
- opacity: 0.6;
- transition: all 0.3s ease;
- // transform: scale(1.2);
- box-shadow: rgba(0, 0, 0, 0.12) 0px 4px 4px, rgba(0, 0, 0, 0.24) 0px 16px 32px;
-}
-.afterOpen {
- opacity: 1;
- // transform: scale(1);
+ box-shadow:
+ rgba(0, 0, 0, 0.12) 0px 4px 4px,
+ rgba(0, 0, 0, 0.24) 0px 16px 32px;
}
diff --git a/src/components/shared/BaseModal.tsx b/src/components/shared/BaseModal.tsx
index 4c166db..c9c28b1 100644
--- a/src/components/shared/BaseModal.tsx
+++ b/src/components/shared/BaseModal.tsx
@@ -1,34 +1,24 @@
import cx from 'clsx';
import * as React from 'react';
-import Modal from '../Modal';
-import modalStyle from '../Modal.module.scss';
-
import s from './BaseModal.module.scss';
-
-const { useMemo } = React;
+import Modal from './Modal';
type BaseModalProps = {
isOpen: boolean;
onRequestClose: (...args: any[]) => unknown;
+ title?: string;
children: React.ReactNode;
};
-export default function BaseModal({ isOpen, onRequestClose, children }: BaseModalProps) {
- const className = useMemo(
- () => ({
- base: cx(modalStyle.content, s.cnt),
- afterOpen: s.afterOpen,
- beforeClose: '',
- }),
- []
- );
+export default function BaseModal({ isOpen, onRequestClose, title, children }: BaseModalProps) {
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
- className={className}
- overlayClassName={cx(modalStyle.overlay, s.overlay)}
+ title={title}
+ className={s.cnt}
+ overlayClassName={cx(s.overlay)}
>
{children}
</Modal>
diff --git a/src/components/shared/Basic.module.scss b/src/components/shared/Basic.module.scss
index df412e5..cb50df0 100644
--- a/src/components/shared/Basic.module.scss
+++ b/src/components/shared/Basic.module.scss
@@ -1,20 +1,5 @@
@use '~/styles/utils/custom-media' as *;
-h2.sectionNameType {
- margin: 0;
- font-size: 1em;
- @media (--breakpoint-not-small) {
- font-size: 1.3em;
- }
-
- span:nth-child(2) {
- font-size: 12px;
- color: #777;
- font-weight: normal;
- margin: 0 0.3em;
- }
-}
-
@mixin light {
--loading-dot-1-1: rgba(0, 0, 0, 0.1);
--loading-dot-1-2: rgba(0, 0, 0, 0.5);
diff --git a/src/components/shared/Basic.tsx b/src/components/shared/Basic.tsx
index 7071938..588fd12 100644
--- a/src/components/shared/Basic.tsx
+++ b/src/components/shared/Basic.tsx
@@ -2,15 +2,6 @@ import React from 'react';
import s from './Basic.module.scss';
-export function SectionNameType({ name, type }) {
- return (
- <h2 className={s.sectionNameType}>
- <span style={{ marginRight: 5 }}>{name}</span>
- <span>{type}</span>
- </h2>
- );
-}
-
export function LoadingDot() {
return <span className={s.loadingDot} />;
}
diff --git a/src/components/Button.module.scss b/src/components/shared/Button.module.scss
index 4f89d4c..8d2ca46 100644
--- a/src/components/Button.module.scss
+++ b/src/components/shared/Button.module.scss
@@ -9,22 +9,25 @@
display: inline-flex;
align-items: center;
justify-content: center;
- color: var(--color-btn-fg);
- background: var(--color-btn-bg);
- border: 1px solid var(--color-input-border);
+ // 与顶栏的 .btnGhost 同一套:卡片色底 + 卡片边框色描边,
+ // 悬停是蓝描边而不是整块填蓝
+ color: var(--color-text);
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
border-radius: 100px;
- transition: all 0.2s ease;
+ transition:
+ color 0.2s ease,
+ background-color 0.2s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
- &:focus {
+ &:focus-visible {
border-color: var(--color-focus-blue);
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
+ box-shadow: 0 0 0 3px var(--color-accent-soft-bg);
}
- &:hover {
- color: #fff;
- background: var(--color-focus-blue);
+ &:hover:not(:disabled) {
+ color: var(--color-focus-blue);
border-color: var(--color-focus-blue);
- transform: translateY(-1px);
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
font-size: 0.75em;
@@ -37,13 +40,10 @@
&.minimal {
border-color: transparent;
background: none;
- &:focus {
- border-color: var(--color-focus-blue);
- }
- &:hover {
- color: #fff;
- background: var(--color-focus-blue);
- border: 1px solid var(--color-focus-blue);
+ &:hover:not(:disabled) {
+ color: var(--color-focus-blue);
+ background: var(--color-hover-soft);
+ border-color: transparent;
}
}
}
diff --git a/src/components/Button.tsx b/src/components/shared/Button.tsx
index 9bd0d61..5ecf029 100644
--- a/src/components/Button.tsx
+++ b/src/components/shared/Button.tsx
@@ -1,8 +1,8 @@
import cx from 'clsx';
import * as React from 'react';
+import { LoadingDot } from './Basic';
import s0 from './Button.module.scss';
-import { LoadingDot } from './shared/Basic';
const { forwardRef, useCallback } = React;
@@ -37,11 +37,11 @@ function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) {
} = props;
const internalProps = { children, label, text, start };
const internalOnClick = useCallback(
- (e) => {
+ (e: React.MouseEvent<HTMLButtonElement>) => {
if (isLoading) return;
onClick && onClick(e);
},
- [isLoading, onClick]
+ [isLoading, onClick],
);
const btnClassName = cx(s0.btn, { [s0.minimal]: kind === 'minimal' }, className);
return (
diff --git a/src/components/Collapsible.tsx b/src/components/shared/Collapsible.tsx
index 6948cef..bc723a0 100644
--- a/src/components/Collapsible.tsx
+++ b/src/components/shared/Collapsible.tsx
@@ -1,4 +1,4 @@
-import { LazyMotion, domAnimation, m } from 'framer-motion';
+import { domAnimation, LazyMotion, m } from 'framer-motion';
import React from 'react';
const { memo } = React;
diff --git a/src/components/shared/Fab.module.scss b/src/components/shared/Fab.module.scss
deleted file mode 100644
index 61aaecb..0000000
--- a/src/components/shared/Fab.module.scss
+++ /dev/null
@@ -1,33 +0,0 @@
-.spining {
- position: relative;
- border-radius: 50%;
- background: linear-gradient(60deg, #e66465, #9198e5);
-
- width: 48px;
- height: 48px;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-.spining:before {
- content: '';
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- border: 2px solid transparent;
- border-top-color: currentColor;
- border-radius: 50%;
- animation: spining_keyframes 1s linear infinite;
-}
-
-@keyframes spining_keyframes {
- 0% {
- transform: rotate(0);
- }
- 100% {
- transform: rotate(360deg);
- }
-}
diff --git a/src/components/shared/Fab.tsx b/src/components/shared/Fab.tsx
deleted file mode 100644
index 49c9a89..0000000
--- a/src/components/shared/Fab.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-// adapted from https://github.com/dericgw/react-tiny-fab/blob/master/src/index.tsx
-import './rtf.css';
-
-import * as React from 'react';
-
-import s from './Fab.module.scss';
-
-const { useState } = React;
-
-export function IsFetching({ children }: { children: React.ReactNode }) {
- return <span className={s.spining}>{children}</span>;
-}
-
-export const position = {
- right: 10,
- bottom: 10,
-};
-
-interface ABProps extends React.HTMLAttributes<HTMLButtonElement> {
- text?: string;
- onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown;
- 'data-testid'?: string;
-}
-
-const AB: React.FC<ABProps> = ({ children, ...p }) => (
- <button type="button" {...p} className="rtf--ab">
- {children}
- </button>
-);
-
-interface MBProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'tabIndex'> {
- tabIndex?: number;
-}
-
-export const MB: React.FC<MBProps> = ({ children, ...p }) => (
- <button type="button" className="rtf--mb" {...p}>
- {children}
- </button>
-);
-
-const defaultStyles: React.CSSProperties = { bottom: 24, right: 24 };
-
-interface FabProps {
- event?: 'hover' | 'click';
- style?: React.CSSProperties;
- alwaysShowTitle?: boolean;
- icon?: React.ReactNode;
- mainButtonStyles?: React.CSSProperties;
- onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown;
- text?: string;
- children?: React.ReactNode;
-}
-
-const Fab: React.FC<FabProps> = ({
- event = 'hover',
- style = defaultStyles,
- alwaysShowTitle = false,
- children,
- icon,
- mainButtonStyles,
- onClick,
- text,
- ...p
-}) => {
- const [isOpen, setIsOpen] = useState(false);
- const ariaHidden = alwaysShowTitle || !isOpen;
- const open = () => setIsOpen(true);
- const close = () => setIsOpen(false);
- const enter = () => event === 'hover' && open();
- const leave = () => event === 'hover' && close();
- const toggle = (e: React.MouseEvent<HTMLButtonElement>) => {
- if (onClick) {
- return onClick(e);
- }
- e.persist();
- return event === 'click' ? (isOpen ? close() : open()) : null;
- };
-
- const actionOnClick = (
- e: React.MouseEvent<HTMLButtonElement>,
- userFunc: (e: React.MouseEvent<HTMLButtonElement>) => unknown
- ) => {
- e.persist();
- setIsOpen(false);
- setTimeout(() => {
- userFunc(e);
- }, 1);
- };
-
- const rc = () =>
- React.Children.map(children, (ch, i) => {
- if (React.isValidElement<ABProps>(ch)) {
- return (
- <li className={`rtf--ab__c ${'top' in style ? 'top' : ''}`}>
- {React.cloneElement(ch, {
- 'data-testid': `action-button-${i}`,
- 'aria-label': ch.props.text || `Menu button ${i + 1}`,
- 'aria-hidden': ariaHidden,
- tabIndex: isOpen ? 0 : -1,
- ...ch.props,
- onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
- if (ch.props.onClick) actionOnClick(e, ch.props.onClick);
- },
- })}
- {ch.props.text && (
- <span
- className={`${'right' in style ? 'right' : ''} ${
- alwaysShowTitle ? 'always-show' : ''
- }`}
- aria-hidden={ariaHidden}
- >
- {ch.props.text}
- </span>
- )}
- </li>
- );
- }
- return null;
- });
-
- return (
- <ul
- onMouseEnter={enter}
- onMouseLeave={leave}
- className={`rtf ${isOpen ? 'open' : 'closed'}`}
- data-testid="fab"
- style={style}
- {...p}
- >
- <li className="rtf--mb__c">
- <MB
- onClick={toggle}
- style={mainButtonStyles}
- data-testid="main-button"
- role="button"
- aria-label="Floating menu"
- tabIndex={0}
- >
- {icon}
- </MB>
- {text && (
- <span
- className={`${'right' in style ? 'right' : ''} ${alwaysShowTitle ? 'always-show' : ''}`}
- aria-hidden={ariaHidden}
- >
- {text}
- </span>
- )}
- <ul>{rc()}</ul>
- </li>
- </ul>
- );
-};
-
-export { Fab, AB as Action };
diff --git a/src/components/shared/FeatherIcons.ts b/src/components/shared/FeatherIcons.ts
index ca9c359..b821ecb 100644
--- a/src/components/shared/FeatherIcons.ts
+++ b/src/components/shared/FeatherIcons.ts
@@ -1,14 +1,17 @@
export {
Activity,
+ AlertCircle,
ArrowDown,
ArrowDownCircle,
ArrowUp,
+ CheckCircle,
ChevronDown,
ChevronUp,
Cpu,
Database,
Download,
DownloadCloud,
+ Edit3,
Eye,
EyeOff,
FileText,
@@ -26,6 +29,7 @@ export {
RefreshCcw,
RefreshCw,
RotateCw,
+ Search,
Settings,
Shield,
Sliders,
diff --git a/src/components/shared/Head.tsx b/src/components/shared/Head.tsx
index 85783c1..aa92443 100644
--- a/src/components/shared/Head.tsx
+++ b/src/components/shared/Head.tsx
@@ -1,9 +1,10 @@
import * as React from 'react';
-import { connect } from '~/components/StateProvider';
import { getClashAPIConfig, getClashAPIConfigs } from '~/store/app';
+import { connect } from '~/store/StateProvider';
+import type { State } from '~/store/types';
-const mapState = (s) => ({
+const mapState = (s: State) => ({
apiConfig: getClashAPIConfig(s),
apiConfigs: getClashAPIConfigs(s),
});
diff --git a/src/components/shared/Input.module.scss b/src/components/shared/Input.module.scss
new file mode 100644
index 0000000..dfa55b5
--- /dev/null
+++ b/src/components/shared/Input.module.scss
@@ -0,0 +1,36 @@
+.input {
+ -webkit-appearance: none;
+ // 和顶栏搜索框一套:凹槽色垫底 + 卡片边框色描边,垫在卡片上才看得出是输入区
+ background-color: var(--color-track);
+ background-image: none;
+ border-radius: 8px;
+ border: 1px solid var(--color-card-border);
+ box-sizing: border-box;
+ color: var(--color-text);
+ display: inline-block;
+ height: 35px;
+ outline: none;
+ padding: 0 15px;
+ width: 100%;
+ font-size: small;
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+
+ &::placeholder {
+ color: var(--color-text-secondary);
+ opacity: 0.75;
+ }
+}
+
+.input:focus {
+ border-color: var(--color-focus-blue);
+ // 与开关的聚焦圈同一个 token,别再写死一个对不上的蓝
+ box-shadow: 0 0 0 3px var(--color-accent-soft-bg);
+}
+
+input::-webkit-outer-spin-button,
+input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
diff --git a/src/components/Input.tsx b/src/components/shared/Input.tsx
index 4fb17f2..901aeb5 100644
--- a/src/components/Input.tsx
+++ b/src/components/shared/Input.tsx
@@ -12,7 +12,11 @@ export default function Input({
return <input className={cx(s0.input, className)} {...props} />;
}
-export function SelfControlledInput({ value, className, ...restProps }) {
+export function SelfControlledInput({
+ value,
+ className,
+ ...restProps
+}: React.InputHTMLAttributes<HTMLInputElement>) {
const [internalValue, setInternalValue] = useState(value);
const refValue = useRef(value);
useEffect(() => {
@@ -22,7 +26,10 @@ export function SelfControlledInput({ value, className, ...restProps }) {
}
refValue.current = value;
}, [value]);
- const onChange = useCallback((e) => setInternalValue(e.target.value), [setInternalValue]);
+ const onChange = useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>) => setInternalValue(e.target.value),
+ [setInternalValue],
+ );
return (
<input
diff --git a/src/components/Loading.module.scss b/src/components/shared/Loading.module.scss
index c3f1d16..c3f1d16 100644
--- a/src/components/Loading.module.scss
+++ b/src/components/shared/Loading.module.scss
diff --git a/src/components/Loading.tsx b/src/components/shared/Loading.tsx
index 12ced75..12ced75 100644
--- a/src/components/Loading.tsx
+++ b/src/components/shared/Loading.tsx
diff --git a/src/components/shared/Modal.module.scss b/src/components/shared/Modal.module.scss
new file mode 100644
index 0000000..e4e6af5
--- /dev/null
+++ b/src/components/shared/Modal.module.scss
@@ -0,0 +1,52 @@
+@keyframes overlayShow {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes contentShow {
+ from {
+ opacity: 0;
+ scale: 0.96;
+ }
+ to {
+ opacity: 1;
+ scale: 1;
+ }
+}
+
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: #444;
+ z-index: 1024;
+
+ &[data-state='open'] {
+ animation: overlayShow 0.2s ease;
+ }
+}
+
+// Radix 的 Content 是 Overlay 的兄弟节点,需自己定位。
+// 这里用独立的 translate / scale 属性而非 transform,避免与各 modal 自带的 transform 冲突。
+.content {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ translate: -50% -50%;
+ z-index: 1025;
+ max-height: 100vh;
+ overflow: auto;
+ outline: none;
+ color: var(--color-text);
+ background: var(--bg-modal);
+ padding: 20px;
+ border-radius: var(--border-radius);
+ box-shadow: var(--shadow-card);
+
+ &[data-state='open'] {
+ animation: contentShow 0.2s ease;
+ }
+}
diff --git a/src/components/shared/Modal.tsx b/src/components/shared/Modal.tsx
new file mode 100644
index 0000000..b208356
--- /dev/null
+++ b/src/components/shared/Modal.tsx
@@ -0,0 +1,81 @@
+import * as Dialog from '@radix-ui/react-dialog';
+import cx from 'clsx';
+import * as React from 'react';
+
+import s0 from './Modal.module.scss';
+
+// react-modal 的 className 支持对象形式;迁移到 Radix 后过渡由 [data-state] 驱动,
+// 这里只取 base,afterOpen/beforeClose 已无意义但保留类型以兼容调用方。
+type ClassNameObj = { base?: string; afterOpen?: string; beforeClose?: string };
+
+type Props = {
+ isOpen: boolean;
+ onRequestClose?: (...args: any[]) => any;
+ onAfterOpen?: () => void;
+ className?: string | ClassNameObj;
+ overlayClassName?: string;
+ shouldCloseOnOverlayClick?: boolean;
+ shouldCloseOnEsc?: boolean;
+ /** 无障碍标题。不传时用视觉隐藏的默认值,避免 Radix 缺少 Title 的告警。 */
+ title?: string;
+ children: React.ReactNode;
+};
+
+function resolveClassName(className: Props['className']): string | undefined {
+ if (!className) return undefined;
+ return typeof className === 'string' ? className : className.base;
+}
+
+function ModalBase({
+ isOpen,
+ onRequestClose,
+ onAfterOpen,
+ className,
+ overlayClassName,
+ shouldCloseOnOverlayClick = true,
+ shouldCloseOnEsc = true,
+ title = 'Dialog',
+ children,
+}: Props) {
+ const onOpenChange = React.useCallback(
+ (open: boolean) => {
+ if (!open) onRequestClose && onRequestClose();
+ },
+ [onRequestClose],
+ );
+
+ // react-modal 的 onAfterOpen 对应 Radix 打开后的自动聚焦时机
+ const onOpenAutoFocus = React.useCallback(
+ (e: Event) => {
+ if (onAfterOpen) {
+ e.preventDefault();
+ onAfterOpen();
+ }
+ },
+ [onAfterOpen],
+ );
+
+ return (
+ <Dialog.Root open={isOpen} onOpenChange={onOpenChange}>
+ <Dialog.Portal>
+ <Dialog.Overlay className={cx(s0.overlay, overlayClassName)} />
+ <Dialog.Content
+ className={cx(s0.content, resolveClassName(className))}
+ onOpenAutoFocus={onOpenAutoFocus}
+ onInteractOutside={(e) => {
+ if (!shouldCloseOnOverlayClick) e.preventDefault();
+ }}
+ onEscapeKeyDown={(e) => {
+ if (!shouldCloseOnEsc) e.preventDefault();
+ }}
+ aria-describedby={undefined}
+ >
+ <Dialog.Title className="visually-hidden">{title}</Dialog.Title>
+ {children}
+ </Dialog.Content>
+ </Dialog.Portal>
+ </Dialog.Root>
+ );
+}
+
+export default React.memo(ModalBase);
diff --git a/src/components/shared/PageHeader.module.scss b/src/components/shared/PageHeader.module.scss
new file mode 100644
index 0000000..bf2fe3e
--- /dev/null
+++ b/src/components/shared/PageHeader.module.scss
@@ -0,0 +1,400 @@
+/**
+ * 页面顶栏的统一样式,代理 / 连接 / 规则 / 日志四个页面共用(见 PageHeader.tsx)。
+ *
+ * 断点约定:
+ * >1024 一行排下:标题 · 分段标签 · 搜索 · 操作钮
+ * ≤1024 搜索换到第二行,操作钮靠 margin 顶到第一行右端
+ * ≤768 顶栏拉平到页面底色(窄屏顶部已经有一条不透明的导航条,
+ * 顶栏再铺一层外壳色会夹出第三条色带),按钮退化成正方形图标钮,
+ * 凹陷的控件(分段标签轨道、输入框)翻成浮起
+ */
+
+.header {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 20px 32px 15px;
+ // 半透明外壳色 + 毛玻璃 + 下边框:内容从底下滚过去时能透出一点,
+ // 同时比纯白的卡片灰一档。弹层都 portal 到 body,
+ // 不受 backdrop-filter 生成包含块的影响
+ background: var(--color-chrome);
+ backdrop-filter: saturate(180%) blur(20px);
+ border-bottom: 1px solid var(--color-chrome-border);
+
+ @media (max-width: 1024px) {
+ flex-wrap: wrap;
+ padding: 16px 16px 12px;
+ }
+
+ @media (max-width: 768px) {
+ gap: 8px;
+ padding: 10px 12px;
+ background: var(--color-background);
+ backdrop-filter: none;
+ }
+}
+
+.title {
+ margin: 0;
+ font-size: 1.6rem;
+ font-weight: 700;
+ line-height: 1.2;
+ letter-spacing: -0.01em;
+ color: var(--color-text-highlight);
+ white-space: nowrap;
+
+ @media (max-width: 1024px) {
+ font-size: 1.35rem;
+ }
+
+ @media (max-width: 768px) {
+ font-size: 1.1rem;
+ }
+}
+
+/** 窄屏下强制换行的占位,让它后面的东西独占下一行 */
+.rowBreak {
+ display: none;
+
+ @media (max-width: 768px) {
+ display: block;
+ flex-basis: 100%;
+ height: 0;
+ }
+}
+
+/* ---------- 分段标签 ---------- */
+
+.tabs {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ padding: 4px;
+ border-radius: 12px;
+ background: var(--color-track);
+ flex-shrink: 0;
+
+ // 顶栏在窄屏拉平到页面底色后,--color-track 就是底色本身,凹槽会整个消失。
+ // 这里去掉凹槽,改成「选中项浮起来」的样式,和导航胶囊、卡片同一套语言
+ @media (max-width: 768px) {
+ padding: 0;
+ gap: 4px;
+ background: none;
+ }
+}
+
+.tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ appearance: none;
+ border: none;
+ background: transparent;
+ font-family: inherit;
+ font-size: 0.88rem;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+ padding: 7px 14px;
+ border-radius: 9px;
+ cursor: pointer;
+ white-space: nowrap;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease,
+ box-shadow 0.15s ease;
+
+ &:hover:not(.tabActive) {
+ color: var(--color-text);
+ background: var(--color-hover-soft);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: -1px;
+ }
+
+ @media (max-width: 768px) {
+ gap: 5px;
+ padding: 5px 10px;
+ font-size: 0.8rem;
+ }
+}
+
+.tabActive {
+ background: var(--color-card);
+ color: var(--color-text-highlight);
+ box-shadow: var(--shadow-segment);
+
+ @media (max-width: 768px) {
+ border: 1px solid var(--color-card-border);
+ // 补掉边框占掉的 1px,选中和未选中的高度才对得齐
+ padding: 4px 9px;
+ }
+}
+
+.tabCount {
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+
+ // 小屏上标题 + 标签 + 几个操作钮刚好挤满一行,计数是这里最先能舍的东西
+ @media (max-width: 400px) {
+ display: none;
+ }
+}
+
+/* ---------- 搜索 ---------- */
+
+.search {
+ position: relative;
+ display: flex;
+ align-items: center;
+ flex: 1 1 240px;
+ min-width: 160px;
+ max-width: 340px;
+ margin-left: auto;
+
+ // 同时覆盖裸 <input> 和 TextFilter 自带的样式,两种用法长得一样
+ input {
+ width: 100%;
+ height: 38px;
+ padding: 0 12px 0 38px;
+ border-radius: 10px;
+ border: 1px solid var(--color-card-border);
+ background: var(--color-track);
+ box-shadow: none;
+ color: var(--color-text);
+ font: inherit;
+ font-size: 0.85rem;
+ outline: none;
+ appearance: none;
+
+ &::placeholder {
+ color: var(--color-text-secondary);
+ opacity: 0.75;
+ }
+
+ &:focus {
+ border-color: var(--color-focus-blue);
+ }
+ }
+
+ @media (max-width: 1024px) {
+ flex-basis: 100%;
+ min-width: 0;
+ max-width: none;
+ margin-left: 0;
+ }
+
+ @media (max-width: 768px) {
+ input {
+ height: 34px;
+ padding-left: 34px;
+ font-size: 0.8rem;
+ // 同分段标签:底色拉平后凹陷的输入框会看不见,改成和卡片同层浮起
+ background: var(--color-card);
+ }
+ }
+}
+
+/**
+ * 搜索旁边还要并排放别的控件(下拉筛选)时叠这个类:
+ * 窄屏下别独占整行,把剩下的宽度让出来
+ */
+.searchInline {
+ @media (max-width: 768px) {
+ flex: 1 1 auto;
+ flex-basis: auto;
+ min-width: 0;
+ }
+}
+
+.searchIcon {
+ position: absolute;
+ left: 13px;
+ color: var(--color-text-secondary);
+ pointer-events: none;
+
+ @media (max-width: 768px) {
+ left: 11px;
+ }
+}
+
+/* ---------- 下拉筛选 ---------- */
+
+// 叠在 Select 组件自带样式之上,把它拉齐到顶栏这套尺寸。
+// 类名写两遍是为了翻倍特异性——Select 自己的 .select 也是单类,
+// 靠打包顺序决定胜负太脆
+.select.select {
+ height: 38px;
+ width: auto;
+ flex-shrink: 0;
+ border-radius: 10px;
+ border-color: var(--color-card-border);
+ background-color: var(--color-track);
+ box-shadow: none;
+ font-size: 0.85rem;
+
+ @media (max-width: 768px) {
+ height: 34px;
+ font-size: 0.8rem;
+ background-color: var(--color-card);
+ }
+}
+
+/* ---------- 操作按钮 ---------- */
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+
+ // 搜索换到第二行后,靠 margin 把操作钮顶到第一行右端
+ @media (max-width: 1024px) {
+ margin-left: auto;
+ }
+
+ @media (max-width: 768px) {
+ gap: 6px;
+ }
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ appearance: none;
+ height: 38px;
+ padding: 0 15px;
+ border-radius: 10px;
+ border: 1px solid transparent;
+ font-family: inherit;
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease,
+ color 0.15s ease,
+ opacity 0.15s ease;
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: 2px;
+ }
+
+ // 窄屏下文字全部收起,按钮退化成正方形图标钮
+ @media (max-width: 768px) {
+ gap: 0;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ justify-content: center;
+ }
+}
+
+.btnGhost {
+ background: var(--color-card);
+ border-color: var(--color-card-border);
+ color: var(--color-text);
+
+ &:hover {
+ border-color: var(--color-focus-blue);
+ color: var(--color-focus-blue);
+ }
+}
+
+.btnPrimary {
+ background: var(--color-focus-blue);
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ filter: brightness(1.08);
+ }
+
+ &:disabled {
+ cursor: default;
+ }
+}
+
+.btnDanger {
+ background: var(--color-danger);
+ color: #fff;
+
+ &:hover {
+ filter: brightness(1.08);
+ }
+}
+
+.btnPaused {
+ background: var(--color-warn-soft-bg);
+ color: var(--color-warn);
+ border-color: transparent;
+}
+
+.btnBusy {
+ opacity: 0.75;
+}
+
+/** 中等宽度就收起的文字,让给更重要的按钮 */
+.btnText {
+ @media (max-width: 1200px) {
+ display: none;
+ }
+}
+
+/** 比 .btnText 保留得久一些:只有窄屏才收起 */
+.btnTextSm {
+ @media (max-width: 768px) {
+ display: none;
+ }
+}
+
+.iconBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ appearance: none;
+ width: 38px;
+ height: 38px;
+ border-radius: 10px;
+ border: 1px solid var(--color-card-border);
+ background: var(--color-card);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease,
+ color 0.15s ease;
+
+ &:hover {
+ border-color: var(--color-focus-blue);
+ color: var(--color-focus-blue);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: 2px;
+ }
+
+ @media (max-width: 768px) {
+ width: 34px;
+ height: 34px;
+ }
+}
+
+.iconBtnActive {
+ background: var(--color-focus-blue);
+ border-color: var(--color-focus-blue);
+ color: #fff;
+
+ &:hover {
+ color: #fff;
+ }
+}
diff --git a/src/components/shared/PageHeader.tsx b/src/components/shared/PageHeader.tsx
new file mode 100644
index 0000000..69cb7ab
--- /dev/null
+++ b/src/components/shared/PageHeader.tsx
@@ -0,0 +1,206 @@
+import cx from 'clsx';
+import * as React from 'react';
+
+import { Search } from '~/components/shared/FeatherIcons';
+
+import s from './PageHeader.module.scss';
+
+/**
+ * 页面顶栏的一套零件,代理 / 连接 / 规则 / 日志四个页面共用,
+ * 样式和断点行为全在 PageHeader.module.scss 里。
+ *
+ * 每个零件都接 className,页面自己的模块可以往上叠特有的东西
+ * (比如连接页窄屏下要重排 order)。
+ */
+
+export function PageHeader({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <header className={cx(s.header, className)}>{children}</header>;
+}
+
+export function HeaderTitle({ children }: { children: React.ReactNode }) {
+ return <h1 className={s.title}>{children}</h1>;
+}
+
+/** 窄屏下强制换行,让它后面的东西独占下一行 */
+export function HeaderRowBreak({ className }: { className?: string }) {
+ return <span className={cx(s.rowBreak, className)} aria-hidden />;
+}
+
+export function HeaderTabs({
+ label,
+ children,
+ className,
+}: {
+ label: string;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <div className={cx(s.tabs, className)} role="tablist" aria-label={label}>
+ {children}
+ </div>
+ );
+}
+
+/** 计数超过三位就收成 999+,否则标签宽度会跟着数字跳 */
+function formatQty(n: number) {
+ return n < 1000 ? String(n) : '999+';
+}
+
+export function HeaderTab({
+ active,
+ label,
+ count,
+ onClick,
+}: {
+ active: boolean;
+ label: string;
+ /** 省略则不显示计数 */
+ count?: number;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ role="tab"
+ aria-selected={active}
+ className={cx(s.tab, { [s.tabActive]: active })}
+ onClick={onClick}
+ >
+ {label}
+ {typeof count === 'number' ? <span className={s.tabCount}>{formatQty(count)}</span> : null}
+ </button>
+ );
+}
+
+/**
+ * 搜索框。传 value/onChange 就是受控输入,传 children 则由调用方
+ * 自己塞输入组件(比如挂在 jotai atom 上的 TextFilter)。
+ */
+export function HeaderSearch({
+ placeholder,
+ value,
+ onChange,
+ children,
+ className,
+}: {
+ placeholder?: string;
+ value?: string;
+ onChange?: (value: string) => void;
+ children?: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <div className={cx(s.search, className)}>
+ <Search size={15} className={s.searchIcon} aria-hidden />
+ {children ?? (
+ <input
+ type="text"
+ name="filter"
+ autoComplete="off"
+ value={value}
+ placeholder={placeholder}
+ onChange={(e) => onChange?.(e.target.value)}
+ />
+ )}
+ </div>
+ );
+}
+
+export function HeaderActions({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <div className={cx(s.actions, className)}>{children}</div>;
+}
+
+type ButtonVariant = 'ghost' | 'primary' | 'danger' | 'paused';
+
+const variantClass: Record<ButtonVariant, string> = {
+ ghost: s.btnGhost,
+ primary: s.btnPrimary,
+ danger: s.btnDanger,
+ paused: s.btnPaused,
+};
+
+export function HeaderButton({
+ variant = 'ghost',
+ icon,
+ label,
+ /** 悬停提示,省略则用 label。展开说明按钮作用时才需要单独给 */
+ title,
+ /**
+ * 文字收起的宽度门槛。'sm' 只在窄屏收(默认,适合主要操作),
+ * 'md' 中等宽度就收(次要操作,给主要操作腾地方)
+ */
+ hideLabelAt = 'sm',
+ busy,
+ disabled,
+ onClick,
+}: {
+ variant?: ButtonVariant;
+ icon: React.ReactNode;
+ label: string;
+ title?: string;
+ hideLabelAt?: 'sm' | 'md';
+ busy?: boolean;
+ disabled?: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ className={cx(s.btn, variantClass[variant], { [s.btnBusy]: busy })}
+ onClick={onClick}
+ disabled={disabled}
+ // 文字会在窄屏被藏掉,读屏和 tooltip 都得靠这两个属性兜住
+ aria-label={label}
+ title={title ?? label}
+ >
+ {icon}
+ <span className={hideLabelAt === 'md' ? s.btnText : s.btnTextSm}>{label}</span>
+ </button>
+ );
+}
+
+export function HeaderIconButton({
+ icon,
+ label,
+ active,
+ expanded,
+ onClick,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ active?: boolean;
+ expanded?: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ className={cx(s.iconBtn, { [s.iconBtnActive]: active })}
+ onClick={onClick}
+ aria-label={label}
+ aria-expanded={expanded}
+ title={label}
+ >
+ {icon}
+ </button>
+ );
+}
+
+/** 顶栏里的 <Select> 要叠的类名,把它拉齐到按钮那套尺寸 */
+export const headerSelectClass = s.select;
+
+/** 搜索旁边还并排放了别的控件时,叠在 HeaderSearch 上 */
+export const headerSearchInlineClass = s.searchInline;
diff --git a/src/components/shared/Popover.module.scss b/src/components/shared/Popover.module.scss
new file mode 100644
index 0000000..78d02ed
--- /dev/null
+++ b/src/components/shared/Popover.module.scss
@@ -0,0 +1,43 @@
+.anchor {
+ position: relative;
+ display: inline-flex;
+}
+
+.panel {
+ position: fixed;
+ z-index: 30;
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ border-radius: 14px;
+ box-shadow: var(--shadow-popover);
+ padding: 14px 16px;
+ overflow-y: auto;
+ // 预留滚动条宽度,否则纵向滚动条出现时会挤掉内容宽度、逼出一条横向滚动条
+ scrollbar-gutter: stable;
+ overscroll-behavior: contain;
+ animation: popoverIn 0.14s ease-out;
+}
+
+/* 首帧先渲染出来量宽高,定位算完之前不要让用户看到 */
+.measuring {
+ top: 0;
+ left: 0;
+ visibility: hidden;
+}
+
+@keyframes popoverIn {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .panel {
+ animation: none;
+ }
+}
diff --git a/src/components/shared/Popover.tsx b/src/components/shared/Popover.tsx
new file mode 100644
index 0000000..f21bd90
--- /dev/null
+++ b/src/components/shared/Popover.tsx
@@ -0,0 +1,127 @@
+import cx from 'clsx';
+import * as React from 'react';
+import { createPortal } from 'react-dom';
+
+import s from './Popover.module.scss';
+
+const { useCallback, useEffect, useLayoutEffect, useRef, useState } = React;
+
+// 弹层与视口边缘的最小间距
+const VIEWPORT_MARGIN = 12;
+// 弹层与触发器的间距
+const ANCHOR_GAP = 8;
+
+type Position = { top: number; left: number; maxWidth: number; maxHeight: number };
+
+type Props = {
+ isOpen: boolean;
+ onClose: () => void;
+ /** 触发器,点击它不会触发「点击外部关闭」 */
+ trigger: React.ReactNode;
+ children: React.ReactNode;
+ /** 面板与触发器的对齐边 */
+ align?: 'left' | 'right';
+ label?: string;
+};
+
+/**
+ * 锚定弹层,点击外部或按 Esc 关闭。
+ *
+ * 面板通过 portal 挂到 body 上而不是留在触发器里:页面滚动容器 `.content` 是
+ * `overflow-x: auto`,绝对定位的面板会被它裁掉;而顶栏的 `backdrop-filter` 又会
+ * 成为 fixed 定位的包含块,所以 fixed 也救不了。挂到 body 上再按触发器的位置
+ * 算坐标,顺便把面板夹在视口内,窄屏下就不会溢出到屏幕外。
+ */
+export function Popover({ isOpen, onClose, trigger, children, align = 'right', label }: Props) {
+ const anchorRef = useRef<HTMLDivElement>(null);
+ const panelRef = useRef<HTMLDivElement>(null);
+ const [position, setPosition] = useState<Position | null>(null);
+
+ const updatePosition = useCallback(() => {
+ const anchor = anchorRef.current?.getBoundingClientRect();
+ const panel = panelRef.current;
+ if (!anchor || !panel) return;
+
+ const vw = document.documentElement.clientWidth;
+ const vh = document.documentElement.clientHeight;
+ const maxWidth = vw - VIEWPORT_MARGIN * 2;
+ const width = Math.min(panel.offsetWidth, maxWidth);
+
+ const preferredLeft = align === 'right' ? anchor.right - width : anchor.left;
+ // 夹在视口内,窄屏上触发器靠中间时也不会溢出到屏幕外
+ const left = Math.min(Math.max(VIEWPORT_MARGIN, preferredLeft), vw - VIEWPORT_MARGIN - width);
+ const top = anchor.bottom + ANCHOR_GAP;
+
+ setPosition({
+ top,
+ left,
+ maxWidth,
+ maxHeight: Math.max(160, vh - top - VIEWPORT_MARGIN),
+ });
+ }, [align]);
+
+ // 定位要在绘制前完成,否则会看到面板从左上角跳过来
+ useLayoutEffect(() => {
+ if (!isOpen) {
+ setPosition(null);
+ return;
+ }
+ updatePosition();
+ }, [isOpen, updatePosition]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const onPointerDown = (e: MouseEvent | TouchEvent) => {
+ const target = e.target as Node;
+ if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) return;
+ onClose();
+ };
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+
+ document.addEventListener('mousedown', onPointerDown);
+ document.addEventListener('touchstart', onPointerDown);
+ document.addEventListener('keydown', onKeyDown);
+ window.addEventListener('resize', updatePosition);
+ // 捕获阶段,这样内层滚动容器(.content)滚动时也能跟着动
+ window.addEventListener('scroll', updatePosition, true);
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown);
+ document.removeEventListener('touchstart', onPointerDown);
+ document.removeEventListener('keydown', onKeyDown);
+ window.removeEventListener('resize', updatePosition);
+ window.removeEventListener('scroll', updatePosition, true);
+ };
+ }, [isOpen, onClose, updatePosition]);
+
+ return (
+ <div className={s.anchor} ref={anchorRef}>
+ {trigger}
+ {isOpen
+ ? createPortal(
+ <div
+ ref={panelRef}
+ className={cx(s.panel, { [s.measuring]: position === null })}
+ role="dialog"
+ aria-label={label}
+ style={
+ position
+ ? {
+ top: position.top,
+ left: position.left,
+ maxWidth: position.maxWidth,
+ maxHeight: position.maxHeight,
+ }
+ : undefined
+ }
+ >
+ {children}
+ </div>,
+ document.body,
+ )
+ : null}
+ </div>
+ );
+}
diff --git a/src/components/shared/SegmentedControl.module.scss b/src/components/shared/SegmentedControl.module.scss
new file mode 100644
index 0000000..522a592
--- /dev/null
+++ b/src/components/shared/SegmentedControl.module.scss
@@ -0,0 +1,50 @@
+.track {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ padding: 3px;
+ min-width: 0;
+ border-radius: 10px;
+ background: var(--color-track);
+ border: 1px solid var(--color-card-border);
+}
+
+.segment {
+ flex: 1;
+ min-width: 0;
+ appearance: none;
+ border: none;
+ background: transparent;
+ color: var(--color-text-secondary);
+ font-family: inherit;
+ font-size: 0.8rem;
+ font-weight: 500;
+ line-height: 1;
+ padding: 7px 10px;
+ border-radius: 8px;
+ cursor: pointer;
+ // 档位多时宁可截断文字,也不要把弹层顶出横向滚动条
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease,
+ box-shadow 0.15s ease;
+
+ &:hover:not(.selected) {
+ color: var(--color-text);
+ background: var(--color-hover-soft);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--color-focus-blue);
+ outline-offset: -1px;
+ }
+}
+
+.selected {
+ background: var(--color-card);
+ color: var(--color-text-highlight);
+ box-shadow: var(--shadow-segment);
+}
diff --git a/src/components/shared/SegmentedControl.tsx b/src/components/shared/SegmentedControl.tsx
new file mode 100644
index 0000000..93ff64e
--- /dev/null
+++ b/src/components/shared/SegmentedControl.tsx
@@ -0,0 +1,48 @@
+import cx from 'clsx';
+import * as React from 'react';
+
+import s from './SegmentedControl.module.scss';
+
+export type SegmentedOption<T extends string | number> = {
+ value: T;
+ label: React.ReactNode;
+ title?: string;
+};
+
+type Props<T extends string | number> = {
+ options: SegmentedOption<T>[];
+ value: T;
+ onChange: (value: T) => void;
+ label?: string;
+ className?: string;
+};
+
+/** 分段选择器:一条轨道内若干选项,选中项以白色药丸高亮 */
+export function SegmentedControl<T extends string | number>({
+ options,
+ value,
+ onChange,
+ label,
+ className,
+}: Props<T>) {
+ return (
+ <div className={cx(s.track, className)} role="radiogroup" aria-label={label}>
+ {options.map((o) => {
+ const selected = o.value === value;
+ return (
+ <button
+ key={o.value}
+ type="button"
+ role="radio"
+ aria-checked={selected}
+ title={o.title}
+ className={cx(s.segment, { [s.selected]: selected })}
+ onClick={() => onChange(o.value)}
+ >
+ {o.label}
+ </button>
+ );
+ })}
+ </div>
+ );
+}
diff --git a/src/components/shared/Select.module.scss b/src/components/shared/Select.module.scss
index 1c42c60..ac54bd6 100644
--- a/src/components/shared/Select.module.scss
+++ b/src/components/shared/Select.module.scss
@@ -5,12 +5,13 @@
font-size: 0.95em;
padding-left: 14px;
appearance: none;
- background-color: var(--color-input-bg);
+ // 和输入框一套凹槽色 + 卡片边框色,别再用只有自己在用的 --color-input-bg
+ background-color: var(--color-track);
color: var(--color-text);
padding-right: 34px;
border-radius: 8px;
- border: 1px solid transparent;
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
+ border: 1px solid var(--color-card-border);
+ box-shadow: none;
background-image: url(data:image/svg+xml,%0A%20%20%20%20%3Csvg%20width%3D%228%22%20height%3D%2224%22%20viewBox%3D%220%200%208%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%207L7%2011H1L4%207Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%2017L1%2013L7%2013L4%2017Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20);
background-position: right 12px center;
background-repeat: no-repeat;
@@ -22,11 +23,10 @@
border-color: var(--color-focus-blue);
outline: none !important;
color: var(--color-text-highlight);
- transform: translateY(-1px);
}
.select:focus {
- transform: translateY(0);
- box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15);
+ // 与输入框、开关的聚焦圈同一个 token
+ box-shadow: 0 0 0 3px var(--color-accent-soft-bg);
}
.select option {
diff --git a/src/components/shared/Select.tsx b/src/components/shared/Select.tsx
index 70e1924..8e67a01 100644
--- a/src/components/shared/Select.tsx
+++ b/src/components/shared/Select.tsx
@@ -5,13 +5,17 @@ import s from './Select.module.scss';
type Props = {
options: Array<string[]>;
- selected: string;
+ selected: string | undefined;
} & React.SelectHTMLAttributes<HTMLSelectElement>;
export default function Select({ options, selected, onChange, className, ...props }: Props) {
return (
-
- <select className={cx(s.select, className)} value={selected} onChange={onChange} {...props}>
+ <select
+ className={cx(s.select, className)}
+ value={selected ?? ''}
+ onChange={onChange}
+ {...props}
+ >
{options.map(([value, name]) => (
<option key={value} value={value}>
{name}
diff --git a/src/components/Selection.module.scss b/src/components/shared/Selection.module.scss
index 44cf4d8..44cf4d8 100644
--- a/src/components/Selection.module.scss
+++ b/src/components/shared/Selection.module.scss
diff --git a/src/components/Selection.tsx b/src/components/shared/Selection.tsx
index 1b1f50e..9cabb6a 100644
--- a/src/components/Selection.tsx
+++ b/src/components/shared/Selection.tsx
@@ -4,10 +4,10 @@ import React from 'react';
import s from './Selection.module.scss';
type SelectionProps = {
- OptionComponent?: (...args: any[]) => any;
- optionPropsList?: any[];
- selectedIndex?: number;
- onChange?: (...args: any[]) => any;
+ OptionComponent: (...args: any[]) => any;
+ optionPropsList: any[];
+ selectedIndex: number;
+ onChange: (value: string) => void;
};
export function Selection2({
diff --git a/src/components/shared/Sparkline.module.scss b/src/components/shared/Sparkline.module.scss
deleted file mode 100644
index bc60060..0000000
--- a/src/components/shared/Sparkline.module.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-.sparkline {
- width: 100%;
- height: 10vh;
- margin-top: auto;
-}
diff --git a/src/components/shared/Sparkline.tsx b/src/components/shared/Sparkline.tsx
deleted file mode 100644
index bcb96f8..0000000
--- a/src/components/shared/Sparkline.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-import * as React from 'react';
-import { Line } from 'react-chartjs-2';
-
-import { chartJSResource, chartStyles, commonDataSetProps } from '~/misc/chart';
-import prettyBytes from '~/misc/pretty-bytes';
-
-import s from './Sparkline.module.scss';
-
-const { useMemo } = React;
-
-const extraChartOptions: any = {
- responsive: true,
- maintainAspectRatio: false,
- parsing: false,
- animation: {
- duration: 1000,
- easing: 'linear',
- },
- animations: {
- y: {
- duration: 0,
- },
- x: {
- duration: 0,
- },
- },
- transitions: {
- active: {
- animation: {
- duration: 0,
- },
- },
- },
- plugins: {
- legend: { display: false },
- tooltip: {
- enabled: true,
- intersect: false,
- mode: 'index',
- },
- },
- scales: {
- x: {
- type: 'time',
- display: false,
- },
- y: {
- display: false,
- beginAtZero: true,
- },
- },
- elements: {
- line: {
- borderWidth: 1,
- tension: 0.4,
- },
- point: {
- radius: 0,
- },
- },
-};
-
-export default function Sparkline({ data: dataArray, labels, type, styleIndex = 0 }) {
- chartJSResource.read();
-
- const isMemory = type === 'inuse';
-
- const options = useMemo(() => {
- return {
- ...extraChartOptions,
- scales: {
- ...extraChartOptions.scales,
- y: {
- display: false,
- // 内存值稳定,不从零开始,让 Y 轴自动适应数据范围以显示波动
- beginAtZero: !isMemory,
- },
- },
- plugins: {
- ...extraChartOptions.plugins,
- tooltip: {
- ...extraChartOptions.plugins.tooltip,
- displayColors: false,
- callbacks: {
- title: () => '',
- label(context) {
- if (context.parsed.y !== null) {
- const suffix = isMemory ? '' : '/s';
- const raw = isMemory ? context.parsed.y : Math.expm1(context.parsed.y);
- return prettyBytes(raw) + suffix;
- }
- return '';
- },
- },
- },
- },
- };
- }, [type, isMemory]);
-
- const data = useMemo(
- () => ({
- datasets: [
- {
- ...commonDataSetProps,
- ...chartStyles[styleIndex][type],
- // 内存用原始值(变化幅度小,不需要压缩);流量用 log1p 压缩尖刺
- data: dataArray.map((v, i) => ({ x: labels[i], y: isMemory ? v : Math.log1p(v) })),
- fill: true,
- },
- ],
- }),
- [dataArray, labels, type, styleIndex, isMemory],
- );
-
- return (
- <div className={s.sparkline}>
- <Line data={data} options={options} redraw={false} />
- </div>
- );
-}
diff --git a/src/components/SvgGithub.tsx b/src/components/shared/SvgGithub.tsx
index 45828c2..45828c2 100644
--- a/src/components/SvgGithub.tsx
+++ b/src/components/shared/SvgGithub.tsx
diff --git a/src/components/SvgYacd.module.scss b/src/components/shared/SvgYacd.module.scss
index f668137..f668137 100644
--- a/src/components/SvgYacd.module.scss
+++ b/src/components/shared/SvgYacd.module.scss
diff --git a/src/components/SvgYacd.tsx b/src/components/shared/SvgYacd.tsx
index d7c1b2f..d7c1b2f 100644
--- a/src/components/SvgYacd.tsx
+++ b/src/components/shared/SvgYacd.tsx
diff --git a/src/components/shared/SwitchThemed.module.scss b/src/components/shared/SwitchThemed.module.scss
new file mode 100644
index 0000000..26edc6c
--- /dev/null
+++ b/src/components/shared/SwitchThemed.module.scss
@@ -0,0 +1,56 @@
+// 尺寸与迁移前的 react-switch 保持一致:轨道 44x28,滑块 24
+.root {
+ --switch-w: 44px;
+ --switch-h: 28px;
+ --switch-thumb: 24px;
+
+ position: relative;
+ flex-shrink: 0;
+ width: var(--switch-w);
+ height: var(--switch-h);
+ padding: 0;
+ border: none;
+ border-radius: calc(var(--switch-h) / 2);
+ background-color: var(--color-toggle-bg);
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ -webkit-tap-highlight-color: transparent;
+
+ &[data-state='checked'] {
+ background-color: var(--color-focus-blue);
+ }
+
+ &:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--color-accent-soft-bg);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+.mini {
+ --switch-w: 34px;
+ --switch-h: 20px;
+ --switch-thumb: 16px;
+}
+
+.thumb {
+ display: block;
+ width: var(--switch-thumb);
+ height: var(--switch-thumb);
+ border-radius: 50%;
+ background-color: #fff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ transition: transform 0.2s ease;
+ transform: translateX(calc((var(--switch-h) - var(--switch-thumb)) / 2));
+ will-change: transform;
+
+ &[data-state='checked'] {
+ transform: translateX(
+ calc(var(--switch-w) - var(--switch-thumb) - (var(--switch-h) - var(--switch-thumb)) / 2)
+ );
+ }
+}
diff --git a/src/components/shared/SwitchThemed.tsx b/src/components/shared/SwitchThemed.tsx
new file mode 100644
index 0000000..61e91e9
--- /dev/null
+++ b/src/components/shared/SwitchThemed.tsx
@@ -0,0 +1,33 @@
+import * as Switch from '@radix-ui/react-switch';
+import cx from 'clsx';
+import * as React from 'react';
+
+import s from './SwitchThemed.module.scss';
+
+type Props = {
+ checked?: boolean;
+ onChange?: (checked: boolean) => void;
+ name?: string;
+ disabled?: boolean;
+ size?: 'default' | 'mini';
+};
+
+export default function SwitchThemed({
+ checked = false,
+ onChange,
+ name,
+ disabled,
+ size = 'default',
+}: Props) {
+ return (
+ <Switch.Root
+ className={cx(s.root, { [s.mini]: size === 'mini' })}
+ checked={checked}
+ onCheckedChange={onChange}
+ name={name}
+ disabled={disabled}
+ >
+ <Switch.Thumb className={s.thumb} />
+ </Switch.Root>
+ );
+}
diff --git a/src/components/shared/TextFitler.module.scss b/src/components/shared/TextFilter.module.scss
index 3977aad..3977aad 100644
--- a/src/components/shared/TextFitler.module.scss
+++ b/src/components/shared/TextFilter.module.scss
diff --git a/src/components/shared/TextFitler.tsx b/src/components/shared/TextFilter.tsx
index 567efbc..79c2cfb 100644
--- a/src/components/shared/TextFitler.tsx
+++ b/src/components/shared/TextFilter.tsx
@@ -1,14 +1,12 @@
+import type { PrimitiveAtom } from 'jotai';
import * as React from 'react';
+import { useTextInput } from '~/hooks/useTextInput';
-import { useTextInut } from '~/hooks/useTextInput';
-
-import s from './TextFitler.module.scss';
-
-import type { PrimitiveAtom } from 'jotai';
+import s from './TextFilter.module.scss';
export function TextFilter(props: { textAtom: PrimitiveAtom<string>; placeholder?: string }) {
- const [onChange, text] = useTextInut(props.textAtom);
+ const [onChange, text] = useTextInput(props.textAtom);
return (
<input
className={s.input}
diff --git a/src/components/shared/ThemeSwitcher.tsx b/src/components/shared/ThemeSwitcher.tsx
index 59c4c3a..4a510d5 100644
--- a/src/components/shared/ThemeSwitcher.tsx
+++ b/src/components/shared/ThemeSwitcher.tsx
@@ -1,15 +1,15 @@
-import { LazyMotion, domAnimation, m } from 'framer-motion';
+import { domAnimation, LazyMotion, m } from 'framer-motion';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '~/components/shared/Tooltip';
-import { connect } from '~/components/StateProvider';
import { getTheme, switchTheme } from '~/store/app';
-import { State } from '~/store/types';
+import { connect } from '~/store/StateProvider';
+import { DispatchFn, State } from '~/store/types';
import s from './ThemeSwitcher.module.scss';
-export function ThemeSwitcherImpl({ theme, dispatch }) {
+export function ThemeSwitcherImpl({ theme, dispatch }: { theme: string; dispatch: DispatchFn }) {
const { t } = useTranslation();
const themeIcon = React.useMemo(() => {
diff --git a/src/components/shared/Toast.module.scss b/src/components/shared/Toast.module.scss
new file mode 100644
index 0000000..d5ba905
--- /dev/null
+++ b/src/components/shared/Toast.module.scss
@@ -0,0 +1,87 @@
+.container {
+ position: fixed;
+ z-index: 1000;
+ right: 16px;
+ bottom: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: flex-end;
+ pointer-events: none;
+
+ @media (max-width: 768px) {
+ right: 10px;
+ left: 10px;
+ bottom: 10px;
+ align-items: stretch;
+ }
+}
+
+.toast {
+ pointer-events: auto;
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ max-width: 420px;
+ padding: 12px 14px;
+ border-radius: 10px;
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ box-shadow: var(--shadow-popover);
+ color: var(--color-text);
+ font-size: 0.9rem;
+ line-height: 1.45;
+ animation: slide-in 0.18s ease-out;
+
+ @media (max-width: 768px) {
+ max-width: none;
+ }
+}
+
+.icon {
+ flex-shrink: 0;
+ display: flex;
+ padding-top: 1px;
+}
+
+.success .icon {
+ color: var(--color-success);
+}
+
+.error .icon {
+ color: var(--color-danger);
+}
+
+.info .icon {
+ color: var(--color-focus-blue);
+}
+
+.message {
+ flex: 1;
+ word-break: break-word;
+}
+
+.close {
+ flex-shrink: 0;
+ display: flex;
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: pointer;
+ color: var(--color-text-secondary);
+
+ &:hover {
+ color: var(--color-text-highlight);
+ }
+}
+
+@keyframes slide-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/src/components/shared/Toast.tsx b/src/components/shared/Toast.tsx
new file mode 100644
index 0000000..4d0140e
--- /dev/null
+++ b/src/components/shared/Toast.tsx
@@ -0,0 +1,40 @@
+import cx from 'clsx';
+import { useAtomValue } from 'jotai';
+import { createPortal } from 'react-dom';
+
+import { AlertCircle, CheckCircle, Info, X } from '~/components/shared/FeatherIcons';
+import { dismissToast, toastsAtom, type ToastKind } from '~/store/toast';
+
+import s from './Toast.module.scss';
+
+const ICONS: Record<ToastKind, typeof Info> = {
+ success: CheckCircle,
+ error: AlertCircle,
+ info: Info,
+};
+
+export function Toaster() {
+ const toasts = useAtomValue(toastsAtom);
+
+ if (toasts.length === 0) return null;
+
+ return createPortal(
+ <div className={s.container} role="region" aria-live="polite">
+ {toasts.map(({ id, kind, message }) => {
+ const Icon = ICONS[kind];
+ return (
+ <div key={id} className={cx(s.toast, s[kind])}>
+ <span className={s.icon}>
+ <Icon size={18} />
+ </span>
+ <span className={s.message}>{message}</span>
+ <button className={s.close} onClick={() => dismissToast(id)} aria-label="Close">
+ <X size={16} />
+ </button>
+ </div>
+ );
+ })}
+ </div>,
+ document.body,
+ );
+}
diff --git a/src/components/ToggleSwitch.module.scss b/src/components/shared/ToggleSwitch.module.scss
index 4b1388c..4b1388c 100644
--- a/src/components/ToggleSwitch.module.scss
+++ b/src/components/shared/ToggleSwitch.module.scss
diff --git a/src/components/ToggleSwitch.tsx b/src/components/shared/ToggleSwitch.tsx
index 58400c9..56d7ffe 100644
--- a/src/components/ToggleSwitch.tsx
+++ b/src/components/shared/ToggleSwitch.tsx
@@ -3,10 +3,10 @@ import React, { useCallback, useMemo } from 'react';
import s0 from './ToggleSwitch.module.scss';
type Props = {
- options?: any[];
- value?: string;
- name?: string;
- onChange?: (...args: any[]) => any;
+ options: Array<{ label: string; value: string }>;
+ value: string;
+ name: string;
+ onChange: React.ChangeEventHandler<HTMLInputElement>;
};
function ToggleSwitch({ options, value, name, onChange }: Props) {
@@ -20,8 +20,10 @@ function ToggleSwitch({ options, value, name, onChange }: Props) {
} else if (idx > -1) {
return w;
}
+ // value 对不上任何一项时 idxSelected 是 -1,滑块宽度按 0 算
+ return 0;
},
- [options]
+ [options],
);
const sliderStyle = useMemo(() => {
diff --git a/src/components/shared/Tooltip.tsx b/src/components/shared/Tooltip.tsx
index 070dd52..2f00a54 100644
--- a/src/components/shared/Tooltip.tsx
+++ b/src/components/shared/Tooltip.tsx
@@ -16,11 +16,7 @@ export function Tooltip({
<RadixTooltip.Root>
<RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger>
<RadixTooltip.Portal>
- <RadixTooltip.Content
- className="tooltip-content"
- sideOffset={5}
- aria-label={ariaLabel}
- >
+ <RadixTooltip.Content className="tooltip-content" sideOffset={5} aria-label={ariaLabel}>
{label}
</RadixTooltip.Content>
</RadixTooltip.Portal>
diff --git a/src/components/shared/TrafficChartSample.tsx b/src/components/shared/TrafficChartSample.tsx
index 516c20b..352af8a 100644
--- a/src/components/shared/TrafficChartSample.tsx
+++ b/src/components/shared/TrafficChartSample.tsx
@@ -23,7 +23,7 @@ const data1 = [23e3, 35e3, 46e3, 33e3, 90e3, 68e3, 23e3, 45e3];
const data2 = [184e3, 183e3, 196e3, 182e3, 190e3, 186e3, 182e3, 189e3];
const labels = data1.map((_, i) => i);
-export default function TrafficChart({ id }) {
+export default function TrafficChart({ id }: { id: number }) {
chartJSResource.read();
const data = useMemo(
@@ -42,7 +42,7 @@ export default function TrafficChart({ id }) {
},
],
}),
- [id]
+ [id],
);
return (
diff --git a/src/components/StyleGuide.tsx b/src/components/styleguide/StyleGuide.tsx
index 4b13f19..2c054cb 100644
--- a/src/components/StyleGuide.tsx
+++ b/src/components/styleguide/StyleGuide.tsx
@@ -1,13 +1,11 @@
import React from 'react';
-import Loading from '~/components/Loading';
+import Button from '~/components/shared/Button';
import { Zap } from '~/components/shared/FeatherIcons';
-
-
-import Button from './Button';
-import Input from './Input';
-import SwitchThemed from './SwitchThemed';
-import ToggleSwitch from './ToggleSwitch';
+import Input from '~/components/shared/Input';
+import Loading from '~/components/shared/Loading';
+import SwitchThemed from '~/components/shared/SwitchThemed';
+import ToggleSwitch from '~/components/shared/ToggleSwitch';
const noop = () => {
/* empty */
diff --git a/src/components/svg/Equalizer.tsx b/src/components/svg/Equalizer.tsx
deleted file mode 100644
index ae3c858..0000000
--- a/src/components/svg/Equalizer.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import * as React from 'react';
-
-type Props = {
- size?: number;
- color?: string;
-};
-
-export default function Equalizer({ color = 'currentColor', size = 24 }: Props) {
- return (
- <svg
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- width={size}
- height={size}
- stroke={color}
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <path d="M2 6h9M18.5 6H22" />
- <circle cx="16" cy="6" r="2" />
- <path d="M22 18h-9M6 18H2" />
- <circle r="2" transform="matrix(-1 0 0 1 8 18)" />
- </svg>
- );
-}