feat: add output/input ratio column to aggregation page
Add a sortable "Out/In Ratio" (completion_tokens / prompt_tokens) column with a portal-based tooltip explaining the metric. Fix hydration mismatch by switching to useSyncExternalStore for localStorage reads in TimeRangeProvider. Update CLAUDE.md with project documentation.
This commit is contained in:
@@ -68,6 +68,8 @@ const translations = {
|
||||
"agg.userCount": "用户数",
|
||||
"agg.totalCalls": "总调用",
|
||||
"agg.totalToken": "总 Token",
|
||||
"agg.ratio": "转换率",
|
||||
"agg.ratioTip": "输出Token / 输入Token,反映每次请求的生成效率。>1 表示输出多于输入(如生成、写作),<1 表示输入多于输出(如分析、摘要)",
|
||||
// logs
|
||||
"logs.title": "日志明细",
|
||||
"logs.filterUser": "用户名",
|
||||
@@ -148,6 +150,8 @@ const translations = {
|
||||
"agg.userCount": "Users",
|
||||
"agg.totalCalls": "Total Calls",
|
||||
"agg.totalToken": "Total Token",
|
||||
"agg.ratio": "Out/In Ratio",
|
||||
"agg.ratioTip": "Completion tokens / Prompt tokens. >1 means more output than input (generation, writing); <1 means more input than output (analysis, summarization)",
|
||||
"logs.title": "Log Details",
|
||||
"logs.filterUser": "Username",
|
||||
"logs.filterModel": "Model",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
||||
import { createContext, useContext, useState, useCallback, useSyncExternalStore, type ReactNode } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import { type TimeRange, getTimeRange } from "@/lib/utils";
|
||||
|
||||
@@ -25,45 +25,70 @@ const TimeRangeContext = createContext<TimeRangeContextType>({
|
||||
getEffectiveRange: () => ({}),
|
||||
});
|
||||
|
||||
function loadSaved(): { range: TimeRange; customStart: string; customEnd: string } | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem("time-range");
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
range: parsed.range || "30d",
|
||||
customStart: parsed.customStart || "",
|
||||
customEnd: parsed.customEnd || "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Use useSyncExternalStore to safely read localStorage without hydration mismatch
|
||||
const STORAGE_KEY = "time-range";
|
||||
const DEFAULT_RANGE = "30d";
|
||||
|
||||
let listeners: Array<() => void> = [];
|
||||
function subscribe(cb: () => void) {
|
||||
listeners = [...listeners, cb];
|
||||
return () => { listeners = listeners.filter(l => l !== cb); };
|
||||
}
|
||||
function emitChange() {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function getSnapshot(): string {
|
||||
return localStorage.getItem(STORAGE_KEY) ?? "";
|
||||
}
|
||||
function getServerSnapshot(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function persist(range: TimeRange, customStart: string, customEnd: string) {
|
||||
localStorage.setItem("time-range", JSON.stringify({ range, customStart, customEnd }));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ range, customStart, customEnd }));
|
||||
emitChange();
|
||||
}
|
||||
|
||||
export function TimeRangeProvider({ children }: { children: ReactNode }) {
|
||||
const [range, setRangeState] = useState<TimeRange>(() => loadSaved()?.range ?? "30d");
|
||||
const [customStart, setCustomStartState] = useState(() => loadSaved()?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD"));
|
||||
const [customEnd, setCustomEndState] = useState(() => loadSaved()?.customEnd || dayjs().format("YYYY-MM-DD"));
|
||||
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const saved = (() => {
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
})();
|
||||
|
||||
const [range, setRangeState] = useState<TimeRange>(saved?.range ?? DEFAULT_RANGE);
|
||||
const [customStart, setCustomStartState] = useState(
|
||||
saved?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD")
|
||||
);
|
||||
const [customEnd, setCustomEndState] = useState(
|
||||
saved?.customEnd || dayjs().format("YYYY-MM-DD")
|
||||
);
|
||||
|
||||
const setRange = useCallback((r: TimeRange) => {
|
||||
setRangeState(r);
|
||||
setCustomStartState(prev => { persist(r, prev, customEnd); return prev; });
|
||||
}, [customEnd]);
|
||||
setCustomStartState(prev => {
|
||||
setCustomEndState(end => { persist(r, prev, end); return end; });
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setCustomStart = useCallback((s: string) => {
|
||||
setCustomStartState(s);
|
||||
persist(range, s, customEnd);
|
||||
}, [range, customEnd]);
|
||||
setRangeState(r => {
|
||||
setCustomEndState(end => { persist(r, s, end); return end; });
|
||||
return r;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setCustomEnd = useCallback((e: string) => {
|
||||
setCustomEndState(e);
|
||||
persist(range, customStart, e);
|
||||
}, [range, customStart]);
|
||||
setRangeState(r => {
|
||||
setCustomStartState(start => { persist(r, start, e); return start; });
|
||||
return r;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getEffectiveRange = useCallback(() => {
|
||||
if (range === "custom") {
|
||||
|
||||
Reference in New Issue
Block a user