tangweijie 5d43154ba5 feat: 新增问卷任务管理模块
- 新增问卷任务页面及组件(创建任务、人员选择、任务详情)
- 新增问卷预览组件
- 新增答题详情对话框
- 优化问卷列表和问卷记录页面
- 优化Dashboard风险趋势图Y轴动态缩放
- 更新评估报告导出页面
2026-01-24 10:56:02 +08:00

819 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- 列表 -->
<ContentWrap>
<div class="question-header">
<div class="header-left">
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['prison:question:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新建问题
</el-button>
<el-button
type="success"
plain
@click="openPartDialog"
v-hasPermi="['prison:question:create']"
>
<Icon icon="ep:folder" class="mr-5px" /> 分区管理
</el-button>
<el-button
type="danger"
plain
:disabled="checkedIds.length === 0"
@click="handleDeleteBatch"
v-hasPermi="['prison:question:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</div>
<div class="header-right">
<span class="total-count"> {{ list.length }} 道问题</span>
</div>
</div>
<!-- 分区显示可拖拽排序 -->
<div class="parts-container">
<draggable
v-model="partitions"
item-key="name"
handle=".part-drag-handle"
:animation="200"
@end="onPartitionDragEnd"
class="parts-list"
>
<template #item="{ element: partition }">
<div class="part-group" :class="{ 'is-default': !partition.name }">
<!-- 分区头部 -->
<div class="part-header">
<div class="part-drag-handle">
<Icon icon="ep:rank" class="drag-icon" />
</div>
<el-checkbox
v-model="partition.selected"
@change="(val: boolean) => togglePartQuestions(partition, val)"
:disabled="!partition.name"
>
<span class="part-title">{{ partition.name || '默认分区' }}</span>
</el-checkbox>
<span class="part-count">({{ partition.questions.length }} 道题)</span>
<div class="part-actions">
<el-button
v-if="partition.name"
type="primary"
link
size="small"
@click="editPartition(partition)"
>
<Icon icon="ep:edit" /> 编辑
</el-button>
<el-button
v-if="partition.name"
type="danger"
link
size="small"
@click="deletePartition(partition)"
>
<Icon icon="ep:delete" /> 删除
</el-button>
</div>
</div>
<!-- 分区内的问题列表可拖拽排序 -->
<draggable
v-model="partition.questions"
item-key="id"
handle=".question-drag-handle"
:animation="200"
@end="onQuestionDragEnd(partition)"
class="questions-table"
ghost-class="ghost-row"
>
<template #item="{ element: question }">
<div class="question-row" :class="{ 'is-hidden': !partition.selected }">
<div class="question-drag-handle">
<Icon icon="ep:rank" class="drag-icon" />
</div>
<el-checkbox
v-model="question._checked"
@change="(val: boolean) => toggleQuestionCheck(question, val, partition)"
class="question-checkbox"
/>
<span class="question-index">{{ question._index + 1 }}</span>
<div class="question-info">
<div class="question-title-row">
<span class="type-badge" :class="'type-' + question.type">
{{ getTypeLabel(question.type) }}
</span>
<span class="title-text">{{ question.title }}</span>
<el-tag v-if="question.isRequired" type="danger" size="small">必填</el-tag>
</div>
<div v-if="question.helpText" class="help-text">
<Icon icon="ep:info-filled" />
{{ question.helpText }}
</div>
</div>
<div class="question-meta">
<span class="meta-item">
<Icon icon="ep:coin" /> {{ question.score || 0 }}
</span>
<el-tooltip
v-if="question.autoFillType && question.autoFillType !== 'NONE'"
:content="getAutoFillContent(question)"
placement="top"
>
<el-tag :type="question.autoFillType === 'AUTO' ? 'primary' : 'warning'" size="small">
{{ getAutoFillLabel(question.autoFillType) }}
</el-tag>
</el-tooltip>
</div>
<div class="question-actions">
<el-button
type="primary"
link
size="small"
@click="openForm('update', question.id)"
v-hasPermi="['prison:question:update']"
>
<Icon icon="ep:edit" /> 修改
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(question.id)"
v-hasPermi="['prison:question:delete']"
>
<Icon icon="ep:delete" /> 删除
</el-button>
</div>
</div>
</template>
</draggable>
</div>
</template>
</draggable>
</div>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<QuestionForm ref="formRef" @success="getList" />
<!-- 分区管理弹窗 -->
<Dialog title="分区管理" v-model="partDialogVisible" width="600px">
<el-form :model="partForm" label-width="100px">
<el-form-item label="分区列表">
<div class="part-manage-list">
<div v-for="(part, index) in allPartitions" :key="part.id || index" class="part-manage-item">
<el-icon class="drag-handle"><Rank /></el-icon>
<el-input
v-model="part.name"
:placeholder="part.isDefault ? '默认分区' : '请输入分区名称'"
:disabled="part.isDefault"
style="flex: 1; margin: 0 10px"
/>
<el-input-number
v-model="part.sort"
:min="0"
:max="999"
controls-position="right"
style="width: 100px"
:disabled="part.isDefault"
/>
<el-button
v-if="!part.isDefault"
type="danger"
:icon="Delete"
circle
@click="removePartition(index)"
/>
</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" plain :icon="Plus" @click="addPartition" v-hasPermi="['prison:question:create']">添加分区</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="savePartitions" type="primary" v-hasPermi="['prison:question:update']">保存设置</el-button>
<el-button @click="partDialogVisible = false">取消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { QuestionApi, Question } from '@/api/prison/question'
import QuestionForm from './QuestionForm.vue'
import { Folder, InfoFilled, Rank, Plus, Delete } from '@element-plus/icons-vue'
import draggable from 'vuedraggable'
defineOptions({ name: 'PrisonQuestionList' })
const message = useMessage()
const { t } = useI18n()
const props = defineProps<{
questionnaireId?: number
}>()
const loading = ref(false)
const list = ref<Question[]>([])
const formRef = ref()
const partDialogVisible = ref(false)
// 分区列表(带问题)
const partitions = ref<Array<{
name: string
sort: number
selected: boolean
questions: Array<Question & { _index: number; _checked: boolean }>
}>>([])
// 所有分区(用于管理弹窗)
const allPartList = ref<Array<{ id: string; name: string; sort: number; isDefault: boolean }>>([])
const allPartitions = computed({
get: () => allPartList.value,
set: (val) => { allPartList.value = val }
})
// 分区表单
const partForm = ref({
name: '',
sort: 0
})
// 选中的问题ID
const checkedIds = ref<number[]>([])
/** 问题类型标签 */
const getTypeLabel = (type: number) => {
const labels: Record<number, string> = { 1: '单选', 2: '多选', 3: '填空', 4: '评分', 5: '日期', 6: '数字' }
return labels[type] || '未知'
}
/** 自动填充标签 */
const getAutoFillLabel = (type: string) => {
return { 'NONE': '-', 'AUTO': '自动', 'MANUAL': '手动' }[type] || type
}
/** 自动填充内容 */
const getAutoFillContent = (row: Question) => {
if (row.autoFillType === 'AUTO') {
return `自动填充来源:${row.autoFillSource || '-'}`
}
return '手动输入'
}
/** 从问题列表提取所有分区 */
const extractPartitions = (questions: Question[]) => {
const partMap = new Map<string, Question[]>()
const defaultPart: Question[] = []
questions.forEach(q => {
const partName = q.partName || ''
if (partName) {
if (!partMap.has(partName)) {
partMap.set(partName, [])
}
partMap.get(partName)!.push(q)
} else {
defaultPart.push(q)
}
})
// 按分区排序
const sortedParts = Array.from(partMap.entries())
.sort((a, b) => {
const partA = partitions.value.find(p => p.name === a[0])
const partB = partitions.value.find(p => p.name === b[0])
const sortA = partA?.sort ?? 0
const sortB = partB?.sort ?? 0
return sortA - sortB
})
// 构建分区列表
const result: typeof partitions.value = []
// 添加默认分区
result.push({
name: '',
sort: 0,
selected: true,
questions: defaultPart.map((q, i) => ({ ...q, _index: i, _checked: false }))
})
// 添加其他分区
sortedParts.forEach(([name, qs]) => {
result.push({
name,
sort: partitions.value.find(p => p.name === name)?.sort ?? 0,
selected: true,
questions: qs.map((q, i) => ({ ...q, _index: i, _checked: false }))
})
})
return result
}
/** 加载问题列表 */
const getList = async () => {
if (!props.questionnaireId) return
loading.value = true
try {
const data = await QuestionApi.getQuestionPage({
pageNo: 1,
pageSize: 200,
questionnaireId: props.questionnaireId
})
list.value = data.list
partitions.value = extractPartitions(data.list)
// 每次都重新初始化分区管理列表(切换问卷时需要重载)
allPartList.value = []
// 添加默认分区
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
}
}
/** 监听问卷ID变化 */
watch(
() => props.questionnaireId,
(val) => {
if (!val) {
list.value = []
partitions.value = []
return
}
getList()
},
{ immediate: true }
)
/** 打开表单 */
const openForm = (type: string, id?: number) => {
if (!props.questionnaireId) {
message.error('请选择一个问卷')
return
}
// 传入可用分区列表
formRef.value.open(type, id, props.questionnaireId, getPartitionNames())
}
/** 获取分区名称列表 */
const getPartitionNames = () => {
return allPartList.value
.filter(p => !p.isDefault && p.name)
.map((p, index) => ({
label: p.name,
value: p.name,
sort: index
}))
.sort((a, b) => a.sort - b.sort)
}
/** 打开分区管理弹窗 */
const openPartDialog = () => {
// 确保默认分区存在且在第一位
if (allPartList.value.length === 0 || !allPartList.value[0]?.isDefault) {
// 添加默认分区(第一个)
allPartList.value.unshift({
id: 'default',
name: '',
sort: 0,
isDefault: true
})
}
// 确保其他分区不是默认分区
for (let i = 1; i < allPartList.value.length; i++) {
allPartList.value[i].isDefault = false
}
partDialogVisible.value = true
}
/** 添加分区 */
const addPartition = () => {
const newSort = allPartList.value.length > 0
? Math.max(...allPartList.value.map(p => p.sort || 0)) + 1
: 0
allPartList.value.push({
id: `part_${Date.now()}`,
name: '',
sort: newSort,
isDefault: false
})
}
/** 删除分区 */
const removePartition = (index: number) => {
allPartList.value.splice(index, 1)
}
/** 编辑分区名称 */
const editPartition = (partition: typeof partitions.value[0]) => {
// 打开分区管理弹窗
openPartDialog()
// 延迟滚动到对应分区并聚焦输入框
nextTick(() => {
const partIndex = allPartList.value.findIndex(p => p.name === partition.name)
if (partIndex > 0) { // 跳过默认分区(index=0)
const element = document.querySelector(`.part-manage-item:nth-child(${partIndex + 1}) .el-input__wrapper`)
if (element) {
(element as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' })
// 尝试聚焦输入框
const input = element.querySelector('input')
if (input) {
input.focus()
}
}
}
})
// 提示用户
message.info(`请在弹窗中编辑分区"${partition.name}"的名称`)
}
/** 删除分区 */
const deletePartition = async (partition: typeof partitions.value[0]) => {
try {
await message.delConfirm(`确定删除分区"${partition.name}"吗?该分区下的问题将移到默认分区`)
// 更新问题的分区为默认分区
for (const q of partition.questions) {
await QuestionApi.updateQuestion({ ...q, id: q.id, partName: '' })
}
await getList()
message.success('删除成功')
} catch {}
}
/** 保存分区设置 */
const savePartitions = async () => {
try {
// 验证分区名称唯一性(非默认分区)
const names = allPartList.value.filter(p => !p.isDefault).map(p => p.name).filter(n => n)
if (new Set(names).size !== names.length) {
message.error('分区名称不能重复')
return
}
// 验证非默认分区必须有名称
const emptyParts = allPartList.value.filter(p => !p.isDefault && !p.name)
if (emptyParts.length > 0) {
message.error('请为所有分区输入名称')
return
}
// 收集所有需要更新的问题
const updates: Array<{ id: number; partName?: string; partSort?: number; sort?: number }> = []
for (let i = 0; i < allPartList.value.length; i++) {
const part = allPartList.value[i]
if (!part.isDefault && part.name) {
for (const p of partitions.value) {
if (p.name === part.name) {
p.questions.forEach((q, sortIndex) => {
updates.push({
id: q.id!,
partName: part.name,
partSort: i,
sort: sortIndex
})
})
}
}
}
}
// 批量更新
await QuestionApi.batchUpdate({ questions: updates })
await getList()
partDialogVisible.value = false
message.success('保存成功')
} catch (e) {
message.error('保存失败')
}
}
/** 分区拖拽排序完成 */
const onPartitionDragEnd = async () => {
// 收集所有需要更新的问题
const updates: Array<{ id: number; partName?: string; partSort?: number; sort?: number }> = []
for (let i = 0; i < partitions.value.length; i++) {
const part = partitions.value[i]
if (part.name) {
part.questions.forEach((q, sortIndex) => {
updates.push({
id: q.id!,
partName: part.name,
partSort: i,
sort: sortIndex
})
})
}
}
// 批量更新
await QuestionApi.batchUpdate({ questions: updates })
message.success('分区排序已更新')
}
/** 问题拖拽排序完成 */
const onQuestionDragEnd = async (partition: typeof partitions.value[0]) => {
// 收集当前分区内需要更新的问题
const updates: Array<{ id: number; partName?: string; partSort?: number; sort?: number }> = []
partition.questions.forEach((q, i) => {
updates.push({
id: q.id!,
partName: partition.name || undefined,
partSort: partition.sort,
sort: i
})
})
// 批量更新
await QuestionApi.batchUpdate({ questions: updates })
message.success('问题排序已更新')
}
/** 切换分区显示/隐藏 */
const togglePartQuestions = (partition: typeof partitions.value[0], selected: boolean) => {
partition.questions.forEach(q => {
q._checked = selected
})
}
/** 切换问题选中状态 */
const toggleQuestionCheck = (question: Question & { _index: number; _checked: boolean }, val: boolean, partition: typeof partitions.value[0]) => {
if (val) {
if (!checkedIds.value.includes(question.id!)) {
checkedIds.value.push(question.id!)
}
} else {
checkedIds.value = checkedIds.value.filter(id => id !== question.id)
}
}
/** 删除问题 */
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await QuestionApi.deleteQuestion(id)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
/** 批量删除 */
const handleDeleteBatch = async () => {
try {
await message.delConfirm()
await QuestionApi.deleteQuestionList(checkedIds.value)
checkedIds.value = []
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
</script>
<style scoped>
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-left {
display: flex;
gap: 10px;
}
.header-right {
color: #909399;
font-size: 14px;
}
.total-count {
padding: 4px 12px;
background: #f4f4f5;
border-radius: 4px;
}
.parts-container {
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
}
.parts-list {
display: flex;
flex-direction: column;
}
.part-group {
border-bottom: 1px solid #e4e7ed;
}
.part-group:last-child {
border-bottom: none;
}
.part-group.is-default {
background: #fafafa;
}
.part-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4fd 100%);
border-bottom: 1px solid #e4e7ed;
}
.part-drag-handle {
cursor: grab;
color: #c0c4cc;
display: flex;
align-items: center;
}
.part-drag-handle:active {
cursor: grabbing;
}
.drag-icon {
font-size: 16px;
}
.part-title {
font-weight: 600;
color: #1a5cb8;
font-size: 14px;
}
.part-count {
color: #909399;
font-size: 12px;
}
.part-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.questions-table {
min-height: 50px;
}
.question-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s;
}
.question-row:hover {
background: #f5f7fa;
}
.question-row.is-hidden {
opacity: 0.5;
}
.question-row:last-child {
border-bottom: none;
}
.ghost-row {
background: #f0f9eb;
opacity: 0.8;
}
.question-drag-handle {
cursor: grab;
color: #c0c4cc;
display: flex;
align-items: center;
}
.question-drag-handle:active {
cursor: grabbing;
}
.question-checkbox {
margin-right: 8px;
}
.question-index {
width: 30px;
text-align: center;
color: #909399;
font-weight: 500;
}
.question-info {
flex: 1;
min-width: 0;
}
.question-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.title-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.type-badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.type-1 { background: #e6f7ff; color: #1890ff; }
.type-2 { background: #f6ffed; color: #52c41a; }
.type-3 { background: #fff7e6; color: #fa8c16; }
.type-4 { background: #f9f0ff; color: #722ed1; }
.type-5 { background: #fff1f0; color: #f5222d; }
.type-6 { background: #e6fffb; color: #13c2c2; }
.help-text {
font-size: 12px;
color: #909399;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.question-meta {
display: flex;
align-items: center;
gap: 12px;
min-width: 150px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
color: #606266;
font-size: 13px;
}
.question-actions {
display: flex;
gap: 8px;
}
.part-manage-list {
max-height: 400px;
overflow-y: auto;
}
.part-manage-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
margin-bottom: 8px;
background: #f5f7fa;
border-radius: 4px;
}
.drag-handle {
cursor: move;
color: #c0c4cc;
}
</style>