328 lines
13 KiB
TypeScript
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; |