feat: 添加得分占比计算功能;优化时间格式化工具函数;更新考试记录和用户任务页面以显示得分占比;新增得分占比测试用例;修复时间解析逻辑

This commit is contained in:
2025-12-30 15:26:53 +08:00
parent 8cd6950631
commit 1822d8b4da
18 changed files with 347 additions and 68 deletions

View File

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

View File

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

View File

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

Binary file not shown.

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '状态',

View File

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

View 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();
}
});