feat: 问卷任务管理前端增强

- 新建问卷页面(question/index.vue)
- 新增 Agent 填写对话框
- 优化任务详情对话框交互
- 新增 API 接口类型定义
This commit is contained in:
tangweijie 2026-01-26 16:02:47 +08:00
parent 5d43154ba5
commit 7272342fe6
9 changed files with 972 additions and 124 deletions

View File

@ -84,5 +84,10 @@ export const QuestionApi = {
// 导出问卷问题 Excel
exportQuestion: async (params: QuestionPageParams) => {
return await request.download({ url: `/prison/question/export-excel`, params })
},
// 获取问卷问题列表(不分页)
getQuestionnaireQuestionList: async (params: QuestionPageParams) => {
return await request.get({ url: `/prison/question/page`, params })
}
}

View File

@ -90,6 +90,40 @@ export interface TaskAreaStatistics {
}
}
/** 按监区统计 */
export interface TaskAreaStatistics {
areaId?: number
areaName?: string
totalCount: number
completedCount: number
completionRate: string | number
avgScore: string | number
passRate: string | number
riskDistribution: {
highRisk: number
mediumRisk: number
lowRisk: number
}
}
/** 人员填写进度 */
export interface PrisonerProgress {
id: number
prisonerId: number
prisonerNo: string
prisonerName: string
areaId?: number
areaName?: string
status: number
objectiveScore?: number
subjectiveScore?: number
totalScore?: number
riskLevel?: number
duration?: number
startTime?: string
finishTime?: string
}
/** 统计汇总 */
export interface TaskStatisticsSummary {
taskCount: number
@ -170,6 +204,21 @@ export const QuestionnaireTaskApi = {
return await request.post({ url: `/prison/questionnaire-task/remind`, params: { id } })
},
// 获取任务的人员填写进度列表
getPrisonerProgress: async (id: number) => {
return await request.get<PrisonerProgress[]>({ url: `/prison/questionnaire-task/prisoner-progress`, params: { id } })
},
// 通知单个人员
notifyPrisoner: async (recordId: number) => {
return await request.post({ url: `/prison/questionnaire-task/notify-prisoner`, params: { recordId } })
},
// 重置人员答题记录
resetPrisonerRecord: async (recordId: number) => {
return await request.post({ url: `/prison/questionnaire-task/reset-record`, params: { recordId } })
},
// ==================== 统计相关 ====================
// 按监区统计任务完成情况

View File

@ -160,6 +160,11 @@ export const QuestionnaireRecordApi = {
return await request.post<boolean>({ url: `/prison/questionnaire-record/submit`, data })
},
/** 代为提交答卷(民警代填) */
submitAnswerByAgent: async (data: AssessmentAnswerSubmitReq) => {
return await request.post<boolean>({ url: `/prison/questionnaire-record/submit-by-agent`, data })
},
/** 结束测评 */
finishAssessment: async (id: number) => {
return await request.post<boolean>({ url: `/prison/questionnaire-record/finish`, params: { id } })

View File

@ -0,0 +1,9 @@
<template>
<ContentWrap>
<el-empty description="问卷题目管理功能已整合到问卷管理中" />
</ContentWrap>
</template>
<script setup lang="ts">
defineOptions({ name: 'PrisonQuestion' })
</script>

View File

@ -0,0 +1,331 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="`代为填写 - ${prisonerInfo?.prisonerName || ''}`"
width="800px"
:close-on-click-modal="false"
destroy-on-close
>
<div v-loading="loading">
<!-- 罪犯信息 -->
<el-descriptions :column="3" border class="mb-20px">
<el-descriptions-item label="罪犯编号">{{ prisonerInfo?.prisonerNo }}</el-descriptions-item>
<el-descriptions-item label="罪犯姓名">{{ prisonerInfo?.prisonerName }}</el-descriptions-item>
<el-descriptions-item label="监区">{{ prisonerInfo?.areaName }}</el-descriptions-item>
</el-descriptions>
<!-- 问卷题目 -->
<div v-if="questions.length > 0" class="questionnaire-content">
<div
v-for="(question, index) in questions"
:key="question.id"
class="question-item"
>
<div class="question-header">
<span class="question-index">{{ index + 1 }}</span>
<span class="question-title">{{ question.title }}</span>
<el-tag v-if="question.required" type="danger" size="small" class="required-tag">必填</el-tag>
</div>
<!-- 单选题 -->
<div v-if="question.type === 1" class="answer-area">
<div class="options-container">
<el-radio-group v-model="answers[question.id]" :disabled="disabled">
<el-radio
v-for="(opt, index) in parseOptions(question.options)"
:key="index"
:value="index"
>
{{ opt.label }}
</el-radio>
</el-radio-group>
</div>
</div>
<!-- 多选题 -->
<div v-else-if="question.type === 2" class="answer-area">
<div class="options-container">
<el-checkbox-group v-model="multiAnswers[question.id]">
<el-checkbox
v-for="(opt, index) in parseOptions(question.options)"
:key="index"
:value="index"
:disabled="disabled"
>
{{ opt.label }}
</el-checkbox>
</el-checkbox-group>
</div>
</div>
<!-- 填空题 -->
<div v-else-if="question.type === 3" class="answer-area">
<el-input
v-model="answers[question.id]"
type="textarea"
:rows="2"
placeholder="请输入答案"
:disabled="disabled"
/>
</div>
<!-- 评分题 -->
<div v-else-if="question.type === 4" class="answer-area">
<el-rate v-model="answers[question.id]" :disabled="disabled" />
</div>
<!-- 日期题 -->
<div v-else-if="question.type === 5" class="answer-area">
<el-date-picker
v-model="answers[question.id]"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
:disabled="disabled"
/>
</div>
<!-- 数字题 -->
<div v-else-if="question.type === 6" class="answer-area">
<el-input-number v-model="answers[question.id]" :min="0" :disabled="disabled" />
</div>
</div>
</div>
<el-empty v-else description="暂无问卷题目" />
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确认代填
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { QuestionnaireApi } from '@/api/prison/questionnaire'
import { QuestionApi } from '@/api/prison/question'
import { QuestionnaireRecordApi } from '@/api/prison/questionnairerecord'
defineOptions({ name: 'AgentFillDialog' })
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
//
const prisonerInfo = ref<any>(null)
const recordId = ref<number>()
const questionnaireId = ref<number>()
//
const questions = ref<any[]>([])
//
const answers = reactive<Record<number, any>>({})
const multiAnswers = reactive<Record<number, number[]>>({})
//
const disabled = computed(() => submitLoading.value)
/** 解析选项JSON */
const parseOptions = (optionsStr: string) => {
try {
if (!optionsStr) return []
return JSON.parse(optionsStr)
} catch {
return []
}
}
/** 打开弹窗 */
const open = async (record: any) => {
dialogVisible.value = true
recordId.value = record.id
prisonerInfo.value = record
questionnaireId.value = record.questionnaireId
//
Object.keys(answers).forEach(key => delete answers[key])
Object.keys(multiAnswers).forEach(key => delete multiAnswers[key])
loading.value = true
try {
//
const countData = await QuestionApi.getQuestionnaireQuestionList(
{ questionnaireId: questionnaireId.value, pageNo: 1, pageSize: 1 }
)
const totalCount = countData.total || 0
// 200
const maxPageSize = 200
const pageSize = Math.min(totalCount, maxPageSize)
const questionData = await QuestionApi.getQuestionnaireQuestionList(
{ questionnaireId: questionnaireId.value, pageNo: 1, pageSize: pageSize }
)
questions.value = questionData.list || []
//
questions.value.forEach(q => {
if (q.type === 2) {
//
multiAnswers[q.id] = []
} else {
answers[q.id] = q.type === 4 ? 0 : null // 0
}
})
} catch (e) {
console.error('获取问卷题目失败', e)
ElMessage.error('获取问卷题目失败')
} finally {
loading.value = false
}
}
/** 提交答案 */
const handleSubmit = async () => {
//
for (const question of questions.value) {
if (question.required) {
const answer = question.type === 2 ? multiAnswers[question.id] : answers[question.id]
if (answer === null || answer === undefined ||
(Array.isArray(answer) && answer.length === 0) ||
(typeof answer === 'string' && !answer.trim())) {
ElMessage.warning(`${questions.value.indexOf(question) + 1}题为必填项`)
return
}
}
}
//
const answerList = questions.value.map(q => {
const answerItem: any = {
questionId: q.id
}
if (q.type === 2) {
//
answerItem.optionIds = multiAnswers[q.id] || []
} else {
answerItem.answer = answers[q.id] !== undefined ? String(answers[q.id]) : ''
}
return answerItem
})
//
const needStartAssessment = prisonerInfo.value?.status === 1 // ""
if (needStartAssessment) {
try {
await QuestionnaireRecordApi.startAssessment(recordId.value!, prisonerInfo.value.prisonerId)
} catch (e) {
console.error('开始测评失败', e)
ElMessage.error('开始测评失败')
return
}
}
//
submitLoading.value = true
try {
await QuestionnaireRecordApi.submitAnswerByAgent({
recordId: recordId.value!,
prisonerId: prisonerInfo.value.prisonerId,
answers: answerList
})
ElMessage.success('代填成功')
dialogVisible.value = false
//
emit('success')
} catch (e) {
console.error('提交失败', e)
ElMessage.error('提交失败')
} finally {
submitLoading.value = false
}
}
const emit = defineEmits(['success'])
defineExpose({ open })
</script>
<style lang="scss" scoped>
.questionnaire-content {
overflow-y: auto;
max-height: 55vh;
padding-right: 8px;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: #e4e7ed;
border-radius: 4px;
}
}
.question-item {
margin-bottom: 24px;
.question-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.question-index {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background-color: #409eff;
color: #fff;
border-radius: 50%;
font-size: 13px;
font-weight: 500;
}
.question-title {
font-size: 15px;
font-weight: 500;
color: #303133;
}
}
.answer-area {
padding-left: 32px;
}
.options-container {
display: flex;
flex-direction: column;
gap: 12px;
}
:deep(.el-radio),
:deep(.el-checkbox) {
margin-right: 0;
white-space: normal;
height: auto;
line-height: 1.6;
}
:deep(.el-radio__label),
:deep(.el-checkbox__label) {
font-size: 14px;
color: #606266;
line-height: 1.6;
word-break: break-word;
}
}
</style>

View File

@ -23,7 +23,7 @@
v-model="formData.questionnaireId"
placeholder="请选择问卷"
filterable
style="width: 100%"
style="width: calc(100% - 90px)"
>
<el-option
v-for="item in questionnaireList"
@ -32,6 +32,14 @@
:value="item.id"
/>
</el-select>
<el-button
type="primary"
link
:disabled="!formData.questionnaireId"
@click="handlePreviewQuestionnaire"
>
<Icon icon="ep:view" class="mr-3px" />预览
</el-button>
</el-form-item>
<!-- 目标范围 -->
@ -115,6 +123,19 @@
@confirm="handlePrisonerSelect"
/>
<!-- 问卷预览弹窗 -->
<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>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
@ -128,6 +149,7 @@
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import PrisonerSelectorDialog from './PrisonerSelectorDialog.vue'
import QuestionnairePreview from '@/views/prison/questionnaire/components/QuestionnairePreview.vue'
import { QuestionnaireTaskApi } from '@/api/prison/questionnaire-task'
import { QuestionnaireApi } from '@/api/prison/questionnaire'
import { AreaApi } from '@/api/prison/area'
@ -141,6 +163,10 @@ const submitLoading = ref(false)
const isEdit = ref(false)
const formRef = ref()
//
const previewVisible = ref(false)
const previewQuestionnaireId = ref<number>()
//
const formData = reactive({
id: undefined,
@ -239,6 +265,16 @@ const openPrisonerSelector = () => {
prisonerSelectorRef.value?.open(formData.prisonerIds)
}
/** 预览问卷 */
const handlePreviewQuestionnaire = () => {
if (!formData.questionnaireId) {
ElMessage.warning('请先选择问卷')
return
}
previewQuestionnaireId.value = formData.questionnaireId
previewVisible.value = true
}
/** 犯人选择确认 */
const handlePrisonerSelect = (selectedIds: number[]) => {
formData.prisonerIds = selectedIds

View File

@ -2,7 +2,7 @@
<el-dialog
v-model="dialogVisible"
:title="taskDetail?.taskName || '任务详情'"
width="900px"
width="1100px"
:close-on-click-modal="false"
>
<div v-loading="loading">
@ -20,144 +20,322 @@
<el-descriptions-item label="截止时间">{{ formatDateTime(taskDetail?.deadline) }}</el-descriptions-item>
</el-descriptions>
<!-- 完成进度 -->
<el-card class="mb-20px">
<template #header>
<div class="card-header">
<span>完成进度</span>
</div>
</template>
<el-row :gutter="20">
<el-col :span="6">
<div class="progress-item">
<div class="progress-value">{{ taskProgress?.totalCount || 0 }}</div>
<div class="progress-label">目标人数</div>
</div>
</el-col>
<el-col :span="6">
<div class="progress-item">
<div class="progress-value text-success">{{ taskProgress?.completedCount || 0 }}</div>
<div class="progress-label">已完成</div>
</div>
</el-col>
<el-col :span="6">
<div class="progress-item">
<div class="progress-value text-warning">{{ taskProgress?.pendingCount || 0 }}</div>
<div class="progress-label">待完成</div>
</div>
</el-col>
<el-col :span="6">
<div class="progress-item">
<div class="progress-value text-primary">{{ taskProgress?.completionRate }}%</div>
<div class="progress-label">完成率</div>
</div>
</el-col>
</el-row>
<div class="mt-20px">
<el-progress
:percentage="Number(taskProgress?.completionRate) || 0"
:stroke-width="20"
status="success"
/>
</div>
<!-- 状态分布 -->
<el-row :gutter="20" class="mt-20px">
<el-col :span="8">
<el-statistic title="待测评" :value="taskProgress?.statusBreakdown?.pending || 0">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="测评中" :value="taskProgress?.statusBreakdown?.inProgress || 0">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="已完成" :value="taskProgress?.statusBreakdown?.completed || 0">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
</el-row>
</el-card>
<!-- 按监区统计 -->
<el-card>
<template #header>
<div class="card-header">
<span>按监区统计</span>
<el-button
v-if="taskProgress?.pendingCount > 0"
type="primary"
size="small"
@click="handleRemind"
:loading="remindLoading"
>
提醒未完成
</el-button>
</div>
</template>
<el-table :data="areaStatistics" stripe>
<el-table-column label="监区" align="center" prop="areaName" width="120" />
<el-table-column label="目标人数" align="center" prop="totalCount" width="100" />
<el-table-column label="已完成" align="center" prop="completedCount" width="100">
<template #default="scope">
<span class="text-success">{{ scope.row.completedCount }}</span>
<!-- 标签页 -->
<el-tabs v-model="activeTab" class="task-tabs" @tab-change="handleTabChange">
<!-- 任务概览 -->
<el-tab-pane label="任务概览" name="overview">
<!-- 完成进度 -->
<el-card class="mb-20px">
<template #header>
<div class="card-header">
<span>完成进度</span>
</div>
</template>
</el-table-column>
<el-table-column label="完成率" align="center" width="150">
<template #default="scope">
<el-row :gutter="20">
<el-col :span="6">
<div class="progress-item">
<div class="progress-value">{{ taskProgress?.totalCount || 0 }}</div>
<div class="progress-label">目标人数</div>
</div>
</el-col>
<el-col :span="6">
<div class="progress-item">
<div class="progress-value text-success">{{ taskProgress?.completedCount || 0 }}</div>
<div class="progress-label">已完成</div>
</div>
</el-col>
<el-col :span="6">
<div class="progress-item">
<div class="progress-value text-warning">{{ taskProgress?.pendingCount || 0 }}</div>
<div class="progress-label">待完成</div>
</div>
</el-col>
<el-col :span="6">
<div class="progress-item">
<div class="progress-value text-primary">{{ taskProgress?.completionRate }}%</div>
<div class="progress-label">完成率</div>
</div>
</el-col>
</el-row>
<div class="mt-20px">
<el-progress
:percentage="Number(scope.row.completionRate) || 0"
:stroke-width="6"
:percentage="Number(taskProgress?.completionRate) || 0"
:stroke-width="20"
status="success"
/>
</div>
<!-- 状态分布 -->
<el-row :gutter="20" class="mt-20px">
<el-col :span="8">
<el-statistic title="待测评" :value="taskProgress?.statusBreakdown?.pending || 0">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="测评中" :value="taskProgress?.statusBreakdown?.inProgress || 0">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="已完成" :value="taskProgress?.statusBreakdown?.completed || 0">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
</el-row>
</el-card>
<!-- 按监区统计 -->
<el-card>
<template #header>
<div class="card-header">
<span>按监区统计</span>
<el-button
v-if="taskProgress?.pendingCount > 0"
type="primary"
size="small"
@click="handleRemind"
:loading="remindLoading"
>
提醒未完成
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="平均分" align="center" prop="avgScore" width="100">
<template #default="scope">
{{ scope.row.avgScore || '-' }}
<el-table :data="areaStatistics" stripe>
<el-table-column label="监区" align="center" prop="areaName" width="120" />
<el-table-column label="目标人数" align="center" prop="totalCount" width="100" />
<el-table-column label="已完成" align="center" prop="completedCount" width="100">
<template #default="scope">
<span class="text-success">{{ scope.row.completedCount }}</span>
</template>
</el-table-column>
<el-table-column label="完成率" align="center" width="150">
<template #default="scope">
<el-progress
:percentage="Number(scope.row.completionRate) || 0"
:stroke-width="6"
/>
</template>
</el-table-column>
<el-table-column label="平均分" align="center" prop="avgScore" width="100">
<template #default="scope">
{{ scope.row.avgScore || '-' }}
</template>
</el-table-column>
<el-table-column label="及格率" align="center" prop="passRate" width="120">
<template #default="scope">
{{ scope.row.passRate ? scope.row.passRate + '%' : '-' }}
</template>
</el-table-column>
<el-table-column label="风险分布" align="center" min-width="150">
<template #default="scope">
<el-tag type="danger" size="small" class="mr-5px">
{{ scope.row.riskDistribution?.highRisk || 0 }}
</el-tag>
<el-tag type="warning" size="small" class="mr-5px">
{{ scope.row.riskDistribution?.mediumRisk || 0 }}
</el-tag>
<el-tag type="success" size="small">
{{ scope.row.riskDistribution?.lowRisk || 0 }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<!-- 人员填写进度 -->
<el-tab-pane label="人员填写进度" name="prisoners">
<el-card>
<template #header>
<div class="card-header">
<span>人员填写进度列表</span>
<div class="filter-actions">
<el-select
v-model="prisonerFilter.status"
placeholder="填写状态"
clearable
size="small"
class="filter-select"
@change="handlePrisonerFilterChange"
>
<el-option label="全部" :value="undefined" />
<el-option label="待测评" :value="1" />
<el-option label="测评中" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已取消" :value="4" />
</el-select>
<el-select
v-model="prisonerFilter.areaId"
placeholder="监区"
clearable
size="small"
class="filter-select"
@change="handlePrisonerFilterChange"
>
<el-option label="全部" :value="undefined" />
<el-option
v-for="area in areaStatistics"
:key="area.areaId"
:label="area.areaName"
:value="area.areaId ?? ''"
/>
</el-select>
<el-select
v-model="prisonerFilter.riskLevel"
placeholder="风险等级"
clearable
size="small"
class="filter-select"
@change="handlePrisonerFilterChange"
>
<el-option label="全部" :value="undefined" />
<el-option label="低风险" :value="1" />
<el-option label="中风险" :value="2" />
<el-option label="高风险" :value="3" />
</el-select>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="及格率" align="center" prop="passRate" width="120">
<template #default="scope">
{{ scope.row.passRate ? scope.row.passRate + '%' : '-' }}
</template>
</el-table-column>
<el-table-column label="风险分布" align="center" min-width="150">
<template #default="scope">
<el-tag type="danger" size="small" class="mr-5px">
{{ scope.row.riskDistribution?.highRisk || 0 }}
</el-tag>
<el-tag type="warning" size="small" class="mr-5px">
{{ scope.row.riskDistribution?.mediumRisk || 0 }}
</el-tag>
<el-tag type="success" size="small">
{{ scope.row.riskDistribution?.lowRisk || 0 }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 统计摘要 -->
<el-row :gutter="20" class="mb-20px">
<el-col :span="6">
<el-statistic title="总人数" :value="filteredPrisoners.length">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="待测评" :value="prisonerStats.pending">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="测评中" :value="prisonerStats.inProgress">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="已完成" :value="prisonerStats.completed">
<template #suffix>
<span class="text-gray"></span>
</template>
</el-statistic>
</el-col>
</el-row>
<!-- 人员列表 -->
<el-table :data="filteredPrisoners" stripe v-loading="prisonersLoading">
<el-table-column label="罪犯编号" align="center" prop="prisonerNo" width="120" />
<el-table-column label="罪犯姓名" align="center" prop="prisonerName" width="100" />
<el-table-column label="监区" align="center" prop="areaName" width="120" />
<el-table-column label="填写状态" align="center" prop="status" width="100">
<template #default="scope">
<el-tag :type="getStatusTag(scope.row.status)" size="small">
{{ getStatusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="客观分" align="center" prop="objectiveScore" width="90" />
<el-table-column label="主观分" align="center" prop="subjectiveScore" width="90" />
<el-table-column label="总分" align="center" prop="totalScore" width="90" />
<el-table-column label="风险等级" align="center" prop="riskLevel" width="100">
<template #default="scope">
<el-tag v-if="scope.row.riskLevel" :type="getRiskTag(scope.row.riskLevel)" size="small">
{{ getRiskLabel(scope.row.riskLevel) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="答题用时" align="center" prop="duration" width="100">
<template #default="scope">
{{ scope.row.duration ? formatDuration(scope.row.duration) : '-' }}
</template>
</el-table-column>
<el-table-column label="完成时间" align="center" prop="finishTime" width="160">
<template #default="scope">
{{ scope.row.finishTime ? formatDateTime(scope.row.finishTime) : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="320" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 3"
link
type="primary"
size="small"
@click="handleViewAnswer(scope.row)"
>
查看答案
</el-button>
<el-button
v-if="scope.row.status === 1"
link
type="success"
size="small"
@click="handleNotifyPrisoner(scope.row)"
>
通知
</el-button>
<el-button
v-if="scope.row.status === 1 || scope.row.status === 2"
link
type="warning"
size="small"
@click="handleAgentFill(scope.row)"
v-hasPermi="['prison:questionnaire-record:agent-fill']"
>
代填
</el-button>
<el-button
v-if="scope.row.status === 2 || scope.row.status === 3"
link
type="info"
size="small"
@click="handleResetRecord(scope.row)"
>
重置
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="dialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 答案详情弹窗 -->
<AnswerDetailDialog ref="answerDetailDialogRef" />
<!-- 代填弹窗 -->
<AgentFillDialog ref="agentFillDialogRef" @success="loadPrisonerProgress" />
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatDateTime } from '@/utils/formatTime'
import AnswerDetailDialog from '@/views/prison/questionnairerecord/AnswerDetailDialog.vue'
import AgentFillDialog from './AgentFillDialog.vue'
import { QuestionnaireTaskApi } from '@/api/prison/questionnaire-task'
defineOptions({ name: 'TaskDetailDialog' })
@ -165,7 +343,15 @@ defineOptions({ name: 'TaskDetailDialog' })
const dialogVisible = ref(false)
const loading = ref(false)
const remindLoading = ref(false)
const prisonersLoading = ref(false)
const taskId = ref<number>()
const activeTab = ref('overview')
//
const answerDetailDialogRef = ref()
//
const agentFillDialogRef = ref()
//
const taskDetail = ref<any>(null)
@ -176,6 +362,16 @@ const taskProgress = ref<any>(null)
//
const areaStatistics = ref<any[]>([])
//
const prisonerProgressList = ref<any[]>([])
//
const prisonerFilter = reactive({
status: undefined as number | undefined,
areaId: undefined as number | undefined,
riskLevel: undefined as number | undefined
})
//
const taskStatusOptions = [
{ value: 1, label: '草稿', type: 'info' },
@ -184,6 +380,44 @@ const taskStatusOptions = [
{ value: 4, label: '已取消', type: 'danger' }
]
//
const riskLevelOptions = [
{ value: 1, label: '低风险', type: 'success' },
{ value: 2, label: '中风险', type: 'warning' },
{ value: 3, label: '高风险', type: 'danger' }
]
/** 筛选后的人员列表 */
const filteredPrisoners = computed(() => {
return prisonerProgressList.value.filter(item => {
if (prisonerFilter.status !== undefined && item.status !== prisonerFilter.status) {
return false
}
if (prisonerFilter.areaId !== undefined && item.areaId !== prisonerFilter.areaId) {
return false
}
if (prisonerFilter.riskLevel !== undefined && item.riskLevel !== prisonerFilter.riskLevel) {
return false
}
return true
})
})
/** 人员统计 */
const prisonerStats = computed(() => {
const stats = {
pending: 0,
inProgress: 0,
completed: 0
}
filteredPrisoners.value.forEach(item => {
if (item.status === 1) stats.pending++
else if (item.status === 2) stats.inProgress++
else if (item.status === 3) stats.completed++
})
return stats
})
/** 获取状态标签类型 */
const getStatusTag = (status: number | undefined) => {
if (!status) return 'info'
@ -198,10 +432,37 @@ const getStatusLabel = (status: number | undefined) => {
return item?.label || '未知'
}
/** 获取风险标签类型 */
const getRiskTag = (level: number) => {
const item = riskLevelOptions.find(item => item.value === level)
return item?.type || 'info'
}
/** 获取风险标签文本 */
const getRiskLabel = (level: number) => {
const item = riskLevelOptions.find(item => item.value === level)
return item?.label || '-'
}
/** 格式化时长 */
const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}${minutes}${secs}`
} else if (minutes > 0) {
return `${minutes}${secs}`
} else {
return `${secs}`
}
}
/** 打开弹窗 */
const open = async (id: number) => {
dialogVisible.value = true
taskId.value = id
activeTab.value = 'overview'
loading.value = true
try {
@ -229,6 +490,89 @@ const open = async (id: number) => {
}
}
/** 切换到人员标签页时加载人员数据 */
const loadPrisonerProgress = async () => {
if (!taskId.value) return
prisonersLoading.value = true
try {
const data = await QuestionnaireTaskApi.getPrisonerProgress(taskId.value)
prisonerProgressList.value = data || []
} catch (e) {
console.error('获取人员进度失败', e)
ElMessage.error('获取人员进度失败')
} finally {
prisonersLoading.value = false
}
}
/** 监听标签页切换 */
const handleTabChange = (tabName: string) => {
if (tabName === 'prisoners' && prisonerProgressList.value.length === 0) {
loadPrisonerProgress()
}
}
/** 筛选条件变化 */
const handlePrisonerFilterChange = () => {
// ,
}
/** 查看答案 */
const handleViewAnswer = (row: any) => {
if (!row.id) {
ElMessage.warning('该人员暂无答题记录')
return
}
answerDetailDialogRef.value?.open(row.id)
}
/** 通知人员 */
const handleNotifyPrisoner = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要通知「${row.prisonerName}」完成问卷吗?`,
'通知确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}
)
await QuestionnaireTaskApi.notifyPrisoner(row.id)
ElMessage.success('通知已发送')
} catch (e) {
//
}
}
/** 重置答题记录 */
const handleResetRecord = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要重置「${row.prisonerName}」的答题记录吗?重置后需要重新填写。`,
'重置确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await QuestionnaireTaskApi.resetPrisonerRecord(row.id)
ElMessage.success('重置成功')
loadPrisonerProgress()
} catch (e) {
//
}
}
/** 代为填写 */
const handleAgentFill = (row: any) => {
agentFillDialogRef.value?.open(row)
}
/** 提醒未完成人员 */
const handleRemind = async () => {
if (!taskId.value) return
@ -290,4 +634,26 @@ defineExpose({ open })
.text-gray {
color: #909399;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.filter-actions {
display: flex;
gap: 10px;
.filter-select {
width: 150px;
}
}
}
.task-tabs {
:deep(.el-tabs__content) {
max-height: 600px;
overflow-y: auto;
}
}
</style>

View File

@ -52,6 +52,23 @@
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="handleCreate"
v-hasPermi="['prison:questionnaire-task:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 创建任务
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['prison:questionnaire-task:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
</el-form-item>
</el-form>
</ContentWrap>

View File

@ -37,6 +37,9 @@
<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>
@ -163,6 +166,19 @@
<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>
<el-button @click="dialogVisible = false"> </el-button>
</template>
@ -176,6 +192,7 @@ import { formatDateTime } from '@/utils/formatTime'
import { QuestionnaireRecordApi, type QuestionnaireRecord } from '@/api/prison/questionnairerecord'
import { QuestionApi, type Question } from '@/api/prison/question'
import { AnswerApi, type Answer } from '@/api/prison/answer'
import QuestionnairePreview from '@/views/prison/questionnaire/components/QuestionnairePreview.vue'
import { getIntDictOptions } from '@/utils/dict'
defineOptions({ name: 'AnswerDetailDialog' })
@ -184,6 +201,10 @@ const dialogVisible = ref(false)
const title = ref('答题详情')
const loading = ref(false)
//
const previewVisible = ref(false)
const previewQuestionnaireId = ref<number>()
//
const recordInfo = ref<QuestionnaireRecord>({
id: undefined,
@ -349,6 +370,15 @@ const open = async (recordId: number) => {
}
}
/** 预览问卷 */
const handlePreviewQuestionnaire = () => {
if (!recordInfo.value.questionnaireId) {
return
}
previewQuestionnaireId.value = recordInfo.value.questionnaireId
previewVisible.value = true
}
defineExpose({ open })
</script>