summaryrefslogtreecommitdiff
path: root/src/components/shared
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/shared')
-rw-r--r--src/components/shared/BaseModal.module.scss12
-rw-r--r--src/components/shared/BaseModal.tsx22
-rw-r--r--src/components/shared/Basic.module.scss15
-rw-r--r--src/components/shared/Basic.tsx9
-rw-r--r--src/components/shared/Button.module.scss74
-rw-r--r--src/components/shared/Button.tsx82
-rw-r--r--src/components/shared/Collapsible.tsx43
-rw-r--r--src/components/shared/Fab.module.scss33
-rw-r--r--src/components/shared/Fab.tsx155
-rw-r--r--src/components/shared/FeatherIcons.ts4
-rw-r--r--src/components/shared/Head.tsx5
-rw-r--r--src/components/shared/Input.module.scss36
-rw-r--r--src/components/shared/Input.tsx42
-rw-r--r--src/components/shared/Loading.module.scss28
-rw-r--r--src/components/shared/Loading.tsx18
-rw-r--r--src/components/shared/Modal.module.scss52
-rw-r--r--src/components/shared/Modal.tsx81
-rw-r--r--src/components/shared/PageHeader.module.scss400
-rw-r--r--src/components/shared/PageHeader.tsx206
-rw-r--r--src/components/shared/Popover.module.scss43
-rw-r--r--src/components/shared/Popover.tsx127
-rw-r--r--src/components/shared/SegmentedControl.module.scss50
-rw-r--r--src/components/shared/SegmentedControl.tsx48
-rw-r--r--src/components/shared/Select.module.scss12
-rw-r--r--src/components/shared/Select.tsx10
-rw-r--r--src/components/shared/Selection.module.scss23
-rw-r--r--src/components/shared/Selection.tsx45
-rw-r--r--src/components/shared/Sparkline.module.scss5
-rw-r--r--src/components/shared/Sparkline.tsx120
-rw-r--r--src/components/shared/SvgGithub.tsx24
-rw-r--r--src/components/shared/SvgYacd.module.scss14
-rw-r--r--src/components/shared/SvgYacd.tsx92
-rw-r--r--src/components/shared/SwitchThemed.module.scss56
-rw-r--r--src/components/shared/SwitchThemed.tsx33
-rw-r--r--src/components/shared/TextFilter.module.scss (renamed from src/components/shared/TextFitler.module.scss)0
-rw-r--r--src/components/shared/TextFilter.tsx (renamed from src/components/shared/TextFitler.tsx)10
-rw-r--r--src/components/shared/ThemeSwitcher.tsx8
-rw-r--r--src/components/shared/Toast.module.scss87
-rw-r--r--src/components/shared/Toast.tsx40
-rw-r--r--src/components/shared/ToggleSwitch.module.scss39
-rw-r--r--src/components/shared/ToggleSwitch.tsx67
-rw-r--r--src/components/shared/Tooltip.tsx6
-rw-r--r--src/components/shared/TrafficChartSample.tsx4
43 files changed, 1890 insertions, 390 deletions
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/shared/Button.module.scss b/src/components/shared/Button.module.scss
new file mode 100644
index 0000000..8d2ca46
--- /dev/null
+++ b/src/components/shared/Button.module.scss
@@ -0,0 +1,74 @@
+@use '~/styles/utils/custom-media' as *;
+
+.btn {
+ -webkit-appearance: none;
+ outline: none;
+ user-select: none;
+ position: relative;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ // 与顶栏的 .btnGhost 同一套:卡片色底 + 卡片边框色描边,
+ // 悬停是蓝描边而不是整块填蓝
+ color: var(--color-text);
+ background: var(--color-card);
+ border: 1px solid var(--color-card-border);
+ border-radius: 100px;
+ transition:
+ color 0.2s ease,
+ background-color 0.2s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+
+ &:focus-visible {
+ border-color: var(--color-focus-blue);
+ box-shadow: 0 0 0 3px var(--color-accent-soft-bg);
+ }
+ &:hover:not(:disabled) {
+ color: var(--color-focus-blue);
+ border-color: var(--color-focus-blue);
+ }
+
+ font-size: 0.75em;
+ padding: 4px 7px;
+ @media (--breakpoint-not-small) {
+ font-size: small;
+ padding: 6px 12px;
+ }
+
+ &.minimal {
+ border-color: transparent;
+ background: none;
+ &:hover:not(:disabled) {
+ color: var(--color-focus-blue);
+ background: var(--color-hover-soft);
+ border-color: transparent;
+ }
+ }
+}
+
+.btn:disabled {
+ opacity: 0.5;
+}
+
+.btnInternal {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ column-gap: 4px;
+}
+
+.btnStart {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.loadingContainer {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ display: inline-flex;
+}
diff --git a/src/components/shared/Button.tsx b/src/components/shared/Button.tsx
new file mode 100644
index 0000000..5ecf029
--- /dev/null
+++ b/src/components/shared/Button.tsx
@@ -0,0 +1,82 @@
+import cx from 'clsx';
+import * as React from 'react';
+
+import { LoadingDot } from './Basic';
+import s0 from './Button.module.scss';
+
+const { forwardRef, useCallback } = React;
+
+type ButtonInternalProps = {
+ children?: React.ReactNode;
+ label?: string;
+ text?: string;
+ start?: React.ReactNode | (() => React.ReactNode);
+};
+
+type ButtonProps = {
+ isLoading?: boolean;
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown;
+ disabled?: boolean;
+ kind?: 'primary' | 'minimal';
+ className?: string;
+ title?: string;
+} & ButtonInternalProps;
+
+function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) {
+ const {
+ onClick,
+ disabled = false,
+ isLoading,
+ kind = 'primary',
+ className,
+ children,
+ label,
+ text,
+ start,
+ ...restProps
+ } = props;
+ const internalProps = { children, label, text, start };
+ const internalOnClick = useCallback(
+ (e: React.MouseEvent<HTMLButtonElement>) => {
+ if (isLoading) return;
+ onClick && onClick(e);
+ },
+ [isLoading, onClick],
+ );
+ const btnClassName = cx(s0.btn, { [s0.minimal]: kind === 'minimal' }, className);
+ return (
+ <button
+ className={btnClassName}
+ ref={ref}
+ onClick={internalOnClick}
+ disabled={disabled}
+ {...restProps}
+ >
+ {isLoading ? (
+ <>
+ <span style={{ display: 'inline-flex', opacity: 0 }}>
+ <ButtonInternal {...internalProps} />
+ </span>
+ <span className={s0.loadingContainer}>
+ <LoadingDot />
+ </span>
+ </>
+ ) : (
+ <ButtonInternal {...internalProps} />
+ )}
+ </button>
+ );
+}
+
+function ButtonInternal({ children, label, text, start }: ButtonInternalProps) {
+ return (
+ <div className={s0.btnInternal}>
+ {start && (
+ <span className={s0.btnStart}>{typeof start === 'function' ? start() : start}</span>
+ )}
+ {children || label || text}
+ </div>
+ );
+}
+
+export default forwardRef(Button);
diff --git a/src/components/shared/Collapsible.tsx b/src/components/shared/Collapsible.tsx
new file mode 100644
index 0000000..bc723a0
--- /dev/null
+++ b/src/components/shared/Collapsible.tsx
@@ -0,0 +1,43 @@
+import { domAnimation, LazyMotion, m } from 'framer-motion';
+import React from 'react';
+
+const { memo } = React;
+
+const variantsCollpapsibleWrap = {
+ initialOpen: {
+ height: 'auto',
+ opacity: 1,
+ visibility: 'visible',
+ transition: { duration: 0 },
+ },
+ open: {
+ height: 'auto',
+ opacity: 1,
+ visibility: 'visible',
+ transition: { duration: 0.3 },
+ },
+ closed: {
+ height: 0,
+ opacity: 0,
+ visibility: 'hidden',
+ overflowY: 'hidden',
+ transition: { duration: 0.3 },
+ },
+};
+
+const Collapsible = memo(({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => {
+ return (
+ <LazyMotion features={domAnimation}>
+ <m.div
+ initial={isOpen ? 'initialOpen' : 'closed'}
+ animate={isOpen ? 'open' : 'closed'}
+ variants={variantsCollpapsibleWrap}
+ style={{ overflow: 'hidden' }}
+ >
+ {children}
+ </m.div>
+ </LazyMotion>
+ );
+});
+
+export default Collapsible;
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/shared/Input.tsx b/src/components/shared/Input.tsx
new file mode 100644
index 0000000..901aeb5
--- /dev/null
+++ b/src/components/shared/Input.tsx
@@ -0,0 +1,42 @@
+import cx from 'clsx';
+import React from 'react';
+
+import s0 from './Input.module.scss';
+
+const { useState, useRef, useEffect, useCallback } = React;
+
+export default function Input({
+ className,
+ ...props
+}: React.InputHTMLAttributes<HTMLInputElement>) {
+ return <input className={cx(s0.input, className)} {...props} />;
+}
+
+export function SelfControlledInput({
+ value,
+ className,
+ ...restProps
+}: React.InputHTMLAttributes<HTMLInputElement>) {
+ const [internalValue, setInternalValue] = useState(value);
+ const refValue = useRef(value);
+ useEffect(() => {
+ if (refValue.current !== value) {
+ // ideally we should only do this when this input is not focused
+ setInternalValue(value);
+ }
+ refValue.current = value;
+ }, [value]);
+ const onChange = useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>) => setInternalValue(e.target.value),
+ [setInternalValue],
+ );
+
+ return (
+ <input
+ className={cx(s0.input, className)}
+ value={internalValue}
+ onChange={onChange}
+ {...restProps}
+ />
+ );
+}
diff --git a/src/components/shared/Loading.module.scss b/src/components/shared/Loading.module.scss
new file mode 100644
index 0000000..c3f1d16
--- /dev/null
+++ b/src/components/shared/Loading.module.scss
@@ -0,0 +1,28 @@
+.loading {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.spinner {
+ width: 20px;
+ height: 20px;
+ display: inline-block;
+ vertical-align: middle;
+ animation: rotate 1s steps(12, end) infinite;
+ background: transparent
+ url('data:image/svg+xml;charset=utf8, %3Csvg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 100 100"%3E%3Cpath fill="none" d="M0 0h100v100H0z"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23E9E9E9" rx="5" ry="5" transform="translate(0 -30)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23989697" rx="5" ry="5" transform="rotate(30 105.98 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%239B999A" rx="5" ry="5" transform="rotate(60 75.98 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23A3A1A2" rx="5" ry="5" transform="rotate(90 65 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23ABA9AA" rx="5" ry="5" transform="rotate(120 58.66 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23B2B2B2" rx="5" ry="5" transform="rotate(150 54.02 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23BAB8B9" rx="5" ry="5" transform="rotate(180 50 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23C2C0C1" rx="5" ry="5" transform="rotate(-150 45.98 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23CBCBCB" rx="5" ry="5" transform="rotate(-120 41.34 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23D2D2D2" rx="5" ry="5" transform="rotate(-90 35 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23DADADA" rx="5" ry="5" transform="rotate(-60 24.02 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23E2E2E2" rx="5" ry="5" transform="rotate(-30 -5.98 65)"/%3E%3C/svg%3E')
+ no-repeat;
+ background-size: 100%;
+}
+
+@keyframes rotate {
+ 0% {
+ transform: rotate3d(0, 0, 1, 0deg);
+ }
+ 100% {
+ transform: rotate3d(0, 0, 1, 360deg);
+ }
+}
diff --git a/src/components/shared/Loading.tsx b/src/components/shared/Loading.tsx
new file mode 100644
index 0000000..12ced75
--- /dev/null
+++ b/src/components/shared/Loading.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+
+import s from './Loading.module.scss';
+
+type Props = {
+ height?: string;
+};
+
+const Loading = ({ height }: Props) => {
+ const style = height ? { height } : {};
+ return (
+ <div className={s.loading} style={style}>
+ <div className={s.spinner} />
+ </div>
+ );
+};
+
+export default Loading;
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/shared/Selection.module.scss b/src/components/shared/Selection.module.scss
new file mode 100644
index 0000000..44cf4d8
--- /dev/null
+++ b/src/components/shared/Selection.module.scss
@@ -0,0 +1,23 @@
+.fieldset {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ display: flex;
+ flex-wrap: wrap;
+ flex-direction: row;
+}
+
+.input + .cnt {
+ border: 1px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-bottom: 5px;
+}
+
+.input:focus + .cnt {
+ border-color: var(--color-focus-blue);
+}
+
+.input:checked + .cnt {
+ border-color: var(--color-focus-blue);
+}
diff --git a/src/components/shared/Selection.tsx b/src/components/shared/Selection.tsx
new file mode 100644
index 0000000..9cabb6a
--- /dev/null
+++ b/src/components/shared/Selection.tsx
@@ -0,0 +1,45 @@
+import cx from 'clsx';
+import React from 'react';
+
+import s from './Selection.module.scss';
+
+type SelectionProps = {
+ OptionComponent: (...args: any[]) => any;
+ optionPropsList: any[];
+ selectedIndex: number;
+ onChange: (value: string) => void;
+};
+
+export function Selection2({
+ OptionComponent,
+ optionPropsList,
+ selectedIndex,
+ onChange,
+}: SelectionProps) {
+ const inputCx = cx('visually-hidden', s.input);
+ const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ onChange(e.target.value);
+ };
+ return (
+ <fieldset className={s.fieldset}>
+ {optionPropsList.map((props, idx) => {
+ return (
+ <label key={idx}>
+ <input
+ type="radio"
+ checked={selectedIndex === idx}
+ name="selection"
+ value={idx}
+ aria-labelledby={'traffic chart type ' + idx}
+ onChange={onInputChange}
+ className={inputCx}
+ />
+ <div className={s.cnt}>
+ <OptionComponent {...props} />
+ </div>
+ </label>
+ );
+ })}
+ </fieldset>
+ );
+}
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/shared/SvgGithub.tsx b/src/components/shared/SvgGithub.tsx
new file mode 100644
index 0000000..45828c2
--- /dev/null
+++ b/src/components/shared/SvgGithub.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+
+type Props = {
+ width?: number;
+ height?: number;
+};
+
+export default function SvgGithub({ width = 24, height = 24 }: Props = {}) {
+ return (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={width}
+ height={height}
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
+ </svg>
+ );
+}
diff --git a/src/components/shared/SvgYacd.module.scss b/src/components/shared/SvgYacd.module.scss
new file mode 100644
index 0000000..f668137
--- /dev/null
+++ b/src/components/shared/SvgYacd.module.scss
@@ -0,0 +1,14 @@
+.path {
+ stroke-dasharray: 890;
+ stroke-dashoffset: 890;
+ animation: dash 3s ease-in-out forwards normal infinite;
+}
+
+@keyframes dash {
+ from {
+ stroke-dashoffset: 890;
+ }
+ to {
+ stroke-dashoffset: 0;
+ }
+}
diff --git a/src/components/shared/SvgYacd.tsx b/src/components/shared/SvgYacd.tsx
new file mode 100644
index 0000000..d7c1b2f
--- /dev/null
+++ b/src/components/shared/SvgYacd.tsx
@@ -0,0 +1,92 @@
+import cx from 'clsx';
+import * as React from 'react';
+
+import s from './SvgYacd.module.scss';
+
+type Props = {
+ width?: number;
+ height?: number;
+ animate?: boolean;
+ c0?: string;
+ c1?: string;
+ stroke?: string;
+ eye?: string;
+ line?: string;
+};
+
+function SvgYacd({
+ width = 320,
+ height = 320,
+ animate = false,
+ c0 = '#316eb5',
+ c1 = '#f19500',
+ line = '#cccccc',
+}: Props) {
+ const faceClasName = cx({ [s.path]: animate });
+ return (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ version="1.2"
+ viewBox="0 0 512 512"
+ width={width}
+ height={height}
+ >
+ <path
+ id="Layer"
+ className={faceClasName}
+ fill={c0}
+ stroke={line}
+ strokeLinecap="round"
+ strokeWidth="4"
+ d="m280.8 182.4l119-108.3c1.9-1.7 4.3-2.7 6.8-2.4l39.5 4.1c2.1 0.3 3.9 2.2 3.9 4.4v251.1c0 2-1.5 3.9-3.5 4.4l-41.9 9c-0.5 0.3-1.2 0.3-1.9 0.3h-18.8c-2.4 0-4.4-2-4.4-4.4v-132.9c0-7.5-9-11.7-14.8-6.3l-59 53.4c-2.2 2.2-5.4 2.9-8.5 1.9-27.1-8-56.3-8-83.4 0-2.9 1-6.1 0.3-8.5-1.9l-59-53.4c-5.6-5.4-14.6-1.2-14.6 6.3v132.9c0 2.4-2.2 4.4-4.7 4.4h-18.7c-0.7 0-1.2 0-2-0.3l-41.6-9c-2-0.5-3.5-2.4-3.5-4.4v-251.1c0-2.2 1.8-4.1 3.9-4.4l39.5-4.1c2.5-0.3 4.9 0.7 6.9 2.4l115.7 105.3c2 1.7 4.6 2.5 7.1 2.2 15.3-2.2 31.4-1.9 46.5 0.8z"
+ />
+ <path
+ id="Layer"
+ className={faceClasName}
+ fill={c0}
+ stroke={line}
+ strokeLinecap="round"
+ strokeWidth="4"
+ d="m269.4 361.8l-7.1 13.4c-2.4 4.2-8.5 4.2-11 0l-7-13.4c-2.5-4.1 0.7-9.3 5.3-9h14.4c4.9 0 7.8 4.9 5.4 9z"
+ />
+ <path
+ id="Layer"
+ className={faceClasName}
+ fill={c1}
+ stroke={line}
+ strokeLinecap="round"
+ strokeWidth="4"
+ d="m160.7 362.5c3.6 0 6.8 3.2 6.8 6.9 0 3.6-3.2 6.5-6.8 6.5h-94.6c-3.6 0-6.8-2.9-6.8-6.5 0-3.7 3.2-6.9 6.8-6.9z"
+ />
+ <path
+ id="Layer"
+ className={faceClasName}
+ fill={c1}
+ stroke={line}
+ strokeLinecap="round"
+ strokeWidth="4"
+ d="m158.7 394.7c3.4-1 7.1 1 8.3 4.4 1 3.4-1 7.3-4.4 8.3l-92.8 31.7c-3.4 1.2-7.3-0.7-8.3-4.2-1.2-3.6 0.7-7.3 4.4-8.5z"
+ />
+ <path
+ id="Layer"
+ className={faceClasName}
+ fill={c1}
+ stroke={line}
+ strokeLinecap="round"
+ strokeWidth="4"
+ d="m446.1 426.4c3.4 1.2 5.3 4.9 4.3 8.5-1.2 3.5-4.8 5.4-8.2 4.2l-93.1-31.7c-3.5-1-5.4-4.9-4.2-8.3 1-3.4 4.9-5.4 8.3-4.4z"
+ />
+ <path
+ id="Layer"
+ className={faceClasName}
+ fill={c1}
+ stroke={line}
+ strokeLinecap="round"
+ strokeWidth="4"
+ d="m445.8 362.5c3.7 0 6.6 3.2 6.6 6.9 0 3.6-2.9 6.5-6.6 6.5h-94.8c-3.6 0-6.6-2.9-6.6-6.5 0-3.7 3-6.9 6.6-6.9z"
+ />
+ </svg>
+ );
+}
+
+export default SvgYacd;
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/shared/ToggleSwitch.module.scss b/src/components/shared/ToggleSwitch.module.scss
new file mode 100644
index 0000000..4b1388c
--- /dev/null
+++ b/src/components/shared/ToggleSwitch.module.scss
@@ -0,0 +1,39 @@
+.ToggleSwitch {
+ user-select: none;
+ border-radius: 4px;
+ border: 1px solid #525252;
+ color: var(--color-text);
+ background: var(--color-toggle-bg);
+ display: flex;
+ position: relative;
+ outline: none;
+
+ &:focus {
+ border-color: var(--color-focus-blue);
+ }
+
+ input {
+ position: absolute;
+ left: 0;
+ opacity: 0;
+ }
+
+ label {
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 0;
+ cursor: pointer;
+ }
+}
+
+.slider {
+ z-index: 1;
+ position: absolute;
+ display: block;
+ left: 0;
+ height: 100%;
+ transition: left 0.2s ease-out;
+ background: var(--color-toggle-selected);
+}
diff --git a/src/components/shared/ToggleSwitch.tsx b/src/components/shared/ToggleSwitch.tsx
new file mode 100644
index 0000000..56d7ffe
--- /dev/null
+++ b/src/components/shared/ToggleSwitch.tsx
@@ -0,0 +1,67 @@
+import React, { useCallback, useMemo } from 'react';
+
+import s0 from './ToggleSwitch.module.scss';
+
+type Props = {
+ options: Array<{ label: string; value: string }>;
+ value: string;
+ name: string;
+ onChange: React.ChangeEventHandler<HTMLInputElement>;
+};
+
+function ToggleSwitch({ options, value, name, onChange }: Props) {
+ const idxSelected = useMemo(() => options.map((o) => o.value).indexOf(value), [options, value]);
+
+ const getPortionPercentage = useCallback(
+ (idx: number) => {
+ const w = Math.floor(100 / options.length);
+ if (idx === options.length - 1) {
+ return 100 - options.length * w + w;
+ } else if (idx > -1) {
+ return w;
+ }
+ // value 对不上任何一项时 idxSelected 是 -1,滑块宽度按 0 算
+ return 0;
+ },
+ [options],
+ );
+
+ const sliderStyle = useMemo(() => {
+ return {
+ width: getPortionPercentage(idxSelected) + '%',
+ left: idxSelected * getPortionPercentage(0) + '%',
+ };
+ }, [idxSelected, getPortionPercentage]);
+
+ return (
+ <div className={s0.ToggleSwitch}>
+ <div className={s0.slider} style={sliderStyle} />
+ {options.map((o, idx) => {
+ const id = `${name}-${o.label}`;
+ const className = idx === 0 ? '' : 'border-left';
+ return (
+ <label
+ htmlFor={id}
+ key={id}
+ className={className}
+ style={{
+ width: getPortionPercentage(idx) + '%',
+ }}
+ >
+ <input
+ id={id}
+ name={name}
+ type="radio"
+ value={o.value}
+ checked={value === o.value}
+ onChange={onChange}
+ />
+ <div>{o.label}</div>
+ </label>
+ );
+ })}
+ </div>
+ );
+}
+
+export default React.memo(ToggleSwitch);
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 (