add more info of AdminVocabularyAudit
This commit is contained in:
135
src/api/admin.ts
135
src/api/admin.ts
@@ -102,10 +102,16 @@ export interface VocabularyPassSummaryItem {
|
||||
passedWords: number;
|
||||
uniqueWords: number;
|
||||
testRecords: number;
|
||||
totalRecords?: number;
|
||||
validRecords?: number;
|
||||
invalidatedRecords?: number;
|
||||
wordSets: number;
|
||||
testTypes: string[];
|
||||
riskFlags?: string[];
|
||||
firstPassedAt: string;
|
||||
lastPassedAt: string;
|
||||
firstRecordAt?: string;
|
||||
lastRecordAt?: string;
|
||||
}
|
||||
|
||||
export interface VocabularyPassSummaryResponse {
|
||||
@@ -113,6 +119,8 @@ export interface VocabularyPassSummaryResponse {
|
||||
end: string;
|
||||
totalStudents: number;
|
||||
totalPassedWords: number;
|
||||
totalRecords?: number;
|
||||
invalidatedRecords?: number;
|
||||
items: VocabularyPassSummaryItem[];
|
||||
}
|
||||
|
||||
@@ -127,6 +135,124 @@ export interface ClearVocabularyPassSummaryResponse {
|
||||
rebuiltWords: number;
|
||||
}
|
||||
|
||||
export interface VocabularyAuditWordBrief {
|
||||
_id: string;
|
||||
word?: string;
|
||||
translation?: string;
|
||||
pronunciation?: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
export interface VocabularyAuditUserBrief {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
fullname?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface VocabularyAuditInteractionSummary {
|
||||
keyCount: number;
|
||||
inputCount: number;
|
||||
pasteCount: number;
|
||||
focusCount: number;
|
||||
blurCount: number;
|
||||
pointerCount: number;
|
||||
firstEventOffset: number;
|
||||
lastEventOffset: number;
|
||||
}
|
||||
|
||||
export interface VocabularyAuditResultDetail {
|
||||
index: number;
|
||||
wordId: string;
|
||||
word: VocabularyAuditWordBrief | null;
|
||||
userAnswer: string;
|
||||
correctAnswer: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
export interface VocabularyAuditAnswerDetail extends VocabularyAuditResultDetail {
|
||||
questionToken: string;
|
||||
submittedAt?: string;
|
||||
duration?: number;
|
||||
riskFlags: string[];
|
||||
interactionSummary?: VocabularyAuditInteractionSummary | null;
|
||||
}
|
||||
|
||||
export interface VocabularyAuditAttemptDetail {
|
||||
attemptId: string;
|
||||
status: string;
|
||||
testType: string;
|
||||
wordSetId: string;
|
||||
issuedAt?: string;
|
||||
expiresAt?: string;
|
||||
submittedAt?: string;
|
||||
riskFlags: string[];
|
||||
reviewDecision?: string;
|
||||
reviewedAt?: string;
|
||||
reviewedBy?: VocabularyAuditUserBrief | null;
|
||||
reviewNote?: string;
|
||||
questionWordIds: string[];
|
||||
questionTokens: string[];
|
||||
optionTokens: string[][];
|
||||
optionTexts: string[][];
|
||||
answeredQuestionTokens: string[];
|
||||
answerDurations: number[];
|
||||
answers: VocabularyAuditAnswerDetail[];
|
||||
}
|
||||
|
||||
export interface VocabularyAuditRecordDetail {
|
||||
recordId: string;
|
||||
attemptId: string;
|
||||
wordSet: {
|
||||
_id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
} | null;
|
||||
testType: string;
|
||||
stats: {
|
||||
totalWords: number;
|
||||
correctWords: number;
|
||||
accuracy: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: number;
|
||||
};
|
||||
invalidated: boolean;
|
||||
riskFlags: string[];
|
||||
reviewDecision?: string;
|
||||
reviewedAt?: string;
|
||||
reviewedBy?: VocabularyAuditUserBrief | null;
|
||||
reviewNote?: string;
|
||||
createdAt: string;
|
||||
results: VocabularyAuditResultDetail[];
|
||||
attempt: VocabularyAuditAttemptDetail | null;
|
||||
}
|
||||
|
||||
export interface VocabularyPassSummaryDetailsResponse {
|
||||
start: string;
|
||||
end: string;
|
||||
user: VocabularyAuditUserBrief;
|
||||
totalRecords: number;
|
||||
totalPassedWords: number;
|
||||
invalidatedRecords: number;
|
||||
approvedRecords: number;
|
||||
records: VocabularyAuditRecordDetail[];
|
||||
}
|
||||
|
||||
export interface ApproveVocabularyTestRecordResponse {
|
||||
message: string;
|
||||
recordId: string;
|
||||
attemptId?: string;
|
||||
totalWords?: number;
|
||||
correctWords?: number;
|
||||
accuracy?: number;
|
||||
riskFlags?: string[];
|
||||
affectedWords?: number;
|
||||
rebuiltWords?: number;
|
||||
alreadyEffective?: boolean;
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
return api.get<User[]>('/admin/users');
|
||||
@@ -217,4 +343,13 @@ export const adminApi = {
|
||||
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
||||
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', data);
|
||||
},
|
||||
|
||||
getVocabularyPassSummaryDetails: async (params: { userId: string; start: string; end: string }): Promise<VocabularyPassSummaryDetailsResponse> => {
|
||||
const { userId, ...query } = params;
|
||||
return api.get<VocabularyPassSummaryDetailsResponse>(`/admin/vocabulary/test-pass-summary/${userId}/details`, { params: query });
|
||||
},
|
||||
|
||||
approveVocabularyTestRecord: async (recordId: string, data: { note?: string } = {}): Promise<ApproveVocabularyTestRecordResponse> => {
|
||||
return api.post<ApproveVocabularyTestRecordResponse>(`/admin/vocabulary/test-records/${recordId}/approve`, data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, DatePicker, message, Popconfirm, Space, Statistic, Table, Tag, Typography } from 'antd';
|
||||
import { Button, Card, Collapse, DatePicker, Descriptions, message, Modal, 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';
|
||||
import { CheckCircleOutlined, DeleteOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
adminApi,
|
||||
VocabularyAuditAnswerDetail,
|
||||
VocabularyAuditRecordDetail,
|
||||
VocabularyPassSummaryDetailsResponse,
|
||||
VocabularyPassSummaryItem,
|
||||
VocabularyPassSummaryResponse
|
||||
} from '../api/admin';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -24,11 +31,76 @@ const formatDateTime = (value?: string) => {
|
||||
return new Date(value).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const formatDuration = (value?: number) => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '-';
|
||||
return `${Math.round(value * 10) / 10} 秒`;
|
||||
};
|
||||
|
||||
const renderCopyableId = (value?: string) => {
|
||||
if (!value) return '-';
|
||||
return (
|
||||
<Typography.Text copyable ellipsis style={{ display: 'inline-block', maxWidth: 180 }}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRiskTags = (flags?: string[]) => {
|
||||
const uniqueFlags = Array.from(new Set((flags || []).filter(Boolean)));
|
||||
if (uniqueFlags.length === 0) return <Typography.Text type="secondary">-</Typography.Text>;
|
||||
|
||||
return (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{uniqueFlags.map(flag => (
|
||||
<Tag key={flag} color="orange">{flag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const renderJsonBlock = (value: any) => (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
padding: 12,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(value ?? null, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
|
||||
const getRecordRiskFlags = (record: VocabularyAuditRecordDetail) => Array.from(new Set([
|
||||
...(record.riskFlags || []),
|
||||
...(record.attempt?.riskFlags || [])
|
||||
]));
|
||||
|
||||
const getAnswerRows = (record: VocabularyAuditRecordDetail): VocabularyAuditAnswerDetail[] => {
|
||||
if (record.attempt?.answers?.length) return record.attempt.answers;
|
||||
|
||||
return record.results.map(result => ({
|
||||
...result,
|
||||
questionToken: '',
|
||||
riskFlags: [],
|
||||
interactionSummary: null
|
||||
}));
|
||||
};
|
||||
|
||||
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 [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [selectedStudent, setSelectedStudent] = useState<VocabularyPassSummaryItem | null>(null);
|
||||
const [details, setDetails] = useState<VocabularyPassSummaryDetailsResponse | null>(null);
|
||||
const [approvingRecordId, setApprovingRecordId] = useState<string | null>(null);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
if (!range) {
|
||||
@@ -51,6 +123,54 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStudentDetails = async (row: VocabularyPassSummaryItem) => {
|
||||
if (!range) return;
|
||||
|
||||
try {
|
||||
setDetailLoading(true);
|
||||
const response = await adminApi.getVocabularyPassSummaryDetails({
|
||||
userId: row.userId,
|
||||
start: range[0],
|
||||
end: range[1]
|
||||
});
|
||||
setDetails(response);
|
||||
} catch (error) {
|
||||
console.error('获取词汇测试明细失败:', error);
|
||||
message.error('获取词汇测试明细失败');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openStudentDetails = async (row: VocabularyPassSummaryItem) => {
|
||||
setSelectedStudent(row);
|
||||
setDetails(null);
|
||||
setDetailOpen(true);
|
||||
await fetchStudentDetails(row);
|
||||
};
|
||||
|
||||
const approveRecord = async (record: VocabularyAuditRecordDetail) => {
|
||||
try {
|
||||
setApprovingRecordId(record.recordId);
|
||||
const response = await adminApi.approveVocabularyTestRecord(record.recordId);
|
||||
if (response.alreadyEffective) {
|
||||
message.info(response.message);
|
||||
} else {
|
||||
message.success(`已恢复为有效成绩:${response.correctWords || 0}/${response.totalWords || 0}`);
|
||||
}
|
||||
|
||||
if (selectedStudent) {
|
||||
await fetchStudentDetails(selectedStudent);
|
||||
}
|
||||
await fetchSummary();
|
||||
} catch (error) {
|
||||
console.error('人工放行词汇测试记录失败:', error);
|
||||
message.error('人工放行词汇测试记录失败');
|
||||
} finally {
|
||||
setApprovingRecordId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearStudentScores = async (row: VocabularyPassSummaryItem) => {
|
||||
if (!range) return;
|
||||
|
||||
@@ -63,6 +183,9 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
});
|
||||
message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`);
|
||||
await fetchSummary();
|
||||
if (selectedStudent?.userId === row.userId) {
|
||||
await fetchStudentDetails(row);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清除词汇测试成绩失败:', error);
|
||||
message.error('清除词汇测试成绩失败');
|
||||
@@ -71,6 +194,247 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const answerColumns: ColumnsType<VocabularyAuditAnswerDetail> = [
|
||||
{
|
||||
title: '#',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 56
|
||||
},
|
||||
{
|
||||
title: '单词',
|
||||
key: 'word',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{row.word?.word || row.wordId || '-'}</div>
|
||||
<Typography.Text type="secondary">{row.word?.translation || '-'}</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '提交答案',
|
||||
dataIndex: 'userAnswer',
|
||||
key: 'userAnswer',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '正确答案',
|
||||
dataIndex: 'correctAnswer',
|
||||
key: 'correctAnswer',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'isCorrect',
|
||||
key: 'isCorrect',
|
||||
width: 96,
|
||||
render: (isCorrect: boolean) => (
|
||||
<Tag color={isCorrect ? 'green' : 'red'}>{isCorrect ? '正确' : '错误'}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '用时',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
width: 96,
|
||||
render: formatDuration
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'submittedAt',
|
||||
key: 'submittedAt',
|
||||
width: 180,
|
||||
render: formatDateTime
|
||||
},
|
||||
{
|
||||
title: '题目 Token',
|
||||
dataIndex: 'questionToken',
|
||||
key: 'questionToken',
|
||||
width: 200,
|
||||
render: renderCopyableId
|
||||
},
|
||||
{
|
||||
title: 'RiskFlags',
|
||||
dataIndex: 'riskFlags',
|
||||
key: 'riskFlags',
|
||||
width: 220,
|
||||
render: renderRiskTags
|
||||
},
|
||||
{
|
||||
title: '交互摘要',
|
||||
dataIndex: 'interactionSummary',
|
||||
key: 'interactionSummary',
|
||||
width: 260,
|
||||
render: summary => {
|
||||
if (!summary) return <Typography.Text type="secondary">-</Typography.Text>;
|
||||
return (
|
||||
<Space wrap size={[4, 4]}>
|
||||
<Tag>key {summary.keyCount}</Tag>
|
||||
<Tag>input {summary.inputCount}</Tag>
|
||||
<Tag>paste {summary.pasteCount}</Tag>
|
||||
<Tag>pointer {summary.pointerCount}</Tag>
|
||||
<Tag>focus {summary.focusCount}</Tag>
|
||||
<Tag>blur {summary.blurCount}</Tag>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderRecordExpanded = (record: VocabularyAuditRecordDetail) => {
|
||||
const recordJson = {
|
||||
recordId: record.recordId,
|
||||
attemptId: record.attemptId,
|
||||
wordSet: record.wordSet,
|
||||
testType: record.testType,
|
||||
stats: record.stats,
|
||||
invalidated: record.invalidated,
|
||||
riskFlags: record.riskFlags,
|
||||
reviewDecision: record.reviewDecision,
|
||||
reviewedAt: record.reviewedAt,
|
||||
reviewedBy: record.reviewedBy,
|
||||
reviewNote: record.reviewNote,
|
||||
createdAt: record.createdAt,
|
||||
results: record.results
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, md: 3 }}>
|
||||
<Descriptions.Item label="记录ID">{renderCopyableId(record.recordId)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Attempt ID">{renderCopyableId(record.attemptId)}</Descriptions.Item>
|
||||
<Descriptions.Item label="单词集">{record.wordSet?.name || record.wordSet?._id || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">{formatDateTime(record.stats?.startTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结束时间">{formatDateTime(record.stats?.endTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时长">{formatDuration(record.stats?.duration)}</Descriptions.Item>
|
||||
<Descriptions.Item label="成绩">{`${record.stats?.correctWords || 0}/${record.stats?.totalWords || 0}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="正确率">{`${Math.round((record.stats?.accuracy || 0) * 10) / 10}%`}</Descriptions.Item>
|
||||
<Descriptions.Item label="人工审核">
|
||||
{record.reviewDecision === 'approved' ? (
|
||||
<Space wrap>
|
||||
<Tag color="green">已放行</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
{record.reviewedBy?.fullname || record.reviewedBy?.username || ''}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">{formatDateTime(record.reviewedAt)}</Typography.Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="记录 RiskFlags" span={3}>{renderRiskTags(record.riskFlags)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Attempt RiskFlags" span={3}>{renderRiskTags(record.attempt?.riskFlags)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Table
|
||||
size="small"
|
||||
columns={answerColumns}
|
||||
dataSource={getAnswerRows(record)}
|
||||
rowKey={row => `${record.recordId}-${row.index}-${row.questionToken || row.wordId}`}
|
||||
pagination={false}
|
||||
scroll={{ x: 1600 }}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'record-json',
|
||||
label: '成绩记录 JSON',
|
||||
children: renderJsonBlock(recordJson)
|
||||
},
|
||||
...(record.attempt ? [{
|
||||
key: 'attempt-json',
|
||||
label: 'Attempt JSON',
|
||||
children: renderJsonBlock(record.attempt)
|
||||
}] : [])
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const detailColumns: ColumnsType<VocabularyAuditRecordDetail> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: ['stats', 'endTime'],
|
||||
key: 'endTime',
|
||||
width: 180,
|
||||
render: formatDateTime,
|
||||
sorter: (a, b) => new Date(a.stats?.endTime || 0).getTime() - new Date(b.stats?.endTime || 0).getTime(),
|
||||
defaultSortOrder: 'descend'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
if (record.invalidated) return <Tag color="red">风控作废</Tag>;
|
||||
if (record.reviewDecision === 'approved') return <Tag color="green">人工有效</Tag>;
|
||||
return <Tag color="blue">有效</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '题型',
|
||||
dataIndex: 'testType',
|
||||
key: 'testType',
|
||||
width: 150,
|
||||
render: (type: string) => <Tag color="blue">{TEST_TYPE_LABELS[type] || type}</Tag>
|
||||
},
|
||||
{
|
||||
title: '单词集',
|
||||
key: 'wordSet',
|
||||
width: 160,
|
||||
render: (_, record) => record.wordSet?.name || record.wordSet?._id || '-'
|
||||
},
|
||||
{
|
||||
title: '成绩',
|
||||
key: 'score',
|
||||
width: 110,
|
||||
sorter: (a, b) => (a.stats?.correctWords || 0) - (b.stats?.correctWords || 0),
|
||||
render: (_, record) => <Tag color="green">{record.stats?.correctWords || 0}/{record.stats?.totalWords || 0}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Attempt ID',
|
||||
dataIndex: 'attemptId',
|
||||
key: 'attemptId',
|
||||
width: 200,
|
||||
render: renderCopyableId
|
||||
},
|
||||
{
|
||||
title: 'RiskFlags',
|
||||
key: 'riskFlags',
|
||||
width: 260,
|
||||
render: (_, record) => renderRiskTags(getRecordRiskFlags(record))
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (_, record) => record.invalidated ? (
|
||||
<Popconfirm
|
||||
title="确认将该风控记录转为有效成绩?"
|
||||
description="会按原始 attempt 答案恢复正确率,并重算这些单词的掌握状态。"
|
||||
okText="确认放行"
|
||||
cancelText="取消"
|
||||
onConfirm={() => approveRecord(record)}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={approvingRecordId === record.recordId}
|
||||
>
|
||||
转有效
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const columns: ColumnsType<VocabularyPassSummaryItem> = [
|
||||
{
|
||||
title: '学生',
|
||||
@@ -83,7 +447,7 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '通过单词数',
|
||||
title: '有效通过',
|
||||
dataIndex: 'passedWords',
|
||||
key: 'passedWords',
|
||||
sorter: (a, b) => a.passedWords - b.passedWords,
|
||||
@@ -91,16 +455,34 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
render: (value: number) => <Tag color="green">{value}</Tag>
|
||||
},
|
||||
{
|
||||
title: '去重单词数',
|
||||
title: '去重单词',
|
||||
dataIndex: 'uniqueWords',
|
||||
key: 'uniqueWords',
|
||||
sorter: (a, b) => a.uniqueWords - b.uniqueWords
|
||||
},
|
||||
{
|
||||
title: '测试记录',
|
||||
dataIndex: 'testRecords',
|
||||
key: 'testRecords',
|
||||
sorter: (a, b) => a.testRecords - b.testRecords
|
||||
key: 'records',
|
||||
sorter: (a, b) => (a.totalRecords || a.testRecords || 0) - (b.totalRecords || b.testRecords || 0),
|
||||
render: (_, row) => (
|
||||
<Space wrap size={[4, 4]}>
|
||||
<Tag color="blue">有效 {row.validRecords ?? row.testRecords ?? 0}</Tag>
|
||||
<Tag>全部 {row.totalRecords || row.testRecords || 0}</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '风控',
|
||||
key: 'risk',
|
||||
sorter: (a, b) => (a.invalidatedRecords || 0) - (b.invalidatedRecords || 0),
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Tag color={(row.invalidatedRecords || 0) > 0 ? 'red' : 'green'}>
|
||||
作废 {row.invalidatedRecords || 0}
|
||||
</Tag>
|
||||
{renderRiskTags(row.riskFlags)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '题型',
|
||||
@@ -115,37 +497,40 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '首次通过',
|
||||
dataIndex: 'firstPassedAt',
|
||||
key: 'firstPassedAt',
|
||||
render: formatDateTime
|
||||
title: '首次记录',
|
||||
key: 'firstRecordAt',
|
||||
render: (_, row) => formatDateTime(row.firstRecordAt || row.firstPassedAt)
|
||||
},
|
||||
{
|
||||
title: '最后通过',
|
||||
dataIndex: 'lastPassedAt',
|
||||
key: 'lastPassedAt',
|
||||
render: formatDateTime
|
||||
title: '最后记录',
|
||||
key: 'lastRecordAt',
|
||||
render: (_, row) => formatDateTime(row.lastRecordAt || row.lastPassedAt)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Popconfirm
|
||||
title="确认清除该学生在所选时间段内的词汇测试成绩?"
|
||||
description="会删除对应测试记录,并重算这些单词的掌握状态。"
|
||||
okText="确认清除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => clearStudentScores(row)}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={clearingUserId === row.userId}
|
||||
>
|
||||
清除成绩
|
||||
<Space wrap>
|
||||
<Button icon={<EyeOutlined />} onClick={() => openStudentDetails(row)}>
|
||||
详情
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确认清除该学生在所选时间段内的词汇测试成绩?"
|
||||
description="会删除对应测试记录,并重算这些单词的掌握状态。"
|
||||
okText="确认清除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => clearStudentScores(row)}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={clearingUserId === row.userId}
|
||||
>
|
||||
清除成绩
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
@@ -174,6 +559,8 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
<Space wrap>
|
||||
<Statistic title="学生数" value={summary.totalStudents} />
|
||||
<Statistic title="通过单词总数" value={summary.totalPassedWords} />
|
||||
<Statistic title="测试记录" value={summary.totalRecords || 0} />
|
||||
<Statistic title="风控作废" value={summary.invalidatedRecords || 0} />
|
||||
<Statistic title="开始时间" value={formatDateTime(summary.start)} />
|
||||
<Statistic title="结束时间" value={formatDateTime(summary.end)} />
|
||||
</Space>
|
||||
@@ -184,6 +571,7 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
dataSource={summary?.items || []}
|
||||
rowKey="userId"
|
||||
loading={loading}
|
||||
scroll={{ x: 1280 }}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
@@ -192,6 +580,50 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={`词汇提交明细 - ${details?.user.fullname || details?.user.username || selectedStudent?.fullname || selectedStudent?.username || ''}`}
|
||||
open={detailOpen}
|
||||
width={1280}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
onCancel={() => {
|
||||
setDetailOpen(false);
|
||||
setDetails(null);
|
||||
setSelectedStudent(null);
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%', maxHeight: '72vh', overflow: 'auto' }}>
|
||||
{details && (
|
||||
<Space wrap>
|
||||
<Statistic title="测试记录" value={details.totalRecords} />
|
||||
<Statistic title="有效通过单词" value={details.totalPassedWords} />
|
||||
<Statistic title="风控作废" value={details.invalidatedRecords} />
|
||||
<Statistic title="人工放行" value={details.approvedRecords} />
|
||||
<Statistic title="开始时间" value={formatDateTime(details.start)} />
|
||||
<Statistic title="结束时间" value={formatDateTime(details.end)} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Table
|
||||
size="small"
|
||||
columns={detailColumns}
|
||||
dataSource={details?.records || []}
|
||||
rowKey="recordId"
|
||||
loading={detailLoading}
|
||||
expandable={{
|
||||
expandedRowRender: renderRecordExpanded,
|
||||
rowExpandable: record => Boolean(record.attempt || record.results.length > 0)
|
||||
}}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
defaultPageSize: 5,
|
||||
showSizeChanger: true,
|
||||
showTotal: total => `共 ${total} 条记录`
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user