修改-试卷总分设置项

This commit is contained in:
2025-12-26 18:39:17 +08:00
parent 7b52abfea3
commit 42fcb71bae
5 changed files with 538 additions and 371 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNumber, Card, Checkbox, Progress, Row, Col, Tag } from 'antd';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNumber, Card, Progress, Row, Col, Tag, Radio } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import api from '../../services/api';
import { getCategoryColorHex } from '../../lib/categoryColors';
@@ -49,6 +49,7 @@ const ExamSubjectPage = () => {
const [previewLoading, setPreviewLoading] = useState(false);
const [currentSubject, setCurrentSubject] = useState<ExamSubject | null>(null);
// 引入状态管理来跟踪实时的比例配置
const [configMode, setConfigMode] = useState<'ratio' | 'count'>('count');
const [typeRatios, setTypeRatios] = useState<Record<string, number>>({
single: 40,
multiple: 30,
@@ -59,6 +60,49 @@ const ExamSubjectPage = () => {
通用: 100
});
const sumValues = (valuesMap: Record<string, number>) => Object.values(valuesMap).reduce((a, b) => a + b, 0);
const isRatioMode = (valuesMap: Record<string, number>) => {
const values = Object.values(valuesMap);
if (values.length === 0) return false;
if (values.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0)) return false;
if (values.some((v) => v > 100)) return false;
const sum = values.reduce((a, b) => a + b, 0);
return Math.abs(sum - 100) <= 0.01;
};
const ensureRatioSum100 = (valuesMap: Record<string, number>) => {
const entries = Object.entries(valuesMap);
if (entries.length === 0) return valuesMap;
const result: Record<string, number> = {};
for (const [k, v] of entries) result[k] = Math.max(0, Math.min(100, Math.round((Number(v) || 0) * 100) / 100));
const keys = Object.keys(result);
const lastKey = keys[keys.length - 1];
const sumWithoutLast = keys.slice(0, -1).reduce((s, k) => s + (result[k] ?? 0), 0);
result[lastKey] = Math.max(0, Math.min(100, Math.round((100 - sumWithoutLast) * 100) / 100));
return result;
};
const convertCountsToRatios = (valuesMap: Record<string, number>) => {
const entries = Object.entries(valuesMap);
if (entries.length === 0) return valuesMap;
const total = entries.reduce((s, [, v]) => s + Math.max(0, Number(v) || 0), 0);
if (total <= 0) return ensureRatioSum100(Object.fromEntries(entries.map(([k]) => [k, 0])) as any);
const ratios: Record<string, number> = {};
for (const [k, v] of entries) {
ratios[k] = Math.round(((Math.max(0, Number(v) || 0) / total) * 100) * 100) / 100;
}
return ensureRatioSum100(ratios);
};
const convertRatiosToCounts = (valuesMap: Record<string, number>) => {
const result: Record<string, number> = {};
for (const [k, v] of Object.entries(valuesMap)) {
result[k] = Math.max(0, Math.round(Number(v) || 0));
}
return result;
};
// 题型配置
const questionTypes = [
{ key: 'single', label: '单选题', color: '#52c41a' },
@@ -91,10 +135,11 @@ const ExamSubjectPage = () => {
setEditingSubject(null);
form.resetFields();
// 设置默认值
const defaultTypeRatios = { single: 40, multiple: 30, judgment: 20, text: 10 };
const defaultCategoryRatios: Record<string, number> = { 通用: 100 };
const defaultTypeRatios = { single: 4, multiple: 3, judgment: 2, text: 1 };
const defaultCategoryRatios: Record<string, number> = { 通用: 10 };
// 初始化状态
setConfigMode('count');
setTypeRatios(defaultTypeRatios);
setCategoryRatios(defaultCategoryRatios);
@@ -121,6 +166,8 @@ const ExamSubjectPage = () => {
}
// 确保状态与表单值正确同步
const inferredMode = isRatioMode(initialTypeRatios) && isRatioMode(initialCategoryRatios) ? 'ratio' : 'count';
setConfigMode(inferredMode);
setTypeRatios(initialTypeRatios);
setCategoryRatios(initialCategoryRatios);
@@ -146,22 +193,44 @@ const ExamSubjectPage = () => {
const handleModalOk = async () => {
try {
// 首先验证状态中的值确保总和为100%
// 验证题型比重总和使用状态中的值允许±0.01的精度误差)
const typeTotal = Object.values(typeRatios).reduce((sum: number, val) => sum + val, 0);
console.log('题型比重总和(状态):', typeTotal);
if (Math.abs(typeTotal - 100) > 0.01) {
message.error('题型比重总和必须为100%');
return;
}
const typeTotal = sumValues(typeRatios);
const categoryTotal = sumValues(categoryRatios);
// 验证类别比重总和使用状态中的值允许±0.01的精度误差)
const categoryTotal = Object.values(categoryRatios).reduce((sum: number, val) => sum + val, 0);
console.log('类别比重总和(状态):', categoryTotal);
if (Math.abs(categoryTotal - 100) > 0.01) {
message.error('题目类别比重总和必须为100%');
return;
if (configMode === 'ratio') {
console.log('题型比重总和(状态):', typeTotal);
if (Math.abs(typeTotal - 100) > 0.01) {
message.error('题型比重总和必须为100%');
return;
}
console.log('类别比重总和(状态):', categoryTotal);
if (Math.abs(categoryTotal - 100) > 0.01) {
message.error('题目类别比重总和必须为100%');
return;
}
} else {
const typeValues = Object.values(typeRatios);
const categoryValues = Object.values(categoryRatios);
if (typeValues.length === 0 || categoryValues.length === 0) {
message.error('题型数量与题目类别数量不能为空');
return;
}
if (typeValues.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0 || !Number.isInteger(v))) {
message.error('题型数量必须为非负整数');
return;
}
if (categoryValues.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0 || !Number.isInteger(v))) {
message.error('题目类别数量必须为非负整数');
return;
}
if (!typeValues.some((v) => v > 0) || !categoryValues.some((v) => v > 0)) {
message.error('题型数量与题目类别数量至少需要一个大于0的配置');
return;
}
if (typeTotal !== categoryTotal) {
message.error('题型数量总和必须等于题目类别数量总和');
return;
}
}
// 然后才获取表单值,确保表单验证通过
@@ -188,6 +257,25 @@ const ExamSubjectPage = () => {
}
};
const handleConfigModeChange = (nextMode: 'ratio' | 'count') => {
if (nextMode === configMode) return;
let nextTypeRatios = typeRatios;
let nextCategoryRatios = categoryRatios;
if (nextMode === 'count') {
nextTypeRatios = convertRatiosToCounts(typeRatios);
nextCategoryRatios = convertRatiosToCounts(categoryRatios);
} else {
nextTypeRatios = convertCountsToRatios(typeRatios);
nextCategoryRatios = convertCountsToRatios(categoryRatios);
}
setConfigMode(nextMode);
setTypeRatios(nextTypeRatios);
setCategoryRatios(nextCategoryRatios);
form.setFieldsValue({ typeRatios: nextTypeRatios, categoryRatios: nextCategoryRatios });
};
const handleTypeRatioChange = (type: string, value: number) => {
const newRatios = { ...typeRatios, [type]: value };
setTypeRatios(newRatios);
@@ -245,70 +333,81 @@ const ExamSubjectPage = () => {
{title: '题型分布',
dataIndex: 'typeRatios',
key: 'typeRatios',
render: (ratios: Record<string, number>) => (
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
{ratios && Object.entries(ratios).map(([type, ratio]) => {
const typeConfig = questionTypes.find(t => t.key === type);
return (
<div
key={type}
className="h-full"
style={{
width: `${ratio}%`,
backgroundColor: typeConfig?.color || '#1890ff'
}}
></div>
);
})}
render: (ratios: Record<string, number>) => {
const ratioMode = isRatioMode(ratios || {});
const total = sumValues(ratios || {});
return (
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
{ratios && Object.entries(ratios).map(([type, value]) => {
const typeConfig = questionTypes.find(t => t.key === type);
const widthPercent = ratioMode ? value : (total > 0 ? (value / total) * 100 : 0);
return (
<div
key={type}
className="h-full"
style={{
width: `${widthPercent}%`,
backgroundColor: typeConfig?.color || '#1890ff'
}}
></div>
);
})}
</div>
<div className="space-y-1">
{ratios && Object.entries(ratios).map(([type, value]) => {
const typeConfig = questionTypes.find(t => t.key === type);
const percent = ratioMode ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
return (
<div key={type} className="flex items-center text-sm">
<span
className="inline-block w-3 h-3 mr-2 rounded-full"
style={{ backgroundColor: typeConfig?.color || '#1890ff' }}
></span>
<span className="flex-1">{typeConfig?.label || type}</span>
<span className="font-medium">{ratioMode ? `${value}%` : `${value}题(${percent}%`}</span>
</div>
);
})}
</div>
</div>
<div className="space-y-1">
{ratios && Object.entries(ratios).map(([type, ratio]) => {
const typeConfig = questionTypes.find(t => t.key === type);
return (
<div key={type} className="flex items-center text-sm">
<span
className="inline-block w-3 h-3 mr-2 rounded-full"
style={{ backgroundColor: typeConfig?.color || '#1890ff' }}
></span>
<span className="flex-1">{typeConfig?.label || type}</span>
<span className="font-medium">{ratio}%</span>
</div>
);
})}
</div>
</div>
),
);
},
},
{title: '题目类别分布',
dataIndex: 'categoryRatios',
key: 'categoryRatios',
render: (ratios: Record<string, number>) => {
const ratioMode = isRatioMode(ratios || {});
const total = sumValues(ratios || {});
return (
<div className="p-3 bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="w-full bg-gray-200 rounded-full h-4 flex mb-3 overflow-hidden">
{ratios && Object.entries(ratios).map(([category, ratio]) => (
{ratios && Object.entries(ratios).map(([category, value]) => (
<div
key={category}
className="h-full"
style={{
width: `${ratio}%`,
width: `${ratioMode ? value : (total > 0 ? (value / total) * 100 : 0)}%`,
backgroundColor: getCategoryColorHex(category)
}}
></div>
))}
</div>
<div className="space-y-1">
{ratios && Object.entries(ratios).map(([category, ratio]) => (
<div key={category} className="flex items-center text-sm">
<span
className="inline-block w-3 h-3 mr-2 rounded-full"
style={{ backgroundColor: getCategoryColorHex(category) }}
></span>
<span className="flex-1">{category}</span>
<span className="font-medium">{ratio}%</span>
</div>
))}
{ratios && Object.entries(ratios).map(([category, value]) => {
const percent = ratioMode ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
return (
<div key={category} className="flex items-center text-sm">
<span
className="inline-block w-3 h-3 mr-2 rounded-full"
style={{ backgroundColor: getCategoryColorHex(category) }}
></span>
<span className="flex-1">{category}</span>
<span className="font-medium">{ratioMode ? `${value}%` : `${value}题(${percent}%`}</span>
</div>
);
})}
</div>
</div>
);
@@ -433,13 +532,33 @@ const ExamSubjectPage = () => {
</Col>
</Row>
<Card size="small" className="mb-4">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<Radio.Group
value={configMode}
onChange={(e) => handleConfigModeChange(e.target.value)}
options={[
{ label: '比例(%)', value: 'ratio' },
{ label: '数量(题)', value: 'count' },
]}
optionType="button"
buttonStyle="solid"
/>
</div>
</Card>
<Card
size="small"
title={
<div className="flex items-center justify-between">
<span></span>
<span className={`text-sm ${Object.values(typeRatios).reduce((sum: number, val) => sum + val, 0) === 100 ? 'text-green-600' : 'text-red-600'}`}>
{Object.values(typeRatios).reduce((sum: number, val) => sum + val, 0)}%
<span>{configMode === 'ratio' ? '题型比重配置' : '题型数量配置'}</span>
<span className={`text-sm ${
configMode === 'ratio'
? (Math.abs(sumValues(typeRatios) - 100) <= 0.01 ? 'text-green-600' : 'text-red-600')
: (sumValues(typeRatios) > 0 ? 'text-green-600' : 'text-red-600')
}`}>
{sumValues(typeRatios)}{configMode === 'ratio' ? '%' : '题'}
</span>
</div>
}
@@ -448,24 +567,27 @@ const ExamSubjectPage = () => {
<Form.Item name="typeRatios" noStyle>
<div className="space-y-4">
{questionTypes.map((type) => {
const ratio = typeRatios[type.key] || 0;
const value = typeRatios[type.key] || 0;
const total = sumValues(typeRatios);
const percent = configMode === 'ratio' ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
return (
<div key={type.key}>
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{type.label}</span>
<span className="text-blue-600 font-bold">{ratio}%</span>
<span className="text-blue-600 font-bold">{configMode === 'ratio' ? `${value}%` : `${value}`}</span>
</div>
<div className="flex items-center space-x-4">
<InputNumber
min={0}
max={100}
value={ratio}
onChange={(value) => handleTypeRatioChange(type.key, value || 0)}
max={configMode === 'ratio' ? 100 : 200}
precision={configMode === 'ratio' ? 2 : 0}
value={value}
onChange={(nextValue) => handleTypeRatioChange(type.key, nextValue || 0)}
style={{ width: 100 }}
/>
<div style={{ flex: 1 }}>
<Progress
percent={ratio}
percent={percent}
strokeColor={type.color}
showInfo={false}
size="small"
@@ -483,9 +605,13 @@ const ExamSubjectPage = () => {
size="small"
title={
<div className="flex items-center justify-between">
<span></span>
<span className={`text-sm ${Object.values(categoryRatios).reduce((sum: number, val) => sum + val, 0) === 100 ? 'text-green-600' : 'text-red-600'}`}>
{Object.values(categoryRatios).reduce((sum: number, val) => sum + val, 0)}%
<span>{configMode === 'ratio' ? '题目类别比重配置' : '题目类别数量配置'}</span>
<span className={`text-sm ${
configMode === 'ratio'
? (Math.abs(sumValues(categoryRatios) - 100) <= 0.01 ? 'text-green-600' : 'text-red-600')
: (sumValues(categoryRatios) === sumValues(typeRatios) && sumValues(categoryRatios) > 0 ? 'text-green-600' : 'text-red-600')
}`}>
{sumValues(categoryRatios)}{configMode === 'ratio' ? '%' : '题'}
</span>
</div>
}
@@ -493,24 +619,27 @@ const ExamSubjectPage = () => {
<Form.Item name="categoryRatios" noStyle>
<div className="space-y-4">
{categories.map((category) => {
const ratio = categoryRatios[category.name] || 0;
const value = categoryRatios[category.name] || 0;
const total = sumValues(categoryRatios);
const percent = configMode === 'ratio' ? value : (total > 0 ? Math.round((value / total) * 1000) / 10 : 0);
return (
<div key={category.id}>
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{category.name}</span>
<span className="text-blue-600 font-bold">{ratio}%</span>
<span className="text-blue-600 font-bold">{configMode === 'ratio' ? `${value}%` : `${value}`}</span>
</div>
<div className="flex items-center space-x-4">
<InputNumber
min={0}
max={100}
value={ratio}
onChange={(value) => handleCategoryRatioChange(category.name, value || 0)}
max={configMode === 'ratio' ? 100 : 200}
precision={configMode === 'ratio' ? 2 : 0}
value={value}
onChange={(nextValue) => handleCategoryRatioChange(category.name, nextValue || 0)}
style={{ width: 100 }}
/>
<div style={{ flex: 1 }}>
<Progress
percent={ratio}
percent={percent}
strokeColor="#1890ff"
showInfo={false}
size="small"