feat(ai): Phase 1C 管理看板 — 用量/成本/功能开关三合一
- UsageService 新增 get_daily_usage + aggregate_daily 日聚合能力 - 新增 3 个管理端点: /ai/admin/daily-usage, /ai/admin/flags (GET+POST) - AiUsageDashboard 扩展为三 Tab: 用量概览/成本分析/功能开关 - 功能开关支持 Switch 实时切换,权限码 ai.admin.flags - 日聚合用量 30 天趋势表,含 Token/成本汇总统计
This commit is contained in:
@@ -45,6 +45,22 @@ export interface CostEstimate {
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface DailyUsageRow {
|
||||
date: string;
|
||||
feature: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
total_calls: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost_cents: number;
|
||||
}
|
||||
|
||||
export interface FeatureFlag {
|
||||
feature: string;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
overview: async () => {
|
||||
const resp = await client.get('/ai/usage/overview');
|
||||
@@ -74,4 +90,18 @@ export const usageApi = {
|
||||
const resp = await client.get('/ai/cost/estimate', { params });
|
||||
return resp.data.data as CostEstimate;
|
||||
},
|
||||
getDailyUsage: async (startDate: string, endDate: string) => {
|
||||
const resp = await client.get('/ai/admin/daily-usage', {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
return resp.data.data as DailyUsageRow[];
|
||||
},
|
||||
getFeatureFlags: async () => {
|
||||
const resp = await client.get('/ai/admin/flags');
|
||||
return resp.data.data as FeatureFlag[];
|
||||
},
|
||||
updateFeatureFlag: async (feature: string, enabled: boolean) => {
|
||||
const resp = await client.post('/ai/admin/flags', { feature, enabled });
|
||||
return resp.data.data as { feature: string; enabled: boolean };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Statistic, message, Empty, Result, Row, Col } from 'antd';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Card, Spin, Statistic, message, Empty, Result, Row, Col, Tabs, Table, Switch, Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
ExperimentOutlined,
|
||||
DollarOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { usageApi, type UsageOverview, type TypeDistribution } from '../../api/ai/usage';
|
||||
import {
|
||||
usageApi,
|
||||
type UsageOverview,
|
||||
type TypeDistribution,
|
||||
type DailyUsageRow,
|
||||
type FeatureFlag,
|
||||
} from '../../api/ai/usage';
|
||||
|
||||
const ANALYSIS_TYPE_MAP: Record<string, string> = {
|
||||
lab_report_interpretation: '化验单解读',
|
||||
health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案',
|
||||
report_summary_generation: '报告摘要',
|
||||
chat: 'AI 聊天',
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
@@ -20,35 +31,80 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
health_trend_analysis: '#52c41a',
|
||||
personalized_checkup_plan: '#722ed1',
|
||||
report_summary_generation: '#fa8c16',
|
||||
chat: '#eb2f96',
|
||||
};
|
||||
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
'ai.analysis': 'AI 分析',
|
||||
'ai.chat': 'AI 聊天',
|
||||
'ai.trend': '趋势分析',
|
||||
'ai.report': '报告摘要',
|
||||
'ai.checkup': '体检方案',
|
||||
'ai.copilot': 'Copilot 辅助',
|
||||
'ai.alert.push': 'AI 预警推送',
|
||||
'ai.rag': 'RAG 知识检索',
|
||||
'ai.voice': '语音交互',
|
||||
'ai.suggestion': 'AI 建议',
|
||||
'ai.lab': '化验解读',
|
||||
'ai.summary': '综合摘要',
|
||||
};
|
||||
|
||||
export default function AiUsageDashboard() {
|
||||
const { hasPermission } = usePermission('ai.usage.list');
|
||||
if (!hasPermission) return <Result status="403" title="权限不足" subTitle="您没有查看 AI 用量的权限" />;
|
||||
const adminFlags = usePermission('ai.admin.flags');
|
||||
const [overview, setOverview] = useState<UsageOverview | null>(null);
|
||||
const [types, setTypes] = useState<TypeDistribution[]>([]);
|
||||
const [dailyUsage, setDailyUsage] = useState<DailyUsageRow[]>([]);
|
||||
const [flags, setFlags] = useState<FeatureFlag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isDark = useThemeMode();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ov, tp] = await Promise.all([
|
||||
usageApi.overview(),
|
||||
usageApi.byType(),
|
||||
]);
|
||||
setOverview(ov);
|
||||
setTypes(tp);
|
||||
} catch {
|
||||
message.error('加载用量统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ov, tp] = await Promise.all([
|
||||
usageApi.overview(),
|
||||
usageApi.byType(),
|
||||
]);
|
||||
setOverview(ov);
|
||||
setTypes(tp);
|
||||
} catch {
|
||||
message.error('加载用量统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDailyUsage = useCallback(async () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 30);
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
try {
|
||||
const rows = await usageApi.getDailyUsage(fmt(start), fmt(end));
|
||||
setDailyUsage(rows);
|
||||
} catch {
|
||||
message.error('加载日聚合用量失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFlags = useCallback(async () => {
|
||||
try {
|
||||
const data = await usageApi.getFeatureFlags();
|
||||
setFlags(data);
|
||||
} catch {
|
||||
message.error('加载功能开关失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (!hasPermission) {
|
||||
return <Result status="403" title="权限不足" subTitle="您没有查看 AI 用量的权限" />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
|
||||
@@ -65,81 +121,236 @@ export default function AiUsageDashboard() {
|
||||
|
||||
const totalCount = types.reduce((sum, t) => sum + t.count, 0);
|
||||
|
||||
// 日聚合汇总
|
||||
const totalCalls = dailyUsage.reduce((s, r) => s + r.total_calls, 0);
|
||||
const totalTokens = dailyUsage.reduce(
|
||||
(s, r) => s + r.total_input_tokens + r.total_output_tokens,
|
||||
0,
|
||||
);
|
||||
const totalCost = dailyUsage.reduce((s, r) => s + r.total_cost_cents, 0);
|
||||
|
||||
const handleToggleFlag = async (feature: string, enabled: boolean) => {
|
||||
try {
|
||||
await usageApi.updateFeatureFlag(feature, enabled);
|
||||
setFlags((prev) =>
|
||||
prev.map((f) => (f.feature === feature ? { ...f, is_enabled: enabled } : f)),
|
||||
);
|
||||
message.success(`${FEATURE_LABELS[feature] || feature} 已${enabled ? '启用' : '禁用'}`);
|
||||
} catch {
|
||||
message.error('更新功能开关失败');
|
||||
}
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '用量概览',
|
||||
icon: <ThunderboltOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="总分析次数"
|
||||
value={overview?.total_count ?? 0}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#1890ff' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="分析类型数"
|
||||
value={types.length}
|
||||
prefix={<ExperimentOutlined style={{ color: '#52c41a' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="本月分析"
|
||||
value={totalCount}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#fa8c16' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={cardStyle} title="分析类型分布">
|
||||
{types.length === 0 ? (
|
||||
<Empty description="暂无分析数据" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{types.map((t) => {
|
||||
const pct = totalCount > 0 ? Math.round((t.count / totalCount) * 100) : 0;
|
||||
const label = ANALYSIS_TYPE_MAP[t.analysis_type] || t.analysis_type;
|
||||
const color = TYPE_COLORS[t.analysis_type] || '#1890ff';
|
||||
return (
|
||||
<Col span={6} key={t.analysis_type}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color }}>{t.count}</div>
|
||||
<div style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#475569', marginTop: 4 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8', marginTop: 4 }}>
|
||||
{pct}%
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
label: '成本分析',
|
||||
icon: <DollarOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="30 天总调用"
|
||||
value={totalCalls}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="30 天总 Token"
|
||||
value={totalTokens}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="30 天成本"
|
||||
value={(totalCost / 100).toFixed(2)}
|
||||
prefix="¥"
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={cardStyle} title="日用量明细(近 30 天)">
|
||||
<Table
|
||||
dataSource={dailyUsage}
|
||||
rowKey={(r) => `${r.date}-${r.feature}`}
|
||||
size="small"
|
||||
pagination={{ pageSize: 15 }}
|
||||
columns={[
|
||||
{ title: '日期', dataIndex: 'date', width: 120 },
|
||||
{ title: '功能', dataIndex: 'feature', width: 150 },
|
||||
{ title: '调用次数', dataIndex: 'total_calls', width: 100 },
|
||||
{
|
||||
title: '输入 Token',
|
||||
dataIndex: 'total_input_tokens',
|
||||
width: 120,
|
||||
render: (v: number) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '输出 Token',
|
||||
dataIndex: 'total_output_tokens',
|
||||
width: 120,
|
||||
render: (v: number) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
dataIndex: 'total_cost_cents',
|
||||
width: 100,
|
||||
render: (v: number) => `¥${(v / 100).toFixed(2)}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'flags',
|
||||
label: '功能开关',
|
||||
icon: <SettingOutlined />,
|
||||
children: (
|
||||
<Card style={cardStyle} title="AI 功能开关">
|
||||
{flags.length === 0 ? (
|
||||
<Empty description="暂无功能开关配置" />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={flags}
|
||||
rowKey="feature"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '功能',
|
||||
dataIndex: 'feature',
|
||||
render: (f: string) => FEATURE_LABELS[f] || f,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_enabled',
|
||||
width: 200,
|
||||
render: (enabled: boolean, record: FeatureFlag) =>
|
||||
adminFlags.hasPermission ? (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(checked) => handleToggleFlag(record.feature, checked)}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="禁用"
|
||||
/>
|
||||
) : (
|
||||
<Tag color={enabled ? 'green' : 'default'}>
|
||||
{enabled ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>AI 用量统计</h4>
|
||||
<div className="erp-page-subtitle">查看 AI 分析服务的使用情况</div>
|
||||
<h4>AI 管理看板</h4>
|
||||
<div className="erp-page-subtitle">AI 功能用量统计、成本分析与功能管理</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="总分析次数"
|
||||
value={overview?.total_count ?? 0}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#1890ff' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="分析类型数"
|
||||
value={types.length}
|
||||
prefix={<ExperimentOutlined style={{ color: '#52c41a' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="本月分析"
|
||||
value={totalCount}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#fa8c16' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={cardStyle} title="分析类型分布">
|
||||
{types.length === 0 ? (
|
||||
<Empty description="暂无分析数据" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{types.map((t) => {
|
||||
const pct = totalCount > 0 ? Math.round((t.count / totalCount) * 100) : 0;
|
||||
const label = ANALYSIS_TYPE_MAP[t.analysis_type] || t.analysis_type;
|
||||
const color = TYPE_COLORS[t.analysis_type] || '#1890ff';
|
||||
return (
|
||||
<Col span={6} key={t.analysis_type}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color }}>{t.count}</div>
|
||||
<div style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#475569', marginTop: 4 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8', marginTop: 4 }}>
|
||||
{pct}%
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
<Tabs
|
||||
defaultActiveKey="overview"
|
||||
items={tabItems}
|
||||
onChange={(key) => {
|
||||
if (key === 'cost') fetchDailyUsage();
|
||||
if (key === 'flags') fetchFlags();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user