民意中心系统
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

747 lines
19 KiB

<!--
* 警务评议与问题处置 - 可视化问卷设计画布
*
* @Author: Antigravity
* @Date: 2026-06-10
-->
<template>
<div class="page-container flex-layout">
<!-- 顶部状态导航栏 -->
<div class="designer-header">
<div class="left-info">
<a-button @click="handleBack" class="back-btn">
<template #icon><ArrowLeftOutlined /></template>
返回列表
</a-button>
<span class="divider">|</span>
<span class="survey-title">正在设计<strong>{{ surveyInfo.title || '测评问卷' }}</strong></span>
<a-tag :color="getBizTypeColor(surveyInfo.sjlyNo)" style="margin-left: 8px">
<DictLabel :dict-code="DICT_CODE_ENUM.BUSINESS_CLASSIFICATION" :data-value="surveyInfo.sjlyNo" />
</a-tag>
</div>
<div class="right-actions">
<a-button @click="handlePreview" style="margin-right: 8px">
<template #icon><EyeOutlined /></template>
预览问卷
</a-button>
<a-button type="primary" @click="handleSave" :loading="saving">
<template #icon><SaveOutlined /></template>
保存问卷设计
</a-button>
</div>
</div>
<!-- 主设计区域 (左右分栏布局) -->
<div class="designer-main">
<!-- 左侧题型控件区 -->
<div class="left-panel">
<h3 class="panel-title">添加题目控件</h3>
<div class="widget-list">
<div class="widget-item" @click="addQuestion('rate')">
<span class="widget-icon"><StarOutlined /></span>
<span class="widget-label">星级打分题</span>
</div>
<div class="widget-item" @click="addQuestion('radio')">
<span class="widget-icon"><BorderInnerOutlined /></span>
<span class="widget-label">单项选择题</span>
</div>
<div class="widget-item" @click="addQuestion('checkbox')">
<span class="widget-icon"><AppstoreOutlined /></span>
<span class="widget-label">多项选择题</span>
</div>
<div class="widget-item" @click="addQuestion('textarea')">
<span class="widget-icon"><FormOutlined /></span>
<span class="widget-label">群众意见框 (多行文本)</span>
</div>
</div>
<div class="designer-tips-card">
<div class="tips-title"><InfoCircleOutlined /> 逻辑跳转与满意度规则</div>
<p class="tips-text">1. 在选择题中可以为不同的选项配置跳转逻辑如不满意自动跳到特定细化题</p>
<p class="tips-text">2. 可以为每个单选项设置对应的选项分值系统在提交时会自动求平均值和累加</p>
</div>
</div>
<!-- 右侧设计画布区 -->
<div class="canvas-panel">
<div v-if="questions.length === 0" class="empty-canvas">
<a-empty description="画布中空空如也,请点击左侧题型控件添加题目" />
</div>
<div v-else class="question-list">
<div
v-for="(q, index) in questions"
:key="q.id"
class="question-card"
:class="{ 'is-active': activeQuestionId === q.id }"
@click="activeQuestionId = q.id"
>
<!-- 题目头部工具栏 -->
<div class="q-card-header">
<span class="q-number">Q{{ index + 1 }}</span>
<span class="q-type-badge">{{ getQuestionTypeLabel(q.type) }}</span>
<div class="q-actions" @click.stop>
<a-button type="text" size="small" :disabled="index === 0" @click="moveQuestion(index, -1)">
<template #icon><UpOutlined /></template>
</a-button>
<a-button type="text" size="small" :disabled="index === questions.length - 1" @click="moveQuestion(index, 1)">
<template #icon><DownOutlined /></template>
</a-button>
<a-popconfirm title="确认删除此题目吗?" @confirm="deleteQuestion(q.id)">
<a-button type="text" size="small" danger>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</div>
</div>
<!-- 题目内容编辑 -->
<div class="q-card-body">
<div class="q-title-row">
<a-input v-model:value="q.title" placeholder="请输入题目名称,例如:您对民警的办事效率是否满意?" class="q-title-input" />
<a-checkbox v-model:checked="q.required" class="q-req-checkbox">必填</a-checkbox>
</div>
<!-- 打分题特有展示 -->
<div v-if="q.type === 'rate'" class="q-body-content">
<a-rate :value="5" disabled />
<span class="hint-text">( 默认五星满分,一星扣2分 )</span>
</div>
<!-- 选择题特有选项配置 (支持分值、跳题逻辑) -->
<div v-if="q.type === 'radio' || q.type === 'checkbox'" class="q-body-content">
<div class="options-header">
<span class="opt-label-head">选项文字</span>
<span class="opt-score-head">对应分值</span>
<span class="opt-jump-head">选项跳题跳转逻辑 (选中时)</span>
<span class="opt-del-head">操作</span>
</div>
<div v-for="(opt, oIndex) in q.options" :key="oIndex" class="option-row">
<a-input v-model:value="opt.label" placeholder="选项文字" class="opt-input" />
<a-input-number v-model:value="opt.score" :min="0" :max="100" class="opt-score-input" placeholder="分值" />
<a-select v-model:value="opt.logicJump" placeholder="常规下一题" class="opt-jump-select">
<a-select-option value="">常规跳转(下一题)</a-select-option>
<template v-for="(otherQ, otherIndex) in questions">
<a-select-option v-if="otherIndex > index" :key="otherQ.id" :value="otherQ.id">
跳至 Q{{ otherIndex + 1 }} ({{ otherQ.title.substring(0, 10) }}...)
</a-select-option>
</template>
<a-select-option value="end">直接结束问卷</a-select-option>
</a-select>
<a-button type="text" danger @click="deleteOption(q, oIndex)" class="opt-del-btn">
<template #icon><MinusCircleOutlined /></template>
</a-button>
</div>
<a-button type="dashed" block @click="addOption(q)" style="margin-top: 8px">
<template #icon><PlusOutlined /></template>
添加新选项
</a-button>
</div>
<!-- 多行文本题特有展示 -->
<div v-if="q.type === 'textarea'" class="q-body-content">
<a-textarea placeholder="群众意见输入框占位符预览..." :rows="2" disabled />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 问卷手机填报端预览 Modal -->
<a-modal
v-model:open="previewVisible"
title="问卷手机填报效果预览"
:footer="null"
:width="420"
wrapClassName="phone-preview-modal"
>
<div class="phone-frame">
<div class="phone-screen">
<div class="phone-header">
<span class="phone-title">{{ surveyInfo?.title || '民意测评问卷' }}</span>
</div>
<div class="phone-content" v-if="questions.length > 0">
<div class="survey-intro">
您好!为了进一步提升我们的政务服务品质,耽误您1分钟时间参与本次民意调查测评,感谢您的配合!
</div>
<!-- 使用通用问卷渲染器 -->
<SurveyFormRenderer :questions="questions" v-model:value="previewAnswers" />
<a-button type="primary" block size="large" style="margin-top: 24px; border-radius: 8px" @click="handleSubmitPreview">
模拟提交问卷
</a-button>
</div>
<div class="phone-content empty-preview" v-else>
<a-empty description="该问卷尚未配置题目,请先前往左侧添加题目" />
</div>
</div>
</div>
</a-modal>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import SurveyFormRenderer from '../my-survey/components/SurveyFormRenderer.vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
ArrowLeftOutlined,
SaveOutlined,
StarOutlined,
BorderInnerOutlined,
AppstoreOutlined,
FormOutlined,
InfoCircleOutlined,
UpOutlined,
DownOutlined,
DeleteOutlined,
MinusCircleOutlined,
PlusOutlined,
EyeOutlined
} from '@ant-design/icons-vue';
import DictLabel from '/@/components/support/dict-label/index.vue';
import { DICT_CODE_ENUM } from '/@/constants/support/dict-const';
const route = useRoute();
const router = useRouter();
import { surveySampleApi } from '/@/api/business/jwpy/survey-sample-api';
const surveyId = ref(null);
const surveyInfo = ref({});
const questions = ref([]);
const activeQuestionId = ref(null);
const saving = ref(false);
// 加载问卷详情
const loadSurveyDetails = async () => {
surveyId.value = Number(route.query.id);
if (!surveyId.value) {
message.error('未指定问卷模板ID');
router.push('/mydc/questionnaire');
return;
}
try {
const res = await surveySampleApi.querySurveys({ pageNum: 1, pageSize: 500 });
const localSurveys = res.data?.list || [];
const found = localSurveys.find(item => item.surveyId === surveyId.value);
if (found) {
surveyInfo.value = found;
try {
questions.value = found.content ? JSON.parse(found.content) : [];
if (questions.value.length > 0) {
activeQuestionId.value = questions.value[0].id;
}
} catch (e) {
questions.value = [];
}
} else {
message.error('未找到对应问卷模板,已自动返回列表');
router.push('/mydc/questionnaire');
}
} catch (err) {
message.error('加载问卷模板详情失败');
}
};
// 增加题目控件
const addQuestion = (type) => {
const newQ = {
id: 'q_' + Date.now(),
type: type,
title: '',
required: true,
options: []
};
// 如果是选择题,默认给两个选项
if (type === 'radio' || type === 'checkbox') {
newQ.options = [
{ label: '满意', score: 10, logicJump: '' },
{ label: '不满意', score: 2, logicJump: '' }
];
}
questions.value.push(newQ);
activeQuestionId.value = newQ.id;
message.success('已添加新题目');
};
// 题目删除
const deleteQuestion = (id) => {
questions.value = questions.value.filter(q => q.id !== id);
if (activeQuestionId.value === id && questions.value.length > 0) {
activeQuestionId.value = questions.value[0].id;
}
};
// 题目位置移动
const moveQuestion = (index, direction) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= questions.value.length) return;
const temp = questions.value[index];
questions.value[index] = questions.value[targetIndex];
questions.value[targetIndex] = temp;
};
// 添加选项
const addOption = (q) => {
q.options.push({ label: '新选项', score: 10, logicJump: '' });
};
// 删除选项
const deleteOption = (q, oIndex) => {
if (q.options.length <= 1) {
message.warning('选项至少保留 1 个');
return;
}
q.options.splice(oIndex, 1);
};
// 题型标签
const getQuestionTypeLabel = (type) => {
const map = {
'rate': '五星打分题',
'radio': '单项选择题',
'checkbox': '多项选择题',
'textarea': '多行文本框'
};
return map[type] || '未知题型';
};
// 业务标签颜色
const getBizTypeColor = (bizType) => {
const map = { '110': 'red', 'hz': 'blue', 'crj': 'purple', 'cjg': 'green', 'ajbl': 'orange' };
return map[bizType] || 'cyan';
};
// 保存问卷设计
const handleSave = async () => {
const emptyQ = questions.value.some(q => !q.title.trim());
if (emptyQ) {
message.warning('画布中存在题目名称未填写的题目,请完善后再保存');
return;
}
saving.value = true;
try {
await surveySampleApi.updateSurvey({
surveyId: surveyId.value,
content: JSON.stringify(questions.value)
});
message.success('保存成功');
handleBack();
} catch (err) {
message.error('保存设计失败');
} finally {
saving.value = false;
}
};
// 返回上一级
const handleBack = () => {
router.push('/mydc/questionnaire');
};
const previewVisible = ref(false);
const previewAnswers = ref({});
const handlePreview = () => {
previewAnswers.value = {};
previewVisible.value = true;
};
const handleSubmitPreview = () => {
message.success('答卷模拟提交成功!');
previewVisible.value = false;
};
onMounted(() => {
loadSurveyDetails();
});
</script>
<style scoped>
/* 全屏弹性高度,锁定滚动条 */
.flex-layout {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
overflow: hidden;
padding: 16px;
background-color: #f0f2f5;
box-sizing: border-box;
}
/* 顶部状态栏 */
.designer-header {
height: 52px;
background: #ffffff;
border-radius: 8px;
padding: 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
flex-shrink: 0;
}
.left-info {
display: flex;
align-items: center;
}
.back-btn {
font-weight: 500;
}
.divider {
margin: 0 12px;
color: #d9d9d9;
}
.survey-title {
font-size: 15px;
color: #262626;
}
.right-actions {
display: flex;
gap: 8px;
}
/* 主画布 */
.designer-main {
display: flex;
flex: 1;
gap: 12px;
overflow: hidden;
}
/* 左侧:题型控件区 */
.left-panel {
width: 260px;
background: #ffffff;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
flex-shrink: 0;
}
.panel-title {
font-size: 14px;
font-weight: 700;
color: #595959;
margin-bottom: 12px;
}
.widget-list {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.widget-item {
display: flex;
align-items: center;
padding: 12px 16px;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.widget-item:hover {
background: #e6f7ff;
border-color: #1890ff;
color: #1890ff;
}
.widget-icon {
font-size: 16px;
margin-right: 10px;
display: flex;
align-items: center;
}
.widget-label {
font-size: 13px;
font-weight: 600;
}
.designer-tips-card {
margin-top: auto;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
}
.tips-title {
font-size: 12px;
font-weight: 700;
color: #fa8c16;
margin-bottom: 6px;
}
.tips-text {
font-size: 11px;
color: #8c8c8c;
line-height: 1.4;
margin-bottom: 4px;
}
.tips-text:last-child {
margin-bottom: 0;
}
/* 画布面板 */
.canvas-panel {
flex: 1;
background: #ffffff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow-y: auto;
box-sizing: border-box;
}
.empty-canvas {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.question-list {
display: flex;
flex-direction: column;
gap: 14px;
}
/* 题目卡片 */
.question-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
background: #fafafa;
transition: all 0.2s ease;
position: relative;
}
.question-card:hover {
border-color: #d9d9d9;
}
.question-card.is-active {
border-color: #1890ff;
background: #ffffff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.08);
}
.q-card-header {
display: flex;
align-items: center;
margin-bottom: 12px;
border-bottom: 1px dashed #f0f0f0;
padding-bottom: 8px;
}
.q-number {
font-size: 14px;
font-weight: 800;
color: #1890ff;
margin-right: 8px;
}
.q-type-badge {
font-size: 11px;
background: rgba(0, 0, 0, 0.04);
color: #595959;
padding: 2px 6px;
border-radius: 4px;
}
.q-actions {
margin-left: auto;
display: flex;
gap: 2px;
}
.q-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.q-title-input {
flex: 1;
font-weight: 600;
}
.q-req-checkbox {
flex-shrink: 0;
}
.q-body-content {
background: #fafafa;
border-radius: 6px;
padding: 12px;
border: 1px dashed #f0f0f0;
}
.hint-text {
font-size: 12px;
color: #bfbfbf;
margin-left: 10px;
}
/* 选择题选项表格配置 */
.options-header {
display: grid;
grid-template-columns: 2fr 1fr 2.5fr 40px;
gap: 8px;
font-size: 11px;
color: #8c8c8c;
padding-bottom: 4px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.option-row {
display: grid;
grid-template-columns: 2fr 1fr 2.5fr 40px;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.opt-score-input {
width: 100%;
}
.opt-jump-select {
width: 100%;
}
.opt-del-btn {
display: flex;
justify-content: center;
align-items: center;
}
/* 手机预览容器框 - 打造真机质感 */
.phone-frame {
width: 360px;
height: 600px;
background: #2f3640;
border-radius: 36px;
padding: 12px;
margin: 0 auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
position: relative;
}
.phone-screen {
width: 100%;
height: 100%;
background: #f5f6fa;
border-radius: 26px;
overflow-y: auto;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* 隐藏手机屏内部滚动条 */
.phone-screen::-webkit-scrollbar {
width: 4px;
}
.phone-screen::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.phone-header {
height: 50px;
background: #1890ff;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
}
.phone-title {
font-size: 14px;
font-weight: 600;
padding: 0 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.phone-content {
padding: 16px;
flex: 1;
}
.survey-intro {
font-size: 12px;
color: #7f8c8d;
line-height: 1.6;
background: #ffffff;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 16px;
border: 1px solid #eef1f6;
}
.preview-q-card {
background: #ffffff;
border-radius: 10px;
padding: 14px;
margin-bottom: 14px;
border: 1px solid #eef1f6;
}
.q-title {
font-size: 13px;
font-weight: 700;
color: #2c3e50;
margin-bottom: 10px;
}
.q-req {
color: #e74c3c;
margin-right: 3px;
}
.rate-hint {
margin-left: 10px;
font-size: 12px;
color: #f1c40f;
font-weight: bold;
}
.empty-preview {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
</style>