xlcp-frontend/src/views/prison/questionnairerecord/QuestionnaireFillDialog.vue
tangweijie 535b7be802 fix(questionnaire): 修复问卷模块前端多个问题
- 修复 AgentFillDialog.vue 的 optionIds 类型问题
- 修复 AnswerDetailDialog.vue 多选题答案显示问题
- 修复 QuestionnaireFillDialog.vue 多选题 optionIds 提交问题
- 优化代码类型定义,修复 linter 错误

Closes #questionnaire-fixes
2026-02-04 18:29:46 +08:00

473 lines
12 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 :title="dialogTitle" v-model="dialogVisible" width="900px">
<div v-loading="loading" class="questionnaire-fill-dialog">
<!-- 问卷信息头部 -->
<div class="questionnaire-header">
<h3 class="questionnaire-name">{{ questionnaireName }}</h3>
<p class="questionnaire-tip">请根据实际情况填写以下问卷内容</p>
</div>
<!-- 分区列表 -->
<div class="parts-container">
<el-collapse v-model="activeParts" accordion>
<el-collapse-item
v-for="partition in partitions"
:key="partition.name || 'default'"
:name="partition.name || 'default'"
:title="partition.name || '默认分区'"
>
<template #title>
<div class="part-title">
<span>{{ partition.name || '默认分区' }}</span>
<el-tag size="small" type="info">{{ partition.questions.length }} </el-tag>
</div>
</template>
<!-- 问题列表 -->
<div class="questions-list">
<div
v-for="(question, qIndex) in partition.questions"
:key="question.id"
class="question-item"
:class="{ 'is-required': question.isRequired }"
>
<div class="question-header">
<span class="question-index">{{ qIndex + 1 }}.</span>
<span class="question-title">
{{ question.title }}
<el-tag v-if="question.isRequired" type="danger" size="small">必填</el-tag>
</span>
</div>
<!-- 帮助说明 -->
<div v-if="question.helpText" class="question-help">
<Icon icon="ep:info-filled" />
{{ question.helpText }}
</div>
<!-- 答题区域 -->
<div class="question-answer">
<AutoFill
v-model="answers[question.id!]"
:question-type="question.type"
:fill-type="question.autoFillType || 'NONE'"
:fill-source="question.autoFillSource"
:prisoner-id="prisonerId"
:options="question.options"
:placeholder="question.placeholder"
:is-required="question.isRequired"
:min-value="question.minValue"
:max-value="question.maxValue"
/>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
提交问卷
</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { QuestionApi } from '@/api/prison/question'
import { QuestionnaireRecordApi } from '@/api/prison/questionnairerecord'
import AutoFill from '@/components/AutoFill/index.vue'
import type { Question, AutoFillType } from '@/api/prison/question'
defineOptions({ name: 'QuestionnaireFillDialog' })
const emit = defineEmits(['success'])
const dialogVisible = ref(false)
const dialogTitle = ref('问卷填写')
const loading = ref(false)
const submitLoading = ref(false)
// 入参
const recordId = ref<number>(0)
const prisonerId = ref<number>(0)
const questionnaireId = ref<number>(0)
const questionnaireName = ref('')
const recordStatus = ref<number>(0) // 记录状态1-待测评 2-测评中
// 问题列表
const questions = ref<Question[]>([])
// 答案存储:支持 string单选/填空)和 string[](多选)
const answers = ref<Record<number, string | string[] | undefined>>({})
// 展开的分区
const activeParts = ref<string[]>([])
// 分区列表
const partitions = computed(() => {
const partMap = new Map<string, Question[]>()
const defaultPart: Question[] = []
questions.value.forEach(q => {
const partName = q.partName || ''
if (partName) {
if (!partMap.has(partName)) {
partMap.set(partName, [])
}
partMap.get(partName)!.push(q)
} else {
defaultPart.push(q)
}
})
// 构建分区列表
const result: Array<{ name: string; questions: Question[] }> = []
// 添加默认分区
if (defaultPart.length > 0) {
result.push({ name: '', questions: defaultPart })
}
// 添加其他分区(按排序)
Array.from(partMap.entries())
.sort((a, b) => {
const partA = questions.value.find(q => q.partName === a[0])
const partB = questions.value.find(q => q.partName === b[0])
const sortA = partA?.partSort ?? 0
const sortB = partB?.partSort ?? 0
return sortA - sortB
})
.forEach(([name, qs]) => {
result.push({ name, questions: qs })
})
return result
})
// 打开弹窗
const open = async (opts: {
recordId: number
prisonerId: number
questionnaireId: number
questionnaireName: string
}) => {
recordId.value = opts.recordId
prisonerId.value = opts.prisonerId
questionnaireId.value = opts.questionnaireId
questionnaireName.value = opts.questionnaireName
dialogVisible.value = true
// 先获取记录详情,获取当前状态
await loadRecordDetail()
await loadQuestions()
}
// 获取记录详情
const loadRecordDetail = async () => {
if (!recordId.value) return
try {
const res = await QuestionnaireRecordApi.getQuestionnaireRecord(recordId.value)
recordStatus.value = res.status || 0
} catch (e) {
console.error('获取记录详情失败:', e)
}
}
defineExpose({ open })
// 加载问题列表
const loadQuestions = async () => {
if (!questionnaireId.value) return
loading.value = true
try {
const allQuestions: Question[] = []
let pageNo = 1
const pageSize = 200 // 后端每页最大限制
// 循环获取所有问题
while (true) {
const res = await QuestionApi.getQuestionPage({
pageNo,
pageSize,
questionnaireId: questionnaireId.value
})
allQuestions.push(...res.list)
// 如果已经是最后一页,退出循环
if (res.list.length < pageSize || !res.pageInfo || res.pageInfo.total <= allQuestions.length) {
break
}
pageNo++
}
questions.value = allQuestions
// 重置答案
answers.value = {}
questions.value.forEach(q => {
answers.value[q.id!] = undefined
})
// 默认展开第一个分区
if (partitions.value.length > 0) {
activeParts.value = [partitions.value[0].name || 'default']
}
} finally {
loading.value = false
}
}
// 验证必填项
const validateRequired = (): boolean => {
for (const q of questions.value) {
if (q.isRequired) {
const answer = answers.value[q.id!]
// 多选:至少选一个
if (q.type === 2) {
if (!answer || (Array.isArray(answer) && answer.length === 0)) {
return false
}
} else {
// 单选/填空等
if (!answer) {
return false
}
}
}
}
return true
}
// 获取未填写的必填项
const getMissingRequired = (): Question | null => {
for (const q of questions.value) {
if (q.isRequired) {
const answer = answers.value[q.id!]
if (q.type === 2) {
if (!answer || (Array.isArray(answer) && answer.length === 0)) {
return q
}
} else {
if (!answer) {
return q
}
}
}
}
return null
}
// 提交问卷
const handleSubmit = async () => {
// 验证必填项
const missing = getMissingRequired()
if (missing) {
// 找到缺失的分区并展开
const part = partitions.value.find(p => p.questions.some(q => q.id === missing.id))
if (part) {
activeParts.value = [part.name || 'default']
}
// 等待 DOM 更新后滚动到缺失位置
setTimeout(() => {
const el = document.querySelector('.question-item.is-required .el-form-item__error')
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
return
}
// 构建答案列表
const answerList = questions.value.map(q => {
const answer = answers.value[q.id!]
// 处理多选题type === 2
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 {
questionId: q.id!,
answer: optionLabels.join('、') || '',
optionIds: answer.map((idx: string) => parseInt(idx, 10))
}
}
// 单选题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
}
})
submitLoading.value = true
try {
// 如果状态是"待测评(1)",需要先开始测评
if (recordStatus.value === 1) {
await QuestionnaireRecordApi.startAssessment(recordId.value, prisonerId.value)
}
// 提交答卷
await QuestionnaireRecordApi.submitAnswer({
recordId: recordId.value,
prisonerId: prisonerId.value,
answers: answerList
})
// 提交成功后结束测评
await QuestionnaireRecordApi.finishAssessment(recordId.value)
dialogVisible.value = false
emit('success')
} catch (e) {
console.error('提交失败:', e)
} finally {
submitLoading.value = false
}
}
</script>
<style lang="scss" scoped>
.questionnaire-fill-dialog {
max-height: 70vh;
overflow-y: auto;
padding: 0 10px;
}
.questionnaire-header {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #ebeef5;
.questionnaire-name {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.questionnaire-tip {
margin: 0;
color: #909399;
font-size: 14px;
}
}
.parts-container {
:deep(.el-collapse-item__header) {
font-weight: 600;
font-size: 15px;
padding-left: 16px;
}
:deep(.el-collapse-item__content) {
padding-bottom: 0;
}
}
.part-title {
display: flex;
align-items: center;
gap: 12px;
}
.questions-list {
padding: 16px;
}
.question-item {
padding: 16px;
margin-bottom: 12px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #ebeef5;
&:last-child {
margin-bottom: 0;
}
&.is-required {
background: #fff7f7;
border-color: #fbc4c4;
}
}
.question-header {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
}
.question-index {
color: #409eff;
font-weight: 600;
margin-right: 8px;
flex-shrink: 0;
}
.question-title {
font-size: 15px;
color: #303133;
line-height: 1.5;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.question-help {
margin-bottom: 12px;
padding: 8px 12px;
background: #ecf5ff;
border-radius: 4px;
font-size: 13px;
color: #409eff;
display: flex;
align-items: center;
gap: 6px;
}
.question-answer {
margin-top: 8px;
}
</style>