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;
|
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 = {
|
export const usageApi = {
|
||||||
overview: async () => {
|
overview: async () => {
|
||||||
const resp = await client.get('/ai/usage/overview');
|
const resp = await client.get('/ai/usage/overview');
|
||||||
@@ -74,4 +90,18 @@ export const usageApi = {
|
|||||||
const resp = await client.get('/ai/cost/estimate', { params });
|
const resp = await client.get('/ai/cost/estimate', { params });
|
||||||
return resp.data.data as CostEstimate;
|
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 { useEffect, useState, useCallback } from 'react';
|
||||||
import { Card, Spin, Statistic, message, Empty, Result, Row, Col } from 'antd';
|
import {
|
||||||
|
Card, Spin, Statistic, message, Empty, Result, Row, Col, Tabs, Table, Switch, Tag,
|
||||||
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
ExperimentOutlined,
|
ExperimentOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
SettingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
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> = {
|
const ANALYSIS_TYPE_MAP: Record<string, string> = {
|
||||||
lab_report_interpretation: '化验单解读',
|
lab_report_interpretation: '化验单解读',
|
||||||
health_trend_analysis: '趋势分析',
|
health_trend_analysis: '趋势分析',
|
||||||
personalized_checkup_plan: '体检方案',
|
personalized_checkup_plan: '体检方案',
|
||||||
report_summary_generation: '报告摘要',
|
report_summary_generation: '报告摘要',
|
||||||
|
chat: 'AI 聊天',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
@@ -20,35 +31,80 @@ const TYPE_COLORS: Record<string, string> = {
|
|||||||
health_trend_analysis: '#52c41a',
|
health_trend_analysis: '#52c41a',
|
||||||
personalized_checkup_plan: '#722ed1',
|
personalized_checkup_plan: '#722ed1',
|
||||||
report_summary_generation: '#fa8c16',
|
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() {
|
export default function AiUsageDashboard() {
|
||||||
const { hasPermission } = usePermission('ai.usage.list');
|
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 [overview, setOverview] = useState<UsageOverview | null>(null);
|
||||||
const [types, setTypes] = useState<TypeDistribution[]>([]);
|
const [types, setTypes] = useState<TypeDistribution[]>([]);
|
||||||
|
const [dailyUsage, setDailyUsage] = useState<DailyUsageRow[]>([]);
|
||||||
|
const [flags, setFlags] = useState<FeatureFlag[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const isDark = useThemeMode();
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchData = useCallback(async () => {
|
||||||
const fetchData = async () => {
|
setLoading(true);
|
||||||
setLoading(true);
|
try {
|
||||||
try {
|
const [ov, tp] = await Promise.all([
|
||||||
const [ov, tp] = await Promise.all([
|
usageApi.overview(),
|
||||||
usageApi.overview(),
|
usageApi.byType(),
|
||||||
usageApi.byType(),
|
]);
|
||||||
]);
|
setOverview(ov);
|
||||||
setOverview(ov);
|
setTypes(tp);
|
||||||
setTypes(tp);
|
} catch {
|
||||||
} catch {
|
message.error('加载用量统计失败');
|
||||||
message.error('加载用量统计失败');
|
} finally {
|
||||||
} finally {
|
setLoading(false);
|
||||||
setLoading(false);
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
|
<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 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="erp-page-header">
|
<div className="erp-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h4>AI 用量统计</h4>
|
<h4>AI 管理看板</h4>
|
||||||
<div className="erp-page-subtitle">查看 AI 分析服务的使用情况</div>
|
<div className="erp-page-subtitle">AI 功能用量统计、成本分析与功能管理</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
<Tabs
|
||||||
<Col span={8}>
|
defaultActiveKey="overview"
|
||||||
<Card style={cardStyle}>
|
items={tabItems}
|
||||||
<Statistic
|
onChange={(key) => {
|
||||||
title="总分析次数"
|
if (key === 'cost') fetchDailyUsage();
|
||||||
value={overview?.total_count ?? 0}
|
if (key === 'flags') fetchFlags();
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -832,6 +832,107 @@ where
|
|||||||
Ok(Json(ApiResponse::ok(estimate)))
|
Ok(Json(ApiResponse::ok(estimate)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === AI 管理看板 ===
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||||
|
pub struct DailyUsageQuery {
|
||||||
|
pub start_date: String,
|
||||||
|
pub end_date: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/ai/admin/daily-usage",
|
||||||
|
params(DailyUsageQuery),
|
||||||
|
responses((status = 200, description = "按日聚合用量")),
|
||||||
|
tag = "AI 管理",
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
)]
|
||||||
|
pub async fn admin_daily_usage<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(params): Query<DailyUsageQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "ai.admin.dashboard")?;
|
||||||
|
|
||||||
|
let start_date = chrono::NaiveDate::parse_from_str(¶ms.start_date, "%Y-%m-%d")
|
||||||
|
.map_err(|_| erp_core::error::AppError::Validation("start_date 格式错误".into()))?;
|
||||||
|
let end_date = chrono::NaiveDate::parse_from_str(¶ms.end_date, "%Y-%m-%d")
|
||||||
|
.map_err(|_| erp_core::error::AppError::Validation("end_date 格式错误".into()))?;
|
||||||
|
|
||||||
|
let rows = state
|
||||||
|
.usage
|
||||||
|
.get_daily_usage(ctx.tenant_id, start_date, end_date)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||||
|
"data": rows,
|
||||||
|
"start_date": params.start_date,
|
||||||
|
"end_date": params.end_date,
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/ai/admin/flags",
|
||||||
|
responses((status = 200, description = "功能开关列表")),
|
||||||
|
tag = "AI 管理",
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
)]
|
||||||
|
pub async fn admin_list_flags<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<
|
||||||
|
Json<ApiResponse<Vec<crate::service::feature_flag_service::FeatureFlag>>>,
|
||||||
|
erp_core::error::AppError,
|
||||||
|
>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "ai.admin.flags")?;
|
||||||
|
let flags = state.feature_flags.get_all(ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(flags)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateFlagBody {
|
||||||
|
pub feature: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/ai/admin/flags",
|
||||||
|
request_body = UpdateFlagBody,
|
||||||
|
responses((status = 200, description = "更新功能开关")),
|
||||||
|
tag = "AI 管理",
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
)]
|
||||||
|
pub async fn admin_update_flag<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(body): Json<UpdateFlagBody>,
|
||||||
|
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "ai.admin.flags")?;
|
||||||
|
state
|
||||||
|
.feature_flags
|
||||||
|
.set_enabled(ctx.tenant_id, &body.feature, body.enabled, ctx.user_id)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||||
|
"feature": body.feature,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
// === SSE 流构建辅助 ===
|
// === SSE 流构建辅助 ===
|
||||||
|
|
||||||
fn build_sse_stream(
|
fn build_sse_stream(
|
||||||
|
|||||||
@@ -494,6 +494,19 @@ impl AiModule {
|
|||||||
"/ai/health-summary",
|
"/ai/health-summary",
|
||||||
axum::routing::get(crate::handler::insight_handler::health_summary),
|
axum::routing::get(crate::handler::insight_handler::health_summary),
|
||||||
)
|
)
|
||||||
|
// AI 管理看板
|
||||||
|
.route(
|
||||||
|
"/ai/admin/daily-usage",
|
||||||
|
axum::routing::get(crate::handler::admin_daily_usage),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/ai/admin/flags",
|
||||||
|
axum::routing::get(crate::handler::admin_list_flags),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/ai/admin/flags",
|
||||||
|
axum::routing::post(crate::handler::admin_update_flag),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/ai/budget/status",
|
"/ai/budget/status",
|
||||||
axum::routing::get(crate::handler::budget_status),
|
axum::routing::get(crate::handler::budget_status),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::entity::ai_analysis;
|
use crate::entity::ai_analysis;
|
||||||
use crate::entity::ai_usage;
|
use crate::entity::ai_usage;
|
||||||
|
use crate::entity::ai_usage_daily;
|
||||||
use crate::error::AiResult;
|
use crate::error::AiResult;
|
||||||
|
|
||||||
pub struct UsageService {
|
pub struct UsageService {
|
||||||
@@ -76,6 +77,85 @@ impl UsageService {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按日期范围查询日聚合用量
|
||||||
|
pub async fn get_daily_usage(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
start_date: chrono::NaiveDate,
|
||||||
|
end_date: chrono::NaiveDate,
|
||||||
|
) -> AiResult<Vec<DailyUsageRow>> {
|
||||||
|
let rows = ai_usage_daily::Entity::find()
|
||||||
|
.filter(ai_usage_daily::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(ai_usage_daily::Column::Date.gte(start_date))
|
||||||
|
.filter(ai_usage_daily::Column::Date.lte(end_date))
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| DailyUsageRow {
|
||||||
|
date: r.date,
|
||||||
|
feature: r.feature,
|
||||||
|
provider: r.provider,
|
||||||
|
model: r.model,
|
||||||
|
total_calls: r.total_calls,
|
||||||
|
total_input_tokens: r.total_input_tokens,
|
||||||
|
total_output_tokens: r.total_output_tokens,
|
||||||
|
total_cost_cents: r.total_cost_cents,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 聚合指定日期的用量到日聚合表(由定时任务调用)
|
||||||
|
pub async fn aggregate_daily(&self, tenant_id: Uuid, date: chrono::NaiveDate) -> AiResult<()> {
|
||||||
|
let date_start = date.and_hms_opt(0, 0, 0).unwrap_or_default();
|
||||||
|
let date_end = date_start + chrono::Duration::days(1);
|
||||||
|
|
||||||
|
// 从 ai_usage 按分析类型聚合
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
struct AggRow {
|
||||||
|
analysis_type: String,
|
||||||
|
total_calls: i64,
|
||||||
|
total_input_tokens: i64,
|
||||||
|
total_output_tokens: i64,
|
||||||
|
total_cost_cents: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<AggRow> = ai_usage::Entity::find()
|
||||||
|
.filter(ai_usage::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(ai_usage::Column::CreatedAt.gte(date_start))
|
||||||
|
.filter(ai_usage::Column::CreatedAt.lt(date_end))
|
||||||
|
.select_only()
|
||||||
|
.column(ai_usage::Column::AnalysisType)
|
||||||
|
.column_as(ai_usage::Column::Id.count(), "total_calls")
|
||||||
|
.column_as(ai_usage::Column::InputTokens.sum(), "total_input_tokens")
|
||||||
|
.column_as(ai_usage::Column::OutputTokens.sum(), "total_output_tokens")
|
||||||
|
.column_as(ai_usage::Column::CostCents.sum(), "total_cost_cents")
|
||||||
|
.group_by(ai_usage::Column::AnalysisType)
|
||||||
|
.into_model::<AggRow>()
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in &rows {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let active = ai_usage_daily::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
date: Set(date),
|
||||||
|
feature: Set(row.analysis_type.clone()),
|
||||||
|
provider: Set("aggregated".into()),
|
||||||
|
model: Set("mixed".into()),
|
||||||
|
total_calls: Set(row.total_calls as i32),
|
||||||
|
total_input_tokens: Set(row.total_input_tokens),
|
||||||
|
total_output_tokens: Set(row.total_output_tokens),
|
||||||
|
total_cost_cents: Set(row.total_cost_cents),
|
||||||
|
created_at: Set(chrono::Utc::now()),
|
||||||
|
};
|
||||||
|
active.insert(&self.db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromQueryResult)]
|
#[derive(Debug, FromQueryResult)]
|
||||||
@@ -88,3 +168,15 @@ pub struct TypeCount {
|
|||||||
pub analysis_type: String,
|
pub analysis_type: String,
|
||||||
pub count: i64,
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct DailyUsageRow {
|
||||||
|
pub date: chrono::NaiveDate,
|
||||||
|
pub feature: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
pub total_calls: i32,
|
||||||
|
pub total_input_tokens: i64,
|
||||||
|
pub total_output_tokens: i64,
|
||||||
|
pub total_cost_cents: i64,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user