fix: resolve all eslint errors (set-state-in-effect, nested components, no-explicit-any)

- Wrap synchronous setState calls in useEffect with startTransition to avoid cascading renders
- Convert nested SortIcon components to renderSortIcon helper functions
- Replace all `any` types with proper interfaces (OverviewData, TrendPoint, RankItem)
- Remove unused formatTokens import in logs page
- Add no-any rule to CLAUDE.md
This commit is contained in:
2026-04-07 15:19:10 +08:00
parent 8b91aa3e97
commit 20a3d399d9
7 changed files with 62 additions and 35 deletions

View File

@@ -19,6 +19,8 @@ Docker deployment: port 8019, `docker-compose up` with `.env.production`, extern
Next.js 16 App Router analytics dashboard for an API gateway. React 19, TypeScript strict mode, Tailwind CSS 4, Recharts 3, PostgreSQL. Next.js 16 App Router analytics dashboard for an API gateway. React 19, TypeScript strict mode, Tailwind CSS 4, Recharts 3, PostgreSQL.
**TypeScript: Never use `any` type.** Always define proper interfaces/types for all data structures. Use `unknown` with type narrowing if the type is truly unknown.
### Three Global Contexts (wrapped in `components/ClientProviders.tsx`) ### Three Global Contexts (wrapped in `components/ClientProviders.tsx`)
1. **ThemeProvider** (`lib/theme.tsx`) — light/dark/system, localStorage `theme`, supports iframe embedding via `?theme=` query param and postMessage sync 1. **ThemeProvider** (`lib/theme.tsx`) — light/dark/system, localStorage `theme`, supports iframe embedding via `?theme=` query param and postMessage sync

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef, startTransition } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp, HelpCircle } from "lucide-react"; import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp, HelpCircle } from "lucide-react";
@@ -22,7 +22,7 @@ function RatioTooltip({ text }: { text: string }) {
const iconRef = useRef<HTMLSpanElement>(null); const iconRef = useRef<HTMLSpanElement>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []); useEffect(() => { startTransition(() => setMounted(true)); }, []);
function handleEnter() { function handleEnter() {
if (iconRef.current) { if (iconRef.current) {
@@ -84,11 +84,11 @@ export default function AggregationPage() {
const [sortAsc, setSortAsc] = useState(false); const [sortAsc, setSortAsc] = useState(false);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); startTransition(() => setLoading(true));
const { start, end } = getEffectiveRange(); const { start, end } = getEffectiveRange();
const res = await fetch(buildQuery("/api/aggregation", { start, end })); const res = await fetch(buildQuery("/api/aggregation", { start, end }));
setData(await res.json()); const json = await res.json();
setLoading(false); startTransition(() => { setData(json); setLoading(false); });
}, [getEffectiveRange]); }, [getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);
@@ -112,12 +112,12 @@ export default function AggregationPage() {
else { setSortKey(key); setSortAsc(false); } else { setSortKey(key); setSortAsc(false); }
} }
function SortIcon({ col }: { col: SortKey }) { const renderSortIcon = (col: SortKey) => {
if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.5 }} />; if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.5 }} />;
return sortAsc return sortAsc
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} /> ? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />; : <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
} };
const sortHeaders: { key: SortKey; label: string }[] = [ const sortHeaders: { key: SortKey; label: string }[] = [
{ key: "calls", label: t("th.calls") }, { key: "calls", label: t("th.calls") },
@@ -165,14 +165,14 @@ export default function AggregationPage() {
{sortHeaders.map(h => ( {sortHeaders.map(h => (
<th key={h.key} className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider cursor-pointer select-none transition-colors" <th key={h.key} className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider cursor-pointer select-none transition-colors"
style={{ color: "var(--text-muted)" }} onClick={() => handleSort(h.key)}> style={{ color: "var(--text-muted)" }} onClick={() => handleSort(h.key)}>
<span className="inline-flex items-center gap-1">{h.label} <SortIcon col={h.key} /></span> <span className="inline-flex items-center gap-1">{h.label} {renderSortIcon(h.key)}</span>
</th> </th>
))} ))}
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider cursor-pointer select-none transition-colors" <th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider cursor-pointer select-none transition-colors"
style={{ color: "var(--text-muted)" }} onClick={() => handleSort("ratio")}> style={{ color: "var(--text-muted)" }} onClick={() => handleSort("ratio")}>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{t("agg.ratio")} {t("agg.ratio")}
<SortIcon col="ratio" /> {renderSortIcon("ratio")}
<RatioTooltip text={t("agg.ratioTip")} /> <RatioTooltip text={t("agg.ratioTip")} />
</span> </span>
</th> </th>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, startTransition } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { ArrowLeft, Hash, Zap, MessageSquare } from "lucide-react"; import { ArrowLeft, Hash, Zap, MessageSquare } from "lucide-react";
@@ -30,11 +30,11 @@ export default function DetailPage() {
const { getEffectiveRange } = useTimeRange(); const { getEffectiveRange } = useTimeRange();
const [data, setData] = useState<DetailData | null>(null); const [data, setData] = useState<DetailData | null>(null);
const [trends, setTrends] = useState<any[]>([]); const [trends, setTrends] = useState<{ date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number }[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); startTransition(() => setLoading(true));
const { start, end } = getEffectiveRange(); const { start, end } = getEffectiveRange();
const tp = { start, end }; const tp = { start, end };
const [detail, tr] = await Promise.all([ const [detail, tr] = await Promise.all([
@@ -45,7 +45,7 @@ export default function DetailPage() {
...(type === "channel" ? { channel_id: decodedId } : {}), ...(type === "channel" ? { channel_id: decodedId } : {}),
})).then(r => r.json()), })).then(r => r.json()),
]); ]);
setData(detail); setTrends(tr); setLoading(false); startTransition(() => { setData(detail); setTrends(tr); setLoading(false); });
}, [type, decodedId, getEffectiveRange]); }, [type, decodedId, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, startTransition } from "react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react"; import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react";
import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { buildQuery, formatNumber, formatTokens, formatDate } from "@/lib/utils"; import { buildQuery, formatNumber, formatDate } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context"; import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
@@ -26,15 +26,15 @@ export default function LogsPage() {
const pageSize = 100; const pageSize = 100;
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); startTransition(() => setLoading(true));
const { start, end } = getEffectiveRange(); const { start, end } = getEffectiveRange();
const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters })); const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters }));
const data = await res.json(); const data = await res.json();
setLogs(data.logs); setTotal(data.total); setLoading(false); startTransition(() => { setLogs(data.logs); setTotal(data.total); setLoading(false); });
}, [page, filters, getEffectiveRange]); }, [page, filters, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => { setPage(1); }, [getEffectiveRange, filters]); useEffect(() => { startTransition(() => setPage(1)); }, [getEffectiveRange, filters]);
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
const headers = [t("th.time"), t("th.user"), t("th.realModel"), t("th.channel"), t("th.input"), t("th.output"), t("th.totalToken"), t("th.latency"), ""]; const headers = [t("th.time"), t("th.user"), t("th.realModel"), t("th.channel"), t("th.input"), t("th.output"), t("th.totalToken"), t("th.latency"), ""];

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, startTransition } from "react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Zap, Hash, Users, Cpu, TrendingUp, BarChart3 } from "lucide-react"; import { Zap, Hash, Users, Cpu, TrendingUp, BarChart3 } from "lucide-react";
import { StatsCard } from "@/components/StatsCard"; import { StatsCard } from "@/components/StatsCard";
@@ -11,19 +11,40 @@ import { buildQuery } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context"; import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
interface OverviewData {
total_calls: number;
total_tokens: number;
active_users: number;
active_models: number;
}
interface TrendPoint {
date: string;
calls: number;
total_tokens: number;
prompt_tokens: number;
completion_tokens: number;
}
interface RankItem {
name: string;
total_tokens: number;
calls: number;
}
export default function DashboardPage() { export default function DashboardPage() {
const { t } = useI18n(); const { t } = useI18n();
const { getEffectiveRange } = useTimeRange(); const { getEffectiveRange } = useTimeRange();
const [granularity, setGranularity] = useState<"day" | "week" | "month">("day"); const [granularity, setGranularity] = useState<"day" | "week" | "month">("day");
const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens"); const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens");
const [overview, setOverview] = useState<any>(null); const [overview, setOverview] = useState<OverviewData | null>(null);
const [trends, setTrends] = useState<any[]>([]); const [trends, setTrends] = useState<TrendPoint[]>([]);
const [userRank, setUserRank] = useState<any[]>([]); const [userRank, setUserRank] = useState<RankItem[]>([]);
const [modelRank, setModelRank] = useState<any[]>([]); const [modelRank, setModelRank] = useState<RankItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); startTransition(() => setLoading(true));
const { start, end } = getEffectiveRange(); const { start, end } = getEffectiveRange();
const tp = { start, end }; const tp = { start, end };
const [ov, tr, ur, mr] = await Promise.all([ const [ov, tr, ur, mr] = await Promise.all([
@@ -32,7 +53,9 @@ export default function DashboardPage() {
fetch(buildQuery("/api/rankings", { ...tp, type: "user", limit: 10 })).then(r => r.json()), fetch(buildQuery("/api/rankings", { ...tp, type: "user", limit: 10 })).then(r => r.json()),
fetch(buildQuery("/api/rankings", { ...tp, type: "model", limit: 10 })).then(r => r.json()), fetch(buildQuery("/api/rankings", { ...tp, type: "model", limit: 10 })).then(r => r.json()),
]); ]);
setOverview(ov); setTrends(tr); setUserRank(ur); setModelRank(mr); setLoading(false); startTransition(() => {
setOverview(ov); setTrends(tr); setUserRank(ur); setModelRank(mr); setLoading(false);
});
}, [granularity, getEffectiveRange]); }, [granularity, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);
@@ -82,7 +105,7 @@ export default function DashboardPage() {
</div> </div>
<div className="flex gap-1 rounded-md p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}> <div className="flex gap-1 rounded-md p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
{([["total_tokens", t("metric.token")], ["calls", t("metric.calls")]] as const).map(([k, l]) => ( {([["total_tokens", t("metric.token")], ["calls", t("metric.calls")]] as const).map(([k, l]) => (
<button key={k} onClick={() => setTrendMetric(k as any)} <button key={k} onClick={() => setTrendMetric(k)}
className="px-2.5 py-1 text-xs rounded transition-colors" className="px-2.5 py-1 text-xs rounded transition-colors"
style={{ style={{
background: trendMetric === k ? "var(--btn-active-bg)" : "transparent", background: trendMetric === k ? "var(--btn-active-bg)" : "transparent",

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, startTransition } from "react";
import Link from "next/link"; import Link from "next/link";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Trophy, Users, Cpu, Radio, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; import { Trophy, Users, Cpu, Radio, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
@@ -33,11 +33,11 @@ export default function RankingsPage() {
}; };
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); startTransition(() => setLoading(true));
const { start, end } = getEffectiveRange(); const { start, end } = getEffectiveRange();
const res = await fetch(buildQuery("/api/rankings", { start, end, type: tab, limit: 100 })); const res = await fetch(buildQuery("/api/rankings", { start, end, type: tab, limit: 100 }));
setData(await res.json()); const json = await res.json();
setLoading(false); startTransition(() => { setData(json); setLoading(false); });
}, [tab, getEffectiveRange]); }, [tab, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);
@@ -58,12 +58,12 @@ export default function RankingsPage() {
return sortAsc ? diff : -diff; return sortAsc ? diff : -diff;
}); });
function SortIcon({ col }: { col: SortKey }) { const renderSortIcon = (col: SortKey) => {
if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.4 }} />; if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.4 }} />;
return sortAsc return sortAsc
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} /> ? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />; : <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
} };
const columns: { key: SortKey | null; label: string; align: "left" | "right" }[] = [ const columns: { key: SortKey | null; label: string; align: "left" | "right" }[] = [
{ key: null, label: t("th.rank"), align: "left" }, { key: null, label: t("th.rank"), align: "left" },
@@ -116,7 +116,7 @@ export default function RankingsPage() {
> >
{col.key ? ( {col.key ? (
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "justify-end" : ""}`}> <span className={`inline-flex items-center gap-1 ${col.align === "right" ? "justify-end" : ""}`}>
{col.label} <SortIcon col={col.key} /> {col.label} {renderSortIcon(col.key)}
</span> </span>
) : col.label} ) : col.label}
</th> </th>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { createContext, useContext, useState, useEffect, type ReactNode } from "react"; import { createContext, useContext, useState, useEffect, startTransition, type ReactNode } from "react";
export type Locale = "zh" | "en"; export type Locale = "zh" | "en";
@@ -195,7 +195,9 @@ export function I18nProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem("locale") as Locale | null; const saved = localStorage.getItem("locale") as Locale | null;
if (saved && (saved === "zh" || saved === "en")) setLocale(saved); if (saved && (saved === "zh" || saved === "en")) {
startTransition(() => setLocale(saved));
}
}, []); }, []);
const handleSetLocale = (l: Locale) => { const handleSetLocale = (l: Locale) => {