Skip to content

Commit a146e3b

Browse files
committed
feat: 添加多列排序功能,支持 Records 页表头排序,优化实时用量错误处理逻辑
1 parent 620fa67 commit a146e3b

5 files changed

Lines changed: 139 additions & 78 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## 2026-03-06
44

5+
- 修复首页"无法加载实时用量:"后内容为空的问题:
6+
- HTTP/2 协议不携带 status text,`res.statusText` 在现代部署中始终为空字符串。
7+
- 改为优先读取响应体 JSON 中的 `error` 字段,回退到 `res.statusText`,最终回退到 `HTTP ${res.status}`
8+
- `catch` 分支的 `error.message` 同样增加 `|| "未知错误"` 兜底。
9+
510
- Explore 页模型图例排序切换:
611
- 点击"模型图例"右侧的排序标签可循环切换:首字母 → Token用量 → 次数 → 首字母。
712
- 排序计算在 `ModelLegend` 组件内部维护(`legendSort` state + `sortedModels` useMemo),不影响外部状态。
@@ -19,6 +24,13 @@
1924

2025
## 2026-03-05
2126

27+
- Records 页表头多列排序:
28+
- 点击**未激活**列 → 插入头部成为主排序键(desc);点击**已激活**列第二次 → 切换为 asc;第三次 → 从排序列表移除(`occurredAt` 列不允许移除,第三次循环回 desc)。
29+
- 存在多个排序键时,表头箭头旁显示小数字标注优先级(₁₂₃...);悬停显示操作提示。
30+
- URL 参数改为 `sort=field:order,field:order` 格式,兼容旧 `sortField`+`sortOrder` 参数。
31+
- 游标分页采用方案 A:以首个排序键(主键)+ id 作为游标,次级排序在每页内精确有序。
32+
- 改动文件:`lib/queries/records.ts`(添加 `SortKey` 类型和 `getSortExpr` 辅助函数,`getUsageRecords` 接受 `sortKeys[]`)、`app/api/records/route.ts`(解析 `sort` 参数)、`app/records/page.tsx`(多键排序状态与交互)。
33+
2234
- 首页饼图颜色分配方式优化:
2335
- 原逻辑按模型在原始数组中的位置分配颜色,导致颜色与排名无关联。
2436
- 改为按 tokens 降序排名为每个模型分配固定颜色索引(`pieColorIndexMap` useMemo),tokens最多的模型始终得到颜色表第一个颜色,依此类推;对饼图 `Cell` 和自定义图例均生效,普通视图和全屏视图保持一致。

app/api/records/route.ts

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextResponse } from "next/server";
22
import { assertEnv } from "@/lib/config";
3-
import { getUsageRecords } from "@/lib/queries/records";
3+
import { getUsageRecords, type SortKey } from "@/lib/queries/records";
44

55
export const runtime = "nodejs";
66

@@ -14,20 +14,21 @@ export async function GET(request: Request) {
1414
try {
1515
const { searchParams } = new URL(request.url);
1616
const limitParam = searchParams.get("limit");
17-
const sortField = searchParams.get("sortField") as
18-
| "occurredAt"
19-
| "model"
20-
| "route"
21-
| "source"
22-
| "totalTokens"
23-
| "inputTokens"
24-
| "outputTokens"
25-
| "reasoningTokens"
26-
| "cachedTokens"
27-
| "cost"
28-
| "isError"
29-
| null;
30-
const sortOrder = searchParams.get("sortOrder") as "asc" | "desc" | null;
17+
const VALID_SORT_FIELDS = new Set(["occurredAt", "model", "route", "source", "totalTokens", "inputTokens", "outputTokens", "reasoningTokens", "cachedTokens", "cost", "isError"]);
18+
const sortParam = searchParams.get("sort");
19+
let sortKeys: SortKey[] | undefined;
20+
if (sortParam) {
21+
const parsed = sortParam.split(",").map(part => {
22+
const [f, o] = part.split(":");
23+
return { field: (f ?? "").trim(), order: (o ?? "desc").trim() };
24+
}).filter(k => VALID_SORT_FIELDS.has(k.field) && (k.order === "asc" || k.order === "desc")) as SortKey[];
25+
if (parsed.length > 0) sortKeys = parsed;
26+
}
27+
// Legacy fallback
28+
const legacySortField = searchParams.get("sortField");
29+
const legacySortOrder = searchParams.get("sortOrder");
30+
const sortField = !sortKeys && legacySortField && VALID_SORT_FIELDS.has(legacySortField) ? legacySortField as SortKey["field"] : undefined;
31+
const sortOrder = !sortKeys && (legacySortOrder === "asc" || legacySortOrder === "desc") ? legacySortOrder : undefined;
3132
const cursor = searchParams.get("cursor");
3233
const model = searchParams.get("model");
3334
const route = searchParams.get("route");
@@ -40,8 +41,9 @@ export async function GET(request: Request) {
4041

4142
const payload = await getUsageRecords({
4243
limit,
43-
sortField: sortField ?? undefined,
44-
sortOrder: sortOrder ?? undefined,
44+
sortKeys,
45+
sortField,
46+
sortOrder,
4547
cursor,
4648
model: model || undefined,
4749
route: route || undefined,

app/page.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,14 @@ export default function DashboardPage() {
792792

793793
if (!res.ok) {
794794
if (active) {
795-
setOverviewError("无法加载实时用量:" + res.statusText);
795+
let errMsg: string;
796+
try {
797+
const errBody = await res.json();
798+
errMsg = errBody?.error || res.statusText || `HTTP ${res.status}`;
799+
} catch {
800+
errMsg = res.statusText || `HTTP ${res.status}`;
801+
}
802+
setOverviewError("无法加载实时用量:" + errMsg);
796803
setOverview(null);
797804
}
798805
return;
@@ -812,7 +819,7 @@ export default function DashboardPage() {
812819
if (!active) return;
813820
const error = err as Error;
814821
if ((error as any)?.name === "AbortError") return;
815-
setOverviewError("无法加载实时用量:" + error.message);
822+
setOverviewError("无法加载实时用量:" + (error.message || "未知错误"));
816823
setOverview(null);
817824
} finally {
818825
if (active) setLoadingOverview(false);

app/records/page.tsx

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type SortField =
5252
| "cost"
5353
| "isError";
5454
type SortOrder = "asc" | "desc";
55+
type SortKey = { field: SortField; order: SortOrder };
5556

5657
type ColumnKey =
5758
| "occurredAt"
@@ -250,25 +251,32 @@ function SkeletonRow() {
250251

251252
function SortHeader({
252253
label,
253-
active,
254+
priority,
254255
order,
256+
showPriority,
255257
onClick
256258
}: {
257259
label: string;
258-
active: boolean;
259-
order: SortOrder;
260+
priority?: number;
261+
order?: SortOrder;
262+
showPriority?: boolean;
260263
onClick: () => void;
261264
}) {
265+
const active = priority !== undefined;
262266
return (
263267
<button
264268
type="button"
265269
onClick={onClick}
270+
title={active ? "" : undefined}
266271
className={`inline-flex items-center gap-1 font-semibold transition ${active ? "text-white" : "text-slate-300 hover:text-white"}`}
267272
>
268273
<span>{label}</span>
269-
{active ? (
274+
{active && order ? (
270275
order === "asc" ? <ArrowUp className="h-3.5 w-3.5" /> : <ArrowDown className="h-3.5 w-3.5" />
271276
) : null}
277+
{active && showPriority && priority !== undefined ? (
278+
<span className="text-[10px] font-bold leading-none opacity-60">{priority}</span>
279+
) : null}
272280
</button>
273281
);
274282
}
@@ -308,8 +316,8 @@ export default function RecordsPage() {
308316
const [appliedStart, setAppliedStart] = useState<string>("");
309317
const [appliedEnd, setAppliedEnd] = useState<string>("");
310318

311-
const [sortField, setSortField] = useState<SortField>("occurredAt");
312-
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
319+
const [sortKeys, setSortKeys] = useState<SortKey[]>([{ field: "occurredAt", order: "desc" }]);
320+
const [hasLoaded, setHasLoaded] = useState(false);
313321
const [columnSettings, setColumnSettings] = useState<ColumnSetting[]>(
314322
DEFAULT_COLUMN_ORDER.map((key) => ({
315323
key,
@@ -509,8 +517,7 @@ export default function RecordsPage() {
509517
(cursorValue?: string | null, includeFilters?: boolean) => {
510518
const params = new URLSearchParams();
511519
params.set("limit", String(PAGE_SIZE));
512-
params.set("sortField", sortField);
513-
params.set("sortOrder", sortOrder);
520+
params.set("sort", sortKeys.map(k => `${k.field}:${k.order}`).join(","));
514521
if (cursorValue) params.set("cursor", cursorValue);
515522
if (appliedModel) params.set("model", appliedModel);
516523
if (appliedRoute) params.set("route", appliedRoute);
@@ -520,7 +527,7 @@ export default function RecordsPage() {
520527
if (includeFilters) params.set("includeFilters", "1");
521528
return params;
522529
},
523-
[sortField, sortOrder, appliedModel, appliedRoute, appliedSource, appliedStart, appliedEnd]
530+
[sortKeys, appliedModel, appliedRoute, appliedSource, appliedStart, appliedEnd]
524531
);
525532

526533
const fetchRecords = useCallback(
@@ -538,6 +545,7 @@ export default function RecordsPage() {
538545
setCursor(data.nextCursor ?? null);
539546
setHasMore(Boolean(data.nextCursor));
540547
setRecords((prev) => (opts.append ? [...prev, ...data.items] : data.items));
548+
setHasLoaded(true);
541549
if (data.filters?.models?.length) {
542550
setModels(data.filters.models);
543551
}
@@ -670,17 +678,27 @@ export default function RecordsPage() {
670678
}, [cursor, fetchRecords, hasMore, loading]);
671679

672680
const handleSort = useCallback((field: SortField) => {
673-
if (field === sortField) {
674-
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
675-
} else {
676-
setSortField(field);
677-
setSortOrder("desc");
678-
}
679-
}, [sortField]);
681+
setSortKeys(prev => {
682+
const idx = prev.findIndex(k => k.field === field);
683+
if (idx !== -1) {
684+
const current = prev[idx];
685+
if (current.order === "asc") {
686+
// 第三次点击:移除(多键时)或循环回 desc(唯一键时)
687+
// occurredAt 列不允许移除,始终保留
688+
if (prev.length > 1 && field !== "occurredAt") return prev.filter((_, i) => i !== idx);
689+
return prev.map((k, i) => i === idx ? { ...k, order: "desc" } : k);
690+
}
691+
// 第二次点击: desc → asc
692+
return prev.map((k, i) => i === idx ? { ...k, order: "asc" } : k);
693+
}
694+
// 第一次点击:插入头部为主键
695+
return [{ field, order: "desc" }, ...prev];
696+
});
697+
}, []);
680698

681699
useEffect(() => {
682700
resetAndFetch(false);
683-
}, [sortField, sortOrder, resetAndFetch]);
701+
}, [sortKeys, resetAndFetch]);
684702

685703
const applyFilters = (overrides?: { model?: string; route?: string; source?: string; start?: string; end?: string }) => {
686704
const nextModel = (overrides?.model ?? modelInput).trim();
@@ -773,16 +791,20 @@ export default function RecordsPage() {
773791
return <span className="font-semibold text-slate-300">{COLUMN_LABELS[columnKey]}</span>;
774792
}
775793

794+
const keyIdx = sortKeys.findIndex(k => k.field === sortTarget);
795+
const priority = keyIdx !== -1 ? keyIdx + 1 : undefined;
796+
const order = keyIdx !== -1 ? sortKeys[keyIdx].order : undefined;
776797
return (
777798
<SortHeader
778799
label={COLUMN_LABELS[columnKey]}
779-
active={sortField === sortTarget}
780-
order={sortOrder}
800+
priority={priority}
801+
order={order}
802+
showPriority={sortKeys.length > 1}
781803
onClick={() => handleSort(sortTarget)}
782804
/>
783805
);
784806
},
785-
[sortField, sortOrder, handleSort]
807+
[sortKeys, handleSort]
786808
);
787809

788810
const renderCellByColumn = useCallback(
@@ -1229,8 +1251,8 @@ export default function RecordsPage() {
12291251
<p className="mt-3 text-xs text-slate-500">当前筛选:{filterSummary}</p>
12301252
</section>
12311253

1232-
<section className={`mt-5 rounded-2xl bg-slate-800/40 p-4 shadow-sm ring-1 ring-slate-700 ${loadingEmpty ? "min-h-[100vh]" : ""}`}>
1233-
{!loadingEmpty ? (
1254+
<section className={`mt-5 rounded-2xl bg-slate-800/40 p-4 shadow-sm ring-1 ring-slate-700 ${loadingEmpty && !hasLoaded ? "min-h-[100vh]" : ""}`}>
1255+
{(!loadingEmpty || hasLoaded) ? (
12341256
<div ref={tableWrapperRef} className="overflow-auto">
12351257
<table className="min-w-full w-full table-fixed border-separate border-spacing-y-2">
12361258
<thead className="sticky top-0 z-10">
@@ -1289,12 +1311,23 @@ export default function RecordsPage() {
12891311
})}
12901312
</tr>
12911313
))}
1314+
{loading && records.length === 0 && hasLoaded ? (
1315+
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map(i => (
1316+
<tr key={`skel-${i}`} className="h-13">
1317+
{visibleColumns.map(colKey => (
1318+
<td key={colKey} className="px-3 py-3">
1319+
<div className="h-3 w-4/5 animate-pulse rounded bg-slate-700/60" />
1320+
</td>
1321+
))}
1322+
</tr>
1323+
))
1324+
) : null}
12921325
</tbody>
12931326
</table>
12941327
</div>
12951328
) : null}
12961329

1297-
{loadingEmpty ? (
1330+
{loadingEmpty && !hasLoaded ? (
12981331
<div className="mt-4 grid min-h-[55vh] gap-3">
12991332
{[1, 2, 3].map((i) => (
13001333
<SkeletonRow key={i} />
@@ -1306,7 +1339,7 @@ export default function RecordsPage() {
13061339

13071340
{isEmpty ? <p className="mt-4 text-sm text-slate-400">暂无记录</p> : null}
13081341

1309-
{records.length > 0 ? (
1342+
{(records.length > 0 || (hasLoaded && loading)) ? (
13101343
<div className="mt-4 flex items-center justify-between text-xs text-slate-500">
13111344
<span>已加载 {records.length}</span>
13121345
{loading ? <span>加载中...</span> : hasMore ? <span>继续向下滚动加载</span> : <span>已到底</span>}

0 commit comments

Comments
 (0)