AnCao/web/src/pages/QuestionManagement.tsx
yanlongqi f7c662d9ac feat: 优化填空题显示,将 **** 渲染为下划线
- 题库管理:填空题题目内容中的 **** 显示为带下划线的正确答案
- 答题抽屉:填空题题目内容中的 **** 显示为下划线占位符
- 提升填空题的可读性和用户体验

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 13:27:57 +08:00

705 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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