You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

435 lines
14 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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