640 lines
18 KiB
Vue
640 lines
18 KiB
Vue
<template>
|
||
<Dialog
|
||
style="display: none"
|
||
title="问卷预览"
|
||
v-model="dialogVisible"
|
||
width="900px"
|
||
:fullscreen="false"
|
||
>
|
||
<div ref="previewRef" class="questionnaire-preview" v-loading="loading">
|
||
<!-- 问卷头部信息 -->
|
||
<h1 class="preview-header">{{ recordInfo?.questionnaireName }}</h1>
|
||
|
||
<!-- 问卷说明 -->
|
||
<div v-if="recordInfo?.description" class="preview-description">
|
||
<div class="section-title">问卷说明</div>
|
||
<div class="description-content" v-html="recordInfo.description"></div>
|
||
</div>
|
||
|
||
<!-- 填写说明 -->
|
||
<div v-if="recordInfo?.instruction" class="preview-instruction">
|
||
<div class="section-title">填写说明</div>
|
||
<div class="instruction-content">{{ recordInfo.instruction }}</div>
|
||
</div>
|
||
|
||
<!-- 问题列表(按分区显示) -->
|
||
<div class="preview-questions">
|
||
<template v-for="(partition, index) in partitions" :key="partition.name || 'default'">
|
||
<!-- 分区标题 -->
|
||
<div v-if="partition.name" class="partition-title">
|
||
{{ ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] }}、
|
||
{{ partition.name }}
|
||
<span class="question-count">({{ partition.questions.length }} 道题)</span>
|
||
</div>
|
||
|
||
<!-- 问题列表 -->
|
||
<div class="question-items">
|
||
<div
|
||
v-for="(questionWithAnswer, index) in partition.questions"
|
||
:key="questionWithAnswer.question.id"
|
||
class="question-item"
|
||
>
|
||
<span class="question-index">{{ index + 1 }}.</span>
|
||
<span class="question-title">{{ questionWithAnswer.question.title }}:</span>
|
||
|
||
<!-- 帮助说明 -->
|
||
<span v-if="questionWithAnswer.question.helpText" class="question-help-inline">
|
||
{{ questionWithAnswer.question.helpText }}
|
||
</span>
|
||
|
||
<!-- 单选/多选题 -->
|
||
<span
|
||
v-if="
|
||
questionWithAnswer.question.type === 1 || questionWithAnswer.question.type === 2
|
||
"
|
||
class="question-options-inline"
|
||
>
|
||
<span
|
||
v-for="option in getQuestionOptions(questionWithAnswer.question)"
|
||
:key="option.label"
|
||
class="option-item"
|
||
>
|
||
<span
|
||
v-if="
|
||
questionWithAnswer.question.type === 1 &&
|
||
questionWithAnswer.answer?.answerText?.trim() === option.label
|
||
"
|
||
>
|
||
☑
|
||
{{ option.label }}
|
||
</span>
|
||
<span
|
||
v-if="
|
||
questionWithAnswer.question.type === 1 &&
|
||
questionWithAnswer.answer?.answerText?.trim() !== option.label
|
||
"
|
||
>
|
||
☐
|
||
{{ option.label }}
|
||
</span>
|
||
<span
|
||
v-if="
|
||
questionWithAnswer.question.type === 2 &&
|
||
getSelectedLabels(questionWithAnswer.answer).includes(option.label)
|
||
"
|
||
>
|
||
☑
|
||
{{ option.label }}
|
||
</span>
|
||
<span
|
||
v-if="
|
||
questionWithAnswer.question.type === 2 &&
|
||
!getSelectedLabels(questionWithAnswer.answer).includes(option.label)
|
||
"
|
||
>
|
||
☐
|
||
{{ option.label }}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
|
||
<!-- 填空题 -->
|
||
<span
|
||
v-else-if="questionWithAnswer.question.type === 3"
|
||
class="question-input-inline"
|
||
>
|
||
{{ getAnswerDisplayValue(questionWithAnswer.answer) }}
|
||
</span>
|
||
|
||
<!-- 评分题 -->
|
||
<span
|
||
v-else-if="questionWithAnswer.question.type === 4"
|
||
class="question-rating-inline"
|
||
>
|
||
<span class="question-input-inline">{{
|
||
getAnswerDisplayValue(questionWithAnswer.answer)
|
||
}}</span>
|
||
</span>
|
||
|
||
<!-- 日期题 -->
|
||
<span v-else-if="questionWithAnswer.question.type === 5" class="question-date-inline">
|
||
<span
|
||
class="date-info"
|
||
v-if="
|
||
getRangeValue(questionWithAnswer.question, 'min') ||
|
||
getRangeValue(questionWithAnswer.question, 'max')
|
||
"
|
||
>
|
||
日期范围:{{ getRangeValue(questionWithAnswer.question, 'min') || '无限制' }} ~
|
||
{{ getRangeValue(questionWithAnswer.question, 'max') || '无限制' }}
|
||
</span>
|
||
<span class="question-input-inline">{{
|
||
getAnswerDisplayValue(questionWithAnswer.answer)
|
||
}}</span>
|
||
</span>
|
||
|
||
<!-- 数字题 -->
|
||
<span
|
||
v-else-if="questionWithAnswer.question.type === 6"
|
||
class="question-number-inline"
|
||
>
|
||
<span class="question-input-inline">{{
|
||
getAnswerDisplayValue(questionWithAnswer.answer)
|
||
}}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<el-empty v-if="partitions.length === 0 && !loading" description="暂无问题" />
|
||
</div>
|
||
|
||
<template #footer>
|
||
<el-button type="primary" @click="exportToWord" :loading="loading">导出Word</el-button>
|
||
<el-button @click="dialogVisible = false">关 闭</el-button>
|
||
</template>
|
||
</Dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||
import { QuestionnaireRecordApi, type QuestionnaireRecord } from '@/api/prison/questionnairerecord'
|
||
import { AnswerApi, type Answer } from '@/api/prison/answer'
|
||
import { QuestionApi, Question } from '@/api/prison/question'
|
||
import { asBlob } from 'html-docx-js-typescript'
|
||
import { saveAs } from 'file-saver'
|
||
|
||
defineOptions({ name: 'QuestionnaireOutput' })
|
||
|
||
const message = useMessage()
|
||
const dialogVisible = ref(false)
|
||
const loading = ref(false)
|
||
const previewRef = ref<HTMLElement | null>(null)
|
||
|
||
const recordInfo = ref<QuestionnaireRecord | null>(null)
|
||
const answers = ref<Answer[]>([])
|
||
|
||
const questions = ref<Question[]>([])
|
||
const questionnaireInfo = ref<any>(null) // 问卷详细信息(包含description和instruction)
|
||
|
||
// 分区列表(带答案)
|
||
interface QuestionWithAnswer {
|
||
question: Question
|
||
answer?: Answer
|
||
index: number
|
||
}
|
||
|
||
const partitions = computed(() => {
|
||
const partMap = new Map<string, QuestionWithAnswer[]>()
|
||
|
||
questions.value.forEach((q, index) => {
|
||
const partName = q.partName || ''
|
||
const answer = answers.value.find((a) => a.questionId === q.id)
|
||
|
||
if (!partMap.has(partName)) {
|
||
partMap.set(partName, [])
|
||
}
|
||
partMap.get(partName)!.push({
|
||
question: q,
|
||
answer,
|
||
index: index + 1
|
||
})
|
||
})
|
||
|
||
// 按分区排序
|
||
const sortedParts = Array.from(partMap.entries()).sort((a, b) => {
|
||
const sortA = a[1][0]?.question.partSort ?? 0
|
||
const sortB = b[1][0]?.question.partSort ?? 0
|
||
return sortA - sortB
|
||
})
|
||
|
||
// 构建分区列表
|
||
const result: Array<{ name: string; questions: QuestionWithAnswer[] }> = []
|
||
|
||
// 添加默认分区
|
||
const defaultQuestions = sortedParts.find(([name]) => !name)
|
||
if (defaultQuestions) {
|
||
result.push({
|
||
name: '',
|
||
questions: defaultQuestions[1]
|
||
})
|
||
}
|
||
|
||
// 添加其他分区
|
||
sortedParts
|
||
.filter(([name]) => name)
|
||
.forEach(([name, qs]) => {
|
||
result.push({
|
||
name,
|
||
questions: qs
|
||
})
|
||
})
|
||
|
||
return result
|
||
})
|
||
|
||
/** 根据问题ID获取答案 */
|
||
const getAnswerByQuestionId = (questionId: number): Answer | undefined => {
|
||
return answers.value.find((a) => a.questionId === questionId)
|
||
}
|
||
|
||
/** 获取答案显示值(兼容不同字段名) */
|
||
const getAnswerDisplayValue = (answer?: Answer): string => {
|
||
if (!answer) return ''
|
||
// 优先使用 answerText,其次使用 optionIds,并去除前后空格和特殊字符
|
||
const value = answer.answerText || answer.optionIds || ''
|
||
return String(value).trim().replace(/^["']|["']$/g, '')
|
||
}
|
||
|
||
/** 问卷类型标签 */
|
||
const getTypeLabel = (type: number) => {
|
||
const options = getIntDictOptions(DICT_TYPE.PRISON_QUESTIONNAIRE_TYPE)
|
||
return options.find((o) => o.value === type)?.label || '未知'
|
||
}
|
||
|
||
/** 获取问题选项 */
|
||
const getQuestionOptions = (question: Question) => {
|
||
if (!question.options) return []
|
||
try {
|
||
const parsed = JSON.parse(question.options)
|
||
// 标准化选项格式,添加索引作为 value
|
||
if (Array.isArray(parsed)) {
|
||
return parsed.map((opt, index) => ({
|
||
label: opt.label ?? opt.text ?? opt.name ?? String(opt),
|
||
value: String(index), // 使用索引作为值
|
||
score: opt.score ?? 0
|
||
}))
|
||
}
|
||
return []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
/** 获取范围值(用于日期、数字、评分题) */
|
||
const getRangeValue = (question: Question, key: 'min' | 'max') => {
|
||
if (!question.options) return undefined
|
||
try {
|
||
const obj = JSON.parse(question.options)
|
||
return obj[key] || undefined
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
/** 安全获取多选答案的标签数组 */
|
||
const getSelectedLabels = (answer?: Answer): string[] => {
|
||
if (!answer?.answerText) return []
|
||
return answer.answerText.split(',').map((s) => s.trim())
|
||
}
|
||
|
||
/** 判断选项是否被选中(支持单选和多选)- 已废弃,改用模板直接绑定 */
|
||
const isOptionSelected = (
|
||
answer?: Answer,
|
||
optionValue?: string,
|
||
optionLabel?: string,
|
||
questionType?: number
|
||
): boolean => {
|
||
if (!answer || !optionValue || !optionLabel) {
|
||
return false
|
||
}
|
||
|
||
// 多选(type === 2):answerText 是逗号分隔的标签列表
|
||
if (questionType === 2 && answer.answerText && answer.answerText.includes(',')) {
|
||
const answerText = answer.answerText
|
||
const selectedLabels = answerText.split(',').map((s) => s.trim())
|
||
return selectedLabels.includes(optionLabel)
|
||
}
|
||
|
||
// 单选(type === 1):answerText 等于选项标签(去除首尾空格后比较)
|
||
if (questionType === 1 && answer.answerText) {
|
||
return answer.answerText.trim() === optionLabel.trim()
|
||
}
|
||
|
||
// 兼容:没有 questionType 时,根据是否有逗号判断
|
||
if (answer.answerText && answer.answerText.includes(',')) {
|
||
const answerText = answer.answerText
|
||
const selectedLabels = answerText.split(',').map((s) => s.trim())
|
||
return selectedLabels.includes(optionLabel)
|
||
}
|
||
|
||
if (answer.answerText?.trim() === optionLabel?.trim()) return true
|
||
|
||
return false
|
||
}
|
||
|
||
/** 打开弹窗 */
|
||
const open = async (recordId: number) => {
|
||
dialogVisible.value = true
|
||
loading.value = true
|
||
|
||
try {
|
||
// 并行加载数据
|
||
const [recordData, answerList] = await Promise.all([
|
||
QuestionnaireRecordApi.getQuestionnaireRecord(recordId),
|
||
AnswerApi.getAnswersByAssessmentRecordId(recordId)
|
||
])
|
||
|
||
recordInfo.value = recordData
|
||
answers.value = answerList
|
||
|
||
// 加载问题列表
|
||
if (recordData.questionnaireId) {
|
||
const questionsData = await QuestionApi.getQuestionPage({
|
||
pageNo: 1,
|
||
pageSize: 200,
|
||
questionnaireId: recordData.questionnaireId
|
||
})
|
||
questions.value = questionsData.list
|
||
}
|
||
|
||
// 加载问卷详细信息(用于导出Word时获取description和instruction)
|
||
if (recordData.questionnaireId) {
|
||
try {
|
||
const { QuestionnaireApi } = await import('@/api/prison/questionnaire')
|
||
questionnaireInfo.value = await QuestionnaireApi.getQuestionnaire(
|
||
recordData.questionnaireId
|
||
)
|
||
} catch (error) {
|
||
console.warn('加载问卷详细信息失败:', error)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('加载答题详情失败:', error)
|
||
} finally {
|
||
loading.value = false
|
||
exportToWord()
|
||
dialogVisible.value = false
|
||
}
|
||
}
|
||
|
||
/** 跳转到填写页面 */
|
||
const openFillPage = () => {
|
||
// TODO: 实现跳转到填写页面的逻辑
|
||
message.info('填写页面功能待实现')
|
||
}
|
||
|
||
/** 导出为Word文档 - 直接使用预览容器的HTML */
|
||
const exportToWord = async () => {
|
||
try {
|
||
loading.value = true
|
||
|
||
if (!previewRef.value) {
|
||
message.error('预览内容未加载完成')
|
||
return
|
||
}
|
||
|
||
// 获取预览容器的HTML内容
|
||
let previewHTML = previewRef.value.innerHTML
|
||
|
||
// 清理可能导致WPS显示引号的特殊字符
|
||
previewHTML = previewHTML.replace(/[\u201C\u201D\u2018\u2019]/g, '') // 移除中文引号
|
||
previewHTML = previewHTML.replace(/^["']|["']$/gm, '') // 移除行首尾的引号
|
||
|
||
// 构建完整的HTML文档,使用与预览页面一致的样式
|
||
const fullHTML = `
|
||
<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word'>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>${recordInfo.value?.questionnaireName || '问卷'}</title>
|
||
<style>
|
||
/* 使用 pt 单位以避免 Word/WPS 在 px->pt 换算时产生差异 */
|
||
body {
|
||
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif;
|
||
font-size: 11pt;
|
||
margin: 15pt;
|
||
padding: 15pt;
|
||
}
|
||
h1 {
|
||
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif;
|
||
font-size: 18pt;
|
||
font-weight: 500;
|
||
color: #000;
|
||
text-align: center;
|
||
}
|
||
.section-title {
|
||
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif;
|
||
margin-bottom: 15pt;
|
||
font-size: 18pt;
|
||
font-weight: 700;
|
||
color: #000;
|
||
}
|
||
.description-content {
|
||
color: #000;
|
||
font-size: 11pt;
|
||
margin-bottom: 6pt;
|
||
line-height: 1.5;
|
||
}
|
||
.instruction-content {
|
||
color: #000;
|
||
font-size: 11pt;
|
||
font-weight: 500;
|
||
margin-bottom: 6pt;
|
||
line-height: 1.5;
|
||
}
|
||
.partition-title {
|
||
margin-top: 15pt;
|
||
color: #000;
|
||
font-size: 15pt;
|
||
font-weight: 500;
|
||
}
|
||
.partition-title:first-child {
|
||
margin-top: 0;
|
||
}
|
||
.question-count {
|
||
font-size: 11pt;
|
||
color: #909399;
|
||
font-weight: normal;
|
||
margin-left: 2pt;
|
||
}
|
||
.question-item {
|
||
font-size: 11pt;
|
||
color: #000;
|
||
margin-bottom: 8pt;
|
||
line-height: 1.5;
|
||
page-break-inside: avoid;
|
||
}
|
||
.question-index {
|
||
margin-right: 2pt;
|
||
}
|
||
.question-title {
|
||
margin-right: 4pt;
|
||
}
|
||
.question-help-inline {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3pt;
|
||
color: #909399;
|
||
font-size: 11pt;
|
||
background: #f4f4f5;
|
||
border-radius: 2pt;
|
||
margin-right: 6pt;
|
||
}
|
||
.question-options-inline {
|
||
display: inline-flex;
|
||
flex-wrap: wrap;
|
||
gap: 8pt;
|
||
align-items: center;
|
||
}
|
||
.option-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3pt;
|
||
}
|
||
.question-rating-inline,
|
||
.question-date-inline,
|
||
.question-number-inline {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8pt;
|
||
flex-wrap: wrap;
|
||
margin-left: 3pt;
|
||
}
|
||
.rating-info,
|
||
.date-info,
|
||
.number-info {
|
||
display: inline-flex;
|
||
gap: 11pt;
|
||
color: #909399;
|
||
font-size: 11pt;
|
||
}
|
||
.question-input-inline {
|
||
color: #000;
|
||
font-size: 11pt;
|
||
line-height: 1.5;
|
||
text-decoration: underline;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
${previewHTML}
|
||
</body>
|
||
</html>
|
||
`
|
||
|
||
// 转换为Blob (asBlob返回Promise)
|
||
const converted = await asBlob(fullHTML)
|
||
|
||
// 下载文件
|
||
const fileName = `${recordInfo.value?.questionnaireName || '问卷'}_${new Date().toLocaleDateString('zh-CN')}.docx`
|
||
saveAs(converted as Blob, fileName)
|
||
|
||
message.success('Word文档导出成功')
|
||
} catch (error) {
|
||
console.error('导出Word失败:', error)
|
||
message.error('导出Word失败,请重试')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
defineExpose({ open, exportToWord })
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.questionnaire-preview {
|
||
padding: 20px;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
h1 {
|
||
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif;
|
||
font-size: 18pt;
|
||
font-weight: 500;
|
||
color: #000;
|
||
text-align: center;
|
||
}
|
||
.section-title {
|
||
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif;
|
||
margin-bottom: 15pt;
|
||
font-size: 18pt;
|
||
font-weight: 700;
|
||
color: #000;
|
||
}
|
||
.description-content {
|
||
color: #000;
|
||
line-height: 3;
|
||
font-size: 11pt;
|
||
}
|
||
.instruction-content {
|
||
color: #000;
|
||
font-size: 11pt;
|
||
font-weight: 500;
|
||
line-height: 3;
|
||
}
|
||
.partition-title {
|
||
margin-top: 15pt;
|
||
color: #000;
|
||
font-size: 15pt;
|
||
font-weight: 500;
|
||
}
|
||
.partition-title:first-child {
|
||
margin-top: 0;
|
||
}
|
||
.question-count {
|
||
font-size: 11pt;
|
||
color: #909399;
|
||
font-weight: normal;
|
||
margin-left: 2pt;
|
||
}
|
||
.question-item {
|
||
font-size: 11pt;
|
||
color: #000;
|
||
line-height: 3;
|
||
page-break-inside: avoid;
|
||
}
|
||
.question-index {
|
||
margin-right: 2pt;
|
||
}
|
||
.question-title {
|
||
margin-right: 4pt;
|
||
}
|
||
.question-help-inline {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3pt;
|
||
color: #909399;
|
||
font-size: 11pt;
|
||
background: #f4f4f5;
|
||
border-radius: 2pt;
|
||
margin-right: 6pt;
|
||
}
|
||
.question-options-inline {
|
||
display: inline-flex;
|
||
flex-wrap: wrap;
|
||
gap: 8pt;
|
||
align-items: center;
|
||
}
|
||
.option-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3pt;
|
||
}
|
||
.question-rating-inline,
|
||
.question-date-inline,
|
||
.question-number-inline {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8pt;
|
||
flex-wrap: wrap;
|
||
margin-left: 3pt;
|
||
}
|
||
.rating-info,
|
||
.date-info,
|
||
.number-info {
|
||
display: inline-flex;
|
||
gap: 11pt;
|
||
color: #909399;
|
||
font-size: 11pt;
|
||
}
|
||
.question-input-inline {
|
||
color: #000;
|
||
font-size: 11pt;
|
||
line-height: 3;
|
||
text-decoration: underline;
|
||
}
|
||
</style>
|