diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index d75208c..9432aa3 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -1,10 +1,10 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useMemo, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Modal } from '@/components/ui/Modal'; +import { Select } from '@/components/ui/Select'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; -import { IconChevronDown } from '@/components/ui/icons'; import { ConfigSection } from '@/components/config/ConfigSection'; import { useNotificationStore } from '@/stores'; import styles from './VisualConfigEditor.module.scss'; @@ -81,120 +81,6 @@ function Divider() { return
; } -type ToastSelectOption = { value: string; label: string }; - -function ToastSelect({ - value, - options, - disabled, - ariaLabel, - onChange, -}: { - value: string; - options: ReadonlyArray; - disabled?: boolean; - ariaLabel: string; - onChange: (value: string) => void; -}) { - const [open, setOpen] = useState(false); - const containerRef = useRef(null); - - const selectedOption = options.find((opt) => opt.value === value) ?? options[0]; - - useEffect(() => { - if (!open) return; - const handleClickOutside = (event: MouseEvent) => { - if (!containerRef.current) return; - if (!containerRef.current.contains(event.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [open]); - - return ( -
- - - {open && !disabled && ( -
- {options.map((opt) => { - const active = opt.value === value; - return ( - - ); - })} -
- )} -
- ); -} - function ApiKeysCardEditor({ value, disabled, @@ -530,7 +416,7 @@ function PayloadRulesEditor({ > {protocolFirst ? ( <> - updateModel(ruleIndex, modelIndex, { name: e.target.value })} disabled={disabled} /> - updateParam(ruleIndex, paramIndex, { path: e.target.value })} disabled={disabled} /> - updateModel(ruleIndex, modelIndex, { name: e.target.value })} disabled={disabled} /> -
- ; onChange: (value: string) => void; placeholder?: string; className?: string; + disabled?: boolean; + ariaLabel?: string; + fullWidth?: boolean; } -export function Select({ value, options, onChange, placeholder, className }: SelectProps) { +export function Select({ + value, + options, + onChange, + placeholder, + className, + disabled = false, + ariaLabel, + fullWidth = true +}: SelectProps) { const [open, setOpen] = useState(false); const wrapRef = useRef(null); useEffect(() => { - if (!open) return; + if (!open || disabled) return; const handleClickOutside = (event: MouseEvent) => { if (!wrapRef.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, [open]); + }, [disabled, open]); + + const isOpen = open && !disabled; const selected = options.find((o) => o.value === value); const displayText = selected?.label ?? placeholder ?? ''; const isPlaceholder = !selected && placeholder; return ( -
+
- {open && ( -
+ {isOpen && ( +
{options.map((opt) => { const active = opt.value === value; return ( diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index 6b597f9..a2ffadb 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -44,100 +44,8 @@ font-weight: 600; } -.timeRangeSelectWrap { - position: relative; - display: inline-flex; - align-items: center; -} - -.timeRangeSelect { - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 8px; +.timeRangeSelectControl { min-width: 164px; - height: 40px; - padding: 0 12px; - border: 1px solid var(--border-color); - border-radius: $radius-md; - background-color: var(--bg-primary); - box-shadow: var(--shadow); - color: var(--text-primary); - font-size: 13px; - font-weight: 500; - cursor: pointer; - appearance: none; - text-align: left; - - &:hover { - border-color: var(--border-hover); - } - - &:focus { - outline: none; - box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18); - } - - &[aria-expanded='true'] { - border-color: var(--primary-color); - box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18); - } -} - -.timeRangeSelectedText { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.timeRangeSelectIcon { - display: inline-flex; - color: var(--text-secondary); - flex-shrink: 0; - transition: transform 0.2s ease; - - [aria-expanded='true'] > & { - transform: rotate(180deg); - } -} - -.timeRangeDropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - z-index: 1000; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: $radius-lg; - padding: 6px; - box-shadow: var(--shadow-lg); - display: flex; - flex-direction: column; - gap: 4px; -} - -.timeRangeOption { - padding: 8px 12px; - border-radius: $radius-md; - border: 1px solid transparent; - background: transparent; - color: var(--text-primary); - cursor: pointer; - text-align: left; - font-size: 13px; - font-weight: 500; - transition: background-color 0.15s ease, border-color 0.15s ease; - - &:hover { - background: var(--bg-secondary); - } -} - -.timeRangeOptionActive { - border-color: rgba($primary-color, 0.5); - background: rgba($primary-color, 0.1); - font-weight: 600; } .pageTitle { diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index 76c0314..3502f6e 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Chart as ChartJS, @@ -13,7 +13,7 @@ import { } from 'chart.js'; import { Button } from '@/components/ui/Button'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; -import { IconChevronDown } from '@/components/ui/icons'; +import { Select } from '@/components/ui/Select'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { useThemeStore, useConfigStore } from '@/stores'; @@ -120,19 +120,6 @@ export function UsagePage() { const isDark = resolvedTheme === 'dark'; const config = useConfigStore((state) => state.config); - // Time range dropdown - const [timeRangeOpen, setTimeRangeOpen] = useState(false); - const timeRangeRef = useRef(null); - - useEffect(() => { - if (!timeRangeOpen) return; - const handleClickOutside = (event: MouseEvent) => { - if (!timeRangeRef.current?.contains(event.target as Node)) setTimeRangeOpen(false); - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [timeRangeOpen]); - // Data hook const { usage, @@ -156,6 +143,15 @@ export function UsagePage() { const [chartLines, setChartLines] = useState(loadChartLines); const [timeRange, setTimeRange] = useState(loadTimeRange); + const timeRangeOptions = useMemo( + () => + TIME_RANGE_OPTIONS.map((opt) => ({ + value: opt.value, + label: t(opt.labelKey) + })), + [t] + ); + const filteredUsage = useMemo( () => (usage ? filterUsageByTimeRange(usage, timeRange) : null), [usage, timeRange] @@ -238,44 +234,14 @@ export function UsagePage() {
{t('usage_stats.range_filter')} -
- - {timeRangeOpen && ( -
- {TIME_RANGE_OPTIONS.map((opt) => { - const active = opt.value === timeRange; - return ( - - ); - })} -
- )} -
+