|
|
@@ -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>
|