Compare commits

..

No commits in common. "3198ad61318fcde981a02e82781c459307a57e9f" and "fc595c2c2634197893bfd56812062bc4d3890f1d" have entirely different histories.

11 changed files with 697 additions and 651 deletions

1
.gitignore vendored
View File

@ -7,4 +7,3 @@ pnpm-debug
auto-*.d.ts auto-*.d.ts
.idea .idea
.history .history
.omc/

View File

@ -34,8 +34,6 @@ const include = [
'markmap-toolbar', 'markmap-toolbar',
'highlight.js', 'highlight.js',
'element-plus', 'element-plus',
'html-docx-js-typescript',
'file-saver',
'element-plus/es', 'element-plus/es',
'element-plus/es/locale/lang/zh-cn', 'element-plus/es/locale/lang/zh-cn',
'element-plus/es/locale/lang/en', 'element-plus/es/locale/lang/en',

View File

@ -50,8 +50,10 @@
"echarts-wordcloud": "^2.1.0", "echarts-wordcloud": "^2.1.0",
"element-plus": "2.11.1", "element-plus": "2.11.1",
"fast-xml-parser": "^4.3.2", "fast-xml-parser": "^4.3.2",
"file-saver": "^2.0.5",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"html-docx-js": "^0.3.1", "html-docx-js": "^0.3.1",
"html-docx-js-typescript": "^0.1.5",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"jsoneditor": "^10.1.3", "jsoneditor": "^10.1.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
@ -113,8 +115,6 @@
"eslint-plugin-import": "^2.29.1", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.22.0", "eslint-plugin-vue": "^9.22.0",
"file-saver": "^2.0.5",
"html-docx-js-typescript": "^0.1.5",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"postcss-html": "^1.6.0", "postcss-html": "^1.6.0",

12
pnpm-lock.yaml generated
View File

@ -83,12 +83,18 @@ importers:
fast-xml-parser: fast-xml-parser:
specifier: ^4.3.2 specifier: ^4.3.2
version: 4.5.0 version: 4.5.0
file-saver:
specifier: ^2.0.5
version: 2.0.5
highlight.js: highlight.js:
specifier: ^11.9.0 specifier: ^11.9.0
version: 11.10.0 version: 11.10.0
html-docx-js: html-docx-js:
specifier: ^0.3.1 specifier: ^0.3.1
version: 0.3.1 version: 0.3.1
html-docx-js-typescript:
specifier: ^0.1.5
version: 0.1.5
jsencrypt: jsencrypt:
specifier: ^3.3.2 specifier: ^3.3.2
version: 3.3.2 version: 3.3.2
@ -267,12 +273,6 @@ importers:
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^9.22.0 specifier: ^9.22.0
version: 9.31.0(eslint@8.57.1) version: 9.31.0(eslint@8.57.1)
file-saver:
specifier: ^2.0.5
version: 2.0.5
html-docx-js-typescript:
specifier: ^0.1.5
version: 0.1.5
lint-staged: lint-staged:
specifier: ^15.2.2 specifier: ^15.2.2
version: 15.2.10 version: 15.2.10

View File

@ -108,8 +108,6 @@ export interface TaskAreaStatistics {
/** 人员填写进度 */ /** 人员填写进度 */
export interface PrisonerProgress { export interface PrisonerProgress {
questionnaireId: number
questionnaireName?: string
id: number id: number
prisonerId: number prisonerId: number
prisonerNo: string prisonerNo: string

View File

@ -1,41 +1,20 @@
<template> <template>
<Dialog style="display: none" :title="'评估报告'" v-model="dialogVisible" width="900px"> <Dialog style="display: none;" :title="'评估报告'" v-model="dialogVisible" width="900px">
<div v-loading="loading" class="report-edit-container" ref="previewRef"> <div v-loading="loading" class="report-edit-container" ref="previewRef">
<template v-if="selectedReport"> <template v-if="selectedReport">
<div class="basic-info-title">{{ selectedReport.templateName }}</div> <div class="basic-info-title">{{ selectedReport.templateName }}</div>
<!-- 基本信息区 --> <!-- 基本信息区 -->
<div class="basic-info-section"> <div class="basic-info-section">
<div class="basic-info-item" <div class="basic-info-item">服刑人员{{ selectedReport.prisonerName }} ({{ selectedReport.prisonerNo }})</div>
>服刑人员{{ selectedReport.prisonerName }} ({{ selectedReport.prisonerNo }})</div
>
<div class="basic-info-item">监区{{ selectedReport.areaName || '-' }}</div> <div class="basic-info-item">监区{{ selectedReport.areaName || '-' }}</div>
<div class="basic-info-item" <div class="basic-info-item">评估日期{{ formatDateTime(selectedReport.evaluationDate, 'YYYY-MM-DD') }}</div>
>评估日期{{ formatDateTime(selectedReport.evaluationDate, 'YYYY-MM-DD') }}</div <div class="basic-info-item">风险等级{{ getDictLabel(DICT_TYPE.PRISON_RISK_LEVEL, selectedReport.riskLevel) }}</div>
> <div class="basic-info-item">状态{{ getDictLabel(DICT_TYPE.PRISON_REPORT_STATUS, selectedReport.status) }}</div>
<div class="basic-info-item"
>风险等级{{
getDictLabel(DICT_TYPE.PRISON_RISK_LEVEL, selectedReport.riskLevel)
}}</div
>
<div class="basic-info-item"
>状态{{ getDictLabel(DICT_TYPE.PRISON_REPORT_STATUS, selectedReport.status) }}</div
>
</div> </div>
<div <div v-for="item in dimensionAnalysisPanelRef" :key="item.id" class="dimension-item">
v-for="(item, index) in dimensionAnalysisPanelRef" <div class="dimension-item-title">{{ item.name }}</div>
:key="item.id" <div style="white-space: pre-line; line-height: 1.5;">{{ item.aiAnalysis?.replace(/## 综合分析建议\n\n/g, '') }}</div>
class="dimension-item"
>
<div class="dimension-item-title"
>{{ ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] }}{{
item.name
}}</div
>
<div style="white-space: pre-line; line-height: 1.5">{{
// '##' CRLF/LF
item.aiAnalysis?.replace(/(^|\r?\n)##.*?\r?\n\r?\n/gm, '$1')
}}</div>
</div> </div>
</template> </template>
@ -53,14 +32,7 @@
import { DICT_TYPE, getDictLabel } from '@/utils/dict' import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { formatDateTime } from '@/utils/formatTime' import { formatDateTime } from '@/utils/formatTime'
import download from '@/utils/download' import download from '@/utils/download'
import { import { ReportApi, ReportVO, DimensionDataApi, DimensionDataVO, DimensionApi, DimensionVO } from '@/api/prison/evaluation-report'
ReportApi,
ReportVO,
DimensionDataApi,
DimensionDataVO,
DimensionApi,
DimensionVO
} from '@/api/prison/evaluation-report'
import { PrisonerApi } from '@/api/prison/prisoner' import { PrisonerApi } from '@/api/prison/prisoner'
import { asBlob } from 'html-docx-js-typescript' import { asBlob } from 'html-docx-js-typescript'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
@ -92,10 +64,8 @@ const open = async (id: number, prisonerId?: number) => {
reportId.value = id reportId.value = id
dialogVisible.value = true dialogVisible.value = true
await loadReportDetail(id) await loadReportDetail(id)
// DOM
await nextTick()
try { try {
await exportToWord() exportToWord()
} catch { } catch {
} finally { } finally {
handleClose() handleClose()
@ -108,8 +78,7 @@ const loadReportDetail = async (id: number) => {
try { try {
selectedReport.value = await ReportApi.getReport(id) selectedReport.value = await ReportApi.getReport(id)
dimensionDataList.value = await DimensionDataApi.getDimensionDataListByReportId(id) dimensionDataList.value = await DimensionDataApi.getDimensionDataListByReportId(id)
drawerTitle.value = drawerTitle.value = selectedReport.value?.title || `${selectedReport.value?.prisonerName} - 评估报告`
selectedReport.value?.title || `${selectedReport.value?.prisonerName} - 评估报告`
// //
if (selectedReport.value?.prisonerId && !selectedReport.value.areaName) { if (selectedReport.value?.prisonerId && !selectedReport.value.areaName) {
@ -123,16 +92,15 @@ const loadReportDetail = async (id: number) => {
// //
if (selectedReport.value?.templateId) { if (selectedReport.value?.templateId) {
try { try {
const dimensionList = await DimensionApi.getDimensionsByTemplateId( const dimensionList = await DimensionApi.getDimensionsByTemplateId(selectedReport.value.templateId)
selectedReport.value.templateId
)
if (dimensionList && dimensionList.length > 0) { if (dimensionList && dimensionList.length > 0) {
console.log(dimensionList) console.log(dimensionList);
dimensions.value = dimensionList dimensions.value = dimensionList
} else { } else {
// 使 // 使
dimensions.value = getDefaultDimensions(selectedReport.value.templateId) dimensions.value = getDefaultDimensions(selectedReport.value.templateId)
} }
} catch { } catch {
dimensions.value = getDefaultDimensions(selectedReport.value.templateId) dimensions.value = getDefaultDimensions(selectedReport.value.templateId)
} }
@ -140,11 +108,11 @@ const loadReportDetail = async (id: number) => {
if (selectedReport.value?.id && dimensions.value.length > 0) { if (selectedReport.value?.id && dimensions.value.length > 0) {
const list = await DimensionDataApi.getDimensionDataListByReportId(selectedReport.value.id) const list = await DimensionDataApi.getDimensionDataListByReportId(selectedReport.value.id)
console.log(list, dimensions.value) console.log(list, dimensions.value);
dimensionAnalysisPanelRef.value = dimensions.value.map((item) => { dimensionAnalysisPanelRef.value = dimensions.value.map(item => {
return { return {
...item, ...item,
aiAnalysis: list.find((analys) => analys.dimensionId === item.id)?.aiAnalysis aiAnalysis: list.find(analys => analys.dimensionId === item.id)?.aiAnalysis
} }
}) })
} }
@ -159,51 +127,11 @@ const loadReportDetail = async (id: number) => {
/** 获取默认维度配置 */ /** 获取默认维度配置 */
const getDefaultDimensions = (templateId: number): DimensionVO[] => { const getDefaultDimensions = (templateId: number): DimensionVO[] => {
return [ return [
{ { id: 1, templateId, name: '基本信息', dimensionType: 1, aiEnabled: 0, status: 0, dataSources: ['prisoner'] },
id: 1, { id: 2, templateId, name: '犯罪情况分析', dimensionType: 1, aiEnabled: 1, status: 0, dataSources: ['prisoner', 'risk'] },
templateId, { id: 3, templateId, name: '服刑表现评估', dimensionType: 1, aiEnabled: 1, status: 0, dataSources: ['score', 'violation', 'reward'] },
name: '基本信息', { id: 4, templateId, name: '消费行为分析', dimensionType: 1, aiEnabled: 1, status: 0, dataSources: ['consumption'] },
dimensionType: 1, { id: 5, templateId, name: '综合评估结论', dimensionType: 1, aiEnabled: 1, status: 0, dataSources: ['prisoner', 'psychology'] }
aiEnabled: 0,
status: 0,
dataSources: ['prisoner']
},
{
id: 2,
templateId,
name: '犯罪情况分析',
dimensionType: 1,
aiEnabled: 1,
status: 0,
dataSources: ['prisoner', 'risk']
},
{
id: 3,
templateId,
name: '服刑表现评估',
dimensionType: 1,
aiEnabled: 1,
status: 0,
dataSources: ['score', 'violation', 'reward']
},
{
id: 4,
templateId,
name: '消费行为分析',
dimensionType: 1,
aiEnabled: 1,
status: 0,
dataSources: ['consumption']
},
{
id: 5,
templateId,
name: '综合评估结论',
dimensionType: 1,
aiEnabled: 1,
status: 0,
dataSources: ['prisoner', 'psychology']
}
] ]
} }
@ -261,36 +189,40 @@ const exportToWord = async () => {
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>
/* 使用 pt 单位以避免 Word/WPS 在 px->pt 换算时产生差异 */
body { body {
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif; font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif;
font-size: 11pt; font-size: 14px;
line-height: 1.5; line-height: 1.6;
margin: 20px;
padding: 20px;
} }
.basic-info-title{ .basic-info-title{
font-size: 18pt; font-family: '黑体', 'Microsoft YaHei', SimSun, Arial, sans-serif;
font-size: 21px;
font-weight: 700; font-weight: 700;
color: black; color: black;
text-align: center; text-align: center;
margin-bottom: 15pt; margin-bottom: 20px;
} }
.basic-info-section { .basic-info-section {
padding: 15px 20px;
color: black; color: black;
font-size: 11pt; font-size: 12px;
} }
.basic-info-item{ .basic-info-item{
margin-right: 25pt; margin-right: 30px;
} }
.dimension-item { .dimension-item {
font-size: 11pt; padding: 0 40px;
font-size: 12px;
color: black; color: black;
} }
.dimension-item-title { .dimension-item-title {
font-size: 15pt; font-size: 21px;
padding: 15px 0;
font-weight: 500; font-weight: 500;
color: black; color: black;
margin-top: 15pt;
} }
</style> </style>
</head> </head>

View File

@ -32,21 +32,13 @@
<div class="options-container"> <div class="options-container">
<el-radio-group v-model="answers[question.id]" :disabled="disabled"> <el-radio-group v-model="answers[question.id]" :disabled="disabled">
<el-radio <el-radio
v-for="(opt, optIdx) in parseOptions(question.options)" v-for="(opt, index) in parseOptions(question.options)"
:key="optIdx" :key="index"
:value="optIdx" :value="index"
> >
{{ opt.label }} {{ opt.label }}
</el-radio> </el-radio>
</el-radio-group> </el-radio-group>
<!-- 其他选项输入框 -->
<div v-if="getOtherOptionIndex(question) !== undefined && answers[question.id] === getOtherOptionIndex(question)" class="other-input">
<el-input
v-model="otherAnswers[question.id]"
placeholder="请输入其他选项内容"
:disabled="disabled"
/>
</div>
</div> </div>
</div> </div>
@ -55,25 +47,14 @@
<div class="options-container"> <div class="options-container">
<el-checkbox-group v-model="multiAnswers[question.id]"> <el-checkbox-group v-model="multiAnswers[question.id]">
<el-checkbox <el-checkbox
v-for="(opt, optIdx) in parseOptions(question.options)" v-for="(opt, index) in parseOptions(question.options)"
:key="optIdx" :key="index"
:value="optIdx" :value="index"
:disabled="disabled" :disabled="disabled"
> >
{{ opt.label }} {{ opt.label }}
</el-checkbox> </el-checkbox>
</el-checkbox-group> </el-checkbox-group>
<!-- 其他选项输入框 -->
<div
v-if="isOtherOptionSelected(question, multiAnswers[question.id])"
class="other-input"
>
<el-input
v-model="otherAnswers[question.id]"
placeholder="请输入其他选项内容"
:disabled="disabled"
/>
</div>
</div> </div>
</div> </div>
@ -126,6 +107,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { QuestionnaireApi } from '@/api/prison/questionnaire'
import { QuestionApi } from '@/api/prison/question' import { QuestionApi } from '@/api/prison/question'
import { QuestionnaireRecordApi } from '@/api/prison/questionnairerecord' import { QuestionnaireRecordApi } from '@/api/prison/questionnairerecord'
@ -146,7 +128,6 @@ const questions = ref<any[]>([])
// //
const answers = reactive<Record<number, any>>({}) const answers = reactive<Record<number, any>>({})
const multiAnswers = reactive<Record<number, number[]>>({}) const multiAnswers = reactive<Record<number, number[]>>({})
const otherAnswers = reactive<Record<number, string>>({}) //
// //
const disabled = computed(() => submitLoading.value) const disabled = computed(() => submitLoading.value)
@ -161,51 +142,24 @@ const parseOptions = (optionsStr: string) => {
} }
} }
/** 获取其他选项的索引 */
const getOtherOptionIndex = (question: any): number | undefined => {
const options = parseOptions(question.options)
return options.findIndex((opt: any) => opt.isOther === true)
}
/** 检查多选题的其他选项是否被选中 */
const isOtherOptionSelected = (question: any, selectedIndices: number[] | undefined): boolean => {
const otherIdx = getOtherOptionIndex(question)
if (otherIdx === undefined || otherIdx === -1) return false
return selectedIndices?.includes(otherIdx) ?? false
}
/** 打开弹窗 */ /** 打开弹窗 */
const open = async (record: any) => { const open = async (record: any) => {
console.log('=== 代填弹窗打开 ===')
console.log('传入的record:', record)
console.log('record.questionnaireId:', record.questionnaireId)
dialogVisible.value = true dialogVisible.value = true
recordId.value = record.id recordId.value = record.id
prisonerInfo.value = record prisonerInfo.value = record
questionnaireId.value = record.questionnaireId questionnaireId.value = record.questionnaireId
console.log('设置的questionnaireId.value:', questionnaireId.value)
// //
Object.keys(answers).forEach(key => delete answers[key]) Object.keys(answers).forEach(key => delete answers[key])
Object.keys(multiAnswers).forEach(key => delete multiAnswers[key]) Object.keys(multiAnswers).forEach(key => delete multiAnswers[key])
Object.keys(otherAnswers).forEach(key => delete otherAnswers[key])
loading.value = true loading.value = true
try { try {
//
const latestRecord = await QuestionnaireRecordApi.getQuestionnaireRecord(record.id)
console.log('获取的最新记录状态:', latestRecord.status)
prisonerInfo.value = { ...record, ...latestRecord }
// //
const countData = await QuestionApi.getQuestionnaireQuestionList( const countData = await QuestionApi.getQuestionnaireQuestionList(
{ questionnaireId: questionnaireId.value, pageNo: 1, pageSize: 1 } { questionnaireId: questionnaireId.value, pageNo: 1, pageSize: 1 }
) )
console.log('获取的countData:', countData)
const totalCount = countData.total || 0 const totalCount = countData.total || 0
console.log('题目总数:', totalCount)
// 200 // 200
const maxPageSize = 200 const maxPageSize = 200
@ -214,8 +168,6 @@ const open = async (record: any) => {
const questionData = await QuestionApi.getQuestionnaireQuestionList( const questionData = await QuestionApi.getQuestionnaireQuestionList(
{ questionnaireId: questionnaireId.value, pageNo: 1, pageSize: pageSize } { questionnaireId: questionnaireId.value, pageNo: 1, pageSize: pageSize }
) )
console.log('获取的questionData:', questionData)
console.log('实际获取到的题目数量:', questionData.list?.length || 0)
questions.value = questionData.list || [] questions.value = questionData.list || []
// //
@ -256,68 +208,11 @@ const handleSubmit = async () => {
questionId: q.id questionId: q.id
} }
const options = parseOptions(q.options)
const otherIndex = getOtherOptionIndex(q)
const hasOtherSelected = otherIndex !== undefined && (
(q.type === 1 && answers[q.id] === otherIndex) ||
(q.type === 2 && multiAnswers[q.id]?.includes(otherIndex))
)
if (q.type === 2) { if (q.type === 2) {
// 使 optionIds //
answerItem.optionIds = multiAnswers[q.id] || [] answerItem.optionIds = multiAnswers[q.id] || []
// answer
const selectedIndices = multiAnswers[q.id] || []
const selectedLabels = selectedIndices.map((idx: number) => {
const opt = options[idx]
return opt ? (opt.value !== undefined ? opt.value : opt.label) : String(idx)
})
if (selectedLabels.length > 0) {
answerItem.answer = selectedLabels.join('、')
}
} else if (q.type === 1) {
// ""
const selectedIndex = answers[q.id]
const otherIndex = getOtherOptionIndex(q)
const isOtherSelected = otherIndex !== undefined && selectedIndex === otherIndex
if (isOtherSelected) {
// "" optionIds
answerItem.optionIds = [selectedIndex]
}
// optionIds
}
// optionIds
// answerText
if (hasOtherSelected && otherAnswers[q.id]) {
const otherOptValue = options[otherIndex] ? (options[otherIndex].value !== undefined ? options[otherIndex].value : options[otherIndex].label) : '其他'
const otherText = `${otherOptValue}${otherAnswers[q.id]}`
if (q.type === 2) {
//
answerItem.answer = answerItem.answer
? `${answerItem.answer}${otherText}`
: otherText
} else { } else {
//
answerItem.answer = otherText
}
} else if (q.type === 2 && answerItem.answer) {
// """"
if (hasOtherSelected) {
const otherOptValue = options[otherIndex] ? (options[otherIndex].value !== undefined ? options[otherIndex].value : options[otherIndex].label) : '其他'
answerItem.answer = `${answerItem.answer}${otherOptValue}`
}
} else if (q.type === 3 || q.type === 4 || q.type === 5 || q.type === 6) {
// 使 answerText
answerItem.answer = answers[q.id] !== undefined ? String(answers[q.id]) : '' answerItem.answer = answers[q.id] !== undefined ? String(answers[q.id]) : ''
} else if (q.type === 1 && !hasOtherSelected) {
// answerText
const selectedIndex = answers[q.id]
if (selectedIndex !== undefined && selectedIndex !== null) {
const opt = options[selectedIndex]
answerItem.answer = opt ? (opt.value !== undefined ? opt.value : opt.label) : String(selectedIndex)
}
} }
return answerItem return answerItem
@ -432,11 +327,5 @@ defineExpose({ open })
line-height: 1.6; line-height: 1.6;
word-break: break-word; word-break: break-word;
} }
.other-input {
margin-top: 12px;
margin-left: 24px;
width: 300px;
}
} }
</style> </style>

View File

@ -493,9 +493,6 @@ const open = async (id: number) => {
activeTab.value = 'overview' activeTab.value = 'overview'
loading.value = true loading.value = true
//
prisonerProgressList.value = []
try { try {
// //
const [progressData, areaData] = await Promise.all([ const [progressData, areaData] = await Promise.all([

View File

@ -1,118 +1,210 @@
<template> <template>
<el-dialog <Dialog v-model="dialogVisible" :title="title" width="1000px" :fullscreen="false">
v-model="dialogVisible"
:title="`答题详情 - ${recordInfo.prisonerName || ''}`"
width="800px"
:close-on-click-modal="false"
destroy-on-close
>
<div v-loading="loading" class="answer-detail-dialog"> <div v-loading="loading" class="answer-detail-dialog">
<!-- 记录基本信息 --> <!-- 记录基本信息 -->
<el-descriptions :column="3" border class="mb-20px"> <div class="record-header">
<el-descriptions-item label="问卷名称">{{ recordInfo.questionnaireName }}</el-descriptions-item> <div class="header-info">
<el-descriptions-item label="罪犯编号">{{ recordInfo.prisonerNo }}</el-descriptions-item> <h3 class="title">{{ recordInfo.questionnaireName }}</h3>
<el-descriptions-item label="完成时间">{{ formatDateTime(recordInfo.createTime) }}</el-descriptions-item> <div class="meta-info">
<el-descriptions-item label="客观分">{{ recordInfo.objectiveScore || 0 }}</el-descriptions-item> <span class="info-item">
<el-descriptions-item label="主观分">{{ recordInfo.subjectiveScore || 0 }}</el-descriptions-item> <Icon icon="ep:user" />
<el-descriptions-item label="总分">{{ recordInfo.totalScore || 0 }}</el-descriptions-item> {{ recordInfo.prisonerName || recordInfo.prisonerNo }}
<el-descriptions-item label="答题用时">{{ formatDuration(recordInfo.duration) }}</el-descriptions-item> </span>
<el-descriptions-item label="及格状态"> <span class="info-item">
<el-tag :type="getPassStatusTag(recordInfo.passStatus)" size="small"> <Icon icon="ep:clock" />
{{ getPassStatusText(recordInfo.passStatus) }} {{ formatDateTime(recordInfo.createTime) }}
</el-tag> </span>
</el-descriptions-item> <span class="info-item" v-if="recordInfo.duration">
</el-descriptions> <Icon icon="ep:timer" />
用时{{ formatDuration(recordInfo.duration) }}
</span>
</div>
</div>
<div class="score-info">
<div class="score-item">
<span class="label">客观分</span>
<span class="value objective">{{ recordInfo.objectiveScore || 0 }}</span>
</div>
<div class="score-item">
<span class="label">主观分</span>
<span class="value subjective">{{ recordInfo.subjectiveScore || 0 }}</span>
</div>
<div class="score-item total">
<span class="label">总分</span>
<span class="value">{{ recordInfo.totalScore || 0 }}</span>
</div>
<div class="score-item" :class="getPassStatusClass(recordInfo.passStatus)">
<span class="label">状态</span>
<span class="value">{{ getPassStatusText(recordInfo.passStatus) }}</span>
</div>
<el-button type="primary" link @click="handlePreviewQuestionnaire">
<Icon icon="ep:view" class="mr-3px" />查看问卷
</el-button>
</div>
</div>
<!-- 问卷题目 --> <!-- 问题列表按分区显示 -->
<div v-if="questions.length > 0" class="questionnaire-content"> <div class="questions-container">
<template v-for="partition in partitions" :key="partition.name || 'default'">
<!-- 分区标题 -->
<div v-if="partition.name" class="partition-title">
<Icon icon="ep:folder" />
{{ partition.name }}
<span class="question-count">({{ partition.questions.length }} 道题)</span>
</div>
<!-- 问题列表 -->
<div class="questions-list">
<div <div
v-for="(item, index) in questions" v-for="item in partition.questions"
:key="item.id" :key="item.question.id"
class="question-item" class="question-item"
> >
<!-- 问题标题 -->
<div class="question-header"> <div class="question-header">
<span class="question-index">{{ index + 1 }}</span> <span class="question-index">{{ item.index }}.</span>
<span class="question-title">{{ item.title }}</span> <span class="question-title">
<el-tag v-if="item.isRequired" type="danger" size="small" class="required-tag">必填</el-tag> {{ item.question.title }}
<el-tag v-if="item.score" type="info" size="small">{{ item.score }}</el-tag> <el-tag v-if="item.question.isRequired" type="danger" size="small" class="required-tag">
必填
</el-tag>
<el-tag v-if="item.question.score" type="info" size="small">
{{ item.question.score }}
</el-tag>
<el-tag
v-if="item.answer"
:type="item.answer.isCorrect ? 'success' : 'danger'"
size="small"
>
{{ item.answer.isCorrect ? '正确' : '错误' }}
</el-tag>
</span>
</div> </div>
<!-- 答案展示 --> <!-- 用户答案 -->
<div class="answer-area"> <div class="answer-section">
<!-- 单选题 --> <div class="answer-label">
<div v-if="item.type === 1" class="options-container"> <Icon icon="ep:edit" />
<span v-if="getAnswerText(item.id)" class="answer-text"> 用户答案
{{ getAnswerText(item.id) }}
</span>
<span v-else class="empty-answer">未作答</span>
</div> </div>
<div class="answer-content">
<!-- 多选题 --> <!-- 单选/多选题 -->
<div v-else-if="item.type === 2" class="options-container"> <template v-if="item.question.type === 1 || item.question.type === 2">
<span v-if="getMultiAnswerText(item.id)" class="answer-text"> <div v-if="item.answer?.answerText" class="selected-options">
{{ getMultiAnswerText(item.id) }} <el-tag
</span> v-for="(opt, idx) in parseAnswerOptions(item.answer.answerText)"
<span v-else class="empty-answer">未作答</span> :key="idx"
type="primary"
size="small"
class="option-tag"
>
{{ opt }}
</el-tag>
</div> </div>
<span v-else class="empty-answer">未作答</span>
</template>
<!-- 填空题 --> <!-- 填空题 -->
<div v-else-if="item.type === 3" class="options-container"> <template v-else-if="item.question.type === 3">
<span v-if="getAnswerText(item.id)" class="answer-text"> <span v-if="item.answer?.answerText" class="text-answer">{{ item.answer.answerText }}</span>
{{ getAnswerText(item.id) }}
</span>
<span v-else class="empty-answer">未作答</span> <span v-else class="empty-answer">未作答</span>
</div> </template>
<!-- 评分题 --> <!-- 评分题 -->
<div v-else-if="item.type === 4" class="options-container"> <template v-else-if="item.question.type === 4">
<span v-if="getAnswerText(item.id)" class="answer-text"> <span v-if="item.answer?.answerText" class="score-answer">
{{ getAnswerText(item.id) }} <el-tag type="warning">{{ item.answer.answerText }} </el-tag>
</span> </span>
<span v-else class="empty-answer">未作答</span> <span v-else class="empty-answer">未作答</span>
</div> </template>
<!-- 日期题 --> <!-- 日期题 -->
<div v-else-if="item.type === 5" class="options-container"> <template v-else-if="item.question.type === 5">
<span v-if="getAnswerText(item.id)" class="answer-text"> <span v-if="item.answer?.answerText" class="date-answer">{{ item.answer.answerText }}</span>
{{ getAnswerText(item.id) }}
</span>
<span v-else class="empty-answer">未作答</span> <span v-else class="empty-answer">未作答</span>
</div> </template>
<!-- 数字题 --> <!-- 数字题 -->
<div v-else-if="item.type === 6" class="options-container"> <template v-else-if="item.question.type === 6">
<span v-if="getAnswerText(item.id)" class="answer-text"> <span v-if="item.answer?.answerText" class="number-answer">{{ item.answer.answerText }}</span>
{{ getAnswerText(item.id) }}
</span>
<span v-else class="empty-answer">未作答</span> <span v-else class="empty-answer">未作答</span>
</div> </template>
</div>
</div> </div>
</div> </div>
<el-empty v-else description="暂无答题记录" /> <!-- 得分信息 -->
<div v-if="item.answer?.score !== undefined" class="score-section">
<span class="score-label">
<Icon icon="ep:coin" />
得分
</span>
<span class="score-value" :class="{ 'full-score': item.answer.score === item.question.score }">
{{ item.answer.score }} / {{ item.question.score || 0 }}
</span>
</div> </div>
<!-- 正确答案客观题 -->
<div v-if="showCorrectAnswer(item.question, item.answer)" class="correct-answer-section">
<span class="correct-label">
<Icon icon="ep:check" />
正确答案
</span>
<span class="correct-content">{{ getCorrectAnswer(item.question) }}</span>
</div>
<!-- 答题用时 -->
<div v-if="item.answer?.duration" class="duration-section">
<Icon icon="ep:timer" />
答题用时{{ formatDuration(item.answer.duration) }}
</div>
</div>
</div>
</template>
</div>
<!-- 空状态 -->
<el-empty v-if="partitions.length === 0 && !loading" description="暂无答题记录" />
</div>
<!-- 问卷预览弹窗 -->
<el-dialog
v-model="previewVisible"
title="问卷预览"
width="800px"
destroy-on-close
>
<QuestionnairePreview :id="previewQuestionnaireId" />
<template #footer>
<el-button @click="previewVisible = false">关闭</el-button>
</template>
</el-dialog>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false"> </el-button> <el-button @click="dialogVisible = false"> </el-button>
</template> </template>
</el-dialog> </Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { DICT_TYPE } from '@/utils/dict' import { DICT_TYPE } from '@/utils/dict'
import { formatDateTime } from '@/utils/formatTime' import { formatDateTime } from '@/utils/formatTime'
import { QuestionnaireRecordApi, type QuestionnaireRecord } from '@/api/prison/questionnairerecord' import { QuestionnaireRecordApi, type QuestionnaireRecord } from '@/api/prison/questionnairerecord'
import { QuestionApi, type Question } from '@/api/prison/question' import { QuestionApi, type Question } from '@/api/prison/question'
import { AnswerApi, type Answer } from '@/api/prison/answer' import { AnswerApi, type Answer } from '@/api/prison/answer'
import QuestionnairePreview from '@/views/prison/questionnaire/components/QuestionnairePreview.vue'
import { getIntDictOptions } from '@/utils/dict' import { getIntDictOptions } from '@/utils/dict'
defineOptions({ name: 'AnswerDetailDialog' }) defineOptions({ name: 'AnswerDetailDialog' })
const dialogVisible = ref(false) const dialogVisible = ref(false)
const title = ref('答题详情')
const loading = ref(false) const loading = ref(false)
//
const previewVisible = ref(false)
const previewQuestionnaireId = ref<number>()
// //
const recordInfo = ref<QuestionnaireRecord>({ const recordInfo = ref<QuestionnaireRecord>({
id: undefined, id: undefined,
@ -133,19 +225,69 @@ const questions = ref<Question[]>([])
// //
const answers = ref<Answer[]>([]) const answers = ref<Answer[]>([])
/** 格式化时长 */ //
const formatDuration = (seconds: number | undefined): string => { interface QuestionWithAnswer {
if (!seconds) return '-' question: Question
const hours = Math.floor(seconds / 3600) answer?: Answer
const minutes = Math.floor((seconds % 3600) / 60) index: number
const secs = seconds % 60
if (hours > 0) {
return `${hours}${minutes}${secs}`
} else if (minutes > 0) {
return `${minutes}${secs}`
} else {
return `${secs}`
} }
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
})
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (!seconds) return '-'
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return minutes > 0 ? `${minutes}${secs}` : `${secs}`
} }
/** 获取及格状态文本 */ /** 获取及格状态文本 */
@ -154,55 +296,52 @@ const getPassStatusText = (status: number | undefined) => {
return options.find(o => o.value === status)?.label || '-' return options.find(o => o.value === status)?.label || '-'
} }
/** 获取及格状态标签类型 */ /** 获取及格状态样式 */
const getPassStatusTag = (status: number | undefined): 'success' | 'danger' | 'warning' | 'info' => { const getPassStatusClass = (status: number | undefined) => {
const tagMap: Record<number, 'success' | 'danger' | 'warning' | 'info'> = { const classMap: Record<number, string> = {
1: 'success', 1: 'pass',
2: 'danger', 2: 'fail',
3: 'warning' 3: 'pending'
} }
return tagMap[status || 0] || 'info' return classMap[status || 0] || ''
} }
/** 获取单选/填空/评分/日期/数字题的答案文本 */ /** 解析答案选项(单选/多选) */
const getAnswerText = (questionId: number | undefined): string | null => { const parseAnswerOptions = (answerText: string): string[] => {
if (questionId === undefined) return null if (!answerText) return []
const answer = answers.value.find(a => a.questionId === questionId) //
return answer?.answerText || null return answerText.split(',').filter(s => s.trim())
} }
/** 获取多选题的答案文本 */ /** 判断是否显示正确答案 */
const getMultiAnswerText = (questionId: number | undefined): string | null => { const showCorrectAnswer = (question: Question, answer?: Answer): boolean => {
if (questionId === undefined) return null //
const answer = answers.value.find(a => a.questionId === questionId) if (question.type !== 1 && question.type !== 2) return false
if (!answer?.optionIds || !Array.isArray(answer.optionIds) || answer.optionIds.length === 0) { //
return null if (!answer) return false
return true
} }
// /** 获取正确答案 */
const question = questions.value.find(q => q.id === questionId) const getCorrectAnswer = (question: Question): string => {
if (!question?.options) return null if (!question.options) return '-'
try { try {
// const options = JSON.parse(question.options)
const options = JSON.parse(question.options) as Array<{ label: string; value?: string; isOther?: boolean }> //
const correctOptions = options
.filter((o: any) => o.score > 0)
.map((o: any) => o.label)
// optionIds return correctOptions.length > 0 ? correctOptions.join('、') : '-'
const selectedLabels = answer.optionIds } catch {
.map((idx: number) => options[idx]) return '-'
.filter((opt): opt is { label: string } => !!opt)
.map(opt => opt.label)
return selectedLabels.length > 0 ? selectedLabels.join('、') : null
} catch (e) {
console.error('解析选项失败:', e)
return null
} }
} }
/** 打开弹窗 */ /** 打开弹窗 */
const open = async (recordId: number) => { const open = async (recordId: number) => {
dialogVisible.value = true dialogVisible.value = true
title.value = '答题详情'
loading.value = true loading.value = true
try { try {
@ -231,85 +370,227 @@ const open = async (recordId: number) => {
} }
} }
/** 预览问卷 */
const handlePreviewQuestionnaire = () => {
if (!recordInfo.value.questionnaireId) {
return
}
previewQuestionnaireId.value = recordInfo.value.questionnaireId
previewVisible.value = true
}
defineExpose({ open }) defineExpose({ open })
</script> </script>
<style lang="scss" scoped> <style scoped lang="scss">
.answer-detail-dialog { .answer-detail-dialog {
max-height: 70vh;
overflow-y: auto; overflow-y: auto;
max-height: 55vh; padding: 20px;
padding-right: 8px; background: #f5f7fa;
&::-webkit-scrollbar {
width: 8px;
} }
&::-webkit-scrollbar-thumb { .record-header {
background-color: #e4e7ed; background: #fff;
border-radius: 4px; padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.header-info {
margin-bottom: 16px;
.title {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.meta-info {
display: flex;
gap: 16px;
flex-wrap: wrap;
.info-item {
display: flex;
align-items: center;
gap: 4px;
color: #606266;
font-size: 14px;
}
} }
} }
.questionnaire-content { .score-info {
overflow-y: auto; display: flex;
max-height: 50vh; gap: 16px;
padding-right: 8px; padding-top: 16px;
border-top: 1px solid #ebeef5;
&::-webkit-scrollbar { .score-item {
width: 8px; flex: 1;
text-align: center;
.label {
display: block;
font-size: 12px;
color: #909399;
margin-bottom: 4px;
} }
&::-webkit-scrollbar-thumb { .value {
background-color: #e4e7ed; display: block;
border-radius: 4px; font-size: 20px;
font-weight: 600;
color: #303133;
&.objective {
color: #67c23a;
}
&.subjective {
color: #e6a23c;
} }
} }
.question-item { &.total {
margin-bottom: 24px; .value {
color: #409eff;
font-size: 24px;
}
}
.question-header { &.pass .value {
color: #67c23a;
}
&.fail .value {
color: #f56c6c;
}
&.pending .value {
color: #e6a23c;
}
}
}
}
.questions-container {
.partition-title {
background: linear-gradient(135deg, #e8f4fd 0%, #f0f7ff 100%);
padding: 12px 20px;
border-radius: 8px;
margin-bottom: 16px;
margin-top: 24px;
font-weight: 600;
color: #1a5cb8;
font-size: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px;
&:first-child {
margin-top: 0;
}
.question-count {
font-size: 12px;
color: #909399;
font-weight: normal;
margin-left: 4px;
}
}
.questions-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.question-item {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.question-header {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 16px;
.question-index { .question-index {
flex-shrink: 0; flex-shrink: 0;
width: 24px; width: 28px;
height: 24px; height: 28px;
background: #409eff;
color: #fff;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: #409eff;
color: #fff;
border-radius: 50%;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 600;
} }
.question-title { .question-title {
flex: 1;
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: #303133; color: #303133;
} line-height: 1.6;
}
.answer-area {
padding-left: 32px;
.options-container {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 12px; gap: 8px;
flex-wrap: wrap;
}
} }
.answer-text { .answer-section {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background: #f4f4f5;
border-radius: 6px;
margin-bottom: 12px;
.answer-label {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #606266;
font-weight: 500;
}
.answer-content {
flex: 1;
.selected-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
.option-tag {
margin: 0;
}
}
.text-answer,
.date-answer,
.number-answer {
font-size: 14px; font-size: 14px;
color: #303133; color: #303133;
line-height: 1.6; line-height: 1.6;
} }
.score-answer {
display: inline-block;
}
.empty-answer { .empty-answer {
color: #c0c4cc; color: #c0c4cc;
font-size: 14px; font-size: 14px;
@ -317,4 +598,69 @@ defineExpose({ open })
} }
} }
} }
.score-section {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #ecf5ff;
border-radius: 6px;
margin-bottom: 8px;
.score-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #409eff;
font-weight: 500;
}
.score-value {
font-size: 16px;
font-weight: 600;
color: #409eff;
&.full-score {
color: #67c23a;
}
}
}
.correct-answer-section {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #f0f9eb;
border-radius: 6px;
margin-bottom: 8px;
.correct-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #67c23a;
font-weight: 500;
}
.correct-content {
font-size: 14px;
color: #67c23a;
font-weight: 500;
}
}
.duration-section {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #909399;
padding: 4px 0;
}
}
}
</style> </style>

View File

@ -283,60 +283,12 @@ const handleSubmit = async () => {
// //
const answerList = questions.value.map(q => { const answerList = questions.value.map(q => {
const answer = answers.value[q.id!] const answer = answers.value[q.id!]
//
// type === 2 const answerStr = Array.isArray(answer) ? answer.join(',') : answer
if (q.type === 2 && Array.isArray(answer)) {
//
let optionLabels: string[] = []
try {
const options = JSON.parse(q.options || '[]') as Array<{ label: string }>
optionLabels = answer
.map((idx: string) => options[parseInt(idx)]?.label)
.filter((label): label is string => !!label)
} catch (e) {
console.error('解析选项失败:', e)
}
return { return {
questionId: q.id!, questionId: q.id!,
answer: optionLabels.join('、') || '', answer: answerStr || '',
optionIds: answer.map((idx: string) => parseInt(idx, 10)) optionIds: undefined as number[] | undefined
}
}
// type === 1"" optionIds
if (q.type === 1 && answer) {
const answerIndex = parseInt(answer as string, 10)
let isOtherOption = false
try {
const options = JSON.parse(q.options || '[]') as Array<{ isOther?: boolean }>
isOtherOption = options[answerIndex]?.isOther === true
} catch (e) {
console.error('解析选项失败:', e)
}
//
let answerText = answer as string
try {
const options = JSON.parse(q.options || '[]') as Array<{ label: string }>
answerText = options[answerIndex]?.label || answer as string
} catch (e) {
console.error('解析选项失败:', e)
}
return {
questionId: q.id!,
answer: answerText,
optionIds: isOtherOption ? [answerIndex] : undefined
}
}
// type 3/4/5/6
return {
questionId: q.id!,
answer: String(answer || ''),
optionIds: undefined
} }
}) })

View File

@ -1,11 +1,5 @@
<template> <template>
<Dialog <Dialog style="display: none;" title="问卷预览" v-model="dialogVisible" width="900px" :fullscreen="false">
style="display: none"
title="问卷预览"
v-model="dialogVisible"
width="900px"
:fullscreen="false"
>
<div ref="previewRef" class="questionnaire-preview" v-loading="loading"> <div ref="previewRef" class="questionnaire-preview" v-loading="loading">
<!-- 问卷头部信息 --> <!-- 问卷头部信息 -->
<h1 class="preview-header">{{ recordInfo?.questionnaireName }}</h1> <h1 class="preview-header">{{ recordInfo?.questionnaireName }}</h1>
@ -24,10 +18,9 @@
<!-- 问题列表按分区显示 --> <!-- 问题列表按分区显示 -->
<div class="preview-questions"> <div class="preview-questions">
<template v-for="(partition, index) in partitions" :key="partition.name || 'default'"> <template v-for="partition in partitions" :key="partition.name || 'default'">
<!-- 分区标题 --> <!-- 分区标题 -->
<div v-if="partition.name" class="partition-title"> <div v-if="partition.name" class="partition-title">
{{ ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] }}
{{ partition.name }} {{ partition.name }}
<span class="question-count">({{ partition.questions.length }} 道题)</span> <span class="question-count">({{ partition.questions.length }} 道题)</span>
</div> </div>
@ -40,7 +33,7 @@
class="question-item" class="question-item"
> >
<span class="question-index">{{ index + 1 }}.</span> <span class="question-index">{{ index + 1 }}.</span>
<span class="question-title">{{ questionWithAnswer.question.title }}</span> <span class="question-title">{{ questionWithAnswer.question.title }}</span>
<!-- 帮助说明 --> <!-- 帮助说明 -->
<span v-if="questionWithAnswer.question.helpText" class="question-help-inline"> <span v-if="questionWithAnswer.question.helpText" class="question-help-inline">
@ -48,50 +41,25 @@
</span> </span>
<!-- 单选/多选题 --> <!-- 单选/多选题 -->
<span <span v-if="questionWithAnswer.question.type === 1 || questionWithAnswer.question.type === 2" class="question-options-inline">
v-if="
questionWithAnswer.question.type === 1 || questionWithAnswer.question.type === 2
"
class="question-options-inline"
>
<span <span
v-for="option in getQuestionOptions(questionWithAnswer.question)" v-for="option in getQuestionOptions(questionWithAnswer.question)"
:key="option.label" :key="option.label"
class="option-item" class="option-item"
> >
<span <span v-if="questionWithAnswer.question.type === 1 && questionWithAnswer.answer?.answerText?.trim() === option.label">
v-if="
questionWithAnswer.question.type === 1 &&
questionWithAnswer.answer?.answerText?.trim() === option.label
"
>
{{ option.label }} {{ option.label }}
</span> </span>
<span <span v-if="questionWithAnswer.question.type === 1 && questionWithAnswer.answer?.answerText?.trim() !== option.label">
v-if="
questionWithAnswer.question.type === 1 &&
questionWithAnswer.answer?.answerText?.trim() !== option.label
"
>
{{ option.label }} {{ option.label }}
</span> </span>
<span <span v-if="questionWithAnswer.question.type === 2 && getSelectedLabels(questionWithAnswer.answer).includes(option.label)">
v-if="
questionWithAnswer.question.type === 2 &&
getSelectedLabels(questionWithAnswer.answer).includes(option.label)
"
>
{{ option.label }} {{ option.label }}
</span> </span>
<span <span v-if="questionWithAnswer.question.type === 2 && !getSelectedLabels(questionWithAnswer.answer).includes(option.label)">
v-if="
questionWithAnswer.question.type === 2 &&
!getSelectedLabels(questionWithAnswer.answer).includes(option.label)
"
>
{{ option.label }} {{ option.label }}
</span> </span>
@ -99,48 +67,26 @@
</span> </span>
<!-- 填空题 --> <!-- 填空题 -->
<span <span v-else-if="questionWithAnswer.question.type === 3" class="question-input-inline">
v-else-if="questionWithAnswer.question.type === 3"
class="question-input-inline"
>
{{ getAnswerDisplayValue(questionWithAnswer.answer) }} {{ getAnswerDisplayValue(questionWithAnswer.answer) }}
</span> </span>
<!-- 评分题 --> <!-- 评分题 -->
<span <span v-else-if="questionWithAnswer.question.type === 4" class="question-rating-inline">
v-else-if="questionWithAnswer.question.type === 4" <span class="question-input-inline">{{ getAnswerDisplayValue(questionWithAnswer.answer) }}</span>
class="question-rating-inline"
>
<span class="question-input-inline">{{
getAnswerDisplayValue(questionWithAnswer.answer)
}}</span>
</span> </span>
<!-- 日期题 --> <!-- 日期题 -->
<span v-else-if="questionWithAnswer.question.type === 5" class="question-date-inline"> <span v-else-if="questionWithAnswer.question.type === 5" class="question-date-inline">
<span <span class="date-info" v-if="getRangeValue(questionWithAnswer.question, 'min') || getRangeValue(questionWithAnswer.question, 'max')">
class="date-info" 日期范围{{ getRangeValue(questionWithAnswer.question, 'min') || '无限制' }} ~ {{ getRangeValue(questionWithAnswer.question, 'max') || '无限制' }}
v-if="
getRangeValue(questionWithAnswer.question, 'min') ||
getRangeValue(questionWithAnswer.question, 'max')
"
>
日期范围{{ getRangeValue(questionWithAnswer.question, 'min') || '无限制' }} ~
{{ getRangeValue(questionWithAnswer.question, 'max') || '无限制' }}
</span> </span>
<span class="question-input-inline">{{ <span class="question-input-inline">{{ getAnswerDisplayValue(questionWithAnswer.answer) }}</span>
getAnswerDisplayValue(questionWithAnswer.answer)
}}</span>
</span> </span>
<!-- 数字题 --> <!-- 数字题 -->
<span <span v-else-if="questionWithAnswer.question.type === 6" class="question-number-inline">
v-else-if="questionWithAnswer.question.type === 6" <span class="question-input-inline">{{ getAnswerDisplayValue(questionWithAnswer.answer) }}</span>
class="question-number-inline"
>
<span class="question-input-inline">{{
getAnswerDisplayValue(questionWithAnswer.answer)
}}</span>
</span> </span>
</div> </div>
</div> </div>
@ -191,7 +137,7 @@ const partitions = computed(() => {
questions.value.forEach((q, index) => { questions.value.forEach((q, index) => {
const partName = q.partName || '' const partName = q.partName || ''
const answer = answers.value.find((a) => a.questionId === q.id) const answer = answers.value.find(a => a.questionId === q.id)
if (!partMap.has(partName)) { if (!partMap.has(partName)) {
partMap.set(partName, []) partMap.set(partName, [])
@ -204,7 +150,8 @@ const partitions = computed(() => {
}) })
// //
const sortedParts = Array.from(partMap.entries()).sort((a, b) => { const sortedParts = Array.from(partMap.entries())
.sort((a, b) => {
const sortA = a[1][0]?.question.partSort ?? 0 const sortA = a[1][0]?.question.partSort ?? 0
const sortB = b[1][0]?.question.partSort ?? 0 const sortB = b[1][0]?.question.partSort ?? 0
return sortA - sortB return sortA - sortB
@ -237,21 +184,20 @@ const partitions = computed(() => {
/** 根据问题ID获取答案 */ /** 根据问题ID获取答案 */
const getAnswerByQuestionId = (questionId: number): Answer | undefined => { const getAnswerByQuestionId = (questionId: number): Answer | undefined => {
return answers.value.find((a) => a.questionId === questionId) return answers.value.find(a => a.questionId === questionId)
} }
/** 获取答案显示值(兼容不同字段名) */ /** 获取答案显示值(兼容不同字段名) */
const getAnswerDisplayValue = (answer?: Answer): string => { const getAnswerDisplayValue = (answer?: Answer): string => {
if (!answer) return '' if (!answer) return ''
// 使 answerText使 optionIds // 使 answerText使 optionIds
const value = answer.answerText || answer.optionIds || '' return answer.answerText || answer.optionIds || '-'
return String(value).trim().replace(/^["']|["']$/g, '')
} }
/** 问卷类型标签 */ /** 问卷类型标签 */
const getTypeLabel = (type: number) => { const getTypeLabel = (type: number) => {
const options = getIntDictOptions(DICT_TYPE.PRISON_QUESTIONNAIRE_TYPE) const options = getIntDictOptions(DICT_TYPE.PRISON_QUESTIONNAIRE_TYPE)
return options.find((o) => o.value === type)?.label || '未知' return options.find(o => o.value === type)?.label || '未知'
} }
/** 获取问题选项 */ /** 获取问题选项 */
@ -287,16 +233,11 @@ const getRangeValue = (question: Question, key: 'min' | 'max') => {
/** 安全获取多选答案的标签数组 */ /** 安全获取多选答案的标签数组 */
const getSelectedLabels = (answer?: Answer): string[] => { const getSelectedLabels = (answer?: Answer): string[] => {
if (!answer?.answerText) return [] if (!answer?.answerText) return []
return answer.answerText.split(',').map((s) => s.trim()) return answer.answerText.split(',').map(s => s.trim())
} }
/** 判断选项是否被选中(支持单选和多选)- 已废弃,改用模板直接绑定 */ /** 判断选项是否被选中(支持单选和多选)- 已废弃,改用模板直接绑定 */
const isOptionSelected = ( const isOptionSelected = (answer?: Answer, optionValue?: string, optionLabel?: string, questionType?: number): boolean => {
answer?: Answer,
optionValue?: string,
optionLabel?: string,
questionType?: number
): boolean => {
if (!answer || !optionValue || !optionLabel) { if (!answer || !optionValue || !optionLabel) {
return false return false
} }
@ -304,7 +245,7 @@ const isOptionSelected = (
// type === 2answerText // type === 2answerText
if (questionType === 2 && answer.answerText && answer.answerText.includes(',')) { if (questionType === 2 && answer.answerText && answer.answerText.includes(',')) {
const answerText = answer.answerText const answerText = answer.answerText
const selectedLabels = answerText.split(',').map((s) => s.trim()) const selectedLabels = answerText.split(',').map(s => s.trim())
return selectedLabels.includes(optionLabel) return selectedLabels.includes(optionLabel)
} }
@ -316,7 +257,7 @@ const isOptionSelected = (
// questionType // questionType
if (answer.answerText && answer.answerText.includes(',')) { if (answer.answerText && answer.answerText.includes(',')) {
const answerText = answer.answerText const answerText = answer.answerText
const selectedLabels = answerText.split(',').map((s) => s.trim()) const selectedLabels = answerText.split(',').map(s => s.trim())
return selectedLabels.includes(optionLabel) return selectedLabels.includes(optionLabel)
} }
@ -325,6 +266,7 @@ const isOptionSelected = (
return false return false
} }
/** 打开弹窗 */ /** 打开弹窗 */
const open = async (recordId: number) => { const open = async (recordId: number) => {
dialogVisible.value = true dialogVisible.value = true
@ -354,9 +296,7 @@ const open = async (recordId: number) => {
if (recordData.questionnaireId) { if (recordData.questionnaireId) {
try { try {
const { QuestionnaireApi } = await import('@/api/prison/questionnaire') const { QuestionnaireApi } = await import('@/api/prison/questionnaire')
questionnaireInfo.value = await QuestionnaireApi.getQuestionnaire( questionnaireInfo.value = await QuestionnaireApi.getQuestionnaire(recordData.questionnaireId)
recordData.questionnaireId
)
} catch (error) { } catch (error) {
console.warn('加载问卷详细信息失败:', error) console.warn('加载问卷详细信息失败:', error)
} }
@ -387,11 +327,7 @@ const exportToWord = async () => {
} }
// HTML // HTML
let previewHTML = previewRef.value.innerHTML const previewHTML = previewRef.value.innerHTML
// WPS
previewHTML = previewHTML.replace(/[\u201C\u201D\u2018\u2019]/g, '') //
previewHTML = previewHTML.replace(/^["']|["']$/gm, '') //
// HTML使 // HTML使
const fullHTML = ` const fullHTML = `
@ -400,110 +336,108 @@ const exportToWord = async () => {
<meta charset="utf-8"> <meta charset="utf-8">
<title>${recordInfo.value?.questionnaireName || '问卷'}</title> <title>${recordInfo.value?.questionnaireName || '问卷'}</title>
<style> <style>
/* 使用 pt 单位以避免 Word/WPS 在 px->pt 换算时产生差异 */
body { body {
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif; font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif;
font-size: 11pt; font-size: 16px;
margin: 15pt; line-height: 1.6;
padding: 15pt; margin: 20px;
padding: 20px;
} }
h1 { h1 {
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif;
font-size: 18pt;
font-weight: 500; font-weight: 500;
color: #000; color: #000;
text-align: center; text-align: center;
} }
.section-title { .section-title {
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif; margin: 15px 0;
margin-bottom: 15pt; font-size: 18px;
font-size: 18pt; font-weight: 500;
font-weight: 700;
color: #000; color: #000;
} }
.description-content { .description-content {
color: #000; color: #000;
font-size: 11pt; line-height: 1.8;
margin-bottom: 6pt; font-size: 16px;
line-height: 1.5;
} }
.instruction-content { .instruction-content {
color: #000; color: #000;
font-size: 11pt; font-size: 18px;
font-weight: 500; font-weight: 500;
margin-bottom: 6pt; line-height: 1.8;
line-height: 1.5;
} }
.partition-title { .partition-title {
margin-top: 15pt; margin-bottom: 16px;
margin-top: 24px;
color: #000; color: #000;
font-size: 15pt; font-size: 17px;
font-weight: 500; font-weight: 500;
} }
.partition-title:first-child { .partition-title:first-child {
margin-top: 0; margin-top: 0;
} }
.question-count { .question-count {
font-size: 11pt; font-size: 14px;
color: #909399; color: #909399;
font-weight: normal; font-weight: normal;
margin-left: 2pt; margin-left: 4px;
} }
.question-item { .question-item {
font-size: 11pt; font-size: 16px;
color: #000; color: #000;
margin-bottom: 8pt; line-height: 1.6;
line-height: 1.5; margin-bottom: 16px;
page-break-inside: avoid; page-break-inside: avoid;
} }
.question-index { .question-index {
margin-right: 2pt; margin-right: 4px;
font-weight: bold;
} }
.question-title { .question-title {
margin-right: 4pt; margin-right: 8px;
} }
.question-help-inline { .question-help-inline {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3pt; gap: 6px;
color: #909399; color: #909399;
font-size: 11pt; font-size: 15px;
padding: 4px 8px;
background: #f4f4f5; background: #f4f4f5;
border-radius: 2pt; border-radius: 4px;
margin-right: 6pt; margin-right: 8px;
} }
.question-options-inline { .question-options-inline {
display: inline-flex; display: inline-flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8pt; gap: 12px;
align-items: center; align-items: center;
} }
.option-item { .option-item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3pt; gap: 8px;
} }
.question-rating-inline, .question-rating-inline,
.question-date-inline, .question-date-inline,
.question-number-inline { .question-number-inline {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8pt; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
margin-left: 3pt; margin-left: 8px;
} }
.rating-info, .rating-info,
.date-info, .date-info,
.number-info { .number-info {
display: inline-flex; display: inline-flex;
gap: 11pt; gap: 16px;
color: #909399; color: #909399;
font-size: 11pt; font-size: 13px;
} }
.question-input-inline { .question-input-inline {
color: #000; color: #000;
font-size: 11pt; font-size: 16px;
line-height: 1.5; line-height: 2.2;
text-decoration: underline; text-decoration: underline;
} }
</style> </style>
@ -541,99 +475,100 @@ defineExpose({ open, exportToWord })
} }
h1 { h1 {
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif;
font-size: 18pt;
font-weight: 500; font-weight: 500;
color: #000; color: #000;
text-align: center; text-align: center;
} }
.section-title { .section-title {
font-family: 'Microsoft YaHei', '微软雅黑', SimSun, Arial, sans-serif; margin: 15px 0;
margin-bottom: 15pt; font-size: 16px;
font-size: 18pt; font-weight: 500;
font-weight: 700;
color: #000; color: #000;
} }
.description-content { .description-content {
color: #000; color: #000;
line-height: 3; line-height: 1.8;
font-size: 11pt; font-size: 14px;
} }
.instruction-content { .instruction-content {
color: #000; color: #000;
font-size: 11pt; font-size: 16px;
font-weight: 500; font-weight: 500;
line-height: 3; line-height: 1.8;
} }
.partition-title { .partition-title {
margin-top: 15pt; margin-bottom: 16px;
margin-top: 24px;
color: #000; color: #000;
font-size: 15pt; font-size: 15px;
font-weight: 500; font-weight: 500;
} }
.partition-title:first-child { .partition-title:first-child {
margin-top: 0; margin-top: 0;
} }
.question-count { .question-count {
font-size: 11pt; font-size: 12px;
color: #909399; color: #909399;
font-weight: normal; font-weight: normal;
margin-left: 2pt; margin-left: 4px;
} }
.question-item { .question-item {
font-size: 11pt; font-size: 14px;
color: #000; color: #000;
line-height: 3; line-height: 1.6;
margin-bottom: 16px;
page-break-inside: avoid; page-break-inside: avoid;
} }
.question-index { .question-index {
margin-right: 2pt; margin-right: 4px;
font-weight: bold;
} }
.question-title { .question-title {
margin-right: 4pt; margin-right: 8px;
} }
.question-help-inline { .question-help-inline {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3pt; gap: 6px;
color: #909399; color: #909399;
font-size: 11pt; font-size: 13px;
padding: 4px 8px;
background: #f4f4f5; background: #f4f4f5;
border-radius: 2pt; border-radius: 4px;
margin-right: 6pt; margin-right: 8px;
} }
.question-options-inline { .question-options-inline {
display: inline-flex; display: inline-flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8pt; gap: 12px;
align-items: center; align-items: center;
} }
.option-item { .option-item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3pt; gap: 8px;
} }
.question-rating-inline, .question-rating-inline,
.question-date-inline, .question-date-inline,
.question-number-inline { .question-number-inline {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8pt; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
margin-left: 3pt; margin-left: 8px;
} }
.rating-info, .rating-info,
.date-info, .date-info,
.number-info { .number-info {
display: inline-flex; display: inline-flex;
gap: 11pt; gap: 16px;
color: #909399; color: #909399;
font-size: 11pt; font-size: 13px;
} }
.question-input-inline { .question-input-inline {
color: #000; color: #000;
font-size: 11pt; font-size: 14px;
line-height: 3; line-height: 2.2;
text-decoration: underline; text-decoration: underline;
} }
</style> </style>