|
|
<template>
|
|
|
<el-dialog :visible="visible" fullscreen title="问卷结果" @close="close">
|
|
|
<div v-if="surveyData" class="results">
|
|
|
<h2>{{ surveyData.title }}</h2>
|
|
|
<el-tabs
|
|
|
v-model="tab"
|
|
|
@tab-click="changeTab"
|
|
|
><el-tab-pane label="汇总统计" name="statistics" /><el-tab-pane
|
|
|
label="答卷列表"
|
|
|
name="responses"
|
|
|
/></el-tabs>
|
|
|
<div v-if="tab === 'statistics'" v-loading="loading">
|
|
|
<div v-if="summary" class="overview">
|
|
|
<span>答卷 <b>{{ summary.responses }}</b> 份</span><span>题目 <b>{{ summary.questions_count }}</b> 道</span><span
|
|
|
v-if="summary.average !== null"
|
|
|
>所有有效评分项均分 <b>{{ summary.average }}</b></span>
|
|
|
</div>
|
|
|
<el-alert
|
|
|
title="未填写不计为0分;均分按有效评分项计算。选项比例以该项有效作答人数为分母,多选比例之和可能超过100%。"
|
|
|
type="info"
|
|
|
:closable="false"
|
|
|
/>
|
|
|
<template v-if="summary">
|
|
|
<section
|
|
|
v-for="q in summary.questions"
|
|
|
:key="q.id"
|
|
|
:class="['stat-card', { chapter: q.edit_input === 'section' }]"
|
|
|
>
|
|
|
<h3>{{ q.name }}</h3>
|
|
|
<p
|
|
|
v-if="q.edit_input === 'section'"
|
|
|
class="description"
|
|
|
v-text="q.help"
|
|
|
/>
|
|
|
<template v-else-if="q.edit_input === 'multi_dimension'">
|
|
|
<p>
|
|
|
有效作答 {{ q.stats.valid_count }} 人
|
|
|
<span v-if="q.stats.average !== null">
|
|
|
· 有效评分项均分 {{ q.stats.average }}</span>
|
|
|
</p>
|
|
|
<el-table
|
|
|
:data="q.dimensions"
|
|
|
border
|
|
|
size="small"
|
|
|
><el-table-column
|
|
|
prop="name"
|
|
|
label="评价项目"
|
|
|
min-width="180"
|
|
|
/><el-table-column
|
|
|
prop="stats.valid_count"
|
|
|
label="作答人数"
|
|
|
width="100"
|
|
|
/><el-table-column
|
|
|
label="均分 / 数量合计"
|
|
|
min-width="130"
|
|
|
><template slot-scope="scope">{{
|
|
|
scope.row.edit_input === "number"
|
|
|
? scope.row.stats.sum
|
|
|
: scope.row.stats.average === null
|
|
|
? "—"
|
|
|
: scope.row.stats.average
|
|
|
}}</template></el-table-column></el-table>
|
|
|
<el-collapse><el-collapse-item
|
|
|
v-for="dim in q.dimensions"
|
|
|
:key="dim.field"
|
|
|
:name="dim.field"
|
|
|
:title="dim.name + ' · 详细分布'"
|
|
|
><SurveyStatsField :field="dim" /></el-collapse-item></el-collapse>
|
|
|
</template>
|
|
|
<SurveyStatsField
|
|
|
v-else
|
|
|
:field="q"
|
|
|
@texts="(kind, key) => openTexts(q, kind, key)"
|
|
|
/>
|
|
|
</section>
|
|
|
</template>
|
|
|
<el-empty
|
|
|
v-if="!loading && !summary"
|
|
|
description="未能加载统计"
|
|
|
><el-button @click="loadStats">重试</el-button></el-empty>
|
|
|
</div>
|
|
|
<div v-else v-loading="responsesLoading">
|
|
|
<div style="margin-bottom: 16px"><el-button type="primary" icon="el-icon-download" :loading="exporting" :disabled="exporting" @click="exportResponses">导出全部答卷及附件</el-button><span style="margin-left: 12px; color: #909399">导出当前问卷全部答卷,不限当前页</span></div>
|
|
|
<el-table
|
|
|
:data="responses"
|
|
|
border
|
|
|
><el-table-column
|
|
|
label="姓名"
|
|
|
><template slot-scope="scope">{{
|
|
|
(scope.row.respondent || scope.row.user || {}).name || "—"
|
|
|
}}</template></el-table-column><el-table-column
|
|
|
label="学号"
|
|
|
><template slot-scope="scope">{{
|
|
|
(scope.row.respondent || scope.row.user || {}).no || "—"
|
|
|
}}</template></el-table-column><el-table-column
|
|
|
label="公司"
|
|
|
><template slot-scope="scope">{{
|
|
|
(scope.row.respondent || scope.row.user || {}).company_name || "—"
|
|
|
}}</template></el-table-column><el-table-column
|
|
|
label="职务"
|
|
|
><template slot-scope="scope">{{
|
|
|
(scope.row.respondent || scope.row.user || {}).company_position || "—"
|
|
|
}}</template></el-table-column><el-table-column
|
|
|
label="最近提交时间"
|
|
|
><template slot-scope="scope">{{
|
|
|
scope.row.updated_at || scope.row.created_at
|
|
|
}}</template></el-table-column><el-table-column
|
|
|
label="操作"
|
|
|
width="150"
|
|
|
><template
|
|
|
slot-scope="scope"
|
|
|
><el-button
|
|
|
type="text"
|
|
|
@click="openReply(scope.row)"
|
|
|
>查看</el-button><el-button type="text" style="color: #f56c6c" :loading="deletingId === scope.row.id" :disabled="deletingId !== null" @click="removeReply(scope.row)">删除</el-button></template></el-table-column></el-table>
|
|
|
<el-pagination
|
|
|
:current-page.sync="page"
|
|
|
:page-size="20"
|
|
|
:total="total"
|
|
|
layout="total, prev, pager, next"
|
|
|
@current-change="loadResponses"
|
|
|
/>
|
|
|
</div>
|
|
|
</div>
|
|
|
<el-dialog
|
|
|
:visible.sync="replyVisible"
|
|
|
title="答卷详情"
|
|
|
width="85%"
|
|
|
append-to-body
|
|
|
>
|
|
|
<div v-if="reply" class="reply">
|
|
|
<h2>{{ reply.snapshot ? reply.snapshot.title : surveyData.title }}</h2>
|
|
|
<SurveyDescription
|
|
|
class="description"
|
|
|
:value="reply.snapshot ? reply.snapshot.desc : surveyData.desc"
|
|
|
/>
|
|
|
<el-descriptions
|
|
|
border
|
|
|
:column="2"
|
|
|
><el-descriptions-item label="姓名">{{
|
|
|
(reply.respondent || reply.user || {}).name || "—"
|
|
|
}}</el-descriptions-item><el-descriptions-item label="最近提交时间">{{
|
|
|
reply.updated_at || reply.created_at
|
|
|
}}</el-descriptions-item><el-descriptions-item label="公司">{{
|
|
|
(reply.respondent || reply.user || {}).company_name || "—"
|
|
|
}}</el-descriptions-item><el-descriptions-item label="学号">{{
|
|
|
(reply.respondent || reply.user || {}).no || "—"
|
|
|
}}</el-descriptions-item></el-descriptions>
|
|
|
<SurveyQuestion
|
|
|
v-for="q in reply.questions"
|
|
|
:key="q.id"
|
|
|
:question="q"
|
|
|
:response-id="reply.id"
|
|
|
answered
|
|
|
/>
|
|
|
</div>
|
|
|
</el-dialog>
|
|
|
<el-dialog
|
|
|
:visible.sync="textsVisible"
|
|
|
:title="textTitle"
|
|
|
width="70%"
|
|
|
append-to-body
|
|
|
>
|
|
|
<div v-loading="textsLoading">
|
|
|
<div v-for="item in texts" :key="item.id" class="text-answer">
|
|
|
<div><strong>{{ (item.respondent && item.respondent.name) || '未填写姓名' }}</strong><span v-if="item.respondent && item.respondent.company_name"> · {{ item.respondent.company_name }}</span></div>
|
|
|
<small>{{ item.created_at }}</small>
|
|
|
<SurveyAttachments
|
|
|
v-if="item.files && item.files.length"
|
|
|
:files="item.files"
|
|
|
:response-id="item.id"
|
|
|
/>
|
|
|
<p v-else>{{ item.text }}</p>
|
|
|
<el-button
|
|
|
type="text"
|
|
|
@click="openReply(item)"
|
|
|
>查看整份答卷</el-button>
|
|
|
</div>
|
|
|
<el-empty
|
|
|
v-if="!texts.length"
|
|
|
description="暂无有效回答"
|
|
|
/><el-pagination
|
|
|
:current-page.sync="textPage"
|
|
|
:page-size="20"
|
|
|
:total="textTotal"
|
|
|
layout="total, prev, pager, next"
|
|
|
@current-change="loadTexts"
|
|
|
/>
|
|
|
</div>
|
|
|
</el-dialog>
|
|
|
</el-dialog>
|
|
|
</template>
|
|
|
<script>
|
|
|
import axios from 'axios'
|
|
|
import { getToken } from '@/utils/auth'
|
|
|
import { saveAs } from 'file-saver'
|
|
|
import {
|
|
|
index,
|
|
|
show,
|
|
|
statistics,
|
|
|
textAnswers,
|
|
|
destroy
|
|
|
} from '@/api/survey/evaluationForm'
|
|
|
import SurveyAttachments from './SurveyAttachments.vue'
|
|
|
import SurveyDescription from './SurveyDescription.vue'
|
|
|
import SurveyQuestion from './SurveyQuestion.vue'
|
|
|
import SurveyStatsField from './SurveyStatsField.vue'
|
|
|
export default {
|
|
|
components: {
|
|
|
SurveyAttachments,
|
|
|
SurveyQuestion,
|
|
|
SurveyStatsField,
|
|
|
SurveyDescription
|
|
|
},
|
|
|
props: { visible: Boolean, surveyData: { type: Object, default: null }},
|
|
|
data() {
|
|
|
return {
|
|
|
tab: 'statistics',
|
|
|
summary: null,
|
|
|
loading: false,
|
|
|
responsesLoading: false,
|
|
|
exporting: false,
|
|
|
deletingId: null,
|
|
|
responses: [],
|
|
|
page: 1,
|
|
|
total: 0,
|
|
|
reply: null,
|
|
|
replyVisible: false,
|
|
|
textsVisible: false,
|
|
|
textsLoading: false,
|
|
|
texts: [],
|
|
|
textPage: 1,
|
|
|
textTotal: 0,
|
|
|
textQuestion: null,
|
|
|
textKind: 'value',
|
|
|
textOptionKey: '',
|
|
|
epoch: 0
|
|
|
}
|
|
|
},
|
|
|
computed: {
|
|
|
textTitle() {
|
|
|
if (!this.textQuestion) return '回答'
|
|
|
const option = (this.textQuestion.select_item || []).find(o => String(o.key) === String(this.textOptionKey))
|
|
|
const suffix = this.textKind === 'option' ? ' · ' + (option ? option.value : '选项') + ' · 补充内容' : this.textKind === 'other' ? ' · 补充内容' : ' · 回答'
|
|
|
return this.textQuestion.name + suffix
|
|
|
}
|
|
|
},
|
|
|
watch: {
|
|
|
visible(value) {
|
|
|
this.epoch++
|
|
|
if (value) {
|
|
|
this.tab = 'statistics'
|
|
|
this.page = 1
|
|
|
this.summary = null
|
|
|
this.responses = []
|
|
|
this.loadStats()
|
|
|
} else {
|
|
|
this.replyVisible = false
|
|
|
this.textsVisible = false
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
methods: {
|
|
|
async removeReply(row) {
|
|
|
if (this.deletingId !== null) return
|
|
|
const epoch = this.epoch
|
|
|
try {
|
|
|
await this.$confirm('确定删除“' + ((row.respondent || row.user || {}).name || '该学员') + '”的答卷?删除后不再计入列表、统计和导出,记录及附件会保留。', '删除答卷', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
|
|
|
} catch (_) { return }
|
|
|
if (epoch !== this.epoch) return
|
|
|
this.deletingId = row.id
|
|
|
try {
|
|
|
await destroy({ id: row.id })
|
|
|
this.$message.success('答卷已删除')
|
|
|
this.$emit('refresh')
|
|
|
if (epoch !== this.epoch) return
|
|
|
if (this.reply && this.reply.id === row.id) { this.replyVisible = false; this.reply = null }
|
|
|
if (this.responses.length === 1 && this.page > 1) this.page--
|
|
|
await Promise.all([this.loadResponses(), this.loadStats()])
|
|
|
if (this.textsVisible) { this.textPage = 1; await this.loadTexts() }
|
|
|
} finally { this.deletingId = null }
|
|
|
},
|
|
|
async exportResponses() {
|
|
|
if (this.exporting || !this.surveyData) return
|
|
|
this.exporting = true
|
|
|
try {
|
|
|
const result = await axios.get('/api/admin/course-content-evaluation-form/index', {
|
|
|
baseURL: process.env.VUE_APP_BASE_API,
|
|
|
params: { export_bundle: 1, course_content_evaluation_id: this.surveyData.id },
|
|
|
headers: { Authorization: 'Bearer ' + getToken() }, responseType: 'blob', timeout: 300000
|
|
|
})
|
|
|
if ((result.headers['content-type'] || '').includes('application/json')) {
|
|
|
const error = JSON.parse(await result.data.text())
|
|
|
throw new Error(error.errmsg || '导出失败,请重试')
|
|
|
}
|
|
|
saveAs(result.data, '问卷答卷_' + this.surveyData.id + '.zip')
|
|
|
} catch (e) { this.$message.error('导出失败,请检查登录状态或稍后重试') } finally { this.exporting = false }
|
|
|
},
|
|
|
close() {
|
|
|
this.$emit('update:visible', false)
|
|
|
},
|
|
|
changeTab() {
|
|
|
if (this.tab === 'responses') this.loadResponses()
|
|
|
},
|
|
|
async loadStats() {
|
|
|
const epoch = this.epoch
|
|
|
this.loading = true
|
|
|
try {
|
|
|
const result = await statistics({
|
|
|
course_content_evaluation_id: this.surveyData.id
|
|
|
})
|
|
|
if (epoch === this.epoch) this.summary = result
|
|
|
} finally {
|
|
|
if (epoch === this.epoch) this.loading = false
|
|
|
}
|
|
|
},
|
|
|
async loadResponses() {
|
|
|
const epoch = this.epoch
|
|
|
this.responsesLoading = true
|
|
|
try {
|
|
|
const res = await index({
|
|
|
filter: [
|
|
|
{
|
|
|
key: 'course_content_evaluation_id',
|
|
|
op: 'eq',
|
|
|
value: this.surveyData.id
|
|
|
}
|
|
|
],
|
|
|
page: this.page,
|
|
|
page_size: 20,
|
|
|
sort_name: 'id',
|
|
|
sort_type: 'DESC'
|
|
|
})
|
|
|
if (epoch === this.epoch) {
|
|
|
this.responses = res.data || []
|
|
|
this.total = Number(res.total || 0)
|
|
|
}
|
|
|
} finally {
|
|
|
if (epoch === this.epoch) this.responsesLoading = false
|
|
|
}
|
|
|
},
|
|
|
async openReply(row) {
|
|
|
const epoch = this.epoch
|
|
|
const reply = await show({ id: row.id })
|
|
|
if (epoch === this.epoch) {
|
|
|
this.reply = reply
|
|
|
this.replyVisible = true
|
|
|
}
|
|
|
},
|
|
|
openTexts(q, kind, optionKey = '') {
|
|
|
this.textQuestion = q
|
|
|
this.textKind = kind
|
|
|
this.textOptionKey = optionKey
|
|
|
this.textTotal = 0
|
|
|
this.textPage = 1
|
|
|
this.texts = []
|
|
|
this.textsVisible = true
|
|
|
this.loadTexts()
|
|
|
},
|
|
|
async loadTexts() {
|
|
|
const epoch = this.epoch
|
|
|
const questionId = this.textQuestion.id
|
|
|
const kind = this.textKind
|
|
|
const optionKey = this.textOptionKey
|
|
|
this.textsLoading = true
|
|
|
try {
|
|
|
const res = await textAnswers({
|
|
|
course_content_evaluation_id: this.surveyData.id,
|
|
|
question_id: questionId,
|
|
|
kind,
|
|
|
option_key: optionKey,
|
|
|
page: this.textPage,
|
|
|
page_size: 20
|
|
|
})
|
|
|
if (epoch === this.epoch && this.textQuestion && questionId === this.textQuestion.id && kind === this.textKind && optionKey === this.textOptionKey) {
|
|
|
this.texts = res.data || []
|
|
|
this.textTotal = Number(res.total || 0)
|
|
|
}
|
|
|
} finally {
|
|
|
this.textsLoading = false
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
</script>
|
|
|
<style scoped>
|
|
|
.results,
|
|
|
.reply {
|
|
|
max-width: 1150px;
|
|
|
margin: 0 auto;
|
|
|
}
|
|
|
.overview {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
gap: 40px;
|
|
|
padding: 20px;
|
|
|
margin-bottom: 20px;
|
|
|
background: #f2f6fc;
|
|
|
}
|
|
|
.overview b {
|
|
|
font-size: 24px;
|
|
|
color: #2563ad;
|
|
|
}
|
|
|
.stat-card {
|
|
|
border: 1px solid #e5e9ef;
|
|
|
border-radius: 8px;
|
|
|
padding: 24px;
|
|
|
margin: 20px 0;
|
|
|
}
|
|
|
.chapter {
|
|
|
background: #edf3ff;
|
|
|
border: 0;
|
|
|
border-left: 4px solid #409eff;
|
|
|
}
|
|
|
h3 {
|
|
|
margin: 0 0 14px;
|
|
|
}
|
|
|
.description,
|
|
|
.text-answer p {
|
|
|
white-space: pre-wrap;
|
|
|
line-height: 1.8;
|
|
|
overflow-wrap: anywhere;
|
|
|
}
|
|
|
.el-pagination {
|
|
|
margin: 24px 0;
|
|
|
}
|
|
|
.text-answer {
|
|
|
padding: 16px 0;
|
|
|
border-bottom: 1px solid #e5e9ef;
|
|
|
}
|
|
|
.text-answer small {
|
|
|
color: #808b99;
|
|
|
}
|
|
|
</style>
|