- 修复 AgentFillDialog.vue 的 optionIds 类型问题 - 修复 AnswerDetailDialog.vue 多选题答案显示问题 - 修复 QuestionnaireFillDialog.vue 多选题 optionIds 提交问题 - 优化代码类型定义,修复 linter 错误 Closes #questionnaire-fixes
473 lines
12 KiB
Vue
473 lines
12 KiB
Vue
<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>
|