check vocabulary

This commit is contained in:
2026-05-25 11:25:12 +08:00
parent 4088fbc9e7
commit 1de143740a
6 changed files with 573 additions and 8 deletions

View File

@@ -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);
},
};

View File

@@ -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;

View 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;