Files
aurak/web/components/views/AssessmentStatsView.tsx

328 lines
13 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Users, FileText, Award, TrendingUp, Calendar, Filter, Download, ChevronDown } from 'lucide-react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useAuth } from '../../src/contexts/AuthContext';
import { assessmentStatsService, AssessmentStats, StatsQueryParams } from '../../src/services/assessmentStatsService';
import { templateService } from '../../services/templateService';
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
import { AssessmentTemplate } from '../../types';
import { KnowledgeGroup } from '../../types';
import { cn } from '../../src/utils/cn';
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: React.ElementType;
color: string;
}
const StatCard: React.FC<StatCardProps> = ({ title, value, subtitle, icon: Icon, color }) => (
<div className="bg-white rounded-2xl p-6 border border-slate-100 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-500">{title}</p>
<p className="text-3xl font-bold mt-1" style={{ color }}>{value}</p>
{subtitle && <p className="text-xs text-slate-400 mt-1">{subtitle}</p>}
</div>
<div className={cn("w-12 h-12 rounded-xl flex items-center justify-center", `bg-${color}/10`)}>
<Icon className={cn("w-6 h-6", `text-${color}`)} style={{ color }} />
</div>
</div>
</div>
);
export const AssessmentStatsView: React.FC = () => {
const { language, t } = useLanguage();
const { user } = useAuth();
const [stats, setStats] = useState<AssessmentStats | null>(null);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<StatsQueryParams>({});
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
const [showFilters, setShowFilters] = useState(false);
const isAdmin = user?.role === 'SUPER_ADMIN';
useEffect(() => {
const fetchData = async () => {
try {
const [templatesData, groupsData] = await Promise.all([
templateService.getAll(),
knowledgeGroupService.getGroups(),
]);
setTemplates(templatesData);
setGroups(groupsData);
} catch (err) {
console.error('Failed to fetch options:', err);
}
};
fetchData();
}, []);
useEffect(() => {
const fetchStats = async () => {
setLoading(true);
try {
const data = await assessmentStatsService.getStats(filters);
setStats(data);
} catch (err) {
console.error('Failed to fetch stats:', err);
} finally {
setLoading(false);
}
};
fetchStats();
}, [filters]);
const handleFilterChange = (key: keyof StatsQueryParams, value: string) => {
setFilters(prev => ({ ...prev, [key]: value || undefined }));
};
const handleExport = () => {
if (!stats?.recentRecords) return;
const csv = [
['ID', 'Knowledge Base/Group', 'Template', 'Score', 'Status', 'Created At'].join(','),
...stats.recentRecords.map(r => [
r.id,
r.knowledgeBase,
r.template,
r.score ?? '',
r.status,
r.createdAt,
].join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `assessment-stats-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString(language === 'zh' ? 'zh-CN' : language === 'ja' ? 'ja-JP' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const isZh = language === 'zh';
if (!isAdmin) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8">
<p className="text-slate-500">{isZh ? '仅管理员可查看统计' : 'Statistics only available for admins'}</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full p-6 space-y-6 overflow-hidden">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">
{isZh ? '评估统计' : 'Assessment Statistics'}
</h1>
<p className="text-sm text-slate-500 mt-1">
{isZh ? '查看所有用户评估数据' : 'View assessment data for all users'}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl border transition-colors",
showFilters ? "bg-blue-50 border-blue-200 text-blue-700" : "bg-white border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
<Filter size={16} />
{isZh ? '筛选' : 'Filters'}
</button>
{stats && (
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
<Download size={16} />
{isZh ? '导出' : 'Export'}
</button>
)}
</div>
</div>
{showFilters && (
<div className="bg-white rounded-2xl p-4 border border-slate-100 shadow-sm">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{isZh ? '开始日期' : 'Start Date'}
</label>
<input
type="date"
value={filters.startDate || ''}
onChange={(e) => handleFilterChange('startDate', e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{isZh ? '结束日期' : 'End Date'}
</label>
<input
type="date"
value={filters.endDate || ''}
onChange={(e) => handleFilterChange('endDate', e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{isZh ? '模板' : 'Template'}
</label>
<select
value={filters.templateId || ''}
onChange={(e) => handleFilterChange('templateId', e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{isZh ? '全部' : 'All'}</option>
{templates.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{isZh ? '知识库' : 'Knowledge Group'}
</label>
<select
value={filters.knowledgeGroupId || ''}
onChange={(e) => handleFilterChange('knowledgeGroupId', e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{isZh ? '全部' : 'All'}</option>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title={isZh ? '总评估次数' : 'Total Attempts'}
value={stats?.totalAttempts ?? 0}
icon={FileText}
color="#6366f1"
/>
<StatCard
title={isZh ? '最高分' : 'Highest Score'}
value={stats?.highestScore ?? 0}
subtitle={isZh ? '分' : 'points'}
icon={Award}
color="#f59e0b"
/>
<StatCard
title={isZh ? '平均分' : 'Average Score'}
value={stats?.averageScore ?? 0}
subtitle={isZh ? '分' : 'points'}
icon={TrendingUp}
color="#10b981"
/>
<StatCard
title={isZh ? '完成率' : 'Completion Rate'}
value={`${stats?.completionRate ?? 0}%`}
icon={Users}
color="#8b5cf6"
/>
</div>
<div className="flex-1 bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden flex flex-col">
<div className="p-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">
{isZh ? '历史记录' : 'Recent Records'}
</h2>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
) : stats?.recentRecords?.length === 0 ? (
<div className="flex items-center justify-center h-full text-slate-400">
{isZh ? '暂无记录' : 'No records found'}
</div>
) : (
<table className="w-full">
<thead className="bg-slate-50 sticky top-0">
<tr>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '知识库' : 'Knowledge Base'}
</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '模板' : 'Template'}
</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '分数' : 'Score'}
</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '状态' : 'Status'}
</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '时间' : 'Created At'}
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{stats?.recentRecords?.map((record) => (
<tr key={record.id} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm text-slate-900">
{record.knowledgeBase}
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{record.template}
</td>
<td className="px-4 py-3 text-sm font-medium">
{record.score !== null ? (
<span className={record.score >= 90 ? 'text-green-600' : record.score >= 60 ? 'text-yellow-600' : 'text-red-600'}>
{record.score}
</span>
) : (
<span className="text-slate-400">-</span>
)}
</td>
<td className="px-4 py-3">
<span className={cn(
"inline-flex px-2 py-0.5 text-xs font-medium rounded-full",
record.status === 'COMPLETED'
? "bg-green-50 text-green-700"
: "bg-yellow-50 text-yellow-700"
)}>
{record.status === 'COMPLETED' ? (isZh ? '已完成' : 'Completed') : (isZh ? '进行中' : 'In Progress')}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-500">
{formatDate(record.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
};
export default AssessmentStatsView;