Skip to content

Commit af9e441

Browse files
committed
feat: 添加筛选功能,支持按路由和名称过滤数据探索
1 parent 374404f commit af9e441

3 files changed

Lines changed: 266 additions & 18 deletions

File tree

app/api/explore/route.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ const EXPLORE_CACHE_TTL_MS = 30_000;
1313
const EXPLORE_CACHE_MAX_ENTRIES = 100;
1414
const exploreCache = new Map<string, CachedExplore>();
1515

16-
function makeCacheKey(input: { days?: number; maxPoints?: number; start?: string | null; end?: string | null }) {
16+
function makeCacheKey(input: { days?: number; maxPoints?: number; start?: string | null; end?: string | null; route?: string | null; name?: string | null }) {
1717
return JSON.stringify({
1818
days: input.days ?? null,
1919
maxPoints: input.maxPoints ?? null,
2020
start: input.start ?? null,
21-
end: input.end ?? null
21+
end: input.end ?? null,
22+
route: input.route ?? null,
23+
name: input.name ?? null
2224
});
2325
}
2426

@@ -53,17 +55,19 @@ export async function GET(request: Request) {
5355
const maxPointsParam = searchParams.get("maxPoints");
5456
const start = searchParams.get("start");
5557
const end = searchParams.get("end");
58+
const route = searchParams.get("route");
59+
const name = searchParams.get("name");
5660

5761
const days = daysParam ? Number.parseInt(daysParam, 10) : undefined;
5862
const maxPoints = maxPointsParam ? Number.parseInt(maxPointsParam, 10) : undefined;
5963

60-
const cacheKey = makeCacheKey({ days, maxPoints, start, end });
64+
const cacheKey = makeCacheKey({ days, maxPoints, start, end, route, name });
6165
const cached = getCached(cacheKey);
6266
if (cached) {
6367
return NextResponse.json(cached, { status: 200 });
6468
}
6569

66-
const payload = await getExplorePoints(days, { maxPoints, start, end });
70+
const payload = await getExplorePoints(days, { maxPoints, start, end, route, name });
6771
setCached(cacheKey, payload);
6872
return NextResponse.json(payload, { status: 200 });
6973
} catch (error) {

app/explore/page.tsx

Lines changed: 199 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)