From 89581b070f49eb08d8dad828e12625b04aa1aed9 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 18 May 2026 23:36:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Phase=201C=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=20=E2=80=94=20=E7=94=A8=E9=87=8F/=E6=88=90?= =?UTF-8?q?=E6=9C=AC/=E5=8A=9F=E8=83=BD=E5=BC=80=E5=85=B3=E4=B8=89?= =?UTF-8?q?=E5=90=88=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UsageService 新增 get_daily_usage + aggregate_daily 日聚合能力 - 新增 3 个管理端点: /ai/admin/daily-usage, /ai/admin/flags (GET+POST) - AiUsageDashboard 扩展为三 Tab: 用量概览/成本分析/功能开关 - 功能开关支持 Switch 实时切换,权限码 ai.admin.flags - 日聚合用量 30 天趋势表,含 Token/成本汇总统计 --- apps/web/src/api/ai/usage.ts | 30 ++ .../web/src/pages/health/AiUsageDashboard.tsx | 389 ++++++++++++++---- crates/erp-ai/src/handler/mod.rs | 101 +++++ crates/erp-ai/src/module.rs | 13 + crates/erp-ai/src/service/usage.rs | 92 +++++ 5 files changed, 536 insertions(+), 89 deletions(-) diff --git a/apps/web/src/api/ai/usage.ts b/apps/web/src/api/ai/usage.ts index 3815942..5b91f63 100644 --- a/apps/web/src/api/ai/usage.ts +++ b/apps/web/src/api/ai/usage.ts @@ -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 }; + }, }; diff --git a/apps/web/src/pages/health/AiUsageDashboard.tsx b/apps/web/src/pages/health/AiUsageDashboard.tsx index 8ed9e61..0663d28 100644 --- a/apps/web/src/pages/health/AiUsageDashboard.tsx +++ b/apps/web/src/pages/health/AiUsageDashboard.tsx @@ -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 = { lab_report_interpretation: '化验单解读', health_trend_analysis: '趋势分析', personalized_checkup_plan: '体检方案', report_summary_generation: '报告摘要', + chat: 'AI 聊天', }; const TYPE_COLORS: Record = { @@ -20,35 +31,80 @@ const TYPE_COLORS: Record = { health_trend_analysis: '#52c41a', personalized_checkup_plan: '#722ed1', report_summary_generation: '#fa8c16', + chat: '#eb2f96', +}; + +const FEATURE_LABELS: Record = { + '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 ; + const adminFlags = usePermission('ai.admin.flags'); const [overview, setOverview] = useState(null); const [types, setTypes] = useState([]); + const [dailyUsage, setDailyUsage] = useState([]); + const [flags, setFlags] = useState([]); 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 ; + } + if (loading) { return (
@@ -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: , + children: ( + <> + + + + } + valueStyle={{ fontWeight: 600 }} + /> + + + + + } + valueStyle={{ fontWeight: 600 }} + /> + + + + + } + valueStyle={{ fontWeight: 600 }} + /> + + + + + + {types.length === 0 ? ( + + ) : ( + + {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 ( + +
+
{t.count}
+
+ {label} +
+
+ {pct}% +
+
+ + ); + })} +
+ )} +
+ + ), + }, + { + key: 'cost', + label: '成本分析', + icon: , + children: ( + <> + + + + + + + + + + + + + + + + + + + + `${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)}`, + }, + ]} + /> + + + ), + }, + { + key: 'flags', + label: '功能开关', + icon: , + children: ( + + {flags.length === 0 ? ( + + ) : ( +
FEATURE_LABELS[f] || f, + }, + { + title: '状态', + dataIndex: 'is_enabled', + width: 200, + render: (enabled: boolean, record: FeatureFlag) => + adminFlags.hasPermission ? ( + handleToggleFlag(record.feature, checked)} + checkedChildren="启用" + unCheckedChildren="禁用" + /> + ) : ( + + {enabled ? '启用' : '禁用'} + + ), + }, + ]} + /> + )} + + ), + }, + ]; + return (
-

AI 用量统计

-
查看 AI 分析服务的使用情况
+

AI 管理看板

+
AI 功能用量统计、成本分析与功能管理
- -
- - } - valueStyle={{ fontWeight: 600 }} - /> - - - - - } - valueStyle={{ fontWeight: 600 }} - /> - - - - - } - valueStyle={{ fontWeight: 600 }} - /> - - - - - - {types.length === 0 ? ( - - ) : ( - - {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 ( - -
-
{t.count}
-
- {label} -
-
- {pct}% -
-
- - ); - })} - - )} - + { + if (key === 'cost') fetchDailyUsage(); + if (key === 'flags') fetchFlags(); + }} + /> ); } diff --git a/crates/erp-ai/src/handler/mod.rs b/crates/erp-ai/src/handler/mod.rs index ca68a8e..270c706 100644 --- a/crates/erp-ai/src/handler/mod.rs +++ b/crates/erp-ai/src/handler/mod.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, +) -> Result< + Json>>, + erp_core::error::AppError, +> +where + AiState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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( diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index c604e24..7fa56ae 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -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), diff --git a/crates/erp-ai/src/service/usage.rs b/crates/erp-ai/src/service/usage.rs index ff0c57f..a3e6b9c 100644 --- a/crates/erp-ai/src/service/usage.rs +++ b/crates/erp-ai/src/service/usage.rs @@ -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> { + 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 = 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::() + .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, +}