feat: token stats and feature gating

main
sp mac bookpro 2605 2026-05-29 22:39:13 +08:00
parent e3997c661e
commit b2df36647c
9 changed files with 311 additions and 17 deletions

View File

@ -11,7 +11,6 @@ import MarketplacePage from './pages/MarketplacePage';
import TeamsPage from './pages/TeamsPage';
import PromptLibraryPage from './pages/PromptLibraryPage';
import StatsPage from './pages/StatsPage';
import LLMProvidersPage from './pages/LLMProvidersPage';
import SharedSessionPage from './pages/SharedSessionPage';
import WorkflowsPage from './pages/WorkflowsPage';
import { useAuth } from './store/auth';
@ -52,7 +51,6 @@ export default function App() {
<Route path="/teams" element={<TeamsPage />} />
<Route path="/prompts" element={<PromptLibraryPage />} />
<Route path="/stats" element={<StatsPage />} />
<Route path="/llm-providers" element={<LLMProvidersPage />} />
<Route path="/workflows" element={<WorkflowsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -486,9 +486,45 @@ export interface AgentStats {
daily: { day: string; count: number }[];
}
export interface AgentTokenStatsByModel {
providerKind: string;
model: string;
calls: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
costUSD: number;
}
export interface AgentTokenStatsDaily {
day: string;
calls: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
costUSD: number;
}
export interface AgentTokenStats {
agentId: string;
days: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
costUSD: number;
byModel: AgentTokenStatsByModel[];
daily: AgentTokenStatsDaily[];
}
export const StatsAPI = {
overview: () => api.get<StatsOverview>('/stats/overview').then((r) => r.data),
agent: (id: string) => api.get<AgentStats>(`/stats/agents/${id}`).then((r) => r.data)
agent: (id: string) => api.get<AgentStats>(`/stats/agents/${id}`).then((r) => r.data),
agentTokens: (id: string, opts: { days?: number; limit?: number } = {}) =>
api
.get<AgentTokenStats>(`/stats/agents/${id}/tokens`, {
params: { days: opts.days ?? 30, limit: opts.limit ?? 10 }
})
.then((r) => r.data)
};
// ============== 流式(手写 SSE不用 EventSource因为它不能 POST ==============

View File

@ -8,7 +8,6 @@ import {
BookOutlined,
ApartmentOutlined,
BarChartOutlined,
ApiOutlined,
TeamOutlined,
SunOutlined,
MoonOutlined,
@ -44,7 +43,6 @@ const NAV_GROUPS: Array<{
label: '管理',
items: [
{ to: '/stats', icon: <BarChartOutlined />, label: '调用统计' },
{ to: '/llm-providers', icon: <ApiOutlined />, label: 'LLM 提供商' },
{ to: '/teams', icon: <TeamOutlined />, label: '团队' }
]
}

View File

@ -74,6 +74,10 @@ export default function ChatPage() {
const abortRef = useRef<AbortController | null>(null);
const autoScrollRef = useRef(true);
const initialScrollDoneRef = useRef(false);
const scrollRafRef = useRef<number | null>(null);
const scrollProgrammaticRef = useRef(false);
const lastScrollTopRef = useRef(0);
const userScrollLockRef = useRef(false);
// URL 参数 ?session=xxx&msg=yyy
useEffect(() => {
@ -91,9 +95,21 @@ export default function ChatPage() {
}, [searchParams]);
const scrollBottom = (force = false) => {
if (!force && !autoScrollRef.current) return;
requestAnimationFrame(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight, behavior: 'smooth' });
if (force) {
userScrollLockRef.current = false;
autoScrollRef.current = true;
}
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) return;
if (scrollRafRef.current) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
}
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null;
const el = bodyRef.current;
if (!el) return;
scrollProgrammaticRef.current = true;
el.scrollTop = el.scrollHeight;
});
};
@ -101,8 +117,24 @@ export default function ChatPage() {
const el = bodyRef.current;
if (!el) return;
const onScroll = () => {
if (scrollProgrammaticRef.current) {
scrollProgrammaticRef.current = false;
lastScrollTopRef.current = el.scrollTop;
} else {
const nextTop = el.scrollTop;
const lastTop = lastScrollTopRef.current;
if (nextTop < lastTop - 2) {
userScrollLockRef.current = true;
}
lastScrollTopRef.current = nextTop;
}
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
autoScrollRef.current = distance < 32;
if (distance < 8) userScrollLockRef.current = false;
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
}
};
el.addEventListener('scroll', onScroll, { passive: true });
onScroll();
@ -294,7 +326,7 @@ export default function ChatPage() {
setSessionRefresh((t) => t + 1);
setAttachments([]); // 用完即清
setImageUrls([]);
scrollBottom(true);
scrollBottom();
},
onAborted: (data) => {
// 已停止保留已生成内容user 消息也留下(用 tempUser 占位)

View File

@ -125,7 +125,8 @@ export default function PromptLibraryPage({
});
return (
<div className="page-container">
<div className="feature-cover-container">
<div className="page-container">
<div
style={{
borderRadius: 24,
@ -489,6 +490,10 @@ export default function PromptLibraryPage({
</Form.Item>
</Form>
</Modal>
</div>
<div className="feature-cover">
<Empty description="功能规划中,本期不支持" />
</div>
</div>
);
}

View File

@ -1,23 +1,43 @@
import { useEffect, useState } from 'react';
import { BarChartOutlined, LineChartOutlined, MessageOutlined, RobotOutlined } from '@ant-design/icons';
import { Card, Empty, App as AntApp, Spin, Tag } from 'antd';
import { Card, Empty, App as AntApp, Spin, Tag, Select, Space, Table } from 'antd';
import { Link } from 'react-router-dom';
import { StatsAPI, StatsOverview } from '../api';
import { AgentTokenStats, StatsAPI, StatsOverview } from '../api';
export default function StatsPage() {
const { message } = AntApp.useApp();
const [data, setData] = useState<StatsOverview | null>(null);
const [loading, setLoading] = useState(false);
const [tokenLoading, setTokenLoading] = useState(false);
const [tokenData, setTokenData] = useState<AgentTokenStats | null>(null);
const [tokenAgentId, setTokenAgentId] = useState<string>('');
const [tokenDays, setTokenDays] = useState<number>(30);
const [tokenLimit, setTokenLimit] = useState<number>(10);
useEffect(() => {
setLoading(true);
StatsAPI.overview()
.then(setData)
.then((res) => {
setData(res);
if (!tokenAgentId && res.topAgents?.[0]?.id) {
setTokenAgentId(res.topAgents[0].id);
}
})
.catch((e) => message.error('加载失败:' + (e?.message ?? e)))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!tokenAgentId) return;
setTokenLoading(true);
StatsAPI.agentTokens(tokenAgentId, { days: tokenDays, limit: tokenLimit })
.then(setTokenData)
.catch((e) => message.error('Token 统计加载失败:' + (e?.message ?? e)))
.finally(() => setTokenLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tokenAgentId, tokenDays, tokenLimit]);
if (loading) return <Spin style={{ marginTop: 80, display: 'block' }} />;
if (!data) return <Empty description="暂无数据" style={{ marginTop: 80 }} />;
@ -25,6 +45,8 @@ export default function StatsPage() {
const maxAgent = Math.max(1, ...data.topAgents.map((a) => a.messageCount));
const last7Days = data.daily.slice(-7).reduce((sum, item) => sum + item.total, 0);
const avgSessionMessages = data.sessionCount > 0 ? (data.messageCount / data.sessionCount).toFixed(1) : '0.0';
const formatUSD = (v?: number | null) =>
`$${Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 4 })}`;
return (
<div className="page-container">
@ -250,6 +272,176 @@ export default function StatsPage() {
)}
</Card>
</div>
<div style={{ marginTop: 18 }}>
<Card
title="Token 使用量"
extra={
<Space size={10} wrap>
<Select
size="small"
value={tokenAgentId || undefined}
style={{ minWidth: 220 }}
placeholder="选择智能体"
onChange={setTokenAgentId}
options={data.topAgents.map((a) => ({ value: a.id, label: a.name }))}
/>
<Select
size="small"
value={tokenDays}
style={{ width: 120 }}
onChange={setTokenDays}
options={[
{ value: 7, label: '近 7 天' },
{ value: 30, label: '近 30 天' },
{ value: 90, label: '近 90 天' }
]}
/>
<Select
size="small"
value={tokenLimit}
style={{ width: 120 }}
onChange={setTokenLimit}
options={[
{ value: 5, label: 'Top 5' },
{ value: 10, label: 'Top 10' },
{ value: 20, label: 'Top 20' }
]}
/>
</Space>
}
style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}
>
{tokenLoading ? (
<Spin style={{ marginTop: 20, display: 'block' }} />
) : !tokenAgentId ? (
<Empty description="暂无可统计的智能体" style={{ marginTop: 20 }} />
) : !tokenData ? (
<Empty description="暂无 Token 统计数据" style={{ marginTop: 20 }} />
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 14, marginBottom: 16 }}>
{[
{ label: '总 Token', value: tokenData.totalTokens, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '输入 Token', value: tokenData.promptTokens, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
{ label: '输出 Token', value: tokenData.completionTokens, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
{ label: '费用 (USD)', value: formatUSD(tokenData.costUSD), tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: 999,
background: item.color,
marginRight: 6,
verticalAlign: 'middle'
}}
/>
{item.label}
</div>
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--color-text)' }}>
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
</div>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 18 }}>
<Card
size="small"
title="近 N 天趋势(总 Token"
extra={
<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)' }}>
days={tokenData.days}
</Tag>
}
style={{ borderRadius: 18, boxShadow: 'none' }}
>
{tokenData.daily.length === 0 ? (
<Empty description="暂无" />
) : (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 220, padding: '18px 12px 10px' }}>
{(() => {
const maxTokenDaily = Math.max(1, ...tokenData.daily.map((d) => d.totalTokens));
return tokenData.daily.map((d) => {
const h = (d.totalTokens / maxTokenDaily) * 170;
return (
<div
key={d.day}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
minWidth: 0
}}
title={`${d.day}\nCalls ${d.calls}\nTotalTokens ${d.totalTokens}\nCost ${formatUSD(d.costUSD)}`}
>
<div
style={{
width: 12,
height: h,
background: 'var(--gradient-brand)',
borderRadius: '6px 6px 0 0'
}}
/>
<div style={{ fontSize: 10, color: 'var(--color-text-tertiary)', marginTop: 6, transform: 'rotate(-45deg)' }}>
{d.day.slice(5)}
</div>
</div>
);
});
})()}
</div>
)}
</Card>
<Card
size="small"
title="模型消耗排行"
style={{ borderRadius: 18, boxShadow: 'none' }}
>
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.providerKind}:${r.model}`}
dataSource={tokenData.byModel || []}
columns={[
{ title: 'Provider', dataIndex: 'providerKind', width: 110 },
{ title: 'Model', dataIndex: 'model' },
{ title: 'Calls', dataIndex: 'calls', width: 80 },
{
title: 'Tokens',
dataIndex: 'totalTokens',
width: 110,
render: (v) => Number(v || 0).toLocaleString()
},
{
title: 'Cost',
dataIndex: 'costUSD',
width: 110,
render: (v) => formatUSD(v)
}
]}
/>
</Card>
</div>
</>
)}
</Card>
</div>
</div>
);
}

View File

@ -47,7 +47,8 @@ export default function TeamsPage() {
};
return (
<div className="page-container" style={{ maxWidth: 1080 }}>
<div className="feature-cover-container">
<div className="page-container" style={{ maxWidth: 1080 }}>
<div
style={{
borderRadius: 24,
@ -390,6 +391,10 @@ export default function TeamsPage() {
</Form>
)}
</Modal>
</div>
</div>
<div className="feature-cover">
<Empty description="功能规划中,本期不支持" />
</div>
</div>
);
}

View File

@ -135,7 +135,8 @@ export default function WorkflowsPage() {
};
return (
<div className="page-container" style={{ maxWidth: 1400 }}>
<div className="feature-cover-container">
<div className="page-container" style={{ maxWidth: 1400 }}>
<div
style={{
borderRadius: 24,
@ -358,6 +359,10 @@ export default function WorkflowsPage() {
onClose={() => setRunsOpen(false)}
/>
)}
</div>
<div className="feature-cover">
<Empty description="功能规划中,本期不支持" />
</div>
</div>
);
}

View File

@ -1004,6 +1004,29 @@ body {
padding: 40px 32px 60px;
}
.feature-cover-container {
position: relative;
}
.feature-cover {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.74);
backdrop-filter: blur(2px);
}
.feature-cover .ant-empty {
padding: 26px 30px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md);
}
.page-header {
margin-bottom: 32px;
}