xlcp-frontend/src/views/prison/questionnairerecord/QuestionnaireOutputfile.vue
2026-02-04 18:49:28 +08:00

640 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 === 2answerText 是逗号分隔的标签列表
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 === 1answerText 等于选项标签(去除首尾空格后比较)
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>