Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

## [Unreleased]

### 新增

- Token 统计支持日期范围筛选(RangePicker 预设 + 自定义起止)并导出 Excel 用量明细(#154)
- Token 用量 Excel 含专家名称、中英列表头,以及按天/专家/模型汇总 sheet 与图表
- Token 用量 Excel 按报表规范打磨:冻结表头、千分位、合计公式行、筛选与仪表盘配色柱图

### 修复

- 共享专家的技能列表现在会在聊天输入框中加载,非所有者可查看并选择专家已配置的技能
Expand Down
9 changes: 6 additions & 3 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createGlobalStyle } from "antd-style";
import { ConfigProvider, theme as antdTheme } from "antd";
import zhCN from "antd/locale/zh_CN";
import enUS from "antd/locale/en_US";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import { useEffect } from "react";
import DesktopWindowControls from "./components/DesktopWindowControls";
import {
Expand Down Expand Up @@ -47,9 +49,10 @@ function ThemedApp() {
const brandTokens = brandTokensFor(palette, isDark, customColor);
// Make antd built-ins (Popconfirm OK/Cancel, Modal default footer, Empty,
// Pagination, DatePicker, Table… ) follow the current UI language.
const antdLocale = i18n.language?.toLowerCase().startsWith("zh")
? zhCN
: enUS;
// DatePicker month/weekday labels come from dayjs — keep it in sync too.
const isZh = i18n.language?.toLowerCase().startsWith("zh") ?? false;
const antdLocale = isZh ? zhCN : enUS;
dayjs.locale(isZh ? "zh-cn" : "en");

useUnauthorizedRedirect();

Expand Down
8 changes: 7 additions & 1 deletion dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2203,7 +2203,13 @@
"last7d": "Last 7 Days",
"last30d": "Last 30 Days",
"allTime": "All Time",
"customRange": "Custom Date",
"thisMonth": "This month",
"lastMonth": "Last month",
"rangeStart": "Start date",
"rangeEnd": "End date",
"exportExcel": "Export Excel",
"exportFailed": "Export failed",
"exportFilenamePrefix": "token-usage",
"summary": "Summary",
"byDay": "By day",
"byExpert": "By expert",
Expand Down
8 changes: 7 additions & 1 deletion dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2203,7 +2203,13 @@
"last7d": "近 7 天",
"last30d": "近 30 天",
"allTime": "全部",
"customRange": "自定义日期",
"thisMonth": "本月",
"lastMonth": "上月",
"rangeStart": "开始日期",
"rangeEnd": "结束日期",
"exportExcel": "导出 Excel",
"exportFailed": "导出失败",
"exportFilenamePrefix": "Token用量",
"summary": "汇总",
"byDay": "按天",
"byExpert": "按专家",
Expand Down
177 changes: 163 additions & 14 deletions dashboard/src/pages/Control/TokenUsage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,18 @@
*/

import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Card, Select, Spin, Table, Empty, Tag, Segmented } from "antd";
import {
Alert,
Button,
Card,
DatePicker,
Empty,
Select,
Segmented,
Spin,
Table,
Tag,
} from "antd";
import {
BarChart,
Bar,
Expand All @@ -22,15 +33,18 @@ import {
Cell,
Legend,
} from "recharts";
import dayjs, { type Dayjs } from "dayjs";
import { Download } from "lucide-react";
import { useTranslation } from "react-i18next";
import PageShell from "../../../layouts/PageShell";
import { useIsMobile } from "../../../hooks/useIsMobile";
import { UsageStats, type UsageStatItem } from "./UsageStats";
import { useUserRole } from "../../../hooks/useUserRole";
import { request } from "../../../api/request";
import { request, requestBlob } from "../../../api/request";
import { useAgent } from "../../../context/AgentContext";
import { useTheme } from "../../../context/ThemeContext";
import { brandPrimary } from "../../../styles/themePalettes";
import { message } from "../../../utils/antdMessage";
import styles from "./index.module.less";

interface UsageUserOption {
Expand Down Expand Up @@ -416,6 +430,59 @@ function fetchSummary(
return request<UsageSummary>(`/usage/summary?${params}`);
}

function usageExportPath(
windowKey: string,
agentFilter: string | "all",
userFilter: number | "all" | null,
): string {
const params = new URLSearchParams({ window: windowKey });
if (agentFilter !== "all") {
params.set("agent_id", agentFilter);
}
if (userFilter === "all") {
return `/admin/usage/export.xlsx?${params}`;
}
if (typeof userFilter === "number") {
params.set("user_id", String(userFilter));
return `/admin/usage/export.xlsx?${params}`;
}
return `/usage/export.xlsx?${params}`;
}

/** ``{title}_{YYYY-MM-DD-YYYY-MM-DD}.xlsx`` — strip ``range:`` / ``day:`` kind tags. */
function usageExportFilename(prefix: string, windowKey: string): string {
const rangeMatch = /^range:(\d{4}-\d{2}-\d{2}):(\d{4}-\d{2}-\d{2})$/.exec(
windowKey,
);
if (rangeMatch) {
return `${prefix}_${rangeMatch[1]}-${rangeMatch[2]}.xlsx`;
}
const dayMatch = /^day:(\d{4}-\d{2}-\d{2})$/.exec(windowKey);
if (dayMatch) {
return `${prefix}_${dayMatch[1]}.xlsx`;
}
const monthMatch = /^month:(\d{4}-\d{2})$/.exec(windowKey);
if (monthMatch) {
return `${prefix}_${monthMatch[1]}.xlsx`;
}
const period =
windowKey.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_|_$/g, "") ||
"usage";
return `${prefix}_${period}.xlsx`;
}

const { RangePicker } = DatePicker;

function rangeToWindowKey(range: [Dayjs, Dayjs] | null): string {
if (range === null) return "all";
const [start, end] = range;
return `range:${start.format("YYYY-MM-DD")}:${end.format("YYYY-MM-DD")}`;
}

function defaultLast30dRange(): [Dayjs, Dayjs] {
return [dayjs().add(-29, "day").startOf("day"), dayjs().endOf("day")];
}

function DonutCard({
title,
data,
Expand Down Expand Up @@ -765,11 +832,14 @@ export default function TokenUsagePage() {
const role = useUserRole();
const isAdmin = role === "admin";
const isMobile = useIsMobile();
const [windowKey, setWindowKey] = useState("last_30d");
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() =>
defaultLast30dRange(),
);
const [view, setView] = useState<ViewMode>("summary");
const [agentFilter, setAgentFilter] = useState<string | "all">("all");
const [userFilter, setUserFilter] = useState<number | "all">("all");
const [users, setUsers] = useState<UsageUserOption[]>([]);
const [exporting, setExporting] = useState(false);

const [totals, setTotals] = useState<UsageSummary | null>(null);
const [dimBuckets, setDimBuckets] = useState<UsageBucket[]>([]);
Expand All @@ -791,13 +861,46 @@ export default function TokenUsagePage() {
// Admin-only: null while role unknown so we don't hit /admin before ready.
const adminUserFilter: number | "all" | null = isAdmin ? userFilter : null;

const windowOptions = useMemo(
const windowKey = useMemo(() => rangeToWindowKey(dateRange), [dateRange]);

const rangePresets = useMemo(
() => [
{ value: "today", label: t("tokenUsage.today") },
{ value: "yesterday", label: t("tokenUsage.yesterday") },
{ value: "last_7d", label: t("tokenUsage.last7d") },
{ value: "last_30d", label: t("tokenUsage.last30d") },
{ value: "all", label: t("tokenUsage.allTime") },
{
label: t("tokenUsage.today"),
value: [dayjs().startOf("day"), dayjs().endOf("day")] as [Dayjs, Dayjs],
},
{
label: t("tokenUsage.yesterday"),
value: [
dayjs().add(-1, "day").startOf("day"),
dayjs().add(-1, "day").endOf("day"),
] as [Dayjs, Dayjs],
},
{
label: t("tokenUsage.last7d"),
value: [
dayjs().add(-6, "day").startOf("day"),
dayjs().endOf("day"),
] as [Dayjs, Dayjs],
},
{
label: t("tokenUsage.last30d"),
value: defaultLast30dRange(),
},
{
label: t("tokenUsage.thisMonth"),
value: [dayjs().startOf("month"), dayjs().endOf("day")] as [
Dayjs,
Dayjs,
],
},
{
label: t("tokenUsage.lastMonth"),
value: [
dayjs().add(-1, "month").startOf("month"),
dayjs().add(-1, "month").endOf("month"),
] as [Dayjs, Dayjs],
},
],
[t],
);
Expand Down Expand Up @@ -892,6 +995,29 @@ export default function TokenUsagePage() {
void refresh();
}, [refresh]);

const onExportExcel = useCallback(async () => {
if (role === null) return;
setExporting(true);
try {
const blob = await requestBlob(
usageExportPath(windowKey, agentFilter, adminUserFilter),
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = usageExportFilename(
t("tokenUsage.exportFilenamePrefix"),
windowKey,
);
link.click();
URL.revokeObjectURL(url);
} catch {
message.error(t("tokenUsage.exportFailed"));
} finally {
setExporting(false);
}
}, [windowKey, agentFilter, adminUserFilter, role, t]);

return (
<PageShell
title={t("pageShell.tokenUsage.title")}
Expand Down Expand Up @@ -926,12 +1052,26 @@ export default function TokenUsagePage() {
optionFilterProp="label"
/>
)}
<Select
value={windowKey}
onChange={setWindowKey}
<RangePicker
value={dateRange}
onChange={(values) => {
if (values?.[0] && values[1]) {
setDateRange([values[0], values[1]]);
} else {
setDateRange(null);
}
}}
disabledDate={(current) =>
current != null && current.isAfter(dayjs().endOf("day"))
}
presets={rangePresets}
allowClear
className={styles.toolbarFilterSelect}
style={{ width: isMobile ? undefined : 140 }}
options={windowOptions}
style={{ width: isMobile ? undefined : 280 }}
placeholder={[
t("tokenUsage.rangeStart"),
t("tokenUsage.rangeEnd"),
]}
/>
<Select
value={agentFilter}
Expand All @@ -940,6 +1080,15 @@ export default function TokenUsagePage() {
style={{ width: isMobile ? undefined : 200 }}
options={agentOptions}
/>
<Button
icon={<Download size={14} />}
loading={exporting}
disabled={role === null}
onClick={() => void onExportExcel()}
aria-label={t("tokenUsage.exportExcel")}
>
{isMobile ? null : t("tokenUsage.exportExcel")}
</Button>
</div>
</div>

Expand Down
Loading
Loading