Files
MaliangAINovalWriter/AINoval/lib/widgets/analytics/model_usage_chart.dart
2025-09-10 00:07:52 +08:00

344 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:ainoval/models/analytics_data.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/analytics/date_range_picker.dart';
class ModelUsageChart extends StatefulWidget {
final List<ModelUsageData> data;
final AnalyticsViewMode viewMode;
final Function(AnalyticsViewMode)? onViewModeChanged;
final DateTimeRange? dateRange;
final Function(DateTimeRange?)? onDateRangeChanged;
const ModelUsageChart({
super.key,
required this.data,
this.viewMode = AnalyticsViewMode.daily,
this.onViewModeChanged,
this.dateRange,
this.onDateRangeChanged,
});
@override
State<ModelUsageChart> createState() => _ModelUsageChartState();
}
class _ModelUsageChartState extends State<ModelUsageChart> {
int touchedIndex = -1;
@override
Widget build(BuildContext context) {
return Column(
children: [
_buildControls(),
const SizedBox(height: 24),
_buildChart(),
const SizedBox(height: 24),
_buildLegend(),
],
);
}
Widget _buildControls() {
return Row(
children: [
_buildViewModeButtons(),
const Spacer(),
if (widget.viewMode == AnalyticsViewMode.range)
AnalyticsDateRangePicker(
dateRange: widget.dateRange,
onDateRangeChanged: widget.onDateRangeChanged,
),
],
);
}
Widget _buildViewModeButtons() {
final modes = [
AnalyticsViewMode.daily,
AnalyticsViewMode.monthly,
AnalyticsViewMode.range,
];
return Row(
children: modes.map((mode) {
final isSelected = widget.viewMode == mode;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () => widget.onViewModeChanged?.call(mode),
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor
: Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: WebTheme.getBorderColor(context),
),
),
child: Text(
mode.displayName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isSelected
? Colors.white
: WebTheme.getTextColor(context),
),
),
),
),
);
}).toList(),
);
}
Widget _buildChart() {
if (widget.data.isEmpty) {
return Container(
height: 260,
alignment: Alignment.center,
child: Text(
'暂无数据',
style: TextStyle(
color: WebTheme.getSecondaryTextColor(context),
),
),
);
}
return Container(
height: 260,
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
enabled: true,
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (event is FlTapUpEvent &&
pieTouchResponse != null &&
pieTouchResponse.touchedSection != null) {
touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
} else {
touchedIndex = -1;
}
});
},
),
borderData: FlBorderData(show: false),
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: _buildPieSections(),
),
),
);
}
Widget _buildLegend() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: LayoutBuilder(
builder: (context, constraints) {
final int crossAxisCount = constraints.maxWidth < 480 ? 1 : 2;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisExtent: 70, // 增加高度,确保有足够空间
crossAxisSpacing: 16,
mainAxisSpacing: 16, // 增加间距,避免溢出
),
itemCount: widget.data.length,
itemBuilder: (context, index) {
final data = widget.data[index];
final color = Color(int.parse(data.color.substring(1, 7), radix: 16) + 0xFF000000);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: WebTheme.getCardColor(context).withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: WebTheme.getBorderColor(context).withOpacity(0.3),
),
),
child: Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
data.modelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13, // 稍微减小字体,确保不溢出
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 4), // 增加间距
Row(
children: [
Text(
'${data.percentage}%',
style: TextStyle(
fontSize: 15, // 稍微减小字体
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
const Spacer(),
Text(
'${(data.totalTokens / 1000).toStringAsFixed(0)}K',
style: TextStyle(
fontSize: 11, // 稍微减小字体
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
],
),
),
],
),
);
},
);
},
),
);
}
List<PieChartSectionData> _buildPieSections() {
return widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
final color = Color(int.parse(data.color.substring(1, 7), radix: 16) + 0xFF000000);
final isTouched = index == touchedIndex;
final fontSize = isTouched ? 16.0 : 14.0;
final radius = isTouched ? 85.0 : 80.0;
return PieChartSectionData(
color: color,
value: data.percentage.toDouble(),
title: '${data.percentage}%',
radius: radius,
titleStyle: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.3),
offset: const Offset(1, 1),
blurRadius: 2,
),
],
),
// 将标题放置在更靠内的位置,避免与边缘碰撞
titlePositionPercentageOffset: 0.55,
borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2)
: BorderSide.none,
showTitle: data.percentage >= 5, // 只有大于5%的才显示标题
);
}).toList();
}
}
// 数据聚合工具类
class ModelUsageAnalytics {
/// 根据Token使用数据按模型名聚合统计
static List<ModelUsageData> aggregateModelUsage(List<TokenUsageData> tokenData) {
final Map<String, int> modelTotals = {};
int totalTokens = 0;
// 聚合所有模型的token使用量
for (final data in tokenData) {
for (final entry in data.modelTokens.entries) {
final modelName = entry.key;
final tokens = entry.value;
modelTotals[modelName] = (modelTotals[modelName] ?? 0) + tokens;
totalTokens += tokens;
}
}
if (totalTokens == 0) return [];
// 按使用量排序并生成ModelUsageData列表
final sortedEntries = modelTotals.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final List<ModelUsageData> result = [];
final colors = ['#3B82F6', '#8B5CF6', '#10B981', '#F59E0B', '#EF4444', '#06B6D4'];
for (int i = 0; i < sortedEntries.length; i++) {
final entry = sortedEntries[i];
final percentage = ((entry.value / totalTokens) * 100).round();
result.add(ModelUsageData(
modelName: entry.key,
percentage: percentage,
totalTokens: entry.value,
color: colors[i % colors.length],
));
}
return result;
}
/// 获取模型使用的颜色
static String getModelColor(String modelName) {
switch (modelName) {
case 'GPT-4':
return '#3B82F6';
case 'Claude-3.5':
return '#8B5CF6';
case 'Gemini Pro':
return '#10B981';
case '其他模型':
return '#F59E0B';
default:
return '#6B7280';
}
}
/// 获取模型的显示名称
static String getModelDisplayName(String modelName) {
switch (modelName) {
case 'gpt-4':
case 'gpt-4-turbo':
return 'GPT-4';
case 'claude-3-5-sonnet':
case 'claude-3.5':
return 'Claude-3.5';
case 'gemini-pro':
case 'gemini-1.5-pro':
return 'Gemini Pro';
default:
return modelName;
}
}
}