@@ -20,6 +20,7 @@ type ExploreResponse = {
2020 returned : number ;
2121 step : number ;
2222 points : ExplorePoint [ ] ;
23+ filters ?: { routes : string [ ] ; names : string [ ] } ;
2324 error ?: string ;
2425} ;
2526
@@ -436,6 +437,12 @@ export default function ExplorePage() {
436437 const [ loading , setLoading ] = useState ( true ) ;
437438 const [ error , setError ] = useState < string | null > ( null ) ;
438439 const [ data , setData ] = useState < ExploreResponse | null > ( null ) ;
440+ const [ routeOptions , setRouteOptions ] = useState < string [ ] > ( [ ] ) ;
441+ const [ nameOptions , setNameOptions ] = useState < string [ ] > ( [ ] ) ;
442+ const [ routeInput , setRouteInput ] = useState ( "" ) ;
443+ const [ nameInput , setNameInput ] = useState ( "" ) ;
444+ const [ appliedRoute , setAppliedRoute ] = useState ( "" ) ;
445+ const [ appliedName , setAppliedName ] = useState ( "" ) ;
439446
440447 // 堆叠面积图开关
441448 const [ showStackedArea , setShowStackedArea ] = useState ( true ) ;
@@ -1158,6 +1165,8 @@ export default function ExplorePage() {
11581165 } else {
11591166 params . set ( "days" , String ( rangeDays ) ) ;
11601167 }
1168+ if ( appliedRoute ) params . set ( "route" , appliedRoute ) ;
1169+ if ( appliedName ) params . set ( "name" , appliedName ) ;
11611170
11621171 const res = await fetch ( `/api/explore?${ params . toString ( ) } ` , { cache : "no-store" } ) ;
11631172 const json : ExploreResponse = await res . json ( ) ;
@@ -1169,6 +1178,8 @@ export default function ExplorePage() {
11691178 if ( ! cancelled ) {
11701179 setData ( json ) ;
11711180 setAppliedDays ( json . days ?? rangeDays ) ;
1181+ setRouteOptions ( Array . from ( new Set ( json . filters ?. routes ?? [ ] ) ) ) ;
1182+ setNameOptions ( Array . from ( new Set ( json . filters ?. names ?? [ ] ) ) ) ;
11721183 }
11731184 } catch ( err ) {
11741185 if ( ! cancelled ) {
@@ -1184,7 +1195,7 @@ export default function ExplorePage() {
11841195 return ( ) => {
11851196 cancelled = true ;
11861197 } ;
1187- } , [ rangeMode , customStart , customEnd , rangeDays ] ) ;
1198+ } , [ rangeMode , customStart , customEnd , rangeDays , appliedRoute , appliedName ] ) ;
11881199
11891200 const models = useMemo ( ( ) => {
11901201 const set = new Set < string > ( ) ;
@@ -1478,15 +1489,27 @@ export default function ExplorePage() {
14781489 setCustomError ( null ) ;
14791490 } , [ globalSelection ] ) ;
14801491
1492+ const applyExploreFilters = useCallback ( ( ) => {
1493+ setAppliedRoute ( routeInput . trim ( ) ) ;
1494+ setAppliedName ( nameInput . trim ( ) ) ;
1495+ } , [ routeInput , nameInput ] ) ;
1496+
1497+ const clearExploreFilters = useCallback ( ( ) => {
1498+ setRouteInput ( "" ) ;
1499+ setNameInput ( "" ) ;
1500+ setAppliedRoute ( "" ) ;
1501+ setAppliedName ( "" ) ;
1502+ } , [ ] ) ;
1503+
14811504 return (
14821505 < main className = "min-h-screen bg-slate-900 px-6 pb-4 pt-8 text-slate-100" >
14831506 < header className = "flex flex-col gap-2 md:flex-row md:items-end md:justify-between" >
14841507 < div >
14851508 < h1 className = "text-2xl font-bold text-white" > 数据探索</ h1 >
14861509 < p className = "text-base text-slate-400" > 每个点代表一次请求(X=时间,Y=token 数,颜色=模型)</ p >
14871510 </ div >
1488- < div className = "flex flex-col items-start gap-2 text-sm text-slate-300 md:items-end " >
1489- < div className = "flex flex-wrap items-center gap-2 md:justify-end " >
1511+ < div className = "flex flex-col items-start gap-2 text-sm text-slate-300 md:items-start " >
1512+ < div className = "flex flex-wrap items-center gap-2" >
14901513 { [ 7 , 14 , 30 ] . map ( ( days ) => (
14911514 < button
14921515 key = { days }
@@ -1577,7 +1600,7 @@ export default function ExplorePage() {
15771600 跟随仪表盘
15781601 </ button >
15791602 </ div >
1580- < div className = "text-xs text-slate-400" >
1603+ < div className = "self-end text-right text-xs text-slate-400" >
15811604 < span className = "text-slate-500" > 时间范围:</ span >
15821605 < span > { rangeSubtitle } </ span >
15831606 { data ?. step && data . step > 1 ? < span className = "ml-3 text-slate-500" > { `已抽样:每 ${ data . step } 个点取 1 个` } </ span > : null }
@@ -1609,7 +1632,55 @@ export default function ExplorePage() {
16091632 重置缩放
16101633 </ button >
16111634 ) }
1612- < div className = "ml-auto flex items-center gap-4" >
1635+ < div className = "ml-auto flex flex-wrap items-center justify-end gap-6" >
1636+ < div className = "flex flex-wrap items-center gap-2" >
1637+ < ComboBox
1638+ value = { routeInput }
1639+ onChange = { setRouteInput }
1640+ options = { routeOptions }
1641+ placeholder = "按 Key 过滤"
1642+ className = "w-40"
1643+ onSelectOption = { ( val ) => {
1644+ setRouteInput ( val ) ;
1645+ setAppliedRoute ( val . trim ( ) ) ;
1646+ } }
1647+ onClear = { ( ) => {
1648+ setRouteInput ( "" ) ;
1649+ setAppliedRoute ( "" ) ;
1650+ } }
1651+ />
1652+ < ComboBox
1653+ value = { nameInput }
1654+ onChange = { setNameInput }
1655+ options = { nameOptions }
1656+ placeholder = "按凭证过滤"
1657+ className = "w-40"
1658+ onSelectOption = { ( val ) => {
1659+ setNameInput ( val ) ;
1660+ setAppliedName ( val . trim ( ) ) ;
1661+ } }
1662+ onClear = { ( ) => {
1663+ setNameInput ( "" ) ;
1664+ setAppliedName ( "" ) ;
1665+ } }
1666+ />
1667+ < button
1668+ type = "button"
1669+ onClick = { applyExploreFilters }
1670+ className = "rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 text-xs font-semibold text-slate-200 hover:border-slate-500"
1671+ >
1672+ 应用筛选
1673+ </ button >
1674+ { appliedRoute || appliedName ? (
1675+ < button
1676+ type = "button"
1677+ onClick = { clearExploreFilters }
1678+ className = "rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 text-xs text-slate-400 hover:border-slate-500"
1679+ >
1680+ 清除
1681+ </ button >
1682+ ) : null }
1683+ </ div >
16131684 < label className = "flex cursor-pointer items-center gap-2 text-sm text-slate-400 hover:text-slate-300" >
16141685 < button
16151686 type = "button"
@@ -1921,3 +1992,126 @@ export default function ExplorePage() {
19211992 </ main >
19221993 ) ;
19231994}
1995+
1996+ function ComboBox ( {
1997+ value,
1998+ onChange,
1999+ options,
2000+ placeholder,
2001+ className,
2002+ onSelectOption,
2003+ onClear
2004+ } : {
2005+ value : string ;
2006+ onChange : ( val : string ) => void ;
2007+ options : string [ ] ;
2008+ placeholder ?: string ;
2009+ className ?: string ;
2010+ onSelectOption ?: ( val : string ) => void ;
2011+ onClear ?: ( ) => void ;
2012+ } ) {
2013+ const [ open , setOpen ] = useState ( false ) ;
2014+ const [ isVisible , setIsVisible ] = useState ( false ) ;
2015+ const [ isClosing , setIsClosing ] = useState ( false ) ;
2016+ const [ hasTyped , setHasTyped ] = useState ( false ) ;
2017+ const inputRef = useRef < HTMLInputElement | null > ( null ) ;
2018+ const containerRef = useRef < HTMLDivElement | null > ( null ) ;
2019+
2020+ const filtered = useMemo ( ( ) => {
2021+ if ( ! hasTyped ) return options ;
2022+ return options . filter ( ( opt ) => opt . toLowerCase ( ) . includes ( value . toLowerCase ( ) ) ) ;
2023+ } , [ hasTyped , options , value ] ) ;
2024+
2025+ const closeDropdown = ( ) => {
2026+ setIsClosing ( true ) ;
2027+ setTimeout ( ( ) => {
2028+ setOpen ( false ) ;
2029+ setIsVisible ( false ) ;
2030+ setIsClosing ( false ) ;
2031+ } , 100 ) ;
2032+ } ;
2033+
2034+ useEffect ( ( ) => {
2035+ if ( open ) {
2036+ requestAnimationFrame ( ( ) => {
2037+ startTransition ( ( ) => {
2038+ setIsVisible ( true ) ;
2039+ setIsClosing ( false ) ;
2040+ } ) ;
2041+ } ) ;
2042+ }
2043+ } , [ open ] ) ;
2044+
2045+ useEffect ( ( ) => {
2046+ if ( ! open ) return ;
2047+
2048+ const handleClickOutside = ( e : MouseEvent ) => {
2049+ if ( containerRef . current && ! containerRef . current . contains ( e . target as Node ) ) {
2050+ closeDropdown ( ) ;
2051+ }
2052+ } ;
2053+
2054+ document . addEventListener ( "mousedown" , handleClickOutside ) ;
2055+ return ( ) => document . removeEventListener ( "mousedown" , handleClickOutside ) ;
2056+ } , [ open ] ) ;
2057+
2058+ return (
2059+ < div className = { `relative ${ className ?? "" } ` } ref = { containerRef } >
2060+ < input
2061+ ref = { inputRef }
2062+ value = { value }
2063+ onChange = { ( e ) => {
2064+ setHasTyped ( true ) ;
2065+ onChange ( e . target . value ) ;
2066+ } }
2067+ onFocus = { ( ) => {
2068+ setOpen ( true ) ;
2069+ setHasTyped ( false ) ;
2070+ } }
2071+ placeholder = { placeholder }
2072+ className = "w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 pr-7 text-xs text-white placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
2073+ />
2074+ { value ? (
2075+ < button
2076+ type = "button"
2077+ onMouseDown = { ( e ) => e . preventDefault ( ) }
2078+ onClick = { ( ) => {
2079+ onChange ( "" ) ;
2080+ setHasTyped ( false ) ;
2081+ onClear ?.( ) ;
2082+ } }
2083+ className = "absolute right-1.5 top-1/2 -translate-y-1/2 rounded px-1 text-slate-400 transition hover:bg-slate-700 hover:text-slate-200"
2084+ title = "清除"
2085+ >
2086+ ×
2087+ </ button >
2088+ ) : null }
2089+
2090+ { isVisible && filtered . length > 0 ? (
2091+ < div
2092+ className = { `absolute z-20 mt-1 max-h-52 w-full overflow-auto rounded-xl border border-slate-700 bg-slate-900 shadow-lg scrollbar-slim ${
2093+ isClosing ? "animate-dropdown-out" : "animate-dropdown-in"
2094+ } `}
2095+ >
2096+ { filtered . map ( ( opt ) => (
2097+ < button
2098+ type = "button"
2099+ key = { opt }
2100+ onMouseDown = { ( e ) => {
2101+ e . preventDefault ( ) ;
2102+ onChange ( opt ) ;
2103+ setHasTyped ( false ) ;
2104+ closeDropdown ( ) ;
2105+ inputRef . current ?. blur ( ) ;
2106+ onSelectOption ?.( opt ) ;
2107+ } }
2108+ className = "flex w-full items-start justify-between px-3 py-2 text-left text-xs text-slate-200 transition hover:bg-slate-800"
2109+ >
2110+ < span className = "whitespace-normal break-words text-left" > { opt } </ span >
2111+ </ button >
2112+ ) ) }
2113+ </ div >
2114+ ) : null }
2115+ </ div >
2116+ ) ;
2117+ }
0 commit comments