check vocabulary
This commit is contained in:
@@ -94,6 +94,39 @@ export interface Word {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VocabularyPassSummaryItem {
|
||||
userId: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
email?: string;
|
||||
passedWords: number;
|
||||
uniqueWords: number;
|
||||
testRecords: number;
|
||||
wordSets: number;
|
||||
testTypes: string[];
|
||||
firstPassedAt: string;
|
||||
lastPassedAt: string;
|
||||
}
|
||||
|
||||
export interface VocabularyPassSummaryResponse {
|
||||
start: string;
|
||||
end: string;
|
||||
totalStudents: number;
|
||||
totalPassedWords: number;
|
||||
items: VocabularyPassSummaryItem[];
|
||||
}
|
||||
|
||||
export interface ClearVocabularyPassSummaryResponse {
|
||||
message: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
fullname?: string;
|
||||
deletedRecords: number;
|
||||
removedPassedWords: number;
|
||||
affectedWords: number;
|
||||
rebuiltWords: number;
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
return api.get<User[]>('/admin/users');
|
||||
@@ -176,5 +209,12 @@ export const adminApi = {
|
||||
updateWords: async (words: any[]) => {
|
||||
return api.put('/api/vocabulary/words', { words });
|
||||
},
|
||||
};
|
||||
|
||||
getVocabularyPassSummary: async (params: { start: string; end: string }): Promise<VocabularyPassSummaryResponse> => {
|
||||
return api.get<VocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary', { params });
|
||||
},
|
||||
|
||||
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
||||
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import AdminCodeManager from './AdminCodeManager';
|
||||
import AdminPracticeRecords from './AdminPracticeRecords';
|
||||
import AdminOAuth2Manager from './AdminOAuth2Manager';
|
||||
import AdminVocabularyManager from './AdminVocabularyManager';
|
||||
import AdminVocabularyScoreAudit from './AdminVocabularyScoreAudit';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -62,6 +63,7 @@ const AdminDashboard: React.FC = () => {
|
||||
<Tab label="代码管理" />
|
||||
<Tab label="练习记录" />
|
||||
<Tab label="单词库管理" />
|
||||
<Tab label="词汇成绩核查" />
|
||||
{user.username === 'bobcoc' && <Tab label="OAuth2管理" />}
|
||||
</Tabs>
|
||||
|
||||
@@ -77,8 +79,11 @@ const AdminDashboard: React.FC = () => {
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<AdminVocabularyManager />
|
||||
</TabPanel>
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
<AdminVocabularyScoreAudit />
|
||||
</TabPanel>
|
||||
{user.username === 'bobcoc' && (
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
<TabPanel value={tabValue} index={5}>
|
||||
<AdminOAuth2Manager />
|
||||
</TabPanel>
|
||||
)}
|
||||
@@ -87,4 +92,4 @@ const AdminDashboard: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
export default AdminDashboard;
|
||||
|
||||
199
src/components/AdminVocabularyScoreAudit.tsx
Normal file
199
src/components/AdminVocabularyScoreAudit.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, DatePicker, message, Popconfirm, Space, Statistic, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DeleteOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { adminApi, VocabularyPassSummaryItem, VocabularyPassSummaryResponse } from '../api/admin';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const TEST_TYPE_LABELS: Record<string, string> = {
|
||||
'chinese-to-english': '看中文写英文',
|
||||
'audio-to-english': '听发音写单词',
|
||||
'multiple-choice': '选择正确翻译'
|
||||
};
|
||||
|
||||
const toIsoString = (value: any): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value.toDate === 'function') return value.toDate().toISOString();
|
||||
if (typeof value.toISOString === 'function') return value.toISOString();
|
||||
return new Date(value).toISOString();
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string) => {
|
||||
if (!value) return '-';
|
||||
return new Date(value).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
const [range, setRange] = useState<[string, string] | null>(null);
|
||||
const [summary, setSummary] = useState<VocabularyPassSummaryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [clearingUserId, setClearingUserId] = useState<string | null>(null);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
if (!range) {
|
||||
message.warning('请先选择开始和结束时间');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await adminApi.getVocabularyPassSummary({
|
||||
start: range[0],
|
||||
end: range[1]
|
||||
});
|
||||
setSummary(response);
|
||||
} catch (error) {
|
||||
console.error('获取词汇测试通过统计失败:', error);
|
||||
message.error('获取词汇测试通过统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearStudentScores = async (row: VocabularyPassSummaryItem) => {
|
||||
if (!range) return;
|
||||
|
||||
try {
|
||||
setClearingUserId(row.userId);
|
||||
const response = await adminApi.clearVocabularyPassSummary({
|
||||
userId: row.userId,
|
||||
start: range[0],
|
||||
end: range[1]
|
||||
});
|
||||
message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`);
|
||||
await fetchSummary();
|
||||
} catch (error) {
|
||||
console.error('清除词汇测试成绩失败:', error);
|
||||
message.error('清除词汇测试成绩失败');
|
||||
} finally {
|
||||
setClearingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<VocabularyPassSummaryItem> = [
|
||||
{
|
||||
title: '学生',
|
||||
key: 'student',
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{row.fullname || row.username || '未知学生'}</div>
|
||||
<Typography.Text type="secondary">{row.username || row.email || row.userId}</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '通过单词数',
|
||||
dataIndex: 'passedWords',
|
||||
key: 'passedWords',
|
||||
sorter: (a, b) => a.passedWords - b.passedWords,
|
||||
defaultSortOrder: 'descend',
|
||||
render: (value: number) => <Tag color="green">{value}</Tag>
|
||||
},
|
||||
{
|
||||
title: '去重单词数',
|
||||
dataIndex: 'uniqueWords',
|
||||
key: 'uniqueWords',
|
||||
sorter: (a, b) => a.uniqueWords - b.uniqueWords
|
||||
},
|
||||
{
|
||||
title: '测试记录',
|
||||
dataIndex: 'testRecords',
|
||||
key: 'testRecords',
|
||||
sorter: (a, b) => a.testRecords - b.testRecords
|
||||
},
|
||||
{
|
||||
title: '题型',
|
||||
dataIndex: 'testTypes',
|
||||
key: 'testTypes',
|
||||
render: (types: string[]) => (
|
||||
<Space wrap>
|
||||
{(types || []).map(type => (
|
||||
<Tag key={type} color="blue">{TEST_TYPE_LABELS[type] || type}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '首次通过',
|
||||
dataIndex: 'firstPassedAt',
|
||||
key: 'firstPassedAt',
|
||||
render: formatDateTime
|
||||
},
|
||||
{
|
||||
title: '最后通过',
|
||||
dataIndex: 'lastPassedAt',
|
||||
key: 'lastPassedAt',
|
||||
render: formatDateTime
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Popconfirm
|
||||
title="确认清除该学生在所选时间段内的词汇测试成绩?"
|
||||
description="会删除对应测试记录,并重算这些单词的掌握状态。"
|
||||
okText="确认清除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => clearStudentScores(row)}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={clearingUserId === row.userId}
|
||||
>
|
||||
清除成绩
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="词汇测试成绩核查">
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<RangePicker
|
||||
showTime
|
||||
onChange={(dates: any) => {
|
||||
if (!dates || !dates[0] || !dates[1]) {
|
||||
setRange(null);
|
||||
setSummary(null);
|
||||
return;
|
||||
}
|
||||
setRange([toIsoString(dates[0]), toIsoString(dates[1])]);
|
||||
}}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={fetchSummary}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Space wrap>
|
||||
<Statistic title="学生数" value={summary.totalStudents} />
|
||||
<Statistic title="通过单词总数" value={summary.totalPassedWords} />
|
||||
<Statistic title="开始时间" value={formatDateTime(summary.start)} />
|
||||
<Statistic title="结束时间" value={formatDateTime(summary.end)} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={summary?.items || []}
|
||||
rowKey="userId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: total => `共 ${total} 名学生`
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminVocabularyScoreAudit;
|
||||
Reference in New Issue
Block a user