feat: 添加得分占比计算功能;优化时间格式化工具函数;更新考试记录和用户任务页面以显示得分占比;新增得分占比测试用例;修复时间解析逻辑
This commit is contained in:
@@ -86,6 +86,7 @@ export class QuizController {
|
||||
}
|
||||
|
||||
const processedAnswers = [];
|
||||
let totalPossibleScore = 0;
|
||||
for (const answer of answers) {
|
||||
const question = await QuestionModel.findById(answer.questionId);
|
||||
if (!question) {
|
||||
@@ -93,6 +94,8 @@ export class QuizController {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalPossibleScore += Number(question.score) || 0;
|
||||
|
||||
if (question.type === 'multiple') {
|
||||
const optionCount = question.options ? question.options.length : 0;
|
||||
const unitScore = optionCount > 0 ? question.score / optionCount : 0;
|
||||
@@ -160,7 +163,7 @@ export class QuizController {
|
||||
processedAnswers.push(answer);
|
||||
}
|
||||
|
||||
const result = await QuizModel.submitQuiz({ userId, answers: processedAnswers });
|
||||
const result = await QuizModel.submitQuiz({ userId, answers: processedAnswers, totalPossibleScore });
|
||||
|
||||
if (subjectId || taskId) {
|
||||
let finalSubjectId: string | null = subjectId || null;
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface TaskReport {
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
score: number | null;
|
||||
scorePercentage: number | null;
|
||||
completedAt: string | null;
|
||||
}>;
|
||||
}
|
||||
@@ -81,16 +82,16 @@ export class ExamTaskModel {
|
||||
: 0;
|
||||
|
||||
const passingUsers = report.details.filter((d) => {
|
||||
if (d.score === null) return false;
|
||||
return d.score / input.totalScore >= 0.6;
|
||||
if (d.scorePercentage === null) return false;
|
||||
return d.scorePercentage >= 60;
|
||||
}).length;
|
||||
|
||||
const passRate =
|
||||
report.totalUsers > 0 ? Math.round((passingUsers / report.totalUsers) * 100) : 0;
|
||||
|
||||
const excellentUsers = report.details.filter((d) => {
|
||||
if (d.score === null) return false;
|
||||
return d.score / input.totalScore >= 0.8;
|
||||
if (d.scorePercentage === null) return false;
|
||||
return d.scorePercentage >= 80;
|
||||
}).length;
|
||||
|
||||
const excellentRate =
|
||||
@@ -142,20 +143,20 @@ export class ExamTaskModel {
|
||||
// 获取该任务的详细报表数据
|
||||
const report = await this.getReport(task.id);
|
||||
|
||||
// 计算合格率(得分率60%以上)
|
||||
// 计算合格率(得分占比60%以上)
|
||||
const passingUsers = report.details.filter((d: any) => {
|
||||
if (d.score === null) return false;
|
||||
return (d.score / task.totalScore) >= 0.6;
|
||||
if (d.scorePercentage === null) return false;
|
||||
return d.scorePercentage >= 60;
|
||||
}).length;
|
||||
|
||||
const passRate = report.totalUsers > 0
|
||||
? Math.round((passingUsers / report.totalUsers) * 100)
|
||||
: 0;
|
||||
|
||||
// 计算优秀率(得分率80%以上)
|
||||
// 计算优秀率(得分占比80%以上)
|
||||
const excellentUsers = report.details.filter((d: any) => {
|
||||
if (d.score === null) return false;
|
||||
return (d.score / task.totalScore) >= 0.8;
|
||||
if (d.scorePercentage === null) return false;
|
||||
return d.scorePercentage >= 80;
|
||||
}).length;
|
||||
|
||||
const excellentRate = report.totalUsers > 0
|
||||
@@ -198,20 +199,20 @@ export class ExamTaskModel {
|
||||
? Math.round((report.completedUsers / report.totalUsers) * 100)
|
||||
: 0;
|
||||
|
||||
// 4. 计算合格率(得分率60%以上)
|
||||
// 4. 计算合格率(得分占比60%以上)
|
||||
const passingUsers = report.details.filter(d => {
|
||||
if (d.score === null) return false;
|
||||
return (d.score / task.totalScore) >= 0.6;
|
||||
if (d.scorePercentage === null) return false;
|
||||
return d.scorePercentage >= 60;
|
||||
}).length;
|
||||
|
||||
const passRate = report.totalUsers > 0
|
||||
? Math.round((passingUsers / report.totalUsers) * 100)
|
||||
: 0;
|
||||
|
||||
// 5. 计算优秀率(得分率80%以上)
|
||||
// 5. 计算优秀率(得分占比80%以上)
|
||||
const excellentUsers = report.details.filter(d => {
|
||||
if (d.score === null) return false;
|
||||
return (d.score / task.totalScore) >= 0.8;
|
||||
if (d.scorePercentage === null) return false;
|
||||
return d.scorePercentage >= 80;
|
||||
}).length;
|
||||
|
||||
const excellentRate = report.totalUsers > 0
|
||||
@@ -509,16 +510,39 @@ export class ExamTaskModel {
|
||||
const subject = await import('./examSubject').then(({ ExamSubjectModel }) => ExamSubjectModel.findById(task.subjectId));
|
||||
if (!subject) throw new Error('科目不存在');
|
||||
|
||||
// 注意:同一用户可能多次参加同一任务考试,这里取“得分占比最高,其次完成时间最新”的那一次。
|
||||
// 得分占比优先用 quiz_answers/题目分值重算(兼容旧记录),否则回退到 quiz_records.score_percentage。
|
||||
const sqlUsers = `
|
||||
WITH best_records AS (
|
||||
SELECT
|
||||
r.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY r.user_id, r.task_id
|
||||
ORDER BY COALESCE(r.score_percentage, -1) DESC, r.created_at DESC
|
||||
) as rn
|
||||
FROM quiz_records r
|
||||
WHERE r.task_id = ?
|
||||
),
|
||||
totals AS (
|
||||
SELECT qa.record_id as recordId, SUM(q.score) as totalPossibleScore
|
||||
FROM quiz_answers qa
|
||||
JOIN questions q ON q.id = qa.question_id
|
||||
GROUP BY qa.record_id
|
||||
)
|
||||
SELECT
|
||||
u.id as userId,
|
||||
u.name as userName,
|
||||
u.phone as userPhone,
|
||||
qr.total_score as score,
|
||||
qr.created_at as completedAt
|
||||
br.total_score as score,
|
||||
br.created_at as completedAt,
|
||||
CASE
|
||||
WHEN totals.totalPossibleScore > 0 THEN (br.total_score * 1.0 / totals.totalPossibleScore) * 100
|
||||
ELSE br.score_percentage
|
||||
END as scorePercentage
|
||||
FROM exam_task_users etu
|
||||
JOIN users u ON etu.user_id = u.id
|
||||
LEFT JOIN quiz_records qr ON u.id = qr.user_id AND qr.task_id = ?
|
||||
LEFT JOIN best_records br ON br.user_id = u.id AND br.rn = 1
|
||||
LEFT JOIN totals ON totals.recordId = br.id
|
||||
WHERE etu.task_id = ?
|
||||
`;
|
||||
|
||||
@@ -529,6 +553,7 @@ export class ExamTaskModel {
|
||||
userName: r.userName,
|
||||
userPhone: r.userPhone,
|
||||
score: r.score !== null ? r.score : null,
|
||||
scorePercentage: r.scorePercentage !== null ? Number(r.scorePercentage) : null,
|
||||
completedAt: r.completedAt || null
|
||||
}));
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface SubmitAnswerData {
|
||||
export interface SubmitQuizData {
|
||||
userId: string;
|
||||
answers: SubmitAnswerData[];
|
||||
// 试卷总分(题目满分之和)。用于计算得分占比 = totalScore/totalPossibleScore * 100
|
||||
totalPossibleScore?: number;
|
||||
}
|
||||
|
||||
export class QuizModel {
|
||||
@@ -53,9 +55,11 @@ export class QuizModel {
|
||||
}
|
||||
|
||||
// 创建答题记录
|
||||
static async createRecord(data: { userId: string; totalScore: number; correctCount: number; totalCount: number }): Promise<QuizRecord> {
|
||||
static async createRecord(data: { userId: string; totalScore: number; correctCount: number; totalCount: number; totalPossibleScore?: number }): Promise<QuizRecord> {
|
||||
const id = uuidv4();
|
||||
const scorePercentage = data.totalCount > 0 ? (data.totalScore / data.totalCount) * 100 : 0;
|
||||
const denomRaw = typeof data.totalPossibleScore === 'number' ? data.totalPossibleScore : data.totalCount;
|
||||
const denom = denomRaw > 0 ? denomRaw : 0;
|
||||
const scorePercentage = denom > 0 ? (data.totalScore / denom) * 100 : 0;
|
||||
const status = this.calculateStatus(scorePercentage);
|
||||
|
||||
const sql = `
|
||||
@@ -119,7 +123,8 @@ export class QuizModel {
|
||||
userId: data.userId,
|
||||
totalScore,
|
||||
correctCount,
|
||||
totalCount
|
||||
totalCount,
|
||||
totalPossibleScore: data.totalPossibleScore
|
||||
});
|
||||
|
||||
// 创建答题答案
|
||||
@@ -130,7 +135,35 @@ export class QuizModel {
|
||||
|
||||
// 根据ID查找答题记录
|
||||
static async findRecordById(id: string): Promise<QuizRecord | null> {
|
||||
const sql = `SELECT id, user_id as userId, total_score as totalScore, correct_count as correctCount, total_count as totalCount, score_percentage as scorePercentage, status, created_at as createdAt FROM quiz_records WHERE id = ?`;
|
||||
const sql = `
|
||||
SELECT r.id,
|
||||
r.user_id as userId,
|
||||
r.total_score as totalScore,
|
||||
r.correct_count as correctCount,
|
||||
r.total_count as totalCount,
|
||||
CASE
|
||||
WHEN totals.totalPossibleScore > 0 THEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100
|
||||
ELSE r.score_percentage
|
||||
END as scorePercentage,
|
||||
CASE
|
||||
WHEN totals.totalPossibleScore > 0 THEN
|
||||
CASE
|
||||
WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 60 THEN '不及格'
|
||||
WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 80 THEN '合格'
|
||||
ELSE '优秀'
|
||||
END
|
||||
ELSE r.status
|
||||
END as status,
|
||||
r.created_at as createdAt
|
||||
FROM quiz_records r
|
||||
LEFT JOIN (
|
||||
SELECT a.record_id as recordId, SUM(q.score) as totalPossibleScore
|
||||
FROM quiz_answers a
|
||||
JOIN questions q ON a.question_id = q.id
|
||||
GROUP BY a.record_id
|
||||
) totals ON totals.recordId = r.id
|
||||
WHERE r.id = ?
|
||||
`;
|
||||
const record = await get(sql, [id]);
|
||||
return record || null;
|
||||
}
|
||||
@@ -139,11 +172,30 @@ export class QuizModel {
|
||||
static async findRecordsByUserId(userId: string, limit = 10, offset = 0): Promise<{ records: QuizRecord[]; total: number }> {
|
||||
const recordsSql = `
|
||||
SELECT r.id, r.user_id as userId, r.total_score as totalScore, r.correct_count as correctCount, r.total_count as totalCount,
|
||||
r.score_percentage as scorePercentage, r.status, r.created_at as createdAt,
|
||||
CASE
|
||||
WHEN totals.totalPossibleScore > 0 THEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100
|
||||
ELSE r.score_percentage
|
||||
END as scorePercentage,
|
||||
CASE
|
||||
WHEN totals.totalPossibleScore > 0 THEN
|
||||
CASE
|
||||
WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 60 THEN '不及格'
|
||||
WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 80 THEN '合格'
|
||||
ELSE '优秀'
|
||||
END
|
||||
ELSE r.status
|
||||
END as status,
|
||||
r.created_at as createdAt,
|
||||
COALESCE(r.subject_id, t.subject_id) as subjectId,
|
||||
COALESCE(s.name, ts.name) as subjectName,
|
||||
r.task_id as taskId, t.name as taskName
|
||||
FROM quiz_records r
|
||||
LEFT JOIN (
|
||||
SELECT a.record_id as recordId, SUM(q.score) as totalPossibleScore
|
||||
FROM quiz_answers a
|
||||
JOIN questions q ON a.question_id = q.id
|
||||
GROUP BY a.record_id
|
||||
) totals ON totals.recordId = r.id
|
||||
LEFT JOIN exam_tasks t ON r.task_id = t.id
|
||||
LEFT JOIN exam_subjects s ON r.subject_id = s.id
|
||||
LEFT JOIN exam_subjects ts ON t.subject_id = ts.id
|
||||
@@ -170,7 +222,19 @@ export class QuizModel {
|
||||
const recordsSql = `
|
||||
SELECT r.id, r.user_id as userId, u.name as userName, u.phone as userPhone,
|
||||
r.total_score as totalScore, r.correct_count as correctCount, r.total_count as totalCount,
|
||||
r.score_percentage as scorePercentage, r.status,
|
||||
CASE
|
||||
WHEN totals.totalPossibleScore > 0 THEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100
|
||||
ELSE r.score_percentage
|
||||
END as scorePercentage,
|
||||
CASE
|
||||
WHEN totals.totalPossibleScore > 0 THEN
|
||||
CASE
|
||||
WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 60 THEN '不及格'
|
||||
WHEN (r.total_score * 1.0 / totals.totalPossibleScore) * 100 < 80 THEN '合格'
|
||||
ELSE '优秀'
|
||||
END
|
||||
ELSE r.status
|
||||
END as status,
|
||||
r.created_at as createdAt,
|
||||
COALESCE(r.subject_id, t.subject_id) as subjectId,
|
||||
COALESCE(s.name, ts.name) as subjectName,
|
||||
@@ -178,6 +242,12 @@ export class QuizModel {
|
||||
t.name as taskName
|
||||
FROM quiz_records r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
LEFT JOIN (
|
||||
SELECT a.record_id as recordId, SUM(q.score) as totalPossibleScore
|
||||
FROM quiz_answers a
|
||||
JOIN questions q ON a.question_id = q.id
|
||||
GROUP BY a.record_id
|
||||
) totals ON totals.recordId = r.id
|
||||
LEFT JOIN exam_tasks t ON r.task_id = t.id
|
||||
LEFT JOIN exam_subjects s ON r.subject_id = s.id
|
||||
LEFT JOIN exam_subjects ts ON t.subject_id = ts.id
|
||||
|
||||
BIN
data/survey.db
BIN
data/survey.db
Binary file not shown.
@@ -10,7 +10,7 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node dist/api/server.js",
|
||||
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts test/user-records-subjectname.test.ts",
|
||||
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts test/user-records-subjectname.test.ts test/score-percentage.test.ts",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useUser } from '../contexts';
|
||||
import { examSubjectAPI, examTaskAPI, quizAPI } from '../services/api';
|
||||
import { UserLayout } from '../layouts/UserLayout';
|
||||
import { formatDate, parseUtcDateTime } from '../utils/validation';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -67,8 +68,8 @@ export const SubjectSelectionPage: React.FC = () => {
|
||||
|
||||
const getTaskStatus = (task: ExamTask) => {
|
||||
const nowMs = Date.now();
|
||||
const startMs = new Date(task.startAt).getTime();
|
||||
const endMs = new Date(task.endAt).getTime();
|
||||
const startMs = parseUtcDateTime(task.startAt)?.getTime() ?? NaN;
|
||||
const endMs = parseUtcDateTime(task.endAt)?.getTime() ?? NaN;
|
||||
const usedAttempts = Number(task.usedAttempts) || 0;
|
||||
const maxAttempts = Number(task.maxAttempts) || 3;
|
||||
|
||||
@@ -232,7 +233,7 @@ export const SubjectSelectionPage: React.FC = () => {
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-1 text-gray-400 text-xs" />
|
||||
<Text className="text-gray-600 text-xs">
|
||||
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
|
||||
{formatDate(task.startAt)} - {formatDate(task.endAt)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="text-green-600">
|
||||
@@ -265,7 +266,7 @@ export const SubjectSelectionPage: React.FC = () => {
|
||||
const usedAttempts = Number(task.usedAttempts) || 0;
|
||||
const maxAttempts = Number(task.maxAttempts) || 3;
|
||||
const attemptsExhausted = usedAttempts >= maxAttempts;
|
||||
const endMs = new Date(task.endAt).getTime();
|
||||
const endMs = parseUtcDateTime(task.endAt)?.getTime() ?? NaN;
|
||||
const isExpired = Number.isFinite(endMs) ? endMs < Date.now() : true;
|
||||
const isDisabled = attemptsExhausted || isExpired;
|
||||
return (
|
||||
@@ -308,7 +309,7 @@ export const SubjectSelectionPage: React.FC = () => {
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-1 text-gray-400 text-xs" />
|
||||
<Text className="text-gray-600 text-xs">
|
||||
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
|
||||
{formatDate(task.startAt)} - {formatDate(task.endAt)}
|
||||
</Text>
|
||||
</div>
|
||||
{attemptsExhausted ? (
|
||||
@@ -385,7 +386,7 @@ export const SubjectSelectionPage: React.FC = () => {
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-1 text-gray-400 text-xs" />
|
||||
<Text className="text-gray-600 text-xs">
|
||||
{new Date(task.startAt).toLocaleDateString()} - {new Date(task.endAt).toLocaleDateString()}
|
||||
{formatDate(task.startAt)} - {formatDate(task.endAt)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="text-blue-600">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useUser } from '../contexts';
|
||||
import { quizAPI } from '../services/api';
|
||||
import { UserLayout } from '../layouts/UserLayout';
|
||||
import { formatDateTime } from '../utils/validation';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -138,7 +139,7 @@ export const UserTaskPage: React.FC = () => {
|
||||
<Space size={4}>
|
||||
<CalendarOutlined style={{ fontSize: '11px' }} className="text-gray-500" />
|
||||
<Text type="secondary" style={{ fontSize: '11px' }}>
|
||||
{new Date(date).toLocaleString('zh-CN')}
|
||||
{formatDateTime(date)}
|
||||
</Text>
|
||||
</Space>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { adminAPI, quizAPI } from '../../services/api';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
import { formatDateTime, parseUtcDateTime } from '../../utils/validation';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import {
|
||||
PieChart,
|
||||
@@ -258,8 +258,8 @@ const AdminDashboardPage = () => {
|
||||
key: 'progress',
|
||||
render: (_: any, record: ActiveTaskStat) => {
|
||||
const now = new Date();
|
||||
const start = new Date(record.startAt);
|
||||
const end = new Date(record.endAt);
|
||||
const start = parseUtcDateTime(record.startAt) ?? new Date(record.startAt);
|
||||
const end = parseUtcDateTime(record.endAt) ?? new Date(record.endAt);
|
||||
|
||||
const totalDuration = end.getTime() - start.getTime();
|
||||
const elapsedDuration = now.getTime() - start.getTime();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNum
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import api from '../../services/api';
|
||||
import { getCategoryColorHex } from '../../lib/categoryColors';
|
||||
import { parseUtcDateTime } from '../../utils/validation';
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
@@ -417,7 +418,8 @@ const ExamSubjectPage = () => {
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => {
|
||||
const date = new Date(text);
|
||||
const date = parseUtcDateTime(text) ?? new Date(text);
|
||||
if (Number.isNaN(date.getTime())) return text;
|
||||
return (
|
||||
<div>
|
||||
<div>{date.toLocaleDateString()}</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePick
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import api, { userGroupAPI } from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatDateTime, parseUtcDateTime } from '../../utils/validation';
|
||||
|
||||
interface ExamTask {
|
||||
id: string;
|
||||
@@ -148,8 +149,8 @@ const ExamTaskPage = () => {
|
||||
form.setFieldsValue({
|
||||
name: task.name,
|
||||
subjectId: task.subjectId,
|
||||
startAt: dayjs(task.startAt),
|
||||
endAt: dayjs(task.endAt),
|
||||
startAt: dayjs(parseUtcDateTime(task.startAt) ?? task.startAt),
|
||||
endAt: dayjs(parseUtcDateTime(task.endAt) ?? task.endAt),
|
||||
userIds: userIds,
|
||||
groupIds: groupIds,
|
||||
});
|
||||
@@ -228,14 +229,14 @@ const ExamTaskPage = () => {
|
||||
dataIndex: 'startAt',
|
||||
key: 'startAt',
|
||||
width: 110,
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
|
||||
render: (text: string) => formatDateTime(text),
|
||||
},
|
||||
{
|
||||
title: '结束时间',
|
||||
dataIndex: 'endAt',
|
||||
key: 'endAt',
|
||||
width: 110,
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
|
||||
render: (text: string) => formatDateTime(text),
|
||||
},
|
||||
{
|
||||
title: '考试进程',
|
||||
@@ -244,8 +245,8 @@ const ExamTaskPage = () => {
|
||||
width: 160,
|
||||
render: (_: any, record: ExamTask) => {
|
||||
const now = dayjs();
|
||||
const start = dayjs(record.startAt);
|
||||
const end = dayjs(record.endAt);
|
||||
const start = dayjs(parseUtcDateTime(record.startAt) ?? record.startAt);
|
||||
const end = dayjs(parseUtcDateTime(record.endAt) ?? record.endAt);
|
||||
|
||||
let progress = 0;
|
||||
if (now < start) {
|
||||
@@ -306,7 +307,7 @@ const ExamTaskPage = () => {
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 110,
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
|
||||
render: (text: string) => formatDateTime(text),
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>操作</div>,
|
||||
@@ -523,11 +524,20 @@ const ExamTaskPage = () => {
|
||||
key: 'score',
|
||||
render: (score: number | null) => score !== null ? `${score} 分` : '未答题',
|
||||
},
|
||||
{
|
||||
title: '得分占比',
|
||||
dataIndex: 'scorePercentage',
|
||||
key: 'scorePercentage',
|
||||
render: (pct: number | null) => {
|
||||
if (pct === null || pct === undefined || Number.isNaN(Number(pct))) return '未答题';
|
||||
return `${Math.round(Number(pct) * 10) / 10}%`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'completedAt',
|
||||
key: 'completedAt',
|
||||
render: (date: string | null) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '未答题',
|
||||
render: (date: string | null) => (date ? formatDateTime(date) : '未答题'),
|
||||
},
|
||||
]}
|
||||
dataSource={reportData.details}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { getCategoryColorHex } from '../../lib/categoryColors';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
|
||||
interface QuestionCategory {
|
||||
id: string;
|
||||
@@ -118,7 +119,7 @@ const QuestionCategoryPage = () => {
|
||||
width: 200,
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => new Date(text).toLocaleString(),
|
||||
render: (text: string) => formatDateTime(text, { includeSeconds: true }),
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>操作</div>,
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { questionAPI } from '../../services/api';
|
||||
import { questionTypeMap, questionTypeColors } from '../../utils/validation';
|
||||
import { formatDate, questionTypeMap, questionTypeColors } from '../../utils/validation';
|
||||
import { getCategoryColorHex } from '../../lib/categoryColors';
|
||||
|
||||
const { Option } = Select;
|
||||
@@ -428,7 +428,7 @@ const QuestionManagePage = () => {
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
render: (date: string) => formatDate(date),
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>操作</div>,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Table, Card, Row, Col, Statistic, Button, message } from 'antd';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import api from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
|
||||
interface Answer {
|
||||
id: string;
|
||||
@@ -154,7 +154,7 @@ const RecordDetailPage = ({ recordId }: { recordId: string }) => {
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">答题记录详情</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
答题时间:{dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
答题时间:{formatDateTime(record.createdAt, { includeSeconds: true })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Tag } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { userGroupAPI } from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
@@ -116,7 +116,7 @@ const UserGroupManage = () => {
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
|
||||
render: (text: string) => formatDateTime(text),
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>操作</div>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
@@ -127,24 +128,22 @@ const UserManagePage = () => {
|
||||
fetchUsers(newPagination.current, newPagination.pageSize, searchKeyword);
|
||||
};
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
fetchUsers(1, pagination.pageSize, searchKeyword);
|
||||
};
|
||||
|
||||
// 处理搜索框变化
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchKeyword(e.target.value);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const groups = await fetchUserGroups();
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
|
||||
const groups = await fetchUserGroups();
|
||||
const systemGroups = groups.filter((g) => g.isSystem).map((g) => g.id);
|
||||
form.setFieldsValue({ groupIds: systemGroups });
|
||||
|
||||
if (systemGroups.length) {
|
||||
form.setFieldsValue({ groupIds: systemGroups });
|
||||
}
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
@@ -367,13 +366,13 @@ const UserManagePage = () => {
|
||||
title: '最后一次考试时间',
|
||||
dataIndex: 'lastExamTime',
|
||||
key: 'lastExamTime',
|
||||
render: (time: string) => time ? new Date(time).toLocaleString() : '无',
|
||||
render: (time: string) => (time ? formatDateTime(time, { includeSeconds: true }) : '无'),
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => new Date(text).toLocaleString(),
|
||||
render: (text: string) => formatDateTime(text, { includeSeconds: true }),
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>操作</div>,
|
||||
@@ -522,7 +521,7 @@ const UserManagePage = () => {
|
||||
title: '考试时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (time: string) => new Date(time).toLocaleString(),
|
||||
render: (time: string) => formatDateTime(time, { includeSeconds: true }),
|
||||
},
|
||||
]}
|
||||
dataSource={userRecords}
|
||||
@@ -621,7 +620,7 @@ const UserManagePage = () => {
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-gray-600 font-medium">考试时间:</label>
|
||||
<span>{new Date(recordDetail.createdAt).toLocaleString()}</span>
|
||||
<span>{formatDateTime(recordDetail.createdAt, { includeSeconds: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Table, Card, Row, Col, Statistic, Button, message } from 'antd';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import api from '../../services/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatDateTime } from '../../utils/validation';
|
||||
|
||||
interface Record {
|
||||
id: string;
|
||||
@@ -77,7 +77,7 @@ const UserRecordsPage = ({ userId }: { userId: string }) => {
|
||||
title: '答题时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm:ss'),
|
||||
render: (text: string) => formatDateTime(text, { includeSeconds: true }),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
|
||||
@@ -54,17 +54,73 @@ export const generateId = (): string => {
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
export const formatDateTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const hasExplicitTimeZone = (value: string) => {
|
||||
// ISO with Z or numeric offset, e.g. 2025-01-01T00:00:00Z / 2025-01-01T00:00:00+08:00
|
||||
return /([zZ]|[+-]\d{2}:?\d{2})$/.test(value.trim());
|
||||
};
|
||||
|
||||
const normalizeUtcStringToIso = (value: string) => {
|
||||
const v = value.trim();
|
||||
|
||||
// Already ISO with timezone
|
||||
if (hasExplicitTimeZone(v)) return v;
|
||||
|
||||
// SQLite CURRENT_TIMESTAMP: "YYYY-MM-DD HH:mm:ss" (UTC)
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/.test(v)) {
|
||||
const iso = v.replace(' ', 'T');
|
||||
return iso.length === 16 ? `${iso}:00Z` : `${iso}Z`;
|
||||
}
|
||||
|
||||
// ISO without timezone: treat as UTC
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?$/.test(v)) {
|
||||
return `${v}Z`;
|
||||
}
|
||||
|
||||
// Date only: treat as UTC midnight
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
||||
return `${v}T00:00:00Z`;
|
||||
}
|
||||
|
||||
return v;
|
||||
};
|
||||
|
||||
export const parseUtcDateTime = (value: string | null | undefined): Date | null => {
|
||||
if (!value) return null;
|
||||
const normalized = normalizeUtcStringToIso(String(value));
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
};
|
||||
|
||||
export const formatDateTime = (
|
||||
dateString: string,
|
||||
options?: { includeSeconds?: boolean; dateOnly?: boolean },
|
||||
): string => {
|
||||
const date = parseUtcDateTime(dateString);
|
||||
if (!date) return dateString;
|
||||
|
||||
if (options?.dateOnly) {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
minute: '2-digit',
|
||||
...(options?.includeSeconds ? { second: '2-digit' as const } : null),
|
||||
});
|
||||
};
|
||||
|
||||
export const formatDate = (dateString: string): string => {
|
||||
return formatDateTime(dateString, { dateOnly: true });
|
||||
};
|
||||
|
||||
// 计算正确率
|
||||
export const calculateCorrectRate = (correct: number, total: number): string => {
|
||||
if (total === 0) return '0%';
|
||||
|
||||
111
test/score-percentage.test.ts
Normal file
111
test/score-percentage.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DB_PATH = ':memory:';
|
||||
|
||||
const jsonFetch = async (
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
options?: { method?: string; body?: unknown },
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: options?.method ?? 'GET',
|
||||
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let json: any = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { status: res.status, json, text };
|
||||
};
|
||||
|
||||
test('得分占比=总得分/试卷总分;40%应判定为不及格', async () => {
|
||||
const { initDatabase, run, get } = await import('../api/database');
|
||||
await initDatabase();
|
||||
|
||||
const { app } = await import('../api/server');
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
const userId = randomUUID();
|
||||
|
||||
await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [
|
||||
userId,
|
||||
'测试用户',
|
||||
'13900139001',
|
||||
'',
|
||||
]);
|
||||
|
||||
// 5道单选题,每题5分,总分25;答对2题得10分,得分占比=40%
|
||||
const questionIds: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const qid = randomUUID();
|
||||
questionIds.push(qid);
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
qid,
|
||||
`题目${i + 1}`,
|
||||
'single',
|
||||
JSON.stringify(['A', 'B', 'C', 'D']),
|
||||
'A',
|
||||
'',
|
||||
5,
|
||||
'通用',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const answers = questionIds.map((questionId, idx) => ({
|
||||
questionId,
|
||||
userAnswer: idx < 2 ? 'A' : 'B',
|
||||
score: 0,
|
||||
isCorrect: false,
|
||||
}));
|
||||
|
||||
const submitRes = await jsonFetch(baseUrl, '/api/quiz/submit', {
|
||||
method: 'POST',
|
||||
body: { userId, answers },
|
||||
});
|
||||
|
||||
assert.equal(submitRes.status, 200, submitRes.text);
|
||||
assert.equal(submitRes.json?.success, true);
|
||||
const recordId = submitRes.json?.data?.recordId;
|
||||
assert.ok(recordId);
|
||||
|
||||
const detailRes = await jsonFetch(baseUrl, `/api/quiz/records/detail/${recordId}`);
|
||||
assert.equal(detailRes.status, 200, detailRes.text);
|
||||
assert.equal(detailRes.json?.success, true);
|
||||
|
||||
const record = detailRes.json?.data?.record;
|
||||
assert.ok(record);
|
||||
assert.equal(record.totalScore, 10);
|
||||
assert.equal(record.correctCount, 2);
|
||||
assert.equal(record.totalCount, 5);
|
||||
|
||||
assert.ok(typeof record.scorePercentage === 'number');
|
||||
assert.ok(Math.abs(record.scorePercentage - 40) < 0.0001);
|
||||
assert.equal(record.status, '不及格');
|
||||
|
||||
// 数据库里也应写入合理的score_percentage(避免旧逻辑200%)
|
||||
const dbRow = await get(`SELECT score_percentage as scorePercentage, status FROM quiz_records WHERE id = ?`, [recordId]);
|
||||
assert.ok(dbRow);
|
||||
assert.ok(typeof dbRow.scorePercentage === 'number');
|
||||
assert.ok(Math.abs(dbRow.scorePercentage - 40) < 0.0001);
|
||||
assert.equal(dbRow.status, '不及格');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user