feat: 新增问卷任务管理模块

- 新增问卷任务页面及组件(创建任务、人员选择、任务详情)
- 新增问卷预览组件
- 新增答题详情对话框
- 优化问卷列表和问卷记录页面
- 优化Dashboard风险趋势图Y轴动态缩放
- 更新评估报告导出页面
This commit is contained in:
tangweijie 2026-01-24 10:56:02 +08:00
parent 230021a7b6
commit 5d43154ba5
13 changed files with 2833 additions and 60 deletions

View File

@ -0,0 +1,189 @@
import request from '@/config/axios'
/** 问卷任务信息 */
export interface QuestionnaireTask {
id?: number
taskName: string
questionnaireId: number
questionnaireName?: string
targetType: number // 1-指定犯人 2-指定监区 3-全部犯人
areaId?: number
areaName?: string
prisonerIds?: string
startTime?: string
deadline: string
status: number // 1-草稿 2-进行中 3-已结束 4-已取消
totalCount?: number
completedCount?: number
pendingCount?: number
completionRate?: string | number
remark?: string
createTime?: string
updateTime?: string
}
/** 问卷任务分页参数 */
export interface QuestionnaireTaskPageParams {
pageNo: number
pageSize: number
taskName?: string
questionnaireId?: number
status?: number
targetType?: number
createTime?: string[]
}
/** 问卷任务创建参数 */
export interface QuestionnaireTaskCreateParams {
taskName: string
questionnaireId: number
targetType: number
prisonerIds?: number[]
areaId?: number
startTime?: string
deadline: string
remark?: string
}
/** 问卷任务更新参数 */
export interface QuestionnaireTaskUpdateParams {
id: number
taskName?: string
deadline?: string
remark?: string
}
/** 任务进度详情 */
export interface TaskProgress {
taskId: number
taskName: string
questionnaireName: string
status: number
startTime?: string
deadline: string
totalCount: number
completedCount: number
pendingCount: number
completionRate: string | number
statusBreakdown: {
pending: number
inProgress: number
completed: number
expired: number
cancelled: number
}
}
/** 按监区统计 */
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 TaskStatisticsSummary {
taskCount: number
totalPrisoners: number
totalCompleted: number
totalPending: number
overallCompletionRate: string | number
}
// 问卷任务 API
export const QuestionnaireTaskApi = {
// 查询问卷任务分页
getQuestionnaireTaskPage: async (params: QuestionnaireTaskPageParams) => {
return await request.get({ url: `/prison/questionnaire-task/page`, params })
},
// 查询问卷任务详情
getQuestionnaireTask: async (id: number) => {
return await request.get({ url: `/prison/questionnaire-task/get`, params: { id } })
},
// 新增问卷任务
createQuestionnaireTask: async (data: QuestionnaireTaskCreateParams) => {
return await request.post({ url: `/prison/questionnaire-task/create`, data })
},
// 修改问卷任务
updateQuestionnaireTask: async (data: QuestionnaireTaskUpdateParams) => {
return await request.put({ url: `/prison/questionnaire-task/update`, data })
},
// 删除问卷任务
deleteQuestionnaireTask: async (id: number) => {
return await request.delete({ url: `/prison/questionnaire-task/delete`, params: { id } })
},
// 批量删除问卷任务
deleteQuestionnaireTaskList: async (ids: number[]) => {
return await request.delete({ url: `/prison/questionnaire-task/delete-list`, params: { ids: ids.join(',') } })
},
// 导出问卷任务 Excel
exportQuestionnaireTask: async (params: QuestionnaireTaskPageParams) => {
return await request.download({ url: `/prison/questionnaire-task/export-excel`, params })
},
// ==================== 任务执行相关 ====================
// 取消任务
cancelTask: async (id: number) => {
return await request.post({ url: `/prison/questionnaire-task/cancel`, params: { id } })
},
// 结束任务
finishTask: async (id: number) => {
return await request.post({ url: `/prison/questionnaire-task/finish`, params: { id } })
},
// 重新开始任务
restartTask: async (id: number) => {
return await request.post({ url: `/prison/questionnaire-task/restart`, params: { id } })
},
// ==================== 进度跟踪相关 ====================
// 获取任务进度
getTaskProgress: async (id: number) => {
return await request.get<TaskProgress>({ url: `/prison/questionnaire-task/progress`, params: { id } })
},
// 获取任务未完成人员
getPendingPrisoners: async (id: number, params: any) => {
return await request.get({ url: `/prison/questionnaire-task/pending-prisoners`, params: { id, ...params } })
},
// 提醒未完成人员
remindPendingPrisoners: async (id: number) => {
return await request.post({ url: `/prison/questionnaire-task/remind`, params: { id } })
},
// ==================== 统计相关 ====================
// 按监区统计任务完成情况
getTaskAreaStatistics: async (id: number) => {
return await request.get<TaskAreaStatistics[]>({ url: `/prison/questionnaire-task/area-statistics`, params: { id } })
},
// 获取全局任务统计汇总
getStatisticsSummary: async () => {
return await request.get<TaskStatisticsSummary>({ url: `/prison/questionnaire-task/statistics-summary` })
},
// 按监区对比分析
compareAreasByQuestionnaire: async (questionnaireId?: number, areaIds?: number[]) => {
return await request.get({ url: `/prison/questionnaire-task/area-comparison`, params: { questionnaireId, areaIds } })
}
}

View File

@ -52,19 +52,19 @@
<div class="list-container">
<div class="list-card-item">
<div class="list-card-item-icon icon-location"></div>
<div class="list-card-item-value">108</div>
<div class="list-card-item-value">-</div>
</div>
<div class="list-card-item">
<div class="list-card-item-icon icon-person"></div>
<div class="list-card-item-value">108</div>
<div class="list-card-item-value">-</div>
</div>
<div class="list-card-item">
<div class="list-card-item-icon icon-person2"></div>
<div class="list-card-item-value">108</div>
<div class="list-card-item-value">-</div>
</div>
<div class="list-card-item">
<div class="list-card-item-icon icon-car"></div>
<div class="list-card-item-value">108</div>
<div class="list-card-item-value">-</div>
</div>
</div>
</div>
@ -109,6 +109,8 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { DashboardApi } from '@/api/prison/dashboard'
import { SituationApi } from '@/api/prison/situation'
import { ScoreApi } from '@/api/prison/score'
defineOptions({ name: 'Dashboard' })
@ -118,51 +120,51 @@ const route = useRoute()
const gaugeValue = ref(0)
const gaugeName = ref('')
//
// -
const centerLeftData = ref({
top: {
value: '0',
label: '加载中...'
label: '累计服刑天数'
},
middle: {
left: {
value: '0',
label: '加载中...'
label: '剩余刑期天数'
},
right: {
value: '0%',
label: '加载中...'
value: '0',
label: '累计违规次数'
}
},
bottom: {
value: '0位',
label: '加载中...'
value: '-',
label: '累计表扬天数'
}
})
//
// -
const centerRightData = ref({
top: {
value: '0',
label: '加载中...'
label: '累计扣分次数'
},
middle: {
left: {
value: '0',
label: '加载中...'
label: '累计加分次数'
},
right: {
value: '0',
label: '加载中...'
value: '-',
label: '本月消费'
}
},
bottomLeft: {
value: '0位',
label: '加载中...'
value: '-',
label: '本月奖励'
},
bottomRight: {
value: '0辆',
label: '加载中...'
value: '-',
label: '本月惩罚'
}
})
@ -272,30 +274,71 @@ const loadData = async (prisonerId: number) => {
//
prisonerName.value = res.prisonerName || '未知'
//
// -
gaugeValue.value = res.riskScore || 0
gaugeName.value = ''
//
const servedDays = res.servedDays || 0;
let remainingDays = 0;
if (res.imprisonmentDate && res.releaseDate) {
const startDate = new Date(res.imprisonmentDate);
const endDate = new Date(res.releaseDate);
const totalDays = Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
remainingDays = Math.max(0, totalDays - servedDays);
}
// -
let totalPenaltyCount = 0;
let totalRewardCount = 0;
try {
const scoreRes = await ScoreApi.getScorePage({
pageNo: 1,
pageSize: 200,
prisonerNo: res.prisonerNo
})
if (scoreRes.list && scoreRes.list.length > 0) {
totalRewardCount = scoreRes.list.filter((item: any) => item.rewardScore > 0).length
totalPenaltyCount = scoreRes.list.filter((item: any) => item.penaltyScore > 0).length
}
} catch (error) {
console.error('获取计分考核数据失败:', error)
}
// -
let totalViolationCount = 0
try {
const situationRes = await SituationApi.getSituationPage({
pageNo: 1,
pageSize: 200
})
if (situationRes.list && situationRes.list.length > 0) {
totalViolationCount = situationRes.list.length
}
} catch (error) {
console.error('获取狱情收集数据失败:', error)
}
//
if (res.centerLeftData) {
centerLeftData.value = {
top: {
value: res.centerLeftData.topValue || '0',
label: res.centerLeftData.topLabel || ''
label: res.centerLeftData.topLabel || '本月消费'
},
middle: {
left: {
value: res.centerLeftData.middleLeftValue || '0',
label: res.centerLeftData.middleLeftLabel || ''
label: res.centerLeftData.middleLeftLabel || '本月奖励'
},
right: {
value: res.centerLeftData.middleRightValue || '0',
label: res.centerLeftData.middleRightLabel || ''
label: res.centerLeftData.middleRightLabel || '本月惩罚'
}
},
bottom: {
value: res.centerLeftData.bottomValue || '0位',
label: res.centerLeftData.bottomLabel || ''
value: res.centerLeftData.bottomValue || '0',
label: res.centerLeftData.bottomLabel || '账户余额'
}
}
}
@ -305,25 +348,25 @@ const loadData = async (prisonerId: number) => {
centerRightData.value = {
top: {
value: res.centerRightData.topValue || '0',
label: res.centerRightData.topLabel || ''
label: res.centerRightData.topLabel || '本月得分'
},
middle: {
left: {
value: res.centerRightData.middleLeftValue || '0',
label: res.centerRightData.middleLeftLabel || ''
label: res.centerRightData.middleLeftLabel || '基础分'
},
right: {
value: res.centerRightData.middleRightValue || '0',
label: res.centerRightData.middleRightLabel || ''
label: res.centerRightData.middleRightLabel || '加分项'
}
},
bottomLeft: {
value: res.centerRightData.bottomLeftValue || '0',
label: res.centerRightData.bottomLeftLabel || ''
value: res.centerRightData.bottomLeftValue || '0',
label: res.centerRightData.bottomLeftLabel || '扣分项'
},
bottomRight: {
value: res.centerRightData.bottomRightValue || '0辆',
label: res.centerRightData.bottomRightLabel || ''
value: res.centerRightData.bottomRightValue || '暂无',
label: res.centerRightData.bottomRightLabel || '考核等级'
}
}
}

View File

@ -62,8 +62,8 @@ const props = withDefaults(
//
const createChartOption = (): EChartsOption => {
const categories = props.data.map((item) => item.category)
const monthlyStandardData = props.data.map((item) => item.monthlyStandard)
const perCapitaData = props.data.map((item) => item.perCapita)
const monthlyStandardData = props.data.map((item) => item.monthlyStandard ?? 0)
const perCapitaData = props.data.map((item) => item.perCapita ?? 0)
// 50
const maxValue = 50

View File

@ -139,6 +139,13 @@
>
<Icon icon="ep:view" /> 查看
</el-button>
<el-button
link
type="success"
@click="handlePreview(row)"
>
<Icon icon="ep:view" /> 预览
</el-button>
<el-button
v-if="row.status === 1 || row.status === 4"
link
@ -183,6 +190,9 @@
<!-- 报告详情弹窗 -->
<ReportDetailDialog ref="detailDialogRef" />
<!-- 报告预览弹窗 -->
<ReportPreviewDialog ref="previewDialogRef" />
</template>
<script lang="ts" setup>
@ -191,6 +201,7 @@ import download from '@/utils/download'
import { EvaluationReportApi, EvaluationReport, EvaluationTemplateApi } from '@/api/prison/evaluation'
import CreateReportDialog from './CreateReportDialog.vue'
import ReportDetailDialog from './ReportDetailDialog.vue'
import ReportPreviewDialog from './components/ReportPreviewDialog.vue'
defineOptions({ name: 'EvaluationReport' })
@ -283,6 +294,12 @@ const handleView = (row: EvaluationReport) => {
detailDialogRef.value?.open(row.id)
}
/** 预览报告 */
const previewDialogRef = ref()
const handlePreview = (row: EvaluationReport) => {
previewDialogRef.value?.open(row.id!)
}
/** 编辑报告 */
const handleEdit = (row: EvaluationReport) => {
router.push({

View File

@ -0,0 +1,300 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑任务' : '创建问卷任务'"
width="600px"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-form-item label="任务名称" prop="taskName">
<el-input v-model="formData.taskName" placeholder="请输入任务名称" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="选择问卷" prop="questionnaireId">
<el-select
v-model="formData.questionnaireId"
placeholder="请选择问卷"
filterable
style="width: 100%"
>
<el-option
v-for="item in questionnaireList"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
</el-form-item>
<!-- 目标范围 -->
<el-divider content-position="left">目标范围</el-divider>
<el-form-item label="目标类型" prop="targetType">
<el-radio-group v-model="formData.targetType">
<el-radio :value="1">指定犯人</el-radio>
<el-radio :value="2">指定监区</el-radio>
<el-radio :value="3">全部犯人</el-radio>
</el-radio-group>
</el-form-item>
<!-- 指定犯人 -->
<el-form-item v-if="formData.targetType === 1" label="选择犯人" prop="prisonerIds">
<el-button type="primary" plain @click="openPrisonerSelector">
<Icon icon="ep:user" class="mr-5px" />
选择犯人已选 {{ formData.prisonerIds.length }}
</el-button>
</el-form-item>
<!-- 指定监区 -->
<el-form-item v-if="formData.targetType === 2" label="选择监区" prop="areaId">
<el-select
v-model="formData.areaId"
placeholder="请选择监区"
filterable
style="width: 100%"
>
<el-option
v-for="item in areaList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<!-- 时间设置 -->
<el-divider content-position="left">时间设置</el-divider>
<el-form-item label="开始时间" prop="startTime">
<el-date-picker
v-model="formData.startTime"
type="datetime"
placeholder="选择开始时间"
style="width: 100%"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item label="截止时间" prop="deadline">
<el-date-picker
v-model="formData.deadline"
type="datetime"
placeholder="选择截止时间"
style="width: 100%"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<!-- 备注 -->
<el-divider content-position="left">备注</el-divider>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<!-- 犯人选择器弹窗 -->
<PrisonerSelectorDialog
ref="prisonerSelectorRef"
:selected-ids="formData.prisonerIds"
@confirm="handlePrisonerSelect"
/>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import PrisonerSelectorDialog from './PrisonerSelectorDialog.vue'
import { QuestionnaireTaskApi } from '@/api/prison/questionnaire-task'
import { QuestionnaireApi } from '@/api/prison/questionnaire'
import { AreaApi } from '@/api/prison/area'
defineOptions({ name: 'CreateTaskDialog' })
const emit = defineEmits(['success'])
const dialogVisible = ref(false)
const submitLoading = ref(false)
const isEdit = ref(false)
const formRef = ref()
//
const formData = reactive({
id: undefined,
taskName: '',
questionnaireId: undefined as number | undefined,
targetType: 2, //
prisonerIds: [] as number[],
areaId: undefined as number | undefined,
startTime: '',
deadline: '',
remark: ''
})
//
const formRules = {
taskName: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
questionnaireId: [{ required: true, message: '请选择问卷', trigger: 'change' }],
targetType: [{ required: true, message: '请选择目标类型', trigger: 'change' }],
deadline: [{ required: true, message: '请选择截止时间', trigger: 'change' }]
}
//
const questionnaireList = ref<any[]>([])
const areaList = ref<any[]>([])
const prisonerSelectorRef = ref()
/** 打开弹窗 */
const open = async (row?: any) => {
dialogVisible.value = true
isEdit.value = !!row
if (row) {
//
formData.id = row.id
formData.taskName = row.taskName
formData.questionnaireId = row.questionnaireId
formData.targetType = row.targetType
formData.areaId = row.areaId
formData.startTime = row.startTime || ''
formData.deadline = row.deadline || ''
formData.remark = row.remark || ''
} else {
//
formData.id = undefined
formData.taskName = ''
formData.questionnaireId = undefined
formData.targetType = 2
formData.prisonerIds = []
formData.areaId = undefined
formData.startTime = ''
formData.deadline = ''
formData.remark = ''
}
//
await Promise.all([getQuestionnaires(), getAreas()])
}
/** 获取问卷列表 */
const getQuestionnaires = async () => {
try {
const data = await QuestionnaireApi.getQuestionnairePage({
pageNo: 1,
pageSize: 100,
status: 2
})
questionnaireList.value = data.list || []
} catch (e) {
console.error('获取问卷列表失败', e)
}
}
/** 获取监区列表 */
const getAreas = async () => {
try {
const data = await AreaApi.getAreaTree({})
//
const extractAreas = (nodes: any[]): any[] => {
const result: any[] = []
nodes.forEach(node => {
result.push({ id: node.id, name: node.name })
if (node.children && node.children.length > 0) {
result.push(...extractAreas(node.children))
}
})
return result
}
areaList.value = extractAreas(data || [])
} catch (e) {
console.error('获取监区列表失败', e)
}
}
/** 打开犯人选择器 */
const openPrisonerSelector = () => {
prisonerSelectorRef.value?.open(formData.prisonerIds)
}
/** 犯人选择确认 */
const handlePrisonerSelect = (selectedIds: number[]) => {
formData.prisonerIds = selectedIds
}
/** 提交 */
const handleSubmit = async () => {
try {
await formRef.value?.validate()
} catch (e) {
return
}
submitLoading.value = true
try {
const data = {
taskName: formData.taskName,
questionnaireId: formData.questionnaireId,
targetType: formData.targetType,
prisonerIds: formData.targetType === 1 ? formData.prisonerIds : undefined,
areaId: formData.targetType === 2 ? formData.areaId : undefined,
startTime: formData.startTime || undefined,
deadline: formData.deadline,
remark: formData.remark || undefined
}
if (isEdit.value) {
await QuestionnaireTaskApi.updateQuestionnaireTask({
id: formData.id,
taskName: formData.taskName,
deadline: formData.deadline,
remark: formData.remark
})
ElMessage.success('修改成功')
} else {
await QuestionnaireTaskApi.createQuestionnaireTask(data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
emit('success')
} catch (e) {
console.error('提交失败', e)
} finally {
submitLoading.value = false
}
}
//
watch(() => formData.targetType, (val) => {
if (val === 1) {
formData.areaId = undefined
} else if (val === 2) {
formData.prisonerIds = []
}
})
defineExpose({ open })
</script>

View File

@ -0,0 +1,200 @@
<template>
<el-dialog
v-model="dialogVisible"
title="选择犯人"
width="800px"
:close-on-click-modal="false"
>
<!-- 搜索区域 -->
<el-form :model="queryParams" :inline="true" class="mb-20px">
<el-form-item label="姓名">
<el-input v-model="queryParams.name" placeholder="请输入姓名" clearable style="width: 150px" />
</el-form-item>
<el-form-item label="编号">
<el-input v-model="queryParams.prisonerNo" placeholder="请输入编号" clearable style="width: 150px" />
</el-form-item>
<el-form-item label="监区">
<el-select v-model="queryParams.areaId" placeholder="请选择监区" clearable style="width: 150px">
<el-option v-for="item in areaList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="handleReset"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="list"
@selection-change="handleSelectionChange"
:row-key="(row: any) => row.id"
>
<el-table-column type="selection" width="55" :reserve-selection="true" />
<el-table-column label="编号" align="center" prop="prisonerNo" width="100" />
<el-table-column label="姓名" align="center" prop="name" width="100" />
<el-table-column label="监区" align="center" prop="prisonAreaName" width="120" />
<el-table-column label="性别" align="center" prop="gender" width="80">
<template #default="scope">
{{ scope.row.gender === 1 ? '男' : '女' }}
</template>
</el-table-column>
<el-table-column label="入狱日期" align="center" prop="prisonDate" width="120">
<template #default="scope">
{{ formatDate(scope.row.prisonDate) }}
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
{{ scope.row.status === 1 ? '在押' : '释放' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<!-- 已选统计 -->
<div class="mt-20px">
<el-tag type="primary" size="large">已选择 {{ selectedList.length }} </el-tag>
<el-button v-if="selectedList.length > 0" type="warning" text @click="clearSelection" class="ml-10px">
清空选择
</el-button>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleConfirm">
确定已选 {{ selectedList.length }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { formatDate } from '@/utils/formatTime'
import { PrisonerApi } from '@/api/prison/prisoner'
import { AreaApi } from '@/api/prison/area'
defineOptions({ name: 'PrisonerSelectorDialog' })
const emit = defineEmits(['confirm'])
const dialogVisible = ref(false)
const loading = ref(false)
const tableRef = ref()
const list = ref<any[]>([])
const total = ref(0)
const selectedList = ref<any[]>([])
const areaList = ref<any[]>([])
//
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: '',
prisonerNo: '',
areaId: undefined as number | undefined,
status: 1 //
})
// ID
const currentSelectedIds = ref<number[]>([])
/** 打开弹窗 */
const open = (selectedIds: number[]) => {
dialogVisible.value = true
currentSelectedIds.value = [...selectedIds]
selectedList.value = []
queryParams.pageNo = 1
getList()
getAreas()
}
/** 获取列表 */
const getList = async () => {
loading.value = true
try {
const data = await PrisonerApi.getPrisonerPage(queryParams)
list.value = data.list || []
total.value = data.total
//
nextTick(() => {
currentSelectedIds.value.forEach(id => {
const row = list.value.find((item: any) => item.id === id)
if (row) {
tableRef.value?.toggleRowSelection(row, true)
}
})
})
} finally {
loading.value = false
}
}
/** 获取监区列表 */
const getAreas = async () => {
try {
const data = await AreaApi.getAreaTree({})
//
const extractAreas = (nodes: any[]): any[] => {
const result: any[] = []
nodes.forEach(node => {
result.push({ id: node.id, name: node.name })
if (node.children && node.children.length > 0) {
result.push(...extractAreas(node.children))
}
})
return result
}
areaList.value = extractAreas(data || [])
} catch (e) {
console.error('获取监区列表失败', e)
}
}
/** 搜索 */
const handleSearch = () => {
queryParams.pageNo = 1
getList()
}
/** 重置 */
const handleReset = () => {
queryParams.name = ''
queryParams.prisonerNo = ''
queryParams.areaId = undefined
handleSearch()
}
/** 选择变化 */
const handleSelectionChange = (rows: any[]) => {
selectedList.value = rows
}
/** 清空选择 */
const clearSelection = () => {
selectedList.value = []
tableRef.value?.clearSelection()
}
/** 确认选择 */
const handleConfirm = () => {
const ids = selectedList.value.map(row => row.id)
emit('confirm', ids)
dialogVisible.value = false
}
defineExpose({ open })
</script>

View File

@ -0,0 +1,293 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="taskDetail?.taskName || '任务详情'"
width="900px"
:close-on-click-modal="false"
>
<div v-loading="loading">
<!-- 基本信息 -->
<el-descriptions title="基本信息" :column="3" border class="mb-20px">
<el-descriptions-item label="任务ID">{{ taskDetail?.taskId }}</el-descriptions-item>
<el-descriptions-item label="任务名称">{{ taskDetail?.taskName }}</el-descriptions-item>
<el-descriptions-item label="问卷名称">{{ taskDetail?.questionnaireName }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTag(taskDetail?.status)">
{{ getStatusLabel(taskDetail?.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ formatDateTime(taskDetail?.startTime) }}</el-descriptions-item>
<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>
</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>
</div>
<template #footer>
<el-button @click="dialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatDateTime } from '@/utils/formatTime'
import { QuestionnaireTaskApi } from '@/api/prison/questionnaire-task'
defineOptions({ name: 'TaskDetailDialog' })
const dialogVisible = ref(false)
const loading = ref(false)
const remindLoading = ref(false)
const taskId = ref<number>()
//
const taskDetail = ref<any>(null)
//
const taskProgress = ref<any>(null)
//
const areaStatistics = ref<any[]>([])
//
const taskStatusOptions = [
{ value: 1, label: '草稿', type: 'info' },
{ value: 2, label: '进行中', type: 'primary' },
{ value: 3, label: '已结束', type: 'success' },
{ value: 4, label: '已取消', type: 'danger' }
]
/** 获取状态标签类型 */
const getStatusTag = (status: number | undefined) => {
if (!status) return 'info'
const item = taskStatusOptions.find(item => item.value === status)
return item?.type || 'info'
}
/** 获取状态标签文本 */
const getStatusLabel = (status: number | undefined) => {
if (!status) return '未知'
const item = taskStatusOptions.find(item => item.value === status)
return item?.label || '未知'
}
/** 打开弹窗 */
const open = async (id: number) => {
dialogVisible.value = true
taskId.value = id
loading.value = true
try {
//
const [progressData, areaData] = await Promise.all([
QuestionnaireTaskApi.getTaskProgress(id),
QuestionnaireTaskApi.getTaskAreaStatistics(id)
])
taskProgress.value = progressData
taskDetail.value = {
taskId: progressData.taskId,
taskName: progressData.taskName,
questionnaireName: progressData.questionnaireName,
status: progressData.status,
startTime: progressData.startTime,
deadline: progressData.deadline
}
areaStatistics.value = areaData || []
} catch (e) {
console.error('获取任务详情失败', e)
ElMessage.error('获取任务详情失败')
} finally {
loading.value = false
}
}
/** 提醒未完成人员 */
const handleRemind = async () => {
if (!taskId.value) return
try {
await ElMessageBox.confirm(
`确定要提醒该任务中 ${taskProgress.value?.pendingCount || 0} 位未完成人员吗?`,
'提醒确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}
)
remindLoading.value = true
const count = await QuestionnaireTaskApi.remindPendingPrisoners(taskId.value)
ElMessage.success(`已提醒 ${count} 位未完成人员`)
} catch (e) {
//
} finally {
remindLoading.value = false
}
}
defineExpose({ open })
</script>
<style lang="scss" scoped>
.progress-item {
text-align: center;
padding: 20px 0;
}
.progress-value {
font-size: 32px;
font-weight: bold;
color: #303133;
}
.progress-label {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
.text-success {
color: #67c23a;
}
.text-warning {
color: #e6a23c;
}
.text-primary {
color: #409eff;
}
.text-gray {
color: #909399;
}
</style>

View File

@ -0,0 +1,534 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="80px"
>
<el-form-item label="任务名称" prop="taskName">
<el-input
v-model="queryParams.taskName"
placeholder="请输入任务名称"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="问卷" prop="questionnaireId">
<el-select
v-model="queryParams.questionnaireId"
placeholder="请选择问卷"
clearable
class="!w-200px"
@change="handleQuery"
>
<el-option
v-for="item in questionnaireList"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select
v-model="queryParams.status"
placeholder="请选择"
clearable
class="!w-120px"
@change="handleQuery"
>
<el-option
v-for="dict in taskStatusOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<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-form-item>
</el-form>
</ContentWrap>
<!-- 统计卡片 -->
<ContentWrap v-if="statistics">
<el-row :gutter="20">
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<template #header>
<div class="card-header">
<span>任务总数</span>
</div>
</template>
<div class="stat-content">
<div class="stat-value">{{ statistics.taskCount }}</div>
<div class="stat-label"></div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<template #header>
<div class="card-header">
<span>目标人数</span>
</div>
</template>
<div class="stat-content">
<div class="stat-value">{{ statistics.totalPrisoners }}</div>
<div class="stat-label"></div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<template #header>
<div class="card-header">
<span>已完成人数</span>
</div>
</template>
<div class="stat-content">
<div class="stat-value text-success">{{ statistics.totalCompleted }}</div>
<div class="stat-label"></div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<template #header>
<div class="card-header">
<span>整体完成率</span>
</div>
</template>
<div class="stat-content">
<div class="stat-value text-primary">{{ statistics.overallCompletionRate }}%</div>
<div class="stat-label">完成率</div>
</div>
</el-card>
</el-col>
</el-row>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
highlight-current-row
@selection-change="handleRowCheckboxChange"
:scroll-x="true"
>
<el-table-column type="selection" width="55" />
<el-table-column label="任务ID" align="center" prop="id" width="80" />
<el-table-column label="任务名称" align="center" prop="taskName" width="200">
<template #default="scope">
<el-link type="primary" @click="handleDetail(scope.row)">
{{ scope.row.taskName }}
</el-link>
</template>
</el-table-column>
<el-table-column label="问卷名称" align="center" prop="questionnaireName" width="180" />
<el-table-column label="目标范围" align="center" prop="targetType" width="120">
<template #default="scope">
<el-tag :type="getTargetTypeTag(scope.row.targetType)">
{{ getTargetTypeLabel(scope.row.targetType) }}
</el-tag>
</template>
</el-table-column>
<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" prop="completionRate" width="120">
<template #default="scope">
<el-progress
:percentage="Number(scope.row.completionRate) || 0"
:stroke-width="6"
:status="getProgressStatus(scope.row.completionRate)"
/>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="100">
<template #default="scope">
<el-tag :type="getStatusTag(scope.row.status)">
{{ getStatusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="截止时间" align="center" prop="deadline" width="160">
<template #default="scope">
{{ formatDateTime(scope.row.deadline) }}
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="160">
<template #default="scope">
{{ formatDateTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" width="200">
<template #default="scope">
<el-button link type="primary" @click="handleDetail(scope.row)">详情</el-button>
<el-button
link
type="primary"
@click="handleRestart(scope.row)"
v-if="scope.row.status === 3"
v-hasPermi="['prison:questionnaire-task:restart']"
>
重启
</el-button>
<el-button
link
type="warning"
@click="handleFinish(scope.row)"
v-if="scope.row.status === 2"
v-hasPermi="['prison:questionnaire-task:finish']"
>
结束
</el-button>
<el-button
link
type="danger"
@click="handleCancel(scope.row)"
v-if="scope.row.status === 2"
v-hasPermi="['prison:questionnaire-task:cancel']"
>
取消
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row)"
v-hasPermi="['prison:questionnaire-task:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 创建任务弹窗 -->
<CreateTaskDialog ref="createTaskDialogRef" @success="getList" />
<!-- 任务详情弹窗 -->
<TaskDetailDialog ref="taskDetailDialogRef" />
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatDateTime } from '@/utils/formatTime'
import CreateTaskDialog from './components/CreateTaskDialog.vue'
import TaskDetailDialog from './components/TaskDetailDialog.vue'
import { QuestionnaireTaskApi } from '@/api/prison/questionnaire-task'
import { QuestionnaireApi } from '@/api/prison/questionnaire'
defineOptions({ name: 'QuestionnaireTask' })
const loading = ref(false)
const exportLoading = ref(false)
const list = ref([])
const total = ref(0)
const checkedIds = ref<number[]>([])
//
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
taskName: undefined,
questionnaireId: undefined,
status: undefined,
targetType: undefined,
createTime: undefined
})
//
const statistics = ref<any>(null)
//
const questionnaireList = ref<any[]>([])
//
const taskStatusOptions = [
{ value: 1, label: '草稿', type: 'info' },
{ value: 2, label: '进行中', type: 'primary' },
{ value: 3, label: '已结束', type: 'success' },
{ value: 4, label: '已取消', type: 'danger' }
]
//
const targetTypeOptions = [
{ value: 1, label: '指定犯人' },
{ value: 2, label: '指定监区' },
{ value: 3, label: '全部犯人' }
]
//
const createTaskDialogRef = ref()
const taskDetailDialogRef = ref()
/** 获取状态标签类型 */
const getStatusTag = (status: number) => {
const item = taskStatusOptions.find(item => item.value === status)
return item?.type || 'info'
}
/** 获取状态标签文本 */
const getStatusLabel = (status: number) => {
const item = taskStatusOptions.find(item => item.value === status)
return item?.label || '未知'
}
/** 获取目标类型标签文本 */
const getTargetTypeLabel = (type: number) => {
const item = targetTypeOptions.find(item => item.value === type)
return item?.label || '未知'
}
/** 获取目标类型标签样式 */
const getTargetTypeTag = (type: number) => {
const map: Record<number, string> = {
1: 'warning',
2: 'primary',
3: 'success'
}
return map[type] || 'info'
}
/** 获取进度条状态 */
const getProgressStatus = (rate: number | string) => {
const r = Number(rate) || 0
if (r >= 80) return 'success'
if (r >= 50) return 'warning'
return 'exception'
}
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await QuestionnaireTaskApi.getQuestionnaireTaskPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 获取统计数据 */
const getStatistics = async () => {
try {
statistics.value = await QuestionnaireTaskApi.getStatisticsSummary()
} catch (e) {
console.error('获取统计数据失败', e)
}
}
/** 获取问卷列表 */
const getQuestionnaireList = async () => {
try {
const data = await QuestionnaireApi.getQuestionnairePage({
pageNo: 1,
pageSize: 100,
status: 2 //
})
questionnaireList.value = data.list || []
} catch (e) {
console.error('获取问卷列表失败', e)
}
}
/** 搜索 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置 */
const resetQuery = () => {
queryParams.taskName = undefined
queryParams.questionnaireId = undefined
queryParams.status = undefined
queryParams.targetType = undefined
queryParams.createTime = undefined
handleQuery()
}
/** 行checkbox选择 */
const handleRowCheckboxChange = (rows: any[]) => {
checkedIds.value = rows.map(row => row.id)
}
/** 查看详情 */
const handleDetail = (row: any) => {
taskDetailDialogRef.value?.open(row.id)
}
/** 创建任务 */
const handleCreate = () => {
createTaskDialogRef.value?.open()
}
/** 重新开始任务 */
const handleRestart = (row: any) => {
ElMessageBox.confirm(
`确定要重新开始任务「${row.taskName}」吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
await QuestionnaireTaskApi.restartTask(row.id)
ElMessage.success('重启成功')
getList()
getStatistics()
}).catch(() => {})
}
/** 结束任务 */
const handleFinish = (row: any) => {
ElMessageBox.confirm(
`确定要结束任务「${row.taskName}」吗?结束后未完成的问卷将标记为已过期。`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
await QuestionnaireTaskApi.finishTask(row.id)
ElMessage.success('结束成功')
getList()
getStatistics()
}).catch(() => {})
}
/** 取消任务 */
const handleCancel = (row: any) => {
ElMessageBox.confirm(
`确定要取消任务「${row.taskName}」吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
await QuestionnaireTaskApi.cancelTask(row.id)
ElMessage.success('取消成功')
getList()
getStatistics()
}).catch(() => {})
}
/** 删除 */
const handleDelete = (row: any) => {
ElMessageBox.confirm(
`确定要删除任务「${row.taskName}」吗?删除后不可恢复。`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
await QuestionnaireTaskApi.deleteQuestionnaireTask(row.id)
ElMessage.success('删除成功')
getList()
getStatistics()
}).catch(() => {})
}
/** 批量删除 */
const handleDeleteBatch = () => {
if (checkedIds.value.length === 0) {
ElMessage.warning('请选择要删除的任务')
return
}
ElMessageBox.confirm(
`确定要批量删除选中的 ${checkedIds.value.length} 个任务吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
await QuestionnaireTaskApi.deleteQuestionnaireTaskList(checkedIds.value)
ElMessage.success('删除成功')
getList()
getStatistics()
}).catch(() => {})
}
/** 导出 */
const handleExport = async () => {
exportLoading.value = true
try {
await QuestionnaireTaskApi.exportQuestionnaireTask(queryParams)
} finally {
exportLoading.value = false
}
}
onMounted(() => {
getList()
getStatistics()
getQuestionnaireList()
})
</script>
<style lang="scss" scoped>
.stat-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 500;
}
.stat-content {
display: flex;
align-items: baseline;
gap: 8px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
}
.stat-label {
font-size: 14px;
color: #909399;
}
.text-success {
color: #67c23a;
}
.text-primary {
color: #409eff;
}
}
</style>

View File

@ -331,30 +331,30 @@ const getList = async () => {
list.value = data.list
partitions.value = extractPartitions(data.list)
//
if (allPartList.value.length === 0) {
//
allPartList.value.push({
id: 'default',
name: '',
sort: 0,
isDefault: true
})
//
allPartList.value = []
//
const existingNames = new Set<string>()
partitions.value.forEach(p => {
if (p.name && !existingNames.has(p.name)) {
existingNames.add(p.name)
allPartList.value.push({
id: `part_${p.name}`,
name: p.name,
sort: p.sort,
isDefault: false
})
}
})
}
//
allPartList.value.push({
id: 'default',
name: '',
sort: 0,
isDefault: true
})
//
const existingNames = new Set<string>()
partitions.value.forEach(p => {
if (p.name && !existingNames.has(p.name)) {
existingNames.add(p.name)
allPartList.value.push({
id: `part_${p.name}`,
name: p.name,
sort: p.sort,
isDefault: false
})
}
})
} finally {
loading.value = false
}

View File

@ -0,0 +1,524 @@
<template>
<Dialog title="问卷预览" v-model="dialogVisible" width="900px" :fullscreen="false">
<div class="questionnaire-preview" v-loading="loading">
<!-- 问卷头部信息 -->
<div class="preview-header">
<h2 class="title">{{ questionnaire.title }}</h2>
<div class="meta-info">
<el-tag v-if="questionnaire.type" type="primary" size="large">
{{ getTypeLabel(questionnaire.type) }}
</el-tag>
<span class="info-item" v-if="questionnaire.estimatedTime">
<Icon icon="ep:clock" /> 预计 {{ questionnaire.estimatedTime }} 分钟
</span>
<span class="info-item" v-if="questionnaire.totalScore">
<Icon icon="ep:coin" /> 总分 {{ questionnaire.totalScore }}
</span>
<span class="info-item" v-if="questionnaire.passScore">
<Icon icon="ep:check" /> 及格分 {{ questionnaire.passScore }}
</span>
</div>
</div>
<!-- 问卷说明 -->
<div v-if="questionnaire.description" class="preview-description">
<div class="section-title">问卷说明</div>
<div class="description-content" v-html="questionnaire.description"></div>
</div>
<!-- 填写说明 -->
<div v-if="questionnaire.instruction" class="preview-instruction">
<div class="section-title">填写说明</div>
<div class="instruction-content">{{ questionnaire.instruction }}</div>
</div>
<!-- 问题列表按分区显示 -->
<div class="preview-questions">
<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="question-items">
<div
v-for="(question, index) in partition.questions"
:key="question.id"
class="question-item"
>
<!-- 问题标题 -->
<div class="question-header">
<span class="question-index">
{{ partition.name ? '' : (index + 1) }}
</span>
<span class="question-title">
{{ question.title }}
<el-tag v-if="question.isRequired" type="danger" size="small" class="required-tag">
必填
</el-tag>
<el-tag v-if="question.score" type="info" size="small">
{{ question.score }}
</el-tag>
</span>
</div>
<!-- 帮助说明 -->
<div v-if="question.helpText" class="question-help">
<Icon icon="ep:info-filled" />
{{ question.helpText }}
</div>
<!-- 单选/多选题 -->
<div v-if="question.type === 1 || question.type === 2" class="question-options">
<div
v-for="option in getQuestionOptions(question)"
:key="option.label"
class="option-item"
>
<el-radio v-if="question.type === 1" :value="false" disabled>
{{ option.label }}
<span v-if="option.score > 0" class="option-score">{{ option.score }}</span>
</el-radio>
<el-checkbox v-else disabled>
{{ option.label }}
<span v-if="option.score > 0" class="option-score">{{ option.score }}</span>
</el-checkbox>
<div v-if="option.isOther" class="other-input">
<el-input placeholder="其他,请说明" disabled />
</div>
</div>
</div>
<!-- 填空题 -->
<div v-else-if="question.type === 3" class="question-input">
<el-input
type="textarea"
:placeholder="question.placeholder || '请输入'"
:rows="3"
disabled
/>
</div>
<!-- 评分题 -->
<div v-else-if="question.type === 4" class="question-rating">
<div class="rating-info">
<span>最低分{{ getRangeValue(question, 'min') }}</span>
<span>最高分{{ getRangeValue(question, 'max') }}</span>
<span>默认值{{ question.score || getRangeValue(question, 'max') }}</span>
</div>
<el-input-number disabled :min="getRangeValue(question, 'min')" :max="getRangeValue(question, 'max')" />
</div>
<!-- 日期题 -->
<div v-else-if="question.type === 5" class="question-date">
<div class="date-info" v-if="getRangeValue(question, 'min') || getRangeValue(question, 'max')">
日期范围{{ getRangeValue(question, 'min') || '无限制' }} ~ {{ getRangeValue(question, 'max') || '无限制' }}
</div>
<el-date-picker
type="date"
placeholder="请选择日期"
disabled
style="width: 100%"
/>
</div>
<!-- 数字题 -->
<div v-else-if="question.type === 6" class="question-number">
<div class="number-info" v-if="getRangeValue(question, 'min') !== undefined || getRangeValue(question, 'max') !== undefined">
数值范围{{ getRangeValue(question, 'min') ?? '无限制' }} ~ {{ getRangeValue(question, 'max') ?? '无限制' }}
</div>
<el-input-number
:placeholder="question.placeholder || '请输入数字'"
disabled
style="width: 100%"
/>
</div>
</div>
</div>
</template>
</div>
<!-- 空状态 -->
<el-empty v-if="partitions.length === 0 && !loading" description="暂无问题" />
</div>
<template #footer>
<el-button @click="dialogVisible = false"> </el-button>
<el-button type="primary" @click="openFillPage" :disabled="!questionnaire.id">
<Icon icon="ep:document" /> 去填写
</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { QuestionnaireApi, Questionnaire } from '@/api/prison/questionnaire'
import { QuestionApi, Question } from '@/api/prison/question'
defineOptions({ name: 'QuestionnairePreview' })
const message = useMessage()
const dialogVisible = ref(false)
const loading = ref(false)
const questionnaire = ref<Questionnaire>({
id: undefined,
title: '',
type: undefined,
description: '',
coverImage: [],
instruction: '',
estimatedTime: undefined,
totalScore: undefined,
passScore: undefined,
allowAnonymous: false,
status: undefined
})
const questions = ref<Question[]>([])
const partitions = ref<Array<{
name: string
sort: number
questions: Question[]
}>>([])
/** 问卷类型标签 */
const getTypeLabel = (type: number) => {
const options = getIntDictOptions(DICT_TYPE.PRISON_QUESTIONNAIRE_TYPE)
return options.find(o => o.value === type)?.label || '未知'
}
/** 获取问题选项 */
const getQuestionOptions = (question: Question) => {
if (!question.options) return []
try {
return JSON.parse(question.options)
} catch {
return []
}
}
/** 获取范围值(用于日期、数字、评分题) */
const getRangeValue = (question: Question, key: 'min' | 'max') => {
if (!question.options) return undefined
try {
const obj = JSON.parse(question.options)
return obj[key] || undefined
} catch {
return undefined
}
}
/** 从问题列表提取所有分区 */
const extractPartitions = (questionList: Question[]) => {
const partMap = new Map<string, Question[]>()
questionList.forEach(q => {
const partName = q.partName || ''
if (!partMap.has(partName)) {
partMap.set(partName, [])
}
partMap.get(partName)!.push(q)
})
//
const sortedParts = Array.from(partMap.entries())
.sort((a, b) => {
const sortA = a[1][0]?.partSort ?? 0
const sortB = b[1][0]?.partSort ?? 0
return sortA - sortB
})
//
const result = []
//
const defaultQuestions = sortedParts.find(([name]) => !name)
if (defaultQuestions) {
result.push({
name: '',
sort: 0,
questions: defaultQuestions[1]
})
}
//
sortedParts
.filter(([name]) => name)
.forEach(([name, qs]) => {
result.push({
name,
sort: qs[0]?.partSort ?? 0,
questions: qs
})
})
return result
}
/** 打开预览 */
const open = async (id: number) => {
dialogVisible.value = true
loading.value = true
try {
//
const qData = await QuestionnaireApi.getQuestionnaire(id)
questionnaire.value = {
...qData,
coverImage: qData.coverImage || []
}
//
const questionsData = await QuestionApi.getQuestionPage({
pageNo: 1,
pageSize: 200,
questionnaireId: id
})
questions.value = questionsData.list
partitions.value = extractPartitions(questionsData.list)
} catch (error) {
message.error('加载失败')
dialogVisible.value = false
} finally {
loading.value = false
}
}
/** 跳转到填写页面 */
const openFillPage = () => {
// TODO:
message.info('填写页面功能待实现')
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.questionnaire-preview {
max-height: 70vh;
overflow-y: auto;
padding: 20px;
background: #f5f7fa;
border-radius: 8px;
}
.preview-header {
background: #fff;
padding: 24px;
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.title {
margin: 0 0 16px 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.meta-info {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
.info-item {
display: flex;
align-items: center;
gap: 4px;
color: #606266;
font-size: 14px;
}
}
}
.preview-description,
.preview-instruction {
background: #fff;
padding: 16px 24px;
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.section-title {
font-weight: 600;
color: #303133;
margin-bottom: 12px;
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '';
width: 4px;
height: 16px;
background: #409eff;
border-radius: 2px;
}
}
.description-content {
color: #606266;
line-height: 1.8;
font-size: 14px;
}
.instruction-content {
color: #909399;
font-size: 14px;
line-height: 1.8;
background: #f4f4f5;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #e4e7ed;
}
}
.preview-questions {
.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;
align-items: center;
gap: 8px;
&:first-child {
margin-top: 0;
}
.question-count {
font-size: 12px;
color: #909399;
font-weight: normal;
margin-left: 4px;
}
}
.question-items {
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: 12px;
.question-index {
flex-shrink: 0;
width: 28px;
height: 28px;
background: #409eff;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
}
.question-title {
flex: 1;
font-size: 15px;
font-weight: 500;
color: #303133;
line-height: 1.6;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.required-tag {
flex-shrink: 0;
}
}
}
.question-help {
display: flex;
align-items: center;
gap: 6px;
color: #909399;
font-size: 13px;
margin-bottom: 12px;
padding: 8px 12px;
background: #f4f4f5;
border-radius: 4px;
}
.question-options {
display: flex;
flex-direction: column;
gap: 12px;
.option-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
.option-score {
color: #67c23a;
font-size: 12px;
font-weight: 500;
}
.other-input {
margin-left: 24px;
flex: 1;
margin-top: 8px;
}
}
}
.question-rating,
.question-date,
.question-number {
.rating-info,
.date-info,
.number-info {
display: flex;
gap: 16px;
color: #909399;
font-size: 13px;
margin-bottom: 12px;
}
}
.question-input {
:deep(.el-textarea) {
.el-textarea__inner {
background: #fafafa;
}
}
}
}
</style>

View File

@ -114,8 +114,16 @@
{{ formatDateTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="150" fixed="right">
<el-table-column label="操作" align="center" width="220" fixed="right">
<template #default="scope">
<el-button
type="success"
link
@click="openPreview(scope.row.id)"
v-hasPermi="['prison:questionnaire:query']"
>
预览
</el-button>
<el-button
type="primary"
link
@ -154,6 +162,9 @@
<!-- 表单弹窗添加/修改 -->
<QuestionnaireForm ref="formRef" @success="getList" />
<!-- 预览弹窗 -->
<QuestionnairePreview ref="previewRef" />
</template>
<script lang="ts" setup>
@ -163,6 +174,7 @@ import download from '@/utils/download'
import { QuestionnaireApi, Questionnaire } from '@/api/prison/questionnaire'
import QuestionnaireForm from './QuestionnaireForm.vue'
import QuestionList from './components/QuestionList.vue'
import QuestionnairePreview from './components/QuestionnairePreview.vue'
defineOptions({ name: 'Questionnaire' })
@ -261,6 +273,12 @@ const handleCurrentChange = (row: Questionnaire | undefined) => {
currentRow.value = row || {} as Questionnaire
}
/** 预览问卷 */
const previewRef = ref()
const openPreview = (id: number) => {
previewRef.value.open(id)
}
/** 初始化 */
onMounted(() => {
getList()

View File

@ -0,0 +1,636 @@
<template>
<Dialog v-model="dialogVisible" :title="title" width="1000px" :fullscreen="false">
<div v-loading="loading" class="answer-detail-dialog">
<!-- 记录基本信息 -->
<div class="record-header">
<div class="header-info">
<h3 class="title">{{ recordInfo.questionnaireName }}</h3>
<div class="meta-info">
<span class="info-item">
<Icon icon="ep:user" />
{{ recordInfo.prisonerName || recordInfo.prisonerNo }}
</span>
<span class="info-item">
<Icon icon="ep:clock" />
{{ formatDateTime(recordInfo.createTime) }}
</span>
<span class="info-item" v-if="recordInfo.duration">
<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>
</div>
</div>
<!-- 问题列表按分区显示 -->
<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
v-for="item in partition.questions"
:key="item.question.id"
class="question-item"
>
<!-- 问题标题 -->
<div class="question-header">
<span class="question-index">{{ item.index }}.</span>
<span class="question-title">
{{ item.question.title }}
<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 class="answer-section">
<div class="answer-label">
<Icon icon="ep:edit" />
用户答案
</div>
<div class="answer-content">
<!-- 单选/多选题 -->
<template v-if="item.question.type === 1 || item.question.type === 2">
<div v-if="item.answer?.answerText" class="selected-options">
<el-tag
v-for="(opt, idx) in parseAnswerOptions(item.answer.answerText)"
:key="idx"
type="primary"
size="small"
class="option-tag"
>
{{ opt }}
</el-tag>
</div>
<span v-else class="empty-answer">未作答</span>
</template>
<!-- 填空题 -->
<template v-else-if="item.question.type === 3">
<span v-if="item.answer?.answerText" class="text-answer">{{ item.answer.answerText }}</span>
<span v-else class="empty-answer">未作答</span>
</template>
<!-- 评分题 -->
<template v-else-if="item.question.type === 4">
<span v-if="item.answer?.answerText" class="score-answer">
<el-tag type="warning">{{ item.answer.answerText }} </el-tag>
</span>
<span v-else class="empty-answer">未作答</span>
</template>
<!-- 日期题 -->
<template v-else-if="item.question.type === 5">
<span v-if="item.answer?.answerText" class="date-answer">{{ item.answer.answerText }}</span>
<span v-else class="empty-answer">未作答</span>
</template>
<!-- 数字题 -->
<template v-else-if="item.question.type === 6">
<span v-if="item.answer?.answerText" class="number-answer">{{ item.answer.answerText }}</span>
<span v-else class="empty-answer">未作答</span>
</template>
</div>
</div>
<!-- 得分信息 -->
<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 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>
<template #footer>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { DICT_TYPE } from '@/utils/dict'
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 { getIntDictOptions } from '@/utils/dict'
defineOptions({ name: 'AnswerDetailDialog' })
const dialogVisible = ref(false)
const title = ref('答题详情')
const loading = ref(false)
//
const recordInfo = ref<QuestionnaireRecord>({
id: undefined,
questionnaireName: '',
prisonerName: '',
prisonerNo: '',
objectiveScore: 0,
subjectiveScore: 0,
totalScore: 0,
passStatus: undefined,
duration: 0,
createTime: ''
})
//
const questions = ref<Question[]>([])
//
const answers = ref<Answer[]>([])
//
interface QuestionWithAnswer {
question: Question
answer?: Answer
index: number
}
const partitions = computed(() => {
const partMap = new Map<string, QuestionWithAnswer[]>()
questions.value.forEach((q, index) => {
const partName = q.partName || ''
const answer = answers.value.find(a => a.questionId === q.id)
if (!partMap.has(partName)) {
partMap.set(partName, [])
}
partMap.get(partName)!.push({
question: q,
answer,
index: index + 1
})
})
//
const sortedParts = Array.from(partMap.entries())
.sort((a, b) => {
const sortA = a[1][0]?.question.partSort ?? 0
const sortB = b[1][0]?.question.partSort ?? 0
return sortA - sortB
})
//
const result: Array<{ name: string; questions: QuestionWithAnswer[] }> = []
//
const defaultQuestions = sortedParts.find(([name]) => !name)
if (defaultQuestions) {
result.push({
name: '',
questions: defaultQuestions[1]
})
}
//
sortedParts
.filter(([name]) => name)
.forEach(([name, qs]) => {
result.push({
name,
questions: qs
})
})
return result
})
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (!seconds) return '-'
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return minutes > 0 ? `${minutes}${secs}` : `${secs}`
}
/** 获取及格状态文本 */
const getPassStatusText = (status: number | undefined) => {
const options = getIntDictOptions(DICT_TYPE.PRISON_RECORD_PASS_STATUS)
return options.find(o => o.value === status)?.label || '-'
}
/** 获取及格状态样式 */
const getPassStatusClass = (status: number | undefined) => {
const classMap: Record<number, string> = {
1: 'pass',
2: 'fail',
3: 'pending'
}
return classMap[status || 0] || ''
}
/** 解析答案选项(单选/多选) */
const parseAnswerOptions = (answerText: string): string[] => {
if (!answerText) return []
//
return answerText.split(',').filter(s => s.trim())
}
/** 判断是否显示正确答案 */
const showCorrectAnswer = (question: Question, answer?: Answer): boolean => {
//
if (question.type !== 1 && question.type !== 2) return false
//
if (!answer) return false
return true
}
/** 获取正确答案 */
const getCorrectAnswer = (question: Question): string => {
if (!question.options) return '-'
try {
const options = JSON.parse(question.options)
//
const correctOptions = options
.filter((o: any) => o.score > 0)
.map((o: any) => o.label)
return correctOptions.length > 0 ? correctOptions.join('、') : '-'
} catch {
return '-'
}
}
/** 打开弹窗 */
const open = async (recordId: number) => {
dialogVisible.value = true
title.value = '答题详情'
loading.value = true
try {
//
const [recordData, answerList] = await Promise.all([
QuestionnaireRecordApi.getQuestionnaireRecord(recordId),
AnswerApi.getAnswersByAssessmentRecordId(recordId)
])
recordInfo.value = recordData
answers.value = answerList
//
if (recordData.questionnaireId) {
const questionsData = await QuestionApi.getQuestionPage({
pageNo: 1,
pageSize: 200,
questionnaireId: recordData.questionnaireId
})
questions.value = questionsData.list
}
} catch (error) {
console.error('加载答题详情失败:', error)
} finally {
loading.value = false
}
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.answer-detail-dialog {
max-height: 70vh;
overflow-y: auto;
padding: 20px;
background: #f5f7fa;
}
.record-header {
background: #fff;
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;
}
}
}
.score-info {
display: flex;
gap: 16px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
.score-item {
flex: 1;
text-align: center;
.label {
display: block;
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.value {
display: block;
font-size: 20px;
font-weight: 600;
color: #303133;
&.objective {
color: #67c23a;
}
&.subjective {
color: #e6a23c;
}
}
&.total {
.value {
color: #409eff;
font-size: 24px;
}
}
&.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;
align-items: center;
gap: 8px;
&: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 {
flex-shrink: 0;
width: 28px;
height: 28px;
background: #409eff;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
}
.question-title {
flex: 1;
font-size: 15px;
font-weight: 500;
color: #303133;
line-height: 1.6;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
}
.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;
color: #303133;
line-height: 1.6;
}
.score-answer {
display: inline-block;
}
.empty-answer {
color: #c0c4cc;
font-size: 14px;
font-style: italic;
}
}
}
.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>

View File

@ -159,8 +159,17 @@
{{ formatDateTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" min-width="200px" fixed="right">
<el-table-column label="操作" align="center" min-width="280px" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 3"
link
type="success"
@click="handleViewDetail(scope.row.id)"
v-hasPermi="['prison:questionnaire-record:query']"
>
查看详情
</el-button>
<el-button
v-if="scope.row.status === 1"
link
@ -233,6 +242,9 @@
<!-- 人工评分弹窗 -->
<ManualScoreDialog ref="manualScoreDialogRef" @success="getList" />
<!-- 答题详情弹窗 -->
<AnswerDetailDialog ref="answerDetailDialogRef" />
</template>
<script setup lang="ts">
@ -245,6 +257,7 @@ import { QuestionnaireApi } from '@/api/prison/questionnaire'
import QuestionnaireRecordForm from './QuestionnaireRecordForm.vue'
import InitiateAssessmentDialog from './InitiateAssessmentDialog.vue'
import ManualScoreDialog from './ManualScoreDialog.vue'
import AnswerDetailDialog from './AnswerDetailDialog.vue'
/** 问卷答题记录/测评记录 列表 */
defineOptions({ name: 'QuestionnaireRecord' })
@ -348,6 +361,12 @@ const handleCancel = async (row: QuestionnaireRecord) => {
} catch {}
}
/** 查看答题详情 */
const answerDetailDialogRef = ref()
const handleViewDetail = (id: number) => {
answerDetailDialogRef.value.open(id)
}
/** 人工评分 */
const manualScoreDialogRef = ref()
const handleManualScore = (row: QuestionnaireRecord) => {