diff options
Diffstat (limited to 'src/components')
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}% ) 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> - ); -} |
