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

View File

@@ -832,6 +832,107 @@ where
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(&params.start_date, "%Y-%m-%d")
.map_err(|_| erp_core::error::AppError::Validation("start_date 格式错误".into()))?;
let end_date = chrono::NaiveDate::parse_from_str(&params.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 流构建辅助 ===
fn build_sse_stream(

View File

@@ -494,6 +494,19 @@ impl AiModule {
"/ai/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(
"/ai/budget/status",
axum::routing::get(crate::handler::budget_status),

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use crate::entity::ai_analysis;
use crate::entity::ai_usage;
use crate::entity::ai_usage_daily;
use crate::error::AiResult;
pub struct UsageService {
@@ -76,6 +77,85 @@ impl UsageService {
.await?;
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)]
@@ -88,3 +168,15 @@ pub struct TypeCount {
pub analysis_type: String,
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,
}