- 题库管理:填空题题目内容中的 **** 显示为带下划线的正确答案 - 答题抽屉:填空题题目内容中的 **** 显示为下划线占位符 - 提升填空题的可读性和用户体验 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
705 lines
22 KiB
TypeScript
705 lines
22 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import {
|
||
Table,
|
||
Button,
|
||
Space,
|
||
Modal,
|
||
Form,
|
||
Input,
|
||
Select,
|
||
message,
|
||
Popconfirm,
|
||
Tag,
|
||
Card,
|
||
Radio,
|
||
Divider,
|
||
} from 'antd'
|
||
import {
|
||
PlusOutlined,
|
||
EditOutlined,
|
||
DeleteOutlined,
|
||
ArrowLeftOutlined,
|
||
MinusCircleOutlined,
|
||
} from '@ant-design/icons'
|
||
import * as questionApi from '../api/question'
|
||
import type { Question } from '../types/question'
|
||
import styles from './QuestionManagement.module.less'
|
||
|
||
const { Option } = Select
|
||
const { TextArea } = Input
|
||
|
||
// 题型配置
|
||
const questionTypes = [
|
||
{ key: 'multiple-choice', label: '单选题' },
|
||
{ key: 'multiple-selection', label: '多选题' },
|
||
{ key: 'true-false', label: '判断题' },
|
||
{ key: 'fill-in-blank', label: '填空题' },
|
||
{ key: 'short-answer', label: '简答题' },
|
||
{ key: 'ordinary-essay', label: '普通涉密人员论述题' },
|
||
{ key: 'management-essay', label: '保密管理人员论述题' },
|
||
]
|
||
|
||
const QuestionManagement: React.FC = () => {
|
||
const navigate = useNavigate()
|
||
const [questions, setQuestions] = useState<Question[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [modalVisible, setModalVisible] = useState(false)
|
||
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null)
|
||
const [form] = Form.useForm()
|
||
|
||
// 筛选和搜索状态
|
||
const [selectedType, setSelectedType] = useState<string>('')
|
||
const [searchText, setSearchText] = useState<string>('')
|
||
|
||
// 加载题目列表
|
||
const loadQuestions = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const params: any = {}
|
||
if (selectedType) {
|
||
params.type = selectedType
|
||
}
|
||
if (searchText) {
|
||
params.search = searchText
|
||
}
|
||
const res = await questionApi.getQuestions(params)
|
||
if (res.success && res.data) {
|
||
setQuestions(res.data)
|
||
}
|
||
} catch (error) {
|
||
message.error('加载题目失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadQuestions()
|
||
}, [selectedType, searchText])
|
||
|
||
// 打开新建/编辑弹窗
|
||
const handleOpenModal = (question?: Question) => {
|
||
if (question) {
|
||
setEditingQuestion(question)
|
||
|
||
// 直接使用后端返回的答案数据
|
||
let answerValue: any = question.answer
|
||
|
||
// 解析选项(单选题和多选题)
|
||
let optionsValue: Array<{ key: string; value: string }> = []
|
||
if (question.options && question.options.length > 0 &&
|
||
(question.type === 'multiple-choice' || question.type === 'multiple-selection')) {
|
||
optionsValue = question.options.map(opt => ({
|
||
key: opt.key,
|
||
value: opt.value,
|
||
}))
|
||
}
|
||
|
||
// 设置表单值
|
||
form.setFieldsValue({
|
||
type: question.type,
|
||
content: question.content,
|
||
answer: answerValue,
|
||
options: optionsValue,
|
||
})
|
||
} else {
|
||
setEditingQuestion(null)
|
||
form.resetFields()
|
||
|
||
// 新建时设置默认值 - 参照编辑逻辑
|
||
const defaultType = 'multiple-choice'
|
||
|
||
// 设置默认答案值
|
||
let defaultAnswer: any = ''
|
||
let defaultOptions: Array<{ key: string; value: string }> = []
|
||
|
||
// 为单选和多选题设置默认选项
|
||
if (defaultType === 'multiple-choice' || defaultType === 'multiple-selection') {
|
||
defaultOptions = [{ key: 'A', value: '' }, { key: 'B', value: '' }]
|
||
}
|
||
|
||
form.setFieldsValue({
|
||
type: defaultType,
|
||
content: '',
|
||
answer: defaultAnswer,
|
||
options: defaultOptions,
|
||
})
|
||
}
|
||
setModalVisible(true)
|
||
}
|
||
|
||
// 关闭弹窗
|
||
const handleCloseModal = () => {
|
||
setModalVisible(false)
|
||
setEditingQuestion(null)
|
||
form.resetFields()
|
||
}
|
||
|
||
// 保存题目
|
||
const handleSave = async () => {
|
||
try {
|
||
const values = await form.validateFields()
|
||
|
||
// 解析答案
|
||
let answer: any
|
||
if (values.type === 'true-false') {
|
||
answer = values.answer
|
||
} else if (values.type === 'multiple-choice') {
|
||
answer = values.answer
|
||
} else if (values.type === 'multiple-selection') {
|
||
// 多选题答案是数组
|
||
answer = values.answer
|
||
} else if (values.type === 'fill-in-blank') {
|
||
// 填空题答案是数组
|
||
answer = values.answer
|
||
} else if (values.type === 'short-answer' || values.type === 'ordinary-essay' || values.type === 'management-essay') {
|
||
answer = values.answer
|
||
} else {
|
||
answer = values.answer
|
||
}
|
||
|
||
// 解析选项(仅选择题和多选题需要)
|
||
let options: Record<string, string> | undefined
|
||
if (values.type === 'multiple-choice' || values.type === 'multiple-selection') {
|
||
if (values.options && values.options.length > 0) {
|
||
// 将数组格式转换为对象格式 { "A": "选项A", "B": "选项B" }
|
||
options = values.options.reduce((acc: Record<string, string>, opt: any) => {
|
||
if (opt && opt.key && opt.value) {
|
||
acc[opt.key] = opt.value
|
||
}
|
||
return acc
|
||
}, {})
|
||
}
|
||
}
|
||
|
||
// 构建请求数据
|
||
const data = {
|
||
type: values.type,
|
||
type_name: '', // 不再使用分类字段
|
||
question: values.content,
|
||
answer: answer,
|
||
options: options,
|
||
}
|
||
|
||
if (editingQuestion) {
|
||
// 更新
|
||
await questionApi.updateQuestion(editingQuestion.id, data)
|
||
message.success('更新成功')
|
||
} else {
|
||
// 创建
|
||
await questionApi.createQuestion(data)
|
||
message.success('创建成功')
|
||
}
|
||
|
||
handleCloseModal()
|
||
loadQuestions()
|
||
} catch (error) {
|
||
console.error('保存失败:', error)
|
||
message.error('保存失败')
|
||
}
|
||
}
|
||
|
||
// 删除题目
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await questionApi.deleteQuestion(id)
|
||
message.success('删除成功')
|
||
loadQuestions()
|
||
} catch (error) {
|
||
message.error('删除失败')
|
||
}
|
||
}
|
||
|
||
// 渲染填空题题目内容:将 **** 替换为带下划线的正确答案
|
||
const renderFillInBlankContent = (content: string, answer: string[] | any): React.ReactNode => {
|
||
// 确保答案是数组
|
||
const answers: string[] = Array.isArray(answer) ? answer : []
|
||
|
||
if (answers.length === 0) {
|
||
return content
|
||
}
|
||
|
||
// 找到所有的 **** 并替换为对应的答案
|
||
let answerIndex = 0
|
||
const parts = content.split('****')
|
||
|
||
return (
|
||
<span>
|
||
{parts.map((part, index) => (
|
||
<React.Fragment key={index}>
|
||
{part}
|
||
{index < parts.length - 1 && (
|
||
<span style={{
|
||
textDecoration: 'underline',
|
||
color: '#1890ff',
|
||
fontWeight: 500,
|
||
padding: '0 4px'
|
||
}}>
|
||
{answers[answerIndex++] || '____'}
|
||
</span>
|
||
)}
|
||
</React.Fragment>
|
||
))}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// 表格列定义
|
||
const columns = [
|
||
{
|
||
title: '题目编号',
|
||
dataIndex: 'question_id',
|
||
key: 'question_id',
|
||
width: 100,
|
||
},
|
||
{
|
||
title: '题型',
|
||
dataIndex: 'type',
|
||
key: 'type',
|
||
width: 180,
|
||
render: (type: string) => {
|
||
const typeConfig = questionTypes.find(t => t.key === type)
|
||
const colorMap: Record<string, string> = {
|
||
'multiple-choice': 'blue',
|
||
'multiple-selection': 'green',
|
||
'true-false': 'orange',
|
||
'fill-in-blank': 'purple',
|
||
'short-answer': 'magenta',
|
||
'ordinary-essay': 'pink',
|
||
'management-essay': 'red',
|
||
}
|
||
return <Tag color={colorMap[type]}>{typeConfig?.label || type}</Tag>
|
||
},
|
||
},
|
||
{
|
||
title: '题目内容',
|
||
dataIndex: 'content',
|
||
key: 'content',
|
||
ellipsis: true,
|
||
render: (content: string, record: Question) => {
|
||
// 如果是填空题,使用特殊渲染
|
||
if (record.type === 'fill-in-blank') {
|
||
return renderFillInBlankContent(content, record.answer)
|
||
}
|
||
return content
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 150,
|
||
render: (_: any, record: Question) => (
|
||
<Space size="small">
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<EditOutlined />}
|
||
onClick={() => handleOpenModal(record)}
|
||
>
|
||
编辑
|
||
</Button>
|
||
<Popconfirm
|
||
title="确定删除这道题目吗?"
|
||
onConfirm={() => handleDelete(record.id)}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
>
|
||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
|
||
// 根据题型动态渲染表单项
|
||
const renderFormByType = () => {
|
||
const type = form.getFieldValue('type')
|
||
|
||
switch (type) {
|
||
case 'true-false':
|
||
return (
|
||
<Form.Item
|
||
label="正确答案"
|
||
name="answer"
|
||
rules={[{ required: true, message: '请选择答案' }]}
|
||
>
|
||
<Radio.Group>
|
||
<Radio value="true">正确</Radio>
|
||
<Radio value="false">错误</Radio>
|
||
</Radio.Group>
|
||
</Form.Item>
|
||
)
|
||
|
||
case 'multiple-choice':
|
||
return (
|
||
<>
|
||
<Form.List
|
||
name="options"
|
||
rules={[
|
||
{
|
||
validator: async (_, options) => {
|
||
if (!options || options.length < 2) {
|
||
return Promise.reject(new Error('至少需要2个选项'))
|
||
}
|
||
},
|
||
},
|
||
]}
|
||
>
|
||
{(fields, { add, remove }, { errors }) => (
|
||
<>
|
||
<Form.Item label="选项" required>
|
||
{fields.map((field) => (
|
||
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||
<Form.Item
|
||
{...field}
|
||
name={[field.name, 'key']}
|
||
rules={[{ required: true, message: '请输入选项键' }]}
|
||
noStyle
|
||
>
|
||
<Input placeholder="A" style={{ width: 60 }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
{...field}
|
||
name={[field.name, 'value']}
|
||
rules={[{ required: true, message: '请输入选项内容' }]}
|
||
noStyle
|
||
>
|
||
<Input placeholder="选项内容" style={{ width: 400 }} />
|
||
</Form.Item>
|
||
{fields.length > 2 && (
|
||
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||
)}
|
||
</Space>
|
||
))}
|
||
<Form.ErrorList errors={errors} />
|
||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||
添加选项
|
||
</Button>
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</Form.List>
|
||
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.options !== currentValues.options}>
|
||
{({ getFieldValue }) => {
|
||
const options = getFieldValue('options') || []
|
||
const optionsList = options
|
||
.filter((opt: any) => opt && opt.key)
|
||
.map((opt: any) => ({
|
||
label: `${opt.key}. ${opt.value || '(请先填写选项内容)'}`,
|
||
value: opt.key,
|
||
}))
|
||
return (
|
||
<Form.Item
|
||
label="正确答案"
|
||
name="answer"
|
||
rules={[{ required: true, message: '请选择答案' }]}
|
||
>
|
||
<Select
|
||
placeholder="选择正确答案"
|
||
options={optionsList}
|
||
/>
|
||
</Form.Item>
|
||
)
|
||
}}
|
||
</Form.Item>
|
||
</>
|
||
)
|
||
|
||
case 'multiple-selection':
|
||
return (
|
||
<>
|
||
<Form.List
|
||
name="options"
|
||
rules={[
|
||
{
|
||
validator: async (_, options) => {
|
||
if (!options || options.length < 2) {
|
||
return Promise.reject(new Error('至少需要2个选项'))
|
||
}
|
||
},
|
||
},
|
||
]}
|
||
>
|
||
{(fields, { add, remove }, { errors }) => (
|
||
<>
|
||
<Form.Item label="选项" required>
|
||
{fields.map((field) => (
|
||
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||
<Form.Item
|
||
{...field}
|
||
name={[field.name, 'key']}
|
||
rules={[{ required: true, message: '请输入选项键' }]}
|
||
noStyle
|
||
>
|
||
<Input placeholder="A" style={{ width: 60 }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
{...field}
|
||
name={[field.name, 'value']}
|
||
rules={[{ required: true, message: '请输入选项内容' }]}
|
||
noStyle
|
||
>
|
||
<Input placeholder="选项内容" style={{ width: 400 }} />
|
||
</Form.Item>
|
||
{fields.length > 2 && (
|
||
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||
)}
|
||
</Space>
|
||
))}
|
||
<Form.ErrorList errors={errors} />
|
||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||
添加选项
|
||
</Button>
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</Form.List>
|
||
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.options !== currentValues.options}>
|
||
{({ getFieldValue }) => {
|
||
const options = getFieldValue('options') || []
|
||
const optionsList = options
|
||
.filter((opt: any) => opt && opt.key)
|
||
.map((opt: any) => ({
|
||
label: `${opt.key}. ${opt.value || '(请先填写选项内容)'}`,
|
||
value: opt.key,
|
||
}))
|
||
return (
|
||
<Form.Item
|
||
label="正确答案"
|
||
name="answer"
|
||
rules={[{ required: true, message: '请选择答案', type: 'array' }]}
|
||
tooltip="可以选择多个正确答案"
|
||
>
|
||
<Select
|
||
mode="multiple"
|
||
placeholder="选择正确答案(可多选)"
|
||
options={optionsList}
|
||
allowClear
|
||
showSearch
|
||
filterOption={(input, option) =>
|
||
String(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||
}
|
||
/>
|
||
</Form.Item>
|
||
)
|
||
}}
|
||
</Form.Item>
|
||
</>
|
||
)
|
||
|
||
case 'fill-in-blank':
|
||
return (
|
||
<Form.List
|
||
name="answer"
|
||
rules={[
|
||
{
|
||
validator: async (_, answers) => {
|
||
if (!answers || answers.length < 1) {
|
||
return Promise.reject(new Error('至少需要1个答案'))
|
||
}
|
||
},
|
||
},
|
||
]}
|
||
>
|
||
{(fields, { add, remove }, { errors }) => (
|
||
<>
|
||
<Form.Item label="正确答案" required>
|
||
{fields.map((field, index) => (
|
||
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||
<span>第{['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || (index + 1)}空:</span>
|
||
<Form.Item
|
||
{...field}
|
||
rules={[{ required: true, message: '请输入答案' }]}
|
||
noStyle
|
||
>
|
||
<Input placeholder="填空答案" style={{ width: 400 }} />
|
||
</Form.Item>
|
||
{fields.length > 1 && (
|
||
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||
)}
|
||
</Space>
|
||
))}
|
||
<Form.ErrorList errors={errors} />
|
||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||
添加空格
|
||
</Button>
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</Form.List>
|
||
)
|
||
|
||
case 'short-answer':
|
||
case 'ordinary-essay':
|
||
case 'management-essay':
|
||
return (
|
||
<Form.Item
|
||
label="参考答案"
|
||
name="answer"
|
||
rules={[{ required: true, message: '请输入答案' }]}
|
||
>
|
||
<TextArea placeholder="输入参考答案" rows={4} />
|
||
</Form.Item>
|
||
)
|
||
|
||
default:
|
||
return (
|
||
<Form.Item
|
||
label="答案"
|
||
name="answer"
|
||
rules={[{ required: true, message: '请输入答案' }]}
|
||
>
|
||
<Input placeholder="输入答案" />
|
||
</Form.Item>
|
||
)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
{/* 头部 */}
|
||
<Card className={styles.header}>
|
||
<div className={styles.headerContent}>
|
||
<Space>
|
||
<Button
|
||
icon={<ArrowLeftOutlined />}
|
||
onClick={() => navigate('/')}
|
||
>
|
||
返回首页
|
||
</Button>
|
||
<Divider type="vertical" />
|
||
<h2 className={styles.title}>题库管理</h2>
|
||
</Space>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => handleOpenModal()}
|
||
>
|
||
新建题目
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 题目列表 */}
|
||
<Card className={styles.content}>
|
||
{/* 筛选和搜索 */}
|
||
<Space style={{ marginBottom: 16 }} size="middle">
|
||
<span>题型筛选:</span>
|
||
<Select
|
||
style={{ width: 150 }}
|
||
placeholder="全部题型"
|
||
allowClear
|
||
value={selectedType || undefined}
|
||
onChange={(value) => setSelectedType(value || '')}
|
||
>
|
||
{questionTypes.map(type => (
|
||
<Option key={type.key} value={type.key}>
|
||
{type.label}
|
||
</Option>
|
||
))}
|
||
</Select>
|
||
|
||
<Input.Search
|
||
placeholder="搜索题目内容或编号"
|
||
style={{ width: 300 }}
|
||
allowClear
|
||
onSearch={(value) => setSearchText(value)}
|
||
onChange={(e) => {
|
||
if (!e.target.value) {
|
||
setSearchText('')
|
||
}
|
||
}}
|
||
/>
|
||
</Space>
|
||
|
||
<Table
|
||
columns={columns}
|
||
dataSource={questions}
|
||
loading={loading}
|
||
rowKey="id"
|
||
pagination={{
|
||
defaultPageSize: 20,
|
||
showSizeChanger: true,
|
||
showTotal: (total) => `共 ${total} 道题目`,
|
||
}}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 新建/编辑弹窗 */}
|
||
<Modal
|
||
title={editingQuestion ? '编辑题目' : '新建题目'}
|
||
open={modalVisible}
|
||
onOk={handleSave}
|
||
onCancel={handleCloseModal}
|
||
width={700}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
>
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
initialValues={{
|
||
type: 'multiple-choice',
|
||
options: [{ key: 'A', value: '' }, { key: 'B', value: '' }],
|
||
answer: '', // 添加默认answer字段
|
||
}}
|
||
>
|
||
<Form.Item
|
||
label="题型"
|
||
name="type"
|
||
rules={[{ required: true, message: '请选择题型' }]}
|
||
>
|
||
<Select
|
||
placeholder="选择题型"
|
||
onChange={(value) => {
|
||
// 切换题型时重置答案和选项
|
||
form.setFieldsValue({ answer: undefined, options: undefined })
|
||
// 为单选和多选题设置默认选项
|
||
if (value === 'multiple-choice' || value === 'multiple-selection') {
|
||
form.setFieldsValue({
|
||
options: [{ key: 'A', value: '' }, { key: 'B', value: '' }],
|
||
answer: value === 'multiple-choice' ? '' : [], // 单选空字符串,多选空数组
|
||
})
|
||
}
|
||
// 为判断题设置默认答案
|
||
else if (value === 'true-false') {
|
||
form.setFieldsValue({ answer: 'true' })
|
||
}
|
||
// 为填空题设置默认答案数组
|
||
else if (value === 'fill-in-blank') {
|
||
form.setFieldsValue({ answer: [''] })
|
||
}
|
||
// 为简答题和论述题设置默认空字符串
|
||
else if (value === 'short-answer' || value === 'ordinary-essay' || value === 'management-essay') {
|
||
form.setFieldsValue({ answer: '' })
|
||
}
|
||
}}
|
||
>
|
||
{questionTypes.map(type => (
|
||
<Option key={type.key} value={type.key}>
|
||
{type.label}
|
||
</Option>
|
||
))}
|
||
</Select>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="题目内容"
|
||
name="content"
|
||
rules={[{ required: true, message: '请输入题目内容' }]}
|
||
>
|
||
<TextArea rows={4} placeholder="输入题目内容" />
|
||
</Form.Item>
|
||
|
||
{renderFormByType()}
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default QuestionManagement
|