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:
iven
2026-05-18 23:36:33 +08:00
parent 5ba28ea349
commit 89581b070f
5 changed files with 536 additions and 89 deletions

View File

@@ -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 };
},
};

View File

@@ -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>
);
}