feat: 新增问卷任务管理模块
- 新增问卷任务页面及组件(创建任务、人员选择、任务详情) - 新增问卷预览组件 - 新增答题详情对话框 - 优化问卷列表和问卷记录页面 - 优化Dashboard风险趋势图Y轴动态缩放 - 更新评估报告导出页面
This commit is contained in:
parent
230021a7b6
commit
5d43154ba5
189
src/api/prison/questionnaire-task/index.ts
Normal file
189
src/api/prison/questionnaire-task/index.ts
Normal 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 } })
|
||||
}
|
||||
}
|
||||
@ -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 || '考核等级'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
534
src/views/prison/questionnaire-task/index.vue
Normal file
534
src/views/prison/questionnaire-task/index.vue
Normal 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>
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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()
|
||||
|
||||
636
src/views/prison/questionnairerecord/AnswerDetailDialog.vue
Normal file
636
src/views/prison/questionnairerecord/AnswerDetailDialog.vue
Normal 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>
|
||||
@ -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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user