瀏覽代碼

feat(统计分析): 添加考试和问卷的统计分析功能

新增统计分析组件,支持查看选择题选项分布和主观题答案列表
在考试管理和问卷管理页面添加统计分析按钮
优化试卷表单中的题目选择逻辑,防止重复选择
tanshanming 6 月之前
父節點
當前提交
abe8557580

+ 495 - 0
src/views/exm/examinationManagement/StatisticAnalysis.vue

@@ -0,0 +1,495 @@
+<template>
+	<div class="statistic-analysis">
+		<a-spin :spinning="loading">
+			<div v-if="statisticData.length > 0">
+				<h2 class="title">{{ paperName }}</h2>
+
+				<div class="question-list">
+					<div v-for="(item, index) in statisticData" :key="index" class="question-item">
+						<div class="question-header">
+							<span class="question-number">题目 {{ item.itemOrder }}:</span>
+							<span class="question-type">{{ getQuestionType(item) }}</span>
+						</div>
+
+						<div class="question-content" v-html="getQuestionContent(item)"></div>
+
+						<!-- 选择题答案统计 -->
+						<div v-if="isChoiceQuestion(item)" class="answer-statistics">
+							<h4>答案统计:</h4>
+							<div class="chart-container">
+								<div :id="`chart-${item.questionId}`" class="pie-chart"></div>
+								<div class="options-legend">
+									<div v-for="option in getQuestionOptions(item)" :key="option.prefix" class="option-stat">
+										<span class="option-prefix">{{ option.prefix }}:</span>
+										<span class="option-content" v-html="option.content"></span>
+										<span class="option-count">{{ getOptionCount(item, option.prefix) }}人选择</span>
+										<span v-if="isCorrectOption(item, option.prefix)" class="correct-mark">(正确答案)</span>
+									</div>
+								</div>
+							</div>
+						</div>
+
+						<!-- 主观题答案列表 -->
+						<div v-else class="subjective-answers">
+							<h4>答案列表:</h4>
+							<div
+								v-for="(answer, answerIndex) in getSubjectiveAnswers(item)"
+								:key="answerIndex"
+								class="subjective-answer"
+							>
+								<div class="answer-content">{{ answer }}</div>
+							</div>
+						</div>
+
+						<div class="question-analysis" v-if="props.examType === 'exam'">
+							<h4>题目分析:</h4>
+							<div v-html="getQuestionAnalysis(item)"></div>
+						</div>
+
+						<a-divider />
+					</div>
+				</div>
+			</div>
+			<a-empty v-else description="暂无统计数据" />
+		</a-spin>
+	</div>
+</template>
+
+<script setup>
+	import { ref, watch } from 'vue'
+	import examManagerApi from '@/api/exam/paper/examManager.js'
+	import * as echarts from 'echarts'
+	import { useExamStore } from '@/store/exam'
+	const examStore = useExamStore()
+	const questionTypeEnum = computed(() => examStore.questionTypeEnum)
+	const props = defineProps({
+		paperId: {
+			type: String,
+			default: null
+		},
+		paperName: {
+			type: String,
+			default: ''
+		},
+		examType: String // exam-考试,questionnaire-调查问卷
+	})
+
+	const loading = ref(false)
+	const statisticData = ref([])
+
+	const getStatistic = async () => {
+		loading.value = true
+		try {
+			const res = await examManagerApi.statistic({
+				paperId: props.paperId // 修正参数名称为 id
+			})
+			if (res) {
+				statisticData.value = res || []
+				// 数据加载完成后初始化图表
+				nextTick(() => {
+					initCharts()
+				})
+			}
+		} catch (error) {
+			console.error('获取统计数据失败', error)
+		} finally {
+			loading.value = false
+		}
+	}
+
+	// 初始化所有选择题的饼图
+	const initCharts = () => {
+		statisticData.value.forEach((item) => {
+			if (isChoiceQuestion(item)) {
+				nextTick(() => {
+					initPieChart(item)
+				})
+			}
+		})
+	}
+
+	// 初始化单个饼图
+	const initPieChart = (item) => {
+		const chartDom = document.getElementById(`chart-${item.questionId}`)
+		if (!chartDom) return
+
+		const myChart = echarts.init(chartDom)
+		const options = getQuestionOptions(item)
+		const chartData = options.map((option) => {
+			const count = getOptionCount(item, option.prefix)
+			return {
+				name: option.prefix,
+				value: count,
+				isCorrect: isCorrectOption(item, option.prefix)
+			}
+		})
+
+		const option = {
+			tooltip: {
+				trigger: 'item',
+				formatter: '{a} <br/>{b}: {c}人 ({d}%)'
+			},
+			legend: {
+				show: false
+			},
+			series: [
+				{
+					name: '选项统计',
+					type: 'pie',
+					radius: ['40%', '70%'],
+					avoidLabelOverlap: false,
+					itemStyle: {
+						borderRadius: 10,
+						borderColor: '#fff',
+						borderWidth: 2
+					},
+					label: {
+						show: true,
+						formatter: '{b}: {c}人 ({d}%)'
+					},
+					emphasis: {
+						label: {
+							show: true,
+							fontSize: 16,
+							fontWeight: 'bold'
+						}
+					},
+					data: chartData.map((item) => ({
+						name: item.name,
+						value: item.value,
+						itemStyle: {
+							color: item.isCorrect ? '#52c41a' : undefined
+						}
+					}))
+				}
+			]
+		}
+
+		myChart.setOption(option)
+
+		// 窗口大小变化时重新调整图表大小
+		window.addEventListener('resize', () => {
+			myChart.resize()
+		})
+	}
+
+	// 解析题目内容
+	const getQuestionContent = (item) => {
+		try {
+			const content = JSON.parse(item.content)
+			return content.titleContent || ''
+		} catch (e) {
+			return ''
+		}
+	}
+
+	// 获取题目类型
+	const getQuestionType = (item) => {
+		// 如果有 questionType 字段,优先使用 questionTypeEnum 获取题型名称
+		if (item.questionType !== undefined) {
+			const questionType = questionTypeEnum.value.find((type) => type.key === item.questionType)
+			if (questionType) {
+				return questionType.value
+			}
+		}
+
+		// 如果没有 questionType 字段或找不到对应的题型,使用原来的逻辑作为备选
+		try {
+			const content = JSON.parse(item.content)
+			if (!content.questionItemObjects || content.questionItemObjects.length === 0) {
+				return '主观题'
+			}
+			if (content.correct && content.correct.includes(',')) {
+				return '多选题'
+			}
+			return '单选题'
+		} catch (e) {
+			return '未知类型'
+		}
+	}
+
+	// 判断是否为选择题
+	const isChoiceQuestion = (item) => {
+		// 使用 questionType 字段判断
+		if (item.questionType !== undefined) {
+			// 1是单选题,2是多选题,3是判断题,这三种都是选择题
+			return [1, 2, 3].includes(item.questionType)
+		}
+
+		// 如果没有 questionType 字段,使用原来的逻辑
+		try {
+			const content = JSON.parse(item.content)
+			return content.questionItemObjects && content.questionItemObjects.length > 0
+		} catch (e) {
+			return false
+		}
+	}
+
+	// 获取题目选项
+	const getQuestionOptions = (item) => {
+		try {
+			const content = JSON.parse(item.content)
+			return content.questionItemObjects || []
+		} catch (e) {
+			return []
+		}
+	}
+
+	// 获取选项选择人数
+	const getOptionCount = (item, prefix) => {
+		try {
+			// 确保 answerList 是字符串,并且是选择题类型
+			if (typeof item.answerList !== 'string' || !isChoiceQuestion(item)) {
+				return 0
+			}
+			const answerList = item.answerList.split(',')
+			return answerList.filter((answer) => answer === prefix).length
+		} catch (e) {
+			console.error('获取选项选择人数失败', e)
+			return 0
+		}
+	}
+
+	// 判断是否为正确选项
+	const isCorrectOption = (item, prefix) => {
+		try {
+			const content = JSON.parse(item.content)
+			if (content.correct) {
+				return content.correct.split(',').includes(prefix)
+			}
+			return false
+		} catch (e) {
+			return false
+		}
+	}
+
+	// 获取主观题答案列表
+	const getSubjectiveAnswers = (item) => {
+		try {
+			if (!isChoiceQuestion(item)) {
+				// 处理填空题的特殊格式
+				if (item.questionType === 4) {
+					// 填空题
+					try {
+						// 填空题的答案格式可能是 "["i","i","o","o"],[]" 这样的格式
+						// 这不是标准的 JSON 格式,需要特殊处理
+						const raw = item.answerList
+						let s = typeof raw === 'string' ? raw.trim() : JSON.stringify(raw)
+
+						if (s === '' || s === 'null' || s === 'undefined') {
+							return []
+						}
+
+						const tryParse = (text) => {
+							try {
+								return JSON.parse(text)
+							} catch (e) {
+								return undefined
+							}
+						}
+
+						let answerData = tryParse(s)
+
+						if (answerData === undefined) {
+							let fixed = s
+							// 去掉 ] 前多余逗号,以及结尾多余逗号
+							fixed = fixed.replace(/,\s*]/g, ']').replace(/,\s*$/, '')
+							// 单引号转双引号(常见于后端返回)
+							if (fixed.includes("'") && !fixed.includes('"')) {
+								fixed = fixed.replace(/'/g, '"')
+							}
+							// 缺少最外层 [] 的情况,例如 ["i","i"],[]
+							if (
+								(fixed.startsWith('[') && fixed.includes('],') && !(fixed.startsWith('[[') && fixed.endsWith(']]'))) ||
+								(!fixed.startsWith('[') && fixed.includes('],'))
+							) {
+								fixed = `[${fixed}]`
+							}
+							answerData = tryParse(fixed)
+						}
+
+						// 将解析结果格式化为字符串数组用于展示
+						const result = []
+						if (Array.isArray(answerData)) {
+							// 二维数组:每个空对应一个数组
+							if (answerData.length > 0 && Array.isArray(answerData[0])) {
+								answerData.forEach((ans) => {
+									if (Array.isArray(ans)) {
+										const line = ans
+											.filter((x) => x !== null && x !== undefined && x !== '')
+											.map((x) => String(x))
+											.join(', ')
+										if (line !== '') result.push(line)
+									} else if (ans !== null && ans !== undefined && ans !== '') {
+										result.push(String(ans))
+									}
+								})
+							} else {
+								// 一维数组:合并为一条
+								const line = answerData
+									.filter((x) => x !== null && x !== undefined && x !== '')
+									.map((x) => String(x))
+									.join(', ')
+								if (line !== '') result.push(line)
+							}
+							return result
+						} else if (typeof answerData === 'string') {
+							return [answerData]
+						} else if (answerData != null) {
+							return [JSON.stringify(answerData)]
+						}
+						return []
+					} catch (error) {
+						console.error('解析填空题答案失败', error)
+						// 如果特殊处理失败,尝试直接显示原始答案
+						return [item.answerList]
+					}
+				}
+
+				// 其他主观题类型
+				try {
+					return JSON.parse(item.answerList)
+				} catch (error) {
+					// 如果解析失败,可能是普通字符串
+					return [item.answerList]
+				}
+			}
+			return []
+		} catch (e) {
+			console.error('解析答案列表失败', e)
+			return []
+		}
+	}
+
+	// 获取题目分析
+	const getQuestionAnalysis = (item) => {
+		try {
+			const content = JSON.parse(item.content)
+			return content.analyze || ''
+		} catch (e) {
+			return ''
+		}
+	}
+
+	watch(
+		() => props.paperId,
+		(newVal) => {
+			if (newVal) {
+				getStatistic()
+			}
+		},
+		{ immediate: true }
+	)
+</script>
+
+<style lang="less" scoped>
+	.statistic-analysis {
+		padding: 20px;
+
+		.title {
+			margin-bottom: 24px;
+			text-align: center;
+		}
+
+		.question-list {
+			.question-item {
+				margin-bottom: 30px;
+
+				.question-header {
+					margin-bottom: 12px;
+					font-weight: bold;
+
+					.question-number {
+						font-size: 16px;
+					}
+
+					.question-type {
+						margin-left: 10px;
+						color: #1890ff;
+						font-size: 14px;
+					}
+				}
+
+				.question-content {
+					margin-bottom: 16px;
+					font-size: 15px;
+				}
+
+				.answer-statistics,
+				.subjective-answers {
+					margin-bottom: 16px;
+					padding: 12px;
+					background-color: #f5f5f5;
+					border-radius: 4px;
+
+					h4 {
+						margin-bottom: 8px;
+						color: #555;
+					}
+
+					.chart-container {
+						display: flex;
+						flex-direction: row;
+						align-items: flex-start;
+
+						.pie-chart {
+							width: 40%;
+							height: 300px;
+							margin-right: 20px;
+						}
+
+						.options-legend {
+							flex: 1;
+							padding: 10px;
+							background-color: #fff;
+							border-radius: 4px;
+
+							.option-stat {
+								margin-bottom: 8px;
+
+								.option-prefix {
+									font-weight: bold;
+									margin-right: 5px;
+								}
+
+								.option-content {
+									display: inline-block;
+									max-width: 80%;
+								}
+
+								.option-count {
+									margin-left: 10px;
+									color: #ff4d4f;
+								}
+
+								.correct-mark {
+									margin-left: 10px;
+									color: #52c41a;
+								}
+							}
+						}
+					}
+
+					.subjective-answer {
+						margin-bottom: 10px;
+						padding: 8px;
+						background-color: #fff;
+						border-radius: 4px;
+					}
+				}
+
+				.question-analysis {
+					margin-top: 16px;
+					padding: 12px;
+					background-color: #e6f7ff;
+					border-radius: 4px;
+
+					h4 {
+						margin-bottom: 8px;
+						color: #1890ff;
+					}
+				}
+			}
+		}
+	}
+</style>

+ 25 - 0
src/views/exm/examinationManagement/index.vue

@@ -52,6 +52,7 @@
 				<template #default="{ record }">
 					<a-button size="small" @click="editTask(record)">编辑</a-button>
 					<a-button size="small" danger style="margin-left: 8px" @click="deleteTask(record)">删除</a-button>
+					<a-button size="small" type="primary" style="margin-left: 8px" @click="statistic(record)">统计分析</a-button>
 				</template>
 			</a-table-column>
 		</a-table>
@@ -76,6 +77,15 @@
 		>
 			<TaskEdit v-if="drawerVisible" :id="editId" @success="onEditSuccess" />
 		</a-drawer>
+		<a-modal
+			:visible="statisticVisible"
+			:title="statisticTitle"
+			:footer="null"
+			width="80%"
+			@cancel="closeStatisticDrawer"
+		>
+			<StatisticAnalysis :paperId="statisticPaperId" :paperName="statisticExamName" examType="exam" />
+		</a-modal>
 	</div>
 </template>
 
@@ -83,6 +93,7 @@
 	import { ref, reactive, onMounted } from 'vue'
 	import { message, Modal } from 'ant-design-vue'
 	import examManagerApi from '@/api/exam/paper/examManager.js'
+	import StatisticAnalysis from './StatisticAnalysis.vue'
 	import TaskEdit from './form.vue'
 	import { useExamStore } from '@/store/exam.js'
 	import { storeToRefs } from 'pinia'
@@ -91,7 +102,12 @@
 	const { levelEnum, enumFormat } = storeToRefs(examStore)
 	const drawerVisible = ref(false)
 	const drawerTitle = ref('')
+	const statisticExamName = ref(null)
 	const editId = ref(null)
+	// 统计分析
+	const statisticVisible = ref(false)
+	const statisticTitle = ref('')
+	const statisticPaperId = ref(null)
 
 	const queryParam = reactive({
 		examName: null,
@@ -122,6 +138,15 @@
 			listLoading.value = false
 		}
 	}
+	const statistic = async (record) => {
+		statisticVisible.value = true
+		statisticTitle.value = '统计分析'
+		statisticExamName.value = record.examName
+		statisticPaperId.value = record.paperId
+	}
+	const closeStatisticDrawer = () => {
+		statisticVisible.value = false
+	}
 
 	onMounted(() => {
 		fetchList()

+ 29 - 3
src/views/exm/exampaper/form.vue

@@ -23,7 +23,7 @@
 				</a-select>
 			</a-form-item> -->
 			<a-form-item label="试卷类型" name="paperType" :rules="rules.paperType">
-				<a-select v-model:value="form.paperType" placeholder="请选择试卷类型">
+				<a-select v-model:value="form.paperType" placeholder="请选择试卷类型" @change="handPaperTypeChange">
 					<a-select-option v-for="item in paperTypeEnum" :key="item.key" :value="item.key">
 						{{ item.value }}
 					</a-select-option>
@@ -90,13 +90,13 @@
 						</a-select-option>
 					</a-select>
 				</a-form-item>
-				<a-form-item label="题库类型">
+				<!-- <a-form-item label="题库类型">
 					<a-select v-model:value="questionPage.queryParam.bankType" allow-clear style="width: 120px">
 						<a-select-option v-for="item in bankTypeEnum" :key="item.key" :value="item.key">
 							{{ item.value }}
 						</a-select-option>
 					</a-select>
-				</a-form-item>
+				</a-form-item> -->
 				<a-form-item>
 					<a-button type="primary" @click="queryForm">查询</a-button>
 				</a-form-item>
@@ -262,10 +262,36 @@
 		questionPage.queryParam.pageIndex = page
 		search()
 	}
+	// 获取所有已选题目的ID
+	const allSelectedQuestionIds = computed(() => {
+		const ids = []
+		form.titleItems.forEach((titleItem) => {
+			titleItem.questionItems.forEach((question) => {
+				ids.push(question.id)
+			})
+		})
+		return ids
+	})
+
 	const rowSelection = {
 		selectedRowKeys: computed(() => questionPage.multipleSelection.map((q) => q.id)),
 		onChange: (selectedRowKeys, selectedRows) => {
 			questionPage.multipleSelection = selectedRows
+		},
+		getCheckboxProps: (record) => ({
+			disabled: allSelectedQuestionIds.value.includes(record.id),
+			title: allSelectedQuestionIds.value.includes(record.id) ? '该题目已被选择' : ''
+		})
+	}
+	const handPaperTypeChange = (paperType) => {
+		if (['2', '3'].includes(paperType)) {
+			questionPage.queryParam.bankType = '1'
+		}
+		if ('5' === paperType) {
+			questionPage.queryParam.bankType = '2'
+		}
+		if ('6' === paperType) {
+			questionPage.queryParam.bankType = '3'
 		}
 	}
 	function questionTypeFormatter({ text }) {

+ 31 - 6
src/views/exm/questionnaireManagement/index.vue

@@ -1,10 +1,10 @@
 <template>
 	<div class="task-container">
 		<a-form layout="inline" :model="queryParam">
-			<a-form-item label="考试标题:">
+			<a-form-item label="问卷名称:">
 				<a-input v-model:value="queryParam.examName" placeholder="请输入考试标题" style="min-width: 200px" allowClear />
 			</a-form-item>
-			<a-form-item label="考试状态:">
+			<a-form-item label="问卷状态:">
 				<a-select style="min-width: 150px" v-model:value="queryParam.examStatus" allowClear placeholder="考试状态">
 					<a-select-option :value="0">未开始</a-select-option>
 					<a-select-option :value="1">已开始</a-select-option>
@@ -13,7 +13,7 @@
 			</a-form-item>
 			<a-form-item>
 				<a-button type="primary" @click="submitForm">查询</a-button>
-				<a-button style="margin-left: 20px" type="primary" @click="createTask">创建考试</a-button>
+				<a-button style="margin-left: 20px" type="primary" @click="createTask">创建问卷</a-button>
 			</a-form-item>
 		</a-form>
 		<a-table
@@ -25,8 +25,8 @@
 			style="margin-top: 16px"
 		>
 			<a-table-column title="Id" dataIndex="id" key="id" width="100" />
-			<a-table-column title="考试标题" dataIndex="examName" key="examName" />
-			<a-table-column title="考试状态" dataIndex="examStatus" key="examStatus" width="120">
+			<a-table-column title="问卷名称" dataIndex="examName" key="examName" />
+			<a-table-column title="问卷状态" dataIndex="examStatus" key="examStatus" width="120">
 				<template #default="{ record }">
 					<a-tag :color="record.examStatus === 0 ? 'default' : record.examStatus === 1 ? 'processing' : 'success'">
 						{{ record.examStatus === 0 ? '未开始' : record.examStatus === 1 ? '已开始' : '已结束' }}
@@ -52,6 +52,7 @@
 				<template #default="{ record }">
 					<a-button size="small" @click="editTask(record)">编辑</a-button>
 					<a-button size="small" danger style="margin-left: 8px" @click="deleteTask(record)">删除</a-button>
+					<a-button size="small" type="primary" style="margin-left: 8px" @click="statistic(record)">统计分析</a-button>
 				</template>
 			</a-table-column>
 		</a-table>
@@ -76,6 +77,15 @@
 		>
 			<TaskEdit v-if="drawerVisible" :id="editId" @success="onEditSuccess" />
 		</a-drawer>
+		<a-modal
+			:visible="statisticVisible"
+			:title="statisticTitle"
+			:footer="null"
+			width="80%"
+			@cancel="closeStatisticDrawer"
+		>
+			<StatisticAnalysis :paperId="statisticPaperId" :paperName="statisticExamName" examType="questionnaire" />
+		</a-modal>
 	</div>
 </template>
 
@@ -83,6 +93,7 @@
 	import { ref, reactive, onMounted } from 'vue'
 	import { message, Modal } from 'ant-design-vue'
 	import examManagerApi from '@/api/exam/paper/examManager.js'
+	import StatisticAnalysis from '../examinationManagement/StatisticAnalysis.vue'
 	import TaskEdit from './form.vue'
 	import { useExamStore } from '@/store/exam.js'
 	import { storeToRefs } from 'pinia'
@@ -92,7 +103,11 @@
 	const drawerVisible = ref(false)
 	const drawerTitle = ref('')
 	const editId = ref(null)
-
+	// 统计分析
+	const statisticVisible = ref(false)
+	const statisticTitle = ref('')
+	const statisticPaperId = ref(null)
+	const statisticExamName = ref(null)
 	const queryParam = reactive({
 		examName: null,
 		examStatus: null,
@@ -183,6 +198,16 @@
 		if (!val) return ''
 		return parseTime(val, '{y}-{m}-{d} {h}:{i}:{s}')
 	}
+	const statistic = async (record) => {
+		statisticVisible.value = true
+		statisticTitle.value = '统计分析'
+		console.log('paperName=', record)
+		statisticExamName.value = record.examName
+		statisticPaperId.value = record.paperId
+	}
+	const closeStatisticDrawer = () => {
+		statisticVisible.value = false
+	}
 </script>
 
 <style lang="less" scoped>