Explorar el Código

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

新增统计分析组件,支持查看选择题选项分布和主观题答案列表
在考试管理和问卷管理页面添加统计分析按钮
优化表格列宽和对齐方式
移除部分不必要的接口调用
tanshanming hace 6 meses
padre
commit
349a6b764e

+ 3 - 1
src/api/exam/paper/examManager.js

@@ -6,5 +6,7 @@ export default {
 	edit: (query) => request('edit', query, 'post'),
 	select: (id) => request(`detail?id=${id}`, '', 'get'),
 	deleteExam: (data) => request('delete', data, 'post'),
-	createExam: (query) => request('add', query, 'post')
+	createExam: (query) => request('add', query, 'post'),
+	// 统计分析
+	statistic: (data) => request('stasticByPaperId', data, 'get')
 }

+ 1 - 1
src/views/courseAdd/components/courseInfo.vue

@@ -274,7 +274,7 @@
 		(newVal) => {
 			// 初始化下拉选项数据
 			getOrgTreeSelector()
-			getCourseAllList()
+			// getCourseAllList()
 			getlecturerListSelector()
 
 			if (newVal) {

+ 7 - 11
src/views/courseManagement/components/ListView.vue

@@ -101,38 +101,34 @@
 		{
 			title: '状态',
 			dataIndex: 'putawayStatusName',
-			sorter: true,
-			width: '10%'
+			sorter: true
 		},
 		{
 			title: '院系',
 			dataIndex: 'collegeTwoIdName',
-			sorter: true,
-			width: '15%'
+			sorter: true
 		},
 		{
 			title: '课程类型',
 			dataIndex: 'courseTypeName',
-			sorter: true,
-			width: '8%'
+			sorter: true
 		},
 		{
 			title: '课时数量',
 			dataIndex: 'hourCount',
-			sorter: true,
-			width: '7%'
+			sorter: true
 		},
 		{
 			title: '发布时间',
 			dataIndex: 'publishTime',
-			sorter: true,
-			width: '12%'
+			sorter: true
 		},
 		{
 			title: '操作',
 			dataIndex: 'action',
 			sorter: true,
-			width: '285px'
+			width: '290px',
+			align: 'center'
 		}
 	]
 	// tool.formatTimestamp()

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

@@ -0,0 +1,384 @@
+<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">
+							<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'
+	const props = defineProps({
+		paperId: {
+			type: String,
+			default: null
+		},
+		paperName: {
+			type: String,
+			default: ''
+		}
+	})
+
+	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) => {
+		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) => {
+		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 {
+			const answerList = item.answerList.split(',')
+			return answerList.filter((answer) => answer === prefix).length
+		} catch (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)) {
+				return JSON.parse(item.answerList)
+			}
+			return []
+		} catch (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>

+ 23 - 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" />
+		</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'
@@ -92,6 +103,10 @@
 	const drawerVisible = ref(false)
 	const drawerTitle = ref('')
 	const editId = ref(null)
+	// 统计分析
+	const statisticVisible = ref(false)
+	const statisticTitle = ref('')
+	const statisticPaperId = ref(null)
 
 	const queryParam = reactive({
 		examName: null,
@@ -122,6 +137,14 @@
 			listLoading.value = false
 		}
 	}
+	const statistic = async (record) => {
+		statisticVisible.value = true
+		statisticTitle.value = '统计分析'
+		statisticPaperId.value = record.paperId
+	}
+	const closeStatisticDrawer = () => {
+		statisticVisible.value = false
+	}
 
 	onMounted(() => {
 		fetchList()

+ 20 - 20
src/views/exm/questionnaireManagement/form.vue

@@ -9,18 +9,18 @@
 			:loading="formLoading"
 			layout="horizontal"
 		>
-			<a-form-item label="考试标题" name="examName" :rules="rules.examName">
-				<a-input v-model:value="form.examName" placeholder="请输入考试标题" />
+			<a-form-item label="问卷标题" name="examName" :rules="rules.examName">
+				<a-input v-model:value="form.examName" placeholder="请输入问卷标题" />
 			</a-form-item>
-			<a-form-item label="选择卷" name="paperId" :rules="rules.paperId">
+			<a-form-item label="选择卷" name="paperId" :rules="rules.paperId">
 				<a-input-group compact>
 					<a-input
 						v-model:value="selectedPaperName"
-						placeholder="请选择卷"
+						placeholder="请选择卷"
 						readonly
 						style="width: calc(100% - 100px)"
 					/>
-					<a-button type="primary" @click="addPaper" style="width: 100px">选择卷</a-button>
+					<a-button type="primary" @click="addPaper" style="width: 100px">选择卷</a-button>
 				</a-input-group>
 			</a-form-item>
 			<a-form-item label="学期" name="semesterId">
@@ -63,7 +63,7 @@
 					style="width: 100%"
 				/>
 			</a-form-item>
-			<a-form-item label="考试状态" name="examStatus">
+			<a-form-item label="问卷状态" name="examStatus">
 				<a-radio-group v-model:value="form.examStatus">
 					<a-radio :value="0">未开始</a-radio>
 					<a-radio :value="1">已开始</a-radio>
@@ -81,15 +81,15 @@
 		<a-modal
 			v-model:visible="paperPage.showDialog"
 			width="70%"
-			title="选择卷"
+			title="选择卷"
 			@ok="confirmPaperSelect"
 			@cancel="() => (paperPage.showDialog = false)"
 		>
 			<a-form layout="inline">
-				<a-form-item label="卷类型">
+				<a-form-item label="卷类型">
 					<a-select
 						v-model:value="paperPage.queryParam.paperType"
-						placeholder="请选择卷类型"
+						placeholder="请选择卷类型"
 						@change="paperTypeChange"
 						disabled
 					>
@@ -169,8 +169,8 @@
 	})
 
 	const rules = {
-		examName: [{ required: true, message: '请输入考试标题', trigger: 'blur' }],
-		paperId: [{ required: true, message: '请选择卷', trigger: 'change' }],
+		examName: [{ required: true, message: '请输入问卷标题', trigger: 'blur' }],
+		paperId: [{ required: true, message: '请选择卷', trigger: 'change' }],
 		startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
 		endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }]
 	}
@@ -204,7 +204,7 @@
 		total: 0
 	})
 
-	// 卷选择表格单选
+	// 卷选择表格单选
 	const selectedRowKeys = ref([])
 	const rowSelection = reactive({
 		type: 'radio',
@@ -265,20 +265,20 @@
 		return current && current < dayjs().endOf('day')
 	}
 
-	// 卷类型变更
+	// 卷类型变更
 	const paperTypeChange = () => {
 		paperPage.queryParam.paperId = null
 		form.paperId = null
 		selectedPaperName.value = ''
 	}
 
-	// 选择
+	// 选择
 	const addPaper = () => {
 		paperPage.showDialog = true
 		search()
 	}
 
-	// 查询
+	// 查询
 	const search = async () => {
 		paperPage.listLoading = true
 		paperPage.showDialog = true
@@ -298,10 +298,10 @@
 		paperPage.listLoading = false
 	}
 
-	// 确认选择
+	// 确认选择
 	const confirmPaperSelect = () => {
 		if (!paperPage.selectedPaper) {
-			message.warning('请选择一个卷')
+			message.warning('请选择一个卷')
 			return
 		}
 		form.paperId = paperPage.selectedPaper.id
@@ -384,9 +384,9 @@
 						startTime: re.startTime ? dayjs(re.startTime) : null,
 						endTime: re.endTime ? dayjs(re.endTime) : null
 					})
-					// 如果有试卷ID,需要获取试卷名称显示
+					// 如果有问卷ID,需要获取问卷名称显示
 					if (re.paperId) {
-						// 这里可以根据需要调用接口获取卷名称
+						// 这里可以根据需要调用接口获取卷名称
 						examPaperApi.select(re.paperId).then((r) => {
 							selectedPaperName.value = r.name
 						})
@@ -399,7 +399,7 @@
 					formLoading.value = false
 				})
 				.catch((err) => {
-					message.error('加载考试信息失败:' + (err.message || '网络错误'))
+					message.error('加载问卷信息失败:' + (err.message || '网络错误'))
 					formLoading.value = false
 				})
 		}

+ 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" />
+		</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>

+ 3 - 3
src/views/myResources/resourceUpload.vue

@@ -140,7 +140,7 @@
 	import UpLoadBreakPoint from '@/components/UpLoadBreakPoint/index.vue'
 	import { useMyResourceStore } from '@/store/myResource'
 	import tool from '@/utils/tool'
-	import sysConfig from "@/config";
+	import sysConfig from '@/config'
 
 	const myResourceStore = useMyResourceStore()
 	const { proxy } = getCurrentInstance()
@@ -169,7 +169,7 @@
 	})
 	//课程类型
 	const courseTypeOptions = tool.dictList('COURSE_TYPE')
-	const action = ref(sysConfig.API_URL+'/api/webapp/dev/file/uploadMinioReturnId')
+	const action = ref(sysConfig.API_URL + '/api/webapp/dev/file/uploadMinioReturnId')
 	const formState = reactive({
 		userfileIds: null, //资源文件id
 		coverImage: null, //封面id
@@ -590,7 +590,7 @@
 	}
 	onMounted(() => {
 		getOrgTreeSelector()
-		getCourseAllList()
+		// getCourseAllList()
 		getHotKeywords()
 		getResourceTypeTree()
 		if (props.isState == 1) {

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 146
stats.html


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio